@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.
- package/CHANGELOG.md +34 -0
- package/bin/agentic-security.js +66 -18
- package/dist/1128.index.js +25 -21
- package/dist/{5817.index.js → 1602.index.js} +378 -29
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/{0b301d45b231f144a1b4.json → 4d06c8944ae121d7085c.json} +8 -8
- package/dist/5752.index.js +466 -0
- package/dist/7405.index.js +1 -1
- package/dist/8158.index.js +1713 -0
- package/dist/8798.index.js +1 -1
- package/dist/9382.index.js +2 -2
- package/dist/9560.index.js +1 -1
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -8
- package/src/dataflow/CLAUDE.md +6 -3
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/demand-query.js +279 -40
- package/src/dataflow/dep-fingerprint.js +1 -1
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/engine.js +62 -5
- package/src/ir/CLAUDE.md +4 -0
- package/src/ir/callgraph.js +36 -8
- package/src/ir/index.js +18 -4
- package/src/ir/parser-js.js +10 -0
- package/src/language/CLAUDE.md +13 -0
- package/src/language/nix-option-types.js +170 -0
- package/src/language/nix-script-taint.js +46 -3
- package/src/language/nix-secrets.js +20 -2
- package/src/lineage/CLAUDE.md +2 -1
- package/src/lineage/smt-orchestrator.js +47 -11
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/posture/CLAUDE.md +5 -1
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/proof-fabric/cli.js +1 -1
- package/src/posture/proof-fabric/container.js +2 -2
- package/src/posture/proof-fabric/generate-haskell.js +205 -0
- package/src/posture/proof-fabric/generate.js +6 -3
- package/src/posture/proof-fabric/reproducer.js +4 -2
- package/src/posture/proof-fabric/runner.js +4 -4
- package/src/sast/CLAUDE.md +2 -0
- package/src/sast/_call-args.js +70 -0
- package/src/sast/init-scope.js +87 -0
- package/src/sast/listen-bind-address.js +49 -0
- package/src/sast/redirect-target.js +106 -0
- package/src/sast/reflected-input.js +76 -0
- package/src/sast/send-file-root.js +27 -0
- package/src/sast/sensitive-data-logging.js +38 -1
- package/dist/119.index.js +0 -197
- package/dist/128.index.js +0 -129
- package/dist/1293.index.js +0 -5689
- package/dist/1336.index.js +0 -16331
- package/dist/1351.index.js +0 -133
- package/dist/1387.index.js +0 -271
- package/dist/1437.index.js +0 -23
- package/dist/1445.index.js +0 -110
- package/dist/1505.index.js +0 -151
- package/dist/1557.index.js +0 -293
- package/dist/1621.index.js +0 -311
- package/dist/1751.index.js +0 -160
- package/dist/1765.index.js +0 -727
- package/dist/1861.index.js +0 -690
- package/dist/1906.index.js +0 -433
- package/dist/208.index.js +0 -1631
- package/dist/2223.index.js +0 -159
- package/dist/2238.index.js +0 -295
- package/dist/2530.index.js +0 -152
- package/dist/2565.index.js +0 -116
- package/dist/2574.index.js +0 -127
- package/dist/2620.index.js +0 -345
- package/dist/2773.index.js +0 -98
- package/dist/2775.index.js +0 -32
- package/dist/2966.index.js +0 -171
- package/dist/2972.index.js +0 -87
- package/dist/2991.index.js +0 -131
- package/dist/3063.index.js +0 -833
- package/dist/3075.index.js +0 -227
- package/dist/3121.index.js +0 -297
- package/dist/3314.index.js +0 -263
- package/dist/3480.index.js +0 -1822
- package/dist/3481.index.js +0 -24
- package/dist/3506.index.js +0 -268
- package/dist/3533.index.js +0 -591
- package/dist/3653.index.js +0 -450
- package/dist/3740.index.js +0 -722
- package/dist/3822.index.js +0 -131
- package/dist/3868.index.js +0 -292
- package/dist/3918.index.js +0 -192
- package/dist/4090.index.js +0 -288
- package/dist/4113.index.js +0 -1286
- package/dist/4121.index.js +0 -1190
- package/dist/4142.index.js +0 -690
- package/dist/4168.index.js +0 -509
- package/dist/4281.index.js +0 -993
- package/dist/4336.index.js +0 -436
- package/dist/4387.index.js +0 -532
- package/dist/4413.index.js +0 -302
- package/dist/4476.index.js +0 -4510
- package/dist/4491.index.js +0 -133
- package/dist/4538.index.js +0 -185
- package/dist/4684.index.js +0 -319
- package/dist/4740.index.js +0 -165
- package/dist/4780.index.js +0 -209
- package/dist/4802.index.js +0 -225
- package/dist/4812.index.js +0 -379
- package/dist/4835.index.js +0 -126
- package/dist/494.index.js +0 -226
- package/dist/4991.index.js +0 -549
- package/dist/5199.index.js +0 -305
- package/dist/5216.index.js +0 -109
- package/dist/5439.index.js +0 -301
- package/dist/5443.index.js +0 -97
- package/dist/5496.index.js +0 -377
- package/dist/5738.index.js +0 -240
- package/dist/5801.index.js +0 -588
- package/dist/5858.index.js +0 -223
- package/dist/5933.index.js +0 -143
- package/dist/601.index.js +0 -23
- package/dist/6173.index.js +0 -273
- package/dist/6291.index.js +0 -1871
- package/dist/6312.index.js +0 -666
- package/dist/634.index.js +0 -16182
- package/dist/644.index.js +0 -87
- package/dist/6508.index.js +0 -283
- package/dist/6560.index.js +0 -645
- package/dist/6576.index.js +0 -422
- package/dist/6579.index.js +0 -271
- package/dist/6655.index.js +0 -3221
- package/dist/6745.index.js +0 -305
- package/dist/6818.index.js +0 -21642
- package/dist/6932.index.js +0 -1190
- package/dist/6972.index.js +0 -167
- package/dist/6995.index.js +0 -163
- package/dist/7249.index.js +0 -191
- package/dist/7372.index.js +0 -1197
- package/dist/7546.index.js +0 -541
- package/dist/76.index.js +0 -448
- package/dist/7879.index.js +0 -163
- package/dist/8025.index.js +0 -130
- package/dist/8054.index.js +0 -248
- package/dist/808.index.js +0 -1330
- package/dist/8097.index.js +0 -201
- package/dist/8126.index.js +0 -191
- package/dist/8178.index.js +0 -78
- package/dist/8382.index.js +0 -93
- package/dist/8408.index.js +0 -261
- package/dist/8540.index.js +0 -174
- package/dist/8635.index.js +0 -802
- package/dist/8688.index.js +0 -410
- package/dist/8699.index.js +0 -779
- package/dist/8891.index.js +0 -457
- package/dist/8916.index.js +0 -885
- package/dist/9146.index.js +0 -170
- package/dist/9168.index.js +0 -841
- package/dist/9292.index.js +0 -369
- package/dist/9422.index.js +0 -866
- package/dist/9440.index.js +0 -453
- package/dist/9479.index.js +0 -152
- package/dist/9481.index.js +0 -220
- package/dist/9539.index.js +0 -100
- package/dist/9547.index.js +0 -103
- package/dist/9692.index.js +0 -90
- package/dist/9800.index.js +0 -462
- package/dist/9899.index.js +0 -138
- package/dist/9981.index.js +0 -206
- package/dist/999.index.js +0 -117
- 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
|
-
|
|
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);
|
|
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
|
-
|
|
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);
|
package/src/lineage/CLAUDE.md
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 { ...
|
|
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 { ...
|
|
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:
|
|
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:
|
|
172
|
+
contradiction: { kind: 'solver-unsat', refs: abstract.clauseRefs },
|
|
137
173
|
};
|
|
138
174
|
}
|