project-athena 0.4.0 → 0.5.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 (51) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +10 -0
  3. package/dist/{activity-BKW7G4I5.js → activity-XCHKBZTG.js} +4 -4
  4. package/dist/{agents-DFSNEOP5.js → agents-HUJ4JXIB.js} +4 -4
  5. package/dist/{ai-ASL4FFQ7.js → ai-LGTLQKL4.js} +3 -3
  6. package/dist/{analyze-ZY3MXBOI.js → analyze-AP4XJT7D.js} +7 -7
  7. package/dist/{chunk-ZB7LC6FP.js → chunk-3KFOI2X4.js} +2 -2
  8. package/dist/{chunk-MPG3IEGB.js → chunk-44EGCEB2.js} +23 -14
  9. package/dist/{chunk-GGXPG6OE.js → chunk-4FETKOY3.js} +8 -7
  10. package/dist/chunk-4IIBVTBS.js +76 -0
  11. package/dist/{chunk-LEMPCNAY.js → chunk-56EXAGTM.js} +3 -3
  12. package/dist/{chunk-DZDI2HRT.js → chunk-6OSQMXUP.js} +5 -5
  13. package/dist/{chunk-OLPSEQS5.js → chunk-AKOI4VTD.js} +10 -1
  14. package/dist/{chunk-FVCDMQUQ.js → chunk-C25FDXOO.js} +133 -23
  15. package/dist/{chunk-7DZK6WSQ.js → chunk-DH5AGULY.js} +2 -2
  16. package/dist/{chunk-TDNZ2RYA.js → chunk-F65QINMR.js} +2 -2
  17. package/dist/{chunk-WVQC2EGO.js → chunk-GU3ZX2J7.js} +2 -2
  18. package/dist/{chunk-UAZLPNVC.js → chunk-GYQG4Q2N.js} +4 -4
  19. package/dist/chunk-IDODPGTX.js +595 -0
  20. package/dist/chunk-KJYSNZXS.js +934 -0
  21. package/dist/{chunk-3XSDI5WF.js → chunk-L2EHJCH4.js} +5 -5
  22. package/dist/{chunk-65TSRDQ3.js → chunk-P4TRQMES.js} +2 -2
  23. package/dist/{chunk-KHMBNT66.js → chunk-PZPMWCXS.js} +5 -5
  24. package/dist/{chunk-XDCKZ5TV.js → chunk-R5AX7VST.js} +2 -2
  25. package/dist/{chunk-QLW657NT.js → chunk-RQKGT2EK.js} +2 -2
  26. package/dist/{chunk-VSYBC3VY.js → chunk-UMWX4UTK.js} +2 -2
  27. package/dist/{chunk-RXNRMIZP.js → chunk-WUIC65QO.js} +2 -2
  28. package/dist/{chunk-JCGDR4UV.js → chunk-WWQ5NKEC.js} +3 -1
  29. package/dist/{chunk-RVTNSNGK.js → chunk-X7EYOAEX.js} +2 -2
  30. package/dist/chunk-Y6SDMZET.js +278 -0
  31. package/dist/{clean-MDBYH62P.js → clean-LQOG56AM.js} +2 -2
  32. package/dist/cli.js +2 -2
  33. package/dist/{context-PLPCAPCO.js → context-G5YHY3L2.js} +8 -8
  34. package/dist/{doctor-BX677KRE.js → doctor-L34QJ7WR.js} +8 -8
  35. package/dist/{event-TNT6CQZR.js → event-6YW6TGFI.js} +3 -3
  36. package/dist/{git-hook-YZNZGAAP.js → git-hook-Y7Y262F7.js} +2 -2
  37. package/dist/{init-WQCAOJ2R.js → init-427QR5AS.js} +7 -7
  38. package/dist/{mcp-U7EPH43L.js → mcp-DWNWRGJW.js} +51 -14
  39. package/dist/{memory-VSVNLEJC.js → memory-XBZCYQLW.js} +3 -3
  40. package/dist/{open-UJZRYF4E.js → open-LJMOB6U2.js} +33 -31
  41. package/dist/{program-CWJNGCTZ.js → program-DCP526LU.js} +46 -32
  42. package/dist/{review-OOKXSYMP.js → review-VEJBPACX.js} +10 -9
  43. package/dist/{rules-4FXJUCXB.js → rules-M4DLLXBL.js} +4 -4
  44. package/dist/scan-JVKCVHPD.js +939 -0
  45. package/dist/{security-LME3XFL3.js → security-JTJN3E7K.js} +13 -11
  46. package/dist/{staged-check-X24C3YRD.js → staged-check-PQSLGQBW.js} +11 -11
  47. package/dist/{status-RR57ZNGI.js → status-PO27BSOZ.js} +4 -4
  48. package/dist/{sync-KFJANRXF.js → sync-GVO2VHPI.js} +10 -10
  49. package/dist/{watch-CY7QHIYN.js → watch-TEHFO47V.js} +10 -10
  50. package/package.json +3 -1
  51. package/dist/chunk-SJ4DQFIJ.js +0 -379
