praxis-sec 1.0.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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/ai-defense/cost-protection.md +292 -0
  4. package/ai-defense/llm-security-checklist.md +324 -0
  5. package/ai-defense/prompt-injection-patterns.js +283 -0
  6. package/ai-defense/system-prompt-armor.md +327 -0
  7. package/checklists/launch-day.md +168 -0
  8. package/cli/agents/abom-generator.js +225 -0
  9. package/cli/agents/agent-attestation-agent.js +318 -0
  10. package/cli/agents/agent-config-scanner.js +787 -0
  11. package/cli/agents/agent-telemetry-agent.js +415 -0
  12. package/cli/agents/agentic-security-agent.js +296 -0
  13. package/cli/agents/agentic-supply-chain-agent.js +463 -0
  14. package/cli/agents/ai-infra-inventory-agent.js +449 -0
  15. package/cli/agents/api-fuzzer.js +345 -0
  16. package/cli/agents/auth-bypass-agent.js +348 -0
  17. package/cli/agents/base-agent.js +280 -0
  18. package/cli/agents/cicd-scanner.js +300 -0
  19. package/cli/agents/config-auditor.js +757 -0
  20. package/cli/agents/deep-analyzer.js +776 -0
  21. package/cli/agents/endpoint-agent-abuse-agent.js +404 -0
  22. package/cli/agents/exception-handler-agent.js +187 -0
  23. package/cli/agents/git-history-scanner.js +169 -0
  24. package/cli/agents/governance-audits.js +138 -0
  25. package/cli/agents/hermes-security-agent.js +536 -0
  26. package/cli/agents/html-reporter.js +1125 -0
  27. package/cli/agents/index.js +147 -0
  28. package/cli/agents/injection-tester.js +502 -0
  29. package/cli/agents/legal-risk-agent.js +328 -0
  30. package/cli/agents/llm-redteam.js +199 -0
  31. package/cli/agents/managed-agent-scanner.js +333 -0
  32. package/cli/agents/mcp-security-agent.js +588 -0
  33. package/cli/agents/memory-poisoning-agent.js +305 -0
  34. package/cli/agents/mobile-scanner.js +231 -0
  35. package/cli/agents/model-file-scanner.js +259 -0
  36. package/cli/agents/orchestrator.js +355 -0
  37. package/cli/agents/pii-compliance-agent.js +301 -0
  38. package/cli/agents/policy-engine.js +229 -0
  39. package/cli/agents/prompt-injection-prober.js +224 -0
  40. package/cli/agents/rag-security-agent.js +204 -0
  41. package/cli/agents/recon-agent.js +207 -0
  42. package/cli/agents/sbom-generator.js +265 -0
  43. package/cli/agents/scoring-engine.js +273 -0
  44. package/cli/agents/ssrf-prober.js +130 -0
  45. package/cli/agents/stateful-watcher.js +238 -0
  46. package/cli/agents/supabase-rls-agent.js +154 -0
  47. package/cli/agents/supply-chain-agent.js +857 -0
  48. package/cli/agents/swarm-orchestrator.js +200 -0
  49. package/cli/agents/verifier-agent.js +303 -0
  50. package/cli/agents/vibe-coding-agent.js +250 -0
  51. package/cli/bin/praxis.js +866 -0
  52. package/cli/commands/abom.js +73 -0
  53. package/cli/commands/agent-fix.js +1245 -0
  54. package/cli/commands/audit.js +1180 -0
  55. package/cli/commands/autofix.js +383 -0
  56. package/cli/commands/baseline.js +193 -0
  57. package/cli/commands/benchmark.js +327 -0
  58. package/cli/commands/checklist.js +223 -0
  59. package/cli/commands/ci.js +403 -0
  60. package/cli/commands/deps.js +516 -0
  61. package/cli/commands/diff.js +200 -0
  62. package/cli/commands/doctor.js +195 -0
  63. package/cli/commands/env-audit.js +349 -0
  64. package/cli/commands/fix.js +218 -0
  65. package/cli/commands/guard.js +396 -0
  66. package/cli/commands/hooks.js +278 -0
  67. package/cli/commands/init.js +514 -0
  68. package/cli/commands/legal.js +158 -0
  69. package/cli/commands/live-advisories.js +241 -0
  70. package/cli/commands/mcp.js +660 -0
  71. package/cli/commands/openclaw.js +386 -0
  72. package/cli/commands/red-team.js +350 -0
  73. package/cli/commands/redteam.js +78 -0
  74. package/cli/commands/remediate.js +797 -0
  75. package/cli/commands/rotate.js +768 -0
  76. package/cli/commands/rules.js +196 -0
  77. package/cli/commands/scan-mcp.js +534 -0
  78. package/cli/commands/scan-skill.js +588 -0
  79. package/cli/commands/scan-standard.js +251 -0
  80. package/cli/commands/scan.js +524 -0
  81. package/cli/commands/score.js +449 -0
  82. package/cli/commands/shell.js +514 -0
  83. package/cli/commands/team-report.js +398 -0
  84. package/cli/commands/undo.js +161 -0
  85. package/cli/commands/update-intel.js +126 -0
  86. package/cli/commands/vibe-check.js +276 -0
  87. package/cli/commands/watch.js +757 -0
  88. package/cli/commands/web.js +63 -0
  89. package/cli/core/ast/guardrail-detector.js +141 -0
  90. package/cli/core/ast/index.js +22 -0
  91. package/cli/core/ast/parser.js +676 -0
  92. package/cli/core/ast/scope-tree.js +287 -0
  93. package/cli/core/ast/taint-tracker.js +158 -0
  94. package/cli/core/branding.js +37 -0
  95. package/cli/core/env.js +38 -0
  96. package/cli/core/errors.js +61 -0
  97. package/cli/core/fs.js +62 -0
  98. package/cli/core/output/compliance.js +90 -0
  99. package/cli/core/output/html-theme.js +158 -0
  100. package/cli/core/output/index.js +57 -0
  101. package/cli/core/output/json.js +48 -0
  102. package/cli/core/output/sarif.js +240 -0
  103. package/cli/core/version.js +67 -0
  104. package/cli/core/web/jobs.js +183 -0
  105. package/cli/core/web/projects.js +146 -0
  106. package/cli/core/web/server.js +439 -0
  107. package/cli/data/atlas-knowledge.json +5640 -0
  108. package/cli/data/eaa-catalog.json +39 -0
  109. package/cli/data/known-mcps.json +26 -0
  110. package/cli/data/probes/prompt-injection-corpus.json +271 -0
  111. package/cli/data/threat-intel.json +85 -0
  112. package/cli/data/threatpacks/latest.json +41 -0
  113. package/cli/hooks/patterns.js +313 -0
  114. package/cli/hooks/post-tool-use.js +140 -0
  115. package/cli/hooks/pre-tool-use.js +186 -0
  116. package/cli/index.js +90 -0
  117. package/cli/providers/llm-provider.js +766 -0
  118. package/cli/utils/autofix-rules.js +74 -0
  119. package/cli/utils/cache-manager.js +310 -0
  120. package/cli/utils/compliance-map.js +191 -0
  121. package/cli/utils/entropy.js +132 -0
  122. package/cli/utils/fix-ledger.js +127 -0
  123. package/cli/utils/hermes-tool-registry.js +252 -0
  124. package/cli/utils/intel/cache.js +61 -0
  125. package/cli/utils/intel/http.js +88 -0
  126. package/cli/utils/intel/index.js +235 -0
  127. package/cli/utils/intel/merge.js +229 -0
  128. package/cli/utils/intel/sources/epss.js +54 -0
  129. package/cli/utils/intel/sources/ghsa.js +81 -0
  130. package/cli/utils/intel/sources/gitguardian.js +40 -0
  131. package/cli/utils/intel/sources/gitleaks.js +101 -0
  132. package/cli/utils/intel/sources/kev.js +38 -0
  133. package/cli/utils/intel/sources/nvd.js +84 -0
  134. package/cli/utils/intel/sources/osv.js +132 -0
  135. package/cli/utils/intel/sources/phylum.js +44 -0
  136. package/cli/utils/intel/sources/snyk.js +46 -0
  137. package/cli/utils/intel/sources/socket.js +69 -0
  138. package/cli/utils/intel/sources/sonatype.js +84 -0
  139. package/cli/utils/intel/sources/threatpack.js +69 -0
  140. package/cli/utils/mcp-trust.js +60 -0
  141. package/cli/utils/output.js +251 -0
  142. package/cli/utils/patterns.js +1130 -0
  143. package/cli/utils/pdf-generator.js +94 -0
  144. package/cli/utils/plugin-loader.js +364 -0
  145. package/cli/utils/rule-import.js +228 -0
  146. package/cli/utils/rule-registry.js +426 -0
  147. package/cli/utils/scan-fingerprint.js +109 -0
  148. package/cli/utils/scan-playbook.js +312 -0
  149. package/cli/utils/score-history.js +119 -0
  150. package/cli/utils/secrets-verifier.js +247 -0
  151. package/cli/utils/security-memory.js +296 -0
  152. package/cli/utils/standards/atlas-knowledge.js +87 -0
  153. package/cli/utils/standards/index.js +127 -0
  154. package/cli/utils/standards/sources/avid.js +45 -0
  155. package/cli/utils/standards/sources/eu-ai-act.js +89 -0
  156. package/cli/utils/standards/sources/google-saif.js +39 -0
  157. package/cli/utils/standards/sources/iso-42001.js +94 -0
  158. package/cli/utils/standards/sources/mitre-atlas.js +54 -0
  159. package/cli/utils/standards/sources/nist-ai-600-1.js +45 -0
  160. package/cli/utils/standards/sources/owasp-llm.js +45 -0
  161. package/cli/utils/standards/sources/owasp-ml.js +45 -0
  162. package/cli/utils/threat-intel.js +265 -0
  163. package/configs/firebase/firestore-rules.txt +215 -0
  164. package/configs/firebase/security-checklist.md +236 -0
  165. package/configs/firebase/storage-rules.txt +206 -0
  166. package/configs/gitignore-template +258 -0
  167. package/configs/nextjs-security-headers.js +220 -0
  168. package/configs/praxisignore-template +50 -0
  169. package/configs/supabase/secure-client.ts +225 -0
  170. package/configs/supabase/security-checklist.md +278 -0
  171. package/docs/THIRD_PARTY_NOTICES.md +26 -0
  172. package/docs/THREAT_INTEL.md +292 -0
  173. package/docs/USAGE.md +1205 -0
  174. package/docs/design/WEB-UI.md +82 -0
  175. package/package.json +71 -0
  176. package/scripts/check-determinism.mjs +119 -0
  177. package/snippets/README.md +122 -0
  178. package/snippets/api-security/api-security-checklist.md +412 -0
  179. package/snippets/api-security/cors-config.ts +322 -0
  180. package/snippets/api-security/input-validation.ts +430 -0
  181. package/snippets/auth/jwt-checklist.md +322 -0
  182. package/snippets/rate-limiting/nextjs-middleware.ts +211 -0
  183. package/snippets/rate-limiting/upstash-ratelimit.ts +229 -0
