@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,62 @@
|
|
|
1
|
+
// Tree-sitter-backed sinks for long-tail languages (roadmap #8).
|
|
2
|
+
//
|
|
3
|
+
// First AST-accurate detector for a language that has no first-class IR parser.
|
|
4
|
+
// Gated behind AGENTIC_SECURITY_TREE_SITTER=1 and the optional tree-sitter
|
|
5
|
+
// dependency; when either is absent it degrades to a no-op (the existing
|
|
6
|
+
// pattern detectors still run). Anchoring on real AST nodes (not regex over
|
|
7
|
+
// raw text) means comments and string literals can't produce false matches.
|
|
8
|
+
//
|
|
9
|
+
// Rust shell-spawn command injection: a `std::process::Command` that launches a
|
|
10
|
+
// shell (sh/bash/cmd/powershell) with -c / -Command AND a non-literal argument.
|
|
11
|
+
// That is the classic "build a shell string from input" injection; spawning a
|
|
12
|
+
// program directly (no shell) is not flagged.
|
|
13
|
+
|
|
14
|
+
import { getParserFor, treeSitterLangOf, walkNamed } from '../ir/tree-sitter-loader.js';
|
|
15
|
+
|
|
16
|
+
const SHELL_PROG = /Command::new\s*\(\s*[bB]?"(?:sh|bash|zsh|dash|ksh|cmd(?:\.exe)?|powershell(?:\.exe)?)"/;
|
|
17
|
+
const SHELL_FLAG = /\.args?\s*\(\s*\[?\s*[bB]?"(?:-c|\/c|\/C|-Command)"/;
|
|
18
|
+
// An .arg()/.args() whose argument does NOT start with a string literal → dynamic.
|
|
19
|
+
const DYNAMIC_ARG = /\.args?\s*\(\s*(?![bB]?["[])[A-Za-z_&*([]/;
|
|
20
|
+
|
|
21
|
+
export async function scanTreeSitterRust(fp, raw) {
|
|
22
|
+
if (process.env.AGENTIC_SECURITY_TREE_SITTER !== '1') return [];
|
|
23
|
+
if (treeSitterLangOf(fp) !== 'rust') return [];
|
|
24
|
+
if (!raw || raw.length > 500_000) return [];
|
|
25
|
+
if (!/Command::new/.test(raw)) return []; // cheap pre-filter
|
|
26
|
+
|
|
27
|
+
const parser = await getParserFor('rust');
|
|
28
|
+
if (!parser) return []; // optional dep absent → degrade
|
|
29
|
+
let tree;
|
|
30
|
+
try { tree = parser.parse(raw); } catch { return []; }
|
|
31
|
+
|
|
32
|
+
const findings = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
walkNamed(tree.rootNode, (n) => {
|
|
35
|
+
if (n.type !== 'call_expression') return;
|
|
36
|
+
const text = n.text;
|
|
37
|
+
if (!SHELL_PROG.test(text) || !SHELL_FLAG.test(text) || !DYNAMIC_ARG.test(text)) return;
|
|
38
|
+
const line = n.startPosition.row + 1;
|
|
39
|
+
if (seen.has(line)) return;
|
|
40
|
+
seen.add(line);
|
|
41
|
+
findings.push({
|
|
42
|
+
id: `ts-rust-cmdi:${fp}:${line}`,
|
|
43
|
+
severity: 'high', file: fp, line,
|
|
44
|
+
vuln: 'Command injection (Rust shell spawn with dynamic argument)',
|
|
45
|
+
cwe: 'CWE-78', family: 'command-injection', parser: 'TREE-SITTER', confidence: 0.55,
|
|
46
|
+
description: 'A std::process::Command spawns a shell (sh/bash/cmd/powershell) with -c/-Command and a non-literal argument. If that argument is attacker-influenced this is command injection.',
|
|
47
|
+
remediation: 'Do not pass input to a shell. Invoke the program directly — Command::new(prog).arg(userArg) without a shell — and validate inputs.',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
return findings;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Convenience: run every tree-sitter sink detector over a file-content map.
|
|
54
|
+
export async function scanTreeSitterSinks(fileContents) {
|
|
55
|
+
if (process.env.AGENTIC_SECURITY_TREE_SITTER !== '1') return [];
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const [fp, c] of Object.entries(fileContents || {})) {
|
|
58
|
+
if (typeof c !== 'string') continue;
|
|
59
|
+
try { out.push(...await scanTreeSitterRust(fp, c)); } catch { /* never fail the scan */ }
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
@@ -32,6 +32,34 @@ const PATTERNS = {
|
|
|
32
32
|
{ re: /\bsha1\.(?:Sum|New)\s*\(\s*(?:\[\]byte\s*\(\s*)?(\w+)/g, label: 'sha1.Sum/New' },
|
|
33
33
|
],
|
|
34
34
|
},
|
|
35
|
+
ruby: {
|
|
36
|
+
ext: /\.rb$/i,
|
|
37
|
+
rules: [
|
|
38
|
+
{ re: /\bDigest::(?:MD5|SHA1)\s*\.\s*(?:hexdigest|digest|base64digest)\s*\(\s*(\w+)/g, label: 'Digest::MD5/SHA1' },
|
|
39
|
+
{ re: /\b(?:Digest::)?(?:MD5|SHA1)\.new[\s\S]{0,40}\.update\s*\(\s*(\w+)/g, label: 'Digest MD5/SHA1 new+update' },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
csharp: {
|
|
43
|
+
ext: /\.cs$/i,
|
|
44
|
+
rules: [
|
|
45
|
+
// MD5.Create()…ComputeHash(<var>) / MD5.HashData(<var>) — capture hashed var.
|
|
46
|
+
{ re: /\b(?:MD5|SHA1)\s*\.\s*(?:Create\s*\(\s*\)\s*\.\s*ComputeHash|HashData)\s*\([\s\S]{0,120}?(\w+)\s*\)/g, label: 'MD5/SHA1 ComputeHash/HashData' },
|
|
47
|
+
{ re: /\bnew\s+(?:MD5|SHA1)(?:CryptoServiceProvider|Managed)?\s*\(/g, label: 'MD5/SHA1 provider' },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
kotlin: {
|
|
51
|
+
ext: /\.kt$/i,
|
|
52
|
+
rules: [
|
|
53
|
+
{ re: /\bMessageDigest\.getInstance\s*\(\s*"(?:MD5|SHA-?1)"\s*\)[\s\S]{0,80}?\.digest\s*\(\s*(\w+)/g, label: 'MessageDigest MD5/SHA-1' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
php: {
|
|
57
|
+
ext: /\.(?:php|phtml)$/i,
|
|
58
|
+
rules: [
|
|
59
|
+
{ re: /\b(?:md5|sha1)\s*\(\s*\$?(\w+)/g, label: 'md5()/sha1()' },
|
|
60
|
+
{ re: /\bhash\s*\(\s*['"](?:md5|sha1)['"]\s*,\s*\$?(\w+)/g, label: 'hash("md5"/"sha1", …)' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
35
63
|
};
|
|
36
64
|
|
|
37
65
|
export function scanWeakPasswordHash(fp, raw) {
|
|
@@ -9,8 +9,18 @@
|
|
|
9
9
|
// Go: rand.Intn(), rand.Int(), rand.Float64(), rand.Int31(), rand.Int63()
|
|
10
10
|
// Ruby: rand(), Random.rand, Random.new.rand
|
|
11
11
|
// PHP: rand(), mt_rand(), array_rand(), shuffle()
|
|
12
|
+
// Java: new Random(), Math.random(), ThreadLocalRandom.current().nextInt()
|
|
13
|
+
// Kotlin: Random.next*, kotlin.random.Random, java.util.Random
|
|
14
|
+
// C#: new Random()
|
|
12
15
|
|
|
13
16
|
const SECURITY_CONTEXT = /\b(token|session|nonce|key|secret|password|otp|csrf|salt|code|pin|auth|reset|verify|captcha|challenge|ticket)\b/i;
|
|
17
|
+
// camelCase carrier: a security term as the suffix of an identifier
|
|
18
|
+
// (`newToken`, `genSecret`, `sessionId`) — `\bword\b` misses these. Case
|
|
19
|
+
// sensitive so it only fires on the CamelCase boundary, not generic prose.
|
|
20
|
+
const SECURITY_CONTEXT_CAMEL = /[a-z](?:Token|Secret|Nonce|Salt|Otp|Password|Passwd|ApiKey|SessionId|Csrf|Cookie|Jwt|Nonce)\b/;
|
|
21
|
+
// snake_case carrier: same idea for `session_token`, `reset_token`,
|
|
22
|
+
// `api_secret` — `\btoken\b` misses these because `_` is a word char.
|
|
23
|
+
const SECURITY_CONTEXT_SNAKE = /_(?:token|secret|nonce|salt|otp|password|passwd|key|session|csrf|seed|cookie|jwt)\b/i;
|
|
14
24
|
|
|
15
25
|
function _line(raw, idx) {
|
|
16
26
|
return raw.slice(0, idx).split('\n').length;
|
|
@@ -47,6 +57,28 @@ const LANG_PATTERNS = {
|
|
|
47
57
|
{ re: /\b(?:rand|mt_rand|array_rand)\s*\(/g, label: 'rand() / mt_rand()' },
|
|
48
58
|
],
|
|
49
59
|
},
|
|
60
|
+
java: {
|
|
61
|
+
ext: /\.java$/i,
|
|
62
|
+
patterns: [
|
|
63
|
+
{ re: /\bnew\s+Random\s*\(/g, label: 'java.util.Random' },
|
|
64
|
+
{ re: /\bMath\.random\s*\(\s*\)/g, label: 'Math.random()' },
|
|
65
|
+
{ re: /\bThreadLocalRandom\.current\s*\(\s*\)\s*\.\s*next\w*\s*\(/g, label: 'ThreadLocalRandom (non-crypto)' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
kotlin: {
|
|
69
|
+
ext: /\.kt$/i,
|
|
70
|
+
patterns: [
|
|
71
|
+
{ re: /\bRandom\s*\(\s*\)\s*\.\s*next\w*\s*\(/g, label: 'Random() (non-crypto)' },
|
|
72
|
+
{ re: /\bRandom\s*\.\s*(?:next\w+|Default)\b/g, label: 'kotlin.random.Random' },
|
|
73
|
+
{ re: /\bnew\s+(?:java\.util\.)?Random\s*\(/g, label: 'java.util.Random' },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
csharp: {
|
|
77
|
+
ext: /\.cs$/i,
|
|
78
|
+
patterns: [
|
|
79
|
+
{ re: /\bnew\s+Random\s*\(/g, label: 'System.Random' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
50
82
|
};
|
|
51
83
|
|
|
52
84
|
export function scanWeakRandomness(fp, raw) {
|
|
@@ -67,10 +99,16 @@ export function scanWeakRandomness(fp, raw) {
|
|
|
67
99
|
const lineStart = raw.lastIndexOf('\n', m.index) + 1;
|
|
68
100
|
const lineEnd = raw.indexOf('\n', m.index);
|
|
69
101
|
const lineText = raw.slice(lineStart, lineEnd > 0 ? lineEnd : raw.length);
|
|
70
|
-
if (!SECURITY_CONTEXT.test(lineText)) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
102
|
+
if (!SECURITY_CONTEXT.test(lineText) && !SECURITY_CONTEXT_CAMEL.test(lineText) && !SECURITY_CONTEXT_SNAKE.test(lineText)) {
|
|
103
|
+
// Widen the lookback to the enclosing block: the security keyword is
|
|
104
|
+
// often on the function signature a few lines up (`function newToken()
|
|
105
|
+
// { … Math.random() … }`), not the line immediately before the call.
|
|
106
|
+
let winStart = lineStart;
|
|
107
|
+
for (let k = 0; k < 6 && winStart > 0; k++) {
|
|
108
|
+
winStart = raw.lastIndexOf('\n', winStart - 2) + 1;
|
|
109
|
+
}
|
|
110
|
+
const window = raw.slice(winStart, lineStart - 1);
|
|
111
|
+
if (!SECURITY_CONTEXT.test(window) && !SECURITY_CONTEXT_CAMEL.test(window) && !SECURITY_CONTEXT_SNAKE.test(window)) continue;
|
|
74
112
|
}
|
|
75
113
|
findings.push({
|
|
76
114
|
id: `weak-rng:${fp}:${line}`,
|
|
@@ -79,7 +117,10 @@ export function scanWeakRandomness(fp, raw) {
|
|
|
79
117
|
vuln: `Insecure Randomness — ${label} used for security-sensitive value`,
|
|
80
118
|
severity: 'high',
|
|
81
119
|
family: 'weak-rng',
|
|
82
|
-
|
|
120
|
+
// CWE-338 (cryptographically weak PRNG) — more specific than the
|
|
121
|
+
// CWE-330 parent; every pattern here (Math.random / random.* / rand /
|
|
122
|
+
// math.rand) is a non-CSPRNG used in a security context.
|
|
123
|
+
cwe: 'CWE-338',
|
|
83
124
|
parser: 'WEAK-RNG',
|
|
84
125
|
confidence: 0.80,
|
|
85
126
|
description: `${label} is not cryptographically secure. An attacker can predict the output and forge tokens, bypass OTP, or guess session identifiers.`,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Wrong-context output encoding (roadmap #1, practical slice) — CWE-79.
|
|
2
|
+
//
|
|
3
|
+
// Context-aware sanitization is the single biggest XSS precision lever, but
|
|
4
|
+
// the full model (context-tagged taint through the lattice) is a core-engine
|
|
5
|
+
// change. This detector ships the most impactful, UNAMBIGUOUS case as a
|
|
6
|
+
// standalone pattern rule:
|
|
7
|
+
//
|
|
8
|
+
// An HTML-ENTITY encoder (escapeHtml / htmlspecialchars / he.encode /
|
|
9
|
+
// lodash.escape …) applied to a value that is then used as a URL
|
|
10
|
+
// (`.href` / `.src` / `location` / a JSX `href=`/`src=` attribute).
|
|
11
|
+
//
|
|
12
|
+
// HTML-entity encoding is the right tool for HTML *body/attribute text*. It
|
|
13
|
+
// does NOT neutralize a dangerous URL SCHEME — `javascript:alert(1)` and
|
|
14
|
+
// `data:text/html,…` survive entity-encoding unchanged, so the value is still
|
|
15
|
+
// XSS-exploitable in a URL context. This is a textbook wrong-context bug and a
|
|
16
|
+
// false sense of safety (the code looks sanitized).
|
|
17
|
+
//
|
|
18
|
+
// Deliberately high precision: only HTML-entity encoders (not generic names
|
|
19
|
+
// like bare `escape`, not `encodeURIComponent`, which is a different — non-XSS
|
|
20
|
+
// — mistake in URLs), and suppressed when a scheme/allow-list check is nearby.
|
|
21
|
+
|
|
22
|
+
import { blankComments } from './_comment-strip.js';
|
|
23
|
+
|
|
24
|
+
// Distinctive HTML-entity encoder callees. Intentionally excludes bare
|
|
25
|
+
// `escape` (too generic) and `encodeURIComponent` (URL-encoding, not XSS).
|
|
26
|
+
const HTML_ENCODER = "(?:escapeHtml|escapeHTML|htmlspecialchars|htmlentities|he\\.encode|he\\.escape|_\\.escape|lodash\\.escape)";
|
|
27
|
+
|
|
28
|
+
const PATTERNS = [
|
|
29
|
+
// Assignment to a URL-bearing property: el.href = …escapeHtml(url)…
|
|
30
|
+
new RegExp("\\b(?:location(?:\\.href)?|[\\w$.]+\\.(?:href|src))\\s*=\\s*([^;\\n]*?\\b" + HTML_ENCODER + "\\s*\\([^;\\n]*)", "g"),
|
|
31
|
+
// JSX / template attribute: href={ escapeHtml(url) } / src="...${he.encode(u)}..."
|
|
32
|
+
new RegExp("\\b(?:href|src)\\s*=\\s*[{\"'`]?[^\"'`>;\\n]*?\\b" + HTML_ENCODER + "\\s*\\(", "g"),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// A nearby URL-scheme allow-list / validation makes the HTML-encoder a
|
|
36
|
+
// belt-and-suspenders choice rather than the sole (wrong) defense → suppress.
|
|
37
|
+
const SCHEME_GUARD = [
|
|
38
|
+
/\bstartsWith\s*\(\s*['"]https?:/i,
|
|
39
|
+
/\^https\?:/i,
|
|
40
|
+
/\bnew\s+URL\s*\(/,
|
|
41
|
+
/\ballow(?:ed|list)?\s*(?:schemes?|protocols?|hosts?)/i,
|
|
42
|
+
/\bprotocol\s*===?\s*['"]https?:/i,
|
|
43
|
+
/\bsanitizeUrl\b|\bisSafeUrl\b|\bvalidateUrl\b/i,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
47
|
+
|
|
48
|
+
function isJsLike(fp) { return /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp); }
|
|
49
|
+
function isPhp(fp) { return /\.php$/i.test(fp); }
|
|
50
|
+
|
|
51
|
+
// R5 (PRD §5) — context-aware sanitizer ADEQUACY. The model: a sanitizer
|
|
52
|
+
// neutralizes a specific context; using it before a sink of a DIFFERENT context
|
|
53
|
+
// is a no-op that looks safe. The highest-signal mismatches beyond the URL case
|
|
54
|
+
// above: an HTML-entity encoder applied to a value that then reaches a SHELL or
|
|
55
|
+
// SQL sink — HTML-escaping does nothing for command/SQL injection.
|
|
56
|
+
const _SHELL_SINK = /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|system|popen|shell_exec|child_process\.\w+)\s*\(/;
|
|
57
|
+
const _SQL_SINK = /\.(?:query|execute|raw|exec)\s*\(|\bdb\.raw\s*\(|sequelize\.query\s*\(/i;
|
|
58
|
+
const _HTML_ENC_CALL = new RegExp(HTML_ENCODER + "\\s*\\(", "g");
|
|
59
|
+
|
|
60
|
+
export function scanSanitizerContextMismatch(fp, raw) {
|
|
61
|
+
if (!raw || raw.length > 500_000) return [];
|
|
62
|
+
if (!isJsLike(fp) && !isPhp(fp) && !/\.py$/i.test(fp)) return [];
|
|
63
|
+
if (!/escapeHtml|escapeHTML|htmlspecialchars|htmlentities|he\.encode|he\.escape|_\.escape|lodash\.escape/i.test(raw)) return [];
|
|
64
|
+
if (!_SHELL_SINK.test(raw) && !_SQL_SINK.test(raw)) return [];
|
|
65
|
+
const code = blankComments(raw);
|
|
66
|
+
const lines = code.split('\n');
|
|
67
|
+
const findings = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
// Track vars assigned directly from an HTML encoder (one hop).
|
|
70
|
+
const htmlVars = new Set();
|
|
71
|
+
const VAR_ASSIGN = new RegExp("(?:\\b(?:const|let|var)\\s+)?([A-Za-z_$][\\w$]*)\\s*=\\s*" + HTML_ENCODER + "\\s*\\(", "g");
|
|
72
|
+
let m;
|
|
73
|
+
while ((m = VAR_ASSIGN.exec(code))) htmlVars.add(m[1]);
|
|
74
|
+
for (let i = 0; i < lines.length; i++) {
|
|
75
|
+
const line = lines[i];
|
|
76
|
+
const isShell = _SHELL_SINK.test(line);
|
|
77
|
+
const isSql = !isShell && _SQL_SINK.test(line);
|
|
78
|
+
if (!isShell && !isSql) continue;
|
|
79
|
+
const argRegion = line.slice(line.search(isShell ? _SHELL_SINK : _SQL_SINK));
|
|
80
|
+
_HTML_ENC_CALL.lastIndex = 0;
|
|
81
|
+
const inline = _HTML_ENC_CALL.test(argRegion);
|
|
82
|
+
const viaVar = !inline && [...htmlVars].some((v) => new RegExp(`\\b${v}\\b`).test(argRegion));
|
|
83
|
+
if (!inline && !viaVar) continue;
|
|
84
|
+
if (seen.has(i + 1)) continue;
|
|
85
|
+
seen.add(i + 1);
|
|
86
|
+
const ctx = isShell ? { cwe: 'CWE-78', kind: 'command', vuln: 'Command Injection' }
|
|
87
|
+
: { cwe: 'CWE-89', kind: 'SQL', vuln: 'SQL Injection' };
|
|
88
|
+
findings.push({
|
|
89
|
+
id: `wrong-context-sanitizer:sink:${fp}:${i + 1}`,
|
|
90
|
+
severity: 'high',
|
|
91
|
+
file: fp,
|
|
92
|
+
line: i + 1,
|
|
93
|
+
vuln: `Wrong-context sanitizer (HTML-encoded value used in a ${ctx.kind} sink)`,
|
|
94
|
+
cwe: ctx.cwe,
|
|
95
|
+
family: ctx.kind === 'command' ? 'command-injection' : 'sql-injection',
|
|
96
|
+
parser: 'SAST',
|
|
97
|
+
description: `An HTML-entity encoder was applied to a value that then flows into a ${ctx.kind} sink. HTML escaping neutralizes HTML markup, NOT ${ctx.kind} metacharacters — the value is still ${ctx.vuln}-exploitable while appearing sanitized.`,
|
|
98
|
+
remediation: ctx.kind === 'command'
|
|
99
|
+
? 'Use an argv-form API (execFile/spawn with an args array), never a shell string; HTML escaping is irrelevant here.'
|
|
100
|
+
: 'Use parameterized queries / prepared statements; HTML escaping does not prevent SQL injection.',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return findings;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function scanWrongContextSanitizer(fp, raw) {
|
|
107
|
+
if (!raw || raw.length > 500_000) return [];
|
|
108
|
+
if (!isJsLike(fp) && !isPhp(fp)) return [];
|
|
109
|
+
// Cheap pre-filter: must contain both an HTML encoder and a URL sink token.
|
|
110
|
+
if (!/escapeHtml|htmlspecialchars|htmlentities|he\.|_\.escape|lodash\.escape/i.test(raw)) return [];
|
|
111
|
+
if (!/\b(?:href|src)\b|location/i.test(raw)) return [];
|
|
112
|
+
|
|
113
|
+
const code = blankComments(raw, isPhp(fp) ? undefined : undefined);
|
|
114
|
+
const lines = code.split('\n');
|
|
115
|
+
const findings = [];
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
|
|
118
|
+
for (const re of PATTERNS) {
|
|
119
|
+
re.lastIndex = 0;
|
|
120
|
+
let m;
|
|
121
|
+
while ((m = re.exec(code))) {
|
|
122
|
+
const line = lineOf(code, m.index);
|
|
123
|
+
if (seen.has(line)) continue;
|
|
124
|
+
// Suppress when a scheme guard appears within 5 lines above.
|
|
125
|
+
const lo = Math.max(0, line - 6);
|
|
126
|
+
const near = lines.slice(lo, line).join('\n');
|
|
127
|
+
if (SCHEME_GUARD.some(g => g.test(near))) continue;
|
|
128
|
+
seen.add(line);
|
|
129
|
+
findings.push({
|
|
130
|
+
id: `wrong-context-sanitizer:${fp}:${line}`,
|
|
131
|
+
severity: 'medium',
|
|
132
|
+
file: fp,
|
|
133
|
+
line,
|
|
134
|
+
vuln: 'Wrong-context output encoding (HTML-encoded value used as a URL)',
|
|
135
|
+
cwe: 'CWE-79',
|
|
136
|
+
family: 'xss',
|
|
137
|
+
parser: 'SAST',
|
|
138
|
+
description:
|
|
139
|
+
'An HTML-entity encoder is applied to a value used in a URL context (href/src/location). ' +
|
|
140
|
+
'HTML-entity encoding does not neutralize dangerous URL schemes such as `javascript:` or ' +
|
|
141
|
+
'`data:`, so the value remains XSS-exploitable — while looking sanitized.',
|
|
142
|
+
remediation:
|
|
143
|
+
'Validate the URL scheme against an allow-list (e.g. only http/https) or build the URL with ' +
|
|
144
|
+
'the URL API; reserve HTML-entity encoding for HTML body/attribute text, not URLs.',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return findings;
|
|
149
|
+
}
|
|
@@ -3,14 +3,32 @@ import { blankComments } from './_comment-strip.js';
|
|
|
3
3
|
//
|
|
4
4
|
// Same shape as LDAP injection — string concatenation into a query language
|
|
5
5
|
// that has its own operators. We catch concatenation patterns into:
|
|
6
|
-
// - javax.xml.xpath / org.jaxen / org.dom4j (Java)
|
|
6
|
+
// - javax.xml.xpath / org.jaxen / org.dom4j (Java, Kotlin)
|
|
7
7
|
// - lxml.etree / xml.etree (Python)
|
|
8
8
|
// - xpath npm pkg (Node)
|
|
9
|
+
// - DOMXPath::query / ::evaluate (PHP)
|
|
10
|
+
// - XPathNavigator.Select / SelectNodes (C#)
|
|
11
|
+
// - Nokogiri xpath / REXML::XPath (Ruby)
|
|
12
|
+
// - xmlpath / htmlquery / antchfx xpath (Go)
|
|
13
|
+
|
|
14
|
+
// A string literal whose body may contain the OTHER quote char — an XPath
|
|
15
|
+
// expression like `"//user[@name='" + x` embeds a single quote, which a naive
|
|
16
|
+
// `[^"']*` body would stop at. Match each quote style with its own body.
|
|
17
|
+
const STR = String.raw`(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')`;
|
|
9
18
|
|
|
10
19
|
const PATTERNS = {
|
|
11
|
-
java:
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
java: new RegExp(String.raw`\.\s*(?:compile|evaluate)\s*\(\s*${STR}\s*\+\s*\w+`, 'g'),
|
|
21
|
+
kt: new RegExp(String.raw`\.\s*(?:compile|evaluate)\s*\(\s*${STR}\s*\+\s*\w+|\.\s*(?:compile|evaluate)\s*\(\s*"[^"\n]*\$\{`, 'g'),
|
|
22
|
+
py: new RegExp(String.raw`\.\s*(?:xpath|find|findall)\s*\(\s*${STR}\s*[%+]\s*\w+|\.\s*xpath\s*\(\s*[fF]["']`, 'g'),
|
|
23
|
+
js: new RegExp(String.raw`\b(?:xpath|select)\s*\(\s*(?:` + '`' + String.raw`[^` + '`' + String.raw`]*` + '`' + String.raw`|${STR})\s*\+\s*\w+|\bxpath\.select\s*\(\s*` + '`' + String.raw`[^` + '`' + String.raw`]*\$\{`, 'g'),
|
|
24
|
+
// PHP DOMXPath: $xpath->query("…" . $u) / ->evaluate("…" . $u)
|
|
25
|
+
php: new RegExp(String.raw`->\s*(?:query|evaluate)\s*\(\s*${STR}\s*\.\s*\$\w+`, 'g'),
|
|
26
|
+
// C#: nav.Select / doc.SelectNodes / SelectSingleNode / XPathSelectElements with concat
|
|
27
|
+
cs: new RegExp(String.raw`\.\s*(?:Select|SelectNodes|SelectSingleNode|XPathSelectElement|XPathSelectElements|Compile)\s*\(\s*\$?${STR}\s*\+\s*\w+|\.\s*(?:Select|SelectNodes|SelectSingleNode)\s*\(\s*\$"[^"\n]*\{`, 'g'),
|
|
28
|
+
// Ruby Nokogiri / REXML: .xpath("…#{u}") / .at_xpath / XPath.match with #{} or concat
|
|
29
|
+
rb: new RegExp(String.raw`\.\s*(?:xpath|at_xpath|search)\s*\(\s*(?:"[^"\n]*#\{|'[^'\n]*#\{)|\.\s*(?:xpath|at_xpath)\s*\(\s*${STR}\s*\+\s*\w+|XPath\s*\.\s*(?:match|first|each)\s*\([^,]+,\s*(?:"[^"\n]*#\{|'[^'\n]*#\{)`, 'g'),
|
|
30
|
+
// Go xmlpath/htmlquery/antchfx: xmlpath.Compile("…"+u) / htmlquery.Find(doc, "…"+u)
|
|
31
|
+
go: new RegExp(String.raw`\b(?:xmlpath\.Compile|htmlquery\.(?:Find|FindOne|QueryAll|Query)|xpath\.Compile|navigator\.Compile)\s*\([^)]*${STR}\s*\+\s*\w+`, 'g'),
|
|
14
32
|
};
|
|
15
33
|
|
|
16
34
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
@@ -20,11 +38,17 @@ export function scanXPathInjection(fp, raw) {
|
|
|
20
38
|
let lang;
|
|
21
39
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) lang = 'js';
|
|
22
40
|
else if (/\.java$/i.test(fp)) lang = 'java';
|
|
41
|
+
else if (/\.kt$/i.test(fp)) lang = 'kt';
|
|
23
42
|
else if (/\.py$/i.test(fp)) lang = 'py';
|
|
43
|
+
else if (/\.(?:php|phtml)$/i.test(fp)) lang = 'php';
|
|
44
|
+
else if (/\.cs$/i.test(fp)) lang = 'cs';
|
|
45
|
+
else if (/\.rb$/i.test(fp)) lang = 'rb';
|
|
46
|
+
else if (/\.go$/i.test(fp)) lang = 'go';
|
|
24
47
|
else return [];
|
|
25
48
|
|
|
26
|
-
const code = blankComments(raw, lang === 'py' ? 'py' : undefined);
|
|
27
|
-
|
|
49
|
+
const code = blankComments(raw, (lang === 'py' || lang === 'rb') ? 'py' : undefined);
|
|
50
|
+
// Relevance gate: an XPath-ish API or an XPath expression literal must appear.
|
|
51
|
+
if (!/\bxpath|XPath|\.xpath\(|DOMXPath|SelectNodes|SelectSingleNode|XPathNavigator|xmlpath|htmlquery|REXML|\/\/\w/i.test(code)) return [];
|
|
28
52
|
const re = new RegExp(PATTERNS[lang].source, PATTERNS[lang].flags);
|
|
29
53
|
const findings = [];
|
|
30
54
|
const seen = new Set();
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Cross-language reflected-XSS structural detector — PRD Tier 1 (recall).
|
|
2
|
+
//
|
|
3
|
+
// JS/Python reflected XSS is handled by the flow engine + framework structural
|
|
4
|
+
// detectors; the second-tier languages had no XSS coverage. This module adds a
|
|
5
|
+
// taint-independent structural rule per language: user input written into an
|
|
6
|
+
// HTML response via concatenation / interpolation, without an output encoder.
|
|
7
|
+
//
|
|
8
|
+
// Precision: each language carries its own escaper exclusion (htmlspecialchars,
|
|
9
|
+
// HtmlEncode, html_escape, template.HTMLEscapeString, …). When an escaper is
|
|
10
|
+
// applied on the sink line the finding is suppressed — the parameterized/encoded
|
|
11
|
+
// form must NOT match. HTML context is required (an HTML tag literal, an HTML
|
|
12
|
+
// content-type, or a raw-superglobal echo) so plain-text responses don't fire.
|
|
13
|
+
|
|
14
|
+
import { blankComments } from './_comment-strip.js';
|
|
15
|
+
|
|
16
|
+
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
17
|
+
|
|
18
|
+
const REMEDIATION =
|
|
19
|
+
'HTML-encode user input before writing it into a response: htmlspecialchars (PHP), ' +
|
|
20
|
+
'template/html.EscapeString (Go), ERB::Util.html_escape / avoid .html_safe (Ruby), ' +
|
|
21
|
+
'HttpUtility.HtmlEncode (C#), or an auto-escaping template engine. Never concatenate ' +
|
|
22
|
+
'request data straight into an HTML response.';
|
|
23
|
+
|
|
24
|
+
// Per-language: { ext, sinks:[RegExp], escaper:RegExp }. A line matches when a
|
|
25
|
+
// sink RegExp matches and the escaper RegExp does NOT.
|
|
26
|
+
const LANGS = {
|
|
27
|
+
go: {
|
|
28
|
+
ext: /\.go$/i,
|
|
29
|
+
// fmt.Fprint*/io.WriteString to the ResponseWriter, or w.Write([]byte(...)),
|
|
30
|
+
// building an HTML string ("<…") by concatenation.
|
|
31
|
+
sinks: [
|
|
32
|
+
/\b(?:fmt\.Fprintf?|fmt\.Fprintln|io\.WriteString)\s*\(\s*w\b[^)\n]*"<[^"\n]*"\s*\+/,
|
|
33
|
+
/\bw\.Write\s*\(\s*\[\]byte\s*\(\s*"<[^"\n]*"\s*\+/,
|
|
34
|
+
],
|
|
35
|
+
escaper: /\b(?:template\.HTMLEscapeString|html\.EscapeString|template\.HTMLEscaper)\s*\(/,
|
|
36
|
+
},
|
|
37
|
+
php: {
|
|
38
|
+
ext: /\.(?:php|phtml)$/i,
|
|
39
|
+
// echo/print of a request superglobal (directly or concatenated).
|
|
40
|
+
sinks: [
|
|
41
|
+
/\b(?:echo|print)\b[^;\n]*\$_(?:GET|POST|REQUEST|COOKIE)\b/,
|
|
42
|
+
/\bprintf\s*\([^;\n]*\$_(?:GET|POST|REQUEST|COOKIE)\b/,
|
|
43
|
+
],
|
|
44
|
+
escaper: /\b(?:htmlspecialchars|htmlentities|strip_tags|filter_var|urlencode|rawurlencode|json_encode|intval|floatval|htmlspecialchars_decode)\s*\(/,
|
|
45
|
+
},
|
|
46
|
+
ruby: {
|
|
47
|
+
ext: /\.rb$/i,
|
|
48
|
+
// render inline:/html: with #{} interpolation, raw(params…), params….html_safe
|
|
49
|
+
sinks: [
|
|
50
|
+
/\brender\s+(?:inline|html):\s*["'][^"'\n]*#\{/,
|
|
51
|
+
/\braw\s*\(\s*(?:params|request|@\w+\.params)\b/,
|
|
52
|
+
/\b(?:params|request)\b[^\n]*\.\s*html_safe\b/,
|
|
53
|
+
],
|
|
54
|
+
escaper: /\b(?:ERB::Util\.html_escape|CGI\.escapeHTML|h\s*\(|sanitize\s*\()/,
|
|
55
|
+
},
|
|
56
|
+
csharp: {
|
|
57
|
+
ext: /\.cs$/i,
|
|
58
|
+
// Response.Write of Request data, or an HTML string concatenation.
|
|
59
|
+
sinks: [
|
|
60
|
+
/\bResponse\.Write\s*\([^)\n]*\bRequest\b/,
|
|
61
|
+
/\bResponse\.Write\s*\(\s*"<[^"\n]*"\s*\+/,
|
|
62
|
+
],
|
|
63
|
+
escaper: /\b(?:HttpUtility\.HtmlEncode|HtmlEncoder\.|Server\.HtmlEncode|WebUtility\.HtmlEncode|AntiXss\.)/,
|
|
64
|
+
},
|
|
65
|
+
kotlin: {
|
|
66
|
+
ext: /\.kt$/i,
|
|
67
|
+
// Ktor respondText building HTML by interpolation/concat.
|
|
68
|
+
sinks: [
|
|
69
|
+
/\brespondText\s*\(\s*"<[^"\n]*\$/,
|
|
70
|
+
/\brespondText\s*\(\s*"<[^"\n]*"\s*\+/,
|
|
71
|
+
/\brespondText\s*\(\s*"[^"\n]*\$[^"\n]*"\s*,\s*ContentType\.Text\.Html/,
|
|
72
|
+
],
|
|
73
|
+
escaper: /\b(?:htmlEscape|escapeHtml|HtmlUtils\.htmlEscape|encodeHTML)\s*\(/,
|
|
74
|
+
},
|
|
75
|
+
java: {
|
|
76
|
+
ext: /\.java$/i,
|
|
77
|
+
// Servlet: response.getWriter().write/print/println(...) or a PrintWriter
|
|
78
|
+
// `out`, building an HTML string ("<…") by concatenation. The concat (`"<…"
|
|
79
|
+
// +`) is the reflected-XSS shape — a static literal has no `+`.
|
|
80
|
+
sinks: [
|
|
81
|
+
/\.\s*(?:getWriter\s*\(\s*\)\s*\.\s*)?(?:write|print|println|append|format|printf)\s*\(\s*"<[^"\n]*"\s*\+/,
|
|
82
|
+
/\b(?:out|writer|pw|w)\s*\.\s*(?:write|print|println|append)\s*\(\s*"<[^"\n]*"\s*\+/,
|
|
83
|
+
],
|
|
84
|
+
escaper: /\b(?:encodeForHTML|forHtml|forHtmlContent|htmlEscape|escapeHtml4?|StringEscapeUtils|HtmlUtils\.htmlEscape|Encode\.forHtml|ESAPI|OWASP)\b/,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export function scanXssReflectedMultilang(fp, raw) {
|
|
89
|
+
if (!raw || raw.length > 500_000) return [];
|
|
90
|
+
let lang = null;
|
|
91
|
+
for (const v of Object.values(LANGS)) { if (v.ext.test(fp)) { lang = v; break; } }
|
|
92
|
+
if (!lang) return [];
|
|
93
|
+
|
|
94
|
+
const code = blankComments(raw, /\.(?:rb|php)$/i.test(fp) ? 'py' : undefined);
|
|
95
|
+
const lines = code.split('\n');
|
|
96
|
+
const findings = [];
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < lines.length; i++) {
|
|
100
|
+
const line = lines[i];
|
|
101
|
+
if (lang.escaper.test(line)) continue;
|
|
102
|
+
if (!lang.sinks.some((re) => re.test(line))) continue;
|
|
103
|
+
const ln = i + 1;
|
|
104
|
+
const id = `xss-reflected:${fp}:${ln}`;
|
|
105
|
+
if (seen.has(id)) continue;
|
|
106
|
+
seen.add(id);
|
|
107
|
+
findings.push({
|
|
108
|
+
id, file: fp, line: ln,
|
|
109
|
+
vuln: 'Reflected XSS — user input written into an HTML response without output encoding',
|
|
110
|
+
severity: 'high', cwe: 'CWE-79', family: 'xss', parser: 'XSS-ML', confidence: 0.62,
|
|
111
|
+
snippet: (raw.split('\n')[ln - 1] || '').trim().slice(0, 200),
|
|
112
|
+
remediation: REMEDIATION,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return findings;
|
|
116
|
+
}
|
package/src/sast/xxe.js
CHANGED
|
@@ -64,6 +64,28 @@ const PYTHON_DEFUSED_RE = /(?:^|\n)\s*(?:from\s+defusedxml\b|import\s+defusedxml
|
|
|
64
64
|
// upstream-recommended safe shape.
|
|
65
65
|
const PYTHON_LXML_SAFE_RE = /XMLParser\s*\([^)]*\bresolve_entities\s*=\s*False\b[^)]*\)/;
|
|
66
66
|
|
|
67
|
+
// ── PHP / Go / Ruby ─────────────────────────────────────────────────────────
|
|
68
|
+
// Each of these XML stacks is XXE-SAFE BY DEFAULT in current versions; the
|
|
69
|
+
// vulnerability appears only when the caller explicitly opts INTO external-
|
|
70
|
+
// entity / DTD-loading behavior. So we flag the opt-in flags, not the parse
|
|
71
|
+
// call itself — the plain parse is correctly clean.
|
|
72
|
+
//
|
|
73
|
+
// PHP: loadXML / simplexml_load_string|file with LIBXML_NOENT, LIBXML_DTDLOAD,
|
|
74
|
+
// or LIBXML_DTDVALID. (PHP >= 8.0 disables entity substitution by
|
|
75
|
+
// default; these flags re-enable it.)
|
|
76
|
+
const PHP_XXE_RE =
|
|
77
|
+
/\b(?:loadXML|simplexml_load_string|simplexml_load_file)\s*\([^;]*\bLIBXML_(?:NOENT|DTDLOAD|DTDVALID)\b/g;
|
|
78
|
+
// Go: encoding/xml Decoder made unsafe by `Strict = false` or a custom
|
|
79
|
+
// `Entity` map that defines expansions. Gated on an xml-decoder hint.
|
|
80
|
+
const GO_XXE_RE =
|
|
81
|
+
/\b\w+\s*\.\s*(?:Strict\s*=\s*false|Entity\s*=\s*(?:map\[string\]string|xml\.HTMLEntity))/g;
|
|
82
|
+
const GO_XML_HINT_RE = /\b(?:encoding\/xml|xml\.NewDecoder|xml\.Decoder)\b/;
|
|
83
|
+
// Ruby: Nokogiri parse whose options enable NOENT / DTDLOAD / replace_entities.
|
|
84
|
+
// Default Nokogiri::XML(xml) is safe. Gated on a Nokogiri/LibXML hint.
|
|
85
|
+
const RUBY_XXE_RE =
|
|
86
|
+
/\.\s*(?:noent|dtdload|dtdvalid|replace_entities)\b/g;
|
|
87
|
+
const RUBY_XML_HINT_RE = /\bNokogiri\s*::\s*XML\b|\bLibXML\b|\bXML::Parser\b/;
|
|
88
|
+
|
|
67
89
|
import { blankComments } from './_comment-strip.js';
|
|
68
90
|
|
|
69
91
|
function _stripLineComment(s, lang) {
|
|
@@ -136,5 +158,50 @@ export function scanXXE(fp, raw) {
|
|
|
136
158
|
return findings;
|
|
137
159
|
}
|
|
138
160
|
|
|
161
|
+
// ── PHP / Go / Ruby: flag the explicit opt-in to external entities ──────────
|
|
162
|
+
const _emitOptIn = (code, re, gate, mkVuln, mkRem) => {
|
|
163
|
+
if (gate && !gate.test(code)) return;
|
|
164
|
+
const r = new RegExp(re.source, re.flags);
|
|
165
|
+
let m;
|
|
166
|
+
const seen = new Set();
|
|
167
|
+
while ((m = r.exec(code))) {
|
|
168
|
+
const line = _lineOf(raw, m.index);
|
|
169
|
+
if (seen.has(line)) continue;
|
|
170
|
+
seen.add(line);
|
|
171
|
+
findings.push({
|
|
172
|
+
id: `xxe:${fp}:${line}`,
|
|
173
|
+
file: fp, line,
|
|
174
|
+
vuln: mkVuln(), severity: 'high', cwe: 'CWE-611',
|
|
175
|
+
stride: 'Information Disclosure',
|
|
176
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
177
|
+
remediation: mkRem(), confidence: 0.8, parser: 'XXE',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (/\.(?:php|phtml)$/i.test(fp)) {
|
|
183
|
+
const code = blankComments(raw, 'py');
|
|
184
|
+
_emitOptIn(code, PHP_XXE_RE, null,
|
|
185
|
+
() => 'XXE: XML parsed with LIBXML_NOENT / LIBXML_DTDLOAD (external entities enabled)',
|
|
186
|
+
() => 'Drop the LIBXML_NOENT / LIBXML_DTDLOAD flags — PHP >= 8.0 disables entity substitution by default, so plain loadXML($xml) / simplexml_load_string($xml) is safe. If you must accept DTDs, set libxml_set_external_entity_loader to reject network/file access.');
|
|
187
|
+
return findings;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (/\.go$/i.test(fp)) {
|
|
191
|
+
const code = blankComments(raw);
|
|
192
|
+
_emitOptIn(code, GO_XXE_RE, GO_XML_HINT_RE,
|
|
193
|
+
() => 'XXE: encoding/xml Decoder configured with Strict=false or a custom Entity map',
|
|
194
|
+
() => 'Go\'s encoding/xml ignores external entities by default — do not set Strict=false or populate Decoder.Entity with untrusted input. Leave the decoder at its defaults.');
|
|
195
|
+
return findings;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (/\.rb$/i.test(fp)) {
|
|
199
|
+
const code = blankComments(raw, 'py');
|
|
200
|
+
_emitOptIn(code, RUBY_XXE_RE, RUBY_XML_HINT_RE,
|
|
201
|
+
() => 'XXE: Nokogiri parse options enable external entities (NOENT / DTDLOAD / replace_entities)',
|
|
202
|
+
() => 'Remove noent/dtdload/replace_entities — Nokogiri::XML(xml) is safe by default (entities are not expanded, DTDs are not loaded). Never enable these on untrusted XML.');
|
|
203
|
+
return findings;
|
|
204
|
+
}
|
|
205
|
+
|
|
139
206
|
return [];
|
|
140
207
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// R8 (PRD §5) — container-image OS-package inventory.
|
|
2
|
+
//
|
|
3
|
+
// container.js reads the Dockerfile (FROM + apt/apk install lines). This reads
|
|
4
|
+
// the ACTUAL installed-package databases from an extracted image filesystem
|
|
5
|
+
// (or the host), catching packages baked into the base image that the
|
|
6
|
+
// Dockerfile never names. Components flow into the existing OSV pipeline + SBOM.
|
|
7
|
+
//
|
|
8
|
+
// Pure parsers (unit-tested) + a path-matching extractor. No daemon; reads the
|
|
9
|
+
// package-DB files that appear when an image layer/tarball is unpacked.
|
|
10
|
+
|
|
11
|
+
// Debian/Ubuntu: /var/lib/dpkg/status — RFC822-style blocks separated by blank
|
|
12
|
+
// lines. Only "install ok installed" entries count (not removed/config-files).
|
|
13
|
+
export function parseDpkgStatus(text) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const block of String(text || '').split(/\n\s*\n/)) {
|
|
16
|
+
const name = (block.match(/^Package:\s*(.+)$/m) || [])[1];
|
|
17
|
+
const version = (block.match(/^Version:\s*(.+)$/m) || [])[1];
|
|
18
|
+
const status = (block.match(/^Status:\s*(.+)$/m) || [])[1] || '';
|
|
19
|
+
if (!name || !version) continue;
|
|
20
|
+
if (!/\binstalled\b/.test(status)) continue;
|
|
21
|
+
out.push({ name: name.trim(), version: version.trim(), ecosystem: 'Debian' });
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Alpine: /lib/apk/db/installed — blocks separated by blank lines; P:=name V:=version.
|
|
27
|
+
export function parseApkInstalled(text) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const block of String(text || '').split(/\n\s*\n/)) {
|
|
30
|
+
const name = (block.match(/^P:(.+)$/m) || [])[1];
|
|
31
|
+
const version = (block.match(/^V:(.+)$/m) || [])[1];
|
|
32
|
+
if (!name || !version) continue;
|
|
33
|
+
out.push({ name: name.trim(), version: version.trim(), ecosystem: 'Alpine' });
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const _PURL = { Debian: (n, v) => `pkg:deb/debian/${n}@${v}`, Alpine: (n, v) => `pkg:apk/alpine/${n}@${v}` };
|
|
39
|
+
|
|
40
|
+
// File-path tells for an unpacked image. Match on path suffix so it works
|
|
41
|
+
// whether the scan root is the rootfs or a subdirectory of it.
|
|
42
|
+
function _dbKind(path) {
|
|
43
|
+
const p = path.replace(/\\/g, '/');
|
|
44
|
+
if (/(?:^|\/)var\/lib\/dpkg\/status$/.test(p)) return 'dpkg';
|
|
45
|
+
if (/(?:^|\/)lib\/apk\/db\/installed$/.test(p)) return 'apk';
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract OS-package components from any image package-DBs present in the scanned
|
|
51
|
+
* files. Returns components in the engine's component shape so they ride the
|
|
52
|
+
* existing OSV/SBOM pipeline. De-dups by ecosystem:name:version.
|
|
53
|
+
*/
|
|
54
|
+
export function extractImagePackages(fileContents) {
|
|
55
|
+
const comps = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
for (const [file, content] of Object.entries(fileContents || {})) {
|
|
58
|
+
const kind = _dbKind(file);
|
|
59
|
+
if (!kind) continue;
|
|
60
|
+
const pkgs = kind === 'dpkg' ? parseDpkgStatus(content) : parseApkInstalled(content);
|
|
61
|
+
for (const p of pkgs) {
|
|
62
|
+
const key = `${p.ecosystem}:${p.name}:${p.version}`;
|
|
63
|
+
if (seen.has(key)) continue;
|
|
64
|
+
seen.add(key);
|
|
65
|
+
comps.push({
|
|
66
|
+
name: p.name, version: p.version, group: '', ecosystem: p.ecosystem,
|
|
67
|
+
purl: (_PURL[p.ecosystem] || (() => null))(p.name, p.version),
|
|
68
|
+
filePath: file, isUnpinned: false, isTransitive: false, isOsPackage: true,
|
|
69
|
+
scope: 'required', reachable: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return comps;
|
|
74
|
+
}
|