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.
- package/CHANGELOG.md +31 -0
- package/README.md +10 -0
- package/dist/{activity-BKW7G4I5.js → activity-XCHKBZTG.js} +4 -4
- package/dist/{agents-DFSNEOP5.js → agents-HUJ4JXIB.js} +4 -4
- package/dist/{ai-ASL4FFQ7.js → ai-LGTLQKL4.js} +3 -3
- package/dist/{analyze-ZY3MXBOI.js → analyze-AP4XJT7D.js} +7 -7
- package/dist/{chunk-ZB7LC6FP.js → chunk-3KFOI2X4.js} +2 -2
- package/dist/{chunk-MPG3IEGB.js → chunk-44EGCEB2.js} +23 -14
- package/dist/{chunk-GGXPG6OE.js → chunk-4FETKOY3.js} +8 -7
- package/dist/chunk-4IIBVTBS.js +76 -0
- package/dist/{chunk-LEMPCNAY.js → chunk-56EXAGTM.js} +3 -3
- package/dist/{chunk-DZDI2HRT.js → chunk-6OSQMXUP.js} +5 -5
- package/dist/{chunk-OLPSEQS5.js → chunk-AKOI4VTD.js} +10 -1
- package/dist/{chunk-FVCDMQUQ.js → chunk-C25FDXOO.js} +133 -23
- package/dist/{chunk-7DZK6WSQ.js → chunk-DH5AGULY.js} +2 -2
- package/dist/{chunk-TDNZ2RYA.js → chunk-F65QINMR.js} +2 -2
- package/dist/{chunk-WVQC2EGO.js → chunk-GU3ZX2J7.js} +2 -2
- package/dist/{chunk-UAZLPNVC.js → chunk-GYQG4Q2N.js} +4 -4
- package/dist/chunk-IDODPGTX.js +595 -0
- package/dist/chunk-KJYSNZXS.js +934 -0
- package/dist/{chunk-3XSDI5WF.js → chunk-L2EHJCH4.js} +5 -5
- package/dist/{chunk-65TSRDQ3.js → chunk-P4TRQMES.js} +2 -2
- package/dist/{chunk-KHMBNT66.js → chunk-PZPMWCXS.js} +5 -5
- package/dist/{chunk-XDCKZ5TV.js → chunk-R5AX7VST.js} +2 -2
- package/dist/{chunk-QLW657NT.js → chunk-RQKGT2EK.js} +2 -2
- package/dist/{chunk-VSYBC3VY.js → chunk-UMWX4UTK.js} +2 -2
- package/dist/{chunk-RXNRMIZP.js → chunk-WUIC65QO.js} +2 -2
- package/dist/{chunk-JCGDR4UV.js → chunk-WWQ5NKEC.js} +3 -1
- package/dist/{chunk-RVTNSNGK.js → chunk-X7EYOAEX.js} +2 -2
- package/dist/chunk-Y6SDMZET.js +278 -0
- package/dist/{clean-MDBYH62P.js → clean-LQOG56AM.js} +2 -2
- package/dist/cli.js +2 -2
- package/dist/{context-PLPCAPCO.js → context-G5YHY3L2.js} +8 -8
- package/dist/{doctor-BX677KRE.js → doctor-L34QJ7WR.js} +8 -8
- package/dist/{event-TNT6CQZR.js → event-6YW6TGFI.js} +3 -3
- package/dist/{git-hook-YZNZGAAP.js → git-hook-Y7Y262F7.js} +2 -2
- package/dist/{init-WQCAOJ2R.js → init-427QR5AS.js} +7 -7
- package/dist/{mcp-U7EPH43L.js → mcp-DWNWRGJW.js} +51 -14
- package/dist/{memory-VSVNLEJC.js → memory-XBZCYQLW.js} +3 -3
- package/dist/{open-UJZRYF4E.js → open-LJMOB6U2.js} +33 -31
- package/dist/{program-CWJNGCTZ.js → program-DCP526LU.js} +46 -32
- package/dist/{review-OOKXSYMP.js → review-VEJBPACX.js} +10 -9
- package/dist/{rules-4FXJUCXB.js → rules-M4DLLXBL.js} +4 -4
- package/dist/scan-JVKCVHPD.js +939 -0
- package/dist/{security-LME3XFL3.js → security-JTJN3E7K.js} +13 -11
- package/dist/{staged-check-X24C3YRD.js → staged-check-PQSLGQBW.js} +11 -11
- package/dist/{status-RR57ZNGI.js → status-PO27BSOZ.js} +4 -4
- package/dist/{sync-KFJANRXF.js → sync-GVO2VHPI.js} +10 -10
- package/dist/{watch-CY7QHIYN.js → watch-TEHFO47V.js} +10 -10
- package/package.json +3 -1
- package/dist/chunk-SJ4DQFIJ.js +0 -379
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
CATEGORIES,
|
|
4
|
+
CONFIDENCES,
|
|
5
|
+
CONFIDENCE_RANK,
|
|
6
|
+
Finding,
|
|
7
|
+
SEVERITIES,
|
|
8
|
+
SEVERITY_RANK,
|
|
9
|
+
codeFingerprint,
|
|
10
|
+
compareFindings,
|
|
11
|
+
dedupeFindings,
|
|
12
|
+
fileFingerprint,
|
|
13
|
+
normalizeLine,
|
|
14
|
+
occurrenceCounter
|
|
15
|
+
} from "./chunk-Y6SDMZET.js";
|
|
16
|
+
import {
|
|
17
|
+
GitCommandError,
|
|
18
|
+
git,
|
|
19
|
+
isSafeRef
|
|
20
|
+
} from "./chunk-AKOI4VTD.js";
|
|
21
|
+
import {
|
|
22
|
+
scanText
|
|
23
|
+
} from "./chunk-WWQ5NKEC.js";
|
|
24
|
+
import {
|
|
25
|
+
readTextIfExists,
|
|
26
|
+
readTextInsideRoot,
|
|
27
|
+
writeFileAtomic
|
|
28
|
+
} from "./chunk-IQQBAWSM.js";
|
|
29
|
+
import {
|
|
30
|
+
AthenaError,
|
|
31
|
+
EXIT
|
|
32
|
+
} from "./chunk-AD4OZCU2.js";
|
|
33
|
+
import {
|
|
34
|
+
ATHENA_DIR
|
|
35
|
+
} from "./chunk-EUPETUWA.js";
|
|
36
|
+
|
|
37
|
+
// src/core/policy/policy.ts
|
|
38
|
+
import path from "path";
|
|
39
|
+
import ignoreFactory from "ignore";
|
|
40
|
+
import { z } from "zod";
|
|
41
|
+
var POLICY_FILE = `${ATHENA_DIR}/policy.json`;
|
|
42
|
+
var GATE_SEVERITIES = ["critical", "high", "medium", "low", "info"];
|
|
43
|
+
var DEFAULT_GATE_CATEGORIES = ["secret", "dependency", "sast", "iac", "review"];
|
|
44
|
+
var RuleKey = z.string().regex(/^[a-z0-9][a-z0-9._/-]{1,120}(\/\*)?$/, 'expected a rule id such as "secret/stripe-key" or a prefix such as "sast/js/*"');
|
|
45
|
+
var Glob = z.string().min(1).max(500);
|
|
46
|
+
var RuleSetting = z.union([
|
|
47
|
+
z.literal("off"),
|
|
48
|
+
z.strictObject({
|
|
49
|
+
severity: z.enum(SEVERITIES).optional(),
|
|
50
|
+
confidence: z.enum(CONFIDENCES).optional()
|
|
51
|
+
})
|
|
52
|
+
]);
|
|
53
|
+
var Policy = z.strictObject({
|
|
54
|
+
schemaVersion: z.literal(1).default(1),
|
|
55
|
+
gate: z.strictObject({
|
|
56
|
+
failOn: z.enum(GATE_SEVERITIES).default("high"),
|
|
57
|
+
minConfidence: z.enum(CONFIDENCES).default("medium"),
|
|
58
|
+
unrated: z.enum(["fail", "warn"]).default("fail"),
|
|
59
|
+
scope: z.enum(["new", "all"]).default("new"),
|
|
60
|
+
categories: z.array(z.enum(CATEGORIES)).default([...DEFAULT_GATE_CATEGORIES])
|
|
61
|
+
}).prefault({}),
|
|
62
|
+
rules: z.record(RuleKey, RuleSetting).default({}),
|
|
63
|
+
paths: z.strictObject({
|
|
64
|
+
exclude: z.array(Glob).default([]),
|
|
65
|
+
tests: z.array(Glob).default([])
|
|
66
|
+
}).prefault({}),
|
|
67
|
+
licenses: z.strictObject({
|
|
68
|
+
deny: z.array(z.string().min(1)).default([]),
|
|
69
|
+
warn: z.array(z.string().min(1)).default([])
|
|
70
|
+
}).prefault({}),
|
|
71
|
+
external: z.strictObject({
|
|
72
|
+
semgrep: z.enum(["auto", "off"]).default("auto"),
|
|
73
|
+
gitleaks: z.enum(["auto", "off"]).default("auto"),
|
|
74
|
+
trivy: z.enum(["auto", "off"]).default("auto"),
|
|
75
|
+
"osv-scanner": z.enum(["auto", "off"]).default("auto"),
|
|
76
|
+
network: z.enum(["allow", "deny"]).default("allow")
|
|
77
|
+
}).prefault({})
|
|
78
|
+
});
|
|
79
|
+
var DEFAULT_POLICY = Policy.parse({});
|
|
80
|
+
var PolicyError = class extends Error {
|
|
81
|
+
constructor(message, hint) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.hint = hint;
|
|
84
|
+
this.name = "PolicyError";
|
|
85
|
+
}
|
|
86
|
+
hint;
|
|
87
|
+
};
|
|
88
|
+
function zodProblem(source, error) {
|
|
89
|
+
const issue = error.issues[0];
|
|
90
|
+
const where = issue && issue.path.length ? issue.path.map(String).join(".") : "(root)";
|
|
91
|
+
return `${source} is invalid at ${where}: ${issue?.message ?? "invalid"}`;
|
|
92
|
+
}
|
|
93
|
+
function parseCommittedJson(schema, text, source) {
|
|
94
|
+
let json;
|
|
95
|
+
try {
|
|
96
|
+
json = JSON.parse(text);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw new PolicyError(`${source} is not valid JSON (${err.message}).`, `Fix or delete ${source}.`);
|
|
99
|
+
}
|
|
100
|
+
const parsed = schema.safeParse(json);
|
|
101
|
+
if (!parsed.success) throw new PolicyError(zodProblem(source, parsed.error), `Fix ${source}; see the policy documentation for the format.`);
|
|
102
|
+
return parsed.data;
|
|
103
|
+
}
|
|
104
|
+
function parsePolicy(text, source = POLICY_FILE) {
|
|
105
|
+
return parseCommittedJson(Policy, text, source);
|
|
106
|
+
}
|
|
107
|
+
async function loadPolicy(root) {
|
|
108
|
+
const raw = await readTextIfExists(path.join(root, POLICY_FILE));
|
|
109
|
+
return raw === null ? DEFAULT_POLICY : parsePolicy(raw);
|
|
110
|
+
}
|
|
111
|
+
function ruleSetting(policy, ruleId) {
|
|
112
|
+
const exact = policy.rules[ruleId];
|
|
113
|
+
if (exact !== void 0) return exact;
|
|
114
|
+
let best;
|
|
115
|
+
for (const [key, setting] of Object.entries(policy.rules)) {
|
|
116
|
+
if (!key.endsWith("/*")) continue;
|
|
117
|
+
const prefix = key.slice(0, -1);
|
|
118
|
+
if (ruleId.startsWith(prefix) && (!best || prefix.length > best.len)) best = { len: prefix.length, setting };
|
|
119
|
+
}
|
|
120
|
+
return best?.setting;
|
|
121
|
+
}
|
|
122
|
+
var PolicyPaths = class _PolicyPaths {
|
|
123
|
+
exclude;
|
|
124
|
+
tests;
|
|
125
|
+
constructor(policy) {
|
|
126
|
+
this.exclude = policy.paths.exclude.length ? ignoreFactory().add(policy.paths.exclude) : null;
|
|
127
|
+
this.tests = policy.paths.tests.length ? ignoreFactory().add(policy.paths.tests) : null;
|
|
128
|
+
}
|
|
129
|
+
static test(m, file) {
|
|
130
|
+
if (!m) return false;
|
|
131
|
+
const rel = file.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
132
|
+
if (!rel || !ignoreFactory.isPathValid(rel)) return false;
|
|
133
|
+
return m.ignores(rel);
|
|
134
|
+
}
|
|
135
|
+
isExcluded(file) {
|
|
136
|
+
return _PolicyPaths.test(this.exclude, file);
|
|
137
|
+
}
|
|
138
|
+
isTest(file) {
|
|
139
|
+
return _PolicyPaths.test(this.tests, file);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
function findingPath(f) {
|
|
143
|
+
return f.location?.file ?? f.package?.manifest;
|
|
144
|
+
}
|
|
145
|
+
function applyRuleOverrides(policy, findings) {
|
|
146
|
+
return findings.map((f) => {
|
|
147
|
+
const s = ruleSetting(policy, f.ruleId);
|
|
148
|
+
if (!s || s === "off" || !s.severity && !s.confidence) return f;
|
|
149
|
+
return { ...f, ...s.severity ? { severity: s.severity } : {}, ...s.confidence ? { confidence: s.confidence } : {} };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/core/findings/triage.ts
|
|
154
|
+
import path2 from "path";
|
|
155
|
+
import { z as z2 } from "zod";
|
|
156
|
+
var TRIAGE_FILE = `${ATHENA_DIR}/triage.json`;
|
|
157
|
+
var TRIAGE_STATUSES = ["safe", "false-positive", "accepted-risk", "fixed", "to-review"];
|
|
158
|
+
var CLOSING_TRIAGE = /* @__PURE__ */ new Set(["safe", "false-positive", "accepted-risk", "fixed"]);
|
|
159
|
+
var MAX_REASON_CHARS = 500;
|
|
160
|
+
var TriageEntry = z2.strictObject({
|
|
161
|
+
status: z2.enum(TRIAGE_STATUSES),
|
|
162
|
+
reason: z2.string().trim().min(1, "a reason is required").max(MAX_REASON_CHARS),
|
|
163
|
+
by: z2.string().max(200).optional(),
|
|
164
|
+
at: z2.string()
|
|
165
|
+
});
|
|
166
|
+
var Triage = z2.strictObject({
|
|
167
|
+
schemaVersion: z2.literal(1),
|
|
168
|
+
entries: z2.record(z2.string().regex(/^[a-f0-9]{32}$/, "keys must be finding fingerprints (32 hex)"), TriageEntry)
|
|
169
|
+
});
|
|
170
|
+
function emptyTriage() {
|
|
171
|
+
return { schemaVersion: 1, entries: {} };
|
|
172
|
+
}
|
|
173
|
+
function parseTriage(text, source = TRIAGE_FILE) {
|
|
174
|
+
return parseCommittedJson(Triage, text, source);
|
|
175
|
+
}
|
|
176
|
+
async function loadTriage(root) {
|
|
177
|
+
const raw = await readTextIfExists(path2.join(root, TRIAGE_FILE));
|
|
178
|
+
return raw === null ? null : parseTriage(raw);
|
|
179
|
+
}
|
|
180
|
+
function serializeTriage(t) {
|
|
181
|
+
const entries = Object.fromEntries(Object.entries(t.entries).sort(([a], [b]) => a.localeCompare(b)));
|
|
182
|
+
return `${JSON.stringify({ schemaVersion: 1, entries }, null, 2)}
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
async function saveTriage(root, t) {
|
|
186
|
+
await writeFileAtomic(path2.join(root, TRIAGE_FILE), serializeTriage(Triage.parse(t)));
|
|
187
|
+
}
|
|
188
|
+
function assertNoSecret(text, what) {
|
|
189
|
+
const hits = scanText(text);
|
|
190
|
+
if (hits.length) {
|
|
191
|
+
throw new PolicyError(`The ${what} looks like it contains a secret (${hits[0].type}); it was not saved.`, `Describe the decision without including credentials.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function setTriage(t, fingerprint, input) {
|
|
195
|
+
if (!/^[a-f0-9]{32}$/.test(fingerprint)) throw new PolicyError(`Not a finding fingerprint: ${fingerprint.slice(0, 64)}`);
|
|
196
|
+
const reason = input.reason.trim();
|
|
197
|
+
if (!reason) throw new PolicyError("A triage decision needs a reason.", 'Say why, e.g. "input is validated by the router schema".');
|
|
198
|
+
if (reason.length > MAX_REASON_CHARS) throw new PolicyError(`The reason is too long (${reason.length} > ${MAX_REASON_CHARS} characters).`);
|
|
199
|
+
assertNoSecret(reason, "triage reason");
|
|
200
|
+
if (input.by) assertNoSecret(input.by, "triage author");
|
|
201
|
+
const entry = TriageEntry.parse({ status: input.status, reason, ...input.by ? { by: input.by } : {}, at: (input.now ?? /* @__PURE__ */ new Date()).toISOString() });
|
|
202
|
+
return { ...t, entries: { ...t.entries, [fingerprint]: entry } };
|
|
203
|
+
}
|
|
204
|
+
function removeTriage(t, fingerprint) {
|
|
205
|
+
const { [fingerprint]: _removed, ...rest } = t.entries;
|
|
206
|
+
return { ...t, entries: rest };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/core/findings/baseline.ts
|
|
210
|
+
import path3 from "path";
|
|
211
|
+
import { z as z3 } from "zod";
|
|
212
|
+
var BASELINE_FILE = `${ATHENA_DIR}/baseline.json`;
|
|
213
|
+
var BaselineEntry = z3.strictObject({
|
|
214
|
+
fingerprint: z3.string().regex(/^[a-f0-9]{32}$/),
|
|
215
|
+
ruleId: z3.string().min(1).max(200),
|
|
216
|
+
file: z3.string().max(1e3).optional(),
|
|
217
|
+
addedAt: z3.string(),
|
|
218
|
+
reason: z3.string().max(500).optional()
|
|
219
|
+
});
|
|
220
|
+
var Baseline = z3.strictObject({
|
|
221
|
+
schemaVersion: z3.literal(1),
|
|
222
|
+
createdAt: z3.string(),
|
|
223
|
+
entries: z3.array(BaselineEntry)
|
|
224
|
+
});
|
|
225
|
+
function emptyBaseline(now = /* @__PURE__ */ new Date()) {
|
|
226
|
+
return { schemaVersion: 1, createdAt: now.toISOString(), entries: [] };
|
|
227
|
+
}
|
|
228
|
+
function parseBaseline(text, source = BASELINE_FILE) {
|
|
229
|
+
const b = parseCommittedJson(Baseline, text, source);
|
|
230
|
+
const seen = /* @__PURE__ */ new Set();
|
|
231
|
+
for (const e of b.entries) {
|
|
232
|
+
if (seen.has(e.fingerprint)) throw new PolicyError(`${source} lists fingerprint ${e.fingerprint} more than once.`, `Remove the duplicate entry from ${source}.`);
|
|
233
|
+
seen.add(e.fingerprint);
|
|
234
|
+
}
|
|
235
|
+
return b;
|
|
236
|
+
}
|
|
237
|
+
async function loadBaseline(root) {
|
|
238
|
+
const raw = await readTextIfExists(path3.join(root, BASELINE_FILE));
|
|
239
|
+
return raw === null ? null : parseBaseline(raw);
|
|
240
|
+
}
|
|
241
|
+
function serializeBaseline(b) {
|
|
242
|
+
const entries = [...b.entries].sort(
|
|
243
|
+
(x, y) => x.ruleId.localeCompare(y.ruleId) || (x.file ?? "").localeCompare(y.file ?? "") || x.fingerprint.localeCompare(y.fingerprint)
|
|
244
|
+
);
|
|
245
|
+
return `${JSON.stringify({ schemaVersion: 1, createdAt: b.createdAt, entries }, null, 2)}
|
|
246
|
+
`;
|
|
247
|
+
}
|
|
248
|
+
async function saveBaseline(root, b) {
|
|
249
|
+
await writeFileAtomic(path3.join(root, BASELINE_FILE), serializeBaseline(Baseline.parse(b)));
|
|
250
|
+
}
|
|
251
|
+
function baselineFingerprints(b) {
|
|
252
|
+
return new Set(b?.entries.map((e) => e.fingerprint) ?? []);
|
|
253
|
+
}
|
|
254
|
+
function entryFor(f, now, reason) {
|
|
255
|
+
const file = f.location?.file ?? f.package?.manifest;
|
|
256
|
+
return { fingerprint: f.fingerprint, ruleId: f.ruleId, ...file ? { file } : {}, addedAt: now.toISOString(), ...reason ? { reason } : {} };
|
|
257
|
+
}
|
|
258
|
+
function updateBaseline(b, findings, opts = {}) {
|
|
259
|
+
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
260
|
+
if (opts.reason !== void 0) {
|
|
261
|
+
if (opts.reason.length > 500) throw new PolicyError(`The baseline reason is too long (${opts.reason.length} > 500 characters).`);
|
|
262
|
+
assertNoSecret(opts.reason, "baseline reason");
|
|
263
|
+
}
|
|
264
|
+
const have = baselineFingerprints(b);
|
|
265
|
+
const added = [];
|
|
266
|
+
for (const f of findings) {
|
|
267
|
+
if (have.has(f.fingerprint)) continue;
|
|
268
|
+
have.add(f.fingerprint);
|
|
269
|
+
added.push(entryFor(f, now, opts.reason));
|
|
270
|
+
}
|
|
271
|
+
return { baseline: { ...b, entries: [...b.entries, ...added] }, added };
|
|
272
|
+
}
|
|
273
|
+
var categoryOf = (ruleId) => {
|
|
274
|
+
const head = ruleId.split("/")[0];
|
|
275
|
+
return CATEGORIES.includes(head ?? "") ? head : void 0;
|
|
276
|
+
};
|
|
277
|
+
function pruneBaseline(b, result) {
|
|
278
|
+
if (result.scope.mode !== "all") {
|
|
279
|
+
throw new PolicyError(`Can't prune the baseline from a partial scan (${result.scope.mode}).`, "Run a full scan first, then prune.");
|
|
280
|
+
}
|
|
281
|
+
const present = new Set(result.findings.map((f) => f.fingerprint));
|
|
282
|
+
const verified = /* @__PURE__ */ new Set();
|
|
283
|
+
const broken = /* @__PURE__ */ new Set();
|
|
284
|
+
for (const c of result.coverage) {
|
|
285
|
+
if (c.status === "ok") verified.add(c.category);
|
|
286
|
+
if (c.status === "failed" || c.status === "timeout") broken.add(c.category);
|
|
287
|
+
}
|
|
288
|
+
const kept = [];
|
|
289
|
+
const removed = [];
|
|
290
|
+
const keptUnverified = [];
|
|
291
|
+
for (const e of b.entries) {
|
|
292
|
+
if (present.has(e.fingerprint)) {
|
|
293
|
+
kept.push(e);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const cat = categoryOf(e.ruleId);
|
|
297
|
+
if (!cat || !verified.has(cat) || broken.has(cat)) {
|
|
298
|
+
kept.push(e);
|
|
299
|
+
keptUnverified.push(e);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
removed.push(e);
|
|
303
|
+
}
|
|
304
|
+
return { baseline: { ...b, entries: kept }, removed, keptUnverified };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/core/policy/tamper.ts
|
|
308
|
+
import path5 from "path";
|
|
309
|
+
|
|
310
|
+
// src/core/findings/suppress.ts
|
|
311
|
+
import path4 from "path";
|
|
312
|
+
var SUPPRESSION_WITHOUT_REASON = "review/suppression-without-reason";
|
|
313
|
+
var INVALID_SUPPRESSION = "review/invalid-suppression";
|
|
314
|
+
var UNSUPPRESSIBLE_RULES = /* @__PURE__ */ new Set(["review/policy-weakened", SUPPRESSION_WITHOUT_REASON, INVALID_SUPPRESSION]);
|
|
315
|
+
var DEFAULT_SUPPRESSION_MAX_BYTES = 2 * 1024 * 1024;
|
|
316
|
+
var RULE_ID = /^[a-z0-9][a-z0-9._/-]{1,120}$/;
|
|
317
|
+
var MARKER = /(?<=^|[\s;])(?:\/\/|#|\/\*|<!--|--)[ \t]*athena-ignore(?![\w-])(.*)$/;
|
|
318
|
+
function parseSuppressionLine(text, line) {
|
|
319
|
+
if (!text.includes("athena-ignore")) return null;
|
|
320
|
+
const m = MARKER.exec(text);
|
|
321
|
+
if (!m) return null;
|
|
322
|
+
const rest = m[1].replace(/\s*(?:\*\/|-->)\s*$/, "").trim();
|
|
323
|
+
const first = rest.split(/\s+/)[0] ?? "";
|
|
324
|
+
const after = rest.slice(first.length).trim();
|
|
325
|
+
if (!first || first === "--" || !RULE_ID.test(first)) return { line, problem: "missing-rule", lineText: text };
|
|
326
|
+
const reason = after.startsWith("--") ? after.slice(2).trim() : "";
|
|
327
|
+
return reason ? { line, ruleId: first, reason, lineText: text } : { line, ruleId: first, problem: "missing-reason", lineText: text };
|
|
328
|
+
}
|
|
329
|
+
function findSuppressions(fileText) {
|
|
330
|
+
const out = [];
|
|
331
|
+
const lines = fileText.split(/\r?\n/);
|
|
332
|
+
for (let i = 0; i < lines.length; i++) {
|
|
333
|
+
const s = parseSuppressionLine(lines[i], i + 1);
|
|
334
|
+
if (s) out.push(s);
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
function suppressionFor(suppressions, ruleId, line) {
|
|
339
|
+
if (UNSUPPRESSIBLE_RULES.has(ruleId)) return void 0;
|
|
340
|
+
return suppressions.find((s) => !s.problem && s.ruleId === ruleId && (s.line === line || s.line === line - 1));
|
|
341
|
+
}
|
|
342
|
+
function problemFinding(file, s, occurrence) {
|
|
343
|
+
const missingRule = s.problem === "missing-rule";
|
|
344
|
+
const ruleId = missingRule ? INVALID_SUPPRESSION : SUPPRESSION_WITHOUT_REASON;
|
|
345
|
+
return Finding.parse({
|
|
346
|
+
fingerprint: codeFingerprint({ ruleId, file, lineText: s.lineText, occurrence }),
|
|
347
|
+
ruleId,
|
|
348
|
+
category: "review",
|
|
349
|
+
severity: "info",
|
|
350
|
+
confidence: "high",
|
|
351
|
+
label: "DETECTED",
|
|
352
|
+
title: missingRule ? "Suppression without a rule id" : "Suppression without a reason",
|
|
353
|
+
message: missingRule ? `This athena-ignore comment names no valid rule id, so it suppresses nothing. Write \`athena-ignore <ruleId> -- <reason>\`.` : `\`athena-ignore ${s.ruleId}\` has no reason, so it is ignored. Add \`-- <why this is safe>\` after the rule id.`,
|
|
354
|
+
location: { file, startLine: s.line },
|
|
355
|
+
engine: { id: "athena" }
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
async function applySuppressions(root, findings, opts = {}) {
|
|
359
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_SUPPRESSION_MAX_BYTES;
|
|
360
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
361
|
+
for (const f of findings) {
|
|
362
|
+
if (!f.location?.file || !f.location.startLine || UNSUPPRESSIBLE_RULES.has(f.ruleId)) continue;
|
|
363
|
+
const list = byFile.get(f.location.file) ?? [];
|
|
364
|
+
list.push(f);
|
|
365
|
+
byFile.set(f.location.file, list);
|
|
366
|
+
}
|
|
367
|
+
const out = { suppressed: /* @__PURE__ */ new Map(), findings: [], skippedFiles: [] };
|
|
368
|
+
for (const file of [...byFile.keys()].sort()) {
|
|
369
|
+
const norm = path4.posix.normalize(file.replace(/\\/g, "/"));
|
|
370
|
+
if (path4.isAbsolute(file) || path4.posix.isAbsolute(norm) || norm === ".." || norm.startsWith("../")) {
|
|
371
|
+
out.skippedFiles.push({ file, reason: "outside-root" });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const read = await readTextInsideRoot(root, file, maxBytes);
|
|
375
|
+
if (!read.ok) {
|
|
376
|
+
out.skippedFiles.push({ file, reason: read.reason });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const all = findSuppressions(read.text);
|
|
380
|
+
if (!all.length) continue;
|
|
381
|
+
const occ = occurrenceCounter();
|
|
382
|
+
for (const s of all) {
|
|
383
|
+
if (s.problem) {
|
|
384
|
+
const rule = s.problem === "missing-rule" ? INVALID_SUPPRESSION : SUPPRESSION_WITHOUT_REASON;
|
|
385
|
+
out.findings.push(problemFinding(file, s, occ(rule, file, normalizeLine(s.lineText))));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const f of byFile.get(file)) {
|
|
389
|
+
const s = suppressionFor(all, f.ruleId, f.location.startLine);
|
|
390
|
+
if (s) out.suppressed.set(f.fingerprint, { file, line: s.line, ruleId: s.ruleId, reason: s.reason });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// src/core/policy/tamper.ts
|
|
397
|
+
var POLICY_WEAKENED = "review/policy-weakened";
|
|
398
|
+
var GIT_TIMEOUT_MS = 6e4;
|
|
399
|
+
async function gitText(root, args, opts) {
|
|
400
|
+
const r = await git(root, args, { timeoutMs: GIT_TIMEOUT_MS, ...opts });
|
|
401
|
+
if (r.aborted) throw opts.signal?.reason ?? new Error("Aborted");
|
|
402
|
+
return r;
|
|
403
|
+
}
|
|
404
|
+
async function readFileAtCommit(root, commit, rel, opts = {}) {
|
|
405
|
+
const spec = `${commit}:./${rel}`;
|
|
406
|
+
const exists = await gitText(root, ["cat-file", "-e", spec], opts);
|
|
407
|
+
if (!exists.ok) return null;
|
|
408
|
+
const r = await gitText(root, ["show", "--no-textconv", spec], { ...opts, maxBytes: 8 * 1024 * 1024 });
|
|
409
|
+
if (!r.ok) throw new PolicyError(`Couldn't read ${rel} at ${commit.slice(0, 12)}: ${r.stderr.trim().split("\n").pop()?.slice(0, 200) ?? "git show failed"}`);
|
|
410
|
+
return r.stdout;
|
|
411
|
+
}
|
|
412
|
+
function parseState(raw, where) {
|
|
413
|
+
return {
|
|
414
|
+
policy: raw.policy === null ? DEFAULT_POLICY : parsePolicy(raw.policy, `${POLICY_FILE} at ${where}`),
|
|
415
|
+
baseline: raw.baseline === null ? null : parseBaseline(raw.baseline, `${BASELINE_FILE} at ${where}`),
|
|
416
|
+
triage: raw.triage === null ? null : parseTriage(raw.triage, `${TRIAGE_FILE} at ${where}`)
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
async function rawAtCommit(root, commit, opts) {
|
|
420
|
+
const [policy, baseline, triage] = await Promise.all([POLICY_FILE, BASELINE_FILE, TRIAGE_FILE].map((f) => readFileAtCommit(root, commit, f, opts)));
|
|
421
|
+
return { policy, baseline, triage };
|
|
422
|
+
}
|
|
423
|
+
async function rawWorkingTree(root) {
|
|
424
|
+
const [policy, baseline, triage] = await Promise.all([POLICY_FILE, BASELINE_FILE, TRIAGE_FILE].map((f) => readTextIfExists(path5.join(root, f))));
|
|
425
|
+
return { policy, baseline, triage };
|
|
426
|
+
}
|
|
427
|
+
async function readPolicyStateAt(root, commit, opts = {}) {
|
|
428
|
+
return parseState(await rawAtCommit(root, commit, opts), commit.slice(0, 12));
|
|
429
|
+
}
|
|
430
|
+
function ruleChangeRelaxes(b, h) {
|
|
431
|
+
if (h === "off") return true;
|
|
432
|
+
if (b === "off") return false;
|
|
433
|
+
const sevOk = h?.severity === void 0 ? b?.severity === void 0 : h.severity === "critical" || b?.severity !== void 0 && SEVERITY_RANK[h.severity] >= SEVERITY_RANK[b.severity];
|
|
434
|
+
const confOk = h?.confidence === void 0 ? b?.confidence === void 0 : h.confidence === "high" || b?.confidence !== void 0 && CONFIDENCE_RANK[h.confidence] >= CONFIDENCE_RANK[b.confidence];
|
|
435
|
+
return !(sevOk && confOk);
|
|
436
|
+
}
|
|
437
|
+
var show = (s) => s === void 0 ? "default" : s === "off" ? "off" : JSON.stringify(s);
|
|
438
|
+
function comparePolicies(base, head) {
|
|
439
|
+
const out = [];
|
|
440
|
+
const bg = base.gate;
|
|
441
|
+
const hg = head.gate;
|
|
442
|
+
if (bg.failOn !== hg.failOn) out.push({ key: "gate.failOn", text: `gate.failOn ${bg.failOn} \u2192 ${hg.failOn}`, relaxes: SEVERITY_RANK[hg.failOn] > SEVERITY_RANK[bg.failOn] });
|
|
443
|
+
if (bg.minConfidence !== hg.minConfidence) {
|
|
444
|
+
out.push({ key: "gate.minConfidence", text: `gate.minConfidence ${bg.minConfidence} \u2192 ${hg.minConfidence}`, relaxes: CONFIDENCE_RANK[hg.minConfidence] > CONFIDENCE_RANK[bg.minConfidence] });
|
|
445
|
+
}
|
|
446
|
+
if (bg.unrated !== hg.unrated) out.push({ key: "gate.unrated", text: `gate.unrated ${bg.unrated} \u2192 ${hg.unrated}`, relaxes: hg.unrated === "warn" });
|
|
447
|
+
if (bg.scope !== hg.scope) out.push({ key: "gate.scope", text: `gate.scope ${bg.scope} \u2192 ${hg.scope}`, relaxes: hg.scope === "new" });
|
|
448
|
+
const removedCats = bg.categories.filter((c) => !hg.categories.includes(c));
|
|
449
|
+
const addedCats = hg.categories.filter((c) => !bg.categories.includes(c));
|
|
450
|
+
if (removedCats.length) out.push({ key: "gate.categories", text: `gate.categories no longer include ${removedCats.join(", ")}`, relaxes: true });
|
|
451
|
+
if (addedCats.length) out.push({ key: "gate.categories+", text: `gate.categories now include ${addedCats.join(", ")}`, relaxes: false });
|
|
452
|
+
for (const key of [.../* @__PURE__ */ new Set([...Object.keys(base.rules), ...Object.keys(head.rules)])].sort()) {
|
|
453
|
+
const b = base.rules[key];
|
|
454
|
+
const h = head.rules[key];
|
|
455
|
+
if (JSON.stringify(b) === JSON.stringify(h)) continue;
|
|
456
|
+
out.push({ key: `rules.${key}`, text: `rules["${key}"] ${show(b)} \u2192 ${show(h)}`, relaxes: ruleChangeRelaxes(b, h) });
|
|
457
|
+
}
|
|
458
|
+
const listDiff = (key, b, h, addRelaxes) => {
|
|
459
|
+
const added = h.filter((x) => !b.includes(x));
|
|
460
|
+
const removed = b.filter((x) => !h.includes(x));
|
|
461
|
+
if (added.length) out.push({ key: `${key}+`, text: `${key} added ${added.map((x) => JSON.stringify(x)).join(", ")}`, relaxes: addRelaxes });
|
|
462
|
+
if (removed.length) out.push({ key: `${key}-`, text: `${key} removed ${removed.map((x) => JSON.stringify(x)).join(", ")}`, relaxes: !addRelaxes });
|
|
463
|
+
};
|
|
464
|
+
listDiff("paths.exclude", base.paths.exclude, head.paths.exclude, true);
|
|
465
|
+
if (JSON.stringify(base.paths.tests) !== JSON.stringify(head.paths.tests)) out.push({ key: "paths.tests", text: "paths.tests changed", relaxes: false });
|
|
466
|
+
listDiff("licenses.deny", base.licenses.deny, head.licenses.deny, false);
|
|
467
|
+
if (JSON.stringify(base.licenses.warn) !== JSON.stringify(head.licenses.warn)) out.push({ key: "licenses.warn", text: "licenses.warn changed", relaxes: false });
|
|
468
|
+
for (const e of ["semgrep", "gitleaks", "trivy", "osv-scanner"]) {
|
|
469
|
+
if (base.external[e] !== head.external[e]) out.push({ key: `external.${e}`, text: `external.${e} ${base.external[e]} \u2192 ${head.external[e]}`, relaxes: head.external[e] === "off" });
|
|
470
|
+
}
|
|
471
|
+
if (base.external.network !== head.external.network) {
|
|
472
|
+
out.push({ key: "external.network", text: `external.network ${base.external.network} \u2192 ${head.external.network}`, relaxes: head.external.network === "deny" });
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
function compareBaselines(base, head) {
|
|
477
|
+
const b = new Map((base?.entries ?? []).map((e) => [e.fingerprint, e]));
|
|
478
|
+
const h = new Map((head?.entries ?? []).map((e) => [e.fingerprint, e]));
|
|
479
|
+
const added = [...h.values()].filter((e) => !b.has(e.fingerprint));
|
|
480
|
+
const removed = [...b.values()].filter((e) => !h.has(e.fingerprint));
|
|
481
|
+
const out = [];
|
|
482
|
+
if (added.length) {
|
|
483
|
+
const rules = [...new Set(added.map((e) => e.ruleId))].sort();
|
|
484
|
+
out.push({ key: "baseline+", text: `${added.length} baseline entr${added.length === 1 ? "y" : "ies"} added (${rules.slice(0, 5).join(", ")}${rules.length > 5 ? ", \u2026" : ""})`, relaxes: true });
|
|
485
|
+
}
|
|
486
|
+
if (removed.length) out.push({ key: "baseline-", text: `${removed.length} baseline entr${removed.length === 1 ? "y" : "ies"} removed`, relaxes: false });
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
function compareTriage(base, head) {
|
|
490
|
+
const b = base?.entries ?? {};
|
|
491
|
+
const h = head?.entries ?? {};
|
|
492
|
+
let closedNew = 0;
|
|
493
|
+
let otherChanges = 0;
|
|
494
|
+
let removed = 0;
|
|
495
|
+
for (const [fp, he] of Object.entries(h)) {
|
|
496
|
+
const be = b[fp];
|
|
497
|
+
const closes = CLOSING_TRIAGE.has(he.status);
|
|
498
|
+
if (!be) {
|
|
499
|
+
if (closes) closedNew++;
|
|
500
|
+
else otherChanges++;
|
|
501
|
+
} else if (be.status !== he.status || be.reason !== he.reason) {
|
|
502
|
+
if (closes && !CLOSING_TRIAGE.has(be.status)) closedNew++;
|
|
503
|
+
else otherChanges++;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
for (const fp of Object.keys(b)) if (!(fp in h)) removed++;
|
|
507
|
+
const out = [];
|
|
508
|
+
if (closedNew) out.push({ key: "triage+", text: `${closedNew} finding${closedNew === 1 ? "" : "s"} newly triaged as not needing a fix (safe / false-positive / accepted-risk / fixed)`, relaxes: true });
|
|
509
|
+
if (otherChanges) out.push({ key: "triage~", text: `${otherChanges} other triage change${otherChanges === 1 ? "" : "s"}`, relaxes: false });
|
|
510
|
+
if (removed) out.push({ key: "triage-", text: `${removed} triage entr${removed === 1 ? "y" : "ies"} removed`, relaxes: false });
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
function addedSuppressionsInDiff(diff) {
|
|
514
|
+
const out = [];
|
|
515
|
+
let file = null;
|
|
516
|
+
let inHeader = false;
|
|
517
|
+
let line = 0;
|
|
518
|
+
for (const raw of diff.split("\n")) {
|
|
519
|
+
if (raw.startsWith("diff --git ")) {
|
|
520
|
+
inHeader = true;
|
|
521
|
+
file = null;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (inHeader) {
|
|
525
|
+
if (!raw.startsWith("@@")) {
|
|
526
|
+
if (raw.startsWith("+++ ")) file = unquotePath(raw.slice(4));
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
inHeader = false;
|
|
530
|
+
}
|
|
531
|
+
if (raw.startsWith("@@")) {
|
|
532
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
|
|
533
|
+
line = m ? Number(m[1]) : 0;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (raw.startsWith("+")) {
|
|
537
|
+
const text = raw.slice(1).replace(/\r$/, "");
|
|
538
|
+
const s = file && file !== "/dev/null" ? parseSuppressionLine(text, line) : null;
|
|
539
|
+
if (s && !s.problem) out.push({ file, line, ruleId: s.ruleId, reason: s.reason, lineText: text });
|
|
540
|
+
line++;
|
|
541
|
+
} else if (raw.startsWith(" ")) line++;
|
|
542
|
+
}
|
|
543
|
+
return out;
|
|
544
|
+
}
|
|
545
|
+
function unquotePath(p) {
|
|
546
|
+
if (p.startsWith('"') && p.endsWith('"')) {
|
|
547
|
+
try {
|
|
548
|
+
return Buffer.from(
|
|
549
|
+
p.slice(1, -1).replace(/\\([0-7]{3})|\\(.)/g, (_m, oct, ch) => {
|
|
550
|
+
if (oct) return String.fromCharCode(parseInt(oct, 8));
|
|
551
|
+
return ch === "n" ? "\n" : ch === "t" ? " " : ch ?? "";
|
|
552
|
+
}),
|
|
553
|
+
"latin1"
|
|
554
|
+
).toString("utf8");
|
|
555
|
+
} catch {
|
|
556
|
+
return p;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return p;
|
|
560
|
+
}
|
|
561
|
+
function weakenedFinding(file, changes, ref) {
|
|
562
|
+
const relaxes = changes.some((c) => c.relaxes);
|
|
563
|
+
const list = changes.map((c) => `${c.relaxes ? "[relaxes] " : ""}${c.text}`).join("; ");
|
|
564
|
+
return Finding.parse({
|
|
565
|
+
fingerprint: fileFingerprint({ ruleId: POLICY_WEAKENED, file, key: changes.map((c) => c.key).sort().join(",") }),
|
|
566
|
+
ruleId: POLICY_WEAKENED,
|
|
567
|
+
category: "review",
|
|
568
|
+
severity: relaxes ? "high" : "info",
|
|
569
|
+
confidence: "high",
|
|
570
|
+
label: "FACT",
|
|
571
|
+
title: relaxes ? `${file} relaxed in this change` : `${file} changed in this change`,
|
|
572
|
+
message: `${file} differs from ${ref}: ${list}. The gate used the version from ${ref}; a maintainer should review this change on its own.`,
|
|
573
|
+
location: { file },
|
|
574
|
+
engine: { id: "athena" }
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
function invalidHeadFinding(file, problem, ref) {
|
|
578
|
+
return Finding.parse({
|
|
579
|
+
fingerprint: fileFingerprint({ ruleId: POLICY_WEAKENED, file, key: "invalid" }),
|
|
580
|
+
ruleId: POLICY_WEAKENED,
|
|
581
|
+
category: "review",
|
|
582
|
+
severity: "high",
|
|
583
|
+
confidence: "high",
|
|
584
|
+
label: "FACT",
|
|
585
|
+
title: `${file} is invalid in this change`,
|
|
586
|
+
message: `${problem}. The gate used the version from ${ref}.`,
|
|
587
|
+
location: { file },
|
|
588
|
+
engine: { id: "athena" }
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
async function checkPolicyFrom(root, ref, opts = {}) {
|
|
592
|
+
if (!isSafeRef(ref)) throw new PolicyError(`Invalid ref for --policy-from: ${ref.slice(0, 120)}`);
|
|
593
|
+
const runOpts = { signal: opts.signal };
|
|
594
|
+
const FETCH = "Fetch the base branch with enough history (e.g. `git fetch --depth=200 origin main`, or `fetch-depth: 0` in actions/checkout).";
|
|
595
|
+
const c = await gitText(root, ["rev-parse", "--verify", "-q", `${ref}^{commit}`], runOpts);
|
|
596
|
+
if (!c.ok || !c.stdout.trim()) throw new PolicyError(`Can't read the policy from ${ref}: it is not a commit in this repository.`, FETCH);
|
|
597
|
+
const commit = c.stdout.trim();
|
|
598
|
+
const mb = await gitText(root, ["merge-base", commit, "HEAD"], runOpts);
|
|
599
|
+
if (!mb.ok || !mb.stdout.trim()) throw new PolicyError(`Can't compare with ${ref}: no common ancestor with HEAD (the clone may be shallow).`, FETCH);
|
|
600
|
+
const mergeBase = mb.stdout.trim();
|
|
601
|
+
const base = await readPolicyStateAt(root, commit, runOpts);
|
|
602
|
+
const [rawMb, rawHead] = await Promise.all([rawAtCommit(root, mergeBase, runOpts), rawWorkingTree(root)]);
|
|
603
|
+
const findings = [];
|
|
604
|
+
const changes = { policy: [], baseline: [], triage: [] };
|
|
605
|
+
const compare = (file, mbText, headText, parse, fallback, diff) => {
|
|
606
|
+
if (mbText === headText) return [];
|
|
607
|
+
let b;
|
|
608
|
+
try {
|
|
609
|
+
b = mbText === null ? fallback : parse(mbText);
|
|
610
|
+
} catch {
|
|
611
|
+
b = fallback;
|
|
612
|
+
}
|
|
613
|
+
let h;
|
|
614
|
+
try {
|
|
615
|
+
h = headText === null ? fallback : parse(headText);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
findings.push(invalidHeadFinding(file, err.message, ref));
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
let list = diff(b, h);
|
|
621
|
+
if (!list.length) list = [{ key: "edited", text: "edited without an effective change", relaxes: false }];
|
|
622
|
+
findings.push(weakenedFinding(file, list, ref));
|
|
623
|
+
return list;
|
|
624
|
+
};
|
|
625
|
+
changes.policy = compare(POLICY_FILE, rawMb.policy, rawHead.policy, (t) => parsePolicy(t), DEFAULT_POLICY, comparePolicies);
|
|
626
|
+
changes.baseline = compare(BASELINE_FILE, rawMb.baseline, rawHead.baseline, (t) => parseBaseline(t), null, compareBaselines);
|
|
627
|
+
changes.triage = compare(TRIAGE_FILE, rawMb.triage, rawHead.triage, (t) => parseTriage(t), null, compareTriage);
|
|
628
|
+
const addedSuppressions = await suppressionsAddedSince(root, mergeBase, runOpts);
|
|
629
|
+
const occ = occurrenceCounter();
|
|
630
|
+
for (const s of addedSuppressions) {
|
|
631
|
+
findings.push(
|
|
632
|
+
Finding.parse({
|
|
633
|
+
fingerprint: codeFingerprint({ ruleId: POLICY_WEAKENED, file: s.file, lineText: s.lineText, occurrence: occ(POLICY_WEAKENED, s.file, normalizeLine(s.lineText)) }),
|
|
634
|
+
ruleId: POLICY_WEAKENED,
|
|
635
|
+
category: "review",
|
|
636
|
+
severity: "high",
|
|
637
|
+
confidence: "high",
|
|
638
|
+
label: "FACT",
|
|
639
|
+
title: `Suppression added for ${s.ruleId}`,
|
|
640
|
+
message: `An \`athena-ignore ${s.ruleId}\` comment was added in this change (reason: "${s.reason.slice(0, 200)}"). It hides a finding from the gate; a maintainer should confirm it.`,
|
|
641
|
+
location: { file: s.file, startLine: s.line },
|
|
642
|
+
engine: { id: "athena" }
|
|
643
|
+
})
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
return { ref, commit, mergeBase, base, changes, addedSuppressions, findings };
|
|
647
|
+
}
|
|
648
|
+
async function suppressionsAddedSince(root, mergeBase, opts) {
|
|
649
|
+
const d = await gitText(root, ["-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--no-ext-diff", "--no-textconv", "--relative", "--no-renames", "--no-prefix", mergeBase, "--", "."], opts);
|
|
650
|
+
if (!d.ok) throw new PolicyError(`Couldn't diff against the merge base: ${d.stderr.trim().split("\n").pop()?.slice(0, 200) ?? "git diff failed"}`);
|
|
651
|
+
const out = addedSuppressionsInDiff(d.stdout);
|
|
652
|
+
const u = await gitText(root, ["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard", "-z", "--", "."], opts);
|
|
653
|
+
if (u.ok) {
|
|
654
|
+
for (const file of u.stdout.split("\0").filter(Boolean).sort()) {
|
|
655
|
+
const r = await readTextInsideRoot(root, file, DEFAULT_SUPPRESSION_MAX_BYTES);
|
|
656
|
+
if (!r.ok) continue;
|
|
657
|
+
for (const s of findSuppressions(r.text)) if (!s.problem) out.push({ file, line: s.line, ruleId: s.ruleId, reason: s.reason, lineText: s.lineText });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return out;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/core/findings/gate.ts
|
|
664
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
665
|
+
function evaluateGate(result, ctx) {
|
|
666
|
+
const g = ctx.policy.gate;
|
|
667
|
+
const gated = new Set(g.categories);
|
|
668
|
+
const counted = {
|
|
669
|
+
total: result.findings.length,
|
|
670
|
+
considered: 0,
|
|
671
|
+
failing: 0,
|
|
672
|
+
unratedWarnings: 0,
|
|
673
|
+
baselinedSkipped: 0,
|
|
674
|
+
notActive: 0,
|
|
675
|
+
outOfCategory: 0,
|
|
676
|
+
belowConfidence: 0,
|
|
677
|
+
belowSeverity: 0,
|
|
678
|
+
brokenEngines: 0
|
|
679
|
+
};
|
|
680
|
+
const failing = [];
|
|
681
|
+
const warned = [];
|
|
682
|
+
for (const f of result.findings) {
|
|
683
|
+
const st = ctx.statuses.get(f.fingerprint);
|
|
684
|
+
const active = st ? st.active : true;
|
|
685
|
+
if (!active) {
|
|
686
|
+
counted.notActive++;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (!gated.has(f.category)) {
|
|
690
|
+
counted.outOfCategory++;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (g.scope === "new" && st?.baselined) {
|
|
694
|
+
counted.baselinedSkipped++;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
counted.considered++;
|
|
698
|
+
if (CONFIDENCE_RANK[f.confidence] < CONFIDENCE_RANK[g.minConfidence]) {
|
|
699
|
+
counted.belowConfidence++;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
if (f.severity === "unrated") {
|
|
703
|
+
if (g.unrated === "fail") failing.push(f);
|
|
704
|
+
else warned.push(f);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (SEVERITY_RANK[f.severity] < SEVERITY_RANK[g.failOn]) {
|
|
708
|
+
counted.belowSeverity++;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
failing.push(f);
|
|
712
|
+
}
|
|
713
|
+
failing.sort(compareFindings);
|
|
714
|
+
warned.sort(compareFindings);
|
|
715
|
+
counted.failing = failing.length;
|
|
716
|
+
counted.unratedWarnings = warned.length;
|
|
717
|
+
const reasons = [];
|
|
718
|
+
const warnings = [];
|
|
719
|
+
if (failing.length) {
|
|
720
|
+
const scope = g.scope === "new" ? "new " : "";
|
|
721
|
+
const unrated = failing.filter((f) => f.severity === "unrated").length;
|
|
722
|
+
reasons.push(
|
|
723
|
+
`${plural(failing.length, `${scope}finding`)} at or above ${g.failOn} severity with ${g.minConfidence}+ confidence` + (unrated ? ` (${unrated} unrated, which the policy treats as failing)` : "")
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
const seenCategory = /* @__PURE__ */ new Set();
|
|
727
|
+
for (const c of result.coverage) {
|
|
728
|
+
if (!gated.has(c.category)) continue;
|
|
729
|
+
if (c.status === "ok") seenCategory.add(c.category);
|
|
730
|
+
if (c.status === "failed" || c.status === "timeout") {
|
|
731
|
+
seenCategory.add(c.category);
|
|
732
|
+
counted.brokenEngines++;
|
|
733
|
+
const what = c.target ? ` on ${c.target}` : "";
|
|
734
|
+
reasons.push(`${c.engine} (${c.category})${what} ${c.status === "timeout" ? "timed out" : "failed"}${c.reason ? `: ${c.reason}` : ""} \u2014 it checked nothing, so the gate can't pass`);
|
|
735
|
+
} else if (c.status === "unavailable" || c.status === "skipped") {
|
|
736
|
+
warnings.push(`${c.engine} (${c.category}) ${c.status}${c.reason ? `: ${c.reason}` : ""}`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const unscanned = g.categories.filter((cat) => !seenCategory.has(cat));
|
|
740
|
+
if (unscanned.length) warnings.push(`no engine scanned ${unscanned.join(", ")} \u2014 the gate says nothing about ${unscanned.length === 1 ? "it" : "them"}`);
|
|
741
|
+
if (warned.length) warnings.push(`${plural(warned.length, "unrated finding")} (the policy only warns on unrated findings)`);
|
|
742
|
+
return {
|
|
743
|
+
passed: reasons.length === 0,
|
|
744
|
+
reasons,
|
|
745
|
+
warnings,
|
|
746
|
+
counted,
|
|
747
|
+
failingFindings: failing.map((f) => f.fingerprint),
|
|
748
|
+
warningFindings: warned.map((f) => f.fingerprint)
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/core/findings/rating.ts
|
|
753
|
+
var GRADE = { critical: "E", high: "D", medium: "C", low: "B", info: "B", unrated: "D" };
|
|
754
|
+
var ORDER = ["A", "B", "C", "D", "E"];
|
|
755
|
+
function gradeFor(severity, policy) {
|
|
756
|
+
if (severity === "unrated" && policy.gate.unrated === "warn") return "C";
|
|
757
|
+
return GRADE[severity];
|
|
758
|
+
}
|
|
759
|
+
function rateCategories(result, ctx) {
|
|
760
|
+
const cats = /* @__PURE__ */ new Set();
|
|
761
|
+
for (const c of result.coverage) cats.add(c.category);
|
|
762
|
+
for (const f of result.findings) cats.add(f.category);
|
|
763
|
+
const out = [];
|
|
764
|
+
for (const category of [...cats].sort()) {
|
|
765
|
+
const cov = result.coverage.filter((c) => c.category === category);
|
|
766
|
+
const ran = cov.filter((c) => c.status === "ok");
|
|
767
|
+
const active = result.findings.filter((f) => f.category === category && (ctx.statuses.get(f.fingerprint)?.active ?? true));
|
|
768
|
+
let worstIdx = -1;
|
|
769
|
+
let worst;
|
|
770
|
+
for (const f of active) {
|
|
771
|
+
const idx = ORDER.indexOf(gradeFor(f.severity, ctx.policy));
|
|
772
|
+
if (idx > worstIdx || idx === worstIdx && worst && SEVERITY_RANK[f.severity] > SEVERITY_RANK[worst]) {
|
|
773
|
+
worstIdx = idx;
|
|
774
|
+
worst = f.severity;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const grade = worstIdx >= 0 ? ORDER[worstIdx] : "A";
|
|
778
|
+
const broken = cov.some((c) => c.status === "failed" || c.status === "timeout");
|
|
779
|
+
const rating = active.length ? grade : ran.length && !broken ? "A" : null;
|
|
780
|
+
out.push({ category, rating, ...worst ? { worst } : {}, active: active.length, basis: basisText(cov, ran, ctx) });
|
|
781
|
+
}
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
784
|
+
function basisText(cov, ran, ctx) {
|
|
785
|
+
if (!ran.length) {
|
|
786
|
+
const why = cov.map((c) => `${c.engine} ${c.status}`).join(", ");
|
|
787
|
+
return `not scanned${why ? ` (${why})` : ""}`;
|
|
788
|
+
}
|
|
789
|
+
const engines = [...new Set(ran.map((c) => c.engine))].sort();
|
|
790
|
+
const known = engines.every((e) => ctx.rulesByEngine?.[e] !== void 0);
|
|
791
|
+
const what = known ? `${engines.reduce((n, e) => n + ctx.rulesByEngine[e], 0)} rules` : `${engines.length} engine${engines.length === 1 ? "" : "s"} (${engines.join(", ")})`;
|
|
792
|
+
const langs = [...new Set(ran.flatMap((c) => c.languages))].sort();
|
|
793
|
+
let notCovered = 0;
|
|
794
|
+
for (const c of ran) {
|
|
795
|
+
const s = c.filesSkipped;
|
|
796
|
+
if (s) notCovered += s.tooLarge + s.binary + s.minified + s.unreadable + s.unsupported;
|
|
797
|
+
}
|
|
798
|
+
const broken = cov.filter((c) => c.status === "failed" || c.status === "timeout").map((c) => c.engine);
|
|
799
|
+
return `${what} across ${langs.length ? langs.join(", ") : "all files"}; ${notCovered} file${notCovered === 1 ? "" : "s"} not covered${broken.length ? `; ${broken.join(", ")} failed` : ""}`;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/core/findings/status.ts
|
|
803
|
+
function deriveStatuses(findings, ctx) {
|
|
804
|
+
const paths = new PolicyPaths(ctx.policy);
|
|
805
|
+
const baselined = new Set(ctx.baseline?.entries.map((e) => e.fingerprint) ?? []);
|
|
806
|
+
const out = /* @__PURE__ */ new Map();
|
|
807
|
+
for (const f of findings) {
|
|
808
|
+
const file = findingPath(f);
|
|
809
|
+
const inBaseline = baselined.has(f.fingerprint);
|
|
810
|
+
const inTests = file ? paths.isTest(file) : false;
|
|
811
|
+
const base = { baselined: inBaseline, inTests };
|
|
812
|
+
if (ruleSetting(ctx.policy, f.ruleId) === "off") {
|
|
813
|
+
out.set(f.fingerprint, { ...base, status: "excluded", active: false, reason: `rule ${f.ruleId} is off in the policy` });
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (file && paths.isExcluded(file)) {
|
|
817
|
+
out.set(f.fingerprint, { ...base, status: "excluded", active: false, reason: `${file} matches policy paths.exclude` });
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const sup = ctx.suppressed.get(f.fingerprint);
|
|
821
|
+
if (sup) {
|
|
822
|
+
out.set(f.fingerprint, { ...base, status: "suppressed", active: false, reason: `athena-ignore at ${sup.file}:${sup.line}: ${sup.reason}` });
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
const t = ctx.triage?.entries[f.fingerprint];
|
|
826
|
+
if (t && CLOSING_TRIAGE.has(t.status)) {
|
|
827
|
+
out.set(f.fingerprint, { ...base, status: `triaged:${t.status}`, active: false, triage: t.status, reason: t.reason });
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
out.set(f.fingerprint, { ...base, status: inBaseline ? "baselined" : "open", active: true, ...t ? { triage: t.status, reason: `to review: ${t.reason}` } : {} });
|
|
831
|
+
}
|
|
832
|
+
return out;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// src/services/findings.ts
|
|
836
|
+
function rethrow(err) {
|
|
837
|
+
if (err instanceof PolicyError) throw new AthenaError(err.message, err.hint);
|
|
838
|
+
if (err instanceof GitCommandError) throw new AthenaError(err.message, void 0, EXIT.NOT_AVAILABLE);
|
|
839
|
+
throw err;
|
|
840
|
+
}
|
|
841
|
+
async function evaluateFindings(root, result, opts = {}) {
|
|
842
|
+
try {
|
|
843
|
+
let policy;
|
|
844
|
+
let baseline;
|
|
845
|
+
let triage;
|
|
846
|
+
let policySource = { kind: "working-tree" };
|
|
847
|
+
let tamper;
|
|
848
|
+
if (opts.policyFrom !== void 0) {
|
|
849
|
+
tamper = await checkPolicyFrom(root, opts.policyFrom, { signal: opts.signal });
|
|
850
|
+
({ policy, baseline, triage } = tamper.base);
|
|
851
|
+
policySource = { kind: "ref", ref: tamper.ref, commit: tamper.commit, mergeBase: tamper.mergeBase };
|
|
852
|
+
} else {
|
|
853
|
+
[policy, baseline, triage] = await Promise.all([loadPolicy(root), loadBaseline(root), loadTriage(root)]);
|
|
854
|
+
}
|
|
855
|
+
opts.signal?.throwIfAborted();
|
|
856
|
+
if (opts.noBaseline) baseline = null;
|
|
857
|
+
if (opts.gate) policy = { ...policy, gate: { ...policy.gate, ...Object.fromEntries(Object.entries(opts.gate).filter(([, v]) => v !== void 0)) } };
|
|
858
|
+
const scanned = applyRuleOverrides(policy, result.findings);
|
|
859
|
+
const sup = await applySuppressions(root, scanned, { maxBytes: opts.maxFileBytes });
|
|
860
|
+
const extra = applyRuleOverrides(policy, [...sup.findings, ...tamper?.findings ?? []]);
|
|
861
|
+
const findings = dedupeFindings([...scanned, ...extra]);
|
|
862
|
+
const statuses = deriveStatuses(findings, { policy, baseline, triage, suppressed: sup.suppressed });
|
|
863
|
+
const view = { findings, coverage: result.coverage };
|
|
864
|
+
const gate = evaluateGate(view, { policy, statuses });
|
|
865
|
+
const ratings = rateCategories(view, { policy, statuses, rulesByEngine: opts.rulesByEngine });
|
|
866
|
+
return {
|
|
867
|
+
findings,
|
|
868
|
+
statuses: Object.fromEntries(statuses),
|
|
869
|
+
notes: Object.fromEntries([...statuses].filter(([, s]) => s.reason).map(([fp, s]) => [fp, s.reason])),
|
|
870
|
+
gate,
|
|
871
|
+
ratings,
|
|
872
|
+
policy,
|
|
873
|
+
policySource,
|
|
874
|
+
suppressions: { applied: [...sup.suppressed.keys()].filter((fp) => statuses.get(fp)?.status === "suppressed").length, skippedFiles: sup.skippedFiles },
|
|
875
|
+
...tamper ? { tamper: { changes: tamper.changes, addedSuppressions: tamper.addedSuppressions } } : {}
|
|
876
|
+
};
|
|
877
|
+
} catch (err) {
|
|
878
|
+
rethrow(err);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
var NEVER_BASELINED = /* @__PURE__ */ new Set(["review/policy-weakened", "review/suppression-without-reason", "review/invalid-suppression"]);
|
|
882
|
+
async function baselineFindings(root, result, opts = {}) {
|
|
883
|
+
try {
|
|
884
|
+
const existing = await loadBaseline(root);
|
|
885
|
+
const eligible = result.findings.filter((f) => !NEVER_BASELINED.has(f.ruleId));
|
|
886
|
+
const { baseline, added } = updateBaseline(existing ?? emptyBaseline(opts.now), eligible, opts);
|
|
887
|
+
await saveBaseline(root, baseline);
|
|
888
|
+
return { added, total: baseline.entries.length, created: existing === null };
|
|
889
|
+
} catch (err) {
|
|
890
|
+
rethrow(err);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
async function pruneBaselineFile(root, result) {
|
|
894
|
+
try {
|
|
895
|
+
const existing = await loadBaseline(root);
|
|
896
|
+
if (!existing) return { removed: [], keptUnverified: [], total: 0 };
|
|
897
|
+
const r = pruneBaseline(existing, result);
|
|
898
|
+
if (r.removed.length) await saveBaseline(root, r.baseline);
|
|
899
|
+
return { removed: r.removed, keptUnverified: r.keptUnverified, total: r.baseline.entries.length };
|
|
900
|
+
} catch (err) {
|
|
901
|
+
rethrow(err);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
async function triageFinding(root, fingerprint, input) {
|
|
905
|
+
try {
|
|
906
|
+
const t = setTriage(await loadTriage(root) ?? emptyTriage(), fingerprint, input);
|
|
907
|
+
await saveTriage(root, t);
|
|
908
|
+
return t;
|
|
909
|
+
} catch (err) {
|
|
910
|
+
rethrow(err);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
async function untriageFinding(root, fingerprint) {
|
|
914
|
+
try {
|
|
915
|
+
const t = await loadTriage(root);
|
|
916
|
+
if (!t || !(fingerprint in t.entries)) return false;
|
|
917
|
+
await saveTriage(root, removeTriage(t, fingerprint));
|
|
918
|
+
return true;
|
|
919
|
+
} catch (err) {
|
|
920
|
+
rethrow(err);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export {
|
|
925
|
+
GATE_SEVERITIES,
|
|
926
|
+
TRIAGE_STATUSES,
|
|
927
|
+
loadBaseline,
|
|
928
|
+
evaluateFindings,
|
|
929
|
+
baselineFindings,
|
|
930
|
+
pruneBaselineFile,
|
|
931
|
+
triageFinding,
|
|
932
|
+
untriageFinding
|
|
933
|
+
};
|
|
934
|
+
//# sourceMappingURL=chunk-KJYSNZXS.js.map
|