@clear-capabilities/agentic-security-scanner 0.185.0 → 0.186.2

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 (174) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/agentic-security.js +66 -18
  3. package/dist/1128.index.js +25 -21
  4. package/dist/{5817.index.js → 1602.index.js} +378 -29
  5. package/dist/2584.index.js +1 -1
  6. package/dist/4171.index.js +2 -2
  7. package/dist/{0b301d45b231f144a1b4.json → 4d06c8944ae121d7085c.json} +8 -8
  8. package/dist/5752.index.js +466 -0
  9. package/dist/7405.index.js +1 -1
  10. package/dist/8158.index.js +1713 -0
  11. package/dist/8798.index.js +1 -1
  12. package/dist/9382.index.js +2 -2
  13. package/dist/9560.index.js +1 -1
  14. package/dist/9579.index.js +1 -1
  15. package/dist/agentic-security.mjs +29 -29
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +8 -8
  18. package/src/dataflow/CLAUDE.md +6 -3
  19. package/src/dataflow/access-paths.js +12 -2
  20. package/src/dataflow/demand-query.js +279 -40
  21. package/src/dataflow/dep-fingerprint.js +1 -1
  22. package/src/dataflow/exploit-prover.js +6 -2
  23. package/src/dataflow/ir-cache.js +268 -0
  24. package/src/dataflow/privacy-taint.js +7 -0
  25. package/src/engine.js +62 -5
  26. package/src/ir/CLAUDE.md +4 -0
  27. package/src/ir/callgraph.js +36 -8
  28. package/src/ir/index.js +18 -4
  29. package/src/ir/parser-js.js +10 -0
  30. package/src/language/CLAUDE.md +13 -0
  31. package/src/language/nix-option-types.js +170 -0
  32. package/src/language/nix-script-taint.js +46 -3
  33. package/src/language/nix-secrets.js +20 -2
  34. package/src/lineage/CLAUDE.md +2 -1
  35. package/src/lineage/smt-orchestrator.js +47 -11
  36. package/src/lineage/smt-z3-backend.js +294 -0
  37. package/src/posture/CLAUDE.md +5 -1
  38. package/src/posture/business-logic.js +16 -7
  39. package/src/posture/dag-commands.js +442 -0
  40. package/src/posture/finding-defaults.js +19 -0
  41. package/src/posture/git-history.js +91 -1
  42. package/src/posture/proof-fabric/cli.js +1 -1
  43. package/src/posture/proof-fabric/container.js +2 -2
  44. package/src/posture/proof-fabric/generate-haskell.js +205 -0
  45. package/src/posture/proof-fabric/generate.js +6 -3
  46. package/src/posture/proof-fabric/reproducer.js +4 -2
  47. package/src/posture/proof-fabric/runner.js +4 -4
  48. package/src/sast/CLAUDE.md +2 -0
  49. package/src/sast/_call-args.js +70 -0
  50. package/src/sast/init-scope.js +87 -0
  51. package/src/sast/listen-bind-address.js +49 -0
  52. package/src/sast/redirect-target.js +106 -0
  53. package/src/sast/reflected-input.js +76 -0
  54. package/src/sast/send-file-root.js +27 -0
  55. package/src/sast/sensitive-data-logging.js +38 -1
  56. package/dist/119.index.js +0 -197
  57. package/dist/128.index.js +0 -129
  58. package/dist/1293.index.js +0 -5689
  59. package/dist/1336.index.js +0 -16331
  60. package/dist/1351.index.js +0 -133
  61. package/dist/1387.index.js +0 -271
  62. package/dist/1437.index.js +0 -23
  63. package/dist/1445.index.js +0 -110
  64. package/dist/1505.index.js +0 -151
  65. package/dist/1557.index.js +0 -293
  66. package/dist/1621.index.js +0 -311
  67. package/dist/1751.index.js +0 -160
  68. package/dist/1765.index.js +0 -727
  69. package/dist/1861.index.js +0 -690
  70. package/dist/1906.index.js +0 -433
  71. package/dist/208.index.js +0 -1631
  72. package/dist/2223.index.js +0 -159
  73. package/dist/2238.index.js +0 -295
  74. package/dist/2530.index.js +0 -152
  75. package/dist/2565.index.js +0 -116
  76. package/dist/2574.index.js +0 -127
  77. package/dist/2620.index.js +0 -345
  78. package/dist/2773.index.js +0 -98
  79. package/dist/2775.index.js +0 -32
  80. package/dist/2966.index.js +0 -171
  81. package/dist/2972.index.js +0 -87
  82. package/dist/2991.index.js +0 -131
  83. package/dist/3063.index.js +0 -833
  84. package/dist/3075.index.js +0 -227
  85. package/dist/3121.index.js +0 -297
  86. package/dist/3314.index.js +0 -263
  87. package/dist/3480.index.js +0 -1822
  88. package/dist/3481.index.js +0 -24
  89. package/dist/3506.index.js +0 -268
  90. package/dist/3533.index.js +0 -591
  91. package/dist/3653.index.js +0 -450
  92. package/dist/3740.index.js +0 -722
  93. package/dist/3822.index.js +0 -131
  94. package/dist/3868.index.js +0 -292
  95. package/dist/3918.index.js +0 -192
  96. package/dist/4090.index.js +0 -288
  97. package/dist/4113.index.js +0 -1286
  98. package/dist/4121.index.js +0 -1190
  99. package/dist/4142.index.js +0 -690
  100. package/dist/4168.index.js +0 -509
  101. package/dist/4281.index.js +0 -993
  102. package/dist/4336.index.js +0 -436
  103. package/dist/4387.index.js +0 -532
  104. package/dist/4413.index.js +0 -302
  105. package/dist/4476.index.js +0 -4510
  106. package/dist/4491.index.js +0 -133
  107. package/dist/4538.index.js +0 -185
  108. package/dist/4684.index.js +0 -319
  109. package/dist/4740.index.js +0 -165
  110. package/dist/4780.index.js +0 -209
  111. package/dist/4802.index.js +0 -225
  112. package/dist/4812.index.js +0 -379
  113. package/dist/4835.index.js +0 -126
  114. package/dist/494.index.js +0 -226
  115. package/dist/4991.index.js +0 -549
  116. package/dist/5199.index.js +0 -305
  117. package/dist/5216.index.js +0 -109
  118. package/dist/5439.index.js +0 -301
  119. package/dist/5443.index.js +0 -97
  120. package/dist/5496.index.js +0 -377
  121. package/dist/5738.index.js +0 -240
  122. package/dist/5801.index.js +0 -588
  123. package/dist/5858.index.js +0 -223
  124. package/dist/5933.index.js +0 -143
  125. package/dist/601.index.js +0 -23
  126. package/dist/6173.index.js +0 -273
  127. package/dist/6291.index.js +0 -1871
  128. package/dist/6312.index.js +0 -666
  129. package/dist/634.index.js +0 -16182
  130. package/dist/644.index.js +0 -87
  131. package/dist/6508.index.js +0 -283
  132. package/dist/6560.index.js +0 -645
  133. package/dist/6576.index.js +0 -422
  134. package/dist/6579.index.js +0 -271
  135. package/dist/6655.index.js +0 -3221
  136. package/dist/6745.index.js +0 -305
  137. package/dist/6818.index.js +0 -21642
  138. package/dist/6932.index.js +0 -1190
  139. package/dist/6972.index.js +0 -167
  140. package/dist/6995.index.js +0 -163
  141. package/dist/7249.index.js +0 -191
  142. package/dist/7372.index.js +0 -1197
  143. package/dist/7546.index.js +0 -541
  144. package/dist/76.index.js +0 -448
  145. package/dist/7879.index.js +0 -163
  146. package/dist/8025.index.js +0 -130
  147. package/dist/8054.index.js +0 -248
  148. package/dist/808.index.js +0 -1330
  149. package/dist/8097.index.js +0 -201
  150. package/dist/8126.index.js +0 -191
  151. package/dist/8178.index.js +0 -78
  152. package/dist/8382.index.js +0 -93
  153. package/dist/8408.index.js +0 -261
  154. package/dist/8540.index.js +0 -174
  155. package/dist/8635.index.js +0 -802
  156. package/dist/8688.index.js +0 -410
  157. package/dist/8699.index.js +0 -779
  158. package/dist/8891.index.js +0 -457
  159. package/dist/8916.index.js +0 -885
  160. package/dist/9146.index.js +0 -170
  161. package/dist/9168.index.js +0 -841
  162. package/dist/9292.index.js +0 -369
  163. package/dist/9422.index.js +0 -866
  164. package/dist/9440.index.js +0 -453
  165. package/dist/9479.index.js +0 -152
  166. package/dist/9481.index.js +0 -220
  167. package/dist/9539.index.js +0 -100
  168. package/dist/9547.index.js +0 -103
  169. package/dist/9692.index.js +0 -90
  170. package/dist/9800.index.js +0 -462
  171. package/dist/9899.index.js +0 -138
  172. package/dist/9981.index.js +0 -206
  173. package/dist/999.index.js +0 -117
  174. package/dist/9999.index.js +0 -1698
