@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
|
@@ -65,6 +65,24 @@ function sliceBy(scored, key) {
|
|
|
65
65
|
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Build the { byLanguage: [{language, taint:{n,d}}] } shape for one
|
|
70
|
+
* taintByLanguage/totalByLanguage pair. Sorted by language so the rendered
|
|
71
|
+
* document is stable run-to-run, matching every other byX array here.
|
|
72
|
+
*
|
|
73
|
+
* A language present in `total` but absent from `taint` reports n:0 — a real
|
|
74
|
+
* measured zero. A language absent from BOTH is simply not included in the
|
|
75
|
+
* output at all, so "measured zero" and "not measured on this subset" never
|
|
76
|
+
* collapse into the same row (see the deepTierOnly caller below).
|
|
77
|
+
*/
|
|
78
|
+
function taintRateByLanguage(taintByLanguage, totalByLanguage) {
|
|
79
|
+
const langs = Object.keys(totalByLanguage || {}).sort();
|
|
80
|
+
return langs.map(language => ({
|
|
81
|
+
language,
|
|
82
|
+
taint: { n: (taintByLanguage || {})[language] || 0, d: totalByLanguage[language] },
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
/**
|
|
69
87
|
* Aggregate the per-entry detail records emitted by the corpus runner.
|
|
70
88
|
*
|
|
@@ -141,6 +159,24 @@ export function buildScorecard(inputs) {
|
|
|
141
159
|
byTier: corpus.byTier,
|
|
142
160
|
},
|
|
143
161
|
selfScan: { measuredThisRun: true, targets, polyglot: selfScan.polyglot || { total: 0, byLanguage: {} } },
|
|
162
|
+
taintRecall: (() => {
|
|
163
|
+
const lr = inputs.layerRecall;
|
|
164
|
+
if (!lr) {
|
|
165
|
+
return { measuredThisRun: false, wholeCorpus: { entriesScored: 0, byLanguage: [] }, deepTierOnly: { entriesScored: 0, byLanguage: [] } };
|
|
166
|
+
}
|
|
167
|
+
const deep = lr.deepTier || { entriesScored: 0, taintByLanguage: {}, totalByLanguage: {} };
|
|
168
|
+
return {
|
|
169
|
+
measuredThisRun: true,
|
|
170
|
+
wholeCorpus: {
|
|
171
|
+
entriesScored: lr.entriesScored || 0,
|
|
172
|
+
byLanguage: taintRateByLanguage(lr.taintByLanguage, lr.totalByLanguage),
|
|
173
|
+
},
|
|
174
|
+
deepTierOnly: {
|
|
175
|
+
entriesScored: deep.entriesScored || 0,
|
|
176
|
+
byLanguage: taintRateByLanguage(deep.taintByLanguage, deep.totalByLanguage),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
})(),
|
|
144
180
|
committedInputs: {
|
|
145
181
|
corpusBaseline: committed.corpusBaseline
|
|
146
182
|
? { source: 'bench/cve-replay/corpus-baseline.json', generatedAt: committed.corpusBaseline.generatedAt, total: committed.corpusBaseline.total, passing: committed.corpusBaseline.passing }
|
|
@@ -279,6 +315,73 @@ export function renderScorecardMarkdown(m) {
|
|
|
279
315
|
L.push('| --- | --- | --- | --- |');
|
|
280
316
|
for (const r of c.byTier) L.push(rateRow(r));
|
|
281
317
|
L.push('');
|
|
318
|
+
L.push('## Taint-layer recall by language');
|
|
319
|
+
L.push('');
|
|
320
|
+
L.push('Two views of the same layer-recall instrument, because reporting only the');
|
|
321
|
+
L.push('first would silently overstate taint capability and reporting only the');
|
|
322
|
+
L.push('second would understate coverage of what the corpus actually contains:');
|
|
323
|
+
L.push('');
|
|
324
|
+
L.push('- **Whole corpus** — diagnostic only. The large majority of this corpus is');
|
|
325
|
+
L.push(' caught by the pattern/structural layers without needing taint at all, so a language\'s');
|
|
326
|
+
L.push(' rate here is diluted by every entry that never exercised the taint');
|
|
327
|
+
L.push(' engine. A language reading near-zero here is not necessarily a taint');
|
|
328
|
+
L.push(' defect — see `docs/METRICS.md`.');
|
|
329
|
+
L.push('- **Deep-tier only (the taint-shaped subset)** — the number to quote for');
|
|
330
|
+
L.push(' taint capability. Every entry in this bucket is required, before it can');
|
|
331
|
+
L.push(' be committed, to be provably invisible with the deep engine off and');
|
|
332
|
+
L.push(' detected with it on (`bench/cve-replay/CONTRIBUTING.md`, "deep/" tier).');
|
|
333
|
+
L.push(' A language absent from this table has no deep-tier entry yet — that is');
|
|
334
|
+
L.push(' "not yet measured", never "zero capability".');
|
|
335
|
+
L.push('');
|
|
336
|
+
L.push('**No taint-specific precision percentage is reported here, deliberately —');
|
|
337
|
+
L.push('same reasoning as the corpus-wide F1 omission above.** A precision figure');
|
|
338
|
+
L.push('needs a labelled population containing both true and false positives; this');
|
|
339
|
+
L.push('section\'s denominator is all-vulnerable by construction (`pre/` fixtures),');
|
|
340
|
+
L.push('so it cannot supply one. The false-positive side is instrumented instead as');
|
|
341
|
+
L.push('a gate, not a rate: `bench/self-scan/fixtures/polyglot/` carries one');
|
|
342
|
+
L.push('untainted, negative-control fixture per language, covering eight of the');
|
|
343
|
+
L.push('nine first-class languages (C/C++ has no fixture in this set yet), and');
|
|
344
|
+
L.push('`bench:self-scan:check`\'s existing exact per-file drift gate fails the');
|
|
345
|
+
L.push('build the moment any of them stops reading zero. See the self-scan section');
|
|
346
|
+
L.push('below for current counts.');
|
|
347
|
+
L.push('');
|
|
348
|
+
L.push('### Whole corpus (diagnostic)');
|
|
349
|
+
L.push('');
|
|
350
|
+
L.push(`Entries scored: ${m.taintRecall.wholeCorpus.entriesScored}`);
|
|
351
|
+
L.push('');
|
|
352
|
+
L.push('| Language | IR-TAINT recall |');
|
|
353
|
+
L.push('| --- | --- |');
|
|
354
|
+
for (const r of m.taintRecall.wholeCorpus.byLanguage) {
|
|
355
|
+
L.push(`| ${r.language} | ${formatRate(r.taint.n, r.taint.d)} |`);
|
|
356
|
+
}
|
|
357
|
+
L.push('');
|
|
358
|
+
L.push('### Deep-tier only — taint-shaped subset (headline)');
|
|
359
|
+
L.push('');
|
|
360
|
+
L.push(`Entries scored: ${m.taintRecall.deepTierOnly.entriesScored}`);
|
|
361
|
+
L.push('');
|
|
362
|
+
if (m.taintRecall.deepTierOnly.byLanguage.length) {
|
|
363
|
+
L.push('| Language | IR-TAINT recall |');
|
|
364
|
+
L.push('| --- | --- |');
|
|
365
|
+
for (const r of m.taintRecall.deepTierOnly.byLanguage) {
|
|
366
|
+
L.push(`| ${r.language} | ${formatRate(r.taint.n, r.taint.d)} |`);
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
L.push('No deep-tier entries scored this run.');
|
|
370
|
+
}
|
|
371
|
+
L.push('');
|
|
372
|
+
// INTEGRITY CONTRACT above requires unmeasured things "disclosed by name",
|
|
373
|
+
// not just described generically — so name the languages that are present
|
|
374
|
+
// in the whole-corpus view but have no deep-tier entry at all, rather than
|
|
375
|
+
// leaving that as an unnamed implication of the two tables above.
|
|
376
|
+
const deepLangs = new Set(m.taintRecall.deepTierOnly.byLanguage.map(r => r.language));
|
|
377
|
+
const notYetMeasured = m.taintRecall.wholeCorpus.byLanguage
|
|
378
|
+
.map(r => r.language)
|
|
379
|
+
.filter(l => !deepLangs.has(l))
|
|
380
|
+
.sort();
|
|
381
|
+
if (notYetMeasured.length) {
|
|
382
|
+
L.push(`Not yet measured on this subset: ${notYetMeasured.join(', ')}`);
|
|
383
|
+
L.push('');
|
|
384
|
+
}
|
|
282
385
|
L.push('## Precision-side signal: self-scan (measured this run)');
|
|
283
386
|
L.push('');
|
|
284
387
|
L.push('The engine scanned its own repository. These are absolute finding');
|
package/src/runScan.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as fs from 'node:fs/promises';
|
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import * as cp from 'node:child_process';
|
|
6
6
|
import { listFiles } from './util/glob.js';
|
|
7
|
-
import { runFullScan, shouldScan } from './engine.js';
|
|
7
|
+
import { runFullScan, shouldScan, isKubernetesManifest } from './engine.js';
|
|
8
8
|
import { appendScanSnapshot } from './posture/security-trend.js';
|
|
9
9
|
import { recover as recoverFixHistory } from './posture/fix-history.js';
|
|
10
10
|
import { stampScan } from './posture/ruleset-version.js';
|
|
@@ -45,7 +45,10 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
45
45
|
else if (/\.proto$/i.test(base)) depFileContents[rel] = content;
|
|
46
46
|
else if (/\.(?:graphql|gql)$/i.test(base)) depFileContents[rel] = content;
|
|
47
47
|
else if (/\.tf$/i.test(base)) depFileContents[rel] = content;
|
|
48
|
-
|
|
48
|
+
// A Kubernetes manifest is admitted on CONTENT, not on living under a
|
|
49
|
+
// directory named k8s/ — see isKubernetesManifest. Without this the
|
|
50
|
+
// k8s-admission detector is wired into the dispatch and never invoked by it.
|
|
51
|
+
if (shouldScan(rel) || isKubernetesManifest(rel, content)) fileContents[rel] = content;
|
|
49
52
|
// Auxiliary files: .properties files are referenced by Java rules
|
|
50
53
|
// (e.g. OWASP Benchmark's benchmark.properties resolves algorithm
|
|
51
54
|
// aliases). They are not scannable for vulns themselves, but the
|
package/src/sast/CLAUDE.md
CHANGED
|
@@ -43,7 +43,7 @@ Both key on the SECRET-NESS of the identifier, never on the comparison or the ty
|
|
|
43
43
|
|
|
44
44
|
## Gotchas
|
|
45
45
|
|
|
46
|
-
- **Comments confuse detectors.**
|
|
46
|
+
- **Comments confuse detectors — now handled centrally, but read this before relying on it.** `runFullScan` (`../engine.js`) computes ONE comment-blanked, line-and-offset-preserving view per file (`cc`, via `blankComments` + `_commentLangFor`) and passes it to the SAST dispatch, so a new module gets comment-blindness by default and does **not** need its own `blankComments()` call. Calling it anyway is harmless (idempotent) and still correct for modules invoked outside that dispatch. Two things to know: (1) **~20 detectors deliberately receive RAW source** — the LLM/agent/prompt-injection family (instructions hidden in a comment are the attack, not a false positive), the two secrets scanners (a committed credential is leaked whether or not its line executes), and `scanTodosNearSecurity` (comments are its subject). If your rule belongs in that group, say so explicitly at the call site. (2) **Language-awareness is not optional**: `#` is a comment in Python/Ruby/shell but `#include`/`#region` in the C family, and `//` is a comment in C-family languages but **floor division in Python** — blanking it there deletes real code. `_commentLangFor()` owns that mapping; extend it rather than hand-rolling a regex. The property is pinned end-to-end by `test/comment-blindness.test.js` (including a positive control and a Python-floor-division guard), so a regression shows up as a test failure rather than as quietly inflated findings.
|
|
47
47
|
- **Detector snippet attribution.** Don't grab `lines[matchLine - 1]` blindly — for multi-line patterns (`exec('ping' + req.body.host, …)`), the match index and the readable sink line can diverge. Premortem item — fixed in the dataflow engine; if you're regressing on it, your fix probably needs to use the actual sink expression, not the regex's match offset.
|
|
48
48
|
- **Severity floor.** No detector should emit `severity: 'critical'` without strong evidence; the calibrator amplifies critical-rated findings and a flood drowns real ones. If in doubt, emit `high` and let `annotateExploitability` push it up.
|
|
49
49
|
- **Family field for calibration.** If you can't tell what family your rule belongs to, that's a signal the rule covers too much. Split it.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Shared cross-framework "is this handler actually protected?" resolver.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS (PRD Theme 1, T1.2). Every authz-adjacent detector used to
|
|
4
|
+
// hand-roll its own list of blessed middleware/dependency names, and each list
|
|
5
|
+
// was a closed enumeration. Real codebases name their auth helpers whatever
|
|
6
|
+
// they like, so the enumerations missed, and the detectors emitted findings
|
|
7
|
+
// asserting an ABSENCE that the same file visibly contradicts.
|
|
8
|
+
//
|
|
9
|
+
// The measured cost of that, from the 2026-08-17 independent-population audit:
|
|
10
|
+
// GHSA-3cg5-48j3-v4gv scored as a TRUE POSITIVE for "FastAPI mutating endpoint
|
|
11
|
+
// create_folder() has no Security() / Depends() auth dependency" against a
|
|
12
|
+
// function whose signature reads `user=Depends(get_verified_user)` and whose
|
|
13
|
+
// first statement is `await check_folders_permission(request, user, db=db)`.
|
|
14
|
+
// The claim was false about the code; it scored only because the CWE and file
|
|
15
|
+
// happened to line up with an unrelated fix 200 lines away. Two more entries in
|
|
16
|
+
// the same population failed the same way.
|
|
17
|
+
//
|
|
18
|
+
// The rule this module encodes: an absence-claim must be checked against BOTH
|
|
19
|
+
// the handler's injected dependencies AND its body. A detector that looks only
|
|
20
|
+
// at a signature cannot see `check_folders_permission(...)`, and one that looks
|
|
21
|
+
// only for known names cannot see a project's own convention.
|
|
22
|
+
//
|
|
23
|
+
// Deliberately biased toward RECOGNISING auth. A false "this is protected"
|
|
24
|
+
// costs one missed finding; a false "this is unprotected" is a wrong statement
|
|
25
|
+
// about code the reader can see, which is what destroys trust in every other
|
|
26
|
+
// finding the tool emits.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Security NOUNS — the concepts that make an identifier about authentication
|
|
30
|
+
* or authorization. Matched against a CALLEE or PARAMETER name, never against
|
|
31
|
+
* free text, so a comment or string literal cannot satisfy it.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately nouns, not action verbs. An earlier draft included bare
|
|
34
|
+
* `session`, `access`, `verify` and `require`, and its own test immediately
|
|
35
|
+
* caught the consequence: `Depends(get_async_session)` — a DATABASE session —
|
|
36
|
+
* read as authentication evidence and would have silently suppressed real
|
|
37
|
+
* missing-auth findings. Over-suppression is the dangerous direction for this
|
|
38
|
+
* module (it hides vulnerabilities), so ambiguous stems are excluded and only
|
|
39
|
+
* their unambiguous compounds are listed. Verbs are handled separately in
|
|
40
|
+
* hasAuthInBody, where they must co-occur with one of these nouns.
|
|
41
|
+
*/
|
|
42
|
+
const AUTH_NAME_RE = new RegExp([
|
|
43
|
+
'auth', 'authn', 'authz', 'authoriz', 'authoris',
|
|
44
|
+
'login', 'logged_?in', 'jwt', 'oauth', 'oidc', 'saml',
|
|
45
|
+
'principal', 'identity', 'credential', 'password',
|
|
46
|
+
'user', // covers current_/verified_/active_/request_user
|
|
47
|
+
'permission', 'privilege', 'role', 'scope',
|
|
48
|
+
'admin', 'superuser', 'staff',
|
|
49
|
+
'api_?key', 'apikey', 'bearer', 'access_?token', 'access_?control',
|
|
50
|
+
'(?:user|auth|login|web|http)_?session', 'session_?(?:user|token|id)',
|
|
51
|
+
].join('|'), 'i');
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A dependency-injection wrapper: FastAPI Depends()/Security(), NestJS, etc.
|
|
55
|
+
* Group 1 is the wrapper, group 2 the injected callee (absent for `Security()`).
|
|
56
|
+
*/
|
|
57
|
+
const DI_CALL_RE = /\b(Depends|Security)\s{0,8}\(\s{0,8}([A-Za-z_$][\w$.]{0,128})?/g;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Does a handler's PARAMETER LIST carry injected auth?
|
|
61
|
+
*
|
|
62
|
+
* Recognises the DI shape first (`Depends(x)` / `Security(x)` with an
|
|
63
|
+
* auth-shaped callee), then falls back to an auth-shaped parameter NAME —
|
|
64
|
+
* `user`, `current_user`, `principal` — which is how most frameworks surface
|
|
65
|
+
* an already-resolved identity.
|
|
66
|
+
*/
|
|
67
|
+
export function hasAuthInParams(paramsText) {
|
|
68
|
+
const params = String(paramsText || '');
|
|
69
|
+
if (!params.trim()) return null;
|
|
70
|
+
|
|
71
|
+
DI_CALL_RE.lastIndex = 0;
|
|
72
|
+
let m;
|
|
73
|
+
while ((m = DI_CALL_RE.exec(params))) {
|
|
74
|
+
const wrapper = m[1];
|
|
75
|
+
const callee = m[2] || '';
|
|
76
|
+
// `Security(...)` is an auth construct by definition, whatever it wraps —
|
|
77
|
+
// FastAPI has no non-security use for it. `Depends(...)` is generic
|
|
78
|
+
// dependency injection (DB sessions, config, pagination), so it only
|
|
79
|
+
// counts when the injected callee itself names a security concept.
|
|
80
|
+
if (wrapper === 'Security' || AUTH_NAME_RE.test(callee)) {
|
|
81
|
+
return { authenticated: true, reason: `dependency-injected auth: ${wrapper}(${callee || '…'})` };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Parameter name shapes: `user: User`, `user=Depends(...)`, `current_user`.
|
|
86
|
+
for (const p of params.split(',')) {
|
|
87
|
+
const name = (p.split(/[:=]/)[0] || '').trim().replace(/^\*+/, '');
|
|
88
|
+
if (!name) continue;
|
|
89
|
+
if (/^(?:user|current_user|principal|identity|viewer|actor|me)$/i.test(name)) {
|
|
90
|
+
return { authenticated: true, reason: `auth-shaped handler parameter: ${name}` };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Does a handler BODY perform an explicit authorization check?
|
|
98
|
+
*
|
|
99
|
+
* This is the half the FastAPI detector was missing entirely. A project's own
|
|
100
|
+
* `check_folders_permission(...)` is not in anyone's enumeration of blessed
|
|
101
|
+
* names, but it is unmistakably an authorization call at the call site.
|
|
102
|
+
*/
|
|
103
|
+
export function hasAuthInBody(bodyText) {
|
|
104
|
+
const body = String(bodyText || '');
|
|
105
|
+
if (!body.trim()) return null;
|
|
106
|
+
// Call shape: <name>(...) where the callee name is auth-shaped AND reads as
|
|
107
|
+
// an action (check/require/verify/ensure/assert/validate/has/can/enforce).
|
|
108
|
+
// Bounded repetition throughout: this walks third-party source, so a
|
|
109
|
+
// pathological identifier chain must not become a scanner-side ReDoS.
|
|
110
|
+
// Only the FINAL segment is used, so the dotted qualifier is not matched at
|
|
111
|
+
// all — which also keeps this free of the nested quantifier the project's own
|
|
112
|
+
// redos-nfa.js correctly flags on `(?:\w+\.)*` shapes.
|
|
113
|
+
const callRe = /\b([A-Za-z_$][\w$]{0,63})\s{0,8}\(/g;
|
|
114
|
+
let m;
|
|
115
|
+
while ((m = callRe.exec(body))) {
|
|
116
|
+
const callee = m[1];
|
|
117
|
+
const actiony = /^(?:check|require|ensure|verify|assert|validate|enforce|has|can|is|get|authorize|authorise|guard)/i.test(callee);
|
|
118
|
+
if (actiony && AUTH_NAME_RE.test(callee)) {
|
|
119
|
+
return { authenticated: true, reason: `explicit authorization call in body: ${callee}()` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Raise-on-forbidden shape: an explicit 401/403 the handler itself emits.
|
|
123
|
+
if (/\b(?:HTTP_401_UNAUTHORIZED|HTTP_403_FORBIDDEN|UnauthorizedError|ForbiddenError|status_code\s*=\s*40[13]|\b40[13]\b\s*,)/.test(body)) {
|
|
124
|
+
return { authenticated: true, reason: 'handler raises an explicit 401/403' };
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The single question every authz detector should ask before asserting that a
|
|
131
|
+
* handler is unprotected. Returns null when there is no evidence (i.e. the
|
|
132
|
+
* detector may fire), or {authenticated:true, reason} when it must not.
|
|
133
|
+
*
|
|
134
|
+
* `reason` exists so the finding — or its absence — is explainable, per the
|
|
135
|
+
* PRD's T2.2 requirement that an absence-claim record what it looked for.
|
|
136
|
+
*/
|
|
137
|
+
export function routeAuthEvidence({ params = '', body = '' } = {}) {
|
|
138
|
+
return hasAuthInParams(params) || hasAuthInBody(body) || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const _internals = { AUTH_NAME_RE, hasAuthInParams, hasAuthInBody };
|
|
@@ -15,43 +15,110 @@
|
|
|
15
15
|
//
|
|
16
16
|
// Skips comment-like content inside string literals (single/double/backtick).
|
|
17
17
|
//
|
|
18
|
+
// - Ruby: `#` line comments PLUS `=begin` / `=end` block comments, which
|
|
19
|
+
// must start at column 0. No other supported language has this form, so a
|
|
20
|
+
// `#`-only pass leaves the whole block intact and every dangerous
|
|
21
|
+
// construct inside it reads as live code.
|
|
22
|
+
//
|
|
18
23
|
// The `lang` parameter is optional; pass 'py' to treat `#` as a line comment
|
|
19
|
-
// (and skip `//`/`/* */`),
|
|
24
|
+
// (and skip `//`/`/* */`), 'rb' for Python's `#` handling plus Ruby's
|
|
25
|
+
// `=begin`/`=end` blocks, or 'php' to strip all three comment forms.
|
|
20
26
|
|
|
27
|
+
// PERFORMANCE. This runs on every scanned file, and `engine.js` calls it
|
|
28
|
+
// through `stripNoise` roughly fifteen times per file, so it sits directly on
|
|
29
|
+
// the hot path. The original implementation appended ONE CHARACTER AT A TIME
|
|
30
|
+
// (`out += c`) for the whole file, which measured a 19.7% end-to-end scan
|
|
31
|
+
// regression on a 307-file entry once it replaced the old native-regex
|
|
32
|
+
// stripper.
|
|
33
|
+
//
|
|
34
|
+
// This version keeps the identical state machine — it must still inspect every
|
|
35
|
+
// character to know whether a `//` sits inside a string literal — but emits
|
|
36
|
+
// output in BULK: untouched code is pushed as a single `slice()` of the input,
|
|
37
|
+
// and only the comment runs (a small minority of any real file) are rewritten,
|
|
38
|
+
// via a native regex for the newline-preserving cases. Characters are scanned;
|
|
39
|
+
// they are no longer individually concatenated.
|
|
21
40
|
export function blankComments(s, lang) {
|
|
22
|
-
|
|
41
|
+
const parts = [];
|
|
42
|
+
let verbatimFrom = 0; // start of the run of input not yet emitted as-is
|
|
23
43
|
let inS = null;
|
|
24
44
|
let i = 0;
|
|
25
|
-
const
|
|
45
|
+
const isRb = lang === 'rb';
|
|
46
|
+
const isPy = lang === 'py' || isRb;
|
|
26
47
|
const isPhp = lang === 'php';
|
|
27
48
|
const stripSlashForms = !isPy || isPhp;
|
|
28
49
|
const stripHash = isPy || isPhp;
|
|
50
|
+
// Flush the pending verbatim run, then blank [start, end) with newlines kept
|
|
51
|
+
// so every byte offset and line number in the output still matches the input.
|
|
52
|
+
const blank = (start, end) => {
|
|
53
|
+
if (start > verbatimFrom) parts.push(s.slice(verbatimFrom, start));
|
|
54
|
+
parts.push(s.slice(start, end).replace(/[^\n]/g, ' '));
|
|
55
|
+
verbatimFrom = end;
|
|
56
|
+
};
|
|
29
57
|
while (i < s.length) {
|
|
30
58
|
const c = s[i];
|
|
31
59
|
if (inS) {
|
|
32
|
-
|
|
33
|
-
|
|
60
|
+
// A `'` or `"` string cannot span a line in any language handled here, so
|
|
61
|
+
// a newline ends it. Without this reset an ODD number of quotes on one
|
|
62
|
+
// line — which regex literals produce routinely, e.g.
|
|
63
|
+
// `/"(?:sh|bash)"\s*,\s*(?!"[^"]*")/` — leaves the scanner stuck in
|
|
64
|
+
// string mode for the WHOLE REST OF THE FILE, silently disabling comment
|
|
65
|
+
// stripping from that point on. Measured on this repo's own
|
|
66
|
+
// `sast/go-extended.js`, where it resurrected a false positive out of a
|
|
67
|
+
// comment six lines below the regex.
|
|
68
|
+
//
|
|
69
|
+
// Backticks are exempt: JS template literals and Go raw strings are
|
|
70
|
+
// genuinely multi-line. Erring toward "this is code" is the safe
|
|
71
|
+
// direction — the failure it causes is a stripped comment, whereas the
|
|
72
|
+
// opposite error hides every subsequent comment in the file.
|
|
73
|
+
if (c === '\n' && inS !== '`') { inS = null; i++; continue; }
|
|
74
|
+
if (c === '\\' && i + 1 < s.length) { i += 2; continue; }
|
|
34
75
|
if (c === inS) inS = null;
|
|
35
76
|
i++; continue;
|
|
36
77
|
}
|
|
37
|
-
|
|
78
|
+
// Ruby `=begin` … `=end`: only recognised at the start of a line, which is
|
|
79
|
+
// what makes it distinguishable from an ordinary `=` assignment.
|
|
80
|
+
if (isRb && c === '=' && (i === 0 || s[i - 1] === '\n') && s.startsWith('=begin', i)) {
|
|
81
|
+
let end = i;
|
|
82
|
+
for (;;) {
|
|
83
|
+
const nl = s.indexOf('\n', end);
|
|
84
|
+
if (nl < 0) { end = s.length; break; }
|
|
85
|
+
if (s.startsWith('=end', nl + 1)) {
|
|
86
|
+
const after = s.indexOf('\n', nl + 1);
|
|
87
|
+
end = after < 0 ? s.length : after;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
end = nl + 1;
|
|
91
|
+
}
|
|
92
|
+
blank(i, end);
|
|
93
|
+
i = end;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (c === "'" || c === '"' || c === '`') { inS = c; i++; continue; }
|
|
38
97
|
if (stripSlashForms && c === '/' && s[i+1] === '/') {
|
|
39
|
-
|
|
98
|
+
const nl = s.indexOf('\n', i);
|
|
99
|
+
const end = nl < 0 ? s.length : nl;
|
|
100
|
+
blank(i, end);
|
|
101
|
+
i = end;
|
|
40
102
|
continue;
|
|
41
103
|
}
|
|
42
104
|
if (stripSlashForms && c === '/' && s[i+1] === '*') {
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
105
|
+
const close = s.indexOf('*/', i + 2);
|
|
106
|
+
const end = close < 0 ? s.length : close + 2;
|
|
107
|
+
blank(i, end);
|
|
108
|
+
i = end;
|
|
46
109
|
continue;
|
|
47
110
|
}
|
|
48
111
|
// PHP 8 attributes (`#[Route(...)]`) use the same `#` prefix as a line
|
|
49
112
|
// comment — `#[` is never a comment, so don't blank it.
|
|
50
113
|
if (stripHash && c === '#' && s[i+1] !== '[') {
|
|
51
|
-
|
|
114
|
+
const nl = s.indexOf('\n', i);
|
|
115
|
+
const end = nl < 0 ? s.length : nl;
|
|
116
|
+
blank(i, end);
|
|
117
|
+
i = end;
|
|
52
118
|
continue;
|
|
53
119
|
}
|
|
54
|
-
|
|
120
|
+
i++;
|
|
55
121
|
}
|
|
56
|
-
|
|
122
|
+
if (verbatimFrom < s.length) parts.push(s.slice(verbatimFrom));
|
|
123
|
+
return parts.join('');
|
|
57
124
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// PRD T4.3 — code generation as an injection sink. 5 of the 96 root-caused
|
|
2
|
+
// real-world misses.
|
|
3
|
+
//
|
|
4
|
+
// A NEW SINK CATEGORY, not a new sink entry. Every injection rule in this
|
|
5
|
+
// codebase asks "does untrusted data reach a dangerous CALL" — eval, exec,
|
|
6
|
+
// a query, a shell. Here there is no dangerous call at all: the program
|
|
7
|
+
// WRITES A FILE, and the file happens to be source code that something else
|
|
8
|
+
// imports and runs later. The execution is in a different process, often on a
|
|
9
|
+
// different machine, minutes or months afterwards.
|
|
10
|
+
//
|
|
11
|
+
// The real entries, all in code generators:
|
|
12
|
+
// - a JSON-Schema `x-python-type` / `default_factory` / `customTypePath`
|
|
13
|
+
// spliced verbatim into a generated .py type annotation or import
|
|
14
|
+
// (GHSA-m34r-v34r-rf9q, GHSA-5578-w22f-pfx9, GHSA-386q-5hp3-95m9)
|
|
15
|
+
// - field/mode strings interpolated into generated validator source
|
|
16
|
+
// (GHSA-8m8r-38jm-f355)
|
|
17
|
+
// - an `--extra-template-data` comment field unescaped for \r, so a
|
|
18
|
+
// generated source COMMENT can hide executable statements from a reviewer
|
|
19
|
+
// (GHSA-wjv6-jcfj-mf9r)
|
|
20
|
+
//
|
|
21
|
+
// Precision: a template engine writing HTML is not this. The rule requires
|
|
22
|
+
// BOTH that the written artifact is source code (a code-ish extension, or a
|
|
23
|
+
// clearly code-shaped template) AND that an interpolated value comes from a
|
|
24
|
+
// parsed document / config surface rather than a literal — and it stays silent
|
|
25
|
+
// when the value passes through an identifier validator, which is what every
|
|
26
|
+
// one of these advisories added as its fix.
|
|
27
|
+
import { blankComments } from './_comment-strip.js';
|
|
28
|
+
|
|
29
|
+
const SRC_RE = /\.(?:py|js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
30
|
+
|
|
31
|
+
/** Writing a file whose name looks like source code. */
|
|
32
|
+
const CODE_WRITE_RE = new RegExp([
|
|
33
|
+
// open("out.py", "w") / Path(...).write_text(...) / fs.writeFile("x.ts", ...)
|
|
34
|
+
'open\\s{0,4}\\([^)]{0,200}\\.(?:py|js|ts|rb|go|java|php|sh)["\\\']',
|
|
35
|
+
'write_text\\s{0,4}\\(', 'writeFileSync?\\s{0,4}\\([^)]{0,200}\\.(?:py|js|ts|mjs|cjs)["\\\']',
|
|
36
|
+
'\\.write\\s{0,4}\\(',
|
|
37
|
+
].join('|'), 'i');
|
|
38
|
+
|
|
39
|
+
/** The generated artifact is program text: an import, def, class, or assignment. */
|
|
40
|
+
const CODE_SHAPED_RE = /(?:^|["'`\s])(?:import\s|from\s+\w|def\s|class\s|return\s|lambda\s|=\s*lambda|@\w)/;
|
|
41
|
+
|
|
42
|
+
/** Interpolation of a non-literal into that text. */
|
|
43
|
+
const INTERPOLATION_RE = /(?:f["'][^"']{0,200}\{|\$\{|%\s{0,2}\(|\.format\s{0,4}\(|\+\s{0,4}\w|Template\s{0,4}\(|render\w{0,10}\s{0,4}\()/;
|
|
44
|
+
|
|
45
|
+
/** Values that come from a parsed document / caller config, not from source. */
|
|
46
|
+
const EXTERNAL_FIELD_RE =
|
|
47
|
+
/\b(?:extras?|schema|spec|definition|properties|metadata|template_?data|extra_?template|custom\w{0,20}|x-[\w-]{1,30}|config|options|field_?name|user_\w{1,20})\b/i;
|
|
48
|
+
|
|
49
|
+
/** The mitigation every one of these advisories shipped. */
|
|
50
|
+
const IDENTIFIER_VALIDATION_RE = new RegExp([
|
|
51
|
+
'isidentifier\\s{0,4}\\(', 'str\\.isidentifier',
|
|
52
|
+
'VALID\\w{0,20}_RE', '_RE\\.(?:match|fullmatch)\\s{0,4}\\(',
|
|
53
|
+
're\\.(?:match|fullmatch)\\s{0,4}\\(', 'allow_?list', 'ALLOWED_\\w{1,30}',
|
|
54
|
+
'sanitiz\\w{0,6}\\s{0,4}\\(', 'escape\\w{0,10}\\s{0,4}\\(',
|
|
55
|
+
'validate_\\w{1,30}\\s{0,4}\\(', 'is_?safe\\w{0,20}\\s{0,4}\\(',
|
|
56
|
+
].join('|'), 'i');
|
|
57
|
+
|
|
58
|
+
const _lineOf = (raw, i) => raw.slice(0, i).split('\n').length;
|
|
59
|
+
const _win = (raw, line, half = 14) => {
|
|
60
|
+
const l = raw.split('\n');
|
|
61
|
+
return l.slice(Math.max(0, line - 1 - half), Math.min(l.length, line - 1 + half)).join('\n');
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function scanCodegenSink(file, raw) {
|
|
65
|
+
if (!raw || typeof raw !== 'string' || raw.length > 500_000) return [];
|
|
66
|
+
if (!SRC_RE.test(file)) return [];
|
|
67
|
+
// Cheap relevance gate: only files that both emit and interpolate.
|
|
68
|
+
if (!CODE_WRITE_RE.test(raw) || !EXTERNAL_FIELD_RE.test(raw)) return [];
|
|
69
|
+
const code = blankComments(raw, /\.py$/i.test(file) ? 'py' : null);
|
|
70
|
+
|
|
71
|
+
const out = [];
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const lines = code.split('\n');
|
|
74
|
+
for (let i = 0; i < lines.length; i++) {
|
|
75
|
+
const text = lines[i];
|
|
76
|
+
// The line must be assembling program text with an interpolation in it.
|
|
77
|
+
if (!CODE_SHAPED_RE.test(text) || !INTERPOLATION_RE.test(text)) continue;
|
|
78
|
+
const line = i + 1;
|
|
79
|
+
const win = _win(raw, line);
|
|
80
|
+
if (!CODE_WRITE_RE.test(win)) continue; // not actually emitted as a file
|
|
81
|
+
if (!EXTERNAL_FIELD_RE.test(win)) continue; // value isn't caller-supplied
|
|
82
|
+
if (IDENTIFIER_VALIDATION_RE.test(win)) continue; // validated — the fix
|
|
83
|
+
if (seen.has(line)) continue;
|
|
84
|
+
seen.add(line);
|
|
85
|
+
out.push({
|
|
86
|
+
id: `codegen-sink:generated-source:${file}:${line}`,
|
|
87
|
+
file, line,
|
|
88
|
+
vuln: 'Untrusted value interpolated into generated source code',
|
|
89
|
+
severity: 'high',
|
|
90
|
+
cwe: 'CWE-94',
|
|
91
|
+
family: 'code-injection',
|
|
92
|
+
subfamily: 'generated-source',
|
|
93
|
+
parser: 'CODEGEN',
|
|
94
|
+
confidence: 0.5,
|
|
95
|
+
description:
|
|
96
|
+
'A value taken from a parsed schema/config surface is interpolated into text that this program writes out '
|
|
97
|
+
+ 'as source code, with no identifier or allow-list validation in between. The generated file is later '
|
|
98
|
+
+ 'imported and executed, so the injection executes in a different process than the one that emitted it — '
|
|
99
|
+
+ 'which is why no eval/exec appears anywhere near this line and why ordinary injection rules do not see it.',
|
|
100
|
+
remediation:
|
|
101
|
+
'Validate every interpolated value against the grammar of what it becomes — `str.isidentifier()` for a '
|
|
102
|
+
+ 'name, an explicit allow-list for a type or import path — and reject embedded newlines and carriage '
|
|
103
|
+
+ 'returns, which can smuggle statements past a generated comment.',
|
|
104
|
+
checkedFor: 'an identifier/allow-list/escape validation applied to the interpolated value within 14 lines',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const _internals = { CODE_WRITE_RE, CODE_SHAPED_RE, INTERPOLATION_RE, EXTERNAL_FIELD_RE, IDENTIFIER_VALIDATION_RE };
|