@clear-capabilities/agentic-security-scanner 0.151.3 → 0.152.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/dist/agentic-security.mjs +80 -80
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +5 -5
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +802 -13
- package/src/dataflow/engine.js +852 -89
- package/src/dataflow/incremental.js +13 -0
- package/src/dataflow/index.js +18 -5
- package/src/dataflow/path-feasibility.js +86 -9
- package/src/dataflow/string-domain.js +132 -0
- package/src/dataflow/summaries.js +69 -8
- package/src/engine.js +108 -3
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/balanced-call.js +33 -1
- package/src/ir/callgraph.js +26 -2
- package/src/ir/class-hierarchy.js +101 -6
- package/src/ir/csharp-ir.js +97 -8
- package/src/ir/index.js +21 -1
- package/src/ir/parser-cs.js +706 -24
- package/src/ir/parser-java.js +498 -17
- package/src/ir/parser-js.js +22 -0
- package/src/ir/parser-php.js +446 -43
- package/src/ir/parser-rust.js +1338 -0
- package/src/ir/php-include-merge.js +190 -0
- package/src/lineage/sink-registry.js +18 -0
- package/src/lineage/source-registry.js +23 -0
- package/src/posture/csharp-analysis.js +341 -43
- package/src/posture/finding-defaults.js +5 -1
- package/src/posture/fix-coverage.js +1 -0
- package/src/posture/proof-coverage.js +1 -0
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/authz.js +144 -1
- package/src/sast/crypto-protocol.js +65 -7
- package/src/sast/csharp-cross-file.js +125 -0
- package/src/sast/csharp-structural.js +78 -2
- package/src/sast/csharp.js +574 -42
- package/src/sast/java-ast-folding.js +268 -11
- package/src/sast/java-bench-extras.js +530 -2
- package/src/sast/java-deserialization.js +31 -2
- package/src/sast/java-structural-cross-file.js +146 -0
- package/src/sast/java-structural.js +499 -6
- package/src/sast/ldap-injection.js +133 -3
- package/src/sast/rust.js +57 -0
- package/src/sast/xss-reflected-multilang.js +252 -3
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// PHP cross-file `include`/`require` scope merging.
|
|
2
|
+
//
|
|
3
|
+
// SARD_80_F1 W5.7 — root-caused in W5.5: PHP's `include`/`require`/
|
|
4
|
+
// `include_once`/`require_once` was previously modeled PURELY as a CWE-98
|
|
5
|
+
// (LFI/RFI) SINK (`__php_include__` in parser-php.js, checked for a tainted
|
|
6
|
+
// PATH argument) — the included FILE'S OWN top-level variable assignments
|
|
7
|
+
// were never parsed and merged into the including file's scope. Real PHP
|
|
8
|
+
// semantics: an `include`d file's top-level code executes IN THE CALLER'S
|
|
9
|
+
// OWN VARIABLE SCOPE (unlike an ordinary function call, which gets its own
|
|
10
|
+
// local scope) — so a two-file split (`source.php`: `$tainted =
|
|
11
|
+
// $_GET['x'];` / `main.php`: `include "source.php"; sink($tainted);`) is
|
|
12
|
+
// genuinely, structurally invisible to a taint engine that treats `include`
|
|
13
|
+
// as an opaque sink call. Confirmed via the SARD PHP generator's own public
|
|
14
|
+
// source (`stivalet/PHP-Vuln-test-suite-generator`), which can emit any
|
|
15
|
+
// test case's source/sanitizer/construction component as its OWN separate
|
|
16
|
+
// file connected via exactly this shape.
|
|
17
|
+
//
|
|
18
|
+
// This module runs AFTER buildProjectIR's per-file parse loop (both the
|
|
19
|
+
// sync and async builders — PHP needs neither Java's async parser nor
|
|
20
|
+
// per-language ordering, so one shared post-pass covers both) and mutates
|
|
21
|
+
// each PHP file's `topLevel` function's CFG in place: for every
|
|
22
|
+
// `__php_include__` call site whose path argument is a LITERAL string that
|
|
23
|
+
// resolves to another file already in `perFile`, the included file's own
|
|
24
|
+
// top-level ASSIGN nodes are cloned and spliced into the includer's CFG
|
|
25
|
+
// immediately before the include call site.
|
|
26
|
+
//
|
|
27
|
+
// Deliberately scoped, matching W5.5's own stated caution:
|
|
28
|
+
// - Only LITERAL include paths are resolved. A dynamic/conditional path
|
|
29
|
+
// (`include $_GET['page'] . '.php';`) is left entirely alone — untainted
|
|
30
|
+
// for taint purposes) — over-approximating scope-merging content we
|
|
31
|
+
// genuinely can't rule out for a target we can't even name.
|
|
32
|
+
// - Control flow INSIDE the included file is flattened to a straight-line
|
|
33
|
+
// sequence of its own assign nodes (in CFG-order from its own entry),
|
|
34
|
+
// the same "over-approximate rather than model precisely" tradeoff this
|
|
35
|
+
// taint model already makes elsewhere (recall-preserving: a value that
|
|
36
|
+
// MIGHT be tainted along some path in the included file becomes
|
|
37
|
+
// unconditionally tainted after the splice, never LESS tainted than
|
|
38
|
+
// reality).
|
|
39
|
+
// - The include call node ITSELF is kept, unchanged, after the spliced
|
|
40
|
+
// chain — its own path-argument taint check (the pre-existing CWE-98
|
|
41
|
+
// sink) still runs exactly as before. This module is purely additive.
|
|
42
|
+
// - Cycle-guarded (a visited-file set per top-level merge) and depth-capped
|
|
43
|
+
// (`maxDepth`), so a mutual-include pair or a long chain can't recurse
|
|
44
|
+
// unboundedly.
|
|
45
|
+
// - Node IDs are per-file counters (reset to `pn1`, `pn2`, ... for every
|
|
46
|
+
// file parsed), so every cloned node gets a fresh, namespaced ID — never
|
|
47
|
+
// naively reusing the source file's own IDs, which would collide with
|
|
48
|
+
// the includer's own nodes or a second include of the same file.
|
|
49
|
+
//
|
|
50
|
+
// Known, accepted tradeoff: if the includer and the included file happen to
|
|
51
|
+
// use the SAME variable name for unrelated purposes, the splice can taint
|
|
52
|
+
// the includer's own pre-existing variable of that name. This mirrors the
|
|
53
|
+
// real PHP semantics being modeled (an actual `include` really does share
|
|
54
|
+
// the variable namespace), so it is not a modeling error — genuine
|
|
55
|
+
// real-world PHP code that reuses generic names across included files has
|
|
56
|
+
// exactly this exposure at runtime too.
|
|
57
|
+
|
|
58
|
+
import path from 'node:path';
|
|
59
|
+
|
|
60
|
+
let _mergeCounter = 0;
|
|
61
|
+
|
|
62
|
+
// Test-only reset so the namespace counter doesn't leak between test files.
|
|
63
|
+
export function _resetPhpIncludeMergeCounter() {
|
|
64
|
+
_mergeCounter = 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _literalPathOf(argExpr) {
|
|
68
|
+
if (!argExpr || argExpr.kind !== 'literal' || typeof argExpr.value !== 'string') return null;
|
|
69
|
+
const v = argExpr.value;
|
|
70
|
+
const m = /^(["'])((?:[^\\]|\\.)*)\1$/.exec(v);
|
|
71
|
+
if (!m) return null;
|
|
72
|
+
// Unescape the handful of sequences PHP double-quoted strings support in
|
|
73
|
+
// a bare filename; single-quoted strings only ever need `\\` and `\'`.
|
|
74
|
+
return m[2].replace(/\\(.)/g, '$1');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Candidate resolved paths for a literal include argument, relative to the
|
|
78
|
+
// includer's own directory (PHP's `include` resolves relative to the
|
|
79
|
+
// including SCRIPT'S directory by default, absent an include_path entry —
|
|
80
|
+
// exactly the SARD generator's own convention, a bare sibling filename).
|
|
81
|
+
function _resolveCandidates(includerFile, literalPath) {
|
|
82
|
+
if (!literalPath || path.posix.isAbsolute(literalPath)) return [];
|
|
83
|
+
const dir = path.posix.dirname(includerFile.replace(/\\/g, '/'));
|
|
84
|
+
const joined = path.posix.normalize(path.posix.join(dir, literalPath));
|
|
85
|
+
const candidates = [joined];
|
|
86
|
+
if (!/\.(?:php|phtml)$/i.test(joined)) candidates.push(`${joined}.php`);
|
|
87
|
+
return candidates;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// BFS node-id order from `cfg.entry`, so cloned assign nodes preserve the
|
|
91
|
+
// included file's own true execution order. Cycle-safe (a `seen` set) even
|
|
92
|
+
// though a straight-line module CFG should never actually cycle.
|
|
93
|
+
function _orderedNodeIds(cfg) {
|
|
94
|
+
const order = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
const queue = [cfg.entry];
|
|
97
|
+
while (queue.length) {
|
|
98
|
+
const id = queue.shift();
|
|
99
|
+
if (!id || seen.has(id) || !cfg.nodes[id]) continue;
|
|
100
|
+
seen.add(id);
|
|
101
|
+
order.push(id);
|
|
102
|
+
for (const s of cfg.nodes[id].succ || []) queue.push(s);
|
|
103
|
+
}
|
|
104
|
+
return order;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function _link(nodes, src, dst) {
|
|
108
|
+
if (!nodes[src] || !nodes[dst]) return;
|
|
109
|
+
if (!nodes[src].succ.includes(dst)) nodes[src].succ.push(dst);
|
|
110
|
+
if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _topLevelFn(ir) {
|
|
114
|
+
if (!ir || !ir.topLevel) return null;
|
|
115
|
+
return (ir.functions || []).find((f) => f.qid === ir.topLevel) || null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _mergeIncludesInFunction(fn, currentFile, perFile, visited, depthLeft) {
|
|
119
|
+
if (!fn || !fn.cfg || depthLeft <= 0) return;
|
|
120
|
+
const cfg = fn.cfg;
|
|
121
|
+
// Snapshot node ids up front — the loop body adds new nodes to cfg.nodes,
|
|
122
|
+
// and those must not themselves be re-scanned for (nonexistent) includes.
|
|
123
|
+
const ids = Object.keys(cfg.nodes);
|
|
124
|
+
for (const id of ids) {
|
|
125
|
+
const node = cfg.nodes[id];
|
|
126
|
+
if (!node || node.kind !== 'call' || node.callee !== '__php_include__') continue;
|
|
127
|
+
const literalPath = _literalPathOf((node.args || [])[0]);
|
|
128
|
+
if (!literalPath) continue; // dynamic/non-literal include path — out of scope, never mistaint
|
|
129
|
+
const candidates = _resolveCandidates(currentFile, literalPath);
|
|
130
|
+
const targetFile = candidates.find((c) => perFile[c]);
|
|
131
|
+
if (!targetFile || visited.has(targetFile)) continue;
|
|
132
|
+
const targetFn = _topLevelFn(perFile[targetFile]);
|
|
133
|
+
if (!targetFn || !targetFn.cfg) continue;
|
|
134
|
+
|
|
135
|
+
// Recurse into the included file's OWN includes first, so a transitive
|
|
136
|
+
// chain (A includes B, B includes C) sees C's assignments merged into
|
|
137
|
+
// B before B's are cloned into A.
|
|
138
|
+
const nextVisited = new Set(visited);
|
|
139
|
+
nextVisited.add(targetFile);
|
|
140
|
+
_mergeIncludesInFunction(targetFn, targetFile, perFile, nextVisited, depthLeft - 1);
|
|
141
|
+
|
|
142
|
+
const srcOrder = _orderedNodeIds(targetFn.cfg).filter(
|
|
143
|
+
(nid) => targetFn.cfg.nodes[nid].kind === 'assign',
|
|
144
|
+
);
|
|
145
|
+
if (!srcOrder.length) continue;
|
|
146
|
+
|
|
147
|
+
_mergeCounter++;
|
|
148
|
+
const ns = `phpinc${_mergeCounter}_`;
|
|
149
|
+
const clones = srcOrder.map((nid) => {
|
|
150
|
+
const orig = targetFn.cfg.nodes[nid];
|
|
151
|
+
const cloneId = ns + nid;
|
|
152
|
+
cfg.nodes[cloneId] = {
|
|
153
|
+
kind: 'assign',
|
|
154
|
+
line: orig.line,
|
|
155
|
+
target: orig.target,
|
|
156
|
+
source: orig.source,
|
|
157
|
+
succ: [],
|
|
158
|
+
pred: [],
|
|
159
|
+
};
|
|
160
|
+
return cloneId;
|
|
161
|
+
});
|
|
162
|
+
for (let i = 0; i < clones.length - 1; i++) _link(cfg.nodes, clones[i], clones[i + 1]);
|
|
163
|
+
|
|
164
|
+
// Splice the clone chain in BEFORE the include call node: every current
|
|
165
|
+
// predecessor of the call node now points at the chain's first clone
|
|
166
|
+
// instead, and the chain's last clone points at the (unchanged) call
|
|
167
|
+
// node, whose own pred is reset to just that.
|
|
168
|
+
for (const p of [...node.pred]) {
|
|
169
|
+
const predNode = cfg.nodes[p];
|
|
170
|
+
if (!predNode) continue;
|
|
171
|
+
predNode.succ = predNode.succ.map((s) => (s === id ? clones[0] : s));
|
|
172
|
+
if (!cfg.nodes[clones[0]].pred.includes(p)) cfg.nodes[clones[0]].pred.push(p);
|
|
173
|
+
}
|
|
174
|
+
_link(cfg.nodes, clones[clones.length - 1], id);
|
|
175
|
+
node.pred = [clones[clones.length - 1]];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Mutates every PHP file's `topLevel` function's CFG in place. Call once,
|
|
180
|
+
// after every file in the project has already been parsed into `perFile`
|
|
181
|
+
// (both `buildProjectIR` and `buildProjectIRAsync` share this one pass).
|
|
182
|
+
export function mergePhpIncludes(perFile, opts = {}) {
|
|
183
|
+
const maxDepth = opts.maxDepth ?? 4;
|
|
184
|
+
for (const [file, ir] of Object.entries(perFile || {})) {
|
|
185
|
+
if (!/\.(?:php|phtml)$/i.test(file)) continue;
|
|
186
|
+
const fn = _topLevelFn(ir);
|
|
187
|
+
if (!fn) continue;
|
|
188
|
+
_mergeIncludesInFunction(fn, file, perFile, new Set([file]), maxDepth);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -138,6 +138,11 @@ export const CWE_MAP = Object.freeze({
|
|
|
138
138
|
'CWE-78': Object.freeze({ category: null, status: 'unsupported', why: 'shell/process execution — no FR-201 category models process execution' }),
|
|
139
139
|
'CWE-95': Object.freeze({ category: null, status: 'unsupported', why: 'code evaluation (eval/Function/exec/compile) — destination is an interpreter' }),
|
|
140
140
|
'CWE-94': Object.freeze({ category: null, status: 'unsupported', why: 'code injection / template compilation — destination is an interpreter or template engine' }),
|
|
141
|
+
// PHP SARD catalog addition: include/require executes the included file
|
|
142
|
+
// as PHP code — same in-process-computation shape as CWE-94/CWE-1336
|
|
143
|
+
// above (the destination is the PHP interpreter itself), no FR-201
|
|
144
|
+
// egress category models it either.
|
|
145
|
+
'CWE-98': Object.freeze({ category: null, status: 'unsupported', why: 'local/remote file inclusion — destination is the PHP interpreter executing the included file as code, not an FR-201 egress category' }),
|
|
141
146
|
'CWE-1336': Object.freeze({ category: null, status: 'unsupported', why: 'SSTI — destination is a template engine, not a data destination' }),
|
|
142
147
|
'CWE-502': Object.freeze({ category: null, status: 'unsupported', why: 'deserialization — destination is a deserializer' }),
|
|
143
148
|
'CWE-611': Object.freeze({ category: null, status: 'unsupported', why: 'XXE — destination is an XML parser' }),
|
|
@@ -146,6 +151,19 @@ export const CWE_MAP = Object.freeze({
|
|
|
146
151
|
'CWE-787': Object.freeze({ category: null, status: 'unsupported', why: 'out-of-bounds write — destination is raw memory' }),
|
|
147
152
|
'CWE-1333': Object.freeze({ category: null, status: 'unsupported', why: 'ReDoS — destination is the regex engine' }),
|
|
148
153
|
'CWE-114': Object.freeze({ category: null, status: 'unsupported', why: 'untrusted library load — destination is the dynamic loader' }),
|
|
154
|
+
// Rust catalog additions (SARD 80% F1 push).
|
|
155
|
+
'CWE-117': Object.freeze({ category: 'log', status: 'modeled', why: 'log injection — the sink IS a log-writing call (log!/tracing! macros)' }),
|
|
156
|
+
'CWE-770': Object.freeze({ category: null, status: 'unsupported', why: 'uncontrolled resource consumption — the sink is an in-process allocation, not an egress destination' }),
|
|
157
|
+
// SARD_80_F1_SCANNER_PRD.md Juliet Java audit: two CWEs newly added to
|
|
158
|
+
// CATALOG (Class.forName/Method.invoke reflection; String.format/printf
|
|
159
|
+
// uncontrolled format string). Both are in-process-computation
|
|
160
|
+
// destinations, same class as CWE-95/CWE-1333 above — no FR-201 egress
|
|
161
|
+
// category models either.
|
|
162
|
+
'CWE-470': Object.freeze({ category: null, status: 'unsupported', why: 'unsafe reflection — destination is the class loader / reflection API, not an FR-201 egress category' }),
|
|
163
|
+
'CWE-134': Object.freeze({ category: null, status: 'unsupported', why: 'uncontrolled format string — destination is the string formatter, not an FR-201 egress category' }),
|
|
164
|
+
// SARD 80% F1 push catalog additions (2026-09).
|
|
165
|
+
'CWE-81': Object.freeze({ category: 'http-response', status: 'modeled', why: 'reflected script in an error-message response (e.g. response.sendError/StatusDescription) is a response-header/body writer, the same destination as ordinary reflected XSS' }),
|
|
166
|
+
'CWE-91': Object.freeze({ category: null, status: 'unsupported', why: 'XML/XPath injection (SimpleXMLElement::xpath and equivalents) — destination is a query engine over an in-memory document, same reasoning as CWE-643' }),
|
|
149
167
|
});
|
|
150
168
|
|
|
151
169
|
// §5.2 — CWE-79's refinement key. FR-201 lumps "browser DOM or client
|
|
@@ -150,6 +150,10 @@ export const NO_PROVENANCE_OVERRIDES = Object.freeze({
|
|
|
150
150
|
'java-request-getCookies': 'http-cookie',
|
|
151
151
|
'java-request-getInputStream': 'http-body',
|
|
152
152
|
'java-request-getReader': 'http-body',
|
|
153
|
+
// SARD_80_F1_SCANNER_PRD.md Juliet Java audit
|
|
154
|
+
'java-request-getAttribute': 'http-query', // request-scoped attribute bag, same request-derived shape as getParameter
|
|
155
|
+
'java-request-getQueryString': 'http-query',
|
|
156
|
+
'java-cookie-getvalue': 'http-cookie',
|
|
153
157
|
// Java / stdlib
|
|
154
158
|
'java-system-getenv': 'env-value',
|
|
155
159
|
'java-system-getProperty': 'env-value', // JVM system properties, env-adjacent
|
|
@@ -159,6 +163,18 @@ export const NO_PROVENANCE_OVERRIDES = Object.freeze({
|
|
|
159
163
|
// vocabulary for exactly this shape.
|
|
160
164
|
'java-resultset-getstring': 'database-read',
|
|
161
165
|
'java-resultset-getobject': 'database-read',
|
|
166
|
+
// C# / stdlib console+stream reads and ADO.NET result-set reads — same
|
|
167
|
+
// two shapes as java-io-readline/java-resultset-getstring above, added by
|
|
168
|
+
// a separate SARD_80_F1_SCANNER_PRD.md catalog push. catalog.js
|
|
169
|
+
// deliberately declares no `provenance` field for these (an earlier
|
|
170
|
+
// draft used invented 'io-read'/'database' provenance strings that
|
|
171
|
+
// PROVENANCE_MAP has never recognized).
|
|
172
|
+
'cs-console-readline': 'user-input',
|
|
173
|
+
'cs-reader-readtoend': 'user-input',
|
|
174
|
+
'cs-console-read': 'user-input',
|
|
175
|
+
'cs-datareader-getstring': 'database-read',
|
|
176
|
+
'cs-datareader-getvalue': 'database-read',
|
|
177
|
+
'cs-datareader-getint32': 'database-read',
|
|
162
178
|
// Java / Spring annotations
|
|
163
179
|
'java-spring-requestparam': 'http-query',
|
|
164
180
|
'java-spring-pathvariable': 'http-route',
|
|
@@ -228,6 +244,13 @@ export const NO_PROVENANCE_OVERRIDES = Object.freeze({
|
|
|
228
244
|
'kt-request-param': 'http-query',
|
|
229
245
|
'kt-request-header': 'http-header',
|
|
230
246
|
'kt-ktor-parameters': 'http-query',
|
|
247
|
+
// Rust — the whole HttpRequest/Request object (actix/axum/hyper). No
|
|
248
|
+
// single SOURCE_CATEGORIES value names "the whole request"; http-body
|
|
249
|
+
// is the closest existing precedent (go-r-body/go-r-form already map a
|
|
250
|
+
// merged/whole-request-shaped read the same way).
|
|
251
|
+
'rs-extract-httprequest': 'http-body',
|
|
252
|
+
'rs-extract-request': 'http-body',
|
|
253
|
+
'rs-closure-extract-request': 'http-body',
|
|
231
254
|
});
|
|
232
255
|
|
|
233
256
|
// ─────────────────────────────────────────────────────────────────────────
|