@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9
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 +432 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +448 -74
- package/dist/113.index.js +16 -7
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +300 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +196 -21
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +24 -12
- package/src/badge.js +2 -1
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +25 -8
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +247 -50
- package/src/integrations/tickets.js +7 -6
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +159 -17
- package/src/posture/CLAUDE.md +45 -8
- package/src/posture/accuracy-scorecard.js +67 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +111 -10
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +40 -10
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +276 -0
- package/src/posture/exploitability-probability.js +15 -2
- package/src/posture/falsification.js +23 -2
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +69 -3
- package/src/posture/mttr.js +5 -0
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +20 -3
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +13 -3
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +9 -1
- package/src/sca/sarif-ingest.js +0 -187
|
@@ -64,14 +64,25 @@ function _line(raw, idx) {
|
|
|
64
64
|
return raw.slice(0, idx).split('\n').length;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Zero-width / invisible Unicode characters: zero-width space, ZWNJ, ZWJ,
|
|
68
|
+
// word joiner, BOM/zero-width no-break space. Interspersing these between
|
|
69
|
+
// every character renders/executes identically to a human or LLM reader
|
|
70
|
+
// (most tokenizers treat them as no-ops) while breaking literal-word regex
|
|
71
|
+
// matching — a documented real-world prompt-injection evasion technique
|
|
72
|
+
// against exactly this kind of pattern-matching defense. None of these are
|
|
73
|
+
// '\n', so removing them never shifts _line()'s line-count computation.
|
|
74
|
+
const _ZERO_WIDTH_RE = /[]/g;
|
|
75
|
+
|
|
67
76
|
export function scanClaudeMdPromptInjection(file, raw) {
|
|
68
77
|
if (!file || !raw || typeof raw !== 'string') return [];
|
|
69
78
|
if (!_INSTRUCTION_FILE_RE.test(file)) return [];
|
|
70
79
|
if (raw.length > 1_000_000) return [];
|
|
71
80
|
|
|
81
|
+
const cleaned = raw.replace(_ZERO_WIDTH_RE, '');
|
|
82
|
+
|
|
72
83
|
// Strip fenced code blocks so example snippets in docs don't trip the
|
|
73
84
|
// detector. Replace with same-length whitespace to preserve line offsets.
|
|
74
|
-
const stripped =
|
|
85
|
+
const stripped = cleaned.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, ' '));
|
|
75
86
|
|
|
76
87
|
const findings = [];
|
|
77
88
|
|
|
@@ -149,12 +160,12 @@ export function scanClaudeMdPromptInjection(file, raw) {
|
|
|
149
160
|
|
|
150
161
|
// Hardcoded credentials in CLAUDE.md / AGENTS.md.
|
|
151
162
|
for (const { re, label } of _CRED_RE) {
|
|
152
|
-
const m = re.exec(
|
|
163
|
+
const m = re.exec(cleaned);
|
|
153
164
|
if (!m) continue;
|
|
154
165
|
findings.push({
|
|
155
166
|
id: `claude-md:hardcoded-cred:${file}:${m.index}`,
|
|
156
167
|
file,
|
|
157
|
-
line: _line(
|
|
168
|
+
line: _line(cleaned, m.index),
|
|
158
169
|
vuln: `Instruction file contains a hardcoded ${label}`,
|
|
159
170
|
severity: 'critical',
|
|
160
171
|
family: 'harness-config-secrets',
|
package/src/sast/cloud-iam.js
CHANGED
|
@@ -90,20 +90,68 @@ function _actionList(a) {
|
|
|
90
90
|
return Array.isArray(a) ? a : [a];
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Finds every balanced {...} substring in `raw` that parses as valid JSON
|
|
94
|
+
// and itself contains a "Statement" key. Recovers the case _isAwsPolicy
|
|
95
|
+
// already promises to route here: an AWS policy document embedded as a
|
|
96
|
+
// JSON-quoted string inside a non-JSON host file — most commonly a
|
|
97
|
+
// Terraform heredoc (`policy = <<EOF ... EOF`). Does not attempt to parse
|
|
98
|
+
// native-HCL jsonencode({...}) blocks (bare identifiers, no quotes) — that
|
|
99
|
+
// shape doesn't match _isAwsPolicy's own quoted-key detection regex either,
|
|
100
|
+
// so it's already outside this detector's claimed scope.
|
|
101
|
+
function _extractJsonPolicyBlocks(raw) {
|
|
102
|
+
const blocks = [];
|
|
103
|
+
for (let i = 0; i < raw.length; i++) {
|
|
104
|
+
if (raw[i] !== '{') continue;
|
|
105
|
+
let depth = 0, inStr = false, strCh = '', esc = false, j = i;
|
|
106
|
+
for (; j < raw.length; j++) {
|
|
107
|
+
const c = raw[j];
|
|
108
|
+
if (inStr) {
|
|
109
|
+
if (esc) esc = false;
|
|
110
|
+
else if (c === '\\') esc = true;
|
|
111
|
+
else if (c === strCh) inStr = false;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (c === '"') { inStr = true; strCh = c; continue; }
|
|
115
|
+
if (c === '{') depth++;
|
|
116
|
+
else if (c === '}') { depth--; if (depth === 0) break; }
|
|
117
|
+
}
|
|
118
|
+
if (depth !== 0 || j >= raw.length) continue;
|
|
119
|
+
const candidate = raw.slice(i, j + 1);
|
|
120
|
+
if (!/"Statement"\s*:/.test(candidate)) continue;
|
|
121
|
+
try { blocks.push(JSON.parse(candidate)); } catch { /* not valid JSON on its own */ }
|
|
122
|
+
}
|
|
123
|
+
return blocks;
|
|
124
|
+
}
|
|
125
|
+
|
|
93
126
|
function detectAws(file, raw, out, seen) {
|
|
94
|
-
let
|
|
95
|
-
try {
|
|
96
|
-
|
|
127
|
+
let ss;
|
|
128
|
+
try {
|
|
129
|
+
ss = _statements(JSON.parse(raw));
|
|
130
|
+
} catch {
|
|
131
|
+
// Non-JSON host file (e.g. Terraform HCL) — fall back to locating
|
|
132
|
+
// embedded JSON policy-document blocks instead of giving up outright.
|
|
133
|
+
ss = _extractJsonPolicyBlocks(raw).flatMap(_statements);
|
|
134
|
+
if (!ss.length) return;
|
|
135
|
+
}
|
|
136
|
+
// _line() takes a raw-text offset, not a substring — a monotonically
|
|
137
|
+
// advancing cursor lets successive statements (JSON.parse preserves
|
|
138
|
+
// source order) each locate their OWN occurrence of a shared marker
|
|
139
|
+
// like `"Principal"` instead of every statement resolving to the same
|
|
140
|
+
// (first) occurrence in the file.
|
|
141
|
+
let _cursor = 0;
|
|
97
142
|
for (const s of ss) {
|
|
98
143
|
if ((s.Effect || 'Allow') !== 'Allow') continue;
|
|
99
144
|
const actions = _actionList(s.Action);
|
|
100
145
|
const isStarAction = actions.includes('*') || actions.some(a => /^[a-z]+:\*$/.test(a));
|
|
101
146
|
const hasCondition = s.Condition && Object.keys(s.Condition).length > 0;
|
|
102
147
|
const principalStar = _principalIsWildcard(s.Principal);
|
|
148
|
+
let _advanceTo = _cursor;
|
|
103
149
|
|
|
104
150
|
// aws-public-s3-policy
|
|
105
151
|
if (principalStar && actions.some(a => /^s3:/.test(a))) {
|
|
106
|
-
const
|
|
152
|
+
const idx = raw.indexOf('"Principal"', _cursor);
|
|
153
|
+
const ln = _line(raw, idx >= 0 ? idx : _cursor);
|
|
154
|
+
if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
|
|
107
155
|
const id = `aws-public-s3-policy:${file}:${ln}`;
|
|
108
156
|
if (!seen.has(id)) {
|
|
109
157
|
seen.add(id);
|
|
@@ -118,7 +166,9 @@ function detectAws(file, raw, out, seen) {
|
|
|
118
166
|
|
|
119
167
|
// aws-public-trust-policy (Principal:* on AssumeRole)
|
|
120
168
|
if (principalStar && actions.includes('sts:AssumeRole')) {
|
|
121
|
-
const
|
|
169
|
+
const idx = raw.indexOf('AssumeRole', _cursor);
|
|
170
|
+
const ln = _line(raw, idx >= 0 ? idx : _cursor);
|
|
171
|
+
if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
|
|
122
172
|
const id = `aws-public-trust-policy:${file}:${ln}`;
|
|
123
173
|
if (!seen.has(id)) {
|
|
124
174
|
seen.add(id);
|
|
@@ -136,7 +186,9 @@ function detectAws(file, raw, out, seen) {
|
|
|
136
186
|
if (_HIGH_RISK_AWS_ACTIONS.includes(a) || a === '*') {
|
|
137
187
|
const conditionStr = JSON.stringify(s.Condition || {});
|
|
138
188
|
if (!/MultiFactorAuthPresent|MultiFactorAuthAge/.test(conditionStr)) {
|
|
139
|
-
const
|
|
189
|
+
const idx = raw.indexOf(`"${a}"`, _cursor);
|
|
190
|
+
const ln = _line(raw, idx >= 0 ? idx : _cursor);
|
|
191
|
+
if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
|
|
140
192
|
const id = `aws-no-mfa-condition:${file}:${a}:${ln}`;
|
|
141
193
|
if (!seen.has(id)) {
|
|
142
194
|
seen.add(id);
|
|
@@ -153,12 +205,13 @@ function detectAws(file, raw, out, seen) {
|
|
|
153
205
|
|
|
154
206
|
// (iam:PassRole with Resource:* is detected by posture/iam-policy.js —
|
|
155
207
|
// not duplicated here.)
|
|
208
|
+
_cursor = _advanceTo;
|
|
156
209
|
}
|
|
157
210
|
|
|
158
211
|
// aws-overbroad-managed-policy
|
|
159
212
|
if (/"AdministratorAccess"|"PowerUserAccess"/.test(raw) && !/root\b|Bootstrap\b/.test(raw)) {
|
|
160
213
|
const m = /"(AdministratorAccess|PowerUserAccess)"/.exec(raw);
|
|
161
|
-
const ln = _line(raw, m
|
|
214
|
+
const ln = _line(raw, m.index);
|
|
162
215
|
const id = `aws-overbroad-managed-policy:${file}:${ln}`;
|
|
163
216
|
if (!seen.has(id)) {
|
|
164
217
|
seen.add(id);
|
|
@@ -64,6 +64,34 @@ function patternsFor(lang) {
|
|
|
64
64
|
gate: /\b(?:DataTable|DataColumn|DataView)\b/ },
|
|
65
65
|
];
|
|
66
66
|
}
|
|
67
|
+
if (lang === 'js') {
|
|
68
|
+
// Server-side JS code injection. Measured gap, not a speculative one: on the
|
|
69
|
+
// independent population CWE-94 scored 0/6, and the missed sink in
|
|
70
|
+
// GHSA-3769-jgqc-cxm7 was `vm.run(\`...${code}...\`)` — the NodeVM / vm2
|
|
71
|
+
// sandbox family. `eval` and `new Function` were already covered elsewhere
|
|
72
|
+
// (client-side.js, agent-untrusted-flow.js); a library whose entire purpose
|
|
73
|
+
// is executing supplied code was not covered anywhere.
|
|
74
|
+
//
|
|
75
|
+
// A SANDBOX IS NOT A CONTROL HERE. vm/vm2 are routinely reached for as the
|
|
76
|
+
// "safe" way to run untrusted code, and both have a long history of escapes.
|
|
77
|
+
// Running attacker-influenced source inside one is still code injection, so
|
|
78
|
+
// these fire on a non-literal argument exactly like every other sink in this
|
|
79
|
+
// module.
|
|
80
|
+
return [
|
|
81
|
+
// vm.run / NodeVM#run / VM#run — the vm2 and @n8n/vm2 family.
|
|
82
|
+
{ key: 'vm-run', re: new RegExp(String.raw`\.\s*run\s*\(\s*${notLiteral}`, 'g'),
|
|
83
|
+
gate: /\b(?:NodeVM|VM|vm2|createContext|new\s+VM\b)\b/ },
|
|
84
|
+
// node:vm's own evaluators. runInNewContext takes the code first.
|
|
85
|
+
{ key: 'vm-run-in-context',
|
|
86
|
+
re: new RegExp(String.raw`\.\s*(?:runInNewContext|runInThisContext|runInContext)\s*\(\s*${notLiteral}`, 'g') },
|
|
87
|
+
// new vm.Script(src) compiles before it runs; the injection is here.
|
|
88
|
+
{ key: 'vm-script', re: new RegExp(String.raw`\bnew\s+(?:vm\s*\.\s*)?Script\s*\(\s*${notLiteral}`, 'g'),
|
|
89
|
+
gate: /\b(?:vm|node:vm)\b/ },
|
|
90
|
+
// Server-side eval / Function, which client-side.js only covers for browsers.
|
|
91
|
+
{ key: 'js-eval', re: new RegExp(String.raw`(?:^|[^.\w])eval\s*\(\s*${notLiteral}`, 'gm') },
|
|
92
|
+
{ key: 'js-new-function', re: new RegExp(String.raw`\bnew\s+Function\s*\(\s*${notLiteral}`, 'g') },
|
|
93
|
+
];
|
|
94
|
+
}
|
|
67
95
|
if (lang === 'go') {
|
|
68
96
|
return [
|
|
69
97
|
// yaegi / gomacro interpreter eval of a non-literal.
|
|
@@ -82,6 +110,7 @@ function _lang(fp) {
|
|
|
82
110
|
if (/\.kt$/i.test(fp)) return 'kt';
|
|
83
111
|
if (/\.cs$/i.test(fp)) return 'cs';
|
|
84
112
|
if (/\.go$/i.test(fp)) return 'go';
|
|
113
|
+
if (/\.(?:js|jsx|mjs|cjs|ts|tsx)$/i.test(fp)) return 'js';
|
|
85
114
|
return null;
|
|
86
115
|
}
|
|
87
116
|
|
|
@@ -82,7 +82,7 @@ function familyOf(finding) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// Return the primary-CWE family for a Juliet C/C++ test path, or null.
|
|
85
|
-
|
|
85
|
+
function julietPrimaryFamily(file) {
|
|
86
86
|
const m = JULIET_DIR_RE.exec(String(file).replace(/\\/g, '/'));
|
|
87
87
|
if (!m) return null;
|
|
88
88
|
return CWE_TO_FAMILY[parseInt(m[1], 10)] || null;
|
package/src/sast/csrf.js
CHANGED
|
@@ -63,11 +63,13 @@ export function scanCSRF(fp, raw) {
|
|
|
63
63
|
if (!langSel) return [];
|
|
64
64
|
|
|
65
65
|
const code = blankComments(raw, (ext === 'py' || ext === 'rb') ? 'py' : undefined);
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
// Per-route defence is checked below (±15-line window around each route).
|
|
67
|
+
// There used to also be a whole-file gate here ("if the file shows CSRF
|
|
68
|
+
// defence ANYWHERE, suppress every route in it") — by regex-substring
|
|
69
|
+
// monotonicity that gate always fires whenever the per-route window would
|
|
70
|
+
// have matched too (the window is a substring of the file), so it made the
|
|
71
|
+
// per-route check provably unreachable: one protected route silently
|
|
72
|
+
// suppressed every OTHER route in the same file, however unprotected.
|
|
71
73
|
|
|
72
74
|
const findings = [];
|
|
73
75
|
const seen = new Set();
|
package/src/sast/env-hygiene.js
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
// NEXT_PUBLIC_ vars that expose private values, .env.example with real
|
|
5
5
|
// credentials, and hardcoded fallback values that look real.
|
|
6
6
|
//
|
|
7
|
-
// Findings
|
|
7
|
+
// Findings (4 implemented; ENV_MISSING_GITIGNORE was documented here but has
|
|
8
|
+
// no implementation anywhere in this file — found via Stage-0 doc audit,
|
|
9
|
+
// 2026. Either implement it or drop it from this list; left as a gap, not
|
|
10
|
+
// silently removed, so the missing coverage stays visible):
|
|
8
11
|
// ENV_NEXT_PUBLIC_SECRET — NEXT_PUBLIC_ variable whose name implies it's secret
|
|
9
12
|
// ENV_EXAMPLE_REAL_VALUE — .env.example / .env.sample with a real-looking value
|
|
10
13
|
// ENV_HARDCODED_FALLBACK — process.env.X || "looks-real" fallback in source
|
|
11
|
-
// ENV_MISSING_GITIGNORE — .env / .env.local present but not in .gitignore
|
|
12
14
|
// ENV_DOTENV_IN_SOURCE — .env file content loaded via require/import in prod code
|
|
15
|
+
// NOT IMPLEMENTED: ENV_MISSING_GITIGNORE — .env / .env.local present but not in .gitignore
|
|
13
16
|
|
|
14
17
|
const _ENV_FILE_RE = /^\.env(?:\.(?:local|development|production|test|staging))?$/i;
|
|
15
18
|
const _NONPROD_RE = /(?:^|\/)(?:tests?|__tests__|spec|fixtures?|examples?|node_modules)\//i;
|
|
@@ -42,6 +42,23 @@ function buildResolver(text) {
|
|
|
42
42
|
|
|
43
43
|
function lineOf(text, idx) { return text.slice(0, idx).split('\n').length; }
|
|
44
44
|
|
|
45
|
+
// AWS security-group `cidr_blocks` lives inside either an `ingress { }` or
|
|
46
|
+
// `egress { }` block. Egress-to-anywhere (0.0.0.0/0 outbound) is the
|
|
47
|
+
// standard default for nearly every security group and is not itself a
|
|
48
|
+
// finding; only the same value on an ingress (inbound) rule is an exposure.
|
|
49
|
+
// Finds the nearest enclosing block by walking back to the last occurrence
|
|
50
|
+
// of either keyword before the match position — security-group ingress/
|
|
51
|
+
// egress blocks don't nest another ingress/egress block inside themselves,
|
|
52
|
+
// so the closest preceding one is the enclosing one.
|
|
53
|
+
const _BLOCK_KEYWORD_RE = /\b(ingress|egress)\s*\{/g;
|
|
54
|
+
function _enclosingBlockType(raw, idx) {
|
|
55
|
+
let last = null;
|
|
56
|
+
_BLOCK_KEYWORD_RE.lastIndex = 0;
|
|
57
|
+
let m;
|
|
58
|
+
while ((m = _BLOCK_KEYWORD_RE.exec(raw)) && m.index < idx) last = m[1];
|
|
59
|
+
return last;
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
const CHECKS = [
|
|
46
63
|
{
|
|
47
64
|
id: 'open-ingress', cwe: 'CWE-284', sev: 'high',
|
|
@@ -49,6 +66,13 @@ const CHECKS = [
|
|
|
49
66
|
bad: (val) => /\b0\.0\.0\.0\/0\b|::\/0\b/.test(val),
|
|
50
67
|
vuln: 'Terraform: security-group ingress open to the world (0.0.0.0/0)',
|
|
51
68
|
fix: 'Restrict cidr_blocks to known networks; never default an ingress CIDR to 0.0.0.0/0.',
|
|
69
|
+
// Only ingress (inbound) exposure is a finding — egress-to-anywhere is
|
|
70
|
+
// the standard, expected default. A cidr_blocks outside any
|
|
71
|
+
// ingress/egress block (no enclosing keyword found) is kept: that's
|
|
72
|
+
// the standalone `aws_security_group_rule` resource shape, which needs
|
|
73
|
+
// a separate `type = "ingress"` attribute this module doesn't parse —
|
|
74
|
+
// erring toward reporting rather than silently dropping it.
|
|
75
|
+
blockGate: (blockType) => blockType !== 'egress',
|
|
52
76
|
},
|
|
53
77
|
{
|
|
54
78
|
id: 'public-bucket', cwe: 'CWE-732', sev: 'high',
|
|
@@ -76,6 +100,7 @@ export function scanTerraform(fp, raw) {
|
|
|
76
100
|
const re = new RegExp(check.attr.source, check.attr.flags);
|
|
77
101
|
let m;
|
|
78
102
|
while ((m = re.exec(raw))) {
|
|
103
|
+
if (check.blockGate && !check.blockGate(_enclosingBlockType(raw, m.index))) continue;
|
|
79
104
|
const rawVal = m[1];
|
|
80
105
|
// A literal list may hold several entries; resolve each token.
|
|
81
106
|
const tokens = rawVal.replace(/^\[|\]$/g, '').split(',').map(t => t.trim()).filter(Boolean);
|
|
@@ -671,7 +671,7 @@ const REQUEST_WRAPPER_CLASS_RE = /\b(?:public\s+|private\s+|protected\s+|static\
|
|
|
671
671
|
|
|
672
672
|
/** Parse a Java file and return the names of any classes that wrap an
|
|
673
673
|
* HttpServletRequest and expose String-returning getters. */
|
|
674
|
-
|
|
674
|
+
function findRequestWrapperGetters(file, raw) {
|
|
675
675
|
if (!JAVA_EXT.test(file) || !raw || raw.length > 500_000) return [];
|
|
676
676
|
const content = blankComments(raw);
|
|
677
677
|
const out = [];
|
|
@@ -68,7 +68,7 @@ function intLocals(raw) {
|
|
|
68
68
|
// Pattern 1: Constant ternary assigning to bar.
|
|
69
69
|
// bar = (<expr> [+-*]) num <cmp> <literal> ? "<safe>" : param;
|
|
70
70
|
// where `num` is a local int constant and the comparison folds to a known bool.
|
|
71
|
-
|
|
71
|
+
function hasConstantTernaryBarSafe(raw) {
|
|
72
72
|
const ints = intLocals(raw);
|
|
73
73
|
// Try a few common shape variants. The first looks for the (lhs ± num) > N pattern.
|
|
74
74
|
// We allow either order — `bar = literal ? ... : ...` style.
|
|
@@ -105,7 +105,7 @@ export function hasConstantTernaryBarSafe(raw) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
// Pattern 2: Constant if/else assigning to bar — same idea.
|
|
108
|
-
|
|
108
|
+
function hasConstantIfBarSafe(raw) {
|
|
109
109
|
const ints = intLocals(raw);
|
|
110
110
|
// `if ((N op M) op2 num cmp K) bar = "<literal>"; else bar = param;`
|
|
111
111
|
const re = /\bif\s*\(\s*\((-?\d+)\s*([+\-*])\s*(-?\d+)\)\s*([+\-])\s*(\w+)\s*([><=!]=?|==)\s*(-?\d+)\s*\)\s*bar\s*=\s*"[^"]*"\s*;\s*else\s+bar\s*=\s*(\w+)\s*;/g;
|
|
@@ -143,7 +143,7 @@ export function hasConstantIfBarSafe(raw) {
|
|
|
143
143
|
// map.put("keyB-XXX", param);
|
|
144
144
|
// bar = map.get("keyB-XXX");
|
|
145
145
|
// bar = map.get("keyA-XXX"); // overwrites with safe
|
|
146
|
-
|
|
146
|
+
function hasMapDoubleGetSafe(raw) {
|
|
147
147
|
// Use a tolerant regex — we don't need to bind every put to every get.
|
|
148
148
|
// The presence of these three lines in this order suffices.
|
|
149
149
|
const re = /\.\s*put\s*\(\s*("[^"]+")\s*,\s*"[^"]*"\s*\)\s*;[\s\S]{0,400}?\.\s*put\s*\(\s*("[^"]+")\s*,\s*\w+\s*\)[\s\S]{0,500}?\bbar\s*=\s*(?:\(String\)\s*)?\w+\.get\(\s*\2\s*\)[\s\S]{0,200}?\bbar\s*=\s*(?:\(String\)\s*)?\w+\.get\(\s*\1\s*\)/;
|
|
@@ -203,7 +203,7 @@ function _simulateListBar(raw) {
|
|
|
203
203
|
return anyResolved;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
function hasListGetIndex0Safe(raw) {
|
|
207
207
|
return _simulateListBar(raw);
|
|
208
208
|
}
|
|
209
209
|
|
|
@@ -217,7 +217,7 @@ export function hasListGetIndex0Safe(raw) {
|
|
|
217
217
|
// case 'B': bar = "literal"; break; // TAKEN
|
|
218
218
|
// ...
|
|
219
219
|
// }
|
|
220
|
-
|
|
220
|
+
function hasSwitchCharAtConstantSafe(raw) {
|
|
221
221
|
// 1. Find `String <var> = "<lit>";` then `char <other> = <var>.charAt(<idx>);`.
|
|
222
222
|
const decl = /\bString\s+(\w+)\s*=\s*"([^"]+)"\s*;\s*(?:\/\/[^\n]*\n)?\s*char\s+\w+\s*=\s*\1\s*\.\s*charAt\s*\(\s*(\d+)\s*\)\s*;/g;
|
|
223
223
|
let m;
|
package/src/sast/llm-owasp.js
CHANGED
|
@@ -369,7 +369,10 @@ export function scanLLMOwasp(fp, raw) {
|
|
|
369
369
|
if (!USER_INJECTED_RAG_RE.test(lines[li])) continue;
|
|
370
370
|
// Suppress if there's an obvious auth or provenance check in the surrounding context
|
|
371
371
|
const ctx20 = _windowText(lines, Math.max(0, li - 10), 20);
|
|
372
|
-
|
|
372
|
+
// continue, not break: a guard near THIS match says nothing about a
|
|
373
|
+
// later, unrelated match elsewhere in the file — stopping the loop here
|
|
374
|
+
// would hide every subsequent unguarded instance.
|
|
375
|
+
if (/\b(?:require_auth|is_admin|check_permission|verified_source|provenance|signed|hmac)\b/i.test(ctx20)) continue;
|
|
373
376
|
push({
|
|
374
377
|
id: `llm-owasp:${fp}:${li + 1}:llm03-user-injected-rag`,
|
|
375
378
|
kind: 'sast', severity: 'high',
|
|
@@ -379,7 +382,6 @@ export function scanLLMOwasp(fp, raw) {
|
|
|
379
382
|
fix: 'Authenticate the submitter and validate content before it enters the knowledge base. Tag each chunk with source, owner, and trust_level metadata at ingest time. At retrieval, filter or down-rank chunks from unverified sources. Scan injected content for prompt-injection markers before storing.',
|
|
380
383
|
confidence: 0.88,
|
|
381
384
|
});
|
|
382
|
-
break;
|
|
383
385
|
}
|
|
384
386
|
|
|
385
387
|
// --- LLM03: auto-ingested third-party feed that may be compromised ---
|
package/src/sast/mcp-audit.js
CHANGED
|
@@ -101,10 +101,17 @@ export function scanMCP(fp, raw) {
|
|
|
101
101
|
|
|
102
102
|
const findings = [];
|
|
103
103
|
const seen = new Set();
|
|
104
|
+
// CMP-1 (Stage 6 follow-up): no call site here ever set `family`, so every
|
|
105
|
+
// finding fell through to a generic backfilled value — invisible to any
|
|
106
|
+
// compliance control mapped to `family:agent-tool-exec` (owasp-llm-top-10's
|
|
107
|
+
// LLM07), and to the family-keyed cost tables in risk-dollars.js/
|
|
108
|
+
// time-to-fix.js, which both already carry an 'agent-tool-exec' entry with
|
|
109
|
+
// nothing in the codebase that could ever produce a matching finding.
|
|
104
110
|
const push = (f) => {
|
|
105
111
|
const key = `${f.file}:${f.line}:${f.vuln}`;
|
|
106
112
|
if (seen.has(key)) return;
|
|
107
113
|
seen.add(key);
|
|
114
|
+
if (!f.family) f.family = 'agent-tool-exec';
|
|
108
115
|
findings.push(f);
|
|
109
116
|
};
|
|
110
117
|
|
package/src/sast/pipeline.js
CHANGED
|
@@ -43,6 +43,13 @@ const PIPELINE_PATTERNS = [
|
|
|
43
43
|
vuln: 'Pipeline: untrusted github.event input interpolated into shell context',
|
|
44
44
|
sev: 'critical', cwe: 'CWE-78',
|
|
45
45
|
fix: 'Pipe untrusted github.event values through an environment variable instead of interpolating into the shell, e.g. `env: TITLE: ${{ github.event.issue.title }}` then use `"$TITLE"` in the run script.',
|
|
46
|
+
// Suppress the exact pattern this fix recommends: a pure YAML mapping
|
|
47
|
+
// entry (`KEY: ${{ github.event.… }}`) assigns the value to an env var
|
|
48
|
+
// at the workflow-engine level, not into a shell command string — the
|
|
49
|
+
// shell later reads it as an ordinary env-var reference ("$KEY"), which
|
|
50
|
+
// is safe. A match embedded inside a larger line (a run: script body)
|
|
51
|
+
// does not have this shape and is still flagged.
|
|
52
|
+
lineSafeRe: /^[\w.-]+\s*:\s*\$\{\{[^}]*\}\}\s*$/,
|
|
46
53
|
},
|
|
47
54
|
{
|
|
48
55
|
re: /\bid-token\s*:\s*write\b/g,
|
|
@@ -70,6 +77,7 @@ export function scanPipeline(fp, raw) {
|
|
|
70
77
|
let m;
|
|
71
78
|
while ((m = re.exec(raw))) {
|
|
72
79
|
const line = raw.substring(0, m.index).split('\n').length;
|
|
80
|
+
if (p.lineSafeRe && p.lineSafeRe.test((lines[line - 1] || '').trim())) continue;
|
|
73
81
|
const id = `pipeline:${fp}:${line}:${p.vuln.replace(/\s/g, '_').slice(0, 48)}`;
|
|
74
82
|
if (seen.has(id)) continue;
|
|
75
83
|
seen.add(id);
|
|
@@ -92,11 +92,6 @@ export function scanPromptTemplate(fp, raw) {
|
|
|
92
92
|
const seen = new Set();
|
|
93
93
|
const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
|
|
94
94
|
|
|
95
|
-
// Strong negative for code files: a proper role-separated messages array anywhere
|
|
96
|
-
// in the file means the developer is using the framework correctly. Suppress
|
|
97
|
-
// inline-string findings in this case (still scan prompt template files).
|
|
98
|
-
const hasRoleSeparation = isCodeFile && ROLE_SEPARATION_RE.test(raw);
|
|
99
|
-
|
|
100
95
|
// CASE 1 — Prompt template files: scan the entire file content for
|
|
101
96
|
// user-input interpolations without isolation markers nearby.
|
|
102
97
|
if (isPromptFile) {
|
|
@@ -120,7 +115,7 @@ export function scanPromptTemplate(fp, raw) {
|
|
|
120
115
|
|
|
121
116
|
// CASE 2 — Inline prompt strings in code files. Scan f-strings (Python) and
|
|
122
117
|
// template literals (JS/TS) for prompt-shape markers + user interpolation.
|
|
123
|
-
if (isCodeFile
|
|
118
|
+
if (isCodeFile) {
|
|
124
119
|
const candidates = [];
|
|
125
120
|
if (/\.py$/i.test(fpNorm)) {
|
|
126
121
|
let m;
|
|
@@ -142,6 +137,13 @@ export function scanPromptTemplate(fp, raw) {
|
|
|
142
137
|
if (!pyInterp && !jsInterp) continue;
|
|
143
138
|
// Suppress if isolation markers are inside the prompt string itself
|
|
144
139
|
if (ISOLATION_MARKER_RE.test(c.text)) continue;
|
|
140
|
+
// Suppress if THIS candidate is itself the `content:` value inside a
|
|
141
|
+
// role-separated messages:[{role,content}] object — checked in a
|
|
142
|
+
// tight preceding window (same object literal), not file-wide, so a
|
|
143
|
+
// well-formed call elsewhere in the file can't blanket-suppress an
|
|
144
|
+
// unrelated, unisolated candidate.
|
|
145
|
+
const roleWindow = raw.substring(Math.max(0, c.start - 120), c.start);
|
|
146
|
+
if (ROLE_SEPARATION_RE.test(roleWindow) || /\brole\s*[:=]/i.test(roleWindow)) continue;
|
|
145
147
|
const line = raw.substring(0, c.start).split('\n').length;
|
|
146
148
|
push(_emit(fp, line,
|
|
147
149
|
'Prompt Template: user input interpolated into prompt string without isolation',
|
|
@@ -89,9 +89,13 @@ export function scanPrototypePollution(fp, raw) {
|
|
|
89
89
|
const r2 = new RegExp(re.source, re.flags);
|
|
90
90
|
let mm;
|
|
91
91
|
while ((mm = r2.exec(code))) {
|
|
92
|
-
// Only flag write context:
|
|
92
|
+
// Only flag write context: an `=` that is a genuine assignment, not
|
|
93
|
+
// part of a comparison operator (==, ===, !=, !==, <=, >=). The
|
|
94
|
+
// previous /=\s*[^=]/ check wasn't anchored to the first `=`, so it
|
|
95
|
+
// matched the trailing `=` of a `===` chain (e.g.
|
|
96
|
+
// `obj.__proto__ === Object.prototype`) as a false "write."
|
|
93
97
|
const post = code.slice(mm.index, mm.index + 60);
|
|
94
|
-
if (
|
|
98
|
+
if (!/(?<![=!<>])=(?!=)/.test(post)) continue;
|
|
95
99
|
const line = lineOf(raw, mm.index);
|
|
96
100
|
push({
|
|
97
101
|
id: `prototype-pollution-direct:${fp}:${line}`,
|
package/src/sast/redos-nfa.js
CHANGED
|
@@ -273,7 +273,7 @@ export function isUnsafeRegex(body) {
|
|
|
273
273
|
return detectSuperlinear(parsed.tree);
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
|
|
276
|
+
function extractJsRegexBodies(code) {
|
|
277
277
|
const out = [];
|
|
278
278
|
// Regex literals: /pattern/flags
|
|
279
279
|
for (const m of code.matchAll(/\/([^/\n]+)\/[gimsuy]*/g)) {
|
|
@@ -307,7 +307,7 @@ export function extractJavaRegexBodies(code) {
|
|
|
307
307
|
|
|
308
308
|
// Go: regexp.MustCompile / Compile / MatchString("…") — Go string literals use
|
|
309
309
|
// "…" (interpreted) or `…` (raw). Both carry the pattern verbatim.
|
|
310
|
-
|
|
310
|
+
function extractGoRegexBodies(code) {
|
|
311
311
|
const out = [];
|
|
312
312
|
for (const m of code.matchAll(/\bregexp\.(?:MustCompile|Compile|CompilePOSIX|MatchString|Match)\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
|
|
313
313
|
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
@@ -320,7 +320,7 @@ export function extractGoRegexBodies(code) {
|
|
|
320
320
|
|
|
321
321
|
// PHP: preg_match / preg_match_all / preg_replace("/…/flags", …) — the regex is
|
|
322
322
|
// a delimited string; strip the delimiter + trailing flags.
|
|
323
|
-
|
|
323
|
+
function extractPhpRegexBodies(code) {
|
|
324
324
|
const out = [];
|
|
325
325
|
for (const m of code.matchAll(/\bpreg_(?:match|match_all|replace|replace_callback|split)\s*\(\s*(['"])(.+?)\1/g)) {
|
|
326
326
|
let lit = m[2];
|
|
@@ -334,7 +334,7 @@ export function extractPhpRegexBodies(code) {
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
// Ruby: a regex literal /…/ used with =~ / .match / .match? / Regexp.new("…").
|
|
337
|
-
|
|
337
|
+
function extractRubyRegexBodies(code) {
|
|
338
338
|
const out = [];
|
|
339
339
|
for (const m of code.matchAll(/(?:=~|\.match\??|\.scan|\.gsub|\.sub|grep)\s*\(?\s*\/((?:\\.|[^\/\n])+)\//g)) {
|
|
340
340
|
out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
|
|
@@ -346,7 +346,7 @@ export function extractRubyRegexBodies(code) {
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
// C#: new Regex("…") / Regex.IsMatch(s, "…") / Regex.Match / Regex.Replace.
|
|
349
|
-
|
|
349
|
+
function extractCsharpRegexBodies(code) {
|
|
350
350
|
const out = [];
|
|
351
351
|
for (const m of code.matchAll(/\bnew\s+Regex\s*\(\s*@?"((?:\\.|""|[^"\n])+)"/g)) {
|
|
352
352
|
out.push({ body: m[1].replace(/""/g, '"'), line: code.slice(0, m.index).split('\n').length });
|
|
@@ -358,7 +358,7 @@ export function extractCsharpRegexBodies(code) {
|
|
|
358
358
|
}
|
|
359
359
|
|
|
360
360
|
// Kotlin: Regex("…") / "…".toRegex() / Pattern.compile("…").
|
|
361
|
-
|
|
361
|
+
function extractKotlinRegexBodies(code) {
|
|
362
362
|
const out = [];
|
|
363
363
|
for (const m of code.matchAll(/\bRegex\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
|
|
364
364
|
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
@@ -48,11 +48,22 @@ export function scanSecretConcat(fp, raw) {
|
|
|
48
48
|
const id = `secret-concat:${fp}:${line}`;
|
|
49
49
|
if (seen.has(id)) continue;
|
|
50
50
|
seen.add(id);
|
|
51
|
+
// Stage 4 correctness audit (coverage breadth, secrets): same
|
|
52
|
+
// unredacted-snippet leak found in engine.js's scanEntropySecrets/
|
|
53
|
+
// scanCredentials — `snippet` carried the raw source line, including
|
|
54
|
+
// the full reassembled secret's literal pieces, straight through to
|
|
55
|
+
// every report format this scanner emits. This detector never
|
|
56
|
+
// computed a masked value at all (the other two secret detectors do).
|
|
57
|
+
// Mask the exact matched concat-expression text (m[2], the same
|
|
58
|
+
// substring this detector reassembled `value` from) within the
|
|
59
|
+
// snippet before reporting it.
|
|
60
|
+
const masked = value.length > 8 ? value.substring(0, 4) + '…' + value.substring(value.length - 4) : '••••';
|
|
61
|
+
const rawSnippet = (raw.split('\n')[line - 1] || '').trim().slice(0, 200);
|
|
51
62
|
findings.push({
|
|
52
63
|
id, file: fp, line,
|
|
53
64
|
vuln: 'Hardcoded credential — secret split across concatenated literals to evade detection',
|
|
54
|
-
severity: 'high', cwe: 'CWE-798', family: 'secret', parser: 'SECRET-CONCAT', confidence: 0.78,
|
|
55
|
-
snippet:
|
|
65
|
+
severity: 'high', cwe: 'CWE-798', family: 'hardcoded-secret', parser: 'SECRET-CONCAT', confidence: 0.78,
|
|
66
|
+
snippet: rawSnippet.split(m[2]).join(masked), masked,
|
|
56
67
|
remediation: 'Load the secret from the environment or a secrets manager (process.env / os.environ / Vault / AWS Secrets Manager). Splitting the literal across a concatenation does not protect it — rotate the exposed value, it must be considered compromised.',
|
|
57
68
|
});
|
|
58
69
|
}
|
|
@@ -66,15 +66,18 @@ export function scanSSRFCloudMetadata(fp, raw) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// 2. User-controlled URL into an HTTP client, with no metadata guard nearby.
|
|
69
|
-
const fileHasMetadataGuard = METADATA_GUARD_RE.test(code);
|
|
70
69
|
const r = new RegExp(SSRF_CLIENT_RE.source, SSRF_CLIENT_RE.flags);
|
|
71
70
|
let m;
|
|
72
71
|
while ((m = r.exec(code))) {
|
|
73
72
|
const line = lineOf(raw, m.index);
|
|
74
|
-
// Skip if a guard appears in ±10 lines.
|
|
73
|
+
// Skip if a guard appears in ±10 lines. A file-wide "does this file
|
|
74
|
+
// contain a guard anywhere" check used to OR into this and blanket-
|
|
75
|
+
// suppressed every unguarded call site once any one call site was
|
|
76
|
+
// properly guarded — removed; proximity is what makes a guard actually
|
|
77
|
+
// apply to a given call.
|
|
75
78
|
const lines = raw.split('\n');
|
|
76
79
|
const window = lines.slice(Math.max(0, line - 11), line + 10).join(' ');
|
|
77
|
-
if (METADATA_GUARD_RE.test(window)
|
|
80
|
+
if (METADATA_GUARD_RE.test(window)) continue;
|
|
78
81
|
push({
|
|
79
82
|
id: `ssrf-meta-usercontrolled:${fp}:${line}`,
|
|
80
83
|
file: fp, line,
|
|
@@ -91,7 +91,7 @@ export function scanXssReflectedMultilang(fp, raw) {
|
|
|
91
91
|
for (const v of Object.values(LANGS)) { if (v.ext.test(fp)) { lang = v; break; } }
|
|
92
92
|
if (!lang) return [];
|
|
93
93
|
|
|
94
|
-
const code = blankComments(raw, /\.(?:
|
|
94
|
+
const code = blankComments(raw, /\.rb$/i.test(fp) ? 'py' : (/\.(?:php|phtml)$/i.test(fp) ? 'php' : undefined));
|
|
95
95
|
const lines = code.split('\n');
|
|
96
96
|
const findings = [];
|
|
97
97
|
const seen = new Set();
|
package/src/sast/xxe.js
CHANGED
|
@@ -180,7 +180,7 @@ export function scanXXE(fp, raw) {
|
|
|
180
180
|
};
|
|
181
181
|
|
|
182
182
|
if (/\.(?:php|phtml)$/i.test(fp)) {
|
|
183
|
-
const code = blankComments(raw, '
|
|
183
|
+
const code = blankComments(raw, 'php');
|
|
184
184
|
_emitOptIn(code, PHP_XXE_RE, null,
|
|
185
185
|
() => 'XXE: XML parsed with LIBXML_NOENT / LIBXML_DTDLOAD (external entities enabled)',
|
|
186
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.');
|
package/src/sca/CLAUDE.md
CHANGED
|
@@ -7,7 +7,7 @@ ecosystems and the most common lockfiles in each.
|
|
|
7
7
|
|
|
8
8
|
Most of the SCA *pipeline* lives in `../engine.js` (the manifest dispatch
|
|
9
9
|
in `parseManifests`, OSV/KEV/EPSS enrichment, attack-path computation).
|
|
10
|
-
This directory holds the
|
|
10
|
+
This directory holds the seven specialized modules called from there.
|
|
11
11
|
|
|
12
12
|
## Modules
|
|
13
13
|
|
|
@@ -16,10 +16,9 @@ This directory holds the eight specialized modules called from there.
|
|
|
16
16
|
| `index.js` | Re-exports six public symbols from `../engine.js` so external consumers can `import { parseManifests, queryOSV, … } from '@…/sca'`. |
|
|
17
17
|
| `binary-metadata.js` | **Opt-in via `AGENTIC_SECURITY_BINARY_SCA=1`.** Reads dependency metadata from compiled artifacts: JAR `META-INF/MANIFEST.MF` + `pom.properties`, Go binary `go.buildinfo`. Never executes the binary. JAR extraction uses `fs.mkdtemp` for an isolated scratch dir (premortem-derived: shared `/tmp` lets a hostile JAR plant a symlinked manifest that escapes the scratch). |
|
|
18
18
|
| `container.js` | Dockerfile parser. Detects EOL `FROM` base images (alpine/debian/ubuntu/node/python) against `base-images.json`, and synthesizes lightweight SCA components from `apt-get install` / `apk add` package lists. No Docker daemon required. |
|
|
19
|
-
| `dep-confusion.js` | Two related detectors. **Typosquat:** Levenshtein distance ≤ 2 against
|
|
19
|
+
| `dep-confusion.js` | Two related detectors. **Typosquat:** Levenshtein distance ≤ 2 against `popular-packages.json` — **188 packages (115 npm + 73 pypi), not "top-1000"** as this row previously said; re-derive the count from the file rather than trusting a hardcoded number here again. **Dependency confusion:** internal-scoped names (declared in `.agentic-security/internal-scopes.yml`) appearing on the public registry. Local-first; **this module does not itself call OSV** — it reads a flag set by an earlier, separate OSV/queryRegistries pass upstream (whether a dep "resolved by OSV"), which is different from "OSV consulted [by this module] to confirm confusion findings" as previously stated. |
|
|
20
20
|
| `llm-function-extract.js` | **Opt-in via `AGENTIC_SECURITY_LLM_SCA=1`.** LLM-assisted extraction of vulnerable function names for CVEs that lack OSV `ecosystem_specific.vulnerable_functions` data. Cached per CVE under `~/.config/agentic-security/llm-sca-cache/`. Endpoint-dependent — degrades to no-op when unreachable. |
|
|
21
21
|
| `py-package-functions.js` | **Opt-in via `AGENTIC_SECURITY_DEEP=1`** (Python only). Locates installed Python packages via `site-packages` and parses them with the CPython `ast` module (subprocess) to *validate* that an OSV-named vulnerable function exists in the installed version. Closes the "OSV says this function is vulnerable, but the version you installed actually removed it" false-positive class. |
|
|
22
|
-
| `sarif-ingest.js` | Normalizes SARIF 2.1.0 from external scanners and merges into the unified scan. Deduplicates by fingerprint `(CWE, file, line ± 2, rule)`. Twelve tool profiles supported with default-severity + semantic-kind mapping. |
|
|
23
22
|
| `vendor-detect.js` | Detects libraries copied into `src/` (lodash, jQuery, Angular, React, etc.) via characteristic version strings and function signatures. Catches the case where a vulnerable library bypasses the lockfile because someone vendored it directly. |
|
|
24
23
|
|
|
25
24
|
## Data sources + caches
|
|
@@ -132,7 +131,7 @@ if a detector forgets to set them.
|
|
|
132
131
|
is updated periodically; an alpine-3.16 today might not appear EOL until
|
|
133
132
|
the file is refreshed. Bias is toward false negatives.
|
|
134
133
|
- **Typosquat threshold is a single distance.** Levenshtein ≤ 2 against
|
|
135
|
-
the top-1000
|
|
134
|
+
the popular-packages.json list (188 entries, not top-1000). Increasing the threshold blows up the FP rate;
|
|
136
135
|
decreasing it loses real typosquats. This is the calibrated default.
|
|
137
136
|
|
|
138
137
|
## Adding a new detector here
|