@clear-capabilities/agentic-security-scanner 0.77.0 → 0.79.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 +1907 -0
- package/bin/.agentic-security/last-scan.json +1907 -0
- package/bin/.agentic-security/last-scan.json.sig +1 -0
- package/bin/.agentic-security/scan-history.json +166 -0
- package/bin/.agentic-security/streak.json +20 -0
- package/bin/agentic-security.js +55 -9
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/476.index.js +5 -5
- package/dist/637.index.js +1 -1
- package/dist/700.index.js +138 -0
- package/dist/718.index.js +159 -0
- package/dist/824.index.js +126 -0
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +5 -0
- package/dist/agentic-security.mjs +32 -32
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- 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 +181 -8
- 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 +270 -19
- package/src/integrations/index.js +2 -1
- package/src/ir/callgraph.js +27 -7
- 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/llm-validator/index.js +7 -5
- package/src/mcp/audit.js +5 -0
- package/src/posture/calibration-drift.js +2 -1
- package/src/posture/calibration.js +16 -1
- package/src/posture/fix-history.js +8 -2
- package/src/posture/profile.js +4 -5
- package/src/posture/rule-overrides.js +2 -3
- package/src/posture/rule-pack-signing.js +2 -3
- package/src/posture/rule-synthesis.js +5 -6
- package/src/posture/security-trend.js +4 -7
- package/src/posture/state-dir.js +124 -0
- package/src/posture/streak.js +3 -0
- package/src/posture/suppressions.js +5 -8
- package/src/posture/triage.js +16 -5
- package/src/posture/validator-metrics.js +3 -6
- package/src/report/index.js +23 -2
- package/src/sast/cache-poisoning.js +77 -0
- package/src/sast/comparison-safety.js +73 -0
- package/src/sast/db-taint.js +78 -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/rust.js +26 -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/binary-metadata.js +124 -0
- package/src/sca/llm-function-extract.js +107 -0
- package/src/sca/py-package-functions.js +118 -0
- package/src/sca/vendor-detect.js +144 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// ReDoS NFA analyzer — detects catastrophic backtracking in regex patterns.
|
|
2
|
+
//
|
|
3
|
+
// Builds a simplified NFA from a regex body string and detects superlinear
|
|
4
|
+
// ambiguity: two distinct paths through a quantifier cycle that accept the
|
|
5
|
+
// same character. This is the core condition for exponential backtracking.
|
|
6
|
+
//
|
|
7
|
+
// Scope: character classes, alternation, quantifiers (+*?{n,m}), groups,
|
|
8
|
+
// escapes, anchors. Unknown constructs → treated as safe (opaque atom).
|
|
9
|
+
// Body-length cap: 500 chars → skip (too complex for static analysis).
|
|
10
|
+
//
|
|
11
|
+
// Also exports extractors for Python re.compile() and Java Pattern.compile().
|
|
12
|
+
|
|
13
|
+
const MAX_BODY_LEN = 500;
|
|
14
|
+
|
|
15
|
+
// ── Regex parser ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function parseRegex(body) {
|
|
18
|
+
let pos = 0;
|
|
19
|
+
const src = body;
|
|
20
|
+
|
|
21
|
+
function peek() { return pos < src.length ? src[pos] : null; }
|
|
22
|
+
function advance() { return src[pos++]; }
|
|
23
|
+
|
|
24
|
+
function parseAlternation() {
|
|
25
|
+
const branches = [parseConcat()];
|
|
26
|
+
while (peek() === '|') {
|
|
27
|
+
advance();
|
|
28
|
+
branches.push(parseConcat());
|
|
29
|
+
}
|
|
30
|
+
return branches.length === 1 ? branches[0] : { type: 'alt', branches };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseConcat() {
|
|
34
|
+
const items = [];
|
|
35
|
+
while (pos < src.length && peek() !== ')' && peek() !== '|') {
|
|
36
|
+
items.push(parseQuantified());
|
|
37
|
+
}
|
|
38
|
+
return items.length === 1 ? items[0] : { type: 'concat', items };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseQuantified() {
|
|
42
|
+
let atom = parseAtom();
|
|
43
|
+
if (!atom) return { type: 'literal', ch: '' };
|
|
44
|
+
while (pos < src.length) {
|
|
45
|
+
const c = peek();
|
|
46
|
+
if (c === '*') { advance(); atom = { type: 'star', child: atom }; }
|
|
47
|
+
else if (c === '+') { advance(); atom = { type: 'plus', child: atom }; }
|
|
48
|
+
else if (c === '?') { advance(); atom = { type: 'opt', child: atom }; }
|
|
49
|
+
else if (c === '{') {
|
|
50
|
+
const saved = pos;
|
|
51
|
+
advance();
|
|
52
|
+
let numStr = '';
|
|
53
|
+
while (pos < src.length && /[\d,]/.test(peek())) numStr += advance();
|
|
54
|
+
if (peek() === '}') {
|
|
55
|
+
advance();
|
|
56
|
+
const parts = numStr.split(',');
|
|
57
|
+
const max = parts.length > 1 ? (parts[1] ? parseInt(parts[1]) : Infinity) : parseInt(parts[0]);
|
|
58
|
+
if (max > 1 || max === Infinity) {
|
|
59
|
+
atom = { type: 'star', child: atom };
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
pos = saved;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
} else break;
|
|
66
|
+
if (peek() === '?') advance(); // lazy modifier
|
|
67
|
+
}
|
|
68
|
+
return atom;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseAtom() {
|
|
72
|
+
const c = peek();
|
|
73
|
+
if (c === null || c === ')' || c === '|') return null;
|
|
74
|
+
if (c === '(') {
|
|
75
|
+
advance();
|
|
76
|
+
if (peek() === '?') {
|
|
77
|
+
advance();
|
|
78
|
+
// Non-capturing group or lookahead — skip modifier chars
|
|
79
|
+
while (pos < src.length && peek() !== ':' && peek() !== ')' && /[imsx<!=P]/.test(peek())) advance();
|
|
80
|
+
if (peek() === ':' || peek() === ')') {
|
|
81
|
+
if (peek() === ':') advance();
|
|
82
|
+
if (peek() === ')') { advance(); return { type: 'literal', ch: '' }; }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const inner = parseAlternation();
|
|
86
|
+
if (peek() === ')') advance();
|
|
87
|
+
return { type: 'group', child: inner };
|
|
88
|
+
}
|
|
89
|
+
if (c === '[') return parseCharClass();
|
|
90
|
+
if (c === '\\') {
|
|
91
|
+
advance();
|
|
92
|
+
const esc = advance();
|
|
93
|
+
if (!esc) return { type: 'literal', ch: '\\' };
|
|
94
|
+
if (esc === 'd') return { type: 'class', chars: '0123456789' };
|
|
95
|
+
if (esc === 'w') return { type: 'class', chars: 'azAZ09_' };
|
|
96
|
+
if (esc === 's') return { type: 'class', chars: ' \t\n\r' };
|
|
97
|
+
if (esc === 'D' || esc === 'W' || esc === 'S') return { type: 'class', chars: 'ANY' };
|
|
98
|
+
if (esc === 'b' || esc === 'B') return { type: 'literal', ch: '' }; // anchor
|
|
99
|
+
return { type: 'literal', ch: esc };
|
|
100
|
+
}
|
|
101
|
+
if (c === '.') { advance(); return { type: 'class', chars: 'ANY' }; }
|
|
102
|
+
if (c === '^' || c === '$') { advance(); return { type: 'literal', ch: '' }; }
|
|
103
|
+
advance();
|
|
104
|
+
return { type: 'literal', ch: c };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseCharClass() {
|
|
108
|
+
advance(); // [
|
|
109
|
+
let chars = '';
|
|
110
|
+
let negated = false;
|
|
111
|
+
if (peek() === '^') { negated = true; advance(); }
|
|
112
|
+
if (peek() === ']') { chars += advance(); }
|
|
113
|
+
while (pos < src.length && peek() !== ']') {
|
|
114
|
+
if (peek() === '\\') {
|
|
115
|
+
advance();
|
|
116
|
+
const esc = advance();
|
|
117
|
+
if (esc === 'd') chars += '0123456789';
|
|
118
|
+
else if (esc === 'w') chars += 'azAZ09_';
|
|
119
|
+
else if (esc === 's') chars += ' \t\n\r';
|
|
120
|
+
else chars += (esc || '');
|
|
121
|
+
} else {
|
|
122
|
+
chars += advance();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (peek() === ']') advance();
|
|
126
|
+
return { type: 'class', chars: negated ? 'ANY' : chars };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const tree = parseAlternation();
|
|
131
|
+
return { ok: true, tree };
|
|
132
|
+
} catch {
|
|
133
|
+
return { ok: false, tree: null };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Ambiguity detection ─────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
function classOverlaps(a, b) {
|
|
140
|
+
if (a === 'ANY' || b === 'ANY') return true;
|
|
141
|
+
for (const ch of a) {
|
|
142
|
+
if (b.includes(ch)) return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function collectFirstChars(node) {
|
|
148
|
+
if (!node) return [];
|
|
149
|
+
switch (node.type) {
|
|
150
|
+
case 'literal':
|
|
151
|
+
return node.ch ? [node.ch] : [];
|
|
152
|
+
case 'class':
|
|
153
|
+
return [node.chars];
|
|
154
|
+
case 'group':
|
|
155
|
+
return collectFirstChars(node.child);
|
|
156
|
+
case 'concat':
|
|
157
|
+
for (const item of (node.items || [])) {
|
|
158
|
+
const fc = collectFirstChars(item);
|
|
159
|
+
if (fc.length) return fc;
|
|
160
|
+
if (!canBeEmpty(item)) return fc;
|
|
161
|
+
}
|
|
162
|
+
return [];
|
|
163
|
+
case 'alt':
|
|
164
|
+
return (node.branches || []).flatMap(collectFirstChars);
|
|
165
|
+
case 'star':
|
|
166
|
+
case 'plus':
|
|
167
|
+
case 'opt':
|
|
168
|
+
return collectFirstChars(node.child);
|
|
169
|
+
default:
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function canBeEmpty(node) {
|
|
175
|
+
if (!node) return true;
|
|
176
|
+
switch (node.type) {
|
|
177
|
+
case 'literal': return !node.ch;
|
|
178
|
+
case 'class': return false;
|
|
179
|
+
case 'group': return canBeEmpty(node.child);
|
|
180
|
+
case 'concat': return (node.items || []).every(canBeEmpty);
|
|
181
|
+
case 'alt': return (node.branches || []).some(canBeEmpty);
|
|
182
|
+
case 'star': case 'opt': return true;
|
|
183
|
+
case 'plus': return canBeEmpty(node.child);
|
|
184
|
+
default: return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function detectSuperlinear(tree) {
|
|
189
|
+
if (!tree) return { unsafe: false };
|
|
190
|
+
const reasons = [];
|
|
191
|
+
_walk(tree, reasons, 0);
|
|
192
|
+
return reasons.length ? { unsafe: true, reason: reasons[0] } : { unsafe: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function _walk(node, reasons, quantifierDepth) {
|
|
196
|
+
if (!node || reasons.length) return;
|
|
197
|
+
switch (node.type) {
|
|
198
|
+
case 'star':
|
|
199
|
+
case 'plus': {
|
|
200
|
+
if (quantifierDepth > 0) {
|
|
201
|
+
reasons.push('nested quantifier');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
_walk(node.child, reasons, quantifierDepth + 1);
|
|
205
|
+
// Unwrap group to check inner structure
|
|
206
|
+
const inner = node.child && node.child.type === 'group' ? node.child.child : node.child;
|
|
207
|
+
if (inner && inner.type === 'alt') {
|
|
208
|
+
const branches = inner.branches || [];
|
|
209
|
+
for (let i = 0; i < branches.length; i++) {
|
|
210
|
+
const fc_i = collectFirstChars(branches[i]);
|
|
211
|
+
for (let j = i + 1; j < branches.length; j++) {
|
|
212
|
+
const fc_j = collectFirstChars(branches[j]);
|
|
213
|
+
for (const a of fc_i) {
|
|
214
|
+
for (const b of fc_j) {
|
|
215
|
+
if (classOverlaps(a, b)) {
|
|
216
|
+
reasons.push('alternation ambiguity under quantifier');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (inner && inner.type === 'concat') {
|
|
225
|
+
const items = inner.items || [];
|
|
226
|
+
if (items.length >= 2) {
|
|
227
|
+
const first = collectFirstChars(items[0]);
|
|
228
|
+
for (let k = 1; k < items.length; k++) {
|
|
229
|
+
// Check if all items before k can be empty (nullable prefix)
|
|
230
|
+
const prefixNullable = items.slice(0, k).every(canBeEmpty);
|
|
231
|
+
const prevNullable = canBeEmpty(items[k - 1]) || items[k - 1].type === 'star' || items[k - 1].type === 'opt';
|
|
232
|
+
if (prevNullable || prefixNullable) {
|
|
233
|
+
const fc_k = collectFirstChars(items[k]);
|
|
234
|
+
for (const a of first) {
|
|
235
|
+
for (const b of fc_k) {
|
|
236
|
+
if (classOverlaps(a, b)) {
|
|
237
|
+
reasons.push('overlapping nullable prefix in quantifier');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case 'opt':
|
|
249
|
+
_walk(node.child, reasons, quantifierDepth);
|
|
250
|
+
break;
|
|
251
|
+
case 'group':
|
|
252
|
+
_walk(node.child, reasons, quantifierDepth);
|
|
253
|
+
break;
|
|
254
|
+
case 'concat':
|
|
255
|
+
for (const item of (node.items || [])) _walk(item, reasons, quantifierDepth);
|
|
256
|
+
break;
|
|
257
|
+
case 'alt':
|
|
258
|
+
for (const b of (node.branches || [])) _walk(b, reasons, quantifierDepth);
|
|
259
|
+
break;
|
|
260
|
+
default:
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
export function isUnsafeRegex(body) {
|
|
268
|
+
if (!body || typeof body !== 'string') return { unsafe: false };
|
|
269
|
+
if (body.length > MAX_BODY_LEN) return { unsafe: false };
|
|
270
|
+
if (!/[*+{]/.test(body)) return { unsafe: false };
|
|
271
|
+
const parsed = parseRegex(body);
|
|
272
|
+
if (!parsed.ok) return { unsafe: false };
|
|
273
|
+
return detectSuperlinear(parsed.tree);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function extractJsRegexBodies(code) {
|
|
277
|
+
const out = [];
|
|
278
|
+
// Regex literals: /pattern/flags
|
|
279
|
+
for (const m of code.matchAll(/\/([^/\n]+)\/[gimsuy]*/g)) {
|
|
280
|
+
out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
|
|
281
|
+
}
|
|
282
|
+
// new RegExp("pattern")
|
|
283
|
+
for (const m of code.matchAll(/new\s+RegExp\s*\(\s*['"]([^'"]+)['"]/g)) {
|
|
284
|
+
out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function extractPyRegexBodies(code) {
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const m of code.matchAll(/\bre\.(?:compile|match|search|sub|findall|fullmatch)\s*\(\s*r?['"]((?:\\.|[^'"\n])+)['"]/g)) {
|
|
292
|
+
out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function extractJavaRegexBodies(code) {
|
|
298
|
+
const out = [];
|
|
299
|
+
for (const m of code.matchAll(/\bPattern\.compile\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
|
|
300
|
+
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
301
|
+
}
|
|
302
|
+
for (const m of code.matchAll(/\.matches\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
|
|
303
|
+
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function scanRegexReDoS(file, raw) {
|
|
309
|
+
if (!file || !raw || typeof raw !== 'string') return [];
|
|
310
|
+
if (raw.length > 500_000) return [];
|
|
311
|
+
const findings = [];
|
|
312
|
+
let bodies = [];
|
|
313
|
+
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) bodies = extractJsRegexBodies(raw);
|
|
314
|
+
else if (/\.py$/i.test(file)) bodies = extractPyRegexBodies(raw);
|
|
315
|
+
else if (/\.java$/i.test(file)) bodies = extractJavaRegexBodies(raw);
|
|
316
|
+
else return [];
|
|
317
|
+
|
|
318
|
+
for (const { body, line } of bodies) {
|
|
319
|
+
const result = isUnsafeRegex(body);
|
|
320
|
+
if (result.unsafe) {
|
|
321
|
+
findings.push({
|
|
322
|
+
id: `redos-nfa:${file}:${line}`,
|
|
323
|
+
file,
|
|
324
|
+
line,
|
|
325
|
+
vuln: 'ReDoS — Catastrophic Backtracking (NFA analysis)',
|
|
326
|
+
severity: 'high',
|
|
327
|
+
family: 'redos',
|
|
328
|
+
cwe: 'CWE-1333',
|
|
329
|
+
parser: 'NFA',
|
|
330
|
+
confidence: 0.85,
|
|
331
|
+
description: `Regex pattern has ${result.reason}. A crafted input can cause exponential backtracking, consuming 100% CPU.`,
|
|
332
|
+
remediation: 'Rewrite the regex to avoid nested quantifiers and overlapping alternation. Consider using the re2 library for guaranteed linear-time matching.',
|
|
333
|
+
snippet: `/${body.slice(0, 60)}${body.length > 60 ? '...' : ''}/`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return findings;
|
|
338
|
+
}
|
package/src/sast/rust.js
CHANGED
|
@@ -103,3 +103,29 @@ export function scanRust(fp, raw) {
|
|
|
103
103
|
}
|
|
104
104
|
return out;
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
export function extractRustImportMap(code) {
|
|
108
|
+
const map = new Map();
|
|
109
|
+
const globs = new Set();
|
|
110
|
+
const useRe = /\buse\s+([\w_][\w_:]*?)(?:::(\w+|\{[^}]+\}|\*))(?:\s+as\s+(\w+))?/g;
|
|
111
|
+
for (const m of code.matchAll(useRe)) {
|
|
112
|
+
const cratePath = m[1];
|
|
113
|
+
const crate = cratePath.split('::')[0];
|
|
114
|
+
const imported = m[2];
|
|
115
|
+
const alias = m[3];
|
|
116
|
+
if (imported === '*') {
|
|
117
|
+
globs.add(crate);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (imported.startsWith('{')) {
|
|
121
|
+
const items = imported.slice(1, -1).split(',').map(s => s.trim());
|
|
122
|
+
for (const item of items) {
|
|
123
|
+
const parts = item.split(/\s+as\s+/);
|
|
124
|
+
map.set(parts[1] || parts[0], crate);
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
map.set(alias || imported, crate);
|
|
129
|
+
}
|
|
130
|
+
return { map, globs };
|
|
131
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Sensitive data logging detector.
|
|
2
|
+
//
|
|
3
|
+
// Flags PII-named variables sent to logging sinks without sanitization.
|
|
4
|
+
// Covers JS (console/winston/pino/bunyan), Python (logging/print),
|
|
5
|
+
// Go (log/fmt.Printf).
|
|
6
|
+
|
|
7
|
+
const PII_CONTEXT = /\b(email|ssn|password|phone|dob|date_of_birth|credit_card|card_number|social_security|passport|medical_record|ip_address|first_name|last_name|address|zip_code|bank_account)\b/i;
|
|
8
|
+
|
|
9
|
+
function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
|
|
10
|
+
|
|
11
|
+
const LANG_SINKS = {
|
|
12
|
+
js: {
|
|
13
|
+
ext: /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i,
|
|
14
|
+
sinks: [
|
|
15
|
+
/\bconsole\.(?:log|warn|error|info|debug|trace)\s*\(/g,
|
|
16
|
+
/\blogger\.(?:log|info|warn|error|debug|trace|fatal)\s*\(/g,
|
|
17
|
+
/\b(?:winston|pino|bunyan|log4js)(?:\.\w+)*\.(?:info|warn|error|debug|log)\s*\(/g,
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
py: {
|
|
21
|
+
ext: /\.py$/i,
|
|
22
|
+
sinks: [
|
|
23
|
+
/\blogging\.(?:info|warning|error|debug|critical)\s*\(/g,
|
|
24
|
+
/\blogger\.(?:info|warning|error|debug|critical)\s*\(/g,
|
|
25
|
+
/\bprint\s*\(/g,
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
go: {
|
|
29
|
+
ext: /\.go$/i,
|
|
30
|
+
sinks: [
|
|
31
|
+
/\blog\.(?:Printf|Println|Print|Fatalf|Fatal)\s*\(/g,
|
|
32
|
+
/\bfmt\.(?:Printf|Println|Print|Fprintf)\s*\(/g,
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function scanSensitiveDataLogging(fp, raw) {
|
|
38
|
+
if (!fp || !raw || typeof raw !== 'string') return [];
|
|
39
|
+
if (raw.length > 500_000) return [];
|
|
40
|
+
|
|
41
|
+
const findings = [];
|
|
42
|
+
let lang = null;
|
|
43
|
+
for (const v of Object.values(LANG_SINKS)) {
|
|
44
|
+
if (v.ext.test(fp)) { lang = v; break; }
|
|
45
|
+
}
|
|
46
|
+
if (!lang) return [];
|
|
47
|
+
|
|
48
|
+
for (const sinkRe of lang.sinks) {
|
|
49
|
+
sinkRe.lastIndex = 0;
|
|
50
|
+
for (const m of raw.matchAll(sinkRe)) {
|
|
51
|
+
const lineNum = _line(raw, m.index);
|
|
52
|
+
const lineEnd = raw.indexOf('\n', m.index);
|
|
53
|
+
const lineText = raw.slice(m.index, lineEnd > 0 ? lineEnd : m.index + 200);
|
|
54
|
+
if (!PII_CONTEXT.test(lineText)) continue;
|
|
55
|
+
// Skip if the line contains redaction/masking
|
|
56
|
+
if (/\b(?:redact|mask|sanitize|censor|scrub|\*{3,}|\.{3}|slice\s*\(\s*0\s*,\s*\d\s*\))\b/i.test(lineText)) continue;
|
|
57
|
+
findings.push({
|
|
58
|
+
id: `sensitive-log:${fp}:${lineNum}`,
|
|
59
|
+
file: fp, line: lineNum,
|
|
60
|
+
vuln: 'Sensitive Data Logged — PII-named variable sent to logger without sanitization',
|
|
61
|
+
severity: 'medium',
|
|
62
|
+
family: 'sensitive-data-logging',
|
|
63
|
+
cwe: 'CWE-532',
|
|
64
|
+
parser: 'PII-LOG',
|
|
65
|
+
confidence: 0.70,
|
|
66
|
+
description: 'A variable with a PII-related name (email, password, ssn, etc.) is logged without redaction. Log aggregators, crash reporters, and stdout can expose this data.',
|
|
67
|
+
remediation: 'Redact sensitive fields before logging: logger.info("Login", { email: email.slice(0, 3) + "***" }). Never log passwords, SSNs, or credit card numbers.',
|
|
68
|
+
snippet: lineText.trim().slice(0, 100),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return findings;
|
|
73
|
+
}
|