@clear-capabilities/agentic-security-scanner 0.76.1 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/.agentic-security/findings.json +320 -9
- package/bin/.agentic-security/last-scan.json +320 -9
- package/bin/.agentic-security/last-scan.json.sig +1 -1
- package/bin/.agentic-security/scan-history.json +17 -377
- package/bin/.agentic-security/streak.json +11 -16
- package/bin/agentic-security.js +33 -2
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/718.index.js +106 -0
- package/dist/824.index.js +126 -0
- package/dist/838.index.js +1 -1
- package/dist/agentic-security.mjs +32 -32
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -7
- package/src/.agentic-security/findings.json +5731 -3933
- package/src/.agentic-security/last-scan.json +5731 -3933
- package/src/.agentic-security/last-scan.json.sig +1 -1
- package/src/.agentic-security/scan-history.json +2533 -887
- package/src/.agentic-security/streak.json +11 -16
- package/src/dataflow/.agentic-security/findings.json +52 -24
- package/src/dataflow/.agentic-security/last-scan.json +52 -24
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -1
- package/src/dataflow/.agentic-security/scan-history.json +101 -134
- package/src/dataflow/.agentic-security/streak.json +8 -10
- package/src/dataflow/async-sequencing.js +16 -7
- package/src/dataflow/builtin-summaries.js +131 -0
- package/src/dataflow/catalog.js +107 -0
- package/src/dataflow/cross-repo.js +75 -1
- package/src/dataflow/engine.js +129 -0
- package/src/dataflow/implicit-flow.js +24 -6
- package/src/dataflow/stub-aware-filter.js +69 -11
- package/src/dataflow/summaries.js +28 -3
- package/src/engine-parallel.js +70 -0
- package/src/engine.js +165 -15
- package/src/ir/.agentic-security/findings.json +757 -16
- package/src/ir/.agentic-security/last-scan.json +757 -16
- package/src/ir/.agentic-security/last-scan.json.sig +1 -1
- package/src/ir/.agentic-security/scan-history.json +545 -138
- package/src/ir/.agentic-security/streak.json +11 -13
- package/src/ir/index.js +22 -1
- package/src/ir/parser-go.js +403 -0
- package/src/ir/parser-js.js +2 -0
- package/src/ir/parser-php.js +330 -0
- package/src/ir/parser-py.helper.py +137 -11
- package/src/ir/parser-rb.js +309 -0
- package/src/posture/.agentic-security/findings.json +407 -84
- package/src/posture/.agentic-security/last-scan.json +407 -84
- package/src/posture/.agentic-security/last-scan.json.sig +1 -1
- package/src/posture/.agentic-security/scan-history.json +16 -4923
- package/src/posture/.agentic-security/streak.json +10 -14
- package/src/posture/calibration.js +14 -0
- package/src/posture/triage.js +13 -0
- package/src/report/.agentic-security/findings.json +6 -5
- package/src/report/.agentic-security/last-scan.json +6 -5
- package/src/report/.agentic-security/last-scan.json.sig +1 -1
- package/src/report/.agentic-security/scan-history.json +3 -300
- package/src/report/.agentic-security/streak.json +7 -8
- package/src/report/index.js +23 -2
- package/src/sast/.agentic-security/findings.json +195 -56
- package/src/sast/.agentic-security/last-scan.json +195 -56
- package/src/sast/.agentic-security/last-scan.json.sig +1 -1
- package/src/sast/.agentic-security/scan-history.json +14 -394
- package/src/sast/.agentic-security/streak.json +10 -13
- package/src/sast/cache-poisoning.js +77 -0
- package/src/sast/comparison-safety.js +73 -0
- package/src/sast/db-taint.js +54 -0
- package/src/sast/graphql.js +127 -0
- package/src/sast/llm-stored-prompt.js +57 -0
- package/src/sast/mutation-xss.js +43 -0
- package/src/sast/nosql-injection.js +5 -0
- package/src/sast/null-byte-injection.js +76 -0
- package/src/sast/redos-nfa.js +338 -0
- package/src/sast/sensitive-data-logging.js +73 -0
- package/src/sast/weak-password-hash.js +77 -0
- package/src/sast/weak-randomness.js +100 -0
- package/src/sca/.agentic-security/findings.json +502 -11
- package/src/sca/.agentic-security/last-scan.json +502 -11
- package/src/sca/.agentic-security/last-scan.json.sig +1 -1
- package/src/sca/.agentic-security/scan-history.json +19 -1
- package/src/sca/.agentic-security/streak.json +6 -6
- package/src/sca/llm-function-extract.js +107 -0
- package/src/sca/vendor-detect.js +91 -0
- package/dist/218.index.js +0 -793
- package/dist/601.index.js +0 -1038
- package/dist/634.index.js +0 -1892
- package/src/integrations/.agentic-security/findings.json +0 -1504
- package/src/integrations/.agentic-security/last-scan.json +0 -1504
- package/src/integrations/.agentic-security/scan-history.json +0 -40
- package/src/integrations/.agentic-security/streak.json +0 -21
- package/src/llm-validator/.agentic-security/findings.json +0 -1891
- package/src/llm-validator/.agentic-security/last-scan.json +0 -1891
- package/src/llm-validator/.agentic-security/last-scan.json.sig +0 -1
- package/src/llm-validator/.agentic-security/scan-history.json +0 -168
- package/src/llm-validator/.agentic-security/streak.json +0 -20
- package/src/lsp/.agentic-security/findings.json +0 -28
- package/src/lsp/.agentic-security/last-scan.json +0 -28
- package/src/lsp/.agentic-security/scan-history.json +0 -79
- package/src/lsp/.agentic-security/streak.json +0 -22
- package/src/mcp/.agentic-security/findings.json +0 -8403
- package/src/mcp/.agentic-security/last-scan.json +0 -8403
- package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
- package/src/mcp/.agentic-security/scan-history.json +0 -1182
- package/src/mcp/.agentic-security/streak.json +0 -22
- package/src/sast/bench-shape/.agentic-security/findings.json +0 -28
- package/src/sast/bench-shape/.agentic-security/last-scan.json +0 -28
- package/src/sast/bench-shape/.agentic-security/scan-history.json +0 -24
- package/src/sast/bench-shape/.agentic-security/streak.json +0 -22
package/src/sast/db-taint.js
CHANGED
|
@@ -213,3 +213,57 @@ export function scanDbTaint(fp, raw) {
|
|
|
213
213
|
}
|
|
214
214
|
return findings;
|
|
215
215
|
}
|
|
216
|
+
|
|
217
|
+
// ── Cross-file stored injection ────────────────────────────────────────────
|
|
218
|
+
//
|
|
219
|
+
// Extends the same-file detector to work across all files in the project.
|
|
220
|
+
// Collects ORM writes and render-of-reads across all files, then matches
|
|
221
|
+
// by field name to find stored XSS / stored injection paths.
|
|
222
|
+
|
|
223
|
+
export function scanDbTaintCrossFile(fileContents) {
|
|
224
|
+
if (!fileContents || typeof fileContents !== 'object') return [];
|
|
225
|
+
const allWrites = [];
|
|
226
|
+
const allReads = [];
|
|
227
|
+
for (const [fp, raw] of Object.entries(fileContents)) {
|
|
228
|
+
if (!raw || typeof raw !== 'string' || raw.length > 500_000) continue;
|
|
229
|
+
const lang = _lang(fp);
|
|
230
|
+
if (!lang) continue;
|
|
231
|
+
const code = blankComments(raw, lang === 'py' ? 'py' : undefined);
|
|
232
|
+
const writes = _findTaintedWrites(code, lang);
|
|
233
|
+
for (const w of writes) allWrites.push({ ...w, file: fp });
|
|
234
|
+
const reads = _findRendersOfReads(code, lang);
|
|
235
|
+
for (const r of reads) allReads.push({ ...r, file: fp });
|
|
236
|
+
}
|
|
237
|
+
const findings = [];
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
for (const w of allWrites) {
|
|
240
|
+
for (const r of allReads) {
|
|
241
|
+
if (w.file === r.file) continue;
|
|
242
|
+
if (w.field !== r.field) continue;
|
|
243
|
+
const id = `db-taint-xfile:${w.file}:${w.line}->${r.file}:${r.line}:${w.field}`;
|
|
244
|
+
if (seen.has(id)) continue;
|
|
245
|
+
seen.add(id);
|
|
246
|
+
findings.push({
|
|
247
|
+
id,
|
|
248
|
+
file: r.file, line: r.line,
|
|
249
|
+
vuln: `Stored XSS via DB round-trip — cross-file (${w.model || '?'}.${w.field})`,
|
|
250
|
+
severity: 'high',
|
|
251
|
+
cwe: 'CWE-79',
|
|
252
|
+
family: 'stored-xss',
|
|
253
|
+
stride: 'Tampering',
|
|
254
|
+
remediation:
|
|
255
|
+
`User content written to ${w.model || '?'}.${w.field} at ${w.file}:${w.line} is later read at ${r.file}:${r.line} and rendered. ` +
|
|
256
|
+
'Sanitize at write time (HTML-escape), escape at render time, and use CSP to block inline scripts.',
|
|
257
|
+
parser: 'DB-TAINT-XFILE',
|
|
258
|
+
confidence: 0.60,
|
|
259
|
+
source: { file: w.file, line: w.line, label: `${w.model || '?'}.${w.field} write` },
|
|
260
|
+
sink: { file: r.file, line: r.line, label: `${w.field} render` },
|
|
261
|
+
trace: [
|
|
262
|
+
{ file: w.file, line: w.line, kind: 'db-write', sourceLabel: `${w.model || '?'}.${w.field} ← user input` },
|
|
263
|
+
{ file: r.file, line: r.line, kind: 'db-read', sourceLabel: `${w.field} → render sink` },
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return findings;
|
|
269
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// GraphQL security detector.
|
|
2
|
+
//
|
|
3
|
+
// Coverage:
|
|
4
|
+
// 1. Query injection — string-concat/template building GraphQL queries from user input
|
|
5
|
+
// 2. Depth/complexity DoS — ApolloServer/express-graphql without depth limiting
|
|
6
|
+
// 3. Introspection in production — introspection enabled or not explicitly disabled
|
|
7
|
+
// 4. Batching DoS — missing batch-size limits
|
|
8
|
+
// 5. Field suggestions — error messages leaking field names
|
|
9
|
+
|
|
10
|
+
function _line(raw, idx) {
|
|
11
|
+
return raw.slice(0, idx).split('\n').length;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function scanGraphQL(fp, raw) {
|
|
15
|
+
if (!fp || !raw || typeof raw !== 'string') return [];
|
|
16
|
+
if (raw.length > 500_000) return [];
|
|
17
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py|go|rb)$/i.test(fp)) return [];
|
|
18
|
+
|
|
19
|
+
const findings = [];
|
|
20
|
+
|
|
21
|
+
// 1. Query injection: string concat/template into GraphQL query strings
|
|
22
|
+
const queryConcat = /(?:query|mutation)\s*[:=]\s*(?:`[^`]*\$\{[^}]*\}|["'][^"']*["']\s*\+\s*\w)/g;
|
|
23
|
+
for (const m of raw.matchAll(queryConcat)) {
|
|
24
|
+
const ln = _line(raw, m.index);
|
|
25
|
+
const after = raw.slice(m.index, m.index + 300);
|
|
26
|
+
if (/\b(?:gql|graphql|\.query|\.mutate)\b/i.test(after) || /\b(?:query|mutation)\s*\{/.test(after)) {
|
|
27
|
+
findings.push({
|
|
28
|
+
id: `graphql-injection:${fp}:${ln}`,
|
|
29
|
+
file: fp, line: ln,
|
|
30
|
+
vuln: 'GraphQL Query Injection — user input concatenated into query string',
|
|
31
|
+
severity: 'high',
|
|
32
|
+
family: 'graphql-injection',
|
|
33
|
+
cwe: 'CWE-943',
|
|
34
|
+
parser: 'GRAPHQL',
|
|
35
|
+
confidence: 0.75,
|
|
36
|
+
description: 'GraphQL query is built via string concatenation or template interpolation with variables that may contain user input. An attacker can inject additional fields, aliases, or mutations.',
|
|
37
|
+
remediation: 'Use parameterized GraphQL queries with variables: `query GetUser($id: ID!) { user(id: $id) { name } }` and pass variables separately.',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Depth/complexity DoS: ApolloServer/createYoga/express-graphql without depth limit
|
|
43
|
+
if (/\b(?:ApolloServer|createYoga|graphqlHTTP|makeExecutableSchema)\b/.test(raw)) {
|
|
44
|
+
if (!/\b(?:depthLimit|graphql-depth-limit|graphql-validation-complexity|costAnalysis|maxDepth|queryDepthLimit)\b/.test(raw)) {
|
|
45
|
+
const m = raw.match(/\b(ApolloServer|createYoga|graphqlHTTP)\b/);
|
|
46
|
+
if (m) {
|
|
47
|
+
findings.push({
|
|
48
|
+
id: `graphql-depth-dos:${fp}:${_line(raw, m.index)}`,
|
|
49
|
+
file: fp, line: _line(raw, m.index),
|
|
50
|
+
vuln: 'GraphQL Depth/Complexity DoS — no depth limiting configured',
|
|
51
|
+
severity: 'medium',
|
|
52
|
+
family: 'graphql-dos',
|
|
53
|
+
cwe: 'CWE-400',
|
|
54
|
+
parser: 'GRAPHQL',
|
|
55
|
+
confidence: 0.70,
|
|
56
|
+
description: 'GraphQL server is configured without query depth or complexity limits. An attacker can send deeply nested queries that exhaust server resources.',
|
|
57
|
+
remediation: 'Add graphql-depth-limit or graphql-query-complexity to validationRules: new ApolloServer({ validationRules: [depthLimit(10)] }).',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. Introspection in production
|
|
64
|
+
if (/\bintrospection\s*:\s*true\b/.test(raw)) {
|
|
65
|
+
const m = raw.match(/\bintrospection\s*:\s*true\b/);
|
|
66
|
+
if (m) {
|
|
67
|
+
findings.push({
|
|
68
|
+
id: `graphql-introspection:${fp}:${_line(raw, m.index)}`,
|
|
69
|
+
file: fp, line: _line(raw, m.index),
|
|
70
|
+
vuln: 'GraphQL Introspection Enabled — schema exposed to clients',
|
|
71
|
+
severity: 'medium',
|
|
72
|
+
family: 'graphql-introspection',
|
|
73
|
+
cwe: 'CWE-200',
|
|
74
|
+
parser: 'GRAPHQL',
|
|
75
|
+
confidence: 0.80,
|
|
76
|
+
description: 'Introspection is explicitly enabled. Attackers can query __schema to discover all types, fields, and mutations — accelerating further attacks.',
|
|
77
|
+
remediation: 'Disable introspection in production: new ApolloServer({ introspection: process.env.NODE_ENV !== "production" }).',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4. Batching DoS: missing batch limits
|
|
83
|
+
if (/\b(?:ApolloServer|ApolloGateway)\b/.test(raw)) {
|
|
84
|
+
if (!/\b(?:allowBatchedHttpRequests\s*:\s*false|maxBatchSize|batching\s*:\s*false)\b/.test(raw)) {
|
|
85
|
+
const m = raw.match(/\b(ApolloServer|ApolloGateway)\b/);
|
|
86
|
+
if (m) {
|
|
87
|
+
const after = raw.slice(m.index, m.index + 500);
|
|
88
|
+
if (/allowBatchedHttpRequests\s*:\s*true/.test(after) && !/maxBatchSize/.test(after)) {
|
|
89
|
+
findings.push({
|
|
90
|
+
id: `graphql-batch-dos:${fp}:${_line(raw, m.index)}`,
|
|
91
|
+
file: fp, line: _line(raw, m.index),
|
|
92
|
+
vuln: 'GraphQL Batch DoS — batching enabled without size limit',
|
|
93
|
+
severity: 'medium',
|
|
94
|
+
family: 'graphql-dos',
|
|
95
|
+
cwe: 'CWE-400',
|
|
96
|
+
parser: 'GRAPHQL',
|
|
97
|
+
confidence: 0.65,
|
|
98
|
+
description: 'Batched HTTP requests are enabled without a maxBatchSize limit. Attackers can send thousands of operations in a single HTTP request.',
|
|
99
|
+
remediation: 'Set allowBatchedHttpRequests: false, or add maxBatchSize: 10 to limit batch size.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 5. Field suggestions leaking schema info
|
|
107
|
+
if (/\b(?:includeStacktraceInErrorResponses|formatError|debug\s*:\s*true)\b/.test(raw)) {
|
|
108
|
+
if (/\b(?:ApolloServer|graphqlHTTP)\b/.test(raw)) {
|
|
109
|
+
for (const m of raw.matchAll(/\bdebug\s*:\s*true\b/g)) {
|
|
110
|
+
findings.push({
|
|
111
|
+
id: `graphql-debug:${fp}:${_line(raw, m.index)}`,
|
|
112
|
+
file: fp, line: _line(raw, m.index),
|
|
113
|
+
vuln: 'GraphQL Debug Mode — error details exposed to clients',
|
|
114
|
+
severity: 'low',
|
|
115
|
+
family: 'graphql-introspection',
|
|
116
|
+
cwe: 'CWE-209',
|
|
117
|
+
parser: 'GRAPHQL',
|
|
118
|
+
confidence: 0.70,
|
|
119
|
+
description: 'Debug mode exposes stack traces and field suggestion in error responses, leaking internal schema structure.',
|
|
120
|
+
remediation: 'Set debug: false or includeStacktraceInErrorResponses: false in production.',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return findings;
|
|
127
|
+
}
|
|
@@ -101,3 +101,60 @@ export function scanStoredPromptInjection(fp, raw) {
|
|
|
101
101
|
}
|
|
102
102
|
return findings;
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
const ORM_READ_RE = /\b(?:findOne|findUnique|findFirst|findById|findByPk|get_object_or_404|objects\.get|objects\.filter|\.query\s*\()\b/;
|
|
106
|
+
|
|
107
|
+
export function scanStoredPromptInjectionCrossFile(fileContents) {
|
|
108
|
+
if (!fileContents || typeof fileContents !== 'object') return [];
|
|
109
|
+
const llmSinks = [];
|
|
110
|
+
const ormReads = [];
|
|
111
|
+
for (const [fp, raw] of Object.entries(fileContents)) {
|
|
112
|
+
if (!raw || typeof raw !== 'string' || raw.length > 500_000) continue;
|
|
113
|
+
const lang = _lang(fp);
|
|
114
|
+
if (!lang) continue;
|
|
115
|
+
for (const [plang, pat, label] of LLM_CALL_PATTERNS) {
|
|
116
|
+
if (plang !== lang) continue;
|
|
117
|
+
const re = new RegExp(pat.source, pat.flags);
|
|
118
|
+
let m;
|
|
119
|
+
while ((m = re.exec(raw))) {
|
|
120
|
+
const varName = (m[1] || '').split('.')[0];
|
|
121
|
+
if (varName) llmSinks.push({ file: fp, line: _lineOf(raw, m.index), varName, label });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (ORM_READ_RE.test(raw)) {
|
|
125
|
+
const lines = raw.split('\n');
|
|
126
|
+
for (let i = 0; i < lines.length; i++) {
|
|
127
|
+
if (ORM_READ_RE.test(lines[i])) {
|
|
128
|
+
const assignMatch = lines[i].match(/(\w+)\s*=\s*/);
|
|
129
|
+
if (assignMatch) ormReads.push({ file: fp, line: i + 1, varName: assignMatch[1] });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const findings = [];
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
for (const sink of llmSinks) {
|
|
137
|
+
for (const read of ormReads) {
|
|
138
|
+
if (sink.file === read.file) continue;
|
|
139
|
+
if (sink.varName !== read.varName) continue;
|
|
140
|
+
const id = `llm-stored-prompt-xfile:${read.file}:${read.line}->${sink.file}:${sink.line}`;
|
|
141
|
+
if (seen.has(id)) continue;
|
|
142
|
+
seen.add(id);
|
|
143
|
+
findings.push({
|
|
144
|
+
id,
|
|
145
|
+
file: sink.file, line: sink.line,
|
|
146
|
+
vuln: `LLM Stored-Prompt Injection — cross-file ORM→LLM (${sink.label})`,
|
|
147
|
+
severity: 'high',
|
|
148
|
+
cwe: 'CWE-1336',
|
|
149
|
+
family: 'llm-prompt-injection',
|
|
150
|
+
parser: 'LLM-STORED-PROMPT-XFILE',
|
|
151
|
+
confidence: 0.55,
|
|
152
|
+
description: `Variable "${sink.varName}" loaded from ORM at ${read.file}:${read.line} is used as LLM system prompt at ${sink.file}:${sink.line}. An attacker who can modify the DB record can inject instructions.`,
|
|
153
|
+
remediation: 'Validate stored prompts against a schema or signing key. Wrap DB-loaded text in delimiters and a role-isolation frame.',
|
|
154
|
+
source: { file: read.file, line: read.line, label: `ORM read → ${read.varName}` },
|
|
155
|
+
sink: { file: sink.file, line: sink.line, label: `LLM ${sink.label}` },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return findings;
|
|
160
|
+
}
|
package/src/sast/mutation-xss.js
CHANGED
|
@@ -83,5 +83,48 @@ export function scanMutationXSS(fp, raw) {
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Email template XSS: user data rendered into HTML email body
|
|
87
|
+
const emailSinkRe = /\b(?:sendMail|transporter\.sendMail|sg\.send|ses\.sendEmail|mailgun\.messages\.create|send_email|mail\.send)\s*\(/g;
|
|
88
|
+
for (const em of code.matchAll(emailSinkRe)) {
|
|
89
|
+
const after = code.slice(em.index, em.index + 500);
|
|
90
|
+
if (!/\bhtml\s*:/i.test(after)) continue;
|
|
91
|
+
const taintHint = /(?:req\.|request\.|params|body|query|user\.\w+|data\.\w+)/.test(after);
|
|
92
|
+
const templateHint = /(?:ejs\.render|pug\.render|mustache\.render|handlebars\.compile|marked\.parse|render_template|Jinja2|\.render\s*\()/.test(after);
|
|
93
|
+
if (!taintHint && !templateHint) continue;
|
|
94
|
+
const line = lineOf(raw, em.index);
|
|
95
|
+
push({
|
|
96
|
+
id: `email-template-xss:${fp}:${line}`,
|
|
97
|
+
file: fp, line,
|
|
98
|
+
vuln: 'Email Template XSS — user data rendered into HTML email body',
|
|
99
|
+
severity: 'high',
|
|
100
|
+
cwe: 'CWE-79',
|
|
101
|
+
family: 'email-template-xss',
|
|
102
|
+
stride: 'Tampering',
|
|
103
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
104
|
+
remediation: 'HTML-escape user-supplied data before inserting into email templates. Use the template engine\'s auto-escape mode. Consider rendering text-only emails for user-generated content.',
|
|
105
|
+
parser: 'EMAIL-XSS',
|
|
106
|
+
confidence: 0.65,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Markdown → HTML → innerHTML chain
|
|
111
|
+
const markdownHtmlRe = /\bmarked\.parse\s*\([^)]*(?:req\.|request\.|params|body|query|user)/g;
|
|
112
|
+
for (const mm of code.matchAll(markdownHtmlRe)) {
|
|
113
|
+
const line = lineOf(raw, mm.index);
|
|
114
|
+
push({
|
|
115
|
+
id: `markdown-xss:${fp}:${line}`,
|
|
116
|
+
file: fp, line,
|
|
117
|
+
vuln: 'Markdown→HTML XSS — user-supplied Markdown rendered to HTML without sanitization',
|
|
118
|
+
severity: 'high',
|
|
119
|
+
cwe: 'CWE-79',
|
|
120
|
+
family: 'xss',
|
|
121
|
+
stride: 'Tampering',
|
|
122
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
123
|
+
remediation: 'Pipe marked output through DOMPurify: `const html = DOMPurify.sanitize(marked.parse(userInput))`. Or use marked with `sanitize: true` option.',
|
|
124
|
+
parser: 'MARKDOWN-XSS',
|
|
125
|
+
confidence: 0.70,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
86
129
|
return findings;
|
|
87
130
|
}
|
|
@@ -18,6 +18,9 @@ const DYNAMO_EXPR_CONCAT_RE = /(?:FilterExpression|ConditionExpression|KeyCondit
|
|
|
18
18
|
|
|
19
19
|
const PY_MONGO_FIND_REQ = /\.\s*(?:find|find_one|update_one|update_many|delete_one|delete_many)\s*\(\s*request\s*\.\s*(?:json|data|args|form)/g;
|
|
20
20
|
|
|
21
|
+
const MONGO_AGGREGATE_RE = /\.\s*aggregate\s*\(\s*\[[\s\S]{0,300}?\{\s*\$(?:match|expr|where|function|redact|lookup)\s*:[\s\S]{0,200}?(?:req|request|params|query|body|input)\b/g;
|
|
22
|
+
const MONGO_MAPREDUCE_RE = /\.\s*mapReduce\s*\([\s\S]{0,300}?(?:req|request|params|query|body|input)\b/g;
|
|
23
|
+
|
|
21
24
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
22
25
|
|
|
23
26
|
export function scanNoSQLInjection(fp, raw) {
|
|
@@ -33,6 +36,8 @@ export function scanNoSQLInjection(fp, raw) {
|
|
|
33
36
|
[MONGO_WHERE_RE, 'mongo-where', 'NoSQL Injection: MongoDB $where with user-controlled string', 0.90],
|
|
34
37
|
[MONGO_FIND_REQ_OBJ_RE, 'mongo-find', 'NoSQL Injection: MongoDB query with raw request object (operator injection)', 0.80],
|
|
35
38
|
[DYNAMO_EXPR_CONCAT_RE, 'dynamo-expr', 'NoSQL Injection: DynamoDB Expression built via string concatenation', 0.85],
|
|
39
|
+
[MONGO_AGGREGATE_RE, 'mongo-aggregate', 'NoSQL Injection: MongoDB aggregate pipeline with user-controlled stage', 0.80],
|
|
40
|
+
[MONGO_MAPREDUCE_RE, 'mongo-mapreduce', 'NoSQL Injection: MongoDB mapReduce with user-controlled function', 0.85],
|
|
36
41
|
]) {
|
|
37
42
|
const r = new RegExp(re.source, re.flags);
|
|
38
43
|
while ((m = r.exec(code))) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Null-byte and path normalization injection detector.
|
|
2
|
+
//
|
|
3
|
+
// Detects patterns where file extension checks can be bypassed via
|
|
4
|
+
// null-byte truncation (%00, \0) or missing path normalization before
|
|
5
|
+
// filesystem operations.
|
|
6
|
+
|
|
7
|
+
function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
|
|
8
|
+
|
|
9
|
+
const EXT_CHECK_RE = /\.(?:endsWith|match|test|includes)\s*\(\s*['"]\.(?:jpg|jpeg|png|gif|pdf|doc|docx|csv|txt|zip|svg|webp|mp4)/gi;
|
|
10
|
+
const SPLITEXT_RE = /(?:path\.extname|os\.path\.splitext|filepath\.Ext|pathinfo)\s*\(/g;
|
|
11
|
+
|
|
12
|
+
const FS_SINK_RE = /(?:fs\.readFile|fs\.readFileSync|fs\.createReadStream|fs\.writeFile|fs\.writeFileSync|open\s*\(|os\.open|sendFile|send_file|send_from_directory|filepath\.Join|os\.path\.join|path\.join)\s*\(/g;
|
|
13
|
+
|
|
14
|
+
const NORMALIZATION_RE = /(?:path\.normalize|path\.resolve|os\.path\.abspath|os\.path\.realpath|filepath\.Clean|filepath\.Abs|realpath|basename)\s*\(/;
|
|
15
|
+
const NULL_STRIP_RE = /(?:replace\s*\(\s*\/\\0\/|replace\s*\(\s*['"]\\0['"]|\.replace\s*\(\s*\/\\x00\/|\.replace\s*\(\s*['"]%00['"])/;
|
|
16
|
+
|
|
17
|
+
export function scanNullByteInjection(fp, raw) {
|
|
18
|
+
if (!fp || !raw || typeof raw !== 'string') return [];
|
|
19
|
+
if (raw.length > 500_000) return [];
|
|
20
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py|go|rb|php|phtml)$/i.test(fp)) return [];
|
|
21
|
+
|
|
22
|
+
const findings = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
|
|
25
|
+
// Pattern: extension check without null-byte stripping before FS operation
|
|
26
|
+
for (const extMatch of raw.matchAll(EXT_CHECK_RE)) {
|
|
27
|
+
const checkLine = _line(raw, extMatch.index);
|
|
28
|
+
// Look ahead for FS operation within 15 lines
|
|
29
|
+
const after = raw.slice(extMatch.index, extMatch.index + 1000);
|
|
30
|
+
if (!FS_SINK_RE.test(after)) continue;
|
|
31
|
+
// Check if normalization or null-byte stripping exists between check and sink
|
|
32
|
+
const between = after.slice(0, after.search(FS_SINK_RE));
|
|
33
|
+
if (NORMALIZATION_RE.test(between) || NULL_STRIP_RE.test(between)) continue;
|
|
34
|
+
const id = `null-byte:${fp}:${checkLine}`;
|
|
35
|
+
if (seen.has(id)) continue;
|
|
36
|
+
seen.add(id);
|
|
37
|
+
findings.push({
|
|
38
|
+
id,
|
|
39
|
+
file: fp, line: checkLine,
|
|
40
|
+
vuln: 'Null-Byte Injection Risk — extension check without path normalization before FS operation',
|
|
41
|
+
severity: 'medium',
|
|
42
|
+
family: 'path-normalization',
|
|
43
|
+
cwe: 'CWE-158',
|
|
44
|
+
parser: 'NULL-BYTE',
|
|
45
|
+
confidence: 0.60,
|
|
46
|
+
description: 'A file extension check is performed, then the path is passed to a filesystem operation without normalization or null-byte stripping. An attacker can bypass the extension check with a null byte: "malicious.php%00.jpg" passes the .jpg check but the filesystem may truncate at the null byte.',
|
|
47
|
+
remediation: 'Always normalize paths before extension checks: path.resolve(uploadsDir, path.basename(filename)). Strip null bytes: filename.replace(/\\0/g, ""). Validate the resolved path is within the expected directory.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Pattern: splitext/extname without null-byte stripping
|
|
52
|
+
for (const splitMatch of raw.matchAll(SPLITEXT_RE)) {
|
|
53
|
+
const line = _line(raw, splitMatch.index);
|
|
54
|
+
const context = raw.slice(Math.max(0, splitMatch.index - 200), splitMatch.index + 200);
|
|
55
|
+
if (NULL_STRIP_RE.test(context) || NORMALIZATION_RE.test(context)) continue;
|
|
56
|
+
// Only fire if user input is nearby
|
|
57
|
+
if (!/(?:req\.|request\.|params|query|body|upload|file|filename|user_input|\$_FILES|\$_GET)/i.test(context)) continue;
|
|
58
|
+
const id = `path-norm:${fp}:${line}`;
|
|
59
|
+
if (seen.has(id)) continue;
|
|
60
|
+
seen.add(id);
|
|
61
|
+
findings.push({
|
|
62
|
+
id,
|
|
63
|
+
file: fp, line,
|
|
64
|
+
vuln: 'Path Normalization Gap — extension extraction without null-byte/traversal sanitization',
|
|
65
|
+
severity: 'medium',
|
|
66
|
+
family: 'path-normalization',
|
|
67
|
+
cwe: 'CWE-176',
|
|
68
|
+
parser: 'NULL-BYTE',
|
|
69
|
+
confidence: 0.55,
|
|
70
|
+
description: 'Path extension is extracted from user-supplied input without prior normalization. Unicode normalization attacks or null-byte truncation can bypass the extension check.',
|
|
71
|
+
remediation: 'Normalize the path first: const safeName = path.basename(userInput).replace(/\\0/g, ""); then check the extension.',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return findings;
|
|
76
|
+
}
|