infernoflow 0.18.0 → 0.20.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.
- package/dist/bin/infernoflow.mjs +72 -0
- package/dist/lib/commands/audit.mjs +335 -0
- package/dist/lib/commands/dashboard.mjs +248 -2
- package/dist/lib/commands/export.mjs +239 -0
- package/dist/lib/commands/health.mjs +309 -0
- package/dist/lib/commands/link.mjs +342 -0
- package/dist/lib/commands/monorepo.mjs +427 -0
- package/dist/lib/commands/scout.mjs +291 -0
- package/dist/lib/commands/snapshot.mjs +383 -0
- package/dist/lib/ui/errors.mjs +142 -0
- package/package.json +1 -1
package/dist/bin/infernoflow.mjs
CHANGED
|
@@ -42,6 +42,13 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
42
42
|
ci: "CI-native check: GitHub Actions annotations, GitLab code quality, exit codes",
|
|
43
43
|
notify: "Post capability drift summary to Slack or Discord",
|
|
44
44
|
report: "Generate a weekly/monthly HTML or Markdown report of capability activity",
|
|
45
|
+
monorepo: "Manage infernoflow across monorepo packages (init | list | status | diff | sync)",
|
|
46
|
+
link: "Link capabilities to Jira, Linear, or GitHub Issues tickets",
|
|
47
|
+
audit: "Classify capabilities by sensitivity (auth, payment, PII, admin) and generate security surface map",
|
|
48
|
+
scout: "Scan source files for undocumented capabilities not yet in the contract",
|
|
49
|
+
export: "Export contract to OpenAPI, Backstage catalog-info.yaml, CSV, or Markdown",
|
|
50
|
+
snapshot: "Save/diff/restore named snapshots of the capability contract",
|
|
51
|
+
health: "Compute a 0–100 health score across coverage, docs, freshness, completeness, drift",
|
|
45
52
|
};
|
|
46
53
|
|
|
47
54
|
const COMMAND_HANDLERS = {
|
|
@@ -77,6 +84,13 @@ const COMMAND_HANDLERS = {
|
|
|
77
84
|
ci: async (args) => (await import("../lib/commands/ci.mjs")).ciCommand(args),
|
|
78
85
|
notify: async (args) => (await import("../lib/commands/notify.mjs")).notifyCommand(args),
|
|
79
86
|
report: async (args) => (await import("../lib/commands/report.mjs")).reportCommand(args),
|
|
87
|
+
monorepo: async (args) => (await import("../lib/commands/monorepo.mjs")).monorepoCommand(args),
|
|
88
|
+
link: async (args) => (await import("../lib/commands/link.mjs")).linkCommand(args),
|
|
89
|
+
audit: async (args) => (await import("../lib/commands/audit.mjs")).auditCommand(args),
|
|
90
|
+
scout: async (args) => (await import("../lib/commands/scout.mjs")).scoutCommand(args),
|
|
91
|
+
export: async (args) => (await import("../lib/commands/export.mjs")).exportCommand(args),
|
|
92
|
+
snapshot: async (args) => (await import("../lib/commands/snapshot.mjs")).snapshotCommand(args),
|
|
93
|
+
health: async (args) => (await import("../lib/commands/health.mjs")).healthCommand(args),
|
|
80
94
|
};
|
|
81
95
|
|
|
82
96
|
function formatCommandsHelp() {
|
|
@@ -243,6 +257,64 @@ ${formatCommandsHelp()}
|
|
|
243
257
|
--fail-on <level> error | warning (default: error)
|
|
244
258
|
--json Machine-readable result + exit code
|
|
245
259
|
|
|
260
|
+
${bold("monorepo sub-commands:")}
|
|
261
|
+
init Run infernoflow init --adopt in every package
|
|
262
|
+
list List detected packages with their capability counts
|
|
263
|
+
status Show contract health across all packages
|
|
264
|
+
diff Show capability changes across packages (--package to filter)
|
|
265
|
+
sync Aggregate all contracts into inferno-monorepo.json
|
|
266
|
+
|
|
267
|
+
${bold("monorepo options:")}
|
|
268
|
+
--package <name> Filter to a specific package
|
|
269
|
+
--json Machine-readable output
|
|
270
|
+
|
|
271
|
+
${bold("link sub-commands:")}
|
|
272
|
+
(default) Link a capability to a ticket
|
|
273
|
+
list Show all capability→ticket links
|
|
274
|
+
status Show linked and unlinked capabilities
|
|
275
|
+
remove Remove a link by capability ID
|
|
276
|
+
|
|
277
|
+
${bold("link options:")}
|
|
278
|
+
--capability <id> Capability to link
|
|
279
|
+
--jira <TICKET> Jira ticket ID (e.g. PROJ-123)
|
|
280
|
+
--linear <ID> Linear issue ID
|
|
281
|
+
--github <NUM> GitHub issue number
|
|
282
|
+
--json Machine-readable output
|
|
283
|
+
|
|
284
|
+
${bold("audit options:")}
|
|
285
|
+
--format text|json|html Output format (default: text)
|
|
286
|
+
--out <path> Save to file (default: prints to stdout)
|
|
287
|
+
--fail-on high|medium Exit 1 if unreviewed caps at given severity exist
|
|
288
|
+
--json Machine-readable output
|
|
289
|
+
|
|
290
|
+
${bold("scout options:")}
|
|
291
|
+
--dir <dirs> Comma-separated directories to scan (default: src,lib,app,api,routes)
|
|
292
|
+
--apply Write discovered capabilities to the contract file
|
|
293
|
+
--min-confidence <0-1> Minimum confidence threshold (default: 0.6)
|
|
294
|
+
--json Machine-readable output
|
|
295
|
+
|
|
296
|
+
${bold("export options:")}
|
|
297
|
+
--format openapi|backstage|csv|markdown|json Output format (required)
|
|
298
|
+
--out <path> Output file path (default: project root, auto-named)
|
|
299
|
+
--json Machine-readable summary
|
|
300
|
+
|
|
301
|
+
${bold("snapshot sub-commands:")}
|
|
302
|
+
save <name> Save current contract as a named snapshot
|
|
303
|
+
list List all snapshots
|
|
304
|
+
show <name> Print a snapshot's capabilities
|
|
305
|
+
diff <name1> [<name2>] Diff two snapshots (omit name2 to diff against current)
|
|
306
|
+
restore <name> Overwrite contract with snapshot contents
|
|
307
|
+
delete <name> Delete a snapshot
|
|
308
|
+
|
|
309
|
+
${bold("snapshot options:")}
|
|
310
|
+
--json Machine-readable output
|
|
311
|
+
|
|
312
|
+
${bold("health options:")}
|
|
313
|
+
--fail-below <score> Exit 1 if health score is below this threshold (CI gate)
|
|
314
|
+
--watch Re-run every 30s (live terminal view)
|
|
315
|
+
--interval <secs> Watch interval in seconds (default: 30)
|
|
316
|
+
--json Machine-readable score + breakdown
|
|
317
|
+
|
|
246
318
|
${bold("Machine output:")}
|
|
247
319
|
${gray("status --json")}
|
|
248
320
|
${gray("check --json")}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow audit
|
|
3
|
+
*
|
|
4
|
+
* Classify capabilities by security sensitivity and generate a surface map.
|
|
5
|
+
* Tags each capability with one or more sensitivity labels:
|
|
6
|
+
* auth — authentication / authorization / sessions / tokens
|
|
7
|
+
* payment — billing, subscriptions, pricing, invoices
|
|
8
|
+
* pii — personal data, email, address, phone, GDPR scope
|
|
9
|
+
* admin — privileged operations, configuration, user management
|
|
10
|
+
* public — read-only, no auth required
|
|
11
|
+
*
|
|
12
|
+
* Stores results in inferno/audit.json (git-trackable).
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* infernoflow audit Run audit, print summary
|
|
16
|
+
* infernoflow audit --format json Machine-readable JSON to stdout
|
|
17
|
+
* infernoflow audit --format html Write HTML report
|
|
18
|
+
* infernoflow audit --out audit.html Custom output path
|
|
19
|
+
* infernoflow audit --fail-on high Exit 1 if any HIGH caps are unreviewed
|
|
20
|
+
* infernoflow audit --json Alias for --format json
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
26
|
+
|
|
27
|
+
const AUDIT_FILE = "audit.json";
|
|
28
|
+
|
|
29
|
+
// ── Sensitivity keyword maps ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const SENSITIVITY_RULES = [
|
|
32
|
+
{
|
|
33
|
+
tag: "auth",
|
|
34
|
+
severity: "high",
|
|
35
|
+
label: "Authentication / Authorization",
|
|
36
|
+
keywords: [
|
|
37
|
+
"auth", "login", "logout", "signin", "signout", "signup",
|
|
38
|
+
"password", "credential", "token", "session", "oauth",
|
|
39
|
+
"jwt", "permission", "role", "access", "privilege", "2fa",
|
|
40
|
+
"mfa", "sso", "saml", "openid", "verify", "authenticate",
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
tag: "payment",
|
|
45
|
+
severity: "high",
|
|
46
|
+
label: "Payment / Billing",
|
|
47
|
+
keywords: [
|
|
48
|
+
"payment", "billing", "invoice", "charge", "subscription",
|
|
49
|
+
"checkout", "stripe", "card", "credit", "debit", "price",
|
|
50
|
+
"plan", "tier", "coupon", "refund", "transaction", "purchase",
|
|
51
|
+
"order", "cart", "paypal", "wallet",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
tag: "pii",
|
|
56
|
+
severity: "high",
|
|
57
|
+
label: "Personal / PII Data",
|
|
58
|
+
keywords: [
|
|
59
|
+
"pii", "personal", "profile", "email", "phone", "address",
|
|
60
|
+
"name", "user", "account", "gdpr", "privacy", "data",
|
|
61
|
+
"export", "download", "delete account", "identity", "dob",
|
|
62
|
+
"birthday", "ssn", "passport", "tax",
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
tag: "admin",
|
|
67
|
+
severity: "medium",
|
|
68
|
+
label: "Admin / Privileged",
|
|
69
|
+
keywords: [
|
|
70
|
+
"admin", "manage", "config", "setting", "system", "deploy",
|
|
71
|
+
"migration", "seed", "reset", "purge", "archive", "batch",
|
|
72
|
+
"bulk", "impersonate", "override", "feature flag",
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
tag: "public",
|
|
77
|
+
severity: "low",
|
|
78
|
+
label: "Public / Read-only",
|
|
79
|
+
keywords: [
|
|
80
|
+
"list", "search", "view", "read", "fetch", "get", "show",
|
|
81
|
+
"display", "browse", "filter", "sort", "paginate",
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, unknown: 0 };
|
|
87
|
+
|
|
88
|
+
// ── Classification ────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function classifyCapability(cap) {
|
|
91
|
+
const text = [
|
|
92
|
+
typeof cap === "string" ? cap : "",
|
|
93
|
+
cap?.id || "",
|
|
94
|
+
cap?.name || "",
|
|
95
|
+
cap?.description || "",
|
|
96
|
+
(cap?.tags || []).join(" "),
|
|
97
|
+
].join(" ").toLowerCase();
|
|
98
|
+
|
|
99
|
+
const matched = [];
|
|
100
|
+
for (const rule of SENSITIVITY_RULES) {
|
|
101
|
+
if (rule.keywords.some(kw => text.includes(kw))) {
|
|
102
|
+
matched.push(rule);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Remove "public" if any higher-severity tag also matched
|
|
107
|
+
const hasHigher = matched.some(r => r.tag !== "public" && r.severity !== "low");
|
|
108
|
+
const filtered = hasHigher ? matched.filter(r => r.tag !== "public") : matched;
|
|
109
|
+
|
|
110
|
+
if (!filtered.length) {
|
|
111
|
+
return { tags: ["unknown"], severity: "unknown", labels: ["Unclassified"] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const severity = filtered.reduce((best, r) => {
|
|
115
|
+
return SEVERITY_ORDER[r.severity] > SEVERITY_ORDER[best] ? r.severity : best;
|
|
116
|
+
}, "low");
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
tags: filtered.map(r => r.tag),
|
|
120
|
+
severity,
|
|
121
|
+
labels: filtered.map(r => r.label),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Storage ───────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function readAudit(infernoDir) {
|
|
128
|
+
const p = path.join(infernoDir, AUDIT_FILE);
|
|
129
|
+
if (!fs.existsSync(p)) return {};
|
|
130
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function writeAudit(infernoDir, data) {
|
|
134
|
+
fs.writeFileSync(path.join(infernoDir, AUDIT_FILE), JSON.stringify(data, null, 2) + "\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readContract(infernoDir) {
|
|
138
|
+
for (const f of ["contract.json", "capabilities.json"]) {
|
|
139
|
+
const p = path.join(infernoDir, f);
|
|
140
|
+
if (fs.existsSync(p)) {
|
|
141
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Formatters ────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function severityColor(sev) {
|
|
150
|
+
if (sev === "high") return red;
|
|
151
|
+
if (sev === "medium") return yellow;
|
|
152
|
+
if (sev === "low") return green;
|
|
153
|
+
return gray;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function severityIcon(sev) {
|
|
157
|
+
if (sev === "high") return "🔴";
|
|
158
|
+
if (sev === "medium") return "🟡";
|
|
159
|
+
if (sev === "low") return "🟢";
|
|
160
|
+
return "⚪";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function printTextReport(results, stats) {
|
|
164
|
+
const bySeverity = { high: [], medium: [], low: [], unknown: [] };
|
|
165
|
+
for (const r of results) (bySeverity[r.severity] || bySeverity.unknown).push(r);
|
|
166
|
+
|
|
167
|
+
console.log();
|
|
168
|
+
console.log(` ${bold("🔥 infernoflow audit — Security Surface Map")}`);
|
|
169
|
+
console.log();
|
|
170
|
+
console.log(` ${bold(String(results.length))} capabilities scanned`);
|
|
171
|
+
console.log(` ${red(String(stats.high))} high · ${yellow(String(stats.medium))} medium · ${green(String(stats.low))} low · ${gray(String(stats.unknown))} unclassified`);
|
|
172
|
+
console.log();
|
|
173
|
+
|
|
174
|
+
for (const [sev, items] of Object.entries(bySeverity)) {
|
|
175
|
+
if (!items.length) continue;
|
|
176
|
+
const col = severityColor(sev);
|
|
177
|
+
console.log(` ${col(bold(`${sev.toUpperCase()} (${items.length})`))}`);
|
|
178
|
+
for (const item of items) {
|
|
179
|
+
const tagStr = item.tags.join(", ");
|
|
180
|
+
console.log(` ${col("▸")} ${bold(item.id.padEnd(30))} ${gray(tagStr)}`);
|
|
181
|
+
if (item.description) console.log(` ${gray(item.description.slice(0, 72))}`);
|
|
182
|
+
}
|
|
183
|
+
console.log();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (stats.unknown > 0) {
|
|
187
|
+
console.log(` ${gray("Tip: Add descriptions to unclassified capabilities for better detection.")}`);
|
|
188
|
+
console.log();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildHtmlReport(results, stats, runAt) {
|
|
193
|
+
const rows = results.map(r => {
|
|
194
|
+
const sev = r.severity;
|
|
195
|
+
const cls = sev === "high" ? "high" : sev === "medium" ? "med" : sev === "low" ? "low" : "unk";
|
|
196
|
+
const tags = r.tags.join(", ");
|
|
197
|
+
const icon = severityIcon(sev);
|
|
198
|
+
return `<tr class="${cls}"><td>${icon} ${sev}</td><td><strong>${escHtml(r.id)}</strong></td><td>${escHtml(tags)}</td><td>${escHtml(r.description || "")}</td></tr>`;
|
|
199
|
+
}).join("\n");
|
|
200
|
+
|
|
201
|
+
return `<!DOCTYPE html>
|
|
202
|
+
<html lang="en">
|
|
203
|
+
<head>
|
|
204
|
+
<meta charset="UTF-8">
|
|
205
|
+
<title>infernoflow audit</title>
|
|
206
|
+
<style>
|
|
207
|
+
body { font-family: system-ui, sans-serif; background: #0d0d0d; color: #e0e0e0; margin: 0; padding: 24px; }
|
|
208
|
+
h1 { color: #ff4500; margin-bottom: 4px; }
|
|
209
|
+
.meta { color: #888; font-size: 13px; margin-bottom: 24px; }
|
|
210
|
+
.stats { display: flex; gap: 16px; margin-bottom: 24px; }
|
|
211
|
+
.stat { background: #1a1a1a; border-radius: 8px; padding: 12px 20px; text-align: center; }
|
|
212
|
+
.stat .n { font-size: 28px; font-weight: bold; }
|
|
213
|
+
.stat .l { font-size: 12px; color: #888; }
|
|
214
|
+
.high .n { color: #f55; }
|
|
215
|
+
.med .n { color: #fa0; }
|
|
216
|
+
.low .n { color: #4c4; }
|
|
217
|
+
.unk .n { color: #888; }
|
|
218
|
+
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
219
|
+
th { background: #1a1a1a; padding: 8px 12px; text-align: left; color: #aaa; font-weight: 500; }
|
|
220
|
+
td { padding: 8px 12px; border-bottom: 1px solid #1e1e1e; vertical-align: top; }
|
|
221
|
+
tr.high td:first-child { color: #f55; }
|
|
222
|
+
tr.med td:first-child { color: #fa0; }
|
|
223
|
+
tr.low td:first-child { color: #4c4; }
|
|
224
|
+
tr.unk td:first-child { color: #888; }
|
|
225
|
+
tr:hover td { background: #181818; }
|
|
226
|
+
</style>
|
|
227
|
+
</head>
|
|
228
|
+
<body>
|
|
229
|
+
<h1>🔥 infernoflow audit</h1>
|
|
230
|
+
<p class="meta">Generated ${runAt}</p>
|
|
231
|
+
<div class="stats">
|
|
232
|
+
<div class="stat high"><div class="n">${stats.high}</div><div class="l">HIGH</div></div>
|
|
233
|
+
<div class="stat med"><div class="n">${stats.medium}</div><div class="l">MEDIUM</div></div>
|
|
234
|
+
<div class="stat low"><div class="n">${stats.low}</div><div class="l">LOW</div></div>
|
|
235
|
+
<div class="stat unk"><div class="n">${stats.unknown}</div><div class="l">UNKNOWN</div></div>
|
|
236
|
+
</div>
|
|
237
|
+
<table>
|
|
238
|
+
<thead><tr><th>Severity</th><th>Capability</th><th>Tags</th><th>Description</th></tr></thead>
|
|
239
|
+
<tbody>
|
|
240
|
+
${rows}
|
|
241
|
+
</tbody>
|
|
242
|
+
</table>
|
|
243
|
+
</body>
|
|
244
|
+
</html>`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function escHtml(str) {
|
|
248
|
+
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
export async function auditCommand(rawArgs) {
|
|
254
|
+
const args = rawArgs.slice(1);
|
|
255
|
+
const jsonMode = args.includes("--json") || args.includes("--format") && args[args.indexOf("--format") + 1] === "json";
|
|
256
|
+
const cwd = process.cwd();
|
|
257
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
258
|
+
|
|
259
|
+
if (!fs.existsSync(infernoDir)) {
|
|
260
|
+
const msg = "inferno/ not found. Run: infernoflow init";
|
|
261
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); }
|
|
262
|
+
else { warn(msg); }
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Parse flags
|
|
267
|
+
const fmtIdx = args.indexOf("--format");
|
|
268
|
+
const format = fmtIdx !== -1 ? args[fmtIdx + 1] : (jsonMode ? "json" : "text");
|
|
269
|
+
const outIdx = args.indexOf("--out");
|
|
270
|
+
const outPath = outIdx !== -1 ? args[outIdx + 1] : null;
|
|
271
|
+
const failOnIdx = args.indexOf("--fail-on");
|
|
272
|
+
const failOn = failOnIdx !== -1 ? args[failOnIdx + 1] : null;
|
|
273
|
+
|
|
274
|
+
const contract = readContract(infernoDir);
|
|
275
|
+
if (!contract) {
|
|
276
|
+
const msg = "No contract.json or capabilities.json found.";
|
|
277
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); }
|
|
278
|
+
else { warn(msg); }
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const rawCaps = contract.capabilities || [];
|
|
283
|
+
const runAt = new Date().toISOString();
|
|
284
|
+
|
|
285
|
+
// Classify every capability
|
|
286
|
+
const results = rawCaps.map(cap => {
|
|
287
|
+
const id = typeof cap === "string" ? cap : (cap.id || cap.name || "unknown");
|
|
288
|
+
const description = typeof cap === "string" ? "" : (cap.description || "");
|
|
289
|
+
const cls = classifyCapability(cap);
|
|
290
|
+
return { id, description, ...cls };
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Sort by severity descending
|
|
294
|
+
results.sort((a, b) => (SEVERITY_ORDER[b.severity] || 0) - (SEVERITY_ORDER[a.severity] || 0));
|
|
295
|
+
|
|
296
|
+
// Stats
|
|
297
|
+
const stats = { high: 0, medium: 0, low: 0, unknown: 0 };
|
|
298
|
+
for (const r of results) {
|
|
299
|
+
const key = r.severity in stats ? r.severity : "unknown";
|
|
300
|
+
stats[key]++;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Persist to audit.json
|
|
304
|
+
const auditData = {
|
|
305
|
+
runAt,
|
|
306
|
+
stats,
|
|
307
|
+
capabilities: results,
|
|
308
|
+
};
|
|
309
|
+
writeAudit(infernoDir, auditData);
|
|
310
|
+
|
|
311
|
+
// Output
|
|
312
|
+
if (format === "json" || jsonMode) {
|
|
313
|
+
console.log(JSON.stringify({ ok: true, ...auditData }));
|
|
314
|
+
} else if (format === "html") {
|
|
315
|
+
const html = buildHtmlReport(results, stats, runAt);
|
|
316
|
+
const dest = outPath || path.join(infernoDir, "audit.html");
|
|
317
|
+
fs.writeFileSync(dest, html);
|
|
318
|
+
if (!jsonMode) done(`HTML audit report written to ${bold(dest)}`);
|
|
319
|
+
} else {
|
|
320
|
+
printTextReport(results, stats);
|
|
321
|
+
if (!jsonMode) done(`Saved to ${bold(path.join(infernoDir, AUDIT_FILE))}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Exit code enforcement
|
|
325
|
+
if (failOn) {
|
|
326
|
+
const threshold = SEVERITY_ORDER[failOn] || 0;
|
|
327
|
+
const violations = results.filter(r => (SEVERITY_ORDER[r.severity] || 0) >= threshold);
|
|
328
|
+
if (violations.length > 0) {
|
|
329
|
+
if (!jsonMode) {
|
|
330
|
+
console.error(red(`\n ✗ ${violations.length} capability/capabilities at or above "${failOn}" severity — review required.\n`));
|
|
331
|
+
}
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|