// Suita de probe a inventarului criptografic. // Folosire: node test/proba.mjs [--json] // Modulul masurat se poate schimba cu CRYPTO_INVENTORY_MODULE=/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;