@clear-capabilities/agentic-security-scanner 0.86.0 → 0.118.2
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 +404 -0
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/agentic-security.mjs +84 -84
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +12 -6
- package/src/.agentic-security/dpia.md +26 -0
- package/src/.agentic-security/pqc-migration-plan.json +80 -0
- package/src/.agentic-security/pqc-migration-plan.md +32 -0
- package/src/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +6 -0
- package/src/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +6 -0
- package/src/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
- package/src/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +6 -0
- package/src/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +6 -0
- package/src/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +6 -0
- package/src/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +6 -0
- package/src/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
- package/src/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +6 -0
- package/src/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +6 -0
- package/src/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +6 -0
- package/src/.agentic-security/threat-model.json +3702 -0
- package/src/.agentic-security/threat-model.md +118 -0
- package/src/dataflow/.agentic-security/dpia.md +26 -0
- package/src/dataflow/.agentic-security/sbom-history/15d7c4d20f9d968ebec29445c9e9b3fc6f74e6a1.json +6 -0
- package/src/dataflow/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
- package/src/dataflow/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +6 -0
- package/src/dataflow/.agentic-security/sbom-history/8869fe64035bec0152d92ece961e9369d8637076.json +6 -0
- package/src/dataflow/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +6 -0
- package/src/dataflow/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +6 -0
- package/src/dataflow/.agentic-security/threat-model.json +161 -0
- package/src/dataflow/.agentic-security/threat-model.md +43 -0
- package/src/dataflow/CLAUDE.md +17 -5
- package/src/dataflow/catalog.js +9 -0
- package/src/dataflow/engine.js +30 -4
- package/src/dataflow/index.js +8 -0
- package/src/dataflow/proof-gate.js +95 -0
- package/src/dataflow/summaries.js +44 -5
- package/src/engine.js +199 -5
- package/src/ir/.agentic-security/dpia.md +26 -0
- package/src/ir/.agentic-security/exploit-bundles.json +77 -0
- package/src/ir/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +6 -0
- package/src/ir/.agentic-security/sbom-history/b5f468a158f6a145aedd8680841c106b9809f16b.json +6 -0
- package/src/ir/.agentic-security/threat-model.json +143 -0
- package/src/ir/.agentic-security/threat-model.md +39 -0
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/callgraph.js +12 -1
- package/src/ir/tree-sitter-loader.js +108 -0
- package/src/posture/.agentic-security/dpia.md +1 -1
- package/src/posture/.agentic-security/pqc-migration-plan.json +1 -1
- package/src/posture/.agentic-security/pqc-migration-plan.md +1 -1
- package/src/posture/.agentic-security/sbom-history/0724c133d2943c87555695b5b2adcfcfc3373a2d.json +6 -0
- package/src/posture/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
- package/src/posture/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +6 -0
- package/src/posture/.agentic-security/threat-model.json +3 -3
- package/src/posture/.agentic-security/threat-model.md +1 -1
- package/src/posture/corpus-status.js +74 -0
- package/src/posture/coverage-report.js +113 -0
- package/src/posture/holdout-eval.js +57 -0
- package/src/posture/router.js +33 -3
- package/src/report/.agentic-security/dpia.md +26 -0
- package/src/report/.agentic-security/sbom-history/f94be839aa6a1011f53da016416a561698b8f338.json +6 -0
- package/src/report/.agentic-security/threat-model.md +1 -1
- package/src/report/index.js +17 -0
- package/src/sast/.agentic-security/dpia.md +26 -0
- package/src/sast/.agentic-security/pqc-migration-plan.json +65 -0
- package/src/sast/.agentic-security/pqc-migration-plan.md +30 -0
- package/src/sast/.agentic-security/sbom-history/043a7b401c68f5a298c83d4c42e94f118ff2a89a.json +6 -0
- package/src/sast/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +6 -0
- package/src/sast/.agentic-security/sbom-history/20d77ad7363a82777d506dedbbb1ecfed52f094a.json +6 -0
- package/src/sast/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +6 -0
- package/src/sast/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
- package/src/sast/.agentic-security/sbom-history/3c6b3205f04e26f7654943d465281d00e0fa395e.json +6 -0
- package/src/sast/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +6 -0
- package/src/sast/.agentic-security/sbom-history/5282502e090013219eb006f7e02d8c983bc29c8e.json +6 -0
- package/src/sast/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +6 -0
- package/src/sast/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +6 -0
- package/src/sast/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +6 -0
- package/src/sast/.agentic-security/sbom-history/73514b485d4770f3c5ee57d87338bccd1c61d8b7.json +6 -0
- package/src/sast/.agentic-security/sbom-history/96fa584fc3134512237d58aac63d6f3778b0cbcb.json +6 -0
- package/src/sast/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
- package/src/sast/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +6 -0
- package/src/sast/.agentic-security/sbom-history/a4a34ab8b3d758de2adc8c3dc3e42d4cb0db00a3.json +6 -0
- package/src/sast/.agentic-security/sbom-history/ac236ec3602cfc8fb133f389ec3687e409084673.json +6 -0
- package/src/sast/.agentic-security/sbom-history/b57b3e1292f1f9f0de6c5a34a436e330c78008a3.json +6 -0
- package/src/sast/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +6 -0
- package/src/sast/.agentic-security/sbom-history/dcbfd4935922dd165093c08a1003c338eed937c5.json +6 -0
- package/src/sast/.agentic-security/sbom-history/fa017f725615b0293814344c8ef7dfeb06775ca0.json +6 -0
- package/src/sast/.agentic-security/threat-model.json +816 -0
- package/src/sast/.agentic-security/threat-model.md +91 -0
- package/src/sast/CLAUDE.md +9 -3
- package/src/sast/code-injection-multilang.js +117 -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/frontend-hygiene.js +106 -0
- package/src/sast/go-structural.js +60 -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/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 +94 -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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Front-end security hygiene — additive, high-precision client-side checks
|
|
2
|
+
// that the engine did not previously cover. None of these change existing
|
|
3
|
+
// findings; each emits its own finding class.
|
|
4
|
+
//
|
|
5
|
+
// 1. Reverse tabnabbing (CWE-1022): `<a target="_blank">` without
|
|
6
|
+
// `rel="noopener"` — the opened page can rewrite `window.opener.location`
|
|
7
|
+
// and phish. (Modern browsers default to noopener, so: low.)
|
|
8
|
+
// 2. Missing Subresource Integrity (CWE-353): a cross-origin
|
|
9
|
+
// `<script src="https://cdn…">` / `<link rel=stylesheet href="//…">`
|
|
10
|
+
// with no `integrity=` — a compromised/MITM'd CDN executes in your origin.
|
|
11
|
+
// 3. Angular sanitizer bypass (CWE-79): `DomSanitizer.bypassSecurityTrust*`
|
|
12
|
+
// with a non-literal argument explicitly disables Angular's built-in XSS
|
|
13
|
+
// protection on attacker-influenced data.
|
|
14
|
+
|
|
15
|
+
import { blankComments } from './_comment-strip.js';
|
|
16
|
+
|
|
17
|
+
const MARKUP_RE = /\.(?:html?|jsx|tsx|vue|svelte|php|erb|ejs|hbs|handlebars|astro)$/i;
|
|
18
|
+
const SCRIPT_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/i;
|
|
19
|
+
|
|
20
|
+
const A_BLANK_RE = /<a\b[^>]*\btarget\s*=\s*["']_blank["'][^>]*>/gi;
|
|
21
|
+
const REL_NOOPENER = /\brel\s*=\s*["'][^"']*\bno(?:opener|referrer)\b/i;
|
|
22
|
+
|
|
23
|
+
const EXT_SCRIPT_RE = /<script\b[^>]*\bsrc\s*=\s*["'](?:https?:)?\/\/[^"']+["'][^>]*>/gi;
|
|
24
|
+
const EXT_LINK_RE = /<link\b[^>]*\bhref\s*=\s*["'](?:https?:)?\/\/[^"']+["'][^>]*>/gi;
|
|
25
|
+
const HAS_INTEGRITY = /\bintegrity\s*=/i;
|
|
26
|
+
const IS_STYLESHEET = /\brel\s*=\s*["'][^"']*\bstylesheet\b/i;
|
|
27
|
+
|
|
28
|
+
const NG_BYPASS_RE = /\.bypassSecurityTrust(?:Html|Script|Style|Url|ResourceUrl)\s*\(\s*([^)]*?)\)/g;
|
|
29
|
+
// A pure string literal argument is a developer-controlled constant — safe.
|
|
30
|
+
const PURE_LITERAL = /^\s*(['"`])(?:\\.|(?!\1).)*\1\s*$/;
|
|
31
|
+
|
|
32
|
+
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
33
|
+
|
|
34
|
+
export function scanFrontendHygiene(fp, raw) {
|
|
35
|
+
if (!raw || raw.length > 500_000) return [];
|
|
36
|
+
const isMarkup = MARKUP_RE.test(fp);
|
|
37
|
+
const isScript = SCRIPT_RE.test(fp);
|
|
38
|
+
if (!isMarkup && !isScript) return [];
|
|
39
|
+
|
|
40
|
+
const findings = [];
|
|
41
|
+
|
|
42
|
+
if (isMarkup) {
|
|
43
|
+
// 1. Reverse tabnabbing.
|
|
44
|
+
A_BLANK_RE.lastIndex = 0;
|
|
45
|
+
let m;
|
|
46
|
+
while ((m = A_BLANK_RE.exec(raw))) {
|
|
47
|
+
if (REL_NOOPENER.test(m[0])) continue;
|
|
48
|
+
findings.push({
|
|
49
|
+
id: `tabnabbing:${fp}:${lineOf(raw, m.index)}`,
|
|
50
|
+
severity: 'low', file: fp, line: lineOf(raw, m.index),
|
|
51
|
+
vuln: 'Reverse tabnabbing (target="_blank" without rel="noopener")',
|
|
52
|
+
cwe: 'CWE-1022', family: 'client-side', parser: 'SAST',
|
|
53
|
+
description: 'A link opens a new tab with target="_blank" but no rel="noopener". The destination page receives a reference to window.opener and can redirect this tab to a phishing page.',
|
|
54
|
+
remediation: 'Add rel="noopener noreferrer" (or rel="noopener") to every target="_blank" link.',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// 2. Missing SRI on cross-origin <script>.
|
|
58
|
+
EXT_SCRIPT_RE.lastIndex = 0;
|
|
59
|
+
while ((m = EXT_SCRIPT_RE.exec(raw))) {
|
|
60
|
+
if (HAS_INTEGRITY.test(m[0])) continue;
|
|
61
|
+
findings.push({
|
|
62
|
+
id: `missing-sri-script:${fp}:${lineOf(raw, m.index)}`,
|
|
63
|
+
severity: 'medium', file: fp, line: lineOf(raw, m.index),
|
|
64
|
+
vuln: 'Missing Subresource Integrity on cross-origin <script>',
|
|
65
|
+
cwe: 'CWE-353', family: 'supply-chain', parser: 'SAST',
|
|
66
|
+
description: 'A script is loaded from a third-party origin without an integrity= hash. If that host (or a CDN in front of it) is compromised or MITM\'d, arbitrary code runs in your origin.',
|
|
67
|
+
remediation: 'Add an integrity="sha384-…" attribute and crossorigin="anonymous", or self-host the asset.',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// 2b. Missing SRI on cross-origin stylesheet <link>.
|
|
71
|
+
EXT_LINK_RE.lastIndex = 0;
|
|
72
|
+
while ((m = EXT_LINK_RE.exec(raw))) {
|
|
73
|
+
if (!IS_STYLESHEET.test(m[0])) continue;
|
|
74
|
+
if (HAS_INTEGRITY.test(m[0])) continue;
|
|
75
|
+
findings.push({
|
|
76
|
+
id: `missing-sri-link:${fp}:${lineOf(raw, m.index)}`,
|
|
77
|
+
severity: 'low', file: fp, line: lineOf(raw, m.index),
|
|
78
|
+
vuln: 'Missing Subresource Integrity on cross-origin stylesheet',
|
|
79
|
+
cwe: 'CWE-353', family: 'supply-chain', parser: 'SAST',
|
|
80
|
+
description: 'A stylesheet is loaded from a third-party origin without an integrity= hash, so a compromised CDN can inject CSS-based exfiltration / UI redress.',
|
|
81
|
+
remediation: 'Add integrity="sha384-…" + crossorigin="anonymous", or self-host the stylesheet.',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isScript) {
|
|
87
|
+
// 3. Angular sanitizer bypass with a non-literal argument.
|
|
88
|
+
const code = blankComments(raw);
|
|
89
|
+
NG_BYPASS_RE.lastIndex = 0;
|
|
90
|
+
let m;
|
|
91
|
+
while ((m = NG_BYPASS_RE.exec(code))) {
|
|
92
|
+
const arg = (m[1] || '').trim();
|
|
93
|
+
if (!arg || PURE_LITERAL.test(arg)) continue; // constant → safe
|
|
94
|
+
findings.push({
|
|
95
|
+
id: `ng-sanitizer-bypass:${fp}:${lineOf(code, m.index)}`,
|
|
96
|
+
severity: 'high', file: fp, line: lineOf(code, m.index),
|
|
97
|
+
vuln: 'Angular sanitizer bypass on dynamic value (bypassSecurityTrust*)',
|
|
98
|
+
cwe: 'CWE-79', family: 'xss', parser: 'SAST',
|
|
99
|
+
description: 'DomSanitizer.bypassSecurityTrust* explicitly disables Angular\'s built-in XSS protection. Called on a non-constant value, attacker-influenced data reaches the DOM unsanitized.',
|
|
100
|
+
remediation: 'Avoid bypassSecurityTrust* on dynamic data; bind trusted values only, or sanitize with DomSanitizer.sanitize() / a strict allow-list first.',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return findings;
|
|
106
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Go structural (taint-independent) injection detectors — PRD Tier 1 (Go recall).
|
|
2
|
+
//
|
|
3
|
+
// Go handler FNs: user input from c.Query / r.URL.Query().Get is built into a
|
|
4
|
+
// sink via fmt.Sprintf or string concat (Go has no string templates). That
|
|
5
|
+
// shape is the vulnerability regardless of variable names. Parameterized
|
|
6
|
+
// queries (`db.Query("… ?", x)`) and canonicalized paths have no Sprintf/concat
|
|
7
|
+
// in the sink argument, so they don't match.
|
|
8
|
+
|
|
9
|
+
import { blankComments } from './_comment-strip.js';
|
|
10
|
+
|
|
11
|
+
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
12
|
+
|
|
13
|
+
export function scanGoStructural(fp, raw) {
|
|
14
|
+
if (!/\.go$/i.test(fp)) return [];
|
|
15
|
+
if (!raw || raw.length > 500_000) return [];
|
|
16
|
+
const code = blankComments(raw);
|
|
17
|
+
const findings = [];
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
const emit = (key, line, meta) => {
|
|
20
|
+
const id = `go-struct-${key}:${fp}:${line}`;
|
|
21
|
+
if (seen.has(id)) return;
|
|
22
|
+
seen.add(id);
|
|
23
|
+
findings.push({
|
|
24
|
+
id, file: fp, line, vuln: meta.vuln, severity: meta.severity, cwe: meta.cwe,
|
|
25
|
+
family: meta.family, parser: 'GO', confidence: meta.confidence ?? 0.72,
|
|
26
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200), remediation: meta.remediation,
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// SQL injection: a database/sql query method built with fmt.Sprintf or concat.
|
|
31
|
+
const SQL_RE = /\.(?:Query|QueryRow|QueryContext|QueryRowContext|Exec|ExecContext|Prepare|PrepareContext)\s*\(\s*(?:fmt\.Sprintf\s*\(|"[^"\n]*"\s*\+)/g;
|
|
32
|
+
let m;
|
|
33
|
+
while ((m = SQL_RE.exec(code))) emit('sqli', lineOf(code, m.index), {
|
|
34
|
+
vuln: 'SQL Injection — query built with fmt.Sprintf / concat (Go)',
|
|
35
|
+
severity: 'critical', cwe: 'CWE-89', family: 'sql-injection', confidence: 0.75,
|
|
36
|
+
remediation: 'Use parameter placeholders: db.Query("… WHERE name = ?", name). Never build SQL with fmt.Sprintf or string concatenation.',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Path traversal: an os/ioutil file op built with concat or fmt.Sprintf.
|
|
40
|
+
const PATH_RE = /\b(?:os\.Open|os\.OpenFile|os\.ReadFile|os\.Create|ioutil\.ReadFile|ioutil\.WriteFile)\s*\(\s*(?:"[^"\n]*"\s*\+|fmt\.Sprintf\s*\()/g;
|
|
41
|
+
while ((m = PATH_RE.exec(code))) emit('path', lineOf(code, m.index), {
|
|
42
|
+
vuln: 'Path Traversal — file path built with concat / fmt.Sprintf (Go)',
|
|
43
|
+
severity: 'high', cwe: 'CWE-22', family: 'path-traversal', confidence: 0.7,
|
|
44
|
+
remediation: 'Strip the path with filepath.Base and assert containment: want := filepath.Join(base, filepath.Base(name)); if !strings.HasPrefix(filepath.Clean(want), base) { reject }.',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Insecure deserialization: encoding/gob decoding attacker-controlled bytes
|
|
48
|
+
// into an interface{} / any target — gob will instantiate registered concrete
|
|
49
|
+
// types, a deserialization gadget surface. Gated on encoding/gob being used.
|
|
50
|
+
if (/\bencoding\/gob\b|\bgob\.NewDecoder\b/.test(code)) {
|
|
51
|
+
const GOB_RE = /\bgob\.NewDecoder\s*\([\s\S]*?\)\s*\.\s*Decode\s*\(|\b\w+\s*:?=\s*gob\.NewDecoder\s*\(/g;
|
|
52
|
+
while ((m = GOB_RE.exec(code))) emit('gob-deser', lineOf(code, m.index), {
|
|
53
|
+
vuln: 'Insecure Deserialization — encoding/gob decoding untrusted input (Go)',
|
|
54
|
+
severity: 'high', cwe: 'CWE-502', family: 'insecure-deserialization', confidence: 0.6,
|
|
55
|
+
remediation: 'Do not gob-decode data from an untrusted source — gob instantiates registered concrete types and is not designed for adversarial input. Use a schema-validated format (JSON with a fixed struct, protobuf) and validate before use.',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
|
@@ -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);
|