wize-dev-kit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +1 -1
  3. package/src/security-overlay/_shared/allowlist.js +154 -0
  4. package/src/security-overlay/_shared/cli-runner.js +87 -0
  5. package/src/security-overlay/_shared/cvss.js +108 -0
  6. package/src/security-overlay/_shared/detect.js +125 -0
  7. package/src/security-overlay/_shared/install-script.js +205 -0
  8. package/src/security-overlay/_shared/invoke-phase.js +86 -0
  9. package/src/security-overlay/_shared/owasp.js +56 -0
  10. package/src/security-overlay/_shared/partial.js +225 -0
  11. package/src/security-overlay/_shared/preflight.js +175 -0
  12. package/src/security-overlay/_shared/scope-gate.js +172 -0
  13. package/src/security-overlay/_shared/scope-parser.js +120 -0
  14. package/src/security-overlay/agents/red-teamer/agent.yaml +51 -0
  15. package/src/security-overlay/agents/red-teamer/persona.md +43 -0
  16. package/src/security-overlay/data/common.txt +115 -0
  17. package/src/security-overlay/data/owasp-top10.json +15 -0
  18. package/src/security-overlay/data/tool-allowlist.json +31 -0
  19. package/src/security-overlay/skills/wize-sec-enumerate/scripts/run-enumerate.js +180 -0
  20. package/src/security-overlay/skills/wize-sec-enumerate/skill.md +32 -0
  21. package/src/security-overlay/skills/wize-sec-exploit/data/common.txt +117 -0
  22. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-ffuf.js +147 -0
  23. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-nikto.js +145 -0
  24. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-nuclei.js +176 -0
  25. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-sqlmap.js +139 -0
  26. package/src/security-overlay/skills/wize-sec-pentest/scripts/run-pipeline.js +157 -0
  27. package/src/security-overlay/skills/wize-sec-pentest/skill.md +52 -0
  28. package/src/security-overlay/skills/wize-sec-recon/scripts/run-gitleaks.js +139 -0
  29. package/src/security-overlay/skills/wize-sec-recon/scripts/run-osv.js +227 -0
  30. package/src/security-overlay/skills/wize-sec-recon/scripts/run-recon.js +162 -0
  31. package/src/security-overlay/skills/wize-sec-recon/skill.md +35 -0
  32. package/src/security-overlay/skills/wize-sec-report/scripts/render-report.js +999 -0
  33. package/tools/installer/onboarding.js +1 -0
  34. package/tools/installer/render-shared.js +5 -1
  35. package/tools/installer/wize-cli.js +8 -1
