@clear-capabilities/agentic-security-scanner 0.195.26 → 0.195.28
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 +27 -0
- package/bin/agentic-security.js +39 -7
- package/dist/396.index.js +936 -0
- package/dist/{60f58f89664e53fc2f96.json → 3eabd90d6d9c8c8dbbd9.json} +4 -3
- package/dist/5856.index.js +634 -0
- package/dist/7033.index.js +74 -14
- package/dist/7838.index.js +22 -0
- package/dist/9220.index.js +24 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/dataflow/catalog.js +42 -1
- package/src/engine.js +90 -10
- package/src/language/CLAUDE.md +6 -0
- package/src/language/assurance.js +53 -2
- package/src/language/haskell-budget-state.js +85 -0
- package/src/language/haskell-coverage.js +230 -0
- package/src/language/haskell-project-models.js +107 -0
- package/src/language/nix-eval-isolation.js +74 -14
- package/src/llm-validator/bounded-context.js +87 -0
- package/src/llm-validator/claim-groups.js +105 -0
- package/src/llm-validator/disposition.js +264 -0
- package/src/llm-validator/index.js +52 -26
- package/src/pipeline/evidence-record.js +112 -0
- package/src/pipeline/scan-health.js +12 -1
- package/src/posture/CLAUDE.md +10 -2
- package/src/posture/artifact-registry.js +2 -0
- package/src/posture/custom-rules.js +7 -2
- package/src/posture/drift.js +19 -1
- package/src/posture/provenance/coordinator.js +6 -2
- package/src/posture/provenance/dag-walk.js +7 -3
- package/src/posture/provenance/origin-resolver.js +16 -2
- package/src/posture/provenance/providers/github.js +6 -3
- package/src/posture/provenance/providers/gitlab.js +5 -3
- package/src/posture/reachability-filter.js +3 -3
- package/src/posture/rule-compiler/cli.js +76 -21
- package/src/posture/rule-compiler/negative-corpus.js +39 -0
- package/src/posture/rule-compiler/promotion.js +21 -0
- package/src/posture/rule-compiler/regression.js +39 -9
- package/src/posture/rule-compiler/store.js +72 -6
- package/src/posture/suppressions.js +21 -0
- package/src/posture/watch-mode.js +22 -1
- package/src/report/disposition-view.js +90 -0
- package/src/report/index.js +47 -0
- package/src/runScan.js +60 -6
- package/src/sast/oidc-trust.js +74 -0
- package/src/sast/pipeline.js +14 -1
- package/src/sast/workflow-flow.js +272 -0
- package/src/sca/basis.js +88 -0
- package/src/scope/flags.js +55 -0
- package/src/scope/inventory.js +241 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The shared negative corpus shipped with the scanner (GA-07 §3, 0.195.27): safe, idiomatic code that no promoted rule may fire
|
|
2
|
+
// on, the same on every machine and in every repository. `regression.js` puts it in front of the shared false-positive corpus, so
|
|
3
|
+
// `rules evaluate`, `rules promote` and `rules regress` all hold a rule to it; a rule that fires on any entry is refused at
|
|
4
|
+
// evaluation and reported as regressed later, wherever it runs.
|
|
5
|
+
//
|
|
6
|
+
// Each entry is the SAFE side of a pattern the compiler is likely to be asked to detect: a parameterised query beside the
|
|
7
|
+
// concatenated one, an argument-vector process spawn beside the shell string, a constant-time comparison beside the plain one.
|
|
8
|
+
// They are plain strings in a module (not files on disk) so the bundle carries them without a build step and the corpus is
|
|
9
|
+
// identical wherever the scanner runs. Keep every entry free of the unsafe form: a vulnerable snippet here would make a correct
|
|
10
|
+
// rule fail its gate.
|
|
11
|
+
export const SHIPPED_NEGATIVE_CORPUS = Object.freeze([
|
|
12
|
+
{ name: 'js-parameterised-query', language: 'javascript', code: 'const rows = await db.query("SELECT * FROM orders WHERE owner = $1", [ownerId]);\n' },
|
|
13
|
+
{ name: 'js-placeholder-query', language: 'javascript', code: 'const [row] = await conn.execute("SELECT id FROM users WHERE email = ?", [email]);\n' },
|
|
14
|
+
{ name: 'js-orm-where-object', language: 'javascript', code: 'const user = await User.findOne({ where: { id: req.params.id, tenantId: req.user.tenantId } });\n' },
|
|
15
|
+
{ name: 'js-execfile-argv', language: 'javascript', code: 'const out = execFileSync("git", ["log", "--format=%H", "-1", ref], { encoding: "utf8" });\n' },
|
|
16
|
+
{ name: 'js-spawn-argv', language: 'javascript', code: 'const child = spawn("convert", [inputPath, "-resize", "200x200", outputPath], { stdio: "ignore" });\n' },
|
|
17
|
+
{ name: 'js-path-confined', language: 'javascript', code: 'const target = path.resolve(root, name);\nif (!target.startsWith(root + path.sep)) throw new Error("outside root");\nconst text = fs.readFileSync(target, "utf8");\n' },
|
|
18
|
+
{ name: 'js-constant-time-compare', language: 'javascript', code: 'const ok = a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));\n' },
|
|
19
|
+
{ name: 'js-password-hash', language: 'javascript', code: 'const hash = await bcrypt.hash(password, 12);\nawait store.save({ email, hash });\n' },
|
|
20
|
+
{ name: 'js-random-token', language: 'javascript', code: 'const token = crypto.randomBytes(32).toString("hex");\n' },
|
|
21
|
+
{ name: 'js-json-parse-validated', language: 'javascript', code: 'const body = schema.parse(JSON.parse(raw));\nres.json({ id: body.id });\n' },
|
|
22
|
+
{ name: 'js-template-escaped', language: 'javascript', code: 'res.send(template.render({ name: escapeHtml(req.query.name) }));\n' },
|
|
23
|
+
{ name: 'js-fetch-allowlist', language: 'javascript', code: 'if (!ALLOWED_HOSTS.has(new URL(target).host)) return res.status(400).end();\nconst r = await fetch(target, { signal: AbortSignal.timeout(5000) });\n' },
|
|
24
|
+
{ name: 'ts-parameterised-query', language: 'typescript', code: 'const rows: Row[] = await db.query("SELECT * FROM t WHERE id = $1", [id]);\n' },
|
|
25
|
+
{ name: 'py-parameterised-query', language: 'python', code: 'cur.execute("SELECT * FROM users WHERE email = %s", (email,))\n' },
|
|
26
|
+
{ name: 'py-subprocess-argv', language: 'python', code: 'subprocess.run(["ls", "-l", directory], check=True, capture_output=True)\n' },
|
|
27
|
+
{ name: 'py-safe-yaml', language: 'python', code: 'config = yaml.safe_load(text)\n' },
|
|
28
|
+
{ name: 'py-path-confined', language: 'python', code: 'target = os.path.realpath(os.path.join(root, name))\nif not target.startswith(root + os.sep):\n raise ValueError("outside root")\n' },
|
|
29
|
+
{ name: 'py-password-hash', language: 'python', code: 'digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 200_000)\n' },
|
|
30
|
+
{ name: 'go-parameterised-query', language: 'go', code: 'row := db.QueryRow("SELECT name FROM users WHERE id = $1", id)\n' },
|
|
31
|
+
{ name: 'go-exec-argv', language: 'go', code: 'out, err := exec.Command("git", "rev-parse", ref).Output()\n' },
|
|
32
|
+
{ name: 'rb-parameterised-query', language: 'ruby', code: 'User.where("email = ?", params[:email]).first\n' },
|
|
33
|
+
{ name: 'rb-system-argv', language: 'ruby', code: 'system("convert", input_path, "-resize", "200x200", output_path)\n' },
|
|
34
|
+
{ name: 'java-prepared-statement', language: 'java', code: 'PreparedStatement ps = conn.prepareStatement("SELECT * FROM t WHERE id = ?");\nps.setInt(1, id);\nResultSet rs = ps.executeQuery();\n' },
|
|
35
|
+
{ name: 'java-processbuilder-argv', language: 'java', code: 'Process p = new ProcessBuilder("git", "log", "-1", ref).start();\n' },
|
|
36
|
+
{ name: 'php-prepared-statement', language: 'php', code: '$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");\n$stmt->execute([":id" => $id]);\n' },
|
|
37
|
+
{ name: 'cs-parameterised-query', language: 'csharp', code: 'var cmd = new SqlCommand("SELECT * FROM t WHERE id = @id", conn);\ncmd.Parameters.AddWithValue("@id", id);\n' },
|
|
38
|
+
{ name: 'rs-parameterised-query', language: 'rust', code: 'let row = client.query_one("SELECT name FROM users WHERE id = $1", &[&id]).await?;\n' },
|
|
39
|
+
]);
|
|
@@ -69,5 +69,26 @@ export function gatePromotion({ ir, corpus, leakageCheck, thresholds } = {}) {
|
|
|
69
69
|
else if (ind.detected < ind.positives) reasons.push(`misses ${ind.positives - ind.detected} of ${ind.positives} independent vulnerable example(s)`);
|
|
70
70
|
if (ind.negatives < t.minIndependentClean) reasons.push(`${ind.negatives} independent safe example(s) (min ${t.minIndependentClean}): supply independentCleanCode`);
|
|
71
71
|
else if (ind.falseAlarms > 0) reasons.push(`fires on ${ind.falseAlarms} of ${ind.negatives} independent safe example(s)`);
|
|
72
|
+
for (const dup of dependentExamples(corpus)) reasons.push(`${dup.label} is not independent: it is the same code as ${dup.sameAs} (the source finding, its patch or a generated mutation), so it grades nothing the source did not`);
|
|
72
73
|
return { pass: reasons.length === 0, reasons, evaluation: evalResult, independent: ind };
|
|
73
74
|
}
|
|
75
|
+
|
|
76
|
+
// GA-07 §2 (0.195.27): an "independent" example that is the original snippet, its patch, or one of the compiler's own mutations
|
|
77
|
+
// (whitespace aside) is circular grading, which the independent-example rule exists to prevent. Compared after collapsing
|
|
78
|
+
// whitespace so a re-indented copy is caught; a genuinely different example with the same shape is not (that is what a rule is for).
|
|
79
|
+
const squash = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
80
|
+
export function dependentExamples(corpus) {
|
|
81
|
+
const known = new Map();
|
|
82
|
+
for (const item of [...(corpus?.positives ?? []), ...(corpus?.negatives ?? [])]) {
|
|
83
|
+
if (item.label.startsWith('independent-')) continue;
|
|
84
|
+
const k = squash(item.code);
|
|
85
|
+
if (k && !known.has(k)) known.set(k, item.label);
|
|
86
|
+
}
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const item of [...(corpus?.positives ?? []), ...(corpus?.negatives ?? [])]) {
|
|
89
|
+
if (!item.label.startsWith('independent-')) continue;
|
|
90
|
+
const sameAs = known.get(squash(item.code));
|
|
91
|
+
if (sameAs) out.push({ label: item.label, sameAs });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
@@ -15,18 +15,22 @@ import { statePath } from '../state-dir.js';
|
|
|
15
15
|
import { buildCorpus, scoreRule } from './corpus.js';
|
|
16
16
|
import { evaluateRule } from './promotion.js';
|
|
17
17
|
import { runRule, normalizeCustomRule } from '../custom-rules.js';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
18
19
|
import { compileToCustomRule } from './ir.js';
|
|
19
20
|
import { loadIR, loadCorpus, loadProvenance } from './store.js';
|
|
20
21
|
import { stateOf } from './lifecycle.js';
|
|
22
|
+
import { SHIPPED_NEGATIVE_CORPUS } from './negative-corpus.js';
|
|
21
23
|
|
|
22
24
|
const FP_DIR = 'rule-compiler/fp-corpus';
|
|
23
25
|
const MAX_FP_FILE = 256 * 1024;
|
|
24
26
|
const MAX_FP_FILES = 500;
|
|
25
27
|
const EXT_OF = { '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.ts': 'typescript', '.py': 'python', '.go': 'go', '.rb': 'ruby', '.java': 'java', '.rs': 'rust', '.cs': 'csharp', '.php': 'php' };
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
// GA-07 §3 (0.195.27): one or more directories of safe code shared ACROSS repositories (a fleet's common false-positive corpus),
|
|
30
|
+
// named by path-separator-joined entries in this variable. Same rules as the project directory: plain files, size and count caps.
|
|
31
|
+
const SHARED_FP_ENV = 'AGENTIC_SECURITY_RULES_FP_CORPUS';
|
|
32
|
+
|
|
33
|
+
function falsePositiveFiles(dir, prefix) {
|
|
30
34
|
const out = [];
|
|
31
35
|
let names = [];
|
|
32
36
|
try { names = fs.readdirSync(dir).sort(); } catch { return out; }
|
|
@@ -35,19 +39,32 @@ function operatorFalsePositives(root) {
|
|
|
35
39
|
try {
|
|
36
40
|
const st = fs.lstatSync(f);
|
|
37
41
|
if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_FP_FILE) continue;
|
|
38
|
-
out.push({ label:
|
|
42
|
+
out.push({ label: `${prefix}:${n}`, file: n, language: EXT_OF[path.extname(n).toLowerCase()] ?? 'any', code: fs.readFileSync(f, 'utf8') });
|
|
39
43
|
} catch { /* unreadable: skipped */ }
|
|
40
44
|
}
|
|
41
45
|
return out;
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/** Operator-supplied false positives (real code a rule should never flag): the project's own directory, then every shared directory. */
|
|
49
|
+
function operatorFalsePositives(root, env = process.env) {
|
|
50
|
+
const out = falsePositiveFiles(statePath(root, ...FP_DIR.split('/')), 'operator-fp');
|
|
51
|
+
for (const dir of String(env[SHARED_FP_ENV] || '').split(path.delimiter).map((s) => s.trim()).filter(Boolean)) {
|
|
52
|
+
out.push(...falsePositiveFiles(path.resolve(dir), `shared-fp:${path.basename(dir)}`));
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
export function promotedRuleIds(ledger) {
|
|
45
58
|
return [...new Set(ledger.entries.map((e) => e.ruleId))].filter((id) => stateOf(ledger, id) === 'PROMOTED');
|
|
46
59
|
}
|
|
47
60
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The shared false-positive corpus as seen by `exceptId`: the safe code shipped with the scanner (`negative-corpus.js`, the same on
|
|
63
|
+
* every machine and in every repository), the safe code of every OTHER promoted rule, the operator's project files, and any
|
|
64
|
+
* directories shared across repositories through AGENTIC_SECURITY_RULES_FP_CORPUS.
|
|
65
|
+
*/
|
|
66
|
+
export function sharedFalsePositiveCorpus(root, ledger, exceptId = null, env = process.env) {
|
|
67
|
+
const items = SHIPPED_NEGATIVE_CORPUS.map((s) => ({ label: `shipped-fp:${s.name}`, language: s.language, code: s.code }));
|
|
51
68
|
for (const id of promotedRuleIds(ledger)) {
|
|
52
69
|
if (id === exceptId) continue;
|
|
53
70
|
const ir = loadIR(root, id); const spec = loadCorpus(root, id);
|
|
@@ -55,7 +72,12 @@ export function sharedFalsePositiveCorpus(root, ledger, exceptId = null) {
|
|
|
55
72
|
let corpus; try { corpus = buildCorpus(ir, spec); } catch { continue; }
|
|
56
73
|
for (const n of corpus.negatives) items.push({ label: `rule:${id}:${n.label}`, language: ir.languages[0] ?? 'any', code: n.code });
|
|
57
74
|
}
|
|
58
|
-
return [...items, ...operatorFalsePositives(root)];
|
|
75
|
+
return [...items, ...operatorFalsePositives(root, env)];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The digest of a corpus half, as `rules evaluate` records it in the provenance record and `rules regress` re-derives it. */
|
|
79
|
+
export function corpusHash(list) {
|
|
80
|
+
return createHash('sha256').update(JSON.stringify(list.map((x) => [x.label, x.code]).sort())).digest('hex');
|
|
59
81
|
}
|
|
60
82
|
|
|
61
83
|
const FILE_FOR_LANG = { javascript: 'x.js', typescript: 'x.ts', python: 'x.py', go: 'x.go', ruby: 'x.rb', java: 'x.java', rust: 'x.rs', csharp: 'x.cs', php: 'x.php', any: 'x.txt' };
|
|
@@ -85,8 +107,16 @@ export function regressRules(root, ledger, { only = null, thresholds } = {}) {
|
|
|
85
107
|
let ev; let corpus;
|
|
86
108
|
try { corpus = buildCorpus(ir, spec); ev = evaluateRule(ir, corpus, thresholds); } catch (e) { results.push({ id, status: 'broken', reasons: [`cannot be scored: ${String(e.message).slice(0, 120)}`] }); continue; }
|
|
87
109
|
const fp = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
|
|
88
|
-
const
|
|
110
|
+
const prov = loadProvenance(root, id);
|
|
111
|
+
const baseline = prov?.benchmarkResults ?? null;
|
|
89
112
|
const reasons = [...ev.reasons];
|
|
113
|
+
// GA-07 §3 (0.195.27): the promotion was a review of ONE rule against ONE corpus, both digested into the promotion event. A
|
|
114
|
+
// corpus edited after the review (an independent example dropped, a protected variant softened) can still clear the gate, so
|
|
115
|
+
// the gate alone would not notice; the digests do. Such a rule is `review-stale`: not regressed, but no longer the reviewed one.
|
|
116
|
+
const reviewed = prov?.promotionEvent?.evidence ?? null;
|
|
117
|
+
if (reviewed) {
|
|
118
|
+
if (reviewed.positiveCorpusHash !== corpusHash(corpus.positives) || reviewed.negativeCorpusHash !== corpusHash(corpus.negatives)) reasons.push('the corpus changed since the reviewed promotion (its digests no longer match the promotion event); re-evaluate and promote again');
|
|
119
|
+
}
|
|
90
120
|
if (fp.length) reasons.push(`fires on ${fp.length} item(s) of the shared false-positive corpus: ${fp.slice(0, 3).map((h) => h.label).join(', ')}${fp.length > 3 ? ', ...' : ''}`);
|
|
91
121
|
if (baseline && ev.score.recall != null && baseline.recall != null && ev.score.recall < baseline.recall) reasons.push(`recall fell from ${(baseline.recall * 100).toFixed(1)}% to ${(ev.score.recall * 100).toFixed(1)}% since promotion`);
|
|
92
122
|
results.push({ id, status: reasons.length ? 'regressed' : 'ok', reasons, precision: ev.score.precision, recall: ev.score.recall, protectedFailures: ev.score.protectedFailures, fpCorpusHits: fp.length });
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// using the SAME filename convention `runRuleTests` already reads (`vulnerable`/`clean` in the path).
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
8
9
|
import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
|
|
9
10
|
import { newLedger, verifyLedger } from './lifecycle.js';
|
|
10
11
|
import { signLastScan, verifyLastScan } from '../integrity.js';
|
|
@@ -35,20 +36,85 @@ export function loadLedger(scanRoot, env = process.env) {
|
|
|
35
36
|
const v = verifyLedger(ledger);
|
|
36
37
|
if (!v.ok) return { ...ledger, tampered: true, tamperReason: v.reason };
|
|
37
38
|
const sig = verifyLastScan(text, `${f}.sig`);
|
|
38
|
-
if (sig ===
|
|
39
|
+
if (sig === true) return { ...ledger, verifiedBy: 'install-key' };
|
|
40
|
+
// GA-07 §3 (0.195.27): the install key never leaves the machine that promoted the rule, so on a CI runner (or any other
|
|
41
|
+
// clone) the HMAC cannot verify. A ledger that `promote --sign-key` also signed with the operator's Ed25519 key is accepted
|
|
42
|
+
// when that signature verifies against a key the environment trusts: AGENTIC_SECURITY_RULES_PUBLIC_KEY (set outside the
|
|
43
|
+
// repository, a CI variable) or the trusted-keys store the rule packs already use. The chain check above still applies, and
|
|
44
|
+
// the signature covers the whole file, so a dropped last entry fails it like any other edit. A project-local key file alone
|
|
45
|
+
// is not trust (an author who can write .agentic-security/ could add one), which is why the env variable is the CI route.
|
|
46
|
+
const author = verifyAuthorSignature(text, `${f}${AUTHOR_SIG_SUFFIX}`, authorKeysFrom(scanRoot, env));
|
|
47
|
+
if (author.ok) return { ...ledger, verifiedBy: 'author-key', authorKeyId: author.keyId };
|
|
48
|
+
if (sig === false) return { ...ledger, tampered: true, tamperReason: `the ledger signature does not verify under this install key${author.present ? ` and its author signature does not verify against any trusted key (${author.reason})` : ' and it carries no author signature (promote with --sign-key to make it portable)'}` };
|
|
39
49
|
if (sig === null && ledger.entries.length && env.AGENTIC_SECURITY_RULES_UNSIGNED !== '1') {
|
|
40
50
|
return { ...ledger, tampered: true, tamperReason: 'the ledger is unsigned (written before 0.195.23 or by another tool); set AGENTIC_SECURITY_RULES_UNSIGNED=1 once to accept and re-sign it' };
|
|
41
51
|
}
|
|
42
|
-
return ledger;
|
|
52
|
+
return { ...ledger, verifiedBy: sig === null ? 'unsigned-accepted' : 'install-key' };
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
|
|
55
|
+
const AUTHOR_SIG_SUFFIX = '.author.sig';
|
|
56
|
+
const PUBLIC_KEY_ENV = 'AGENTIC_SECURITY_RULES_PUBLIC_KEY';
|
|
57
|
+
|
|
58
|
+
/** The Ed25519 public keys a ledger's author signature may verify against: the environment variable (one or more base64 raw keys,
|
|
59
|
+
* comma separated) first, then the rule-pack trust store (bundled keys, and the project file only when the operator honours it). */
|
|
60
|
+
function authorKeysFrom(scanRoot, env) {
|
|
61
|
+
const keys = [];
|
|
62
|
+
for (const k of String(env[PUBLIC_KEY_ENV] || '').split(',').map((s) => s.trim()).filter(Boolean)) keys.push({ id: 'env', publicKey: k, alg: 'ed25519' });
|
|
63
|
+
const origErr = console.error; console.error = () => {};
|
|
64
|
+
try { keys.push(...loadTrustedKeys(scanRoot)); } catch { /* no trust store */ } finally { console.error = origErr; }
|
|
65
|
+
return keys;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function verifyAuthorSignature(text, sigPath, keys) {
|
|
69
|
+
let sig;
|
|
70
|
+
try { sig = fs.readFileSync(sigPath); } catch { return { ok: false, present: false, reason: 'no author signature' }; }
|
|
71
|
+
if (!keys.length) return { ok: false, present: true, reason: `no trusted key (set ${PUBLIC_KEY_ENV})` };
|
|
72
|
+
const body = Buffer.from(text, 'utf8');
|
|
73
|
+
for (const k of keys) {
|
|
74
|
+
try {
|
|
75
|
+
const keyBytes = Buffer.from(k.publicKey, 'base64');
|
|
76
|
+
if (keyBytes.length !== 32) continue;
|
|
77
|
+
const keyObj = crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: keyBytes.toString('base64url') }, format: 'jwk' });
|
|
78
|
+
if (crypto.verify(null, body, keyObj, sig)) return { ok: true, present: true, keyId: k.id || '(unnamed)' };
|
|
79
|
+
} catch { /* next key */ }
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, present: true, reason: 'bad signature' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function signWithAuthorKey(text, privateKeyB64) {
|
|
85
|
+
const privBytes = Buffer.from(privateKeyB64, 'base64');
|
|
86
|
+
if (privBytes.length !== 32) throw new Error('private key must be 32 bytes (raw ed25519)');
|
|
87
|
+
const keyObj = crypto.createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), privBytes]), format: 'der', type: 'pkcs8' });
|
|
88
|
+
return crypto.sign(null, Buffer.from(text, 'utf8'), keyObj);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The public key (base64 raw) that pairs with a raw Ed25519 private key; printed by `promote` so the operator can set it in CI. */
|
|
92
|
+
export function publicKeyOf(privateKeyB64) {
|
|
93
|
+
const privBytes = Buffer.from(privateKeyB64, 'base64');
|
|
94
|
+
if (privBytes.length !== 32) return null;
|
|
95
|
+
const keyObj = crypto.createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), privBytes]), format: 'der', type: 'pkcs8' });
|
|
96
|
+
return Buffer.from(crypto.createPublicKey(keyObj).export({ format: 'jwk' }).x, 'base64url').toString('base64');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write the ledger with its install-key signature and, when the operator's Ed25519 key is given, an author signature beside it
|
|
101
|
+
* (`ledger.json.author.sig`) that another machine can verify (see loadLedger). Without a key an existing author signature is
|
|
102
|
+
* removed rather than left stale: a signature that no longer matches the file would read as tampering on the next clone.
|
|
103
|
+
*/
|
|
104
|
+
export function saveLedger(scanRoot, ledger, { authorKey = null } = {}) {
|
|
46
105
|
if (ledger?.tampered) return { ok: false, reason: `refusing to overwrite a ledger that failed its integrity check (${ledger.tamperReason})` };
|
|
47
106
|
if (!ensureStateDir(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
48
107
|
const f = statePath(scanRoot, DIR, 'ledger.json');
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
108
|
+
const { verifiedBy, authorKeyId, ...body } = ledger;
|
|
109
|
+
const text = JSON.stringify(body, null, 2);
|
|
110
|
+
let ok = safeWriteState(f, text) && safeWriteState(`${f}.sig`, signLastScan(text));
|
|
111
|
+
let authorSigned = false;
|
|
112
|
+
if (ok && authorKey) {
|
|
113
|
+
try { ok = safeWriteState(`${f}${AUTHOR_SIG_SUFFIX}`, signWithAuthorKey(text, authorKey)); authorSigned = ok; } catch (e) { return { ok: false, reason: `cannot author-sign the ledger: ${String(e.message).slice(0, 120)}` }; }
|
|
114
|
+
} else if (ok) {
|
|
115
|
+
try { fs.unlinkSync(`${f}${AUTHOR_SIG_SUFFIX}`); } catch { /* none to remove */ }
|
|
116
|
+
}
|
|
117
|
+
return { ok, authorSigned };
|
|
52
118
|
}
|
|
53
119
|
|
|
54
120
|
export function saveIR(scanRoot, ir) {
|
|
@@ -39,6 +39,7 @@ import * as path from 'node:path';
|
|
|
39
39
|
import * as yaml from '../util/yaml.js';
|
|
40
40
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
41
41
|
import { loadApproverRegistry, verifyApprover, requiredRolesFor } from '../fix/approver-registry.js';
|
|
42
|
+
import { contentFingerprint } from '../llm-validator/disposition.js';
|
|
42
43
|
|
|
43
44
|
const MS_PER_DAY = 86400000;
|
|
44
45
|
const SOFT_TTL_DAYS = 30;
|
|
@@ -63,6 +64,15 @@ export function saveSoftAccepted(scanRoot, items) {
|
|
|
63
64
|
safeWriteState(fp, JSON.stringify({ accepted: items }, null, 2));
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
// Content fingerprint of a scanned file, or null when it cannot be read.
|
|
68
|
+
function _diskFingerprint(scanRoot, file) {
|
|
69
|
+
try {
|
|
70
|
+
const abs = path.resolve(scanRoot, file);
|
|
71
|
+
if (abs !== path.resolve(scanRoot) && !abs.startsWith(path.resolve(scanRoot) + path.sep)) return null;
|
|
72
|
+
return contentFingerprint(fs.readFileSync(abs, 'utf8'));
|
|
73
|
+
} catch { return null; }
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
export function addSoftAcceptance(scanRoot, finding, reason) {
|
|
67
77
|
const items = loadSoftAccepted(scanRoot);
|
|
68
78
|
const expires = new Date(_now() + SOFT_TTL_DAYS * MS_PER_DAY).toISOString().slice(0, 10);
|
|
@@ -74,6 +84,8 @@ export function addSoftAcceptance(scanRoot, finding, reason) {
|
|
|
74
84
|
reason: reason || 'vibecoded for now',
|
|
75
85
|
accepted_at: new Date().toISOString().slice(0, 10),
|
|
76
86
|
expires_at: expires,
|
|
87
|
+
// PRD FR 05: bind the acceptance to the code it was made on, so a changed file reopens it.
|
|
88
|
+
fingerprint: _diskFingerprint(scanRoot, finding.file),
|
|
77
89
|
});
|
|
78
90
|
saveSoftAccepted(scanRoot, items);
|
|
79
91
|
return expires;
|
|
@@ -156,6 +168,15 @@ export function applySuppressions(findings, scanRoot, profile) {
|
|
|
156
168
|
kept.push({ ...f, _suppressionExpired: true });
|
|
157
169
|
continue;
|
|
158
170
|
}
|
|
171
|
+
// PRD FR 05: an entry bound to a content fingerprint reopens when the
|
|
172
|
+
// file changes or disappears. Entries without one keep the legacy behaviour.
|
|
173
|
+
if (matched.fingerprint) {
|
|
174
|
+
const now_fp = _diskFingerprint(scanRoot, f.file);
|
|
175
|
+
if (now_fp !== matched.fingerprint) {
|
|
176
|
+
kept.push({ ...f, _suppressionReopened: now_fp ? 'source changed since the suppression was recorded' : 'suppressed source is no longer available' });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
159
180
|
// Pro: validate the entry still passes
|
|
160
181
|
if (isPro) {
|
|
161
182
|
// Same registry/category shape FR-307 already uses for high-impact
|
|
@@ -25,6 +25,7 @@ import * as path from 'node:path';
|
|
|
25
25
|
|
|
26
26
|
import { stateDir, statePath, stateWritesEnabled } from './state-dir.js';
|
|
27
27
|
import { isLanguageSource, isLanguageManifest, isLanguageExcludedPath, isExplicitExport, impactedFiles } from '../language/discovery.js';
|
|
28
|
+
import { buildScanInventory } from '../scope/inventory.js';
|
|
28
29
|
const STATUS_MD = 'watch-status.md';
|
|
29
30
|
const STATUS_JSON = 'watch-status.json';
|
|
30
31
|
const DEBOUNCE_MS = 350;
|
|
@@ -33,6 +34,25 @@ const MAX_BURST = 50; // ignore beyond this # of rapid events
|
|
|
33
34
|
const SCAN_EXT_RE = /\.(?:[jt]sx?|mjs|cjs|py|java|kt|go|rb|php|cs|c|cc|cpp|h|hpp|rs|sol|vy|swift|dart|toml|yml|yaml|json|tf|tfvars|bicep)$/i;
|
|
34
35
|
const IGNORE_DIR_RE = /(?:^|\/)(?:\.git|node_modules|\.bench-cache|dist|build|\.next|coverage|\.agentic-security)(?:$|\/)/;
|
|
35
36
|
|
|
37
|
+
// Under Git scope (PRD FR 01) the watcher asks the same inventory the scan uses, so a change to a Git-ignored file does not
|
|
38
|
+
// trigger a rescan, and a tracked file under a directory the default list would skip (`dist/`, `build/`) still does. A change
|
|
39
|
+
// to an ignore source rebuilds the inventory. A path the inventory has not seen (a new file) rebuilds it, at most once a second.
|
|
40
|
+
const IGNORE_SOURCE = /(?:^|\/)\.gitignore$|^\.git\/info\/exclude$/;
|
|
41
|
+
export function createScopeFilter(scanRoot, scope = {}, { build = buildScanInventory, now = Date.now } = {}) {
|
|
42
|
+
let included = null;
|
|
43
|
+
let builtAt = -Infinity;
|
|
44
|
+
const rebuild = () => { included = new Set(build(scanRoot, scope).included); builtAt = now(); };
|
|
45
|
+
rebuild();
|
|
46
|
+
return (rel) => {
|
|
47
|
+
if (!rel || /^\.agentic-security(?:\/|$)/.test(rel)) return false;
|
|
48
|
+
if (IGNORE_SOURCE.test(rel)) { rebuild(); return true; }
|
|
49
|
+
if (/^\.git(?:\/|$)/.test(rel)) return false;
|
|
50
|
+
if (included.has(rel)) return true;
|
|
51
|
+
if (now() - builtAt >= 1000) rebuild();
|
|
52
|
+
return included.has(rel);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
function _isScanable(rel) {
|
|
37
57
|
if (!rel) return false;
|
|
38
58
|
// An explicit export is evidence even though it lives in a build directory.
|
|
@@ -162,6 +182,7 @@ export function readStatus(scanRoot) {
|
|
|
162
182
|
export async function watchProject(scanRoot, onChange, opts = {}) {
|
|
163
183
|
if (process.env.AGENTIC_SECURITY_NO_WATCH === '1') return { stop: async () => {}, _disabled: true };
|
|
164
184
|
const recursive = opts.recursive !== false;
|
|
185
|
+
const eligible = typeof opts.isEligible === 'function' ? opts.isEligible : _isScanable;
|
|
165
186
|
const ac = new AbortController();
|
|
166
187
|
let timer = null;
|
|
167
188
|
const pending = new Set();
|
|
@@ -183,7 +204,7 @@ export async function watchProject(scanRoot, onChange, opts = {}) {
|
|
|
183
204
|
for await (const evt of fs.watch(scanRoot, { recursive, signal: ac.signal })) {
|
|
184
205
|
if (stopped) break;
|
|
185
206
|
const rel = String(evt.filename || '').replace(/\\/g, '/');
|
|
186
|
-
if (!
|
|
207
|
+
if (!eligible(rel)) continue;
|
|
187
208
|
pending.add(path.join(scanRoot, rel));
|
|
188
209
|
if (timer) clearTimeout(timer);
|
|
189
210
|
timer = setTimeout(flush, DEBOUNCE_MS);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Report view over dispositions (PRD FR 09). Display only: it reads a scan and
|
|
2
|
+
// returns groups and counts, and never changes stored findings, so hiding a
|
|
3
|
+
// category here cannot mark anything assessed or imply remediation. Raw and
|
|
4
|
+
// displayed counts reconcile: raw = shown + every hidden bucket.
|
|
5
|
+
|
|
6
|
+
import { DISPOSITIONS as D } from '../llm-validator/disposition.js';
|
|
7
|
+
|
|
8
|
+
export const VIEW_SCHEMA = 'report-view/1';
|
|
9
|
+
export const EXCLUDABLE_KINDS = Object.freeze(['sca', 'generated', 'secrets', 'logic']);
|
|
10
|
+
|
|
11
|
+
const LANG_BY_EXT = { js: 'javascript', mjs: 'javascript', cjs: 'javascript', ts: 'typescript', tsx: 'typescript', jsx: 'javascript', py: 'python', java: 'java', go: 'go', rb: 'ruby', rs: 'rust', cs: 'csharp', kt: 'kotlin', php: 'php', hs: 'haskell', lhs: 'haskell', nix: 'nix', yml: 'yaml', yaml: 'yaml', tf: 'terraform' };
|
|
12
|
+
export function languageOf(file) {
|
|
13
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(String(file || ''));
|
|
14
|
+
return (m && LANG_BY_EXT[m[1].toLowerCase()]) || 'other';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const GENERATED = /(?:^|\/)(?:dist|build|buck-out|dist-newstyle|\.stack-work|generated|gen)\//i;
|
|
18
|
+
|
|
19
|
+
function groupOf(f) {
|
|
20
|
+
const d = f.disposition;
|
|
21
|
+
if (d === D.CONFIRMED) return 'confirmed';
|
|
22
|
+
if (d === D.HARDENING) return 'hardening';
|
|
23
|
+
if (d === D.REFUTED) return 'history';
|
|
24
|
+
if (d === D.NEEDS_REVIEW) return 'needsReview';
|
|
25
|
+
return 'unvalidated';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parse `--exclude-kind sca,generated` style values. Returns { kinds, errors }. */
|
|
29
|
+
export function parseExcludeKinds(v) {
|
|
30
|
+
const kinds = []; const errors = [];
|
|
31
|
+
for (const k of String(v ?? '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
32
|
+
if (EXCLUDABLE_KINDS.includes(k)) kinds.push(k); else errors.push(`--exclude-kind: unknown kind "${k}" (known: ${EXCLUDABLE_KINDS.join(', ')})`);
|
|
33
|
+
}
|
|
34
|
+
return { kinds, errors };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} scan
|
|
39
|
+
* @param {{excludeKinds?: string[], filters?: {category?: string, language?: string, package?: string, path?: string, disposition?: string, minConfidence?: number}}} [o]
|
|
40
|
+
*/
|
|
41
|
+
export function buildDispositionView(scan, { excludeKinds = [], filters = {} } = {}) {
|
|
42
|
+
const buckets = [
|
|
43
|
+
['code', scan.findings || [], null],
|
|
44
|
+
['secrets', scan.secrets || [], 'secrets'],
|
|
45
|
+
['logic', scan.logicVulns || [], 'logic'],
|
|
46
|
+
['sca', scan.supplyChain || [], 'sca'],
|
|
47
|
+
];
|
|
48
|
+
const hidden = { byKind: {}, byFilter: 0 };
|
|
49
|
+
const groups = { confirmed: [], needsReview: [], unvalidated: [], hardening: [], history: [] };
|
|
50
|
+
let raw = 0;
|
|
51
|
+
for (const [bucket, items, kind] of buckets) {
|
|
52
|
+
for (const f of items) {
|
|
53
|
+
raw++;
|
|
54
|
+
const isSca = bucket === 'sca';
|
|
55
|
+
const k = kind || (GENERATED.test(String(f.file || '')) ? 'generated' : null);
|
|
56
|
+
if (k && excludeKinds.includes(k)) { hidden.byKind[k] = (hidden.byKind[k] || 0) + 1; continue; }
|
|
57
|
+
if (filters.category && String(f.family || f.category || '') !== filters.category) { hidden.byFilter++; continue; }
|
|
58
|
+
if (filters.language && languageOf(f.file) !== filters.language) { hidden.byFilter++; continue; }
|
|
59
|
+
if (filters.package && String(f.package || f.component || f.name || '') !== filters.package) { hidden.byFilter++; continue; }
|
|
60
|
+
if (filters.path && !String(f.file || '').startsWith(filters.path)) { hidden.byFilter++; continue; }
|
|
61
|
+
if (filters.disposition && (isSca ? 'unvalidated' : groupOf(f)) !== filters.disposition && f.disposition !== filters.disposition) { hidden.byFilter++; continue; }
|
|
62
|
+
if (typeof filters.minConfidence === 'number' && (typeof f.confidence === 'number' ? f.confidence : 1) < filters.minConfidence) { hidden.byFilter++; continue; }
|
|
63
|
+
groups[isSca ? 'unvalidated' : groupOf(f)].push(f);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Refuted findings were moved to the suppression ledger by the engine; they appear as history.
|
|
67
|
+
for (const s of scan.suppressions || []) if (s && s.disposition === D.REFUTED) groups.history.push(s);
|
|
68
|
+
const counts = Object.fromEntries(Object.entries(groups).map(([k, v]) => [k, v.length]));
|
|
69
|
+
// History comes from the ledger, not from the live buckets, so it is outside the raw/shown reconciliation.
|
|
70
|
+
const shown = counts.confirmed + counts.needsReview + counts.unvalidated + counts.hardening;
|
|
71
|
+
const hiddenTotal = Object.values(hidden.byKind).reduce((a, b) => a + b, 0) + hidden.byFilter;
|
|
72
|
+
return {
|
|
73
|
+
schema: VIEW_SCHEMA,
|
|
74
|
+
excludeKinds: [...excludeKinds],
|
|
75
|
+
filters: { ...filters },
|
|
76
|
+
counts,
|
|
77
|
+
actionable: counts.confirmed + counts.needsReview,
|
|
78
|
+
raw, shown, hidden,
|
|
79
|
+
reconciles: raw === shown + hiddenTotal,
|
|
80
|
+
note: hiddenTotal ? 'Hidden items are still stored in the scan and are not assessed or remediated by hiding them.' : null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function viewLine(v) {
|
|
85
|
+
if (!v) return null;
|
|
86
|
+
const hid = Object.entries(v.hidden.byKind).map(([k, n]) => `${n} ${k}`);
|
|
87
|
+
if (v.hidden.byFilter) hid.push(`${v.hidden.byFilter} filtered`);
|
|
88
|
+
return `Findings: ${v.counts.confirmed} confirmed, ${v.counts.needsReview} needs review, ${v.counts.unvalidated} unvalidated, ${v.counts.hardening} hardening advice, ${v.counts.history} refuted history`
|
|
89
|
+
+ (hid.length ? `; hidden from this view (still stored, not assessed): ${hid.join(', ')}` : '');
|
|
90
|
+
}
|
package/src/report/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Report writers — JSON / Markdown / SARIF.
|
|
2
|
+
import { viewLine } from './disposition-view.js';
|
|
2
3
|
import * as crypto from 'node:crypto';
|
|
3
4
|
import { _isCustomSuppressed } from '../engine.js';
|
|
4
5
|
import { alertFace, approveFace } from './mascot.js';
|
|
@@ -290,6 +291,14 @@ export function normalizeFindings(scan){
|
|
|
290
291
|
blastRadius: f.blastRadius || null,
|
|
291
292
|
// Sentinel-parity (FR-PREC, FR-L3) preserved fields.
|
|
292
293
|
stableId: f.stableId || null,
|
|
294
|
+
// FR 05 / FR 10: the final disposition, why, and the evidence record. Present only when the engine computed them.
|
|
295
|
+
...(f.disposition ? { disposition: f.disposition, dispositionDetail: f.dispositionDetail || null } : {}),
|
|
296
|
+
...(Array.isArray(f.claims) && f.claims.length > 1 ? { claims: f.claims } : {}),
|
|
297
|
+
...(f.record ? { record: f.record } : {}),
|
|
298
|
+
// FR 06: every workflow finding names the workflow, job, step, input origin, sensitive operation and privilege boundary.
|
|
299
|
+
...(f.workflow ? { workflow: f.workflow, job: f.job ?? null, step: f.step ?? null, inputOrigin: f.inputOrigin ?? null, inputOriginKind: f.inputOriginKind ?? null, sensitiveOperation: f.sensitiveOperation ?? null, privilegeBoundary: f.privilegeBoundary ?? null, guards: f.guards ?? [], missingEvidence: f.missingEvidence ?? (f.dispositionDetail && f.dispositionDetail.missingEvidence) ?? [] } : {}),
|
|
300
|
+
...(f.oidcTrust ? { oidcTrust: f.oidcTrust } : {}),
|
|
301
|
+
...(f.validator_reject_retained ? { validator_reject_retained: true } : {}),
|
|
293
302
|
confidenceTier: f.confidenceTier || null,
|
|
294
303
|
exploitability: typeof f.exploitability === 'number' ? f.exploitability : null,
|
|
295
304
|
exploitabilityTier: f.exploitabilityTier || null,
|
|
@@ -569,6 +578,8 @@ export function normalizeFindings(scan){
|
|
|
569
578
|
version: sc.version,
|
|
570
579
|
cveAliases: sc.cveAliases || [],
|
|
571
580
|
osvId: sc.osvId || null,
|
|
581
|
+
...(sc.basis ? { basis: sc.basis } : {}),
|
|
582
|
+
...(sc.advisoryIds ? { advisoryIds: sc.advisoryIds, occurrences: sc.occurrences } : {}),
|
|
572
583
|
advisory: sc.advisory || sc.description || sc.summary || '',
|
|
573
584
|
// Hackage (and any matcher that decides from ranges): WHY the dependency matched. Without these a `possibly-affected` finding
|
|
574
585
|
// (only a declared range is known) reads exactly like an `affected` one (the resolved version is inside the range).
|
|
@@ -764,6 +775,13 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
764
775
|
// "analysis complete". Null when the engine did not compute one (e.g. a
|
|
765
776
|
// hand-built `scan` object in a test), same convention as the S7 fields
|
|
766
777
|
// above; a real scan always computes it.
|
|
778
|
+
...(scan.scanRecord ? { scanRecord: scan.scanRecord } : {}),
|
|
779
|
+
...(scan.reportView ? { reportView: scan.reportView } : {}),
|
|
780
|
+
...(scan.dispositionCounts ? { dispositionCounts: scan.dispositionCounts } : {}),
|
|
781
|
+
// PRD FR 05: every candidate stays discoverable. Equivalent claims grouped under a primary are listed here, and the
|
|
782
|
+
// reconciliation shows raw, grouped, refuted and active counts with nothing counted twice.
|
|
783
|
+
...(scan.candidateReconciliation ? { candidateReconciliation: scan.candidateReconciliation } : {}),
|
|
784
|
+
...(Array.isArray(scan.groupedClaims) && scan.groupedClaims.length ? { groupedClaims: scan.groupedClaims.map((g) => ({ id: g.id ?? null, stableId: g.stableId ?? null, parser: g.parser ?? null, vuln: g.vuln ?? null, cwe: g.cwe ?? null, severity: g.severity ?? null, file: g.file ?? null, line: g.line ?? null, groupedInto: g.groupedInto ?? null, disposition: g.disposition ?? null })) } : {}),
|
|
767
785
|
scanHealth: scan.scanHealth || null,
|
|
768
786
|
// X-207: advisory hypotheses a caller put in the finding list; excluded from `findings`, listed here so it is never silent.
|
|
769
787
|
...(Array.isArray(scan.advisoryExcluded) && scan.advisoryExcluded.length ? { advisoryExcluded: scan.advisoryExcluded } : {}),
|
|
@@ -1079,6 +1097,29 @@ export const TOOL_CAVEATS = Object.freeze([
|
|
|
1079
1097
|
},
|
|
1080
1098
|
]);
|
|
1081
1099
|
|
|
1100
|
+
// FR 02: one reading of the Haskell coverage record, shared by the CLI, HTML and SARIF outputs so they cannot disagree.
|
|
1101
|
+
export function haskellCoverageSummary(scan) {
|
|
1102
|
+
const h = scan && scan.scanHealth && scan.scanHealth.languageCoverage && scan.scanHealth.languageCoverage.haskell;
|
|
1103
|
+
if (!h || !h.present) return null;
|
|
1104
|
+
const pct = (r) => (r == null ? 'n/a' : `${(r * 100).toFixed(1)}%`);
|
|
1105
|
+
return {
|
|
1106
|
+
status: h.capability.status,
|
|
1107
|
+
discovered: h.totals.discovered, eligible: h.totals.eligible, eligibleHs: h.totals.eligibleHs,
|
|
1108
|
+
parseRate: h.stages.parse.rate, semanticRate: h.stages.semantic.rate,
|
|
1109
|
+
parseText: pct(h.stages.parse.rate), semanticText: pct(h.stages.semantic.rate),
|
|
1110
|
+
functions: h.functions, unresolvedCalls: h.unresolvedCalls,
|
|
1111
|
+
ruleFamilies: h.ruleFamilies.map((f) => ({ id: f.id, status: f.status, findings: f.findings })),
|
|
1112
|
+
reasons: h.capability.reasons, limitations: h.capability.limitations,
|
|
1113
|
+
incompleteEntries: h.incompleteEntries.length,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function haskellCoverageLine(scan) {
|
|
1118
|
+
const h = haskellCoverageSummary(scan);
|
|
1119
|
+
if (!h) return null;
|
|
1120
|
+
return `Haskell coverage: ${h.status}; ${h.eligibleHs} eligible .hs file(s), parsed ${h.parseText}, semantic analysis completed ${h.semanticText}; ${h.functions.lowered}/${h.functions.discovered} function(s) analysed, ${h.unresolvedCalls == null ? 'n/a' : h.unresolvedCalls} unresolved call(s)`;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1082
1123
|
export function toSARIF(scan, meta={}){
|
|
1083
1124
|
const findings = normalizeFindings(scan);
|
|
1084
1125
|
const ruleMap = new Map();
|
|
@@ -1128,6 +1169,8 @@ export function toSARIF(scan, meta={}){
|
|
|
1128
1169
|
// /security-trend regression analysis can attribute deltas to rule
|
|
1129
1170
|
// changes vs. code changes.
|
|
1130
1171
|
properties: {
|
|
1172
|
+
...(haskellCoverageSummary(scan) ? { haskellCoverage: haskellCoverageSummary(scan) } : {}),
|
|
1173
|
+
...(scan.reportView ? { reportView: scan.reportView } : {}),
|
|
1131
1174
|
...(scan && scan._rulesetVersion ? { rulesetVersion: scan._rulesetVersion } : {}),
|
|
1132
1175
|
...(scan && scan._rulesetVersionSource ? { rulesetVersionSource: scan._rulesetVersionSource } : {}),
|
|
1133
1176
|
...(scan && scan._rulesetVersionMismatch ? { rulesetVersionMismatch: scan._rulesetVersionMismatch } : {}),
|
|
@@ -1376,6 +1419,8 @@ export function toHTML(scan, meta = {}) {
|
|
|
1376
1419
|
<div class="card"><h2>STRIDE coverage</h2><table><tbody>${strideRows}</tbody></table></div>
|
|
1377
1420
|
<div class="card"><h2>Top files</h2><table><tbody>${hotRows}</tbody></table></div>
|
|
1378
1421
|
</div>
|
|
1422
|
+
${(() => { const vl = viewLine(scan.reportView); return vl ? `<div class="card" style="margin-bottom:24px"><h2>Findings by disposition</h2><div class="meta">${_esc(vl)}</div></div>` : ''; })()}
|
|
1423
|
+
${(() => { const h = haskellCoverageSummary(scan); if (!h) return ''; return `<div class="card" style="margin-bottom:24px"><h2>Haskell coverage — ${_esc(h.status)}</h2><div class="meta">${_esc(haskellCoverageLine(scan))}</div><div class="meta">Rule families: ${h.ruleFamilies.map((f) => `${_esc(f.id)} (${_esc(f.status)})`).join(', ')}</div>${h.reasons.map((r) => `<div class="meta">• ${_esc(r)}</div>`).join('')}${h.limitations.map((r) => `<div class="meta">• ${_esc(r)}</div>`).join('')}</div>`; })()}
|
|
1379
1424
|
<div class="filters">
|
|
1380
1425
|
<input id="q" placeholder="Filter by file, vuln, CWE…" />
|
|
1381
1426
|
<select id="sev"><option value="">All severities</option><option>critical</option><option>high</option><option>medium</option><option>low</option><option>info</option></select>
|
|
@@ -1688,6 +1733,8 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1688
1733
|
const conditions = Array.isArray(scan.scanHealth.conditions) ? scan.scanHealth.conditions : [];
|
|
1689
1734
|
lines.push(c(` ${scan.scanHealth.status}: ${conditions[0] || 'analysis did not complete cleanly'}${conditions.length > 1 ? ` (+${conditions.length - 1} more)` : ''}`, SEV_COLOR.high));
|
|
1690
1735
|
}
|
|
1736
|
+
{ const hl = haskellCoverageLine(scan); if (hl) lines.push(c(` ${hl}`, DIM)); }
|
|
1737
|
+
{ const vl = viewLine(scan.reportView); if (vl) lines.push(c(` ${vl}`, DIM)); }
|
|
1691
1738
|
lines.push(` • ${sev.critical} critical · ${sev.high} high · ${advisoryCount} advisory`);
|
|
1692
1739
|
// Per-category 0..100 score bars — Secrets / Permissions / Hooks / MCP / Agents.
|
|
1693
1740
|
// Only render when at least one category has been contributed to (skip the
|