1554ae83c1
- 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>
157 lines
6.2 KiB
TypeScript
157 lines
6.2 KiB
TypeScript
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([]);
|
|
});
|
|
});
|