@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
|
@@ -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
|
+
}
|
package/src/report/index.js
CHANGED
|
@@ -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.
|
package/src/sast/CLAUDE.md
CHANGED
|
@@ -39,6 +39,8 @@ Both key on the SECRET-NESS of the identifier, never on the comparison or the ty
|
|
|
39
39
|
|
|
40
40
|
**Bench-shape adapters (label-leakage, OFF by default)** — `bench-shape/` directory, plus `cpp-bench-extras.js`, `java-bench-extras.js`, `juliet-shape.js`, `primary-cwe-java.js`. These read Juliet / OWASP-Benchmark answer keys. Disabled unless `AGENTIC_SECURITY_BENCH_SHAPE=1`; `AGENTIC_SECURITY_BLIND_BENCH=1` overrides to force everything off. **Never use their emissions as a quality signal.**
|
|
41
41
|
|
|
42
|
+
**Precision guards (bench/dag).** Small, general recognisers that answer one semantic question each about the TEXT of a call, shared by every emitter that can fire on it. Each is tested as a pair (the safe construct stays quiet, the near-miss that changes the semantics keeps its verdict) in `test/dag-precision.test.js` and `bench/mutation/cases.mjs` (`DAG_PRECISION_CASES`). `_call-args.js` (quote- and bracket-aware argument splitter; a window that ends inside the call yields the arguments completed so far, flagged `partial`), `redirect-target.js` (CWE-601: a literal prefix that fixes the origin, `'/user/' + id` or `'https://host/' + p`, cannot be steered off-origin; `'/' + x`, `'//' + x` and a host with no closing slash can), `reflected-input.js` (CWE-79: only the properties of `req` that carry the client's input count, and not inside an HTML escaper; `req.session`/`req.user`/`req.app` are server state), `send-file-root.js` (CWE-22: `sendFile`/`download` with an inline `root` option confine the path), `init-scope.js` (CWE-400: is a position inside a function body, including expression-bodied arrows and class bodies, or at module load; a block is a function unless it is positively a control block or a literal, because the error that matters is hiding a finding), `listen-bind-address.js` (the `address` a `listen` callback receives is the bound socket address, not personal data). Wired in `../engine.js` (`dropGuardedFindings` for redirect and file serving, the `predicate` hook of the structural patterns for reflection and blocking I/O, `_detectSafeSinkShape`), `sensitive-data-logging.js` and `../dataflow/privacy-taint.js`. `engine.js` `_usedIdentifiers` is the same idea for the flow-pair detector: words inside string literals and property names after a dot are not reads of a variable of that name.
|
|
43
|
+
|
|
42
44
|
**Helpers** — `_comment-strip.js` strips comments while preserving line numbers (so finding `line` stays accurate). `index.js` is the public re-export.
|
|
43
45
|
|
|
44
46
|
## Gotchas
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Argument splitting for guard recognisers that must look at WHAT a call is
|
|
2
|
+
// given, not at the text around it. Quote- and bracket-aware; handles template
|
|
3
|
+
// literal substitutions. Not a parser: it answers "where does each top-level
|
|
4
|
+
// argument start and end" for text that begins right after a call's '('.
|
|
5
|
+
|
|
6
|
+
const isQuote = (c) => c === '"' || c === "'" || c === '`';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Split the text that follows a call's opening paren into its top-level
|
|
10
|
+
* arguments (source text, quotes intact). Returns null when the closing paren
|
|
11
|
+
* is not in the text given, so a caller never reasons about a truncated call.
|
|
12
|
+
*/
|
|
13
|
+
export function callArguments(text) {
|
|
14
|
+
const args = [];
|
|
15
|
+
let depth = 0;
|
|
16
|
+
let start = 0;
|
|
17
|
+
let i = 0;
|
|
18
|
+
while (i < text.length) {
|
|
19
|
+
const c = text[i];
|
|
20
|
+
if (isQuote(c)) {
|
|
21
|
+
const q = c;
|
|
22
|
+
i++;
|
|
23
|
+
while (i < text.length && text[i] !== q) {
|
|
24
|
+
if (text[i] === '\\') { i += 2; continue; }
|
|
25
|
+
if (q === '`' && text[i] === '$' && text[i + 1] === '{') {
|
|
26
|
+
let d = 1; i += 2;
|
|
27
|
+
while (i < text.length && d > 0) { if (text[i] === '{') d++; else if (text[i] === '}') d--; i++; }
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
36
|
+
else if (c === ')' || c === ']' || c === '}') {
|
|
37
|
+
if (depth === 0) {
|
|
38
|
+
const last = text.slice(start, i);
|
|
39
|
+
if (last.trim() !== '' || args.length > 0) args.push(last);
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
depth--;
|
|
43
|
+
} else if (c === ',' && depth === 0) {
|
|
44
|
+
args.push(text.slice(start, i));
|
|
45
|
+
start = i + 1;
|
|
46
|
+
}
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
// The window ended inside the call (a long callback, a wrapped call). The
|
|
50
|
+
// arguments completed so far are still exactly what the call was given; the
|
|
51
|
+
// unfinished last one is dropped rather than guessed. `partial` lets a caller
|
|
52
|
+
// that needs the whole call refuse.
|
|
53
|
+
if (args.length === 0) return null;
|
|
54
|
+
args.partial = true;
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find the first call on or just after `line` (1-based) whose callee matches
|
|
60
|
+
* `calleeRe` (which must end at the opening paren) and return its arguments.
|
|
61
|
+
* Looks at the finding line and the next three, because a call is routinely
|
|
62
|
+
* wrapped. Returns null when there is no such call or it is not closed.
|
|
63
|
+
*/
|
|
64
|
+
export function callArgumentsAt(lines, line, calleeRe) {
|
|
65
|
+
if (!Array.isArray(lines) || !Number.isInteger(line) || line < 1) return null;
|
|
66
|
+
const text = lines.slice(line - 1, line + 3).join('\n');
|
|
67
|
+
const m = calleeRe.exec(text);
|
|
68
|
+
if (!m) return null;
|
|
69
|
+
return callArguments(text.slice(m.index + m[0].length));
|
|
70
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Is a source position inside a function body, or at module/initialisation
|
|
2
|
+
// level? Some hazards exist only while the process is serving work: a blocking
|
|
3
|
+
// call made once at module load (reading a TLS key, a config file) delays
|
|
4
|
+
// startup once, and nothing is waiting on the event loop yet. The same call in
|
|
5
|
+
// a request callback stalls every connection.
|
|
6
|
+
//
|
|
7
|
+
// A position is inside a function when it is inside
|
|
8
|
+
// * a `{ ... }` block opened by `=>`, a `function` header, a method or class
|
|
9
|
+
// member header (`name(params) {`), or an async/generator form,
|
|
10
|
+
// * the body of an expression-bodied arrow (`(p) => fs.readFileSync(p)`),
|
|
11
|
+
// which runs when the arrow is CALLED, not where it is written, or
|
|
12
|
+
// * a class body (field initialisers run per instance).
|
|
13
|
+
// Plain blocks (`if`, `for`, `try`, `else`) and object literals are not function scopes.
|
|
14
|
+
//
|
|
15
|
+
// Operates on a comment- and string-blanked view so braces in strings or
|
|
16
|
+
// comments never count. It is a lexical approximation (no parse). The one error
|
|
17
|
+
// that matters is calling a function body "module level", because that hides a
|
|
18
|
+
// finding, so a block is classed as a function unless it is positively a control
|
|
19
|
+
// block or a literal, and parameter lists are matched by bracket depth, not by a
|
|
20
|
+
// pattern that stops at a fixed nesting.
|
|
21
|
+
|
|
22
|
+
const CONTROL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'with', 'else', 'try', 'finally', 'do']);
|
|
23
|
+
const CLASS_HEAD = /\bclass\b\s*[\w$]*(?:\s+extends\s+[\w$.()]+)?\s*$/;
|
|
24
|
+
|
|
25
|
+
/** Index of the '(' matching the ')' at `closeIdx`, scanning backwards; -1 if unbalanced. */
|
|
26
|
+
function matchingOpenParen(text, closeIdx) {
|
|
27
|
+
let depth = 0;
|
|
28
|
+
for (let i = closeIdx; i >= 0; i--) {
|
|
29
|
+
const c = text[i];
|
|
30
|
+
if (c === ')') depth++;
|
|
31
|
+
else if (c === '(') { depth--; if (depth === 0) return i; }
|
|
32
|
+
}
|
|
33
|
+
return -1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function opensFunction(text, braceIdx) {
|
|
37
|
+
let end = braceIdx;
|
|
38
|
+
while (end > 0 && /\s/.test(text[end - 1])) end--;
|
|
39
|
+
const before = text.slice(Math.max(0, end - 400), end);
|
|
40
|
+
if (/=>$/.test(before)) return true;
|
|
41
|
+
if (CLASS_HEAD.test(before)) return true;
|
|
42
|
+
// `else {`, `try {`, `finally {`, `do {`, `catch {` carry no parenthesised head
|
|
43
|
+
const bare = /(?:^|[^\w$.])([A-Za-z_$][\w$]*)$/.exec(before);
|
|
44
|
+
if (bare && CONTROL_KEYWORDS.has(bare[1])) return false;
|
|
45
|
+
// find the ')' that ends a parameter list, allowing a return type annotation `): Type {`
|
|
46
|
+
let closeIdx = end - 1;
|
|
47
|
+
if (text[closeIdx] !== ')') {
|
|
48
|
+
const m = /\)\s*:\s*[^(){};=]*$/.exec(before);
|
|
49
|
+
if (!m) return false;
|
|
50
|
+
closeIdx = end - before.length + m.index;
|
|
51
|
+
}
|
|
52
|
+
const openIdx = matchingOpenParen(text, closeIdx);
|
|
53
|
+
if (openIdx < 0) return true; // cannot match the head: keep findings, so call it a function
|
|
54
|
+
const head = text.slice(Math.max(0, openIdx - 80), openIdx).replace(/\s+$/, '');
|
|
55
|
+
const word = /([A-Za-z_$][\w$]*)$/.exec(head);
|
|
56
|
+
if (word) return !CONTROL_KEYWORDS.has(word[1]); // `if (` `while (` ...: control; any other name( ... ) { is a function or method
|
|
57
|
+
if (/\bfunction\s*\*?$/.test(head)) return true; // `function (` and `function* (`
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} blanked source with comments and string bodies blanked
|
|
63
|
+
* @param {number} offset index into `blanked`
|
|
64
|
+
* @returns {boolean} true when `offset` is lexically inside a function body
|
|
65
|
+
*/
|
|
66
|
+
export function insideFunctionBody(blanked, offset) {
|
|
67
|
+
// entries: 'F' function block, 'B' other block, 'P' paren/bracket, 'A' expression-bodied arrow
|
|
68
|
+
const stack = [];
|
|
69
|
+
const end = Math.min(offset, blanked.length);
|
|
70
|
+
for (let i = 0; i < end; i++) {
|
|
71
|
+
const c = blanked[i];
|
|
72
|
+
if (c === '{') stack.push(opensFunction(blanked, i) ? 'F' : 'B');
|
|
73
|
+
else if (c === '(' || c === '[') stack.push('P');
|
|
74
|
+
else if (c === '}' || c === ')' || c === ']') {
|
|
75
|
+
while (stack.length && stack[stack.length - 1] === 'A') stack.pop(); // an arrow body ends where its enclosing bracket closes
|
|
76
|
+
stack.pop();
|
|
77
|
+
} else if ((c === ',' || c === ';') && stack[stack.length - 1] === 'A') {
|
|
78
|
+
stack.pop(); // ... or at a separator at its own nesting level
|
|
79
|
+
} else if (c === '=' && blanked[i + 1] === '>') {
|
|
80
|
+
let j = i + 2;
|
|
81
|
+
while (j < blanked.length && /\s/.test(blanked[j])) j++;
|
|
82
|
+
if (blanked[j] !== '{') stack.push('A');
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return stack.some((e) => e === 'F' || e === 'A');
|
|
87
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The `address` a `listen` callback receives.
|
|
2
|
+
//
|
|
3
|
+
// server.listen({ port }, (err, address) => console.log(`listening on ${address}`))
|
|
4
|
+
//
|
|
5
|
+
// `address` here is the socket address the server just bound (host and port),
|
|
6
|
+
// handed to the callback by the server. It is not a person's postal address, so a
|
|
7
|
+
// rule that classifies a value by the NAME `address` must not treat this binding
|
|
8
|
+
// as personal data. The rule is about the binding site, not about the word: the
|
|
9
|
+
// same name bound anywhere else (a form field, a column, a request property) is
|
|
10
|
+
// unaffected.
|
|
11
|
+
|
|
12
|
+
/** Index just past the paren that closes the one opened at `openIdx`, or -1 when the text ends first. */
|
|
13
|
+
function closeOfParen(text, openIdx) {
|
|
14
|
+
let depth = 0;
|
|
15
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
16
|
+
const c = text[i];
|
|
17
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
18
|
+
const q = c; i++;
|
|
19
|
+
while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++; }
|
|
20
|
+
} else if (c === '(') depth++;
|
|
21
|
+
else if (c === ')') { depth--; if (depth === 0) return i + 1; }
|
|
22
|
+
}
|
|
23
|
+
return -1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Is the identifier at `idx` in `raw` a parameter declared by a callback passed
|
|
28
|
+
* to a `.listen(` call? `idx` is any position at or after the callback header
|
|
29
|
+
* (the use site); the header is searched backwards within a bounded window, and
|
|
30
|
+
* must sit inside the listen call's own parentheses, so a function declared after
|
|
31
|
+
* the call has closed does not count.
|
|
32
|
+
*/
|
|
33
|
+
export function isListenCallbackParam(raw, idx, name) {
|
|
34
|
+
if (typeof raw !== 'string' || !name) return false;
|
|
35
|
+
const from = Math.max(0, idx - 800);
|
|
36
|
+
const at = raw.slice(from, idx).lastIndexOf('.listen(');
|
|
37
|
+
if (at < 0) return false;
|
|
38
|
+
const start = from + at;
|
|
39
|
+
const openIdx = start + '.listen'.length;
|
|
40
|
+
const windowEnd = idx + name.length + 80;
|
|
41
|
+
const close = closeOfParen(raw.slice(start, windowEnd), openIdx - start);
|
|
42
|
+
// the call closed before the use site: the use is not inside the callback
|
|
43
|
+
if (close !== -1 && start + close <= idx) return false;
|
|
44
|
+
const after = raw.slice(start, close === -1 ? windowEnd : Math.min(windowEnd, start + close));
|
|
45
|
+
const id = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
46
|
+
const arrow = new RegExp(String.raw`\([^)]*\b${id}\b[^)]*\)\s*=>|\b${id}\s*=>`);
|
|
47
|
+
const fn = new RegExp(String.raw`\bfunction\b[^(]*\([^)]*\b${id}\b[^)]*\)`);
|
|
48
|
+
return arrow.test(after) || fn.test(after);
|
|
49
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Open-redirect target shape (CWE-601): can the part of a redirect target the
|
|
2
|
+
// program does not control change the ORIGIN the browser is sent to?
|
|
3
|
+
//
|
|
4
|
+
// A redirect target is attacker-steerable to another origin only if the
|
|
5
|
+
// attacker controls the authority (scheme, host, port) of the resulting URL.
|
|
6
|
+
// A target that begins with a literal which already fixes the authority cannot
|
|
7
|
+
// be moved off-origin by whatever is appended after it:
|
|
8
|
+
//
|
|
9
|
+
// '/user/' + id path-absolute, second character is a name
|
|
10
|
+
// character: same origin, whatever `id` is
|
|
11
|
+
// 'https://app.example.com/' + p the whole authority is in the literal and a
|
|
12
|
+
// '/' closes it, so `p` is only path/query
|
|
13
|
+
//
|
|
14
|
+
// and these are NOT pinned, because the dynamic part decides the authority:
|
|
15
|
+
//
|
|
16
|
+
// '/' + x x = '/evil.example' gives '//evil.example'
|
|
17
|
+
// '//' + x, '/\\' + x protocol-relative (browsers treat '\' as '/')
|
|
18
|
+
// 'https://app.example.com' + x x = '.evil.example' or '@evil.example'
|
|
19
|
+
// '' + x, x, x + '/path' no literal prefix at all
|
|
20
|
+
//
|
|
21
|
+
// This is deliberately the ONLY thing decided here. It reads the text of the
|
|
22
|
+
// redirect call's first argument; it does not decide whether the dynamic part
|
|
23
|
+
// is attacker-controlled (that is the taint layer's question) and it never
|
|
24
|
+
// reads a file name, a fixture name or a label.
|
|
25
|
+
|
|
26
|
+
import { callArgumentsAt } from './_call-args.js';
|
|
27
|
+
|
|
28
|
+
const REDIRECT_CALL_RE = /\b(?:redirect|sendRedirect|Redirect|redirect_to|HttpResponseRedirect|RedirectResponse|redirectTo|permanentRedirect)\s*\(/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Leading literal text of an expression, and whether anything dynamic follows.
|
|
32
|
+
* Handles 'a' + x, "a" + x + y, `a${x}`, and a lone literal.
|
|
33
|
+
* `literal` is the decoded-enough prefix (escapes kept verbatim, which is safe
|
|
34
|
+
* here because the checks below only look at slashes and the first characters).
|
|
35
|
+
*/
|
|
36
|
+
export function leadingLiteral(expr) {
|
|
37
|
+
const s = String(expr).trim();
|
|
38
|
+
let i = 0;
|
|
39
|
+
let prefix = '';
|
|
40
|
+
let dynamic = false;
|
|
41
|
+
for (;;) {
|
|
42
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
43
|
+
if (i >= s.length) break;
|
|
44
|
+
const c = s[i];
|
|
45
|
+
if (c === '"' || c === "'") {
|
|
46
|
+
let j = i + 1; let body = '';
|
|
47
|
+
while (j < s.length && s[j] !== c) { if (s[j] === '\\') { body += s[j] + (s[j + 1] || ''); j += 2; } else body += s[j++]; }
|
|
48
|
+
if (j >= s.length) return { literal: prefix, dynamic: true, closed: false };
|
|
49
|
+
prefix += body; i = j + 1;
|
|
50
|
+
} else if (c === '`') {
|
|
51
|
+
let j = i + 1; let body = '';
|
|
52
|
+
while (j < s.length && s[j] !== '`') {
|
|
53
|
+
if (s[j] === '\\') { body += s[j] + (s[j + 1] || ''); j += 2; continue; }
|
|
54
|
+
if (s[j] === '$' && s[j + 1] === '{') { dynamic = true; break; }
|
|
55
|
+
body += s[j++];
|
|
56
|
+
}
|
|
57
|
+
prefix += body;
|
|
58
|
+
if (dynamic) return { literal: prefix, dynamic: true, closed: true };
|
|
59
|
+
if (j >= s.length) return { literal: prefix, dynamic: true, closed: false };
|
|
60
|
+
i = j + 1;
|
|
61
|
+
} else {
|
|
62
|
+
return { literal: prefix, dynamic: true, closed: true };
|
|
63
|
+
}
|
|
64
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
65
|
+
if (i >= s.length) return { literal: prefix, dynamic, closed: true };
|
|
66
|
+
if (s[i] === '+') { i++; continue; }
|
|
67
|
+
// anything else after a literal (a call suffix, `.concat`, `%`, ...): not a plain literal
|
|
68
|
+
return { literal: prefix, dynamic: true, closed: true };
|
|
69
|
+
}
|
|
70
|
+
return { literal: prefix, dynamic, closed: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A literal prefix that fixes the origin of the URL it begins. */
|
|
74
|
+
export function prefixPinsOrigin(prefix) {
|
|
75
|
+
// Path-absolute: '/' then a character that is neither '/' nor '\' (both are
|
|
76
|
+
// read as a second slash, turning the reference into a network-path one).
|
|
77
|
+
if (/^\/[^\/\\\s]/.test(prefix)) return true;
|
|
78
|
+
// Absolute URL whose authority is complete: scheme://authority/ .
|
|
79
|
+
if (/^[A-Za-z][A-Za-z0-9+.\-]*:\/\/[^\/?#\\\s]+\//.test(prefix)) return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Does the redirect argument keep the destination on a fixed origin?
|
|
85
|
+
* `arg` is the first argument of the redirect call. A numeric first argument
|
|
86
|
+
* (`res.redirect(302, url)`) is skipped by the caller, not here.
|
|
87
|
+
*/
|
|
88
|
+
export function redirectArgumentPinsOrigin(arg) {
|
|
89
|
+
const { literal, dynamic, closed } = leadingLiteral(arg);
|
|
90
|
+
if (!closed) return false;
|
|
91
|
+
if (!dynamic) return literal.length > 0; // a fully static target
|
|
92
|
+
return prefixPinsOrigin(literal);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Inspect source lines starting at `line` (1-based) for a redirect call and
|
|
97
|
+
* decide whether its target pins the origin. Returns null when no redirect
|
|
98
|
+
* call is found (no opinion), else true/false.
|
|
99
|
+
*/
|
|
100
|
+
export function redirectTargetPinsOriginAt(lines, line) {
|
|
101
|
+
const args = callArgumentsAt(lines, line, REDIRECT_CALL_RE);
|
|
102
|
+
if (!args || args.length === 0) return null;
|
|
103
|
+
// res.redirect(302, '/x'): the status code is not the target
|
|
104
|
+
const target = args.length > 1 && /^\s*\d{3}\s*$/.test(args[0]) ? args[1] : args[0];
|
|
105
|
+
return redirectArgumentPinsOrigin(target);
|
|
106
|
+
}
|