@clear-capabilities/agentic-security-scanner 0.153.1 → 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.
Files changed (136) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/bin/agentic-security.js +123 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/2862.index.js +325 -0
  6. package/dist/3499.index.js +5 -1
  7. package/dist/3531.index.js +261 -0
  8. package/dist/3596.index.js +103 -0
  9. package/dist/4113.index.js +15 -3
  10. package/dist/4171.index.js +319 -0
  11. package/dist/5637.index.js +10 -4
  12. package/dist/6359.index.js +433 -0
  13. package/dist/7838.index.js +15 -3
  14. package/dist/8551.index.js +1164 -0
  15. package/dist/8587.index.js +410 -0
  16. package/dist/8714.index.js +98 -0
  17. package/dist/8798.index.js +218 -0
  18. package/dist/9220.index.js +35 -3
  19. package/dist/9560.index.js +26 -3
  20. package/dist/agentic-security.mjs +51 -51
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +18 -3
  23. package/src/dataflow/CLAUDE.md +9 -0
  24. package/src/dataflow/catalog-haskell.js +37 -0
  25. package/src/dataflow/catalog.js +4 -0
  26. package/src/dataflow/engine.js +33 -3
  27. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  28. package/src/dataflow/privacy-catalog.js +5 -1
  29. package/src/discovery/CLAUDE.md +6 -0
  30. package/src/discovery/index.js +12 -3
  31. package/src/discovery/lenses.js +4 -1
  32. package/src/discovery/partition.js +28 -0
  33. package/src/egress/redact.js +8 -0
  34. package/src/engine.js +140 -19
  35. package/src/ir/index.js +39 -2
  36. package/src/language/CLAUDE.md +74 -0
  37. package/src/language/accuracy.js +90 -0
  38. package/src/language/aibom.js +307 -0
  39. package/src/language/assurance.js +193 -0
  40. package/src/language/bom-validate.js +227 -0
  41. package/src/language/bom.js +236 -0
  42. package/src/language/bridges.js +392 -0
  43. package/src/language/compliance-map.js +61 -0
  44. package/src/language/context.js +199 -0
  45. package/src/language/contracts.js +339 -0
  46. package/src/language/discovery.js +359 -0
  47. package/src/language/engine-pass.js +120 -0
  48. package/src/language/fix-lifecycle.js +120 -0
  49. package/src/language/haskell-adapter.js +51 -0
  50. package/src/language/haskell-disclosure.js +170 -0
  51. package/src/language/haskell-findings.js +44 -0
  52. package/src/language/haskell-fix.js +409 -0
  53. package/src/language/haskell-grammar.js +78 -0
  54. package/src/language/haskell-guards.js +382 -0
  55. package/src/language/haskell-ir.js +1377 -0
  56. package/src/language/haskell-llm.js +128 -0
  57. package/src/language/haskell-manifests.js +1290 -0
  58. package/src/language/haskell-models.js +278 -0
  59. package/src/language/haskell-parser.js +1073 -0
  60. package/src/language/haskell-resolved-graph.js +414 -0
  61. package/src/language/haskell-sca.js +387 -0
  62. package/src/language/haskell-security-rules.js +332 -0
  63. package/src/language/haskell-supply.js +140 -0
  64. package/src/language/haskell-sweep.js +85 -0
  65. package/src/language/haskell-syntax.js +895 -0
  66. package/src/language/haskell-web.js +749 -0
  67. package/src/language/nix-adapter.js +42 -0
  68. package/src/language/nix-agents.js +155 -0
  69. package/src/language/nix-build-trust.js +790 -0
  70. package/src/language/nix-closure.js +331 -0
  71. package/src/language/nix-eval-isolation.js +274 -0
  72. package/src/language/nix-fix.js +268 -0
  73. package/src/language/nix-grammar.js +97 -0
  74. package/src/language/nix-inventory.js +379 -0
  75. package/src/language/nix-ir.js +642 -0
  76. package/src/language/nix-parser.js +908 -0
  77. package/src/language/nix-privacy.js +114 -0
  78. package/src/language/nix-sca.js +358 -0
  79. package/src/language/nix-script-taint.js +885 -0
  80. package/src/language/nix-secrets.js +396 -0
  81. package/src/language/nixos-hardening.js +564 -0
  82. package/src/language/nixos-module-resolver.js +595 -0
  83. package/src/language/nixos-option-catalog.js +96 -0
  84. package/src/language/pragma.js +150 -0
  85. package/src/language/resolved-pass.js +146 -0
  86. package/src/language/secrets.js +323 -0
  87. package/src/language/state-artifacts.js +78 -0
  88. package/src/language/support-registry.js +224 -0
  89. package/src/language/witness.js +185 -0
  90. package/src/lineage/CLAUDE.md +8 -0
  91. package/src/lineage/coverage.js +2 -0
  92. package/src/lineage/cross-repo-link.js +22 -0
  93. package/src/lineage/graph-builder.js +5 -1
  94. package/src/lineage/haskell-view.js +319 -0
  95. package/src/lineage/index.js +8 -1
  96. package/src/lineage/nix-view.js +198 -0
  97. package/src/lineage/sink-registry.js +10 -1
  98. package/src/lineage/source-registry.js +3 -0
  99. package/src/lineage/transform-catalog.js +12 -0
  100. package/src/llm-validator/index.js +16 -2
  101. package/src/lsp/server.js +69 -9
  102. package/src/mcp/CLAUDE.md +7 -0
  103. package/src/mcp/tools.js +26 -3
  104. package/src/pipeline/coverage-ledger.js +0 -0
  105. package/src/pipeline/scan-health.js +19 -1
  106. package/src/posture/accuracy-scorecard.js +51 -0
  107. package/src/posture/aibom.js +61 -3
  108. package/src/posture/artifact-registry.js +12 -0
  109. package/src/posture/auditor-walkthrough.js +41 -0
  110. package/src/posture/clustering.js +2 -2
  111. package/src/posture/evidence-bundle.js +7 -1
  112. package/src/posture/execution-proof.js +15 -0
  113. package/src/posture/falsification.js +7 -0
  114. package/src/posture/family-resolve.js +7 -0
  115. package/src/posture/fix-history.js +40 -0
  116. package/src/posture/fix-verify.js +15 -3
  117. package/src/posture/provenance/git-evidence.js +24 -0
  118. package/src/posture/provenance/origin-resolver.js +14 -2
  119. package/src/posture/provenance/predicate-replay.js +11 -2
  120. package/src/posture/provenance/sca-origin.js +20 -3
  121. package/src/posture/sbom-diff.js +7 -6
  122. package/src/posture/sbom.js +85 -32
  123. package/src/posture/sca-policy.js +10 -1
  124. package/src/posture/sca-verdict.js +8 -1
  125. package/src/posture/secret-history.js +62 -18
  126. package/src/posture/stable-id.js +21 -2
  127. package/src/posture/state-dir.js +21 -0
  128. package/src/posture/threat-model-auto.js +4 -4
  129. package/src/posture/triage.js +18 -1
  130. package/src/posture/watch-mode.js +34 -3
  131. package/src/pr-delta.js +9 -4
  132. package/src/report/index.js +35 -9
  133. package/src/runScan.js +56 -5
  134. package/src/sast/code-injection-multilang.js +15 -0
  135. package/src/sast/go-extended.js +11 -0
  136. package/src/sast/pipeline.js +34 -1
