From 1554ae83c18a3432c0813ce5ec5b3340435ccd6b Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 15:15:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(cert-manager):=20Hersteller-ZIP,=20PKCS#7,?= =?UTF-8?q?=20eingef=C3=BCgter=20Text,=20Analysieren=20und=20Aufteilen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ZIP wird an den Anfangsbytes erkannt und mit Grenzen geöffnet (eine Ebene, Verhältnis, Gesamtgröße, verschlüsselte Einträge) - PKCS#7 als PEM und DER, auch für EC, über den ASN.1-Lauf - Eingefügter PEM-Text als eigener Eintrag im Reiter Dateien - Neue Reiter Analysieren und Aufteilen auf dem gemeinsamen Arbeitsbereich Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/src/cert-manager/cert-analyze.spec.ts | 60 +++++ apps/api/src/cert-manager/cert-analyze.ts | 14 +- apps/api/src/cert-manager/cert-model.spec.ts | 80 ++++++ apps/api/src/cert-manager/cert-model.ts | 95 ++++++- apps/api/src/cert-manager/cert-names.ts | 11 + apps/api/src/cert-manager/zip-expand.spec.ts | 156 ++++++++++++ apps/api/src/cert-manager/zip-expand.ts | 136 +++++++++++ .../cert-manager/cert-manager.test.tsx | 21 +- .../components/AnalyzeTab.test.tsx | 231 ++++++++++++++++++ .../cert-manager/components/AnalyzeTab.tsx | 76 ++++++ .../cert-manager/components/FilesTab.test.tsx | 75 ++++++ .../cert-manager/components/FilesTab.tsx | 139 ++++++++--- .../cert-manager/components/ItemCard.tsx | 90 +++++++ .../cert-manager/components/MergeTab.test.tsx | 1 + .../cert-manager/components/SplitTab.test.tsx | 207 ++++++++++++++++ .../cert-manager/components/SplitTab.tsx | 124 ++++++++++ .../(portal)/modules/cert-manager/page.tsx | 8 +- .../cert-manager/use-cert-workspace.ts | 30 ++- .../modules/cert-manager/working-set.test.ts | 55 +++++ .../modules/cert-manager/working-set.ts | 49 +++- apps/web/src/messages/de.json | 47 +++- apps/web/src/messages/en.json | 47 +++- 22 files changed, 1692 insertions(+), 60 deletions(-) create mode 100644 apps/api/src/cert-manager/zip-expand.spec.ts create mode 100644 apps/api/src/cert-manager/zip-expand.ts create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/ItemCard.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx diff --git a/apps/api/src/cert-manager/cert-analyze.spec.ts b/apps/api/src/cert-manager/cert-analyze.spec.ts index 48a8761..49e34ea 100644 --- a/apps/api/src/cert-manager/cert-analyze.spec.ts +++ b/apps/api/src/cert-manager/cert-analyze.spec.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import AdmZip from 'adm-zip'; import { describe, expect, it } from 'vitest'; import { analyzeWorkingSet, cleanSourcePath } from './cert-analyze'; import type { CertItem } from './cert-types'; @@ -71,6 +72,65 @@ describe('analyzeWorkingSet', () => { }); }); +describe('analyzeWorkingSet: Hersteller-ZIP', () => { + const vendor = new AdmZip(); + vendor.addFile('ec-leaf.crt', fx('ec-leaf.pem')); + vendor.addFile('Zwischen/ec-inter.crt', fx('ec-inter.pem')); + vendor.addFile('ec-root.crt', fx('ec-root.pem')); + vendor.addFile('kopie/rsa-leaf.pem', fx('rsa-leaf.pem')); + vendor.addFile('__MACOSX/._ec-leaf.crt', Buffer.from('mac')); + vendor.addFile('inner.zip', new AdmZip().toBuffer()); + vendor.addFile('readme.txt', Buffer.from('Bitte lesen')); + + const result = analyzeWorkingSet([ + file('rsa-leaf.pem'), + file('rsa-inter.pem'), + { originalname: 'vendor.zip', buffer: vendor.toBuffer() }, + ]); + const certs = result.items.filter((i): i is CertItem => i.kind === 'certificate'); + + it('rsa-leaf ist EIN Eintrag mit zwei Quellen (Datei 0 und Pfad im ZIP)', () => { + const leaf = certs.filter((c) => c.cn === 'www.example.test'); + expect(leaf).toHaveLength(1); + expect(leaf[0].sources).toEqual([ + { file: 0, path: 'rsa-leaf.pem' }, + { file: 2, path: 'vendor.zip/kopie/rsa-leaf.pem' }, + ]); + }); + + it('ordnet Serverzertifikate, Zwischenzertifikate, Wurzel', () => { + expect(certs.map((c) => c.role)).toEqual([ + 'end-entity', + 'end-entity', + 'intermediate', + 'intermediate', + 'root', + ]); + }); + + it('zwei Ketten: RSA unvollstaendig (afterCa), EC vollstaendig mit Wurzel', () => { + expect(result.chains).toHaveLength(2); + const rsa = result.chains.find( + (c) => certs.find((x) => x.id === c.headId)?.cn === 'www.example.test', + ); + expect(rsa?.gap?.kind).toBe('afterCa'); + const ec = result.chains.find( + (c) => certs.find((x) => x.id === c.headId)?.cn === 'ec.example.test', + ); + expect(ec?.complete).toBe(true); + }); + + it('meldet verschachteltes ZIP und unbekannte Datei mit Dateiindex und Pfad', () => { + expect(result.ignored).toEqual( + expect.arrayContaining([ + { file: 2, path: 'vendor.zip/inner.zip', reason: 'nestedZip' }, + { file: 2, path: 'vendor.zip/readme.txt', reason: 'unknown' }, + ]), + ); + expect(JSON.stringify(result)).not.toContain('MACOSX'); + }); +}); + describe('cleanSourcePath', () => { it('entfernt Steuerzeichen und kuerzt auf 255', () => { expect(cleanSourcePath('a\u0000b\u001fc.pem')).toBe('abc.pem'); diff --git a/apps/api/src/cert-manager/cert-analyze.ts b/apps/api/src/cert-manager/cert-analyze.ts index 8031750..1077c77 100644 --- a/apps/api/src/cert-manager/cert-analyze.ts +++ b/apps/api/src/cert-manager/cert-analyze.ts @@ -1,5 +1,6 @@ import { buildChains } from './cert-chain'; import { detectBlob } from './cert-model'; +import { cleanSourcePath } from './cert-names'; import type { AnalysisResult, AnyItem, @@ -15,22 +16,13 @@ import type { * Ketten ab Task 2; Schluessel/CSR-Zuordnung und gesperrte Container (Task 4) folgen. */ +export { cleanSourcePath }; + export interface AnalyzeFile { originalname: string; buffer: Buffer; } -/** Anzeigename einer Quelle: ohne Steuerzeichen, hoechstens 255 Zeichen. Nur Anzeige, nie ein Dateipfad. */ -export function cleanSourcePath(raw: string): string { - let out = ''; - for (const ch of raw) { - const code = ch.codePointAt(0) ?? 0; - if (code < 0x20 || code === 0x7f) continue; - out += ch; - } - return out.slice(0, 255); -} - const ROLE_RANK: Record = { 'end-entity': 0, intermediate: 1, root: 2 }; function sameSource(a: ItemSource, b: ItemSource): boolean { diff --git a/apps/api/src/cert-manager/cert-model.spec.ts b/apps/api/src/cert-manager/cert-model.spec.ts index 1e97b58..7474487 100644 --- a/apps/api/src/cert-manager/cert-model.spec.ts +++ b/apps/api/src/cert-manager/cert-model.spec.ts @@ -1,6 +1,7 @@ import { X509Certificate } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import AdmZip from 'adm-zip'; import { describe, expect, it } from 'vitest'; import { certItemFromDer, detectBlob } from './cert-model'; import type { CertItem } from './cert-types'; @@ -129,3 +130,82 @@ describe('detectBlob: Zertifikate', () => { expect(item.id).toMatch(/^c-[0-9a-f]{16}$/); }); }); + +describe('detectBlob: PKCS#7', () => { + it.each([ + 'rsa-chain.p7b', + 'rsa-chain.p7c', + 'ec-chain.p7b', + ])('%s liefert drei Zertifikate mit unveraenderten Fingerabdruecken', (name) => { + const r = detectBlob(fx(name), ctx(name)); + expect(r.ignored).toEqual([]); + expect(r.items).toHaveLength(3); + const prefix = name.startsWith('rsa') ? 'rsa' : 'ec'; + const expected = ['leaf', 'inter', 'root'].map((p) => certs(`${prefix}-${p}.pem`)[0].id); + expect(r.items.map((i) => i.id).sort()).toEqual([...expected].sort()); + expect((r.items[0] as CertItem).sources).toEqual([{ file: 0, path: name }]); + }); + + it('PKCS#7 als DER ohne Endung wird erkannt (Inhalt, nicht Name)', () => { + const r = detectBlob(fx('rsa-chain.p7c'), ctx('irgendwas.dat')); + expect(r.items).toHaveLength(3); + }); + + it('abgeschnittenes PKCS#7 ergibt unbekannt, keinen Fehler', () => { + const r = detectBlob(fx('rsa-chain.p7c').subarray(0, 400), ctx('halb.p7c')); + expect(r.items).toEqual([]); + expect(r.ignored).toEqual([{ file: 0, path: 'halb.p7c', reason: 'unknown' }]); + }); + + it('PKCS#7-Block neben einem Zertifikat im selben Text', () => { + const both = Buffer.concat([fx('rsa-chain.p7b'), Buffer.from('\n'), fx('ec-leaf.pem')]); + const r = detectBlob(both, ctx('beides.pem')); + expect(r.items).toHaveLength(4); + }); +}); + +describe('detectBlob: ZIP', () => { + function zip(entries: Record): Buffer { + const z = new AdmZip(); + for (const [name, data] of Object.entries(entries)) z.addFile(name, data); + return z.toBuffer(); + } + + it('oeffnet ein ZIP an den Anfangsbytes und gibt jedem Teil den Pfad "zip/eintrag"', () => { + const blob = zip({ + 'ServerCertificate.crt': fx('ec-leaf.pem'), + 'Intermediate/CA.crt': fx('ec-inter.pem'), + 'chain.p7b': fx('rsa-chain.p7b'), + 'readme.txt': Buffer.from('Bitte lesen'), + '__MACOSX/._x': Buffer.from('mac'), + }); + const r = detectBlob(blob, { file: 2, path: 'bundle.dat', passwords: [] }); + const ec = r.items.find((i) => (i as CertItem).cn === 'ec.example.test'); + expect(ec?.sources).toEqual([{ file: 2, path: 'bundle.dat/ServerCertificate.crt' }]); + expect(r.items).toHaveLength(5); + expect(r.ignored).toEqual([{ file: 2, path: 'bundle.dat/readme.txt', reason: 'unknown' }]); + }); + + it('ein ZIP im ZIP: nestedZip mit Pfad, der Rest wird gelesen', () => { + const inner = zip({ 'x.pem': fx('rsa-root.pem') }); + const blob = zip({ 'a.pem': fx('rsa-leaf.pem'), 'inner.zip': inner }); + const r = detectBlob(blob, ctx('v.zip')); + expect(r.items).toHaveLength(1); + expect(r.ignored).toEqual([{ file: 0, path: 'v.zip/inner.zip', reason: 'nestedZip' }]); + }); + + it('kaputtes ZIP und verschluesseltes ZIP werden gemeldet', () => { + const broken = Buffer.concat([Buffer.from('PK\x03\x04', 'binary'), Buffer.alloc(100, 9)]); + expect(detectBlob(broken, ctx('b.zip')).ignored).toEqual([ + { file: 0, path: 'b.zip', reason: 'brokenZip' }, + ]); + expect(detectBlob(fx('encrypted-entry.zip'), ctx('e.zip')).ignored).toEqual([ + { file: 0, path: 'e.zip/rsa-leaf.pem', reason: 'encryptedZip' }, + ]); + }); + + it('ein ZIP ganz ohne lesbare Teile ergibt einen Eintrag unbekannt fuer das ZIP', () => { + const r = detectBlob(new AdmZip().toBuffer(), ctx('leer.zip')); + expect(r.ignored).toEqual([{ file: 0, path: 'leer.zip', reason: 'unknown' }]); + }); +}); diff --git a/apps/api/src/cert-manager/cert-model.ts b/apps/api/src/cert-manager/cert-model.ts index 632fcc8..4fa6029 100644 --- a/apps/api/src/cert-manager/cert-model.ts +++ b/apps/api/src/cert-manager/cert-model.ts @@ -1,4 +1,5 @@ import { createHash, type KeyObject, X509Certificate } from 'node:crypto'; +import * as forge from 'node-forge'; import { safeBaseName } from './cert-names'; import type { AnyItem, @@ -8,6 +9,7 @@ import type { ItemSource, LockedEntry, } from './cert-types'; +import { expandZip, isZip } from './zip-expand'; /** * Der eine Parser des Zertifikat-Managers (quick-261009-ikt, D-08, D-16). @@ -18,8 +20,9 @@ import type { * * Erkennung nach Inhalt, nie nach Dateiendung. Jede Stufe steht in try/catch: eine kaputte * Datei ergibt hoechstens einen Eintrag „unbekannt“, nie einen Fehler fuer die ganze Anfrage. - * Stand Task 1: Zertifikate als PEM (auch TRUSTED CERTIFICATE) und als DER. ZIP und PKCS#7 - * (Task 3) sowie Schluessel, PKCS#12 und CSR (Task 4) sind benannte, noch leere Stufen. + * Stand Task 3: Zertifikate als PEM (auch TRUSTED CERTIFICATE) und als DER, PKCS#7 als PEM und DER + * (auch fuer EC, ueber den ASN.1-Lauf von forge) und ZIP (eine Ebene, Grenzen in zip-expand.ts). + * Schluessel, PKCS#12 und CSR (Task 4) sind benannte, noch leere Stufen. */ export interface DetectContext { @@ -38,6 +41,7 @@ export interface DetectResult { } const CERT_LABELS = new Set(['CERTIFICATE', 'X509 CERTIFICATE', 'TRUSTED CERTIFICATE']); +const PKCS7_LABELS = new Set(['PKCS7', 'CMS']); const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g; const BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/; @@ -211,9 +215,72 @@ function emptyResult(): DetectResult { // Stufen. Jede liefert null, wenn sie den Inhalt nicht als „ihren“ erkennt. // --------------------------------------------------------------------------- -/** ZIP (Task 3): erkannt an den Anfangsbytes, nicht an der Endung. */ -function detectZip(_blob: Buffer, _ctx: DetectContext): DetectResult | null { - return null; +/** + * ZIP: erkannt an den Anfangsbytes, nicht an der Endung. Jeder Eintrag laeuft durch dieselbe + * Erkennung wie eine hochgeladene Datei; sein Pfad ist "zipname/eintrag". Ein ZIP im ZIP wird + * von expandZip schon als nestedZip gemeldet und nicht geoeffnet. + */ +function detectZip(blob: Buffer, ctx: DetectContext): DetectResult | null { + if (!isZip(blob)) return null; + const expansion = expandZip(blob, ctx.path, ctx.file); + const result: DetectResult = { items: [], ignored: [...expansion.ignored], locked: [] }; + for (const entry of expansion.blobs) { + const inner = detectBlob(entry.buffer, { ...ctx, path: entry.path }); + result.items.push(...inner.items); + result.ignored.push(...inner.ignored); + result.locked.push(...inner.locked); + } + if (result.items.length === 0 && result.ignored.length === 0 && result.locked.length === 0) { + result.ignored.push({ file: ctx.file, path: ctx.path, reason: 'unknown' }); + } + return result; +} + +const OID_SIGNED_DATA = '1.2.840.113549.1.7.2'; + +/** + * Zertifikate aus einem PKCS#7-/CMS-signedData-Block (DER, auch BER mit unbestimmter Laenge). + * Reiner ASN.1-Lauf: ContentInfo -> [0] SignedData -> [0] certificates. Jedes Zertifikat wird + * als DER an node:crypto gegeben; die RSA-only-Zertifikatsleser von forge kommen nie vor. + */ +function pkcs7Certificates(der: Buffer): Buffer[] { + // Die Typdefinition kennt nur `strict: boolean`; forge nimmt zur Laufzeit ein Optionsobjekt. + // decodeBitStrings aus: Bitfolgen bleiben unveraendert, damit die Zertifikats-Bytes beim + // erneuten Schreiben mit den Originalen uebereinstimmen (gleicher Fingerabdruck). + const options = { decodeBitStrings: false } as unknown as boolean; + const root = forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), options); + const children = root.value as forge.asn1.Asn1[]; + if (!Array.isArray(children) || children.length < 2) return []; + const [contentType, content] = children; + if ( + contentType.type !== forge.asn1.Type.OID || + forge.asn1.derToOid(contentType.value as string) !== OID_SIGNED_DATA + ) { + return []; + } + const signedData = (content.value as forge.asn1.Asn1[])?.[0]; + const parts = signedData?.value as forge.asn1.Asn1[] | undefined; + if (!Array.isArray(parts)) return []; + const certificates = parts.find( + (p) => p.tagClass === forge.asn1.Class.CONTEXT_SPECIFIC && p.type === 0 && p.constructed, + ); + if (!certificates) return []; + const out: Buffer[] = []; + for (const cert of certificates.value as forge.asn1.Asn1[]) { + if (cert.type !== forge.asn1.Type.SEQUENCE) continue; // andere Zertifikatsformen (Attributzertifikate) ueberspringen + out.push(Buffer.from(forge.asn1.toDer(cert).getBytes(), 'binary')); + } + return out; +} + +/** Zertifikate aus PKCS#7-DER als Eintraege; Elemente, die keine X.509-Zertifikate sind, fallen weg. */ +function pkcs7Items(der: Buffer, ctx: DetectContext): CertItem[] { + const items: CertItem[] = []; + for (const certDer of pkcs7Certificates(der)) { + const item = certFromDer(certDer, ctx); + if (item) items.push(item); + } + return items; } interface PemBlock { @@ -260,8 +327,15 @@ function detectPem(blob: Buffer, ctx: DetectContext): DetectResult | null { const item = certFromDer(block.der, ctx); if (item) result.items.push(item); } - // PKCS7/CMS (Task 3); PRIVATE KEY, RSA/EC PRIVATE KEY, ENCRYPTED PRIVATE KEY und - // CERTIFICATE REQUEST (Task 4) folgen in dieser Schleife. + if (PKCS7_LABELS.has(block.label)) { + try { + result.items.push(...pkcs7Items(block.der, ctx)); + } catch { + // kaputter PKCS#7-Block: die anderen Bloecke der Datei bleiben gueltig + } + } + // PRIVATE KEY, RSA/EC PRIVATE KEY, ENCRYPTED PRIVATE KEY und CERTIFICATE REQUEST + // (Task 4) folgen in dieser Schleife. } return result.items.length > 0 ? result : null; } @@ -278,9 +352,10 @@ function detectPkcs12(_blob: Buffer, _ctx: DetectContext): DetectResult | null { return null; } -/** PKCS#7 signedData als DER (Task 3). */ -function detectPkcs7(_blob: Buffer, _ctx: DetectContext): DetectResult | null { - return null; +/** PKCS#7 signedData als DER. */ +function detectPkcs7(blob: Buffer, ctx: DetectContext): DetectResult | null { + const items = pkcs7Items(blob, ctx); + return items.length > 0 ? { items, ignored: [], locked: [] } : null; } /** Privater Schluessel als DER (Task 4). */ diff --git a/apps/api/src/cert-manager/cert-names.ts b/apps/api/src/cert-manager/cert-names.ts index 6d50ca4..c2cd3a5 100644 --- a/apps/api/src/cert-manager/cert-names.ts +++ b/apps/api/src/cert-manager/cert-names.ts @@ -12,3 +12,14 @@ export function safeBaseName(raw: string, fallback: string): string { .slice(0, 80); return cleaned || fallback; } + +/** Anzeigename einer Quelle: ohne Steuerzeichen, hoechstens 255 Zeichen. Nur Anzeige, nie ein Dateipfad. */ +export function cleanSourcePath(raw: string): string { + let out = ''; + for (const ch of raw) { + const code = ch.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) continue; + out += ch; + } + return out.slice(0, 255); +} diff --git a/apps/api/src/cert-manager/zip-expand.spec.ts b/apps/api/src/cert-manager/zip-expand.spec.ts new file mode 100644 index 0000000..7c6c636 --- /dev/null +++ b/apps/api/src/cert-manager/zip-expand.spec.ts @@ -0,0 +1,156 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import AdmZip from 'adm-zip'; +import { describe, expect, it } from 'vitest'; +import { expandZip, isZip, ZIP_LIMITS } from './zip-expand'; + +const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); + +function zipOf(entries: Record): Buffer { + const zip = new AdmZip(); + for (const [name, content] of Object.entries(entries)) { + zip.addFile(name, Buffer.isBuffer(content) ? content : Buffer.from(content)); + } + return zip.toBuffer(); +} + +function vendorZip(): Buffer { + const inner = zipOf({ 'x.txt': 'innen' }); + const zip = new AdmZip(); + zip.addFile('ServerCertificate.crt', fx('rsa-leaf.pem')); + zip.addFile('Intermediate/CA.crt', fx('rsa-inter.pem')); + zip.addFile('__MACOSX/._ServerCertificate.crt', Buffer.from('mac')); + zip.addFile('.DS_Store', Buffer.from('ds')); + zip.addFile('Thumbs.db', Buffer.from('thumbs')); + zip.addFile('Intermediate/', Buffer.alloc(0)); + zip.addFile('readme.txt', Buffer.from('Bitte lesen')); + zip.addFile('inner.zip', inner); + return zip.toBuffer(); +} + +describe('isZip', () => { + it('erkennt ZIP an den Anfangsbytes, nicht am Namen', () => { + expect(isZip(vendorZip())).toBe(true); + expect(isZip(Buffer.from('PK\x05\x06rest', 'binary'))).toBe(true); + expect(isZip(fx('rsa-leaf.pem'))).toBe(false); + expect(isZip(Buffer.alloc(0))).toBe(false); + }); +}); + +describe('expandZip: Hersteller-ZIP', () => { + const result = expandZip(vendorZip(), 'vendor.zip', 3); + + it('liefert die Zertifikate und die Textdatei mit Pfad "zip/eintrag"', () => { + expect(result.blobs.map((b) => b.path).sort()).toEqual([ + 'vendor.zip/Intermediate/CA.crt', + 'vendor.zip/ServerCertificate.crt', + 'vendor.zip/readme.txt', + ]); + const server = result.blobs.find((b) => b.path === 'vendor.zip/ServerCertificate.crt'); + expect(server?.buffer.equals(fx('rsa-leaf.pem'))).toBe(true); + }); + + it('Muell (MACOSX, Punktdateien, Thumbs.db, Ordner) erzeugt nichts', () => { + const all = JSON.stringify(result); + expect(all).not.toContain('MACOSX'); + expect(all).not.toContain('DS_Store'); + expect(all).not.toContain('Thumbs'); + }); + + it('ein ZIP im ZIP wird mit Grund gemeldet und nicht geoeffnet', () => { + expect(result.ignored).toEqual([ + { file: 3, path: 'vendor.zip/inner.zip', reason: 'nestedZip' }, + ]); + expect(result.blobs.some((b) => b.path.endsWith('inner.zip'))).toBe(false); + }); + + it('dasselbe ZIP wird auch unter anderem Namen geoeffnet (Anfangsbytes)', () => { + const renamed = expandZip(vendorZip(), 'bundle.dat', 0); + expect(renamed.blobs).toHaveLength(3); + expect(renamed.blobs[0].path.startsWith('bundle.dat/')).toBe(true); + }); +}); + +describe('expandZip: Grenzen und Fehler', () => { + it('Zufallsbytes mit ZIP-Anfang ergeben brokenZip', () => { + const junk = Buffer.concat([Buffer.from('PK\x03\x04', 'binary'), Buffer.alloc(200, 7)]); + const r = expandZip(junk, 'kaputt.zip', 1); + expect(r.blobs).toEqual([]); + expect(r.ignored).toEqual([{ file: 1, path: 'kaputt.zip', reason: 'brokenZip' }]); + }); + + it('ein verschluesselter Eintrag wird mit Grund gemeldet', () => { + const r = expandZip(fx('encrypted-entry.zip'), 'enc.zip', 0); + expect(r.blobs).toEqual([]); + expect(r.ignored).toEqual([{ file: 0, path: 'enc.zip/rsa-leaf.pem', reason: 'encryptedZip' }]); + }); + + it('mehr Eintraege als erlaubt: ein tooManyEntries fuer das ganze ZIP, keine Teile', () => { + const zip = zipOf({ 'a.pem': 'a', 'b.pem': 'b', 'c.pem': 'c' }); + const r = expandZip(zip, 'viele.zip', 2, { ...ZIP_LIMITS, maxEntries: 2 }); + expect(r.blobs).toEqual([]); + expect(r.ignored).toEqual([{ file: 2, path: 'viele.zip', reason: 'tooManyEntries' }]); + }); + + it('Muell zaehlt nicht zu den erlaubten Eintraegen', () => { + const zip = zipOf({ 'a.pem': 'a', '.hidden': 'x', '__MACOSX/b': 'y', 'Thumbs.db': 'z' }); + const r = expandZip(zip, 'z.zip', 0, { ...ZIP_LIMITS, maxEntries: 1 }); + expect(r.blobs).toHaveLength(1); + expect(r.ignored).toEqual([]); + }); + + it('ein zu grosser Eintrag wird mit tooLarge uebersprungen, der Rest bleibt', () => { + const zip = zipOf({ 'gross.pem': Buffer.from('ab'.repeat(400)), 'klein.pem': 'k' }); + const r = expandZip(zip, 'g.zip', 0, { ...ZIP_LIMITS, maxEntryBytes: 500 }); + expect(r.ignored).toEqual([{ file: 0, path: 'g.zip/gross.pem', reason: 'tooLarge' }]); + expect(r.blobs.map((b) => b.path)).toEqual(['g.zip/klein.pem']); + }); + + it('600 kB Nullbytes (Verhaeltnis ueber 100) ergeben suspicious', () => { + const zip = zipOf({ 'null.bin': Buffer.alloc(600 * 1024) }); + const r = expandZip(zip, 'bombe.zip', 0); + expect(r.blobs).toEqual([]); + expect(r.ignored).toEqual([{ file: 0, path: 'bombe.zip/null.bin', reason: 'suspicious' }]); + }); + + it('behaltene Eintraege ueber der Gesamtgrenze: ein zipTooLarge, keine Teile', () => { + const zip = zipOf({ 'a.pem': Buffer.from('1234567890'), 'b.pem': Buffer.from('1234567890') }); + const r = expandZip(zip, 'summe.zip', 4, { ...ZIP_LIMITS, maxTotalBytes: 15 }); + expect(r.blobs).toEqual([]); + expect(r.ignored).toEqual([{ file: 4, path: 'summe.zip', reason: 'zipTooLarge' }]); + }); + + it('prueft die Grenzen vor dem Entpacken (kein Entpacken bei zipTooLarge)', () => { + const zip = zipOf({ 'a.pem': Buffer.from('1234567890'), 'b.pem': Buffer.from('1234567890') }); + const original = AdmZip.prototype.getEntries; + let inflated = 0; + AdmZip.prototype.getEntries = function patched(this: AdmZip) { + const entries = original.call(this); + for (const e of entries) { + const get = e.getData.bind(e); + e.getData = () => { + inflated++; + return get(); + }; + } + return entries; + }; + try { + expandZip(zip, 's.zip', 0, { ...ZIP_LIMITS, maxTotalBytes: 15 }); + } finally { + AdmZip.prototype.getEntries = original; + } + expect(inflated).toBe(0); + }); + + it('Anzeigepfad ohne Steuerzeichen', () => { + const zip = zipOf({ 'a\u0001b.pem': 'x' }); + const r = expandZip(zip, 'c.zip', 0); + expect(r.blobs[0].path).toBe('c.zip/ab.pem'); + }); + + it('leeres ZIP ergibt keine Teile und keinen Fehler', () => { + const r = expandZip(new AdmZip().toBuffer(), 'leer.zip', 0); + expect(r.blobs).toEqual([]); + }); +}); diff --git a/apps/api/src/cert-manager/zip-expand.ts b/apps/api/src/cert-manager/zip-expand.ts new file mode 100644 index 0000000..f5f4795 --- /dev/null +++ b/apps/api/src/cert-manager/zip-expand.ts @@ -0,0 +1,136 @@ +import AdmZip from 'adm-zip'; +import { cleanSourcePath } from './cert-names'; +import type { IgnoredEntry } from './cert-types'; + +/** + * ZIP-Erkennung und -Entpacken des Zertifikat-Managers (quick-261009-ikt, D-17). + * + * Regeln: + * - Ein ZIP erkennt man an den Anfangsbytes (PK\x03\x04 oder PK\x05\x06), nie an der Endung. + * - Nur eine Ebene: ein Eintrag, der selbst ein ZIP ist, wird mit Grund `nestedZip` gemeldet. + * - Muell wird still uebersprungen: Ordner, `__MACOSX/`, Namen mit fuehrendem Punkt, Thumbs.db, + * desktop.ini. Er zaehlt auch nicht zur Eintragsgrenze. + * - Mehr als `maxEntries` Eintraege: das ganze ZIP wird mit `tooManyEntries` abgelehnt. + * - Verschluesselter Eintrag (Flag Bit 0): `encryptedZip`. Deklarierte Groesse ueber `maxEntryBytes`: + * `tooLarge`. Deklarierte Groesse / max(gepackt, 1) ueber `maxRatio`: `suspicious`. + * - Summe der deklarierten Groessen der behaltenen Eintraege ueber `maxTotalBytes`: das ganze ZIP + * wird mit `zipTooLarge` abgelehnt. + * - Alle diese Pruefungen laufen auf den Kopfdaten und damit VOR dem ersten Entpacken. + * adm-zip entpackt hoechstens die deklarierte Groesse und prueft die CRC; zusaetzlich wird das + * Ergebnis noch einmal gegen die Grenze geprueft. + * - Eintragsnamen dienen nur der Anzeige (Steuerzeichen entfernt, hoechstens 255 Zeichen) und + * werden nie als Dateipfad benutzt. + */ + +export interface ZipLimits { + maxEntries: number; + maxEntryBytes: number; + maxRatio: number; + maxTotalBytes: number; +} + +export const ZIP_LIMITS: ZipLimits = { + maxEntries: 100, + maxEntryBytes: 1024 * 1024, + maxRatio: 100, + maxTotalBytes: 20 * 1024 * 1024, +}; + +export interface ZipBlob { + /** Anzeigepfad: "zipname/eintrag" */ + path: string; + buffer: Buffer; +} + +export interface ZipExpansion { + blobs: ZipBlob[]; + ignored: IgnoredEntry[]; +} + +/** ZIP-Anfangsbytes: lokaler Dateikopf (PK 03 04) oder leeres Archiv (PK 05 06). */ +export function isZip(buffer: Buffer): boolean { + return ( + buffer.length >= 4 && + buffer[0] === 0x50 && + buffer[1] === 0x4b && + ((buffer[2] === 0x03 && buffer[3] === 0x04) || (buffer[2] === 0x05 && buffer[3] === 0x06)) + ); +} + +const JUNK_FILES = new Set(['thumbs.db', 'desktop.ini']); + +function isJunk(entryName: string, isDirectory: boolean): boolean { + if (isDirectory) return true; + const segments = entryName.split(/[\\/]/).filter(Boolean); + if (segments.length === 0) return true; + if (segments.some((s) => s === '__MACOSX' || s.startsWith('.'))) return true; + return JUNK_FILES.has(segments[segments.length - 1].toLowerCase()); +} + +function displayPath(zipName: string, entryName: string): string { + return cleanSourcePath(`${zipName}/${entryName.replace(/^[\\/]+/, '')}`); +} + +export function expandZip( + buffer: Buffer, + zipName: string, + file: number, + limits: ZipLimits = ZIP_LIMITS, +): ZipExpansion { + const blobs: ZipBlob[] = []; + const ignored: IgnoredEntry[] = []; + const whole = (reason: IgnoredEntry['reason']): ZipExpansion => ({ + blobs: [], + ignored: [{ file, path: cleanSourcePath(zipName), reason }], + }); + + let entries: ReturnType; + try { + entries = new AdmZip(buffer).getEntries(); + } catch { + return whole('brokenZip'); + } + + const candidates = entries.filter((e) => !isJunk(e.entryName, e.isDirectory)); + if (candidates.length > limits.maxEntries) return whole('tooManyEntries'); + + // Kopfdaten pruefen, bevor irgendein Eintrag entpackt wird. + const kept: typeof candidates = []; + let total = 0; + for (const entry of candidates) { + const path = displayPath(zipName, entry.entryName); + const size = entry.header.size; + if (entry.header.encrypted) { + ignored.push({ file, path, reason: 'encryptedZip' }); + } else if (entry.entryName.toLowerCase().endsWith('.zip')) { + ignored.push({ file, path, reason: 'nestedZip' }); + } else if (size > limits.maxEntryBytes) { + ignored.push({ file, path, reason: 'tooLarge' }); + } else if (size / Math.max(entry.header.compressedSize, 1) > limits.maxRatio) { + ignored.push({ file, path, reason: 'suspicious' }); + } else { + kept.push(entry); + total += size; + } + } + if (total > limits.maxTotalBytes) return whole('zipTooLarge'); + + for (const entry of kept) { + const path = displayPath(zipName, entry.entryName); + let data: Buffer; + try { + data = entry.getData(); + } catch { + ignored.push({ file, path, reason: 'brokenZip' }); + continue; + } + if (data.length > limits.maxEntryBytes) { + ignored.push({ file, path, reason: 'tooLarge' }); + } else if (isZip(data)) { + ignored.push({ file, path, reason: 'nestedZip' }); + } else { + blobs.push({ path, buffer: data }); + } + } + return { blobs, ignored }; +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx index 00e9bd5..c255d1e 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx @@ -43,14 +43,19 @@ beforeEach(() => { afterEach(cleanup); describe('CertManagerPage', () => { - it('zeigt Titel und die Reiter „Dateien“ und „Zusammenführen“, „Dateien“ ist aktiv', () => { + it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen', () => { render(); expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument(); const nav = screen.getByRole('navigation'); const tabs = within(nav).getAllByRole('button'); - expect(tabs.map((b) => b.textContent)).toEqual(['Dateien', 'Zusammenführen']); + expect(tabs.map((b) => b.textContent)).toEqual([ + 'Dateien', + 'Analysieren', + 'Aufteilen', + 'Zusammenführen', + ]); expect(tabs[0]).toHaveAttribute('aria-current', 'page'); - expect(tabs[1]).not.toHaveAttribute('aria-current'); + for (const tab of tabs.slice(1)) expect(tab).not.toHaveAttribute('aria-current'); // Startansicht ist der Dateien-Reiter expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument(); }); @@ -84,6 +89,16 @@ describe('CertManagerPage', () => { expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument(); }); + it.each([ + 'Analysieren', + 'Aufteilen', + ])('%s mit leerem Arbeitsbereich: Hinweis auf den Reiter „Dateien“', (tab) => { + render(); + fireEvent.click(screen.getByRole('button', { name: tab })); + expect(screen.getByText(/Noch keine Dateien\. Laden Sie Ihre Zertifikate/)).toBeInTheDocument(); + expect(screen.queryByTestId('cert-file-input')).not.toBeInTheDocument(); + }); + it('mit Dateien zeigt Zusammenführen die Grundlage, und die Liste bleibt beim Reiterwechsel', async () => { render(); fireEvent.change(screen.getByTestId('cert-file-input'), { diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx new file mode 100644 index 0000000..4e9f8b2 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx @@ -0,0 +1,231 @@ +import { cleanup, render as rtlRender, screen, within } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import de from '@/messages/de.json'; +import type { AnalysisResult, CertItem, ChainInfo } from '../actions'; +import type { CertWorkspace } from '../use-cert-workspace'; +import type { WorkingEntry } from '../working-set'; +import { AnalyzeTab } from './AnalyzeTab'; + +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +function cert(over: Partial & Pick): CertItem { + return { + kind: 'certificate', + sources: [{ file: 0, path: 'a.pem' }], + pem: `PEM-${over.id}`, + baseName: over.cn, + organization: '', + issuerCn: 'Test Inter', + issuerOrganization: '', + notBefore: '2026-01-01T00:00:00.000Z', + notAfter: '2126-03-15T23:59:59.000Z', + isExpired: false, + daysLeft: 36000, + san: ['www.example.test', 'example.test'], + keyType: 'RSA', + keyBits: 2048, + curve: null, + serialNumber: 'AB12', + sha256: 'AA:BB:CC', + sha1: '11:22:33', + isCa: over.role !== 'end-entity', + selfSigned: over.role === 'root', + aiaIssuerUrls: [], + keyId: null, + csrIds: [], + ...over, + }; +} + +function entry(id: string, name: string, label = name): WorkingEntry { + return { + id, + file: new File(['x'], name), + label, + origin: 'upload', + host: null, + password: '', + }; +} + +function workspace( + analysis: AnalysisResult | null, + entries: WorkingEntry[] = [], + analysisIds: string[] = [], +): CertWorkspace { + return { + entries, + analysis, + analysisIds, + status: 'idle', + errorKey: null, + addFiles: () => [], + addText: () => null, + remove: () => {}, + clear: () => {}, + retry: () => {}, + }; +} + +const leaf = cert({ + id: 'c-leaf', + cn: 'www.example.test', + role: 'end-entity', + sources: [ + { file: 0, path: 'a.pem' }, + { file: 1, path: 'vendor.zip/kopie/a.pem' }, + ], +}); +const inter = cert({ + id: 'c-inter', + cn: 'Test Inter', + role: 'intermediate', + issuerCn: 'Test Root', + sources: [{ file: 1, path: 'vendor.zip/Intermediate/CA.crt' }], +}); +const ec = cert({ + id: 'c-ec', + cn: 'ec.example.test', + role: 'end-entity', + keyType: 'EC', + keyBits: 256, + curve: 'P-256', + isExpired: true, + daysLeft: -4, + notAfter: '2026-10-05T10:00:00.000Z', +}); +const soon = cert({ id: 'c-soon', cn: 'bald.example.test', role: 'end-entity', daysLeft: 12 }); + +const chains: ChainInfo[] = [ + { + headId: 'c-leaf', + path: ['c-leaf', 'c-inter'], + rootId: null, + complete: false, + alternatives: 0, + gap: { certId: 'c-inter', kind: 'afterCa', missingIssuerCn: 'Test Root', aiaUrls: [] }, + }, + { + headId: 'c-ec', + path: ['c-ec'], + rootId: null, + complete: false, + alternatives: 0, + gap: { certId: 'c-ec', kind: 'afterLeaf', missingIssuerCn: 'Inter EC', aiaUrls: [] }, + }, +]; + +afterEach(cleanup); + +describe('AnalyzeTab', () => { + const analysis: AnalysisResult = { + items: [leaf, ec, soon, inter], + chains, + locked: [], + ignored: [ + { file: 1, path: 'vendor.zip/inner.zip', reason: 'nestedZip' }, + { file: 1, path: 'vendor.zip/readme.txt', reason: 'unknown' }, + ], + }; + const entries = [entry('e1', 'a.pem'), entry('e2', 'vendor.zip')]; + + it('zeigt zuerst die Ketten, dann die Zertifikate, dann Nicht verwendetes', () => { + render(); + const headings = screen.getAllByRole('heading').map((h) => h.textContent); + expect(headings).toEqual(['Ketten', 'Zertifikate', 'Nicht verwendet']); + const chainsRegion = screen.getByRole('region', { name: 'Ketten' }); + expect(within(chainsRegion).getByText(/Zwischenzertifikat fehlt/)).toBeInTheDocument(); + }); + + it('eine Karte je Zertifikat mit Rolle, Name, Aussteller, Gueltigkeit in UTC, Namen, Schluessel', () => { + render(); + const cards = screen.getAllByTestId('cert-card'); + expect(cards).toHaveLength(4); + const first = cards[0]; + expect(within(first).getByText('Serverzertifikat')).toBeInTheDocument(); + expect(within(first).getByText('www.example.test')).toBeInTheDocument(); + expect(within(first).getByText('Test Inter')).toBeInTheDocument(); + expect(within(first).getByText('www.example.test, example.test')).toBeInTheDocument(); + expect(within(first).getByText('RSA, 2048 Bit')).toBeInTheDocument(); + expect(within(first).getByText('AB12')).toBeInTheDocument(); + expect(within(first).getByText('AA:BB:CC')).toBeInTheDocument(); + expect(within(first).getByText('11:22:33')).toBeInTheDocument(); + // 23:59:59 UTC bleibt der 15. Maerz (nicht der Folgetag in Berlin) + expect(first).toHaveTextContent('15.03.2126'); + }); + + it('EC-Zertifikat nennt die Kurve, abgelaufen und bald ablaufend sind unterscheidbar', () => { + render(); + const ecCard = screen + .getAllByTestId('cert-card') + .find((c) => c.textContent?.includes('ec.example.test')) as HTMLElement; + expect(within(ecCard).getByText('EC, Kurve P-256')).toBeInTheDocument(); + expect(within(ecCard).getByText('Abgelaufen')).toBeInTheDocument(); + const soonCard = screen + .getAllByTestId('cert-card') + .find((c) => c.textContent?.includes('bald.example.test')) as HTMLElement; + expect(within(soonCard).getByText('Läuft in 12 Tagen ab')).toBeInTheDocument(); + const okCard = screen.getAllByTestId('cert-card')[0]; + expect(within(okCard).getByText('Gültig', { selector: 'span' })).toBeInTheDocument(); + }); + + it('nennt die Quellen mit Datei und ZIP-Pfad', () => { + render(); + const card = screen.getAllByTestId('cert-card')[0]; + expect(within(card).getByText('a.pem, vendor.zip/kopie/a.pem')).toBeInTheDocument(); + }); + + it('eingefuegter Text erscheint mit seiner Beschriftung als Quelle', () => { + const pasted = entry('e9', 'pasted-1.pem', 'Eingefügter Text 1'); + const a: AnalysisResult = { + items: [ + cert({ + id: 'c-p', + cn: 'p.example.test', + role: 'end-entity', + sources: [{ file: 0, path: 'pasted-1.pem' }], + }), + ], + chains: [], + locked: [], + ignored: [], + }; + render(); + expect(screen.getByText('Eingefügter Text 1')).toBeInTheDocument(); + }); + + it('listet uebersprungene Eintraege mit Pfad und Grund', () => { + render(); + const region = screen.getByRole('region', { name: 'Nicht verwendet' }); + const items = within(region).getAllByRole('listitem'); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('vendor.zip/inner.zip'); + expect(items[0]).toHaveTextContent('Ein ZIP in einem ZIP wird nicht geöffnet.'); + expect(items[1]).toHaveTextContent('Darin wurde kein Zertifikat erkannt.'); + }); + + it('ohne erkanntes Zertifikat und ohne Ausgelassenes: ruhiger Hinweis', () => { + render( + , + ); + expect( + screen.getByText('In den Dateien wurde noch kein Zertifikat erkannt.'), + ).toBeInTheDocument(); + }); + + it('enthaelt keine Pfeilzeichen und keine ALL-CAPS-Beschriftungen', () => { + const { container } = render( + , + ); + expect(container.textContent).not.toMatch(/[→←·]/); + expect(container.innerHTML).not.toContain('uppercase'); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx new file mode 100644 index 0000000..438fe89 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import type { CertItem } from '../actions'; +import type { CertWorkspace } from '../use-cert-workspace'; +import { ChainView } from './ChainView'; +import { ItemCard } from './ItemCard'; + +interface AnalyzeTabProps { + workspace: CertWorkspace; +} + +/** + * Reiter „Analysieren“: arbeitet auf dem gemeinsamen Arbeitsbereich (D-01). Zuerst die Ketten + * (eine je Serverzertifikat), dann jedes Zertifikat im Einzelnen, zuletzt, was uebersprungen wurde. + */ +export function AnalyzeTab({ workspace }: AnalyzeTabProps) { + const t = useTranslations('certManager'); + const { analysis, entries, analysisIds, status } = workspace; + + if (!analysis) { + return status === 'analyzing' ? ( +

