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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +34 -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 +17 -2
  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 +125 -14
  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/pipeline.js +34 -1
@@ -0,0 +1,103 @@
1
+ export const id = 3596;
2
+ export const ids = [3596];
3
+ export const modules = {
4
+
5
+ /***/ 53596:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
+ /* harmony export */ sweepHaskellSiblings: () => (/* binding */ sweepHaskellSiblings)
10
+ /* harmony export */ });
11
+ /* unused harmony export HS_SWEEP_VERSION */
12
+ /* harmony import */ var _dataflow_catalog_haskell_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(7225);
13
+ /* harmony import */ var _lineage_source_seeding_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(88383);
14
+ // Root-cause sweep for Haskell (X-008). The generic sweep anchors on a `callee(args)` text shape, which Haskell's
15
+ // juxtaposition syntax never produces, so a confirmed Haskell finding swept nothing. This sweep works on the IR: for
16
+ // each confirmed finding it takes the RESOLVED sink callee from the model catalog and lists every other call of that
17
+ // same callee in the project, then accounts for all of them:
18
+ //
19
+ // found === candidates + mitigated
20
+ //
21
+ // `found` is every other call site of the callee (the finding's own site, and the sites of other findings in the same
22
+ // group, are the origins and are excluded). `mitigated` is a site where a detector already produced a finding for a
23
+ // different reason, `candidates` is the rest. A candidate whose argument is a literal constant is flagged
24
+ // (`constantArgument`) and stays a candidate: the flag is information, not a verdict. Nothing is dropped.
25
+ //
26
+ // Resolution is by import-qualified callee, so an unrelated function that merely shares a name is not a sibling.
27
+
28
+
29
+
30
+
31
+ const HS_SWEEP_VERSION = 'haskell-sweep/1';
32
+ const SAMPLE_LIMIT = 100;
33
+ const isHs = (f) => /\.l?hs$/i.test(f || '');
34
+ const BY_ID = new Map(_dataflow_catalog_haskell_js__WEBPACK_IMPORTED_MODULE_0__/* .HASKELL_CATALOG */ .R.filter((e) => e.kind === 'sink').map((e) => [e.id, e]));
35
+
36
+ const allConstant = (e) => {
37
+ let constant = true; let sawLiteral = false;
38
+ (0,_lineage_source_seeding_js__WEBPACK_IMPORTED_MODULE_1__/* .walkExpr */ .lN)(e, (x) => {
39
+ if (x.kind === 'literal') sawLiteral = true;
40
+ else if (x.kind === 'ident' && !(x.hs && x.hs.functionRef)) constant = false;
41
+ else if (x.kind === 'member') constant = false;
42
+ });
43
+ return constant && sawLiteral;
44
+ };
45
+
46
+ /**
47
+ * @param {object[]} findings confirmed findings (only Haskell IR-taint findings are swept)
48
+ * @param {{functions: Map}} callGraph the project call graph (ir/index.js buildProjectIR)
49
+ */
50
+ function sweepHaskellSiblings(findings, callGraph) {
51
+ const groups = new Map();
52
+ for (const f of findings || []) {
53
+ if (!f || !isHs(f.file) || f.parser !== 'IR-TAINT' || !f.sink || !f.sink.label) continue;
54
+ const entry = BY_ID.get(f.sink.label);
55
+ if (!entry || !entry.match || !entry.match.callee) continue;
56
+ const key = entry.match.callee;
57
+ if (!groups.has(key)) groups.set(key, { callee: key, entry, findings: [] });
58
+ groups.get(key).findings.push(f);
59
+ }
60
+ const sites = new Map(); // callee -> [{file, line, constantArgument}]
61
+ const wanted = new Set(groups.keys());
62
+ for (const fn of callGraph && callGraph.functions ? callGraph.functions.values() : []) {
63
+ if (!isHs(fn.file) || !fn.cfg) continue;
64
+ for (const node of Object.values(fn.cfg.nodes)) {
65
+ const visit = (e) => {
66
+ if (e.kind === 'call' && typeof e.callee === 'string' && wanted.has(e.callee)) {
67
+ const entry = groups.get(e.callee).entry;
68
+ const arg = (e.args || [])[entry.argIndex || 0];
69
+ if (!sites.has(e.callee)) sites.set(e.callee, []);
70
+ sites.get(e.callee).push({ file: fn.file, line: e.line || node.line || null, constantArgument: arg ? allConstant(arg) : false });
71
+ }
72
+ };
73
+ if (node.kind === 'call' && typeof node.callee === 'string' && wanted.has(node.callee)) visit(node);
74
+ for (const root of (0,_lineage_source_seeding_js__WEBPACK_IMPORTED_MODULE_1__/* .exprRoots */ .Zf)(node)) (0,_lineage_source_seeding_js__WEBPACK_IMPORTED_MODULE_1__/* .walkExpr */ .lN)(root, visit);
75
+ }
76
+ }
77
+ const covered = new Map(); // "file:line" -> true, any finding at that site
78
+ for (const f of findings || []) if (f && f.file && Number.isInteger(f.line)) covered.set(`${f.file}:${f.line}`, true);
79
+ const sweeps = [];
80
+ const totals = { found: 0, candidates: 0, mitigated: 0 };
81
+ for (const [callee, g] of [...groups.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
82
+ const origin = new Set(g.findings.map((f) => `${f.file}:${f.line}`));
83
+ const seen = new Set(); const all = [];
84
+ for (const s of sites.get(callee) || []) { const k = `${s.file}:${s.line}`; if (origin.has(k) || seen.has(k)) continue; seen.add(k); all.push(s); }
85
+ all.sort((a, b) => (a.file + String(a.line)).localeCompare(b.file + String(b.line), undefined, { numeric: true }));
86
+ const instances = all.map((s) => ({ ...s, status: covered.has(`${s.file}:${s.line}`) ? 'mitigated' : 'candidate' }));
87
+ const mitigated = instances.filter((i) => i.status === 'mitigated').length;
88
+ const candidates = instances.length - mitigated;
89
+ sweeps.push({
90
+ callee, cwe: g.entry.vuln.cwe, family: g.entry.hs && g.entry.hs.family, origins: [...origin].sort(), findingIds: g.findings.map((f) => f.id).sort(),
91
+ found: instances.length, candidates, mitigated, constantArgumentCandidates: instances.filter((i) => i.status === 'candidate' && i.constantArgument).length,
92
+ instances: instances.slice(0, SAMPLE_LIMIT), instancesTruncated: instances.length > SAMPLE_LIMIT, remaining: candidates,
93
+ note: 'siblings are other calls of the same import-qualified sink; a candidate is unreviewed, not vulnerable',
94
+ });
95
+ totals.found += instances.length; totals.candidates += candidates; totals.mitigated += mitigated;
96
+ }
97
+ return { version: HS_SWEEP_VERSION, sweeps, totals };
98
+ }
99
+
100
+
101
+ /***/ })
102
+
103
+ };
@@ -761,8 +761,17 @@ async function verifyPatch({
761
761
  depFileContents = {},
762
762
  } = {}) {
763
763
  if (!files || typeof files !== 'object') return { ok: false, reason: 'no-files-provided' };
764
- const fileContents = { ...files };
764
+ let fileContents = { ...files };
765
765
  let scan;
766
+ // Haskell/Nix findings come from the deep (IR) layer and depend on imported modules: verifying a patch without
767
+ // either would never see the original finding at all, so a patch that fixes nothing would read "verified". The
768
+ // patched files are scanned deep, together with their import closure and manifests read from disk.
769
+ let deep;
770
+ if (Object.keys(fileContents).some((f) => /\.(?:l?hs|hs-boot|hsc|nix)$/i.test(f))) {
771
+ const { withLanguageContext } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 15911));
772
+ const lc = withLanguageContext(scanRoot, fileContents, depFileContents);
773
+ fileContents = lc.fileContents; depFileContents = lc.depFileContents; deep = true;
774
+ }
766
775
  try {
767
776
  // `provenance:false` is REQUIRED here, not an optimisation. This scan is
768
777
  // deliberately scoped to just the patched file(s), so its finding set is a
@@ -772,7 +781,7 @@ async function verifyPatch({
772
781
  // would mass-mark the rest of the project as remediated, then
773
782
  // `reintroduced` on the next real scan. The patched content is also not
774
783
  // committed, so there is no history to resolve provenance against anyway.
775
- scan = await (0,_engine_js__WEBPACK_IMPORTED_MODULE_3__/* .runFullScan */ .wW)({ fileContents, depFileContents, scanRoot, provenance: false }, () => {});
784
+ scan = await (0,_engine_js__WEBPACK_IMPORTED_MODULE_3__/* .runFullScan */ .wW)({ fileContents, depFileContents, scanRoot, provenance: false, ...(deep ? { deep: true } : {}) }, () => {});
776
785
  } catch (e) {
777
786
  return { ok: false, reason: 'rescan-failed', error: e.message };
778
787
  }
@@ -782,8 +791,11 @@ async function verifyPatch({
782
791
  if (stillHasOriginal) {
783
792
  return { ok: false, reason: 'original-finding-still-present', stableId: originalFindingStableId };
784
793
  }
794
+ // With language context in play, files that were only read for context carry findings that predate the patch;
795
+ // only findings in the PATCHED files can be introduced by it.
796
+ const patchedSet = new Set(Object.keys(files));
785
797
  const introducedHighOrAbove = findings.filter(f =>
786
- (SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium);
798
+ (SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium && (!deep || patchedSet.has(f.file)));
787
799
  // Don't count findings on lines outside the patched files — but our
788
800
  // fileContents map IS the patched files, so every finding is in-scope.
789
801
  return {
@@ -0,0 +1,319 @@
1
+ export const id = 4171;
2
+ export const ids = [4171];
3
+ export const modules = {
4
+
5
+ /***/ 94171:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+
9
+ // EXPORTS
10
+ __webpack_require__.d(__webpack_exports__, {
11
+ analyzeLanguageFindings: () => (/* binding */ analyzeLanguageFindings),
12
+ analyzeLanguageSupplyChain: () => (/* binding */ analyzeLanguageSupplyChain)
13
+ });
14
+
15
+ // UNUSED EXPORTS: nixosEntries
16
+
17
+ // EXTERNAL MODULE: ./src/language/resolved-pass.js + 3 modules
18
+ var resolved_pass = __webpack_require__(38551);
19
+ // EXTERNAL MODULE: ./src/language/nix-build-trust.js
20
+ var nix_build_trust = __webpack_require__(36668);
21
+ // EXTERNAL MODULE: ./src/language/discovery.js
22
+ var discovery = __webpack_require__(30951);
23
+ // EXTERNAL MODULE: ./src/language/haskell-supply.js
24
+ var haskell_supply = __webpack_require__(86349);
25
+ // EXTERNAL MODULE: ./src/language/nix-script-taint.js
26
+ var nix_script_taint = __webpack_require__(11793);
27
+ // EXTERNAL MODULE: ./src/language/nix-secrets.js
28
+ var nix_secrets = __webpack_require__(69466);
29
+ // EXTERNAL MODULE: ./src/language/nix-parser.js
30
+ var nix_parser = __webpack_require__(54352);
31
+ // EXTERNAL MODULE: ./src/language/secrets.js
32
+ var secrets = __webpack_require__(95108);
33
+ // EXTERNAL MODULE: ./src/language/haskell-manifests.js
34
+ var haskell_manifests = __webpack_require__(16522);
35
+ ;// CONCATENATED MODULE: ./src/language/nix-agents.js
36
+ // Nix-launched agent and MCP service configuration (X-003).
37
+ //
38
+ // What this reads is DECLARATION: a systemd service or container whose command line starts an MCP server or
39
+ // an agent CLI, with the filesystem roots, flags, bind address, user and environment the configuration gives
40
+ // it. Every finding here is configuration evidence of a CAPABILITY, never proof that an agent exercised it
41
+ // (`declaredCapability: true`, `exercised: 'not-established'`). Bridges to the application that reads the
42
+ // configuration (an environment variable the Nix service sets and the Haskell program reads) carry explicit
43
+ // provenance on both sides, and only become `executable-linked` when the service launches an executable the
44
+ // project's own manifest builds; a matching variable name alone is a `candidate`.
45
+ //
46
+ // Static only: nothing is evaluated or launched.
47
+
48
+
49
+
50
+
51
+
52
+ const NIX_AGENTS_VERSION = 'nix-agents/1';
53
+ const PH = '';
54
+
55
+ const MCP_RE = /(?:@modelcontextprotocol\/|\bmcp[-_]server|\bserver[-_]mcp|\bmcp[-_]proxy|fastmcp|\buvx\s+mcp|\bnpx\b[^\n]*\bmcp\b|\s--mcp\b)/i;
56
+ const SHELL_MCP_RE = /(?:mcp[-_]server[-_](?:shell|commands|exec)|server-shell|shell-server|mcp-shell|mcp[-_]commands)/i;
57
+ const AGENT_RE = /(?:^|[/\s])(?:claude|aider|goose|codex|gemini|gemini-cli|opencode|crush|openhands|open-interpreter|interpreter|autogpt|cline)(?:\s|$|--)/i;
58
+ const AUTO_APPROVE_RE = /(?:--dangerously-skip-permissions|--dangerously-bypass[\w-]*|--yolo\b|--auto-?approve\b|--approval-mode[= ]yolo|--no-confirm\b|--yes-always\b|--permission-mode[= ]bypass\w*|-y\s+--(?:auto|run))/i;
59
+ const BIND_ALL_RE = /(?:--(?:host|listen|address|bind)[= ]\s*(?:0\.0\.0\.0|::|\[::\]|\*)|-H\s+0\.0\.0\.0|\b(?:HOST|LISTEN_ADDR|BIND(?:_ADDRESS)?)=0\.0\.0\.0)/i;
60
+ const BROAD_ROOTS = [[/(?:^|\s)\/(?:\s|$)/, '/', 'high'], [/(?:^|\s)\/(?:etc|root|nix|boot|proc|sys|usr)(?:\/\S*)?(?:\s|$)/, '/etc, /root, /nix or another system tree', 'high'], [/(?:^|\s)(?:\/home|\/var|\/mnt|\/srv\/?)(?:\s|$)/, '/home, /var or /mnt', 'medium'], [/(?:^|\s)(?:~\/?|\$HOME\/?|%h\/?)(?:\s|$)/, 'the user home directory', 'medium']];
61
+
62
+ const segName = (s) => (s && s.kind === 'static' ? s.name : null);
63
+ const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
64
+
65
+ /** A string's text with every interpolation replaced by a placeholder (never evaluated). */
66
+ function textOf(n) {
67
+ n = unparen(n);
68
+ if (!n) return null;
69
+ if (n.type === 'string') return n.parts.map((p) => (p.kind === 'text' ? p.value : PH)).join('');
70
+ if (n.type === 'list') return n.items.map((i) => textOf(i)).filter((x) => x !== null).join(' ');
71
+ if (n.type === 'path') return n.literal || null;
72
+ if (n.type === 'binop' && n.op === '+') { const a = textOf(n.left); const b = textOf(n.right); return a !== null && b !== null ? a + b : null; }
73
+ return null;
74
+ }
75
+
76
+ function collectServices(files) {
77
+ const services = new Map();
78
+ const get = (file, name, kind, line) => { const k = `${kind}:${name}`; if (!services.has(k)) services.set(k, { file, name, kind, line, commands: [], env: {}, user: null, dynamicUser: null, openFirewall: null, extra: {} }); return services.get(k); };
79
+ for (const [file, text] of Object.entries(files)) {
80
+ const parse = (0,nix_parser/* parseNix */.Z$)(text, { file });
81
+ if (!parse.ast) continue;
82
+ const stack = [{ n: parse.ast, path: [] }];
83
+ while (stack.length) {
84
+ const { n, path } = stack.pop();
85
+ if (!n || typeof n !== 'object') continue;
86
+ if (n.type === 'attrset') {
87
+ for (const b of n.bindings || []) {
88
+ if (b.kind !== 'attr' || !b.value) continue;
89
+ const key = (b.path || []).map(segName);
90
+ if (key.some((x) => x === null)) { stack.push({ n: b.value, path: [...path, ...key.filter(Boolean)] }); continue; }
91
+ const full = [...path, ...key];
92
+ const line = b.span ? b.span.startLine : null;
93
+ let m = /^systemd\.(?:user\.)?services\.([^.]+)\.(.+)$/.exec(full.join('.'));
94
+ let kind = 'systemd';
95
+ const oci = /^virtualisation\.oci-containers\.containers\.([^.]+)\.(.+)$/.exec(full.join('.'));
96
+ if (oci) { m = oci; kind = 'oci'; }
97
+ if (m) {
98
+ const svc = get(file, m[1], kind, line); const rest = m[2];
99
+ const v = unparen(b.value); const t = textOf(v);
100
+ if (kind === 'systemd') {
101
+ if ((/^(?:script|preStart|postStart)$/.test(rest) || /^serviceConfig\.ExecStart(?:Pre)?$/.test(rest)) && t !== null) svc.commands.push({ text: t, line, file, option: rest });
102
+ if (rest === 'serviceConfig.User' && t !== null) svc.user = { value: t, line };
103
+ if (rest === 'serviceConfig.DynamicUser' && v && v.type === 'ident') svc.dynamicUser = { value: v.name === 'true', line };
104
+ const env = /^environment\.(.+)$/.exec(rest);
105
+ if (env && t !== null) svc.env[env[1]] = { text: t, line, file };
106
+ if (rest === 'environment' && v && v.type === 'attrset') for (const eb of v.bindings) { if (eb.kind !== 'attr') continue; const ek = (eb.path || []).map(segName).filter(Boolean).join('.'); const et = textOf(eb.value); if (et !== null) svc.env[ek] = { text: et, line: eb.span ? eb.span.startLine : line, file }; }
107
+ } else {
108
+ if (/^(?:cmd|entrypoint)$/.test(rest) && t !== null) svc.commands.push({ text: t, line, file, option: rest });
109
+ if (rest === 'image' && t !== null) svc.extra.image = t;
110
+ if (rest === 'user' && t !== null) svc.user = { value: t, line };
111
+ if (rest === 'ports' && t !== null) svc.extra.ports = t;
112
+ if (rest === 'volumes' && t !== null) svc.extra.volumes = t;
113
+ const env = /^environment\.(.+)$/.exec(rest);
114
+ if (env && t !== null) svc.env[env[1]] = { text: t, line, file };
115
+ }
116
+ }
117
+ stack.push({ n: b.value, path: full });
118
+ }
119
+ }
120
+ for (const k of Object.keys(n)) { if (k === 'span') continue; const c = n[k]; if (c && typeof c === 'object' && !(n.type === 'attrset' && k === 'bindings')) { if (Array.isArray(c)) for (const x of c) stack.push({ n: x, path }); else stack.push({ n: c, path }); } }
121
+ }
122
+ }
123
+ return [...services.values()];
124
+ }
125
+
126
+ const finding = (rule, svc, cmd, o) => ({
127
+ id: `${rule}:${svc.file}:${o.line || svc.line}`, file: svc.file, line: o.line || svc.line, parser: 'NIX-AGENT', family: 'agent-config', rule,
128
+ severity: o.severity, cwe: o.cwe, vuln: o.vuln, owaspLlm: 'LLM06',
129
+ description: `${o.desc} This is configuration evidence of a declared capability; nothing shows an agent has exercised it.`,
130
+ remediation: o.fix, language: 'nix', capability: 'iac', analysisKind: 'configuration', evidenceKind: 'config', scope: 'system', confidence: o.confidence ?? 0.7,
131
+ declaredCapability: true, exercised: 'not-established', service: { name: svc.name, kind: svc.kind }, subject: `${svc.kind}:${svc.name}`,
132
+ attrPath: `${svc.kind === 'oci' ? 'virtualisation.oci-containers.containers' : 'systemd.services'}.${svc.name}`,
133
+ chain: [{ file: svc.file, line: o.line || svc.line, label: o.label || o.vuln, kind: 'config' }],
134
+ });
135
+
136
+ /**
137
+ * @param {Record<string,string>} files Nix sources (and, for bridges, Haskell sources and .cabal manifests)
138
+ */
139
+ function analyzeNixAgents(files) {
140
+ const nix = Object.fromEntries(Object.entries(files).filter(([p, t]) => /\.nix$/i.test(p) && typeof t === 'string'));
141
+ const findings = []; const agents = []; const bridges = [];
142
+ const services = collectServices(nix);
143
+ for (const svc of services) {
144
+ const all = svc.commands.map((c) => c.text).join('\n');
145
+ const isMcp = MCP_RE.test(all) || MCP_RE.test(svc.name) || (svc.extra.image && MCP_RE.test(svc.extra.image));
146
+ const isAgent = AGENT_RE.test(all);
147
+ if (!isMcp && !isAgent) continue;
148
+ const cmd = svc.commands.find((c) => MCP_RE.test(c.text) || AGENT_RE.test(c.text)) || svc.commands[0] || { text: '', line: svc.line };
149
+ const entry = { name: svc.name, kind: isMcp ? 'mcp-server' : 'agent', launcher: svc.kind, file: svc.file, line: cmd.line || svc.line, user: svc.user ? svc.user.value : null, dynamicUser: svc.dynamicUser ? svc.dynamicUser.value : null, env: Object.keys(svc.env), declaredCapability: true, exercised: 'not-established' };
150
+ agents.push(entry);
151
+ const text = ` ${all.replace(new RegExp(PH, 'g'), ' ')} `;
152
+ if (SHELL_MCP_RE.test(all)) findings.push(finding('nix-mcp-shell-server', svc, cmd, { severity: 'high', cwe: 'CWE-250', vuln: 'An MCP server that executes shell commands is launched as a service', desc: `${svc.name} starts an MCP server whose purpose is to run commands.`, fix: 'Do not expose a general shell to a model. Offer narrow tools, or run it in a sandbox with no network and a read-only root.', line: cmd.line }));
153
+ if (isMcp) for (const [re, what, sev] of BROAD_ROOTS) if (re.test(text)) { findings.push(finding('nix-mcp-broad-filesystem', svc, cmd, { severity: sev, cwe: 'CWE-732', vuln: 'An MCP/agent service is given a broad filesystem root', desc: `${svc.name} is started with ${what} as a root the server may read or write.`, fix: 'Point the server at the one project directory it needs, and add systemd ReadWritePaths/ProtectSystem so the unit cannot reach more.', line: cmd.line })); break; }
154
+ if (AUTO_APPROVE_RE.test(text)) findings.push(finding('nix-agent-auto-approve', svc, cmd, { severity: 'high', cwe: 'CWE-862', vuln: 'An agent is launched with permission prompts disabled', desc: `${svc.name} starts an agent with an auto-approve / skip-permissions flag, so tool use needs no confirmation.`, fix: 'Remove the flag, or run the agent in an isolated unit with a minimal allow-list.', line: cmd.line }));
155
+ const bindAll = BIND_ALL_RE.test(text) || Object.values(svc.env).some((e) => BIND_ALL_RE.test(` ${e.text} `) || /^0\.0\.0\.0$/.test(String(e.text).trim()) && false);
156
+ const envBind = Object.entries(svc.env).find(([k, e]) => /^(?:HOST|LISTEN_ADDR|BIND|BIND_ADDRESS|MCP_HOST)$/i.test(k) && String(e.text).trim() === '0.0.0.0');
157
+ if (bindAll || envBind) findings.push(finding('nix-agent-network-exposed', svc, cmd, { severity: 'medium', cwe: 'CWE-668', vuln: 'An agent/MCP service listens on every interface', desc: `${svc.name} binds 0.0.0.0, so anything that can reach the host can reach the tool surface.`, fix: 'Bind 127.0.0.1 (or a unix socket) and put authentication in front of anything that must be remote.', line: envBind ? envBind[1].line : cmd.line }));
158
+ const root = svc.kind === 'systemd' && (!svc.user || svc.user.value === 'root') && !(svc.dynamicUser && svc.dynamicUser.value);
159
+ if (root) findings.push(finding('nix-agent-runs-as-root', svc, cmd, { severity: 'medium', cwe: 'CWE-250', vuln: 'An agent/MCP service runs as root', desc: `${svc.name} has no User or DynamicUser, so systemd runs it as root.`, fix: 'Set serviceConfig.DynamicUser = true (or a dedicated User) and grant only the paths it needs.', line: svc.line }));
160
+ entry.environment = Object.entries(svc.env).map(([k, e]) => ({ name: k, line: e.line, valueRecorded: false }));
161
+ }
162
+ // bridges: a Nix service environment variable and the Haskell program that reads it
163
+ const hsFiles = Object.entries(files).filter(([p, t]) => /\.l?hs$/i.test(p) && typeof t === 'string');
164
+ const manifests = Object.entries(files).filter(([p, t]) => /\.cabal$/i.test(p) && typeof t === 'string').map(([path, text]) => ({ path, text }));
165
+ const executables = new Set();
166
+ if (manifests.length) { try { for (const pkg of (0,haskell_manifests/* analyzeHaskellManifests */.JS)(manifests).packages) for (const c of pkg.components || []) if (c.kind === 'executable') executables.add(c.name); } catch { /* no manifest evidence */ } }
167
+ const reads = [];
168
+ for (const [file, text] of hsFiles) {
169
+ const toks = (0,secrets/* tokenizeHaskell */.cH)(text);
170
+ for (let i = 0; i < toks.length; i++) {
171
+ const t = toks[i];
172
+ if (t.k !== 'i' || !/^(?:getEnv|lookupEnv)$/.test(t.v.replace(/^.*\./, ''))) continue;
173
+ let j = i + 1; while (toks[j] && toks[j].k === 'x' && toks[j].v === '(') j++;
174
+ if (toks[j] && toks[j].k === 's') { let line = 1; for (let q = 0; q < t.start; q++) if (text.charCodeAt(q) === 10) line++; reads.push({ file, line, variable: toks[j].v }); }
175
+ }
176
+ }
177
+ for (const svc of services) {
178
+ const exec = svc.commands.map((c) => c.text).join(' ');
179
+ const launched = [...executables].filter((e) => new RegExp(`(?:^|[/\\s])${e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|$)`).test(exec.replace(new RegExp(PH, 'g'), ' ')));
180
+ for (const [variable, e] of Object.entries(svc.env)) {
181
+ for (const r of reads.filter((x) => x.variable === variable)) {
182
+ bridges.push({ kind: 'env-read', variable, status: launched.length ? 'executable-linked' : 'candidate', executable: launched[0] || null,
183
+ nix: { service: svc.name, file: e.file, line: e.line, role: 'declares-environment' }, haskell: { file: r.file, line: r.line, role: 'reads-environment' },
184
+ provenance: { nix: 'systemd service environment (configuration)', haskell: 'getEnv/lookupEnv literal (source)', link: launched.length ? `the service launches ${launched[0]}, an executable built by the project's cabal file` : 'only the variable name matches: no evidence this service runs that program' },
185
+ flow: 'a runtime configuration bridge, not a data-flow proof' });
186
+ }
187
+ }
188
+ }
189
+ return { version: NIX_AGENTS_VERSION, findings, agents, bridges };
190
+ }
191
+
192
+ // EXTERNAL MODULE: ./src/language/nixos-hardening.js
193
+ var nixos_hardening = __webpack_require__(37099);
194
+ ;// CONCATENATED MODULE: ./src/language/engine-pass.js
195
+ // Language supply-chain pass for the scan engine. Nix fetch/build/cache trust findings are
196
+ // supply-chain entries, so they join the ordinary `supplyChain` bucket (and from there SCA
197
+ // policy, scan health and every report) instead of a side channel. Static only: nothing here
198
+ // evaluates Nix, fetches a URL or runs a builder.
199
+
200
+
201
+
202
+
203
+
204
+
205
+
206
+
207
+
208
+
209
+ const isNixSource = (p) => /\.nix$/i.test(p);
210
+ const isFlakeLock = (p) => /(^|\/)flake\.lock$/i.test(p);
211
+
212
+ /**
213
+ * @param {Record<string,string>} files rel path -> content (sources and manifests as the engine read them)
214
+ * @returns {{supplyChain: object[], gaps: object[], analyzed: number}}
215
+ */
216
+ const NIXOS_OPTION = /(?:^|[\s;{])(?:services|networking|security|systemd|users|environment|programs|boot|virtualisation|nix)\.[A-Za-z_]|(?:^|[\s;{])(?:services|networking|security|systemd|users|environment|programs|boot|virtualisation)\s*=\s*\{/m;
217
+
218
+ /** NixOS entry points of a Nix file set: every configuration.nix, else every module nothing imports (capped). */
219
+ function nixosEntries(nix, { max = 15 } = {}) {
220
+ const conf = Object.keys(nix).filter((p) => /(?:^|\/)configuration\.nix$/i.test(p)).sort().slice(0, 5);
221
+ const imported = new Set();
222
+ try { const { graph } = (0,discovery/* buildImportGraph */.oK)(nix); for (const deps of graph.values()) for (const d of deps) imported.add(d); } catch { /* no import graph */ }
223
+ const standalone = Object.keys(nix).filter((p) => !conf.includes(p) && !imported.has(p) && !/(?:^|\/)flake\.nix$/i.test(p) && NIXOS_OPTION.test(nix[p])).sort().slice(0, max);
224
+ return [...conf, ...standalone];
225
+ }
226
+
227
+ function _baseSupplyChain(files = {}, opts = {}) {
228
+ const nix = {};
229
+ for (const [p, text] of Object.entries(files)) {
230
+ if (typeof text !== 'string') continue;
231
+ if (!(isNixSource(p) || isFlakeLock(p))) continue;
232
+ if ((0,discovery/* isLanguageExcludedPath */.EG)(p)) continue;
233
+ nix[p] = text;
234
+ }
235
+ // Haskell: Hackage advisories and source/name policy (manifests, a freeze or a plan, and the .hs imports).
236
+ const hs = (0,haskell_supply/* analyzeHaskellSupply */.i1)(files, { root: opts.scanRoot || null, resolved: opts.resolved || null });
237
+ const sources = Object.keys(nix).filter(isNixSource);
238
+ if (!sources.length) {
239
+ if (!hs.components.length) return { supplyChain: [], gaps: [], analyzed: 0 };
240
+ return { supplyChain: hs.supplyChain, gaps: hs.gaps, analyzed: hs.components.length, haskell: hs };
241
+ }
242
+ // The effective configuration is decided from an ENTRY: configuration.nix when there is one, otherwise each NixOS module that
243
+ // nothing imports (a flake's host file under any name). With no entry at all only the file-local rules run.
244
+ const entries = nixosEntries(nix);
245
+ const reports = entries.length ? entries.map((entry) => (0,nix_build_trust/* analyzeNixBuildTrust */.OQ)({ files: nix, entry })) : [(0,nix_build_trust/* analyzeNixBuildTrust */.OQ)({ files: nix })];
246
+ const seenIds = new Set(); const merged = []; const gaps = [];
247
+ for (const r of reports) {
248
+ for (const f of r.findings) { const k = f.id || `${f.rule}|${f.file}|${f.line}`; if (!seenIds.has(k)) { seenIds.add(k); merged.push(f); } }
249
+ for (const g of r.gaps) gaps.push(g);
250
+ }
251
+ return { supplyChain: [...hs.supplyChain, ...merged], gaps: [...hs.gaps, ...gaps], analyzed: sources.length + hs.components.length, haskell: hs };
252
+ }
253
+
254
+ /**
255
+ * Findings (not supply-chain entries) from Nix: embedded-shell taint (NIX-003). Returns the findings plus the
256
+ * coverage gaps (unsupported shells, scripts built by expressions) so scan health can report them.
257
+ */
258
+ function analyzeLanguageFindings(files = {}) {
259
+ const nix = {};
260
+ for (const [p, text] of Object.entries(files)) {
261
+ if (typeof text !== 'string' || !isNixSource(p) || (0,discovery/* isLanguageExcludedPath */.EG)(p)) continue;
262
+ nix[p] = text;
263
+ }
264
+ if (!Object.keys(nix).length) return { findings: [], gaps: [], analyzed: 0 };
265
+ const r = (0,nix_script_taint/* analyzeNixScripts */.S3)({ files: nix });
266
+ // secret placement sees referenced data files (sopsFile / agenix file) as well as the Nix sources
267
+ const sec = (0,nix_secrets/* analyzeNixSecrets */.D0)({ files: { ...files, ...nix } });
268
+ let agents = { findings: [] };
269
+ try { agents = analyzeNixAgents(files); } catch { /* agent/MCP rules are best effort */ }
270
+ // NIX-004: effective-configuration hardening, judged per NixOS entry point (configuration.nix). A finding with no
271
+ // option source (a catalog default decided it) is anchored to the entry file at line 1 and says so, so it can be
272
+ // reported, attributed and suppressed instead of being lineless. The files that decided the value travel with it.
273
+ const hardening = [];
274
+ const effective = [];
275
+ // Entry points: every configuration.nix, plus NixOS modules NOTHING imports (a standalone module, a flake's host file under any
276
+ // name): its options are the only place their values are decided, and a file name is not what makes a configuration real.
277
+ const confEntries = Object.keys(nix).filter((p) => /(?:^|\/)configuration\.nix$/i.test(p)).sort().slice(0, 5);
278
+ const entryList = nixosEntries(nix);
279
+ for (const entry of entryList) {
280
+ try {
281
+ const h = (0,nixos_hardening/* analyzeNixosHardening */.QC)({ entry, files: nix });
282
+ effective.push({ entry, completeness: h.reconciliation && h.reconciliation.completeness, unresolved: (h.reconciliation && h.reconciliation.unanalyzed) || [], truncated: [], target: h.target });
283
+ for (const f of h.findings) {
284
+ const ctl = [...new Set((f.evidence || []).flatMap((e) => (e.sources || []).map((x) => x.file)).filter(Boolean))];
285
+ const anchored = Number.isInteger(f.line) && f.file;
286
+ hardening.push({ ...f, file: anchored ? f.file : entry, line: anchored ? f.line : 1, locationKind: anchored ? 'option-source' : 'target-level', controlFiles: ctl, entry, entryKind: confEntries.includes(entry) ? 'configuration' : 'standalone-module', optionEvidence: f.evidence, option: f.evidence && f.evidence[0] ? f.evidence[0].option : null });
287
+ }
288
+ } catch (e) { r.gaps.push({ kind: 'hardening-failed', file: entry, reason: String((e && e.message) || e) }); }
289
+ }
290
+ // Two different rules of one family can land on the same line (a one-line configuration, or several defaults that
291
+ // anchor at the entry). The engine's per-(file, line, family) de-duplication must not merge them into one finding, so
292
+ // each carries a site key built from what makes it a different finding. The key also feeds the stable id.
293
+ const keyed = (list, parts) => list.map((f) => (f && f.siteKey ? f : { ...f, siteKey: parts(f).filter((x) => x !== null && x !== undefined && x !== '').join('|') }));
294
+ const out = [
295
+ ...keyed(r.findings, (f) => [f.rule, f.attrPath, f.sink && f.sink.command, f.generatedLocation && `${f.generatedLocation.line}:${f.generatedLocation.startColumn}`]),
296
+ ...keyed(sec.findings, (f) => [f.rule, f.source && f.source.detail, f.destination && f.destination.label]),
297
+ ...agents.findings,
298
+ ...keyed(hardening, (f) => [f.rule, f.subject]),
299
+ ];
300
+ return { findings: out, gaps: [...r.gaps, ...sec.gaps], analyzed: r.analyzed, effectiveConfigs: effective };
301
+ }
302
+
303
+ /**
304
+ * The language supply chain: declared and resolved Hackage dependencies, Nix inputs and build trust, and (when the project exported
305
+ * one) the Nix closure matched against an advisory snapshot. The resolved data comes from EXPLICIT export files only.
306
+ */
307
+ function analyzeLanguageSupplyChain(files = {}, opts = {}) {
308
+ let resolved = null; let closure = null;
309
+ try { resolved = (0,resolved_pass.resolvedHackageComponents)(files); } catch { resolved = null; }
310
+ try { closure = (0,resolved_pass.analyzeNixClosure)(files, { scanRoot: opts.scanRoot || null }); } catch (e) { closure = { findings: [], gaps: [{ kind: 'closure-analysis-failed', detail: String((e && e.message) || e).slice(0, 160) }], statuses: [], sources: [], status: 'failed' }; }
311
+ const base = _baseSupplyChain(files, { ...opts, resolved });
312
+ if (!closure) return resolved && resolved.summary ? { ...base, resolved: resolved.summary } : base;
313
+ return { ...base, supplyChain: [...base.supplyChain, ...closure.findings], gaps: [...(base.gaps || []), ...closure.gaps], analyzed: (base.analyzed || 0) + closure.statuses.length, resolved: resolved && resolved.summary, nixClosure: closure };
314
+ }
315
+
316
+
317
+ /***/ })
318
+
319
+ };
@@ -14,6 +14,7 @@ __webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependen
14
14
  /* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(31421);
15
15
  /* harmony import */ var _util_git_hardening_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(78844);
16
16
  /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(67198);
17
+ /* harmony import */ var _language_discovery_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(30951);
17
18
  var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_engine_js__WEBPACK_IMPORTED_MODULE_2__]);
18
19
  _engine_js__WEBPACK_IMPORTED_MODULE_2__ = (__webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__)[0];
19
20
  // Shadowscan / security-DELTA on PR (v0.72).
@@ -45,7 +46,8 @@ _engine_js__WEBPACK_IMPORTED_MODULE_2__ = (__webpack_async_dependencies__.then ?
45
46
 
46
47
 
47
48
 
48
- 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;
49
+
50
+ 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;
49
51
  const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
50
52
 
51
53
  // `root` is the PR's repository — a scan target, not this project's own
@@ -72,23 +74,27 @@ function _listFilesAtRef(root, ref) {
72
74
  return r.stdout.trim().split('\n').filter(p => {
73
75
  if (!p) return false;
74
76
  if (p.includes('/node_modules/') || p.includes('/.venv/')) return false;
75
- return FILE_EXT_RE.test(p);
77
+ if ((0,_language_discovery_js__WEBPACK_IMPORTED_MODULE_3__/* .isLanguageExcludedPath */ .EG)(p)) return false;
78
+ return FILE_EXT_RE.test(p) || (0,_language_discovery_js__WEBPACK_IMPORTED_MODULE_3__/* .isLanguageManifest */ .mD)(p);
76
79
  });
77
80
  }
78
81
 
79
82
  async function _scanAtRef(root, ref) {
80
83
  const files = _listFilesAtRef(root, ref);
81
84
  const fileContents = {};
85
+ const depFileContents = {};
82
86
  for (const f of files) {
83
87
  const c = _readFileAtRef(root, ref, f);
84
- if (c != null) fileContents[f] = c;
88
+ if (c == null) continue;
89
+ // Haskell/Nix manifests and lock files are dependency inputs, not code (the same split a working-tree scan makes).
90
+ if ((0,_language_discovery_js__WEBPACK_IMPORTED_MODULE_3__/* .isLanguageManifest */ .mD)(f)) depFileContents[f] = c; else fileContents[f] = c;
85
91
  }
86
92
  // `provenance:false` — a base-ref snapshot, not the current working state.
87
93
  // Beyond the wasted git walks, updateLifecycle marks every open stableId
88
94
  // absent from the finding set it is handed as `remediated`; running the PR
89
95
  // delta gate would silently rewrite the project's lifecycle store from the
90
96
  // base ref's findings.
91
- return (0,_engine_js__WEBPACK_IMPORTED_MODULE_2__/* .runFullScan */ .wW)({ fileContents, scanRoot: root, provenance: false }, () => {});
97
+ return (0,_engine_js__WEBPACK_IMPORTED_MODULE_2__/* .runFullScan */ .wW)({ fileContents, depFileContents, scanRoot: root, provenance: false }, () => {});
92
98
  }
93
99
 
94
100
  function _summary(findings) {