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

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 (167) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/agentic-security.js +24 -1
  3. package/dist/1128.index.js +25 -21
  4. package/dist/2584.index.js +1 -1
  5. package/dist/4171.index.js +2 -2
  6. package/dist/5752.index.js +466 -0
  7. package/dist/7405.index.js +1 -1
  8. package/dist/8158.index.js +1713 -0
  9. package/dist/8798.index.js +1 -1
  10. package/dist/9382.index.js +2 -2
  11. package/dist/9579.index.js +1 -1
  12. package/dist/agentic-security.mjs +29 -29
  13. package/dist/agentic-security.mjs.sha256 +1 -1
  14. package/dist/{0b301d45b231f144a1b4.json → ca62ee93079be4c24eee.json} +7 -7
  15. package/package.json +7 -7
  16. package/src/dataflow/CLAUDE.md +6 -3
  17. package/src/dataflow/access-paths.js +12 -2
  18. package/src/dataflow/demand-query.js +279 -40
  19. package/src/dataflow/dep-fingerprint.js +1 -1
  20. package/src/dataflow/exploit-prover.js +6 -2
  21. package/src/dataflow/ir-cache.js +268 -0
  22. package/src/dataflow/privacy-taint.js +7 -0
  23. package/src/engine.js +62 -5
  24. package/src/ir/CLAUDE.md +4 -0
  25. package/src/ir/callgraph.js +36 -8
  26. package/src/ir/index.js +18 -4
  27. package/src/ir/parser-js.js +10 -0
  28. package/src/language/CLAUDE.md +13 -0
  29. package/src/language/nix-option-types.js +170 -0
  30. package/src/language/nix-script-taint.js +46 -3
  31. package/src/language/nix-secrets.js +20 -2
  32. package/src/lineage/CLAUDE.md +2 -1
  33. package/src/lineage/smt-orchestrator.js +47 -11
  34. package/src/lineage/smt-z3-backend.js +294 -0
  35. package/src/posture/CLAUDE.md +5 -1
  36. package/src/posture/business-logic.js +16 -7
  37. package/src/posture/dag-commands.js +442 -0
  38. package/src/posture/finding-defaults.js +19 -0
  39. package/src/posture/git-history.js +91 -1
  40. package/src/sast/CLAUDE.md +2 -0
  41. package/src/sast/_call-args.js +70 -0
  42. package/src/sast/init-scope.js +87 -0
  43. package/src/sast/listen-bind-address.js +49 -0
  44. package/src/sast/redirect-target.js +106 -0
  45. package/src/sast/reflected-input.js +76 -0
  46. package/src/sast/send-file-root.js +27 -0
  47. package/src/sast/sensitive-data-logging.js +38 -1
  48. package/dist/119.index.js +0 -197
  49. package/dist/128.index.js +0 -129
  50. package/dist/1293.index.js +0 -5689
  51. package/dist/1336.index.js +0 -16331
  52. package/dist/1351.index.js +0 -133
  53. package/dist/1387.index.js +0 -271
  54. package/dist/1437.index.js +0 -23
  55. package/dist/1445.index.js +0 -110
  56. package/dist/1505.index.js +0 -151
  57. package/dist/1557.index.js +0 -293
  58. package/dist/1621.index.js +0 -311
  59. package/dist/1751.index.js +0 -160
  60. package/dist/1765.index.js +0 -727
  61. package/dist/1861.index.js +0 -690
  62. package/dist/1906.index.js +0 -433
  63. package/dist/208.index.js +0 -1631
  64. package/dist/2223.index.js +0 -159
  65. package/dist/2238.index.js +0 -295
  66. package/dist/2530.index.js +0 -152
  67. package/dist/2565.index.js +0 -116
  68. package/dist/2574.index.js +0 -127
  69. package/dist/2620.index.js +0 -345
  70. package/dist/2773.index.js +0 -98
  71. package/dist/2775.index.js +0 -32
  72. package/dist/2966.index.js +0 -171
  73. package/dist/2972.index.js +0 -87
  74. package/dist/2991.index.js +0 -131
  75. package/dist/3063.index.js +0 -833
  76. package/dist/3075.index.js +0 -227
  77. package/dist/3121.index.js +0 -297
  78. package/dist/3314.index.js +0 -263
  79. package/dist/3480.index.js +0 -1822
  80. package/dist/3481.index.js +0 -24
  81. package/dist/3506.index.js +0 -268
  82. package/dist/3533.index.js +0 -591
  83. package/dist/3653.index.js +0 -450
  84. package/dist/3740.index.js +0 -722
  85. package/dist/3822.index.js +0 -131
  86. package/dist/3868.index.js +0 -292
  87. package/dist/3918.index.js +0 -192
  88. package/dist/4090.index.js +0 -288
  89. package/dist/4113.index.js +0 -1286
  90. package/dist/4121.index.js +0 -1190
  91. package/dist/4142.index.js +0 -690
  92. package/dist/4168.index.js +0 -509
  93. package/dist/4281.index.js +0 -993
  94. package/dist/4336.index.js +0 -436
  95. package/dist/4387.index.js +0 -532
  96. package/dist/4413.index.js +0 -302
  97. package/dist/4476.index.js +0 -4510
  98. package/dist/4491.index.js +0 -133
  99. package/dist/4538.index.js +0 -185
  100. package/dist/4684.index.js +0 -319
  101. package/dist/4740.index.js +0 -165
  102. package/dist/4780.index.js +0 -209
  103. package/dist/4802.index.js +0 -225
  104. package/dist/4812.index.js +0 -379
  105. package/dist/4835.index.js +0 -126
  106. package/dist/494.index.js +0 -226
  107. package/dist/4991.index.js +0 -549
  108. package/dist/5199.index.js +0 -305
  109. package/dist/5216.index.js +0 -109
  110. package/dist/5439.index.js +0 -301
  111. package/dist/5443.index.js +0 -97
  112. package/dist/5496.index.js +0 -377
  113. package/dist/5738.index.js +0 -240
  114. package/dist/5801.index.js +0 -588
  115. package/dist/5817.index.js +0 -714
  116. package/dist/5858.index.js +0 -223
  117. package/dist/5933.index.js +0 -143
  118. package/dist/601.index.js +0 -23
  119. package/dist/6173.index.js +0 -273
  120. package/dist/6291.index.js +0 -1871
  121. package/dist/6312.index.js +0 -666
  122. package/dist/634.index.js +0 -16182
  123. package/dist/644.index.js +0 -87
  124. package/dist/6508.index.js +0 -283
  125. package/dist/6560.index.js +0 -645
  126. package/dist/6576.index.js +0 -422
  127. package/dist/6579.index.js +0 -271
  128. package/dist/6655.index.js +0 -3221
  129. package/dist/6745.index.js +0 -305
  130. package/dist/6818.index.js +0 -21642
  131. package/dist/6932.index.js +0 -1190
  132. package/dist/6972.index.js +0 -167
  133. package/dist/6995.index.js +0 -163
  134. package/dist/7249.index.js +0 -191
  135. package/dist/7372.index.js +0 -1197
  136. package/dist/7546.index.js +0 -541
  137. package/dist/76.index.js +0 -448
  138. package/dist/7879.index.js +0 -163
  139. package/dist/8025.index.js +0 -130
  140. package/dist/8054.index.js +0 -248
  141. package/dist/808.index.js +0 -1330
  142. package/dist/8097.index.js +0 -201
  143. package/dist/8126.index.js +0 -191
  144. package/dist/8178.index.js +0 -78
  145. package/dist/8382.index.js +0 -93
  146. package/dist/8408.index.js +0 -261
  147. package/dist/8540.index.js +0 -174
  148. package/dist/8635.index.js +0 -802
  149. package/dist/8688.index.js +0 -410
  150. package/dist/8699.index.js +0 -779
  151. package/dist/8891.index.js +0 -457
  152. package/dist/8916.index.js +0 -885
  153. package/dist/9146.index.js +0 -170
  154. package/dist/9168.index.js +0 -841
  155. package/dist/9292.index.js +0 -369
  156. package/dist/9422.index.js +0 -866
  157. package/dist/9440.index.js +0 -453
  158. package/dist/9479.index.js +0 -152
  159. package/dist/9481.index.js +0 -220
  160. package/dist/9539.index.js +0 -100
  161. package/dist/9547.index.js +0 -103
  162. package/dist/9692.index.js +0 -90
  163. package/dist/9800.index.js +0 -462
  164. package/dist/9899.index.js +0 -138
  165. package/dist/9981.index.js +0 -206
  166. package/dist/999.index.js +0 -117
  167. 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
  }