aere-quantum/control-plane/remediere.mjs

256 lines
18 KiB
JavaScript

#!/usr/bin/env node
// remediere.mjs: remedierea actiunilor pe care produsele AERE NU le pot face singure (planul de migrare B1, metoda 'manual', si
// alternativa la gateway pentru expunerea HNDL): configuratia TLS/HTTP a serverului CLIENTULUI. Doua jumatati:
// 1. RETETA (recipe): pentru fiecare actiune, pe software-ul serverului (nginx, apache, haproxy, node, go), fragmentul exact de
// configuratie, preconditia masurabila (versiunea OpenSSL / Go si comanda care o arata) si comanda de verificare a configuratiei
// inainte de reincarcare. Fragmentul se GENEREAZA dintr-o singura forma structurata (grupuri, versiunea minima TLS, HSTS).
// 2. DOVADA (verify): dupa ce operatorul a aplicat reteta, rescanarea (acelasi scaner ca /v1/pq/readiness) judeca fiecare actiune:
// RESOLVED numai daca defectul lipseste SI proprietatea pozitiva e masurata, UNRESOLVED, sau UNMEASURED (scanare cazuta, alta
// gazda, masuratoare de dinainte de aplicare, proprietate nemasurabila). Fiecare judecata intra intr-un lant sha256(seq|prev|
// inregistrare), acelasi ca al executorului.
// Ce NU face, scris: nu se conecteaza la serverul clientului si nu ii scrie configuratia (o aplica operatorul); nu emite certificate.
// Ce e MASURAT de noi si ce nu, pe fiecare reteta (campul `measured`): vezi RETETE_MASURATE mai jos. Iesirea e in engleza (forma 2,
// 2026-09-29).
//
// node remediere.mjs recipe --plan plan.json --profile nginx|apache|haproxy|node|go [--json]
// node remediere.mjs verify --plan plan.json --scan after.json [--applied-at 2026-09-28T10:00:00Z] [--out dir]
// iesire: recipe 0; verify 0 = toate RESOLVED, 1 = cel putin una UNRESOLVED, 2 = cel putin una UNMEASURED (si niciuna nerezolvata)
import fs from 'node:fs';
import path from 'node:path';
import { CLASIFICARE_SCAN } from './plan-migrare.mjs';
import { lantulExecutiei, verificaExecutie } from './executa-migrare.mjs';
export const VERSIUNE = 'aere-control-plane/remediation/2 (2026-09-29)';
export const PROFILURI = ['nginx', 'apache', 'haproxy', 'node', 'go'];
const GRUPURI_PQ = ['X25519MLKEM768', 'X25519'];
const HSTS = 'max-age=31536000';
// ce masuratoare sustine fiecare profil (un mesaj nu afirma mai mult decat masuratoarea)
export const RETETE_MASURATE = {
node: 'measured end to end on 2026-09-28: a Node 24.14.1 / OpenSSL 3.5.5 server with the options of this recipe, scanned by the AERE scanner (proba-remediere.mjs)',
nginx: 'the group names accepted by OpenSSL 3.5.5 (measured); the server directive from the nginx documentation, not measured by us on a real nginx',
apache: 'the group names accepted by OpenSSL 3.5.5 (measured); the server directive from the mod_ssl documentation, not measured by us on a real Apache',
haproxy: 'the group names accepted by OpenSSL 3.5.5 (measured); the server directive from the HAProxy documentation, not measured by us on a real HAProxy',
go: 'from the Go 1.24 release notes (X25519MLKEM768 is the default when CurvePreferences is nil); not measured by us (the Go on the test machine is 1.22)',
};
// --- forma structurata: ce cere fiecare defect al scanerului --------------------------------------------------------------------
// fiecare intrare: settings (groups | tlsMin | hsts), sau `operational` (certificatul: nu e o setare de server, e o reemitere)
export const REMEDIERI = {
'hndl-exposed': { settings: { groups: GRUPURI_PQ }, requires: 'pq', reason: 'the hybrid group on your server, instead of (or before) the PQ Gateway' },
'pq-not-preferred': { settings: { groups: GRUPURI_PQ }, requires: 'pq', reason: 'the hybrid group FIRST in the server\'s group list' },
'tls13-missing': { settings: { tlsMin: '1.2' }, requires: 'tls13', reason: 'TLS 1.3 allowed (TLS 1.2 stays until you retire it separately)' },
'tls12-accepted': { settings: { tlsMin: '1.3' }, requires: null, reason: 'TLS 1.3 only; clients that know only TLS 1.2 can no longer connect (measure them first)' },
'hsts-missing': { settings: { hsts: HSTS }, requires: null, reason: 'HSTS for one year' },
'cert-expiring': { operational: 'renew now and check the automatic renewal', commands: ['certbot renew', 'systemctl list-timers | grep -i certbot'] },
'rsa-short': { operational: 'reissue with ECDSA P-256 (still classical: no public CA issues post-quantum certificates today)', commands: ['certbot certonly --key-type ecdsa --elliptic-curve secp256r1 -d <domain>'] },
'chain-untrusted': { operational: 'serve the complete chain (the leaf certificate and the intermediates), not only the leaf', commands: ['openssl s_client -connect <domain>:443 -servername <domain> -showcerts < /dev/null'] },
};
// ref-ul actiunii -> id-ul defectului, DERIVAT din clasificatorul planificatorului (nu scris de mana): se cheama fiecare clasificare
// cu un domeniu marcat si se citeste forma ref-ului rezultat
const MARCA = 'domeniu.proba.invalid';
export const REF_LA_ID = (() => {
const m = [];
for (const id of Object.keys(CLASIFICARE_SCAN)) {
const a = CLASIFICARE_SCAN[id]({ id }, MARCA);
if (a && a.ref) m.push({ id, prefix: a.ref.split(MARCA)[0], sufix: a.ref.split(MARCA)[1] || '' });
}
return m;
})();
// 2026-09-29, revizuirea adversariala (pista B, inainte de publicare), R1: domeniul iese din ref-ul planului (un fisier) si intra in
// comenzi de copiat in terminal (`certbot ... -d <domain>`, `openssl s_client -connect <domain>:443`); un "domeniu" ca
// `x.com; comanda` facea din reteta o comanda straina. Numai un nume de gazda (litere, cifre, cratima, puncte) primeste reteta.
const NUME_GAZDA = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
export const domeniuValid = (d) => typeof d === 'string' && NUME_GAZDA.test(d);
export function defectDinRef(ref) {
for (const r of REF_LA_ID) {
if (ref.startsWith(r.prefix) && ref.endsWith(r.sufix) && ref.length > r.prefix.length + r.sufix.length) {
return { id: r.id, domain: ref.slice(r.prefix.length, ref.length - r.sufix.length) };
}
}
return null;
}
// --- generatoarele, cate unul pe profil, din aceeasi forma --------------------------------------------------------------------
const grup = (g) => g.join(':');
const GENERATOARE = {
nginx: (s) => ({
where: 'in the server { listen 443 ssl; ... } block of the domain',
fragment: [
s.groups && `ssl_ecdh_curve ${grup(s.groups)};`,
s.tlsMin === '1.3' && 'ssl_protocols TLSv1.3;',
s.tlsMin === '1.2' && 'ssl_protocols TLSv1.2 TLSv1.3;',
s.hsts && `add_header Strict-Transport-Security "${s.hsts}" always;`,
].filter(Boolean),
precondition: s.groups ? { what: 'nginx linked to OpenSSL 3.5 or later (both "built with" and "running with")', command: 'nginx -V 2>&1 | grep -i openssl' } : null,
verify: 'nginx -t', reload: 'systemctl reload nginx',
}),
apache: (s) => ({
where: 'in the <VirtualHost *:443> of the domain (HSTS needs mod_headers)',
fragment: [
s.groups && `SSLOpenSSLConfCmd Groups ${grup(s.groups)}`,
s.tlsMin === '1.3' && 'SSLProtocol -all +TLSv1.3',
s.tlsMin === '1.2' && 'SSLProtocol -all +TLSv1.2 +TLSv1.3',
s.hsts && `Header always set Strict-Transport-Security "${s.hsts}"`,
].filter(Boolean),
precondition: s.groups ? { what: 'mod_ssl linked to OpenSSL 3.5 or later (the startup line in error.log names the version)', command: 'grep -i "openssl/" /var/log/apache2/error.log | tail -1' } : null,
verify: 'apachectl configtest', reload: 'systemctl reload apache2',
}),
haproxy: (s) => ({
where: 'the groups and the version in the global section; HSTS in the HTTPS frontend',
fragment: [
s.groups && `ssl-default-bind-curves ${grup(s.groups)}`,
s.tlsMin && `ssl-default-bind-options ssl-min-ver TLSv${s.tlsMin}`,
s.hsts && `http-response set-header Strict-Transport-Security "${s.hsts}"`,
].filter(Boolean),
precondition: s.groups ? { what: 'HAProxy running on OpenSSL 3.5 or later', command: 'haproxy -vv | grep -i "running on openssl"' } : null,
verify: 'haproxy -c -f /etc/haproxy/haproxy.cfg', reload: 'systemctl reload haproxy',
}),
node: (s) => {
const opt = {};
if (s.groups) opt.ecdhCurve = grup(s.groups);
if (s.tlsMin) opt.minVersion = `TLSv${s.tlsMin}`;
return {
where: 'in the options of https.createServer / tls.createServer',
options: opt,
fragment: [
Object.keys(opt).length ? `https.createServer({ ...yourOptions, ${Object.entries(opt).map(([k, v]) => `${k}: '${v}'`).join(', ')} }, app)` : null,
s.hsts && `res.setHeader('Strict-Transport-Security', '${s.hsts}')`,
].filter(Boolean),
headers: s.hsts ? { 'strict-transport-security': s.hsts } : {},
precondition: s.groups ? { what: 'Node linked to OpenSSL 3.5 or later', command: 'node -p process.versions.openssl' } : null,
verify: 'node --check <your server file>', reload: 'restart the process',
};
},
go: (s) => ({
where: 'in the tls.Config of the server (crypto/tls)',
fragment: [
(s.groups || s.tlsMin) && `&tls.Config{${[s.tlsMin && `MinVersion: tls.VersionTLS1${s.tlsMin === '1.3' ? '3' : '2'}`, s.groups && `CurvePreferences: []tls.CurveID{${s.groups.map((g) => 'tls.' + g).join(', ')}}`].filter(Boolean).join(', ')}}`,
s.groups && '// Go 1.24 or later: with CurvePreferences nil, X25519MLKEM768 is already the default; if you set it, put it first. GODEBUG=tlsmlkem=0 turns it off.',
s.hsts && `w.Header().Set("Strict-Transport-Security", "${s.hsts}")`,
].filter(Boolean),
precondition: s.groups ? { what: 'the binary built with Go 1.24 or later and without GODEBUG=tlsmlkem=0', command: 'go version <your server binary>' } : null,
verify: 'go vet ./...', reload: 'rebuild and restart',
}),
};
// uneste setarile mai multor actiuni ale aceluiasi domeniu (grupurile o data, versiunea minima cea mai stricta ceruta)
function uneste(lista) {
const s = {};
for (const x of lista) {
if (x.groups) s.groups = x.groups;
if (x.tlsMin) s.tlsMin = s.tlsMin === '1.3' || x.tlsMin === '1.3' ? '1.3' : '1.2';
if (x.hsts) s.hsts = x.hsts;
}
return s;
}
/** reteta(plan, profil) -> { version, profile, measured, domains: [{ domain, actions, settings, config, operational }], none } */
export function reteta(plan, profil) {
if (!Object.hasOwn(GENERATOARE, profil)) throw new Error(`unknown profile: ${profil} (${PROFILURI.join(', ')})`);
const peDomeniu = new Map(); const fara = [];
for (const a of plan.actions || []) {
const d = defectDinRef(String(a.ref || ''));
if (d && Object.hasOwn(REMEDIERI, d.id) && !domeniuValid(d.domain)) { fara.push({ ref: a.ref, reason: 'the domain in the ref is not a valid host name: no configuration and no command is generated with it' }); continue; }
if (!d || !Object.hasOwn(REMEDIERI, d.id)) { if (a.method === 'manual') fara.push({ ref: a.ref, reason: 'an action from the code inventory: it is fixed in the code (see `how`), not in the server configuration' }); continue; }
if (d.id === 'hndl-exposed' && a.method !== 'auto-aere' && a.method !== 'manual') continue;
const x = peDomeniu.get(d.domain) || { domain: d.domain, actions: [], settings: [], operational: [] };
const r = REMEDIERI[d.id];
x.actions.push({ ref: a.ref, defect: d.id, reason: r.reason || r.operational });
if (r.settings) x.settings.push(r.settings);
if (r.operational) x.operational.push({ ref: a.ref, what: r.operational, commands: r.commands.map((c) => c.replaceAll('<domain>', d.domain)) });
peDomeniu.set(d.domain, x);
}
const domenii = [...peDomeniu.values()].map((x) => {
const setari = uneste(x.settings);
return { domain: x.domain, actions: x.actions, settings: setari, config: Object.keys(setari).length ? GENERATOARE[profil](setari) : null, operational: x.operational };
});
return { version: VERSIUNE, profile: profil, measured: RETETE_MASURATE[profil], domains: domenii, none: fara };
}
// --- dovada: judecata pe rescanare ------------------------------------------------------------------------------------------------
// proprietatea POZITIVA ceruta pe raportul de dupa, pe fiecare defect; null = nemasurabila din acest raport
const POZITIV = {
'hndl-exposed': (r) => r.summary?.pqKeyExchange ? true : false,
'pq-not-preferred': (r) => !r.summary?.pqKeyExchange ? false : (r.summary.prefersPqWhenOffered === true ? true : (r.summary.prefersPqWhenOffered === false ? false : null)),
'tls13-missing': (r) => r.summary?.tls13 === true,
'tls12-accepted': (r) => r.summary?.tls12Accepted === false,
'hsts-missing': (r) => (r.summary?.hsts == null ? null : r.summary.hsts === true),
'cert-expiring': (r) => (r.summary?.certificate?.daysLeft == null ? null : r.summary.certificate.daysLeft >= 30),
'rsa-short': (r) => { const c = r.summary?.certificate; if (!c) return null; return !(c.keyType === 'RSA' && c.bits && c.bits < 3072); },
'chain-untrusted': (r) => (r.handshakes?.classicalBaseline?.ok ? r.handshakes.classicalBaseline.authorized === true : null),
};
/** verifica(plan, scanDupa, { appliedAt }) -> { summary, results, records } */
export function verifica(plan, scan, o = {}) {
const lant = lantulExecutiei();
lant.adauga({ type: 'start', version: VERSIUNE, at: o.at || new Date().toISOString(), domain: scan?.domain || null, measuredAt: scan?.measuredAt || null, appliedAt: o.appliedAt || null });
const rezultate = []; const sumar = { RESOLVED: 0, UNRESOLVED: 0, UNMEASURED: 0 };
// R2 (2026-09-29): fara o margine de timp, orice scanare (si una de acum o luna) putea dovedi RESOLVED. Marginea e momentul aplicarii
// dat de operator, altfel momentul generarii planului; fara niciuna, judecata e UNMEASURED, nu un verde pe o scanare de oricand.
const limita = o.appliedAt || plan.generatedAt || null;
const deCe = o.appliedAt ? 'the application' : 'the plan was generated';
for (const a of plan.actions || []) {
const d = defectDinRef(String(a.ref || ''));
if (!d || !Object.hasOwn(POZITIV, d.id)) continue;
let stare, motiv;
if (!scan || scan.error) { stare = 'UNMEASURED'; motiv = `the scan after failed (${scan?.error || 'missing'})`; }
else if (scan.domain !== d.domain) { stare = 'UNMEASURED'; motiv = `the scan is of ${scan.domain}, the action is of ${d.domain}`; }
else if (!limita || !Number.isFinite(Date.parse(limita))) { stare = 'UNMEASURED'; motiv = 'no time of application (--applied-at) and no generatedAt in the plan: a scan from any time would pass as proof'; }
else if (!(Date.parse(scan.measuredAt) > Date.parse(limita))) { stare = 'UNMEASURED'; motiv = `the scan (${scan.measuredAt}) is not from after ${deCe} (${limita})`; }
else {
const inca = (scan.findings || []).some((f) => f.id === d.id);
const poz = POZITIV[d.id](scan);
if (inca || poz === false) { stare = 'UNRESOLVED'; motiv = inca ? `the scanner still reports ${d.id}` : `the defect is gone, but the required property is missing (${d.id})`; }
else if (poz === null) { stare = 'UNMEASURED'; motiv = `the property required by ${d.id} cannot be measured from the report`; }
else { stare = 'RESOLVED'; motiv = `${d.id} is absent and the required property is measured`; }
}
sumar[stare]++;
const rec = { ref: a.ref, defect: d.id, domain: d.domain, state: stare, reason: motiv };
rezultate.push(rec); lant.adauga(rec);
}
lant.adauga({ type: 'end', summary: sumar });
return { summary: sumar, results: rezultate, records: lant.lista() };
}
export { verificaExecutie as verificaRemedierea, reteta as recipe, verifica as verifyRemediation };
// --- CLI -------------------------------------------------------------------------------------------------------------------------
const RULAT_DIRECT = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/remediere.mjs');
if (RULAT_DIRECT) {
const arg = (n) => { const i = process.argv.indexOf(n); return i >= 0 ? process.argv[i + 1] : undefined; };
const cmd = process.argv[2];
try {
if (cmd !== 'recipe' && cmd !== 'verify') { console.log('usage: node remediere.mjs recipe --plan p.json --profile nginx | verify --plan p.json --scan s.json [--applied-at T] [--out dir]'); process.exitCode = 2; }
else {
const plan = JSON.parse(fs.readFileSync(arg('--plan'), 'utf8'));
if (cmd === 'recipe') {
const r = reteta(plan, arg('--profile'));
if (process.argv.includes('--json')) console.log(JSON.stringify(r, null, 2));
else {
console.log(`recipe for ${r.profile} (${r.measured})`);
for (const d of r.domains) {
console.log(`\n== ${d.domain}: ${d.actions.map((a) => a.defect).join(', ')}`);
if (d.config) {
if (d.config.precondition) console.log(` precondition: ${d.config.precondition.what}\n ${d.config.precondition.command}`);
console.log(` ${d.config.where}:`); for (const l of d.config.fragment) console.log(` ${l}`);
console.log(` before reloading: ${d.config.verify}\n then: ${d.config.reload}`);
}
for (const x of d.operational) { console.log(` ${x.what}:`); for (const c of x.commands) console.log(` ${c}`); }
}
for (const f of r.none) console.log(`\nno server recipe: ${f.ref} (${f.reason})`);
console.log('\nafter applying it: rescan the domain and run `node remediere.mjs verify --plan ... --scan ...`');
}
process.exitCode = 0;
} else {
const scan = JSON.parse(fs.readFileSync(arg('--scan'), 'utf8'));
const r = verifica(plan, scan, { appliedAt: arg('--applied-at') });
for (const x of r.results) console.log(` ${x.state.padEnd(10)} ${x.ref}: ${x.reason}`);
console.log(`RESOLVED ${r.summary.RESOLVED} | UNRESOLVED ${r.summary.UNRESOLVED} | UNMEASURED ${r.summary.UNMEASURED}`);
if (arg('--out')) { fs.mkdirSync(arg('--out'), { recursive: true }); fs.writeFileSync(path.join(arg('--out'), 'remediation.json'), JSON.stringify({ version: VERSIUNE, records: r.records }, null, 1) + '\n'); }
process.exitCode = r.summary.UNRESOLVED ? 1 : (r.summary.UNMEASURED ? 2 : 0);
}
}
} catch (e) { console.error(`UNMEASURED: ${String(e.message || e).slice(0, 200)}`); process.exitCode = 2; }
}