256 lines
18 KiB
JavaScript
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; }
|
|
}
|