@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.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 +34 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1164 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +17 -2
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Secrets parity for Haskell and Nix syntax (X-001).
|
|
2
|
+
//
|
|
3
|
+
// What the generic secret scanners already do for these files (known provider tokens, entropy next to a
|
|
4
|
+
// credential word) is kept as is. This module adds what is language-specific and was missing:
|
|
5
|
+
//
|
|
6
|
+
// * comment/string-aware lexing: Haskell nested `{- {- -} -}` and `--` (an operator run such as `-->` is not a
|
|
7
|
+
// comment), quasi-quotes, Nix `#`, `/* */`, `"..."` and indented `''...''` strings with their escapes. A
|
|
8
|
+
// lexer, not a regex, decides what is a comment, so a nested comment cannot swallow or expose live code.
|
|
9
|
+
// * split secrets: `"AKIA" ++ "..."` and `"ghp_" <> "..."` (Haskell), `"a" + "b"` (Nix)
|
|
10
|
+
// * dependency-URL credentials in Cabal/Stack manifests, flake.lock and Nix `url`/`fetch*` attributes
|
|
11
|
+
// * the provider and rotation guidance a response needs, derived from the secret's SHAPE only
|
|
12
|
+
//
|
|
13
|
+
// Nothing here verifies a credential, contacts a provider, or revokes anything: rotation guidance is text.
|
|
14
|
+
// Every finding carries a masked value only, never the secret.
|
|
15
|
+
|
|
16
|
+
const SECRET_PREFIX = /(?:sk_|sk-|AKIA|ASIA|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[abprs]-|AIza|ya29\.|eyJ[A-Za-z0-9_-]{8,}|-----BEGIN|glpat-|shpat_|shpss_|npm_|dop_v1_|SG\.[A-Za-z0-9_-]{10,})/;
|
|
17
|
+
const SECRET_NAME = /(?:api[_-]?key|secret|token|passwd|password|pwd|credential|private[_-]?key|access[_-]?key|client[_-]?secret|auth[_-]?token|psk)/i;
|
|
18
|
+
const PLACEHOLDER = /(?:placeholder|example|xxx+|your[_-]|changeme|<[A-Za-z_ -]+>|^MY_|INSERT_|REPLACE_|TODO|test_key|fake_|sample_|dummy_)/i;
|
|
19
|
+
|
|
20
|
+
// ── lexers ───────────────────────────────────────────────────────────────────
|
|
21
|
+
const HS_SYMBOL = /[!#$%&*+./<=>?@\\^|~:-]/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Replace comment characters with spaces, keeping every newline and every other offset. Strings and
|
|
25
|
+
* quasi-quotes are skipped, never blanked.
|
|
26
|
+
*/
|
|
27
|
+
export function blankHaskell(src) {
|
|
28
|
+
const n = src.length; const out = src.split('');
|
|
29
|
+
const blank = (a, b) => { for (let i = a; i < b; i++) if (out[i] !== '\n' && out[i] !== '\r') out[i] = ' '; };
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < n) {
|
|
32
|
+
const c = src[i]; const d = src[i + 1];
|
|
33
|
+
if (c === '{' && d === '-') { // nested block comment (also {-# pragmas #-})
|
|
34
|
+
let depth = 1; let j = i + 2;
|
|
35
|
+
while (j < n && depth > 0) { if (src[j] === '{' && src[j + 1] === '-') { depth++; j += 2; } else if (src[j] === '-' && src[j + 1] === '}') { depth--; j += 2; } else j++; }
|
|
36
|
+
blank(i, j); i = j; continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === '-' && d === '-') { // a run of >=2 dashes not part of a longer operator
|
|
39
|
+
let j = i; while (src[j] === '-') j++;
|
|
40
|
+
const prev = src[i - 1];
|
|
41
|
+
if (!(prev && HS_SYMBOL.test(prev)) && !(src[j] && HS_SYMBOL.test(src[j]))) { let e = src.indexOf('\n', j); if (e < 0) e = n; blank(i, e); i = e; continue; }
|
|
42
|
+
i = j; continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === '"') { let j = i + 1; while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === '"' || src[j] === '\n') break; j++; } i = Math.min(j + 1, n); continue; }
|
|
45
|
+
if (c === "'") { // a char literal, never a prime after an identifier character
|
|
46
|
+
const prev = src[i - 1];
|
|
47
|
+
if (prev && /[A-Za-z0-9_']/.test(prev)) { i++; continue; }
|
|
48
|
+
const m = /^'(?:[^'\\\n]|\\[^\n]{1,8}?)'/.exec(src.slice(i, i + 12));
|
|
49
|
+
if (m) { i += m[0].length; continue; }
|
|
50
|
+
i++; continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === '[' && /[A-Za-z_][\w']*\|/.test(src.slice(i + 1, i + 40)) && /^\[[A-Za-z_][\w'.]*\|/.test(src.slice(i, i + 40))) { // [quote| ... |]
|
|
53
|
+
const e = src.indexOf('|]', i + 2); i = e < 0 ? n : e + 2; continue;
|
|
54
|
+
}
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
return out.join('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function blankNix(src) {
|
|
61
|
+
const n = src.length; const out = src.split('');
|
|
62
|
+
const blank = (a, b) => { for (let i = a; i < b; i++) if (out[i] !== '\n' && out[i] !== '\r') out[i] = ' '; };
|
|
63
|
+
// The string states nest through ${ ... }, so scan with an explicit stack.
|
|
64
|
+
const stack = []; // 'q' (double-quoted), 'i' (indented), '{' (interpolation / attrset brace)
|
|
65
|
+
let i = 0;
|
|
66
|
+
while (i < n) {
|
|
67
|
+
const top = stack[stack.length - 1];
|
|
68
|
+
const c = src[i]; const d = src[i + 1];
|
|
69
|
+
if (top === 'q') {
|
|
70
|
+
if (c === '\\') { i += 2; continue; }
|
|
71
|
+
if (c === '"') { stack.pop(); i++; continue; }
|
|
72
|
+
if (c === '$' && d === '{') { stack.push('{'); i += 2; continue; }
|
|
73
|
+
i++; continue;
|
|
74
|
+
}
|
|
75
|
+
if (top === 'i') {
|
|
76
|
+
if (c === "'" && d === "'") { if (src[i + 2] === "'" || src[i + 2] === '$' || src[i + 2] === '\\') { i += src[i + 2] === '\\' ? 4 : 3; continue; } stack.pop(); i += 2; continue; }
|
|
77
|
+
if (c === '$' && d === '{') { stack.push('{'); i += 2; continue; }
|
|
78
|
+
i++; continue;
|
|
79
|
+
}
|
|
80
|
+
// code context (top level or inside an interpolation)
|
|
81
|
+
if (c === '#') { let e = src.indexOf('\n', i); if (e < 0) e = n; blank(i, e); i = e; continue; }
|
|
82
|
+
if (c === '/' && d === '*') { let e = src.indexOf('*/', i + 2); e = e < 0 ? n : e + 2; blank(i, e); i = e; continue; }
|
|
83
|
+
if (c === '"') { stack.push('q'); i++; continue; }
|
|
84
|
+
if (c === "'" && d === "'") { stack.push('i'); i += 2; continue; }
|
|
85
|
+
if (c === '{') { stack.push('{'); i++; continue; }
|
|
86
|
+
if (c === '}') { if (stack.length) stack.pop(); i++; continue; }
|
|
87
|
+
i++;
|
|
88
|
+
}
|
|
89
|
+
return out.join('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const blankFor = (fp) => (/\.l?hs(?:c|ig)?$|\.hs-boot$/i.test(fp) ? blankHaskell : /\.nix$/i.test(fp) ? blankNix : null);
|
|
93
|
+
|
|
94
|
+
// ── tokenizers (comments skipped, strings kept whole) ───────────────────────
|
|
95
|
+
/**
|
|
96
|
+
* Ordered tokens {k:'s'|'i'|'o'|'x', v, start, end, interp?}. `s` is a string literal (its raw body in `v`),
|
|
97
|
+
* `i` an identifier, `o` a run of operator characters, `x` anything else. Comments never become tokens.
|
|
98
|
+
*/
|
|
99
|
+
export function tokenizeHaskell(src) {
|
|
100
|
+
const n = src.length; const toks = []; let i = 0;
|
|
101
|
+
while (i < n) {
|
|
102
|
+
const c = src[i]; const d = src[i + 1];
|
|
103
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
104
|
+
if (c === '{' && d === '-') { let depth = 1; let j = i + 2; while (j < n && depth > 0) { if (src[j] === '{' && src[j + 1] === '-') { depth++; j += 2; } else if (src[j] === '-' && src[j + 1] === '}') { depth--; j += 2; } else j++; } i = j; continue; }
|
|
105
|
+
if (c === '-' && d === '-') { let j = i; while (src[j] === '-') j++; const prev = src[i - 1]; if (!(prev && HS_SYMBOL.test(prev)) && !(src[j] && HS_SYMBOL.test(src[j]))) { const e = src.indexOf('\n', j); i = e < 0 ? n : e; continue; } }
|
|
106
|
+
if (c === '"') { let j = i + 1; while (j < n && src[j] !== '"' && src[j] !== '\n') { if (src[j] === '\\') j++; j++; } toks.push({ k: 's', v: src.slice(i + 1, j), start: i, end: j + 1 }); i = j + 1; continue; }
|
|
107
|
+
if (/[A-Za-z_]/.test(c)) { let j = i + 1; while (j < n && /[\w']/.test(src[j])) j++; toks.push({ k: 'i', v: src.slice(i, j), start: i, end: j }); i = j; continue; }
|
|
108
|
+
if (c === "'") { const m = /^'(?:[^'\\\n]|\\[^\n]{1,8}?)'/.exec(src.slice(i, i + 12)); if (m) { toks.push({ k: 'x', v: m[0], start: i, end: i + m[0].length }); i += m[0].length; continue; } }
|
|
109
|
+
if (HS_SYMBOL.test(c)) { let j = i + 1; while (j < n && HS_SYMBOL.test(src[j])) j++; toks.push({ k: 'o', v: src.slice(i, j), start: i, end: j }); i = j; continue; }
|
|
110
|
+
toks.push({ k: 'x', v: c, start: i, end: i + 1 }); i++;
|
|
111
|
+
}
|
|
112
|
+
return toks;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function tokenizeNix(src) {
|
|
116
|
+
const n = src.length; const toks = []; let i = 0;
|
|
117
|
+
// read one string starting at i (either " or ''), returning {end, v, interp}
|
|
118
|
+
const readString = (start) => {
|
|
119
|
+
const indented = src[start] === "'";
|
|
120
|
+
let j = start + (indented ? 2 : 1); let interp = false; const stack = [];
|
|
121
|
+
for (; j < n; j++) {
|
|
122
|
+
const c = src[j]; const d = src[j + 1];
|
|
123
|
+
if (stack.length) { if (c === '{') stack.push('{'); else if (c === '}') stack.pop(); else if (c === '"') { j = readString(j).end - 1; } continue; }
|
|
124
|
+
if (indented) {
|
|
125
|
+
if (c === "'" && d === "'") { const e = src[j + 2]; if (e === "'" || e === '$') { j += 2; continue; } if (e === '\\') { j += 3; continue; } return { end: j + 2, v: src.slice(start + 2, j), interp }; }
|
|
126
|
+
} else {
|
|
127
|
+
if (c === '\\') { j++; continue; }
|
|
128
|
+
if (c === '"') return { end: j + 1, v: src.slice(start + 1, j), interp };
|
|
129
|
+
}
|
|
130
|
+
if (c === '$' && d === '{') { interp = true; stack.push('{'); j++; }
|
|
131
|
+
}
|
|
132
|
+
return { end: n, v: src.slice(start + (indented ? 2 : 1)), interp };
|
|
133
|
+
};
|
|
134
|
+
while (i < n) {
|
|
135
|
+
const c = src[i]; const d = src[i + 1];
|
|
136
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
137
|
+
if (c === '#') { const e = src.indexOf('\n', i); i = e < 0 ? n : e; continue; }
|
|
138
|
+
if (c === '/' && d === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? n : e + 2; continue; }
|
|
139
|
+
if (c === '"' || (c === "'" && d === "'")) { const r = readString(i); toks.push({ k: 's', v: r.v, start: i, end: r.end, interp: r.interp }); i = r.end; continue; }
|
|
140
|
+
if (/[A-Za-z_]/.test(c)) { let j = i + 1; while (j < n && /[\w'-]/.test(src[j])) j++; toks.push({ k: 'i', v: src.slice(i, j), start: i, end: j }); i = j; continue; }
|
|
141
|
+
if (/[+=<>!&|-]/.test(c)) { let j = i + 1; while (j < n && /[+=<>!&|-]/.test(src[j])) j++; toks.push({ k: 'o', v: src.slice(i, j), start: i, end: j }); i = j; continue; }
|
|
142
|
+
toks.push({ k: 'x', v: c, start: i, end: i + 1 }); i++;
|
|
143
|
+
}
|
|
144
|
+
return toks;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── split secrets ────────────────────────────────────────────────────────────
|
|
148
|
+
const HS_CONCAT_OPS = new Set(['++', '<>']);
|
|
149
|
+
const NIX_CONCAT_OPS = new Set(['+']);
|
|
150
|
+
const lineOf = (text, idx) => { let l = 1; for (let i = 0; i < idx; i++) if (text.charCodeAt(i) === 10) l++; return l; };
|
|
151
|
+
const mask = (v) => (v.length > 8 ? `${v.slice(0, 4)}…${v.slice(-4)}` : '••••');
|
|
152
|
+
|
|
153
|
+
export function scanLanguageSecretConcat(fp, raw) {
|
|
154
|
+
const isNix = /\.nix$/i.test(fp); const isHs = /\.l?hs(?:c|ig)?$|\.hs-boot$/i.test(fp);
|
|
155
|
+
if (!(isNix || isHs) || !raw || raw.length > 500_000) return [];
|
|
156
|
+
const toks = isNix ? tokenizeNix(raw) : tokenizeHaskell(raw);
|
|
157
|
+
const ops = isNix ? NIX_CONCAT_OPS : HS_CONCAT_OPS;
|
|
158
|
+
const lines = raw.split('\n');
|
|
159
|
+
const out = []; const seen = new Set();
|
|
160
|
+
for (let i = 0; i < toks.length; i++) {
|
|
161
|
+
const t = toks[i];
|
|
162
|
+
if (t.k !== 's' || t.interp || !(toks[i + 1] && toks[i + 1].k === 'o' && ops.has(toks[i + 1].v) && toks[i + 2] && toks[i + 2].k === 's')) continue;
|
|
163
|
+
if (i > 0 && toks[i - 1].k === 'o' && ops.has(toks[i - 1].v)) continue; // not the start of the chain
|
|
164
|
+
const parts = [t]; let j = i;
|
|
165
|
+
while (toks[j + 1] && toks[j + 1].k === 'o' && ops.has(toks[j + 1].v) && toks[j + 2] && toks[j + 2].k === 's' && !toks[j + 2].interp) { parts.push(toks[j + 2]); j += 2; }
|
|
166
|
+
if (parts.length < 2) { i = j; continue; }
|
|
167
|
+
const ident = toks[i - 1] && toks[i - 1].k === 'o' && /^(?:=|<-|:=)$/.test(toks[i - 1].v) && toks[i - 2] && toks[i - 2].k === 'i' ? toks[i - 2].v : '';
|
|
168
|
+
const value = parts.map((p) => p.v).join('');
|
|
169
|
+
i = j;
|
|
170
|
+
if (PLACEHOLDER.test(value)) continue;
|
|
171
|
+
const byPrefix = SECRET_PREFIX.test(value);
|
|
172
|
+
const byName = SECRET_NAME.test(ident) && value.length >= 24 && /[A-Za-z]/.test(value) && /\d/.test(value);
|
|
173
|
+
if (!byPrefix && !byName) continue;
|
|
174
|
+
const line = lineOf(raw, parts[0].start);
|
|
175
|
+
const id = `secret-concat:${fp}:${line}`;
|
|
176
|
+
if (seen.has(id)) continue;
|
|
177
|
+
seen.add(id);
|
|
178
|
+
const m8 = mask(value);
|
|
179
|
+
let snippet = (lines[line - 1] || '').trim().slice(0, 200);
|
|
180
|
+
for (const p of parts) if (p.v) snippet = snippet.split(p.v).join(m8);
|
|
181
|
+
out.push({
|
|
182
|
+
id, file: fp, line, language: isNix ? 'nix' : 'haskell',
|
|
183
|
+
vuln: 'Hardcoded credential — secret split across concatenated literals to evade detection',
|
|
184
|
+
severity: 'high', cwe: 'CWE-798', family: 'hardcoded-secret', parser: 'SECRET-CONCAT', confidence: 0.78,
|
|
185
|
+
snippet, masked: m8, ...providerInfo(value),
|
|
186
|
+
remediation: 'Load the secret from a runtime file or a secrets manager. Splitting the literal across a concatenation does not protect it: rotate the exposed credential.',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── dependency-URL credentials ───────────────────────────────────────────────
|
|
193
|
+
const URL_CRED = /\b((?:https?|git\+https?|git|ssh|ftp|svn\+https?|hg\+https?):\/\/)([^\s/@:"']{1,100})(?::([^\s/@"']{1,200}))?@([A-Za-z0-9.-]+(?::\d+)?[^\s"']*)/g;
|
|
194
|
+
const BENIGN_USER = /^(?:git|hg|svn|anonymous|ftp|guest)$/i;
|
|
195
|
+
const BENIGN_HOST = /(?:^|\.)(?:localhost|example\.(?:com|org|net))(?::|$|\/)|^127\.|^0\.0\.0\.0|^::1/i;
|
|
196
|
+
|
|
197
|
+
const MANIFEST_RE = /(?:^|\/)(?:[^/]+\.cabal|cabal\.project(?:\.[a-z]+)?|stack\.yaml(?:\.lock)?|package\.yaml|flake\.(?:nix|lock)|[^/]+\.nix)$/i;
|
|
198
|
+
export const isSecretManifest = (p) => MANIFEST_RE.test(p);
|
|
199
|
+
|
|
200
|
+
/** Credentials embedded in a dependency or source URL. The value is never kept, only its mask. */
|
|
201
|
+
export function scanDependencyUrlCredentials(fp, raw) {
|
|
202
|
+
if (!isSecretManifest(fp) || !raw || raw.length > 2_000_000) return [];
|
|
203
|
+
const code = raw; // a credential in a comment is still in the repository: comments are NOT blanked here
|
|
204
|
+
const out = []; const seen = new Set(); const lines = raw.split('\n');
|
|
205
|
+
const re = new RegExp(URL_CRED.source, 'g'); let m;
|
|
206
|
+
while ((m = re.exec(code))) {
|
|
207
|
+
const user = m[2]; const pass = m[3]; const host = m[4];
|
|
208
|
+
const secretish = pass !== undefined ? pass : user;
|
|
209
|
+
if (BENIGN_HOST.test(host) || (pass === undefined && BENIGN_USER.test(user)) || PLACEHOLDER.test(secretish) || /^\$\{|^%\w+%$|^\$\w+$/.test(secretish)) continue;
|
|
210
|
+
if (pass === undefined && !(/^[A-Za-z0-9_-]{20,}$/.test(user) || SECRET_PREFIX.test(user))) continue; // a bare user name is not a secret
|
|
211
|
+
const line = lineOf(code, m.index);
|
|
212
|
+
const id = `dep-url-credential:${fp}:${line}`;
|
|
213
|
+
if (seen.has(id)) continue;
|
|
214
|
+
seen.add(id);
|
|
215
|
+
const m8 = mask(secretish);
|
|
216
|
+
const snippet = (lines[line - 1] || '').trim().slice(0, 200).split(secretish).join(m8);
|
|
217
|
+
const hostOnly = host.replace(/[/?#].*$/, '');
|
|
218
|
+
out.push({
|
|
219
|
+
id, file: fp, line, language: /\.nix$|flake\./i.test(fp) ? 'nix' : 'haskell',
|
|
220
|
+
vuln: 'Credential embedded in a dependency or source URL', severity: 'high', cwe: 'CWE-798', family: 'dependency-url-credential', parser: 'SECRET-DEPURL', confidence: 0.85,
|
|
221
|
+
snippet, masked: m8, host: hostOnly, scheme: m[1].replace('://', ''),
|
|
222
|
+
...providerInfo(secretish, hostOnly),
|
|
223
|
+
remediation: 'Remove the credential from the URL. Use a credential helper, netrc/ssh-agent or a CI secret that is not written into a manifest, then rotate the exposed token: it is in every clone and every lock.',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── provider and rotation guidance (shape only; nothing is verified or called) ──
|
|
230
|
+
const PROVIDERS = [
|
|
231
|
+
[/^AKIA[0-9A-Z]{16}$|^ASIA[0-9A-Z]{16}$/, 'AWS', 'https://console.aws.amazon.com/iam/home#/security_credentials', ['Deactivate the access key in IAM, then delete it.', 'Check CloudTrail for use of the key between its exposure and now.', 'Issue a replacement and update the consumers.']],
|
|
232
|
+
[/^(?:ghp|gho|ghu|ghs|ghr)_|^github_pat_/, 'GitHub', 'https://github.com/settings/tokens', ['Revoke the token under Settings > Developer settings.', 'Review the audit log for use of the token.', 'Create a fine-scoped replacement.']],
|
|
233
|
+
[/^glpat-/, 'GitLab', 'https://gitlab.com/-/user_settings/personal_access_tokens', ['Revoke the personal access token.', 'Review the audit events.']],
|
|
234
|
+
[/^xox[abprs]-/, 'Slack', 'https://api.slack.com/apps', ['Revoke or rotate the token in the Slack app settings.']],
|
|
235
|
+
[/^sk-ant-/, 'Anthropic', 'https://console.anthropic.com/settings/keys', ['Delete the API key in the console and create a new one.', 'Review usage for the exposure window.']],
|
|
236
|
+
[/^sk-(?:proj-|live-)?[A-Za-z0-9_-]{16,}/, 'OpenAI', 'https://platform.openai.com/api-keys', ['Revoke the key and create a new one.', 'Check usage for the exposure window.']],
|
|
237
|
+
[/^sk_live_|^rk_live_|^sk_test_/, 'Stripe', 'https://dashboard.stripe.com/apikeys', ['Roll the key in the Stripe dashboard.']],
|
|
238
|
+
[/^AIza[0-9A-Za-z_-]{35}$/, 'Google', 'https://console.cloud.google.com/apis/credentials', ['Delete or regenerate the API key and restrict it.']],
|
|
239
|
+
[/^SG\./, 'SendGrid', 'https://app.sendgrid.com/settings/api_keys', ['Delete the API key and create a new one.']],
|
|
240
|
+
[/^npm_/, 'npm', 'https://www.npmjs.com/settings/~/tokens', ['Revoke the token.']],
|
|
241
|
+
[/^dop_v1_/, 'DigitalOcean', 'https://cloud.digitalocean.com/account/api/tokens', ['Revoke the token.']],
|
|
242
|
+
[/^-----BEGIN/, 'private-key', null, ['Treat the key as compromised: generate a new key pair, replace the public key everywhere it is trusted, and revoke the old one.']],
|
|
243
|
+
];
|
|
244
|
+
const HOST_PROVIDERS = [[/(^|\.)github\.com$/i, 'GitHub'], [/(^|\.)gitlab\.com$/i, 'GitLab'], [/(^|\.)bitbucket\.org$/i, 'Bitbucket'], [/(^|\.)hackage\.haskell\.org$/i, 'Hackage'], [/(^|\.)cachix\.org$/i, 'Cachix']];
|
|
245
|
+
|
|
246
|
+
/** Provider by SHAPE, plus rotation text. `host` names the provider only when the value's shape does not. */
|
|
247
|
+
export function providerInfo(value, host = null) {
|
|
248
|
+
const v = String(value || '');
|
|
249
|
+
for (const [re, provider, url, steps] of PROVIDERS) if (re.test(v)) return { provider, rotation: { provider, revokeUrl: url, steps, automatic: false, liveCheck: 'not performed' } };
|
|
250
|
+
if (host) for (const [re, provider] of HOST_PROVIDERS) if (re.test(host)) return { provider, rotation: { provider, revokeUrl: null, steps: ['Revoke the token or password for this account at the provider, then update the consumers.'], automatic: false, liveCheck: 'not performed' } };
|
|
251
|
+
return {};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Value-free description of where a secret is, for responders. */
|
|
255
|
+
export function redactSecret(v) { return mask(String(v || '')); }
|
|
256
|
+
|
|
257
|
+
export const SECRETS_LANGUAGE_VERSION = 'language-secrets/1';
|
|
258
|
+
|
|
259
|
+
// ── URL credential redaction for inventories and findings ────────────────────
|
|
260
|
+
const URL_USERINFO = /((?:https?|git\+https?|git|ssh|ftp|svn\+https?|hg\+https?):\/\/)[^\s/@"'`]*:[^\s/@"'`]+@/gi;
|
|
261
|
+
const URL_TOKEN_USER = /((?:https?|git\+https?):\/\/)(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}@/gi;
|
|
262
|
+
/** `scheme://user:password@host` -> `scheme://***@host`; any string, never throws. */
|
|
263
|
+
export function stripUrlCredentials(str) {
|
|
264
|
+
if (typeof str !== 'string' || str.indexOf('@') < 0) return str;
|
|
265
|
+
return str.replace(URL_USERINFO, '$1***@').replace(URL_TOKEN_USER, '$1***@');
|
|
266
|
+
}
|
|
267
|
+
/** In-place deep redaction of URL credentials in every string of a plain object graph (cycle safe). */
|
|
268
|
+
export function redactUrlsDeep(value, seen = new WeakSet()) {
|
|
269
|
+
if (typeof value === 'string') return stripUrlCredentials(value);
|
|
270
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return value;
|
|
271
|
+
seen.add(value);
|
|
272
|
+
// Only a CHANGED string is written back, and a frozen container is never assigned to: shared constants (rule tables)
|
|
273
|
+
// are frozen and contain no credential, so they are left exactly as they are.
|
|
274
|
+
const frozen = Object.isFrozen(value);
|
|
275
|
+
if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { const r = redactUrlsDeep(value[i], seen); if (r !== value[i] && !frozen) value[i] = r; } return value; }
|
|
276
|
+
for (const k of Object.keys(value)) { const r = redactUrlsDeep(value[k], seen); if (r !== value[k] && !frozen) value[k] = r; }
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── redaction for text that leaves the machine (model prompts, exports) ─────────
|
|
281
|
+
const SECRET_BINDING = /^(?:[A-Za-z0-9_]*(?:password|passwd|pwd|secret|token|api_?key|apikey|auth|credential|private_?key|passphrase|signing_?key)[A-Za-z0-9_]*)$/i;
|
|
282
|
+
// camelCase and snake_case spellings of a key: stripeKey, signing_key, apiKey; `monkey` and `keyboard` are not
|
|
283
|
+
const isSecretBindingName = (n) => SECRET_BINDING.test(n) || /[a-z0-9]Key$|_key$|^key$|^[A-Z0-9_]*_KEY$/.test(n);
|
|
284
|
+
const CONCAT_OPS_HS = new Set(['++', '<>']);
|
|
285
|
+
const CONCAT_OPS_NIX = new Set(['+']);
|
|
286
|
+
const PLACEHOLDER_TEXT = '[REDACTED-SECRET]';
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Replaces the VALUE of a credential-named Haskell/Nix binding with a placeholder, including a value split across
|
|
290
|
+
* several string literals joined by `++`, `<>` (Haskell) or `+` (Nix), which a single-literal pattern cannot see.
|
|
291
|
+
* Token based: comments and the text of other strings are never touched. Returns {text, redactions}.
|
|
292
|
+
*/
|
|
293
|
+
export function redactLanguageSecrets(filePath, text) {
|
|
294
|
+
if (typeof text !== 'string' || !text) return { text: typeof text === 'string' ? text : '', redactions: 0 };
|
|
295
|
+
const isHs = /\.l?hs$|\.hs-boot$|\.hsc$/i.test(filePath || '');
|
|
296
|
+
const isNix = /\.nix$/i.test(filePath || '');
|
|
297
|
+
if (!isHs && !isNix) return { text, redactions: 0 };
|
|
298
|
+
let toks;
|
|
299
|
+
try { toks = isHs ? tokenizeHaskell(text) : tokenizeNix(text); } catch { return { text, redactions: 0 }; }
|
|
300
|
+
const ops = isHs ? CONCAT_OPS_HS : CONCAT_OPS_NIX;
|
|
301
|
+
const edits = [];
|
|
302
|
+
for (let i = 0; i < toks.length - 2; i++) {
|
|
303
|
+
const t = toks[i];
|
|
304
|
+
if (t.k !== 'i' || !isSecretBindingName(t.v.replace(/^.*\./, ''))) continue;
|
|
305
|
+
const eq = toks[i + 1];
|
|
306
|
+
if (!(eq.k === 'o' && eq.v === '=')) continue;
|
|
307
|
+
let j = i + 2; let total = 0; const pieces = [];
|
|
308
|
+
while (j < toks.length && toks[j].k === 's') {
|
|
309
|
+
pieces.push(toks[j]); total += toks[j].v.length;
|
|
310
|
+
const next = toks[j + 1];
|
|
311
|
+
if (next && next.k === 'o' && ops.has(next.v) && toks[j + 2] && toks[j + 2].k === 's') j += 2; else { j += 1; break; }
|
|
312
|
+
}
|
|
313
|
+
if (!pieces.length || total < 6) continue;
|
|
314
|
+
for (const p of pieces) if (p.v !== PLACEHOLDER_TEXT && !p.interp) edits.push(p);
|
|
315
|
+
}
|
|
316
|
+
if (!edits.length) return { text, redactions: 0 };
|
|
317
|
+
let out = text; let n = 0;
|
|
318
|
+
for (const p of edits.sort((a, b) => b.start - a.start)) {
|
|
319
|
+
const q = out[p.start] === "'" ? "''" : '"';
|
|
320
|
+
out = out.slice(0, p.start) + q + PLACEHOLDER_TEXT + q + out.slice(p.end); n++;
|
|
321
|
+
}
|
|
322
|
+
return { text: out, redactions: n };
|
|
323
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Haskell and Nix analysis metadata as registered state artifacts (X-016).
|
|
2
|
+
//
|
|
3
|
+
// An ordinary scan already leaves last-scan.json. Two things a reviewer, auditor or retention policy needs about the
|
|
4
|
+
// Haskell/Nix side were not artifacts of their own: WHAT analysed the sources (parser/resolver outcomes, capability
|
|
5
|
+
// status, limitations, optional modes) and WHICH components the language BOM recorded. They are written beside
|
|
6
|
+
// last-scan.json as `language-analysis.json` and `language-bom.json`, each with a detached signature, registered in
|
|
7
|
+
// posture/artifact-registry.js (so export, retention, legal hold and `reset` all see them), and described by a record
|
|
8
|
+
// that carries sha256 digests of every input and of the artifact bodies. No source text is ever written: paths and
|
|
9
|
+
// digests only, so the file is metadata, not a copy of customer code.
|
|
10
|
+
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { statePath, safeWriteState } from '../posture/state-dir.js';
|
|
13
|
+
import { signLastScan } from '../posture/integrity.js';
|
|
14
|
+
import { loadLanguageProject } from './context.js';
|
|
15
|
+
|
|
16
|
+
export const LANGUAGE_ANALYSIS_FILE = 'language-analysis.json';
|
|
17
|
+
export const LANGUAGE_BOM_FILE = 'language-bom.json';
|
|
18
|
+
export const LANGUAGE_ARTIFACT_SCHEMA = 'agentic-security/language-analysis@1';
|
|
19
|
+
|
|
20
|
+
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
21
|
+
function canonical(v) {
|
|
22
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
23
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
|
|
24
|
+
return JSON.stringify(v === undefined ? null : v);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when the scan has anything Haskell/Nix to record. */
|
|
28
|
+
export function hasLanguageContent(scan) {
|
|
29
|
+
const lc = scan && scan.scanHealth && scan.scanHealth.languageCoverage;
|
|
30
|
+
const files = lc && lc.totals && Number(lc.totals.files || lc.totals.analyzed || 0);
|
|
31
|
+
return !!((lc && (files > 0 || (lc.capabilities && Object.keys(lc.capabilities).length))) || (scan && scan.languageBom && (scan.languageBom.components || []).length));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The analysis record for `scan`, plus the BOM body. `inputs` is [{path, sha256}] over the Haskell/Nix sources and
|
|
36
|
+
* manifests (digests only). Pure: no I/O.
|
|
37
|
+
*/
|
|
38
|
+
export function buildLanguageArtifacts(scan, inputs = []) {
|
|
39
|
+
const lc = (scan && scan.scanHealth && scan.scanHealth.languageCoverage) || null;
|
|
40
|
+
const bom = (scan && scan.languageBom) || null;
|
|
41
|
+
const bridges = (scan && scan.languageBridges) || null;
|
|
42
|
+
const bomBody = bom ? JSON.stringify(bom, null, 2) : null;
|
|
43
|
+
const record = {
|
|
44
|
+
schema: LANGUAGE_ARTIFACT_SCHEMA,
|
|
45
|
+
classification: { class: 'scan-metadata', containsSourceText: false, containsSecrets: false },
|
|
46
|
+
inputs: inputs.map((i) => ({ path: String(i.path), sha256: String(i.sha256) })).sort((a, b) => (a.path < b.path ? -1 : 1)),
|
|
47
|
+
analysis: lc ? { totals: lc.totals, byKind: lc.byKind, conditions: lc.conditions, limitations: lc.limitations, capabilities: lc.capabilities, optionalModes: lc.optionalModes } : null,
|
|
48
|
+
bom: bom ? { file: LANGUAGE_BOM_FILE, sha256: sha256(bomBody), components: (bom.components || []).length } : null,
|
|
49
|
+
bridges: bridges ? { sha256: sha256(canonical(bridges)) } : null,
|
|
50
|
+
};
|
|
51
|
+
return { record, bomBody };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Writes the artifacts under the project's state directory, each with a `.sig`. Returns {written:[{name, sha256}], skipped}.
|
|
56
|
+
* Never throws and never blocks a scan; a project that disallows state writes gets `skipped: 'state-writes-disabled'`.
|
|
57
|
+
*/
|
|
58
|
+
export function persistLanguageArtifacts(root, scan) {
|
|
59
|
+
if (!hasLanguageContent(scan)) return { written: [], skipped: 'no-language-content' };
|
|
60
|
+
let inputs = [];
|
|
61
|
+
try {
|
|
62
|
+
const proj = loadLanguageProject(root);
|
|
63
|
+
inputs = [...Object.entries(proj.files), ...Object.entries(proj.manifests)].map(([path, text]) => ({ path, sha256: sha256(text) }));
|
|
64
|
+
} catch { /* the record then says it has no input digests */ }
|
|
65
|
+
const { record, bomBody } = buildLanguageArtifacts(scan, inputs);
|
|
66
|
+
const out = [];
|
|
67
|
+
const put = (name, body) => {
|
|
68
|
+
const fp = statePath(root, name);
|
|
69
|
+
if (!safeWriteState(fp, body)) return false;
|
|
70
|
+
try { safeWriteState(`${fp}.sig`, signLastScan(body)); } catch { /* the signature is best effort, as for last-scan.json */ }
|
|
71
|
+
out.push({ name, sha256: sha256(body) });
|
|
72
|
+
return true;
|
|
73
|
+
};
|
|
74
|
+
const body = JSON.stringify(record, null, 2);
|
|
75
|
+
if (!put(LANGUAGE_ANALYSIS_FILE, body)) return { written: [], skipped: 'state-writes-disabled' };
|
|
76
|
+
if (bomBody) put(LANGUAGE_BOM_FILE, bomBody);
|
|
77
|
+
return { written: out, skipped: null };
|
|
78
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Haskell and Nix support registry and promotion gate (HS-011, QA-002).
|
|
2
|
+
//
|
|
3
|
+
// A capability row says what is SUPPORTED, from MEASURED evidence only. The registry never grants support from an installed parser,
|
|
4
|
+
// a structural-only detection, a metric of another kind, or labels that changed after the measurement. `evaluateSupport` is pure:
|
|
5
|
+
// it takes a measurement (bench/language-support/measure.mjs output) plus the results of the named test suites and returns each
|
|
6
|
+
// capability's status with the reasons it is or is not supported. Nothing here runs a scan or reads the corpus.
|
|
7
|
+
//
|
|
8
|
+
// supported every criterion for the row has passing evidence of the row's OWN metric kind, on the frozen corpus
|
|
9
|
+
// partial measured, some criteria met and some not (the unmet ones are listed)
|
|
10
|
+
// failed measured and below target
|
|
11
|
+
// not-measured no evidence of the right kind (a zero or absent denominator is never 100%)
|
|
12
|
+
// blocked a required tool or host is absent (a compiler, nix, a NixOS host): the criterion could not run, which is a
|
|
13
|
+
// failed criterion for support, never a skip
|
|
14
|
+
|
|
15
|
+
export const SUPPORT_REGISTRY_VERSION = 'language-support/1';
|
|
16
|
+
|
|
17
|
+
/** The section 9.2 numerical gates. */
|
|
18
|
+
export const TARGETS = Object.freeze({ precision: 0.9, recall: 0.85, f1: 0.8, perFamilyF1: 0.8, minHoldoutPerLabelPerFamily: 5, minPrivacyPositives: 100, minPrivacyNegatives: 100 });
|
|
19
|
+
|
|
20
|
+
/** The pairs gate is this project's own (the PRD sets none): semantics-changing pairs must ALL flip; preserving pairs may not drift much. */
|
|
21
|
+
export const PAIR_GATE = Object.freeze({ change: 1, preserve: 0.9 });
|
|
22
|
+
|
|
23
|
+
/** Required capabilities per ecosystem, with the metric kind each one may be promoted from. */
|
|
24
|
+
export const REQUIRED_CAPABILITIES = Object.freeze({
|
|
25
|
+
haskell: Object.freeze({
|
|
26
|
+
parser: 'parse-fixtures', sast: 'sast-holdout', taint: 'taint-holdout', 'privacy-lineage': 'privacy-holdout',
|
|
27
|
+
auth: 'test-suite', sca: 'sca-fixtures', bom: 'test-suite', fix: 'fix-fixtures', integration: 'test-suite',
|
|
28
|
+
}),
|
|
29
|
+
nix: Object.freeze({
|
|
30
|
+
parser: 'parse-fixtures', 'config-sast': 'sast-holdout', 'config-taint': 'taint-holdout', 'privacy-lineage': 'privacy-holdout',
|
|
31
|
+
sca: 'sca-fixtures', bom: 'test-suite', fix: 'fix-fixtures', integration: 'test-suite', 'nix-eval': 'test-suite', 'nixos-host': 'host-run',
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Capabilities that need an external tool or host to be exercised at all: absent, the row is BLOCKED whatever its tests say. */
|
|
36
|
+
export const REQUIRES_TOOL = Object.freeze({
|
|
37
|
+
haskell: Object.freeze({ auth: Object.freeze({ tool: 'ghc', why: 'the route fixtures are compiled by GHC (HS-006.AC01)' }) }),
|
|
38
|
+
nix: Object.freeze({
|
|
39
|
+
'nix-eval': Object.freeze({ tool: 'nix', why: 'a successful controlled evaluation needs a real nix binary (NIX-011.AC04)' }),
|
|
40
|
+
'nixos-host': Object.freeze({ tool: 'nixos', why: 'the complete scanner must run on a NixOS host (NIX-012)' }),
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Metric kinds that can NEVER promote a row, whatever their value. */
|
|
45
|
+
export const NON_PROMOTING_EVIDENCE = Object.freeze(['installation', 'grammar-present', 'structural-only', 'documentation', 'model-assertion', 'taint-as-privacy', 'unknown']);
|
|
46
|
+
|
|
47
|
+
const ratio = (n, d) => (d > 0 ? n / d : null);
|
|
48
|
+
const pct = (x) => (x == null ? 'not measured' : `${(x * 100).toFixed(1)}%`);
|
|
49
|
+
|
|
50
|
+
function rate(layer) {
|
|
51
|
+
if (!layer || !layer.measured) return { precision: null, recall: null, f1: null };
|
|
52
|
+
return { precision: layer.precision, recall: layer.recall, f1: layer.f1 };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Section 9.2 gate for one scored layer. Returns {ok, reasons}. `perFamilyCases` supplies the holdout denominators per family. */
|
|
56
|
+
export function gateLayer(layer, perFamilyCases, { kind }) {
|
|
57
|
+
const reasons = [];
|
|
58
|
+
if (!layer || !layer.measured) return { ok: false, reasons: [`${kind}: not measured (a zero or absent denominator is never 100%)`] };
|
|
59
|
+
const r = rate(layer);
|
|
60
|
+
if (!(r.precision >= TARGETS.precision)) reasons.push(`precision ${pct(r.precision)} < ${pct(TARGETS.precision)} (${layer.tp} TP, ${layer.fp} FP)`);
|
|
61
|
+
if (!(r.recall >= TARGETS.recall)) reasons.push(`recall ${pct(r.recall)} < ${pct(TARGETS.recall)} (${layer.tp} TP, ${layer.fn} FN)`);
|
|
62
|
+
if (!(r.f1 >= TARGETS.f1)) reasons.push(`F1 ${pct(r.f1)} < ${pct(TARGETS.f1)}`);
|
|
63
|
+
const fams = layer.families || {};
|
|
64
|
+
for (const [name, m] of Object.entries(fams)) {
|
|
65
|
+
// a family absent from the holdout cannot pass; a family with too few examples of either label cannot pass
|
|
66
|
+
const f1 = m.f1 == null ? null : m.f1;
|
|
67
|
+
if (!(f1 >= TARGETS.perFamilyF1)) reasons.push(`family ${name}: F1 ${pct(f1)} < ${pct(TARGETS.perFamilyF1)}`);
|
|
68
|
+
}
|
|
69
|
+
for (const [name, c] of Object.entries(perFamilyCases || {})) {
|
|
70
|
+
if (!(c.vulnerable >= TARGETS.minHoldoutPerLabelPerFamily && c.safe >= TARGETS.minHoldoutPerLabelPerFamily)) reasons.push(`family ${name}: holdout has ${c.vulnerable} vulnerable / ${c.safe} safe (< ${TARGETS.minHoldoutPerLabelPerFamily} each)`);
|
|
71
|
+
}
|
|
72
|
+
return { ok: reasons.length === 0, reasons };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function suiteStatus(results, names) {
|
|
76
|
+
// suites are keyed by file base name; a capability lists the files (paths) that back it
|
|
77
|
+
const rows = (names || []).map((n) => results && results[String(n).split('/').pop()]);
|
|
78
|
+
if (!(names || []).length || rows.some((r) => !r)) return { ok: false, status: 'not-measured', reasons: ['no test-suite evidence was recorded for this capability'] };
|
|
79
|
+
const reasons = [];
|
|
80
|
+
for (const r of rows) {
|
|
81
|
+
if (r.blocked) reasons.push(`${r.name}: blocked (${r.blocked})`);
|
|
82
|
+
else if (r.fail > 0) reasons.push(`${r.name}: ${r.fail} failing test(s)`);
|
|
83
|
+
if (r.skipped > 0) reasons.push(`${r.name}: ${r.skipped} skipped test(s) (a skip is not a pass)`);
|
|
84
|
+
if (!(r.tests > 0)) reasons.push(`${r.name}: no tests ran`);
|
|
85
|
+
}
|
|
86
|
+
if (reasons.some((x) => /blocked/.test(x))) return { ok: false, status: 'blocked', reasons };
|
|
87
|
+
return { ok: reasons.length === 0, status: reasons.length ? 'failed' : 'supported', reasons };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} language 'haskell' | 'nix'
|
|
92
|
+
* @param {{measurement: object, suites?: Record<string,object>, capabilitySuites?: Record<string,string[]>, frozen?: object}} input
|
|
93
|
+
* measurement: the holdout measurement; suites: test-suite results by name; capabilitySuites: which suites back each capability;
|
|
94
|
+
* frozen: {holdoutRollup, labelsSha256, privacyLabelsSha256} of the corpus AS IT IS NOW (a measurement on other data cannot promote).
|
|
95
|
+
*/
|
|
96
|
+
export function evaluateSupport(language, input) {
|
|
97
|
+
const required = REQUIRED_CAPABILITIES[language];
|
|
98
|
+
if (!required) throw new Error(`unknown language ${language}`);
|
|
99
|
+
const m = input.measurement || {};
|
|
100
|
+
const eco = (m.ecosystems && m.ecosystems[language]) || {};
|
|
101
|
+
const out = { language, version: SUPPORT_REGISTRY_VERSION, rows: {}, summary: {} };
|
|
102
|
+
const dataOk = (() => {
|
|
103
|
+
if (!input.frozen) return { ok: false, reasons: ['no frozen corpus hashes were supplied to check the measurement against'] };
|
|
104
|
+
const h = m.hashes || {};
|
|
105
|
+
const reasons = [];
|
|
106
|
+
if (m.split !== 'holdout') reasons.push(`the measurement is on the "${m.split}" split, not the frozen holdout`);
|
|
107
|
+
if (h.holdoutRollup !== input.frozen.holdoutRollup) reasons.push('the measured holdout hash is not the current frozen holdout');
|
|
108
|
+
if (h.labelsSha256 !== input.frozen.labelsSha256) reasons.push('the measured labels are not the current labels (changed benchmark labels cannot promote a row)');
|
|
109
|
+
if (h.privacyLabelsSha256 !== input.frozen.privacyLabelsSha256) reasons.push('the measured privacy labels are not the current privacy labels');
|
|
110
|
+
return { ok: reasons.length === 0, reasons };
|
|
111
|
+
})();
|
|
112
|
+
|
|
113
|
+
const row = (capability, status, reasons, evidence) => { out.rows[capability] = { capability, status, reasons, evidence: evidence || null }; };
|
|
114
|
+
const detection = eco.detection || {};
|
|
115
|
+
const layerKey = (cap) => (/taint/.test(cap) ? 'taint' : 'sast');
|
|
116
|
+
|
|
117
|
+
for (const [cap, kind] of Object.entries(required)) {
|
|
118
|
+
if (kind === 'parse-fixtures') {
|
|
119
|
+
const p = eco.parser;
|
|
120
|
+
if (!p || !(p.validFixtures > 0)) { row(cap, 'not-measured', ['no parse-fixture measurement']); continue; }
|
|
121
|
+
const reasons = [];
|
|
122
|
+
if (p.validFixturesParsedCleanly !== p.validFixtures) reasons.push(`${p.validFixturesParsedCleanly}/${p.validFixtures} valid fixtures parsed cleanly`);
|
|
123
|
+
if ((p.crashed || []).length) reasons.push(`${p.crashed.length} crash(es)`);
|
|
124
|
+
const u = detection.unknownOutcomes;
|
|
125
|
+
if (u && (u.silentClean > 0 || u.requiredDisclosureMissing > 0)) reasons.push(`unknown cases: ${u.silentClean} silent-clean, ${u.requiredDisclosureMissing} undisclosed required boundary`);
|
|
126
|
+
row(cap, reasons.length ? 'failed' : 'supported', reasons, { metric: 'parse-fixtures', files: p.files, parsed: p.parsed, validFixtures: p.validFixtures, crashed: (p.crashed || []).length });
|
|
127
|
+
} else if (kind === 'sast-holdout' || kind === 'taint-holdout') {
|
|
128
|
+
const key = layerKey(cap);
|
|
129
|
+
const layer = (detection.layers || {})[key];
|
|
130
|
+
const g = gateLayer(layer, filterFamilies(detection.perFamilyCases, layer), { kind: key });
|
|
131
|
+
const reasons = [...g.reasons, ...dataOk.reasons];
|
|
132
|
+
const u = detection.unknownOutcomes;
|
|
133
|
+
if (u && u.silentClean > 0) reasons.push(`${u.silentClean} unknown case(s) came back with neither a finding nor a disclosure (false assurance)`);
|
|
134
|
+
row(cap, !layer || !layer.measured ? 'not-measured' : reasons.length ? 'failed' : 'supported', reasons, layer ? { metric: kind, layer: key, tp: layer.tp, fp: layer.fp, fn: layer.fn, tn: layer.tn, precision: layer.precision, recall: layer.recall, f1: layer.f1, cases: layer.cases, families: layer.families, strictPrecision: layer.strict && layer.strict.precision } : null);
|
|
135
|
+
} else if (kind === 'privacy-holdout') {
|
|
136
|
+
const p = eco.privacy;
|
|
137
|
+
if (!p || !(p.positives > 0 && p.negatives > 0)) { row(cap, 'not-measured', ['no privacy field-to-sink measurement (a security finding or a taint metric is never a privacy metric)']); continue; }
|
|
138
|
+
const reasons = [...dataOk.reasons];
|
|
139
|
+
if (!(p.precision >= TARGETS.precision)) reasons.push(`precision ${pct(p.precision)} < ${pct(TARGETS.precision)}`);
|
|
140
|
+
if (!(p.recall >= TARGETS.recall)) reasons.push(`recall ${pct(p.recall)} < ${pct(TARGETS.recall)}`);
|
|
141
|
+
if (!(p.f1 >= TARGETS.f1)) reasons.push(`F1 ${pct(p.f1)} < ${pct(TARGETS.f1)}`);
|
|
142
|
+
const tot = (m.corpusTotals && m.corpusTotals[language] && m.corpusTotals[language].privacy) || null;
|
|
143
|
+
if (!tot || !(tot.positives >= TARGETS.minPrivacyPositives && tot.negatives >= TARGETS.minPrivacyNegatives)) reasons.push(`the corpus has ${tot ? `${tot.positives} positive / ${tot.negatives} negative` : 'unknown'} field-to-sink expectations (< ${TARGETS.minPrivacyPositives} each)`);
|
|
144
|
+
row(cap, reasons.length ? 'failed' : 'supported', reasons, { metric: 'privacy-holdout', positives: p.positives, negatives: p.negatives, tp: p.tp, fp: p.fp, fn: p.fn, tn: p.tn, precision: p.precision, recall: p.recall, f1: p.f1, byKind: p.byKind });
|
|
145
|
+
} else if (kind === 'sca-fixtures') {
|
|
146
|
+
const s = m.supply || {};
|
|
147
|
+
const reasons = [];
|
|
148
|
+
const man = s.manifests; const rng = s.advisoryRanges;
|
|
149
|
+
if (!man || !man.total) reasons.push('no manifest-resolution measurement');
|
|
150
|
+
else if (man.exact !== man.total) reasons.push(`${man.exact}/${man.total} manifest fixtures exact`);
|
|
151
|
+
if (language === 'haskell') { if (!rng || !rng.total) reasons.push('no advisory-range replay'); else if (rng.correct !== rng.total) reasons.push(`${rng.correct}/${rng.total} advisory range checks correct`); }
|
|
152
|
+
const ts = suiteStatus(input.suites, (input.capabilitySuites || {})[cap]);
|
|
153
|
+
if (!ts.ok) reasons.push(...ts.reasons);
|
|
154
|
+
row(cap, reasons.length ? (ts.status === 'blocked' ? 'blocked' : 'failed') : 'supported', reasons, { metric: 'sca-fixtures', manifests: man ? `${man.exact}/${man.total}` : null, advisoryRanges: rng ? `${rng.correct}/${rng.total}` : null });
|
|
155
|
+
} else if (kind === 'fix-fixtures') {
|
|
156
|
+
const f = eco.fixes;
|
|
157
|
+
if (!f) { row(cap, 'not-measured', ['no fix-fixture measurement']); continue; }
|
|
158
|
+
const reasons = [];
|
|
159
|
+
if (f.accept.correct !== f.accept.total) reasons.push(`${f.accept.correct}/${f.accept.total} valid proposals accepted`);
|
|
160
|
+
if (f.reject.correct !== f.reject.total) reasons.push(`${f.reject.correct}/${f.reject.total} bad proposals rejected`);
|
|
161
|
+
if (f.advertised.verified !== f.advertised.planned) reasons.push(`${f.advertised.verified}/${f.advertised.planned} advertised deterministic fixes verified`);
|
|
162
|
+
const ts = suiteStatus(input.suites, (input.capabilitySuites || {})[cap]);
|
|
163
|
+
if (!ts.ok) reasons.push(...ts.reasons);
|
|
164
|
+
const pairs = eco.pairs;
|
|
165
|
+
row(cap, reasons.length ? (ts.status === 'blocked' ? 'blocked' : 'failed') : 'supported', reasons, { metric: 'fix-fixtures', accepted: `${f.accept.correct}/${f.accept.total}`, rejected: `${f.reject.correct}/${f.reject.total}`, advertised: `${f.advertised.verified}/${f.advertised.planned}`, unsupportedShapes: f.advertised.unsupported, pairs: pairs ? { preserve: `${pairs.preserve.held}/${pairs.preserve.total}`, change: `${pairs.change.held}/${pairs.change.total}` } : null });
|
|
166
|
+
} else if (kind === 'test-suite' || kind === 'host-run') {
|
|
167
|
+
const ts = suiteStatus(input.suites, (input.capabilitySuites || {})[cap]);
|
|
168
|
+
row(cap, ts.status === 'supported' ? 'supported' : ts.status, ts.reasons, { metric: kind, suites: ((input.capabilitySuites || {})[cap] || []) });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// a capability whose tool is absent is blocked, not passed and not skipped
|
|
172
|
+
const tools = input.tools || {};
|
|
173
|
+
for (const [cap, req] of Object.entries(REQUIRES_TOOL[language] || {})) {
|
|
174
|
+
const r = out.rows[cap];
|
|
175
|
+
if (r && !tools[req.tool]) out.rows[cap] = { ...r, status: 'blocked', reasons: [`${req.tool} is not available on the measuring host: ${req.why}`, ...r.reasons] };
|
|
176
|
+
}
|
|
177
|
+
const rows = Object.values(out.rows);
|
|
178
|
+
out.summary = { required: rows.length, supported: rows.filter((r) => r.status === 'supported').length, notSupported: rows.filter((r) => r.status !== 'supported').map((r) => `${r.capability}:${r.status}`), dataOk: dataOk.ok };
|
|
179
|
+
const pairs = eco.pairs;
|
|
180
|
+
if (pairs) {
|
|
181
|
+
const pr = pairs.preserve.total ? pairs.preserve.held / pairs.preserve.total : null; const ch = pairs.change.total ? pairs.change.held / pairs.change.total : null;
|
|
182
|
+
out.metamorphic = { preserve: pr, change: ch, gate: PAIR_GATE, ok: pr != null && ch != null && pr >= PAIR_GATE.preserve && ch >= PAIR_GATE.change, broken: [...pairs.preserve.broken, ...pairs.change.broken] };
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Only the families the layer actually scored are held to the holdout-denominator rule.
|
|
188
|
+
function filterFamilies(perFamilyCases, layer) {
|
|
189
|
+
if (!layer || !layer.families) return perFamilyCases;
|
|
190
|
+
const wanted = new Set(Object.keys(layer.families));
|
|
191
|
+
return Object.fromEntries(Object.entries(perFamilyCases || {}).filter(([k]) => wanted.has(k)));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The promotion check applied to one proposed registry row. A row may be marked `supported` only if `evidence.metric` is the kind
|
|
196
|
+
* the capability requires, the evidence is not of a non-promoting kind, and `evaluateSupport` agrees. Returns {ok, reasons}.
|
|
197
|
+
*/
|
|
198
|
+
export function checkPromotion(language, capability, proposedStatus, evidence, evaluated) {
|
|
199
|
+
const required = REQUIRED_CAPABILITIES[language];
|
|
200
|
+
const reasons = [];
|
|
201
|
+
if (!required || !(capability in required)) return { ok: false, reasons: [`${capability} is not a required capability of ${language}`] };
|
|
202
|
+
if (proposedStatus !== 'supported') return { ok: true, reasons: [] };
|
|
203
|
+
const metric = evidence && evidence.metric;
|
|
204
|
+
if (!metric) reasons.push('no evidence metric was named');
|
|
205
|
+
else if (NON_PROMOTING_EVIDENCE.includes(metric)) reasons.push(`evidence of kind "${metric}" can never promote a row`);
|
|
206
|
+
else if (metric !== required[capability]) reasons.push(`the row needs "${required[capability]}" evidence, not "${metric}" (a metric of another kind is not a measurement of this capability)`);
|
|
207
|
+
const actual = evaluated && evaluated.rows[capability];
|
|
208
|
+
if (!actual) reasons.push('the evaluated measurement has no such row');
|
|
209
|
+
else if (actual.status !== 'supported') reasons.push(`the measurement does not support it: ${actual.status}${actual.reasons.length ? ` (${actual.reasons[0]})` : ''}`);
|
|
210
|
+
return { ok: reasons.length === 0, reasons };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Re-checks a stored registry against the corpus as it is now: a row whose recorded hashes no longer match is demoted to
|
|
215
|
+
* `unverified` (changed labels never keep a promotion).
|
|
216
|
+
*/
|
|
217
|
+
export function verifyRegistry(registry, frozen) {
|
|
218
|
+
const stale = [];
|
|
219
|
+
for (const [lang, entry] of Object.entries((registry && registry.languages) || {})) {
|
|
220
|
+
const f = entry.frozen || {};
|
|
221
|
+
for (const k of ['holdoutRollup', 'labelsSha256', 'privacyLabelsSha256']) if (f[k] !== (frozen || {})[k]) { stale.push({ language: lang, field: k }); }
|
|
222
|
+
}
|
|
223
|
+
return { ok: stale.length === 0, stale };
|
|
224
|
+
}
|