+ {t('files.analyzing')} +

+ ) : null; + } + + const certs = analysis.items.filter((i): i is CertItem => i.kind === 'certificate'); + + if (certs.length === 0 && analysis.ignored.length === 0) { + return

{t('analyze.nothingFound')}

; + } + + return ( +
+

{t('analyze.intro')}

+ + {analysis.chains.length > 0 && ( +
+

{t('analyze.chainsTitle')}

+ {analysis.chains.map((chain) => ( + + ))} +
+ )} + + {certs.length > 0 && ( +
+

+ {t('analyze.certificatesTitle')} +

+
    + {certs.map((cert) => ( + + ))} +
+
+ )} + + {analysis.ignored.length > 0 && ( +
+

{t('analyze.ignoredTitle')}

+
    + {analysis.ignored.map((i) => ( +
  • + {i.path}{' '} + {t(`files.ignored.${i.reason}`)} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx index 4a46e03..4489da5 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx @@ -215,4 +215,79 @@ describe('FilesTab', () => { expect(screen.getByText('Test Inter')).toBeInTheDocument(); expect(screen.queryByText('Darin wurde kein Zertifikat erkannt.')).not.toBeInTheDocument(); }); + it('ein ZIP zeigt die enthaltenen Teile nach Pfad mit Rolle und Name', async () => { + mockAnalyze.mockResolvedValueOnce({ + items: [ + cert('c-srv', 'www.example.test', 'end-entity', [0], ['vendor.zip/ServerCertificate.crt']), + cert('c-ca', 'Test Inter', 'intermediate', [0], ['vendor.zip/Intermediate/CA.crt']), + ], + chains: [], + locked: [], + ignored: [{ file: 0, path: 'vendor.zip/inner.zip', reason: 'nestedZip' }], + } satisfies AnalysisResult); + render(); + selectFiles([makeFile('vendor.zip')]); + const srvPath = await screen.findByText('vendor.zip/ServerCertificate.crt'); + const group = srvPath.closest('li') as HTMLElement; + expect(within(group).getByText('www.example.test')).toBeInTheDocument(); + expect(within(group).getByText('Serverzertifikat')).toBeInTheDocument(); + const caGroup = screen.getByText('vendor.zip/Intermediate/CA.crt').closest('li') as HTMLElement; + expect(within(caGroup).getByText('Zwischenzertifikat')).toBeInTheDocument(); + const nested = screen.getByText('vendor.zip/inner.zip').closest('li') as HTMLElement; + expect( + within(nested).getByText('Ein ZIP in einem ZIP wird nicht geöffnet.'), + ).toBeInTheDocument(); + }); + + it.each([ + ['nestedZip', 'Ein ZIP in einem ZIP wird nicht geöffnet.'], + ['encryptedZip', 'Der ZIP-Inhalt ist mit einem Passwort geschützt und wird übersprungen.'], + ['tooLarge', 'Die Datei ist zu groß und wird übersprungen.'], + ['suspicious', 'Die Datei ist ungewöhnlich stark gepackt und wird übersprungen.'], + ['zipTooLarge', 'Das ZIP enthält zu viele Daten und wird nicht geöffnet.'], + ['tooManyEntries', 'Das ZIP enthält zu viele Dateien und wird nicht geöffnet.'], + ['brokenZip', 'Das ZIP lässt sich nicht lesen.'], + ] as const)('Grund %s wird als Text gezeigt', async (reason, text) => { + mockAnalyze.mockResolvedValueOnce({ + items: [], + chains: [], + locked: [], + ignored: [{ file: 0, path: 'v.zip/x', reason }], + } satisfies AnalysisResult); + render(); + selectFiles([makeFile('v.zip')]); + expect(await screen.findByText(text)).toBeInTheDocument(); + }); + + it('der eingeklappte Bereich „PEM-Text einfügen“ fügt „Eingefügter Text 1“ als Texteintrag hinzu', async () => { + render(); + expect(screen.queryByLabelText('Eingefügter PEM-Text')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' })); + const area = screen.getByLabelText('Eingefügter PEM-Text'); + const add = screen.getByRole('button', { name: 'Hinzufügen' }); + expect(add).toBeDisabled(); + fireEvent.change(area, { target: { value: '-----BEGIN CERTIFICATE-----\nabc\n' } }); + fireEvent.click(add); + expect(await screen.findByText('Eingefügter Text 1')).toBeInTheDocument(); + expect(screen.getByText('Eingefügter Text')).toBeInTheDocument(); + const sent = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[]; + expect(sent.map((e) => e.file.name)).toEqual(['pasted-1.pem']); + // Bereich ist wieder zu, der naechste Text wird Nummer 2 + expect(screen.queryByLabelText('Eingefügter PEM-Text')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' })); + fireEvent.change(screen.getByLabelText('Eingefügter PEM-Text'), { target: { value: 'zwei' } }); + fireEvent.click(screen.getByRole('button', { name: 'Hinzufügen' })); + expect(await screen.findByText('Eingefügter Text 2')).toBeInTheDocument(); + }); + + it('zu langer eingefügter Text wird mit Grund abgelehnt', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' })); + fireEvent.change(screen.getByLabelText('Eingefügter PEM-Text'), { + target: { value: 'a'.repeat(256_001) }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Hinzufügen' })); + expect(screen.getByRole('alert')).toHaveTextContent('länger als'); + expect(mockAnalyze).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx index 580976b..3d9cfa1 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx @@ -2,12 +2,13 @@ import { useTranslations } from 'next-intl'; import { useRef, useState } from 'react'; -import type { AnyItem } from '../actions'; +import type { AnyItem, IgnoredEntry } from '../actions'; import type { CertWorkspace } from '../use-cert-workspace'; import { formatBytes, MAX_ENTRIES, MAX_FILE_BYTES, + MAX_PASTE_CHARS, MAX_TOTAL_BYTES, type RejectedFile, type WorkingEntry, @@ -51,6 +52,13 @@ function itemName(item: AnyItem): string { return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName; } +/** Was in einer Datei (bei ZIP: unter einem Pfad im ZIP) erkannt oder uebersprungen wurde. */ +interface PartGroup { + path: string; + items: AnyItem[]; + ignored: IgnoredEntry[]; +} + interface FilesTabProps { workspace: CertWorkspace; } @@ -64,6 +72,8 @@ export function FilesTab({ workspace }: FilesTabProps) { const inputRef = useRef(null); const [dragging, setDragging] = useState(false); const [rejected, setRejected] = useState([]); + const [pasteOpen, setPasteOpen] = useState(false); + const [pasteText, setPasteText] = useState(''); const { entries, analysis, analysisIds, status, errorKey } = workspace; const add = (list: FileList | File[] | null | undefined) => { @@ -72,12 +82,36 @@ export function FilesTab({ workspace }: FilesTabProps) { setRejected(workspace.addFiles(files)); }; - const itemsOf = (entry: WorkingEntry): AnyItem[] => - (analysis?.items ?? []).filter((item) => - item.sources.some((s) => analysisIds[s.file] === entry.id), - ); - const ignoredOf = (entry: WorkingEntry) => - (analysis?.ignored ?? []).filter((i) => analysisIds[i.file] === entry.id); + const addPasted = () => { + const problem = workspace.addText(pasteText, (n) => t('files.pastedLabel', { n })); + setRejected(problem ? [problem] : []); + if (!problem) { + setPasteText(''); + setPasteOpen(false); + } + }; + + /** Teile eines Eintrags nach Pfad gruppiert: bei einem ZIP je enthaltene Datei, sonst eine Gruppe. */ + const groupsOf = (entry: WorkingEntry): PartGroup[] => { + const groups = new Map(); + const group = (path: string): PartGroup => { + let g = groups.get(path); + if (!g) { + g = { path, items: [], ignored: [] }; + groups.set(path, g); + } + return g; + }; + for (const item of analysis?.items ?? []) { + for (const source of item.sources) { + if (analysisIds[source.file] === entry.id) group(source.path).items.push(item); + } + } + for (const ignored of analysis?.ignored ?? []) { + if (analysisIds[ignored.file] === entry.id) group(ignored.path).ignored.push(ignored); + } + return [...groups.values()].sort((a, b) => a.path.localeCompare(b.path)); + }; return (
@@ -123,6 +157,40 @@ export function FilesTab({ workspace }: FilesTabProps) { })}

+
+ + {pasteOpen && ( +
+

{t('files.pasteHint')}

+