@clear-capabilities/agentic-security-scanner 0.137.0 → 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 +219 -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
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
// Inline role checks — on the route's OWN registration line (middleware args).
|
|
16
16
|
const INLINE_PATTERNS = [
|
|
17
|
-
/\b(?:requireRole|hasRole|hasAnyRole|checkRole|ensureRole|restrictTo|requireScope|hasAuthority|hasPermission|requirePermission)\s*\(\s*\[?\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
|
|
17
|
+
/\b(?:requireRole|hasRole|hasAnyRole|checkRole|ensureRole|restrictTo|requireScope|hasAuthority|hasPermission|hasAnyPermission|requirePermission|checkPermission|checkAnyPermission)\s*\(\s*\[?\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
|
|
18
18
|
/\brole\s*(?:===?|==)\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
|
|
19
19
|
];
|
|
20
20
|
// Decorator/attribute role checks — on the line(s) directly ABOVE the handler.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// PRD T4.4 + T4.5 — redirect/header-forwarding semantics, and validate-then-
|
|
2
|
+
// resolve TOCTOU. 11 of the 96 root-caused real-world misses between them.
|
|
3
|
+
//
|
|
4
|
+
// WHY THESE ARE NOT ALREADY COVERED. Every existing "redirect" rule in this
|
|
5
|
+
// codebase is CWE-601 open-redirect — the app sends an attacker-controlled
|
|
6
|
+
// Location header. These are the OPPOSITE direction: the app is the CLIENT,
|
|
7
|
+
// and the danger is what its own outbound request does when the SERVER
|
|
8
|
+
// redirects it.
|
|
9
|
+
//
|
|
10
|
+
// T4.4a (CWE-200) — credential headers survive an origin-changing redirect.
|
|
11
|
+
// `httpx.get(url, headers=auth_headers, follow_redirects=True)` replays
|
|
12
|
+
// Authorization to whatever host the first server names.
|
|
13
|
+
// (GHSA-r5vv-ff45-prp2, GHSA-4jc5-g844-4x33)
|
|
14
|
+
// T4.4b (CWE-918) — a validated URL is followed into redirect space with no
|
|
15
|
+
// per-hop re-validation, so the allow-list is checked once and bypassed
|
|
16
|
+
// on hop 2. (GHSA-c9hr-64h3-gxpc)
|
|
17
|
+
// T4.5 (CWE-367) — the value CHECKED and the value USED are resolved
|
|
18
|
+
// separately, so they can differ: DNS rebinding between an IP check and
|
|
19
|
+
// the connect, or a re-resolved path after a containment check.
|
|
20
|
+
// (GHSA-ch52-px8q-f22j, GHSA-vx7x-vcc2-c44g)
|
|
21
|
+
//
|
|
22
|
+
// Precision comes from requiring the mitigation to be ABSENT: each rule names
|
|
23
|
+
// the specific control the real fix added, and stays silent when it is present.
|
|
24
|
+
import { blankComments } from './_comment-strip.js';
|
|
25
|
+
|
|
26
|
+
const SRC_RE = /\.(?:py|js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
27
|
+
|
|
28
|
+
/** An outbound HTTP call that can be told to follow redirects. */
|
|
29
|
+
const CLIENT_CALL_RE =
|
|
30
|
+
/\b(?:requests|httpx|session|client|axios|got|fetch|urlopen|request)\b[\w.]{0,40}\s{0,4}\(([^;]{0,400})/gi;
|
|
31
|
+
|
|
32
|
+
/** Redirect-following turned on (explicitly, or by a library that defaults to it). */
|
|
33
|
+
const FOLLOWS_REDIRECTS_RE = /\b(?:follow_redirects\s{0,4}=\s{0,4}True|allow_redirects\s{0,4}=\s{0,4}True|maxRedirects\s{0,4}:\s{0,4}[1-9]|redirect\s{0,4}:\s{0,4}['"]follow['"])/;
|
|
34
|
+
|
|
35
|
+
/** Credential-bearing headers handed to that call. */
|
|
36
|
+
const CREDENTIAL_HEADER_RE = /\b(?:headers|auth|Authorization|Cookie|api_?key|bearer|token)\b/i;
|
|
37
|
+
|
|
38
|
+
/** The T4.4a mitigation: headers stripped or rescoped across the hop. */
|
|
39
|
+
const HEADER_STRIP_RE =
|
|
40
|
+
/\b(?:_?redirect_headers|strip_?(?:auth|headers)|rebuild_auth|same_?origin|origin\s{0,4}[!=]==?|del\s+headers|headers\.pop|headers\.delete|drop_?headers)\b/i;
|
|
41
|
+
|
|
42
|
+
/** The T4.4b mitigation: the target is re-checked per hop, not once up front. */
|
|
43
|
+
const PER_HOP_CHECK_RE =
|
|
44
|
+
/\b(?:on_?redirect|beforeRedirect|redirect_?hook|validate_?redirect|check_?redirect|per_?hop|for\s+hop\b)/i;
|
|
45
|
+
|
|
46
|
+
/** A one-time URL/host validation — the thing that gets bypassed. */
|
|
47
|
+
const URL_VALIDATION_RE =
|
|
48
|
+
/\b(?:validate_?url|check_?url|is_?allowed|allow_?list|allowlist|deny_?list|is_?private|is_?internal|ssrf|_validate_url_for_fetch)\w{0,20}\s{0,4}\(/i;
|
|
49
|
+
|
|
50
|
+
/** T4.5: a resolution whose result is checked. */
|
|
51
|
+
const RESOLVE_RE = /\b(?:getaddrinfo|gethostbyname|resolve|\w*[dD]ns\w*\.lookup|socket\.getaddrinfo|realpath|os\.path\.realpath|resolve\(\))\b/i;
|
|
52
|
+
|
|
53
|
+
/** T4.5 mitigation: the checked result is PINNED and reused, not re-resolved. */
|
|
54
|
+
const PIN_RE = /\b(?:pin|pinned|resolved_?ip|cached_?ip|use_?resolved|connect_?to_?ip|sock\.connect\(\s*\(?\s*resolved)/i;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* T4.5's OTHER mitigation shape, found via GHSA-ch52-px8q-f22j: instead of
|
|
58
|
+
* pinning the checked value, the fix installs a custom resolver/lookup hook so
|
|
59
|
+
* the SAME resolution used to connect is the one that gets validated — a
|
|
60
|
+
* connect-time revalidation gate rather than a cache. Window-scoped like PIN_RE
|
|
61
|
+
* would miss this: the hook is typically a sibling function, not adjacent to
|
|
62
|
+
* the original resolve-and-check call it supersedes. File-scoped is safe here
|
|
63
|
+
* because assignment to `.lookup`/`.resolveLookup`/an http(s) agent's `lookup`
|
|
64
|
+
* option is a specific code shape, not a word that appears in prose or pattern
|
|
65
|
+
* tables the way "resolve" or "getaddrinfo" do.
|
|
66
|
+
*/
|
|
67
|
+
const CONNECT_TIME_REVALIDATION_RE = /\.\s{0,2}(?:lookup|resolveLookup)\s{0,4}=\s{0,4}(?:async\s{1,4})?\(/;
|
|
68
|
+
|
|
69
|
+
const _lineOf = (raw, i) => raw.slice(0, i).split('\n').length;
|
|
70
|
+
const _win = (raw, line, half = 14) => {
|
|
71
|
+
const l = raw.split('\n');
|
|
72
|
+
return l.slice(Math.max(0, line - 1 - half), Math.min(l.length, line - 1 + half)).join('\n');
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function mk(file, line, sub, severity, cwe, vuln, description, remediation, checkedFor) {
|
|
76
|
+
return {
|
|
77
|
+
id: `redirect-toctou:${sub}:${file}:${line}`,
|
|
78
|
+
file, line, vuln, severity, cwe,
|
|
79
|
+
family: sub === 'toctou-resolve' ? 'toctou' : 'redirect-forwarding',
|
|
80
|
+
parser: 'REDIRECT-TOCTOU',
|
|
81
|
+
subfamily: sub,
|
|
82
|
+
confidence: 0.5,
|
|
83
|
+
description, remediation, checkedFor,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function scanRedirectToctou(file, raw) {
|
|
88
|
+
if (!raw || typeof raw !== 'string' || raw.length > 500_000) return [];
|
|
89
|
+
if (!SRC_RE.test(file)) return [];
|
|
90
|
+
if (!/redirect|getaddrinfo|gethostbyname|realpath|resolve/i.test(raw)) return [];
|
|
91
|
+
const code = blankComments(raw, /\.py$/i.test(file) ? 'py' : null);
|
|
92
|
+
const out = [];
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
const push = (f) => { const k = `${f.subfamily}:${f.line}`; if (!seen.has(k)) { seen.add(k); out.push(f); } };
|
|
95
|
+
|
|
96
|
+
CLIENT_CALL_RE.lastIndex = 0;
|
|
97
|
+
let m;
|
|
98
|
+
while ((m = CLIENT_CALL_RE.exec(code))) {
|
|
99
|
+
const argsText = m[1] || '';
|
|
100
|
+
const line = _lineOf(code, m.index);
|
|
101
|
+
const win = _win(raw, line);
|
|
102
|
+
if (!FOLLOWS_REDIRECTS_RE.test(argsText) && !FOLLOWS_REDIRECTS_RE.test(win)) continue;
|
|
103
|
+
|
|
104
|
+
// T4.4a — credentials handed to a redirect-following request, with no
|
|
105
|
+
// evidence anywhere nearby that headers are dropped when the origin changes.
|
|
106
|
+
if (CREDENTIAL_HEADER_RE.test(argsText) && !HEADER_STRIP_RE.test(win)) {
|
|
107
|
+
push(mk(file, line, 'credential-across-redirect', 'medium', 'CWE-200',
|
|
108
|
+
'Credential headers forwarded across an origin-changing redirect',
|
|
109
|
+
'This request carries credential headers AND follows redirects, and nothing in the surrounding code drops '
|
|
110
|
+
+ 'those headers when the redirect changes origin. Most HTTP clients only strip Authorization and Cookie by '
|
|
111
|
+
+ 'default, so a custom header (X-Api-Key, X-Auth-Token) is replayed verbatim to whatever host the first '
|
|
112
|
+
+ 'server names — handing the caller\'s credentials to a third party that merely had to answer with a 302.',
|
|
113
|
+
'Drop every credential header when the redirect target\'s origin differs from the original request\'s, or '
|
|
114
|
+
+ 'disable redirect-following and handle each hop explicitly.',
|
|
115
|
+
'a header-stripping / same-origin check within 14 lines'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// T4.4b — the URL was validated once, then followed wherever it leads.
|
|
119
|
+
if (URL_VALIDATION_RE.test(win) && !PER_HOP_CHECK_RE.test(win)) {
|
|
120
|
+
push(mk(file, line, 'unvalidated-redirect-hop', 'medium', 'CWE-918',
|
|
121
|
+
'URL allow-list checked once, then redirects followed without re-validation',
|
|
122
|
+
'The target URL is validated before the request, but redirects are followed and nothing re-validates the '
|
|
123
|
+
+ 'destination of each hop. An allowed host can answer with a 302 to an internal address, so the check '
|
|
124
|
+
+ 'protects only the first hop — the classic SSRF allow-list bypass.',
|
|
125
|
+
'Re-run the same host validation on every redirect hop (a redirect hook / per-hop callback), or follow '
|
|
126
|
+
+ 'redirects manually so each Location can be checked before it is fetched.',
|
|
127
|
+
'a per-hop redirect hook or re-validation within 14 lines'));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// T4.5 — resolve, check the result, then let the consumer resolve again.
|
|
132
|
+
//
|
|
133
|
+
// LOCALITY IS REQUIRED. The first draft asked whether the file ANYWHERE
|
|
134
|
+
// contained a resolve, a validation, and no pin — and immediately fired on
|
|
135
|
+
// two of this project's own detector modules, whose rule tables mention
|
|
136
|
+
// getaddrinfo/is_allowed as pattern DATA rather than performing either. A
|
|
137
|
+
// real TOCTOU has the resolve and the check in the same handful of lines,
|
|
138
|
+
// so the window is the unit, not the file.
|
|
139
|
+
// The resolved value must be BOUND to a name and that same name checked —
|
|
140
|
+
// window co-occurrence alone was still too loose (it fired on catalog.js and
|
|
141
|
+
// two structural detectors, whose rule tables merely mention these APIs).
|
|
142
|
+
const RESOLVE_BIND_RE = /\b(\w{1,60})\s{0,4}=\s{0,4}[^\n]{0,120}\b(?:getaddrinfo|gethostbyname|\w*[dD]ns\w*\.lookup|realpath)\b/g;
|
|
143
|
+
let rmm;
|
|
144
|
+
while ((rmm = RESOLVE_BIND_RE.exec(code))) {
|
|
145
|
+
const bound = rmm[1];
|
|
146
|
+
const line = _lineOf(code, rmm.index);
|
|
147
|
+
const win = _win(raw, line, 10);
|
|
148
|
+
// That exact name must be what the validation looks at.
|
|
149
|
+
const checked = new RegExp(`${URL_VALIDATION_RE.source.replace(/^\\b/, '\\b')}[^)]{0,80}\\b${bound}\\b`, 'i');
|
|
150
|
+
if (!checked.test(win)) continue; // the resolved value is not what is checked
|
|
151
|
+
if (PIN_RE.test(win)) continue; // the checked value is reused — the fix
|
|
152
|
+
if (CONNECT_TIME_REVALIDATION_RE.test(code)) continue; // a custom lookup hook revalidates at connect time — the other fix
|
|
153
|
+
push(mk(file, line, 'toctou-resolve', 'medium', 'CWE-367',
|
|
154
|
+
'Validated address is re-resolved before use (TOCTOU / DNS rebinding)',
|
|
155
|
+
'A name is resolved and the result is validated, but the resolved value is not pinned — the connection (or '
|
|
156
|
+
+ 'file open) resolves the name a second time. Between the two resolutions the answer can change, so the '
|
|
157
|
+
+ 'address that was checked is not the address that is used. For DNS this is rebinding: the attacker answers '
|
|
158
|
+
+ 'once with a public IP to pass the check and once with an internal one to be connected to.',
|
|
159
|
+
'Pin the validated result and use it directly (connect to the checked IP, open the checked realpath) instead '
|
|
160
|
+
+ 'of re-resolving the original name.',
|
|
161
|
+
'evidence that the validated result is pinned and reused rather than re-resolved'));
|
|
162
|
+
break; // one finding per file is enough to act on
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const _internals = { FOLLOWS_REDIRECTS_RE, HEADER_STRIP_RE, PER_HOP_CHECK_RE, URL_VALIDATION_RE, PIN_RE, CONNECT_TIME_REVALIDATION_RE };
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// PRD T4.2 — resource exhaustion from an unbounded, externally-influenced size.
|
|
2
|
+
//
|
|
3
|
+
// The largest unimplemented class in the evidence table: 14 of the 96
|
|
4
|
+
// root-caused real-world misses. Distinct from ReDoS (redos-nfa.js covers
|
|
5
|
+
// regex backtracking only, and correctly returned "safe" for a real
|
|
6
|
+
// polynomial-split() DoS) and from injection — nothing is escaped or
|
|
7
|
+
// concatenated here. The bug is that a value which the caller controls is used
|
|
8
|
+
// as an ALLOCATION or ITERATION size with no upper bound on any path.
|
|
9
|
+
//
|
|
10
|
+
// Shapes drawn from the real entries:
|
|
11
|
+
// pypdf — a /W CID-width array's start..stop range expanded into a dict,
|
|
12
|
+
// one key per index, with no cap (CWE-834).
|
|
13
|
+
// pypdf — a CMap token length with no ceiling (CWE-400).
|
|
14
|
+
// thumbor — `new_width = source_width * value` from a URL filter argument
|
|
15
|
+
// with no upper bound (CWE-400).
|
|
16
|
+
// mermaid — an unbounded `ticks` from parsed diagram text driving render
|
|
17
|
+
// geometry (CWE-606).
|
|
18
|
+
//
|
|
19
|
+
// PRECISION IS THE WHOLE GAME. "A number is used as a size" describes most
|
|
20
|
+
// code ever written, so this fires only when ALL of the following hold:
|
|
21
|
+
// 1. the size derives from a recognised EXTERNAL surface (a request/params
|
|
22
|
+
// object, a parsed document field, or a function parameter that reaches
|
|
23
|
+
// the operation unmodified) — never a local constant;
|
|
24
|
+
// 2. the value reaches a bounded-cost operation (range/repeat/allocation);
|
|
25
|
+
// 3. no comparison against an upper bound appears anywhere in the enclosing
|
|
26
|
+
// window.
|
|
27
|
+
// Condition 3 is what keeps it quiet: a single `if (n > MAX)` silences it, and
|
|
28
|
+
// that is exactly the fix each of these advisories shipped — so the detector
|
|
29
|
+
// discriminates the fixed revision from the vulnerable one by construction.
|
|
30
|
+
import { blankComments } from './_comment-strip.js';
|
|
31
|
+
|
|
32
|
+
const PY_RE = /\.py$/i;
|
|
33
|
+
const JS_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
34
|
+
|
|
35
|
+
/** External surfaces whose values a caller can choose. */
|
|
36
|
+
const EXTERNAL_RE = /\b(?:request|req|params|query|body|args|argv|options|opts|payload|form|headers|data|spec|config|input|user_input|kwargs)\b/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An upper-bound check that actually applies to the size-controlling
|
|
40
|
+
* identifier — the mitigation. GHSA-phj3-59pf-cp83's PRE revision already
|
|
41
|
+
* contains `if new_width < 1 or new_height < 1: return`, a lower-bound
|
|
42
|
+
* sanity check on the DERIVED value, not a ceiling on the source `value`.
|
|
43
|
+
* An identifier-agnostic `[<>]=?\s{0,4}\d` match treated that as the fix and
|
|
44
|
+
* silenced the finding on both pre/ and post/. The comparison-based
|
|
45
|
+
* alternatives now require the identifier itself; the keyword-based ones
|
|
46
|
+
* (min/max/clamp/raise/...) stay window-scoped — those tokens are
|
|
47
|
+
* distinctive enough on their own that binding them to one identifier would
|
|
48
|
+
* just miss the common `n = min(n, MAX)` reassignment shape.
|
|
49
|
+
*/
|
|
50
|
+
const BOUND_KEYWORDS_RE = new RegExp([
|
|
51
|
+
'\\bmin\\s{0,4}\\(', '\\bMath\\.min\\s{0,4}\\(',
|
|
52
|
+
'\\bmax_\\w{1,40}\\b', '\\bMAX_\\w{1,40}\\b', '\\blimit\\b', '\\bcap\\b',
|
|
53
|
+
'\\bclamp\\b', '\\bslice\\s{0,4}\\(', '\\bislice\\s{0,4}\\(',
|
|
54
|
+
'\\braise\\b', '\\bthrow\\b',
|
|
55
|
+
].join('|'), 'i');
|
|
56
|
+
function _isBounded(id, win) {
|
|
57
|
+
if (BOUND_KEYWORDS_RE.test(win)) return true;
|
|
58
|
+
if (!id) return false;
|
|
59
|
+
const esc = String(id).split('.')[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
60
|
+
const named = new RegExp(
|
|
61
|
+
`\\b${esc}\\b\\s{0,4}[<>]=?\\s{0,4}(?:\\d|[A-Z_]{2,40}\\b)|(?:\\d+|[A-Z_]{2,40})\\s{0,4}[<>]=?\\s{0,4}\\b${esc}\\b`,
|
|
62
|
+
'i');
|
|
63
|
+
return named.test(win);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Bounded-cost operations: allocate or iterate proportional to a value.
|
|
68
|
+
*
|
|
69
|
+
* `candidates` returns every identifier the match could be sized from, when
|
|
70
|
+
* more than one is plausible (a multiplication's two operands). Without it,
|
|
71
|
+
* only the FIRST non-empty capture group was ever tested — GHSA-phj3-59pf-cp83
|
|
72
|
+
* (`new_width = source_width * value`) happened to pass anyway because
|
|
73
|
+
* `source_width`'s own assignment line mentions `request`, but a version with
|
|
74
|
+
* the constant on the left (`n = CONSTANT * value`) would have silently
|
|
75
|
+
* skipped `value`, the actual caller-controlled operand, forever.
|
|
76
|
+
*/
|
|
77
|
+
const PY_SIZE_OPS = [
|
|
78
|
+
{ re: /\brange\s*\(([^)]{1,200})\)/g, what: 'range()' },
|
|
79
|
+
{ re: /\b(\w{1,60})\s*\*\s*(\w{1,60})\b(?=\s*(?:\)|,|$|\n))/g, what: 'multiplication used as a size', candidates: m => [m[1], m[2]] },
|
|
80
|
+
{ re: /\[\s*[^\]]{0,60}\s*\]\s*\*\s*(\w{1,60})/g, what: 'list repetition' },
|
|
81
|
+
{ re: /\bbytearray\s*\(([^)]{1,120})\)/g, what: 'bytearray()' },
|
|
82
|
+
];
|
|
83
|
+
/**
|
|
84
|
+
* A loop bounded by an input's OWN `.length`/`.size` iterates exactly what
|
|
85
|
+
* the process already holds in memory — it allocates nothing beyond that, so
|
|
86
|
+
* it isn't the CWE-400 shape this rule targets. Every parser in existence
|
|
87
|
+
* loops `for (i = 0; i < input.length; i++)`; flagging that would make the
|
|
88
|
+
* rule fire on essentially all parsing code. Exposed by the identifier-scoped
|
|
89
|
+
* `_isBounded` fix above no longer being coincidentally silenced by an
|
|
90
|
+
* unrelated comparison elsewhere in the window.
|
|
91
|
+
*/
|
|
92
|
+
const LENGTH_BOUND_RE = /\.(?:length|size|len|byteLength)$/i;
|
|
93
|
+
const JS_SIZE_OPS = [
|
|
94
|
+
{ re: /\bnew\s+Array\s*\(([^)]{1,120})\)/g, what: 'new Array()' },
|
|
95
|
+
{ re: /\.repeat\s*\(([^)]{1,120})\)/g, what: '.repeat()' },
|
|
96
|
+
{ re: /\bBuffer\.alloc\w{0,10}\s*\(([^)]{1,120})\)/g, what: 'Buffer.alloc()' },
|
|
97
|
+
{ re: /for\s*\([^;]{0,80};[^;]{0,80}<\s*([\w.]{1,60})\s*;/g, what: 'loop bound', skip: expr => LENGTH_BOUND_RE.test(expr.trim()) },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const _lineOf = (raw, idx) => raw.slice(0, idx).split('\n').length;
|
|
101
|
+
const _window = (raw, line, half = 12) => {
|
|
102
|
+
const l = raw.split('\n');
|
|
103
|
+
return l.slice(Math.max(0, line - 1 - half), Math.min(l.length, line - 1 + half)).join('\n');
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A generic (non-staticmethod/property/etc.) decorator marks a function as
|
|
108
|
+
* invoked BY something else across a trust boundary — a route, a filter, a
|
|
109
|
+
* task queue, a validator. Its parameters are as externally-influenced as a
|
|
110
|
+
* literal `req`/`params` name, without needing to guess a naming convention.
|
|
111
|
+
* GHSA-phj3-59pf-cp83's `value` (a `@filter_method`-decorated parameter) is
|
|
112
|
+
* the case this exists for; EXTERNAL_RE's fixed vocabulary cannot express it.
|
|
113
|
+
*/
|
|
114
|
+
const NON_HANDLER_DECORATOR_RE = /^@(?:staticmethod|classmethod|property|dataclass|lru_cache|cached_property|abstractmethod|overload|wraps|final|override)\b/i;
|
|
115
|
+
/** Cheap top-level gate: any indented `@decorator` line at all. */
|
|
116
|
+
const HAS_DECORATOR_RE = /^[ \t]{0,20}@[A-Za-z_]/m;
|
|
117
|
+
const _handlerDefRe = () => /@([\w.]+)[^\n]{0,200}\n\s{0,20}(?:async\s{1,4})?def\s{1,4}\w{1,60}\s{0,4}\(([^)]{0,400})\)/g;
|
|
118
|
+
function _isHandlerParam(name, code, idx) {
|
|
119
|
+
if (!name || /^(?:self|cls)$/.test(name)) return false;
|
|
120
|
+
const re = _handlerDefRe();
|
|
121
|
+
let last = null, m;
|
|
122
|
+
while ((m = re.exec(code)) && m.index < idx) last = m;
|
|
123
|
+
if (!last) return false;
|
|
124
|
+
if (NON_HANDLER_DECORATOR_RE.test('@' + last[1])) return false;
|
|
125
|
+
const params = String(last[2]).split(',').map(p => p.trim().split(/[:=]/)[0].replace(/^\*{1,2}/, '').trim());
|
|
126
|
+
return params.includes(name);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Does this size expression trace to something a caller chooses, within the
|
|
131
|
+
* enclosing window? Deliberately window-scoped rather than flow-sensitive:
|
|
132
|
+
* this is a structural detector, and the taint engine already owns real flow.
|
|
133
|
+
*/
|
|
134
|
+
function _externallyInfluenced(sizeExpr, win, code, matchIdx) {
|
|
135
|
+
const idents = String(sizeExpr).match(/[A-Za-z_$][\w$.]{0,60}/g) || [];
|
|
136
|
+
for (const id of idents) {
|
|
137
|
+
if (/^\d+$/.test(id)) continue;
|
|
138
|
+
// A BARE (unqualified) 'headers' is as likely to be a local variable —
|
|
139
|
+
// a table's column headers, a config section name — as an HTTP request's
|
|
140
|
+
// headers; scripts/nist-compliance/scan.py's ASCII-table renderer has
|
|
141
|
+
// exactly this shape (`for i in range(len(headers))`). Requiring
|
|
142
|
+
// qualification (`req.headers`) keeps the word useful without matching
|
|
143
|
+
// every unrelated local var that happens to share its name.
|
|
144
|
+
if (id !== 'headers' && EXTERNAL_RE.test(id)) return id; // params.count / req.headers
|
|
145
|
+
// Assigned from an external surface nearby: `n = data["count"]`
|
|
146
|
+
const bare = id.split('.')[0];
|
|
147
|
+
const assigned = new RegExp(`\\b${bare.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\s{0,4}=[^=]{0,120}`, 'g');
|
|
148
|
+
for (const m of win.match(assigned) || []) if (EXTERNAL_RE.test(m)) return id;
|
|
149
|
+
if (code && _isHandlerParam(bare, code, matchIdx)) return id;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _scan(file, raw, code, ops) {
|
|
155
|
+
const out = [];
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
for (const { re, what, candidates, skip } of ops) {
|
|
158
|
+
re.lastIndex = 0;
|
|
159
|
+
let m;
|
|
160
|
+
while ((m = re.exec(code))) {
|
|
161
|
+
const exprCandidates = candidates ? candidates(m) : [m[1] || m[2] || ''];
|
|
162
|
+
if (skip && exprCandidates.some(skip)) continue;
|
|
163
|
+
const line = _lineOf(code, m.index);
|
|
164
|
+
const key = `${line}`;
|
|
165
|
+
if (seen.has(key)) continue;
|
|
166
|
+
const win = _window(raw, line);
|
|
167
|
+
let source = null;
|
|
168
|
+
for (const sizeExpr of exprCandidates) {
|
|
169
|
+
if (!sizeExpr || /^['"]/.test(sizeExpr.trim())) continue;
|
|
170
|
+
source = _externallyInfluenced(sizeExpr, win, code, m.index);
|
|
171
|
+
if (source) break;
|
|
172
|
+
}
|
|
173
|
+
if (!source) continue; // not caller-chosen
|
|
174
|
+
// Bounding EITHER operand bounds the product/size, so check every
|
|
175
|
+
// candidate identifier, not just the one that proved external. GHSA-
|
|
176
|
+
// phj3-59pf-cp83's fix caps `value`, but `source_width` (checked first
|
|
177
|
+
// above, since its own assignment line visibly mentions `request`) is
|
|
178
|
+
// what got identified as the external source — checking only that one
|
|
179
|
+
// identifier for a bound would miss a fix applied to its sibling.
|
|
180
|
+
const allIdents = exprCandidates.flatMap(c => String(c).match(/[A-Za-z_$][\w$.]{0,60}/g) || []);
|
|
181
|
+
if (allIdents.some(id => _isBounded(id, win))) continue; // already bounded — the fix
|
|
182
|
+
seen.add(key);
|
|
183
|
+
out.push({
|
|
184
|
+
id: `resource-exhaustion:unbounded-size:${file}:${line}`,
|
|
185
|
+
file, line,
|
|
186
|
+
vuln: `Unbounded resource allocation — ${what} sized from caller-controlled '${source}' with no upper bound`,
|
|
187
|
+
severity: 'medium',
|
|
188
|
+
cwe: 'CWE-400',
|
|
189
|
+
family: 'resource-exhaustion',
|
|
190
|
+
parser: 'RESOURCE',
|
|
191
|
+
confidence: 0.5,
|
|
192
|
+
description:
|
|
193
|
+
`The size of ${what} derives from '${source}', which a caller can choose, and no upper-bound check ` +
|
|
194
|
+
'appears in the surrounding code. A large value forces proportional memory or CPU use, so a single ' +
|
|
195
|
+
'request can exhaust the process — a denial of service that needs no injection and no malformed input, ' +
|
|
196
|
+
'just a big number.',
|
|
197
|
+
remediation:
|
|
198
|
+
`Clamp the value before it is used as a size (e.g. \`n = min(n, MAX_${String(what).replace(/\\W/g, '').toUpperCase().slice(0, 12)})\`) ` +
|
|
199
|
+
'or reject it with an explicit error when it exceeds the documented maximum.',
|
|
200
|
+
checkedFor: 'an upper-bound comparison, min()/clamp, or an explicit raise/throw within 12 lines',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function scanResourceExhaustion(file, raw) {
|
|
208
|
+
if (!raw || typeof raw !== 'string' || raw.length > 500_000) return [];
|
|
209
|
+
const isPy = PY_RE.test(file), isJs = JS_RE.test(file);
|
|
210
|
+
if (!isPy && !isJs) return [];
|
|
211
|
+
if (!EXTERNAL_RE.test(raw) && !(isPy && HAS_DECORATOR_RE.test(raw))) return []; // cheap relevance gate
|
|
212
|
+
const code = blankComments(raw, isPy ? 'py' : null);
|
|
213
|
+
try { return _scan(file, raw, code, isPy ? PY_SIZE_OPS : JS_SIZE_OPS); }
|
|
214
|
+
catch { return []; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const _internals = { EXTERNAL_RE, BOUND_KEYWORDS_RE, _isBounded, _externallyInfluenced, _isHandlerParam, NON_HANDLER_DECORATOR_RE };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Sibling-guard omission — CWE-22.
|
|
2
|
+
//
|
|
3
|
+
// THE SHAPE, taken from a real advisory rather than from prose. GHSA-95cv-r8x4-vh75
|
|
4
|
+
// (alist): a batch-rename handler reads two fields off the same request object
|
|
5
|
+
// and passes only one of them through the project's own path guard.
|
|
6
|
+
//
|
|
7
|
+
// for _, renameObject := range req.RenameObjects {
|
|
8
|
+
// err := checkRelativePath(renameObject.NewName) // guarded
|
|
9
|
+
// if err != nil { ... return }
|
|
10
|
+
// filePath := fmt.Sprintf("%s/%s", reqPath, renameObject.SrcName) // NOT guarded
|
|
11
|
+
// fs.Rename(ctx, filePath, renameObject.NewName)
|
|
12
|
+
// }
|
|
13
|
+
//
|
|
14
|
+
// The upstream fix adds `checkRelativePath(renameObject.SrcName)` above the
|
|
15
|
+
// existing call. Two lines, one sibling field.
|
|
16
|
+
//
|
|
17
|
+
// WHY THIS IS HIGH PRECISION BY CONSTRUCTION. The rule never decides what a
|
|
18
|
+
// guard is. It OBSERVES the codebase applying some function to one field and
|
|
19
|
+
// then finds a sibling field of the SAME receiver, in the SAME function, that
|
|
20
|
+
// reaches a path-ish operation without it. The claim is therefore always
|
|
21
|
+
// "this file guards X and forgets Y" — falsifiable by a reviewer looking at one
|
|
22
|
+
// screen of code, with the guard name and both fields carried on the finding.
|
|
23
|
+
// It is the intra-function form of the argument `convention-deviation.js` makes
|
|
24
|
+
// at project scope.
|
|
25
|
+
//
|
|
26
|
+
// Deliberately NOT modelled:
|
|
27
|
+
// · guards applied through a wrapper or in a callee — a cross-function version
|
|
28
|
+
// needs the taint engine's summaries, and inflating this rule to guess at it
|
|
29
|
+
// would trade the property that makes it credible.
|
|
30
|
+
// · languages beyond Go. The shape is general, but the measured need is Go
|
|
31
|
+
// (0/72 on the independent population), and each language needs its own
|
|
32
|
+
// field-access and sink vocabulary verified against real code first.
|
|
33
|
+
//
|
|
34
|
+
// Found via the F1.1 root-cause histogram: this entry was one of 12 of 25 Go
|
|
35
|
+
// entries where the vulnerable file produced NO finding of any kind.
|
|
36
|
+
|
|
37
|
+
const GO_FILE_RE = /\.go$/i;
|
|
38
|
+
|
|
39
|
+
// `ident.Field` / `ident.Field.Sub` — a receiver and at least one field.
|
|
40
|
+
// Anchored on a word boundary so `a.b` inside a longer chain still resolves to
|
|
41
|
+
// its own receiver rather than to a substring.
|
|
42
|
+
const FIELD_ACCESS = /\b([A-Za-z_]\w*)\.([A-Z]\w*)\b/g;
|
|
43
|
+
|
|
44
|
+
// A call of the form `name(<receiver>.<Field>)`, single argument. The single
|
|
45
|
+
// argument matters: a multi-argument call is far more likely to be the
|
|
46
|
+
// operation itself (`fsRename(path, name)`) than a validator.
|
|
47
|
+
const GUARD_CALL = /\b([A-Za-z_]\w*)\s*\(\s*([A-Za-z_]\w*)\.([A-Z]\w*)\s*\)/g;
|
|
48
|
+
|
|
49
|
+
// Callees that are never guards even when called with one field argument —
|
|
50
|
+
// these consume a value, they do not validate it. Without this the rule reads
|
|
51
|
+
// `log.Printf(req.Name)` as establishing a convention.
|
|
52
|
+
const NOT_A_GUARD = /^(?:print|println|printf|sprintf|fprintf|log|logf|append|len|cap|new|make|string|byte|error|errorf|wrap|wrapf|panic|recover|close|delete|copy)$/i;
|
|
53
|
+
|
|
54
|
+
// Operations where an unvalidated relative path is a traversal. Kept narrow and
|
|
55
|
+
// literal: every entry is either a filesystem call or the string-building step
|
|
56
|
+
// that feeds one.
|
|
57
|
+
const PATH_SINK = new RegExp([
|
|
58
|
+
// "%s/%s" style path joins. FORWARD slash only, deliberately.
|
|
59
|
+
//
|
|
60
|
+
// This originally accepted `/` OR `\`, and that produced a false positive on
|
|
61
|
+
// real code (rclone `cmd/bisync/help.go:72`): a help-text formatter
|
|
62
|
+
// `fmt.Sprintf("- %s - (%s) %s \n", …)` contains a BACKSLASH as part of the
|
|
63
|
+
// `\n` escape, which read as a path separator. A Go format string that builds
|
|
64
|
+
// a path uses `/`; a backslash in one is nearly always an escape sequence.
|
|
65
|
+
// Found on real code rather than by a fixture, which is the argument for
|
|
66
|
+
// measuring a new rule against the independent population before trusting it.
|
|
67
|
+
String.raw`\bfmt\.Sprintf\s*\(\s*"[^"]*/[^"]*"`,
|
|
68
|
+
String.raw`\bfilepath\.(?:Join|Clean|Abs|Walk)\s*\(`,
|
|
69
|
+
String.raw`\bpath\.Join\s*\(`,
|
|
70
|
+
String.raw`\bos\.(?:Open|OpenFile|Create|Remove|RemoveAll|Rename|ReadFile|WriteFile|Stat|Mkdir|MkdirAll)\s*\(`,
|
|
71
|
+
String.raw`\bioutil\.(?:ReadFile|WriteFile)\s*\(`,
|
|
72
|
+
String.raw`\b\w*[Ff]s\.(?:Rename|Remove|Copy|Move|Open|Create|Link)\s*\(`,
|
|
73
|
+
String.raw`\b(?:fsRename|fsRemove|fsCopy|fsMove)\s*\(`,
|
|
74
|
+
].join('|'));
|
|
75
|
+
|
|
76
|
+
/** Split a Go source file into top-level function bodies by brace matching. */
|
|
77
|
+
function goFunctions(src) {
|
|
78
|
+
const out = [];
|
|
79
|
+
const re = /\bfunc\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*\(/g;
|
|
80
|
+
let m;
|
|
81
|
+
while ((m = re.exec(src))) {
|
|
82
|
+
const open = src.indexOf('{', m.index);
|
|
83
|
+
if (open < 0) continue;
|
|
84
|
+
let depth = 0, end = open;
|
|
85
|
+
for (let i = open; i < src.length; i++) {
|
|
86
|
+
const ch = src[i];
|
|
87
|
+
if (ch === '{') depth++;
|
|
88
|
+
else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } }
|
|
89
|
+
}
|
|
90
|
+
if (end <= open) continue;
|
|
91
|
+
out.push({ name: m[1], body: src.slice(open, end + 1), offset: open });
|
|
92
|
+
re.lastIndex = end;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const lineOf = (src, index) => src.slice(0, index).split('\n').length;
|
|
98
|
+
|
|
99
|
+
export function scanSiblingGuard(fp, raw) {
|
|
100
|
+
if (!raw || !GO_FILE_RE.test(String(fp || ''))) return [];
|
|
101
|
+
if (raw.length > 500_000) return [];
|
|
102
|
+
|
|
103
|
+
const findings = [];
|
|
104
|
+
for (const fn of goFunctions(raw)) {
|
|
105
|
+
// 1. Which (receiver, field) pairs does some single-arg call validate?
|
|
106
|
+
// receiver -> Map<field, guardName>
|
|
107
|
+
const guarded = new Map();
|
|
108
|
+
let g;
|
|
109
|
+
const guardRe = new RegExp(GUARD_CALL.source, 'g');
|
|
110
|
+
while ((g = guardRe.exec(fn.body))) {
|
|
111
|
+
const [, callee, recv, field] = g;
|
|
112
|
+
if (NOT_A_GUARD.test(callee)) continue;
|
|
113
|
+
if (!guarded.has(recv)) guarded.set(recv, new Map());
|
|
114
|
+
guarded.get(recv).set(field, callee);
|
|
115
|
+
}
|
|
116
|
+
if (!guarded.size) continue;
|
|
117
|
+
|
|
118
|
+
// 2. Every (receiver, field) the function touches, at EVERY offset.
|
|
119
|
+
//
|
|
120
|
+
// Recording only the first occurrence was the first version's bug and it
|
|
121
|
+
// made the rule silent on the very advisory it was written from: the first
|
|
122
|
+
// mention of `renameObject.SrcName` is the `if … == ""` emptiness check,
|
|
123
|
+
// several lines above the `fmt.Sprintf` that actually builds the path. A
|
|
124
|
+
// field is interesting wherever it reaches a sink, not where it debuts.
|
|
125
|
+
const touched = new Map();
|
|
126
|
+
let a;
|
|
127
|
+
const accessRe = new RegExp(FIELD_ACCESS.source, 'g');
|
|
128
|
+
while ((a = accessRe.exec(fn.body))) {
|
|
129
|
+
const [, recv, field] = a;
|
|
130
|
+
if (!touched.has(recv)) touched.set(recv, new Map());
|
|
131
|
+
const byField = touched.get(recv);
|
|
132
|
+
if (!byField.has(field)) byField.set(field, []);
|
|
133
|
+
byField.get(field).push(a.index);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const [recv, fields] of touched) {
|
|
137
|
+
const guards = guarded.get(recv);
|
|
138
|
+
if (!guards || !guards.size) continue; // no convention on this receiver
|
|
139
|
+
for (const [field, offsets] of fields) {
|
|
140
|
+
if (guards.has(field)) continue; // this field IS guarded
|
|
141
|
+
// 3. Does the unguarded sibling reach a path-ish operation at ANY of
|
|
142
|
+
// its uses? Field and sink must share a line — the shape the advisory
|
|
143
|
+
// shows, and the one a reviewer confirms without tracing dataflow.
|
|
144
|
+
const at = offsets.find((off) => {
|
|
145
|
+
const lineStart = fn.body.lastIndexOf('\n', off) + 1;
|
|
146
|
+
const lineEnd = fn.body.indexOf('\n', off);
|
|
147
|
+
return PATH_SINK.test(fn.body.slice(lineStart, lineEnd < 0 ? undefined : lineEnd));
|
|
148
|
+
});
|
|
149
|
+
if (at === undefined) continue;
|
|
150
|
+
|
|
151
|
+
const [guardedField, guardName] = [...guards.entries()][0];
|
|
152
|
+
findings.push({
|
|
153
|
+
id: `sibling-guard:${fp}:${recv}.${field}`,
|
|
154
|
+
severity: 'high',
|
|
155
|
+
file: fp,
|
|
156
|
+
line: lineOf(fn.body, at) + lineOf(raw, fn.offset) - 1,
|
|
157
|
+
vuln: `Path traversal — \`${recv}.${field}\` skips the \`${guardName}\` guard its sibling \`${recv}.${guardedField}\` uses`,
|
|
158
|
+
cwe: 'CWE-22',
|
|
159
|
+
family: 'sibling-guard-omission',
|
|
160
|
+
parser: 'SIBLING-GUARD',
|
|
161
|
+
description:
|
|
162
|
+
`\`${fn.name}\` validates \`${recv}.${guardedField}\` with \`${guardName}\`, then builds a filesystem `
|
|
163
|
+
+ `path from the sibling field \`${recv}.${field}\` without applying the same check. A relative path in `
|
|
164
|
+
+ `\`${field}\` therefore escapes the intended directory.`,
|
|
165
|
+
remediation: `Apply \`${guardName}(${recv}.${field})\` before using it, exactly as the sibling field does.`,
|
|
166
|
+
// T2.2 — an absence-claim must record what it looked for, so a
|
|
167
|
+
// reviewer (or a refutation lens) can contradict it mechanically.
|
|
168
|
+
checkedFor: guardName,
|
|
169
|
+
evidenceGuardedSibling: `${recv}.${guardedField}`,
|
|
170
|
+
evidenceUnguardedField: `${recv}.${field}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return findings;
|
|
176
|
+
}
|
package/src/sast/zip-slip.js
CHANGED
|
@@ -77,6 +77,57 @@ export function scanZipSlip(fp, raw) {
|
|
|
77
77
|
// filter="data" / filter=tarfile.data_filter in the same call. File-level
|
|
78
78
|
// suppression was too aggressive — a safe function later in the file would
|
|
79
79
|
// hide an unsafe one earlier.
|
|
80
|
+
/**
|
|
81
|
+
* The OTHER real mitigation: intercept per-member extraction and refuse
|
|
82
|
+
* any destination outside the target directory. Both halves are required —
|
|
83
|
+
* GHSA-f42x-p2mx-hm8r's VULNERABLE revision also patched `_extract_member`,
|
|
84
|
+
* but only to add a write bit, with no path validation. Recognising the
|
|
85
|
+
* patch alone would therefore silence the vulnerable code too.
|
|
86
|
+
*
|
|
87
|
+
* `filter="data"` needs Python 3.12; this interception form is what
|
|
88
|
+
* codebases supporting older Pythons actually ship, so treating it as
|
|
89
|
+
* exotic left the finding surviving its own fix.
|
|
90
|
+
*/
|
|
91
|
+
// Resolved through the ASSIGNED FUNCTION'S OWN BODY, never a text window.
|
|
92
|
+
// A window is useless here: penelope.py is 5000+ lines, and any window
|
|
93
|
+
// wide enough to reach the interceptor also sweeps up unrelated
|
|
94
|
+
// os.path.realpath calls, which silenced the VULNERABLE revision too.
|
|
95
|
+
const CONTAINMENT_RE = /\b(?:commonpath|realpath|abspath)\s*\(|\w{0,20}(?:is_?within|inside_?dir|safe_?path|within_?dir)\w{0,20}\s*\(|\.startswith\s*\(/i;
|
|
96
|
+
const MEMBER_PATCH_RE = /\b_extract_member\s*=\s*([A-Za-z_]\w*)/g;
|
|
97
|
+
/** Body of a Python `def name(...)`, by indentation. */
|
|
98
|
+
const _pyBody = (name) => {
|
|
99
|
+
const d = new RegExp(`^([ \\t]*)def\\s+${name}\\s*\\(`, 'm').exec(code);
|
|
100
|
+
if (!d) return null;
|
|
101
|
+
const indent = d[1].length;
|
|
102
|
+
// Start at the line AFTER the signature. Slicing at the end of the
|
|
103
|
+
// matched `def name(` leaves the rest of that same line as element 0
|
|
104
|
+
// with zero indentation, which ends the body before it begins.
|
|
105
|
+
const nl = code.indexOf('\n', d.index);
|
|
106
|
+
if (nl === -1) return null;
|
|
107
|
+
const rest = code.slice(nl + 1).split('\n');
|
|
108
|
+
const body = [];
|
|
109
|
+
for (const ln of rest) {
|
|
110
|
+
if (ln.trim() && (ln.length - ln.trimStart().length) <= indent) break;
|
|
111
|
+
body.push(ln);
|
|
112
|
+
}
|
|
113
|
+
return body.join('\n');
|
|
114
|
+
};
|
|
115
|
+
const _isContainmentGuardedExtract = () => {
|
|
116
|
+
MEMBER_PATCH_RE.lastIndex = 0;
|
|
117
|
+
let a;
|
|
118
|
+
while ((a = MEMBER_PATCH_RE.exec(code))) {
|
|
119
|
+
const body = _pyBody(a[1]);
|
|
120
|
+
if (!body) continue;
|
|
121
|
+
// The interceptor must REFUSE, not merely observe. penelope's
|
|
122
|
+
// vulnerable revision also patched _extract_member — to add a write
|
|
123
|
+
// bit (`args[0].mode |= 0o200`) with no validation and no early
|
|
124
|
+
// return — so requiring a conditional refusal is what separates the
|
|
125
|
+
// two revisions.
|
|
126
|
+
const refuses = /\bif\b[\s\S]{0,400}?\b(?:return|raise|continue)\b/.test(body);
|
|
127
|
+
if (refuses && CONTAINMENT_RE.test(body)) return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
};
|
|
80
131
|
const _isFilteredExtract = (afterIdx) => {
|
|
81
132
|
let depth = 0;
|
|
82
133
|
let inS = null;
|
|
@@ -101,7 +152,7 @@ export function scanZipSlip(fp, raw) {
|
|
|
101
152
|
let m;
|
|
102
153
|
while ((m = reA.exec(code))) {
|
|
103
154
|
const openParen = m.index + m[0].length - 1; // position of '('
|
|
104
|
-
if (_isFilteredExtract(openParen)) continue;
|
|
155
|
+
if (_isFilteredExtract(openParen) || _isContainmentGuardedExtract()) continue;
|
|
105
156
|
const line = _lineOf(raw, m.index);
|
|
106
157
|
push({
|
|
107
158
|
id: `zip-slip:${fp}:${line}:py-tarfile`,
|
|
@@ -119,7 +170,7 @@ export function scanZipSlip(fp, raw) {
|
|
|
119
170
|
const reB = new RegExp(PY_TARFILE_EXTRACTALL_SHORT_RE.source, PY_TARFILE_EXTRACTALL_SHORT_RE.flags);
|
|
120
171
|
while ((m = reB.exec(code))) {
|
|
121
172
|
const openParen = m.index + m[0].length - 1;
|
|
122
|
-
if (_isFilteredExtract(openParen)) continue;
|
|
173
|
+
if (_isFilteredExtract(openParen) || _isContainmentGuardedExtract()) continue;
|
|
123
174
|
const line = _lineOf(raw, m.index);
|
|
124
175
|
push({
|
|
125
176
|
id: `zip-slip:${fp}:${line}:py-tarfile-bare`,
|