aere-quantum/crypto-inventory/test/proba.mjs

487 lines
31 KiB
JavaScript

// Suita de probe a inventarului criptografic.
// Folosire: node test/proba.mjs [--json]
// Modulul masurat se poate schimba cu CRYPTO_INVENTORY_MODULE=<cale>/inventar.mjs (asa il foloseste
// control-negativ.mjs pe o copie stricata). Fixturile raman mereu cele din test/fixturi.
// Fiecare proba ruleaza izolat: o proba care arunca iese ROSU cu mesajul ei, restul ruleaza mai departe.
import { readFileSync, readdirSync, statSync, existsSync, mkdtempSync, writeFileSync, rmSync, copyFileSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { generateKeyPairSync } from 'node:crypto';
const AICI = dirname(fileURLToPath(import.meta.url));
const FIX = join(AICI, 'fixturi');
const CALE_MODUL = process.env.CRYPTO_INVENTORY_MODULE ? resolve(process.env.CRYPTO_INVENTORY_MODULE) : join(AICI, '..', 'inventar.mjs');
const inv = await import(pathToFileURL(CALE_MODUL).href);
const CA_JSON = process.argv.includes('--json');
// numarul total de marcaje EXPECT scrise de mana in fixturi; o schimbare aici se face constient
const MARCAJE_ASTEPTATE = 96;
const FISIERE_CU_MARCAJE = ['js/node-crypto.mjs', 'js/webcrypto.js', 'js/biblioteci.ts', 'js/tls.cjs', 'python/crypto_py.py', 'java/Crypto.java', 'go/crypto.go', 'negative/variabile.js'];
const probe = [];
const proba = (nume, fn) => probe.push({ nume, fn });
function asigura(cond, mesaj) { if (!cond) throw new Error(mesaj); }
const memo = new Map();
function scan(dir, optiuni = {}) {
const k = dir + JSON.stringify(optiuni);
if (!memo.has(k)) {
try { memo.set(k, { rez: inv.scan(dir, optiuni) }); } catch (err) { memo.set(k, { err }); }
}
const r = memo.get(k);
if (r.err) throw r.err;
return r.rez;
}
function marcaje(rel) {
const r = [];
readFileSync(join(FIX, rel), 'utf8').split('\n').forEach((l, i) => {
const m = /EXPECT:\s*(.+?)\s*$/.exec(l);
if (!m) return;
for (const p of m[1].split(' ; ')) {
const [n, c] = p.split(' | ').map((s) => s.trim());
r.push(`${i + 1}|${n}|${c}`);
}
});
return r.sort();
}
function gasiriDin(rez, rel) {
return rez.findings.filter((g) => g.file === rel);
}
function comparaExact(rel) {
const rez = scan(FIX);
const ast = marcaje(rel);
asigura(ast.length > 0, `${rel}: fixtura nu are marcaje`);
const real = gasiriDin(rez, rel).map((g) => `${g.line}|${g.name}|${g.classification}`).sort();
const lipsa = ast.filter((x) => !real.includes(x));
const extra = real.filter((x) => !ast.includes(x));
asigura(!lipsa.length && !extra.length && ast.length === real.length,
`${rel}: lipsa [${lipsa.join(', ')}] extra [${extra.join(', ')}] (asteptate ${ast.length}, gasite ${real.length})`);
}
// --- pozitive: fiecare gasire asteptata, cu linia si clasa ei, si nimic in plus ---
for (const rel of FISIERE_CU_MARCAJE.filter((f) => !f.startsWith('negative/'))) {
proba(`pozitiv: ${rel}`, () => comparaExact(rel));
}
proba('pozitiv: numarul total de marcaje EXPECT este cel scris', () => {
const n = FISIERE_CU_MARCAJE.reduce((s, f) => s + marcaje(f).length, 0);
asigura(n === MARCAJE_ASTEPTATE, `marcaje citite ${n}, scrise ${MARCAJE_ASTEPTATE}`);
const rez = scan(FIX);
const inFisiere = rez.findings.filter((g) => FISIERE_CU_MARCAJE.includes(g.file)).length;
asigura(inFisiere === MARCAJE_ASTEPTATE, `gasiri in fisierele cu marcaje: ${inFisiere}, marcaje ${MARCAJE_ASTEPTATE}`);
});
proba('pozitiv: variabila rezolvata la un literal unic poarta resolvedFrom', () => {
const g = gasiriDin(scan(FIX), 'js/node-crypto.mjs').find((x) => x.name === 'SHA-256');
asigura(g && /HASH_ALG \(line 6\)/.test(g.resolvedFrom || ''), `resolvedFrom: ${g && g.resolvedFrom}`);
const j = gasiriDin(scan(FIX), 'java/Crypto.java').find((x) => x.name === 'SHA-256');
asigura(j && /DIGEST/.test(j.resolvedFrom || ''), `java resolvedFrom: ${j && j.resolvedFrom}`);
});
proba('pozitiv: manifestele dau biblioteci declarate, nu gasiri', () => {
const rez = scan(join(FIX, 'manifeste'));
asigura(rez.findings.length === 0, `gasiri din manifeste: ${rez.findings.length}`);
const libs = rez.libraries.map((b) => `${b.file}:${b.line} ${b.name}`).sort();
const ast = ['go.mod:6 github.com/cloudflare/circl', 'go.mod:7 golang.org/x/crypto', 'package.json:10 @noble/post-quantum',
'package.json:5 ethers', 'package.json:7 jsonwebtoken', 'pom.xml:5 org.bouncycastle:bcprov-jdk18on',
'requirements.txt:2 cryptography', 'requirements.txt:3 pyjwt'].sort();
asigura(JSON.stringify(libs) === JSON.stringify(ast), `biblioteci: ${libs.join(' | ')}`);
const bom = inv.buildCbom(rez, { deterministic: true });
const c = bom.components.filter((x) => x.type === 'library');
asigura(c.length === 8 && c.every((x) => x.properties.some((p) => p.name.endsWith('declared-only') && p.value === 'true')), 'componentele library nu poarta declared-only');
});
proba('pozitiv: material PEM (certificat si cheie publica)', () => {
const rez = scan(FIX);
const c = gasiriDin(rez, 'material/certificat-proba.pem');
asigura(c.length === 1 && c[0].assetType === 'certificate' && c[0].name === 'EC-secp256r1' && c[0].classification === 'quantum-vulnerable' && c[0].line === 1, `certificat: ${JSON.stringify(c.map((x) => [x.assetType, x.name, x.line]))}`);
asigura(/crypto-inventory-fixture/.test(c[0].certificate.subjectName), 'subiectul certificatului lipseste');
const p = gasiriDin(rez, 'material/cheie-publica-proba.pem');
asigura(p.length === 1 && p[0].assetType === 'related-crypto-material' && p[0].material.type === 'public-key' && p[0].name === 'RSA-2048' && p[0].line === 2, `cheie publica: ${JSON.stringify(p.map((x) => [x.assetType, x.name, x.line]))}`);
});
proba('pozitiv: cheie privata generata la rulare e raportata, iar valoarea ei nu apare in iesire', () => {
const d = mkdtempSync(join(tmpdir(), 'ci-cheie-'));
try {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' });
writeFileSync(join(d, 'config.txt'), `setari\n${pem}`);
const rez = inv.scan(d);
asigura(rez.findings.length === 1, `gasiri: ${rez.findings.length}`);
const g = rez.findings[0];
asigura(g.assetType === 'related-crypto-material' && g.material.type === 'private-key' && g.name === 'EC-secp256r1' && g.line === 2, `gasirea: ${JSON.stringify([g.assetType, g.material, g.name, g.line])}`);
const corp = pem.split('\n').filter((l) => l && !l.startsWith('-----')).join('');
const iesire = JSON.stringify(inv.buildCbom(rez)) + JSON.stringify(rez.findings) + inv.renderSummary(rez);
for (let i = 0; i + 24 <= corp.length; i += 8) asigura(!iesire.includes(corp.slice(i, i + 24)), `o bucata din cheia privata apare in iesire (offset ${i})`);
} finally {
rmSync(d, { recursive: true, force: true });
}
});
// --- clasificare ---
proba('clasificare: secp256k1 este quantum-vulnerable, cu recomandare de cont post-cuantic', () => {
const a = inv.classify({ grup: 'SECP256K1' });
asigura(a.classification === 'quantum-vulnerable' && a.quantumVulnerable === true, `clasa ${a.classification}`);
asigura(/post-quantum key/.test(a.recommendation) && /not a library swap/.test(a.recommendation), 'recomandarea nu vorbeste de contul post-cuantic');
const f = gasiriDin(scan(FIX), 'js/node-crypto.mjs').find((x) => x.curve === 'secp256k1');
asigura(f && f.classification === 'quantum-vulnerable', 'gasirea secp256k1 din fixtura nu e quantum-vulnerable');
});
proba('clasificare: MD5 si SHA-1 sunt weak-now, SHA-256 quantum-safe', () => {
const c = (h) => inv.classify({ grup: 'HASH', hash: h }).classification;
asigura(c('MD5') === 'weak-now' && c('SHA1') === 'weak-now' && c('SHA256') === 'quantum-safe', `MD5 ${c('MD5')} SHA1 ${c('SHA1')} SHA256 ${c('SHA256')}`);
});
proba('clasificare: AES-128 quantum-safe cu nota Grover, ECB weak-now, DES weak-now', () => {
const a = inv.classify({ grup: 'CIPHER', nume: 'AES', param: '128', mod: 'gcm' });
asigura(a.classification === 'quantum-safe' && /Grover/.test(a.reason + a.recommendation) && a.nistQuantumSecurityLevel === 1, `AES-128: ${a.classification} ${a.nistQuantumSecurityLevel}`);
asigura(inv.classify({ grup: 'CIPHER', nume: 'AES', param: '256', mod: 'ecb' }).classification === 'weak-now', 'ECB nu e weak-now');
asigura(inv.classify({ grup: 'CIPHER', nume: 'DES' }).classification === 'weak-now', 'DES nu e weak-now');
asigura(inv.classify({ grup: 'TLS', param: '1.1', rol: 'min' }).classification === 'weak-now', 'TLS 1.1 nu e weak-now');
});
proba('clasificare: fiecare gasire vulnerabila sau slaba are motiv si recomandare', () => {
const rez = scan(FIX);
for (const g of rez.findings) {
asigura(g.reason && g.reason.length > 20, `${g.file}:${g.line} ${g.name} fara motiv`);
if (g.classification === 'quantum-vulnerable' || g.classification === 'weak-now' || g.classification === 'unknown') {
asigura(g.recommendation && g.recommendation.length > 20, `${g.file}:${g.line} ${g.name} (${g.classification}) fara recomandare`);
}
}
});
proba('clasificare: JWT asimetric spune ca standardul post-cuantic nu e final', () => {
const g = gasiriDin(scan(FIX), 'js/biblioteci.ts').find((x) => x.name === 'JWS RS256');
asigura(g && /no final standard/.test(g.recommendation) && /draft/.test(g.recommendation), 'recomandarea JWT nu spune ca nu exista standard final');
});
// --- negative ---
function controlPozitivMd5() {
const g = gasiriDin(scan(FIX), 'js/node-crypto.mjs').find((x) => x.name === 'MD5');
asigura(g, 'controlul pozitiv al metodei: MD5 din js/node-crypto.mjs lipseste, deci un zero nu inseamna nimic');
}
proba('negativ: comentariile JS nu produc gasiri', () => {
controlPozitivMd5();
const g = gasiriDin(scan(FIX), 'negative/comentarii.js');
asigura(g.length === 0, `comentariile au produs: ${g.map((x) => `${x.line} ${x.name}`).join(', ')}`);
});
proba('negativ: apelul scris intr-un sir JS nu e folosire', () => {
controlPozitivMd5();
const g = gasiriDin(scan(FIX), 'negative/siruri.js');
asigura(g.length === 0, `sirurile au produs: ${g.map((x) => `${x.line} ${x.name}`).join(', ')}`);
});
proba('negativ: comentariile si docstring-urile Python nu produc gasiri', () => {
const rez = scan(FIX);
asigura(gasiriDin(rez, 'python/crypto_py.py').some((x) => x.name === 'MD5'), 'controlul pozitiv: hashlib.md5 din fixtura Python lipseste');
const g = gasiriDin(rez, 'negative/docstring.py');
asigura(g.length === 0, `docstring/comentariu au produs: ${g.map((x) => `${x.line} ${x.name}`).join(', ')}`);
});
proba('negativ: comentariile si sirurile Java si Go nu produc gasiri', () => {
const rez = scan(FIX);
asigura(gasiriDin(rez, 'java/Crypto.java').some((x) => x.name === 'MD5') && gasiriDin(rez, 'go/crypto.go').some((x) => x.name === 'MD5'), 'controlul pozitiv: MD5 din fixturile Java/Go lipseste');
const g = [...gasiriDin(rez, 'negative/Comentarii.java'), ...gasiriDin(rez, 'negative/comentarii.go')];
asigura(g.length === 0, `au produs: ${g.map((x) => `${x.file}:${x.line} ${x.name}`).join(', ')}`);
});
proba('negativ: fisier fara criptografie da zero gasiri, si a fost citit', () => {
const rez = scan(join(FIX, 'negative'));
asigura(rez.stats.filesSeen === 7 && rez.stats.codeFilesTotal === 7, `vazute ${rez.stats.filesSeen}, analizate ${rez.stats.codeFilesTotal}`);
const g = gasiriDin(rez, 'fara-cripto.js');
asigura(g.length === 0, `fara-cripto.js a produs ${g.length}`);
});
proba('negativ: algoritm din variabila reasignata sau din parametru iese unknown, nu ghicit', () => {
comparaExact('negative/variabile.js');
const g = gasiriDin(scan(FIX), 'negative/variabile.js');
asigura(g.every((x) => x.classification === 'unknown' && !x.resolvedFrom), 'o variabila nerezolvabila a primit o valoare');
});
proba('negativ: fisierul binar e sarit si numarat', () => {
const rez = scan(join(FIX, 'binar'));
asigura(rez.stats.filesSeen === 1 && rez.stats.notRead.binary === 1 && rez.findings.length === 0, `vazute ${rez.stats.filesSeen}, binare ${rez.stats.notRead.binary}, gasiri ${rez.findings.length}`);
asigura(rez.stats.notReadExamples.binary.includes('modul.js'), 'exemplul binar lipseste din statistica');
// control pozitiv: aceiasi octeti fara NUL dau gasirea, deci zeroul de mai sus vine din saritura
const d = mkdtempSync(join(tmpdir(), 'ci-binar-'));
try {
const b = readFileSync(join(FIX, 'binar', 'modul.js'));
writeFileSync(join(d, 'modul.js'), Buffer.from([...b].filter((x) => x !== 0)));
const r2 = inv.scan(d);
asigura(r2.findings.some((x) => x.name === 'MD5'), 'controlul pozitiv: fara NUL, fisierul nu produce MD5');
} finally { rmSync(d, { recursive: true, force: true }); }
});
proba('negativ: fisier prea mare sarit si numarat', () => {
const rez = scan(join(FIX, 'limite'), { maxFileBytes: 400 });
asigura(rez.stats.filesSeen === 2 && rez.stats.notRead.tooLarge === 1, `vazute ${rez.stats.filesSeen}, prea mari ${rez.stats.notRead.tooLarge}`);
asigura(/^mare\.js \(\d+ bytes\)$/.test(rez.stats.notReadExamples.tooLarge[0] || ''), `exemplu: ${rez.stats.notReadExamples.tooLarge[0]}`);
asigura(!rez.findings.some((x) => x.file === 'mare.js') && rez.findings.some((x) => x.file === 'mic.js'), 'gasirile nu corespund limitei');
const tot = scan(join(FIX, 'limite'));
asigura(tot.findings.some((x) => x.file === 'mare.js' && x.name === 'MD5'), 'controlul pozitiv: fara limita, mare.js nu da MD5');
});
proba('negativ: limita de fisiere numara restul', () => {
const rez = scan(join(FIX, 'limite'), { maxFiles: 1 });
asigura(rez.stats.filesSeen === 2 && rez.stats.notRead.fileLimit === 1 && rez.stats.codeFilesTotal === 1, `vazute ${rez.stats.filesSeen}, peste limita ${rez.stats.notRead.fileLimit}`);
});
// --- cazurile-limita ale formelor liniare (2026-09-29, revizuirea adversariala: cautarea PEM, literalul regex JS, docstring-ul
// Python si rezolvarea variabilelor aveau cost patratic; formele noi trebuie sa dea EXACT ce dadeau cele vechi, si aici stau
// cazurile pe care o forma liniara scrisa gresit le-ar schimba). Modulele se iau de langa modulul masurat (copia controlului).
const LIB = join(dirname(CALE_MODUL), 'lib');
const lexer = await import(pathToFileURL(join(LIB, 'lexer.mjs')).href);
const pem = await import(pathToFileURL(join(LIB, 'pem.mjs')).href);
const contextMod = await import(pathToFileURL(join(LIB, 'context.mjs')).href);
proba('limite: un bloc PEM care incepe in liniutele unui antet fara sfarsit e gasit', () => {
const corp = 'A'.repeat(64);
const t = `-----BEGIN PRIVATE KEY-----BEGIN CERTIFICATE-----${corp}-----END CERTIFICATE-----`;
const b = [...pem.blocuriPem(t)].map((x) => `${x.index}|${x.tip}|${x.corp}`);
asigura(JSON.stringify(b) === JSON.stringify([`22|CERTIFICATE|${corp}`]), `blocuri: ${JSON.stringify(b)}`);
const fara = [...pem.blocuriPem(`-----BEGIN CERTIFICATE-----${corp}."-----END CERTIFICATE-----`)];
asigura(fara.length === 0, 'un caracter din afara alfabetului corpului inainte de sfarsit trebuie sa anuleze blocul');
});
proba('limite: literalul regex JS se cauta din starea lui, nu din ce a esuat alt literal pe acelasi rand', () => {
// primul `/` deschide o clasa care nu se inchide (esec pana la capatul randului, trecand prin 8 IN clasa); al doilea porneste
// din 8 IN AFARA clasei si se inchide la 10
const r = lexer.curata('x =/[ =/ x/;', 'javascript');
asigura(JSON.stringify(r.siruri) === JSON.stringify([[7, 11]]), `siruri: ${JSON.stringify(r.siruri)}`);
});
proba('limite: docstring Python pe randul lui (si dupa un sir neterminat), dar nu dupa cod pe acelasi rand', () => {
// docstring-ul de dupa sirul neterminat e INDENTAT: spatiile de inainte de el sunt pe randul nou, nu in coada sirului
const t = "a = 'abc\n '''doc'''\ny = '''s'''\n r'''d2'''\n";
const r = lexer.curata(t, 'python');
asigura(!r.code.includes('doc') && !r.code.includes('d2'), `docstring-urile trebuiau golite: ${JSON.stringify(r.code)}`);
asigura(r.code.includes("'''s'''"), `sirul triplu de dupa cod trebuia pastrat: ${JSON.stringify(r.code)}`);
const s = t.indexOf("'''s'''");
asigura(r.siruri.some(([a, b]) => a === s && b === s + 7), `intervalul sirului s lipseste: ${JSON.stringify(r.siruri)}`);
});
proba('limite: peste plafonul de variabile pe fisier numele ies unknown si fisierul se numara', () => {
const d = mkdtempSync(join(tmpdir(), 'ci-plafon-'));
try {
const n = contextMod.REZOLVARI_PE_FISIER;
const linii = ["import { createHash } from 'node:crypto';"];
for (let i = 0; i <= n; i++) linii.push(`const h${i} = 'sha256';`, `createHash(h${i});`);
writeFileSync(join(d, 'multe.mjs'), linii.join('\n') + '\n');
const rez = inv.scan(d);
const rezolvate = rez.findings.filter((g) => g.name === 'SHA-256' && g.resolvedFrom).length;
const necunoscute = rez.findings.filter((g) => g.classification === 'unknown').length;
asigura(rezolvate === n && necunoscute === 1, `rezolvate ${rezolvate} (cerut ${n}), unknown ${necunoscute} (cerut 1)`);
asigura(rez.stats.resolutionLimitFiles === 1 && rez.stats.resolutionLimitExamples[0] === 'multe.mjs', `statistica: ${rez.stats.resolutionLimitFiles}`);
} finally { rmSync(d, { recursive: true, force: true }); }
});
// --- CBOM ---
const E = {
bom: ['$schema', 'bomFormat', 'specVersion', 'serialNumber', 'version', 'metadata', 'components', 'services', 'externalReferences', 'dependencies', 'compositions', 'properties', 'vulnerabilities', 'annotations', 'formulation', 'declarations', 'definitions', 'signature'],
component: ['type', 'mime-type', 'bom-ref', 'supplier', 'manufacturer', 'authors', 'author', 'publisher', 'group', 'name', 'version', 'description', 'scope', 'hashes', 'licenses', 'copyright', 'cpe', 'purl', 'omniborId', 'swhid', 'swid', 'modified', 'pedigree', 'externalReferences', 'properties', 'components', 'evidence', 'releaseNotes', 'modelCard', 'data', 'cryptoProperties', 'signature', 'tags'],
componentType: ['application', 'framework', 'library', 'container', 'platform', 'operating-system', 'device', 'device-driver', 'firmware', 'file', 'machine-learning-model', 'data', 'cryptographic-asset'],
cryptoProperties: ['assetType', 'algorithmProperties', 'certificateProperties', 'relatedCryptoMaterialProperties', 'protocolProperties', 'oid'],
assetType: ['algorithm', 'certificate', 'protocol', 'related-crypto-material'],
algorithmProperties: ['primitive', 'parameterSetIdentifier', 'curve', 'executionEnvironment', 'implementationPlatform', 'certificationLevel', 'mode', 'padding', 'cryptoFunctions', 'classicalSecurityLevel', 'nistQuantumSecurityLevel'],
primitive: ['drbg', 'mac', 'block-cipher', 'stream-cipher', 'signature', 'hash', 'pke', 'xof', 'kdf', 'key-agree', 'kem', 'ae', 'combiner', 'other', 'unknown'],
mode: ['cbc', 'ecb', 'ccm', 'gcm', 'cfb', 'ofb', 'ctr', 'other', 'unknown'],
padding: ['pkcs5', 'pkcs7', 'pkcs1v15', 'oaep', 'raw', 'other', 'unknown'],
cryptoFunctions: ['generate', 'keygen', 'encrypt', 'decrypt', 'digest', 'tag', 'keyderive', 'sign', 'verify', 'encapsulate', 'decapsulate', 'other', 'unknown'],
protocolProperties: ['type', 'version', 'cipherSuites', 'ikev2TransformTypes', 'cryptoRefArray'],
protocolType: ['tls', 'ssh', 'ipsec', 'ike', 'sstp', 'wpa', 'other', 'unknown'],
certificateProperties: ['subjectName', 'issuerName', 'notValidBefore', 'notValidAfter', 'signatureAlgorithmRef', 'subjectPublicKeyRef', 'certificateFormat', 'certificateExtension'],
relatedCryptoMaterialProperties: ['type', 'id', 'state', 'algorithmRef', 'creationDate', 'activationDate', 'updateDate', 'expirationDate', 'value', 'size', 'format', 'securedBy'],
materialType: ['private-key', 'public-key', 'secret-key', 'key', 'ciphertext', 'signature', 'digest', 'initialization-vector', 'nonce', 'seed', 'salt', 'shared-secret', 'tag', 'additional-data', 'password', 'credential', 'token', 'other', 'unknown'],
evidence: ['identity', 'occurrences', 'callstack', 'licenses', 'copyright'],
occurrence: ['bom-ref', 'location', 'line', 'offset', 'symbol', 'additionalContext'],
};
export function valideazaCbom(bom) {
const err = [];
const e = (m) => err.push(m);
const chei = (o, perm, unde) => { for (const k of Object.keys(o)) if (!perm.includes(k)) e(`${unde}: cheie nepermisa "${k}"`); };
const sir = (v, unde) => { if (typeof v !== 'string' || !v.length) e(`${unde}: trebuie sir nevid`); };
const dataIso = (v, unde) => { if (typeof v !== 'string' || Number.isNaN(Date.parse(v)) || !/^\d{4}-\d{2}-\d{2}T/.test(v)) e(`${unde}: nu e date-time`); };
chei(bom, E.bom, 'bom');
if (bom.bomFormat !== 'CycloneDX') e('bomFormat');
if (bom.specVersion !== '1.6') e('specVersion');
if (!/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(bom.serialNumber || '')) e(`serialNumber ${bom.serialNumber}`);
if (!Number.isInteger(bom.version) || bom.version < 1) e('version');
const md = bom.metadata || {};
if (md.timestamp !== undefined) dataIso(md.timestamp, 'metadata.timestamp');
if (!md.tools || !Array.isArray(md.tools.components) || !md.tools.components.length) e('metadata.tools.components');
else for (const t of md.tools.components) { sir(t.name, 'tool.name'); sir(t.version, 'tool.version'); if (!E.componentType.includes(t.type)) e('tool.type'); }
if (!Array.isArray(bom.components)) { e('components nu e lista'); return err; }
const refs = new Set();
for (const [i, c] of bom.components.entries()) {
const u = `components[${i}]`;
chei(c, E.component, u);
if (!E.componentType.includes(c.type)) e(`${u}.type ${c.type}`);
sir(c.name, `${u}.name`);
sir(c['bom-ref'], `${u}.bom-ref`);
if (refs.has(c['bom-ref'])) e(`${u}: bom-ref dublat ${c['bom-ref']}`);
refs.add(c['bom-ref']);
if (c.properties) for (const p of c.properties) { sir(p.name, `${u}.properties.name`); if (typeof p.value !== 'string') e(`${u}.properties.value`); }
if (c.type === 'cryptographic-asset') {
const cp = c.cryptoProperties;
if (!cp) { e(`${u}: cryptoProperties lipsa`); continue; }
chei(cp, E.cryptoProperties, `${u}.cryptoProperties`);
if (!E.assetType.includes(cp.assetType)) e(`${u}.assetType ${cp.assetType}`);
if (cp.oid !== undefined && !/^[0-2](\.\d+)+$/.test(cp.oid)) e(`${u}.oid ${cp.oid}`);
if (cp.assetType === 'algorithm') {
const ap = cp.algorithmProperties;
if (!ap) { e(`${u}: algorithmProperties lipsa`); continue; }
chei(ap, E.algorithmProperties, `${u}.algorithmProperties`);
if (!E.primitive.includes(ap.primitive)) e(`${u}.primitive ${ap.primitive}`);
if (ap.parameterSetIdentifier !== undefined) sir(ap.parameterSetIdentifier, `${u}.parameterSetIdentifier`);
if (ap.curve !== undefined) sir(ap.curve, `${u}.curve`);
if (ap.mode !== undefined && !E.mode.includes(ap.mode)) e(`${u}.mode ${ap.mode}`);
if (ap.padding !== undefined && !E.padding.includes(ap.padding)) e(`${u}.padding ${ap.padding}`);
if (ap.cryptoFunctions !== undefined && (!Array.isArray(ap.cryptoFunctions) || ap.cryptoFunctions.some((f) => !E.cryptoFunctions.includes(f)))) e(`${u}.cryptoFunctions`);
if (ap.classicalSecurityLevel !== undefined && (!Number.isInteger(ap.classicalSecurityLevel) || ap.classicalSecurityLevel < 0)) e(`${u}.classicalSecurityLevel`);
if (ap.nistQuantumSecurityLevel !== undefined && (!Number.isInteger(ap.nistQuantumSecurityLevel) || ap.nistQuantumSecurityLevel < 0 || ap.nistQuantumSecurityLevel > 6)) e(`${u}.nistQuantumSecurityLevel`);
} else if (cp.assetType === 'protocol') {
const pp = cp.protocolProperties || {};
chei(pp, E.protocolProperties, `${u}.protocolProperties`);
if (!E.protocolType.includes(pp.type)) e(`${u}.protocolProperties.type`);
if (pp.version !== undefined) sir(pp.version, `${u}.protocolProperties.version`);
} else if (cp.assetType === 'certificate') {
const cc = cp.certificateProperties || {};
chei(cc, E.certificateProperties, `${u}.certificateProperties`);
for (const k of ['notValidBefore', 'notValidAfter']) if (cc[k] !== undefined) dataIso(cc[k], `${u}.${k}`);
} else if (cp.assetType === 'related-crypto-material') {
const rm = cp.relatedCryptoMaterialProperties || {};
chei(rm, E.relatedCryptoMaterialProperties, `${u}.relatedCryptoMaterialProperties`);
if (!E.materialType.includes(rm.type)) e(`${u}.relatedCryptoMaterialProperties.type`);
if (rm.size !== undefined && !Number.isInteger(rm.size)) e(`${u}.size`);
if (rm.value !== undefined) e(`${u}: valoarea materialului nu are voie sa apara`);
}
}
if (!c.evidence || !Array.isArray(c.evidence.occurrences) || !c.evidence.occurrences.length) { e(`${u}: evidence.occurrences lipsa`); continue; }
chei(c.evidence, E.evidence, `${u}.evidence`);
for (const o of c.evidence.occurrences) {
chei(o, E.occurrence, `${u}.occurrence`);
sir(o.location, `${u}.occurrence.location`);
if (o.line !== undefined && (!Number.isInteger(o.line) || o.line < 1)) e(`${u}.occurrence.line`);
}
}
return err;
}
proba('cbom: structura CycloneDX 1.6 (campuri obligatorii, tipuri, enumerari)', () => {
const rez = scan(FIX);
for (const det of [false, true]) {
const bom = inv.buildCbom(rez, { deterministic: det });
const err = valideazaCbom(bom);
asigura(err.length === 0, `erori (${det ? 'determinist' : 'normal'}): ${err.slice(0, 5).join('; ')}`);
asigura(det ? bom.metadata.timestamp === undefined : typeof bom.metadata.timestamp === 'string', 'timestamp');
}
const bom = inv.buildCbom(rez);
const tipuri = new Set(bom.components.map((c) => (c.cryptoProperties ? c.cryptoProperties.assetType : c.type)));
for (const t of ['algorithm', 'protocol', 'certificate', 'related-crypto-material', 'library']) asigura(tipuri.has(t), `lipseste tipul ${t}`);
// controlul negativ al validatorului: o forma stricata trebuie sa fie refuzata
const rau = JSON.parse(JSON.stringify(bom));
rau.components[0].cryptoProperties = { assetType: 'algorithm', algorithmProperties: { primitive: 'magic' } };
rau.components[1]['bom-ref'] = rau.components[2]['bom-ref'];
rau.specVersion = '1.5';
asigura(valideazaCbom(rau).length >= 3, 'validatorul nu refuza un CBOM stricat');
});
proba('cbom: bom-ref unice si stabile intre doua rulari', () => {
const a = inv.buildCbom(inv.scan(FIX), { deterministic: true });
const b = inv.buildCbom(inv.scan(FIX), { deterministic: true });
const ra = a.components.map((c) => c['bom-ref']);
asigura(new Set(ra).size === ra.length, `bom-ref dublate: ${ra.length - new Set(ra).size}`);
asigura(JSON.stringify(a) === JSON.stringify(b), 'doua rulari deterministe difera');
const gasiri = inv.scan(FIX).findings.length;
const occ = a.components.filter((c) => c.type === 'cryptographic-asset').reduce((s, c) => s + c.evidence.occurrences.length, 0);
asigura(occ === gasiri, `occurrences ${occ} != gasiri ${gasiri}`);
});
proba('cbom: fiecare occurrence arata un fisier care exista, cu o linie din fisier', () => {
const bom = inv.buildCbom(scan(FIX));
let n = 0;
for (const c of bom.components) {
for (const o of c.evidence.occurrences) {
const f = join(FIX, o.location);
asigura(existsSync(f) && statSync(f).isFile(), `nu exista: ${o.location}`);
const linii = readFileSync(f, 'utf8').split('\n').length;
asigura(o.line >= 1 && o.line <= linii, `${o.location}: linia ${o.line} din ${linii}`);
n++;
}
}
asigura(n > 50, `prea putine occurrences: ${n}`);
});
// --- CLI ---
function cli(args) {
return spawnSync(process.execPath, [CALE_MODUL, ...args], { encoding: 'utf8', timeout: 60000 });
}
proba('cli: --fail-on vulnerable iese 1 pe cod vulnerabil si 0 pe cod fara criptografie', () => {
const r = cli(['scan', join(FIX, 'js'), '--fail-on', 'vulnerable', '--summary']);
asigura(r.status === 1 && /quantum-vulnerable finding/.test(r.stderr), `status ${r.status}, stderr ${r.stderr.slice(0, 200)}`);
const d = mkdtempSync(join(tmpdir(), 'ci-cli-'));
try {
copyFileSync(join(FIX, 'negative', 'fara-cripto.js'), join(d, 'fara-cripto.js'));
const r0 = cli(['scan', d, '--fail-on', 'vulnerable', '--summary']);
asigura(r0.status === 0, `fara criptografie: status ${r0.status}`);
} finally { rmSync(d, { recursive: true, force: true }); }
const r2 = cli(['scan', join(FIX, 'js'), '--fail-on', 'nimic']);
asigura(r2.status === 2, `valoare --fail-on invalida: status ${r2.status}`);
const r3 = cli(['scan', join(FIX, 'js'), '--fail-on', 'weak', '--summary']);
asigura(r3.status === 1 && /weak-now finding/.test(r3.stderr), `--fail-on weak: status ${r3.status}`);
});
proba('cli: --summary spune cate fisiere nu au fost citite si de ce', () => {
const r = cli(['scan', FIX, '--summary', '--max-file-bytes', '400']);
asigura(r.status === 0, `status ${r.status} ${r.stderr}`);
const m = /Not read: (\d+) binary, (\d+) too large \(> 400 bytes\), (\d+) over the file limit/.exec(r.stdout);
asigura(m && m[1] === '1' && Number(m[2]) >= 1 && m[3] === '0', `randul "Not read": ${m ? m[0] : 'lipsa'}`);
asigura(/e\.g\. binary: binar\/modul\.js/.test(r.stdout), 'exemplul binar lipseste din rezumat');
const rj = cli(['scan', join(FIX, 'go'), '--deterministic']);
const bom = JSON.parse(rj.stdout);
asigura(bom.specVersion === '1.6' && valideazaCbom(bom).length === 0, 'CBOM-ul de pe stdout nu e valid');
});
proba('rezumat: caracterele de control din numele de fisiere nu ajung in terminal', () => {
const rez = scan(FIX);
const i = rez.findings.findIndex((g) => g.classification === 'quantum-vulnerable');
asigura(i >= 0, 'controlul pozitiv: fixturile trebuie sa aiba o gasire vulnerabila');
const rau = 'x\u001b]0;titlu\u0007\u001b[31mrosu.js';
const t = inv.renderSummary({ ...rez, findings: rez.findings.map((g, k) => (k === i ? { ...g, file: rau } : g)) });
asigura(t.includes('x?]0;titlu??[31mrosu.js'), 'numele fisierului trebuia sa apara, cu caracterele de control inlocuite');
asigura(!/[\u0000-\u0009\u000b-\u001f\u007f]/.test(t), 'un caracter de control a ajuns in rezumat');
});
proba('rezumat: numaratoarea pe clase egala cu gasirile', () => {
const rez = scan(FIX);
const c = inv.countByClass(rez.findings);
const s = Object.values(c).reduce((a, b) => a + b, 0);
asigura(s === rez.findings.length, `suma claselor ${s}, gasiri ${rez.findings.length}`);
const t = inv.renderSummary(rez);
for (const [k, v] of Object.entries(c)) asigura(new RegExp(`${k}\\s+${v}\\b`).test(t), `rezumatul nu arata ${k} ${v}`);
});
// --- rularea ---
const rezultate = {};
const erori = {};
for (const p of probe) {
try { await p.fn(); rezultate[p.nume] = 'VERDE'; } catch (err) { rezultate[p.nume] = 'ROSU'; erori[p.nume] = String(err && err.message || err); }
}
const verzi = Object.values(rezultate).filter((x) => x === 'VERDE').length;
if (CA_JSON) {
process.stdout.write(JSON.stringify({ suita: 'crypto-inventory', modul: CALE_MODUL, rulate: probe.length, verzi, rosii: probe.length - verzi, rezultate, erori }) + '\n');
} else {
for (const p of probe) process.stdout.write(`${rezultate[p.nume].padEnd(6)} ${p.nume}${erori[p.nume] ? `\n ${erori[p.nume]}` : ''}\n`);
process.stdout.write(`\n${verzi} din ${probe.length} probe verzi (modul: ${CALE_MODUL})\n`);
}
process.exitCode = verzi === probe.length ? 0 : 1;