@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bin/agentic-security.js +131 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/1919.index.js +445 -0
  6. package/dist/2862.index.js +325 -0
  7. package/dist/3499.index.js +5 -1
  8. package/dist/3531.index.js +261 -0
  9. package/dist/3596.index.js +103 -0
  10. package/dist/4113.index.js +15 -3
  11. package/dist/4171.index.js +319 -0
  12. package/dist/5637.index.js +10 -4
  13. package/dist/6359.index.js +433 -0
  14. package/dist/7838.index.js +15 -3
  15. package/dist/8551.index.js +1192 -0
  16. package/dist/8587.index.js +410 -0
  17. package/dist/8714.index.js +98 -0
  18. package/dist/8798.index.js +218 -0
  19. package/dist/9220.index.js +35 -3
  20. package/dist/9560.index.js +26 -3
  21. package/dist/agentic-security.mjs +51 -51
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/package.json +20 -3
  24. package/src/dataflow/CLAUDE.md +9 -0
  25. package/src/dataflow/catalog-haskell.js +37 -0
  26. package/src/dataflow/catalog.js +4 -0
  27. package/src/dataflow/engine.js +33 -3
  28. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  29. package/src/dataflow/privacy-catalog.js +5 -1
  30. package/src/discovery/CLAUDE.md +6 -0
  31. package/src/discovery/index.js +12 -3
  32. package/src/discovery/lenses.js +4 -1
  33. package/src/discovery/partition.js +28 -0
  34. package/src/egress/redact.js +8 -0
  35. package/src/engine.js +125 -14
  36. package/src/ir/index.js +39 -2
  37. package/src/language/CLAUDE.md +74 -0
  38. package/src/language/accuracy.js +90 -0
  39. package/src/language/aibom.js +307 -0
  40. package/src/language/assurance.js +193 -0
  41. package/src/language/bom-validate.js +227 -0
  42. package/src/language/bom.js +236 -0
  43. package/src/language/bridges.js +392 -0
  44. package/src/language/compliance-map.js +61 -0
  45. package/src/language/context.js +199 -0
  46. package/src/language/contracts.js +339 -0
  47. package/src/language/discovery.js +359 -0
  48. package/src/language/engine-pass.js +120 -0
  49. package/src/language/fix-lifecycle.js +120 -0
  50. package/src/language/haskell-adapter.js +51 -0
  51. package/src/language/haskell-disclosure.js +170 -0
  52. package/src/language/haskell-findings.js +44 -0
  53. package/src/language/haskell-fix.js +409 -0
  54. package/src/language/haskell-grammar.js +78 -0
  55. package/src/language/haskell-guards.js +382 -0
  56. package/src/language/haskell-ir.js +1377 -0
  57. package/src/language/haskell-llm.js +128 -0
  58. package/src/language/haskell-manifests.js +1290 -0
  59. package/src/language/haskell-models.js +278 -0
  60. package/src/language/haskell-parser.js +1073 -0
  61. package/src/language/haskell-resolved-graph.js +414 -0
  62. package/src/language/haskell-sca.js +387 -0
  63. package/src/language/haskell-security-rules.js +332 -0
  64. package/src/language/haskell-supply.js +140 -0
  65. package/src/language/haskell-sweep.js +85 -0
  66. package/src/language/haskell-syntax.js +895 -0
  67. package/src/language/haskell-web.js +749 -0
  68. package/src/language/nix-adapter.js +42 -0
  69. package/src/language/nix-agents.js +155 -0
  70. package/src/language/nix-build-trust.js +790 -0
  71. package/src/language/nix-closure.js +331 -0
  72. package/src/language/nix-eval-isolation.js +274 -0
  73. package/src/language/nix-fix.js +268 -0
  74. package/src/language/nix-grammar.js +97 -0
  75. package/src/language/nix-inventory.js +379 -0
  76. package/src/language/nix-ir.js +642 -0
  77. package/src/language/nix-parser.js +908 -0
  78. package/src/language/nix-privacy.js +114 -0
  79. package/src/language/nix-sca.js +358 -0
  80. package/src/language/nix-script-taint.js +885 -0
  81. package/src/language/nix-secrets.js +396 -0
  82. package/src/language/nixos-hardening.js +564 -0
  83. package/src/language/nixos-module-resolver.js +595 -0
  84. package/src/language/nixos-option-catalog.js +96 -0
  85. package/src/language/pragma.js +150 -0
  86. package/src/language/resolved-pass.js +174 -0
  87. package/src/language/secrets.js +323 -0
  88. package/src/language/state-artifacts.js +78 -0
  89. package/src/language/support-registry.js +289 -0
  90. package/src/language/trusted-inputs.js +38 -0
  91. package/src/language/witness.js +185 -0
  92. package/src/lineage/CLAUDE.md +8 -0
  93. package/src/lineage/coverage.js +2 -0
  94. package/src/lineage/cross-repo-link.js +22 -0
  95. package/src/lineage/graph-builder.js +5 -1
  96. package/src/lineage/haskell-view.js +319 -0
  97. package/src/lineage/index.js +8 -1
  98. package/src/lineage/nix-view.js +198 -0
  99. package/src/lineage/sink-registry.js +10 -1
  100. package/src/lineage/source-registry.js +3 -0
  101. package/src/lineage/transform-catalog.js +12 -0
  102. package/src/llm-validator/index.js +16 -2
  103. package/src/lsp/server.js +69 -9
  104. package/src/mcp/CLAUDE.md +7 -0
  105. package/src/mcp/tools.js +26 -3
  106. package/src/pipeline/coverage-ledger.js +0 -0
  107. package/src/pipeline/scan-health.js +19 -1
  108. package/src/posture/accuracy-scorecard.js +51 -0
  109. package/src/posture/aibom.js +61 -3
  110. package/src/posture/artifact-registry.js +12 -0
  111. package/src/posture/auditor-walkthrough.js +41 -0
  112. package/src/posture/clustering.js +2 -2
  113. package/src/posture/evidence-bundle.js +7 -1
  114. package/src/posture/execution-proof.js +15 -0
  115. package/src/posture/falsification.js +7 -0
  116. package/src/posture/family-resolve.js +7 -0
  117. package/src/posture/fix-history.js +40 -0
  118. package/src/posture/fix-verify.js +15 -3
  119. package/src/posture/provenance/git-evidence.js +24 -0
  120. package/src/posture/provenance/origin-resolver.js +14 -2
  121. package/src/posture/provenance/predicate-replay.js +11 -2
  122. package/src/posture/provenance/sca-origin.js +20 -3
  123. package/src/posture/sbom-diff.js +7 -6
  124. package/src/posture/sbom.js +85 -32
  125. package/src/posture/sca-policy.js +10 -1
  126. package/src/posture/sca-verdict.js +8 -1
  127. package/src/posture/secret-history.js +62 -18
  128. package/src/posture/stable-id.js +21 -2
  129. package/src/posture/state-dir.js +21 -0
  130. package/src/posture/threat-model-auto.js +4 -4
  131. package/src/posture/triage.js +18 -1
  132. package/src/posture/watch-mode.js +34 -3
  133. package/src/pr-delta.js +9 -4
  134. package/src/report/index.js +35 -9
  135. package/src/runScan.js +56 -5
  136. 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
+ }