@clear-capabilities/agentic-security-scanner 0.184.0 → 0.185.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/{dad58d1113a12c23a142.json → 0b301d45b231f144a1b4.json} +26 -9
  4. package/dist/1128.index.js +908 -0
  5. package/dist/1336.index.js +261 -112
  6. package/dist/1665.index.js +828 -0
  7. package/dist/9579.index.js +1 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/dist/frontend/src/shell.js +12 -2
  11. package/package.json +26 -9
  12. package/src/dataflow/CLAUDE.md +43 -1
  13. package/src/dataflow/backward.js +42 -7
  14. package/src/dataflow/cross-service-taint.js +83 -1
  15. package/src/dataflow/demand-query.js +929 -0
  16. package/src/dataflow/dep-fingerprint.js +718 -0
  17. package/src/dataflow/engine.js +48 -6
  18. package/src/dataflow/ifds-precise.js +22 -0
  19. package/src/dataflow/incremental-replay.js +118 -0
  20. package/src/dataflow/incremental-session.js +257 -0
  21. package/src/dataflow/incremental.js +237 -35
  22. package/src/dataflow/index.js +25 -94
  23. package/src/dataflow/summaries.js +72 -6
  24. package/src/dataflow/tabulation.js +80 -24
  25. package/src/engine.js +51 -3
  26. package/src/graph/CLAUDE.md +31 -0
  27. package/src/graph/adapters/artifact.js +81 -0
  28. package/src/graph/adapters/crossrepo.js +75 -0
  29. package/src/graph/adapters/dataflow.js +76 -0
  30. package/src/graph/adapters/provenance.js +92 -0
  31. package/src/graph/adapters/semantic.js +89 -0
  32. package/src/graph/crosswalk-builders.js +152 -0
  33. package/src/graph/crosswalk.js +146 -0
  34. package/src/graph/index.js +15 -0
  35. package/src/graph/observation.js +57 -0
  36. package/src/graph/refs.js +105 -0
  37. package/src/graph/registry.js +129 -0
  38. package/src/graph/relations.js +81 -0
  39. package/src/graph/scc.js +241 -0
  40. package/src/graph/view.js +254 -0
  41. package/src/lineage/CLAUDE.md +71 -8
  42. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  43. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  44. package/src/lineage/boundary-contract.js +273 -0
  45. package/src/lineage/completion.js +205 -0
  46. package/src/lineage/continuation.js +140 -0
  47. package/src/lineage/control-credit.js +313 -0
  48. package/src/lineage/correlation.js +199 -0
  49. package/src/lineage/coverage.js +8 -1
  50. package/src/lineage/cross-repo-link.js +22 -0
  51. package/src/lineage/cut-planner.js +548 -0
  52. package/src/lineage/driver.js +219 -66
  53. package/src/lineage/engine.js +166 -21
  54. package/src/lineage/export-json.js +5 -0
  55. package/src/lineage/graph-builder.js +84 -7
  56. package/src/lineage/haskell-view.js +59 -0
  57. package/src/lineage/index.js +3 -0
  58. package/src/lineage/nix-view.js +35 -0
  59. package/src/lineage/owned-build.js +77 -0
  60. package/src/lineage/owned-worker-entry.js +22 -0
  61. package/src/lineage/owned-worker.js +95 -0
  62. package/src/lineage/path-evidence.js +123 -0
  63. package/src/lineage/path-predicates.js +529 -0
  64. package/src/lineage/path-query.js +189 -20
  65. package/src/lineage/path-store.js +191 -17
  66. package/src/lineage/scc-solver.js +186 -0
  67. package/src/lineage/smt-orchestrator.js +138 -0
  68. package/src/lineage/source-seeding.js +36 -2
  69. package/src/lineage/summaries.js +66 -4
  70. package/src/lineage/value-versions.js +130 -0
  71. package/src/lineage/witness-selection.js +95 -0
  72. package/src/pipeline/scan-health.js +18 -0
  73. package/src/posture/CLAUDE.md +13 -0
  74. package/src/posture/evidence-bundle.js +1 -1
  75. package/src/posture/witness-bundle.js +573 -0
  76. package/src/posture/witness-runtime.js +276 -0
  77. package/src/report/index.js +2 -0
