@clear-capabilities/agentic-security-scanner 0.137.1 → 0.139.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 +204 -0
- package/dist/113.index.js +2 -2
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +29 -1
- package/dist/526.index.js +2 -2
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +10 -6
- package/src/dataflow/CLAUDE.md +30 -0
- package/src/dataflow/catalog.js +512 -14
- package/src/dataflow/engine.js +275 -27
- package/src/dataflow/summaries.js +30 -5
- package/src/engine.js +512 -120
- package/src/ir/CLAUDE.md +20 -5
- package/src/ir/balanced-call.js +11 -1
- package/src/ir/callgraph.js +34 -0
- package/src/ir/parser-cs.js +55 -6
- package/src/ir/parser-go.js +106 -2
- package/src/ir/parser-java.js +111 -10
- package/src/ir/parser-js.js +40 -0
- package/src/ir/parser-kt.js +194 -10
- package/src/ir/parser-php.js +108 -6
- package/src/ir/parser-py.helper.py +199 -10
- package/src/ir/parser-rb.js +405 -31
- package/src/mcp/tools.js +29 -1
- package/src/posture/accuracy-scorecard.js +103 -0
- package/src/runScan.js +5 -2
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/_auth-signals.js +141 -0
- package/src/sast/_comment-strip.js +80 -13
- package/src/sast/codegen-sink.js +110 -0
- package/src/sast/convention-deviation.js +235 -0
- package/src/sast/fastapi-hardening.js +45 -6
- package/src/sast/file-upload.js +29 -1
- package/src/sast/ownership-authz.js +245 -0
- package/src/sast/php.js +12 -2
- package/src/sast/rate-limit.js +2 -0
- package/src/sast/rbac-consistency.js +1 -1
- package/src/sast/redirect-toctou.js +167 -0
- package/src/sast/resource-exhaustion.js +217 -0
- package/src/sast/sibling-guard.js +176 -0
- package/src/sast/zip-slip.js +53 -2
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// PRD Theme 6 — repo-internal convention deviation.
|
|
2
|
+
//
|
|
3
|
+
// THE IDEA, AND WHY IT IS DIFFERENT FROM EVERY OTHER DETECTOR HERE.
|
|
4
|
+
// Every other rule in this directory asks "does this match a known-bad
|
|
5
|
+
// pattern?" — which requires someone to have enumerated the pattern first.
|
|
6
|
+
// This one asks the inverse: "does this site deviate from what THIS codebase
|
|
7
|
+
// has already established as its own correct handling?"
|
|
8
|
+
//
|
|
9
|
+
// That inversion matters because 10 of the 96 real-world misses root-caused in
|
|
10
|
+
// docs/INDEPENDENT_POPULATION_ROOT_CAUSE.md share exactly one shape: the
|
|
11
|
+
// correct guard already exists elsewhere in the same file, and one site forgot
|
|
12
|
+
// it. No catalog can express that — the guard is the project's own invention.
|
|
13
|
+
//
|
|
14
|
+
// Worked example (GHSA-9rj7-rf2p-w77r, GitPython CVE, argument injection):
|
|
15
|
+
// Repo.blame / blame_incremental / _clone / archive → call
|
|
16
|
+
// Git.check_unsafe_options(...) before forwarding **kwargs to git.*
|
|
17
|
+
// Repo.init → forwards **kwargs
|
|
18
|
+
// straight to git.init(**kwargs) with no such call
|
|
19
|
+
// A `--template=<path>` kwarg becomes a git CLI flag that installs an
|
|
20
|
+
// attacker-controlled hook. The fix added exactly the missing guard call.
|
|
21
|
+
//
|
|
22
|
+
// WHAT THIS DOES NOT CLAIM. A deviation is not a proof of exploitability — it
|
|
23
|
+
// is evidence that this site is inconsistent with its own neighbours, which is
|
|
24
|
+
// why findings carry the supporting siblings and sit at medium severity. That
|
|
25
|
+
// is the honest strength of the signal, and it is deliberately not inflated.
|
|
26
|
+
//
|
|
27
|
+
// PRECISION CONTROLS (all required before anything is reported):
|
|
28
|
+
// - a real population: at least MIN_GUARDED_SIBLINGS guarded neighbours
|
|
29
|
+
// - a real majority: guarded / (guarded + unguarded) >= MIN_GUARDED_RATIO
|
|
30
|
+
// - structural similarity: siblings are only compared when they forward
|
|
31
|
+
// caller-controlled options into the SAME primitive receiver, so
|
|
32
|
+
// `TagReference.create(**kwargs)` is never weighed against `git.init(...)`
|
|
33
|
+
import { blankComments } from './_comment-strip.js';
|
|
34
|
+
|
|
35
|
+
const PY_RE = /\.py$/i;
|
|
36
|
+
const JS_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
37
|
+
|
|
38
|
+
/** Guard-shaped callee: an action verb applied to a checkable concern. */
|
|
39
|
+
// The dotted qualifier is an OPTIONAL single-quantifier group rather than a
|
|
40
|
+
// repeated one: `(?:\w+\.)*` is a nested quantifier, which this project's own
|
|
41
|
+
// redos-nfa.js flags — correctly, since this scanner runs over untrusted code.
|
|
42
|
+
// Accepts both snake_case (`check_unsafe_options`) and camelCase
|
|
43
|
+
// (`assertWorkspaceAccess`) so the same comparator works across Python and
|
|
44
|
+
// JS/TS. The verb must be followed by `_` or an uppercase letter, so a bare
|
|
45
|
+
// `check(x)` or an unrelated `requirement(x)` does not qualify.
|
|
46
|
+
const GUARD_CALL_RE = /\b((?:[\w.]{0,120}\.)?(?:check|verify|validate|assert|require|ensure|guard|sanitiz[e]?|authoriz[e]?)(?:_\w{1,64}|[A-Z]\w{0,63}))\s{0,8}\(/;
|
|
47
|
+
|
|
48
|
+
/** A call that forwards caller-controlled options onward: f(..., **kwargs). */
|
|
49
|
+
const FORWARD_INTO_RE = /\b(\w{1,64})\s{0,8}\.\s{0,8}(\w{1,64})\s{0,8}\([^)]{0,400}(?:\*\*|\.{3})\w{1,64}/g;
|
|
50
|
+
|
|
51
|
+
/** At least this many neighbours must already guard before absence means anything. */
|
|
52
|
+
export const MIN_GUARDED_SIBLINGS = 3;
|
|
53
|
+
/** And they must be the majority, not a vocal minority. */
|
|
54
|
+
export const MIN_GUARDED_RATIO = 0.5;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Split Python source into function units: name, signature, body, line.
|
|
58
|
+
* Indentation-based, matching the language; a unit ends at the next `def` at
|
|
59
|
+
* any indent, which is sufficient for the sibling comparison this makes.
|
|
60
|
+
*/
|
|
61
|
+
export function pythonUnits(code) {
|
|
62
|
+
const lines = code.split('\n');
|
|
63
|
+
const units = [];
|
|
64
|
+
let cur = null;
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
const m = lines[i].match(/^(\s{0,200})(?:async\s{1,8})?def\s{1,8}(\w{1,80})\s{0,8}\(/);
|
|
67
|
+
if (m) {
|
|
68
|
+
if (cur) units.push(cur);
|
|
69
|
+
// Capture the signature across continuation lines by paren balance.
|
|
70
|
+
let j = i, depth = 0, sig = '';
|
|
71
|
+
do {
|
|
72
|
+
sig += lines[j];
|
|
73
|
+
depth += (lines[j].match(/\(/g) || []).length - (lines[j].match(/\)/g) || []).length;
|
|
74
|
+
j++;
|
|
75
|
+
} while (depth > 0 && j < lines.length && j < i + 40);
|
|
76
|
+
cur = { name: m[2], indent: m[1].length, line: i + 1, sig, body: [] };
|
|
77
|
+
} else if (cur) {
|
|
78
|
+
cur.body.push(lines[i]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (cur) units.push(cur);
|
|
82
|
+
return units.map(u => ({ ...u, body: u.body.join('\n') }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Split brace-language source into function units.
|
|
87
|
+
*
|
|
88
|
+
* Recognises the four shapes that carry a guard-then-forward convention in
|
|
89
|
+
* JS/TS: `function name(...)`, `name(...) {` class methods, `name = (...) =>`,
|
|
90
|
+
* and `async` variants of each. The body is taken by brace matching from the
|
|
91
|
+
* opening `{`, which is sufficient for the sibling comparison this makes —
|
|
92
|
+
* this is a convention comparator, not a parser.
|
|
93
|
+
*
|
|
94
|
+
* 6 of the 10 known sibling-omission entries are TypeScript, and were
|
|
95
|
+
* unreachable while this module was Python-only.
|
|
96
|
+
*/
|
|
97
|
+
export function jsUnits(code) {
|
|
98
|
+
const units = [];
|
|
99
|
+
const lines = code.split('\n');
|
|
100
|
+
const DECL = /(?:^|\s)(?:(?:async\s{1,4})?function\s{1,4}(\w{1,80})\s{0,4}\(|(?:public|private|protected|static|async)\s{1,4}(\w{1,80})\s{0,4}\(|(?:const|let|var)\s{1,4}(\w{1,80})\s{0,4}=\s{0,4}(?:async\s{1,4})?\(|^\s{0,80}(\w{1,80})\s{0,4}\([^)]{0,400}\)\s{0,4}\{)/;
|
|
101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
102
|
+
const m = lines[i].match(DECL);
|
|
103
|
+
if (!m) continue;
|
|
104
|
+
const name = m[1] || m[2] || m[3] || m[4];
|
|
105
|
+
if (!name || /^(?:if|for|while|switch|catch|return|typeof|new)$/.test(name)) continue;
|
|
106
|
+
// Signature: from the decl to the first `{` (may span lines).
|
|
107
|
+
let sig = '', j = i, open = -1;
|
|
108
|
+
for (; j < lines.length && j < i + 20; j++) {
|
|
109
|
+
sig += lines[j];
|
|
110
|
+
const k = lines[j].indexOf('{', j === i ? m.index : 0);
|
|
111
|
+
if (k !== -1) { open = j; break; }
|
|
112
|
+
}
|
|
113
|
+
if (open === -1) continue;
|
|
114
|
+
// Body by brace matching from `open`.
|
|
115
|
+
let depth = 0, body = [], done = false;
|
|
116
|
+
for (let b = open; b < lines.length && !done; b++) {
|
|
117
|
+
const text = lines[b];
|
|
118
|
+
for (const ch of text) { if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { done = true; break; } } }
|
|
119
|
+
body.push(text);
|
|
120
|
+
}
|
|
121
|
+
units.push({ name, line: i + 1, sig, body: body.join('\n') });
|
|
122
|
+
i = open;
|
|
123
|
+
}
|
|
124
|
+
return units;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The core, pure analysis. Takes units from the WHOLE PROJECT — each tagged
|
|
129
|
+
* with its file — and reports sites that skip the guard their peers apply.
|
|
130
|
+
*
|
|
131
|
+
* WHY PROJECT-SCOPED, NOT PER-FILE. The first version analysed one file at a
|
|
132
|
+
* time and measured 1 of 10 against its own exit gate. Diagnosis: the
|
|
133
|
+
* convention is a property of the CODEBASE, not of a file.
|
|
134
|
+
* `Git.check_unsafe_options` is called from git/repo/base.py (5 sites),
|
|
135
|
+
* git/index/base.py (2) and git/objects/commit.py (1) — project-wide an
|
|
136
|
+
* unambiguous 8-site convention, but per-file it fragments into populations of
|
|
137
|
+
* 5, 2 and 1, and only the first clears MIN_GUARDED_SIBLINGS. Lowering the
|
|
138
|
+
* threshold would have been the wrong fix: it weakens the precision control
|
|
139
|
+
* everywhere instead of restoring the population that genuinely exists.
|
|
140
|
+
*/
|
|
141
|
+
export function analyseUnits(units) {
|
|
142
|
+
// receiver -> { guarded: [...], unguarded: [...] }
|
|
143
|
+
const groups = new Map();
|
|
144
|
+
for (const u of units) {
|
|
145
|
+
// Python `**kwargs` / JS `...opts` — the option bag this compares on.
|
|
146
|
+
if (!/\*\*\w+|\.{3}\w/.test(u.sig)) continue;
|
|
147
|
+
const forwards = [...u.body.matchAll(FORWARD_INTO_RE)];
|
|
148
|
+
if (!forwards.length) continue; // never forwards it on
|
|
149
|
+
const guard = u.body.match(GUARD_CALL_RE);
|
|
150
|
+
for (const f of forwards) {
|
|
151
|
+
const receiver = f[1];
|
|
152
|
+
// `self.<method>` delegates to a sibling; the guard, if any, belongs to
|
|
153
|
+
// that sibling. Grouping on it would compare a method against itself
|
|
154
|
+
// one hop away.
|
|
155
|
+
if (receiver === 'self' || receiver === 'cls' || receiver === 'this') continue;
|
|
156
|
+
if (!groups.has(receiver)) groups.set(receiver, { guarded: [], unguarded: [] });
|
|
157
|
+
const g = groups.get(receiver);
|
|
158
|
+
const key = `${u.file}::${u.name}`;
|
|
159
|
+
const row = { name: u.name, line: u.line, file: u.file, into: `${f[1]}.${f[2]}` };
|
|
160
|
+
if (guard) { if (!g.guarded.some(x => `${x.file}::${x.name}` === key)) g.guarded.push({ ...row, guard: guard[1] }); }
|
|
161
|
+
else if (!g.unguarded.some(x => `${x.file}::${x.name}` === key)) g.unguarded.push(row);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const [receiver, g] of groups) {
|
|
167
|
+
if (g.guarded.length < MIN_GUARDED_SIBLINGS) continue;
|
|
168
|
+
const total = g.guarded.length + g.unguarded.length;
|
|
169
|
+
if (g.guarded.length / total < MIN_GUARDED_RATIO) continue;
|
|
170
|
+
const tally = new Map();
|
|
171
|
+
for (const x of g.guarded) tally.set(x.guard, (tally.get(x.guard) || 0) + 1);
|
|
172
|
+
const consensusGuard = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
173
|
+
for (const u of g.unguarded) {
|
|
174
|
+
out.push({
|
|
175
|
+
...u, receiver, consensusGuard,
|
|
176
|
+
guardedSiblings: g.guarded.map(x => (x.file === u.file ? x.name : `${x.file}:${x.name}`)),
|
|
177
|
+
ratio: g.guarded.length / total,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _finding(d) {
|
|
185
|
+
return {
|
|
186
|
+
id: `convention-deviation:guard-omitted:${d.file}:${d.line}:${d.name}`,
|
|
187
|
+
file: d.file, line: d.line,
|
|
188
|
+
vuln: `Convention deviation — ${d.name}() forwards caller-controlled options to ${d.into} without the ${d.consensusGuard}() guard its peers apply`,
|
|
189
|
+
severity: 'medium',
|
|
190
|
+
cwe: 'CWE-88',
|
|
191
|
+
family: 'convention-deviation',
|
|
192
|
+
parser: 'CONVENTION',
|
|
193
|
+
confidence: 0.55,
|
|
194
|
+
description:
|
|
195
|
+
`${d.guardedSiblings.length} other method(s) in this project — ${d.guardedSiblings.slice(0, 6).join(', ')} — call ` +
|
|
196
|
+
`${d.consensusGuard}() before forwarding an option bag to ${d.receiver}.*, and ${d.name}() does not. ` +
|
|
197
|
+
'Where those options become command-line flags, an unvalidated option bag lets a caller inject flags the ' +
|
|
198
|
+
'author never intended (arbitrary file read/write, or code execution via a hook-installing flag).',
|
|
199
|
+
remediation:
|
|
200
|
+
`Apply the same guard the neighbouring methods use — ${d.consensusGuard}() — to ${d.name}()'s options before ` +
|
|
201
|
+
'forwarding them, or document why this call site is exempt.',
|
|
202
|
+
checkedFor: `${d.consensusGuard}() call in ${d.name}(); compared against ${d.guardedSiblings.length} peer(s) forwarding into ${d.receiver}.*`,
|
|
203
|
+
evidenceSiblings: d.guardedSiblings,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Project-level entry point. `fileContents` is the engine's `fc` map
|
|
209
|
+
* (path -> source), so the convention population spans the whole codebase.
|
|
210
|
+
*/
|
|
211
|
+
export function scanConventionDeviationProject(fileContents) {
|
|
212
|
+
if (!fileContents || typeof fileContents !== 'object') return [];
|
|
213
|
+
const units = [];
|
|
214
|
+
for (const [file, raw] of Object.entries(fileContents)) {
|
|
215
|
+
if (!raw || typeof raw !== 'string' || raw.length > 500_000) continue;
|
|
216
|
+
const isPy = PY_RE.test(file), isJs = JS_RE.test(file);
|
|
217
|
+
if (!isPy && !isJs) continue;
|
|
218
|
+
// Cheap relevance gate: the option-bag spread this detector compares on.
|
|
219
|
+
if (isPy && !/\*\*\w+/.test(raw)) continue;
|
|
220
|
+
if (isJs && !/\.{3}\w/.test(raw)) continue;
|
|
221
|
+
try {
|
|
222
|
+
const us = isPy ? pythonUnits(blankComments(raw, 'py')) : jsUnits(blankComments(raw));
|
|
223
|
+
for (const u of us) units.push({ ...u, file, lang: isPy ? 'py' : 'js' });
|
|
224
|
+
} catch { /* per-file best-effort, same as every other detector here */ }
|
|
225
|
+
}
|
|
226
|
+
if (!units.length) return [];
|
|
227
|
+
try { return analyseUnits(units).map(_finding); } catch { return []; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Single-file convenience wrapper — the project view of one file. */
|
|
231
|
+
export function scanConventionDeviation(file, raw) {
|
|
232
|
+
return scanConventionDeviationProject({ [file]: raw });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export const _internals = { GUARD_CALL_RE, FORWARD_INTO_RE, pythonUnits, jsUnits, analyseUnits };
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// 6. HTTPBearer used without auto_error checks
|
|
10
10
|
// 7. app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"])
|
|
11
11
|
|
|
12
|
+
import { routeAuthEvidence } from './_auth-signals.js';
|
|
13
|
+
|
|
12
14
|
const _PY_RE = /\.py$/i;
|
|
13
15
|
|
|
14
16
|
function _line(raw, idx) {
|
|
@@ -76,12 +78,46 @@ export function scanFastapiHardening(file, raw) {
|
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
// 3. Mutating endpoint without Depends() injecting security
|
|
79
|
-
|
|
81
|
+
//
|
|
82
|
+
// PRD T1.2 — this rule previously keyed on a CLOSED list of blessed
|
|
83
|
+
// dependency names (get_current_user|require_auth|verify_jwt|require_admin|
|
|
84
|
+
// oauth2_scheme) and never looked at the handler BODY at all. Measured
|
|
85
|
+
// consequence (independent-population entry GHSA-3cg5-48j3-v4gv): it
|
|
86
|
+
// asserted that a handler declaring `user=Depends(get_verified_user)` and
|
|
87
|
+
// calling `await check_folders_permission(request, user, db=db)` had no auth
|
|
88
|
+
// dependency. Both halves now go through the shared resolver in
|
|
89
|
+
// _auth-signals.js, which recognises the SHAPE rather than an enumeration.
|
|
90
|
+
// The parameter list is captured by BALANCED paren matching, not `[^)]*`.
|
|
91
|
+
//
|
|
92
|
+
// T2.1 audit finding, against this rule's own earlier fix: FastAPI
|
|
93
|
+
// signatures routinely contain nested parens as parameter DEFAULTS —
|
|
94
|
+
// `delete_file: bool = Query(True)` — and `[^)]*` stops at the first `)`,
|
|
95
|
+
// truncating the capture BEFORE the auth dependency that usually follows.
|
|
96
|
+
// Measured: all 8 surviving missing-auth findings across the cached
|
|
97
|
+
// independent population were on handlers that visibly declare
|
|
98
|
+
// `user=Depends(get_verified_user)`, which the truncated capture never saw.
|
|
99
|
+
const mutatingRouteRe = /@\s*(?:app|router)\.(?:post|put|patch|delete)\s*\([^\n]{0,300}\n?[^\n]{0,300}?\)\s*(?:async\s+)?def\s+(\w+)\s*\(/g;
|
|
80
100
|
for (const m of raw.matchAll(mutatingRouteRe)) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
101
|
+
// Walk from the '(' that opened the signature to its true partner.
|
|
102
|
+
const sigOpen = m.index + m[0].length - 1;
|
|
103
|
+
let depth = 0, sigClose = -1;
|
|
104
|
+
for (let i = sigOpen; i < raw.length && i < sigOpen + 4000; i++) {
|
|
105
|
+
const ch = raw[i];
|
|
106
|
+
if (ch === '(') depth++;
|
|
107
|
+
else if (ch === ')') { depth--; if (depth === 0) { sigClose = i; break; } }
|
|
108
|
+
}
|
|
109
|
+
if (sigClose === -1) continue;
|
|
110
|
+
const params = raw.slice(sigOpen + 1, sigClose);
|
|
111
|
+
// The handler body: from the end of the signature to the next top-level
|
|
112
|
+
// decorator/def, so an authorization call inside THIS handler counts and
|
|
113
|
+
// one in the next handler does not.
|
|
114
|
+
const rest = raw.slice(sigClose + 1);
|
|
115
|
+
const nextHandler = rest.search(/\n@\s{0,8}(?:app|router)\.|\n(?:async\s{1,8})?def\s{1,8}/);
|
|
116
|
+
const body = nextHandler === -1 ? rest : rest.slice(0, nextHandler);
|
|
117
|
+
|
|
118
|
+
const evidence = routeAuthEvidence({ params, body });
|
|
119
|
+
if (evidence) continue;
|
|
120
|
+
|
|
85
121
|
findings.push({
|
|
86
122
|
id: `fastapi:no-auth-dep:${file}:${_line(raw, m.index)}:${m[1]}`,
|
|
87
123
|
file, line: _line(raw, m.index),
|
|
@@ -90,8 +126,11 @@ export function scanFastapiHardening(file, raw) {
|
|
|
90
126
|
family: 'fastapi-missing-auth',
|
|
91
127
|
cwe: 'CWE-862',
|
|
92
128
|
confidence: 0.7,
|
|
93
|
-
description: 'A POST/PUT/PATCH/DELETE handler is declared without a Security(...) or Depends(get_current_user) parameter. Unless a global middleware enforces auth (rare), this endpoint is callable anonymously.',
|
|
129
|
+
description: 'A POST/PUT/PATCH/DELETE handler is declared without a Security(...) or Depends(get_current_user) parameter, and its body performs no recognisable authorization check. Unless a global middleware enforces auth (rare), this endpoint is callable anonymously.',
|
|
94
130
|
remediation: 'Add: current_user: User = Depends(get_current_user) — or Security(oauth2_scheme, scopes=["admin"]) — as a parameter to the route handler.',
|
|
131
|
+
// T2.2 — an absence-claim must record what it looked for, so a reviewer
|
|
132
|
+
// (or a refutation lens) can falsify it.
|
|
133
|
+
checkedFor: 'Depends()/Security() auth dependency in the signature; authorization call or explicit 401/403 in the body',
|
|
95
134
|
});
|
|
96
135
|
}
|
|
97
136
|
|
package/src/sast/file-upload.js
CHANGED
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
// sanitizer (basename / uuid / randomUUID / sanitize / whitelist) nearby.
|
|
15
15
|
// A validated upload (fileFilter+limits, or a generated/sanitized name) does
|
|
16
16
|
// NOT match.
|
|
17
|
+
//
|
|
18
|
+
// Content-type spoofing subfamily (client-mimetype-trusted): the client-
|
|
19
|
+
// supplied `.mimetype` (multer/Express) is trusted as the stored/served
|
|
20
|
+
// Content-Type with no derivation from the actual file extension — an
|
|
21
|
+
// attacker who names a file `shell.php` but sets its multipart Content-Type
|
|
22
|
+
// to `image/png` gets that lie stored and later served back as truth. This
|
|
23
|
+
// is a distinct CWE-434 subtype from the filename-as-path-traversal one
|
|
24
|
+
// above (found via CVE-2026-70490, GHSA-944x-pm95-3jpr: Ghost's file-serving
|
|
25
|
+
// endpoint stored `type: frame.file.mimetype` verbatim instead of deriving
|
|
26
|
+
// it from the on-disk filename via `mime.lookup()`).
|
|
17
27
|
import { blankComments } from './_comment-strip.js';
|
|
18
28
|
|
|
19
29
|
const JS_EXT = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
@@ -23,6 +33,10 @@ const _lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
|
23
33
|
const _snip = (raw, line) => (raw.split('\n')[line - 1] || '').trim().slice(0, 200);
|
|
24
34
|
// A sanitizer for the destination filename anywhere in the ±6-line window.
|
|
25
35
|
const NAME_SANITIZER = /\b(?:basename|randomUUID|uuidv4|uuid4|uuid\.v4|nanoid|sanitize[-_]?filename|sanitizeFilename|slugify|crypto\.random|secure_filename|werkzeug)\b/i;
|
|
36
|
+
// A client-supplied `.mimetype` assigned straight to a `type:`-ish stored/
|
|
37
|
+
// served field, with no extension-derived lookup nearby.
|
|
38
|
+
const MIMETYPE_TO_TYPE_FIELD = /\btype\s*:\s*\S*\.mimetype\b/;
|
|
39
|
+
const MIMETYPE_DERIVED = /\b(?:mime\.lookup|mime\.getType|mimeTypes\.lookup|mimetypes\.guess_type)\s*\(/i;
|
|
26
40
|
|
|
27
41
|
function _window(raw, line, half = 6) {
|
|
28
42
|
const lines = raw.split('\n');
|
|
@@ -82,6 +96,20 @@ function scanJs(file, raw, code, out, seen) {
|
|
|
82
96
|
'The uploaded file is written using its client-controlled name. An attacker can choose the extension (upload `shell.php`) or embed path traversal (`../../etc/x`) to escape the upload directory.',
|
|
83
97
|
'Never trust the uploaded filename. Generate a server-side name (uuid/nanoid) and validate the extension against an allow-list; write with path.basename() into a fixed directory outside the web root.'));
|
|
84
98
|
}
|
|
99
|
+
|
|
100
|
+
// 3) Client-supplied `.mimetype` trusted as the stored/served Content-Type,
|
|
101
|
+
// with no derivation from the actual file extension (content-type
|
|
102
|
+
// spoofing — an attacker can name a file `shell.php` but set its
|
|
103
|
+
// multipart Content-Type to `image/png`).
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
if (!MIMETYPE_TO_TYPE_FIELD.test(lines[i])) continue;
|
|
106
|
+
const line = i + 1;
|
|
107
|
+
if (MIMETYPE_DERIVED.test(_window(raw, line))) continue; // derived from extension → safe
|
|
108
|
+
push(line, mk(file, raw, line, 'client-mimetype-trusted', 'medium',
|
|
109
|
+
'Unrestricted file upload — client-supplied mimetype trusted as the stored Content-Type',
|
|
110
|
+
'The uploaded file\'s Content-Type is taken directly from the client-supplied `.mimetype` and stored/served as-is. An attacker can upload a malicious file (e.g. `shell.php`) while claiming an innocuous Content-Type (`image/png`), and later requests will trust that lie.',
|
|
111
|
+
'Derive the served Content-Type from the actual file extension (e.g. `mime.lookup(filename)`), never from the client-supplied multipart Content-Type header.'));
|
|
112
|
+
}
|
|
85
113
|
}
|
|
86
114
|
|
|
87
115
|
function scanPy(file, raw, code, out, seen) {
|
|
@@ -108,7 +136,7 @@ export function scanFileUpload(fp, raw) {
|
|
|
108
136
|
const isJs = JS_EXT.test(fp), isPy = PY_EXT.test(fp);
|
|
109
137
|
if (!isJs && !isPy) return [];
|
|
110
138
|
// Cheap relevance gate — skip files with no upload surface.
|
|
111
|
-
if (!/\b(?:multer|originalname|req\.files|UploadFile|\.filename|createWriteStream|\.mv\s*\()/i.test(raw)) return [];
|
|
139
|
+
if (!/\b(?:multer|originalname|req\.files|UploadFile|\.filename|\.mimetype|createWriteStream|\.mv\s*\()/i.test(raw)) return [];
|
|
112
140
|
const code = blankComments(raw, isPy ? 'py' : null);
|
|
113
141
|
const out = [];
|
|
114
142
|
const seen = new Set();
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// PRD Theme 5 (T5.1–T5.4) — business-logic authorization.
|
|
2
|
+
//
|
|
3
|
+
// The largest single family in the evidence table: 19 of the 96 root-caused
|
|
4
|
+
// real-world misses. Not injection, not missing authentication — the caller IS
|
|
5
|
+
// authenticated, and the handler simply never checks that the object it is
|
|
6
|
+
// about to read or mutate belongs to them.
|
|
7
|
+
//
|
|
8
|
+
// Why the existing rules miss these (each verified against the real entries):
|
|
9
|
+
// - api-authz.js works at ROUTE-INVENTORY granularity, comparing which
|
|
10
|
+
// routes carry auth middleware. These handlers all have auth.
|
|
11
|
+
// - authz.js's multi-tenant rule requires a literal ORM `where: {...}`
|
|
12
|
+
// clause in the same file. The real code forwards the id to a service
|
|
13
|
+
// layer (`identityManager.getCustomer(customerId)`), or uses TypeORM's
|
|
14
|
+
// `findOneBy`, neither of which matches.
|
|
15
|
+
// - business-logic.js's id heuristic recognises only `id`/`userId`; the real
|
|
16
|
+
// parameters are `credentialId`, `subscriptionId`, `chatflowId`.
|
|
17
|
+
//
|
|
18
|
+
// Four sub-rules, one per shape found in the evidence:
|
|
19
|
+
// T5.1 ownership-missing — a request-supplied object id reaches a lookup or
|
|
20
|
+
// mutation and nothing in the handler compares the result (or the
|
|
21
|
+
// query) against an identity derived from the authenticated principal.
|
|
22
|
+
// T5.2 tenant-scope-missing — a lookup by primary key in a codebase that
|
|
23
|
+
// elsewhere always scopes by workspace/org/tenant.
|
|
24
|
+
// T5.3 branch-inconsistent-authz — one branch of a handler checks
|
|
25
|
+
// permission and a sibling branch performs the same action without it.
|
|
26
|
+
// T5.4 lifecycle-gate-missing — a state-changing action on a resource that
|
|
27
|
+
// carries a status/archived/deleted field, with no check of it.
|
|
28
|
+
//
|
|
29
|
+
// Every sub-rule names the control its advisory's fix added, and goes silent
|
|
30
|
+
// when that control is present.
|
|
31
|
+
import { blankComments } from './_comment-strip.js';
|
|
32
|
+
|
|
33
|
+
const SRC_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py)$/i;
|
|
34
|
+
|
|
35
|
+
/** A handler that receives a request. */
|
|
36
|
+
const HANDLER_RE =
|
|
37
|
+
/\b(?:async\s{1,4})?(?:function\s{1,4})?(\w{1,60})\s{0,4}\([^)]{0,300}\b(?:req|request|ctx|event)\b[^)]{0,300}\)\s{0,4}(?:=>\s{0,4})?\{/g;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An object identifier read off the request — ANY *Id name, not just `id`.
|
|
41
|
+
*
|
|
42
|
+
* Three surface forms, all seen in the real entries. The DESTRUCTURING form is
|
|
43
|
+
* what the code this rule was designed from actually uses
|
|
44
|
+
* (`const { customerId } = req.query`), and the first version of this rule
|
|
45
|
+
* matched only member access — which is why it returned zero findings on its
|
|
46
|
+
* own target entry.
|
|
47
|
+
*/
|
|
48
|
+
const REQ_ID_RE =
|
|
49
|
+
/\b(?:req|request|ctx)\s{0,2}\.\s{0,2}(?:params|query|body|args)\s{0,2}(?:\.\s{0,2}(\w{0,40}[iI]d)\b|\[\s*['"](\w{0,40}[iI]d)['"]\s*\])/g;
|
|
50
|
+
const REQ_ID_DESTRUCTURE_RE =
|
|
51
|
+
/(?:const|let|var)\s{1,4}\{([^}]{0,200})\}\s{0,4}=\s{0,4}(?:await\s{1,4})?(?:req|request|ctx)\s{0,2}\.\s{0,2}(?:params|query|body|args)\b/g;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The id is HANDED TO something that acts on it.
|
|
55
|
+
*
|
|
56
|
+
* A fixed ORM verb list was the second reason this rule missed its own target:
|
|
57
|
+
* the real sink is `identityManager.getCustomerWithDefaultSource(customerId)`,
|
|
58
|
+
* a domain method that matches no generic vocabulary. Any receiver-qualified
|
|
59
|
+
* call taking the id counts, EXCEPT the response/logging/control shapes below —
|
|
60
|
+
* echoing an id back to the caller is not acting on the object it names.
|
|
61
|
+
*/
|
|
62
|
+
const NON_ACTING_RECEIVER_RE = /^(?:res|response|reply|ctx|console|logger|log|next|JSON|Number|String|Boolean|parseInt|Array|Object)$/i;
|
|
63
|
+
/** Index of the acting call within `body`, or -1. Line attribution needs the
|
|
64
|
+
* SINK, not the handler's opening brace — GHSA-2364's fix lands 8 lines into
|
|
65
|
+
* the body, well outside the ±3 localization window a handler-line finding
|
|
66
|
+
* would need to land in. */
|
|
67
|
+
function _usesIdAt(body, id) {
|
|
68
|
+
const esc = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
69
|
+
const re = new RegExp(`\\b(\\w{1,60})\\s{0,4}\\.\\s{0,4}\\w{1,60}\\s{0,4}\\([^)]{0,200}\\b${esc}\\b`, 'g');
|
|
70
|
+
let m;
|
|
71
|
+
while ((m = re.exec(body))) if (!NON_ACTING_RECEIVER_RE.test(m[1])) return m.index;
|
|
72
|
+
return -1;
|
|
73
|
+
}
|
|
74
|
+
function _usesId(body, id) { return _usesIdAt(body, id) !== -1; }
|
|
75
|
+
|
|
76
|
+
/** Evidence the caller's identity constrains the operation. */
|
|
77
|
+
const OWNERSHIP_RE = new RegExp([
|
|
78
|
+
// Compared against the principal.
|
|
79
|
+
'\\b(?:req|request|ctx)\\s{0,2}\\.\\s{0,2}(?:user|auth|principal|session)\\b',
|
|
80
|
+
'\\b(?:current_?user|currentUser|authUser|viewer|me)\\b',
|
|
81
|
+
// Or the query itself is scoped.
|
|
82
|
+
'\\b(?:user_?id|owner_?id|userId|ownerId|createdBy|author_?id)\\s{0,4}[:=]',
|
|
83
|
+
].join('|'), 'i');
|
|
84
|
+
|
|
85
|
+
/** Tenant dimension — the T5.2 control. */
|
|
86
|
+
const TENANT_RE = /\b(?:workspace_?id|workspaceId|org_?id|orgId|organization_?id|tenant_?id|tenantId|account_?id|accountId)\b/i;
|
|
87
|
+
|
|
88
|
+
/** An explicit authorization call — T5.3's control. */
|
|
89
|
+
const PERMISSION_CALL_RE =
|
|
90
|
+
/\b(?:has_?permission|hasPermission|check_?permission|checkPermission|checkAnyPermission|require_?permission|can\w{0,20}|authorize\w{0,10}|assert\w{0,20}(?:Access|Permission)|verify\w{0,20}Access)\s{0,4}\(/i;
|
|
91
|
+
|
|
92
|
+
/** Lifecycle fields whose state should gate a mutation — T5.4. */
|
|
93
|
+
const LIFECYCLE_FIELD_RE = /\b(?:status|state|archived|is_?archived|deleted|is_?deleted|active|is_?active|expired|published|revoked)\b/i;
|
|
94
|
+
const STATE_CHANGE_RE = /\.(?:update|delete|remove|destroy|save|redeem|activate|apply|execute|publish)\s{0,4}\(/;
|
|
95
|
+
|
|
96
|
+
const _lineOf = (raw, i) => raw.slice(0, i).split('\n').length;
|
|
97
|
+
|
|
98
|
+
/** Body of a handler, by brace matching from its opening `{`. */
|
|
99
|
+
function _bodyFrom(code, openIdx) {
|
|
100
|
+
let depth = 0;
|
|
101
|
+
for (let i = openIdx; i < code.length && i < openIdx + 20000; i++) {
|
|
102
|
+
const ch = code[i];
|
|
103
|
+
if (ch === '{') depth++;
|
|
104
|
+
else if (ch === '}') { depth--; if (depth === 0) return code.slice(openIdx, i + 1); }
|
|
105
|
+
}
|
|
106
|
+
return code.slice(openIdx, openIdx + 4000);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function mk(file, line, sub, cwe, vuln, description, remediation, checkedFor) {
|
|
110
|
+
return {
|
|
111
|
+
id: `ownership-authz:${sub}:${file}:${line}`,
|
|
112
|
+
file, line, vuln,
|
|
113
|
+
severity: 'high',
|
|
114
|
+
cwe,
|
|
115
|
+
family: 'broken-access-control',
|
|
116
|
+
subfamily: sub,
|
|
117
|
+
parser: 'OWNERSHIP-AUTHZ',
|
|
118
|
+
confidence: 0.5,
|
|
119
|
+
description, remediation, checkedFor,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function scanOwnershipAuthz(file, raw) {
|
|
124
|
+
if (!raw || typeof raw !== 'string' || raw.length > 500_000) return [];
|
|
125
|
+
if (!SRC_RE.test(file)) return [];
|
|
126
|
+
if (!/\b(?:req|request|ctx)\b/.test(raw)) return []; // cheap relevance gate
|
|
127
|
+
const code = blankComments(raw, /\.py$/i.test(file) ? 'py' : null);
|
|
128
|
+
|
|
129
|
+
// Does this FILE establish a tenant convention at all? T5.2 only means
|
|
130
|
+
// something where the codebase demonstrably scopes by tenant elsewhere.
|
|
131
|
+
const fileUsesTenant = TENANT_RE.test(code);
|
|
132
|
+
|
|
133
|
+
const out = [];
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
const push = (f) => { const k = `${f.subfamily}:${f.line}`; if (!seen.has(k)) { seen.add(k); out.push(f); } };
|
|
136
|
+
|
|
137
|
+
HANDLER_RE.lastIndex = 0;
|
|
138
|
+
let h;
|
|
139
|
+
while ((h = HANDLER_RE.exec(code))) {
|
|
140
|
+
const name = h[1];
|
|
141
|
+
const openIdx = code.indexOf('{', h.index + h[0].length - 1);
|
|
142
|
+
if (openIdx === -1) continue;
|
|
143
|
+
const body = _bodyFrom(code, openIdx);
|
|
144
|
+
const line = _lineOf(code, h.index);
|
|
145
|
+
|
|
146
|
+
REQ_ID_RE.lastIndex = 0;
|
|
147
|
+
REQ_ID_DESTRUCTURE_RE.lastIndex = 0;
|
|
148
|
+
const ids = [...body.matchAll(REQ_ID_RE)].map(m => m[1] || m[2]).filter(Boolean);
|
|
149
|
+
for (const d of body.matchAll(REQ_ID_DESTRUCTURE_RE)) {
|
|
150
|
+
for (const part of String(d[1]).split(',')) {
|
|
151
|
+
const nm = part.split(':').pop().trim();
|
|
152
|
+
if (/^\w{0,40}[iI]d$/.test(nm)) ids.push(nm);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Aliases: `const x = req.params.credentialId` then `repo.findOneBy({id: x})`.
|
|
156
|
+
// The rewrite keyed on the id NAME appearing at the call, which the old
|
|
157
|
+
// verb-list check handled implicitly; without this an aliased id is missed.
|
|
158
|
+
const names = [...ids];
|
|
159
|
+
for (const id of ids) {
|
|
160
|
+
const esc = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
161
|
+
for (const a of body.matchAll(new RegExp(`(?:const|let|var)\\s{1,4}(\\w{1,60})\\s{0,4}=[^\\n]{0,80}\\b${esc}\\b`, 'g'))) names.push(a[1]);
|
|
162
|
+
}
|
|
163
|
+
// Attribute the finding to where the id is actually USED, not the handler
|
|
164
|
+
// declaration — a fix inserted mid-body would otherwise sit well outside
|
|
165
|
+
// any localization window keyed to the finding's line.
|
|
166
|
+
let sinkIdx = -1;
|
|
167
|
+
for (const n of names) {
|
|
168
|
+
const idx = _usesIdAt(body, n);
|
|
169
|
+
if (idx !== -1) { sinkIdx = idx; break; }
|
|
170
|
+
}
|
|
171
|
+
const touchesObject = sinkIdx !== -1;
|
|
172
|
+
const sinkLine = touchesObject ? _lineOf(code, openIdx + sinkIdx) : line;
|
|
173
|
+
|
|
174
|
+
// T5.1 — an object id from the request reaches a lookup/mutation and
|
|
175
|
+
// nothing ties the operation to the authenticated principal. TENANT_RE is
|
|
176
|
+
// checked too, not just OWNERSHIP_RE: GHSA-r745-8hwv-h473's /authorize
|
|
177
|
+
// handler scopes its lookup by `workspaceId` (from
|
|
178
|
+
// getActiveWorkspaceIdForRequest(req)) with no per-user ownership
|
|
179
|
+
// predicate at all — workspace/tenant scoping IS an ownership check at a
|
|
180
|
+
// coarser grain, and a rule that can't see that flags well-guarded
|
|
181
|
+
// multi-tenant code as vulnerable. The sibling /refresh handler in the
|
|
182
|
+
// same file, which has neither, still fires correctly.
|
|
183
|
+
if (ids.length && touchesObject && !OWNERSHIP_RE.test(body) && !TENANT_RE.test(body)) {
|
|
184
|
+
push(mk(file, sinkLine, 'ownership-missing', 'CWE-639',
|
|
185
|
+
`${name}() looks up or mutates by request-supplied '${ids[0]}' with no ownership check`,
|
|
186
|
+
`The object identifier '${ids[0]}' comes straight from the request and is used to read or change a record, `
|
|
187
|
+
+ 'but nothing in this handler compares that record — or constrains the query — against the authenticated '
|
|
188
|
+
+ 'caller. Authentication alone does not help here: any logged-in user can substitute another user\'s id. '
|
|
189
|
+
+ 'This is the shape route-level authz rules cannot see, because the route IS authenticated.',
|
|
190
|
+
'Scope the lookup by the caller (add the principal\'s id to the query), or fetch first and compare the '
|
|
191
|
+
+ 'record\'s owner against the authenticated user before acting on it.',
|
|
192
|
+
'a comparison against req.user/current_user, or an owner/tenant predicate in the query'));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// T5.2 — tenant-scoped codebase, unscoped lookup.
|
|
196
|
+
// An explicit ownership assertion IS scoping. Without this, T5.2 simply
|
|
197
|
+
// takes over from T5.1 the moment the maintainer's fix lands, and the
|
|
198
|
+
// entry never discriminates — observed on GHSA-2364's post/ revision,
|
|
199
|
+
// where assertStripeIdMatchesSession(id, req.user...) silenced T5.1 and
|
|
200
|
+
// T5.2 immediately fired in its place.
|
|
201
|
+
if (fileUsesTenant && ids.length && touchesObject && !TENANT_RE.test(body) && !OWNERSHIP_RE.test(body)) {
|
|
202
|
+
push(mk(file, sinkLine, 'tenant-scope-missing', 'CWE-863',
|
|
203
|
+
`${name}() queries by id without the workspace/tenant scope this file uses elsewhere`,
|
|
204
|
+
'Other code in this file constrains queries by a workspace/organization/tenant dimension; this handler '
|
|
205
|
+
+ 'looks up by primary key alone. In a multi-tenant system that returns records belonging to other '
|
|
206
|
+
+ 'tenants whenever an id can be guessed or enumerated.',
|
|
207
|
+
'Add the tenant dimension to the query, the same way the neighbouring handlers do.',
|
|
208
|
+
'a workspace/org/tenant predicate inside this handler, given the file uses one elsewhere'));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// T5.3 — one branch authorizes, a sibling performs the same action bare.
|
|
212
|
+
if (PERMISSION_CALL_RE.test(body)) {
|
|
213
|
+
const branches = body.split(/\belse\b|\belif\b/);
|
|
214
|
+
if (branches.length > 1) {
|
|
215
|
+
const guarded = branches.filter(b => PERMISSION_CALL_RE.test(b)).length;
|
|
216
|
+
const acting = branches.filter(b => STATE_CHANGE_RE.test(b)).length;
|
|
217
|
+
if (guarded < acting) {
|
|
218
|
+
push(mk(file, line, 'branch-inconsistent-authz', 'CWE-862',
|
|
219
|
+
`${name}() checks permission on one branch but acts without it on another`,
|
|
220
|
+
'This handler performs an authorization check in one branch and performs the same class of '
|
|
221
|
+
+ 'state-changing action in a sibling branch that has no such check. Whichever path skips the check '
|
|
222
|
+
+ 'is reachable by choosing the input that selects it.',
|
|
223
|
+
'Hoist the authorization check above the branch so every path is covered, or repeat it on each '
|
|
224
|
+
+ 'acting branch.',
|
|
225
|
+
'a permission call on every branch that performs a state-changing action'));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// T5.4 — mutate a resource whose lifecycle state is never consulted.
|
|
231
|
+
if (STATE_CHANGE_RE.test(body) && LIFECYCLE_FIELD_RE.test(code) && !LIFECYCLE_FIELD_RE.test(body)) {
|
|
232
|
+
push(mk(file, line, 'lifecycle-gate-missing', 'CWE-863',
|
|
233
|
+
`${name}() acts on a resource without checking its lifecycle state`,
|
|
234
|
+
'This handler performs a state-changing action on a record whose type carries a lifecycle field '
|
|
235
|
+
+ '(status/archived/deleted/expired — used elsewhere in this file) without consulting it. An archived, '
|
|
236
|
+
+ 'revoked or already-consumed record can therefore still be acted on.',
|
|
237
|
+
'Check the resource\'s lifecycle state before acting, and reject the request when it is not in a state '
|
|
238
|
+
+ 'that permits the action.',
|
|
239
|
+
'a status/archived/deleted check inside this handler, given the file uses one elsewhere'));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const _internals = { REQ_ID_RE, REQ_ID_DESTRUCTURE_RE, _usesId, _usesIdAt, OWNERSHIP_RE, TENANT_RE, PERMISSION_CALL_RE, LIFECYCLE_FIELD_RE };
|
package/src/sast/php.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { blankComments } from './_comment-strip.js';
|
|
1
2
|
// PHP-specific detectors. Covers the canonical PHP foot-guns:
|
|
2
3
|
//
|
|
3
4
|
// - $_REQUEST / $_GET / $_POST flowing into eval / system / exec / passthru / shell_exec / `` / popen / proc_open
|
|
@@ -37,9 +38,18 @@ const RE = {
|
|
|
37
38
|
|
|
38
39
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
39
40
|
|
|
40
|
-
export function scanPhp(fp,
|
|
41
|
+
export function scanPhp(fp, rawInput) {
|
|
41
42
|
if (!/\.(?:php|phtml|phar)$/i.test(fp)) return [];
|
|
42
|
-
if (!
|
|
43
|
+
if (!rawInput || rawInput.length > 500_000) return [];
|
|
44
|
+
// T2.1 audit — this module scanned raw source with NO comment stripping,
|
|
45
|
+
// which sast/CLAUDE.md names as its first gotcha. Measured cost: the
|
|
46
|
+
// backtick command-injection rule was the single highest-volume rule across
|
|
47
|
+
// the independent population (105 findings from 24 entries), and the sampled
|
|
48
|
+
// ones were PROSE — `// Abort if \`taxonomies\` resource is disabled` — where
|
|
49
|
+
// backticks quoting a word in English read as PHP's shell-execution
|
|
50
|
+
// operator. blankComments preserves line numbers, so reported lines are
|
|
51
|
+
// unaffected.
|
|
52
|
+
const raw = blankComments(rawInput, 'php');
|
|
43
53
|
const findings = [];
|
|
44
54
|
const seen = new Set();
|
|
45
55
|
const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
|
package/src/sast/rate-limit.js
CHANGED
|
@@ -93,6 +93,7 @@ function scanRateLimit(file, content) {
|
|
|
93
93
|
findings.push({
|
|
94
94
|
id: `rate-limit:RATE_LIMIT_${cat.toUpperCase()}:${file}:${lineNum}`,
|
|
95
95
|
title: meta.title,
|
|
96
|
+
vuln: meta.title,
|
|
96
97
|
severity: meta.severity,
|
|
97
98
|
file, line: lineNum,
|
|
98
99
|
description: meta.description,
|
|
@@ -113,6 +114,7 @@ function scanRateLimit(file, content) {
|
|
|
113
114
|
findings.push({
|
|
114
115
|
id: `rate-limit:RATE_LIMIT_${cat.toUpperCase()}:${file}:${handlerLine}`,
|
|
115
116
|
title: meta.title,
|
|
117
|
+
vuln: meta.title,
|
|
116
118
|
severity: meta.severity,
|
|
117
119
|
file, line: handlerLine,
|
|
118
120
|
description: meta.description,
|