feat(cert-manager): Hersteller-ZIP, PKCS#7, eingefügter Text, Analysieren und Aufteilen
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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<CertItem['role'], number> = { 'end-entity': 0, intermediate: 1, root: 2 };
|
||||
|
||||
function sameSource(a: ItemSource, b: ItemSource): boolean {
|
||||
|
||||
@@ -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<string, Buffer>): 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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, Buffer | string>): 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<AdmZip['getEntries']>;
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user