@clear-capabilities/agentic-security-scanner 0.184.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.
- package/CHANGELOG.md +37 -0
- package/bin/agentic-security.js +111 -1
- package/dist/1128.index.js +912 -0
- package/dist/1665.index.js +828 -0
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- 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/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +29 -12
- package/src/dataflow/CLAUDE.md +46 -1
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +1168 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ifds-precise.js +22 -0
- package/src/dataflow/incremental-replay.js +118 -0
- package/src/dataflow/incremental-session.js +257 -0
- package/src/dataflow/incremental.js +237 -35
- package/src/dataflow/index.js +25 -94
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/engine.js +113 -8
- package/src/graph/CLAUDE.md +31 -0
- package/src/graph/adapters/artifact.js +81 -0
- package/src/graph/adapters/crossrepo.js +75 -0
- package/src/graph/adapters/dataflow.js +76 -0
- package/src/graph/adapters/provenance.js +92 -0
- package/src/graph/adapters/semantic.js +89 -0
- package/src/graph/crosswalk-builders.js +152 -0
- package/src/graph/crosswalk.js +146 -0
- package/src/graph/index.js +15 -0
- package/src/graph/observation.js +57 -0
- package/src/graph/refs.js +105 -0
- package/src/graph/registry.js +129 -0
- package/src/graph/relations.js +81 -0
- package/src/graph/scc.js +241 -0
- package/src/graph/view.js +254 -0
- 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 +72 -8
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
- package/src/lineage/boundary-contract.js +273 -0
- package/src/lineage/completion.js +205 -0
- package/src/lineage/continuation.js +140 -0
- package/src/lineage/control-credit.js +313 -0
- package/src/lineage/correlation.js +199 -0
- package/src/lineage/coverage.js +8 -1
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/cut-planner.js +548 -0
- package/src/lineage/driver.js +219 -66
- package/src/lineage/engine.js +166 -21
- package/src/lineage/export-json.js +5 -0
- package/src/lineage/graph-builder.js +84 -7
- package/src/lineage/haskell-view.js +59 -0
- package/src/lineage/index.js +3 -0
- package/src/lineage/nix-view.js +35 -0
- package/src/lineage/owned-build.js +77 -0
- package/src/lineage/owned-worker-entry.js +22 -0
- package/src/lineage/owned-worker.js +95 -0
- package/src/lineage/path-evidence.js +123 -0
- package/src/lineage/path-predicates.js +529 -0
- package/src/lineage/path-query.js +189 -20
- package/src/lineage/path-store.js +191 -17
- package/src/lineage/scc-solver.js +186 -0
- package/src/lineage/smt-orchestrator.js +174 -0
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/lineage/source-seeding.js +36 -2
- package/src/lineage/summaries.js +66 -4
- package/src/lineage/value-versions.js +130 -0
- package/src/lineage/witness-selection.js +95 -0
- package/src/pipeline/scan-health.js +18 -0
- package/src/posture/CLAUDE.md +17 -0
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
- 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 -16182
- 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/5817.index.js +0 -714
- 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
|
@@ -125,6 +125,11 @@ function seedPathFor(expr, parentOf) {
|
|
|
125
125
|
// from seed records, must not surface a literal `*` name to a user
|
|
126
126
|
// without translating it into an honest "dynamic key" disclosure.
|
|
127
127
|
|
|
128
|
+
// DAG-07 R07.5: generous defaults so an ordinary project never reaches them;
|
|
129
|
+
// they exist so a pathological input cannot make the planner unbounded.
|
|
130
|
+
export const DEFAULT_MAX_SEEDS = 1000000;
|
|
131
|
+
export const DEFAULT_MAX_NODES = 20000000;
|
|
132
|
+
|
|
128
133
|
// ── §3.3/§3.4: planSeeds ────────────────────────────────────────────────────
|
|
129
134
|
|
|
130
135
|
/**
|
|
@@ -144,11 +149,29 @@ function seedPathFor(expr, parentOf) {
|
|
|
144
149
|
* A matched expression with NO access path (`accessPathOf` → `null`) is
|
|
145
150
|
* recorded in `unseedable[]` — never silently dropped (§3.3).
|
|
146
151
|
*/
|
|
147
|
-
export function planSeeds(callGraph, { repository } = {}) {
|
|
152
|
+
export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, deadline } = {}) {
|
|
148
153
|
const seeds = [];
|
|
149
154
|
const unseedable = [];
|
|
155
|
+
// DAG-07 R07.1: a completion record for the seeding step. A function the
|
|
156
|
+
// planner did not reach has no seeds, which looks identical to "this
|
|
157
|
+
// function reads no sources"; the record is what tells them apart.
|
|
158
|
+
const seedCap = Number.isInteger(maxSeeds) && maxSeeds >= 0 ? maxSeeds : DEFAULT_MAX_SEEDS;
|
|
159
|
+
const nodeCap = Number.isInteger(maxNodes) && maxNodes >= 0 ? maxNodes : DEFAULT_MAX_NODES;
|
|
160
|
+
let functionsTotal = 0;
|
|
161
|
+
let functionsScanned = 0;
|
|
162
|
+
let nodesVisited = 0;
|
|
163
|
+
let stopReason = null;
|
|
164
|
+
const shouldStop = () => !!(signal && signal.aborted) || (Number.isFinite(deadline) && Date.now() > deadline);
|
|
150
165
|
for (const fn of callGraph.functions.values()) {
|
|
166
|
+
functionsTotal += 1;
|
|
167
|
+
if (stopReason) continue;
|
|
168
|
+
if (shouldStop()) { stopReason = 'cancelled'; continue; }
|
|
169
|
+
if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; continue; }
|
|
170
|
+
if (nodesVisited >= nodeCap) { stopReason = 'seeding-limit'; continue; }
|
|
171
|
+
functionsScanned += 1;
|
|
151
172
|
for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
|
|
173
|
+
nodesVisited += 1;
|
|
174
|
+
if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; break; }
|
|
152
175
|
const parentOf = new Map();
|
|
153
176
|
for (const root of exprRoots(node)) walkExpr(root, (e, p) => { if (p) parentOf.set(e, p); });
|
|
154
177
|
for (const root of exprRoots(node)) {
|
|
@@ -182,7 +205,18 @@ export function planSeeds(callGraph, { repository } = {}) {
|
|
|
182
205
|
}
|
|
183
206
|
}
|
|
184
207
|
}
|
|
185
|
-
|
|
208
|
+
const notScanned = functionsTotal - functionsScanned;
|
|
209
|
+
const completion = {
|
|
210
|
+
status: stopReason === 'cancelled' ? 'cancelled' : stopReason ? 'partial' : 'complete',
|
|
211
|
+
reasons: stopReason ? [stopReason] : [],
|
|
212
|
+
functionsTotal,
|
|
213
|
+
functionsScanned,
|
|
214
|
+
functionsNotScanned: notScanned,
|
|
215
|
+
seeds: seeds.length,
|
|
216
|
+
unseedable: unseedable.length,
|
|
217
|
+
limits: { maxSeeds: seedCap, maxNodes: nodeCap, nodesVisited },
|
|
218
|
+
};
|
|
219
|
+
return { seeds, unseedable, completion };
|
|
186
220
|
}
|
|
187
221
|
|
|
188
222
|
// ── §3.5: seedEntryStateFactory — the driver.js opts.seedEntryState hook ──
|
package/src/lineage/summaries.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { hashState, emptyState, addIdentity } from './field-identity.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
resolveExprIdentities, residualFlat, analyzeFunctionFieldIdentity,
|
|
4
|
+
argumentContext, callInvocationId, calleeRecorder,
|
|
5
|
+
} from './engine.js';
|
|
3
6
|
import { accessPathOf } from '../dataflow/access-paths.js';
|
|
4
7
|
import { functionRecord } from '../ir/callgraph.js';
|
|
5
8
|
|
|
@@ -51,6 +54,13 @@ export class FieldIdentitySummaryCache {
|
|
|
51
54
|
this._stack = new Set();
|
|
52
55
|
this._contextsByQid = new Map();
|
|
53
56
|
this._maxContextsPerFn = maxContextsPerFn;
|
|
57
|
+
// DAG-07 R07.3: degradations and unfinished refinements, recorded so a
|
|
58
|
+
// caller can tell a precise result from one that fell back. Append-only.
|
|
59
|
+
this._notes = [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
completionNotes() {
|
|
63
|
+
return this._notes.slice();
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
_key(qid, entryState) {
|
|
@@ -74,6 +84,21 @@ export class FieldIdentitySummaryCache {
|
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
compute(qid, entryState, analyzeFn) {
|
|
87
|
+
// While `qid` is marked by `refreshing()` (SCC solver only, driver.js): re-run `analyzeFn` for an
|
|
88
|
+
// entry that is ALREADY seeded in the cache, without the recursion-stub
|
|
89
|
+
// stack and without the bounded refinement loop. A self-call or a call
|
|
90
|
+
// to another member of the same component inside `analyzeFn` therefore
|
|
91
|
+
// reads the previous round's cached summary instead of a bottom stub.
|
|
92
|
+
// Never marked => behavior is byte-identical to before. The mark lives on
|
|
93
|
+
// the cache (not an argument) so wrappers around compute() stay valid.
|
|
94
|
+
if (this._refreshing && this._refreshing.get(qid) === hashState(entryState)) {
|
|
95
|
+
// Consume the mark: a nested call to the same context (a self-call)
|
|
96
|
+
// must take the normal path and hit the cached previous-round summary.
|
|
97
|
+
this._refreshing.delete(qid);
|
|
98
|
+
const refreshed = analyzeFn(entryState);
|
|
99
|
+
this.set(qid, entryState, refreshed);
|
|
100
|
+
return refreshed;
|
|
101
|
+
}
|
|
77
102
|
// Ordering matches dataflow/summaries.js's own compute() exactly:
|
|
78
103
|
// cache-hit checked FIRST, before the `_stack`-based recursion guard.
|
|
79
104
|
// This is load-bearing, not incidental — B5's whole point is that a
|
|
@@ -130,6 +155,7 @@ export class FieldIdentitySummaryCache {
|
|
|
130
155
|
// every later reader of the empty-entry context too.
|
|
131
156
|
const base = this._cache.get(this._key(qid, emptyState())) ?? emptyFieldSummary();
|
|
132
157
|
const fallback = { ...base, degradedReason: 'context-cap' };
|
|
158
|
+
this._notes.push({ qid, reason: 'context-cap' });
|
|
133
159
|
this.set(qid, entryState, fallback);
|
|
134
160
|
return fallback;
|
|
135
161
|
}
|
|
@@ -219,12 +245,16 @@ export class FieldIdentitySummaryCache {
|
|
|
219
245
|
// round) mirrors dataflow/summaries.js's own compute() exactly.
|
|
220
246
|
if (this._hitRecursion) {
|
|
221
247
|
const FP_MAX = 3;
|
|
248
|
+
let converged = false;
|
|
222
249
|
for (let fp = 0; fp < FP_MAX; fp++) {
|
|
223
250
|
const prev = summary;
|
|
224
251
|
summary = analyzeFn(entryState);
|
|
225
|
-
if (fieldSummaryEq(prev, summary)) break;
|
|
252
|
+
if (fieldSummaryEq(prev, summary)) { converged = true; break; }
|
|
226
253
|
this.set(qid, entryState, summary);
|
|
227
254
|
}
|
|
255
|
+
// R07.3/R01.5: still changing after the last round means the
|
|
256
|
+
// summary is an under-approximation, not a fixed point.
|
|
257
|
+
if (!converged) this._notes.push({ qid, reason: 'recursion-nonconverged', rounds: FP_MAX });
|
|
228
258
|
}
|
|
229
259
|
// Defensive strip, mirroring dataflow's own equivalent: analyzeFn's
|
|
230
260
|
// real return shape here never actually carries `_recursive` under
|
|
@@ -238,6 +268,28 @@ export class FieldIdentitySummaryCache {
|
|
|
238
268
|
}
|
|
239
269
|
}
|
|
240
270
|
|
|
271
|
+
// Marks `qid` as being refreshed by the SCC solver for the duration of `fn`.
|
|
272
|
+
// Only the exact `entryState` context is refreshed; any other context of
|
|
273
|
+
// `qid` reached meanwhile (e.g. a call-site argument state) takes the normal
|
|
274
|
+
// guarded path, so recursion through it still terminates.
|
|
275
|
+
refreshing(qid, entryState, fn) {
|
|
276
|
+
(this._refreshing ||= new Map()).set(qid, hashState(entryState));
|
|
277
|
+
try { return fn(); } finally { this._refreshing.delete(qid); }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Forgets every cached context of `qid` except those whose state hash is in
|
|
281
|
+
// `keepHashes` (a Set). Used by the SCC solver between rounds so a context
|
|
282
|
+
// computed against an earlier round's summaries is never reused.
|
|
283
|
+
dropContexts(qid, keepHashes = new Set()) {
|
|
284
|
+
const seen = this._contextsByQid.get(qid);
|
|
285
|
+
if (!seen) return;
|
|
286
|
+
for (const h of [...seen]) {
|
|
287
|
+
if (keepHashes.has(h)) continue;
|
|
288
|
+
this._cache.delete(`${qid}::${h}`);
|
|
289
|
+
seen.delete(h);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
241
293
|
size() {
|
|
242
294
|
return this._cache.size;
|
|
243
295
|
}
|
|
@@ -326,11 +378,11 @@ export function entryStateFromCall(paramNames, callArgs, callerState, ctx) {
|
|
|
326
378
|
// call site) is load-bearing, not stylistic — it means no future second
|
|
327
379
|
// caller of entryStateFromCall can reintroduce the hazard by passing the
|
|
328
380
|
// full ctx through a different path.
|
|
329
|
-
const argCtx = ctx?.recordHop ? { recordHop: ctx.recordHop } : undefined;
|
|
330
381
|
let entryState = emptyState();
|
|
331
382
|
const n = Math.min(paramNames.length, callArgs.length);
|
|
332
383
|
for (let i = 0; i < n; i++) {
|
|
333
384
|
const paramName = paramNames[i];
|
|
385
|
+
const argCtx = argumentContext(ctx, i);
|
|
334
386
|
const resolved = resolveExprIdentities(callerState, callArgs[i], argCtx);
|
|
335
387
|
const residual = residualFlat(resolved.flat, resolved.byPath);
|
|
336
388
|
for (const id of residual) entryState = addIdentity(entryState, paramName, id);
|
|
@@ -409,6 +461,12 @@ export function createCallSummaryResolver(cache, lookupCallee) {
|
|
|
409
461
|
fromPath: null, toPath: path, dataElementId: id,
|
|
410
462
|
syntacticPath: null, widenReason: null, lossReason: null,
|
|
411
463
|
peerScope: qid, peerContext: calleeContext,
|
|
464
|
+
...(ctx.correlationVersion === 2 ? {
|
|
465
|
+
callRole: 'bind', invocationId: callInvocationId(ctx),
|
|
466
|
+
formalPosition: fn.params.indexOf(path.split('.')[0]),
|
|
467
|
+
relPath: path.includes('.') ? path.slice(path.indexOf('.') + 1) : '',
|
|
468
|
+
toVersion: 'entry',
|
|
469
|
+
} : null),
|
|
412
470
|
});
|
|
413
471
|
}
|
|
414
472
|
}
|
|
@@ -444,7 +502,11 @@ export function createCallSummaryResolver(cache, lookupCallee) {
|
|
|
444
502
|
// deepest call always wins). Passing a `context` field here would be
|
|
445
503
|
// silently overwritten and is dead code.
|
|
446
504
|
const calleeCtx = ctx?.recordHop
|
|
447
|
-
? {
|
|
505
|
+
? {
|
|
506
|
+
resolveCallSummary,
|
|
507
|
+
recordHop: ctx.correlationVersion === 2 ? calleeRecorder(ctx) : ctx.recordHop,
|
|
508
|
+
...(ctx.correlationVersion === 2 ? { correlationVersion: 2 } : null),
|
|
509
|
+
}
|
|
448
510
|
: { resolveCallSummary };
|
|
449
511
|
const result = analyzeFunctionFieldIdentity(fn, es, calleeCtx);
|
|
450
512
|
return summaryFromAnalysisResult(result);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
//
|
|
2
|
+
// value-versions.js: SSA-like definition identities for the lineage engine
|
|
3
|
+
// (DAG-03, R03.3).
|
|
4
|
+
//
|
|
5
|
+
// A reaching-definitions pass over one function's CFG that answers: "which
|
|
6
|
+
// definitions of this state path can reach the entry of this CFG node?" The
|
|
7
|
+
// answer is a SET, so a merge keeps both alternatives (a phi) instead of
|
|
8
|
+
// pretending they co-occur or picking one, and a later strong write kills the
|
|
9
|
+
// earlier definition of that path and everything under it, whether or not
|
|
10
|
+
// the written value carried any identity (a clean literal is still a kill).
|
|
11
|
+
//
|
|
12
|
+
// Definition ids are the CFG node ids of the writing `assign`, plus the
|
|
13
|
+
// reserved id 'entry' for a value that was live on function entry (a
|
|
14
|
+
// parameter, a seeded source, anything no assignment has overwritten).
|
|
15
|
+
//
|
|
16
|
+
// The pass is purely structural. It does not read or change the field
|
|
17
|
+
// identity state, so it cannot alter an analysis result; it only labels the
|
|
18
|
+
// provenance halves the engine emits when correlation version 2 is on.
|
|
19
|
+
//
|
|
20
|
+
export const ENTRY_VERSION = 'entry';
|
|
21
|
+
|
|
22
|
+
function nodeEntries(nodes) {
|
|
23
|
+
if (nodes instanceof Map) return [...nodes.entries()];
|
|
24
|
+
return Object.entries(nodes ?? {});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pathHasWildcard(path) {
|
|
28
|
+
return path.split('.').includes('*');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function definitePrefix(path) {
|
|
32
|
+
const segs = path.split('.');
|
|
33
|
+
const idx = segs.indexOf('*');
|
|
34
|
+
if (idx <= 0) return null;
|
|
35
|
+
return segs.slice(0, idx).join('.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The definitions recorded for the longest self-or-ancestor key of `path` in `st`, or {entry}. */
|
|
39
|
+
function lookup(st, path) {
|
|
40
|
+
let p = path;
|
|
41
|
+
for (;;) {
|
|
42
|
+
const hit = st.get(p);
|
|
43
|
+
if (hit) return hit;
|
|
44
|
+
const i = p.lastIndexOf('.');
|
|
45
|
+
if (i === -1) return null;
|
|
46
|
+
p = p.slice(0, i);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function join(a, b) {
|
|
51
|
+
const out = new Map();
|
|
52
|
+
const keys = new Set([...a.keys(), ...b.keys()]);
|
|
53
|
+
for (const k of keys) {
|
|
54
|
+
const sa = a.get(k) ?? lookup(a, k) ?? new Set([ENTRY_VERSION]);
|
|
55
|
+
const sb = b.get(k) ?? lookup(b, k) ?? new Set([ENTRY_VERSION]);
|
|
56
|
+
out.set(k, new Set([...sa, ...sb]));
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function equal(a, b) {
|
|
62
|
+
if (a.size !== b.size) return false;
|
|
63
|
+
for (const [k, sa] of a) {
|
|
64
|
+
const sb = b.get(k);
|
|
65
|
+
if (!sb || sb.size !== sa.size) return false;
|
|
66
|
+
for (const d of sa) if (!sb.has(d)) return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function transfer(nid, node, st) {
|
|
72
|
+
if (node.kind !== 'assign' || typeof node.target !== 'string') return st;
|
|
73
|
+
const out = new Map(st);
|
|
74
|
+
if (pathHasWildcard(node.target)) {
|
|
75
|
+
// A computed-key write is a WEAK update: it adds a definition to the
|
|
76
|
+
// container and kills nothing.
|
|
77
|
+
const container = definitePrefix(node.target);
|
|
78
|
+
if (container) {
|
|
79
|
+
const prior = out.get(container) ?? lookup(st, container) ?? new Set([ENTRY_VERSION]);
|
|
80
|
+
out.set(container, new Set([...prior, nid]));
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
const t = node.target;
|
|
85
|
+
for (const k of [...out.keys()]) {
|
|
86
|
+
if (k === t || k.startsWith(`${t}.`)) out.delete(k);
|
|
87
|
+
}
|
|
88
|
+
out.set(t, new Set([nid]));
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {{cfg:{entry:string, nodes:Map|object}, params?:string[]}} fn
|
|
94
|
+
* @returns {{versionsAt:(nodeId:string, path:string)=>string[]}}
|
|
95
|
+
*/
|
|
96
|
+
export function computeReachingDefinitions(fn) {
|
|
97
|
+
const nodes = new Map(nodeEntries(fn.cfg?.nodes));
|
|
98
|
+
const inStates = new Map();
|
|
99
|
+
const entry = new Map();
|
|
100
|
+
for (const p of fn.params ?? []) entry.set(p, new Set([ENTRY_VERSION]));
|
|
101
|
+
inStates.set(fn.cfg.entry, entry);
|
|
102
|
+
const work = [fn.cfg.entry];
|
|
103
|
+
const outStates = new Map();
|
|
104
|
+
let guard = 0;
|
|
105
|
+
const cap = Math.max(10000, nodes.size * 64);
|
|
106
|
+
while (work.length && guard++ < cap) {
|
|
107
|
+
const nid = work.shift();
|
|
108
|
+
const node = nodes.get(nid);
|
|
109
|
+
if (!node) continue;
|
|
110
|
+
const out = transfer(nid, node, inStates.get(nid) ?? new Map());
|
|
111
|
+
const prev = outStates.get(nid);
|
|
112
|
+
if (prev && equal(prev, out)) continue;
|
|
113
|
+
outStates.set(nid, out);
|
|
114
|
+
for (const s of node.succ ?? []) {
|
|
115
|
+
const pi = inStates.get(s);
|
|
116
|
+
const ni = pi ? join(pi, out) : out;
|
|
117
|
+
if (!pi || !equal(pi, ni)) {
|
|
118
|
+
inStates.set(s, ni);
|
|
119
|
+
work.push(s);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
versionsAt(nodeId, path) {
|
|
125
|
+
const st = inStates.get(nodeId);
|
|
126
|
+
const hit = st ? lookup(st, path) : null;
|
|
127
|
+
return hit ? [...hit].sort() : [ENTRY_VERSION];
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// witness-selection.js: which paths to DISPLAY, and what may be claimed from
|
|
2
|
+
// them (DAG-07 R07.8).
|
|
3
|
+
//
|
|
4
|
+
// Two jobs that must never be confused:
|
|
5
|
+
// 1. rankWitnesses: choose a small, diverse sample of the enumerated paths
|
|
6
|
+
// to show. Diversity is over source, sink, data class, trust boundary,
|
|
7
|
+
// control state and ambiguity. This is presentation only.
|
|
8
|
+
// 2. assessAllPathsProtected: decide whether "every path is protected" may
|
|
9
|
+
// be said. It looks at EVERY enumerated path, displayed or not, and it
|
|
10
|
+
// refuses outright when the enumeration itself was truncated, so a
|
|
11
|
+
// sample that happens to contain only protected paths can never produce
|
|
12
|
+
// the claim. A hidden unprotected path refutes the claim.
|
|
13
|
+
//
|
|
14
|
+
// Pure: no store access, no engine imports.
|
|
15
|
+
|
|
16
|
+
export const DIVERSITY_DIMENSIONS = Object.freeze(['source', 'sink', 'dataClass', 'trustBoundary', 'controlState', 'ambiguity']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Attribute values for one path. `attrsOf(path)` may supply `dataClass` and
|
|
20
|
+
* `controlState` ('protected' | 'unprotected' | 'unknown'); source, sink,
|
|
21
|
+
* boundary and ambiguity come from the path itself.
|
|
22
|
+
*/
|
|
23
|
+
export function witnessAttributes(path, attrsOf) {
|
|
24
|
+
const extra = typeof attrsOf === 'function' ? (attrsOf(path) ?? {}) : {};
|
|
25
|
+
return {
|
|
26
|
+
source: path.sourceNodeId ?? null,
|
|
27
|
+
sink: path.sinkNodeId ?? null,
|
|
28
|
+
dataClass: extra.dataClass ?? path.dataElementId ?? 'unknown',
|
|
29
|
+
trustBoundary: (path.crossScopeCount ?? 0) > 0 ? 'crosses' : 'local',
|
|
30
|
+
controlState: extra.controlState ?? 'unknown',
|
|
31
|
+
ambiguity: (path.ambiguousHopCount ?? 0) > 0 ? 'ambiguous' : 'correlated',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Greedy diversity ranking. Each step picks the path adding the most
|
|
37
|
+
* not-yet-shown values across the six dimensions; ties go to the path id so
|
|
38
|
+
* the order is deterministic. Never drops or alters a path: `ranked` is a
|
|
39
|
+
* permutation of the input and `displayed` is its prefix.
|
|
40
|
+
*/
|
|
41
|
+
export function rankWitnesses(paths, { limit = 10, attrsOf } = {}) {
|
|
42
|
+
const list = Array.isArray(paths) ? paths : [];
|
|
43
|
+
const cap = Number.isInteger(limit) && limit >= 0 ? limit : 10;
|
|
44
|
+
const seen = Object.fromEntries(DIVERSITY_DIMENSIONS.map((d) => [d, new Set()]));
|
|
45
|
+
const pool = list.map((p) => ({ p, a: witnessAttributes(p, attrsOf) }));
|
|
46
|
+
const ranked = [];
|
|
47
|
+
while (pool.length) {
|
|
48
|
+
let best = -1;
|
|
49
|
+
let bestGain = -1;
|
|
50
|
+
for (let i = 0; i < pool.length; i++) {
|
|
51
|
+
let gain = 0;
|
|
52
|
+
for (const d of DIVERSITY_DIMENSIONS) if (!seen[d].has(pool[i].a[d])) gain += 1;
|
|
53
|
+
if (gain > bestGain || (gain === bestGain && String(pool[i].p.id) < String(pool[best].p.id))) { best = i; bestGain = gain; }
|
|
54
|
+
}
|
|
55
|
+
const [chosen] = pool.splice(best, 1);
|
|
56
|
+
for (const d of DIVERSITY_DIMENSIONS) seen[d].add(chosen.a[d]);
|
|
57
|
+
ranked.push(chosen.p);
|
|
58
|
+
}
|
|
59
|
+
const displayed = ranked.slice(0, cap);
|
|
60
|
+
const coverage = Object.fromEntries(DIVERSITY_DIMENSIONS.map((d) => [d, new Set(displayed.map((p) => witnessAttributes(p, attrsOf)[d])).size]));
|
|
61
|
+
return { ranked, displayed, hiddenCount: ranked.length - displayed.length, coverage };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* May "all paths are protected" be asserted?
|
|
66
|
+
*
|
|
67
|
+
* `enumerated` is every path the query produced (before any display cap);
|
|
68
|
+
* `result` is the reconstructPaths result they came from. The claim is
|
|
69
|
+
* allowed only when the query was exhaustive (status complete, nothing
|
|
70
|
+
* dropped or truncated, at least one path) and every path is protected.
|
|
71
|
+
* `holds` is false when ANY enumerated path, hidden or not, is unprotected
|
|
72
|
+
* (a refutation needs no exhaustiveness), and null when it is merely not
|
|
73
|
+
* established.
|
|
74
|
+
*/
|
|
75
|
+
export function assessAllPathsProtected({ result, enumerated, attrsOf }) {
|
|
76
|
+
const paths = Array.isArray(enumerated) ? enumerated : [];
|
|
77
|
+
const stateOf = (p) => (typeof attrsOf === 'function' ? (attrsOf(p)?.controlState ?? 'unknown') : 'unknown');
|
|
78
|
+
const unprotected = paths.filter((p) => stateOf(p) === 'unprotected');
|
|
79
|
+
const unknown = paths.filter((p) => stateOf(p) !== 'protected' && stateOf(p) !== 'unprotected');
|
|
80
|
+
const reasons = [];
|
|
81
|
+
if (!result || result.status !== 'complete') reasons.push('query-not-complete');
|
|
82
|
+
if (result && (result.truncated || result.droppedPathCount > 0 || result.analysisTruncated)) reasons.push('truncated-sample');
|
|
83
|
+
if (result && result.resumed) reasons.push('resumed-page');
|
|
84
|
+
if (paths.length === 0) reasons.push('no-paths');
|
|
85
|
+
if (unknown.length) reasons.push('control-state-unknown');
|
|
86
|
+
if (unprotected.length) {
|
|
87
|
+
return {
|
|
88
|
+
claim: 'all-paths-protected', allowed: false, holds: false,
|
|
89
|
+
reasons: ['refuted-by-unprotected-path'],
|
|
90
|
+
refutingPathId: unprotected[0].id ?? null, unprotectedCount: unprotected.length,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const allowed = reasons.length === 0;
|
|
94
|
+
return { claim: 'all-paths-protected', allowed, holds: allowed ? true : null, reasons, unprotectedCount: 0 };
|
|
95
|
+
}
|
|
@@ -84,6 +84,19 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
|
|
|
84
84
|
if (lineageStatus?.failure) {
|
|
85
85
|
conditions.push(`lineage graph build threw and was skipped: ${lineageStatus.failure}`);
|
|
86
86
|
}
|
|
87
|
+
if (Array.isArray(lineageStatus?.incomplete) && lineageStatus.incomplete.length) {
|
|
88
|
+
conditions.push(`lineage field analysis stopped early in ${lineageStatus.incomplete.length} function(s) (iteration budget); a missing flow from those functions is not evidence of safety`);
|
|
89
|
+
}
|
|
90
|
+
if (lineageStatus?.completion && lineageStatus.completion.status !== 'complete') {
|
|
91
|
+
// DAG-07 R07.2: construction limits, cancellation, seeding limits and a
|
|
92
|
+
// zero-flow result from incomplete analysis all land here, in terms of
|
|
93
|
+
// the shared envelope, so none of them can be silent.
|
|
94
|
+
const c = lineageStatus.completion;
|
|
95
|
+
conditions.push(`lineage analysis is ${c.status} (${(c.reasons || []).join(', ') || 'no reason recorded'}); absence of a flow is not evidence that none exists`);
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(lineageStatus?.degraded) && lineageStatus.degraded.length) {
|
|
98
|
+
conditions.push(`lineage analysis used a coarser summary for ${lineageStatus.degraded.length} call context(s) (context cap or unconverged recursion)`);
|
|
99
|
+
}
|
|
87
100
|
// FR-203: a detector that threw on at least one file (captured via
|
|
88
101
|
// FR-201's runDetector isolation) is a real analysis gap distinct from
|
|
89
102
|
// an ANNOTATOR error above — annotators run post-detection over the
|
|
@@ -136,6 +149,8 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
|
|
|
136
149
|
reason: deepStatus.reason ?? null,
|
|
137
150
|
failure: deepStatus.failure ?? null,
|
|
138
151
|
...(Array.isArray(deepStatus.incomplete) && deepStatus.incomplete.length ? { incomplete: deepStatus.incomplete } : {}),
|
|
152
|
+
// DAG-05 R05.7: present only when the cross-scan cache was requested; says what was reused, what was recomputed, and why a run was cold.
|
|
153
|
+
...(deepStatus.incremental ? { incremental: deepStatus.incremental } : {}),
|
|
139
154
|
}
|
|
140
155
|
: null,
|
|
141
156
|
lineageAnalysis: lineageStatus
|
|
@@ -144,6 +159,9 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
|
|
|
144
159
|
enabled: !!lineageStatus.enabled,
|
|
145
160
|
reason: lineageStatus.reason ?? null,
|
|
146
161
|
failure: lineageStatus.failure ?? null,
|
|
162
|
+
...(lineageStatus.completion ? { completion: lineageStatus.completion } : {}),
|
|
163
|
+
...(Array.isArray(lineageStatus.incomplete) && lineageStatus.incomplete.length ? { incomplete: lineageStatus.incomplete } : {}),
|
|
164
|
+
...(Array.isArray(lineageStatus.degraded) && lineageStatus.degraded.length ? { degraded: lineageStatus.degraded } : {}),
|
|
147
165
|
}
|
|
148
166
|
: null,
|
|
149
167
|
annotatorErrorCount: safeAnnotatorErrors.length,
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -1019,6 +1019,23 @@ Measures a deterministic baseline and model arms, and routes tasks from the meas
|
|
|
1019
1019
|
* **The router is the policy layer, `routing/decide.js` is the calibrated one.** `policy-router.js` selects from measured scoreboard entries (quality, median cost, median latency, run count) under four policies; `decide.js` keeps quality LOWER bounds on held-out outcomes per stratum. They do not call each other. The router refuses to guess: too few runs, unknown cost under a cost policy, an egress denial or a dataset mismatch excludes a model with the reason, and `threat-model` is blocked because it has no evaluation task.
|
|
1020
1020
|
* **Digest-checked state.** Runs and the scoreboard live under `.agentic-security/eval-lab/` (registered in `artifact-registry.js`); a record that no longer matches its digest is reported and never used for routing.
|
|
1021
1021
|
|
|
1022
|
+
## Replayable path witnesses and scoped runtime support: `witness-bundle.js`, `witness-runtime.js` (DAG-10)
|
|
1023
|
+
|
|
1024
|
+
A sibling of `evidence-bundle.js` for one source-to-sink PATH rather than one finding. Same Ed25519 key, same self-issued trust basis (`buildLocalIssuance`, newly exported, is the only edit to `evidence-bundle.js`). Pure of the scan path: nothing here is wired into `engine.js`; the one production caller is `verify-attestation` in `bin/agentic-security.js`. Suite: `test/witness-bundle.test.js` (in `test:posture`).
|
|
1025
|
+
|
|
1026
|
+
- **Artifacts, not trust.** `exportProvenanceArtifact({hops, incomplete})` is the provenance store export: the normalized 14-field hop stream plus the truncation marks that never travel as hops (`analysisCompletion.incomplete`). `buildWitnessBundle` records graph digest (`computeGraphDigest`), provenance digest, source blob sha256s, the analysis configuration digest, transfer-model fingerprints, ordered edges (full derivation fields plus call/return binding), sink/source/field/context references and completion records. `replayWitness(bundle, {graph, provenance, sources, config})` is offline and bounded (`WITNESS_BOUNDS`): it rebuilds a `PathStore` from the hop stream and requires every recorded edge to be the edge the store derives, the edges to chain, each call/return binding to match both the store and a carrying hop, the terminal reason and truncation state to re-derive, and every digest to match.
|
|
1027
|
+
- **A valid signature proves integrity only.** The bundle carries `proves`/`doesNotProve` inside the signed bytes and the verifier rejects a bundle that drops or edits them. Transfer-model fingerprints are computed by RUNNING the half-edge classifiers on fixed probe hops (a minified bundle and the source tree agree); replay under a different fingerprint fails rather than pretending the old model still holds. Replay does not validate the model.
|
|
1028
|
+
- **Four statuses, never merged** (`witness-runtime.js`): `modeledPath`, `feasibility` (the nullable DAG-04 `controlEvidence` slot; `not_assessed` until it exists), `runtimeSupport`, `exploitProof`. `evidence` holds exactly these four keys, each `{status}`; any other key (a score, a confidence) fails replay.
|
|
1029
|
+
- **Runtime support rules.** `path_supported` needs ONE causal group covering every path edge with compatible environment, deployment, time window, identity scope, order and unambiguous mappings; edges seen in unrelated traces are `edge_partial`. Low-confidence mappings stay `candidate_only`; `no_observation_in_window` and `not_consulted` are different and neither is evidence of safety; a denied observation is `blocked_attempt` and proves only that attempt. The closed-world `RuntimeObservation` contract has no trace or session field, so causal groups are caller-supplied: from the observation store alone the strongest answer is `edge_partial`.
|
|
1030
|
+
- **Proof binding.** An external proof reference must name this bundle's sink node, data element, graph digest and configuration digest; a stale or mismatched one is listed under `exploit.rejected` with reasons and is never re-attached.
|
|
1031
|
+
- **Partial stays partial.** Completion fields are required (a missing one is invalid, never "complete"), the bundle is JSON-normalized at build so serialization cannot change it, and replay re-derives the path terminal, the truncation marks and `overall`.
|
|
1032
|
+
- **Redaction.** Identities become `idt_` tokens (`lineage/identity-token.js`), trace/session ids become `sref_` references, free text goes through `mcp/redact.js`, unknown input fields are dropped and only counted.
|
|
1033
|
+
- **Limits.** Building needs the hop stream, which a scan does not persist; `witness build` (`dag-commands.js`) therefore re-runs the field analysis rather than reading a stored stream. Source files are bound only when the caller supplies their text (the rest are listed `unboundSourceFiles` and replay warns). The graph-edge to provenance-edge relation is the flow binding (`graphBinding.flowId`), checked for existence, not re-derived. Cross-service replay depends on DAG-08.
|
|
1034
|
+
|
|
1035
|
+
## Opt-in DAG commands: `dag-commands.js` (DAG-01, 02, 06, 08, 09, 10)
|
|
1036
|
+
|
|
1037
|
+
CLI entry points for capabilities that were library-only: `dataflow view` (typed graph view plus SCC condensation), `dataflow contracts` (boundary contracts via `runBoundaryTaint`), `dataflow sink-query` (demand reverse taint), the completion contract of `dataflow plan-cuts` (`planContract`; the command body is still in `bin/agentic-security.js`), and `witness build|verify`. Dispatched lazily from `bin/agentic-security.js`, so a default scan never imports it and its output is unchanged. Every report carries a `completion` record in the shared vocabulary and `negativeClaimAllowed` is false unless the engine's own envelope earned it. Exit codes: 0 report produced, 1 nothing usable or verification failed, 2 usage. Suite: `test/cli/dag-commands.test.js` (in `test:lineage`). User docs: `docs/security-graph/graph-precision.md` (Commands section).
|
|
1038
|
+
|
|
1022
1039
|
## Gotchas
|
|
1023
1040
|
|
|
1024
1041
|
- The seed `calibration-seed.json` is small (n < 30 for several families). Don't treat it as a held-out set — that's `holdout-eval.js`'s job, against an externally-supplied JSONL.
|
|
@@ -154,19 +154,28 @@ function collectStateSets(fileContents) {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
function extractStateMachine(fileContents) {
|
|
157
|
-
// Then look for direct writes like `.status = "<not in set>"` and flag.
|
|
157
|
+
// Then look for direct writes like `.status = "<not in set>"` and flag. A write is
|
|
158
|
+
// compared only against the status sets declared in the SAME file: a set in one
|
|
159
|
+
// module says nothing about what another module's `.status` may hold, and matching
|
|
160
|
+
// every `.status =` in the repository against the first set found anywhere flags
|
|
161
|
+
// unrelated objects. Test files are skipped (they assign arbitrary states on purpose).
|
|
158
162
|
const stateSets = collectStateSets(fileContents);
|
|
159
163
|
if (!stateSets.length) return [];
|
|
164
|
+
const byFile = new Map();
|
|
165
|
+
for (const s of stateSets) {
|
|
166
|
+
if (!byFile.has(s.file)) byFile.set(s.file, []);
|
|
167
|
+
byFile.get(s.file).push(s);
|
|
168
|
+
}
|
|
160
169
|
const findings = [];
|
|
161
|
-
for (const [fp,
|
|
162
|
-
|
|
170
|
+
for (const [fp, sets] of byFile) {
|
|
171
|
+
const c = fileContents[fp];
|
|
172
|
+
if (typeof c !== 'string' || /(^|\/)(test|tests|__tests__|fixtures?)\//i.test(fp) || /\.(test|spec)\.[cm]?[jt]sx?$/i.test(fp)) continue;
|
|
163
173
|
const writeRe = /\.\s*(?:status|state|phase)\s*=\s*['"]([^'"]+)['"]/g;
|
|
164
174
|
let m;
|
|
165
175
|
while ((m = writeRe.exec(c))) {
|
|
166
176
|
const value = m[1];
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (validSet.values.has(value)) continue;
|
|
177
|
+
if (sets.some(s => s.values.has(value))) continue;
|
|
178
|
+
const validSet = sets[0];
|
|
170
179
|
const line = _lineOf(c, m.index);
|
|
171
180
|
findings.push({
|
|
172
181
|
id: `state-machine:${fp}:${line}:${value}`,
|
|
@@ -179,7 +188,7 @@ function extractStateMachine(fileContents) {
|
|
|
179
188
|
parser: 'STATE-MACHINE',
|
|
180
189
|
confidence: 0.5,
|
|
181
190
|
snippet: m[0].slice(0, 200),
|
|
182
|
-
remediation: `The statuses recognized by
|
|
191
|
+
remediation: `The statuses recognized by this file appear to be {${[...validSet.values].join(', ')}}. This write sets a different value ('${value}'). If the new value is legitimate, add it to the set; otherwise treat the write as a state-machine bypass and reject it.`,
|
|
183
192
|
});
|
|
184
193
|
}
|
|
185
194
|
}
|