@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/bin/agentic-security.js +131 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/1919.index.js +445 -0
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1192 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +20 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +174 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +289 -0
- package/src/language/trusted-inputs.js +38 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// A lineage VIEW of the Haskell call graph (X-004).
|
|
2
|
+
//
|
|
3
|
+
// The field-identity engine behind the Data Flow Explorer seeds identities at a function's ENTRY from
|
|
4
|
+
// matched member reads (`req.body.email`), and enumerates privacy sinks only at call-STATEMENT sites. The
|
|
5
|
+
// Haskell IR is JS-shaped but differs in exactly the ways that matter to that machinery:
|
|
6
|
+
//
|
|
7
|
+
// * a source is a monadic call bound to a variable (`s <- jsonData`), and its fields are read later
|
|
8
|
+
// (`email s`), so there is no member read to seed;
|
|
9
|
+
// * a sink is wrapped (`liftIO (execute conn q row)`) or bound to a discarded variable, so it is never a
|
|
10
|
+
// statement-level call;
|
|
11
|
+
// * a request body setter was lowered to an assignment (to keep the URL clean of body taint).
|
|
12
|
+
//
|
|
13
|
+
// This module rewrites a CLONE of each Haskell function so the same machinery sees what the code means,
|
|
14
|
+
// and nothing else: sources become member reads on a synthetic root (`$hs.<Module.name>[.<field>]`) with
|
|
15
|
+
// one property per FIELD the program actually reads, lifts are unwrapped, and body-setter assignments become
|
|
16
|
+
// call statements. The original call graph (shared with the taint engine) is never mutated.
|
|
17
|
+
//
|
|
18
|
+
// What it does not do is also part of the contract: a field the program never reads on a source value is not
|
|
19
|
+
// invented, and a source whose fields cannot be enumerated (it is only passed whole to code outside the
|
|
20
|
+
// project) is seeded at container level and disclosed as such, never as a set of made-up fields.
|
|
21
|
+
|
|
22
|
+
import { matchSource } from '../dataflow/catalog.js';
|
|
23
|
+
|
|
24
|
+
export const HS_LINEAGE_ROOT = '$hs';
|
|
25
|
+
// the label of a record parameter handed to an exported function (see language/haskell-ir.js `recordParams`)
|
|
26
|
+
export const HS_PARAM_SOURCE = 'hs:record-parameter';
|
|
27
|
+
const isHsFile = (f) => typeof f === 'string' && /\.l?hs$/i.test(f);
|
|
28
|
+
const LIFT = /(?:^|\.)(?:liftIO|lift)$/;
|
|
29
|
+
const MAX_DEPTH = 3;
|
|
30
|
+
|
|
31
|
+
const clone = (o) => { try { return structuredClone(o); } catch { return JSON.parse(JSON.stringify(o)); } };
|
|
32
|
+
|
|
33
|
+
function walk(e, visit, depth = 0) {
|
|
34
|
+
if (!e || typeof e !== 'object' || depth > 60) return;
|
|
35
|
+
visit(e);
|
|
36
|
+
switch (e.kind) {
|
|
37
|
+
case 'member': walk(e.object, visit, depth + 1); break;
|
|
38
|
+
case 'call': walk(e.callee, visit, depth + 1); for (const a of e.args || []) walk(a, visit, depth + 1); break;
|
|
39
|
+
case 'binary': case 'logical': walk(e.left, visit, depth + 1); walk(e.right, visit, depth + 1); break;
|
|
40
|
+
case 'tpl': for (const p of e.parts || []) walk(p, visit, depth + 1); break;
|
|
41
|
+
case 'union': for (const p of e.options || []) walk(p, visit, depth + 1); break;
|
|
42
|
+
case 'array': for (const p of e.elements || []) walk(p, visit, depth + 1); break;
|
|
43
|
+
case 'object': for (const p of e.props || []) walk(p && p.value, visit, depth + 1); break;
|
|
44
|
+
default: break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const nodesOf = (fn) => Object.values(fn.cfg.nodes);
|
|
48
|
+
const roots = (n) => { const r = []; if (n.kind === 'assign' && n.source) r.push(n.source); if (n.kind === 'call') { if (n.callee && typeof n.callee === 'object') r.push(n.callee); for (const a of n.args || []) r.push(a); } if (n.kind === 'return' && n.value) r.push(n.value); return r; };
|
|
49
|
+
|
|
50
|
+
/** unwrap `liftIO (x)` / `lift (x)` wrappers around a call expression (transparent for data flow). */
|
|
51
|
+
function unwrapLift(e) {
|
|
52
|
+
let cur = e;
|
|
53
|
+
while (cur && cur.kind === 'call' && typeof cur.callee === 'string' && LIFT.test(cur.callee) && (cur.args || []).length === 1 && cur.args[0] && cur.args[0].kind === 'call') cur = cur.args[0];
|
|
54
|
+
return cur;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** property names read off variable `name` across a function body. */
|
|
58
|
+
function fieldsRead(fn, name) {
|
|
59
|
+
const props = new Set();
|
|
60
|
+
let whole = false;
|
|
61
|
+
for (const n of nodesOf(fn)) for (const r of roots(n)) {
|
|
62
|
+
const parents = new Map();
|
|
63
|
+
walk(r, (e) => {
|
|
64
|
+
if (e.kind === 'member' && e.object && e.object.kind === 'ident' && e.object.name === name && typeof e.prop === 'string') props.add(e.prop);
|
|
65
|
+
for (const k of ['object', 'left', 'right']) if (e[k] && typeof e[k] === 'object') parents.set(e[k], e);
|
|
66
|
+
for (const k of ['args', 'elements', 'parts', 'options']) if (Array.isArray(e[k])) for (const c of e[k]) if (c) parents.set(c, e);
|
|
67
|
+
if (Array.isArray(e.props)) for (const p of e.props) if (p && p.value) parents.set(p.value, e);
|
|
68
|
+
});
|
|
69
|
+
walk(r, (e) => { if (e.kind === 'ident' && e.name === name) { const p = parents.get(e); if (!(p && p.kind === 'member' && p.object === e)) whole = true; } });
|
|
70
|
+
}
|
|
71
|
+
return { props, whole };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** fields read through a resolved callee's parameter when the variable is passed whole. */
|
|
75
|
+
function fieldsViaCallees(fn, name, fnByQid, depth, seen) {
|
|
76
|
+
const out = new Set();
|
|
77
|
+
if (depth > MAX_DEPTH) return out;
|
|
78
|
+
for (const n of nodesOf(fn)) for (const r of roots(n)) walk(r, (e) => {
|
|
79
|
+
if (e.kind !== 'call' || !e.hs || !e.hs.target) return;
|
|
80
|
+
(e.args || []).forEach((a, i) => {
|
|
81
|
+
if (!(a && a.kind === 'ident' && a.name === name)) return;
|
|
82
|
+
const callee = fnByQid.get(e.hs.target);
|
|
83
|
+
if (!callee || seen.has(`${callee.qid}#${i}`)) return;
|
|
84
|
+
seen.add(`${callee.qid}#${i}`);
|
|
85
|
+
const p = (callee.params || [])[i];
|
|
86
|
+
const pname = typeof p === 'string' ? p : (p && p.name);
|
|
87
|
+
if (!pname) return;
|
|
88
|
+
for (const f of fieldsRead(callee, pname).props) out.add(f);
|
|
89
|
+
for (const f of fieldsViaCallees(callee, pname, fnByQid, depth + 1, seen)) out.add(f);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const memberOf = (obj, prop) => ({ kind: 'member', object: obj, prop });
|
|
96
|
+
const rootMember = (label) => memberOf({ kind: 'ident', name: HS_LINEAGE_ROOT }, label);
|
|
97
|
+
|
|
98
|
+
export function sourceLabelOf(call) { return typeof call.callee === 'string' ? call.callee : null; }
|
|
99
|
+
|
|
100
|
+
function transformFunction(fn, fnByQid) {
|
|
101
|
+
const stats = { sources: 0, containerOnly: 0, unwrapped: 0, bodySetters: 0 };
|
|
102
|
+
for (const [id, n] of Object.entries(fn.cfg.nodes)) {
|
|
103
|
+
// lifts and body setters at statement level
|
|
104
|
+
if (n.kind === 'call' && typeof n.callee === 'string' && LIFT.test(n.callee) && (n.args || []).length === 1 && n.args[0] && n.args[0].kind === 'call') {
|
|
105
|
+
const inner = unwrapLift(n);
|
|
106
|
+
n.callee = inner.callee; n.args = inner.args; n.hs = inner.hs || n.hs; stats.unwrapped++;
|
|
107
|
+
}
|
|
108
|
+
if (n.kind === 'assign' && n.source && n.source.kind === 'call') {
|
|
109
|
+
const inner = unwrapLift(n.source);
|
|
110
|
+
if (inner !== n.source) { n.source = inner; stats.unwrapped++; }
|
|
111
|
+
if (n.hs && n.hs.bodySetter && n.source.kind === 'call') {
|
|
112
|
+
// `$bodyN = setRequestBodyJSON body req` -> a call statement, so the sink is enumerable
|
|
113
|
+
n.kind = 'call'; n.callee = n.source.callee; n.args = n.source.args; n.line = n.line ?? n.source.line; delete n.source; delete n.target; stats.bodySetters++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// a discarded or unused bind of a call: `_ <- execute ...` is a statement-level call
|
|
117
|
+
if ((n.target === '_' || /^_\w*$/.test(n.target || '')) && !matchSource(n.source, fn.file)) { n.kind = 'call'; n.callee = n.source.callee; n.args = n.source.args; delete n.source; delete n.target; continue; }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// The last expression of a function body is a `return` of a call (`handle acct = putStrLn (email acct)`). A sink is only
|
|
121
|
+
// enumerated at a call STATEMENT, so the call is also placed before the return as a statement of its own; the return stays
|
|
122
|
+
// (a caller still needs what the function returns).
|
|
123
|
+
for (const [id, n] of Object.entries({ ...fn.cfg.nodes })) {
|
|
124
|
+
if (n.kind !== 'return' || !n.value) continue;
|
|
125
|
+
const v = unwrapLift(n.value);
|
|
126
|
+
if (!v || v.kind !== 'call' || typeof v.callee !== 'string') continue;
|
|
127
|
+
const sid = `${id}_stmt`;
|
|
128
|
+
const stmt = { kind: 'call', line: v.line ?? n.line, callee: v.callee, args: v.args || [], hs: v.hs, succ: [id], pred: [...(n.pred || [])] };
|
|
129
|
+
for (const pid of n.pred || []) {
|
|
130
|
+
const pn = fn.cfg.nodes[pid];
|
|
131
|
+
if (!pn) continue;
|
|
132
|
+
pn.succ = (pn.succ || []).map((x) => (x === id ? sid : x));
|
|
133
|
+
if (pn.thenEntry === id) pn.thenEntry = sid;
|
|
134
|
+
if (pn.elseEntry === id) pn.elseEntry = sid;
|
|
135
|
+
}
|
|
136
|
+
n.pred = [sid];
|
|
137
|
+
fn.cfg.nodes[sid] = stmt;
|
|
138
|
+
stats.returnSinks = (stats.returnSinks || 0) + 1;
|
|
139
|
+
}
|
|
140
|
+
// sources
|
|
141
|
+
for (const [id, n] of Object.entries(fn.cfg.nodes)) {
|
|
142
|
+
if (n.kind !== 'assign' || !n.source || n.source.kind !== 'call' || typeof n.target !== 'string' || n.target.includes('.')) continue;
|
|
143
|
+
const entry = matchSource(n.source, fn.file);
|
|
144
|
+
if (!entry || !String(entry.id).startsWith('hs-src-')) continue;
|
|
145
|
+
const label = sourceLabelOf(n.source);
|
|
146
|
+
if (!label) continue;
|
|
147
|
+
const lit = (n.source.args || []).find((a) => a && a.kind === 'literal' && typeof a.value === 'string' && /^[A-Za-z_][\w.-]*$/.test(a.value));
|
|
148
|
+
const target = n.target;
|
|
149
|
+
if (lit) {
|
|
150
|
+
// `param "email"`: the argument names the field
|
|
151
|
+
n.source = memberOf(rootMember(label), lit.value);
|
|
152
|
+
n.hs = { ...(n.hs || {}), lineageSource: { label, fields: [lit.value], form: 'named-parameter' } };
|
|
153
|
+
stats.sources++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const { props } = fieldsRead(fn, target);
|
|
157
|
+
for (const f of fieldsViaCallees(fn, target, fnByQid, 1, new Set())) props.add(f);
|
|
158
|
+
if (props.size) {
|
|
159
|
+
n.source = { kind: 'object', props: [...props].sort().map((p) => ({ key: p, value: memberOf(rootMember(label), p) })) };
|
|
160
|
+
n.hs = { ...(n.hs || {}), lineageSource: { label, fields: [...props].sort(), form: 'record-fields' } };
|
|
161
|
+
} else {
|
|
162
|
+
n.source = rootMember(label);
|
|
163
|
+
n.hs = { ...(n.hs || {}), lineageSource: { label, fields: [], form: 'container' } };
|
|
164
|
+
stats.containerOnly++;
|
|
165
|
+
}
|
|
166
|
+
stats.sources++;
|
|
167
|
+
}
|
|
168
|
+
// record-typed parameters of an entry point: a source of the record's fields, one per field the body (and its callees) reads
|
|
169
|
+
for (const rp of (fn.hs && fn.hs.recordParams) || []) {
|
|
170
|
+
const { props } = fieldsRead(fn, rp.name);
|
|
171
|
+
for (const f of fieldsViaCallees(fn, rp.name, fnByQid, 1, new Set())) props.add(f);
|
|
172
|
+
const fields = [...props].filter((f) => (rp.fields || []).includes(f)).sort();
|
|
173
|
+
if (!fields.length) continue;
|
|
174
|
+
const entry = Object.entries(fn.cfg.nodes).find(([, c]) => c.kind === 'entry');
|
|
175
|
+
if (!entry) continue;
|
|
176
|
+
const id = `n_param_${rp.name}`;
|
|
177
|
+
const next = [...(entry[1].succ || [])];
|
|
178
|
+
const node = { kind: 'assign', line: fn.line, target: rp.name, source: { kind: 'object', props: fields.map((p) => ({ key: p, value: memberOf(rootMember(HS_PARAM_SOURCE), p) })) }, succ: next, pred: [entry[0]], hs: { lineageSource: { label: HS_PARAM_SOURCE, fields, form: 'record-parameter', type: rp.type } } };
|
|
179
|
+
fn.cfg.nodes[id] = node;
|
|
180
|
+
for (const nid of next) { const nn = fn.cfg.nodes[nid]; if (nn) nn.pred = [...new Set([...(nn.pred || []).filter((x) => x !== entry[0]), id])]; }
|
|
181
|
+
entry[1].succ = [id];
|
|
182
|
+
stats.sources++; stats.paramSources = (stats.paramSources || 0) + 1;
|
|
183
|
+
}
|
|
184
|
+
cutNonContent(fn, stats);
|
|
185
|
+
fn.hs = { ...(fn.hs || {}), lineageView: stats };
|
|
186
|
+
return fn;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A size or emptiness test carries none of the value's content: `length`/`null` of a field is a number or a boolean. In the lineage
|
|
190
|
+
// clone the call is replaced by a literal, so the field identity ends there; the original call graph is untouched, and the
|
|
191
|
+
// replacement is counted on the function (`lineageView.nonContentCuts`).
|
|
192
|
+
const NON_CONTENT = /^(?:(?:Prelude|Data\.List|Data\.Foldable|Data\.Text|Data\.Text\.Lazy|Data\.ByteString|Data\.ByteString\.Char8|Data\.ByteString\.Lazy|Data\.ByteString\.Lazy\.Char8|Data\.Map|Data\.Set)\.)?(?:length|null)$|^(?:length|null)$/;
|
|
193
|
+
function cutNonContent(fn, stats) {
|
|
194
|
+
const rewrite = (e, depth = 0) => {
|
|
195
|
+
if (!e || typeof e !== 'object' || depth > 60) return e;
|
|
196
|
+
if (Array.isArray(e)) return e.map((x) => rewrite(x, depth + 1));
|
|
197
|
+
if (e.kind === 'call' && typeof e.callee === 'string' && NON_CONTENT.test(e.callee)) {
|
|
198
|
+
stats.nonContentCuts = (stats.nonContentCuts || 0) + 1;
|
|
199
|
+
return { kind: 'literal', value: '<size>', hs: { derived: 'non-content', callee: e.callee } };
|
|
200
|
+
}
|
|
201
|
+
const out = { ...e };
|
|
202
|
+
for (const k of ['object', 'callee', 'left', 'right', 'value', 'source']) if (e[k] && typeof e[k] === 'object') out[k] = rewrite(e[k], depth + 1);
|
|
203
|
+
for (const k of ['args', 'elements', 'parts', 'options', 'branches']) if (Array.isArray(e[k])) out[k] = e[k].map((x) => rewrite(x, depth + 1));
|
|
204
|
+
if (Array.isArray(e.props)) out.props = e.props.map((p) => (p && p.value ? { ...p, value: rewrite(p.value, depth + 1) } : p));
|
|
205
|
+
if (e.hs && Array.isArray(e.hs.branchConds)) out.hs = { ...e.hs, branchConds: e.hs.branchConds.map((b) => (b && b.cond ? { ...b, cond: rewrite(b.cond, depth + 1) } : b)) };
|
|
206
|
+
return out;
|
|
207
|
+
};
|
|
208
|
+
for (const n of Object.values(fn.cfg.nodes)) {
|
|
209
|
+
if (n.source) n.source = rewrite(n.source);
|
|
210
|
+
if (n.value) n.value = rewrite(n.value);
|
|
211
|
+
if (n.cond) n.cond = rewrite(n.cond);
|
|
212
|
+
if (Array.isArray(n.args)) n.args = rewrite(n.args);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── callee inlining ──────────────────────────────────────────────────────────
|
|
217
|
+
// The field-identity engine analyses one function at a time and does not carry a caller's parameter identities
|
|
218
|
+
// into a callee's sink sites. For Haskell, whose handlers delegate to other modules, that would drop every
|
|
219
|
+
// cross-module flow, so each statement-level call to a project function is replaced by a renamed COPY of the
|
|
220
|
+
// callee's CFG (bounded depth and size; recursion is not inlined). Parameters become assignments from the call's
|
|
221
|
+
// arguments, locals are prefixed so two inlined copies never collide, and a `return` becomes an assignment to the
|
|
222
|
+
// call's result variable. Branch structure is preserved: nothing is flattened.
|
|
223
|
+
const INLINE_MAX_DEPTH = 3;
|
|
224
|
+
const INLINE_MAX_NODES = 600;
|
|
225
|
+
|
|
226
|
+
const paramName = (p) => (typeof p === 'string' ? p : p && p.name);
|
|
227
|
+
|
|
228
|
+
function renameExpr(e, map) {
|
|
229
|
+
if (!e || typeof e !== 'object') return e;
|
|
230
|
+
if (Array.isArray(e)) return e.map((x) => renameExpr(x, map));
|
|
231
|
+
const out = { ...e };
|
|
232
|
+
if (e.kind === 'ident' && typeof e.name === 'string' && map.has(e.name)) out.name = map.get(e.name);
|
|
233
|
+
for (const k of ['object', 'callee', 'left', 'right', 'value', 'source']) if (e[k] && typeof e[k] === 'object') out[k] = renameExpr(e[k], map);
|
|
234
|
+
for (const k of ['args', 'elements', 'parts', 'options']) if (Array.isArray(e[k])) out[k] = e[k].map((x) => renameExpr(x, map));
|
|
235
|
+
if (Array.isArray(e.props)) out.props = e.props.map((p) => (p && p.value ? { ...p, value: renameExpr(p.value, map) } : p));
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
const renameTarget = (t, map) => { if (typeof t !== 'string') return t; const head = t.split('.')[0]; return map.has(head) ? map.get(head) + t.slice(head.length) : t; };
|
|
239
|
+
|
|
240
|
+
let inlineSeq = 0;
|
|
241
|
+
function inlineCalls(fn, ctx, stack = []) {
|
|
242
|
+
if (fn.hs && fn.hs.lineageInlined) return;
|
|
243
|
+
fn.hs = { ...(fn.hs || {}), lineageInlined: true };
|
|
244
|
+
let total = Object.keys(fn.cfg.nodes).length;
|
|
245
|
+
const skipped = [];
|
|
246
|
+
for (const [id, n] of Object.entries({ ...fn.cfg.nodes })) {
|
|
247
|
+
const isCall = n.kind === 'call' && typeof n.callee === 'string';
|
|
248
|
+
const isAssignCall = n.kind === 'assign' && n.source && n.source.kind === 'call' && typeof n.source.callee === 'string';
|
|
249
|
+
if (!isCall && !isAssignCall) continue;
|
|
250
|
+
const call = isCall ? n : n.source;
|
|
251
|
+
const target = (call.hs && call.hs.target) || (ctx.resolve ? ctx.resolve(call.callee, fn.file) : null);
|
|
252
|
+
if (!target) continue;
|
|
253
|
+
const callee = ctx.clones.get(target);
|
|
254
|
+
if (!callee) continue;
|
|
255
|
+
if (stack.includes(target) || stack.length >= INLINE_MAX_DEPTH) { skipped.push({ callee: call.callee, reason: stack.includes(target) ? 'recursion' : 'depth' }); continue; }
|
|
256
|
+
inlineCalls(callee, ctx, [...stack, fn.qid]);
|
|
257
|
+
const body = Object.entries(callee.cfg.nodes).filter(([, c]) => c.kind !== 'entry' && c.kind !== 'exit');
|
|
258
|
+
if (!body.length || total + body.length > INLINE_MAX_NODES) { if (body.length) skipped.push({ callee: call.callee, reason: 'size' }); continue; }
|
|
259
|
+
const tag = `__i${++inlineSeq}_`;
|
|
260
|
+
const params = (callee.params || []).map(paramName).filter(Boolean);
|
|
261
|
+
const map = new Map();
|
|
262
|
+
for (const p of params) map.set(p, `${tag}${p}`);
|
|
263
|
+
for (const [, c] of body) if (c.kind === 'assign' && typeof c.target === 'string') { const head = c.target.split('.')[0]; if (!map.has(head)) map.set(head, `${tag}${head}`); }
|
|
264
|
+
const idMap = new Map(body.map(([cid]) => [cid, `${id}~${tag}${cid}`]));
|
|
265
|
+
const newNodes = new Map();
|
|
266
|
+
// parameter binding nodes
|
|
267
|
+
const binds = params.map((p, i) => ({ id: `${id}~${tag}bind${i}`, node: { kind: 'assign', line: n.line, target: map.get(p), source: (call.args || [])[i] || { kind: 'literal', value: null } } }));
|
|
268
|
+
for (const [cid, c] of body) {
|
|
269
|
+
const nn = { ...c, succ: (c.succ || []).map((x) => idMap.get(x) || null).filter(Boolean), pred: (c.pred || []).map((x) => idMap.get(x) || null).filter(Boolean) };
|
|
270
|
+
if (nn.kind === 'assign') { nn.target = renameTarget(nn.target, map); nn.source = renameExpr(nn.source, map); }
|
|
271
|
+
else if (nn.kind === 'call') { nn.args = renameExpr(nn.args, map); if (nn.callee && typeof nn.callee === 'object') nn.callee = renameExpr(nn.callee, map); }
|
|
272
|
+
else if (nn.kind === 'return') {
|
|
273
|
+
const value = renameExpr(nn.value, map);
|
|
274
|
+
if (isAssignCall && value) { nn.kind = 'assign'; nn.target = n.target; nn.source = value; delete nn.value; } else if (value && value.kind === 'call') { nn.kind = 'call'; nn.callee = value.callee; nn.args = value.args; nn.hs = value.hs || nn.hs; delete nn.value; } else { nn.kind = 'noop'; delete nn.value; }
|
|
275
|
+
} else if (nn.kind === 'if' || nn.kind === 'branch') { if (nn.cond) nn.cond = renameExpr(nn.cond, map); if (nn.thenEntry) nn.thenEntry = idMap.get(nn.thenEntry) || nn.thenEntry; if (nn.elseEntry) nn.elseEntry = idMap.get(nn.elseEntry) || nn.elseEntry; }
|
|
276
|
+
newNodes.set(idMap.get(cid), nn);
|
|
277
|
+
}
|
|
278
|
+
// wire: pred(X) -> binds -> callee entry successors ; callee returns/exit predecessors -> succ(X)
|
|
279
|
+
const entry = Object.entries(callee.cfg.nodes).find(([, c]) => c.kind === 'entry');
|
|
280
|
+
const exit = Object.entries(callee.cfg.nodes).find(([, c]) => c.kind === 'exit');
|
|
281
|
+
const entryNext = ((entry && entry[1].succ) || []).map((x) => idMap.get(x)).filter(Boolean);
|
|
282
|
+
const exitPreds = ((exit && exit[1].pred) || []).map((x) => idMap.get(x)).filter(Boolean);
|
|
283
|
+
const chain = binds.map((b) => b.id);
|
|
284
|
+
const first = chain.length ? chain[0] : null;
|
|
285
|
+
for (let i = 0; i < binds.length; i++) newNodes.set(binds[i].id, { ...binds[i].node, succ: i + 1 < binds.length ? [binds[i + 1].id] : entryNext, pred: i === 0 ? [...(n.pred || [])] : [binds[i - 1].id] });
|
|
286
|
+
const startIds = first ? [first] : entryNext;
|
|
287
|
+
for (const nid of entryNext) { const nn = newNodes.get(nid); if (nn) nn.pred = binds.length ? [binds[binds.length - 1].id] : [...(n.pred || [])]; }
|
|
288
|
+
for (const xid of exitPreds) { const nn = newNodes.get(xid); if (nn) nn.succ = [...(n.succ || [])]; }
|
|
289
|
+
for (const pid of n.pred || []) { const pn = fn.cfg.nodes[pid]; if (pn) pn.succ = [...new Set([...(pn.succ || []).filter((x) => x !== id), ...startIds])]; if (pn && pn.thenEntry === id) pn.thenEntry = startIds[0]; if (pn && pn.elseEntry === id) pn.elseEntry = startIds[0]; }
|
|
290
|
+
for (const sid of n.succ || []) { const sn = fn.cfg.nodes[sid]; if (sn) sn.pred = [...new Set([...(sn.pred || []).filter((x) => x !== id), ...exitPreds])]; }
|
|
291
|
+
delete fn.cfg.nodes[id];
|
|
292
|
+
for (const [nid, nn] of newNodes) fn.cfg.nodes[nid] = nn;
|
|
293
|
+
total += newNodes.size;
|
|
294
|
+
fn.hs.lineageInlinedCalls = (fn.hs.lineageInlinedCalls || 0) + 1;
|
|
295
|
+
}
|
|
296
|
+
if (skipped.length) fn.hs.lineageNotInlined = skipped;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @param {object} callGraph the shared call graph (never mutated)
|
|
301
|
+
* @returns {object} a call graph whose Haskell functions are lineage-view clones; the input itself when there is no Haskell
|
|
302
|
+
*/
|
|
303
|
+
export function adaptCallGraphForLineage(callGraph) {
|
|
304
|
+
if (!callGraph || !callGraph.haskell || typeof callGraph.functions?.values !== 'function') return callGraph;
|
|
305
|
+
const hsOriginals = [...callGraph.functions.values()].filter((f) => isHsFile(f.file));
|
|
306
|
+
if (!hsOriginals.length) return callGraph;
|
|
307
|
+
const originalsByQid = new Map(hsOriginals.map((f) => [f.qid, f]));
|
|
308
|
+
const clones = new Map();
|
|
309
|
+
for (const f of hsOriginals) clones.set(f.qid, clone(f));
|
|
310
|
+
for (const c of clones.values()) transformFunction(c, originalsByQid);
|
|
311
|
+
const ctx = { clones, resolve: callGraph.resolveKnownCallee ? (name, file) => { const r = callGraph.resolveKnownCallee(name, file); return typeof r === 'string' ? r : (r && r.qid) || null; } : null };
|
|
312
|
+
for (const c of clones.values()) inlineCalls(c, ctx);
|
|
313
|
+
const functions = new Map();
|
|
314
|
+
for (const [k, f] of callGraph.functions) functions.set(k, isHsFile(f.file) && clones.has(f.qid) ? clones.get(f.qid) : f);
|
|
315
|
+
const swap = (f) => (f && isHsFile(f.file) && clones.has(f.qid) ? clones.get(f.qid) : f);
|
|
316
|
+
const resolve = callGraph.resolve ? (...a) => swap(callGraph.resolve(...a)) : undefined;
|
|
317
|
+
const resolveKnownCallee = callGraph.resolveKnownCallee ? (...a) => swap(callGraph.resolveKnownCallee(...a)) : undefined;
|
|
318
|
+
return { ...callGraph, functions, ...(resolve ? { resolve } : {}), ...(resolveKnownCallee ? { resolveKnownCallee } : {}), lineageView: { haskell: true, functions: clones.size } };
|
|
319
|
+
}
|
package/src/lineage/index.js
CHANGED
|
@@ -69,6 +69,8 @@ import { loadObservations } from './observation-store.js';
|
|
|
69
69
|
// mirroring `scenario.js`'s own boundary) or `federation-loader.js`
|
|
70
70
|
// (which owns only the REMOTE side).
|
|
71
71
|
import { validateCrossRepoLink, CROSS_REPO_LINKS_FILENAME } from './cross-repo-link.js';
|
|
72
|
+
import { contributeNixConfigLineage } from './nix-view.js';
|
|
73
|
+
import { adaptCallGraphForLineage } from './haskell-view.js';
|
|
72
74
|
|
|
73
75
|
// A small, LOCAL, tolerant loader for the operator-declared
|
|
74
76
|
// cross-repo-links.json config file — mirrors `loadRecipientConfig`'s own
|
|
@@ -257,7 +259,10 @@ export function buildLineageGraph(callGraph, opts = {}) {
|
|
|
257
259
|
const crossRepoLinkRecords = _crossRepoLinksFile && fs.existsSync(_crossRepoLinksFile)
|
|
258
260
|
? _loadCrossRepoLinkRecords(_crossRepoLinksFile)
|
|
259
261
|
: undefined;
|
|
260
|
-
|
|
262
|
+
// Haskell is rewritten into a lineage VIEW (source reads become seedable member reads, sinks become
|
|
263
|
+
// statement-level calls); every other language, and the shared call graph itself, are untouched.
|
|
264
|
+
const lineageCallGraph = adaptCallGraphForLineage(callGraph);
|
|
265
|
+
const built = buildGraphWithCoverage(lineageCallGraph, {
|
|
261
266
|
repository: opts.repository,
|
|
262
267
|
generatedAt: opts.deterministic ? undefined : new Date().toISOString(),
|
|
263
268
|
perFile: opts.perFile,
|
|
@@ -273,6 +278,8 @@ export function buildLineageGraph(callGraph, opts = {}) {
|
|
|
273
278
|
observationWindowEnd: opts.observationWindowEnd,
|
|
274
279
|
onProgress: opts.onProgress,
|
|
275
280
|
});
|
|
281
|
+
const nixFiles = opts.fileContents && Object.keys(opts.fileContents).some((f) => /\.nix$/i.test(f));
|
|
282
|
+
if (nixFiles) contributeNixConfigLineage(built.graph, opts.fileContents, { repository: opts.repository });
|
|
276
283
|
return { status: 'complete', graph: built.graph, transitEvidence, failure: null, elapsedMs: Date.now() - t0 };
|
|
277
284
|
} catch (e) {
|
|
278
285
|
// Best-effort (DESIGN_GRAPH_BUILDER.md §9.5 item 1): recorded, never
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Nix configuration lineage (X-004 / X-005). Contributes nodes, data elements, edges, flows and evidence to an
|
|
2
|
+
// EXISTING DataFlowGraph v1; it never forks the schema. The inputs are the NIX-006 secret-placement findings
|
|
3
|
+
// (which already include secrets interpolated into generated scripts), so a credential-bearing option is followed
|
|
4
|
+
// to where the Nix evaluation puts it: the world-readable store, a derivation environment, a log, or the source
|
|
5
|
+
// file itself.
|
|
6
|
+
//
|
|
7
|
+
// Scope and honesty:
|
|
8
|
+
// * Every contribution is STATIC CONFIGURATION evidence. A node carries `analysis: {language: 'nix',
|
|
9
|
+
// kind: 'configuration', scope: 'static-configuration', runtime: false}`: a declared service or a store
|
|
10
|
+
// path is not proof that a value is exposed at runtime, and nothing here claims it is.
|
|
11
|
+
// * A runtime reference (a `…File` option, EnvironmentFile, sops/agenix path) is not a flow; the secret analyzer
|
|
12
|
+
// already treats it as safe and so does this view.
|
|
13
|
+
// * The original location is the Nix source; a generated-script location is kept as `generatedLocation` when the
|
|
14
|
+
// value lands in a generated shell script. Neither is invented.
|
|
15
|
+
// * No application-level link is inferred: a Nix store file does not become an application read by name.
|
|
16
|
+
|
|
17
|
+
import * as ids from './ids.js';
|
|
18
|
+
import { classifyDataElementName } from './classification.js';
|
|
19
|
+
import { analyzeNixSecrets } from '../language/nix-secrets.js';
|
|
20
|
+
import { analyzeNixPersonalData, destinationOf } from '../language/nix-privacy.js';
|
|
21
|
+
|
|
22
|
+
export const NIX_VIEW_VERSION = 'nix-lineage/1';
|
|
23
|
+
|
|
24
|
+
const ANALYSIS = Object.freeze({ language: 'nix', kind: 'configuration', scope: 'static-configuration', runtime: false });
|
|
25
|
+
|
|
26
|
+
// exposure -> sink decision. Each is a registry-shaped decision (kind + subtype), never a call site.
|
|
27
|
+
const SINKS = {
|
|
28
|
+
store: { kind: 'store', subtype: 'file', label: 'Nix store (world-readable)', stage: 'storage' },
|
|
29
|
+
build: { kind: 'store', subtype: 'file', label: 'Derivation environment (recorded in the .drv)', stage: 'storage' },
|
|
30
|
+
log: { kind: 'log', subtype: 'log', label: 'Evaluation or script log output', stage: 'storage' },
|
|
31
|
+
'plaintext-source': { kind: 'store', subtype: 'file', label: 'Configuration source file', stage: 'storage' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const lastSegment = (detail) => String(detail || '').replace(/^option\s+/, '').replace(/^config\./, '').split('.').filter(Boolean).pop() || 'secret';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Adds Nix configuration lineage to `graph` in place. Returns `{added, nixFiles, gaps}`.
|
|
38
|
+
* @param {object} graph a DataFlowGraph v1 (mutated)
|
|
39
|
+
* @param {Record<string,string>} files every file handed to the scan
|
|
40
|
+
* @param {{repository?: string}} opts
|
|
41
|
+
*/
|
|
42
|
+
export function contributeNixConfigLineage(graph, files, opts = {}) {
|
|
43
|
+
const nixFiles = Object.keys(files || {}).filter((f) => /\.nix$/i.test(f));
|
|
44
|
+
const result = { added: { nodes: 0, edges: 0, flows: 0, dataElements: 0 }, nixFiles: nixFiles.length, gaps: [] };
|
|
45
|
+
if (!nixFiles.length) return result;
|
|
46
|
+
const repository = opts.repository || '';
|
|
47
|
+
let analysis;
|
|
48
|
+
try { analysis = analyzeNixSecrets({ files }); } catch (e) { result.gaps.push(`nix secret analysis failed: ${String((e && e.message) || e)}`); return result; }
|
|
49
|
+
result.gaps.push(...(analysis.gaps || []).map((g) => (typeof g === 'string' ? g : (g && (g.reason || g.message)) || 'unresolved Nix construct')));
|
|
50
|
+
|
|
51
|
+
const nodes = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
52
|
+
const els = new Map(graph.dataElements.map((d) => [d.id, d]));
|
|
53
|
+
const edges = new Map(graph.edges.map((e) => [e.id, e]));
|
|
54
|
+
const flows = new Map(graph.flows.map((f) => [f.id, f]));
|
|
55
|
+
const evidence = new Map(graph.evidence.map((e) => [e.id, e]));
|
|
56
|
+
|
|
57
|
+
const mint = (kind, subtype, label, stage, externality = 'internal') => {
|
|
58
|
+
const id = ids.nodeId(kind, [repository, `nix:${subtype}`, 'modeled', externality, '']);
|
|
59
|
+
let n = nodes.get(id);
|
|
60
|
+
if (!n) {
|
|
61
|
+
n = {
|
|
62
|
+
id, kind, subtype, label, aliases: [], location: null,
|
|
63
|
+
system: { application: repository, environment: null }, destination: null, storeDetail: null, queueDetail: null,
|
|
64
|
+
externality: { value: externality, evidenceRefs: [] }, lifecycleStages: [stage], governanceRefs: {},
|
|
65
|
+
dataElementIds: [], evidenceRefs: [], confidence: { score: 0.8, tier: 'high' }, coverageStatus: 'modeled', coverageReason: null,
|
|
66
|
+
analysis: ANALYSIS,
|
|
67
|
+
};
|
|
68
|
+
nodes.set(id, n); result.added.nodes++;
|
|
69
|
+
}
|
|
70
|
+
return n;
|
|
71
|
+
};
|
|
72
|
+
const source = mint('source', 'declared', 'Nix configuration value', 'collection');
|
|
73
|
+
|
|
74
|
+
for (const f of analysis.findings || []) {
|
|
75
|
+
const sink = SINKS[f.exposure];
|
|
76
|
+
if (!sink || !f.source) continue; // runtime references and unclassified exposures are not flows
|
|
77
|
+
const name = lastSegment(f.source.detail);
|
|
78
|
+
const deId = ids.dataElementId(name, [repository, f.source.file || f.file || '']);
|
|
79
|
+
let de = els.get(deId);
|
|
80
|
+
if (!de) {
|
|
81
|
+
const cls = classifyDataElementName(name).classes;
|
|
82
|
+
de = {
|
|
83
|
+
id: deId, name, aliases: [], declaredType: null, dataClasses: cls.length ? cls : ['CREDENTIALS'], aiContexts: [],
|
|
84
|
+
sourceLocations: [{ file: f.source.file || f.file, line: f.line, scope: 'nix', path: f.source.detail }],
|
|
85
|
+
dataSubjectCategory: null, classificationEvidence: [], manualOverride: false,
|
|
86
|
+
};
|
|
87
|
+
els.set(deId, de); result.added.dataElements++;
|
|
88
|
+
}
|
|
89
|
+
const snk = mint(sink.kind, sink.subtype, sink.label, sink.stage);
|
|
90
|
+
if (!source.dataElementIds.includes(deId)) source.dataElementIds.push(deId);
|
|
91
|
+
if (!snk.dataElementIds.includes(deId)) snk.dataElementIds.push(deId);
|
|
92
|
+
|
|
93
|
+
const edgeIdStr = ids.edgeId(source.id, snk.id, 'data_flow', [f.source.detail, f.exposure, deId, 'identity']);
|
|
94
|
+
if (!edges.has(edgeIdStr)) {
|
|
95
|
+
edges.set(edgeIdStr, {
|
|
96
|
+
id: edgeIdStr, from: source.id, to: snk.id, relationship: 'data_flow',
|
|
97
|
+
fieldMappings: [{ fromPath: f.source.detail, toPath: null, dataElementIds: [deId], mappingType: 'identity', transformationIds: [] }],
|
|
98
|
+
protocol: { name: 'nix-evaluation', destinationResolution: 'resolved_from_config' },
|
|
99
|
+
boundaryCrossings: [], provenance: 'code',
|
|
100
|
+
protection: { transit: { verdict: 'not_applicable', evidenceGrade: 'none' }, atRest: { verdict: f.exposure === 'log' ? 'not_assessed' : 'unprotected', evidenceGrade: 'config' }, handling: { verdict: 'not_assessed', evidenceGrade: 'none' } },
|
|
101
|
+
evidenceRefs: [], coverageStatus: 'modeled', analysis: ANALYSIS,
|
|
102
|
+
});
|
|
103
|
+
result.added.edges++;
|
|
104
|
+
}
|
|
105
|
+
const claim = `${f.rule}: ${f.source.detail} reaches ${sink.label}`;
|
|
106
|
+
const evId = ids.evidenceId(claim, `${f.file}:${f.line}`, [deId, f.exposure]);
|
|
107
|
+
if (!evidence.has(evId)) {
|
|
108
|
+
evidence.set(evId, {
|
|
109
|
+
id: evId, claim, evidenceType: 'configuration', location: { file: f.file, line: f.line, column: f.column ?? null, generatedLocation: f.generatedLocation || null },
|
|
110
|
+
producer: 'nix-secrets', confidenceTier: null, snippet: null, timestamp: null, commit: null,
|
|
111
|
+
limitations: ['static configuration evidence: the value is not proven to be present on a running host'], conflict: null,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const fId = ids.flowId(source.id, snk.id, [deId], [f.exposure, f.rule]);
|
|
115
|
+
if (!flows.has(fId)) {
|
|
116
|
+
flows.set(fId, {
|
|
117
|
+
id: fId, dataElementIds: [deId], source: source.id, sink: snk.id, edgeIds: [edgeIdStr], transformationIds: [],
|
|
118
|
+
alternatePathCount: 0, policyVerdict: 'not_evaluated', protectionSummary: f.exposure === 'log' ? 'not_assessed' : 'unprotected',
|
|
119
|
+
evidenceRefs: [evId], confidence: { score: 0.8, tier: 'high' }, coverageStatus: 'modeled', findingRefs: [], governanceRefs: {},
|
|
120
|
+
limitations: ['static Nix configuration evidence', ...(f.generatedLocation ? ['the value lands in a generated script; both the original and generated locations are recorded'] : [])],
|
|
121
|
+
evidenceGrade: 'explicit', handling: 'raw', analysis: ANALYSIS,
|
|
122
|
+
});
|
|
123
|
+
result.added.flows++;
|
|
124
|
+
} else {
|
|
125
|
+
const ex = flows.get(fId);
|
|
126
|
+
if (!ex.evidenceRefs.includes(evId)) ex.evidenceRefs.push(evId);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Personal data (PII, PHI, PCI, financial) written into configuration: a field-to-store flow per unprotected reference.
|
|
131
|
+
let personal = { references: [], gaps: [] };
|
|
132
|
+
try { personal = analyzeNixPersonalData({ files }); } catch (e) { result.gaps.push(`nix personal-data analysis failed: ${String((e && e.message) || e)}`); }
|
|
133
|
+
result.gaps.push(...personal.gaps);
|
|
134
|
+
for (const ref of personal.references) {
|
|
135
|
+
if (ref.protected) continue; // hashed, measured or only compared: no flow of the value
|
|
136
|
+
const dest = destinationOf(ref.attr);
|
|
137
|
+
const sink = { kind: 'store', subtype: 'file', label: dest.label, stage: 'storage' };
|
|
138
|
+
const deId = ids.dataElementId(ref.field, [repository, ref.file]);
|
|
139
|
+
let de = els.get(deId);
|
|
140
|
+
if (!de) {
|
|
141
|
+
de = {
|
|
142
|
+
id: deId, name: ref.field, aliases: [], declaredType: null, dataClasses: ref.classes, aiContexts: [],
|
|
143
|
+
sourceLocations: [{ file: ref.file, line: ref.line, scope: 'nix', path: ref.option }],
|
|
144
|
+
dataSubjectCategory: null, classificationEvidence: [], manualOverride: false,
|
|
145
|
+
};
|
|
146
|
+
els.set(deId, de); result.added.dataElements++;
|
|
147
|
+
}
|
|
148
|
+
const snk = mint(sink.kind, sink.subtype, sink.label, sink.stage);
|
|
149
|
+
if (!source.dataElementIds.includes(deId)) source.dataElementIds.push(deId);
|
|
150
|
+
if (!snk.dataElementIds.includes(deId)) snk.dataElementIds.push(deId);
|
|
151
|
+
const edgeIdStr = ids.edgeId(source.id, snk.id, 'data_flow', [ref.option, 'personal-data', deId, 'identity']);
|
|
152
|
+
if (!edges.has(edgeIdStr)) {
|
|
153
|
+
edges.set(edgeIdStr, {
|
|
154
|
+
id: edgeIdStr, from: source.id, to: snk.id, relationship: 'data_flow',
|
|
155
|
+
fieldMappings: [{ fromPath: ref.option, toPath: ref.attr.join('.'), dataElementIds: [deId], mappingType: 'identity', transformationIds: [] }],
|
|
156
|
+
protocol: { name: 'nix-evaluation', destinationResolution: 'resolved_from_config' },
|
|
157
|
+
boundaryCrossings: [], provenance: 'code',
|
|
158
|
+
protection: { transit: { verdict: 'not_applicable', evidenceGrade: 'none' }, atRest: { verdict: 'unprotected', evidenceGrade: 'config' }, handling: { verdict: 'not_assessed', evidenceGrade: 'none' } },
|
|
159
|
+
evidenceRefs: [], coverageStatus: 'modeled', analysis: ANALYSIS,
|
|
160
|
+
});
|
|
161
|
+
result.added.edges++;
|
|
162
|
+
}
|
|
163
|
+
const claim = `nix-personal-data-in-store: ${ref.option} reaches ${sink.label}`;
|
|
164
|
+
const evId = ids.evidenceId(claim, `${ref.file}:${ref.line}`, [deId, 'personal-data']);
|
|
165
|
+
if (!evidence.has(evId)) {
|
|
166
|
+
evidence.set(evId, {
|
|
167
|
+
id: evId, claim, evidenceType: 'configuration', location: { file: ref.file, line: ref.line, column: null, generatedLocation: null },
|
|
168
|
+
producer: 'nix-privacy', confidenceTier: null, snippet: null, timestamp: null, commit: null,
|
|
169
|
+
limitations: ['static configuration evidence: the option may hold no real personal data, and the value is not proven present on a running host'], conflict: null,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const fId = ids.flowId(source.id, snk.id, [deId], ['personal-data', ref.attr.join('.')]);
|
|
173
|
+
if (!flows.has(fId)) {
|
|
174
|
+
flows.set(fId, {
|
|
175
|
+
id: fId, dataElementIds: [deId], source: source.id, sink: snk.id, edgeIds: [edgeIdStr], transformationIds: [],
|
|
176
|
+
alternatePathCount: 0, policyVerdict: 'not_evaluated', protectionSummary: 'unprotected',
|
|
177
|
+
evidenceRefs: [evId], confidence: { score: 0.7, tier: 'medium' }, coverageStatus: 'modeled', findingRefs: [], governanceRefs: {},
|
|
178
|
+
limitations: ['static Nix configuration evidence', 'classified by the option name only'],
|
|
179
|
+
evidenceGrade: 'inferred', handling: 'raw', analysis: ANALYSIS,
|
|
180
|
+
});
|
|
181
|
+
result.added.flows++;
|
|
182
|
+
} else {
|
|
183
|
+
const ex = flows.get(fId);
|
|
184
|
+
if (!ex.evidenceRefs.includes(evId)) ex.evidenceRefs.push(evId);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const byId = (a, b) => (a.id < b.id ? -1 : 1);
|
|
189
|
+
graph.nodes = [...nodes.values()].sort(byId);
|
|
190
|
+
graph.dataElements = [...els.values()].sort(byId);
|
|
191
|
+
graph.edges = [...edges.values()].sort(byId);
|
|
192
|
+
graph.flows = [...flows.values()].sort(byId);
|
|
193
|
+
graph.evidence = [...evidence.values()].sort(byId);
|
|
194
|
+
if (result.added.flows) {
|
|
195
|
+
graph.limitations = [...graph.limitations, 'Nix configuration lineage is static: a store path or declared service is not proof of runtime exposure.'];
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
@@ -116,6 +116,10 @@ export const CWE_MAP = Object.freeze({
|
|
|
116
116
|
'CWE-22': Object.freeze({ category: 'file', status: 'modeled', why: 'path traversal sinks are filesystem reads/writes' }),
|
|
117
117
|
'CWE-73': Object.freeze({ category: 'file', status: 'modeled', why: 'arbitrary file write' }),
|
|
118
118
|
'CWE-918': Object.freeze({ category: 'external-api', status: 'modeled', why: 'SSRF sinks are outbound HTTP client calls' }),
|
|
119
|
+
// X-004 / haskell-models.js: untrusted text placed in a model request BODY. The call is an ordinary outbound HTTP
|
|
120
|
+
// body setter, so it is an external-api destination; whether the destination is a model provider is decided by
|
|
121
|
+
// the AI-evidence overlay (lineage/haskell-ai.js), never by this table.
|
|
122
|
+
'CWE-1427': Object.freeze({ category: 'external-api', status: 'modeled', why: 'a model request body is an outbound HTTP request body' }),
|
|
119
123
|
'CWE-601': Object.freeze({ category: 'http-response', status: 'modeled', why: 'a redirect is written as a response header' }),
|
|
120
124
|
'CWE-113': Object.freeze({ category: 'http-response', status: 'modeled', why: 'response splitting — the sink IS the response header writer' }),
|
|
121
125
|
// The sink side's ONE documented refinement (§5.2). `status: 'split'` is
|
|
@@ -136,6 +140,7 @@ export const CWE_MAP = Object.freeze({
|
|
|
136
140
|
// memory). FR-201's category list is an EGRESS taxonomy and models none
|
|
137
141
|
// of them — the single largest structural finding of D1 (§3/§7.1).
|
|
138
142
|
'CWE-78': Object.freeze({ category: null, status: 'unsupported', why: 'shell/process execution — no FR-201 category models process execution' }),
|
|
143
|
+
'CWE-88': Object.freeze({ category: null, status: 'unsupported', why: 'process-argument injection (Haskell argv sinks), no FR-201 category models process execution' }),
|
|
139
144
|
'CWE-95': Object.freeze({ category: null, status: 'unsupported', why: 'code evaluation (eval/Function/exec/compile) — destination is an interpreter' }),
|
|
140
145
|
'CWE-94': Object.freeze({ category: null, status: 'unsupported', why: 'code injection / template compilation — destination is an interpreter or template engine' }),
|
|
141
146
|
// PHP SARD catalog addition: include/require executes the included file
|
|
@@ -291,7 +296,11 @@ export function reclassifySink(entry, opts = {}) {
|
|
|
291
296
|
}
|
|
292
297
|
if (row.status === 'split') {
|
|
293
298
|
// The single documented refinement on the sink side (§5.2).
|
|
294
|
-
|
|
299
|
+
// A Haskell text-output sink (`putStrLn`) that is an XSS sink only when the string being assembled carries markup
|
|
300
|
+
// (`hs.htmlSkeleton`): the destination is standard output (a log), not a browser response.
|
|
301
|
+
row = entry.hs && entry.hs.htmlSkeleton
|
|
302
|
+
? { category: 'log', status: 'modeled', why: 'text written to standard output (the console is a log destination); an XSS sink to the taint engine only when the assembled string carries markup' }
|
|
303
|
+
: DOM_FRAMEWORKS.has(entry.framework)
|
|
295
304
|
? { category: 'client-storage', status: 'partial', why: "LOSSY: the destination is the rendered browser DOM; schema.js's `client-storage` is its encoding of FR-201's 'browser DOM or client storage' bullet, and under-names the DOM half" }
|
|
296
305
|
: { category: 'http-response', status: 'modeled', why: 'a server-side response writer (res.send / PrintWriter / echo / Fprintf)' };
|
|
297
306
|
}
|
|
@@ -144,6 +144,9 @@ export const NO_PROVENANCE_OVERRIDES = Object.freeze({
|
|
|
144
144
|
'py-os-getenv': 'env-value',
|
|
145
145
|
'py-os-environ': 'env-value',
|
|
146
146
|
'py-input': 'user-input',
|
|
147
|
+
// Haskell: a parameter of an exported function carries caller-supplied text or a customer record (no request is modelled)
|
|
148
|
+
'hs-src-caller-controlled-param': 'user-input',
|
|
149
|
+
'hs-lin-src-record-parameter': 'user-input',
|
|
147
150
|
// Java / Servlet
|
|
148
151
|
'java-request-getParameter': 'http-query',
|
|
149
152
|
'java-request-getHeader': 'http-header',
|
|
@@ -195,6 +195,18 @@ export const TRANSFORM_CATALOG = Object.freeze([
|
|
|
195
195
|
{ id: 'py-hashlib-md5', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'md5', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'md5' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm (recognized, not endorsed: MD5 is broken)", examples: ['hashlib.md5'] },
|
|
196
196
|
{ id: 'java-message-digest', language: 'java', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'MessageDigest', method: 'getInstance' }, evidence: "call to '{name}' — the JCA MessageDigest factory (the digest algorithm is an argument, not read here)", examples: ['MessageDigest.getInstance'] },
|
|
197
197
|
|
|
198
|
+
// Haskell (cryptonite / crypton / bcrypt / argon2 packages). A dotted callee is matched on its last two segments, so
|
|
199
|
+
// `Crypto.Hash.hashWith` is object `Hash`, method `hashWith`.
|
|
200
|
+
{ id: 'hs-crypto-hash', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'Hash', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash digest (the algorithm is a type or argument)", examples: ['Crypto.Hash.hash'] },
|
|
201
|
+
{ id: 'hs-crypto-hashwith', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'Hash', method: 'hashWith' }, evidence: "call to '{name}', Crypto.Hash digest with an explicit algorithm value", examples: ['Crypto.Hash.hashWith'] },
|
|
202
|
+
{ id: 'hs-crypto-hashlazy', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'Hash', method: 'hashlazy' }, evidence: "call to '{name}', Crypto.Hash digest of a lazy value", examples: ['Crypto.Hash.hashlazy'] },
|
|
203
|
+
{ id: 'hs-crypto-sha256', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha256', confidence: 'high', match: { type: 'member-call', object: 'SHA256', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash.SHA256", examples: ['Crypto.Hash.SHA256.hash'] },
|
|
204
|
+
{ id: 'hs-crypto-sha512', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha512', confidence: 'high', match: { type: 'member-call', object: 'SHA512', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash.SHA512", examples: ['Crypto.Hash.SHA512.hash'] },
|
|
205
|
+
{ id: 'hs-crypto-blake2', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'blake2', confidence: 'high', match: { type: 'member-call', object: 'Blake2b_256', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash BLAKE2", examples: ['Crypto.Hash.Blake2b_256.hash'] },
|
|
206
|
+
{ id: 'hs-hmac', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'HMAC', method: 'hmac' }, evidence: "call to '{name}', Crypto.MAC.HMAC keyed digest; still one-way", examples: ['Crypto.MAC.HMAC.hmac'] },
|
|
207
|
+
{ id: 'hs-bcrypt-hash', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'bcrypt', confidence: 'high', match: { type: 'member-call', object: 'BCrypt', method: 'hashPassword' }, evidence: "call to '{name}', Crypto.KDF.BCrypt", examples: ['Crypto.KDF.BCrypt.hashPassword'] },
|
|
208
|
+
{ id: 'hs-argon2-hash', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'argon2', confidence: 'high', match: { type: 'member-call', object: 'Argon2', method: 'hash' }, evidence: "call to '{name}', Crypto.KDF.Argon2", examples: ['Crypto.KDF.Argon2.hash'] },
|
|
209
|
+
|
|
198
210
|
// ─── encrypt — reversible by design ────────────────────────────────────
|
|
199
211
|
{ id: 'js-node-cipheriv', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createCipheriv' }, evidence: "call to '{name}' — Node's built-in symmetric cipher factory (the cipher suite is an argument, not read here)", examples: ['crypto.createCipheriv'] },
|
|
200
212
|
{ id: 'js-node-cipher-legacy', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createCipher' }, evidence: "call to '{name}' — Node's deprecated symmetric cipher factory (removed in Node 22; still present in legacy code)", examples: ['crypto.createCipher'] },
|