package/src/pr-delta.js CHANGED
@@ -26,8 +26,9 @@
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
28
28
  import { runFullScan } from './engine.js';
29
+ import { isLanguageManifest, isLanguageExcludedPath } from './language/discovery.js';
29
30
 
30
- const FILE_EXT_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|java|cs|kt|go|rb|php|sol|swift|rs|tf|yml|yaml|json|toml|md)$/i;
31
+ const FILE_EXT_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|java|cs|kt|go|rb|php|sol|swift|rs|tf|yml|yaml|json|toml|md|hs|lhs|hsc|hs-boot|nix|cabal)$/i;
31
32
  const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
32
33
 
33
34
  // `root` is the PR's repository — a scan target, not this project's own
@@ -54,23 +55,27 @@ function _listFilesAtRef(root, ref) {
54
55
  return r.stdout.trim().split('\n').filter(p => {
55
56
  if (!p) return false;
56
57
  if (p.includes('/node_modules/') || p.includes('/.venv/')) return false;
57
- return FILE_EXT_RE.test(p);
58
+ if (isLanguageExcludedPath(p)) return false;
59
+ return FILE_EXT_RE.test(p) || isLanguageManifest(p);
58
60
  });
59
61
  }
60
62
 
61
63
  async function _scanAtRef(root, ref) {
62
64
  const files = _listFilesAtRef(root, ref);
63
65
  const fileContents = {};
66
+ const depFileContents = {};
64
67
  for (const f of files) {
65
68
  const c = _readFileAtRef(root, ref, f);
66
- if (c != null) fileContents[f] = c;
69
+ if (c == null) continue;
70
+ // Haskell/Nix manifests and lock files are dependency inputs, not code (the same split a working-tree scan makes).
71
+ if (isLanguageManifest(f)) depFileContents[f] = c; else fileContents[f] = c;
67
72
  }
68
73
  // `provenance:false` — a base-ref snapshot, not the current working state.
69
74
  // Beyond the wasted git walks, updateLifecycle marks every open stableId
70
75
  // absent from the finding set it is handed as `remediated`; running the PR
71
76
  // delta gate would silently rewrite the project's lifecycle store from the
72
77
  // base ref's findings.
73
- return runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
78
+ return runFullScan({ fileContents, depFileContents, scanRoot: root, provenance: false }, () => {});
74
79
  }
