@clear-capabilities/agentic-security-scanner 0.86.0 → 0.119.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +660 -0
- package/bin/agentic-security.js +78 -1
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +90 -0
- package/dist/444.index.js +89 -0
- package/dist/503.index.js +288 -0
- package/dist/637.index.js +15 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +5 -1
- package/dist/agentic-security.mjs +84 -84
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +21 -10
- package/src/dataflow/CLAUDE.md +18 -6
- package/src/dataflow/catalog.js +59 -11
- package/src/dataflow/engine.js +106 -5
- package/src/dataflow/implicit-flow.js +4 -2
- package/src/dataflow/index.js +17 -4
- package/src/dataflow/proof-gate.js +100 -0
- package/src/dataflow/summaries.js +63 -6
- package/src/engine.js +292 -10
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/callgraph.js +12 -1
- package/src/ir/parser-go.js +8 -0
- package/src/ir/tree-sitter-loader.js +108 -0
- package/src/mcp/tools.js +6 -2
- package/src/posture/corpus-status.js +74 -0
- package/src/posture/coverage-report.js +113 -0
- package/src/posture/fix-history.js +24 -0
- package/src/posture/holdout-eval.js +57 -0
- package/src/posture/provenance.js +39 -0
- package/src/posture/router.js +33 -3
- package/src/posture/sca-verdict.js +130 -0
- package/src/posture/secret-history.js +72 -0
- package/src/pr-delta.js +13 -0
- package/src/report/index.js +67 -0
- package/src/sast/CLAUDE.md +9 -3
- package/src/sast/agent-untrusted-flow.js +107 -0
- package/src/sast/api-authz.js +71 -0
- package/src/sast/code-injection-multilang.js +117 -0
- package/src/sast/cross-service.js +98 -0
- package/src/sast/crypto-protocol.js +43 -1
- package/src/sast/csharp-structural.js +65 -0
- package/src/sast/csharp.js +3 -0
- package/src/sast/csrf.js +24 -3
- package/src/sast/csv-injection.js +84 -0
- package/src/sast/event-entrypoint.js +90 -0
- package/src/sast/frontend-hygiene.js +106 -0
- package/src/sast/go-structural.js +60 -0
- package/src/sast/iac-terraform.js +107 -0
- package/src/sast/java-structural.js +81 -0
- package/src/sast/js-framework-structural.js +115 -0
- package/src/sast/kotlin.js +72 -0
- package/src/sast/ldap-injection.js +73 -17
- package/src/sast/open-redirect.js +47 -5
- package/src/sast/php.js +35 -0
- package/src/sast/python-structural.js +83 -0
- package/src/sast/rbac-consistency.js +113 -0
- package/src/sast/redos-nfa.js +75 -0
- package/src/sast/response-splitting.js +48 -19
- package/src/sast/ruby.js +38 -0
- package/src/sast/secret-concat.js +60 -0
- package/src/sast/ssrf-cloud-metadata.js +13 -0
- package/src/sast/stored-taint.js +88 -0
- package/src/sast/tree-sitter-sinks.js +62 -0
- package/src/sast/weak-password-hash.js +28 -0
- package/src/sast/weak-randomness.js +46 -5
- package/src/sast/wrong-context-sanitizer.js +149 -0
- package/src/sast/xpath-injection.js +30 -6
- package/src/sast/xss-reflected-multilang.js +116 -0
- package/src/sast/xxe.js +67 -0
- package/src/sca/image-packages.js +74 -0
- package/src/sca/import-reachability.js +271 -0
- package/src/sca/install-script-analysis.js +82 -0
- package/bin/.agentic-security/findings.json +0 -1907
- package/bin/.agentic-security/last-scan.json +0 -1907
- package/bin/.agentic-security/last-scan.json.sig +0 -1
- package/bin/.agentic-security/scan-history.json +0 -166
- package/bin/.agentic-security/streak.json +0 -20
- package/src/.agentic-security/findings.json +0 -117732
- package/src/.agentic-security/last-scan.json +0 -117732
- package/src/.agentic-security/last-scan.json.sig +0 -1
- package/src/.agentic-security/scan-history.json +0 -12946
- package/src/.agentic-security/streak.json +0 -21
- package/src/dataflow/.agentic-security/findings.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
- package/src/dataflow/.agentic-security/scan-history.json +0 -250
- package/src/dataflow/.agentic-security/streak.json +0 -21
- package/src/ir/.agentic-security/findings.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json.sig +0 -1
- package/src/ir/.agentic-security/scan-history.json +0 -193
- package/src/ir/.agentic-security/streak.json +0 -20
- package/src/mcp/.agentic-security/findings.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
- package/src/mcp/.agentic-security/scan-history.json +0 -331
- package/src/mcp/.agentic-security/streak.json +0 -20
- package/src/posture/.agentic-security/dpia.md +0 -26
- package/src/posture/.agentic-security/findings.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
- package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
- package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/posture/.agentic-security/scan-history.json +0 -8904
- package/src/posture/.agentic-security/streak.json +0 -21
- package/src/posture/.agentic-security/threat-model.json +0 -2038
- package/src/posture/.agentic-security/threat-model.md +0 -73
- package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
- package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
- package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/report/.agentic-security/threat-model.json +0 -7
- package/src/report/.agentic-security/threat-model.md +0 -22
- package/src/sast/.agentic-security/findings.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json.sig +0 -1
- package/src/sast/.agentic-security/scan-history.json +0 -941
- package/src/sast/.agentic-security/streak.json +0 -22
- package/src/sca/.agentic-security/findings.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json.sig +0 -1
- package/src/sca/.agentic-security/scan-history.json +0 -113
- package/src/sca/.agentic-security/streak.json +0 -21
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Python framework structural detectors — PRD Tier 1 (JS/Python recall).
|
|
2
|
+
//
|
|
3
|
+
// Closes Flask/Django handler FNs where user input is concatenated into a
|
|
4
|
+
// template or raw SQL. Taint-independent: the concat / f-string / %-format is
|
|
5
|
+
// the injection shape. Crucially, a Jinja `{{ name }}` placeholder is SAFE
|
|
6
|
+
// (auto-escaped), so `{` alone is NOT a signal — only `+`, f-strings, or
|
|
7
|
+
// `.format()`/`%`.
|
|
8
|
+
|
|
9
|
+
import { blankComments } from './_comment-strip.js';
|
|
10
|
+
|
|
11
|
+
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
12
|
+
|
|
13
|
+
// A string literal that may contain the *other* quote char (a SQL string like
|
|
14
|
+
// `"… name = '"` embeds a single quote) — the naive `["'][^"'\n]*["']` breaks
|
|
15
|
+
// on the embedded quote and misses the concat. Match each quote style with its
|
|
16
|
+
// own body so embedded quotes are allowed.
|
|
17
|
+
const STR = String.raw`(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')`;
|
|
18
|
+
// Dynamic-string shape inside a sink call: concat (`"…" +`), f-string (`f"…"`),
|
|
19
|
+
// `.format(`, or `%`-format via the `%` OPERATOR after the string (`"…%s" % v`).
|
|
20
|
+
// NOT a plain literal, and NOT a `%s` placeholder followed by a params list —
|
|
21
|
+
// `execute("… %s", [v])` is the SAFE parameterized DB-API form.
|
|
22
|
+
const DYN = String.raw`(?:[fF]["']|${STR}\s*\+|${STR}\s*%\s*[(\w]|${STR}\s*\.\s*format\s*\()`;
|
|
23
|
+
|
|
24
|
+
export function scanPythonStructural(fp, raw) {
|
|
25
|
+
if (!/\.py$/i.test(fp)) return [];
|
|
26
|
+
if (!raw || raw.length > 500_000) return [];
|
|
27
|
+
const code = blankComments(raw, 'py');
|
|
28
|
+
const findings = [];
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const emit = (key, line, meta) => {
|
|
31
|
+
const id = `py-struct-${key}:${fp}:${line}`;
|
|
32
|
+
if (seen.has(id)) return;
|
|
33
|
+
seen.add(id);
|
|
34
|
+
findings.push({
|
|
35
|
+
id, file: fp, line, vuln: meta.vuln, severity: meta.severity, cwe: meta.cwe,
|
|
36
|
+
family: meta.family, parser: 'PYTHON', confidence: meta.confidence ?? 0.7,
|
|
37
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200), remediation: meta.remediation,
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Flask SSTI: render_template_string built from a dynamic string. A static
|
|
42
|
+
// template with Jinja `{{ }}` placeholders is safe and must not match.
|
|
43
|
+
const SSTI_RE = new RegExp(String.raw`\brender_template_string\s*\(\s*` + DYN, 'g');
|
|
44
|
+
let m;
|
|
45
|
+
while ((m = SSTI_RE.exec(code))) emit('flask-ssti', lineOf(code, m.index), {
|
|
46
|
+
// render_template_string with a built string is both reflected XSS
|
|
47
|
+
// (unescaped user input in the rendered HTML) and template injection
|
|
48
|
+
// (`{{7*7}}` executes). Classified CWE-79 (the dominant, ground-truth
|
|
49
|
+
// taxonomy); the remediation covers the SSTI aspect too.
|
|
50
|
+
vuln: 'Reflected XSS / template injection (SSTI) via render_template_string (Flask)',
|
|
51
|
+
severity: 'critical', cwe: 'CWE-79', family: 'xss', confidence: 0.7,
|
|
52
|
+
remediation: 'Use a static template with `{{ }}` placeholders and pass values as named context (`render_template_string("Hi {{ name }}", name=name)`). Never build the template body from input.',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Django raw SQL: .raw() / .extra() built from a dynamic string.
|
|
56
|
+
const DJ_RE = new RegExp(String.raw`\.\s*(?:raw|extra)\s*\(\s*` + DYN, 'g');
|
|
57
|
+
while ((m = DJ_RE.exec(code))) emit('django-sqli', lineOf(code, m.index), {
|
|
58
|
+
vuln: 'SQL Injection — Django raw()/extra() built with string concat / format (Python)',
|
|
59
|
+
severity: 'critical', cwe: 'CWE-89', family: 'sql-injection', confidence: 0.7,
|
|
60
|
+
remediation: 'Use the ORM with an allow-list for column/order fields, or parameterize: `User.objects.raw("… WHERE name = %s", [name])`. Never concatenate into raw SQL.',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// DB-API cursor.execute built from a dynamic string.
|
|
64
|
+
const EXEC_RE = new RegExp(String.raw`\.\s*execute(?:script)?\s*\(\s*` + DYN, 'g');
|
|
65
|
+
while ((m = EXEC_RE.exec(code))) emit('cursor-sqli', lineOf(code, m.index), {
|
|
66
|
+
vuln: 'SQL Injection — cursor.execute built with string concat / format (Python)',
|
|
67
|
+
severity: 'critical', cwe: 'CWE-89', family: 'sql-injection', confidence: 0.7,
|
|
68
|
+
remediation: 'Pass parameters as the second argument with `%s` placeholders: `cursor.execute("… WHERE id = %s", [id])`. Never concatenate or %-format values into the SQL string.',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Path traversal: a file-open / send_file built from a dynamic string
|
|
72
|
+
// (concat / f-string / format). `open('/var/data/' + name)` is the shape;
|
|
73
|
+
// a bare literal path is not. Hardened forms (os.path.basename, secure
|
|
74
|
+
// containment) are removed by engine.js dropGuardedFindings (CWE-22).
|
|
75
|
+
const PATH_RE = new RegExp(String.raw`\b(?:open|os\.open|io\.open|codecs\.open|send_file|send_from_directory)\s*\(\s*` + DYN, 'g');
|
|
76
|
+
while ((m = PATH_RE.exec(code))) emit('path-traversal', lineOf(code, m.index), {
|
|
77
|
+
vuln: 'Path Traversal — file path built with string concat / f-string / format (Python)',
|
|
78
|
+
severity: 'high', cwe: 'CWE-22', family: 'path-traversal', confidence: 0.62,
|
|
79
|
+
remediation: 'Strip directory components and assert containment: `safe = os.path.basename(name); full = os.path.join(BASE, safe); assert os.path.realpath(full).startswith(BASE)`. Never concatenate user input straight into a file path.',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// R21 (PRD §5) — RBAC role-tier authorization model.
|
|
2
|
+
//
|
|
3
|
+
// R19 (api-authz.js) flags missing AUTHENTICATION among authenticated siblings.
|
|
4
|
+
// This goes one level deeper to AUTHORIZATION TIER: it extracts the role/
|
|
5
|
+
// permission a route requires (from middleware/annotations near the handler) and
|
|
6
|
+
// flags two broken-access-control shapes the route inventory alone can't see:
|
|
7
|
+
// 1. A state-changing route that enforces NO role while sibling routes on the
|
|
8
|
+
// same resource DO → missing function-level authorization (BFLA).
|
|
9
|
+
// 2. A state-changing route gated at a STRICTLY LOWER tier than a read sibling
|
|
10
|
+
// → privilege tier inversion (delete allowed below the tier that can read).
|
|
11
|
+
//
|
|
12
|
+
// Pure cross-route analysis (routes + file contents). Precision-first: fires
|
|
13
|
+
// only on inconsistency within a resource group that uses roles at all.
|
|
14
|
+
|
|
15
|
+
// Inline role checks — on the route's OWN registration line (middleware args).
|
|
16
|
+
const INLINE_PATTERNS = [
|
|
17
|
+
/\b(?:requireRole|hasRole|hasAnyRole|checkRole|ensureRole|restrictTo|requireScope|hasAuthority|hasPermission|requirePermission)\s*\(\s*\[?\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
|
|
18
|
+
/\brole\s*(?:===?|==)\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
|
|
19
|
+
];
|
|
20
|
+
// Decorator/attribute role checks — on the line(s) directly ABOVE the handler.
|
|
21
|
+
const DECORATOR_PATTERNS = [
|
|
22
|
+
/@(?:PreAuthorize|Secured|RolesAllowed)\s*\(\s*[^)]*['"]([A-Za-z0-9_.:-]+)['"]/i,
|
|
23
|
+
/\[Authorize\s*\(\s*Roles\s*=\s*"([A-Za-z0-9_.,:-]+)"/i,
|
|
24
|
+
/@(?:roles_required|permission_required)\s*\(\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// Coarse tier ranking (higher = more privileged). Unknown roles → tier 1 (mid).
|
|
28
|
+
function tierOf(role) {
|
|
29
|
+
const r = String(role || '').toLowerCase();
|
|
30
|
+
if (/\b(super.?admin|root|owner|sysadmin)\b/.test(r) || r === 'superadmin') return 3;
|
|
31
|
+
if (/\b(admin|administrator|manager|staff|moderator|mod)\b/.test(r)) return 2;
|
|
32
|
+
if (/\b(user|member|authenticated|customer|editor|contributor)\b/.test(r)) return 1;
|
|
33
|
+
if (/\b(guest|public|anon|anonymous|viewer|reader)\b/.test(r)) return 0;
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const STATE_CHANGING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
38
|
+
|
|
39
|
+
function roleOf(route, fileContents) {
|
|
40
|
+
const content = fileContents[route.file];
|
|
41
|
+
if (typeof content !== 'string') return { hasRole: false, role: null };
|
|
42
|
+
const lines = content.split('\n');
|
|
43
|
+
const idx = (route.line || 1) - 1;
|
|
44
|
+
// 1. Inline middleware / role check on the route's OWN line (don't look at
|
|
45
|
+
// siblings — that would leak an adjacent route's role into this one).
|
|
46
|
+
const own = lines[idx] || '';
|
|
47
|
+
for (const re of INLINE_PATTERNS) { const m = own.match(re); if (m) return { hasRole: true, role: m[1] }; }
|
|
48
|
+
// 2. Stacked decorators/attributes directly ABOVE the handler; stop at the
|
|
49
|
+
// first non-decorator line so we never cross into the previous route.
|
|
50
|
+
for (let j = idx - 1; j >= 0 && j >= idx - 4; j--) {
|
|
51
|
+
const l = (lines[j] || '').trim();
|
|
52
|
+
if (!l) continue;
|
|
53
|
+
if (/^@\w|^\[Authorize/i.test(l)) {
|
|
54
|
+
for (const re of DECORATOR_PATTERNS) { const m = l.match(re); if (m) return { hasRole: true, role: m[1] }; }
|
|
55
|
+
continue; // an unrecognized decorator — keep scanning the stack upward
|
|
56
|
+
}
|
|
57
|
+
break; // hit a non-decorator line → stop
|
|
58
|
+
}
|
|
59
|
+
return { hasRole: false, role: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resource key: path with dynamic segments collapsed (so /users and /users/:id
|
|
63
|
+
// group together as one resource).
|
|
64
|
+
function resourceKey(p) {
|
|
65
|
+
return String(p || '').split('/').map((s) => (/^(:|\{|<|\d)/.test(s) ? '*' : s)).join('/').replace(/\/\*$/, '') || '/';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mk(route, kind, cwe, why) {
|
|
69
|
+
return {
|
|
70
|
+
id: `rbac:${kind}:${route.file}:${route.line}`,
|
|
71
|
+
severity: 'high',
|
|
72
|
+
file: route.file, line: route.line || 0,
|
|
73
|
+
vuln: kind === 'inversion'
|
|
74
|
+
? 'Authorization tier inversion (state-change allowed below the read tier)'
|
|
75
|
+
: 'Missing role-based authorization on a state-changing route',
|
|
76
|
+
cwe, family: 'broken-access-control', parser: 'RBAC',
|
|
77
|
+
description: why,
|
|
78
|
+
remediation: 'Enforce a role/permission check on the route at least as strong as its sibling routes; gate state-changing actions at the appropriate privilege tier.',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function scanRbacConsistency(routes, fileContents) {
|
|
83
|
+
if (!Array.isArray(routes) || routes.length < 2) return [];
|
|
84
|
+
const groups = new Map();
|
|
85
|
+
for (const r of routes) {
|
|
86
|
+
if (!r || r.path === '(file-based)' || !r.path) continue;
|
|
87
|
+
const key = `${r.file}::${resourceKey(r.path)}`;
|
|
88
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
89
|
+
groups.get(key).push({ ...r, ...roleOf(r, fileContents) });
|
|
90
|
+
}
|
|
91
|
+
const findings = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
const push = (f) => { const k = `${f.file}:${f.line}`; if (!seen.has(k)) { seen.add(k); findings.push(f); } };
|
|
94
|
+
for (const group of groups.values()) {
|
|
95
|
+
if (group.length < 2) continue;
|
|
96
|
+
const roled = group.filter((r) => r.hasRole);
|
|
97
|
+
if (!roled.length) continue; // resource doesn't use roles at all → not our signal
|
|
98
|
+
const maxReadTier = Math.max(...group.filter((r) => !STATE_CHANGING.has(r.method) && r.hasRole).map((r) => tierOf(r.role)), -1);
|
|
99
|
+
for (const r of group) {
|
|
100
|
+
if (!STATE_CHANGING.has(r.method)) continue;
|
|
101
|
+
if (!r.hasRole) {
|
|
102
|
+
push(mk(r, 'missing', '285',
|
|
103
|
+
`${r.method} ${r.path} enforces no role check, while ${roled.length} sibling route(s) on this resource require one — a state-changing action left at a lower authorization tier (BFLA).`));
|
|
104
|
+
} else if (maxReadTier >= 0 && tierOf(r.role) < maxReadTier) {
|
|
105
|
+
push(mk(r, 'inversion', '269',
|
|
106
|
+
`${r.method} ${r.path} requires role "${r.role}" (tier ${tierOf(r.role)}), but reading this resource requires a higher tier (${maxReadTier}). A lower-privileged user can mutate what they may not even read.`));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return findings;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const _internals = { tierOf, resourceKey };
|
package/src/sast/redos-nfa.js
CHANGED
|
@@ -305,6 +305,73 @@ export function extractJavaRegexBodies(code) {
|
|
|
305
305
|
return out;
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
// Go: regexp.MustCompile / Compile / MatchString("…") — Go string literals use
|
|
309
|
+
// "…" (interpreted) or `…` (raw). Both carry the pattern verbatim.
|
|
310
|
+
export function extractGoRegexBodies(code) {
|
|
311
|
+
const out = [];
|
|
312
|
+
for (const m of code.matchAll(/\bregexp\.(?:MustCompile|Compile|CompilePOSIX|MatchString|Match)\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
|
|
313
|
+
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
314
|
+
}
|
|
315
|
+
for (const m of code.matchAll(/\bregexp\.(?:MustCompile|Compile|CompilePOSIX|MatchString|Match)\s*\(\s*`([^`]+)`/g)) {
|
|
316
|
+
out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// PHP: preg_match / preg_match_all / preg_replace("/…/flags", …) — the regex is
|
|
322
|
+
// a delimited string; strip the delimiter + trailing flags.
|
|
323
|
+
export function extractPhpRegexBodies(code) {
|
|
324
|
+
const out = [];
|
|
325
|
+
for (const m of code.matchAll(/\bpreg_(?:match|match_all|replace|replace_callback|split)\s*\(\s*(['"])(.+?)\1/g)) {
|
|
326
|
+
let lit = m[2];
|
|
327
|
+
// delimited: first char is the delimiter (e.g. / # ~), trailing flags after it
|
|
328
|
+
const delim = lit[0];
|
|
329
|
+
const close = lit.lastIndexOf(delim);
|
|
330
|
+
if (close > 0) lit = lit.slice(1, close);
|
|
331
|
+
out.push({ body: lit, line: code.slice(0, m.index).split('\n').length });
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Ruby: a regex literal /…/ used with =~ / .match / .match? / Regexp.new("…").
|
|
337
|
+
export function extractRubyRegexBodies(code) {
|
|
338
|
+
const out = [];
|
|
339
|
+
for (const m of code.matchAll(/(?:=~|\.match\??|\.scan|\.gsub|\.sub|grep)\s*\(?\s*\/((?:\\.|[^\/\n])+)\//g)) {
|
|
340
|
+
out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
|
|
341
|
+
}
|
|
342
|
+
for (const m of code.matchAll(/\bRegexp\.new\s*\(\s*['"]((?:\\.|[^'"\n])+)['"]/g)) {
|
|
343
|
+
out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// C#: new Regex("…") / Regex.IsMatch(s, "…") / Regex.Match / Regex.Replace.
|
|
349
|
+
export function extractCsharpRegexBodies(code) {
|
|
350
|
+
const out = [];
|
|
351
|
+
for (const m of code.matchAll(/\bnew\s+Regex\s*\(\s*@?"((?:\\.|""|[^"\n])+)"/g)) {
|
|
352
|
+
out.push({ body: m[1].replace(/""/g, '"'), line: code.slice(0, m.index).split('\n').length });
|
|
353
|
+
}
|
|
354
|
+
for (const m of code.matchAll(/\bRegex\.(?:IsMatch|Match|Matches|Replace|Split)\s*\(\s*[^,]+,\s*@?"((?:\\.|""|[^"\n])+)"/g)) {
|
|
355
|
+
out.push({ body: m[1].replace(/""/g, '"'), line: code.slice(0, m.index).split('\n').length });
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Kotlin: Regex("…") / "…".toRegex() / Pattern.compile("…").
|
|
361
|
+
export function extractKotlinRegexBodies(code) {
|
|
362
|
+
const out = [];
|
|
363
|
+
for (const m of code.matchAll(/\bRegex\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
|
|
364
|
+
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
365
|
+
}
|
|
366
|
+
for (const m of code.matchAll(/"((?:\\.|[^"\n])+)"\s*\.\s*toRegex\s*\(/g)) {
|
|
367
|
+
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
368
|
+
}
|
|
369
|
+
for (const m of code.matchAll(/\bPattern\.compile\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
|
|
370
|
+
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
|
|
308
375
|
export function scanRegexReDoS(file, raw) {
|
|
309
376
|
if (!file || !raw || typeof raw !== 'string') return [];
|
|
310
377
|
if (raw.length > 500_000) return [];
|
|
@@ -313,6 +380,14 @@ export function scanRegexReDoS(file, raw) {
|
|
|
313
380
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) bodies = extractJsRegexBodies(raw);
|
|
314
381
|
else if (/\.py$/i.test(file)) bodies = extractPyRegexBodies(raw);
|
|
315
382
|
else if (/\.java$/i.test(file)) bodies = extractJavaRegexBodies(raw);
|
|
383
|
+
// NOTE: Go's regexp package uses RE2 (guaranteed linear time, no catastrophic
|
|
384
|
+
// backtracking), so Go is intentionally NOT scanned for ReDoS — a "vulnerable"
|
|
385
|
+
// pattern there is not actually exploitable. (extractGoRegexBodies is exported
|
|
386
|
+
// for completeness / third-party regexp libs but not wired into the default.)
|
|
387
|
+
else if (/\.(?:php|phtml)$/i.test(file)) bodies = extractPhpRegexBodies(raw);
|
|
388
|
+
else if (/\.rb$/i.test(file)) bodies = extractRubyRegexBodies(raw);
|
|
389
|
+
else if (/\.cs$/i.test(file)) bodies = extractCsharpRegexBodies(raw);
|
|
390
|
+
else if (/\.kt$/i.test(file)) bodies = extractKotlinRegexBodies(raw);
|
|
316
391
|
else return [];
|
|
317
392
|
|
|
318
393
|
for (const { body, line } of bodies) {
|
|
@@ -24,25 +24,43 @@
|
|
|
24
24
|
|
|
25
25
|
import { blankComments } from './_comment-strip.js';
|
|
26
26
|
|
|
27
|
+
// Each pattern captures the header VALUE expression in its LAST group (the
|
|
28
|
+
// code reads the last defined group, so header-name groups can come first).
|
|
27
29
|
const PATTERNS = [
|
|
28
30
|
// res.setHeader('name', val) | res.set('name', val) | ctx.response.set(…)
|
|
29
31
|
['js', /\b(?:res|reply|response|ctx\.response)\s*\.\s*(?:setHeader|set|append)\s*\(\s*[`"'][^`"']+[`"']\s*,\s*([^)]+?)\s*\)/g, 'res.setHeader'],
|
|
30
32
|
// response.setHeader / response.addHeader (Java Servlet API). Accept any
|
|
31
33
|
// identifier as the receiver — `resp`, `response`, `httpResponse`, etc.
|
|
32
|
-
['java', /\b(?:[A-Za-z_][\w]*)\s*\.\s*(?:setHeader|addHeader)\s*\(\s*"[^"]+"\s*,\s*([^)]+?)\s*\)/g, 'setHeader'],
|
|
34
|
+
['java', /\b(?:[A-Za-z_][\w]*)\s*\.\s*(?:setHeader|addHeader|addIntHeader|setDateHeader)\s*\(\s*"[^"]+"\s*,\s*([^)]+?)\s*\)/g, 'setHeader'],
|
|
33
35
|
// Django/Flask response.headers['X-…'] = userValue
|
|
34
36
|
['py', /\b(?:response|resp|r)\s*(?:\.\s*headers)?\s*\[\s*['"][^'"]+['"]\s*\]\s*=\s*([^\n;]+)/g, 'response.headers[…] = …'],
|
|
35
37
|
// PHP header("X-Foo: " . $_GET[…]) (Location: variants handled by open-redirect)
|
|
36
38
|
['php', /\bheader\s*\(\s*['"](?!Location)([^'"]+):\s*['"]\s*\.\s*(\$\w[\w\[\]'"]*)/g, 'PHP header()'],
|
|
39
|
+
// Go net/http: w.Header().Set/Add("X-…", value) | w.Header()["X-…"] = …
|
|
40
|
+
['go', /\.\s*Header\s*\(\s*\)\s*\.\s*(?:Set|Add)\s*\(\s*"[^"]+"\s*,\s*([^)]+?)\s*\)/g, 'Header().Set'],
|
|
41
|
+
// Ruby Rack/Rails: response.headers["X-…"] = … | headers["X-…"] = … | response["X-…"] = …
|
|
42
|
+
['rb', /\b(?:response\s*(?:\.\s*headers)?|headers)\s*\[\s*['"][^'"]+['"]\s*\]\s*=\s*([^\n;]+)/g, 'response headers[…] = …'],
|
|
43
|
+
// C#: Response.Headers.Add/Append("X-…", value) | Response.AddHeader(…) | Response.AppendHeader(…)
|
|
44
|
+
['cs', /\bResponse\s*\.\s*(?:Headers\s*\.\s*(?:Add|Append)|AddHeader|AppendHeader)\s*\(\s*"[^"]+"\s*,\s*([^)]+?)\s*\)/g, 'Response.Headers.Add'],
|
|
45
|
+
// Kotlin: resp.setHeader("X-…", value) | resp.addHeader(…) — JVM servlet API
|
|
46
|
+
['kt', /\b(?:[A-Za-z_][\w]*)\s*\.\s*(?:setHeader|addHeader)\s*\(\s*"[^"]+"\s*,\s*([^)]+?)\s*\)/g, 'setHeader'],
|
|
37
47
|
];
|
|
38
48
|
|
|
39
49
|
const TAINT_HINT_RE =
|
|
40
|
-
/\b(?:req\.|request\.|params\.|query\.|body\.|ctx\.query|ctx\.request|reply\.query|c\.Query|r\.URL\.Query|_GET|_POST|_REQUEST|getParameter|getHeader)\b/;
|
|
50
|
+
/\b(?:req\.|request\.|params\.|params\s*\[|query\.|body\.|ctx\.query|ctx\.request|reply\.query|c\.Query|r\.URL\.Query|_GET|_POST|_REQUEST|getParameter|getHeader)\b|\b(?:params|query|cookies|session)\s*\[/;
|
|
41
51
|
|
|
42
52
|
const SANITIZER_PATTERNS = [
|
|
43
53
|
/\.replace\s*\(\s*\/\\r\?\\?n/i, // .replace(/\r?\n/, '')
|
|
44
54
|
/\.replace\s*\(\s*\/[\[]\\r\\n[\]]/i, // .replace(/[\r\n]/, '')
|
|
55
|
+
/\.replace\s*\(\s*['"]\\r['"]/, // .replace("\r", "") (Python/JS chain)
|
|
56
|
+
/\.replace\s*\(\s*['"]\\n['"]/, // .replace("\n", "")
|
|
45
57
|
/\.replaceAll\s*\(\s*['"]\\r?\\?n?['"]/,
|
|
58
|
+
/\bgsub\s*\(\s*\/[\[]?\\r/i, // Ruby .gsub(/[\r\n]/, '') or /\r\n/
|
|
59
|
+
/\bgsub\s*\(\s*\/[\[]\\r\\n[\]]/i,
|
|
60
|
+
/\bdelete\s*\(\s*['"]\\r\\n['"]\s*\)/, // Ruby .delete("\r\n")
|
|
61
|
+
/\bstrings\.NewReplacer\s*\([^)]*\\r/, // Go strings.NewReplacer("\r","","\n","")
|
|
62
|
+
/\bstrings\.ReplaceAll\s*\([^,]+,\s*['"]\\[rn]['"]/,
|
|
63
|
+
/\bstr_replace\s*\(\s*\[[^\]]*\\[rn]/, // PHP str_replace(["\r","\n"], …)
|
|
46
64
|
/\bstripNewlines\b/i,
|
|
47
65
|
/\bsanitizeHeader\b/i,
|
|
48
66
|
/\bescapeCRLF\b/i,
|
|
@@ -63,7 +81,11 @@ function _lang(fp) {
|
|
|
63
81
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) return 'js';
|
|
64
82
|
if (/\.py$/i.test(fp)) return 'py';
|
|
65
83
|
if (/\.java$/i.test(fp)) return 'java';
|
|
66
|
-
if (/\.php$/i.test(fp)) return 'php';
|
|
84
|
+
if (/\.(?:php|phtml)$/i.test(fp)) return 'php';
|
|
85
|
+
if (/\.go$/i.test(fp)) return 'go';
|
|
86
|
+
if (/\.rb$/i.test(fp)) return 'rb';
|
|
87
|
+
if (/\.cs$/i.test(fp)) return 'cs';
|
|
88
|
+
if (/\.kt$/i.test(fp)) return 'kt';
|
|
67
89
|
return null;
|
|
68
90
|
}
|
|
69
91
|
|
|
@@ -79,8 +101,8 @@ export function scanResponseSplitting(fp, raw) {
|
|
|
79
101
|
if (!raw || raw.length > 500_000) return [];
|
|
80
102
|
const lang = _lang(fp);
|
|
81
103
|
if (!lang) return [];
|
|
82
|
-
const code = blankComments(raw, lang === 'py' ? 'py' : undefined);
|
|
83
|
-
if (!/\b(?:setHeader|addHeader|response\s*\.\s*headers|response\s*\[|reply\s*\.\s*set|\bheader\s*\()/i.test(code)) return [];
|
|
104
|
+
const code = blankComments(raw, (lang === 'py' || lang === 'rb') ? 'py' : undefined);
|
|
105
|
+
if (!/\b(?:setHeader|addHeader|addIntHeader|setDateHeader|AddHeader|AppendHeader|response\s*\.\s*headers|response\s*\[|headers\s*\[|reply\s*\.\s*set|\.\s*Header\s*\(\s*\)\s*\.\s*(?:Set|Add)|Response\s*\.\s*Headers|\bheader\s*\()/i.test(code)) return [];
|
|
84
106
|
const findings = [];
|
|
85
107
|
const seen = new Set();
|
|
86
108
|
for (const [plang, pat, label] of PATTERNS) {
|
|
@@ -88,23 +110,30 @@ export function scanResponseSplitting(fp, raw) {
|
|
|
88
110
|
const re = new RegExp(pat.source, pat.flags);
|
|
89
111
|
let m;
|
|
90
112
|
while ((m = re.exec(code))) {
|
|
91
|
-
|
|
113
|
+
// Use the LAST defined capture group as the header VALUE (php's pattern
|
|
114
|
+
// captures the header NAME first, the value second).
|
|
115
|
+
const value = ((m[m.length - 1] ?? m[1]) || '').trim();
|
|
92
116
|
let tainted = TAINT_HINT_RE.test(value);
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
const sigRe = /\b(?:
|
|
103
|
-
let sig = null;
|
|
104
|
-
let sm;
|
|
117
|
+
// Handler-context heuristic: when `value` is a plain identifier that is a
|
|
118
|
+
// parameter of the enclosing method/function, treat it as user-derived
|
|
119
|
+
// (same "request-scope param" rule a real SAST tool applies). Covers the
|
|
120
|
+
// single-file `void h(String v, … resp){ resp.setHeader("X", v); }` shape
|
|
121
|
+
// for Java / Kotlin / C#.
|
|
122
|
+
if (!tainted && (plang === 'java' || plang === 'kt' || plang === 'cs') && /^[A-Za-z_][\w]*$/.test(value)) {
|
|
123
|
+
const before = code.slice(0, m.index);
|
|
124
|
+
// Most recent signature `(...)` immediately followed by `{` — matches
|
|
125
|
+
// Java/C# (`… name(params) {`) and Kotlin (`fun name(params) … {`).
|
|
126
|
+
const sigRe = /\b(?:fun\s+\w+|[A-Za-z_][\w<>\[\],.\s]*\b\w+)\s*\(([^)]*)\)\s*(?::[^={]+)?(?:throws[^{]+)?\{/g;
|
|
127
|
+
let sig = null, sm;
|
|
105
128
|
while ((sm = sigRe.exec(before)) !== null) sig = sm;
|
|
106
129
|
if (sig) {
|
|
107
|
-
|
|
130
|
+
// Param name is the last token of each `Type name` / first of Kotlin `name: Type`.
|
|
131
|
+
const params = sig[1].split(',').map((p) => {
|
|
132
|
+
const t = p.trim();
|
|
133
|
+
if (!t) return '';
|
|
134
|
+
if (t.includes(':')) return t.split(':')[0].trim(); // Kotlin `name: Type`
|
|
135
|
+
return (t.split(/\s+/).pop() || '').trim(); // Java/C# `Type name`
|
|
136
|
+
});
|
|
108
137
|
if (params.includes(value)) tainted = true;
|
|
109
138
|
}
|
|
110
139
|
}
|
package/src/sast/ruby.js
CHANGED
|
@@ -23,6 +23,24 @@ const RE = {
|
|
|
23
23
|
attributesEq: /\.\s*attributes\s*=\s*params\b(?!\s*\.permit)/g,
|
|
24
24
|
openSsrf: /\b(?:open|URI\.open|URI\.parse\([^)]*\)\.read)\s*\(\s*(?:params|request|@\w+\.params)\b/g,
|
|
25
25
|
fileRead: /\bFile\s*\.\s*(?:read|open|new|readlines)\s*\(\s*params\s*\[/g,
|
|
26
|
+
// Structural (taint-independent): an ActiveRecord query or shell command
|
|
27
|
+
// built with string interpolation (#{...}) or concat is the injection shape
|
|
28
|
+
// regardless of whether the value is `params` or a local variable that came
|
|
29
|
+
// from params — the existing rules above require the literal `params` token
|
|
30
|
+
// on the sink line, which misses `name = params[:x]; where("... #{name}")`.
|
|
31
|
+
sqlInjectionStructural: /\.(?:where|find_by_sql|having|order|group|joins|select|from|pluck|update_all|delete_all|exec_query|execute|select_all|select_value|find_by)\s*\(\s*(?:"[^"\n]*#\{|['"][^'"\n]*['"]\s*\+)/g,
|
|
32
|
+
cmdInjectionStructural: /(?:`[^`\n]*#\{|\b(?:system|exec)\s*\(\s*"[^"\n]*#\{|\bIO\.popen\s*\(\s*"[^"\n]*#\{|\b(?:system|exec)\s*\(\s*['"][^'"\n]*['"]\s*\+)/g,
|
|
33
|
+
// Structural path traversal: File/IO op whose path is built by interpolation
|
|
34
|
+
// (#{...}) or concat. `File.read("/data/" + name)` routes through a local var
|
|
35
|
+
// so the `params[`-on-sink fileRead rule misses it. Containment-guarded forms
|
|
36
|
+
// (File.expand_path + start_with?) are dropped by engine.js dropGuardedFindings.
|
|
37
|
+
pathTraversalStructural: /\b(?:File|IO)\s*\.\s*(?:read|open|new|readlines|binread|write|foreach)\s*\(\s*(?:"[^"\n]*#\{|['"][^'"\n]*['"]\s*\+)/g,
|
|
38
|
+
// Structural deserialization (taint-independent): Marshal.load / YAML.load /
|
|
39
|
+
// YAML.unsafe_load are unsafe on ANY non-trivial argument — the danger is the
|
|
40
|
+
// sink, not the source. The `params`-on-sink marshalLoad/yamlUnsafe rules
|
|
41
|
+
// above miss `data = request.body.read; Marshal.load(data)`. A bare string
|
|
42
|
+
// literal arg (Marshal.load("...")) won't match (`\w` requires an identifier).
|
|
43
|
+
deserStructural: /\bMarshal\s*\.\s*load\s*\(\s*[A-Za-z_@]\w*|\bYAML\s*\.\s*(?:load|unsafe_load)\s*\(\s*[A-Za-z_@]\w*/g,
|
|
26
44
|
};
|
|
27
45
|
|
|
28
46
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
@@ -92,6 +110,26 @@ export function scanRuby(fp, raw) {
|
|
|
92
110
|
severity: 'high', cwe: 'CWE-22',
|
|
93
111
|
remediation: 'Canonicalize the path and verify it stays under an allowed base: `path = File.expand_path(File.join(base, name)); raise unless path.start_with?(base)`.',
|
|
94
112
|
},
|
|
113
|
+
sqlInjectionStructural: {
|
|
114
|
+
vuln: 'SQL Injection: ActiveRecord query built with string interpolation / concat',
|
|
115
|
+
severity: 'critical', cwe: 'CWE-89',
|
|
116
|
+
remediation: 'Use the parameterized form: `User.where("name = ?", name)` or `where(name: name)`. Never interpolate (`#{...}`) or concatenate values into a SQL fragment.',
|
|
117
|
+
},
|
|
118
|
+
cmdInjectionStructural: {
|
|
119
|
+
vuln: 'Command Injection: shell command built with string interpolation / concat',
|
|
120
|
+
severity: 'critical', cwe: 'CWE-78',
|
|
121
|
+
remediation: 'Use `Open3.capture2("cmd", arg1, arg2)` with separate arguments (no shell). Backticks and `system("... #{x}")` run through the shell.',
|
|
122
|
+
},
|
|
123
|
+
pathTraversalStructural: {
|
|
124
|
+
vuln: 'Path Traversal: File/IO operation built with interpolated/concatenated path',
|
|
125
|
+
severity: 'high', cwe: 'CWE-22',
|
|
126
|
+
remediation: 'Canonicalize and assert containment: `path = File.expand_path(File.join(base, name)); raise unless path.start_with?(base)`. Never interpolate/concatenate request input straight into a file path.',
|
|
127
|
+
},
|
|
128
|
+
deserStructural: {
|
|
129
|
+
vuln: 'Insecure Deserialization: Marshal.load / YAML.load on a non-literal value',
|
|
130
|
+
severity: 'critical', cwe: 'CWE-502', family: 'insecure-deserialization',
|
|
131
|
+
remediation: 'Marshal and YAML.load instantiate arbitrary Ruby objects (RCE gadget chains). Use JSON for data crossing a trust boundary, or YAML.safe_load(input, permitted_classes: [Symbol]). Never Marshal.load attacker-controlled bytes.',
|
|
132
|
+
},
|
|
95
133
|
}[key];
|
|
96
134
|
push({
|
|
97
135
|
id: `ruby-${key}:${fp}:${line}`,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Split-concatenation secret detector (language-agnostic) — PRD Tier 1.
|
|
2
|
+
//
|
|
3
|
+
// A hardcoded credential is frequently SPLIT across concatenated string
|
|
4
|
+
// literals specifically to slip past secret regexes that anchor on a
|
|
5
|
+
// contiguous token: `AWS_KEY = 'AKIA' + 'IOSFODNN7EXAMPLE'`,
|
|
6
|
+
// `GITHUB_TOKEN = 'ghp' + '_…'`, `STRIPE_KEY = 'sk' + '_live_…' + '…'`.
|
|
7
|
+
//
|
|
8
|
+
// We join the literals back together and match the *reassembled* value against
|
|
9
|
+
// known provider prefixes (high precision — these markers don't occur by
|
|
10
|
+
// accident) or, for a credential-named LHS, a long joined literal. The flow
|
|
11
|
+
// engine and the contiguous-token secrets scanner both miss the split shape;
|
|
12
|
+
// csharp-structural.js handles it for C# only. This covers JS/TS/Py/Rb/Go/
|
|
13
|
+
// Java/PHP via the `+` (and PHP `.`) concat operators.
|
|
14
|
+
|
|
15
|
+
import { blankComments } from './_comment-strip.js';
|
|
16
|
+
|
|
17
|
+
// Real provider markers — anchored, so a generic `'foo' + 'bar'` won't match.
|
|
18
|
+
const SECRET_PREFIX = /(?:sk_|sk-|AKIA|ASIA|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[abprs]-|AIza|ya29\.|eyJ[A-Za-z0-9_-]{8,}|-----BEGIN|glpat-|shpat_|shpss_|npm_|dop_v1_|SG\.[A-Za-z0-9_-]{10,})/;
|
|
19
|
+
// Credential-signalling LHS identifier.
|
|
20
|
+
const SECRET_NAME = /(?:api[_-]?key|secret|token|passwd|password|pwd|credential|private[_-]?key|access[_-]?key|client[_-]?secret|auth[_-]?token)/i;
|
|
21
|
+
|
|
22
|
+
// LHS ident, then `=`/`:=`/`:`, then a concat of >=2 string literals joined by
|
|
23
|
+
// `+` (most langs) or `.` (PHP). Single or double quoted.
|
|
24
|
+
const ASSIGN_CONCAT = /([A-Za-z_$][\w$]*)\s*(?::?=|:)\s*((?:(?:'[^'\n]*'|"[^"\n]*")\s*[+.]\s*)+(?:'[^'\n]*'|"[^"\n]*"))/g;
|
|
25
|
+
|
|
26
|
+
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
27
|
+
function joinLiterals(expr) {
|
|
28
|
+
const parts = expr.match(/'[^'\n]*'|"[^"\n]*"/g) || [];
|
|
29
|
+
return parts.map((p) => p.slice(1, -1)).join('');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function scanSecretConcat(fp, raw) {
|
|
33
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py|rb|go|java|kt|php|phtml|cs|scala|groovy)$/i.test(fp)) return [];
|
|
34
|
+
if (!raw || raw.length > 500_000) return [];
|
|
35
|
+
const code = blankComments(raw);
|
|
36
|
+
const findings = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
|
|
39
|
+
const re = new RegExp(ASSIGN_CONCAT.source, ASSIGN_CONCAT.flags);
|
|
40
|
+
let m;
|
|
41
|
+
while ((m = re.exec(code))) {
|
|
42
|
+
const ident = m[1];
|
|
43
|
+
const value = joinLiterals(m[2]);
|
|
44
|
+
const byPrefix = SECRET_PREFIX.test(value);
|
|
45
|
+
const byName = SECRET_NAME.test(ident) && value.length >= 24;
|
|
46
|
+
if (!byPrefix && !byName) continue;
|
|
47
|
+
const line = lineOf(code, m.index);
|
|
48
|
+
const id = `secret-concat:${fp}:${line}`;
|
|
49
|
+
if (seen.has(id)) continue;
|
|
50
|
+
seen.add(id);
|
|
51
|
+
findings.push({
|
|
52
|
+
id, file: fp, line,
|
|
53
|
+
vuln: 'Hardcoded credential — secret split across concatenated literals to evade detection',
|
|
54
|
+
severity: 'high', cwe: 'CWE-798', family: 'secret', parser: 'SECRET-CONCAT', confidence: 0.78,
|
|
55
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
56
|
+
remediation: 'Load the secret from the environment or a secrets manager (process.env / os.environ / Vault / AWS Secrets Manager). Splitting the literal across a concatenation does not protect it — rotate the exposed value, it must be considered compromised.',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
|
@@ -21,6 +21,13 @@ const SSRF_CLIENT_RE = /\b(?:fetch|axios\.\w+|requests\.\w+|http\.get|http\.requ
|
|
|
21
21
|
|
|
22
22
|
const METADATA_GUARD_RE = /(?:169\.254\.169\.254|169\.254\.|metadata\.google\.internal|metadata\.azure\.com|fd00:ec2|metadata\.aws\.amazon)/i;
|
|
23
23
|
|
|
24
|
+
// A metadata literal that appears inside an allow/deny-list or a host
|
|
25
|
+
// comparison is a GUARD (the remediation), not an SSRF — e.g.
|
|
26
|
+
// `DENY = Set.of("169.254.169.254", …)`, `if (u.Host == "169.254.169.254")
|
|
27
|
+
// throw`, `host !in setOf("169.254.169.254")`, `["169.254.169.254"].includes`.
|
|
28
|
+
// Flagging it makes the scanner cry wolf on correctly-hardened code.
|
|
29
|
+
const METADATA_GUARD_CONTEXT_RE = /\b(?:deny|denylist|blocklist|blocked?|allow(?:list|ed)?|forbidden|reject(?:ed)?|disallow|banned?)\b|[!=]==?\s*['"]|\.equals\s*\(|\.contains\b|\.includes\b|\.indexOf\b|\bin\s+\w|\bnot\s+in\b|!in\b|Set\s*\.\s*of|setOf|new\s+HashSet|\bthrow\b|SecurityException|\babort\b|getHost\s*\(|\.host\b|\.Host\b/i;
|
|
30
|
+
|
|
24
31
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
25
32
|
|
|
26
33
|
export function scanSSRFCloudMetadata(fp, raw) {
|
|
@@ -37,6 +44,12 @@ export function scanSSRFCloudMetadata(fp, raw) {
|
|
|
37
44
|
let m;
|
|
38
45
|
while ((m = r.exec(code))) {
|
|
39
46
|
const line = lineOf(raw, m.index);
|
|
47
|
+
// Guard recognition: if the metadata literal sits in an allow/deny-list
|
|
48
|
+
// or host-comparison (this line ± 1), it's blocking the endpoint, not
|
|
49
|
+
// calling it — suppress the false positive on hardened code.
|
|
50
|
+
const lines = raw.split('\n');
|
|
51
|
+
const ctx = lines.slice(Math.max(0, line - 2), line + 1).join('\n');
|
|
52
|
+
if (METADATA_GUARD_CONTEXT_RE.test(ctx)) continue;
|
|
40
53
|
push({
|
|
41
54
|
id: `ssrf-meta-hardcoded:${fp}:${line}`,
|
|
42
55
|
file: fp, line,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Stored / second-order taint (roadmap #2) — OPT-IN.
|
|
2
|
+
//
|
|
3
|
+
// Classic SAST blind spot: data written to a persistence store (DB / cache /
|
|
4
|
+
// file) by one request and read back by another is implicitly trusted, but if
|
|
5
|
+
// any user input was ever stored there it is still attacker-controlled. The
|
|
6
|
+
// result is stored XSS, second-order SQLi, etc. The engine's forward taint
|
|
7
|
+
// can't see across the persistence boundary.
|
|
8
|
+
//
|
|
9
|
+
// This detector flags the second-order SHAPE: a value READ from a store that
|
|
10
|
+
// flows into an injection sink without re-validation. It is inherently lower
|
|
11
|
+
// confidence than first-order taint (stored data is not ALWAYS attacker-
|
|
12
|
+
// controlled), so it is OFF by default and enabled with:
|
|
13
|
+
//
|
|
14
|
+
// AGENTIC_SECURITY_STORED_TAINT=1
|
|
15
|
+
//
|
|
16
|
+
// Being opt-in, it cannot change default scan behavior (no default FPs/FNs).
|
|
17
|
+
|
|
18
|
+
import { blankComments } from './_comment-strip.js';
|
|
19
|
+
|
|
20
|
+
const JS_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
21
|
+
const PY_RE = /\.py$/i;
|
|
22
|
+
|
|
23
|
+
// A read from a persistence store, capturing the assigned variable.
|
|
24
|
+
// const row = await db.query(...) | row = collection.find_one(...)
|
|
25
|
+
const READ_ASSIGN = [
|
|
26
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?[\w$.]*\b(?:query|find|findOne|findById|findAll|fetchRow|fetchAll|getItem|get|scan|select|aggregate|exec)\s*\(/g,
|
|
27
|
+
/\b([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?[\w$.]*\b(?:query|find|find_one|fetchone|fetchall|first|all|get_object_or_404)\s*\(/g,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// Injection sinks that, fed stored data, are second-order vulnerabilities.
|
|
31
|
+
function sinkPatternsFor(varName) {
|
|
32
|
+
const v = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
33
|
+
return [
|
|
34
|
+
[new RegExp(`\\b(?:eval|exec|execSync|Function)\\s*\\([^)]*\\b${v}\\b`), 'CWE-94', 'code-injection'],
|
|
35
|
+
[new RegExp(`\\.(?:innerHTML|outerHTML)\\s*=\\s*[^;\\n]*\\b${v}\\b`), 'CWE-79', 'xss'],
|
|
36
|
+
[new RegExp(`\\bres(?:ponse)?\\s*\\.\\s*(?:send|write|end)\\s*\\([^)]*\\b${v}\\b`), 'CWE-79', 'xss'],
|
|
37
|
+
[new RegExp(`\\b(?:query|execute|raw)\\s*\\([^)]*\\b${v}\\b[^)]*\\+`), 'CWE-89', 'sql-injection'],
|
|
38
|
+
[new RegExp(`\\b(?:exec|execSync|spawn|system|popen)\\s*\\([^)]*\\b${v}\\b`), 'CWE-78', 'command-injection'],
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REVALIDATED = /\b(?:validate|sanitize|escape|encode|allow(?:list|ed)|schema\.\w*parse)\b/i;
|
|
43
|
+
|
|
44
|
+
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
45
|
+
|
|
46
|
+
export function scanStoredTaint(fp, raw) {
|
|
47
|
+
if (process.env.AGENTIC_SECURITY_STORED_TAINT !== '1') return [];
|
|
48
|
+
if (!raw || raw.length > 500_000) return [];
|
|
49
|
+
if (!JS_RE.test(fp) && !PY_RE.test(fp)) return [];
|
|
50
|
+
|
|
51
|
+
const code = blankComments(raw, PY_RE.test(fp) ? 'py' : undefined);
|
|
52
|
+
const lines = code.split('\n');
|
|
53
|
+
const findings = [];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
|
|
56
|
+
for (const re of READ_ASSIGN) {
|
|
57
|
+
re.lastIndex = 0;
|
|
58
|
+
let m;
|
|
59
|
+
while ((m = re.exec(code))) {
|
|
60
|
+
const varName = m[1];
|
|
61
|
+
if (!varName) continue;
|
|
62
|
+
const readLine = lineOf(code, m.index);
|
|
63
|
+
// Search a forward window for the var reaching a sink, with no
|
|
64
|
+
// re-validation in between.
|
|
65
|
+
const winEnd = Math.min(lines.length, readLine + 20);
|
|
66
|
+
const window = lines.slice(readLine, winEnd).join('\n');
|
|
67
|
+
if (REVALIDATED.test(window)) continue;
|
|
68
|
+
for (const [sinkRe, cwe, family] of sinkPatternsFor(varName)) {
|
|
69
|
+
const sm = sinkRe.exec(window);
|
|
70
|
+
if (!sm) continue;
|
|
71
|
+
const sinkLine = readLine + window.substring(0, sm.index).split('\n').length;
|
|
72
|
+
const key = `${sinkLine}:${family}`;
|
|
73
|
+
if (seen.has(key)) continue;
|
|
74
|
+
seen.add(key);
|
|
75
|
+
findings.push({
|
|
76
|
+
id: `stored-taint:${fp}:${sinkLine}:${family}`,
|
|
77
|
+
severity: 'medium', file: fp, line: sinkLine,
|
|
78
|
+
vuln: `Second-order / stored taint candidate (${family})`,
|
|
79
|
+
cwe, family, parser: 'SAST', confidence: 0.4,
|
|
80
|
+
description: `A value read from a persistence store ('${varName}') flows into a ${family} sink without re-validation. If any user input is ever written to that store, this is a second-order injection (e.g. stored XSS / second-order SQLi).`,
|
|
81
|
+
remediation: 'Treat data read from a store as untrusted: validate/encode it at the sink exactly as you would direct request input. Do not rely on validation performed at write time.',
|
|
82
|
+
});
|
|
83
|
+
break; // one sink class per read is enough
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return findings;
|
|
88
|
+
}
|