134 lines
11 KiB
JavaScript
134 lines
11 KiB
JavaScript
// Proba remedierii B1 (remediere.mjs), pe servere TLS REALE pornite local si scanate de scanerul AERE (scaneazaAdresa din
|
|
// readiness-service.mjs, aceeasi masuratoare ca /v1/pq/readiness, pe 127.0.0.1 si alt port). Fara retea externa.
|
|
// 1. acoperirea: fiecare id pe care scanerul il poate emite (extras din SURSA lui) si pe care planificatorul il face actiune
|
|
// manual/auto are reteta si judecator; controlul: un id nou, plantat, e prins ca neacoperit
|
|
// 2. fragmentele generate poarta fiecare setare, pe fiecare profil (grupurile, versiunea, HSTS)
|
|
// 3. grupurile scrise in fragmentele nginx/apache/haproxy/node sunt acceptate de OpenSSL (3.5); controlul: un nume stricat e refuzat
|
|
// 4. cap la cap, profilul node: server CLASIC -> scanare -> plan -> reteta -> server cu OPTIUNILE RETETEI (luate din reteta, nu
|
|
// rescrise) -> rescanare -> REZOLVAT pe hndl-exposed, tls12-accepted, hsts-missing; chain-untrusted (autosemnat) NEREZOLVAT
|
|
// 5. controale negative: remediere falsa (numai HSTS) -> NEREZOLVAT pe schimbul de cheie si pe TLS 1.2; "PQ nepreferat" disparut
|
|
// fiindca PQ a disparut de tot -> NEREZOLVAT, nu REZOLVAT; alta gazda, scanare de dinainte de aplicare, scanare cazuta -> NEMASURAT
|
|
// 6. lantul judecatilor se re-verifica; o judecata schimbata e prinsa
|
|
// node proba-remediere.mjs iesire 0 = toate cum trebuia
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import tls from 'node:tls';
|
|
import https from 'node:https';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { planeaza, CLASIFICARE_SCAN } from './plan-migrare.mjs';
|
|
import { reteta, verifica, verificaRemedierea, REMEDIERI, PROFILURI, defectDinRef } from './remediere.mjs';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { caleaScanerului } from './control-plane.mjs';
|
|
// scanerul se incarca de unde e (readiness/ intr-o copie publica, langa planul de control in depozitul de dezvoltare)
|
|
const { scaneazaAdresa } = await import(pathToFileURL(caleaScanerului()).href);
|
|
|
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
|
let bune = 0, rele = 0;
|
|
const cer = (nume, ok) => { if (ok) { bune++; console.log(` OK ${nume}`); } else { rele++; console.log(` RAU ${nume}`); } };
|
|
|
|
// 1. acoperirea, derivata din sursa scanerului
|
|
const SRC = fs.readFileSync(caleaScanerului(), 'utf8');
|
|
const idsScaner = [...new Set([...SRC.matchAll(/add\('([a-z0-9-]+)',\s*'([a-z]+)'/g)].map((m) => m[1]))];
|
|
function neacoperite(ids) {
|
|
return ids.filter((id) => {
|
|
const a = Object.hasOwn(CLASIFICARE_SCAN, id) ? CLASIFICARE_SCAN[id]({ id }, 'x.exemplu.com') : { method: 'manual' };
|
|
if (!a || a.method === 'blocked') return false; // informativ sau blocat onest: nu are ce remedia pe server
|
|
return !Object.hasOwn(REMEDIERI, id);
|
|
});
|
|
}
|
|
cer(`controlul metodei: din sursa scanerului ies ${idsScaner.length} id-uri (cerut >= 8)`, idsScaner.length >= 8);
|
|
cer(`acoperirea: fiecare defect remediabil al scanerului are reteta (neacoperite: ${neacoperite(idsScaner).join(',') || 'niciunul'})`, neacoperite(idsScaner).length === 0);
|
|
cer('CONTROL: un id nou al scanerului, fara reteta, e prins', neacoperite([...idsScaner, 'tls-ceva-nou']).join() === 'tls-ceva-nou');
|
|
|
|
// 2. fragmentele poarta fiecare setare
|
|
const planToate = planeaza({ scan: { domain: 'toate.exemplu.com', findings: ['hndl-exposed', 'tls12-accepted', 'hsts-missing', 'tls13-missing', 'rsa-short', 'chain-untrusted', 'cert-expiring'].map((id) => ({ id, severity: 'medium' })) } });
|
|
for (const p of PROFILURI) {
|
|
const r = reteta(planToate, p); const d = r.domains[0]; const t = d.config.fragment.join('\n');
|
|
cer(`${p}: grupul hibrid primul, versiunea 1.3, HSTS in fragment; ${d.operational.length} pasi operationali (certificatul)`,
|
|
/X25519MLKEM768[:,] ?(tls\.)?X25519/.test(t) && /1\.3|TLSv1\.3|TLS13/.test(t) && /Strict-Transport-Security/i.test(t) && d.operational.length === 3 && typeof r.measured === 'string');
|
|
}
|
|
cer('actiunile de cod (inventar) raman fara reteta de server, cu motivul scris', reteta({ actions: [{ ref: 'algorithm:rsa', method: 'manual' }] }, 'nginx').none.length === 1);
|
|
let aruncat = false; try { reteta(planToate, 'constructor'); } catch { aruncat = true; }
|
|
cer('un profil necunoscut (si unul mostenit din prototip) e refuzat', aruncat);
|
|
|
|
// 3. grupurile din fragmente, acceptate de OpenSSL
|
|
const grupuriDin = (p) => { const t = reteta(planToate, p).domains[0].config.fragment.join('\n'); const m = t.match(/(X25519MLKEM768:[A-Za-z0-9:]+)/); return m && m[1]; };
|
|
for (const p of ['nginx', 'apache', 'haproxy', 'node']) {
|
|
const g = grupuriDin(p); let ok = false; try { tls.createSecureContext({ ecdhCurve: g }); ok = true; } catch {}
|
|
cer(`${p}: lista "${g}" acceptata de OpenSSL ${process.versions.openssl}`, !!g && ok);
|
|
}
|
|
let refuzat = false; try { tls.createSecureContext({ ecdhCurve: 'X25519MLKEM768:X25519BOGUS' }); } catch { refuzat = true; }
|
|
cer('CONTROL: o lista cu un grup stricat e refuzata de OpenSSL (metoda poate iesi rosie)', refuzat);
|
|
|
|
// 4-6. cap la cap pe servere reale
|
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-b1-remediere-'));
|
|
const DOM = 'remediere.proba.invalid';
|
|
execFileSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '365', '-subj', `/CN=${DOM}`,
|
|
'-addext', `subjectAltName=DNS:${DOM}`, '-keyout', path.join(T, 'k.pem'), '-out', path.join(T, 'c.pem')], { stdio: 'ignore' });
|
|
const cert = fs.readFileSync(path.join(T, 'c.pem')), key = fs.readFileSync(path.join(T, 'k.pem'));
|
|
function server(opt, antete = {}) {
|
|
return new Promise((res) => {
|
|
const s = https.createServer({ cert, key, ...opt }, (q, r) => { for (const [k, v] of Object.entries(antete)) r.setHeader(k, v); r.end('ok'); });
|
|
s.listen(0, '127.0.0.1', () => res(s));
|
|
});
|
|
}
|
|
const scaneaza = (s) => scaneazaAdresa(DOM, { address: '127.0.0.1', family: 4 }, { port: s.address().port, jurnal: false });
|
|
const inchide = (s) => new Promise((r) => s.close(() => r()));
|
|
|
|
try {
|
|
const clasic = await server({ ecdhCurve: 'X25519:P-256', minVersion: 'TLSv1.2' });
|
|
const inainte = await scaneaza(clasic);
|
|
const ids = (inainte.findings || []).map((f) => f.id);
|
|
cer(`serverul clasic, scanat: ${ids.join(',')}`, ids.includes('hndl-exposed') && ids.includes('tls12-accepted') && ids.includes('hsts-missing'));
|
|
const plan = planeaza({ scan: { domain: DOM, findings: inainte.findings } });
|
|
const r = reteta(plan, 'node'); const d = r.domains.find((x) => x.domain === DOM);
|
|
cer(`reteta node: optiunile ${JSON.stringify(d.config.options)}, antete ${Object.keys(d.config.headers).join(',')}`, d.config.options.ecdhCurve && d.config.options.minVersion === 'TLSv1.3' && d.config.headers['strict-transport-security']);
|
|
await inchide(clasic);
|
|
const aplicatLa = new Date().toISOString();
|
|
await new Promise((z) => setTimeout(z, 20));
|
|
const reparat = await server({ ecdhCurve: 'X25519:P-256', minVersion: 'TLSv1.2', ...d.config.options }, d.config.headers);
|
|
const dupa = await scaneaza(reparat);
|
|
await inchide(reparat);
|
|
const v = verifica(plan, dupa, { appliedAt: aplicatLa });
|
|
const st = Object.fromEntries(v.results.map((x) => [x.defect, x.state]));
|
|
cer(`dupa reteta: schimb de cheie ${st['hndl-exposed']}, TLS 1.2 ${st['tls12-accepted']}, HSTS ${st['hsts-missing']}`, st['hndl-exposed'] === 'RESOLVED' && st['tls12-accepted'] === 'RESOLVED' && st['hsts-missing'] === 'RESOLVED');
|
|
cer(`lantul autosemnat ramane ${st['chain-untrusted']} (operational, nu o setare): nu se pretinde rezolvat`, st['chain-untrusted'] === 'UNRESOLVED');
|
|
cer(`dupa reteta, scanerul vede PQ: ${dupa.summary.pqKeyExchange}`, dupa.summary.pqKeyExchange === 'X25519MLKEM768');
|
|
|
|
// 5. controale negative
|
|
const fals = await server({ ecdhCurve: 'X25519:P-256', minVersion: 'TLSv1.2' }, d.config.headers);
|
|
const dupaFals = await scaneaza(fals); await inchide(fals);
|
|
const vf = Object.fromEntries(verifica(plan, dupaFals, { appliedAt: aplicatLa }).results.map((x) => [x.defect, x.state]));
|
|
cer(`CONTROL: remediere falsa (numai HSTS): schimb de cheie ${vf['hndl-exposed']}, TLS 1.2 ${vf['tls12-accepted']}, HSTS ${vf['hsts-missing']}`, vf['hndl-exposed'] === 'UNRESOLVED' && vf['tls12-accepted'] === 'UNRESOLVED' && vf['hsts-missing'] === 'RESOLVED');
|
|
const planPref = { actions: [{ ref: CLASIFICARE_SCAN['pq-not-preferred']({}, DOM).ref, method: 'manual' }] };
|
|
const vp = verifica(planPref, dupaFals, { appliedAt: aplicatLa }).results[0];
|
|
cer(`CONTROL: "PQ nepreferat" absent fiindca PQ lipseste de tot -> ${vp.state} (${vp.reason})`, vp.state === 'UNRESOLVED');
|
|
cer('CONTROL: scanarea altei gazde -> NEMASURAT', verifica(plan, { ...dupa, domain: 'alta.exemplu.com' }, { appliedAt: aplicatLa }).results.every((x) => x.state === 'UNMEASURED'));
|
|
cer('CONTROL: scanarea de DINAINTE de aplicare -> NEMASURAT', verifica(plan, inainte, { appliedAt: aplicatLa }).results.every((x) => x.state === 'UNMEASURED'));
|
|
cer('CONTROL: scanarea cazuta -> NEMASURAT', verifica(plan, { domain: DOM, error: 'no_tls' }).results.every((x) => x.state === 'UNMEASURED'));
|
|
cer('ref-urile planului se citesc inapoi in defect si domeniu (derivat din clasificator)', v.results.every((x) => defectDinRef(x.ref)?.domain === DOM));
|
|
// R2 (2026-09-29): fara margine de timp (nici --aplicat-la, nici generatedAt) nicio judecata nu iese REZOLVAT; cu generatedAt ca margine se judeca
|
|
cer('R2: fara momentul aplicarii si fara generatedAt -> NEMASURAT, nu verde pe o scanare de oricand', verifica(plan, dupa).results.every((x) => x.state === 'UNMEASURED'));
|
|
cer('R2: planul generat DUPA scanare -> NEMASURAT', verifica({ ...plan, generatedAt: new Date(Date.parse(dupa.measuredAt) + 60000).toISOString() }, dupa).results.every((x) => x.state === 'UNMEASURED'));
|
|
const vg = Object.fromEntries(verifica({ ...plan, generatedAt: aplicatLa }, dupa).results.map((x) => [x.defect, x.state]));
|
|
cer(`R2: cu generatedAt ca margine se judeca (schimb de cheie ${vg['hndl-exposed']})`, vg['hndl-exposed'] === 'RESOLVED');
|
|
// R1 (2026-09-29): un domeniu care nu e nume de gazda nu intra in nicio comanda generata
|
|
const rau = 'x.exemplu.com; touch /tmp/aere-pwn';
|
|
const planRau = { actions: ['chain-untrusted', 'cert-expiring', 'hsts-missing'].map((id) => ({ ref: CLASIFICARE_SCAN[id]({ id }, rau).ref, method: 'manual' })) };
|
|
const rr = reteta(planRau, 'nginx'); const tot = JSON.stringify(rr.domains);
|
|
cer(`R1: domeniul "${rau}" nu produce nicio configuratie sau comanda (${rr.none.length} actiuni refuzate)`, !tot.includes('touch') && rr.domains.length === 0 && rr.none.length === 3);
|
|
const rb = reteta({ actions: [{ ref: CLASIFICARE_SCAN['chain-untrusted']({ id: 'chain-untrusted' }, DOM).ref, method: 'manual' }] }, 'nginx');
|
|
cer('R1 CONTROL: un nume de gazda valid produce comanda cu el', JSON.stringify(rb.domains).includes(`-servername ${DOM}`));
|
|
|
|
// 6. lantul
|
|
cer('lantul judecatilor se re-verifica', verificaRemedierea(v.records).ok);
|
|
const alterat = JSON.parse(JSON.stringify(v.records)); const i = alterat.findIndex((e) => e.record.state === 'UNRESOLVED'); alterat[i].record.state = 'RESOLVED';
|
|
cer('CONTROL: o judecata NEREZOLVAT rescrisa REZOLVAT e prinsa', verificaRemedierea(alterat).ok === false);
|
|
} catch (e) { rele++; console.log(` RAU cap la cap a cazut: ${e.message}`); }
|
|
finally { fs.rmSync(T, { recursive: true, force: true }); }
|
|
|
|
console.log(`\n${bune} treceri, ${rele} esecuri`);
|
|
process.exitCode = rele ? 1 : 0;
|