#!/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 '] }, 'chain-untrusted': { operational: 'serve the complete chain (the leaf certificate and the intermediates), not only the leaf', commands: ['openssl s_client -connect :443 -servername -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 `, `openssl s_client -connect :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 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 ', 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 ' } : 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('', 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; } }