@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,78 @@
1
+ // Shipped Haskell lexical/layout grammar data (HS-001).
2
+ //
3
+ // This is the grammar asset the parser in haskell-parser.js loads. It is plain
4
+ // data inside the package, so a default offline install parses Haskell with no
5
+ // GHC, no cabal, no network and no optional dependency.
6
+ //
7
+ // Provenance. The tables below are original work, written from the public
8
+ // Haskell 2010 Report lexical syntax (chapter 2) and layout rule (section 10.3)
9
+ // plus the documented GHC extension keywords. No third-party grammar source or
10
+ // generated parser table is copied in, so there is no upstream licence to carry
11
+ // beyond this project's own. The pinned checksum covers the canonical JSON of
12
+ // GRAMMAR_DATA: any edit, truncation or substitution fails loadHaskellGrammar
13
+ // with a `corrupt-grammar` gap instead of parsing with a different language
14
+ // definition.
15
+
16
+ import * as crypto from 'node:crypto';
17
+
18
+ export const GRAMMAR_DATA = Object.freeze({
19
+ name: 'haskell-lexical-layout',
20
+ version: '1.0.0',
21
+ languageReport: 'Haskell 2010',
22
+ license: 'PolyForm-Internal-Use-1.0.0',
23
+ origin: 'original; derived from the public Haskell 2010 Report and GHC user guide, no third-party grammar code',
24
+ keywords: Object.freeze([
25
+ 'case', 'class', 'data', 'default', 'deriving', 'do', 'else', 'foreign', 'if', 'import', 'in',
26
+ 'infix', 'infixl', 'infixr', 'instance', 'let', 'module', 'newtype', 'of', 'then', 'type',
27
+ 'where', 'mdo',
28
+ ]),
29
+ reservedOps: Object.freeze(['..', ':', '::', '=', '\\', '|', '<-', '->', '@', '~', '=>']),
30
+ layoutKeywords: Object.freeze(['where', 'let', 'do', 'of', 'mdo']),
31
+ symbolChars: '!#$%&*+./<=>?@\\^|-~:',
32
+ declarationKeywords: Object.freeze([
33
+ 'import', 'data', 'newtype', 'type', 'class', 'instance', 'deriving', 'default', 'foreign',
34
+ 'infix', 'infixl', 'infixr', 'module',
35
+ ]),
36
+ cppDirectives: Object.freeze([
37
+ 'if', 'ifdef', 'ifndef', 'else', 'elif', 'endif', 'define', 'undef', 'include', 'error',
38
+ 'warning', 'line', 'pragma',
39
+ ]),
40
+ // Operators that feed a value to the function on their right, so that function
41
+ // is applied even without a visible argument.
42
+ forwardingOps: Object.freeze(['&', '>>=', '>>', '=<<', '<&>', '>=>', '<=<']),
43
+ });
44
+
45
+ export const HASKELL_GRAMMAR_SHA256 = '26a6650e48976accacd0bbd5246e306a84e25be63b4e52a5876938316355e05d';
46
+
47
+ export function grammarChecksum(data) {
48
+ return crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
49
+ }
50
+
51
+ const REQUIRED_LISTS = ['keywords', 'reservedOps', 'layoutKeywords', 'declarationKeywords', 'cppDirectives', 'forwardingOps'];
52
+
53
+ /**
54
+ * Loads and verifies the grammar. Never throws.
55
+ * @param {{grammarSource?: (() => object|null|undefined)}} [opts] test/ops hook that
56
+ * substitutes the data source, so absent and corrupt installs can be exercised.
57
+ * @returns {{available:boolean, grammar?:object, checksum?:string, gap?:{kind:string, detail:string}}}
58
+ */
59
+ export function loadHaskellGrammar(opts = {}) {
60
+ let data;
61
+ try {
62
+ data = typeof opts.grammarSource === 'function' ? opts.grammarSource() : GRAMMAR_DATA;
63
+ } catch (err) {
64
+ return { available: false, gap: { kind: 'missing-grammar', detail: `grammar could not be read: ${(err && err.message) || err}` } };
65
+ }
66
+ if (data === null || data === undefined) {
67
+ return { available: false, gap: { kind: 'missing-grammar', detail: 'Haskell grammar asset is absent' } };
68
+ }
69
+ let checksum;
70
+ try { checksum = grammarChecksum(data); } catch { checksum = null; }
71
+ if (checksum !== HASKELL_GRAMMAR_SHA256) {
72
+ return { available: false, gap: { kind: 'corrupt-grammar', detail: `Haskell grammar checksum mismatch (got ${checksum || 'unhashable'})` } };
73
+ }
74
+ for (const k of REQUIRED_LISTS) {
75
+ if (!Array.isArray(data[k])) return { available: false, gap: { kind: 'corrupt-grammar', detail: `Haskell grammar is missing table "${k}"` } };
76
+ }
77
+ return { available: true, grammar: data, checksum };
78
+ }
@@ -0,0 +1,382 @@
1
+ // Dominating-guard recognition for Haskell taint findings (HS-003.AC03).
2
+ //
3
+ // A finding is only refuted by a control that (a) is a real condition in the code, (b) actually
4
+ // DOMINATES the sink (the sink is reachable only through the branch on which the condition makes the
5
+ // value safe), and (c) is the RIGHT KIND of control for the finding's vulnerability family. All three
6
+ // are judged on the IR, never on adjacent text:
7
+ //
8
+ // - structural branches `if c then A else B` and guards: the IR records which condition selects
9
+ // which branch (`hs.branchConds`), so a sink inside branch i inherits that fact;
10
+ // - exit guards `unless safe (throwIO e)` / `when unsafe (die m)` earlier in a `do` block,
11
+ // counted only when the call node dominates the sink node in the CFG (removing it makes the sink
12
+ // unreachable from entry).
13
+ //
14
+ // Context matters. A `..` check or a containment check is a PATH control and says nothing about SQL or
15
+ // a URL; an allow-list membership or a character-class check holds for any family. A containment
16
+ // check (`isPrefixOf base v`) counts only when `v` was canonicalised, because `base ++ "/../x"` also
17
+ // has the prefix. A weak guard is reported as a note on the finding and does not refute it.
18
+
19
+ const CANON_CALLS = new Set(['System.Directory.canonicalizePath', 'System.Directory.makeAbsolute', 'System.FilePath.normalise']);
20
+ const EXIT_CALLS = new Set(['Control.Exception.throwIO', 'Control.Exception.throw', 'System.Exit.die', 'System.Exit.exitFailure', 'System.Exit.exitWith', 'throwIO', 'throw', 'die', 'exitFailure', 'exitWith', 'error', 'fail', 'ioError', 'Control.Monad.Fail.fail']);
21
+ const WHEN = new Set(['Control.Monad.when', 'when']);
22
+ const UNLESS = new Set(['Control.Monad.unless', 'unless']);
23
+ const IS_ELEM = new Set(['Data.List.elem', 'Prelude.elem', 'elem', 'Data.Foldable.elem']);
24
+ const NOT_ELEM = new Set(['Data.List.notElem', 'Prelude.notElem', 'notElem', 'Data.Foldable.notElem']);
25
+ const IS_MEMBER = /^Data\.(?:Set|HashSet|Map|Map\.Strict|HashMap\.Strict)\.member$/;
26
+ const NOT_MEMBER = /^Data\.(?:Set|HashSet|Map|Map\.Strict|HashMap\.Strict)\.notMember$/;
27
+ const CHARCLASS = new Set(['Data.Char.isAlphaNum', 'Data.Char.isAlpha', 'Data.Char.isDigit', 'Data.Char.isAscii', 'isAlphaNum', 'isAlpha', 'isDigit']);
28
+
29
+ const FAMILY_BY_CWE = { 'CWE-22': 'path', 'CWE-918': 'url', 'CWE-78': 'cmd', 'CWE-88': 'cmd', 'CWE-89': 'sql', 'CWE-79': 'xss', 'CWE-1427': 'prompt' };
30
+
31
+ const isStr = (e) => e && e.kind === 'literal' && typeof e.value === 'string';
32
+
33
+ function identNames(e, out = new Set(), depth = 0) {
34
+ if (!e || typeof e !== 'object' || depth > 30) return out;
35
+ if (e.kind === 'ident' && e.name && !(e.hs && e.hs.functionRef)) out.add(e.name);
36
+ for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') identNames(e[k], out, depth + 1);
37
+ for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) for (const x of e[k]) identNames(x, out, depth + 1);
38
+ if (Array.isArray(e.props)) for (const p of e.props) identNames(p && p.value, out, depth + 1);
39
+ return out;
40
+ }
41
+
42
+ const mentions = (e, vars) => { for (const n of identNames(e)) if (vars.has(n)) return true; return false; };
43
+
44
+ /** Classify a condition over the value `vars`. -> {safeWhen, kind, families, needsCanon?, subject} | null */
45
+ export function classifyCondition(c, vars) {
46
+ if (!c || typeof c !== 'object') return null;
47
+ if (c.kind === 'binary') {
48
+ if (c.op === '&&') { const a = classifyCondition(c.left, vars); const b = classifyCondition(c.right, vars); return [a, b].find((x) => x && x.safeWhen === true) || null; }
49
+ if (c.op === '||') { const a = classifyCondition(c.left, vars); const b = classifyCondition(c.right, vars); return [a, b].find((x) => x && x.safeWhen === false) || null; }
50
+ if (c.op === '==' || c.op === '===') {
51
+ const pair = (l, r) => l && l.kind === 'call' && /(?:^|\.)takeFileName$/.test(l.callee || '') && l.args && l.args[0] && r && r.kind === 'ident' && l.args[0].kind === 'ident' && l.args[0].name === r.name && vars.has(r.name);
52
+ if (pair(c.left, c.right) || pair(c.right, c.left)) return { safeWhen: true, kind: 'bare-filename', families: ['path'], subject: (c.left.kind === 'ident' ? c.left : c.right).name };
53
+ }
54
+ return null;
55
+ }
56
+ if (c.kind !== 'call') return null;
57
+ const name = c.callee || '';
58
+ const a = c.args || [];
59
+ if (name === 'not' || name === 'Prelude.not') { const inner = classifyCondition(a[0], vars); return inner ? { ...inner, safeWhen: !inner.safeWhen } : null; }
60
+ if (name === 'Data.List.isInfixOf' && isStr(a[0]) && a[0].value.includes('..') && a[1] && a[1].kind === 'ident' && vars.has(a[1].name)) {
61
+ return { safeWhen: false, kind: 'traversal-check', families: ['path'], subject: a[1].name };
62
+ }
63
+ if (name === 'Data.List.isPrefixOf' && a[1] && a[1].kind === 'ident' && vars.has(a[1].name)) {
64
+ // A URL that must start with a scheme-and-host literal ending in "/" cannot name another host: an allow-list for the
65
+ // URL family. A path prefix is only a weak check unless canonicalised (a "/../" survives it).
66
+ if (isStr(a[0]) && /^[a-z][a-z0-9+.-]*:\/\/[^/@?#\s]+\/(?:[^\s]*)$/i.test(a[0].value)) return { safeWhen: true, kind: 'host-anchored-prefix', families: ['url'], subject: a[1].name };
67
+ return { safeWhen: true, kind: 'containment', families: ['path'], needsCanon: true, subject: a[1].name };
68
+ }
69
+ // `".." `elem` splitDirectories v`: the path has a parent-directory component (and `notElem` is the opposite)
70
+ if ((IS_ELEM.has(name) || NOT_ELEM.has(name)) && isStr(a[0]) && a[0].value === '..' && a[1] && a[1].kind === 'call' && /(?:^|\.)(?:splitDirectories|splitPath)$/.test(a[1].callee || '') && a[1].args && a[1].args[0] && a[1].args[0].kind === 'ident' && vars.has(a[1].args[0].name)) {
71
+ return { safeWhen: NOT_ELEM.has(name), kind: 'traversal-check', families: ['path'], subject: a[1].args[0].name };
72
+ }
73
+ if ((IS_ELEM.has(name) || NOT_ELEM.has(name)) && a[0] && a[0].kind === 'ident' && vars.has(a[0].name) && !mentions(a[1], vars)) {
74
+ return { safeWhen: IS_ELEM.has(name), kind: 'allow-list', families: ['*'], subject: a[0].name };
75
+ }
76
+ if ((IS_MEMBER.test(name) || NOT_MEMBER.test(name)) && a[0] && a[0].kind === 'ident' && vars.has(a[0].name) && !mentions(a[1], vars)) {
77
+ return { safeWhen: IS_MEMBER.test(name), kind: 'allow-list', families: ['*'], subject: a[0].name };
78
+ }
79
+ if ((name === 'all' || name === 'Prelude.all' || name === 'Data.List.all') && a[0] && a[0].kind === 'ident' && CHARCLASS.has(a[0].name) && a[1] && a[1].kind === 'ident' && vars.has(a[1].name)) {
80
+ return { safeWhen: true, kind: 'character-class', families: ['*'], subject: a[1].name };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function nodeList(fn) { return Object.entries(fn.cfg.nodes).map(([id, n]) => ({ id, ...n })); }
86
+
87
+ function reachableWithout(fn, skipId, targetId) {
88
+ const seen = new Set();
89
+ const stack = [fn.cfg.entry];
90
+ while (stack.length) {
91
+ const id = stack.pop();
92
+ if (id === skipId || seen.has(id)) continue;
93
+ if (id === targetId) return true;
94
+ seen.add(id);
95
+ for (const s of fn.cfg.nodes[id].succ || []) stack.push(s);
96
+ }
97
+ return false;
98
+ }
99
+
100
+ const exprChildren = (e) => {
101
+ const out = [];
102
+ for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') out.push([k, e[k]]);
103
+ for (const k of ['args', 'elements', 'parts']) if (Array.isArray(e[k])) e[k].forEach((x, i) => out.push([`${k}${i}`, x]));
104
+ if (Array.isArray(e.props)) e.props.forEach((p, i) => p && p.value && out.push([`props${i}`, p.value]));
105
+ return out;
106
+ };
107
+
108
+ // Find the sink call at `line` with callee `callee`; returns [{ nodeId, facts, call }].
109
+ function findSites(fn, callee, line) {
110
+ const sites = [];
111
+ const visit = (e, nodeId, facts, depth) => {
112
+ if (!e || typeof e !== 'object' || depth > 60) return;
113
+ if (e.kind === 'call' && e.callee === callee && (e.line === line || nodeLine(nodeId) === line)) sites.push({ nodeId, facts, call: e });
114
+ if (e.kind === 'union') {
115
+ const bc = e.hs && e.hs.branchConds;
116
+ e.branches.forEach((b, i) => visit(b, nodeId, bc && bc[i] ? [...facts, bc[i]] : facts, depth + 1));
117
+ return;
118
+ }
119
+ for (const [, c] of exprChildren(e)) visit(c, nodeId, facts, depth + 1);
120
+ };
121
+ const nodeLine = (id) => (fn.cfg.nodes[id] ? fn.cfg.nodes[id].line : -1);
122
+ for (const n of nodeList(fn)) {
123
+ if (n.kind === 'call' && n.callee === callee && n.line === line) sites.push({ nodeId: n.id, facts: [], call: { kind: 'call', callee: n.callee, args: n.args || [] } });
124
+ for (const k of ['source', 'value', 'cond']) if (n[k]) visit(n[k], n.id, [], 0);
125
+ if (Array.isArray(n.args) && n.kind === 'call') for (const a of n.args) visit(a, n.id, [], 0);
126
+ }
127
+ return sites;
128
+ }
129
+
130
+ function reachableWithoutEdge(fn, from, to, target) {
131
+ const seen = new Set();
132
+ const stack = [fn.cfg.entry];
133
+ while (stack.length) {
134
+ const id = stack.pop();
135
+ if (seen.has(id)) continue;
136
+ if (id === target) return true;
137
+ seen.add(id);
138
+ for (const s of fn.cfg.nodes[id].succ || []) { if (id === from && s === to) continue; stack.push(s); }
139
+ }
140
+ return false;
141
+ }
142
+
143
+ // Facts from the CFG's own `if` structure: a node reachable only through ONE outgoing edge of an `if`
144
+ // node runs only when that branch's condition holds (then) or fails (else).
145
+ function cfgBranchFacts(fn, siteNodeId) {
146
+ const facts = [];
147
+ for (const n of nodeList(fn)) {
148
+ if (n.kind !== 'if' || !n.cond || (n.thenEntry === undefined && (n.succ || []).length !== 2) || n.thenEntry === n.elseEntry) continue;
149
+ // `thenEntry`/`elseEntry` are recorded by the IR: the successor order alone is not then/else.
150
+ const thenEntry = n.thenEntry ?? n.succ[0];
151
+ const elseEntry = n.elseEntry ?? n.succ[1];
152
+ const viaThen = reachableWithoutEdge(fn, n.id, elseEntry, siteNodeId); // reachable with the else edge removed
153
+ const viaElse = reachableWithoutEdge(fn, n.id, thenEntry, siteNodeId);
154
+ if (viaThen && !viaElse) facts.push({ cond: n.cond, when: true, line: n.line });
155
+ else if (viaElse && !viaThen) facts.push({ cond: n.cond, when: false, line: n.line });
156
+ }
157
+ return facts;
158
+ }
159
+
160
+ // `unless C (exit)` / `when C (exit)` call nodes that dominate `siteNodeId`.
161
+ function exitGuardFacts(fn, siteNodeId) {
162
+ const facts = [];
163
+ for (const n of nodeList(fn)) {
164
+ if (n.kind !== 'call' || n.id === siteNodeId) continue;
165
+ const isWhen = WHEN.has(n.callee), isUnless = UNLESS.has(n.callee);
166
+ if (!isWhen && !isUnless) continue;
167
+ const [cond, action] = n.args || [];
168
+ if (!cond || !action || !containsExit(action)) continue;
169
+ if (reachableWithout(fn, n.id, siteNodeId)) continue; // does not dominate
170
+ // continuing past `unless C exit` means C held; past `when C exit` means C did not
171
+ facts.push({ cond, when: isUnless, line: n.line, via: isUnless ? 'unless' : 'when' });
172
+ }
173
+ return facts;
174
+ }
175
+
176
+ function containsExit(e, depth = 0) {
177
+ if (!e || typeof e !== 'object' || depth > 20) return false;
178
+ if (e.kind === 'call' && EXIT_CALLS.has(e.callee)) return true;
179
+ for (const [, c] of exprChildren(e)) if (containsExit(c, depth + 1)) return true;
180
+ return false;
181
+ }
182
+
183
+ // The variables a value was built from: for `req <- parse target`, the origins of `req` include `target` (transitively).
184
+ function originsOf(fn, seed) {
185
+ const out = new Set(seed);
186
+ const assigns = nodeList(fn).filter((n) => n.kind === 'assign' && n.target && n.source);
187
+ for (let round = 0; round < 8; round++) {
188
+ let grew = false;
189
+ for (const n of assigns) if (out.has(n.target)) for (const v of identNames(n.source)) if (!out.has(v)) { out.add(v); grew = true; }
190
+ if (!grew) break;
191
+ }
192
+ for (const v of seed) out.delete(v);
193
+ return out;
194
+ }
195
+
196
+ // Variables assigned from an expression that mentions one of `seed` (transitively, bounded): the values derived from it.
197
+ function derivedFrom(fn, seed) {
198
+ const out = new Set(seed);
199
+ const assigns = nodeList(fn).filter((n) => n.kind === 'assign' && n.target && n.source);
200
+ for (let round = 0; round < 8; round++) {
201
+ let grew = false;
202
+ for (const n of assigns) if (!out.has(n.target) && mentions(n.source, out)) { out.add(n.target); grew = true; }
203
+ if (!grew) break;
204
+ }
205
+ for (const v of seed) out.delete(v);
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * One flow reaching several sinks of one family in one function (`req <- parseRequest u; httpLbs req m`) is ONE finding:
211
+ * the later sink's argument is derived from the earlier sink's argument. The earliest sink is kept, the others are listed
212
+ * on it as `relatedSinks`, and the highest severity wins. Independent flows (a different tainted value) stay separate.
213
+ * Mutates `findings` in place; returns the number merged.
214
+ */
215
+ export function consolidateFlowSinks(findings, perFile) {
216
+ const live = (findings || []).filter((f) => f && f.parser === 'IR-TAINT' && /\.(?:l?hs|hsc)$/i.test(String(f.file || '')) && f._funcQid && f.callee && !f._provenUnreachable);
217
+ const groups = new Map();
218
+ for (const f of live) { const k = `${f.file}|${f._funcQid}|${f.cwe}`; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(f); }
219
+ const drop = new Set();
220
+ const SEV = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
221
+ for (const list of groups.values()) {
222
+ if (list.length < 2) continue;
223
+ list.sort((a, b) => a.line - b.line);
224
+ const ir = perFile && perFile[list[0].file];
225
+ const fn = ir && ir.functions.find((x) => x.qid === list[0]._funcQid);
226
+ if (!fn) continue;
227
+ const argVars = (f) => { const site = findSites(fn, f.callee, f.line)[0]; const e = site && (site.call.args || [])[Number.isInteger(f.argIndex) ? f.argIndex : 0]; return e ? identNames(e) : new Set(); };
228
+ for (let i = 0; i < list.length; i++) {
229
+ const head = list[i];
230
+ if (drop.has(head)) continue;
231
+ const headVars = argVars(head);
232
+ if (!headVars.size) continue;
233
+ const derived = derivedFrom(fn, headVars);
234
+ for (let j = i + 1; j < list.length; j++) {
235
+ const later = list[j];
236
+ if (drop.has(later)) continue;
237
+ // the later sink must be handed a bare variable BUILT from the earlier sink's argument, not the same value again:
238
+ // `readIt (takeFileName l)` and `readIt l` are two separate flows with their own sanitizer verdicts.
239
+ const lv = argVars(later);
240
+ const site = findSites(fn, later.callee, later.line)[0];
241
+ const lateArg = site && (site.call.args || [])[Number.isInteger(later.argIndex) ? later.argIndex : 0];
242
+ if (!lateArg || lateArg.kind !== 'ident' || lv.size !== 1 || !derived.has([...lv][0])) continue;
243
+ drop.add(later);
244
+ head.relatedSinks = [...(head.relatedSinks || []), { line: later.line, callee: later.callee }];
245
+ if ((SEV[later.severity] || 0) > (SEV[head.severity] || 0)) head.severity = later.severity;
246
+ }
247
+ }
248
+ }
249
+ if (!drop.size) return 0;
250
+ for (let i = findings.length - 1; i >= 0; i--) if (drop.has(findings[i])) findings.splice(i, 1);
251
+ return drop.size;
252
+ }
253
+
254
+ function isCanonical(fn, name) {
255
+ for (const n of nodeList(fn)) {
256
+ if (n.kind === 'assign' && n.target === name && n.source && n.source.kind === 'call' && CANON_CALLS.has(n.source.callee)) return true;
257
+ }
258
+ return false;
259
+ }
260
+
261
+ /**
262
+ * Judge one finding. Mutates it when a dominating, family-appropriate guard refutes the flow.
263
+ * @returns {{status:'refuted'|'weak-guard'|'none', guard?:object}}
264
+ */
265
+ export function judgeFinding(f, perFile) {
266
+ const fam = FAMILY_BY_CWE[f.cwe];
267
+ const ir = perFile && perFile[f.file];
268
+ if (!fam || !ir || !f._funcQid || !f.callee) return { status: 'none' };
269
+ const fn = ir.functions.find((x) => x.qid === f._funcQid);
270
+ if (!fn) return { status: 'none' };
271
+ const sites = findSites(fn, f.callee, f.line);
272
+ if (!sites.length) return { status: 'none' };
273
+ let weak = null;
274
+ let first = null;
275
+ for (const site of sites) {
276
+ const argIdx = Number.isInteger(f.argIndex) ? f.argIndex : 0;
277
+ const argExpr = (site.call.args || [])[argIdx];
278
+ const vars = identNames(argExpr);
279
+ if (!vars.size) return { status: 'none' };
280
+ // The sink argument may be a value BUILT from the checked one (`req <- parseUrlThrow target; httpLbs req m`): the
281
+ // guard names the original, so the origins of the argument count as its subject too.
282
+ for (const v of originsOf(fn, vars)) vars.add(v);
283
+ const facts = [...site.facts, ...cfgBranchFacts(fn, site.nodeId), ...exitGuardFacts(fn, site.nodeId)];
284
+ let refuted = null;
285
+ for (const fact of facts) {
286
+ const cls = classifyCondition(fact.cond, vars);
287
+ if (!cls || cls.safeWhen !== fact.when) continue;
288
+ if (!(cls.families.includes('*') || cls.families.includes(fam))) continue; // wrong-context control
289
+ if (cls.needsCanon && !isCanonical(fn, cls.subject)) { weak = { kind: cls.kind, line: fact.line || fn.line, reason: 'containment check on a path that was not canonicalised' }; continue; }
290
+ refuted = { kind: cls.kind, line: fact.line || (fact.cond && fact.cond.line) || fn.line, subject: cls.subject };
291
+ break;
292
+ }
293
+ if (!refuted) return weak ? { status: 'weak-guard', guard: weak } : { status: 'none' };
294
+ if (!first) first = refuted;
295
+ }
296
+ return { status: 'refuted', guard: first };
297
+ }
298
+
299
+ /**
300
+ * CWE-88 (argument injection) into a process argv: a literal "--" element BEFORE every tainted element ends
301
+ * option parsing for programs that follow that convention. It is a convention, not a guarantee, so the finding
302
+ * is lowered to `low` with the assumption stated; it is never refuted or dropped.
303
+ */
304
+ export function optionTerminator(f, perFile) {
305
+ if (f.cwe !== 'CWE-88') return null;
306
+ const ir = perFile && perFile[f.file];
307
+ const fn = ir && f._funcQid && ir.functions.find((x) => x.qid === f._funcQid);
308
+ if (!fn || !f.callee) return null;
309
+ const sites = findSites(fn, f.callee, f.line);
310
+ if (!sites.length) return null;
311
+ for (const site of sites) {
312
+ const list = (site.call.args || [])[Number.isInteger(f.argIndex) ? f.argIndex : 1];
313
+ if (!list || list.kind !== 'array') return null;
314
+ const idx = list.elements.findIndex((e) => e && e.kind === 'literal' && e.value === '--');
315
+ if (idx < 0) return null;
316
+ const tainted = list.elements.some((e, i) => i < idx && e && e.kind !== 'literal');
317
+ if (tainted) return null;
318
+ }
319
+ return { kind: 'option-terminator' };
320
+ }
321
+
322
+ const SHELLS = /^(?:\/(?:usr\/)?(?:local\/)?bin\/)?(?:sh|bash|zsh|dash|ksh|ash|fish|csh|tcsh|cmd(?:\.exe)?|powershell(?:\.exe)?|pwsh)$/i;
323
+ const SHELL_C = /^(?:-[a-z]*c[a-z]*|\/[ck]|-command|-encodedcommand)$/i;
324
+ const PROCESS_FNS = new Set(['System.Process.callProcess', 'System.Process.spawnProcess', 'System.Process.readProcess', 'System.Process.readProcessWithExitCode', 'System.Process.proc']);
325
+
326
+ /**
327
+ * `callProcess "sh" ["-c", script]` runs `script` through a shell: tainted text in the script is command injection (CWE-78),
328
+ * not an argument-injection into a fixed program. Only a literal shell program followed by a command flag qualifies, and the
329
+ * tainted element must come AFTER the flag (a tainted argument before `-c` is just an argument).
330
+ * Mutates the finding; returns true when it was promoted.
331
+ */
332
+ export function promoteShellInterpreter(f, perFile) {
333
+ if (!f || f.cwe !== 'CWE-88' || !PROCESS_FNS.has(f.callee)) return false;
334
+ const ir = perFile && perFile[f.file];
335
+ const fn = ir && f._funcQid && ir.functions.find((x) => x.qid === f._funcQid);
336
+ if (!fn) return false;
337
+ for (const site of findSites(fn, f.callee, f.line)) {
338
+ const [prog, list] = site.call.args || [];
339
+ if (!isStr(prog) || !SHELLS.test(prog.value) || !list || list.kind !== 'array') continue;
340
+ const flag = list.elements.findIndex((e) => isStr(e) && SHELL_C.test(e.value));
341
+ if (flag < 0) continue;
342
+ if (list.elements.slice(flag + 1).some((e) => e && e.kind !== 'literal')) {
343
+ f.cwe = 'CWE-78'; f.severity = 'critical'; f.vuln = 'OS Command Injection (shell interpreter -c)';
344
+ f.id = String(f.id).replace('cwe-88', 'cwe-78'); f.shellInterpreter = prog.value;
345
+ return true;
346
+ }
347
+ }
348
+ return false;
349
+ }
350
+
351
+ /** Applies judgeFinding to every Haskell taint finding. Returns counts. */
352
+ export function applyHaskellGuards(findings, perFile) {
353
+ const stats = { refuted: 0, weak: 0 };
354
+ for (const f of findings || []) {
355
+ if (!f || f.parser !== 'IR-TAINT') continue;
356
+ try { promoteShellInterpreter(f, perFile); } catch { /* judged as an argument-injection below */ }
357
+ try {
358
+ const ot = optionTerminator(f, perFile);
359
+ if (ot && !f._optionTerminated) {
360
+ f._optionTerminated = true; f.severity = 'info';
361
+ f.controls = [...(f.controls || []), { kind: 'option-terminator', evidenceKind: 'source' }];
362
+ f.guardNote = { kind: 'option-terminator', reason: 'a literal "--" precedes the tainted argument; this assumes the program treats "--" as the end of options (a convention, not a guarantee)' };
363
+ stats.weak++;
364
+ continue;
365
+ }
366
+ } catch { /* judged below */ }
367
+ let r;
368
+ try { r = judgeFinding(f, perFile); } catch { continue; }
369
+ if (r.status === 'refuted') {
370
+ // Same channel the proof gate already reads: a flow a dominating control makes infeasible is
371
+ // demoted (never dropped), so the auditor still sees it and severity gates are unaffected.
372
+ f._provenUnreachable = true;
373
+ f._provenUnreachableReason = `dominating ${r.guard.kind} guard`;
374
+ f.controls = [...(f.controls || []), { kind: 'dominating-guard', evidenceKind: 'source' }];
375
+ stats.refuted++;
376
+ } else if (r.status === 'weak-guard') {
377
+ f.guardNote = r.guard;
378
+ stats.weak++;
379
+ }
380
+ }
381
+ return stats;
382
+ }