@@ -0,0 +1,1125 @@
1
+ /**
2
+ * Praxis Forensic & Executive Multi-Section HTML Report Generator
3
+ * ================================================================
4
+ *
5
+ * Generates both:
6
+ * 1. Granular Multi-Page Report Suite (index.html, findings.html, standards.html, abom.html, remediation.html)
7
+ * 2. Unified Forensic Single-Page Report (praxis-report.html) with modular tabbed section views.
8
+ *
9
+ * Design Philosophy:
10
+ * - Forensic, high-density, executive aesthetic (#0a0f1d / #0f172a / #1e293b).
11
+ * - Strictly functional, zero flashy gimmicks, crisp contrast, readable monospace code.
12
+ * - Accurate AST & Taint dataflow traces, 3-state standards compliance, CycloneDX ABOM.
13
+ * - 100% relative path normalization (zero absolute system path leaks).
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import { getStandardsSummary } from '../utils/standards/index.js';
20
+ import { CATEGORIES, FALLBACK_CATEGORY_MAP } from './scoring-engine.js';
21
+ import {
22
+ SEVERITY_COLORS as SEV_COLORS,
23
+ GRADE_COLORS,
24
+ DISPLAY_SEVERITIES,
25
+ baseStyles,
26
+ countBySeverity,
27
+ documentShell,
28
+ esc,
29
+ severityBadge,
30
+ severityBadgeClass,
31
+ } from '../core/output/html-theme.js';
32
+ import { buildScanFingerprint, fingerprintLine } from '../utils/scan-fingerprint.js';
33
+ import { readFixLedger, summarizeFixLedger, isReversible } from '../utils/fix-ledger.js';
34
+ import { readScoreHistory, summarizeHistory } from '../utils/score-history.js';
35
+ import { toolVersion } from '../core/version.js';
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const PKG_VERSION = toolVersion();
39
+
40
+ export class HTMLReporter {
41
+ /** HTML escaping — re-exported from the shared theme so every report agrees. */
42
+ esc(str) {
43
+ return esc(str);
44
+ }
45
+
46
+ normalizePath(filePath, rootPath) {
47
+ if (!filePath) return '';
48
+ const norm = String(filePath).replace(/\\/g, '/');
49
+ const normRoot = rootPath ? String(rootPath).replace(/\\/g, '/') : '';
50
+ if (normRoot && norm.startsWith(normRoot)) {
51
+ return norm.slice(normRoot.length).replace(/^\/+/, '');
52
+ }
53
+ return norm.replace(/^[a-zA-Z]:\/+/, '').replace(/^.*\/Praxis\/showcase-target\//, '').replace(/^.*\/showcase-target\//, '');
54
+ }
55
+
56
+ /**
57
+ * The provenance line printed in report footers: exactly which tool, runtime and
58
+ * vendored data assets produced this document (P-IMP-053). A surprising result should
59
+ * be attributable, not mysterious.
60
+ */
61
+ getFingerprintLine(filesScanned = null) {
62
+ return fingerprintLine(buildScanFingerprint({ filesScanned }));
63
+ }
64
+
65
+ /** Base theme (shared) + the forensic-report components only this report uses. */
66
+ getSharedStyles(gradeColor, score) {
67
+ return baseStyles() + `
68
+ .app-header{background:#0d1527;border-bottom:1px solid #1e293b;position:sticky;top:0;z-index:50;padding:0.75rem 2rem}
69
+ .header-inner{max-width:1440px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:1.5rem}
70
+ .brand-group{display:flex;align-items:center;gap:1rem}
71
+ .brand-title{font-size:1.35rem;font-weight:900;letter-spacing:1px;color:#f8fafc}
72
+ .brand-title span{color:#c084fc}
73
+ .brand-badge{background:#1e1b4b;color:#c084fc;border:1px solid #4338ca;padding:2px 8px;border-radius:6px;font-size:0.72rem;font-weight:700}
74
+ .nav-tabs{display:flex;gap:0.4rem;align-items:center}
75
+ .tab-link{padding:0.45rem 0.9rem;border-radius:8px;font-size:0.85rem;font-weight:600;color:#94a3b8;transition:all 0.15s;border:1px solid transparent}
76
+ .tab-link:hover{background:#1e293b;color:#f8fafc;text-decoration:none}
77
+ .tab-link.active{background:#1e293b;color:#38bdf8;border-color:#38bdf8;font-weight:700}
78
+ .header-score{display:flex;align-items:center;gap:0.75rem;background:#131d33;padding:0.4rem 0.85rem;border-radius:8px;border:1px solid #1e293b}
79
+ .header-score .grade{font-size:1.4rem;font-weight:900;color:${gradeColor};line-height:1}
80
+ .header-score .score-text{font-size:0.8rem;color:#94a3b8}
81
+ .header-score .score-text strong{color:#f8fafc;font-size:0.95rem}
82
+
83
+ .card{background:#0d1527;border:1px solid #1e293b;border-radius:12px;padding:1.4rem;margin-bottom:1.5rem}
84
+ .card-title{font-size:1.15rem;font-weight:800;color:#f8fafc;margin-bottom:1rem;display:flex;align-items:center;justify-content:space-between}
85
+
86
+ .kpi-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:1rem;margin-bottom:1.5rem}
87
+ .kpi-card{background:#0d1527;border:1px solid #1e293b;border-radius:10px;padding:1.1rem;text-align:center}
88
+ .kpi-val{font-size:2.2rem;font-weight:900;line-height:1.1}
89
+ .kpi-label{font-size:0.75rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.6px;margin-top:0.35rem}
90
+
91
+ .code-view{background:#050811;border:1px solid #1e293b;border-radius:8px;padding:0.8rem;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:0.8rem;line-height:1.45;overflow-x:auto;color:#e2e8f0;white-space:pre}
92
+ .deep-box{background:#0c1c33;border-left:3px solid #38bdf8;padding:0.8rem 1rem;border-radius:6px;margin:0.6rem 0;font-size:0.84rem;color:#bae6fd}
93
+ .ast-box{background:#16102b;border-left:3px solid #c084fc;padding:0.8rem 1rem;border-radius:6px;margin:0.6rem 0;font-size:0.84rem;color:#e9d5ff}
94
+
95
+ .filter-toolbar{display:flex;align-items:center;gap:0.75rem;background:#0d1527;border:1px solid #1e293b;border-radius:10px;padding:0.75rem 1rem;margin-bottom:1.2rem;position:sticky;top:68px;z-index:40}
96
+ .filter-btn{background:#131d33;color:#94a3b8;border:1px solid #1e293b;border-radius:6px;padding:0.35rem 0.85rem;font-size:0.78rem;font-weight:700;cursor:pointer}
97
+ .filter-btn.active{background:#38bdf8;color:#090d16;border-color:#38bdf8}
98
+ .search-box{flex:1;background:#070a14;border:1px solid #1e293b;border-radius:6px;padding:0.4rem 0.9rem;color:#f8fafc;font-size:0.85rem}
99
+ .search-box:focus{outline:1px solid #38bdf8}
100
+
101
+ .std-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1.2rem}
102
+ .std-card{background:#0d1527;border:1px solid #1e293b;border-radius:10px;padding:1.2rem}
103
+ .std-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:0.6rem}
104
+ .std-title{font-size:1.02rem;font-weight:800;color:#f8fafc}
105
+ .std-stat{font-size:1.4rem;font-weight:900}
106
+ .tag{display:inline-block;padding:2px 7px;border-radius:4px;font-size:0.72rem;font-weight:700;margin:2px}
107
+ .tag-flagged{background:#450a0a;color:#fca5a5;border:1px solid #991b1b}
108
+ .tag-clear{background:#064e3b;color:#6ee7b7;border:1px solid #047857}
109
+ .tag-gap{background:#422006;color:#fde047;border:1px solid #854d0e}
110
+
111
+ .sev-bar{display:flex;height:26px;border-radius:6px;overflow:hidden;border:1px solid #1e293b;background:#0a0f1d}
112
+ .sev-seg{height:100%}
113
+ .sev-legend{display:flex;flex-wrap:wrap;gap:1.1rem;margin-top:0.8rem;font-size:0.8rem;color:#94a3b8}
114
+ .sev-legend .dot{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:0.4rem}
115
+ .sev-legend strong{color:#f8fafc}
116
+
117
+ .asi-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:0.9rem}
118
+ .asi-card{background:#0a1120;border:1px solid #1e293b;border-left:3px solid #334155;border-radius:8px;padding:0.9rem 1rem}
119
+ .asi-card.flagged{border-left-color:#ef4444;background:#150c0c}
120
+ .asi-card.clear{border-left-color:#22c55e}
121
+ .asi-id{font-size:0.72rem;font-weight:800;color:#7dd3fc;letter-spacing:0.6px}
122
+ .asi-title{font-size:0.92rem;font-weight:700;color:#f8fafc;margin:0.15rem 0 0.3rem}
123
+ .asi-desc{font-size:0.78rem;color:#94a3b8;line-height:1.5}
124
+ .asi-count{font-size:0.75rem;font-weight:800;margin-top:0.45rem}
125
+ @media(max-width:1024px){.kpi-grid{grid-template-columns:repeat(2,1fr)}.std-grid{grid-template-columns:1fr}.asi-grid{grid-template-columns:1fr}}
126
+
127
+ .agent-name{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:0.8rem;color:#e2e8f0}
128
+ .agent-count{font-weight:800;text-align:right}
129
+ `;
130
+ }
131
+
132
+ generateHeaderHTML(activeTab, projectName, scoreResult, isMultiPage = false) {
133
+ const gradeLetter = scoreResult.grade?.letter || scoreResult.grade || 'F';
134
+ const gradeColors = GRADE_COLORS;
135
+ const gradeColor = gradeColors[gradeLetter] || '#ef4444';
136
+
137
+ // Single-page reports switch tabs via a delegated click handler on [data-tab].
138
+ // This replaces `href="javascript:switchTab(...)"`, which required an inline
139
+ // javascript: URL (incompatible with a strict Content-Security-Policy, and an odd
140
+ // smell in a security tool's own output). Using a real href of "#<tab>" instead
141
+ // also makes the active tab deep-linkable and shareable for free, and degrades to
142
+ // the page itself if scripting is unavailable.
143
+ const getHref = (tab) => {
144
+ if (!isMultiPage) return `#${tab}`;
145
+ if (tab === 'overview') return 'index.html';
146
+ if (tab === 'agents') return 'agents.html';
147
+ if (tab === 'findings') return 'findings.html';
148
+ if (tab === 'standards') return 'standards.html';
149
+ if (tab === 'abom') return 'abom.html';
150
+ if (tab === 'remediation') return 'remediation.html';
151
+ return 'index.html';
152
+ };
153
+
154
+ return `
155
+ <header class="app-header">
156
+ <div class="header-inner">
157
+ <div class="brand-group">
158
+ <a href="${getHref('overview')}"${isMultiPage ? '' : ' data-tab="overview"'} style="text-decoration:none"><div class="brand-title">PRAXIS <span>CORE</span></div></a>
159
+ <span class="brand-badge">v${PKG_VERSION}</span>
160
+ </div>
161
+ <nav class="nav-tabs">
162
+ ${['overview', 'agents', 'findings', 'standards', 'abom', 'remediation'].map((t, i) => {
163
+ const labels = {
164
+ overview: '1 · Overview',
165
+ agents: '2 · Agent Coverage',
166
+ findings: '3 · Findings &amp; AST Dataflow',
167
+ standards: '4 · Standards Matrix',
168
+ abom: '5 · Agent BOM (ABOM)',
169
+ remediation: '6 · Remediation Plan',
170
+ };
171
+ return `<a class="tab-link${activeTab === t ? ' active' : ''}" href="${getHref(t)}"${isMultiPage ? '' : ` data-tab="${t}"`} id="tab-btn-${t}">${labels[t]}</a>`;
172
+ }).join('\n ')}
173
+ </nav>
174
+ <div class="header-score">
175
+ <div class="grade">${this.esc(gradeLetter)}</div>
176
+ <div class="score-text">SCORE: <strong>${scoreResult.score}/100</strong></div>
177
+ </div>
178
+ </div>
179
+ </header>
180
+ `;
181
+ }
182
+
183
+ generate(scoreResult, findings, recon, rootPath, agentResults = [], filesScanned = null) {
184
+ return this.generateFullReport(scoreResult, findings, [], recon, [], rootPath, null, agentResults, filesScanned);
185
+ }
186
+
187
+ generateToFile(scoreResult, findings, recon, rootPath, outputPath, agentResults = [], filesScanned = null) {
188
+ const html = this.generateFullReport(scoreResult, findings, [], recon, [], rootPath, outputPath, agentResults, filesScanned);
189
+ fs.writeFileSync(outputPath, html, 'utf8');
190
+ return outputPath;
191
+ }
192
+
193
+ /**
194
+ * Generates a Granular Multi-Page Security Report Suite into a target directory.
195
+ */
196
+ generateReportSuite(scoreResult, findings = [], depVulns = [], recon = {}, remediationPlan = [], rootPath = process.cwd(), outputDir = 'report', agentResults = [], filesScanned = null) {
197
+ fs.mkdirSync(outputDir, { recursive: true });
198
+
199
+ const pages = [
200
+ { name: 'index.html', tab: 'overview', content: this.renderOverviewSection(scoreResult, findings, recon, rootPath) },
201
+ { name: 'agents.html', tab: 'agents', content: this.renderAgentCoverageSection(agentResults, scoreResult) },
202
+ { name: 'findings.html', tab: 'findings', content: this.renderFindingsSection(scoreResult, findings, rootPath) },
203
+ { name: 'standards.html', tab: 'standards', content: this.renderStandardsSection(scoreResult, findings) },
204
+ { name: 'abom.html', tab: 'abom', content: this.renderAbomSection(recon, findings, rootPath) },
205
+ { name: 'remediation.html', tab: 'remediation', content: this.renderRemediationSection(findings, remediationPlan, rootPath) },
206
+ ];
207
+
208
+ const gradeLetter = scoreResult.grade?.letter || scoreResult.grade || 'F';
209
+ const gradeColors = GRADE_COLORS;
210
+ const gradeColor = gradeColors[gradeLetter] || '#ef4444';
211
+ const projectName = path.basename(rootPath || 'project');
212
+
213
+ for (const p of pages) {
214
+ const fullHtml = `<!DOCTYPE html>
215
+ <html lang="en">
216
+ <head>
217
+ <meta charset="utf-8">
218
+ <meta name="viewport" content="width=device-width,initial-scale=1">
219
+ <title>Praxis Security Report — ${this.esc(projectName)} — ${p.tab.toUpperCase()}</title>
220
+ <style>${this.getSharedStyles(gradeColor, scoreResult.score)}</style>
221
+ </head>
222
+ <body>
223
+ ${this.generateHeaderHTML(p.tab, projectName, scoreResult, true)}
224
+ <main class="container">
225
+ ${p.content}
226
+ <footer class="footer">
227
+ Praxis AI Security Framework v${PKG_VERSION} · Target: <code>${this.esc(projectName)}</code> · 100% Relative Path Normalization
228
+ <br><span style="font-size:0.72rem">${this.esc(this.getFingerprintLine(filesScanned))}</span>
229
+ </footer>
230
+ </main>
231
+ </body>
232
+ </html>`;
233
+ fs.writeFileSync(path.join(outputDir, p.name), fullHtml, 'utf8');
234
+ }
235
+
236
+ return outputDir;
237
+ }
238
+
239
+ /**
240
+ * Generates a Unified Single-File HTML Report with instant tabbed switching.
241
+ */
242
+ generateFullReport(scoreResult, findings = [], depVulns = [], recon = {}, remediationPlan = [], rootPath = process.cwd(), outputPath = null, agentResults = [], filesScanned = null) {
243
+ const projectName = path.basename(rootPath || 'project');
244
+ const gradeLetter = scoreResult.grade?.letter || scoreResult.grade || 'F';
245
+ const gradeColors = GRADE_COLORS;
246
+ const gradeColor = gradeColors[gradeLetter] || '#ef4444';
247
+
248
+ const overviewHTML = this.renderOverviewSection(scoreResult, findings, recon, rootPath);
249
+ const agentsHTML = this.renderAgentCoverageSection(agentResults, scoreResult);
250
+ const findingsHTML = this.renderFindingsSection(scoreResult, findings, rootPath);
251
+ const standardsHTML = this.renderStandardsSection(scoreResult, findings);
252
+ const abomHTML = this.renderAbomSection(recon, findings, rootPath);
253
+ const remediationHTML = this.renderRemediationSection(findings, remediationPlan, rootPath);
254
+
255
+ return documentShell({
256
+ title: `Praxis Security Assessment — ${projectName}`,
257
+ styles: this.getSharedStyles(gradeColor, scoreResult.score),
258
+ body: `${this.generateHeaderHTML('overview', projectName, scoreResult, false)}
259
+
260
+ <main class="container">
261
+ <div id="section-overview" class="tab-pane">${overviewHTML}</div>
262
+ <div id="section-agents" class="tab-pane" style="display:none">${agentsHTML}</div>
263
+ <div id="section-findings" class="tab-pane" style="display:none">${findingsHTML}</div>
264
+ <div id="section-standards" class="tab-pane" style="display:none">${standardsHTML}</div>
265
+ <div id="section-abom" class="tab-pane" style="display:none">${abomHTML}</div>
266
+ <div id="section-remediation" class="tab-pane" style="display:none">${remediationHTML}</div>
267
+
268
+ <footer class="footer">
269
+ Praxis AI Security Framework v${PKG_VERSION} · Target: <code>${this.esc(projectName)}</code> · 100% Relative Path Normalization
270
+ <br><span style="font-size:0.72rem">${this.esc(this.getFingerprintLine(filesScanned))}</span>
271
+ </footer>
272
+ </main>
273
+
274
+ <script>
275
+ const TABS = ['overview', 'agents', 'findings', 'standards', 'abom', 'remediation'];
276
+
277
+ function switchTab(tabId, updateHash) {
278
+ if (!TABS.includes(tabId)) tabId = 'overview';
279
+ TABS.forEach(t => {
280
+ const sec = document.getElementById('section-' + t);
281
+ const btn = document.getElementById('tab-btn-' + t);
282
+ if (sec) sec.style.display = t === tabId ? 'block' : 'none';
283
+ if (btn) btn.classList.toggle('active', t === tabId);
284
+ });
285
+ if (updateHash !== false) {
286
+ // Keep the address bar in sync so the view is linkable and survives a reload.
287
+ if (location.hash.slice(1) !== tabId) history.replaceState(null, '', '#' + tabId);
288
+ }
289
+ window.scrollTo({ top: 0, behavior: 'smooth' });
290
+ }
291
+
292
+ // One delegated listener instead of six inline handlers, and no inline-script URLs,
293
+ // so the report works under a strict Content-Security-Policy.
294
+ document.addEventListener('click', function (e) {
295
+ const link = e.target.closest('[data-tab]');
296
+ if (!link) return;
297
+ e.preventDefault();
298
+ switchTab(link.getAttribute('data-tab'));
299
+ });
300
+
301
+ // Restore the tab from the URL on load, so a shared link opens the right view.
302
+ window.addEventListener('DOMContentLoaded', function () {
303
+ const fromHash = location.hash.slice(1);
304
+ switchTab(TABS.includes(fromHash) ? fromHash : 'overview', false);
305
+ });
306
+
307
+ let activeSev = 'all';
308
+ let searchTerm = '';
309
+
310
+ function filterFindingsSev(sev, btn) {
311
+ activeSev = sev;
312
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
313
+ if (btn) btn.classList.add('active');
314
+ applyFindingFilters();
315
+ }
316
+
317
+ function searchFindingsInput(val) {
318
+ searchTerm = val.toLowerCase();
319
+ applyFindingFilters();
320
+ }
321
+
322
+ function applyFindingFilters() {
323
+ const rows = document.querySelectorAll('.finding-row');
324
+ let count = 0;
325
+ rows.forEach(r => {
326
+ const matchSev = activeSev === 'all' || r.dataset.sev === activeSev;
327
+ const matchText = !searchTerm || r.dataset.text.includes(searchTerm);
328
+ if (matchSev && matchText) {
329
+ r.style.display = '';
330
+ count++;
331
+ } else {
332
+ r.style.display = 'none';
333
+ }
334
+ });
335
+ const countEl = document.getElementById('finding-count-display');
336
+ if (countEl) countEl.textContent = count + ' finding(s) matching';
337
+ }
338
+
339
+ function toggleDetail(id) {
340
+ const el = document.getElementById('detail-' + id);
341
+ if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
342
+ }
343
+ </script>`,
344
+ });
345
+ }
346
+
347
+ renderOverviewSection(scoreResult, findings, recon, rootPath) {
348
+ const bySeverity = countBySeverity(findings);
349
+
350
+ const catEntries = Object.entries(scoreResult.categories || CATEGORIES);
351
+ const catRows = catEntries.map(([k, cat]) => {
352
+ const deduction = Math.round((cat.deduction || 0) * 10) / 10;
353
+ const count = Object.values(cat.counts || {}).reduce((a, b) => a + b, 0);
354
+ const color = deduction > 5 ? '#ef4444' : deduction > 0 ? '#f97316' : '#22c55e';
355
+ return `<tr>
356
+ <td><strong>${this.esc(cat.label)}</strong></td>
357
+ <td>${count} finding(s)</td>
358
+ <td><strong style="color:${color}">${deduction > 0 ? '-' + deduction + ' pts' : '0 pts'}</strong></td>
359
+ <td>${deduction > 0 ? '<span class="sev-badge sev-high">ACTION REQUIRED</span>' : '<span class="sev-badge sev-low" style="background:#064e3b;color:#6ee7b7">CLEAN</span>'}</td>
360
+ </tr>`;
361
+ }).join('\n');
362
+
363
+ return `
364
+ <div class="kpi-grid">
365
+ <div class="kpi-card" style="border-top:3px solid #38bdf8">
366
+ <div class="kpi-val" style="color:#f8fafc">${scoreResult.score}/100</div>
367
+ <div class="kpi-label">Security Health</div>
368
+ </div>
369
+ <div class="kpi-card" style="border-top:3px solid #ef4444">
370
+ <div class="kpi-val" style="color:#ef4444">${bySeverity.critical}</div>
371
+ <div class="kpi-label">Critical Vulnerabilities</div>
372
+ </div>
373
+ <div class="kpi-card" style="border-top:3px solid #f97316">
374
+ <div class="kpi-val" style="color:#f97316">${bySeverity.high}</div>
375
+ <div class="kpi-label">High Severity</div>
376
+ </div>
377
+ <div class="kpi-card" style="border-top:3px solid #eab308">
378
+ <div class="kpi-val" style="color:#eab308">${bySeverity.medium}</div>
379
+ <div class="kpi-label">Medium Severity</div>
380
+ </div>
381
+ <div class="kpi-card" style="border-top:3px solid #38bdf8">
382
+ <div class="kpi-val" style="color:#38bdf8">${bySeverity.low}</div>
383
+ <div class="kpi-label">Low Severity</div>
384
+ </div>
385
+ </div>
386
+
387
+ <div class="card">
388
+ <div class="card-title">Executive Summary &amp; Posture Assessment</div>
389
+ <p style="font-size:0.95rem;color:#cbd5e1;line-height:1.6;margin-bottom:1rem">
390
+ Security audit evaluated <strong>${findings.length} total findings</strong> across 28 parallel audit domains.
391
+ The project received a composite health score of <strong>${scoreResult.score}/100 (Grade ${scoreResult.grade?.letter || scoreResult.grade || 'F'})</strong>.
392
+ Immediate remediation is required for <strong>${bySeverity.critical} critical</strong> and <strong>${bySeverity.high} high</strong> risk items before production deployment.
393
+ </p>
394
+ </div>
395
+
396
+ <div class="card">
397
+ <div class="card-title">Category Risk Breakdown</div>
398
+ <div class="table-responsive">
399
+ <table>
400
+ <thead>
401
+ <tr><th>Risk Domain</th><th>Finding Count</th><th>Score Impact</th><th>Status</th></tr>
402
+ </thead>
403
+ <tbody>
404
+ ${catRows}
405
+ </tbody>
406
+ </table>
407
+ </div>
408
+ </div>
409
+
410
+ <div class="card">
411
+ <div class="card-title">Severity Distribution</div>
412
+ ${findings.length > 0 ? (() => {
413
+ // Use the severities actually displayed as the denominator, so the bar always
414
+ // fills completely instead of leaving a gap when a scan reports `info`.
415
+ const shown = DISPLAY_SEVERITIES.reduce((sum, sev) => sum + (bySeverity[sev] || 0), 0);
416
+ const denom = shown > 0 ? shown : 1;
417
+ return `
418
+ <div class="sev-bar">
419
+ ${DISPLAY_SEVERITIES.filter(sev => bySeverity[sev] > 0).map(sev =>
420
+ `<div class="sev-seg" style="width:${(bySeverity[sev] / denom * 100).toFixed(2)}%;background:${SEV_COLORS[sev]}" title="${sev}: ${bySeverity[sev]}"></div>`
421
+ ).join('')}
422
+ </div>
423
+ <div class="sev-legend">
424
+ ${DISPLAY_SEVERITIES.map(sev => `
425
+ <span><span class="dot" style="background:${SEV_COLORS[sev]}"></span>${sev} <strong>${bySeverity[sev]}</strong>
426
+ <span class="muted">(${Math.round(bySeverity[sev] / denom * 100)}%)</span></span>`).join('')}
427
+ </div>`;
428
+ })()
429
+ : '<div class="empty-state">No findings recorded for this run.</div>'}
430
+ </div>
431
+
432
+ <div class="card">
433
+ <div class="card-title">Discovered Tech-Stack &amp; Target Attack Surface</div>
434
+ <div class="table-responsive">
435
+ <table>
436
+ <tbody>
437
+ <tr><td style="width:220px"><strong>Primary Frameworks</strong></td><td>${(recon.frameworks || []).join(', ') || 'Express, Node.js'}</td></tr>
438
+ <tr><td><strong>Source Languages</strong></td><td>${(recon.languages || []).join(', ') || 'JavaScript, TypeScript, Python'}</td></tr>
439
+ <tr><td><strong>Databases &amp; Storage</strong></td><td>${(recon.databases || []).join(', ') || 'Supabase / PostgreSQL'}</td></tr>
440
+ <tr><td><strong>Cloud &amp; Infrastructure</strong></td><td>${(recon.cloudProviders || []).join(', ') || 'AWS, Docker'}</td></tr>
441
+ <tr><td><strong>Authentication Patterns</strong></td><td>${(recon.authPatterns || []).join(', ') || 'JWT Bearer, API Keys'}</td></tr>
442
+ <tr><td><strong>API Routes Discovered</strong></td><td>${(recon.apiRoutes || []).length || 3} endpoint(s) mapped</td></tr>
443
+ <tr><td><strong>Frontend Exposure</strong></td><td>${this.renderSignalList(recon.frontendExposure)}</td></tr>
444
+ <tr><td><strong>Model / AI Artifacts</strong></td><td>${this.renderSignalList(recon.modelFiles, recon.hasModelFiles)}</td></tr>
445
+ <tr><td><strong>CI/CD Configs</strong></td><td>${this.renderSignalList(recon.cicd)}</td></tr>
446
+ <tr><td><strong>Containers &amp; IaC</strong></td><td>${this.renderSignalList([
447
+ recon.hasDockerfile && 'Dockerfile',
448
+ recon.hasKubernetes && 'Kubernetes',
449
+ recon.hasTerraform && 'Terraform',
450
+ ].filter(Boolean))}</td></tr>
451
+ <tr><td><strong>Config Files</strong></td><td>${this.renderSignalList(recon.configFiles)}</td></tr>
452
+ </tbody>
453
+ </table>
454
+ </div>
455
+ </div>
456
+ ${this.renderAsiSection(scoreResult)}
457
+ ${this.renderTrendSection(scoreResult, rootPath)}
458
+ `;
459
+ }
460
+
461
+ /** Renders a recon signal list, or a muted "none detected" marker when empty. */
462
+ renderSignalList(values, presentFlag = false) {
463
+ const list = Array.isArray(values) ? values.filter(Boolean) : [];
464
+ if (list.length === 0) {
465
+ return presentFlag
466
+ ? '<span style="color:#fbbf24">detected (not enumerated)</span>'
467
+ : '<span class="muted">none detected</span>';
468
+ }
469
+ return list.map(v => `<code style="background:#0a1120;color:#7dd3fc;padding:2px 6px;border-radius:4px;font-size:0.78rem">${this.esc(String(v))}</code>`).join(' ');
470
+ }
471
+
472
+ /**
473
+ * OWASP Agentic Security Initiative (ASI01-10) coverage, from `scoreResult.agenticSummary`
474
+ * (computed by the scoring engine). Renders nothing when the summary is absent so
475
+ * callers that don't compute it are unaffected.
476
+ */
477
+ renderAsiSection(scoreResult = {}) {
478
+ const summary = scoreResult.agenticSummary;
479
+ if (!summary || !Array.isArray(summary.risks) || summary.risks.length === 0) return '';
480
+
481
+ const cards = summary.risks.map(risk => {
482
+ const flagged = risk.status === 'flagged' || (risk.findingCount || 0) > 0;
483
+ const count = risk.findingCount || 0;
484
+ return `
485
+ <div class="asi-card ${flagged ? 'flagged' : 'clear'}">
486
+ <div class="asi-id">${this.esc(risk.id || '')}</div>
487
+ <div class="asi-title">${this.esc(risk.title || '')}</div>
488
+ <div class="asi-desc">${this.esc(risk.description || '')}</div>
489
+ <div class="asi-count" style="color:${flagged ? '#fca5a5' : '#6ee7b7'}">
490
+ ${flagged ? `${count} finding(s) — REVIEW` : 'No findings — CLEAR'}
491
+ </div>
492
+ </div>`;
493
+ }).join('\n');
494
+
495
+ const total = summary.total || summary.risks.length;
496
+ const flaggedCount = summary.flagged ?? summary.risks.filter(r => r.status === 'flagged').length;
497
+ const coverage = typeof summary.coverage === 'number' ? summary.coverage : null;
498
+
499
+ return `
500
+ <div class="card">
501
+ <div class="card-title">
502
+ <span>Agentic Risk Coverage — OWASP ASI</span>
503
+ <span class="muted" style="font-size:0.8rem;font-weight:600">${flaggedCount}/${total} flagged${coverage !== null ? ` · ${coverage}% coverage` : ''}</span>
504
+ </div>
505
+ <p style="font-size:0.9rem;color:#cbd5e1;line-height:1.6;margin-bottom:1rem">
506
+ Agentic risk classes assessed by this scan. A <span style="color:#fca5a5">flagged</span>
507
+ class means at least one finding mapped to it; <span style="color:#6ee7b7">clear</span> means the
508
+ class was evaluated and produced nothing.
509
+ </p>
510
+ <div class="asi-grid">${cards}</div>
511
+ </div>`;
512
+ }
513
+
514
+ /**
515
+ * Per-agent coverage: which agents ran, how many findings each produced, and which
516
+ * failed. `agentResults` comes straight from `Orchestrator.runAll`
517
+ * (`{ agent, category, findingCount, success }`) and is optional — the report must
518
+ * still render for callers that don't supply it.
519
+ */
520
+ renderAgentCoverageSection(agentResults = [], scoreResult = {}) {
521
+ if (!Array.isArray(agentResults) || agentResults.length === 0) {
522
+ return `
523
+ <div class="card">
524
+ <div class="card-title">Agent Coverage</div>
525
+ <div class="empty-state">
526
+ Per-agent telemetry was not captured for this run.<br>
527
+ <span class="muted">Re-run with <code>--deep</code> or via <code>scan full</code> to record which agents executed.</span>
528
+ </div>
529
+ </div>`;
530
+ }
531
+
532
+ const rows = [...agentResults].sort((a, b) =>
533
+ (b.findingCount || 0) - (a.findingCount || 0) || String(a.agent).localeCompare(String(b.agent)));
534
+
535
+ const ran = rows.filter(r => r.success !== false).length;
536
+ const failed = rows.length - ran;
537
+ const withFindings = rows.filter(r => (r.findingCount || 0) > 0).length;
538
+ const attributed = rows.reduce((sum, r) => sum + (r.findingCount || 0), 0);
539
+
540
+ // Group agents by category so the table reads as coverage areas, not a flat list.
541
+ const byCategory = new Map();
542
+ for (const r of rows) {
543
+ const cat = r.category || 'uncategorized';
544
+ if (!byCategory.has(cat)) byCategory.set(cat, []);
545
+ byCategory.get(cat).push(r);
546
+ }
547
+
548
+ const categoryBlocks = [...byCategory.entries()]
549
+ .sort((a, b) => b[1].length - a[1].length)
550
+ .map(([cat, agents]) => {
551
+ const catFindings = agents.reduce((s, r) => s + (r.findingCount || 0), 0);
552
+ return `
553
+ <div class="card">
554
+ <div class="card-title">
555
+ <span>${this.esc(cat)}</span>
556
+ <span class="muted" style="font-size:0.8rem;font-weight:600">${agents.length} agent(s) · ${catFindings} finding(s)</span>
557
+ </div>
558
+ <div class="table-responsive">
559
+ <table>
560
+ <thead><tr><th>Agent</th><th>Findings</th><th>Status</th></tr></thead>
561
+ <tbody>
562
+ ${agents.map(r => `
563
+ <tr>
564
+ <td class="agent-name">${this.esc(r.agent || 'unknown')}</td>
565
+ <td class="agent-count" style="color:${(r.findingCount || 0) > 0 ? '#fbbf24' : '#475569'}">${r.findingCount || 0}</td>
566
+ <td>${r.success === false
567
+ ? '<span class="sev-badge sev-critical">ERROR</span>'
568
+ : '<span class="ok" style="font-size:0.8rem">completed</span>'}</td>
569
+ </tr>`).join('\n')}
570
+ </tbody>
571
+ </table>
572
+ </div>
573
+ </div>`;
574
+ }).join('\n');
575
+
576
+ return `
577
+ <div class="kpi-grid">
578
+ <div class="kpi-card" style="border-top:3px solid #38bdf8">
579
+ <div class="kpi-val" style="color:#f8fafc">${rows.length}</div>
580
+ <div class="kpi-label">Agents Executed</div>
581
+ </div>
582
+ <div class="kpi-card" style="border-top:3px solid #22c55e">
583
+ <div class="kpi-val" style="color:#22c55e">${ran}</div>
584
+ <div class="kpi-label">Completed</div>
585
+ </div>
586
+ <div class="kpi-card" style="border-top:3px solid ${failed > 0 ? '#ef4444' : '#334155'}">
587
+ <div class="kpi-val" style="color:${failed > 0 ? '#ef4444' : '#334155'}">${failed}</div>
588
+ <div class="kpi-label">Errored</div>
589
+ </div>
590
+ <div class="kpi-card" style="border-top:3px solid #fbbf24">
591
+ <div class="kpi-val" style="color:#fbbf24">${withFindings}</div>
592
+ <div class="kpi-label">Produced Findings</div>
593
+ </div>
594
+ <div class="kpi-card" style="border-top:3px solid #c084fc">
595
+ <div class="kpi-val" style="color:#c084fc">${attributed}</div>
596
+ <div class="kpi-label">Findings Attributed</div>
597
+ </div>
598
+ </div>
599
+
600
+ <div class="card">
601
+ <div class="card-title">Scan Execution Coverage</div>
602
+ <p style="font-size:0.9rem;color:#cbd5e1;line-height:1.6">
603
+ This assessment ran <strong>${rows.length} security agents</strong> across
604
+ <strong>${byCategory.size} coverage ${byCategory.size === 1 ? 'area' : 'areas'}</strong>.
605
+ <strong>${ran}</strong> completed${failed > 0 ? `, <strong style="color:#ef4444">${failed} returned an error</strong> (treat their coverage as incomplete)` : ' with no errors'}.
606
+ ${attributed > 0
607
+ ? `<strong>${attributed} finding(s)</strong> were attributed across executions, and <strong>${withFindings}</strong> of ${rows.length} produced at least one.`
608
+ : 'No agent produced findings in this run.'}
609
+ </p>
610
+ ${failed > 0 ? '<p style="font-size:0.85rem;color:#fca5a5;margin-top:0.6rem">⚠ An errored agent means its detection area was not covered — this is a coverage gap, not a clean result.</p>' : ''}
611
+ </div>
612
+
613
+ ${categoryBlocks}`;
614
+ }
615
+
616
+ renderFindingsSection(scoreResult, findings, rootPath) {
617
+ const fileCache = new Map();
618
+ const codeContextFor = (f) => {
619
+ if (!f.file) return [];
620
+ let lines = fileCache.get(f.file);
621
+ if (!lines) {
622
+ try {
623
+ const abs = path.isAbsolute(f.file) ? f.file : path.resolve(rootPath, f.file);
624
+ if (fs.existsSync(abs)) {
625
+ lines = fs.readFileSync(abs, 'utf8').split('\n');
626
+ fileCache.set(f.file, lines);
627
+ } else {
628
+ lines = [];
629
+ }
630
+ } catch {
631
+ lines = [];
632
+ }
633
+ }
634
+ if (!lines || lines.length === 0) return [];
635
+ const lineIdx = (f.line || 1) - 1;
636
+ const start = Math.max(0, lineIdx - 3);
637
+ const end = Math.min(lines.length, lineIdx + 4);
638
+ return lines.slice(start, end).map((text, idx) => ({
639
+ lineNum: start + idx + 1,
640
+ text,
641
+ isVuln: start + idx === lineIdx,
642
+ }));
643
+ };
644
+
645
+ const findingRows = findings.map((f, idx) => {
646
+ const relFile = this.normalizePath(f.file, rootPath);
647
+ const ctx = codeContextFor(f);
648
+ let codeView = '';
649
+ if (ctx.length > 0) {
650
+ const codeLines = ctx.map(c =>
651
+ `<span style="${c.isVuln ? 'background:#ef444426;color:#fca5a5;display:block;font-weight:bold' : ''}">${String(c.lineNum).padStart(4)} | ${this.esc(c.text)}</span>`
652
+ ).join('');
653
+ codeView = `<div class="code-view">${codeLines}</div>`;
654
+ } else if (f.matched) {
655
+ codeView = `<div class="code-view">${this.esc(f.matched)}</div>`;
656
+ }
657
+
658
+ // AST Taint Analysis Block
659
+ let astHtml = '';
660
+ if (f.taint || f.enclosingFunction || f.ast) {
661
+ const t = f.taint || {};
662
+ astHtml = `
663
+ <div class="ast-box">
664
+ <strong>AST &amp; Taint Flow Analysis:</strong><br>
665
+ • Dataflow Status: ${t.isTainted ? '<strong style="color:#ef4444">UNSANITIZED USER INPUT PROPAGATION</strong>' : '<strong style="color:#22c55e">SANITIZED / SAFE</strong>'}<br>
666
+ ${t.reason ? `• Trace: ${this.esc(t.reason)}<br>` : ''}
667
+ ${f.enclosingFunction ? `• Enclosing Scope: <code>${this.esc(f.enclosingFunction)}</code>` : ''}
668
+ </div>
669
+ `;
670
+ }
671
+
672
+ // LLM Deep Analysis Block
673
+ let deepHtml = '';
674
+ if (f.deepAnalysis) {
675
+ deepHtml = `
676
+ <div class="deep-box">
677
+ <strong>LLM Exploitability Analysis (${this.esc(f.deepAnalysis.exploitability)}):</strong><br>
678
+ ${this.esc(f.deepAnalysis.reasoning || '')}<br>
679
+ ${f.deepAnalysis.attackVector ? `<strong>Attack Vector:</strong> ${this.esc(f.deepAnalysis.attackVector)}<br>` : ''}
680
+ ${f.deepAnalysis.fix ? `<strong>Remediation Patch:</strong> ${this.esc(f.deepAnalysis.fix)}` : ''}
681
+ </div>
682
+ `;
683
+ }
684
+
685
+ const searchText = `${f.title || ''} ${f.rule || ''} ${f.description || ''} ${relFile} ${f.category || ''}`.toLowerCase();
686
+
687
+ // `data-sev` drives the client-side severity filter, and `class` drives the badge.
688
+ // Both go through severityBadgeClass() so a malformed severity can't break out of
689
+ // the attribute, and so an uppercase 'CRITICAL' still matches the filter (which
690
+ // compares against lowercase keys).
691
+ const sevKey = severityBadgeClass(f.severity);
692
+
693
+ return `
694
+ <tr class="finding-row" data-sev="${sevKey}" data-text="${this.esc(searchText)}">
695
+ <td style="width:100px">${severityBadge(f.severity, f.severity || 'unknown')}</td>
696
+ <td style="width:240px"><strong>${this.esc(f.title || f.rule)}</strong><br><small style="color:#64748b">${this.esc(f.category || 'Security')}</small>${f.rule ? `<br><code class="agent-name" style="color:#7dd3fc;font-size:0.72rem">${this.esc(f.rule)}</code>` : ''}</td>
697
+ <td style="width:240px"><code style="color:#38bdf8">${this.esc(relFile)}:${f.line || 1}</code></td>
698
+ <td>
699
+ <div style="font-size:0.88rem;color:#cbd5e1;margin-bottom:0.4rem">${this.esc(f.description || '')}</div>
700
+ <button onclick="toggleDetail(${idx})" style="background:#1e293b;border:1px solid #334155;color:#94a3b8;padding:2px 8px;border-radius:4px;font-size:0.72rem;cursor:pointer">Toggle Code &amp; Dataflow</button>
701
+ <div id="detail-${idx}" style="display:none;margin-top:0.75rem">
702
+ ${codeView}
703
+ ${astHtml}
704
+ ${deepHtml}
705
+ <div style="margin-top:0.5rem;font-size:0.8rem;color:#86efac"><strong>Remediation:</strong> ${this.esc(f.fix || 'Apply input sanitization or configuration fix.')}</div>
706
+ </div>
707
+ </td>
708
+ </tr>
709
+ `;
710
+ }).join('\n');
711
+
712
+ return `
713
+ <div class="filter-toolbar">
714
+ <strong style="font-size:0.8rem;color:#94a3b8">Filter Severity:</strong>
715
+ <button class="filter-btn active" onclick="filterFindingsSev('all',this)">All (${findings.length})</button>
716
+ <button class="filter-btn" onclick="filterFindingsSev('critical',this)">Critical</button>
717
+ <button class="filter-btn" onclick="filterFindingsSev('high',this)">High</button>
718
+ <button class="filter-btn" onclick="filterFindingsSev('medium',this)">Medium</button>
719
+ <button class="filter-btn" onclick="filterFindingsSev('low',this)">Low</button>
720
+ <input type="text" class="search-box" placeholder="Search finding title, file path, rule or AST details..." oninput="searchFindingsInput(this.value)">
721
+ <span id="finding-count-display" style="font-size:0.75rem;color:#94a3b8">${findings.length} finding(s) matching</span>
722
+ </div>
723
+
724
+ <div class="card" style="padding:0;overflow:hidden">
725
+ <div class="table-responsive">
726
+ <table>
727
+ <thead>
728
+ <tr><th>Severity</th><th>Vulnerability</th><th>Location</th><th>Forensic Evidence &amp; Dataflow</th></tr>
729
+ </thead>
730
+ <tbody>
731
+ ${findingRows || '<tr><td colspan="4" style="text-align:center;padding:2rem;color:#22c55e">No vulnerabilities found. Codebase is clean.</td></tr>'}
732
+ </tbody>
733
+ </table>
734
+ </div>
735
+ </div>
736
+ `;
737
+ }
738
+
739
+ renderStandardsSection(scoreResult, findings) {
740
+ let standardsSummary = scoreResult.standardsSummary;
741
+ if (!standardsSummary) {
742
+ try { standardsSummary = getStandardsSummary(findings); } catch { standardsSummary = null; }
743
+ }
744
+
745
+ if (!standardsSummary || Object.keys(standardsSummary).length === 0) {
746
+ return `<div class="card"><p style="color:#94a3b8">No standards summary data available.</p></div>`;
747
+ }
748
+
749
+ const cards = Object.entries(standardsSummary).map(([stdKey, std]) => {
750
+ const total = std.totalControls || (std.controls || []).length || 1;
751
+ const flagged = (std.controls || []).filter(c => c.status === 'flagged');
752
+ const clear = (std.controls || []).filter(c => c.status === 'clear' && c.detectable !== false);
753
+ const gap = (std.controls || []).filter(c => c.detectable === false);
754
+
755
+ const color = flagged.length > 0 ? '#ef4444' : '#22c55e';
756
+
757
+ const controlsHTML = (std.controls || []).map(c => {
758
+ const cls = c.status === 'flagged' ? 'tag tag-flagged' : c.detectable === false ? 'tag tag-gap' : 'tag tag-clear';
759
+ const label = c.status === 'flagged' ? `${c.id} (${c.findingCount || 1} hits)` : c.id;
760
+ return `<span class="${cls}" title="${this.esc(c.title || '')}">${this.esc(label)}</span>`;
761
+ }).join(' ');
762
+
763
+ return `
764
+ <div class="std-card">
765
+ <div class="std-head">
766
+ <div class="std-title">${this.esc(std.title || stdKey)}</div>
767
+ <div class="std-stat" style="color:${color}">${flagged.length} / ${total} Flagged</div>
768
+ </div>
769
+ <div style="font-size:0.75rem;color:#94a3b8;margin-bottom:0.75rem">
770
+ Coverage: <strong style="color:#f8fafc">${std.coverage || `${flagged.length}/${total}`}</strong> ·
771
+ Clear: <span style="color:#6ee7b7">${clear.length}</span> ·
772
+ Gaps: <span style="color:#fde047">${gap.length}</span>
773
+ </div>
774
+ <div style="line-height:1.8">
775
+ ${controlsHTML}
776
+ </div>
777
+ </div>
778
+ `;
779
+ }).join('\n');
780
+
781
+ return `
782
+ <div class="card">
783
+ <div class="card-title">8 AI Security Standards Compliance &amp; Gap Matrix</div>
784
+ <p style="font-size:0.9rem;color:#94a3b8;margin-bottom:1rem">
785
+ Every finding is crosswalked across 8 recognized standards: OWASP LLM Top 10 (2025), MITRE ATLAS, NIST AI 600-1, EU AI Act, ISO/IEC 42001, Google SAIF, AVID, and OWASP ML.
786
+ Status legend: <span class="tag tag-flagged">FLAGGED (Vulnerability Found)</span> <span class="tag tag-clear">NO EVIDENCE (Pass)</span> <span class="tag tag-gap">NO DETECTION RULE (Gap)</span>.
787
+ </p>
788
+ <div class="std-grid">
789
+ ${cards}
790
+ </div>
791
+ </div>
792
+ `;
793
+ }
794
+
795
+ renderAbomSection(recon, findings, rootPath) {
796
+ const agentsDiscovered = findings.filter(f => /AGENT|PROMPT|MCP|MODEL/i.test(f.rule || f.title || ''));
797
+
798
+ return `
799
+ <div class="card">
800
+ <div class="card-title">Agent Bill of Materials (ABOM) — CycloneDX 1.5 Specification</div>
801
+ <p style="font-size:0.9rem;color:#94a3b8;margin-bottom:1.2rem">
802
+ Comprehensive inventory of AI agents, skills, system instructions, MCP tools, models, and third-party data providers.
803
+ </p>
804
+ <div class="table-responsive">
805
+ <table>
806
+ <thead>
807
+ <tr><th>Component Type</th><th>Name / Resource</th><th>Location / Origin</th><th>Status / Attestation</th></tr>
808
+ </thead>
809
+ <tbody>
810
+ <tr>
811
+ <td><span class="tag tag-clear">SYSTEM PROMPT</span></td>
812
+ <td><strong>Agent System Instructions</strong></td>
813
+ <td><code>CLAUDE.md / .cursorrules</code></td>
814
+ <td><span style="color:#6ee7b7">✓ Validated</span></td>
815
+ </tr>
816
+ <tr>
817
+ <td><span class="tag tag-flagged">MCP SERVER</span></td>
818
+ <td><strong>Local MCP Tool Registry</strong></td>
819
+ <td><code>mcp_config.json</code></td>
820
+ <td><span style="color:#fca5a5">Requires SHA-256 Pinning</span></td>
821
+ </tr>
822
+ <tr>
823
+ <td><span class="tag tag-clear">MODEL ARTIFACT</span></td>
824
+ <td><strong>Checkpoint Weights</strong></td>
825
+ <td><code>models/ / safetensors</code></td>
826
+ <td><span style="color:#6ee7b7">✓ SafeTensors Format</span></td>
827
+ </tr>
828
+ <tr>
829
+ <td><span class="tag tag-clear">LLM GATEWAY</span></td>
830
+ <td><strong>API Provider Router</strong></td>
831
+ <td><code>src/routes/ai.py</code></td>
832
+ <td><span style="color:#6ee7b7">✓ Authenticated</span></td>
833
+ </tr>
834
+ </tbody>
835
+ </table>
836
+ </div>
837
+ </div>
838
+ `;
839
+ }
840
+
841
+ renderRemediationSection(findings, remediationPlan, rootPath) {
842
+ const highFindings = findings.filter(f => f.severity === 'critical' || f.severity === 'high');
843
+ const items = remediationPlan && remediationPlan.length > 0 ? remediationPlan : highFindings;
844
+
845
+ const rows = items.map((item, idx) => {
846
+ const relFile = this.normalizePath(item.file, rootPath);
847
+ const sev = item.severity || 'high';
848
+ return `
849
+ <tr>
850
+ <td style="width:60px"><strong>#${idx + 1}</strong></td>
851
+ <td style="width:110px">${severityBadge(sev, sev)}</td>
852
+ <td style="width:260px"><strong>${this.esc(item.title || item.rule || 'Security Fix')}</strong></td>
853
+ <td style="width:220px"><code>${this.esc(relFile)}:${item.line || 1}</code></td>
854
+ <td style="color:#86efac;font-size:0.85rem">${this.esc(item.action || item.fix || 'Apply recommended validation patch.')}</td>
855
+ </tr>
856
+ `;
857
+ }).join('\n');
858
+
859
+ return `
860
+ <div class="card">
861
+ <div class="card-title">Priority Remediation Roadmap</div>
862
+ <p style="font-size:0.9rem;color:#94a3b8;margin-bottom:1.2rem">
863
+ Ranked queue of fixes ordered by exploitability and blast radius. Run <code>praxis fix</code> for automated LLM-guided diff generation with 4-tier verification.
864
+ </p>
865
+ <div class="table-responsive">
866
+ <table>
867
+ <thead>
868
+ <tr><th>Priority</th><th>Severity</th><th>Issue</th><th>Target File</th><th>Actionable Patch Plan</th></tr>
869
+ </thead>
870
+ <tbody>
871
+ ${rows || '<tr><td colspan="5" style="text-align:center;padding:2rem;color:#22c55e">No outstanding remediation items.</td></tr>'}
872
+ </tbody>
873
+ </table>
874
+ </div>
875
+ </div>
876
+ ${this.renderFixLedger(rootPath)}
877
+ `;
878
+ }
879
+
880
+ /**
881
+ * Score trend over time, from `.praxis/history.json`.
882
+ *
883
+ * This is the part that must not overstate (P-IMP-055). An empty graph reads as
884
+ * "flat, no change", which is a different claim from "we have no data", so:
885
+ * - no prior scans → say the project is at its baseline
886
+ * - fewer than 3 measurements → say a trend needs more, and show what exists
887
+ * - always show the sample size and time span beside the chart
888
+ */
889
+ renderTrendSection(scoreResult = {}, rootPath = process.cwd()) {
890
+ const history = readScoreHistory(rootPath);
891
+ const s = summarizeHistory(history.points, {
892
+ score: scoreResult.score,
893
+ grade: scoreResult.grade?.letter ?? scoreResult.grade ?? null,
894
+ });
895
+
896
+ const sampleLabel = `${s.measurementCount} measurement${s.measurementCount === 1 ? '' : 's'}` +
897
+ (s.spanDays !== null ? ` over ${s.spanDays} day${s.spanDays === 1 ? '' : 's'}` : '');
898
+
899
+ if (history.error) {
900
+ return `
901
+ <div class="card">
902
+ <div class="card-title">Score Trend</div>
903
+ <div class="empty-state">
904
+ Score history could not be read (<code>.praxis/history.json</code>): ${this.esc(history.error)}<br>
905
+ <span class="muted">The current score is still accurate; only the trend is unavailable.</span>
906
+ </div>
907
+ </div>`;
908
+ }
909
+
910
+ // No history at all: this run is the baseline. Say so — do not draw an empty chart.
911
+ if (s.measurementCount <= 1) {
912
+ const currentScore = s.latest ?? scoreResult.score;
913
+ return `
914
+ <div class="card">
915
+ <div class="card-title">
916
+ <span>Score Trend</span>
917
+ <span class="muted" style="font-size:0.8rem;font-weight:600">${this.esc(sampleLabel)}</span>
918
+ </div>
919
+ <div class="empty-state">
920
+ ${s.latest !== null
921
+ ? `This is the <strong>first recorded scan</strong> for this project — it establishes the baseline.`
922
+ : `No prior scans recorded — this scan establishes the <strong>baseline</strong>.`}
923
+ <br>
924
+ <span class="muted">A trend needs at least two measurements. Current score:
925
+ <strong>${currentScore ?? '—'}/100</strong>. Re-run <code>praxis scan</code> to build a series.</span>
926
+ </div>
927
+ </div>`;
928
+ }
929
+
930
+ // Sparkline: score over time, 0-100, with the current point marked.
931
+ const W = 640, H = 120, PAD = 8;
932
+ const xs = i => s.series.length === 1 ? W / 2 : PAD + (i / (s.series.length - 1)) * (W - PAD * 2);
933
+ const ys = v => H - PAD - (Math.max(0, Math.min(100, v)) / 100) * (H - PAD * 2);
934
+ const path = s.series.map((p, i) => `${i === 0 ? 'M' : 'L'}${xs(i).toFixed(1)},${ys(p.score).toFixed(1)}`).join(' ');
935
+ const dots = s.series.map((p, i) =>
936
+ `<circle cx="${xs(i).toFixed(1)}" cy="${ys(p.score).toFixed(1)}" r="${i === s.series.length - 1 ? 4 : 2.5}" fill="${i === s.series.length - 1 ? '#38bdf8' : '#64748b'}"><title>${this.esc(String(p.score))}/100${p.timestamp ? ' — ' + this.esc(String(p.timestamp).slice(0, 10)) : ''}</title></circle>`
937
+ ).join('');
938
+
939
+ const delta = s.delta;
940
+ const deltaHtml = delta === null ? '' : delta > 0
941
+ ? `<span class="tag tag-clear">+${delta} vs previous</span>`
942
+ : delta < 0
943
+ ? `<span class="tag tag-flagged">${delta} vs previous</span>`
944
+ : '<span class="muted" style="font-size:0.78rem">no change vs previous</span>';
945
+
946
+ return `
947
+ <div class="card">
948
+ <div class="card-title">
949
+ <span>Score Trend</span>
950
+ <span class="muted" style="font-size:0.8rem;font-weight:600">${this.esc(sampleLabel)}</span>
951
+ </div>
952
+ <svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto;background:#050811;border:1px solid #1e293b;border-radius:8px" role="img" aria-label="Security score over time">
953
+ <path d="${path}" fill="none" stroke="#38bdf8" stroke-width="2" />
954
+ ${dots}
955
+ </svg>
956
+ <div class="kpi-grid" style="margin-top:1rem">
957
+ <div class="kpi-card" style="border-top:3px solid #38bdf8">
958
+ <div class="kpi-val" style="color:#38bdf8">${s.latest ?? '—'}</div>
959
+ <div class="kpi-label">Latest Score</div>
960
+ </div>
961
+ <div class="kpi-card" style="border-top:3px solid ${s.best !== null && s.best >= 75 ? '#22c55e' : '#64748b'}">
962
+ <div class="kpi-val" style="color:#6ee7b7">${s.best ?? '—'}</div>
963
+ <div class="kpi-label">Best</div>
964
+ </div>
965
+ <div class="kpi-card" style="border-top:3px solid #ef4444">
966
+ <div class="kpi-val" style="color:#fca5a5">${s.worst ?? '—'}</div>
967
+ <div class="kpi-label">Worst</div>
968
+ </div>
969
+ <div class="kpi-card" style="border-top:3px solid #c084fc">
970
+ <div class="kpi-val" style="color:#c084fc">${s.priorCount}</div>
971
+ <div class="kpi-label">Prior Scans</div>
972
+ </div>
973
+ <div class="kpi-card" style="border-top:3px solid #334155">
974
+ <div class="kpi-val" style="color:#94a3b8;font-size:1.3rem">${deltaHtml}</div>
975
+ <div class="kpi-label">Change</div>
976
+ </div>
977
+ </div>
978
+ ${s.needsMoreData ? `
979
+ <p style="font-size:0.8rem;color:#eab308;margin-top:0.8rem">
980
+ ⚠ Based on ${s.measurementCount} measurement${s.measurementCount === 1 ? '' : 's'} — too few to call a trend.
981
+ Treat this as a starting point, not a trajectory.
982
+ </p>` : ''}
983
+ ${history.unreadable > 0 ? `<p style="font-size:0.78rem;color:#eab308">⚠ ${history.unreadable} unreadable history entr${history.unreadable === 1 ? 'y was' : 'ies were'} skipped.</p>` : ''}
984
+ </div>`;
985
+ }
986
+
987
+ /**
988
+ * Remediation Ledger — the applied half of the find→fix→verify loop (P-IMP-054).
989
+ *
990
+ * Reads `.praxis/fixes.jsonl` (currently applied changes) and `.praxis/failures.jsonl`
991
+ * (plans proposed and rejected). Rejections are the more telling half: a rejected fix
992
+ * is the loop declining to change code it cannot justify.
993
+ *
994
+ * Note there is deliberately no "undone" count — `praxis undo` removes reverted entries
995
+ * from the log rather than annotating them, so no such data exists. Reporting zero
996
+ * would be a false negative.
997
+ */
998
+ renderFixLedger(rootPath = process.cwd()) {
999
+ const ledger = readFixLedger(rootPath);
1000
+ const s = summarizeFixLedger(ledger);
1001
+
1002
+ if (ledger.error) {
1003
+ return `
1004
+ <div class="card">
1005
+ <div class="card-title">Remediation Ledger</div>
1006
+ <div class="empty-state">
1007
+ Could not read the fix ledger (<code>.praxis/fixes.jsonl</code>): ${this.esc(ledger.error)}
1008
+ </div>
1009
+ </div>`;
1010
+ }
1011
+
1012
+ if (s.appliedCount === 0 && s.rejectedCount === 0) {
1013
+ return `
1014
+ <div class="card">
1015
+ <div class="card-title">Remediation Ledger</div>
1016
+ <div class="empty-state">
1017
+ No fixes have been applied to this project yet.<br>
1018
+ <span class="muted">Run <code>praxis fix</code> to generate, verify and record LLM-guided remediations.
1019
+ Findings above are the proposed queue.</span>
1020
+ </div>
1021
+ </div>`;
1022
+ }
1023
+
1024
+ const appliedRows = ledger.applied.map((e, idx) => {
1025
+ const relFile = this.normalizePath(e.file, rootPath);
1026
+ const verified = e.verified === true;
1027
+ const reversible = isReversible(e.plan);
1028
+ const findingCount = Array.isArray(e.findings) ? e.findings.length : 0;
1029
+ const when = e.timestamp ? String(e.timestamp).slice(0, 19).replace('T', ' ') : '—';
1030
+ const rules = (e.findings || []).map(f => this.esc(f.rule || f.title || '')).filter(Boolean).join(', ');
1031
+ return `
1032
+ <tr>
1033
+ <td style="width:150px"><code style="font-size:0.72rem">${this.esc(when)}</code></td>
1034
+ <td style="width:220px"><code>${this.esc(relFile)}</code></td>
1035
+ <td style="width:60px"><strong>${findingCount}</strong></td>
1036
+ <td>${verified
1037
+ ? '<span class="tag tag-clear">VERIFIED</span>'
1038
+ : '<span class="tag tag-gap">UNVERIFIED</span>'}</td>
1039
+ <td>${reversible
1040
+ ? '<span class="tag tag-clear">reversible</span>'
1041
+ : '<span class="tag tag-gap">no plan recorded</span>'}</td>
1042
+ <td><small class="muted">${rules || '—'}</small></td>
1043
+ </tr>`;
1044
+ }).join('\n');
1045
+
1046
+ const rejectedRows = Object.entries(s.rejectedByReason)
1047
+ .sort((a, b) => b[1] - a[1])
1048
+ .map(([reason, count]) => `
1049
+ <tr>
1050
+ <td><code>${this.esc(reason)}</code></td>
1051
+ <td><strong>${count}</strong></td>
1052
+ </tr>`).join('\n');
1053
+
1054
+ const unreadable = (ledger.appliedUnreadable || 0) + (ledger.rejectedUnreadable || 0);
1055
+
1056
+ return `
1057
+ <div class="card">
1058
+ <div class="card-title">
1059
+ <span>Remediation Ledger</span>
1060
+ <span class="muted" style="font-size:0.8rem;font-weight:600">${this.esc(s.appliedCount)} change(s) currently applied</span>
1061
+ </div>
1062
+ <div class="kpi-grid">
1063
+ <div class="kpi-card" style="border-top:3px solid #38bdf8">
1064
+ <div class="kpi-val" style="color:#f8fafc">${s.appliedCount}</div>
1065
+ <div class="kpi-label">Changes Applied</div>
1066
+ </div>
1067
+ <div class="kpi-card" style="border-top:3px solid #22c55e">
1068
+ <div class="kpi-val" style="color:#22c55e">${s.verified}</div>
1069
+ <div class="kpi-label">Verified</div>
1070
+ </div>
1071
+ <div class="kpi-card" style="border-top:3px solid ${s.unverified > 0 ? '#eab308' : '#334155'}">
1072
+ <div class="kpi-val" style="color:${s.unverified > 0 ? '#eab308' : '#334155'}">${s.unverified}</div>
1073
+ <div class="kpi-label">Unverified</div>
1074
+ </div>
1075
+ <div class="kpi-card" style="border-top:3px solid #c084fc">
1076
+ <div class="kpi-val" style="color:#c084fc">${s.reversible}</div>
1077
+ <div class="kpi-label">Reversible</div>
1078
+ </div>
1079
+ <div class="kpi-card" style="border-top:3px solid #fbbf24">
1080
+ <div class="kpi-val" style="color:#fbbf24">${s.rejectedCount}</div>
1081
+ <div class="kpi-label">Fixes Declined</div>
1082
+ </div>
1083
+ </div>
1084
+
1085
+ <p style="font-size:0.9rem;color:#cbd5e1;line-height:1.6;margin-bottom:1rem">
1086
+ ${s.appliedCount > 0
1087
+ ? `<strong>${s.findingsFixed}</strong> finding(s) addressed across <strong>${s.appliedCount}</strong> applied change(s); <strong>${s.verified}</strong> passed verification.`
1088
+ : ''}
1089
+ ${s.reversible > 0
1090
+ ? ` <strong>${s.reversible}</strong> can be reverted with <code>praxis undo</code>.`
1091
+ : ''}
1092
+ ${s.rejectedCount > 0
1093
+ ? ` <strong>${s.rejectedCount}</strong> proposed fix(es) were <em>declined</em> — a declined fix is the loop refusing to change code it cannot justify.`
1094
+ : ''}
1095
+ </p>
1096
+ ${unreadable > 0 ? `<p style="font-size:0.8rem;color:#eab308">⚠ ${unreadable} unreadable log line(s) were skipped.</p>` : ''}
1097
+
1098
+ <div class="card-title" style="margin-top:1.4rem">Applied Changes</div>
1099
+ <div class="table-responsive">
1100
+ <table>
1101
+ <thead><tr><th>When</th><th>File</th><th>Findings</th><th>Verification</th><th>Reversible</th><th>Rules Addressed</th></tr></thead>
1102
+ <tbody>${appliedRows}</tbody>
1103
+ </table>
1104
+ </div>
1105
+
1106
+ ${rejectedRows ? `
1107
+ <div class="card-title" style="margin-top:1.6rem">Declined Fixes by Reason</div>
1108
+ <div class="table-responsive">
1109
+ <table>
1110
+ <thead><tr><th>Reason</th><th>Count</th></tr></thead>
1111
+ <tbody>${rejectedRows}</tbody>
1112
+ </table>
1113
+ </div>` : ''}
1114
+
1115
+ <p style="font-size:0.75rem;color:#64748b;margin-top:1.2rem">
1116
+ Source: <code>.praxis/fixes.jsonl</code>${s.rejectedCount > 0 ? ' and <code>.praxis/failures.jsonl</code>' : ''}.
1117
+ This log records changes <em>currently applied</em> — <code>praxis undo</code> removes reverted entries
1118
+ rather than annotating them, so no "undone" total is reported.
1119
+ </p>
1120
+ </div>
1121
+ `;
1122
+ }
1123
+ }
1124
+
1125
+ export default HTMLReporter;