@@ -0,0 +1,276 @@
1
+ // Runtime support, feasibility and exploit-proof evidence for a path witness (DAG-10, R10.3 to R10.7).
2
+ //
3
+ // Pure: no fs, no network, never throws, never mutates its inputs. It READS the per-edge states that
4
+ // `lineage/runtime-edge-states.js` already derives and adds the one thing those states cannot say: whether
5
+ // the observed edges of a path belong to ONE execution.
6
+ //
7
+ // FOUR EVIDENCE STATUSES, NEVER MERGED
8
+ // ------------------------------------
9
+ // modeledPath the static analysis connected source to sink (complete or partial)
10
+ // feasibility a predicate / control check on the path (a nullable slot until DAG-04 supplies it)
11
+ // runtimeSupport what telemetry says about the path (this file)
12
+ // exploitProof explicit, execution-backed proof bound to this sink, value and configuration
13
+ // They are reported side by side. There is deliberately no function here (and the verifier rejects any bundle
14
+ // field) that combines them into a score, a level or a single confidence.
15
+ //
16
+ // WHAT RUNTIME SUPPORT MAY CLAIM
17
+ // ------------------------------
18
+ // * Several edges observed does NOT prove one end-to-end execution. `path_supported` needs ONE causal group
19
+ // (a trace or session the caller can name) that covers EVERY edge, in a compatible environment,
20
+ // deployment, time window and identity scope, with unambiguous edge mappings. Edges seen in unrelated
21
+ // traces are `edge_partial`, however many there are.
22
+ // * Low-confidence and ambiguous mappings stay CANDIDATES (`candidate_only`); they never count as observed.
23
+ // * No telemetry is not evidence of safety: `no_observation_in_window` and `not_consulted` are different
24
+ // answers and neither says "the path is not exercised".
25
+ // * A denied observation proves only that attempt was refused (`blocked_attempt`), not every execution.
26
+ //
27
+ // KNOWN LIMIT: the closed-world RuntimeObservation contract carries no trace or session token (by design:
28
+ // it is metadata-only). Causal groups are therefore supplied by the caller from a source that has one, and
29
+ // without them the strongest honest answer from the observation store alone is `edge_partial`.
30
+
31
+ import * as crypto from 'node:crypto';
32
+ import { redactString } from '../mcp/redact.js';
33
+ import { makeIdentityTokenizer } from '../lineage/identity-token.js';
34
+
35
+ export const RUNTIME_SUPPORT_STATUSES = Object.freeze([
36
+ 'not_consulted', 'no_observation_in_window', 'candidate_only', 'edge_partial', 'path_supported', 'blocked_attempt',
37
+ ]);
38
+ export const EXPLOIT_PROOF_STATUSES = Object.freeze(['none', 'bound', 'rejected']);
39
+ export const FEASIBILITY_STATUSES = Object.freeze(['not_assessed', 'feasible', 'infeasible', 'unknown']);
40
+ export const MODELED_PATH_STATUSES = Object.freeze(['connected_complete', 'connected_partial']);
41
+ export const EVIDENCE_STATUS_KEYS = Object.freeze(['modeledPath', 'feasibility', 'runtimeSupport', 'exploitProof']);
42
+
43
+ const DEFAULT_MAX_WINDOW_MS = 5 * 60 * 1000;
44
+ const TRUSTED_CONFIDENCE = new Set(['high', 'medium']);
45
+ const REF_RE = /^[A-Za-z0-9._:@/-]{1,160}$/;
46
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
47
+ const arr = (v) => (Array.isArray(v) ? v : []);
48
+ const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
49
+
50
+ /** A caller trace/session id becomes an opaque reference: comparable, never the raw value. */
51
+ function pseudonymizeSessionRef(raw) {
52
+ return typeof raw === 'string' && raw.length > 0 ? `sref_${sha(`session-ref:v1:${raw}`).slice(0, 16)}` : null;
53
+ }
54
+
55
+ function iso(v) {
56
+ if (typeof v !== 'string') return null;
57
+ const t = Date.parse(v);
58
+ return Number.isFinite(t) ? t : null;
59
+ }
60
+
61
+ /**
62
+ * Reduce caller-supplied causal groups to the closed set of fields the assessment reads. Unknown fields are
63
+ * dropped and only COUNTED (their names could themselves carry a value). Identities are tokenized with the
64
+ * existing keyed-hash convention; a raw identity with no tokenizer is dropped, never kept.
65
+ */
66
+ export function sanitizeCausalGroups(groups, { tokenize = makeIdentityTokenizer() } = {}) {
67
+ const out = []; const rejected = []; let droppedFields = 0;
68
+ const GROUP_KEYS = new Set(['id', 'sessionRef', 'sessionId', 'traceId', 'environment', 'deploymentRef', 'deploymentConfigDigest',
69
+ 'identity', 'identityToken', 'firstObservedAt', 'lastObservedAt', 'edgeObservations']);
70
+ const OBS_KEYS = new Set(['edgeId', 'firstObservedAt', 'lastObservedAt', 'matchConfidence', 'matchedEdgeIds', 'observationIds']);
71
+ arr(groups).forEach((g, i) => {
72
+ if (!isObj(g)) { rejected.push({ index: i, reason: 'not-an-object' }); return; }
73
+ droppedFields += Object.keys(g).filter((k) => !GROUP_KEYS.has(k)).length;
74
+ // Idempotent: an already-opaque reference is kept, so re-sanitizing a recorded group (replay) changes nothing.
75
+ const ref = typeof g.sessionRef === 'string' && /^sref_[0-9a-f]{16}$/.test(g.sessionRef)
76
+ ? g.sessionRef : pseudonymizeSessionRef(g.sessionRef ?? g.sessionId ?? g.traceId);
77
+ const token = typeof g.identityToken === 'string' && /^idt_[0-9a-f]{16}$/.test(g.identityToken)
78
+ ? g.identityToken : (typeof g.identity === 'string' ? tokenize(g.identity) : null);
79
+ const ident = (v) => (typeof v === 'string' && REF_RE.test(v) ? v : null);
80
+ const edgeObservations = [];
81
+ for (const o of arr(g.edgeObservations)) {
82
+ if (!isObj(o) || typeof o.edgeId !== 'string' || !REF_RE.test(o.edgeId)) continue;
83
+ droppedFields += Object.keys(o).filter((k) => !OBS_KEYS.has(k)).length;
84
+ edgeObservations.push({
85
+ edgeId: o.edgeId,
86
+ firstObservedAt: iso(o.firstObservedAt) === null ? null : o.firstObservedAt,
87
+ lastObservedAt: iso(o.lastObservedAt) === null ? null : o.lastObservedAt,
88
+ matchConfidence: ['high', 'medium', 'low', 'ambiguous'].includes(o.matchConfidence) ? o.matchConfidence : 'ambiguous',
89
+ matchedEdgeIds: arr(o.matchedEdgeIds).filter((x) => typeof x === 'string' && REF_RE.test(x)).sort(),
90
+ observationIds: arr(o.observationIds).filter((x) => typeof x === 'string' && REF_RE.test(x)).sort().slice(0, 8),
91
+ });
92
+ }
93
+ out.push({
94
+ id: ident(g.id) ?? `group-${i}`,
95
+ sessionRef: ref,
96
+ environment: ident(g.environment),
97
+ deploymentRef: ident(g.deploymentRef),
98
+ deploymentConfigDigest: ident(g.deploymentConfigDigest),
99
+ identityToken: token ?? null,
100
+ firstObservedAt: iso(g.firstObservedAt) === null ? null : g.firstObservedAt,
101
+ lastObservedAt: iso(g.lastObservedAt) === null ? null : g.lastObservedAt,
102
+ edgeObservations: edgeObservations.sort((a, b) => (a.edgeId < b.edgeId ? -1 : a.edgeId > b.edgeId ? 1 : 0)),
103
+ });
104
+ });
105
+ return { groups: out, rejected, droppedFields };
106
+ }
107
+
108
+ /** Why a single causal group cannot support the WHOLE path (empty list means it can). */
109
+ function groupIncompatibilities(group, pathEdgeIds, expected, maxWindowMs) {
110
+ const why = [];
111
+ if (!group.sessionRef) why.push('no-session-or-trace-reference');
112
+ const covered = new Map(group.edgeObservations.map((o) => [o.edgeId, o]));
113
+ const missing = pathEdgeIds.filter((id) => !covered.has(id));
114
+ if (missing.length) why.push(`edges-not-in-group:${missing.length}`);
115
+ if (!expected.environment) why.push('environment-unspecified');
116
+ else if (group.environment !== expected.environment) why.push('environment-mismatch');
117
+ if (!expected.deploymentRef) why.push('deployment-unspecified');
118
+ else if (group.deploymentRef !== expected.deploymentRef) why.push('deployment-mismatch');
119
+ if (expected.deploymentConfigDigest && group.deploymentConfigDigest && expected.deploymentConfigDigest !== group.deploymentConfigDigest) {
120
+ why.push('deployment-configuration-mismatch');
121
+ }
122
+ if (expected.identityToken && group.identityToken && expected.identityToken !== group.identityToken) why.push('identity-scope-mismatch');
123
+ const first = iso(group.firstObservedAt); const last = iso(group.lastObservedAt);
124
+ if (first === null || last === null) why.push('time-window-missing');
125
+ else {
126
+ if (last < first) why.push('time-window-inverted');
127
+ if (last - first > maxWindowMs) why.push('time-window-too-wide');
128
+ const w = expected.window;
129
+ if (w && (iso(w.start) !== null && first < iso(w.start) || iso(w.end) !== null && last > iso(w.end))) why.push('outside-requested-window');
130
+ }
131
+ // Edge-level mapping and order: each edge's own interval is inside the group window and the path order
132
+ // is not contradicted (an edge cannot have been last seen before its predecessor was first seen).
133
+ let prevFirst = null;
134
+ for (const id of pathEdgeIds) {
135
+ const o = covered.get(id);
136
+ if (!o) continue;
137
+ if (!TRUSTED_CONFIDENCE.has(o.matchConfidence) || o.matchedEdgeIds.length > 1) { why.push('ambiguous-edge-mapping'); break; }
138
+ const f = iso(o.firstObservedAt); const l = iso(o.lastObservedAt);
139
+ if (f === null || l === null) { why.push('edge-time-missing'); break; }
140
+ if (first !== null && last !== null && (f < first || l > last)) { why.push('edge-outside-group-window'); break; }
141
+ if (prevFirst !== null && l < prevFirst) { why.push('causal-order-violated'); break; }
142
+ prevFirst = f;
143
+ }
144
+ return why;
145
+ }
146
+
147
+ /**
148
+ * Assess what telemetry says about ONE path.
149
+ *
150
+ * @param {object} p
151
+ * @param {string[]} p.pathEdgeIds DataFlowGraph edge ids of the path, source first.
152
+ * @param {object|null} p.edgeStates the result of `deriveEdgeStates` (null or evaluated:false means no telemetry was consulted)
153
+ * @param {object[]} [p.causalGroups]
154
+ * @param {object} [p.expected] { environment, deploymentRef, deploymentConfigDigest, identityToken, window, maxWindowMs }
155
+ */
156
+ export function assessRuntimeSupport({ pathEdgeIds, edgeStates, causalGroups = [], expected = {}, tokenize } = {}) {
157
+ const ids = arr(pathEdgeIds).filter((x) => typeof x === 'string');
158
+ const base = {
159
+ pathEdgeCount: ids.length, observedEdgeIds: [], unobservedEdgeIds: [], candidateEdgeIds: [], blockedEdgeIds: [],
160
+ wholePath: { supported: false, groupId: null, reasons: [] }, groupsConsidered: 0, rejectedGroups: 0, droppedFields: 0,
161
+ statements: [],
162
+ };
163
+ if (!ids.length || !isObj(edgeStates) || !edgeStates.evaluated) {
164
+ return { ...base, status: 'not_consulted', statements: ['No telemetry was consulted for this path. That is not an observation of absence.'] };
165
+ }
166
+ const sanitized = sanitizeCausalGroups(causalGroups, { tokenize });
167
+ base.groupsConsidered = sanitized.groups.length; base.rejectedGroups = sanitized.rejected.length; base.droppedFields = sanitized.droppedFields;
168
+
169
+ for (const id of ids) {
170
+ const s = edgeStates.byEdge?.[id];
171
+ const states = arr(s?.states);
172
+ if (states.includes('RUNTIME_OBSERVED')) base.observedEdgeIds.push(id);
173
+ else if (states.includes('RUNTIME_BLOCKED')) base.blockedEdgeIds.push(id);
174
+ else {
175
+ base.unobservedEdgeIds.push(id);
176
+ if ((s?.uncertainObservations ?? 0) > 0) base.candidateEdgeIds.push(id);
177
+ }
178
+ // An edge observed AND denied keeps both facts.
179
+ if (states.includes('RUNTIME_OBSERVED') && states.includes('RUNTIME_BLOCKED')) base.blockedEdgeIds.push(id);
180
+ }
181
+
182
+ const allObserved = base.observedEdgeIds.length === ids.length;
183
+ if (allObserved) {
184
+ const maxWindowMs = Number.isFinite(expected.maxWindowMs) && expected.maxWindowMs > 0 ? expected.maxWindowMs : DEFAULT_MAX_WINDOW_MS;
185
+ let best = null;
186
+ for (const g of sanitized.groups) {
187
+ const why = groupIncompatibilities(g, ids, expected, maxWindowMs);
188
+ if (!why.length) { best = { g, why: [] }; break; }
189
+ if (!best || why.length < best.why.length) best = { g, why };
190
+ }
191
+ if (best && !best.why.length) {
192
+ base.wholePath = { supported: true, groupId: best.g.id, reasons: [] };
193
+ return { ...base, status: 'path_supported', statements: [
194
+ 'Every edge was observed inside ONE compatible trace or session. This supports that this path executed in that session; it does not prove the data was sensitive or that the path is exploitable.',
195
+ ] };
196
+ }
197
+ base.wholePath.reasons = best ? best.why : ['no-causal-group-supplied'];
198
+ } else {
199
+ base.wholePath.reasons = ['not-every-edge-observed'];
200
+ }
201
+
202
+ if (base.blockedEdgeIds.length && base.blockedEdgeIds.some((id) => !base.observedEdgeIds.includes(id))) {
203
+ return { ...base, status: 'blocked_attempt', statements: [
204
+ 'A denied observation proves only that this attempt was refused. It does not show every execution of this path is blocked.',
205
+ ] };
206
+ }
207
+ if (base.observedEdgeIds.length) {
208
+ return { ...base, status: 'edge_partial', statements: [
209
+ 'Some edges were observed, but not within one compatible trace or session. Independently observed edges do not establish an end-to-end execution.',
210
+ ] };
211
+ }
212
+ if (base.candidateEdgeIds.length) {
213
+ return { ...base, status: 'candidate_only', statements: [
214
+ 'Only low-confidence or ambiguous mappings were seen. They remain candidates and are not counted as observed.',
215
+ ] };
216
+ }
217
+ return { ...base, status: 'no_observation_in_window', statements: [
218
+ 'Telemetry was consulted and nothing mapped to this path in the window. That is not evidence that the path is unreachable or safe.',
219
+ ] };
220
+ }
221
+
222
+ /**
223
+ * R10.6: external proof references are bound to the sink, the value and the configuration. A reference that is
224
+ * stale or mismatched is REJECTED with its reasons; it is never re-attached to the changed graph.
225
+ */
226
+ export function bindExploitProofs(proofs, expected) {
227
+ const bound = []; const rejected = [];
228
+ for (const [i, p] of arr(proofs).entries()) {
229
+ const id = isObj(p) && typeof p.id === 'string' && REF_RE.test(p.id) ? p.id : `proof-${i}`;
230
+ if (!isObj(p)) { rejected.push({ id, reasons: ['not-an-object'] }); continue; }
231
+ const reasons = [];
232
+ if (!['proof', 'dnr-hunt', 'incident'].includes(p.kind)) reasons.push('unknown-kind');
233
+ if (!p.sinkRef || !p.dataElementId || !p.graphDigest || !p.analysisConfigDigest) reasons.push('unbound');
234
+ else {
235
+ if (p.sinkRef !== expected.sinkRef) reasons.push('sink-mismatch');
236
+ if (p.dataElementId !== expected.dataElementId) reasons.push('value-mismatch');
237
+ if (p.graphDigest !== expected.graphDigest) reasons.push('stale-graph');
238
+ if (p.analysisConfigDigest !== expected.analysisConfigDigest) reasons.push('configuration-mismatch');
239
+ }
240
+ if (reasons.length) rejected.push({ id, reasons });
241
+ else bound.push({ id, kind: p.kind, executionBacked: p.kind === 'proof', note: typeof p.note === 'string' ? redactString(p.note).slice(0, 200) : null });
242
+ }
243
+ bound.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
244
+ rejected.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
245
+ // Rejected references stay listed with their reasons whatever the status; they are never silently dropped.
246
+ return { status: bound.length ? 'bound' : rejected.length ? 'rejected' : 'none', bound, rejected };
247
+ }
248
+
249
+ /** Validate the nullable DAG-04 slot. Returns { ok, errors }. */
250
+ export function validateControlEvidence(ce, pathEdgeIds) {
251
+ if (ce === null) return { ok: true, errors: [] };
252
+ const errors = [];
253
+ if (!isObj(ce)) return { ok: false, errors: ['control evidence must be an object or null'] };
254
+ if (!FEASIBILITY_STATUSES.includes(ce.status) || ce.status === 'not_assessed') errors.push('control evidence status must be feasible, infeasible or unknown');
255
+ const set = new Set(pathEdgeIds);
256
+ for (const k of ['predicateRefs', 'controlRefs']) {
257
+ if (!Array.isArray(ce[k])) { errors.push(`${k} must be an array`); continue; }
258
+ for (const r of ce[k]) {
259
+ if (!isObj(r) || !set.has(r.edgeId) || typeof r.ref !== 'string' || !REF_RE.test(r.ref)) errors.push(`${k} entry must name an edge on this path and an identifier-shaped ref`);
260
+ }
261
+ }
262
+ return { ok: errors.length === 0, errors };
263
+ }
264
+
265
+ /**
266
+ * The four statuses, side by side. Returns exactly the four keys in EVIDENCE_STATUS_KEYS and nothing that
267
+ * ranks, scores or combines them.
268
+ */
269
+ export function buildEvidenceStatuses({ pathComplete, controlEvidence, runtime, exploit }) {
270
+ return {
271
+ modeledPath: { status: pathComplete ? 'connected_complete' : 'connected_partial' },
272
+ feasibility: { status: controlEvidence ? controlEvidence.status : 'not_assessed' },
273
+ runtimeSupport: { status: runtime.status },
274
+ exploitProof: { status: exploit.status },
275
+ };
276
+ }
@@ -339,6 +339,8 @@ export function normalizeFindings(scan){
339
339
  // (posture/proof-tier.js) actually attached it. Never synthesised here;
340
340
  // a finding with no proof backing simply omits these fields.
341
341
  ...(f.proofTier !== undefined ? { proofTier: f.proofTier } : {}),
342
+ // DAG-05 R05.7: whether this finding was replayed from the cross-scan cache or analysed in this scan; present only when the cache was in use.
343
+ ...(f.cacheProvenance !== undefined ? { cacheProvenance: f.cacheProvenance } : {}),
342
344
  ...(f.proofEvidence !== undefined ? { proofEvidence: f.proofEvidence } : {}),
343
345
  // X-206: the version-1 verification record and its shared projection, ADDITIVE and present only when the finding carries
344
346
  // a record (a plain scan output is byte-identical). `verificationReplay` is the typed prerequisite list of a replay attempt.