75
80
 
76
81
  function _summary(findings) {
@@ -110,6 +110,20 @@ function explainParts(f, { verbose = false } = {}) {
110
110
  // <confidence>". Nothing here throws on a partially-populated object; every
111
111
  // field access is optional-chained or defaulted, because a provenance record
112
112
  // that survived a failure path is exactly the input this has to render.
113
+ // Additive Haskell/Nix finding metadata (src/language/contracts.js). Only keys the finding actually carries.
114
+ const _LANGUAGE_KEYS = ['language', 'capability', 'analysisKind', 'evidenceKind', 'originalLocation', 'generatedLocation', 'scope', 'uncertainty', 'resolutionStatus', 'modelVersion', 'trustBoundary', 'route', 'modelNote', 'controls', 'guardNote', 'rule', 'exposure', 'destination', 'source', 'origins', 'sink', 'protection', 'attrPath', 'bridge', 'applicationFlow', 'secretValue', 'environmentVariable', 'provider', 'rotation', 'llmRole', 'llmContext', 'owaspLlm', 'end2endProof', 'chainNote', 'declaredCapability', 'exercised', 'service', 'subject', 'option', 'optionEvidence', 'locationKind', 'entry', 'ignorePragmaIgnored'];
115
+ // Keys that other detectors already use with a different meaning; passed through only for Haskell/Nix findings.
116
+ const _LANGUAGE_ONLY_KEYS = new Set(['rule', 'source', 'sink', 'destination', 'origins', 'protection', 'exposure', 'service']);
117
+ function languageFields(f) {
118
+ const out = {};
119
+ const lang = f.language === 'haskell' || f.language === 'nix';
120
+ for (const k of _LANGUAGE_KEYS) {
121
+ if (_LANGUAGE_ONLY_KEYS.has(k) && !lang) continue;
122
+ if (f[k] !== undefined && f[k] !== null) out[k] = f[k];
123
+ }
124
+ return out;
125
+ }
126
+
113
127
  export function explainProvenance(f) {
114
128
  const fp = f && f.findingProvenance;
115
129
  if (!fp) return null;
@@ -274,6 +288,8 @@ export function normalizeFindings(scan){
274
288
  unvalidated: f.unvalidated === true,
275
289
  cross_language: f.cross_language === true,
276
290
  family: f.family || null,
291
+ // Haskell/Nix language contract (additive; absent for every other finding, so existing output is unchanged).
292
+ ...languageFields(f),
277
293
  // Premortem #8: surface the parser field so downstream consumers
278
294
  // (UI, SARIF, calibration) see the value the engine backfilled.
279
295
  parser: f.parser || null,
@@ -527,7 +543,8 @@ export function normalizeFindings(scan){
527
543
  cveAliases: sc.cveAliases || [],
528
544
  osvId: sc.osvId || null,
529
545
  advisory: sc.advisory || sc.description || '',
530
- fixedIn: sc.range || null,
546
+ fixedIn: sc.range || (Array.isArray(sc.fixedIn) && sc.fixedIn.length ? sc.fixedIn : null),
547
+ ...(Number.isInteger(sc.line) && sc.line > 0 ? { declaringLine: sc.line, declaringFile: sc.file || null } : {}),
531
548
  // Feat-9: real-world risk signals
532
549
  epssScore: sc.epssScore ?? null,
533
550
  epssPercentile: sc.epssPercentile ?? null,
@@ -1034,15 +1051,24 @@ export function toSARIF(scan, meta={}){
1034
1051
  const chain = Array.isArray(f.chain) ? f.chain : [];
1035
1052
  const codeFlows = chain.length >= 2 ? [{
1036
1053
  threadFlows: [{
1037
- locations: chain.map((hop, idx) => ({
1038
- location: {
1039
- physicalLocation: {
1040
- artifactLocation: { uri: hop.file || f.file },
1041
- region: { startLine: Math.max(1, hop.line || 1) },
1054
+ locations: chain.map((hop, idx) => {
1055
+ // A hop inside a GENERATED script (a shell script a Nix string builds) has coordinates in the script
1056
+ // text, not in the file. It is reported at the nearest ORIGINAL span (the interpolation that feeds it)
1057
+ // and carries its generated position and origin in properties, so no reader mistakes script line 1 for
1058
+ // line 1 of the Nix file.
1059
+ const generated = hop.generated === true;
1060
+ const orig = generated && f.originalLocation && Number.isInteger(f.originalLocation.startLine) ? f.originalLocation : null;
1061
+ const region = orig
1062
+ ? { startLine: Math.max(1, orig.startLine), ...(Number.isInteger(orig.startColumn) ? { startColumn: orig.startColumn + 1 } : {}), ...(Number.isInteger(orig.endLine) ? { endLine: orig.endLine } : {}), ...(Number.isInteger(orig.endColumn) ? { endColumn: orig.endColumn + 1 } : {}) }
1063
+ : { startLine: Math.max(1, hop.line || 1), ...(Number.isInteger(hop.column) ? { startColumn: hop.column + 1 } : {}), ...(Number.isInteger(hop.endLine) ? { endLine: hop.endLine } : {}), ...(Number.isInteger(hop.endColumn) ? { endColumn: hop.endColumn + 1 } : {}) };
1064
+ return {
1065
+ location: {
1066
+ physicalLocation: { artifactLocation: { uri: hop.file || f.file }, region },
1067
+ message: { text: `${hop.label || (idx === 0 ? 'source' : idx === chain.length - 1 ? 'sink' : 'propagation')}${generated ? ' [generated script]' : ''}` },
1042
1068
  },
1043
- message: { text: hop.label || (idx === 0 ? 'source' : idx === chain.length - 1 ? 'sink' : 'propagation') },
1044
- },
1045
- })),
1069
+ ...(generated ? { kinds: ['generated'], properties: { generated: true, generatedLocation: { line: hop.line, ...(Number.isInteger(hop.column) ? { startColumn: hop.column } : {}), ...(Number.isInteger(hop.endLine) ? { endLine: hop.endLine } : {}), ...(Number.isInteger(hop.endColumn) ? { endColumn: hop.endColumn } : {}) }, ...(f.attrPath ? { generatedBy: f.attrPath } : {}) } } : {}),
1070
+ };
1071
+ }),
1046
1072
  }],
1047
1073
  }] : undefined;
1048
1074
  const fixes = f.remediation ? [{
package/src/runScan.js CHANGED
@@ -9,6 +9,7 @@ import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate
9
9
  import { appendScanSnapshot } from './posture/security-trend.js';
10
10
  import { recover as recoverFixHistory } from './posture/fix-history.js';
11
11
  import { stampScan } from './posture/ruleset-version.js';
12
+ import { isLanguageManifest, isLanguageExcludedPath, isLanguageSource, readExplicitExports, WALK_IGNORE_GLOBS, impactedFiles, buildImportGraph, importClosure } from './language/discovery.js';
12
13
 
13
14
  const DEP_FILE_NAMES = new Set([
14
15
  'package.json','package-lock.json','yarn.lock','pnpm-lock.yaml',
@@ -40,6 +41,9 @@ export function isDepFile(rel) {
40
41
  if (DEP_FILE_NAMES.has(base)) return true;
41
42
  if (REQUIREMENTS_FILE.test(base)) return true;
42
43
  if (REQUIREMENTS_DIR_FILE.test(rel.split(path.sep).join('/'))) return true;
44
+ // Haskell/Nix project manifests (cabal, stack, flake, lockfiles): a change to
45
+ // any of them moves the global checkpoint key via depFileContents.
46
+ if (isLanguageManifest(rel) && !isLanguageExcludedPath(rel)) return true;
43
47
  return false;
44
48
  }
45
49
 
@@ -68,18 +72,27 @@ const DEFAULT_IGNORE = [
68
72
  '**/dist/**','**/build/**','**/.next/**','**/venv/**','**/env/**','**/.venv/**',
69
73
  '**/target/**','**/bin/**','**/obj/**','**/.cache/**','**/coverage/**',
70
74
  '**/bower_components/**','**/tests/**','**/test/**','**/__tests__/**','**/spec/**','**/mocks/**',
75
+ // Haskell/Nix build output and store closures; explicit exports inside them
76
+ // are read by exact path below, never by walking.
77
+ ...WALK_IGNORE_GLOBS,
71
78
  ];
72
79
 
73
80
  export async function readTree(root, { ignore = [] } = {}) {
74
81
  const entries = await listFiles(root, { ignore: [...DEFAULT_IGNORE, ...ignore] });
75
82
  const fileContents = {};
76
83
  const depFileContents = {};
84
+ const oversize = [];
77
85
  for (const rel of entries) {
78
86
  const abs = path.join(root, rel);
79
87
  let stat;
80
88
  try { stat = await fs.stat(abs); } catch { continue; }
81
89
  const dep = isDepFile(rel);
82
- if (stat.size > (dep ? MAX_DEP_BYTES : MAX_CODE_BYTES)) continue;
90
+ if (stat.size > (dep ? MAX_DEP_BYTES : MAX_CODE_BYTES)) {
91
+ // A Haskell/Nix source or manifest over the read cap is not analysed. That is a coverage gap a reader must be told about,
92
+ // not a quiet omission: it is listed (path, size, cap) and reaches scan health as a condition.
93
+ if (isLanguageSource(rel) || isLanguageManifest(rel)) oversize.push({ file: rel, bytes: stat.size, cap: dep ? MAX_DEP_BYTES : MAX_CODE_BYTES });
94
+ continue;
95
+ }
83
96
  let content;
84
97
  try { content = await fs.readFile(abs, 'utf8'); } catch { continue; }
85
98
  const base = path.basename(rel);
@@ -107,7 +120,14 @@ export async function readTree(root, { ignore = [] } = {}) {
107
120
  // project index parses key=value lines for cross-file lookup.
108
121
  else if (/\.properties$/i.test(rel)) fileContents[rel] = content;
109
122
  }
110
- return { fileContents, depFileContents };
123
+ // Explicit Cabal plan / Stack / Nix exports live inside directories the walk
124
+ // prunes. Read them by exact path next to each project manifest.
125
+ try {
126
+ const manifests = Object.keys(depFileContents).concat(Object.keys(fileContents)).filter(isLanguageManifest);
127
+ const { contents } = readExplicitExports(root, manifests);
128
+ for (const [rel, text] of Object.entries(contents)) depFileContents[rel] = text;
129
+ } catch { /* exports are best-effort evidence */ }
130
+ return { fileContents, depFileContents, oversize };
111
131
  }
112
132
 
113
133
  // Feat-10: incremental scan via `--changed-since <git-ref>`. Returns the set of
@@ -146,6 +166,14 @@ export function changedSince(root, gitRef) {
146
166
  }
147
167
  }
148
168
 
169
+ const POLICY_ONLY_HEAVY = ['**/node_modules/**', '**/.git/**', '**/venv/**', '**/.venv/**', '**/__pycache__/**', ...WALK_IGNORE_GLOBS];
170
+ /** Haskell/Nix sources the default ignore list kept out of the scan (relative paths, sorted, capped). */
171
+ export async function languageFilesIgnoredByPolicy(root, fileContents, depFileContents) {
172
+ const all = await listFiles(root, { ignore: POLICY_ONLY_HEAVY });
173
+ const seen = new Set([...Object.keys(fileContents), ...Object.keys(depFileContents)]);
174
+ return all.filter((rel) => (isLanguageSource(rel) || isLanguageManifest(rel)) && !isLanguageExcludedPath(rel) && !seen.has(rel)).sort().slice(0, 500);
175
+ }
176
+
149
177
  export async function runScan(rootDir, opts = {}) {
150
178
  const root = path.resolve(rootDir);
151
179
  const startedAt = new Date().toISOString();
@@ -173,6 +201,7 @@ export async function runScan(rootDir, opts = {}) {
173
201
  // path added later must opt OUT explicitly rather than silently inherit a
174
202
  // false claim of coverage.
175
203
  let completeScan = true;
204
+ let sizeSkipped = [];
176
205
  if (opts.fileContents) {
177
206
  // Caller-supplied file list (MCP `scan_diff`, the LSP's on-save scan): by
178
207
  // construction a subset of the tree, not a scan of it.
@@ -180,16 +209,38 @@ export async function runScan(rootDir, opts = {}) {
180
209
  depFileContents = opts.depFileContents || {};
181
210
  completeScan = false;
182
211
  } else {
183
- ({ fileContents, depFileContents } = await readTree(root, opts));
212
+ ({ fileContents, depFileContents, oversize: sizeSkipped } = await readTree(root, opts));
213
+ }
214
+
215
+ // Haskell/Nix sources under a default-ignored directory (tests, specs, mocks, bin, build, dist, vendor) are not
216
+ // scanned, exactly as for every other language. That policy is kept, but it is never silent: the files it kept
217
+ // out are listed and reach scan health as a stated limitation.
218
+ let policyExcluded = [];
219
+ if (!opts.fileContents && Object.keys(fileContents).some(isLanguageSource)) {
220
+ try { policyExcluded = await languageFilesIgnoredByPolicy(root, fileContents, depFileContents); } catch { policyExcluded = []; }
184
221
  }
185
222
 
186
223
  // Feat-10: incremental mode — restrict the scan to files changed since a git ref
187
224
  if (opts.changedSince) {
188
225
  const changed = changedSince(root, opts.changedSince);
189
226
  if (changed) {
227
+ const keep = new Set(changed);
228
+ // Haskell and Nix results depend on more than the file that changed: a module that imports a changed module
229
+ // may now be (or stop being) vulnerable, an effective NixOS value is decided by the modules it imports, and a
230
+ // lock file, Cabal flags or an explicit export can change every file's verdict. So the scan set is the changed
231
+ // files, everything that transitively imports one (all language sources for a manifest change), and the import
232
+ // closure of those as context. A deleted language file or manifest widens to every language source, because
233
+ // its importers can no longer be found from the tree on disk.
234
+ const langSources = Object.keys(fileContents).filter(isLanguageSource);
235
+ if (langSources.length) {
236
+ const deletedLang = [...changed].some((c) => (isLanguageSource(c) || isLanguageManifest(c)) && !(c in fileContents) && !(c in depFileContents));
237
+ const impacted = deletedLang ? langSources : impactedFiles(fileContents, depFileContents, [...changed]);
238
+ const { graph } = buildImportGraph(fileContents);
239
+ for (const f of impacted) { keep.add(f); for (const d of importClosure(graph, f).files) keep.add(d); }
240
+ }
190
241
  const filtered = {};
191
242
  for (const f of Object.keys(fileContents)) {
192
- if (changed.has(f)) filtered[f] = fileContents[f];
243
+ if (keep.has(f)) filtered[f] = fileContents[f];
193
244
  }
194
245
  fileContents = filtered;
195
246
  // Only when the filter actually applied. A `changedSince` that resolved
@@ -203,7 +254,7 @@ export async function runScan(rootDir, opts = {}) {
203
254
 
204
255
  // R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
205
256
  // AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
206
- const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan }, opts.onProgress || (()=>{}));
257
+ const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan, policyExcluded, sizeSkipped }, opts.onProgress || (()=>{}));
207
258
  // Premortem 2R4.2: stamp ruleset version + source on the scan result, and
208
259
  // notify if the operator pinned a different version than what's installed.
209
260
  try { stampScan(root, scan); } catch {}
@@ -123,6 +123,20 @@ function _lang(fp) {
123
123
  return null;
124
124
  }
125
125
 
126
+ // True when `idx` sits inside a '…' or "…" literal on its own line. A sink name
127
+ // quoted in a string (a rule library's `fix:`/`example:` text, a log message) is
128
+ // prose, not a call. Backticks are deliberately not tracked: a template literal's
129
+ // `${…}` holds real code, and erring toward "this is code" is the safe direction.
130
+ function _insideQuote(code, idx) {
131
+ let q = null;
132
+ for (let i = code.lastIndexOf('\n', idx - 1) + 1; i < idx; i++) {
133
+ const c = code[i];
134
+ if (q) { if (c === '\\') i++; else if (c === q) q = null; }
135
+ else if (c === '\'' || c === '"') q = c;
136
+ }
137
+ return q !== null;
138
+ }
139
+
126
140
  export function scanCodeInjectionMultilang(fp, raw) {
127
141
  if (!raw || raw.length > 500_000) return [];
128
142
  const lang = _lang(fp);
@@ -137,6 +151,7 @@ export function scanCodeInjectionMultilang(fp, raw) {
137
151
  const r = new RegExp(re.source, re.flags);
138
152
  let m;
139
153
  while ((m = r.exec(code))) {
154
+ if (_insideQuote(code, m.index + Math.max(0, m[0].search(/\w/)))) continue;
140
155
  const line = lineOf(code, m.index);
141
156
  const id = `code-injection-ml:${fp}:${line}:${key}`;
142
157
  if (seen.has(id)) continue;
@@ -5,6 +5,9 @@
5
5
  // - exec.Command shell-form exec.Command("sh", "-c", <var>) vs argv-form
6
6
  // - http.Client custom-transport http.Get(<var>) or http.NewRequest with
7
7
  // user-controlled URL and no allowlist
8
+ // - package-level http.Get/Post runs on http.DefaultClient, which has no
9
+ // timeout (DoS: one stalled upstream pins
10
+ // the goroutine and its connection forever)
8
11
  //
9
12
  // These patterns are narrow and complement the existing regex-based Go rules
10
13
  // (GORM raw SQL, source patterns for net/http, Echo, Chi, Gin).
@@ -38,6 +41,14 @@ const FINDINGS = [
38
41
  vuln: 'SSRF — http.Get/Post with variable URL',
39
42
  remediation: 'Allowlist the destination host before any net/http call. Use `net/url.Parse(target)` then check `parsed.Host` against an explicit allowlist. Reject RFC1918 (10/8, 172.16/12, 192.168/16) and the cloud metadata addresses (169.254.169.254, fd00:ec2::254).',
40
43
  },
44
+ {
45
+ id: 'go-http-no-timeout', severity: 'medium', cwe: 'CWE-400', family: 'dos-no-timeout',
46
+ // Only the package-level helpers. `client.Get(...)` is the caller's own
47
+ // client, which may well set Timeout, so it is deliberately not matched.
48
+ re: /\bhttp\.(?:Get|Post|Head|PostForm)\s*\(/g,
49
+ vuln: 'Missing Timeout on Outbound HTTP Request (DoS)',
50
+ remediation: 'http.Get/Post/Head/PostForm use http.DefaultClient, which never times out. Build a client instead: `c := &http.Client{Timeout: 10 * time.Second}` and call `c.Get(...)`, or pass a context with a deadline to http.NewRequestWithContext.',
51
+ },
41
52
  {
42
53
  id: 'go-newrequest-user-url', severity: 'high', cwe: 'CWE-918', family: 'ssrf',
43
54
  re: /\bhttp\.NewRequest(?:WithContext)?\s*\(\s*[^,]+,\s*(?!"[^"]*"\s*[,)])[a-zA-Z_]\w*/g,
@@ -14,6 +14,38 @@ const _GH_WORKFLOW_RE = /(?:^|\/)\.github\/workflows\/.*\.ya?ml$/i;
14
14
  const _NONPROD_RE = /(?:^|\/)(?:tests?|examples?|fixtures?)\//i;
15
15
 
16
16
  const PIPELINE_PATTERNS = [
17
+ // ── build hooks common to Haskell (ghcup, Cabal, Stack, Hackage) and Nix pipelines ──
18
+ {
19
+ re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b/g,
20
+ vuln: 'Pipeline: remote script piped into a shell (unverified installer)',
21
+ sev: 'high', cwe: 'CWE-494',
22
+ fix: 'Download the installer to a file, verify its published checksum or signature, then run it. Installers for ghcup, Nix and similar tools are a common supply-chain target when piped straight into `sh`.',
23
+ },
24
+ {
25
+ re: /\bnix\s+(?:run|shell|develop|build|profile\s+install|eval)\b[^\n]*?\b(?:github|gitlab|sourcehut):[\w.-]+\/[\w.-]+(?!\/[0-9a-f]{40}\b)(?=[\s#?]|$)/g,
26
+ vuln: 'Pipeline: Nix flake reference executed without a pinned revision',
27
+ sev: 'high', cwe: 'CWE-829',
28
+ fix: 'Pin the flake reference to a full commit (`github:owner/repo/<40-hex-rev>`) or run it from this repository\'s own locked flake, so CI cannot execute whatever the branch points to today.',
29
+ },
30
+ {
31
+ re: /\bnix\b[^\n]*(?:--accept-flake-config|--option\s+sandbox\s+false|--option\s+substituters\s+http:|--extra-trusted-public-keys|--impure\b)/g,
32
+ vuln: 'Pipeline: Nix evaluation or build trust relaxed in CI',
33
+ sev: 'medium', cwe: 'CWE-693',
34
+ fix: 'Do not accept a flake\'s own configuration, disable the sandbox, add unauthenticated substituters or evaluate impurely in CI. Put the trust decision in the repository\'s reviewed configuration instead.',
35
+ },
36
+ {
37
+ re: /^env\s*:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+[A-Za-z0-9_]+\s*:\s*\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/gm,
38
+ vuln: 'Pipeline: secret exposed at workflow-wide environment scope',
39
+ sev: 'medium', cwe: 'CWE-522',
40
+ fix: 'Set secrets such as a Hackage or Cachix token on the single step that needs them (`env:` under that step), not at the top of the workflow where every step and every third-party action can read them.',
41
+ },
42
+ {
43
+ re: /\bref\s*:\s*\$\{\{\s*github\.(?:event\.pull_request\.head\.(?:sha|ref)|head_ref)\s*\}\}/g,
44
+ vuln: 'Pipeline: pull_request_target checks out untrusted pull request code',
45
+ sev: 'high', cwe: 'CWE-829',
46
+ fix: 'A `pull_request_target` workflow runs with repository secrets. Never check out and build the pull request head in it (a Cabal custom Setup.hs, a Nix flake or a build hook would run attacker code with those secrets); use `pull_request`, or split the privileged step.',
47
+ contextRe: /\bpull_request_target\b/,
48
+ },
17
49
  {
18
50
  re: /\buses\s*:\s*[\w-]+\/[\w-]+@(?:main|master|latest)\b/g,
19
51
  vuln: 'Pipeline: GitHub Action pinned to floating tag',
@@ -96,7 +128,7 @@ export function scanPipeline(fp, raw) {
96
128
  // `uses:` step with its pin (SHA or tag), every secret reference, every
97
129
  // permissions block. The PBOM is meant to be stored alongside the SBOM and
98
130
  // produced from the same scan.
99
- export function toPBOM(fileContents, meta = {}) {
131
+ export function toPBOM(fileContents, meta = {}, extras = {}) {
100
132
  const workflows = [];
101
133
  for (const [fp, raw] of Object.entries(fileContents || {})) {
102
134
  if (!_GH_WORKFLOW_RE.test(fp.replace(/\\/g, '/'))) continue;
@@ -120,6 +152,7 @@ export function toPBOM(fileContents, meta = {}) {
120
152
  version: '1',
121
153
  generatedAt: meta.startedAt || new Date().toISOString(),
122
154
  workflows,
155
+ ...(extras.languageBuild && (extras.languageBuild.haskell || extras.languageBuild.nix) ? { languageBuild: extras.languageBuild } : {}),
123
156
  summary: {
124
157
  totalWorkflows: workflows.length,
125
158
  totalActions: workflows.reduce((n, w) => n + w.uses.length, 0),