@@ -0,0 +1,939 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ GATE_SEVERITIES,
4
+ TRIAGE_STATUSES,
5
+ baselineFindings,
6
+ evaluateFindings,
7
+ loadBaseline,
8
+ pruneBaselineFile,
9
+ triageFinding,
10
+ untriageFinding
11
+ } from "./chunk-KJYSNZXS.js";
12
+ import {
13
+ dependencyScanner,
14
+ loadFindings,
15
+ saveFindings
16
+ } from "./chunk-IDODPGTX.js";
17
+ import "./chunk-UMWX4UTK.js";
18
+ import {
19
+ createReviewScanner
20
+ } from "./chunk-C25FDXOO.js";
21
+ import {
22
+ CATEGORIES,
23
+ CONFIDENCES,
24
+ CONFIDENCE_RANK,
25
+ Coverage,
26
+ FINGERPRINT_VERSION,
27
+ Finding,
28
+ compareFindings,
29
+ dedupeFindings,
30
+ secretsScanner
31
+ } from "./chunk-Y6SDMZET.js";
32
+ import "./chunk-3IS2X2Z7.js";
33
+ import {
34
+ dim,
35
+ heading,
36
+ info,
37
+ isJson,
38
+ json,
39
+ line,
40
+ ok,
41
+ relativeTime,
42
+ spinner,
43
+ warn
44
+ } from "./chunk-Z6QWMJLK.js";
45
+ import {
46
+ requireProjectRoot
47
+ } from "./chunk-XFN7UZCS.js";
48
+ import "./chunk-KBO7XRBW.js";
49
+ import {
50
+ projectSession
51
+ } from "./chunk-GU3ZX2J7.js";
52
+ import {
53
+ ATHENA_VERSION
54
+ } from "./chunk-2R6PXY6Z.js";
55
+ import {
56
+ analyzeProject
57
+ } from "./chunk-44EGCEB2.js";
58
+ import "./chunk-PVCIGUIT.js";
59
+ import "./chunk-ELKFRTZT.js";
60
+ import {
61
+ changesSince,
62
+ git,
63
+ isSafeRef,
64
+ workingChanges
65
+ } from "./chunk-AKOI4VTD.js";
66
+ import "./chunk-SVTEJYMS.js";
67
+ import "./chunk-QCC4VJUH.js";
68
+ import {
69
+ SECRET_PATTERNS
70
+ } from "./chunk-WWQ5NKEC.js";
71
+ import {
72
+ loadConfig
73
+ } from "./chunk-2DY4PUR4.js";
74
+ import {
75
+ writeFileAtomic
76
+ } from "./chunk-IQQBAWSM.js";
77
+ import {
78
+ AthenaError,
79
+ EXIT
80
+ } from "./chunk-AD4OZCU2.js";
81
+ import "./chunk-XW3MLHI2.js";
82
+ import "./chunk-EUPETUWA.js";
83
+
84
+ // src/cli/commands/scan.ts
85
+ import { promises as fs } from "fs";
86
+ import path from "path";
87
+
88
+ // src/core/report/shared.ts
89
+ var TOOL_NAME = "athena";
90
+ var INFORMATION_URI = "https://athena.sabari.me";
91
+ var DISPLAY_ORDER = ["critical", "high", "unrated", "medium", "low", "info"];
92
+ var statusOf = (input, f) => input.statuses?.[f.fingerprint] ?? "open";
93
+ var statusKind = (s) => s.startsWith("triaged:") ? "triaged" : s;
94
+ var statusTag = (s) => s.startsWith("triaged:") ? `triaged (${s.slice("triaged:".length) || "unspecified"})` : s;
95
+ function summarize(input) {
96
+ const s = {
97
+ total: input.result.findings.length,
98
+ open: 0,
99
+ openBySeverity: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unrated: 0 },
100
+ openPotential: 0,
101
+ baselined: 0,
102
+ suppressed: 0,
103
+ triaged: 0,
104
+ excluded: 0
105
+ };
106
+ for (const f of input.result.findings) {
107
+ const k = statusKind(statusOf(input, f));
108
+ if (k === "open") {
109
+ s.open++;
110
+ s.openBySeverity[f.severity]++;
111
+ if (f.potential) s.openPotential++;
112
+ } else s[k]++;
113
+ }
114
+ return s;
115
+ }
116
+ function sortForDisplay(findings) {
117
+ return [...findings].sort((a, b) => DISPLAY_ORDER.indexOf(a.severity) - DISPLAY_ORDER.indexOf(b.severity) || compareFindings(a, b));
118
+ }
119
+ var displayTitle = (f) => f.potential && !/^potential\b/i.test(f.title) ? `Potential ${lowerFirst(f.title)}` : f.title;
120
+ var lowerFirst = (s) => /^[A-Z][a-z]/.test(s) ? s[0].toLowerCase() + s.slice(1) : s;
121
+ function relPath(file, root) {
122
+ let p = file.replace(/\\/g, "/");
123
+ if (root) {
124
+ const r = root.replace(/\\/g, "/").replace(/\/+$/, "");
125
+ if (p === r) p = ".";
126
+ else if (p.startsWith(`${r}/`)) p = p.slice(r.length + 1);
127
+ }
128
+ return p.replace(/^(?:\.\/)+/, "");
129
+ }
130
+ var fileOf = (f, root) => {
131
+ const file = f.location?.file ?? f.package?.manifest;
132
+ return file ? relPath(file, root) : void 0;
133
+ };
134
+ function whereOf(f, root) {
135
+ const file = fileOf(f, root);
136
+ const pkg = f.package ? `${f.package.name}${f.package.version ? `@${f.package.version}` : ""}` : "";
137
+ if (f.location) return `${file}${f.location.startLine ? `:${f.location.startLine}` : ""}`;
138
+ if (file && pkg) return `${file} (${pkg})`;
139
+ return pkg || file || "(no location)";
140
+ }
141
+ var engineText = (f) => `${f.engine.id}${f.alsoReportedBy.length ? ` (+${f.alsoReportedBy.join(", ")})` : ""}`;
142
+ var coverageGaps = (coverage) => coverage.filter((c) => c.status !== "ok");
143
+ var uncoveredCategories = (coverage) => CATEGORIES.filter((cat) => !coverage.some((c) => c.category === cat && c.status === "ok"));
144
+ function filesSkippedText(c) {
145
+ const s = c.filesSkipped;
146
+ if (!s) return "";
147
+ const parts = [
148
+ ["too large", s.tooLarge],
149
+ ["binary", s.binary],
150
+ ["minified", s.minified],
151
+ ["unreadable", s.unreadable],
152
+ ["unsupported", s.unsupported]
153
+ ].filter(([, n]) => n > 0).map(([k, n]) => `${n} ${k}`);
154
+ return parts.join(", ");
155
+ }
156
+ var coverageGapText = (c) => `${c.engine} (${c.category}${c.target ? `, ${c.target}` : ""}): ${c.status}${c.reason ? ` \u2014 ${c.reason}` : ""}`;
157
+ var scopeText = (r) => `${r.scope.mode}${r.scope.base ? ` vs ${r.scope.base}` : ""}${r.scope.files !== void 0 ? ` (${r.scope.files} file${r.scope.files === 1 ? "" : "s"})` : ""}`;
158
+ var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
159
+
160
+ // src/core/report/github.ts
161
+ var MAX_ANNOTATIONS = 50;
162
+ var KIND = { critical: "error", high: "error", medium: "warning", unrated: "warning", low: "notice", info: "notice" };
163
+ var ghData = (s) => String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
164
+ var ghProp = (s) => ghData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
165
+ function command(kind, props, message) {
166
+ const p = Object.entries(props).filter(([, v]) => v !== void 0 && v !== "").map(([k, v]) => `${k}=${typeof v === "number" ? v : ghProp(v)}`).join(",");
167
+ return `::${kind}${p ? ` ${p}` : ""}::${ghData(message)}`;
168
+ }
169
+ function renderGithub(input) {
170
+ const r = input.result;
171
+ const s = summarize(input);
172
+ const out = [];
173
+ const open = sortForDisplay(r.findings).filter((f) => statusKind(statusOf(input, f)) === "open");
174
+ for (const f of open.slice(0, MAX_ANNOTATIONS)) {
175
+ const loc = f.location;
176
+ const message = f.message && f.message !== f.title ? `${f.message} (${f.ruleId}, ${f.label}, ${f.engine.id})` : `${f.ruleId}, ${f.label}, ${f.engine.id}`;
177
+ out.push(
178
+ command(
179
+ KIND[f.severity],
180
+ {
181
+ file: fileOf(f, input.root),
182
+ line: loc?.startLine,
183
+ col: loc?.startLine ? loc.startColumn : void 0,
184
+ endLine: loc?.startLine ? loc.endLine : void 0,
185
+ endColumn: loc?.startLine && loc.endLine === loc.startLine ? loc.endColumn : void 0,
186
+ title: `Athena [${f.severity}]: ${displayTitle(f)}`
187
+ },
188
+ message
189
+ )
190
+ );
191
+ }
192
+ const rest = open.length - Math.min(open.length, MAX_ANNOTATIONS);
193
+ const hidden = ["baselined", "suppressed", "triaged", "excluded"].filter((k) => s[k] > 0).map((k) => `${s[k]} ${k}`);
194
+ const summary = `Athena: ${plural(s.open, "open finding")}${rest ? `, ${rest} not annotated (limit ${MAX_ANNOTATIONS})` : ""}${hidden.length ? `; not counted: ${hidden.join(", ")}` : ""}.`;
195
+ out.push(command(s.openBySeverity.critical + s.openBySeverity.high ? "error" : s.open ? "warning" : "notice", { title: "Athena scan" }, summary));
196
+ if (input.gate) {
197
+ const text = `Gate ${input.gate.passed ? "passed" : "failed"}${input.gate.reasons.length ? `: ${input.gate.reasons.join("; ")}` : "."}`;
198
+ out.push(command(input.gate.passed ? "notice" : "error", { title: "Athena gate" }, text));
199
+ }
200
+ for (const g of coverageGaps(r.coverage)) {
201
+ out.push(command(g.status === "failed" || g.status === "timeout" ? "warning" : "notice", { title: "Athena coverage gap" }, coverageGapText(g)));
202
+ }
203
+ if (!r.coverage.length) out.push(command("warning", { title: "Athena coverage gap" }, "No engine reported coverage: nothing was checked."));
204
+ const uncovered = uncoveredCategories(r.coverage);
205
+ if (uncovered.length) out.push(command("notice", { title: "Athena coverage" }, `Not covered by any engine: ${uncovered.join(", ")}.`));
206
+ return `${out.join("\n")}
207
+ `;
208
+ }
209
+
210
+ // src/core/report/json.ts
211
+ function toJsonReport(input) {
212
+ const r = input.result;
213
+ return {
214
+ schemaVersion: 1,
215
+ tool: { name: TOOL_NAME, version: input.toolVersion },
216
+ fingerprintVersion: FINGERPRINT_VERSION,
217
+ scope: r.scope,
218
+ scannedAt: r.scannedAt,
219
+ durationMs: r.durationMs,
220
+ summary: summarize(input),
221
+ findings: sortForDisplay(r.findings).map((f) => ({ ...f, status: statusOf(input, f) })),
222
+ coverage: r.coverage,
223
+ ...input.gate ? { gate: input.gate } : {},
224
+ ...input.ratings ? { ratings: input.ratings } : {}
225
+ };
226
+ }
227
+ var renderJson = (input) => `${JSON.stringify(toJsonReport(input), null, 2)}
228
+ `;
229
+
230
+ // src/core/report/markdown.ts
231
+ var SCAN_MARKER = "<!-- athena-scan:v1 -->";
232
+ var MAX_MARKDOWN_FINDINGS = 50;
233
+ var MAX_CELL = 200;
234
+ var SEV_LABEL = { critical: "Critical", high: "High", unrated: "Unrated", medium: "Medium", low: "Low", info: "Info" };
235
+ function mdText(s, max = MAX_CELL) {
236
+ let t = String(s).replace(/[\r\n\t\x00-\x1f\x7f]+/g, " ").trim();
237
+ if ([...t].length > max) t = `${[...t].slice(0, max - 1).join("")}\u2026`;
238
+ return t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/[\\`*_[\]|~]/g, (ch) => `\\${ch}`).replace(/^([#+=-])/, "\\$1").replace(/^(\d+)([.)])/, "$1\\$2").replace(/@/g, "@\u200B");
239
+ }
240
+ function mdCode(s, inTable = false) {
241
+ let t = String(s).replace(/[\r\n\x00-\x1f\x7f`]+/g, "");
242
+ if ([...t].length > MAX_CELL) t = `${[...t].slice(0, MAX_CELL - 1).join("")}\u2026`;
243
+ if (inTable) t = t.replace(/\|/g, "\\|");
244
+ return t ? `\`${t}\`` : "";
245
+ }
246
+ function renderMarkdown(input) {
247
+ const r = input.result;
248
+ const s = summarize(input);
249
+ const lines = [SCAN_MARKER, "### Athena scan", ""];
250
+ lines.push(`Scope: ${mdCode(scopeText(r))} \xB7 ${plural(s.open, "open finding")}${s.openPotential ? ` (${s.openPotential} potential)` : ""}`, "");
251
+ if (input.gate) {
252
+ lines.push(`**Gate: ${input.gate.passed ? "passed" : "failed"}**`);
253
+ for (const reason of input.gate.reasons) lines.push(`- ${mdText(reason)}`);
254
+ lines.push("");
255
+ }
256
+ if (s.open) {
257
+ lines.push("| Severity | Open |", "| --- | ---: |");
258
+ for (const sev of DISPLAY_ORDER) if (s.openBySeverity[sev]) lines.push(`| ${SEV_LABEL[sev]} | ${s.openBySeverity[sev]} |`);
259
+ lines.push("");
260
+ } else lines.push("No open findings.", "");
261
+ const hidden = ["baselined", "suppressed", "triaged", "excluded"].filter((k) => s[k] > 0).map((k) => `${s[k]} ${k}`);
262
+ if (hidden.length) lines.push(`Not counted: ${hidden.join(", ")}.`, "");
263
+ const open = sortForDisplay(r.findings).filter((f) => statusKind(statusOf(input, f)) === "open");
264
+ if (open.length) {
265
+ const shown = open.slice(0, MAX_MARKDOWN_FINDINGS);
266
+ lines.push("#### Open findings", "", "| Severity | Finding | Location | Rule | Source |", "| --- | --- | --- | --- | --- |");
267
+ for (const f of shown) {
268
+ lines.push(`| ${SEV_LABEL[f.severity]} | ${mdText(displayTitle(f))} | ${mdCode(whereOf(f, input.root), true)} | ${mdCode(f.ruleId, true)} | ${f.label} \xB7 ${mdText(engineText(f))} |`);
269
+ }
270
+ lines.push("");
271
+ if (open.length > shown.length) lines.push(`_Showing ${shown.length} of ${open.length} open findings; ${open.length - shown.length} more not shown. Run \`athena scan\` for the full list._`, "");
272
+ }
273
+ const ratings = Object.entries(input.ratings ?? {}).filter(([, v]) => v);
274
+ if (ratings.length) {
275
+ lines.push("#### Ratings", "", "| Category | Grade | Basis |", "| --- | :---: | --- |");
276
+ for (const [cat, rating] of ratings) lines.push(`| ${mdText(cat)} | ${rating.grade} | ${mdText(rating.basis)} |`);
277
+ lines.push("");
278
+ }
279
+ const gaps = coverageGaps(r.coverage);
280
+ const uncovered = uncoveredCategories(r.coverage);
281
+ lines.push("#### Coverage", "");
282
+ if (!r.coverage.length) lines.push("- **No engine reported coverage: nothing was checked.**");
283
+ for (const g of gaps) lines.push(`- ${mdCode(g.engine)} (${mdText(g.category)}${g.target ? `, ${mdCode(g.target)}` : ""}): **${g.status}**${g.reason ? ` \u2014 ${mdText(g.reason)}` : ""}`);
284
+ if (r.coverage.length && !gaps.length) lines.push(`- All ${plural(r.coverage.length, "engine")} ran cleanly.`);
285
+ const network = r.coverage.filter((c) => c.network).map((c) => mdCode(c.engine));
286
+ if (network.length) lines.push(`- Used the network: ${network.join(", ")}.`);
287
+ if (uncovered.length) lines.push(`- Not covered by any engine: ${uncovered.join(", ")}.`);
288
+ lines.push("");
289
+ lines.push(
290
+ `<sub>Generated by <a href="https://athena.sabari.me">Athena</a> ${mdText(input.toolVersion, 40)}. Findings show a type and location only, never a matched value. \u201CPotential\u201D findings are heuristics: check them.</sub>`
291
+ );
292
+ return `${lines.join("\n")}
293
+ `;
294
+ }
295
+
296
+ // src/core/report/rules.ts
297
+ var SECRET_HELP = "Remove the value from the code, load it from an environment variable or a secret manager, and rotate the credential: anyone with access to the repository or its history may already have it. Removing it from the latest commit does not remove it from Git history.";
298
+ var HEURISTIC_SECRETS = /* @__PURE__ */ new Set(["generic-secret-assignment", "env-secret-assignment"]);
299
+ var secretRules = SECRET_PATTERNS.map((p) => ({
300
+ id: `secret/${p.id}`,
301
+ category: "secret",
302
+ title: `${p.description} in source`,
303
+ description: HEURISTIC_SECRETS.has(p.id) ? `A value that looks like a credential (${p.description.toLowerCase()}) was found by a keyword and entropy heuristic. It may be a real secret or a false positive; check it.` : `A string matching the format of a ${p.description} was found in a tracked file. Athena reports the type and location only, never the value.`,
304
+ help: SECRET_HELP,
305
+ cwe: ["CWE-798"],
306
+ ...HEURISTIC_SECRETS.has(p.id) ? { potential: true } : {}
307
+ }));
308
+ var review = (id, title, description, help, cwe = []) => ({ id: `review/${id}`, category: "review", title, description, help, cwe });
309
+ var OTHER_RULES = [
310
+ {
311
+ id: "dependency/vulnerable-package",
312
+ category: "dependency",
313
+ title: "Dependency with a known vulnerability",
314
+ description: "A package in the dependency tree matches a published security advisory, as reported by the ecosystem audit tool (npm audit, pip-audit, cargo audit, \u2026).",
315
+ help: "Upgrade to a fixed version when one is available (see the advisory), or remove the dependency. If the vulnerable code path is not reachable, record that with a triage decision instead of ignoring the finding.",
316
+ cwe: ["CWE-1395"]
317
+ },
318
+ review("env-file", "Environment file in the change", "A .env-style file is part of the change. These usually hold credentials and machine-specific settings.", "Add it to .gitignore and commit a .env.example with variable names only.", ["CWE-538"]),
319
+ review("dependencies", "New dependencies", "The change adds dependencies to a manifest.", "Justify each addition; check its licence, maintenance and size."),
320
+ review("tests", "Source changed without tests", "Source files changed but no test files did.", "Add or update tests for the changed behaviour, or say why none are needed."),
321
+ review("api", "API surface changed", "Route or API handler files changed.", "Check authentication, authorization and error handling for the changed endpoints."),
322
+ review("database", "Database schema or migrations changed", "Schema or migration files changed.", "Check indexes, constraints and backward compatibility with running code and existing data."),
323
+ review("auth", "Authentication or authorization code changed", "Files that implement authentication or authorization changed.", "Review the change against the project security notes; such code deserves a second reviewer."),
324
+ review("large-files", "Large files added", "The change adds unusually large files.", "Consider whether these belong in Git (Git LFS, an artifact store, or generated at build time)."),
325
+ review("leftovers", "TODO/FIXME or debug statements added", "Added lines contain TODO/FIXME markers or debug statements.", "Remove debug output and resolve or track the TODOs before merging.", ["CWE-489"]),
326
+ review("vulnerabilities", "Known vulnerable dependencies", "The last dependency scan reported high or critical advisories.", "Run the dependency scan for details and upgrade the affected packages.", ["CWE-1395"]),
327
+ review("knowledge", "Project knowledge out of date", "Athena knowledge (.athena/) does not reflect these changes.", "Run `athena sync` so agents read current context."),
328
+ review("skipped", "Changed files not checked", "Some changed files could not be read (too large, binary or unreadable), so their contents were not checked.", "Check these files yourself; Athena makes no claim about them."),
329
+ review("policy-weakened", "Security policy changed on this branch", "The branch changes .athena/policy.json, baseline.json or triage.json, or adds an athena-ignore comment, compared with the base branch. High when the change relaxes the quality gate.", "Have a maintainer review the change. With `--policy-from <base>` the gate keeps using the base branch policy until this merges."),
330
+ review("suppression-without-reason", "Inline suppression without a reason", "An `athena-ignore <ruleId>` comment has no `-- <reason>`, so it was not applied.", "Add a reason: `athena-ignore <ruleId> -- <why this is safe>`."),
331
+ review("invalid-suppression", "Inline suppression with no valid rule id", "An `athena-ignore` comment does not name a valid rule id, so it was not applied.", "Name exactly one rule: `athena-ignore secret/stripe-key -- <reason>`. Run `athena scan --list-rules` for ids.")
332
+ ];
333
+ var RULES = new Map([...secretRules, ...OTHER_RULES].map((r) => [r.id, r]));
334
+ var ruleInfo = (id) => RULES.get(id);
335
+ var listRules = () => [...RULES.values()].sort((a, b) => a.id.localeCompare(b.id));
336
+
337
+ // src/core/report/sarif.ts
338
+ var SECURITY_SEVERITY = { critical: "9.5", high: "8.0", medium: "5.5", low: "3.0", info: "0.0", unrated: "7.0" };
339
+ var SARIF_LEVEL = { critical: "error", high: "error", medium: "warning", unrated: "warning", low: "note", info: "note" };
340
+ var SECURITY_CATEGORIES = /* @__PURE__ */ new Set(["secret", "dependency", "sast", "iac"]);
341
+ var SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
342
+ var SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
343
+ var uriOf = (p) => p.split("/").map(encodeURIComponent).join("/");
344
+ var isAbsolute = (p) => p.startsWith("/") || /^[A-Za-z]:\//.test(p);
345
+ function artifactLocation(file) {
346
+ if (isAbsolute(file)) return { uri: `file://${file.startsWith("/") ? "" : "/"}${uriOf(file)}` };
347
+ return { uri: uriOf(file), uriBaseId: "%SRCROOT%" };
348
+ }
349
+ function buildRule(id, findings) {
350
+ const info2 = ruleInfo(id);
351
+ const score = (f) => Number(SECURITY_SEVERITY[f.severity]);
352
+ const top = findings.reduce((a, b) => score(b) > score(a) ? b : a);
353
+ const severity = top.severity;
354
+ const confidence = findings.map((f) => f.confidence).reduce((a, b) => CONFIDENCE_RANK[b] > CONFIDENCE_RANK[a] ? b : a, "low");
355
+ const cwe = [.../* @__PURE__ */ new Set([...info2?.cwe ?? [], ...findings.flatMap((f) => f.cwe)])].sort();
356
+ const categories = [...new Set(findings.map((f) => f.category))];
357
+ const security = categories.some((c) => SECURITY_CATEGORIES.has(c)) || cwe.length > 0;
358
+ const withHelp = findings.find((f) => f.help);
359
+ const helpText = withHelp?.help?.text ?? info2?.help ?? top.message ?? top.title;
360
+ const helpUrl = withHelp?.help?.url ?? info2?.helpUrl;
361
+ return {
362
+ id,
363
+ shortDescription: { text: info2?.title ?? top.title },
364
+ ...info2?.description ? { fullDescription: { text: info2.description } } : {},
365
+ help: { text: helpText, markdown: `${mdText(helpText, 4e3)}${helpUrl && /^https?:\/\//.test(helpUrl) ? `
366
+
367
+ [More information](${encodeURI(helpUrl)})` : ""}` },
368
+ ...helpUrl && /^https?:\/\//.test(helpUrl) ? { helpUri: encodeURI(helpUrl) } : {},
369
+ defaultConfiguration: { level: SARIF_LEVEL[severity] },
370
+ properties: {
371
+ tags: [...categories, ...security ? ["security"] : [], ...cwe.map((c) => `external/cwe/${c.toLowerCase()}`)],
372
+ "security-severity": SECURITY_SEVERITY[severity],
373
+ precision: confidence
374
+ }
375
+ };
376
+ }
377
+ function notification(c) {
378
+ const level = c.status === "failed" || c.status === "timeout" ? "error" : c.status === "unavailable" ? "warning" : "note";
379
+ return {
380
+ level,
381
+ message: { text: `${c.engine} (${c.category}${c.target ? `, ${c.target}` : ""}): ${c.status}${c.reason ? ` \u2014 ${c.reason}` : ""}. Not covered by this run.` },
382
+ descriptor: { id: `athena/coverage/${c.status}` },
383
+ properties: { engine: c.engine, category: c.category, status: c.status, network: c.network, ...c.target ? { target: c.target } : {} }
384
+ };
385
+ }
386
+ function toSarif(input) {
387
+ const r = input.result;
388
+ const findings = sortForDisplay(r.findings).filter((f) => statusKind(statusOf(input, f)) !== "excluded");
389
+ const baselineApplied = Object.values(input.statuses ?? {}).includes("baselined") || r.scope.mode !== "all";
390
+ const ruleIds = [...new Set(findings.map((f) => f.ruleId))].sort();
391
+ const rules = ruleIds.map((id) => buildRule(id, findings.filter((f) => f.ruleId === id)));
392
+ const ruleIndex = new Map(ruleIds.map((id, i) => [id, i]));
393
+ const results = findings.map((f) => {
394
+ const status = statusOf(input, f);
395
+ const kind = statusKind(status);
396
+ const file = fileOf(f, input.root);
397
+ const loc = f.location;
398
+ const region = loc?.startLine ? {
399
+ startLine: loc.startLine,
400
+ ...loc.startColumn ? { startColumn: loc.startColumn } : {},
401
+ ...loc.endLine && loc.endLine >= loc.startLine ? { endLine: loc.endLine } : {},
402
+ ...loc.endColumn && (loc.endLine ?? loc.startLine) >= loc.startLine ? { endColumn: loc.endColumn } : {}
403
+ } : void 0;
404
+ const note = input.notes?.[f.fingerprint];
405
+ const suppression = kind === "suppressed" ? { kind: "inSource", status: "accepted", justification: note ?? "Suppressed by an inline Athena comment." } : kind === "triaged" ? { kind: "external", status: "accepted", justification: note ?? `Triaged as ${status.slice("triaged:".length) || "unspecified"} in Athena triage.` } : kind === "baselined" ? { kind: "external", status: "accepted", justification: note ?? "Accepted in the Athena baseline (existing finding)." } : void 0;
406
+ const title = displayTitle(f);
407
+ return {
408
+ ruleId: f.ruleId,
409
+ ruleIndex: ruleIndex.get(f.ruleId),
410
+ level: SARIF_LEVEL[f.severity],
411
+ message: { text: f.message && f.message !== f.title ? `${title}: ${f.message}` : title },
412
+ ...file ? { locations: [{ physicalLocation: { artifactLocation: artifactLocation(file), ...region ? { region } : {} } }] } : {},
413
+ partialFingerprints: { [`athena/v${FINGERPRINT_VERSION}`]: f.fingerprint },
414
+ ...baselineApplied ? { baselineState: kind === "baselined" ? "unchanged" : "new" } : {},
415
+ ...suppression ? { suppressions: [suppression] } : {},
416
+ properties: {
417
+ severity: f.severity,
418
+ confidence: f.confidence,
419
+ category: f.category,
420
+ label: f.label,
421
+ potential: f.potential,
422
+ engine: f.engine.id,
423
+ ...f.engine.version ? { engineVersion: f.engine.version } : {},
424
+ alsoReportedBy: f.alsoReportedBy,
425
+ status,
426
+ ...f.owasp.length ? { owasp: f.owasp } : {},
427
+ ...f.package ? { package: f.package } : {}
428
+ }
429
+ };
430
+ });
431
+ const gaps = r.coverage.filter((c) => c.status !== "ok");
432
+ const endTime = Number.isNaN(Date.parse(r.scannedAt)) ? void 0 : new Date(Date.parse(r.scannedAt)).toISOString();
433
+ return {
434
+ $schema: SARIF_SCHEMA,
435
+ version: "2.1.0",
436
+ runs: [
437
+ {
438
+ tool: {
439
+ driver: {
440
+ name: "Athena",
441
+ version: input.toolVersion,
442
+ ...SEMVER.test(input.toolVersion) ? { semanticVersion: input.toolVersion } : {},
443
+ informationUri: INFORMATION_URI,
444
+ rules
445
+ }
446
+ },
447
+ invocations: [
448
+ {
449
+ executionSuccessful: !r.coverage.some((c) => c.status === "failed" || c.status === "timeout"),
450
+ ...endTime ? { endTimeUtc: endTime } : {},
451
+ toolExecutionNotifications: gaps.map(notification)
452
+ }
453
+ ],
454
+ columnKind: "utf16CodeUnits",
455
+ results,
456
+ properties: {
457
+ scope: r.scope,
458
+ coverage: r.coverage.map((c) => ({ engine: c.engine, category: c.category, status: c.status, network: c.network, ...c.reason ? { reason: c.reason } : {}, ...c.target ? { target: c.target } : {} })),
459
+ ...input.gate ? { gate: input.gate } : {},
460
+ ...input.ratings ? { ratings: input.ratings } : {}
461
+ }
462
+ }
463
+ ]
464
+ };
465
+ }
466
+ var renderSarif = (input) => `${JSON.stringify(toSarif(input), null, 2)}
467
+ `;
468
+
469
+ // src/core/report/text.ts
470
+ import pc from "picocolors";
471
+ var BADGE = { critical: "CRITICAL", high: "HIGH", unrated: "UNRATED", medium: "MEDIUM", low: "LOW", info: "INFO" };
472
+ var BADGE_W = 8;
473
+ var INDENT = " ".repeat(4 + BADGE_W + 1);
474
+ function cut(s, max) {
475
+ const chars = [...s.replace(/[\r\n\t]+/g, " ")];
476
+ if (chars.length <= max) return chars.join("");
477
+ return max <= 1 ? chars.slice(0, Math.max(0, max)).join("") : `${chars.slice(0, max - 1).join("")}\u2026`;
478
+ }
479
+ var clean = (s) => s.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
480
+ var len = (s) => [...s].length;
481
+ function renderText(input, opts = {}) {
482
+ const c = pc.createColors(opts.color ?? pc.isColorSupported);
483
+ const W = Math.max(40, opts.width ?? (process.stdout.columns ? Math.min(process.stdout.columns, 160) : 100));
484
+ const r = input.result;
485
+ const out = [];
486
+ const sevColor = {
487
+ critical: (s2) => c.bold(c.red(s2)),
488
+ high: c.red,
489
+ unrated: c.magenta,
490
+ medium: c.yellow,
491
+ low: c.cyan,
492
+ info: c.dim
493
+ };
494
+ out.push(c.bold(cut(`Athena scan \u2014 ${scopeText(r)} \xB7 ${r.scannedAt}`, W)));
495
+ out.push("");
496
+ const s = summarize(input);
497
+ const bySev = DISPLAY_ORDER.filter((k) => s.openBySeverity[k] > 0).map((k) => `${s.openBySeverity[k]} ${k}`);
498
+ const headline = `Open findings: ${s.open}`;
499
+ const detail = `${bySev.length ? ` (${bySev.join(" \xB7 ")})` : ""}${s.openPotential ? ` \xB7 ${s.openPotential} potential (heuristic, check them)` : ""}`;
500
+ out.push(`${c.bold(headline)}${cut(detail, W - headline.length)}`);
501
+ const hidden = ["baselined", "suppressed", "triaged", "excluded"].filter((k) => s[k] > 0).map((k) => `${s[k]} ${k}`);
502
+ if (hidden.length) out.push(c.dim(cut(`Not counted: ${hidden.join(" \xB7 ")}`, W)));
503
+ const findings = sortForDisplay(r.findings);
504
+ for (const sev of DISPLAY_ORDER) {
505
+ const inSev = findings.filter((f) => f.severity === sev);
506
+ if (!inSev.length) continue;
507
+ out.push("", sevColor[sev](BADGE[sev]));
508
+ const cats = [...new Set(inSev.map((f) => f.category))].sort();
509
+ for (const cat of cats) {
510
+ out.push(` ${c.bold(cat)}`);
511
+ for (const f of inSev.filter((x) => x.category === cat)) out.push(...findingLines(f));
512
+ }
513
+ }
514
+ if (!findings.length) out.push("", "No findings.");
515
+ function findingLines(f) {
516
+ const status = statusOf(input, f);
517
+ const open = statusKind(status) === "open";
518
+ const badge = BADGE[f.severity].padEnd(BADGE_W);
519
+ const where2 = clean(whereOf(f, input.root));
520
+ const title = clean(displayTitle(f));
521
+ const tag = open ? "" : ` [${statusTag(status)}]`;
522
+ const room = W - 4 - BADGE_W - 1;
523
+ const right = ` ${where2}${tag}`;
524
+ const head = cut(cut(title, Math.max(12, room - len(right))) + right, room);
525
+ const meta = cut(`${f.ruleId} \xB7 ${f.label} \xB7 ${clean(engineText(f))}`, room);
526
+ if (!open) return [c.dim(` ${badge} ${head}`), c.dim(`${INDENT}${meta}`)];
527
+ const lines = [` ${sevColor[f.severity](badge)} ${head}`];
528
+ const msg = clean(f.message);
529
+ if (msg && msg !== f.title) lines.push(`${INDENT}${cut(msg, room)}`);
530
+ lines.push(c.dim(`${INDENT}${meta}`));
531
+ return lines;
532
+ }
533
+ if (input.gate) {
534
+ out.push("", `${c.bold("Gate:")} ${input.gate.passed ? c.green("PASSED") : c.red(c.bold("FAILED"))}`);
535
+ for (const reason of input.gate.reasons) out.push(` - ${cut(clean(reason), W - 4)}`);
536
+ }
537
+ const ratings = Object.entries(input.ratings ?? {});
538
+ if (ratings.length) {
539
+ out.push("", c.bold("Ratings"));
540
+ const cw = Math.max(...ratings.map(([k]) => k.length));
541
+ for (const [cat, rating] of ratings) if (rating) out.push(` ${cat.padEnd(cw)} ${c.bold(rating.grade)} ${cut(clean(rating.basis), W - cw - 7)}`);
542
+ }
543
+ out.push("", c.bold("Coverage"));
544
+ if (!r.coverage.length) out.push(c.yellow(cut(" No engine reported coverage: nothing was checked.", W)));
545
+ const ew = Math.max(0, ...r.coverage.map((x) => x.engine.length));
546
+ const kw = Math.max(0, ...r.coverage.map((x) => x.category.length));
547
+ const sw = Math.max(0, ...r.coverage.map((x) => x.status.length));
548
+ for (const cov of r.coverage) {
549
+ const details = [];
550
+ if (cov.target) details.push(cov.target);
551
+ if (cov.filesScanned !== void 0) details.push(plural(cov.filesScanned, "file") + " scanned");
552
+ const skipped = filesSkippedText(cov);
553
+ if (skipped) details.push(`skipped ${skipped}`);
554
+ if (cov.network) details.push("used network");
555
+ if (cov.reason) details.push(cov.reason);
556
+ const status = cov.status === "ok" ? c.green("ok") : cov.status === "failed" || cov.status === "timeout" ? c.red(cov.status) : c.yellow(cov.status);
557
+ const prefix = ` ${clean(cov.engine).padEnd(ew)} ${cov.category.padEnd(kw)} `;
558
+ const plain = cut(`${prefix}${cov.status.padEnd(sw)}${details.length ? ` ${clean(details.join(" \xB7 "))}` : ""}`.trimEnd(), W);
559
+ const head = `${prefix}${cov.status}`;
560
+ out.push(plain.startsWith(head) ? `${prefix}${status}${plain.slice(head.length)}` : plain);
561
+ }
562
+ const gaps = coverageGaps(r.coverage);
563
+ if (gaps.length) out.push(c.yellow(cut(` ${plural(gaps.length, "engine")} did not run cleanly; the results above are incomplete.`, W)));
564
+ const uncovered = uncoveredCategories(r.coverage);
565
+ if (uncovered.length) out.push(c.dim(cut(` Not covered by any engine: ${uncovered.join(", ")}`, W)));
566
+ return `${out.join("\n")}
567
+ `;
568
+ }
569
+
570
+ // src/core/report/index.ts
571
+ var REPORT_FORMATS = ["text", "json", "markdown", "github", "sarif"];
572
+ function render(format, input, opts = {}) {
573
+ switch (format) {
574
+ case "text":
575
+ return renderText(input, opts);
576
+ case "json":
577
+ return renderJson(input);
578
+ case "markdown":
579
+ return renderMarkdown(input);
580
+ case "github":
581
+ return renderGithub(input);
582
+ case "sarif":
583
+ return renderSarif(input);
584
+ }
585
+ }
586
+
587
+ // src/core/scanners/index.ts
588
+ function builtinScanners(opts = {}) {
589
+ const { review: review2, skipAudit, ...deps } = opts;
590
+ return [secretsScanner, ...skipAudit ? [] : [dependencyScanner(deps)], ...review2 ? [review2] : []];
591
+ }
592
+
593
+ // src/core/scanners/types.ts
594
+ async function runScanners(scanners, ctx) {
595
+ const started = Date.now();
596
+ const findings = [];
597
+ const coverage = [];
598
+ for (const s of scanners) {
599
+ ctx.signal?.throwIfAborted();
600
+ const t = Date.now();
601
+ try {
602
+ const out = await s.run(ctx);
603
+ for (const f of out.findings) findings.push(Finding.parse(f));
604
+ for (const c of out.coverage) coverage.push(Coverage.parse(c));
605
+ } catch (err) {
606
+ if (ctx.signal?.aborted) throw err;
607
+ coverage.push(Coverage.parse({ engine: s.id, category: s.category, status: "failed", reason: err.message.slice(0, 300), durationMs: Date.now() - t }));
608
+ }
609
+ }
610
+ return {
611
+ schemaVersion: 1,
612
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
613
+ durationMs: Date.now() - started,
614
+ scope: { mode: ctx.mode, ...ctx.base ? { base: ctx.base } : {}, ...ctx.files ? { files: ctx.files.length } : {} },
615
+ findings: dedupeFindings(findings),
616
+ coverage
617
+ };
618
+ }
619
+
620
+ // src/services/scan.ts
621
+ var SCAN_TARGETS = ["secrets", "deps", "review"];
622
+ function parseTargets(raw) {
623
+ if (raw === void 0) return void 0;
624
+ const alias = { secret: "secrets", secrets: "secrets", dep: "deps", deps: "deps", dependencies: "deps", dependency: "deps", review: "review" };
625
+ const out = /* @__PURE__ */ new Set();
626
+ for (const part of raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)) {
627
+ const t = alias[part];
628
+ if (!t) throw new AthenaError(`Unknown scanner in --only: ${part}`, `Use a comma-separated list of: ${SCAN_TARGETS.join(", ")}`);
629
+ out.add(t);
630
+ }
631
+ if (!out.size) throw new AthenaError("--only needs at least one scanner", `Use a comma-separated list of: ${SCAN_TARGETS.join(", ")}`);
632
+ return [...out];
633
+ }
634
+ async function filesInScope(root, mode, base, signal) {
635
+ if (mode === "all") return void 0;
636
+ const hint = "Athena did not scan this change. Fix the Git problem and run the scan again.";
637
+ if (mode === "base") {
638
+ if (!base || !isSafeRef(base)) throw new AthenaError(`Invalid base ref: ${base ?? "(none)"}`);
639
+ const changes2 = await changesSince(root, base, { signal });
640
+ if (!changes2) throw new AthenaError(`Could not compute the changes since ${base}.`, `Check that the ref exists and that the clone has enough history (fetch-depth: 0 in CI). ${hint}`, EXIT.NOT_AVAILABLE);
641
+ return changes2.filter((c) => c.status !== "deleted").map((c) => c.path);
642
+ }
643
+ if (mode === "staged") {
644
+ const r = await git(root, ["diff", "--cached", "--name-only", "-z", "--relative", "--no-color", "--diff-filter=d"], { signal });
645
+ if (!r.ok) throw new AthenaError("Could not list staged files.", hint, EXIT.NOT_AVAILABLE);
646
+ return r.stdout.split("\0").filter(Boolean);
647
+ }
648
+ const changes = await workingChanges(root, { signal });
649
+ if (!changes) throw new AthenaError("Could not list changed files (is this a Git repository?).", hint, EXIT.NOT_AVAILABLE);
650
+ return changes.filter((c) => c.status !== "deleted").map((c) => c.path);
651
+ }
652
+ async function runScan(root, opts) {
653
+ const targets = new Set(opts.only ?? (opts.mode === "all" ? ["secrets", "deps"] : ["secrets", "deps", "review"]));
654
+ const { config } = await loadConfig(root);
655
+ const files = await filesInScope(root, opts.mode, opts.base, opts.signal);
656
+ const ctx = { root, config, mode: opts.mode, ...opts.base ? { base: opts.base } : {}, ...files ? { files } : {}, offline: opts.offline, signal: opts.signal };
657
+ const model = targets.has("deps") ? await projectSession(root).model() ?? (await analyzeProject(root, { signal: opts.signal })).model : void 0;
658
+ const scanners = builtinScanners({
659
+ review: targets.has("review") ? createReviewScanner() : void 0,
660
+ skipAudit: !targets.has("deps"),
661
+ model,
662
+ timeoutMs: opts.timeoutMs,
663
+ onTool: opts.onTool
664
+ }).filter((s) => targets.has("secrets") || s.category !== "secret");
665
+ const result = await runScanners(scanners, ctx);
666
+ if (targets.has("review") && opts.mode === "all") {
667
+ result.coverage = result.coverage.map((c) => c.category === "review" && c.status === "skipped" ? Coverage.parse({ ...c, reason: "review checks need a change: use --base <ref>, --staged or --changed" }) : c);
668
+ }
669
+ if (opts.save && opts.mode === "all") await saveFindings(root, result);
670
+ return result;
671
+ }
672
+ function reportInput(root, result, evaluated) {
673
+ if (!evaluated) return { result, root, toolVersion: ATHENA_VERSION };
674
+ const statuses = {};
675
+ for (const [fp, s] of Object.entries(evaluated.statuses)) statuses[fp] = s.status;
676
+ const ratings = {};
677
+ for (const r of evaluated.ratings) if (r.rating) ratings[r.category] = { grade: r.rating, basis: r.basis };
678
+ return {
679
+ result: { ...result, findings: evaluated.findings },
680
+ statuses,
681
+ notes: evaluated.notes,
682
+ gate: { passed: evaluated.gate.passed, reasons: [...evaluated.gate.reasons, ...evaluated.gate.warnings.map((w) => `warning: ${w}`)] },
683
+ ratings,
684
+ root,
685
+ toolVersion: ATHENA_VERSION
686
+ };
687
+ }
688
+ async function scanAndEvaluate(root, opts) {
689
+ const result = await runScan(root, opts);
690
+ const evaluated = await evaluateFindings(root, result, opts);
691
+ return { result, evaluated };
692
+ }
693
+ function findByPrefix(findings, prefix) {
694
+ const p = prefix.trim().toLowerCase();
695
+ if (!/^[a-f0-9]{6,32}$/.test(p)) throw new AthenaError(`Not a fingerprint: ${prefix}`, "Use at least 6 hex characters of a fingerprint from `athena findings list`.");
696
+ const hits = findings.filter((f) => f.fingerprint.startsWith(p));
697
+ if (!hits.length) throw new AthenaError(`No finding with fingerprint ${p} in the last scan.`, "Run `athena scan` and `athena findings list` to see current fingerprints.", EXIT.ERROR, "not-found");
698
+ if (hits.length > 1) throw new AthenaError(`Fingerprint prefix ${p} matches ${hits.length} findings.`, "Use more characters.");
699
+ return hits[0];
700
+ }
701
+
702
+ // src/cli/commands/scan.ts
703
+ function scanMode(o) {
704
+ const picked = [o.base !== void 0, Boolean(o.staged), Boolean(o.changed)].filter(Boolean).length;
705
+ if (picked > 1) throw new AthenaError("Use only one of --base, --staged and --changed.");
706
+ return o.base !== void 0 ? "base" : o.staged ? "staged" : o.changed ? "changed" : "all";
707
+ }
708
+ function pick(flag, value, allowed) {
709
+ if (value === void 0) return void 0;
710
+ const v = value.toLowerCase();
711
+ if (!allowed.includes(v)) throw new AthenaError(`Invalid ${flag} value: ${value}`, `Use one of: ${allowed.join(", ")}`);
712
+ return v;
713
+ }
714
+ function printRules() {
715
+ const rules = listRules();
716
+ if (isJson()) return json(rules);
717
+ heading(`Athena rules (${rules.length})`);
718
+ for (const r of rules) line(` ${r.id.padEnd(44)} ${dim(`${r.category}${r.potential ? ", potential" : ""}`)} ${r.title}`);
719
+ line(dim("\nRun `athena explain <ruleId>` for details. External engines (later releases) bring their own rules."));
720
+ }
721
+ async function scanCommand(opts) {
722
+ if (opts.listRules) {
723
+ printRules();
724
+ return EXIT.OK;
725
+ }
726
+ const root = await requireProjectRoot(opts);
727
+ const mode = scanMode(opts);
728
+ const only = parseTargets(opts.only);
729
+ const format = pick("--format", opts.format ?? (isJson() ? "json" : "text"), REPORT_FORMATS);
730
+ const failOn = pick("--fail-on", opts.failOn === "moderate" ? "medium" : opts.failOn, GATE_SEVERITIES);
731
+ const minConfidence = pick("--min-confidence", opts.minConfidence, CONFIDENCES);
732
+ const unrated = pick("--unrated", opts.unrated, ["fail", "warn"]);
733
+ const interactive = format === "text" && !opts.output && !isJson();
734
+ const sp = interactive ? spinner("Scanning...") : null;
735
+ let scanned;
736
+ try {
737
+ scanned = await scanAndEvaluate(root, {
738
+ mode,
739
+ base: opts.base,
740
+ only,
741
+ offline: opts.offline,
742
+ signal: opts.signal,
743
+ onTool: (tool) => sp?.update(`Running ${tool}...`),
744
+ save: true,
745
+ noBaseline: opts.noBaseline,
746
+ policyFrom: opts.policyFrom,
747
+ gate: { failOn, minConfidence, unrated }
748
+ });
749
+ sp?.stop();
750
+ } catch (err) {
751
+ sp?.stop();
752
+ throw err;
753
+ }
754
+ const { result, evaluated } = scanned;
755
+ const report = render(format, reportInput(root, result, evaluated), { color: interactive ? void 0 : false });
756
+ if (opts.output) {
757
+ const out = path.resolve(opts.output);
758
+ await fs.mkdir(path.dirname(out), { recursive: true });
759
+ await writeFileAtomic(out, report.endsWith("\n") ? report : `${report}
760
+ `);
761
+ if (!isJson()) {
762
+ const open = Object.values(evaluated.statuses).filter((s) => s.status === "open").length;
763
+ info(`${format} report written to ${path.relative(process.cwd(), out) || out}: ${open} open finding(s); gate ${evaluated.gate.passed ? "passed" : "failed"}`);
764
+ }
765
+ } else {
766
+ process.stdout.write(report.endsWith("\n") ? report : `${report}
767
+ `);
768
+ }
769
+ if (!evaluated.gate.passed && !opts.noFail) {
770
+ process.stderr.write(`athena scan: quality gate failed: ${evaluated.gate.reasons.join("; ")}
771
+ `);
772
+ return 1;
773
+ }
774
+ return EXIT.OK;
775
+ }
776
+ async function explainCommand(ruleId, opts) {
777
+ const r = ruleInfo(ruleId.trim().toLowerCase());
778
+ if (!r) throw new AthenaError(`Unknown rule: ${ruleId}`, "Run `athena scan --list-rules` for the rule ids Athena knows.", EXIT.ERROR, "not-found");
779
+ void opts;
780
+ if (isJson()) {
781
+ json(r);
782
+ return EXIT.OK;
783
+ }
784
+ heading(`${r.id}${r.potential ? dim(' (heuristic: findings are "potential")') : ""}`);
785
+ line(r.title);
786
+ line(dim(`Category: ${r.category}${r.cwe.length ? ` \xB7 ${r.cwe.join(", ")}` : ""}`));
787
+ line();
788
+ line(r.description);
789
+ line();
790
+ heading("What to do");
791
+ line(r.help);
792
+ if (r.helpUrl) line(dim(r.helpUrl));
793
+ line();
794
+ line(dim(`To accept a specific finding: \`athena findings triage <fingerprint> <status> --reason \u2026\` or an inline \`athena-ignore ${r.id} -- <reason>\` comment.`));
795
+ return EXIT.OK;
796
+ }
797
+ async function fullScan(root, opts) {
798
+ if (opts.last) {
799
+ const last = await loadFindings(root);
800
+ if (!last) throw new AthenaError("No previous scan found.", "Run `athena scan` first, or drop --last.");
801
+ if (last.scope.mode !== "all") throw new AthenaError("The last scan was not a full-project scan.", "Run `athena scan` (no --base/--staged/--changed) first.");
802
+ return last;
803
+ }
804
+ const sp = isJson() ? null : spinner("Scanning the whole project...");
805
+ try {
806
+ const r = await runScan(root, { mode: "all", offline: opts.offline, signal: opts.signal, save: true, onTool: (t) => sp?.update(`Running ${t}...`) });
807
+ sp?.stop();
808
+ return r;
809
+ } catch (err) {
810
+ sp?.stop();
811
+ throw err;
812
+ }
813
+ }
814
+ async function baselineCreateCommand(opts) {
815
+ const root = await requireProjectRoot(opts);
816
+ if (!opts.update && !opts.force && await loadBaseline(root)) {
817
+ throw new AthenaError(".athena/baseline.json already exists.", "Use `athena baseline update` to add new findings, or `--force` to add to it anyway.");
818
+ }
819
+ const result = await fullScan(root, opts);
820
+ const r = await baselineFindings(root, result, { reason: opts.reason });
821
+ if (isJson()) json({ added: r.added.length, total: r.total, created: r.created });
822
+ else {
823
+ ok(`${r.created ? "Created" : "Updated"} .athena/baseline.json: ${r.added.length} finding(s) added, ${r.total} in total`);
824
+ line(dim(' Commit it. Baselined findings stay visible but only new findings fail the gate (scope "new").'));
825
+ const broken = result.coverage.filter((c) => c.status === "failed" || c.status === "timeout");
826
+ if (broken.length) warn(`Not in the baseline because the engine did not complete: ${broken.map((c) => `${c.engine} (${c.status})`).join(", ")}`);
827
+ }
828
+ return EXIT.OK;
829
+ }
830
+ async function baselinePruneCommand(opts) {
831
+ const root = await requireProjectRoot(opts);
832
+ if (!await loadBaseline(root)) throw new AthenaError("No .athena/baseline.json to prune.", "Create one with `athena baseline create`.");
833
+ const result = await fullScan(root, opts);
834
+ const r = await pruneBaselineFile(root, result);
835
+ if (isJson()) json({ removed: r.removed.length, keptUnverified: r.keptUnverified.length, total: r.total });
836
+ else {
837
+ ok(`Removed ${r.removed.length} baseline entr${r.removed.length === 1 ? "y" : "ies"} the scan no longer reports; ${r.total} remain`);
838
+ if (r.keptUnverified.length) line(dim(` Kept ${r.keptUnverified.length} entr${r.keptUnverified.length === 1 ? "y" : "ies"} whose engine did not run fully, so Athena can't tell whether they were fixed.`));
839
+ }
840
+ return EXIT.OK;
841
+ }
842
+ async function baselineShowCommand(opts) {
843
+ const root = await requireProjectRoot(opts);
844
+ const b = await loadBaseline(root);
845
+ if (isJson()) {
846
+ json(b ?? { entries: [] });
847
+ return EXIT.OK;
848
+ }
849
+ if (!b) {
850
+ line("No baseline (.athena/baseline.json). Every finding counts as new.");
851
+ return EXIT.OK;
852
+ }
853
+ heading(`Baseline: ${b.entries.length} entr${b.entries.length === 1 ? "y" : "ies"} (created ${relativeTime(b.createdAt)})`);
854
+ const byRule = /* @__PURE__ */ new Map();
855
+ for (const e of b.entries) byRule.set(e.ruleId, (byRule.get(e.ruleId) ?? 0) + 1);
856
+ for (const [rule, n] of [...byRule].sort((a, z) => z[1] - a[1])) line(` ${String(n).padStart(4)} ${rule}`);
857
+ return EXIT.OK;
858
+ }
859
+ async function lastEvaluated(root, signal) {
860
+ const result = await loadFindings(root);
861
+ if (!result) throw new AthenaError("No scan results yet.", "Run `athena scan` first.");
862
+ return { result, evaluated: await evaluateFindings(root, result, { signal }) };
863
+ }
864
+ var short = (fp) => fp.slice(0, 12);
865
+ var where = (f) => f.location ? `${f.location.file}${f.location.startLine ? `:${f.location.startLine}` : ""}` : f.package ? `${f.package.name}${f.package.version ? `@${f.package.version}` : ""}` : "";
866
+ async function findingsListCommand(opts) {
867
+ const root = await requireProjectRoot(opts);
868
+ const { result, evaluated } = await lastEvaluated(root, opts.signal);
869
+ const wantStatus = opts.status?.toLowerCase();
870
+ const rows = evaluated.findings.map((f) => ({ f, s: evaluated.statuses[f.fingerprint] })).filter(({ s }) => opts.all || wantStatus ? true : s.active).filter(({ s }) => !wantStatus || s.status === wantStatus || s.status.startsWith(`${wantStatus}:`) || s.triage === wantStatus).filter(({ f }) => !opts.severity || f.severity === opts.severity.toLowerCase()).filter(({ f }) => !opts.category || f.category === opts.category.toLowerCase());
871
+ if (isJson()) {
872
+ json({ scannedAt: result.scannedAt, scope: result.scope, findings: rows.map(({ f, s }) => ({ ...f, status: s.status, ...s.triage ? { triage: s.triage } : {} })) });
873
+ return EXIT.OK;
874
+ }
875
+ heading(`Findings from the scan ${relativeTime(result.scannedAt)} (${rows.length} shown)`);
876
+ for (const { f, s } of rows) {
877
+ const title = f.potential && !/^potential\b/i.test(f.title) ? `Potential ${f.title.charAt(0).toLowerCase()}${f.title.slice(1)}` : f.title;
878
+ line(` ${dim(short(f.fingerprint))} ${f.severity.padEnd(8)} ${title} ${dim(where(f))}${s.status === "open" ? "" : dim(` [${s.status}]`)}`);
879
+ }
880
+ if (!rows.length) line(dim(" None."));
881
+ if (!opts.all && !wantStatus) line(dim("\nShowing active findings. Use --all for baselined, suppressed, triaged and excluded ones too."));
882
+ return EXIT.OK;
883
+ }
884
+ async function findingsShowCommand(fp, opts) {
885
+ const root = await requireProjectRoot(opts);
886
+ const { evaluated } = await lastEvaluated(root, opts.signal);
887
+ const f = findByPrefix(evaluated.findings, fp);
888
+ const s = evaluated.statuses[f.fingerprint];
889
+ if (isJson()) {
890
+ json({ ...f, status: s.status, ...s.triage ? { triage: s.triage } : {}, ...s.reason ? { statusReason: s.reason } : {}, rule: ruleInfo(f.ruleId) ?? null });
891
+ return EXIT.OK;
892
+ }
893
+ heading(f.title);
894
+ line(dim(`${f.fingerprint} \xB7 ${f.ruleId} \xB7 ${f.category} \xB7 ${f.severity} \xB7 confidence ${f.confidence} \xB7 ${f.label}${f.potential ? " \xB7 potential" : ""}`));
895
+ if (where(f)) line(`Where: ${where(f)}`);
896
+ line(`Status: ${s.status}${s.triage === "to-review" ? " (triaged: to-review)" : ""}${s.reason ? dim(` \u2014 ${s.reason}`) : ""}`);
897
+ line(`Engine: ${f.engine.id}${f.alsoReportedBy.length ? ` (also: ${f.alsoReportedBy.join(", ")})` : ""}`);
898
+ if (f.cwe.length) line(`CWE: ${f.cwe.join(", ")}`);
899
+ if (f.package?.advisoryIds.length) line(`Advisories: ${f.package.advisoryIds.join(", ")}${f.package.fixedIn ? ` \xB7 fixed in ${f.package.fixedIn}` : ""}`);
900
+ line();
901
+ line(f.message);
902
+ const help = f.help?.text ?? ruleInfo(f.ruleId)?.help;
903
+ if (help) {
904
+ line();
905
+ line(dim(help));
906
+ }
907
+ return EXIT.OK;
908
+ }
909
+ async function findingsTriageCommand(fp, status, opts) {
910
+ const root = await requireProjectRoot(opts);
911
+ const { evaluated } = await lastEvaluated(root, opts.signal);
912
+ const f = findByPrefix(evaluated.findings, fp);
913
+ if (opts.clear || status === "clear") {
914
+ const removed = await untriageFinding(root, f.fingerprint);
915
+ if (isJson()) json({ fingerprint: f.fingerprint, removed });
916
+ else ok(removed ? `Removed the triage decision for ${short(f.fingerprint)}` : `${short(f.fingerprint)} had no triage decision`);
917
+ return EXIT.OK;
918
+ }
919
+ const st = pick("status", status, TRIAGE_STATUSES);
920
+ if (!opts.reason?.trim()) throw new AthenaError("A triage decision needs a reason.", 'Add --reason "<why>" (it is committed in .athena/triage.json for reviewers).');
921
+ await triageFinding(root, f.fingerprint, { status: st, reason: opts.reason.trim(), ...opts.by ? { by: opts.by } : {} });
922
+ if (isJson()) json({ fingerprint: f.fingerprint, status: st });
923
+ else {
924
+ ok(`${short(f.fingerprint)} (${f.ruleId}) triaged as ${st}`);
925
+ line(dim(" Recorded in .athena/triage.json \u2014 commit it so the decision is reviewed with the code."));
926
+ }
927
+ return EXIT.OK;
928
+ }
929
+ export {
930
+ baselineCreateCommand,
931
+ baselinePruneCommand,
932
+ baselineShowCommand,
933
+ explainCommand,
934
+ findingsListCommand,
935
+ findingsShowCommand,
936
+ findingsTriageCommand,
937
+ scanCommand
938
+ };
939
+ //# sourceMappingURL=scan-JVKCVHPD.js.map