aere-quantum/control-plane/proba-plan-migrare.mjs

132 lines
12 KiB
JavaScript

'use strict';
// Proba planificatorului de migrare (B1), cu CONTROALE NEGATIVE: un asset vulnerabil TREBUIE sa apara in plan cu tinta si metoda
// corecte; un asset deja post-cuantic NU trebuie sa apara; HNDL (schimb de cheie) trebuie sa fie mai urgent decat o semnatura.
// node proba-plan-migrare.mjs -> 0 toate cum trebuia, 1 altfel
import { planeaza } from './plan-migrare.mjs';
let rele = 0; let total = 0;
function cer(nume, cond) { total++; console.log(` [${cond ? 'OK ' : 'RAU '}] ${nume}`); if (!cond) rele++; }
// fixtura de inventar: doua vulnerabile (schimb de cheie + semnatura de certificat) + unul deja PQ (nu trebuie in plan)
const inventory = [
{ ref: 'algorithm:ecdh', assetType: 'algorithm', name: 'ECDH', primitive: 'key-agree', quantumVulnerable: true },
{ ref: 'algorithm:rsa', assetType: 'algorithm', name: 'RSA-2048', primitive: 'signature', quantumVulnerable: true },
{ ref: 'cert:interna', assetType: 'certificate', primitive: 'signature', quantumVulnerable: true,
certificate: { subjectName: 'CN=api.intern', issuerName: 'CN=Intern CA', notValidAfter: '2030-01-01T00:00:00Z' } },
{ ref: 'cert:webpki', assetType: 'certificate', primitive: 'signature', quantumVulnerable: true,
certificate: { subjectName: 'CN=www.exemplu.com', issuerName: "C=US, O=Let's Encrypt, CN=R3", notValidAfter: '2026-12-01T00:00:00Z' } },
{ ref: 'algorithm:mlkem', assetType: 'algorithm', name: 'ML-KEM-768', primitive: 'kem', quantumVulnerable: false },
{ ref: 'algorithm:mldsa', assetType: 'algorithm', name: 'ML-DSA-65', primitive: 'signature', quantumVulnerable: false },
{ ref: 'algorithm:aes', assetType: 'algorithm', name: 'AES-256-GCM', primitive: 'ae', quantumVulnerable: false },
];
// 2026-09-27: constatarile de scanare se DERIVA din sursa scanerului (readiness-service.mjs, apelurile lui add(id, severitate,
// titlu)), nu se scriu de mana. Fixtura veche folosea id-uri inventate ('tls-kex-classical', 'tls12-enabled'), deci proba era verde
// pe un clasificator care, pe id-urile reale, arunca `tls13-missing` si facea din `pq-not-preferred`/`auth-classical` expuneri CRITICE.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const { caleaScanerului } = await import('./control-plane.mjs');
const SRC_SCANER = fs.readFileSync(caleaScanerului(), 'utf8');
function constatariDinScaner(src) {
const re = /add\('([a-z0-9-]+)',\s*'([a-z]+)',\s*[`']([^`']+)[`']/g; const f = []; let m;
while ((m = re.exec(src))) f.push({ id: m[1], severity: m[2], title: m[3] });
return f;
}
const REALE = constatariDinScaner(SRC_SCANER);
const dupaId = (id) => REALE.find((x) => x.id === id);
const scan = { domain: 'client.exemplu.com', findings: [dupaId('hndl-exposed'), dupaId('tls12-accepted')].filter(Boolean) };
const plan = planeaza({ inventory, scan });
const byRef = Object.fromEntries(plan.actions.map((a) => [a.ref, a]));
console.log('plan:', plan.summary.total, 'actiuni;', JSON.stringify(plan.summary.byUrgency), 'auto=' + plan.summary.autoAere, 'manual=' + plan.summary.manual, 'blocked=' + plan.summary.blocked);
// pozitive
cer('ECDH (schimb de cheie) e in plan, auto prin gateway', byRef['algorithm:ecdh'] && byRef['algorithm:ecdh'].product === 'gateway');
cer('RSA (semnatura) e in plan, tinta ML-DSA prin PKI', byRef['algorithm:rsa'] && /ML-DSA/.test(byRef['algorithm:rsa'].target) && byRef['algorithm:rsa'].product === 'pki');
cer('certificatul intern e in plan, auto prin PKI', byRef['cert:interna'] && byRef['cert:interna'].product === 'pki');
cer('certificatul WebPKI public e BLOCAT (niciun CA public PQ), nu se pretinde auto', byRef['cert:webpki'] && byRef['cert:webpki'].method === 'blocked' && byRef['cert:webpki'].blocker);
cer('scanarea: schimbul de cheie clasic e in plan, auto prin gateway', byRef['tls-kex:client.exemplu.com'] && byRef['tls-kex:client.exemplu.com'].product === 'gateway');
cer('scanarea: TLS 1.2 e in plan, manual (config)', byRef['tls-conf:client.exemplu.com:tls12-accepted'] && byRef['tls-conf:client.exemplu.com:tls12-accepted'].method === 'manual');
// controale negative: ce NU trebuie sa fie in plan
cer('CONTROL: ML-KEM (deja PQ) NU e in plan', !byRef['algorithm:mlkem']);
cer('CONTROL: ML-DSA (deja PQ) NU e in plan', !byRef['algorithm:mldsa']);
cer('CONTROL: AES-256 (nu e cu risc cuantic de spart) NU e in plan', !byRef['algorithm:aes']);
// HNDL: schimbul de cheie e mai urgent decat semnatura (memorie: HNDL nu se aplica semnaturilor)
const uKex = byRef['tls-kex:client.exemplu.com'].urgency, uSig = byRef['algorithm:rsa'].urgency;
const rang = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
cer(`HNDL: schimbul de cheie (${uKex}) e mai urgent decat semnatura (${uSig})`, rang[uKex] < rang[uSig]);
// planul e sortat pe urgenta (prima actiune e cea mai urgenta)
cer('planul e sortat: prima actiune e CRITICA', plan.actions[0] && plan.actions[0].urgency === 'CRITICAL');
// CONTROL NEGATIV al metodei: un plan pe un inventar CURAT (totul PQ) e GOL
const planCurat = planeaza({ inventory: [{ ref: 'a', assetType: 'algorithm', name: 'ML-KEM-768', primitive: 'kem', quantumVulnerable: false }] });
cer('CONTROL: inventar tot-PQ -> plan gol', planCurat.actions.length === 0);
// CLASIFICAREA PE CONTRACTUL REAL AL SCANERULUI: fiecare id din sursa scanerului e in tabel, si semantica lui e cea corecta
const CLASIFICARE_SCAN = (await import('./plan-migrare.mjs')).CLASIFICARE_SCAN || {}; // modul fara tabel -> nimic acoperit (rosu masurat, nu cadere)
const distincte = [...new Set(REALE.map((x) => x.id))];
cer(`scanerul emite ${distincte.length} id-uri distincte, extrase din sursa (control pozitiv al extragerii: >= 10)`, distincte.length >= 10);
const neacoperite = distincte.filter((i) => !Object.hasOwn(CLASIFICARE_SCAN, i));
cer(`fiecare id al scanerului are o clasificare scrisa (neacoperite: ${neacoperite.join(',') || 'niciunul'})`, neacoperite.length === 0);
// CONTROL NEGATIV al acoperirii: un id nou, adaugat in scaner fara clasificare, TREBUIE sa iasa neacoperit
const cuNou = [...distincte, 'pq-ceva-nou'];
cer('CONTROL: un id nou al scanerului, neclasificat, e prins de verificarea acoperirii', cuNou.some((i) => !Object.hasOwn(CLASIFICARE_SCAN, i)));
const unul = (id) => { const f = dupaId(id); const pl = planeaza({ scan: { domain: 'd.test', findings: [f] } }); return pl.actions[0] || null; };
cer('numai expunerea HNDL e CRITICA si automata (gateway)', unul('hndl-exposed') && unul('hndl-exposed').urgency === 'CRITICAL' && unul('hndl-exposed').product === 'gateway');
cer('PQ deja suportat dar nepreferat NU e CRITIC (ordinea grupurilor, MEDIE, manual)', unul('pq-not-preferred') && unul('pq-not-preferred').urgency === 'MEDIUM' && unul('pq-not-preferred').method === 'manual');
cer('TLS 1.3 lipsa produce o actiune INALTA (inainte era aruncata)', unul('tls13-missing') && unul('tls13-missing').urgency === 'HIGH');
cer('informativul pq-preference-unmeasured nu devine actiune', unul('pq-preference-unmeasured') === null);
cer('autentificarea clasica e BLOCATA onest (CA public fara PQ), nu CRITICA', unul('auth-classical') && unul('auth-classical').method === 'blocked' && unul('auth-classical').urgency !== 'CRITICAL');
cer('expirarea certificatului e o actiune de EXPIRARE, nu de semnatura clasica', unul('cert-expiring') && /tls-cert-expiry/.test(unul('cert-expiring').ref));
cer('lantul neincrezut produce o actiune (inainte era aruncat)', unul('chain-untrusted') !== null);
// in toate formele reale, o singura actiune e CRITICA: cea HNDL
const critice = distincte.map((i) => unul(i)).filter((a) => a && a.urgency === 'CRITICAL');
cer(`pe toate cele ${distincte.length} forme reale, exact o actiune CRITICA (HNDL): ${critice.map((a) => a.ref).join(',')}`, critice.length === 1 && /^tls-kex:/.test(critice[0].ref));
// CERTIFICATELE PE FORMA REALA (2026-09-27): certificate si chei generate cu openssl, inventarul REAL si control-plane.mjs --json pe ele.
// Fixtura de sus scrie `primitive: 'signature'` pe certificate; inventarul real scrie primitiva CHEII ('unknown' la RSA, 'other' la EC),
// si pe forma aceea regula veche a planificatorului nu se aplica niciodata. Aici se masoara exact forma aceea.
{
const os = await import('node:os');
const { spawnSync } = await import('node:child_process');
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-plan-cert-'));
const D = path.join(T, 'deploy', 'tls'); fs.mkdirSync(D, { recursive: true });
const openssl = (args) => {
let r = spawnSync('openssl', args, { encoding: 'utf8' });
if (r.error && r.error.code === 'ENOENT') r = spawnSync('C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe', args, { encoding: 'utf8' });
return !r.error && r.status === 0;
};
const facut = openssl(['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', path.join(D, 'api.key'), '-out', path.join(D, 'api.crt'), '-subj', '/O=Client/CN=api.internal', '-days', '800'])
&& openssl(['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', path.join(D, 'edge.key'), '-out', path.join(D, 'edge.crt'), '-subj', '/CN=edge.internal', '-days', '30'])
&& openssl(['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', path.join(D, 'wild.key'), '-out', path.join(D, 'wild.crt'), '-subj', '/CN=*.svc.internal', '-days', '400']);
cer('certificate reale: openssl a generat cele trei certificate si chei (fara el nimic de mai jos nu masoara)', facut);
const cp = spawnSync(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'control-plane.mjs'), '--code', T, '--json'], { encoding: 'utf8' });
let pr = null; try { pr = JSON.parse(cp.stdout); } catch { pr = null; }
cer(`certificate reale: control-plane.mjs --json a rulat (cod ${cp.status})`, cp.status === 0 && pr && Array.isArray(pr.actions));
const { caleaInventarului } = await import('./control-plane.mjs');
const inv = await import((await import('node:url')).pathToFileURL(caleaInventarului()).href);
const certInv = (inv.scan(T).findings || []).filter((g) => g.assetType === 'certificate');
// controlul fixturii: inventarul real chiar da primitive NEDECISE pe certificate; daca intr-o zi da 'signature', cazul de fata nu mai
// masoara regula veche, si trebuie stiut
cer(`certificate reale: controlul fixturii, inventarul da ${certInv.length} certificate cu primitivele ${[...new Set(certInv.map((g) => g.primitive))].join(',')}`,
certInv.length === 3 && certInv.every((g) => g.primitive === 'unknown' || g.primitive === 'other'));
const act = (fis) => (pr && pr.actions ? pr.actions.find((a) => new RegExp(`:deploy/tls/${fis.replace('.', '\\.')}:`).test(a.ref)) : null);
const api = act('api.crt'), edge = act('edge.crt'), wild = act('wild.crt'), cheie = act('api.key');
cer(`certificatul RSA intern (800 de zile) -> auto prin PKI, ML-DSA, INALTA, pentru acelasi CN (${api ? [api.method, api.product, api.urgency, api.cn].join(' ') : 'lipsa'})`,
api && api.method === 'auto-aere' && api.product === 'pki' && /ML-DSA/.test(api.target) && api.urgency === 'HIGH' && api.cn === 'api.internal');
cer(`certificatul EC intern (30 de zile) -> auto prin PKI, MEDIE (${edge ? [edge.method, edge.product, edge.urgency, edge.cn].join(' ') : 'lipsa'})`,
edge && edge.method === 'auto-aere' && edge.product === 'pki' && edge.urgency === 'MEDIUM' && edge.cn === 'edge.internal');
cer(`certificatul wildcard -> manual, cu motivul scris, fara CN inventat (${wild ? [wild.method, (wild.blocker || '').slice(0, 40)].join(' | ') : 'lipsa'})`,
wild && wild.method === 'manual' && !wild.product && /wildcard/.test(wild.blocker || '') && !wild.cn);
cer(`cheia privata -> manual, numita ca cheie privata, cu motivul (${cheie ? cheie.asset + ' | ' + (cheie.how || '').slice(0, 50) : 'lipsa'})`,
cheie && cheie.method === 'manual' && /private-key/.test(cheie.asset) && /private key/.test(cheie.how || ''));
try { fs.rmSync(T, { recursive: true, force: true }); } catch {}
}
console.log(`\nB1 planificator: ${total - rele}/${total} cum trebuia`);
process.exitCode = rele ? 1 : 0;