@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,81 @@
|
|
|
1
|
+
// Java structural (taint-independent) injection detectors — PRD Tier 1.
|
|
2
|
+
//
|
|
3
|
+
// The flow-based Java modules miss standalone DAO/handler methods whose
|
|
4
|
+
// tainted-by-convention parameter has no in-file source. Java has no string
|
|
5
|
+
// templates, so the injection shape is string CONCATENATION (`"…" +`) into a
|
|
6
|
+
// dangerous sink — which is itself the vulnerability regardless of the
|
|
7
|
+
// variable's name. Parameterized statements / canonicalized paths / host-
|
|
8
|
+
// guarded URLs do not match, keeping this high-precision.
|
|
9
|
+
|
|
10
|
+
import { blankComments } from './_comment-strip.js';
|
|
11
|
+
|
|
12
|
+
// Concat-into-sink families with no guard needed (parameterized form has no
|
|
13
|
+
// string-concat argument, so it auto-clears).
|
|
14
|
+
const RE = {
|
|
15
|
+
sqlInjection: /\b(?:executeQuery|executeUpdate|execute|createQuery|createNativeQuery|prepareStatement|prepareCall)\s*\(\s*"[^"\n]*"\s*\+/g,
|
|
16
|
+
cmdInjection: /\b(?:Runtime\.getRuntime\(\)\s*\.\s*exec|ProcessBuilder)\s*\(\s*(?:new\s+String\s*\[\s*\]\s*\{\s*)?"[^"\n]*"\s*\+/g,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const META = {
|
|
20
|
+
sqlInjection: {
|
|
21
|
+
vuln: 'SQL Injection — query built with string concatenation (Java)',
|
|
22
|
+
severity: 'critical', cwe: 'CWE-89',
|
|
23
|
+
remediation: 'Use a PreparedStatement with bind parameters: prepareStatement("… WHERE name = ?") then setString(1, name). Never concatenate values into SQL.',
|
|
24
|
+
},
|
|
25
|
+
cmdInjection: {
|
|
26
|
+
vuln: 'Command Injection — exec built with string concatenation (Java)',
|
|
27
|
+
severity: 'critical', cwe: 'CWE-78',
|
|
28
|
+
remediation: 'Use ProcessBuilder with an argument array (no shell): new ProcessBuilder("cmd", arg1, arg2). Never concatenate input into a command string.',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
33
|
+
|
|
34
|
+
export function scanJavaStructural(fp, raw) {
|
|
35
|
+
if (!/\.java$/i.test(fp)) return [];
|
|
36
|
+
if (!raw || raw.length > 500_000) return [];
|
|
37
|
+
const code = blankComments(raw);
|
|
38
|
+
const findings = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
|
|
41
|
+
const emit = (key, line, meta) => push({
|
|
42
|
+
id: `java-struct-${key}:${fp}:${line}`, file: fp, line,
|
|
43
|
+
vuln: meta.vuln, severity: meta.severity, cwe: meta.cwe, family: meta.family,
|
|
44
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
45
|
+
remediation: meta.remediation, parser: 'JAVA', confidence: 0.78,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
for (const [key, re] of Object.entries(RE)) {
|
|
49
|
+
const r = new RegExp(re.source, re.flags);
|
|
50
|
+
let m;
|
|
51
|
+
while ((m = r.exec(code))) emit(key, lineOf(code, m.index), META[key]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Path traversal (CWE-22): a file path built by concatenation, unless the
|
|
55
|
+
// file canonicalizes / normalizes / range-checks the path.
|
|
56
|
+
const PATH_SINK = /\bnew\s+(?:File|FileInputStream|FileReader|FileOutputStream|RandomAccessFile)\s*\(\s*"[^"\n]*"\s*\+|\bPaths\.get\s*\(\s*"[^"\n]*"\s*\+/;
|
|
57
|
+
const PATH_GUARD = /getCanonicalPath|toRealPath|\.normalize\s*\(\s*\)|\bstartsWith\s*\(|FilenameUtils|\.isAbsolute\s*\(/;
|
|
58
|
+
if (PATH_SINK.test(code) && !PATH_GUARD.test(code)) {
|
|
59
|
+
const line = lineOf(code, code.search(PATH_SINK));
|
|
60
|
+
emit('path', line, {
|
|
61
|
+
vuln: 'Path Traversal — file path built with string concatenation (Java)',
|
|
62
|
+
severity: 'high', cwe: 'CWE-22',
|
|
63
|
+
remediation: 'Resolve against an allow-listed base and verify containment: Path want = base.resolve(name).normalize().toRealPath(); if (!want.startsWith(base)) throw …',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// SSRF (CWE-918): new URL/URI opened from a non-literal/templated value,
|
|
68
|
+
// unless a host allow/deny guard is present.
|
|
69
|
+
const SSRF_SINK = /\bnew\s+(?:URL|URI)\s*\(\s*(?:[A-Za-z_]\w*\s*\)|"[^"\n]*"\s*\+)/;
|
|
70
|
+
const SSRF_GUARD = /169\.254\.169\.254|getHost\s*\(\s*\)|allow(?:ed)?Hosts?|isLoopback|isSiteLocal|isLinkLocal|InetAddress|\bDENY\b|deny(?:list)?|block(?:list|ed)/i;
|
|
71
|
+
if (SSRF_SINK.test(code) && !SSRF_GUARD.test(code)) {
|
|
72
|
+
const line = lineOf(code, code.search(SSRF_SINK));
|
|
73
|
+
emit('ssrf', line, {
|
|
74
|
+
vuln: 'SSRF — URL/URI opened from a non-literal value (Java)',
|
|
75
|
+
severity: 'high', cwe: 'CWE-918',
|
|
76
|
+
remediation: 'Resolve the host and reject RFC1918 / link-local / metadata (169.254.169.254) addresses, or use an allow-list, before opening the connection.',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return findings;
|
|
81
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// JS/TS framework structural detectors — PRD Tier 1 (JS/Python recall).
|
|
2
|
+
//
|
|
3
|
+
// The weakest languages (JS/Python) miss framework-handler shapes where user
|
|
4
|
+
// input arrives via a framework source (@Query, ctx.query, req.query) and is
|
|
5
|
+
// concatenated into a sink. Taint-independent structural rules close them.
|
|
6
|
+
// SSRF/path findings (CWE-918/CWE-22) are emitted here too; the centralized
|
|
7
|
+
// guard pass (engine.js dropGuardedFindings) handles their hardened forms.
|
|
8
|
+
|
|
9
|
+
import { blankComments } from './_comment-strip.js';
|
|
10
|
+
|
|
11
|
+
const JS_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
12
|
+
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
13
|
+
|
|
14
|
+
// Skip an XSS finding when the value is HTML-escaped on the same line.
|
|
15
|
+
const XSS_ESCAPE = /\b(?:escape|escapeHtml|escape_html|sanitize|sanitizeHtml|DOMPurify\.sanitize|he\.encode|he\.escape|_\.escape|encodeURIComponent|xss)\s*\(/;
|
|
16
|
+
// Skip prototype pollution when the file filters forbidden keys.
|
|
17
|
+
const PROTO_GUARD = /(?:__proto__|constructor|prototype|FORBIDDEN_KEYS|DANGEROUS_KEYS|blocked?Keys)\b[\s\S]{0,80}?(?:continue|delete|\.has\s*\(|new\s+Set|\.includes\s*\(|skip|filter|reject|block|throw|return)/;
|
|
18
|
+
|
|
19
|
+
export function scanJsFrameworkStructural(fp, raw) {
|
|
20
|
+
if (!JS_RE.test(fp)) return [];
|
|
21
|
+
if (!raw || raw.length > 500_000) return [];
|
|
22
|
+
const code = blankComments(raw);
|
|
23
|
+
const lines = code.split('\n');
|
|
24
|
+
const findings = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const emit = (key, line, meta) => {
|
|
27
|
+
const id = `js-fw-${key}:${fp}:${line}`;
|
|
28
|
+
if (seen.has(id)) return;
|
|
29
|
+
seen.add(id);
|
|
30
|
+
findings.push({
|
|
31
|
+
id, file: fp, line, vuln: meta.vuln, severity: meta.severity, cwe: meta.cwe,
|
|
32
|
+
family: meta.family, parser: 'JS-FW', confidence: meta.confidence ?? 0.7,
|
|
33
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200), remediation: meta.remediation,
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// True when `idx` falls inside a string literal on its own line — i.e. an odd
|
|
38
|
+
// number of unescaped quotes of some kind precede it. Used to skip a `.query(`
|
|
39
|
+
// that is itself the CONTENT of a string (e.g. a rule-definition's
|
|
40
|
+
// `example: "db.query(\`…\`)"`), which is data, not a call.
|
|
41
|
+
const _insideStringLiteral = (idx) => {
|
|
42
|
+
const lineStart = code.lastIndexOf('\n', idx - 1) + 1;
|
|
43
|
+
const prefix = code.slice(lineStart, idx);
|
|
44
|
+
for (const q of ['"', "'"]) {
|
|
45
|
+
const n = (prefix.match(new RegExp(`(?<!\\\\)${q}`, 'g')) || []).length;
|
|
46
|
+
if (n % 2 === 1) return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// SQL Injection: db.query/.execute/.raw with template-literal ${} or concat.
|
|
52
|
+
// (TypeORM Connection.query, mysql connection.query, etc.)
|
|
53
|
+
const SQL_RE = /\.(?:query|execute|raw|prepare)\s*\(\s*(?:`[^`\n]*\$\{|"[^"\n]*"\s*\+|'[^'\n]*'\s*\+|"[^"\n]*\$\{)/g;
|
|
54
|
+
let m;
|
|
55
|
+
while ((m = SQL_RE.exec(code))) {
|
|
56
|
+
if (_insideStringLiteral(m.index)) continue;
|
|
57
|
+
emit('sqli', lineOf(code, m.index), {
|
|
58
|
+
vuln: 'SQL Injection — query built with template literal / concat (JS/TS)',
|
|
59
|
+
severity: 'critical', cwe: 'CWE-89', family: 'sql-injection', confidence: 0.75,
|
|
60
|
+
remediation: 'Use parameterized queries — pass `?`/`$1` placeholders and a values array (e.g. `conn.query("… WHERE name = ?", [name])`). Never interpolate or concatenate values into SQL.',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// NestJS / axios HttpService SSRF: http(.|Service|Client).get(<non-literal>).
|
|
65
|
+
const SSRF_RE = /\b(?:http|httpService|httpClient|axios)\s*\.\s*(?:get|post|put|patch|delete|request)\s*\(\s*[A-Za-z_$][\w$.]*\s*[),]/g;
|
|
66
|
+
while ((m = SSRF_RE.exec(code))) emit('ssrf', lineOf(code, m.index), {
|
|
67
|
+
vuln: 'SSRF — HTTP client request to a non-literal URL (JS/TS)',
|
|
68
|
+
severity: 'high', cwe: 'CWE-918', family: 'ssrf', confidence: 0.55,
|
|
69
|
+
remediation: 'Validate the URL host against an allow-list and reject RFC1918 / link-local / metadata (169.254.169.254) addresses before requesting.',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Koa path traversal: koa-send with a user-controlled path.
|
|
73
|
+
const KOA_PATH_RE = /\bsend\s*\(\s*ctx\s*,\s*ctx\s*\.\s*(?:query|params|request)\b/g;
|
|
74
|
+
while ((m = KOA_PATH_RE.exec(code))) emit('koa-path', lineOf(code, m.index), {
|
|
75
|
+
vuln: 'Path Traversal — koa-send with user-controlled path',
|
|
76
|
+
severity: 'high', cwe: 'CWE-22', family: 'path-traversal', confidence: 0.6,
|
|
77
|
+
remediation: 'Resolve against an allow-listed root and reject paths that escape it; koa-send `root` alone does not stop `..` in all versions. Use `path.basename` or a canonicalize+containment check.',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Koa reflected XSS: ctx.body assigned from ctx.query/params (unless escaped).
|
|
81
|
+
const KOA_XSS_RE = /ctx\s*\.\s*body\s*=\s*([^;\n]*\bctx\s*\.\s*(?:query|params|request)\b[^;\n]*)/g;
|
|
82
|
+
while ((m = KOA_XSS_RE.exec(code))) {
|
|
83
|
+
if (XSS_ESCAPE.test(m[1])) continue;
|
|
84
|
+
emit('koa-xss', lineOf(code, m.index), {
|
|
85
|
+
vuln: 'Reflected XSS — ctx.body built from user input without encoding (Koa)',
|
|
86
|
+
severity: 'high', cwe: 'CWE-79', family: 'xss', confidence: 0.65,
|
|
87
|
+
remediation: 'HTML-encode user input before placing it in the response body (e.g. `escape-html`), or render via a template engine with auto-escaping.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// XXE: libxmljs parse with entity expansion / DTD loading enabled
|
|
92
|
+
// (`noent: true`, `dtdload: true`, `replaceEntities: true`). These options
|
|
93
|
+
// turn an external-entity reference in attacker XML into file read / SSRF.
|
|
94
|
+
const XXE_RE = /\bparse(?:XmlString|Xml|FromString|XmlAsync)?\s*\([^)]*\b(?:noent|dtdload|dtdvalid|replaceEntities|external|expandEntities)\s*:\s*(?:true|1)\b/g;
|
|
95
|
+
while ((m = XXE_RE.exec(code))) emit('xxe', lineOf(code, m.index), {
|
|
96
|
+
vuln: 'XXE — XML parsed with entity expansion / DTD loading enabled (libxmljs)',
|
|
97
|
+
severity: 'high', cwe: 'CWE-611', family: 'xxe', confidence: 0.7,
|
|
98
|
+
remediation: 'Parse with entity expansion and DTD loading OFF — drop `noent`/`dtdload`/`replaceEntities` (they default to false). Untrusted XML with external entities enabled allows local file read and SSRF.',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Prototype pollution: a deep-merge/extend of req/ctx user input, unless the
|
|
102
|
+
// file filters __proto__/constructor/prototype keys.
|
|
103
|
+
const PROTO_RE = /\b(?:deepMerge|deepExtend|mergeDeep|defaultsDeep|mergeWith|_\.merge|merge|extend|assignIn)\s*\([^)\n]*\b(?:req|request|ctx)\s*\.\s*(?:body|query|params)\b/g;
|
|
104
|
+
const fileFiltersProto = PROTO_GUARD.test(code);
|
|
105
|
+
while ((m = PROTO_RE.exec(code))) {
|
|
106
|
+
if (fileFiltersProto) continue;
|
|
107
|
+
emit('proto', lineOf(code, m.index), {
|
|
108
|
+
vuln: 'Prototype Pollution — user input deep-merged without key filtering',
|
|
109
|
+
severity: 'critical', cwe: 'CWE-1321', family: 'prototype-pollution', confidence: 0.6,
|
|
110
|
+
remediation: 'Reject `__proto__`, `constructor`, and `prototype` keys before merging, or use a Map / Object.create(null) / structuredClone. Avoid merging request bodies into existing objects.',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return findings;
|
|
115
|
+
}
|
package/src/sast/kotlin.js
CHANGED
|
@@ -18,6 +18,15 @@ const RE = {
|
|
|
18
18
|
exec: /\bRuntime\.getRuntime\(\)\s*\.\s*exec\s*\(\s*[^)]*\b(?:request|req|input|params|userInput)\b/g,
|
|
19
19
|
gsonPolymorphic: /\bGson\(\)\s*\.\s*fromJson\s*\(\s*[^,)]+,\s*(?:Any::class|Object::class)/g,
|
|
20
20
|
fileReadText: /\bFile\s*\(\s*[^)]*\b(?:request|req|input|userInput|params)\b[^)]*\)\s*\.\s*read(?:Text|Bytes|Lines)/g,
|
|
21
|
+
// Structural (taint-independent) detectors: a dangerous sink built with a
|
|
22
|
+
// Kotlin string template (`${x}` / `$x`) or string concatenation (`"…" +`)
|
|
23
|
+
// is the injection shape regardless of the variable's name — parameterized
|
|
24
|
+
// queries / array-form exec / canonicalized paths never interpolate. A pure
|
|
25
|
+
// string literal (no template, no concat) does NOT match, so this stays
|
|
26
|
+
// high-precision and complements the taint engine (which needs a source).
|
|
27
|
+
sqlInjection: /\b(?:executeQuery|executeUpdate|prepareStatement|prepareCall|createQuery|createNativeQuery|nativeQuery|rawQuery)\s*\(\s*"[^"\n]*(?:\$\{?[A-Za-z_]|"\s*\+)/g,
|
|
28
|
+
cmdInjectionStructural: /\b(?:Runtime\.getRuntime\(\)\s*\.\s*exec|ProcessBuilder)\s*\(\s*(?:listOf\s*\(\s*)?"[^"\n]*(?:\$\{?[A-Za-z_]|"\s*\+)/g,
|
|
29
|
+
pathTraversalStructural: /\b(?:File|FileInputStream|FileReader|FileOutputStream|RandomAccessFile|Paths\.get)\s*\(\s*"[^"\n]*(?:\$\{?[A-Za-z_]|"\s*\+)/g,
|
|
21
30
|
};
|
|
22
31
|
|
|
23
32
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
@@ -66,6 +75,21 @@ export function scanKotlin(fp, raw) {
|
|
|
66
75
|
severity: 'high', cwe: 'CWE-22',
|
|
67
76
|
remediation: 'Canonicalize and verify the path is within an allowed base directory before reading: `if (!File(path).canonicalPath.startsWith(baseDir.canonicalPath)) throw ...`. Better: store files by content-hash filenames generated server-side and let the client request by hash, never by user-supplied path.',
|
|
68
77
|
},
|
|
78
|
+
sqlInjection: {
|
|
79
|
+
vuln: 'SQL Injection — query built with a Kotlin string template / concat (Kotlin)',
|
|
80
|
+
severity: 'critical', cwe: 'CWE-89',
|
|
81
|
+
remediation: 'Use a parameterized query: `prepareStatement("… WHERE name = ?")` then `setString(1, name)`. Never interpolate (`${…}`) or concatenate untrusted values into SQL.',
|
|
82
|
+
},
|
|
83
|
+
cmdInjectionStructural: {
|
|
84
|
+
vuln: 'Command Injection — exec built with a string template / concat (Kotlin)',
|
|
85
|
+
severity: 'critical', cwe: 'CWE-78',
|
|
86
|
+
remediation: 'Use `ProcessBuilder(listOf("cmd", arg1, arg2))` (array form) so no shell parses the string. Never concatenate or interpolate input into a command string.',
|
|
87
|
+
},
|
|
88
|
+
pathTraversalStructural: {
|
|
89
|
+
vuln: 'Path Traversal — file path built with a string template / concat (Kotlin)',
|
|
90
|
+
severity: 'high', cwe: 'CWE-22',
|
|
91
|
+
remediation: 'Canonicalize and assert the path stays within an allow-listed base dir before opening: `require(File(base, name).canonicalPath.startsWith(File(base).canonicalPath))`. Do not interpolate untrusted segments into a path.',
|
|
92
|
+
},
|
|
69
93
|
}[key];
|
|
70
94
|
push({
|
|
71
95
|
id: `kotlin-${key}:${fp}:${line}`,
|
|
@@ -78,5 +102,53 @@ export function scanKotlin(fp, raw) {
|
|
|
78
102
|
});
|
|
79
103
|
}
|
|
80
104
|
}
|
|
105
|
+
// XXE (CWE-611): an XML factory that parses without DTD/external-entity
|
|
106
|
+
// hardening. Flag only when no secure-processing config is present anywhere
|
|
107
|
+
// in the file (so the fixed form with setFeature(...) is not flagged).
|
|
108
|
+
const XXE_FACTORY = /\b(?:DocumentBuilderFactory|SAXParserFactory|XMLInputFactory|SAXReader|XMLReaderFactory|TransformerFactory)\b/;
|
|
109
|
+
const XXE_PARSE = /\.(?:parse|newDocumentBuilder|newSAXParser|createXMLStreamReader|read)\s*\(/;
|
|
110
|
+
const XXE_GUARD = /setFeature|disallow-doctype-decl|external-general-entities|external-parameter-entities|FEATURE_SECURE_PROCESSING|setExpandEntityReferences\s*\(\s*false|ACCESS_EXTERNAL_DTD|XMLConstants/i;
|
|
111
|
+
if (XXE_FACTORY.test(code) && XXE_PARSE.test(code) && !XXE_GUARD.test(code)) {
|
|
112
|
+
const idx = code.search(XXE_FACTORY);
|
|
113
|
+
const line = lineOf(code, idx);
|
|
114
|
+
push({
|
|
115
|
+
id: `kotlin-xxe:${fp}:${line}`, file: fp, line,
|
|
116
|
+
vuln: 'XXE — XML parser without DTD/external-entity hardening (Kotlin)',
|
|
117
|
+
severity: 'high', cwe: 'CWE-611',
|
|
118
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
119
|
+
remediation: 'Disable DTDs/external entities: factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) and external-general-entities=false, or set XMLConstants.FEATURE_SECURE_PROCESSING.',
|
|
120
|
+
parser: 'KOTLIN', confidence: 0.6,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// SSRF (CWE-918): URL/URI opened from a non-literal or templated value,
|
|
124
|
+
// unless a host-validation guard (allow/deny-list, RFC1918/metadata check)
|
|
125
|
+
// is present in the file — the fixed form keeps URL(var) but adds the check.
|
|
126
|
+
const SSRF_SINK = /\b(?:URL|URI|HttpURLConnection)\s*\(\s*(?:"[^"\n]*\$\{?[A-Za-z_]|[A-Za-z_]\w*\s*\))/;
|
|
127
|
+
const SSRF_GUARD = /\.host\b[^\n]*\b(?:in|!in)\b|allow(?:ed)?Hosts?|isLoopback|isSiteLocal|isLinkLocal|169\.254\.169\.254|require\s*\([^)]*\bhost\b|InetAddress|block(?:list|ed)|denylist/i;
|
|
128
|
+
if (SSRF_SINK.test(code) && !SSRF_GUARD.test(code)) {
|
|
129
|
+
const idx = code.search(SSRF_SINK);
|
|
130
|
+
const line = lineOf(code, idx);
|
|
131
|
+
push({
|
|
132
|
+
id: `kotlin-ssrf:${fp}:${line}`, file: fp, line,
|
|
133
|
+
vuln: 'SSRF — URL/URI opened from a non-literal or templated value (Kotlin)',
|
|
134
|
+
severity: 'high', cwe: 'CWE-918',
|
|
135
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
136
|
+
remediation: 'Resolve the host and reject RFC1918 / link-local / metadata (169.254.169.254) addresses before opening the connection, or restrict to an allow-list of hosts.',
|
|
137
|
+
parser: 'KOTLIN', confidence: 0.55,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// Insecure deserialization (CWE-502): native ObjectInputStream.readObject.
|
|
141
|
+
if (/\bObjectInputStream\s*\(/.test(code) && /\.\s*readObject\s*\(/.test(code)) {
|
|
142
|
+
const idx = code.search(/\bObjectInputStream\s*\(/);
|
|
143
|
+
const line = lineOf(code, idx);
|
|
144
|
+
push({
|
|
145
|
+
id: `kotlin-deser:${fp}:${line}`, file: fp, line,
|
|
146
|
+
vuln: 'Insecure Deserialization — ObjectInputStream.readObject on untrusted data (Kotlin)',
|
|
147
|
+
severity: 'high', cwe: 'CWE-502',
|
|
148
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
149
|
+
remediation: 'Never deserialize untrusted data with native Java/Kotlin serialization. Use a typed format (kotlinx.serialization, Jackson with a fixed type, protobuf); if unavoidable, install a strict ObjectInputFilter allow-list.',
|
|
150
|
+
parser: 'KOTLIN', confidence: 0.6,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
81
153
|
return findings;
|
|
82
154
|
}
|
|
@@ -12,33 +12,82 @@ import { blankComments } from './_comment-strip.js';
|
|
|
12
12
|
// - Python ldap3: conn.search(base, "(uid=" + name + ")")
|
|
13
13
|
// - Python python-ldap: conn.search_s(base, scope, "(uid=" + name + ")")
|
|
14
14
|
// - Python f-strings: conn.search_s(base, scope, f"(uid={name})")
|
|
15
|
+
// - PHP: ldap_search($ds, $base, "(uid=" . $u . ")")
|
|
16
|
+
// - Go (go-ldap): ldap.NewSearchRequest(base, ..., "(uid="+u+")", ...)
|
|
17
|
+
// - C# DirectorySearcher: ds.Filter = "(uid=" + u + ")" (also $"(uid={u})")
|
|
18
|
+
// - Ruby (net-ldap): conn.search(filter: "(uid=#{u})")
|
|
19
|
+
// - Kotlin JNDI: ctx.search(base, "(uid=" + u + ")")
|
|
15
20
|
//
|
|
16
21
|
// We require an LDAP context hint in the file (DirContext, javax.naming,
|
|
17
|
-
// ldap.initialize, ldapjs,
|
|
18
|
-
// concatenation in unrelated
|
|
22
|
+
// ldap.initialize, ldapjs, ldap_search, go-ldap, DirectorySearcher, Net::LDAP,
|
|
23
|
+
// etc.) so we don't fire on every `"foo=" + bar` concatenation in unrelated
|
|
24
|
+
// code.
|
|
19
25
|
|
|
26
|
+
// LDAP filter attributes that strongly imply an LDAP filter (not a generic
|
|
27
|
+
// key=value string). Shared across all language patterns.
|
|
28
|
+
const ATTR = '(?:uid|cn|mail|sAMAccountName|givenName|sn|memberOf|userPrincipalName|distinguishedName|ou)';
|
|
29
|
+
|
|
30
|
+
// Path A — concatenation/interpolation INSIDE (or adjacent to) the sink call.
|
|
31
|
+
// High-confidence; does not need the file-level context hint.
|
|
20
32
|
const FILTER_INLINE_RE = {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
33
|
+
js: new RegExp(String.raw`\bfilter\s*:\s*[` + '`' + String.raw`"']?\([^` + '`' + String.raw`"')]*\b` + ATTR + String.raw`\s*=\s*[` + '`' + String.raw`"']?\s*(?:\+|\$\{)`, 'g'),
|
|
34
|
+
java: new RegExp(String.raw`\.search(?:_s)?\s*\(\s*[^,]+,\s*"[^"]*\b` + ATTR + String.raw`\s*=[^"]*"\s*\+\s*\w+`, 'g'),
|
|
35
|
+
py: new RegExp(String.raw`\.(?:search|search_s|search_ext|paged_search)\s*\([^)]*\b` + ATTR + String.raw`\s*=[^)]*['"]?\s*\+\s*\w+`, 'g'),
|
|
36
|
+
php: new RegExp(String.raw`\bldap_(?:search|list|read)\s*\([^)]*\(\s*` + ATTR + String.raw`\s*=[^)]*["']\s*\.\s*\$`, 'g'),
|
|
37
|
+
go: new RegExp(String.raw`\b(?:NewSearchRequest|SearchRequest|Search)\s*\([^)]*"\(\s*` + ATTR + String.raw`\s*=[^"]*"\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()`, 'g'),
|
|
38
|
+
// C#: DirectorySearcher.Filter assigned a concat ("(uid=" + u) or an
|
|
39
|
+
// interpolated string ($"(uid={u})").
|
|
40
|
+
cs: new RegExp(String.raw`\bFilter\s*=\s*\$?@?"[^"]*\(\s*` + ATTR + String.raw`\s*=[^"]*(?:"\s*\+|\{)`, 'g'),
|
|
41
|
+
// Ruby net-ldap: a filter built with #{} interpolation inside a search/
|
|
42
|
+
// construct/filter call.
|
|
43
|
+
rb: new RegExp(String.raw`\.(?:search|filter|construct|equals)\s*\([^)]*\(\s*` + ATTR + String.raw`\s*=[^)]*#\{`, 'g'),
|
|
44
|
+
kt: new RegExp(String.raw`\.search\s*\(\s*[^,]+,\s*"[^"]*\b` + ATTR + String.raw`\s*=[^"]*"\s*\+\s*\w+`, 'g'),
|
|
25
45
|
};
|
|
26
46
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
// Path B — filter built in a variable then passed to the sink. Lower-
|
|
48
|
+
// confidence, so gated on the file-level LDAP hint. Per-language because the
|
|
49
|
+
// concat operator differs: `+` (js/java/py/go/cs/kt), `.` (php), `#{` (rb),
|
|
50
|
+
// and interpolation forms (`${`, `f"…{`, `$"…{`).
|
|
51
|
+
// `(?![\w.]*\s*\()` after the concat operand: a value immediately followed by `(`
|
|
52
|
+
// is a function CALL (e.g. `ldap.EscapeFilter(u)`, `escape_filter_chars(u)`),
|
|
53
|
+
// which is the *escaped* (safe) form — must not match.
|
|
54
|
+
const FILTER_VAR_RE = {
|
|
55
|
+
js: new RegExp(String.raw`["'` + '`' + String.raw`]\s*\(\s*` + ATTR + String.raw`\s*=\s*["'` + '`' + String.raw`]?\s*(?:\+|\$\{)\s*[A-Za-z_$][\w.]*(?![\w.]*\s*\()`, 'g'),
|
|
56
|
+
java: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()`, 'g'),
|
|
57
|
+
py: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()|[fF]["']\s*\(\s*` + ATTR + String.raw`\s*=\s*\{`, 'g'),
|
|
58
|
+
php: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\.\s*\$[A-Za-z_]\w*`, 'g'),
|
|
59
|
+
go: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()`, 'g'),
|
|
60
|
+
cs: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()|\$"[^"]*\(\s*` + ATTR + String.raw`\s*=\s*\{`, 'g'),
|
|
61
|
+
rb: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=[^"']*#\{`, 'g'),
|
|
62
|
+
kt: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*(?:\+|\$\{)\s*[A-Za-z_$][\w.]*(?![\w.]*\s*\()`, 'g'),
|
|
63
|
+
};
|
|
31
64
|
|
|
32
65
|
// LDAP context hint: at least one of these must be in the file before we
|
|
33
66
|
// trust the variable-form heuristic.
|
|
34
67
|
const LDAP_HINT_RE =
|
|
35
|
-
/\b(?:DirContext|javax\.naming|ldap\.initialize|ldap3|ldapjs|LdapContext|InitialDirContext|SearchResult|conn\.search|client\.search|\.search_s|getLdapTemplate)\b/;
|
|
68
|
+
/\b(?:DirContext|javax\.naming|ldap\.initialize|ldap3|ldapjs|LdapContext|InitialDirContext|SearchResult|conn\.search|client\.search|\.search_s|getLdapTemplate|ldap_search|ldap_list|ldap_read|ldap_connect|ldap_bind|go-ldap|NewSearchRequest|DirectorySearcher|DirectoryEntry|System\.DirectoryServices|Net::LDAP|net\/ldap)\b/;
|
|
69
|
+
|
|
70
|
+
// An LDAP filter-escape API applied in the file. When the value reaching a
|
|
71
|
+
// filter is escaped, the metacharacter-injection risk is removed. We can't see
|
|
72
|
+
// which variable on the sink line was escaped (escape-then-use spans lines —
|
|
73
|
+
// e.g. PHP `$uid = ldap_escape(...); ... "(uid=" . $uid . ")"`), and the
|
|
74
|
+
// inline call-guard only catches escape applied AT the concat. So when an
|
|
75
|
+
// escape API is present in the file we suppress the lower-confidence finding —
|
|
76
|
+
// matching the file-level guard-recognition style used elsewhere. Anchored on
|
|
77
|
+
// real escape APIs so it doesn't over-suppress.
|
|
78
|
+
const LDAP_ESCAPE_RE =
|
|
79
|
+
/\b(?:ldap_escape|EscapeFilter|escape_filter_chars|escapeForLDAP|encodeForLDAP|escapeLDAPSearchFilter|LDAP_ESCAPE_FILTER)\b|\bNet::LDAP::Filter\b|\bEqualityFilter\b|\bfilters\.\w+\b/;
|
|
36
80
|
|
|
37
81
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
38
82
|
function _lang(fp) {
|
|
39
83
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) return 'js';
|
|
40
84
|
if (/\.java$/i.test(fp)) return 'java';
|
|
41
85
|
if (/\.py$/i.test(fp)) return 'py';
|
|
86
|
+
if (/\.(?:php|phtml)$/i.test(fp)) return 'php';
|
|
87
|
+
if (/\.go$/i.test(fp)) return 'go';
|
|
88
|
+
if (/\.cs$/i.test(fp)) return 'cs';
|
|
89
|
+
if (/\.rb$/i.test(fp)) return 'rb';
|
|
90
|
+
if (/\.kt$/i.test(fp)) return 'kt';
|
|
42
91
|
return null;
|
|
43
92
|
}
|
|
44
93
|
|
|
@@ -56,7 +105,10 @@ function _emit(fp, raw, line, why) {
|
|
|
56
105
|
'Node ldapjs: `new EqualityFilter({ attribute: "uid", value: name })`. ' +
|
|
57
106
|
'Java JNDI: bind via search filter args — `ctx.search(base, "(uid={0})", new Object[]{ name }, controls)`. ' +
|
|
58
107
|
'Python python-ldap: `ldap.filter.escape_filter_chars(name)`. ' +
|
|
59
|
-
'
|
|
108
|
+
'PHP: `ldap_escape($name, "", LDAP_ESCAPE_FILTER)`. ' +
|
|
109
|
+
'Go go-ldap: `ldap.EscapeFilter(name)`. ' +
|
|
110
|
+
'C#: set `DirectorySearcher` with an escaped value or use parameterized binding. ' +
|
|
111
|
+
'Ruby net-ldap: `Net::LDAP::Filter.eq("uid", name)` instead of interpolating.',
|
|
60
112
|
parser: 'LDAP-INJECTION',
|
|
61
113
|
confidence: 0.85,
|
|
62
114
|
};
|
|
@@ -66,12 +118,16 @@ export function scanLDAPInjection(fp, raw) {
|
|
|
66
118
|
if (!raw || raw.length > 500_000) return [];
|
|
67
119
|
const lang = _lang(fp);
|
|
68
120
|
if (!lang) return [];
|
|
69
|
-
const code = blankComments(raw, lang === 'py' ? 'py' : undefined);
|
|
121
|
+
const code = blankComments(raw, (lang === 'py' || lang === 'rb') ? 'py' : undefined);
|
|
70
122
|
const findings = [];
|
|
71
123
|
const seen = new Set();
|
|
124
|
+
// Escape-then-use: if the file applies an LDAP escape API, the filter value
|
|
125
|
+
// is sanitized before substitution — suppress (recall-safe per the corpus
|
|
126
|
+
// post/ pairs, which are the invariant proving this doesn't drop a real TP).
|
|
127
|
+
const escaped = LDAP_ESCAPE_RE.test(code);
|
|
72
128
|
// Path A — concatenation inside the .search call. High-confidence,
|
|
73
|
-
// doesn't need the context hint.
|
|
74
|
-
{
|
|
129
|
+
// doesn't need the context hint (but still skip when the file escapes).
|
|
130
|
+
if (!escaped) {
|
|
75
131
|
const re = new RegExp(FILTER_INLINE_RE[lang].source, FILTER_INLINE_RE[lang].flags);
|
|
76
132
|
let m;
|
|
77
133
|
while ((m = re.exec(code))) {
|
|
@@ -85,8 +141,8 @@ export function scanLDAPInjection(fp, raw) {
|
|
|
85
141
|
// Path B — filter built into a variable then passed downstream. Lower-
|
|
86
142
|
// confidence so we gate on a file-level LDAP hint to suppress unrelated
|
|
87
143
|
// string concatenations.
|
|
88
|
-
if (LDAP_HINT_RE.test(code)) {
|
|
89
|
-
const re = new RegExp(FILTER_VAR_RE.source, FILTER_VAR_RE.flags);
|
|
144
|
+
if (!escaped && LDAP_HINT_RE.test(code)) {
|
|
145
|
+
const re = new RegExp(FILTER_VAR_RE[lang].source, FILTER_VAR_RE[lang].flags);
|
|
90
146
|
let m;
|
|
91
147
|
while ((m = re.exec(code))) {
|
|
92
148
|
const line = lineOf(raw, m.index);
|
|
@@ -33,11 +33,20 @@ const PATTERNS = [
|
|
|
33
33
|
['java', /\bnew\s+RedirectView\s*\(\s*(\w[\w.]*)\s*\)/g, 'Spring RedirectView'],
|
|
34
34
|
// PHP header("Location: " . $...)
|
|
35
35
|
['php', /\bheader\s*\(\s*['"]\s*Location\s*:\s*['"]\s*\.\s*(\$\w[\w\[\]'"]*)/g, 'PHP Location'],
|
|
36
|
+
// Go net/http: http.Redirect(w, r, <target>, code) — target is the 3rd arg
|
|
37
|
+
// (may contain calls/parens); status is a digit or an http.Status* constant.
|
|
38
|
+
['go', /\bhttp\.Redirect\s*\(\s*[^,]+,\s*[^,]+,\s*([\s\S]+?)\s*,\s*(?:\d|http\.Status)/g, 'Go net/http'],
|
|
39
|
+
// Ruby on Rails: redirect_to <target> (params/var).
|
|
40
|
+
['rb', /\bredirect_to\s+(?!.*\bonly_path:\s*true)([^\n,]+?)(?:\s+status:|\s*$|\n)/gm, 'Rails redirect_to'],
|
|
41
|
+
// C#: Response.Redirect(<target>) / return Redirect(<target>) / RedirectPermanent.
|
|
42
|
+
['cs', /\b(?:Response\.Redirect|return\s+Redirect|return\s+RedirectPermanent|RedirectPermanent)\s*\(\s*([^)]+?)\s*\)/g, 'ASP.NET Redirect'],
|
|
43
|
+
// Kotlin/Servlet: resp.sendRedirect(<target>).
|
|
44
|
+
['kt', /\.\s*sendRedirect\s*\(\s*([^)]+?)\s*\)/g, 'Servlet sendRedirect'],
|
|
36
45
|
];
|
|
37
46
|
|
|
38
47
|
// What counts as "user-derived" inside the captured target expression.
|
|
39
48
|
const TAINT_HINT_RE =
|
|
40
|
-
/\b(?:req\.|request\.|params\.|query\.|body\.|ctx\.query|ctx\.request|ctx\.params|reply\.query|r\.URL\.Query|c\.Query|next\b|_GET|_POST|_REQUEST|getParameter|getHeader)\b/;
|
|
49
|
+
/\b(?:req\.|request\.|params\.|params\s*\[|query\.|body\.|ctx\.query|ctx\.request|ctx\.params|reply\.query|r\.URL\.Query|c\.Query|c\.Param|next\b|_GET|_POST|_REQUEST|getParameter|getHeader|Request\.(?:Query|Form|Params|QueryString))\b|\b(?:params|query|cookies)\s*\[/;
|
|
41
50
|
|
|
42
51
|
// What counts as an allow-list check earlier in the function. We look back
|
|
43
52
|
// up to 30 lines before the redirect call for any of these patterns.
|
|
@@ -54,6 +63,12 @@ const ALLOWLIST_PATTERNS = [
|
|
|
54
63
|
/allowedRedirectTargets/i,
|
|
55
64
|
/\babort\s*\(\s*4\d\d/, // any abort(4xx) earlier
|
|
56
65
|
/\bres\s*\.\s*status\s*\(\s*4\d\d\b/,
|
|
66
|
+
/\bin_array\s*\(/i, // PHP in_array(...) allow-list check
|
|
67
|
+
/\bonly_path:\s*true/, // Rails redirect_to …, only_path: true
|
|
68
|
+
/\bUrl\.IsLocalUrl\b|\bLocalRedirect\b/, // ASP.NET local-only redirect
|
|
69
|
+
/\bin\s+(?:allowed|allow|ALLOWED)\b/, // Kotlin/Ruby `next in allowed`
|
|
70
|
+
/\bstrings\.HasPrefix\s*\(\s*\w+\s*,\s*"\/"/, // Go relative-path check
|
|
71
|
+
/\bset(?:Of)?\s*\([^)]*\)\.contains\b/, // Kotlin allowed.contains(next)
|
|
57
72
|
];
|
|
58
73
|
|
|
59
74
|
function _lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
@@ -61,10 +76,34 @@ function _lang(fp) {
|
|
|
61
76
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) return 'js';
|
|
62
77
|
if (/\.py$/i.test(fp)) return 'py';
|
|
63
78
|
if (/\.java$/i.test(fp)) return 'java';
|
|
64
|
-
if (/\.php$/i.test(fp)) return 'php';
|
|
79
|
+
if (/\.(?:php|phtml)$/i.test(fp)) return 'php';
|
|
80
|
+
if (/\.go$/i.test(fp)) return 'go';
|
|
81
|
+
if (/\.rb$/i.test(fp)) return 'rb';
|
|
82
|
+
if (/\.cs$/i.test(fp)) return 'cs';
|
|
83
|
+
if (/\.kt$/i.test(fp)) return 'kt';
|
|
65
84
|
return null;
|
|
66
85
|
}
|
|
67
86
|
|
|
87
|
+
// For C#/Kotlin/Go a bare-identifier redirect target is user-derived when it is
|
|
88
|
+
// a parameter of the enclosing method/handler (the single-file handler shape).
|
|
89
|
+
const _PARAM_LANGS = new Set(['cs', 'kt', 'go']);
|
|
90
|
+
function _isEnclosingParam(code, callIdx, target) {
|
|
91
|
+
if (!/^[A-Za-z_]\w*$/.test(target)) return false;
|
|
92
|
+
const before = code.slice(0, callIdx);
|
|
93
|
+
const sigRe = /\b(?:fun\s+\w+|func\s+\w*|[A-Za-z_][\w<>\[\],.\s]*\b\w+)\s*\(([^)]*)\)\s*(?::[^={]+|[A-Za-z_][\w.<>\[\]* ]*)?\{/g;
|
|
94
|
+
let sig = null, sm;
|
|
95
|
+
while ((sm = sigRe.exec(before)) !== null) sig = sm;
|
|
96
|
+
if (!sig) return false;
|
|
97
|
+
const params = sig[1].split(',').map((p) => {
|
|
98
|
+
const t = p.trim(); if (!t) return '';
|
|
99
|
+
if (t.includes(':')) return t.split(':')[0].trim(); // Kotlin name: Type
|
|
100
|
+
return (t.split(/\s+/)[t.includes(' ') ? (t.trim().split(/\s+/).length - 1) : 0] || '').trim();
|
|
101
|
+
});
|
|
102
|
+
// Go params are `name Type` (name first); JVM/C# are `Type name` (name last).
|
|
103
|
+
const goParams = sig[1].split(',').map((p) => (p.trim().split(/\s+/)[0] || '').trim());
|
|
104
|
+
return params.includes(target) || goParams.includes(target);
|
|
105
|
+
}
|
|
106
|
+
|
|
68
107
|
function _allowListedPrior(raw, callLine, target) {
|
|
69
108
|
const lines = raw.split('\n');
|
|
70
109
|
const lo = Math.max(0, callLine - 30);
|
|
@@ -79,8 +118,8 @@ export function scanOpenRedirect(fp, raw) {
|
|
|
79
118
|
if (!raw || raw.length > 500_000) return [];
|
|
80
119
|
const lang = _lang(fp);
|
|
81
120
|
if (!lang) return [];
|
|
82
|
-
const code = blankComments(raw, lang === 'py' ? 'py' : undefined);
|
|
83
|
-
if (!/\bredirect
|
|
121
|
+
const code = blankComments(raw, (lang === 'py' || lang === 'rb') ? 'py' : undefined);
|
|
122
|
+
if (!/\bredirect|RedirectView|HttpResponseRedirect|Location\s*:|sendRedirect|http\.Redirect/i.test(code)) return [];
|
|
84
123
|
const findings = [];
|
|
85
124
|
const seen = new Set();
|
|
86
125
|
for (const [plang, pat, framework] of PATTERNS) {
|
|
@@ -90,7 +129,10 @@ export function scanOpenRedirect(fp, raw) {
|
|
|
90
129
|
while ((m = re.exec(code))) {
|
|
91
130
|
const target = (m[1] || '').trim();
|
|
92
131
|
if (!target) continue;
|
|
93
|
-
|
|
132
|
+
// Literal-string target ("/home", "https://fixed") is never user-derived.
|
|
133
|
+
if (/^["'`][^"'`]*["'`]$/.test(target)) continue;
|
|
134
|
+
if (!TAINT_HINT_RE.test(target) &&
|
|
135
|
+
!(_PARAM_LANGS.has(plang) && _isEnclosingParam(code, m.index, target))) continue;
|
|
94
136
|
const line = _lineOf(raw, m.index);
|
|
95
137
|
// Suppress if an allow-list check appears in the preceding window.
|
|
96
138
|
if (_allowListedPrior(raw, line, target)) continue;
|
package/src/sast/php.js
CHANGED
|
@@ -18,6 +18,21 @@ const RE = {
|
|
|
18
18
|
extractRequest: /\bextract\s*\(\s*\$(?:_REQUEST|_GET|_POST|HTTP_GET_VARS|HTTP_POST_VARS)\s*[,)]/g,
|
|
19
19
|
passwordHashMd5: /\b(?:md5|sha1)\s*\(\s*\$(?:_(?:REQUEST|GET|POST)|password|passwd|pwd|hash_input)/gi,
|
|
20
20
|
phpinfo: /\bphpinfo\s*\(/g,
|
|
21
|
+
// Structural (taint-independent): a shell or SQL sink built with string
|
|
22
|
+
// concat (`'…' .`) or double-quote `$var` interpolation is the injection
|
|
23
|
+
// shape regardless of the source token — the rules above require the literal
|
|
24
|
+
// $_REQUEST/$_GET/$_POST on the sink line, which misses framework idioms
|
|
25
|
+
// (`$f = $r->query->get('x'); shell_exec('gzip ' . $f)`).
|
|
26
|
+
cmdInjectionStructural: /\b(?:shell_exec|exec|system|passthru|popen|proc_open|pcntl_exec)\s*\(\s*(?:"[^"\n]*\$|'[^'\n]*'\s*\.|"[^"\n]*"\s*\.)/g,
|
|
27
|
+
sqlInjectionStructural: /\b(?:DB::raw|DB::select|DB::statement|DB::insert|DB::update|DB::delete|whereRaw|orWhereRaw|havingRaw|selectRaw|orderByRaw|mysqli_query|mysql_query|pg_query|pg_send_query|sqlite_query|->query|->rawQuery)\s*\(\s*(?:DB::raw\s*\(\s*)?(?:"[^"\n]*\$|"[^"\n]*"\s*\.|'[^'\n]*'\s*\.)/g,
|
|
28
|
+
// Structural path traversal: a filesystem read/write built by concatenating
|
|
29
|
+
// or interpolating into the path. Containment-guarded forms (basename) are
|
|
30
|
+
// dropped centrally by engine.js dropGuardedFindings (CWE-22).
|
|
31
|
+
pathTraversalStructural: /\b(?:readfile|file_get_contents|file_put_contents|fopen|fpassthru|fgets|file|copy|unlink|highlight_file|show_source)\s*\(\s*(?:"[^"\n]*"\s*\.|'[^'\n]*'\s*\.|"[^"\n]*\$(?:_(?:REQUEST|GET|POST|COOKIE)|[A-Za-z_]))/g,
|
|
32
|
+
// SSRF: a network fetch whose URL is user-controlled. cURL is unambiguously
|
|
33
|
+
// network; file_get_contents/fopen with a $_GET/$_POST value direct (not a
|
|
34
|
+
// path-base concat) is the PHP fopen-wrapper SSRF (http:// / ftp:// / phar://).
|
|
35
|
+
ssrfFetch: /\bcurl_init\s*\(\s*\$(?:_(?:REQUEST|GET|POST|COOKIE)|\w*url\w*)|\bcurl_setopt\s*\([^,]+,\s*CURLOPT_URL\s*,\s*\$(?:_(?:REQUEST|GET|POST|COOKIE)|\w*url\w*)|\b(?:file_get_contents|fopen)\s*\(\s*\$_(?:REQUEST|GET|POST|COOKIE)\b/g,
|
|
21
36
|
};
|
|
22
37
|
|
|
23
38
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
@@ -75,6 +90,26 @@ export function scanPhp(fp, raw) {
|
|
|
75
90
|
severity: 'high', cwe: 'CWE-200',
|
|
76
91
|
remediation: 'Delete phpinfo() before deploy. It leaks the PHP version, loaded extensions, environment variables, document root, and request headers — a one-shot recon page for an attacker.',
|
|
77
92
|
},
|
|
93
|
+
cmdInjectionStructural: {
|
|
94
|
+
vuln: 'Command Injection: shell function built with string concat / interpolation',
|
|
95
|
+
severity: 'critical', cwe: 'CWE-78',
|
|
96
|
+
remediation: 'Use the array form of proc_open(["gzip", $f], ...) so no shell parses the string, and validate inputs. Never concatenate or interpolate values into shell_exec/exec/system/passthru.',
|
|
97
|
+
},
|
|
98
|
+
sqlInjectionStructural: {
|
|
99
|
+
vuln: 'SQL Injection: raw query built with string concat / interpolation',
|
|
100
|
+
severity: 'critical', cwe: 'CWE-89',
|
|
101
|
+
remediation: 'Use parameter bindings: DB::select("SELECT … WHERE name = ?", [$name]) or the query builder with bindings. Never concatenate/interpolate into DB::raw / whereRaw / mysqli_query.',
|
|
102
|
+
},
|
|
103
|
+
pathTraversalStructural: {
|
|
104
|
+
vuln: 'Path Traversal: filesystem operation built with concatenated/interpolated input',
|
|
105
|
+
severity: 'high', cwe: 'CWE-22', family: 'path-traversal',
|
|
106
|
+
remediation: 'Strip directory components with basename() and resolve against a fixed root: $full = realpath($base . "/" . basename($name)); if (strpos($full, $base) !== 0) abort(). Never concatenate request input straight into a file path.',
|
|
107
|
+
},
|
|
108
|
+
ssrfFetch: {
|
|
109
|
+
vuln: 'SSRF: network fetch (cURL / fopen-wrapper) to a user-controlled URL',
|
|
110
|
+
severity: 'high', cwe: 'CWE-918', family: 'ssrf',
|
|
111
|
+
remediation: 'Validate the URL host against an allow-list and reject RFC1918 / link-local / metadata (169.254.169.254) addresses before fetching. Disable fopen URL wrappers (allow_url_fopen=0) and only allow https:// to known hosts.',
|
|
112
|
+
},
|
|
78
113
|
}[key];
|
|
79
114
|
push({
|
|
80
115
|
id: `php-${key}:${fp}:${line}`,
|