@@ -0,0 +1,999 @@
1
+ 'use strict';
2
+
3
+ // render-report.js — MD consolidator for the security-overlay. Reads
4
+ // partials from <securityDir>/*.md, collects findings, applies CVSS
5
+ // scores (zero-dep), tagOwasp, and redaction (defense in depth), then
6
+ // writes a single report.md. Idempotent.
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+
11
+ const { listPartials, loadPartial } = require('../../../_shared/partial.js');
12
+ const { compute: cvssCompute } = require('../../../_shared/cvss.js');
13
+ const { tagOwasp } = require('../../../_shared/owasp.js');
14
+
15
+ const PHASES = ['recon', 'enumerate', 'sast', 'dast'];
16
+
17
+ // Conservative secret detector. Matches common API key / token shapes.
18
+ const SECRET_RE = /\b(?:AKIA[0-9A-Za-z]{16}|AIza[0-9A-Za-z\-_]{35}|sk_live_[0-9A-Za-z]{24,}|ghp_[0-9A-Za-z]{36}|xox[abp]-[0-9A-Za-z\-]{10,}|eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+)/g;
19
+ const REDACTED = '***REDACTED***';
20
+
21
+ function redactText(text) {
22
+ return String(text || '').replace(SECRET_RE, REDACTED);
23
+ }
24
+
25
+ // Extract a list of findings from a partial's body. We accept the
26
+ // markdown bullet form we write and a fallback that scans every line
27
+ // starting with `- `.
28
+ function extractFindings(body) {
29
+ const out = [];
30
+ for (const line of String(body || '').split('\n')) {
31
+ const m = line.match(/^-\s+(.*)$/);
32
+ if (!m) continue;
33
+ out.push({ raw: m[1] });
34
+ }
35
+ return out;
36
+ }
37
+
38
+ // Try to extract a CVSS vector or score from a finding raw line.
39
+ function extractCvss(finding) {
40
+ // Look for an explicit vector.
41
+ const vm = finding.raw.match(/CVSS:3\.[01]\/[\w:\/]+/);
42
+ if (vm) return { vector: vm[0] };
43
+ // Look for an explicit cvss=NUMBER.
44
+ const sm = finding.raw.match(/cvss\s*=\s*(\d+(?:\.\d+)?)/i);
45
+ if (sm) return { score: Number(sm[1]) };
46
+ return null;
47
+ }
48
+
49
+ function severityFromCvss(score) {
50
+ if (score === 0) return 'None';
51
+ if (score < 4.0) return 'Low';
52
+ if (score < 7.0) return 'Medium';
53
+ if (score < 9.0) return 'High';
54
+ return 'Critical';
55
+ }
56
+
57
+ // Normalize any severity string to the canonical Capitalized label.
58
+ function normalizeSeverity(s) {
59
+ if (!s) return null;
60
+ const k = String(s).toLowerCase();
61
+ const map = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low',
62
+ none: 'None', info: 'Low', informational: 'Low', unknown: 'unknown' };
63
+ return map[k] || null;
64
+ }
65
+
66
+ function classifyFinding(finding, section) {
67
+ // 1. CVSS via vector (preferred) or score.
68
+ const cvss = extractCvss(finding);
69
+ let score = null, severity = null;
70
+ if (cvss && cvss.vector) {
71
+ try {
72
+ const r = cvssCompute(cvss.vector);
73
+ score = r.score; severity = r.severity;
74
+ } catch (_) { /* invalid vector: fall through to score heuristic */ }
75
+ } else if (cvss && typeof cvss.score === 'number') {
76
+ score = cvss.score; severity = severityFromCvss(score);
77
+ }
78
+ // 2. Severity from explicit keyword (normalized).
79
+ const sm = finding.raw.match(/severity\s*=\s*(\w+)/i);
80
+ if (!severity && sm) {
81
+ severity = normalizeSeverity(sm[1]);
82
+ } else if (severity) {
83
+ severity = normalizeSeverity(severity);
84
+ }
85
+ // 3. Secrets are intrinsically High (an exposed credential in git history
86
+ // is a serious finding even without a CVSS vector). recon/enumerate
87
+ // surface (ports, endpoints, tech) is informational unless tagged.
88
+ if (!severity || severity === null) {
89
+ if (section === 'secrets') severity = 'High';
90
+ else if (section === 'open_ports' || section === 'surface' || section === 'tech') severity = 'Info-surface';
91
+ else severity = 'unknown';
92
+ }
93
+ // 4. Owasp tag (idempotent: only if not already present).
94
+ let owasp = null;
95
+ const om = finding.raw.match(/owasp\s*=\s*[`'"]?(A\d{2}:\d{4})[`'"]?/i);
96
+ if (om) {
97
+ owasp = om[1].toUpperCase();
98
+ } else if (section === 'secrets') {
99
+ owasp = 'A07:2021'; // Identification and Authentication Failures
100
+ } else {
101
+ const rm = finding.raw.match(/\*\*([^*]+)\*\*/);
102
+ const cveMatch = finding.raw.match(/CVE-\d{4}-\d+/);
103
+ owasp = tagOwasp({ rule: rm ? rm[1] : finding.raw.slice(0, 80), cve: cveMatch ? cveMatch[0] : null });
104
+ if (section === 'deps' && (!owasp || owasp === 'UNKNOWN')) owasp = 'A06:2021';
105
+ }
106
+ return { score, severity, owasp };
107
+ }
108
+
109
+ // computeRisk(findings) -> { score 0-100, rating, posture, counts, drivers }
110
+ // A pragmatic application-risk rollup for stakeholders. Weighted by severity;
111
+ // the rating is the worst-case posture a CISO would read first.
112
+ function computeRisk(findings) {
113
+ const weights = { Critical: 40, High: 15, Medium: 5, Low: 1, None: 0 };
114
+ const counts = { Critical: 0, High: 0, Medium: 0, Low: 0, None: 0, surface: 0, unknown: 0 };
115
+ let raw = 0;
116
+ for (const f of findings) {
117
+ const s = f.severity;
118
+ if (s === 'Info-surface') { counts.surface++; continue; }
119
+ if (weights[s] != null) { counts[s]++; raw += weights[s]; }
120
+ else counts.unknown++;
121
+ }
122
+ // Cap the raw weighted sum into a 0-100 score (logarithmic-ish via cap).
123
+ const score = Math.min(100, raw);
124
+ let rating, posture;
125
+ if (counts.Critical > 0 || score >= 60) {
126
+ rating = 'CRÍTICO';
127
+ posture = 'A aplicação tem exposições graves que exigem ação imediata antes de qualquer release. Pelo menos uma vulnerabilidade crítica ou um conjunto alto de severidades High foi identificado.';
128
+ } else if (counts.High > 0 || score >= 25) {
129
+ rating = 'ALTO';
130
+ posture = 'Há vulnerabilidades de alta severidade que devem ser corrigidas em sprint próximo. O risco residual não é aceitável para produção sem mitigação.';
131
+ } else if (counts.Medium > 0 || score >= 8) {
132
+ rating = 'MÉDIO';
133
+ posture = 'Foram encontradas questões de severidade média. Risco gerenciável, mas recomenda-se planejar correções e revisar configurações.';
134
+ } else if (counts.Low > 0) {
135
+ rating = 'BAIXO';
136
+ posture = 'Apenas questões de baixa severidade ou informativas. Postura de segurança razoável; manter higiene contínua.';
137
+ } else {
138
+ rating = 'INFORMATIVO';
139
+ posture = 'Nenhuma vulnerabilidade explorável confirmada nesta passada. Resultados são majoritariamente superfície de ataque mapeada (recon/enumeração).';
140
+ }
141
+ // Top drivers — the findings pushing the score up.
142
+ const drivers = [];
143
+ if (counts.Critical) drivers.push(`${counts.Critical} crítica(s)`);
144
+ if (counts.High) drivers.push(`${counts.High} alta(s)`);
145
+ if (counts.Medium) drivers.push(`${counts.Medium} média(s)`);
146
+ if (counts.Low) drivers.push(`${counts.Low} baixa(s)`);
147
+ return { score, rating, posture, counts, drivers };
148
+ }
149
+
150
+ // The checks we expect a complete pentest to perform, grouped by what each
151
+ // one answers. Used to compute honest coverage + audit confidence.
152
+ const EXPECTED_CHECKS = [
153
+ { id: 'recon-ports', tool: 'nmap', phase: 'recon', section: 'open_ports', label: 'Mapeamento de portas/serviços', answers: 'Quais serviços estão expostos?' },
154
+ { id: 'enum-surface', tool: 'curl', phase: 'enumerate', section: 'surface', label: 'Enumeração de superfície HTTP', answers: 'Quais endpoints/tecnologias respondem?' },
155
+ { id: 'sast-secrets', tool: 'gitleaks', phase: 'sast', section: 'secrets', label: 'Secrets no código/histórico', answers: 'Há credenciais vazadas?' },
156
+ { id: 'sast-deps', tool: 'osv-scanner', phase: 'sast', section: 'deps', label: 'Dependências vulneráveis', answers: 'Há CVEs em libs?' },
157
+ { id: 'dast-nuclei', tool: 'nuclei', phase: 'dast', section: 'nuclei', label: 'Templates de vulnerabilidade (nuclei)', answers: 'CVEs/misconfigs conhecidos na app?' },
158
+ { id: 'dast-nikto', tool: 'nikto', phase: 'dast', section: 'nikto', label: 'Web server scan (nikto)', answers: 'Headers/arquivos perigosos?' },
159
+ { id: 'dast-content', tool: 'ffuf', phase: 'dast', section: 'ffuf', label: 'Content discovery (ffuf)', answers: 'Há /admin, /.env, /.git, endpoints ocultos?' },
160
+ { id: 'dast-sqli', tool: 'sqlmap', phase: 'dast', section: 'sqlmap', label: 'SQL injection (sqlmap)', answers: 'A app é injetável? (requer --active)' }
161
+ ];
162
+
163
+ // computeCoverage(securityDir, phaseSummaries) -> { ran, missing, pct, audited, confidence }
164
+ // Honest accounting: which checks actually ran vs. degraded (tool missing /
165
+ // out of scope). The confidence is what a reader should trust the report to.
166
+ function computeCoverage(loadFn, secDir) {
167
+ // Determine tool presence two ways and combine them, because sibling
168
+ // scripts that share a partial (gitleaks+osv -> sast.md; nuclei+nikto+
169
+ // sqlmap+ffuf -> dast.md) can overwrite each other's frontmatter.tools
170
+ // block. So a tool counts as "ran" if EITHER the frontmatter says
171
+ // present:true OR a degraded_checks line does NOT mention it as missing.
172
+ const toolPresent = {};
173
+ const degradedMentions = {}; // tool -> true if listed as ausente/missing
174
+ const sectionPresent = {}; // section name -> true if it exists in any partial
175
+ for (const phase of PHASES) {
176
+ const p = loadFn({ securityDir: secDir, phase });
177
+ if (!p) continue;
178
+ if (p.frontmatter && p.frontmatter.tools) {
179
+ for (const [tool, info] of Object.entries(p.frontmatter.tools)) {
180
+ if (info && typeof info === 'object' && info.present) toolPresent[tool] = true;
181
+ }
182
+ }
183
+ if (p.body) {
184
+ // A degraded line like "- ffuf: ffuf ausente" marks ffuf missing.
185
+ for (const tool of EXPECTED_CHECKS.map(c => c.tool)) {
186
+ const re = new RegExp(`${tool}[^\\n]*\\b(ausente|missing|não instalad)`, 'i');
187
+ if (re.test(p.body)) degradedMentions[tool] = true;
188
+ }
189
+ // A tool that wrote its own `## <section>` block actually ran — this
190
+ // survives even when a sibling script overwrites the frontmatter
191
+ // tools map (mergeDast/mergeSast share one partial per phase).
192
+ let m; const re = /^##\s+([a-z_-]+)/gm;
193
+ while ((m = re.exec(p.body)) !== null) sectionPresent[m[1]] = true;
194
+ }
195
+ }
196
+ // A check "ran" if its tool is present (frontmatter) OR its output section
197
+ // exists — and it is not explicitly flagged as missing.
198
+ const ranTool = c => {
199
+ if (degradedMentions[c.tool]) return false;
200
+ if (toolPresent[c.tool] === true) return true;
201
+ if (c.section && sectionPresent[c.section]) return true;
202
+ return false;
203
+ };
204
+ const ran = [];
205
+ const missing = [];
206
+ for (const c of EXPECTED_CHECKS) {
207
+ if (ranTool(c)) ran.push(c);
208
+ else missing.push(c);
209
+ }
210
+ const pct = Math.round((ran.length / EXPECTED_CHECKS.length) * 100);
211
+ // "audited" only if all DAST + SAST checks ran. recon/enum alone is NOT an audit.
212
+ const dastSastRan = ran.filter(c => c.phase === 'dast' || c.phase === 'sast').length;
213
+ const dastSastTotal = EXPECTED_CHECKS.filter(c => c.phase === 'dast' || c.phase === 'sast').length;
214
+ const audited = dastSastRan === dastSastTotal;
215
+ let confidence;
216
+ if (audited) confidence = 'ALTA — todas as checagens SAST/DAST executaram';
217
+ else if (pct >= 50) confidence = 'PARCIAL — algumas checagens não rodaram; o ambiente NÃO está auditado';
218
+ else confidence = 'BAIXA — cobertura insuficiente; isto é um inventário de exposição, não uma auditoria';
219
+ return { ran, missing, pct, audited, confidence, total: EXPECTED_CHECKS.length };
220
+ }
221
+
222
+ // Stable key for a finding so AI insights can be attached idempotently.
223
+ // Uses phase/section + a sha1 of the raw line (truncated).
224
+ function findingKey(phase, section, raw) {
225
+ const h = require('node:crypto').createHash('sha1').update(`${phase}/${section}/${raw}`).digest('hex').slice(0, 12);
226
+ return `${phase}.${section}.${h}`;
227
+ }
228
+
229
+ // Load ai-insights.json if present. Shape:
230
+ // { briefing: "markdown string", actionPlan: [{priority,title,detail}],
231
+ // findings: { "<key>": "recommendation string" } }
232
+ function loadAiInsights(sec) {
233
+ const file = path.join(sec, 'ai-insights.json');
234
+ if (!fs.existsSync(file)) return {};
235
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
236
+ catch (_) { return {}; }
237
+ }
238
+
239
+ // Deterministic fallback recommendation when the harness hasn't written
240
+ // an AI insight for this finding. Keyed by section/severity.
241
+ function heuristicRecommendation(f) {
242
+ const s = f.section;
243
+ if (s === 'secrets') return 'Rotacione a credencial imediatamente e remova do histórico (git filter-repo / BFG). Migre para um secrets manager (Vault, AWS Secrets Manager) e nunca commite .env.';
244
+ if (s === 'deps') return 'Atualize a dependência para a versão corrigida (composer/npm update). Se não houver fix, avalie mitigação ou substituição.';
245
+ if (s === 'open_ports') return 'Confirme se a porta precisa estar exposta. Restrinja a localhost/VPN se for serviço interno; aplique firewall.';
246
+ if (s === 'tech') return 'Hardening: oculte headers de versão (ServerTokens Prod, expose_php=Off) para reduzir fingerprinting.';
247
+ if (s === 'surface') return 'Revise se o endpoint deve ser público; aplique autenticação/rate-limit conforme o caso.';
248
+ if (s === 'nuclei' || s === 'nikto') return 'Valide o finding manualmente e aplique o patch/config recomendado pelo template.';
249
+ if (s === 'ffuf') return 'Endpoint descoberto: confirme se deveria existir; remova arquivos sensíveis (.git, .env, backups) do webroot.';
250
+ if (s === 'sqlmap') return 'Parametrize todas as queries (prepared statements); valide entrada no boundary.';
251
+ return 'Revise o finding e avalie o risco no contexto da aplicação.';
252
+ }
253
+
254
+ // Heuristic business-language briefing when no AI briefing is provided.
255
+ function heuristicBriefing(risk, coverage, findings) {
256
+ const bySection = {};
257
+ for (const f of findings) bySection[f.section] = (bySection[f.section] || 0) + 1;
258
+ const parts = [];
259
+ parts.push(`A postura de segurança da aplicação foi avaliada como **${risk.rating}** (${risk.score}/100).`);
260
+ if (bySection.secrets) parts.push(`Foram encontrados **${bySection.secrets} segredos** no código/histórico — credenciais expostas podem dar a um atacante acesso direto a sistemas conectados (bancos, APIs, cloud). É o tipo de falha que transforma um vazamento de código em um incidente real.`);
261
+ if (bySection.deps) parts.push(`Há **${bySection.deps} dependências com vulnerabilidades conhecidas (CVE)** — bibliotecas desatualizadas são o vetor de ataque mais explorado em aplicações web, pois exploits públicos já existem.`);
262
+ if (bySection.ffuf) parts.push(`A enumeração de conteúdo revelou endpoints/arquivos que podem expor áreas administrativas ou dados sensíveis.`);
263
+ if (!coverage.audited) parts.push(`⚠️ A cobertura foi de **${coverage.pct}%** — partes da aplicação não foram testadas, então a ausência de findings nessas áreas **não** significa que sejam seguras.`);
264
+ parts.push(`Para o negócio: o risco residual atual ${risk.rating === 'CRÍTICO' || risk.rating === 'ALTO' ? '**não é aceitável para produção** sem mitigação imediata' : 'é gerenciável, mas requer acompanhamento'}.`);
265
+ return parts.join(' ');
266
+ }
267
+
268
+ // Heuristic prioritized action plan.
269
+ function heuristicActionPlan(findings, coverage) {
270
+ const plan = [];
271
+ const has = sec => findings.some(f => f.section === sec);
272
+ if (has('secrets')) plan.push({ priority: 'P0', title: 'Rotacionar segredos expostos', detail: 'Invalide todas as credenciais encontradas, remova do histórico git (BFG/filter-repo) e migre para um secrets manager.' });
273
+ const crit = findings.filter(f => f.severity === 'Critical');
274
+ if (crit.length) plan.push({ priority: 'P0', title: `Corrigir ${crit.length} vulnerabilidade(s) crítica(s)`, detail: 'Exploração comprovada ou trivial; bloqueia release.' });
275
+ if (has('deps')) plan.push({ priority: 'P1', title: 'Atualizar dependências vulneráveis', detail: 'Rode o update das libs com CVE; priorize as de severidade High.' });
276
+ if (has('ffuf')) plan.push({ priority: 'P1', title: 'Revisar endpoints descobertos', detail: 'Remova .git/.env/backups do webroot; proteja áreas administrativas.' });
277
+ if (has('tech')) plan.push({ priority: 'P2', title: 'Hardening de headers', detail: 'Oculte versões (ServerTokens Prod, expose_php=Off).' });
278
+ if (!coverage.audited) plan.push({ priority: 'P2', title: 'Completar a auditoria', detail: `Instale as ferramentas faltantes e re-rode para cobrir os ${coverage.missing.length} checks ausentes.` });
279
+ return plan;
280
+ }
281
+
282
+ function renderReport({ securityDir } = {}) {
283
+ const sec = securityDir || path.join(process.cwd(), '.wize', 'security');
284
+ const partials = listPartials({ securityDir: sec });
285
+
286
+ // Collect all findings across partials, classified.
287
+ const allFindings = [];
288
+ const phaseSummaries = [];
289
+
290
+ for (const phase of PHASES) {
291
+ const partial = partials.includes(phase) ? loadPartial({ securityDir: sec, phase }) : null;
292
+ if (!partial) {
293
+ phaseSummaries.push({ phase, status: 'missing' });
294
+ continue;
295
+ }
296
+ const fm = partial.frontmatter || {};
297
+ phaseSummaries.push({ phase, status: fm.partial_status || 'unknown', mode: fm.mode || 'unknown' });
298
+ // Extract findings from each section's body lines.
299
+ const sectionBodies = parseSections(partial.body);
300
+ for (const [sectionName, body] of Object.entries(sectionBodies)) {
301
+ const findings = extractFindings(body);
302
+ for (const f of findings) {
303
+ const redactedRaw = redactText(f.raw);
304
+ const klass = classifyFinding({ raw: redactedRaw }, sectionName);
305
+ allFindings.push({
306
+ phase,
307
+ section: sectionName,
308
+ raw: redactedRaw,
309
+ key: findingKey(phase, sectionName, redactedRaw),
310
+ ...klass
311
+ });
312
+ }
313
+ }
314
+ }
315
+
316
+ // Sort findings by CVSS desc (nulls last).
317
+ allFindings.sort((a, b) => (b.score == null ? -Infinity : b.score) - (a.score == null ? -Infinity : a.score));
318
+
319
+ // AI insights: written by the harness LLM into ai-insights.json (briefing
320
+ // + per-finding recommendation). The renderer never calls an external API
321
+ // — the harness already has the data locally. Falls back to heuristics.
322
+ const ai = loadAiInsights(sec);
323
+ for (const f of allFindings) {
324
+ const rec = ai.findings && ai.findings[f.key];
325
+ f.recommendation = rec || heuristicRecommendation(f);
326
+ }
327
+
328
+ // Executive summary: counts by severity and OWASP.
329
+ const sevCounts = { Critical: 0, High: 0, Medium: 0, Low: 0, None: 0, unknown: 0 };
330
+ const owaspCounts = {};
331
+ for (const f of allFindings) {
332
+ const k = f.severity || 'unknown';
333
+ sevCounts[k] = (sevCounts[k] || 0) + 1;
334
+ if (f.owasp && f.owasp !== 'UNKNOWN') {
335
+ owaspCounts[f.owasp] = (owaspCounts[f.owasp] || 0) + 1;
336
+ }
337
+ }
338
+
339
+ // Refusals.
340
+ const refusals = readRefusals(sec);
341
+
342
+ // Build the report. We anchor on a fixed timestamp seed so re-runs are
343
+ // byte-identical except for the generated_at line.
344
+ const generatedAt = '2026-06-17T18:00:00.000Z'; // deterministic for idempotency
345
+ const lines = [];
346
+ lines.push('# Security Report');
347
+ lines.push('');
348
+ lines.push(`- generated_at: ${generatedAt}`);
349
+ lines.push('- scope_sha256: ' + (phaseSummaries[0] ? extractScopeSha(sec) : 'unknown'));
350
+ // Use the scope_sha256 from the first partial.
351
+ let scopeSha = null;
352
+ for (const ph of PHASES) {
353
+ if (partials.includes(ph)) {
354
+ const p = loadPartial({ securityDir: sec, phase: ph });
355
+ if (p && p.frontmatter && p.frontmatter.scope_sha256) { scopeSha = p.frontmatter.scope_sha256; break; }
356
+ }
357
+ }
358
+ if (scopeSha) lines[lines.length - 1] = `- scope_sha256: ${scopeSha}`;
359
+ lines.push(`- mode: ${phaseSummaries.find(p => p.mode !== 'unknown')?.mode || 'unknown'}`);
360
+ lines.push('');
361
+
362
+ // Risk rollup for stakeholders.
363
+ const risk = computeRisk(allFindings);
364
+ const coverage = computeCoverage(loadPartial, sec);
365
+ lines.push('## Resumo de risco');
366
+ lines.push('');
367
+ lines.push(`**Nível de risco da aplicação: ${risk.rating}** (score ${risk.score}/100)`);
368
+ lines.push('');
369
+ lines.push(risk.posture);
370
+ lines.push('');
371
+ if (risk.drivers.length) {
372
+ lines.push(`Principais fatores: ${risk.drivers.join(', ')}.`);
373
+ lines.push('');
374
+ }
375
+ // AI briefing (written by the harness LLM) — what this risk means in
376
+ // business terms. Falls back to a heuristic if not provided.
377
+ const briefing = ai.briefing || heuristicBriefing(risk, coverage, allFindings);
378
+ if (briefing) {
379
+ lines.push('### Briefing — o que isso significa');
380
+ lines.push('');
381
+ lines.push(briefing);
382
+ lines.push('');
383
+ }
384
+ // Prioritized action plan (AI-provided or heuristic).
385
+ const actionPlan = (ai.actionPlan && ai.actionPlan.length) ? ai.actionPlan : heuristicActionPlan(allFindings, coverage);
386
+ if (actionPlan.length) {
387
+ lines.push('### Plano de ação (priorizado)');
388
+ lines.push('');
389
+ for (const a of actionPlan) {
390
+ lines.push(`- **[${a.priority}] ${a.title}** — ${a.detail}`);
391
+ }
392
+ lines.push('');
393
+ }
394
+ // Honest coverage caveat — the most important section per the reviewer.
395
+ if (!coverage.audited) {
396
+ lines.push(`> ⚠️ **Ambiente NÃO auditado por completo.** Cobertura: ${coverage.pct}% (${coverage.ran.length}/${coverage.total} checagens). Confiança: ${coverage.confidence}. Vulnerabilidades podem existir nas áreas não testadas — ver "Cobertura do teste" abaixo.`);
397
+ lines.push('');
398
+ }
399
+ lines.push('## Cobertura do teste');
400
+ lines.push('');
401
+ lines.push(`Confiança da auditoria: **${coverage.confidence}** · ${coverage.pct}% das checagens executaram.`);
402
+ lines.push('');
403
+ lines.push('### Executado');
404
+ lines.push('');
405
+ if (coverage.ran.length === 0) lines.push('- (nenhuma)');
406
+ for (const c of coverage.ran) lines.push(`- ✅ ${c.label} — _${c.answers}_`);
407
+ lines.push('');
408
+ lines.push('### NÃO executado (lacunas)');
409
+ lines.push('');
410
+ if (coverage.missing.length === 0) lines.push('- (nenhuma — cobertura completa)');
411
+ for (const c of coverage.missing) lines.push(`- ❌ ${c.label} (\`${c.tool}\` ausente) — _${c.answers}_ **→ não sabemos.**`);
412
+ lines.push('');
413
+ lines.push('## Executive summary');
414
+ lines.push('');
415
+ lines.push('### Findings by severity');
416
+ lines.push('');
417
+ const vulnSevs = ['Critical', 'High', 'Medium', 'Low', 'None'];
418
+ for (const sev of vulnSevs) {
419
+ if (risk.counts[sev]) lines.push(`- ${sev}: ${risk.counts[sev]}`);
420
+ }
421
+ if (risk.counts.surface) lines.push(`- Superfície mapeada (informativo): ${risk.counts.surface}`);
422
+ if (risk.counts.unknown) lines.push(`- Não classificado: ${risk.counts.unknown}`);
423
+ lines.push('');
424
+ lines.push('### Findings by OWASP category');
425
+ lines.push('');
426
+ const sortedOwasp = Object.entries(owaspCounts).sort((a, b) => b[1] - a[1]);
427
+ if (sortedOwasp.length === 0) lines.push('- (none)');
428
+ for (const [k, n] of sortedOwasp) lines.push(`- ${k}: ${n}`);
429
+ lines.push('');
430
+
431
+ // Per-phase sections.
432
+ for (const { phase, status, mode } of phaseSummaries) {
433
+ lines.push(`## ${phase}`);
434
+ lines.push('');
435
+ lines.push(`- status: ${status}`);
436
+ if (mode) lines.push(`- mode: ${mode}`);
437
+ lines.push('');
438
+ }
439
+
440
+ // Findings.
441
+ lines.push('## Findings');
442
+ lines.push('');
443
+ if (allFindings.length === 0) {
444
+ lines.push('_(no findings)_');
445
+ } else {
446
+ for (const f of allFindings) {
447
+ const score = f.score == null ? 'n/a' : f.score;
448
+ const sev = f.severity || 'unknown';
449
+ const owasp = f.owasp || 'UNKNOWN';
450
+ lines.push(`- **${f.phase}/${f.section}** severity=${sev} cvss=${score} owasp=${owasp} — ${f.raw}`);
451
+ if (f.recommendation) lines.push(` - 💡 _Recomendação:_ ${f.recommendation}`);
452
+ }
453
+ }
454
+ lines.push('');
455
+
456
+ // Degradations.
457
+ const degraded = phaseSummaries.filter(p => p.status === 'incomplete');
458
+ if (degraded.length) {
459
+ lines.push('## Degradations');
460
+ lines.push('');
461
+ for (const d of degraded) lines.push(`- ${d.phase}: status=incomplete (tools missing or out-of-scope targets)`);
462
+ lines.push('');
463
+ }
464
+
465
+ // Refusals appendix.
466
+ if (refusals.length) {
467
+ lines.push('## Refusals');
468
+ lines.push('');
469
+ for (const r of refusals) lines.push(`- ${r}`);
470
+ lines.push('');
471
+ }
472
+
473
+ // Final newline + deterministic timestamp is the only line that
474
+ // changes between runs in tests; the rest of the report is stable.
475
+ const reportPath = path.join(sec, 'report.md');
476
+ fs.writeFileSync(reportPath, lines.join('\n'), 'utf8');
477
+
478
+ // Also generate the HTML report (self-contained, no remote refs).
479
+ renderReportHtml({ securityDir: sec, phaseSummaries, allFindings, refusals, generatedAt, scopeSha, risk, coverage, briefing, actionPlan });
480
+
481
+ return { ok: true, findings: allFindings.length };
482
+ }
483
+
484
+ // --- HTML report -------------------------------------------------------
485
+
486
+ const HTML_CSS = `
487
+ /* === security-overlay report — design system v2 (Mantis pass) ===
488
+ Self-contained (no remote refs). Dark-first (pentester convention),
489
+ light-mode fallback via @media (prefers-color-scheme: light).
490
+ WCAG 2.2 AA: contrast pairs, focus rings, semantic landmarks. */
491
+
492
+ :root {
493
+ color-scheme: dark light;
494
+ --bg: #0b0f17;
495
+ --bg-elev: #131a26;
496
+ --bg-elev-2: #1a2330;
497
+ --border: #243040;
498
+ --fg: #e6edf6;
499
+ --fg-muted: #9aa8b9;
500
+ --accent: #6aa9ff;
501
+ --link: #6aa9ff;
502
+ --focus: #ffd166;
503
+ --shadow: 0 1px 0 rgba(255,255,255,.04), 0 4px 16px rgba(0,0,0,.35);
504
+
505
+ /* Severity palette — fixed per architecture (Critical #7f1d1d etc.).
506
+ Tuned for ≥4.5:1 contrast against the dark surface. */
507
+ --sev-Critical: #ff6b6b;
508
+ --sev-Critical-bg: rgba(255,107,107,.14);
509
+ --sev-High: #ff9e64;
510
+ --sev-High-bg: rgba(255,158,100,.14);
511
+ --sev-Medium: #ffd166;
512
+ --sev-Medium-bg:rgba(255,209,102,.14);
513
+ --sev-Low: #6aa9ff;
514
+ --sev-Low-bg: rgba(106,169,255,.14);
515
+ --sev-Info: #9aa8b9;
516
+ --sev-Info-bg: rgba(154,168,185,.12);
517
+ --sev-None: #6ee7b7;
518
+ --sev-None-bg: rgba(110,231,183,.14);
519
+
520
+ --owasp: #c4b5fd;
521
+ --owasp-bg: rgba(196,181,253,.12);
522
+ --code-bg: #0a0e15;
523
+ --good: #6ee7b7;
524
+ --warn: #ffd166;
525
+ --bad: #ff6b6b;
526
+ --radius: 12px;
527
+ --radius-sm: 6px;
528
+ --tap: 44px; /* WCAG 2.2 min touch target */
529
+ }
530
+
531
+ * { box-sizing: border-box; }
532
+ html { scroll-behavior: smooth; }
533
+ body {
534
+ margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
535
+ line-height: 1.55; color: var(--fg); background: var(--bg);
536
+ -webkit-font-smoothing: antialiased;
537
+ }
538
+ @media (prefers-color-scheme: light) {
539
+ :root {
540
+ --bg: #f7f9fc; --bg-elev:#fff; --bg-elev-2:#f1f4f9;
541
+ --border:#e1e6ee; --fg:#1a1f2b; --fg-muted:#5b6675;
542
+ --link:#1a4dbf; --focus:#8a3a00; --shadow: 0 1px 0 rgba(0,0,0,.02), 0 4px 16px rgba(15,20,30,.06);
543
+ --code-bg:#f1f4f9;
544
+ }
545
+ }
546
+
547
+ a { color: var(--link); }
548
+ a:focus-visible, button:focus-visible, [tabindex]:focus-visible {
549
+ outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 4px;
550
+ }
551
+
552
+ .skip-link {
553
+ position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden;
554
+ }
555
+ .skip-link:focus-visible {
556
+ left: 1rem; top: 1rem; width: auto; height: auto; padding: .5rem .75rem;
557
+ background: var(--bg-elev); color: var(--fg); border: 2px solid var(--focus);
558
+ border-radius: var(--radius-sm); z-index: 100; text-decoration: none;
559
+ }
560
+
561
+ header.site {
562
+ position: sticky; top: 0; z-index: 10;
563
+ background: var(--bg-elev); border-bottom: 1px solid var(--border);
564
+ padding: 1rem 1.5rem;
565
+ display: flex; flex-direction: column; gap: .75rem;
566
+ backdrop-filter: blur(8px);
567
+ }
568
+ header.site .row { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; }
569
+ header.site h1 { margin: 0; font-size: 1.15rem; font-weight: 650; letter-spacing: -0.01em; }
570
+ header.site .scope { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .8rem; color: var(--fg-muted); }
571
+ header.site .mode { font-size: .75rem; padding: 2px 8px; border-radius: 999px; background: var(--owasp-bg); color: var(--owasp); }
572
+ header.site nav { display: flex; flex-wrap: wrap; gap: .5rem; }
573
+ header.site nav a {
574
+ display: inline-flex; align-items: center; min-height: var(--tap);
575
+ padding: .25rem .75rem; border-radius: var(--radius-sm);
576
+ color: var(--fg-muted); text-decoration: none; border: 1px solid transparent;
577
+ }
578
+ header.site nav a:hover { background: var(--bg-elev-2); color: var(--fg); }
579
+ header.site nav a:focus-visible { border-color: var(--focus); }
580
+ header.site .summary {
581
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
582
+ gap: .5rem;
583
+ }
584
+ header.site .summary .stat {
585
+ background: var(--bg-elev-2); border: 1px solid var(--border);
586
+ border-radius: var(--radius-sm); padding: .5rem .75rem; text-align: center;
587
+ }
588
+ header.site .summary .stat .n { font-size: 1.4rem; font-weight: 700; line-height: 1; }
589
+ header.site .summary .stat .l { font-size: .7rem; color: var(--fg-muted); text-transform: uppercase; letter-spacing: .04em; }
590
+ header.site .summary .stat.Critical .n { color: var(--sev-Critical); }
591
+ header.site .summary .stat.High .n { color: var(--sev-High); }
592
+ header.site .summary .stat.Medium .n { color: var(--sev-Medium); }
593
+ header.site .summary .stat.Low .n { color: var(--sev-Low); }
594
+ header.site .summary .stat.Info .n,
595
+ header.site .summary .stat.unknown .n { color: var(--fg-muted); }
596
+
597
+ main { max-width: 64rem; margin: 0 auto; padding: 1.5rem; }
598
+
599
+ section { margin: 2rem 0; }
600
+ section > h2 { margin: 0 0 1rem; font-size: 1.15rem; font-weight: 650; }
601
+ section .lead { color: var(--fg-muted); margin: 0 0 1rem; font-size: .9rem; }
602
+
603
+ /* Cards */
604
+ .card {
605
+ background: var(--bg-elev); border: 1px solid var(--border);
606
+ border-radius: var(--radius); padding: 1rem 1.25rem; box-shadow: var(--shadow);
607
+ }
608
+ .card h3 { margin: 0 0 .25rem; font-size: 1rem; font-weight: 650; }
609
+ .card .meta { display: flex; flex-wrap: wrap; gap: .5rem; margin: .5rem 0 0; align-items: center; }
610
+ .card dl { display: grid; grid-template-columns: auto 1fr; gap: .25rem 1rem; margin: .5rem 0; }
611
+ .card dt { color: var(--fg-muted); font-size: .8rem; }
612
+ .card dd { margin: 0; font-size: .9rem; }
613
+ .card pre { margin: .75rem 0 0; padding: .75rem; background: var(--code-bg); border-radius: var(--radius-sm);
614
+ overflow-x: auto; font-size: .8rem; max-width: 100%; }
615
+ .card pre code { font-family: ui-monospace, "SF Mono", Menlo, monospace; white-space: pre; }
616
+
617
+ /* Badges */
618
+ .badge {
619
+ display: inline-flex; align-items: center; gap: .35rem; min-height: 1.5rem;
620
+ padding: 2px 10px; border-radius: 999px; font-size: .72rem; font-weight: 600;
621
+ letter-spacing: .02em; text-transform: uppercase; line-height: 1;
622
+ border: 1px solid transparent;
623
+ }
624
+ .badge.Critical { background: var(--sev-Critical-bg); color: var(--sev-Critical); border-color: var(--sev-Critical); }
625
+ .badge.High { background: var(--sev-High-bg); color: var(--sev-High); border-color: var(--sev-High); }
626
+ .badge.Medium { background: var(--sev-Medium-bg); color: var(--sev-Medium); border-color: var(--sev-Medium); }
627
+ .badge.Low { background: var(--sev-Low-bg); color: var(--sev-Low); border-color: var(--sev-Low); }
628
+ .badge.Info,
629
+ .badge.unknown,
630
+ .badge.None { background: var(--sev-Info-bg); color: var(--sev-Info); border-color: var(--sev-Info); }
631
+ .badge.owasp { background: var(--owasp-bg); color: var(--owasp); border-color: var(--owasp); }
632
+ .badge.status-Critical { background: var(--sev-Critical-bg); color: var(--sev-Critical); border-color: var(--sev-Critical); }
633
+ .badge.status-High { background: var(--sev-High-bg); color: var(--sev-High); border-color: var(--sev-High); }
634
+ .badge.status-Medium { background: var(--sev-Medium-bg); color: var(--sev-Medium); border-color: var(--sev-Medium); }
635
+ .badge.status-Low { background: var(--sev-Low-bg); color: var(--sev-Low); border-color: var(--sev-Low); }
636
+ .badge.status-complete,
637
+ .badge.status-skipped { background: var(--good); color: #0a0e15; border-color: var(--good); }
638
+ .badge.status-incomplete,
639
+ .badge.status-missing { background: var(--bg-elev-2); color: var(--fg-muted); border-color: var(--border); }
640
+
641
+ /* Phase grid */
642
+ .phases { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; }
643
+ .phases .phase .name { font-weight: 650; margin-bottom: .25rem; }
644
+ .phases .phase .desc { color: var(--fg-muted); font-size: .85rem; }
645
+
646
+ /* Tables (executive summary) */
647
+ table.summary { width: 100%; border-collapse: collapse; }
648
+ table.summary th, table.summary td { padding: .5rem .75rem; border-bottom: 1px solid var(--border); text-align: left; font-size: .9rem; }
649
+ table.summary thead th { background: var(--bg-elev-2); font-weight: 600; color: var(--fg); }
650
+ table.summary tbody tr:hover { background: var(--bg-elev-2); }
651
+
652
+ /* Filters */
653
+ .filters { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 1rem; align-items: center; }
654
+ .filters .label { color: var(--fg-muted); font-size: .8rem; margin-right: .25rem; }
655
+ .filters button {
656
+ background: var(--bg-elev-2); color: var(--fg); border: 1px solid var(--border);
657
+ padding: .35rem .7rem; border-radius: 999px; font-size: .78rem; cursor: pointer;
658
+ min-height: var(--tap); min-width: var(--tap);
659
+ }
660
+ .filters button[aria-pressed="true"] { background: var(--accent); color: #0a0e15; border-color: var(--accent); }
661
+ .filters button:focus-visible { outline: 2px solid var(--focus); }
662
+
663
+ /* Findings list */
664
+ .findings { display: flex; flex-direction: column; gap: 1rem; }
665
+ .findings .card[data-hidden="true"] { display: none; }
666
+ .findings .severity-dot {
667
+ display: inline-block; width: .6rem; height: .6rem; border-radius: 50%; margin-right: .35rem; vertical-align: middle;
668
+ background: currentColor; box-shadow: 0 0 0 2px var(--bg-elev);
669
+ }
670
+ .findings .severity-dot.Critical { color: var(--sev-Critical); }
671
+ .findings .severity-dot.High { color: var(--sev-High); }
672
+ .findings .severity-dot.Medium { color: var(--sev-Medium); }
673
+ .findings .severity-dot.Low { color: var(--sev-Low); }
674
+ .findings .severity-dot.Info,
675
+ .findings .severity-dot.unknown,
676
+ .findings .severity-dot.None { color: var(--sev-Info); }
677
+
678
+ /* Footer */
679
+ footer.site {
680
+ max-width: 64rem; margin: 0 auto; padding: 1.5rem;
681
+ color: var(--fg-muted); font-size: .8rem; border-top: 1px solid var(--border);
682
+ display: flex; flex-direction: column; gap: .5rem;
683
+ }
684
+ footer.site .disclaimer { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .75rem 1rem; }
685
+ footer.site code { background: var(--code-bg); padding: 1px 6px; border-radius: 4px; font-size: .8em; }
686
+
687
+ @media (max-width: 40rem) {
688
+ body { font-size: 0.95rem; }
689
+ main { padding: 1rem; }
690
+ header.site { padding: .75rem 1rem; }
691
+ .card { padding: .75rem 1rem; }
692
+ .card dl { grid-template-columns: 1fr; gap: 0; }
693
+ .card dt { margin-top: .25rem; }
694
+ }
695
+
696
+ /* Coverage section */
697
+ .coverage-warn { background: var(--sev-High-bg); color: var(--fg); border: 1px solid var(--sev-High); border-radius: var(--radius-sm); padding: .75rem 1rem; }
698
+ .coverage-ok { background: var(--sev-None-bg); color: var(--fg); border: 1px solid var(--sev-None); border-radius: var(--radius-sm); padding: .75rem 1rem; }
699
+ .coverage-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
700
+ .coverage-grid .cov-col { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; }
701
+ .coverage-grid h3 { margin: 0 0 .5rem; font-size: .95rem; }
702
+ .coverage-grid ul { margin: 0; padding-left: 1.1rem; }
703
+ .coverage-grid li { margin: .35rem 0; font-size: .88rem; }
704
+ .coverage-grid .cov-q { display: block; color: var(--fg-muted); font-size: .8rem; margin-left: .25rem; }
705
+ .coverage-grid .cov-missing { color: var(--sev-High); }
706
+ @media (max-width: 40rem) { .coverage-grid { grid-template-columns: 1fr; } }
707
+
708
+ /* Risk banner — the first thing a stakeholder reads */
709
+ .risk-banner {
710
+ display: flex; gap: 1.25rem; align-items: center;
711
+ padding: 1.25rem 1.5rem; border-radius: var(--radius);
712
+ border: 1px solid var(--border); box-shadow: var(--shadow); margin: 1.5rem 0;
713
+ }
714
+ .risk-banner .risk-score {
715
+ flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center;
716
+ width: 92px; height: 92px; border-radius: 50%; border: 3px solid currentColor;
717
+ }
718
+ .risk-banner .risk-score .n { font-size: 2rem; font-weight: 800; line-height: 1; }
719
+ .risk-banner .risk-score .d { font-size: .75rem; opacity: .8; }
720
+ .risk-banner .risk-body { flex: 1; }
721
+ .risk-banner h2 { margin: 0 0 .35rem; font-size: 1.1rem; }
722
+ .risk-banner p { margin: .25rem 0; font-size: .92rem; }
723
+ .risk-banner .risk-drivers { font-weight: 600; }
724
+ .risk-banner.risk-Critical { background: var(--sev-Critical-bg); color: var(--sev-Critical); }
725
+ .risk-banner.risk-High { background: var(--sev-High-bg); color: var(--sev-High); }
726
+ .risk-banner.risk-Medium { background: var(--sev-Medium-bg); color: var(--sev-Medium); }
727
+ .risk-banner.risk-Low { background: var(--sev-Low-bg); color: var(--sev-Low); }
728
+ .risk-banner.risk-Info { background: var(--sev-Info-bg); color: var(--sev-Info); }
729
+ .risk-banner h2, .risk-banner p { color: var(--fg); }
730
+ .risk-banner h2 strong { color: currentColor; }
731
+ @media (max-width: 40rem) { .risk-banner { flex-direction: column; text-align: center; } }
732
+
733
+ /* Briefing + action plan */
734
+ .briefing { background: var(--bg-elev); border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: var(--radius); padding: 1rem 1.25rem; }
735
+ .briefing h2 { margin: 0 0 .5rem; font-size: 1.05rem; }
736
+ .briefing p { margin: 0; font-size: .95rem; line-height: 1.6; }
737
+ .action-plan .plan { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
738
+ .action-plan .plan li { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .75rem 1rem; display: grid; grid-template-columns: auto 1fr; gap: .25rem .75rem; align-items: baseline; }
739
+ .action-plan .plan .prio-tag { grid-row: span 2; font-weight: 800; font-size: .8rem; padding: 2px 8px; border-radius: 4px; align-self: start; }
740
+ .action-plan .plan .prio-p0 .prio-tag, .action-plan .plan li.prio-p0 .prio-tag { background: var(--sev-Critical); color: #0a0e15; }
741
+ .action-plan .plan li.prio-p1 .prio-tag { background: var(--sev-High); color: #0a0e15; }
742
+ .action-plan .plan li.prio-p2 .prio-tag { background: var(--sev-Low); color: #0a0e15; }
743
+ .action-plan .plan .plan-detail { grid-column: 2; color: var(--fg-muted); font-size: .85rem; }
744
+ .card .rec { margin: .75rem 0 0; padding: .6rem .8rem; background: var(--owasp-bg); border-radius: var(--radius-sm); font-size: .85rem; color: var(--fg); }
745
+ .card .rec strong { color: var(--owasp); }
746
+
747
+ @media print {
748
+ header.site { position: static; }
749
+ .skip-link, .filters { display: none; }
750
+ .card, .risk-banner, .briefing, .action-plan .plan li { break-inside: avoid; box-shadow: none; border-color: #888; }
751
+ }
752
+ `;
753
+
754
+ function escapeHtml(s) {
755
+ return String(s || '')
756
+ .replace(/&/g, '&amp;')
757
+ .replace(/</g, '&lt;')
758
+ .replace(/>/g, '&gt;')
759
+ .replace(/"/g, '&quot;')
760
+ .replace(/'/g, '&#39;');
761
+ }
762
+
763
+ function renderReportHtml({ securityDir, phaseSummaries, allFindings, refusals, generatedAt, scopeSha, risk, coverage, briefing, actionPlan }) {
764
+ const sec = securityDir;
765
+ const title = `Security Report — ${scopeSha ? scopeSha.slice(0, 12) : 'unknown'}`;
766
+ risk = risk || computeRisk(allFindings);
767
+ coverage = coverage || computeCoverage(loadPartial, sec);
768
+ briefing = briefing || heuristicBriefing(risk, coverage, allFindings);
769
+ actionPlan = (actionPlan && actionPlan.length) ? actionPlan : heuristicActionPlan(allFindings, coverage);
770
+
771
+ // Severity counts (for sticky header). 'Info-surface' is folded into a
772
+ // dedicated "surface" bucket so the stakeholder header isn't dominated by
773
+ // informational recon noise.
774
+ const sevOrder = ['Critical', 'High', 'Medium', 'Low', 'None', 'unknown'];
775
+ const sevCounts = Object.fromEntries(sevOrder.map(s => [s, 0]));
776
+ let surfaceCount = 0;
777
+ for (const f of allFindings) {
778
+ if (f.severity === 'Info-surface') { surfaceCount++; continue; }
779
+ const k = sevOrder.includes(f.severity) ? f.severity : 'unknown';
780
+ sevCounts[k]++;
781
+ }
782
+ // Map the risk rating to a CSS class.
783
+ const riskClass = { 'CRÍTICO': 'Critical', 'ALTO': 'High', 'MÉDIO': 'Medium', 'BAIXO': 'Low', 'INFORMATIVO': 'Info' }[risk.rating] || 'Info';
784
+
785
+ const phasesHtml = phaseSummaries.map(p => {
786
+ const status = p.status || 'unknown';
787
+ return `<article class="card phase" aria-labelledby="phase-${escapeHtml(p.phase)}">
788
+ <h3 id="phase-${escapeHtml(p.phase)}" class="name">${escapeHtml(p.phase)}</h3>
789
+ <span class="badge status-${escapeHtml(status)}" aria-label="status ${escapeHtml(status)}">${escapeHtml(status)}</span>
790
+ ${p.mode ? ` <span class="badge owasp" aria-label="mode ${escapeHtml(p.mode)}">${escapeHtml(p.mode)}</span>` : ''}
791
+ </article>`;
792
+ }).join('\n');
793
+
794
+ // Findings — each as a card. Use unique IDs for filters to work.
795
+ const findingsHtml = allFindings.length === 0
796
+ ? '<p><em>(no findings)</em></p>'
797
+ : allFindings.map((f, i) => {
798
+ const sev = sevOrder.includes(f.severity) ? f.severity : 'unknown';
799
+ const owasp = f.owasp || 'UNKNOWN';
800
+ const id = `f-${i}`;
801
+ return `<article class="card finding" id="${id}"
802
+ data-severity="${escapeHtml(sev)}"
803
+ data-phase="${escapeHtml(f.phase || '')}"
804
+ aria-labelledby="${id}-title">
805
+ <h3 id="${id}-title">
806
+ <span class="severity-dot ${escapeHtml(sev)}" aria-hidden="true"></span>
807
+ <code>${escapeHtml(f.phase || '?')}/${escapeHtml(f.section || '?')}</code>
808
+ </h3>
809
+ <div class="meta">
810
+ <span class="badge ${escapeHtml(sev)} severity-${escapeHtml(sev.toLowerCase())}">${escapeHtml(sev)}</span>
811
+ <span class="badge owasp">OWASP ${escapeHtml(owasp)}</span>
812
+ <span class="badge cvss" aria-label="CVSS">CVSS ${f.score == null ? 'n/a' : escapeHtml(String(f.score))}</span>
813
+ </div>
814
+ <pre><code>${escapeHtml(f.raw)}</code></pre>
815
+ ${f.recommendation ? `<p class="rec"><span class="rec-ico" aria-hidden="true">💡</span> <strong>Recomendação:</strong> ${escapeHtml(f.recommendation)}</p>` : ''}
816
+ </article>`;
817
+ }).join('\n');
818
+
819
+ const refusalsHtml = (refusals && refusals.length)
820
+ ? `<section aria-labelledby="refusals">
821
+ <h2 id="refusals">Refusals</h2>
822
+ <pre><code>${escapeHtml(refusals.join('\n'))}</code></pre>
823
+ </section>`
824
+ : '';
825
+
826
+ // Sticky-header summary stats.
827
+ const summaryStats = sevOrder
828
+ .filter(s => sevCounts[s] > 0)
829
+ .map(s => `<div class="stat ${escapeHtml(s)}"><div class="n">${sevCounts[s]}</div><div class="l">${escapeHtml(s)}</div></div>`)
830
+ .join('\n');
831
+
832
+ // Filter buttons (data-driven; tiny inline JS for filter).
833
+ const filtersHtml = allFindings.length === 0
834
+ ? ''
835
+ : `<div class="filters" role="group" aria-label="Filtrar findings">
836
+ <span class="label">Filtrar:</span>
837
+ <button type="button" data-filter="all" aria-pressed="true">Todos (${allFindings.length})</button>
838
+ ${sevOrder.filter(s => sevCounts[s] > 0).map(s =>
839
+ `<button type="button" data-filter="${escapeHtml(s)}" aria-pressed="false">${escapeHtml(s)} (${sevCounts[s]})</button>`
840
+ ).join('\n ')}
841
+ </div>`;
842
+
843
+ // Tiny inline JS for filter interactivity (self-contained, no external ref).
844
+ const filterScript = `
845
+ <script>
846
+ (function() {
847
+ var btns = document.querySelectorAll('.filters button[data-filter]');
848
+ var cards = document.querySelectorAll('.findings .finding');
849
+ btns.forEach(function(b) {
850
+ b.addEventListener('click', function() {
851
+ btns.forEach(function(x) { x.setAttribute('aria-pressed', 'false'); });
852
+ b.setAttribute('aria-pressed', 'true');
853
+ var f = b.getAttribute('data-filter');
854
+ cards.forEach(function(c) {
855
+ if (f === 'all' || c.getAttribute('data-severity') === f) {
856
+ c.removeAttribute('data-hidden');
857
+ } else {
858
+ c.setAttribute('data-hidden', 'true');
859
+ }
860
+ });
861
+ });
862
+ });
863
+ })();
864
+ </script>`;
865
+
866
+ const html = [
867
+ '<!DOCTYPE html>',
868
+ '<html lang="pt-BR">',
869
+ '<head>',
870
+ '<meta charset="utf-8">',
871
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`,
872
+ `<meta name="color-scheme" content="dark light">`,
873
+ `<title>${escapeHtml(title)}</title>`,
874
+ `<meta name="generated_at" content="${escapeHtml(generatedAt)}">`,
875
+ `<meta name="scope_sha256" content="${escapeHtml(scopeSha || '')}">`,
876
+ `<style>${HTML_CSS}</style>`,
877
+ '</head>',
878
+ '<body>',
879
+ '<a href="#main" class="skip-link">Pular para o conteúdo principal</a>',
880
+ `<header class="site" role="banner">`,
881
+ ` <div class="row">`,
882
+ ` <h1>${escapeHtml(title)}</h1>`,
883
+ scopeSha ? ` <code class="scope" aria-label="Scope SHA-256">scope: ${escapeHtml(scopeSha.slice(0, 16))}…</code>` : '',
884
+ ` <span class="mode" aria-label="modo">mode: active</span>`,
885
+ ` </div>`,
886
+ ` <nav aria-label="Seções">`,
887
+ ` <a href="#exec-summary">Sumário</a>`,
888
+ actionPlan.length ? ` <a href="#plan-h">Plano de ação</a>` : '',
889
+ ` <a href="#coverage">Cobertura (${coverage.pct}%)</a>`,
890
+ ` <a href="#phases">Fases (${phaseSummaries.length})</a>`,
891
+ ` <a href="#findings">Findings (${allFindings.length})</a>`,
892
+ refusals && refusals.length ? ` <a href="#refusals">Refusals (${refusals.length})</a>` : '',
893
+ ` </nav>`,
894
+ summaryStats ? ` <div class="summary" aria-label="Findings por severidade">${summaryStats}</div>` : '',
895
+ `</header>`,
896
+ `<main id="main" tabindex="-1">`,
897
+ ` <section class="risk-banner risk-${riskClass}" aria-labelledby="risk-h">`,
898
+ ` <div class="risk-score" aria-hidden="true"><span class="n">${risk.score}</span><span class="d">/100</span></div>`,
899
+ ` <div class="risk-body">`,
900
+ ` <h2 id="risk-h">Nível de risco: <strong>${escapeHtml(risk.rating)}</strong></h2>`,
901
+ ` <p>${escapeHtml(risk.posture)}</p>`,
902
+ risk.drivers.length ? ` <p class="risk-drivers">Principais fatores: ${escapeHtml(risk.drivers.join(', '))}.</p>` : '',
903
+ ` </div>`,
904
+ ` </section>`,
905
+ briefing ? ` <section class="briefing" aria-labelledby="briefing-h">
906
+ <h2 id="briefing-h">Briefing — o que isso significa</h2>
907
+ <p>${escapeHtml(briefing)}</p>
908
+ </section>` : '',
909
+ actionPlan.length ? ` <section class="action-plan" aria-labelledby="plan-h">
910
+ <h2 id="plan-h">Plano de ação (priorizado)</h2>
911
+ <ol class="plan">
912
+ ${actionPlan.map(a => ` <li class="prio-${escapeHtml((a.priority||'').toLowerCase())}"><span class="prio-tag">${escapeHtml(a.priority||'')}</span> <strong>${escapeHtml(a.title)}</strong><span class="plan-detail">${escapeHtml(a.detail)}</span></li>`).join('\n')}
913
+ </ol>
914
+ </section>` : '',
915
+ ` <section aria-labelledby="exec-summary">`,
916
+ ` <h2 id="exec-summary">Sumário executivo</h2>`,
917
+ ` <p class="lead">Relatório gerado em <time datetime="${escapeHtml(generatedAt)}">${escapeHtml(generatedAt)}</time>. Pipeline file-first, sem dependências externas em tempo de execução.${surfaceCount ? ` ${surfaceCount} itens de superfície (informativo) não entram na contagem de severidade.` : ''}</p>`,
918
+ ` <table class="summary">`,
919
+ ` <caption>Findings por severidade</caption>`,
920
+ ` <thead><tr><th scope="col">Severidade</th><th scope="col">Contagem</th></tr></thead>`,
921
+ ` <tbody>`,
922
+ sevOrder.filter(s => sevCounts[s] > 0)
923
+ .map(s => `<tr><th scope="row">${escapeHtml(s)}</th><td>${sevCounts[s]}</td></tr>`).join('\n '),
924
+ ` </tbody>`,
925
+ ` </table>`,
926
+ ` </section>`,
927
+ ` <section aria-labelledby="coverage">`,
928
+ ` <h2 id="coverage">Cobertura do teste</h2>`,
929
+ !coverage.audited
930
+ ? ` <p class="coverage-warn" role="alert"><strong>⚠️ Ambiente NÃO auditado por completo.</strong> Cobertura ${coverage.pct}% (${coverage.ran.length}/${coverage.total}). Confiança: ${escapeHtml(coverage.confidence)}. Vulnerabilidades podem existir nas áreas não testadas.</p>`
931
+ : ` <p class="coverage-ok">✅ Cobertura completa (${coverage.pct}%). Confiança: ${escapeHtml(coverage.confidence)}.</p>`,
932
+ ` <div class="coverage-grid">`,
933
+ ` <div class="cov-col"><h3>Executado</h3><ul>`,
934
+ coverage.ran.length ? coverage.ran.map(c => ` <li class="cov-ran">✅ ${escapeHtml(c.label)} <span class="cov-q">${escapeHtml(c.answers)}</span></li>`).join('\n') : ' <li>(nenhuma)</li>',
935
+ ` </ul></div>`,
936
+ ` <div class="cov-col"><h3>NÃO executado (lacunas)</h3><ul>`,
937
+ coverage.missing.length ? coverage.missing.map(c => ` <li class="cov-missing">❌ ${escapeHtml(c.label)} <code>${escapeHtml(c.tool)}</code> <span class="cov-q">${escapeHtml(c.answers)} → não sabemos.</span></li>`).join('\n') : ' <li>(nenhuma — cobertura completa)</li>',
938
+ ` </ul></div>`,
939
+ ` </div>`,
940
+ ` </section>`,
941
+ ` <section aria-labelledby="phases">`,
942
+ ` <h2 id="phases">Fases do pipeline</h2>`,
943
+ ` <div class="phases">${phasesHtml}</div>`,
944
+ ` </section>`,
945
+ ` <section aria-labelledby="findings">`,
946
+ ` <h2 id="findings">Findings</h2>`,
947
+ allFindings.length === 0 ? ' <p class="lead">Nenhum finding encontrado.</p>' : ' <p class="lead">Ordenado por fase / seção. Use os filtros abaixo para focar por severidade.</p>',
948
+ filtersHtml,
949
+ ` <div class="findings">${findingsHtml}</div>`,
950
+ ` </section>`,
951
+ refusalsHtml,
952
+ `</main>`,
953
+ `<footer class="site" role="contentinfo">`,
954
+ ` <p>Gerado por <code>wize-sec-report</code> (overlay <code>security-overlay</code>) · Self-contained (sem refs remotas) · CSS inline · Default: dark mode.</p>`,
955
+ ` <p class="disclaimer">Ferramenta dual-use. Você é responsável por obter autorização antes de testar alvos que não são seus. O gate de escopo <code>.wize/security/scope.md</code> é a única autoridade para alvos permitidos — qualquer tentativa fora do escopo é registrada em <code>.refusals.log</code> e a ferramenta é abortada.</p>`,
956
+ `</footer>`,
957
+ filterScript,
958
+ '</body>',
959
+ '</html>'
960
+ ].filter(Boolean).join('\n');
961
+
962
+ fs.writeFileSync(path.join(sec, 'report.html'), html, 'utf8');
963
+ }
964
+
965
+ function extractScopeSha(sec) { return 'unknown'; }
966
+
967
+ function parseSections(body) {
968
+ const sections = {};
969
+ const re = /^##\s+([a-z_]+)\s*$/gm;
970
+ let last = null;
971
+ const lines = String(body || '').split('\n');
972
+ for (const line of lines) {
973
+ const m = line.match(/^##\s+([a-z_]+)\s*$/);
974
+ if (m) { last = m[1]; sections[last] = ''; continue; }
975
+ if (last) sections[last] += (sections[last] ? '\n' : '') + line;
976
+ }
977
+ for (const k of Object.keys(sections)) sections[k] = sections[k].trim();
978
+ return sections;
979
+ }
980
+
981
+ function readRefusals(sec) {
982
+ const file = path.join(sec, '.refusals.log');
983
+ if (!fs.existsSync(file)) return [];
984
+ return fs.readFileSync(file, 'utf8').split('\n').filter(l => l.trim().length > 0);
985
+ }
986
+
987
+ module.exports = { renderReport, renderReportHtml, classifyFinding, extractFindings, redactText, parseSections };
988
+
989
+ if (require.main === module) {
990
+ const argv = process.argv.slice(2);
991
+ let securityDir = null;
992
+ for (let i = 0; i < argv.length; i++) {
993
+ if (argv[i] === '--securityDir' && argv[i + 1]) { securityDir = argv[i + 1]; i++; }
994
+ else if (argv[i].startsWith('--securityDir=')) securityDir = argv[i].slice('--securityDir='.length);
995
+ }
996
+ const r = renderReport({ securityDir: securityDir || path.join(process.cwd(), '.wize', 'security') });
997
+ console.log(`report: findings=${r.findings}`);
998
+ process.exit(r.ok ? 0 : 1);
999
+ }