@@ -0,0 +1,170 @@
1
+ // Declared option types, read from the module sources that are in scope.
2
+ //
3
+ // A NixOS or Home Manager option is declared with `mkOption { type = ...; }`
4
+ // (or `mkEnableOption`, `mkPackageOption`). The module system checks a value
5
+ // against that type before the module's `config` is used, so the TYPE bounds
6
+ // what a reference to the option can contain, whatever the name suggests:
7
+ //
8
+ // bool, int, ints.*, port, float, number digits, `0`/`1`, a sign or a dot
9
+ // package, shellPackage a store path (`/nix/store/<hash>-<name>`),
10
+ // whose name part is restricted by Nix to
11
+ // [A-Za-z0-9+._?=-]
12
+ // enum [ "a" "b" ] one of the listed literals
13
+ // nullOr T, either A B, uniq T only when every member is itself bounded
14
+ //
15
+ // `str`, `path`, `lines`, `attrs`, `listOf`, `submodule`, an unknown or computed
16
+ // type are all UNBOUNDED here: they can carry any characters, so nothing is
17
+ // inferred from them.
18
+ //
19
+ // Two consumers use the answer in opposite directions, and both are sound for
20
+ // the same reason:
21
+ // * the script-injection analysis treats a reference to a bounded option as
22
+ // a constant, because no value of that type contains a shell metacharacter;
23
+ // * the secret analysis does not treat a reference to a bounded option as a
24
+ // credential, because a bool, a number, a package path or a choice among
25
+ // listed words does not hold a secret, whatever the option is called
26
+ // (`cacheCredentials`, `forwardPass`, `wheelNeedsPassword`).
27
+ //
28
+ // Only options DECLARED in the files handed to the analyzer are known. An
29
+ // option declared elsewhere (upstream NixOS) has no entry, so it keeps the
30
+ // previous behaviour: unknown stays conservative.
31
+
32
+ const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
33
+ const segName = (s) => (s && s.kind === 'static' ? s.name : null);
34
+ const attrKey = (path) => (path || []).map(segName);
35
+
36
+ const BOUNDED_LEAF = new Set(['bool', 'int', 'float', 'number', 'port', 'package', 'shellPackage', 'unsigned', 'positive', 'u8', 'u16', 'u32', 's8', 's16', 's32', 'between']);
37
+ const BOUNDED_NAMESPACES = new Set(['ints', 'numbers']);
38
+ const SAFE_ENUM_MEMBER = /^[A-Za-z0-9_.+-]*$/;
39
+
40
+ function flatten(n) {
41
+ const args = []; let fn = unparen(n);
42
+ while (fn && fn.type === 'app') { args.unshift(fn.arg); fn = unparen(fn.fn); }
43
+ return { fn, args };
44
+ }
45
+
46
+ function calleeName(fn) {
47
+ const f = unparen(fn);
48
+ if (!f) return null;
49
+ if (f.type === 'ident') return f.name;
50
+ if (f.type === 'select') { const segs = f.attrpath.map(segName); return segs[segs.length - 1] || null; }
51
+ return null;
52
+ }
53
+
54
+ function selectSegments(n) {
55
+ const segs = n.attrpath.map(segName);
56
+ return segs.every((s) => s !== null) ? segs : null;
57
+ }
58
+
59
+ /** Classify a `type = ...` expression. Returns { kind, bounded }. */
60
+ export function classifyOptionType(expr, depth = 0) {
61
+ const n = unparen(expr);
62
+ if (!n || depth > 8) return { kind: 'unknown', bounded: false };
63
+ if (n.type === 'ident') return { kind: n.name, bounded: BOUNDED_LEAF.has(n.name) };
64
+ if (n.type === 'select') {
65
+ const segs = selectSegments(n);
66
+ if (!segs) return { kind: 'unknown', bounded: false };
67
+ const last = segs[segs.length - 1];
68
+ if (segs.some((s) => BOUNDED_NAMESPACES.has(s))) return { kind: `${segs.slice(-2).join('.')}`, bounded: true };
69
+ return { kind: last, bounded: BOUNDED_LEAF.has(last) };
70
+ }
71
+ if (n.type === 'app') {
72
+ const { fn, args } = flatten(n);
73
+ const name = calleeName(fn);
74
+ const fnSel = unparen(fn);
75
+ if (fnSel && fnSel.type === 'select') {
76
+ const segs = selectSegments(fnSel);
77
+ if (segs && segs.some((s) => BOUNDED_NAMESPACES.has(s))) return { kind: segs.slice(-2).join('.'), bounded: true };
78
+ }
79
+ if (name === 'nullOr' || name === 'uniq' || name === 'unique') return args[0] ? classifyOptionType(args[0], depth + 1) : { kind: name, bounded: false };
80
+ if (name === 'either') {
81
+ const parts = args.slice(0, 2).map((a) => classifyOptionType(a, depth + 1));
82
+ return { kind: 'either', bounded: parts.length === 2 && parts.every((p) => p.bounded) };
83
+ }
84
+ if (name === 'enum') {
85
+ const list = unparen(args[0]);
86
+ if (list && list.type === 'list') {
87
+ const members = list.items.map((i) => unparen(i));
88
+ const ok = members.length > 0 && members.every((m) => m && m.type === 'string' && !m.interpolated && SAFE_ENUM_MEMBER.test(m.literal || ''));
89
+ return { kind: 'enum', bounded: ok };
90
+ }
91
+ return { kind: 'enum', bounded: false };
92
+ }
93
+ return { kind: name || 'unknown', bounded: false };
94
+ }
95
+ return { kind: 'unknown', bounded: false };
96
+ }
97
+
98
+ function declarationOf(valueNode) {
99
+ const v = unparen(valueNode);
100
+ if (!v || v.type !== 'app') return null;
101
+ const { fn, args } = flatten(v);
102
+ const name = calleeName(fn);
103
+ if (name === 'mkEnableOption') return { kind: 'bool', bounded: true };
104
+ if (name === 'mkPackageOption' || name === 'mkPackageOptionMD') return { kind: 'package', bounded: true };
105
+ if (name === 'mkOption') {
106
+ const a = unparen(args[0]);
107
+ if (!a || a.type !== 'attrset') return { kind: 'unknown', bounded: false };
108
+ for (const b of a.bindings || []) {
109
+ if (b.kind !== 'attr') continue;
110
+ const k = attrKey(b.path);
111
+ if (k.length === 1 && k[0] === 'type') return classifyOptionType(b.value);
112
+ }
113
+ return { kind: 'untyped', bounded: false };
114
+ }
115
+ return null;
116
+ }
117
+
118
+ function record(index, key, decl) {
119
+ const prev = index.get(key);
120
+ // two declarations of one option: bounded only if every declaration is
121
+ index.set(key, prev ? { kind: prev.kind, bounded: prev.bounded && decl.bounded } : decl);
122
+ }
123
+
124
+ function collect(node, prefix, index, depth) {
125
+ if (!node || typeof node !== 'object' || depth > 200) return;
126
+ if (Array.isArray(node)) { for (const x of node) collect(x, prefix, index, depth + 1); return; }
127
+ if (node.type === 'attrset') {
128
+ for (const b of node.bindings || []) {
129
+ if (b.kind !== 'attr' || !b.value) continue;
130
+ const keys = attrKey(b.path);
131
+ if (keys.some((k) => k === null)) { collect(b.value, null, index, depth + 1); continue; }
132
+ let full = null;
133
+ if (prefix !== null) full = [...prefix, ...keys];
134
+ else if (keys[0] === 'options') full = keys.slice(1);
135
+ if (full === null) { collect(b.value, null, index, depth + 1); continue; }
136
+ const decl = full.length ? declarationOf(b.value) : null;
137
+ if (decl) { record(index, full.join('.'), decl); continue; }
138
+ const v = unparen(b.value);
139
+ if (v && v.type === 'attrset') collect(v, full, index, depth + 1);
140
+ else collect(b.value, null, index, depth + 1);
141
+ }
142
+ return;
143
+ }
144
+ for (const [k, c] of Object.entries(node)) {
145
+ if (k === 'span' || k === 'loc') continue;
146
+ if (c && typeof c === 'object') collect(c, prefix === null ? null : null, index, depth + 1);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Index every option declared in the parsed files: dotted path -> { kind, bounded }.
152
+ * `parsed` is an iterable of ASTs.
153
+ */
154
+ export function buildOptionTypeIndex(parsed) {
155
+ const index = new Map();
156
+ for (const ast of parsed) collect(ast, null, index, 0);
157
+ return index;
158
+ }
159
+
160
+ /**
161
+ * Is the option behind an origin detail (`option config.a.b.c`) declared with a
162
+ * bounded type? Returns the declaration, or null when it is unknown or unbounded.
163
+ */
164
+ export function boundedOptionFor(index, detail) {
165
+ if (!index || typeof detail !== 'string') return null;
166
+ const m = /^option (?:config|osConfig)\.(.+)$/.exec(detail);
167
+ if (!m) return null;
168
+ const d = index.get(m[1]);
169
+ return d && d.bounded ? d : null;
170
+ }
@@ -22,6 +22,7 @@
22
22
  import { parseNix, makeLocator } from './nix-parser.js';
23
23
  import { resolveImportPath } from './nix-ir.js';
24
24
  import { isLanguageExcludedPath } from './discovery.js';
25
+ import { buildOptionTypeIndex } from './nix-option-types.js';
25
26
 
26
27
  export const NIX_SCRIPT_VERSION = 'nix-script-taint/1';
27
28
  const PH = '';
@@ -44,7 +45,7 @@ const TRUSTED_PARAMS = new Set(['pkgs', 'lib', 'stdenv', 'self', 'inputs', 'syst
44
45
  const CONFIG_PARAMS = new Set(['config', 'options', 'osConfig']);
45
46
  const RUNTIME_SECRET_DIRS = /^\/(?:run\/(?:secrets|agenix|credentials|keys)|var\/lib\/(?:secrets|credentials)|etc\/secrets)\b/;
46
47
  const SECRET_NAME = /(?:pass(?:word|wd|phrase)?|psk|secret|token|api_?key|private_?key|credential|auth_?key|client_?secret|bearer|cookie|session_?key)/i;
47
- const PATHISH_TAIL = /(?:file|path|dir|directory|package|pkg|enable|port|user|group|name)$/i;
48
+ const PATHISH_TAIL = /(?:file|path|dir|directory|package|pkg|enable|port|user|group|name|scripts?|commands?|cmds?)$/i;
48
49
  const FETCHERS = new Set(['fetchurl', 'fetchTarball', 'fetchGit', 'fetchzip', 'fetchFromGitHub', 'fetchgit', 'fetchTree', 'fetchClosure', 'fetchMercurial', 'fetchpatch']);
49
50
  const FS_CMDS = new Set(['rm', 'rmdir', 'mv', 'cp', 'chmod', 'chown', 'chgrp', 'mkdir', 'ln', 'touch', 'tee', 'dd', 'install', 'truncate', 'shred', 'unlink', 'rsync', 'tar', 'unzip', 'cat', 'sed', 'find', 'mount', 'umount']);
50
51
  const NET_CMDS = new Set(['curl', 'wget', 'nc', 'ncat', 'netcat', 'ssh', 'scp', 'sftp', 'rsync', 'ftp', 'telnet', 'socat', 'git', 'psql', 'mysql', 'redis-cli', 'mongo', 'aws', 'gsutil']);
@@ -58,7 +59,16 @@ const spanOf = (n) => (n && n.span ? n.span : null);
58
59
  // ── files ────────────────────────────────────────────────────────────────────
59
60
  class Workspace {
60
61
  constructor(files) {
61
- this.files = files; this.parsed = new Map();
62
+ this.files = files; this.parsed = new Map(); this._optionTypes = null;
63
+ }
64
+ /** Declared option types across every file in scope (see nix-option-types.js). Built once. */
65
+ optionTypes() {
66
+ if (!this._optionTypes) {
67
+ const asts = [];
68
+ for (const f of Object.keys(this.files)) { const r = this.parse(f); if (r && r.ast) asts.push(r.ast); }
69
+ this._optionTypes = buildOptionTypeIndex(asts);
70
+ }
71
+ return this._optionTypes;
62
72
  }
63
73
  parse(file) {
64
74
  if (this.parsed.has(file)) return this.parsed.get(file);
@@ -227,7 +237,38 @@ class Evaluator {
227
237
  return { origins: [mkOrigin('argument', `function argument "${name}"`, hops)], protection: null };
228
238
  }
229
239
 
230
- eval(expr, env, depth = 0) {
240
+ /**
241
+ * An option reference whose DECLARED type bounds its value (bool, int, port, package, a listed enum) cannot carry
242
+ * a shell metacharacter or a credential, whatever its name suggests, so it is a constant. An option declared out of
243
+ * scope keeps its origin, except that the module-system convention for a package option (`package`, `finalPackage`,
244
+ * `fooPackage`) is a store path.
245
+ */
246
+ refine(r) {
247
+ if (!r || !r.origins.some((o) => o.kind === 'configuration' || o.kind === 'secret')) return r;
248
+ const idx = this.res.ws.optionTypes();
249
+ let changed = false;
250
+ const origins = r.origins.map((o) => {
251
+ if (o.kind !== 'configuration' && o.kind !== 'secret') return o;
252
+ const m = /^(?:option|module parameter) (?:config|osConfig)\.(.+)$/.exec(o.detail);
253
+ if (!m) return o;
254
+ const decl = idx.get(m[1]);
255
+ if (decl) {
256
+ if (!decl.bounded) return o;
257
+ changed = true;
258
+ return mkOrigin('constant', `${o.detail} (declared ${decl.kind})`, o.chain);
259
+ }
260
+ if (/^(?:package|finalPackage|[a-z][A-Za-z0-9]*Package)$/.test(m[1].split('.').pop())) {
261
+ changed = true;
262
+ return mkOrigin('store', `${o.detail} (a package option)`, o.chain);
263
+ }
264
+ return o;
265
+ });
266
+ return changed ? { ...r, origins: dedupe(origins) } : r;
267
+ }
268
+
269
+ eval(expr, env, depth = 0) { return this.refine(this.evalRaw(expr, env, depth)); }
270
+
271
+ evalRaw(expr, env, depth = 0) {
231
272
  const n = unparen(expr);
232
273
  if (!n || depth > MAX_DEPTH) return this.unknown('depth limit');
233
274
  switch (n.type) {
@@ -364,6 +405,8 @@ class Evaluator {
364
405
  const r = rest.length ? this.combine(rest) : this.constant('empty');
365
406
  return { ...r, origins: dedupe([...(sep ? sep.origins : []), ...r.origins]) };
366
407
  }
408
+ // a hex digest, whatever it was computed from
409
+ if (name === 'hashString' || name === 'hashFile') return this.constant(`${name} (hex digest)`);
367
410
  if (name === 'optionalString' || name === 'optional' || name === 'optionals') return args[1] ? this.eval(args[1], env, depth + 1) : this.constant('empty');
368
411
  if (name === 'getExe' || name === 'getBin' || name === 'getLib' || name === 'getDev' || name === 'getExe\'') return { origins: [mkOrigin('store', `${name} (store path)`)], protection: null };
369
412
  if (name === 'writeText' || name === 'writeTextFile' || name === 'writeShellScript' || name === 'writeShellScriptBin' || name === 'writeScript' || name === 'writeScriptBin' || name === 'runCommand' || name === 'mkDerivation') return { origins: [mkOrigin('store', `${name} output (store path)`)], protection: null };
@@ -217,7 +217,8 @@ class Walker {
217
217
  }
218
218
  case 'app': {
219
219
  if (!asFn) this.inspectApp(file, node, env, path);
220
- this.visit(file, node.fn, env, path, depth + 1, true); this.visit(file, node.arg, env, path, depth + 1, false); return;
220
+ this.visit(file, node.fn, env, path, depth + 1, true);
221
+ this.visit(file, this.withoutOptionDocs(node), env, path, depth + 1, false); return;
221
222
  }
222
223
  case 'string': {
223
224
  if (!node.interpolated) { for (const [label, re, sev] of TOKEN_FORMATS) if (re.test(node.literal || '')) { /* reported through its binding when it has one; bare strings: */ if (!this.stringSeen(file, node)) this.emit('nix-secret-plaintext', { ...this.at(file, node.span), file, severity: sev, exposure: 'plaintext-source', description: `A ${label} is written as a literal (value redacted).`, source: { kind: 'literal', file, line: node.span.startLine }, destination: { kind: 'source', label: 'Nix source' }, evidence: { ...redact(node.literal), format: label }, originalLocation: node.span, chain: [hop(file, node.span, `literal ${label}`)].filter(Boolean), origins: [mkOrigin('secret', `literal ${label}`)] }); } }
@@ -229,10 +230,27 @@ class Walker {
229
230
  if (node.items) for (const it of node.items) this.visit(file, it, env, path, depth + 1, false);
230
231
  }
231
232
  }
233
+ /**
234
+ * The argument of `mkOption { ... }` without its documentation attributes. `example`, `description` and
235
+ * `defaultText` are rendered into the manual, never into the configuration, so a credential-shaped literal
236
+ * there is an illustration. `default` is NOT documentation: it is the value the option takes.
237
+ */
238
+ withoutOptionDocs(appNode) {
239
+ const arg = appNode.arg;
240
+ const { fn } = this.ev.flatten(appNode);
241
+ const name = this.ev.calleeOf(fn).name;
242
+ const a = unparen(arg);
243
+ if (name !== 'mkOption' || !a || a.type !== 'attrset') return arg;
244
+ const DOC = new Set(['example', 'description', 'defaultText', 'longDescription', 'relatedPackages']);
245
+ return { ...a, bindings: a.bindings.filter((b) => !(b.kind === 'attr' && DOC.has(attrKey(b.path)[0]))) };
246
+ }
232
247
  stringSeen(file, node) { const k = `${file}:${node.start}`; if (this.scriptKeys.has(k)) return true; this.scriptKeys.add(k); return false; }
233
248
 
234
249
  inspectBinding(file, full, value, bspan, env) {
235
- const v = unparen(value);
250
+ let v = unparen(value);
251
+ // `x = let ... in { ... }` / `with ...; { ... }` is an attribute SET, not a scalar: a credential name on the
252
+ // binding (a service called pass-secret-service) says nothing about a set's members, which are visited on their own
253
+ for (let guard = 0; v && (v.type === 'let' || v.type === 'with' || v.type === 'assert') && guard < 20; guard++) v = unparen(v.body);
236
254
  const last = full[full.length - 1];
237
255
  const dest = destOf(full);
238
256
  const sname = secretName(last);
@@ -1274,7 +1274,8 @@ restricts characters); the projection refuses them, but the graph contract itsel
1274
1274
  | Module | Responsibility |
1275
1275
  |---|---|
1276
1276
  | `path-predicates.js` | Branch conditions for a witness. A node is guarded by (branch, polarity) only when that arm dominates it and has the branch as its only predecessor, so a node after a join is guarded by neither arm. Conditions are kept as bounded DNF (`a || b` is two distinct alternatives), variables are keyed by (call frame, name, reaching-definition versions), and a callee parameter is tied to the caller argument only through an explicit call binding. Both arms of one branch on one witness are reported (`mutually-exclusive-branches`), never conjoined; inside a loop with a per-iteration definition the answer is `unknown`. Evaluation reuses `numeric-domain.js` intervals through `symbolic-exec.js` `assume`, plus equality on constants and boolean atoms. Results: `feasible-model`, `infeasible-under-model`, `unknown`, `unsupported` (reflective dispatch anywhere in a frame's function, or no CFG), `timeout`; each with semantics version, reasons, limitations. Satisfiability never implies exploitability, and `infeasible-under-model` is relative to the stated limits. |
1277
- | `smt-orchestrator.js` | The optional solver path. `checkWithSolver` is awaited, deadline-bounded, cancellable (caller `AbortSignal` plus its own deadline abort the solver's signal) and never throws; a late solver settlement is swallowed. `noSolver` is the default stub (`unsupported`). A solver is consulted only when the abstract answer is `unknown`, and the abstract answer stays on the result. No solver or dependency ships. |
1277
+ | `smt-orchestrator.js` | The optional solver path. `checkWithSolver` is awaited, deadline-bounded, cancellable (caller `AbortSignal` plus its own deadline abort the solver's signal) and never throws; a late solver settlement is swallowed. `noSolver` is the default stub (`unsupported`). A solver is consulted when the abstract answer is `unknown`, or `feasible-model` with `consultOnFeasible` (default: on only when `AGENTIC_SECURITY_LINEAGE_SMT=z3`); the abstract answer stays on the result, and on a feasible answer only a solver `unsat` changes it. `resolveSolver` picks an explicit solver, else the z3 backend when the env switch is `z3`, else `noSolver`. An `unsat` over branch arms in a loop is not trusted. |
1278
+ | `smt-z3-backend.js` | The real backend on the optional `z3-solver`. Lazy dynamic import (variable specifier, build flag `-e z3-solver`), so a default scan never loads it; a missing or failing package, or a context that ignores its timeout, is `unsupported`, never a crash and never `unsat`. `encodeProblem` is the verified encoding (Real sort, `v0..vN` renaming, constants asserted, opaque predicate = no constraint); an inexpressible problem is `unknown` with an `encoding-unverified:*` reason, and a `sat` that used an opaque predicate is `unknown`. Per-query timeout, Z3 memory cap, fixed seed, one query at a time, definitive answers cached by SMT-LIB text. Reached only from the async path-evidence entry points; the synchronous graph build never calls it. Suite: `test/lineage/smt-z3-backend.test.js` (real-solver tests skip loudly where z3 is absent). |
1278
1279
  | `control-credit.js` | A control earns credit on a witness only for the matching sink family (catalog `appliesTo` vs the sink CWE family), on the value version the sink reads (reassignment or a merge with another definition refuses), and when nothing later on the witness reverses it (`_reversedOnPath`). A parameterized SQL call earns SQL credit only for values bound after a literal placeholder query text. A bare callee name shared by entries that disagree on families is ambiguous and not credited. An unrecognized call that widened the value is opaque: `unknown`. Credits say `semanticsProof: false`: dominance and catalog family are evidence, not proof of sanitizer semantics. `assessAllPathsProtectedWithControls` feeds real control states into `witness-selection.js` `assessAllPathsProtected`, lists `infeasible-under-model` paths it excluded, keeps `unknown`/`timeout` paths relevant, and accepts an `abstractControlProof` (dominating, single-reaching-definition control, intraprocedural) only when no enumerated path refutes. |
1279
1280
  | `path-evidence.js` | Assembles per-path evidence, summarizes a flow group (alternatives stay distinct, aggregate control state is the worst over relevant paths) and `buildControlEvidence`, which fills the witness bundle `controlEvidence` slot (`feasible`/`infeasible`/`unknown` plus the precise result in `detail`). |
1280
1281
 
@@ -10,12 +10,14 @@
10
10
  // * honest: no solver reports `unsupported`, a deadline reports `timeout`, a malformed or failing solver
11
11
  // reports `unknown`. None of these ever drops or downgrades the finding. The abstract result is kept.
12
12
  //
13
- // No solver ships here and no dependency is added. `noSolver` is the default stub. A real solver is an
13
+ // `noSolver` is the default stub. The real backend is smt-z3-backend.js (optional `z3-solver`, lazy, used only
14
+ // when a solver is passed in or AGENTIC_SECURITY_LINEAGE_SMT=z3 is set). A solver is an
14
15
  // object with `{ name, version?, available?, check(problem, { signal }) -> Promise<{ result }> }` where
15
16
  // `result` is 'sat' | 'unsat' | 'unknown'. `problem` is the solver-neutral view from `toSolverProblem`.
16
17
  // Satisfiability never implies exploitability.
17
18
 
18
19
  import { evaluateWitnessConditions, toSolverProblem, PATH_PREDICATE_VERSION, SATISFIABILITY_NOTE } from './path-predicates.js';
20
+ import { sharedZ3Solver, smtBackendEnabled } from './smt-z3-backend.js';
19
21
 
20
22
  export const SOLVER_DEFAULT_TIMEOUT_MS = 1000;
21
23
  export const SOLVER_TIMEOUT_MAX_MS = 60_000;
@@ -26,7 +28,17 @@ export const noSolver = Object.freeze({
26
28
  async check() { return { result: 'unsupported' }; },
27
29
  });
28
30
 
29
- const MAP = Object.freeze({ sat: 'feasible-model', unsat: 'infeasible-under-model', unknown: 'unknown' });
31
+ const MAP = Object.freeze({ sat: 'feasible-model', unsat: 'infeasible-under-model', unknown: 'unknown', timeout: 'timeout' });
32
+
33
+ /**
34
+ * The solver a call uses. An explicit `solver` always wins. Otherwise the real z3 backend is used only when
35
+ * AGENTIC_SECURITY_LINEAGE_SMT=z3 is set (it loads lazily and reports `unsupported` if the optional package
36
+ * is missing); with nothing set the default is `noSolver`, so a default run never loads a solver.
37
+ */
38
+ export function resolveSolver(solver, env = process.env) {
39
+ if (solver !== undefined) return solver;
40
+ return smtBackendEnabled(env) ? sharedZ3Solver() : noSolver;
41
+ }
30
42
 
31
43
  /** Render the problem as SMT-LIB 2 text for a solver adapter that wants text. Opaque predicates become comments. */
32
44
  export function toSmtLib(problem) {
@@ -65,7 +77,8 @@ function boundedTimeout(ms) {
65
77
  *
66
78
  * @returns {Promise<{status:string, reasons:string[], solver:object, elapsedMs:number, semanticsVersion:string, limitations:string[]}>}
67
79
  */
68
- export async function checkWithSolver(collected, { solver = noSolver, timeoutMs, signal, now = Date.now } = {}) {
80
+ export async function checkWithSolver(collected, { solver: solverOpt, timeoutMs, signal, now = Date.now } = {}) {
81
+ const solver = resolveSolver(solverOpt);
69
82
  const started = now();
70
83
  const info = { name: solver?.name ?? 'unknown', version: solver?.version ?? null };
71
84
  const out = (status, reasons, extra = {}) => ({
@@ -101,9 +114,10 @@ export async function checkWithSolver(collected, { solver = noSolver, timeoutMs,
101
114
  if (r && r.__timeout) { ac.abort(); return out('timeout', ['solver-deadline']); }
102
115
  if (r && r.__cancelled) { ac.abort(); return out('unknown', ['cancelled']); }
103
116
  const status = isObjectWithResult(r) ? MAP[r.result] : undefined;
104
- if (r?.result === 'unsupported') return out('unsupported', ['solver-declined']);
117
+ if (r?.result === 'unsupported') return out('unsupported', ['solver-declined', ...(typeof r.reason === 'string' ? [r.reason] : [])]);
105
118
  if (!status) return out('unknown', ['solver-malformed']);
106
- return out(status, [`solver-${r.result}`], r.result === 'sat' && r.model && typeof r.model === 'object' ? { model: r.model } : {});
119
+ const why = typeof r.reason === 'string' ? [r.reason] : [];
120
+ return out(status, [`solver-${r.result}`, ...why], r.result === 'sat' && r.model && typeof r.model === 'object' ? { model: r.model } : {});
107
121
  } catch {
108
122
  return out('unknown', ['solver-error']);
109
123
  } finally {
@@ -121,18 +135,40 @@ function isObjectWithResult(r) { return typeof r === 'object' && r !== null && t
121
135
  */
122
136
  export async function evaluateWitnessConditionsAsync(collected, o = {}) {
123
137
  const abstract = evaluateWitnessConditions(collected, o);
124
- if (abstract.status !== 'unknown') return { ...abstract, abstractStatus: abstract.status, solverConsulted: false };
138
+ const consultFeasible = o.consultOnFeasible ?? smtBackendEnabled();
139
+ const refine = abstract.status === 'feasible-model' && consultFeasible && abstract.clauseRefs.length > 0;
140
+ if (abstract.status !== 'unknown' && !refine) return { ...abstract, abstractStatus: abstract.status, solverConsulted: false };
125
141
  const s = await checkWithSolver(collected, o);
126
- if (s.status === 'unsupported') return { ...abstract, abstractStatus: abstract.status, solverConsulted: false, solver: s.solver, solverStatus: 'unsupported' };
142
+ const kept = { ...abstract, abstractStatus: abstract.status };
143
+ // Branch arms of one branch inside a loop may belong to different iterations. The solver's problem carries no
144
+ // iteration structure, so its `unsat` there is not evidence of infeasibility: the answer stays `unknown`.
145
+ const loopArms = abstract.reasons.includes('branch-arms-in-loop');
146
+ if (refine) {
147
+ // On a feasible-model answer ONLY a solver unsat changes anything; every other outcome keeps the abstract result.
148
+ if (s.status === 'infeasible-under-model' && !loopArms) return unsatResult(abstract, s);
149
+ return { ...kept, solverConsulted: s.status !== 'unsupported', solver: s.solver, solverStatus: s.status, elapsedMs: s.elapsedMs };
150
+ }
151
+ if (s.status === 'unsupported') return { ...kept, solverConsulted: false, solver: s.solver, solverStatus: 'unsupported' };
127
152
  if (s.status === 'timeout') {
128
- return { ...abstract, status: 'timeout', reasons: [...abstract.reasons, 'solver-deadline'], abstractStatus: abstract.status, solverConsulted: true, solver: s.solver, solverStatus: 'timeout', elapsedMs: s.elapsedMs };
153
+ return { ...kept, status: 'timeout', reasons: [...abstract.reasons, 'solver-deadline'], solverConsulted: true, solver: s.solver, solverStatus: 'timeout', elapsedMs: s.elapsedMs };
154
+ }
155
+ if (s.status === 'infeasible-under-model' && loopArms) {
156
+ return { ...kept, solverConsulted: true, solver: s.solver, solverStatus: 'unknown', reasons: [...abstract.reasons, 'solver-unsat-not-trusted-in-loop'], elapsedMs: s.elapsedMs };
129
157
  }
130
158
  if (s.status === 'unknown') {
131
- return { ...abstract, abstractStatus: abstract.status, solverConsulted: true, solver: s.solver, solverStatus: 'unknown', reasons: [...abstract.reasons, ...s.reasons], elapsedMs: s.elapsedMs };
159
+ return { ...kept, solverConsulted: true, solver: s.solver, solverStatus: 'unknown', reasons: [...abstract.reasons, ...s.reasons], elapsedMs: s.elapsedMs };
132
160
  }
161
+ if (s.status === 'infeasible-under-model') return unsatResult(abstract, s);
162
+ return {
163
+ ...kept, status: s.status, reasons: s.reasons, solverConsulted: true, solver: s.solver, solverStatus: s.status,
164
+ limitations: [...abstract.limitations, ...s.limitations], elapsedMs: s.elapsedMs, contradiction: null,
165
+ };
166
+ }
167
+
168
+ function unsatResult(abstract, s) {
133
169
  return {
134
- ...abstract, status: s.status, reasons: s.reasons, abstractStatus: abstract.status, solverConsulted: true, solver: s.solver, solverStatus: s.status,
170
+ ...abstract, status: 'infeasible-under-model', reasons: s.reasons, abstractStatus: abstract.status, solverConsulted: true, solver: s.solver, solverStatus: s.status,
135
171
  limitations: [...abstract.limitations, ...s.limitations], elapsedMs: s.elapsedMs,
136
- contradiction: s.status === 'infeasible-under-model' ? { kind: 'solver-unsat', refs: abstract.clauseRefs } : null,
172
+ contradiction: { kind: 'solver-unsat', refs: abstract.clauseRefs },
137
173
  };
138
174
  }