@clear-capabilities/agentic-security-scanner 0.86.0 → 0.119.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +660 -0
- package/bin/agentic-security.js +78 -1
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +90 -0
- package/dist/444.index.js +89 -0
- package/dist/503.index.js +288 -0
- package/dist/637.index.js +15 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +5 -1
- package/dist/agentic-security.mjs +84 -84
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +21 -10
- package/src/dataflow/CLAUDE.md +18 -6
- package/src/dataflow/catalog.js +59 -11
- package/src/dataflow/engine.js +106 -5
- package/src/dataflow/implicit-flow.js +4 -2
- package/src/dataflow/index.js +17 -4
- package/src/dataflow/proof-gate.js +100 -0
- package/src/dataflow/summaries.js +63 -6
- package/src/engine.js +292 -10
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/callgraph.js +12 -1
- package/src/ir/parser-go.js +8 -0
- package/src/ir/tree-sitter-loader.js +108 -0
- package/src/mcp/tools.js +6 -2
- package/src/posture/corpus-status.js +74 -0
- package/src/posture/coverage-report.js +113 -0
- package/src/posture/fix-history.js +24 -0
- package/src/posture/holdout-eval.js +57 -0
- package/src/posture/provenance.js +39 -0
- package/src/posture/router.js +33 -3
- package/src/posture/sca-verdict.js +130 -0
- package/src/posture/secret-history.js +72 -0
- package/src/pr-delta.js +13 -0
- package/src/report/index.js +67 -0
- package/src/sast/CLAUDE.md +9 -3
- package/src/sast/agent-untrusted-flow.js +107 -0
- package/src/sast/api-authz.js +71 -0
- package/src/sast/code-injection-multilang.js +117 -0
- package/src/sast/cross-service.js +98 -0
- package/src/sast/crypto-protocol.js +43 -1
- package/src/sast/csharp-structural.js +65 -0
- package/src/sast/csharp.js +3 -0
- package/src/sast/csrf.js +24 -3
- package/src/sast/csv-injection.js +84 -0
- package/src/sast/event-entrypoint.js +90 -0
- package/src/sast/frontend-hygiene.js +106 -0
- package/src/sast/go-structural.js +60 -0
- package/src/sast/iac-terraform.js +107 -0
- package/src/sast/java-structural.js +81 -0
- package/src/sast/js-framework-structural.js +115 -0
- package/src/sast/kotlin.js +72 -0
- package/src/sast/ldap-injection.js +73 -17
- package/src/sast/open-redirect.js +47 -5
- package/src/sast/php.js +35 -0
- package/src/sast/python-structural.js +83 -0
- package/src/sast/rbac-consistency.js +113 -0
- package/src/sast/redos-nfa.js +75 -0
- package/src/sast/response-splitting.js +48 -19
- package/src/sast/ruby.js +38 -0
- package/src/sast/secret-concat.js +60 -0
- package/src/sast/ssrf-cloud-metadata.js +13 -0
- package/src/sast/stored-taint.js +88 -0
- package/src/sast/tree-sitter-sinks.js +62 -0
- package/src/sast/weak-password-hash.js +28 -0
- package/src/sast/weak-randomness.js +46 -5
- package/src/sast/wrong-context-sanitizer.js +149 -0
- package/src/sast/xpath-injection.js +30 -6
- package/src/sast/xss-reflected-multilang.js +116 -0
- package/src/sast/xxe.js +67 -0
- package/src/sca/image-packages.js +74 -0
- package/src/sca/import-reachability.js +271 -0
- package/src/sca/install-script-analysis.js +82 -0
- package/bin/.agentic-security/findings.json +0 -1907
- package/bin/.agentic-security/last-scan.json +0 -1907
- package/bin/.agentic-security/last-scan.json.sig +0 -1
- package/bin/.agentic-security/scan-history.json +0 -166
- package/bin/.agentic-security/streak.json +0 -20
- package/src/.agentic-security/findings.json +0 -117732
- package/src/.agentic-security/last-scan.json +0 -117732
- package/src/.agentic-security/last-scan.json.sig +0 -1
- package/src/.agentic-security/scan-history.json +0 -12946
- package/src/.agentic-security/streak.json +0 -21
- package/src/dataflow/.agentic-security/findings.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
- package/src/dataflow/.agentic-security/scan-history.json +0 -250
- package/src/dataflow/.agentic-security/streak.json +0 -21
- package/src/ir/.agentic-security/findings.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json.sig +0 -1
- package/src/ir/.agentic-security/scan-history.json +0 -193
- package/src/ir/.agentic-security/streak.json +0 -20
- package/src/mcp/.agentic-security/findings.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
- package/src/mcp/.agentic-security/scan-history.json +0 -331
- package/src/mcp/.agentic-security/streak.json +0 -20
- package/src/posture/.agentic-security/dpia.md +0 -26
- package/src/posture/.agentic-security/findings.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
- package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
- package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/posture/.agentic-security/scan-history.json +0 -8904
- package/src/posture/.agentic-security/streak.json +0 -21
- package/src/posture/.agentic-security/threat-model.json +0 -2038
- package/src/posture/.agentic-security/threat-model.md +0 -73
- package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
- package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
- package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/report/.agentic-security/threat-model.json +0 -7
- package/src/report/.agentic-security/threat-model.md +0 -22
- package/src/sast/.agentic-security/findings.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json.sig +0 -1
- package/src/sast/.agentic-security/scan-history.json +0 -941
- package/src/sast/.agentic-security/streak.json +0 -22
- package/src/sca/.agentic-security/findings.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json.sig +0 -1
- package/src/sca/.agentic-security/scan-history.json +0 -113
- package/src/sca/.agentic-security/streak.json +0 -21
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// R15 (PRD §5) — git-history secret sweep.
|
|
2
|
+
//
|
|
3
|
+
// A secret removed from HEAD but present in any past commit is still
|
|
4
|
+
// recoverable from `.git` and must be rotated — the most dangerous secret case,
|
|
5
|
+
// and one a working-tree-only scan misses entirely. This sweeps recent history
|
|
6
|
+
// (bounded), feeding the ADDED lines of each commit through the same credential
|
|
7
|
+
// detector the file scan uses.
|
|
8
|
+
//
|
|
9
|
+
// The detector is INJECTED (detectFn) rather than imported, so this module has
|
|
10
|
+
// no dependency back into engine.js (avoids a circular import) and the parsing
|
|
11
|
+
// stays pure + unit-testable. Live-credential validation (is the key active?)
|
|
12
|
+
// needs network and is deferred — see the rollup.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
// Pull the post-image (added) lines out of a unified diff: lines starting with
|
|
17
|
+
// a single '+' (not the '+++' file header). Returns reconstructed text.
|
|
18
|
+
export function extractAddedLines(diffText) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const line of String(diffText || '').split('\n')) {
|
|
21
|
+
if (line.startsWith('+') && !line.startsWith('+++')) out.push(line.slice(1));
|
|
22
|
+
}
|
|
23
|
+
return out.join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Pure: run the injected credential detector over a commit's added lines.
|
|
27
|
+
// detectFn has the scanCredentials(fp, raw) shape and returns Finding[].
|
|
28
|
+
export function scanHistoryDiff(diffText, commit, detectFn) {
|
|
29
|
+
if (typeof detectFn !== 'function') return [];
|
|
30
|
+
const added = extractAddedLines(diffText);
|
|
31
|
+
if (!added.trim()) return [];
|
|
32
|
+
let findings = [];
|
|
33
|
+
try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
|
|
34
|
+
return findings.map((f) => ({
|
|
35
|
+
...f,
|
|
36
|
+
id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
|
|
37
|
+
file: `git-history@${commit}`,
|
|
38
|
+
line: 0,
|
|
39
|
+
commit,
|
|
40
|
+
_historical: true,
|
|
41
|
+
vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
|
|
42
|
+
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
43
|
+
remediation: 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.',
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sweep up to `maxCommits` of recent history for secrets. Best-effort: returns
|
|
49
|
+
* [] when `scanRoot` is not a git repo or git is unavailable. Dedups a secret
|
|
50
|
+
* that recurs across commits to its earliest sighting.
|
|
51
|
+
*/
|
|
52
|
+
export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000 } = {}) {
|
|
53
|
+
if (!scanRoot || typeof detectFn !== 'function') return [];
|
|
54
|
+
let out;
|
|
55
|
+
try {
|
|
56
|
+
out = execFileSync('git', ['-C', scanRoot, 'log', '-p', '-n', String(maxCommits), '--no-color', '--no-merges', '--no-textconv'],
|
|
57
|
+
{ encoding: 'utf8', maxBuffer: 96 * 1024 * 1024, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
58
|
+
} catch { return []; }
|
|
59
|
+
const parts = out.split(/^commit ([0-9a-f]{7,40})/m); // [pre, sha, body, sha, body, ...]
|
|
60
|
+
const findings = [];
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
63
|
+
const sha = (parts[i] || '').slice(0, 12);
|
|
64
|
+
for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn)) {
|
|
65
|
+
const key = `${f.vuln}:${(f.snippet || f.match || '').slice(0, 40)}`;
|
|
66
|
+
if (seen.has(key)) continue;
|
|
67
|
+
seen.add(key);
|
|
68
|
+
findings.push(f);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
package/src/pr-delta.js
CHANGED
|
@@ -170,6 +170,19 @@ export async function computePrDelta(root, { baseRef, headRef = 'HEAD' } = {}) {
|
|
|
170
170
|
/**
|
|
171
171
|
* Lightweight text summary used as a CLI fallback when --json isn't set.
|
|
172
172
|
*/
|
|
173
|
+
// R24 (PRD §5) — net-new CI gate. Blocks the build ONLY on findings the PR
|
|
174
|
+
// INTRODUCED (delta.introduced), at or above `failOn` severity — never on the
|
|
175
|
+
// pre-existing backlog. This is the only SAST CI posture teams tolerate
|
|
176
|
+
// long-term. Pure + deterministic: takes a delta, returns the gate decision.
|
|
177
|
+
const _NETNEW_SEV_RANK = { critical: 3, high: 2, medium: 1, low: 0, info: -1 };
|
|
178
|
+
export function netNewGate(delta, failOn = 'critical') {
|
|
179
|
+
const introduced = (delta && Array.isArray(delta.introduced)) ? delta.introduced : [];
|
|
180
|
+
if (failOn === 'none') return { fail: false, failOn, introducedCount: introduced.length, blocked: [] };
|
|
181
|
+
const threshold = _NETNEW_SEV_RANK[failOn] ?? 3;
|
|
182
|
+
const blocked = introduced.filter((f) => (_NETNEW_SEV_RANK[f.severity] ?? -1) >= threshold);
|
|
183
|
+
return { fail: blocked.length > 0, failOn, introducedCount: introduced.length, blocked };
|
|
184
|
+
}
|
|
185
|
+
|
|
173
186
|
export function renderPrDeltaText(delta) {
|
|
174
187
|
const i = delta.summary.introduced;
|
|
175
188
|
const r = delta.summary.resolved;
|
package/src/report/index.js
CHANGED
|
@@ -44,6 +44,15 @@ export function normalizeFindings(scan){
|
|
|
44
44
|
dataClasses: f.dataClasses || [],
|
|
45
45
|
chain: Array.isArray(f.chain) ? f.chain : null,
|
|
46
46
|
confidence: typeof f.confidence === 'number' ? f.confidence : null,
|
|
47
|
+
// R17: corroboration ("one issue, many signals") — count of independent
|
|
48
|
+
// analyses that agreed, and which ones.
|
|
49
|
+
corroborationCount: typeof f.corroborationCount === 'number' ? f.corroborationCount : null,
|
|
50
|
+
corroboration: f.corroboration || null,
|
|
51
|
+
multiSignal: f.multiSignal === true ? true : null,
|
|
52
|
+
// R13: proof verdict — `provablySafe` means a flow proof discharged this
|
|
53
|
+
// finding (parameterizer on every path / sanitizer excludes metachars).
|
|
54
|
+
proof: f.proof || null,
|
|
55
|
+
provablySafe: f.provablySafe === true ? true : null,
|
|
47
56
|
// 0.6.0 Feat-2
|
|
48
57
|
toxicity: f.toxicityScore ?? null,
|
|
49
58
|
toxicityFactors: f.toxicityFactors || null,
|
|
@@ -630,6 +639,47 @@ function _esc(s) {
|
|
|
630
639
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
631
640
|
}
|
|
632
641
|
|
|
642
|
+
// R11 (PRD §5) — OpenVEX export. Turns our reachability verdict (R7/R12) into a
|
|
643
|
+
// standard interchange statement per CVE: unreachable tiers → not_affected with
|
|
644
|
+
// the `vulnerable_code_not_in_execute_path` justification; reachable tiers →
|
|
645
|
+
// affected with an action statement; unknown → under_investigation. Lets
|
|
646
|
+
// downstream tools and auditors inherit "this CVE can't fire here" in a format
|
|
647
|
+
// they accept, instead of re-triaging our transitive noise.
|
|
648
|
+
const _VEX_REACHABLE = new Set(['route-reachable-via-function', 'function-reachable', 'import-reachable']);
|
|
649
|
+
const _VEX_NOT_AFFECTED = new Set(['unreachable', 'build-only', 'manifest-only', 'transitive-only']);
|
|
650
|
+
export function toVex(scan, meta = {}) {
|
|
651
|
+
const deps = (scan.supplyChain || []).filter(s => s && s.type === 'vulnerable_dep');
|
|
652
|
+
const statements = [];
|
|
653
|
+
for (const s of deps) {
|
|
654
|
+
const cves = (Array.isArray(s.cveAliases) && s.cveAliases.length ? s.cveAliases : [s.osvId]).filter(Boolean);
|
|
655
|
+
if (!cves.length) continue;
|
|
656
|
+
const purl = s.purl || `pkg:${s.ecosystem || 'generic'}/${s.name}@${s.version || '0'}`;
|
|
657
|
+
const stmt = {};
|
|
658
|
+
if (_VEX_NOT_AFFECTED.has(s.reachabilityTier)) {
|
|
659
|
+
stmt.status = 'not_affected';
|
|
660
|
+
stmt.justification = 'vulnerable_code_not_in_execute_path';
|
|
661
|
+
stmt.impact_statement = `agentic-security reachability: ${s.reachabilityTier}`;
|
|
662
|
+
} else if (_VEX_REACHABLE.has(s.reachabilityTier)) {
|
|
663
|
+
stmt.status = 'affected';
|
|
664
|
+
stmt.action_statement = s.scaVerdictReason || s.remediation ||
|
|
665
|
+
(Array.isArray(s.fixedVersions) && s.fixedVersions.length ? `upgrade ${s.name} to ${s.fixedVersions[0]}` : 'review and remediate');
|
|
666
|
+
} else {
|
|
667
|
+
stmt.status = 'under_investigation';
|
|
668
|
+
}
|
|
669
|
+
for (const cve of cves) {
|
|
670
|
+
statements.push({ vulnerability: { name: cve }, products: [{ '@id': purl }], ...stmt });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
'@context': 'https://openvex.dev/ns/v0.2.0',
|
|
675
|
+
'@id': `https://agentic-security/vex/${(meta && meta.scanId) || 'scan'}`,
|
|
676
|
+
author: 'agentic-security',
|
|
677
|
+
timestamp: (meta && meta.startedAt) || new Date().toISOString(),
|
|
678
|
+
version: 1,
|
|
679
|
+
statements,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
633
683
|
export function toHTML(scan, meta = {}) {
|
|
634
684
|
const findings = normalizeFindings(scan);
|
|
635
685
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
@@ -1001,6 +1051,23 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1001
1051
|
} else if (advisoryCount > 0) {
|
|
1002
1052
|
lines.push(c(` ${advisoryCount} advisory item${advisoryCount === 1 ? '' : 's'} — run /security-scan-all --firehose to see them.`, DIM));
|
|
1003
1053
|
}
|
|
1054
|
+
// Coverage-honesty line (#5/#6): show the scan's blind spots — which
|
|
1055
|
+
// languages got flow analysis vs pattern-only, what was skipped, and how
|
|
1056
|
+
// many dangerous-looking calls had no finding. One concise line, not bloat.
|
|
1057
|
+
const _covMeta = scan._scanMeta;
|
|
1058
|
+
if (_covMeta && _covMeta.analysisTier) {
|
|
1059
|
+
const t = _covMeta.analysisTier;
|
|
1060
|
+
const pat = Object.keys(t.patternOnly || {});
|
|
1061
|
+
const segs = [`${_covMeta.filesScanned ?? '?'} files`];
|
|
1062
|
+
const ir = Object.keys(t.irTaint || {});
|
|
1063
|
+
if (ir.length) segs.push(`flow=[${ir.join(',')}]`);
|
|
1064
|
+
if (pat.length) segs.push(`pattern-only=[${pat.join(',')}]`);
|
|
1065
|
+
const skipped = (_covMeta.filesSkipped || 0) + (_covMeta.filesDenseSkipped || 0);
|
|
1066
|
+
if (skipped) segs.push(`${skipped} skipped`);
|
|
1067
|
+
if (_covMeta.unmodeledSinkCandidates?.count) segs.push(`${_covMeta.unmodeledSinkCandidates.count} unmodeled-sink candidate${_covMeta.unmodeledSinkCandidates.count === 1 ? '' : 's'}`);
|
|
1068
|
+
lines.push('');
|
|
1069
|
+
lines.push(c(' Coverage: ' + segs.join(' · '), DIM));
|
|
1070
|
+
}
|
|
1004
1071
|
lines.push('');
|
|
1005
1072
|
lines.push(c(' 🛡 agentic-security · created by ClearCapabilities.Com', DIM));
|
|
1006
1073
|
return lines.join('\n');
|
package/src/sast/CLAUDE.md
CHANGED
|
@@ -13,11 +13,17 @@ SAST detector modules. Each file exports one or more `scan*()` functions returni
|
|
|
13
13
|
|
|
14
14
|
## What lives here, by category
|
|
15
15
|
|
|
16
|
-
**Language-specific** — `cpp.js`, `csharp.js`, `dart-flutter.js`, `go-extended.js`, `java-deserialization.js`, `kotlin.js
|
|
16
|
+
**Language-specific** — `cpp.js`, `csharp.js`, `csharp-structural.js` (regex structural: hardcoded-secret incl. split-concat + guarded SSRF — complements the flow-based `csharp.js`), `dart-flutter.js`, `go-extended.js`, `java-deserialization.js`, `java-structural.js` (regex structural SQLi/cmdi/path/SSRF via concat, with path/SSRF guards — complements the AST/flow Java modules), `kotlin.js` (Kotlin idioms + **taint-independent structural injection** detectors: SQLi/cmdi/path via string template/concat, SSRF (guarded), XXE (insecure XML config), ObjectInputStream deser — closes the corpus Kotlin FNs where a tainted-by-convention param has no in-file source), `php.js` (+ structural SQLi/cmdi via concat/`$`-interp: DB::raw/whereRaw, shell_exec/exec; structural path traversal: readfile/file_get_contents/fopen with concat), `python-sinks.js`, `ruby.js` (+ structural ActiveRecord SQLi via `#{}`/concat, backtick/system cmdi, and File/IO path traversal via interpolation/concat), `rust.js`, `solidity.js`, `swift.js`, `xxe.js` (CWE-611 for Java/Python **and** PHP/Go/Ruby — each non-JVM stack is XXE-safe by default, so it flags the explicit external-entity opt-in: PHP `LIBXML_NOENT`/`LIBXML_DTDLOAD`, Go `xml.Decoder` `Strict=false`/custom `Entity`, Ruby Nokogiri `noent`/`dtdload`/`replace_entities`; default-safe parses don't match).
|
|
17
|
+
|
|
18
|
+
> **Structural-detector pattern (Tier 1 recall).** `kotlin.js`/`ruby.js`/`php.js` carry taint-independent rules: a dangerous sink built with string interpolation/concat is the injection shape regardless of variable names. This closes corpus FNs where a value is routed through a local var (`params[:x]` → `where("…#{x}")`) so the taint engine sees no source. Keep them high-precision: parameterized / array-form / literal variants must NOT match. Verify on the cve-replay `pre/`+`post/` pairs.
|
|
19
|
+
|
|
20
|
+
**Tree-sitter (long-tail languages, opt-in)** — `tree-sitter-sinks.js` (roadmap #8). AST-accurate detectors for languages with no first-class IR parser (rust/solidity/cpp/go/swift/dart), via `../ir/tree-sitter-loader.js`. Gated behind `AGENTIC_SECURITY_TREE_SITTER=1` and the **optional** `web-tree-sitter` + `tree-sitter-wasms` deps (ABI-pinned 0.20.8 ↔ 0.1.13, marked `--external` so they're never bundled). Degrades to a no-op when the flag or deps are absent. First rule: Rust shell-spawn command injection (CWE-78). Anchoring on real AST nodes means comments/strings can't false-match.
|
|
17
21
|
|
|
18
22
|
**Framework hardening** — `django-hardening.js`, `fastapi-hardening.js`, `laravel-hardening.js`, `quarkus-hardening.js`, `springboot-hardening.js`. Each detects "you used framework X but forgot the security-hardening step that ships with it" rather than a primary vuln.
|
|
19
23
|
|
|
20
|
-
**
|
|
24
|
+
**Framework structural (taint-independent, JS/Py recall)** — `js-framework-structural.js` (Express/Koa/NestJS/TypeORM: SQLi via `.query`/`.execute` concat-template, koa-send path, `ctx.body` XSS, HttpService SSRF, deep-merge prototype pollution) `python-structural.js` (Flask `render_template_string` XSS/SSTI, Django `.raw`/`.extra` + `cursor.execute` SQLi — robust string-literal matching spans embedded quotes like `"… name = '" + x`; `open()`/`send_file` path traversal via concat/f-string, CWE-22 deferring to `dropGuardedFindings`), and `go-structural.js` (db query + `fmt.Sprintf`/concat SQLi, `os.Open` + concat/Sprintf path traversal). High precision: parameterized/escaped/`{{ }}`-Jinja/`%s`-placeholder forms do NOT match; SSRF/path findings defer to `engine.js dropGuardedFindings` (which also drops a reflected-XSS finding when the reflected value passed through a captured HTML escaper — a *discarded* `escapeHtml(s);` does not count — and skips already-`isSanitized` findings so the suppression pipeline keeps its bookkeeping).
|
|
25
|
+
|
|
26
|
+
**Cross-cutting vuln classes** — `authz.js`, `csrf.js` (POST/PUT/PATCH/DELETE state-changing routes without CSRF defence; defence-aware suppression covers Express/Fastify/Flask/Django/FastAPI/Spring/Symfony **and Go (gin/echo/mux), Rails routes, ASP.NET MVC** — recognizes `gorilla/csrf`/`protect_from_forgery`/`[ValidateAntiForgeryToken]` defences and exempts token-auth (`[ApiController]`, Bearer scheme); bare ASP.NET `[Authorize]` still flags as cookie auth is CSRF-vulnerable), `code-injection-multilang.js` (CWE-94 for Java/C#/Go/Kotlin — dynamic code/expression evaluators on a NON-LITERAL argument: javax.script `eval`, GroovyShell, Spring SpEL `parseExpression`, MVEL/OGNL, Roslyn `CSharpScript`, `DataTable.Compute`, yaegi `interp.Eval`, `text/template` Parse of a user-controlled body; literal arguments don't match. JS/Python/Ruby eval stay with the flow engine + per-language modules), `csv-injection.js` (formula injection into spreadsheet cells, CWE-1236), `secret-concat.js` (language-agnostic hardcoded-secret SPLIT across concatenated literals — `'AKIA' + 'IOSF…'` / `'ghp' + '_…'` / `'sk' + '_live_…'` — reassembled and matched against provider prefixes; complements the contiguous-token secrets scanner and the C#-only split-concat rule), `host-header.js`, `jndi.js`, `jwt-exp.js`, `ldap-injection.js` (CWE-90 across JS/Java/Python **and** PHP/Go/C#/Ruby/Kotlin — filter built by concat/interpolation; an inline call-guard and a file-level escape-API guard suppress `ldap_escape`/`EscapeFilter`/`escape_filter_chars`/`Net::LDAP::Filter`/`EqualityFilter` forms), `xpath-injection.js` (CWE-643 across Java/Python/JS **and** PHP/Go/Ruby/C#/Kotlin — XPath expression built by concat/interpolation: `DOMXPath->query`, `SelectNodes`, Nokogiri `.xpath`, htmlquery/xmlpath, `XPath.compile`; embedded-quote-tolerant literal matching; parameterized/variable-bound APIs and static literals don't match), `mass-assignment.js`, `mutation-xss.js`, `nosql-injection.js`, `prototype-pollution.js`, `response-splitting.js` (CWE-113 CRLF/header injection across JS/Python/Java/PHP/Go/Ruby/C#/Kotlin — a response header value set from a request source without stripping CR/LF; recognizes CRLF-strip sanitizers — `.replace(/[\r\n]/)`, chained `.replace("\r")`, Ruby `gsub`/`delete`, Go `strings.NewReplacer`, PHP `str_replace` — and a request-scope param heuristic for the JVM/C# single-file shape), `ssrf-cloud-metadata.js`, `xss-reflected-multilang.js` (cross-language reflected XSS for Go/Ruby/PHP/C#/Kotlin/Java — user input written into an HTML response via concat/interpolation, e.g. Java servlet `response.getWriter().write("<…" + q)`, with a per-language escaper exclusion so `htmlspecialchars`/`HtmlEncode`/`template.HTMLEscapeString`/ERB `<%= %>`/OWASP `Encode.forHtml` forms don't match; JS/Python XSS stays with the flow engine + framework structural detectors), `stored-taint.js` (second-order / stored injection — **opt-in** via `AGENTIC_SECURITY_STORED_TAINT=1`), `toctou.js`, `wrong-context-sanitizer.js` (HTML-entity encoder used in a URL context — wrong-context output encoding, CWE-79), `zip-slip.js`.
|
|
21
27
|
|
|
22
28
|
**Cloud/infra** — `db-rls.js` (Supabase RLS), `env-hygiene.js` (NEXT_PUBLIC_ leaks, .env.example real values), `mobile-manifest.js`, `pipeline.js` (CI/CD integrity), `rate-limit.js`, `webhook.js`.
|
|
23
29
|
|
|
@@ -25,7 +31,7 @@ SAST detector modules. Each file exports one or more `scan*()` functions returni
|
|
|
25
31
|
|
|
26
32
|
**Agent-of-agent / Claude Code hardening** — `claude-hook-injection.js`, `claude-md-prompt-injection.js`, `claude-settings.js`.
|
|
27
33
|
|
|
28
|
-
**Auth / web** — `auth-provider.js`, `client-side.js
|
|
34
|
+
**Auth / web** — `auth-provider.js`, `client-side.js`, `frontend-hygiene.js` (reverse tabnabbing CWE-1022, missing Subresource Integrity CWE-353, Angular `bypassSecurityTrust*` sanitizer-bypass CWE-79).
|
|
29
35
|
|
|
30
36
|
**Bench-shape adapters (label-leakage, OFF by default)** — `bench-shape/` directory, plus `cpp-bench-extras.js`, `java-bench-extras.js`, `juliet-shape.js`, `primary-cwe-java.js`. These read Juliet / OWASP-Benchmark answer keys. Disabled unless `AGENTIC_SECURITY_BENCH_SHAPE=1`; `AGENTIC_SECURITY_BLIND_BENCH=1` overrides to force everything off. **Never use their emissions as a quality signal.**
|
|
31
37
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// R20 (PRD §5) — agent-loop taint: untrusted content → high-privilege tool.
|
|
2
|
+
//
|
|
3
|
+
// The agent threat that pattern matchers and ordinary taint both miss: content
|
|
4
|
+
// the model does NOT control — a retrieved RAG document, a tool's output — is
|
|
5
|
+
// fed into a side-effecting sink (shell/exec/eval). That's indirect prompt
|
|
6
|
+
// injection escalating to code/command execution (OWASP LLM01 + Excessive
|
|
7
|
+
// Agency). agent-tool-escalation.js flags the read+act tool COMBINATION; this
|
|
8
|
+
// flags the actual DATA FLOW from an untrusted source into the sink, and only
|
|
9
|
+
// when there is no mediation step (human approval, allow-list, sanitizer)
|
|
10
|
+
// between them.
|
|
11
|
+
//
|
|
12
|
+
// Precision-first, like the other structural detectors: it runs only in
|
|
13
|
+
// agent/LLM/RAG files, tracks untrusted source variables (+ one assignment
|
|
14
|
+
// hop), and suppresses when a mediation token sits between source and sink.
|
|
15
|
+
|
|
16
|
+
import { blankComments } from './_comment-strip.js';
|
|
17
|
+
|
|
18
|
+
const AGENT_CONTEXT = /(retriever|vectorstore|vector_store|similarity_search|get_relevant_documents|langchain|llama_?index|page_content|@tool\b|tool_use|input_schema|ChatOpenAI|ChatAnthropic|create_react_agent|AgentExecutor|\.invoke\s*\()/i;
|
|
19
|
+
|
|
20
|
+
// RHS APIs whose return value is attacker-influenceable retrieved content.
|
|
21
|
+
const RETRIEVAL_API = /\b(get_relevant_documents|aget_relevant_documents|similarity_search(?:_with_score|_with_relevance_scores)?|max_marginal_relevance_search)\s*\(/;
|
|
22
|
+
|
|
23
|
+
// A var assignment whose RHS calls a retrieval API → the LHS is untrusted.
|
|
24
|
+
const SRC_ASSIGN = /(?:\b(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?[^\n;=]*?\b(?:get_relevant_documents|aget_relevant_documents|similarity_search(?:_with_score|_with_relevance_scores)?|max_marginal_relevance_search)\s*\(/g;
|
|
25
|
+
|
|
26
|
+
// Untrusted member access that needs no variable tracking (the doc body).
|
|
27
|
+
const UNTRUSTED_MEMBER = /\.(?:page_content|text)\b/;
|
|
28
|
+
|
|
29
|
+
// High-privilege / side-effecting sinks (code + command execution).
|
|
30
|
+
const SINKS = [
|
|
31
|
+
{ re: /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync)\s*\(/, label: 'child_process exec/spawn' },
|
|
32
|
+
{ re: /\beval\s*\(/, label: 'eval()' },
|
|
33
|
+
{ re: /\bnew\s+Function\s*\(/, label: 'new Function()' },
|
|
34
|
+
{ re: /\bos\.system\s*\(/, label: 'os.system()' },
|
|
35
|
+
{ re: /\bsubprocess\.(?:run|call|check_output|check_call|Popen)\s*\(/, label: 'subprocess' },
|
|
36
|
+
{ re: /\b(?:Popen|check_output|getoutput|getstatusoutput)\s*\(/, label: 'subprocess' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// A mediation/guard step between source and sink defuses the flow.
|
|
40
|
+
// Substring match (NOT word-bounded): a guard token inside a compound
|
|
41
|
+
// identifier — human_in_the_loop_approve, approveCommand, sanitizeInput,
|
|
42
|
+
// isSafeCommand — must still count as mediation.
|
|
43
|
+
const MEDIATION = /(approve|approval|confirm|human_in_the_loop|human_input|interrupt|require_consent|consent|allow_?list|whitelist|sanitiz|validate_command|validate_input|is_safe|safe_to_run|safeguard|deny|reject)/i;
|
|
44
|
+
|
|
45
|
+
function lineOf(code, idx) { return code.slice(0, idx).split('\n').length; }
|
|
46
|
+
|
|
47
|
+
export function scanAgentUntrustedFlow(fp, raw) {
|
|
48
|
+
if (typeof raw !== 'string' || !raw) return [];
|
|
49
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py)$/i.test(fp)) return [];
|
|
50
|
+
const code = blankComments(raw);
|
|
51
|
+
if (!AGENT_CONTEXT.test(code)) return [];
|
|
52
|
+
const lines = code.split('\n');
|
|
53
|
+
|
|
54
|
+
// 1. Untrusted source variables (retrieval-API results) + one assignment hop.
|
|
55
|
+
const untrusted = new Map(); // varName -> defining line
|
|
56
|
+
let m;
|
|
57
|
+
SRC_ASSIGN.lastIndex = 0;
|
|
58
|
+
while ((m = SRC_ASSIGN.exec(code))) untrusted.set(m[1], lineOf(code, m.index));
|
|
59
|
+
// One hop: `X = <expr referencing an untrusted var or .page_content/.text>`.
|
|
60
|
+
const HOP = /(?:\b(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=\s*([^\n;]+)/g;
|
|
61
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
62
|
+
HOP.lastIndex = 0;
|
|
63
|
+
while ((m = HOP.exec(code))) {
|
|
64
|
+
const lhs = m[1], rhs = m[2];
|
|
65
|
+
if (untrusted.has(lhs)) continue;
|
|
66
|
+
if (UNTRUSTED_MEMBER.test(rhs) || [...untrusted.keys()].some((v) => new RegExp(`\\b${v}\\b`).test(rhs))) {
|
|
67
|
+
untrusted.set(lhs, lineOf(code, m.index));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!untrusted.size && !UNTRUSTED_MEMBER.test(code)) return [];
|
|
72
|
+
|
|
73
|
+
const findings = [];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
for (let i = 0; i < lines.length; i++) {
|
|
76
|
+
const line = lines[i];
|
|
77
|
+
for (const sink of SINKS) {
|
|
78
|
+
if (!sink.re.test(line)) continue;
|
|
79
|
+
// Does the sink's argument reference untrusted content?
|
|
80
|
+
const argText = line.slice(line.search(sink.re));
|
|
81
|
+
const hitsMember = UNTRUSTED_MEMBER.test(argText);
|
|
82
|
+
const srcVar = [...untrusted.keys()].find((v) => new RegExp(`\\b${v}\\b`).test(argText));
|
|
83
|
+
if (!hitsMember && !srcVar) continue;
|
|
84
|
+
// Mediation between the source definition and this sink suppresses it.
|
|
85
|
+
const srcLine = srcVar ? untrusted.get(srcVar) : Math.max(1, i - 8);
|
|
86
|
+
const window = lines.slice(Math.max(0, srcLine - 1), i + 1).join('\n');
|
|
87
|
+
if (MEDIATION.test(window)) continue;
|
|
88
|
+
const key = `${i + 1}:${sink.label}`;
|
|
89
|
+
if (seen.has(key)) continue;
|
|
90
|
+
seen.add(key);
|
|
91
|
+
findings.push({
|
|
92
|
+
id: `agent-untrusted-flow:${fp}:${i + 1}`,
|
|
93
|
+
severity: 'high',
|
|
94
|
+
file: fp,
|
|
95
|
+
line: i + 1,
|
|
96
|
+
vuln: 'Indirect Prompt Injection → Code/Command Execution (untrusted agent content reaches a high-privilege sink)',
|
|
97
|
+
cwe: '94',
|
|
98
|
+
owaspLlm: 'LLM01',
|
|
99
|
+
family: 'agent-untrusted-flow',
|
|
100
|
+
parser: 'AGENT-FLOW',
|
|
101
|
+
description: `Untrusted agent content (${srcVar ? `via \`${srcVar}\`` : 'a retrieved document body'}) flows into ${sink.label} with no mediation step. A poisoned document or tool output can drive code/command execution (indirect prompt injection → excessive agency).`,
|
|
102
|
+
remediation: 'Never pass retrieved/tool-derived content to a code/command sink. Require a human-approval/allow-list/sanitizer mediation step, or constrain the tool to a fixed, non-interpolated command.',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return findings;
|
|
107
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// R19 (PRD §5) — OWASP API Top 10: Broken Object/Function Level Authorization.
|
|
2
|
+
//
|
|
3
|
+
// The data-layer IDOR detector (engine.js) catches `findById(req.params.id)`
|
|
4
|
+
// without an ownership clause. This complements it at the ROUTE-INVENTORY
|
|
5
|
+
// layer, where the highest-signal tell is INCONSISTENCY: a route registered in
|
|
6
|
+
// a file whose siblings are authenticated, but which itself has no auth.
|
|
7
|
+
//
|
|
8
|
+
// BFLA (API5): a state-changing route (POST/PUT/PATCH/DELETE) with no auth
|
|
9
|
+
// while sibling routes in the same file do enforce it.
|
|
10
|
+
// BOLA (API1): a route taking an object identifier (:id / {id} / <id>) with
|
|
11
|
+
// no auth, so object-level ownership cannot be enforced.
|
|
12
|
+
//
|
|
13
|
+
// Precision: we only fire inside files that DO authenticate some routes (an
|
|
14
|
+
// authenticated API surface) — a fully-public API isn't flagged, and the
|
|
15
|
+
// inconsistency is what makes a missing check a likely bug rather than intent.
|
|
16
|
+
|
|
17
|
+
const STATE_CHANGING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
18
|
+
const ID_PARAM = /\/(?::\w*(?:id|key|uuid|guid|slug)\w*|\{\w*(?:id|key|uuid|guid|slug)\w*\}|<\w*(?:id|key|uuid|guid|slug)\w*>)/i;
|
|
19
|
+
|
|
20
|
+
function mk(r, kind, api, cwe, why) {
|
|
21
|
+
return {
|
|
22
|
+
id: `api-authz:${kind}:${r.file}:${r.line}`,
|
|
23
|
+
severity: 'high',
|
|
24
|
+
file: r.file,
|
|
25
|
+
line: r.line || 0,
|
|
26
|
+
vuln: `Broken ${kind === 'BOLA' ? 'Object' : 'Function'} Level Authorization (${api})`,
|
|
27
|
+
cwe,
|
|
28
|
+
family: 'broken-access-control',
|
|
29
|
+
parser: 'API-AUTHZ',
|
|
30
|
+
description: why,
|
|
31
|
+
remediation: kind === 'BOLA'
|
|
32
|
+
? 'Authenticate the route and verify the caller owns the referenced object (scope the lookup by req.user.id / tenantId), don\'t trust the id from the request alone.'
|
|
33
|
+
: 'Apply the same authentication/authorization middleware the sibling routes use; state-changing endpoints must enforce a role/permission check.',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cross-route analysis over the aggregated route inventory (aR).
|
|
39
|
+
* Pure: takes routes[], returns Finding[].
|
|
40
|
+
*/
|
|
41
|
+
export function scanApiBrokenAuthz(routes) {
|
|
42
|
+
if (!Array.isArray(routes) || routes.length < 2) return [];
|
|
43
|
+
const byFile = new Map();
|
|
44
|
+
for (const r of routes) {
|
|
45
|
+
if (!r || r.path === '(file-based)' || !r.file) continue;
|
|
46
|
+
if (!byFile.has(r.file)) byFile.set(r.file, []);
|
|
47
|
+
byFile.get(r.file).push(r);
|
|
48
|
+
}
|
|
49
|
+
const findings = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const push = (f) => { const k = `${f.file}:${f.line}`; if (seen.has(k)) return; seen.add(k); findings.push(f); };
|
|
52
|
+
for (const group of byFile.values()) {
|
|
53
|
+
if (group.length < 2) continue;
|
|
54
|
+
const authed = group.filter((r) => r.hasAuth).length;
|
|
55
|
+
// Only an authenticated API surface — at least one protected sibling, but
|
|
56
|
+
// not all (otherwise there's no inconsistency to flag).
|
|
57
|
+
if (authed < 1 || authed === group.length) continue;
|
|
58
|
+
for (const r of group) {
|
|
59
|
+
if (r.hasAuth) continue;
|
|
60
|
+
const idParam = ID_PARAM.test(r.path || '');
|
|
61
|
+
if (idParam) {
|
|
62
|
+
push(mk(r, 'BOLA', 'API1', '639',
|
|
63
|
+
`${r.method} ${r.path} takes an object identifier but enforces no authorization, while ${authed}/${group.length} sibling routes do. Object-level ownership cannot be enforced — an attacker can substitute another user's id.`));
|
|
64
|
+
} else if (STATE_CHANGING.has(r.method)) {
|
|
65
|
+
push(mk(r, 'BFLA', 'API5', '285',
|
|
66
|
+
`${r.method} ${r.path} is a state-changing route with no authorization, while ${authed}/${group.length} sibling routes in this file enforce it — a likely missing function-level access check.`));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return findings;
|
|
71
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Cross-language code-injection detector (CWE-94) — Java / C# / Go / Kotlin.
|
|
2
|
+
//
|
|
3
|
+
// The JS/Python/Ruby eval-family sinks are covered by the flow engine + the
|
|
4
|
+
// per-language modules. The compiled second-tier languages had no CWE-94
|
|
5
|
+
// coverage at all. This module flags the canonical dynamic-code-execution
|
|
6
|
+
// sinks in those languages when the evaluated argument is NON-LITERAL (a
|
|
7
|
+
// variable or expression) — a literal `eval("1+1")` is not injection.
|
|
8
|
+
//
|
|
9
|
+
// Sinks covered:
|
|
10
|
+
// Java/Kotlin (JVM):
|
|
11
|
+
// - javax.script ScriptEngine.eval(<expr>)
|
|
12
|
+
// - GroovyShell().evaluate(<expr>) / GroovyShell.parse
|
|
13
|
+
// - Spring SpEL: (Spel)ExpressionParser().parseExpression(<expr>)
|
|
14
|
+
// - MVEL.eval / OGNL: Ognl.getValue / OgnlUtil
|
|
15
|
+
// C#:
|
|
16
|
+
// - Roslyn: CSharpScript.EvaluateAsync/RunAsync(<expr>)
|
|
17
|
+
// - DataTable().Compute(<expr>, …) (expression evaluator → injection)
|
|
18
|
+
// Go:
|
|
19
|
+
// - yaegi/gomacro interpreter: interp.Eval(<expr>)
|
|
20
|
+
// - text/template|html/template Parse(<expr>) of a NON-LITERAL template
|
|
21
|
+
// (user-controlled template body → action/code execution)
|
|
22
|
+
//
|
|
23
|
+
// Precision: the matched argument must not be a string literal. Escaped/safe
|
|
24
|
+
// constructs (a literal template, a constant expression) don't match.
|
|
25
|
+
|
|
26
|
+
import { blankComments } from './_comment-strip.js';
|
|
27
|
+
|
|
28
|
+
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
29
|
+
|
|
30
|
+
// A non-literal argument: an identifier / member / call expression — i.e. NOT
|
|
31
|
+
// a bare string/char/number literal as the whole argument. Used as a trailing
|
|
32
|
+
// assertion `(?!\s*STRING\s*\))` after the opening paren.
|
|
33
|
+
const STR_LIT = String.raw`(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|@?"[^"]*")`;
|
|
34
|
+
|
|
35
|
+
const REMEDIATION =
|
|
36
|
+
'Never evaluate runtime-built code. Replace the dynamic evaluator with an explicit ' +
|
|
37
|
+
'dispatch table / allow-list of operations, a sandboxed expression library with no ' +
|
|
38
|
+
'I/O and a fixed grammar, or precompiled templates whose body is never user-controlled. ' +
|
|
39
|
+
'For Spring SpEL use a SimpleEvaluationContext (no type/bean access); for Go templates ' +
|
|
40
|
+
'keep the template body static and pass values as data, never Parse user input.';
|
|
41
|
+
|
|
42
|
+
// Per-language sink patterns. Each is built so the argument is captured and a
|
|
43
|
+
// negative lookahead rejects a pure string-literal argument.
|
|
44
|
+
function patternsFor(lang) {
|
|
45
|
+
// `(?!\s*<str>\s*[),])` — reject when the FIRST argument is a bare literal.
|
|
46
|
+
const notLiteral = String.raw`(?!\s*${STR_LIT}\s*[),])`;
|
|
47
|
+
if (lang === 'java' || lang === 'kt') {
|
|
48
|
+
return [
|
|
49
|
+
// ScriptEngine.eval(expr) — but not eval(reader) literal; require non-literal.
|
|
50
|
+
{ key: 'script-eval', re: new RegExp(String.raw`\.\s*eval\s*\(\s*${notLiteral}`, 'g'),
|
|
51
|
+
gate: /\b(?:ScriptEngine|ScriptEngineManager|getEngineByName|javax\.script|NashornScriptEngine)\b/ },
|
|
52
|
+
{ key: 'groovy', re: new RegExp(String.raw`\bGroovyShell\s*\([^)]*\)\s*\.\s*(?:evaluate|parse)\s*\(\s*${notLiteral}`, 'g') },
|
|
53
|
+
{ key: 'groovy2', re: new RegExp(String.raw`\b(?:GroovyShell|GroovyClassLoader)\b[\s\S]{0,120}?\.\s*(?:evaluate|parse|parseClass)\s*\(\s*${notLiteral}`, 'g') },
|
|
54
|
+
{ key: 'spel', re: new RegExp(String.raw`\bparseExpression\s*\(\s*${notLiteral}`, 'g'),
|
|
55
|
+
gate: /\b(?:SpelExpressionParser|ExpressionParser|org\.springframework\.expression)\b/ },
|
|
56
|
+
{ key: 'mvel', re: new RegExp(String.raw`\bMVEL\s*\.\s*(?:eval|evalToString|compileExpression|executeExpression)\s*\(\s*${notLiteral}`, 'g') },
|
|
57
|
+
{ key: 'ognl', re: new RegExp(String.raw`\bOgnl\s*\.\s*(?:getValue|setValue|parseExpression)\s*\(\s*${notLiteral}`, 'g') },
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
if (lang === 'cs') {
|
|
61
|
+
return [
|
|
62
|
+
{ key: 'roslyn', re: new RegExp(String.raw`\bCSharpScript\s*\.\s*(?:EvaluateAsync|RunAsync|Create)\s*\(\s*${notLiteral}`, 'g') },
|
|
63
|
+
{ key: 'datatable-compute', re: new RegExp(String.raw`\.\s*Compute\s*\(\s*${notLiteral}`, 'g'),
|
|
64
|
+
gate: /\b(?:DataTable|DataColumn|DataView)\b/ },
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
if (lang === 'go') {
|
|
68
|
+
return [
|
|
69
|
+
// yaegi / gomacro interpreter eval of a non-literal.
|
|
70
|
+
{ key: 'interp-eval', re: new RegExp(String.raw`\.\s*Eval\s*\(\s*${notLiteral}`, 'g'),
|
|
71
|
+
gate: /\b(?:interp|yaegi|gomacro|Interpreter)\b/ },
|
|
72
|
+
// template Parse of a non-literal template body (user-controlled template).
|
|
73
|
+
{ key: 'template-parse', re: new RegExp(String.raw`\.\s*Parse\s*\(\s*${notLiteral}`, 'g'),
|
|
74
|
+
gate: /\b(?:text\/template|html\/template|template\.New)\b/ },
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _lang(fp) {
|
|
81
|
+
if (/\.java$/i.test(fp)) return 'java';
|
|
82
|
+
if (/\.kt$/i.test(fp)) return 'kt';
|
|
83
|
+
if (/\.cs$/i.test(fp)) return 'cs';
|
|
84
|
+
if (/\.go$/i.test(fp)) return 'go';
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function scanCodeInjectionMultilang(fp, raw) {
|
|
89
|
+
if (!raw || raw.length > 500_000) return [];
|
|
90
|
+
const lang = _lang(fp);
|
|
91
|
+
if (!lang) return [];
|
|
92
|
+
const code = blankComments(raw);
|
|
93
|
+
const pats = patternsFor(lang);
|
|
94
|
+
const findings = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
|
|
97
|
+
for (const { key, re, gate } of pats) {
|
|
98
|
+
if (gate && !gate.test(code)) continue;
|
|
99
|
+
const r = new RegExp(re.source, re.flags);
|
|
100
|
+
let m;
|
|
101
|
+
while ((m = r.exec(code))) {
|
|
102
|
+
const line = lineOf(code, m.index);
|
|
103
|
+
const id = `code-injection-ml:${fp}:${line}:${key}`;
|
|
104
|
+
if (seen.has(id)) continue;
|
|
105
|
+
seen.add(id);
|
|
106
|
+
findings.push({
|
|
107
|
+
id, file: fp, line,
|
|
108
|
+
vuln: 'Code Injection — dynamic code/expression evaluation of a non-literal argument',
|
|
109
|
+
severity: 'critical', cwe: 'CWE-94', family: 'code-injection',
|
|
110
|
+
parser: lang.toUpperCase(), confidence: 0.7, stride: 'Elevation of Privilege',
|
|
111
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
112
|
+
remediation: REMEDIATION,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// R22 (PRD §5) — cross-service dataflow (contract-file-free).
|
|
2
|
+
//
|
|
3
|
+
// posture/cross-lang-* links services through a shared contract artifact
|
|
4
|
+
// (OpenAPI/proto). This infers the edge directly from CODE: an HTTP client call
|
|
5
|
+
// whose URL path matches a route registered in ANOTHER file is a service edge.
|
|
6
|
+
// When the client sends user-controlled data over that edge — especially to an
|
|
7
|
+
// UNAUTHENTICATED internal endpoint — data crosses a trust boundary unobserved.
|
|
8
|
+
//
|
|
9
|
+
// Pure cross-file analysis over the aggregated route inventory + file contents.
|
|
10
|
+
// Path matching normalizes dynamic segments (/:id, /{id}, numeric) so a literal
|
|
11
|
+
// client URL aligns with a parameterized route. Precision-first: requires a
|
|
12
|
+
// real route match AND user-controlled data in the client call.
|
|
13
|
+
|
|
14
|
+
const CLIENT_CALLS = [
|
|
15
|
+
/\bfetch\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
16
|
+
/\baxios\s*\.\s*(?:get|post|put|patch|delete|request)\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
17
|
+
/\baxios\s*\(\s*\{[^}]*?\burl\s*:\s*[`'"]([^`'"]+)[`'"]/g,
|
|
18
|
+
/\b(?:got|ky|superagent|needle)\s*(?:\.\s*\w+)?\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
19
|
+
/\brequests\s*\.\s*(?:get|post|put|patch|delete)\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
20
|
+
/\bhttpClient\s*\.\s*\w+\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
21
|
+
];
|
|
22
|
+
const USER_DATA = /\b(?:req\.(?:body|params|query|headers)|request\.(?:data|json|args)|ctx\.request|params|\.body\b|userInput|user_input|formData)\b/;
|
|
23
|
+
|
|
24
|
+
// Normalize a URL path: drop scheme+host, collapse dynamic segments to '*'.
|
|
25
|
+
export function normalizePath(url) {
|
|
26
|
+
let p = String(url || '').trim();
|
|
27
|
+
p = p.replace(/^[a-z]+:\/\/[^/]+/i, ''); // strip scheme://host
|
|
28
|
+
p = p.replace(/[?#].*$/, ''); // strip query/hash
|
|
29
|
+
if (!p.startsWith('/')) p = '/' + p;
|
|
30
|
+
const segs = p.split('/').map((s) => {
|
|
31
|
+
if (/^(?::\w+|\{\w+\}|<\w+>|\*|\$\{[^}]+\}|\d+)$/.test(s)) return '*';
|
|
32
|
+
return s.toLowerCase();
|
|
33
|
+
});
|
|
34
|
+
let out = segs.join('/').replace(/\/+$/, '');
|
|
35
|
+
return out || '/';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function lineOf(text, idx) { return text.slice(0, idx).split('\n').length; }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* routes: aggregated route inventory (aR). fileContents: { file: text }.
|
|
42
|
+
* Returns cross-service edge findings.
|
|
43
|
+
*/
|
|
44
|
+
export function scanCrossService(routes, fileContents) {
|
|
45
|
+
if (!Array.isArray(routes) || !routes.length) return [];
|
|
46
|
+
// Index server routes by normalized path → list of {route}.
|
|
47
|
+
const byPath = new Map();
|
|
48
|
+
for (const r of routes) {
|
|
49
|
+
if (!r || r.path === '(file-based)' || !r.path || !r.path.startsWith('/')) continue;
|
|
50
|
+
const np = normalizePath(r.path);
|
|
51
|
+
if (!byPath.has(np)) byPath.set(np, []);
|
|
52
|
+
byPath.get(np).push(r);
|
|
53
|
+
}
|
|
54
|
+
if (!byPath.size) return [];
|
|
55
|
+
|
|
56
|
+
const findings = [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const [file, content] of Object.entries(fileContents || {})) {
|
|
59
|
+
if (typeof content !== 'string') continue;
|
|
60
|
+
for (const re of CLIENT_CALLS) {
|
|
61
|
+
const rx = new RegExp(re.source, re.flags);
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = rx.exec(content))) {
|
|
64
|
+
const url = m[1];
|
|
65
|
+
const np = normalizePath(url);
|
|
66
|
+
const targets = byPath.get(np);
|
|
67
|
+
if (!targets) continue;
|
|
68
|
+
// Only a cross-FILE edge is interesting (same-file is a local call).
|
|
69
|
+
const remote = targets.find((t) => t.file !== file);
|
|
70
|
+
if (!remote) continue;
|
|
71
|
+
const line = lineOf(content, m.index);
|
|
72
|
+
// Is user-controlled data in the call's vicinity?
|
|
73
|
+
const ctx = content.slice(m.index, m.index + 240);
|
|
74
|
+
const carriesUserData = USER_DATA.test(ctx);
|
|
75
|
+
if (!carriesUserData) continue; // precision: only flag tainted edges
|
|
76
|
+
const key = `${file}:${line}:${np}`;
|
|
77
|
+
if (seen.has(key)) continue;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
const unauthed = remote.hasAuth === false;
|
|
80
|
+
findings.push({
|
|
81
|
+
id: `cross-service:${file}:${line}`,
|
|
82
|
+
severity: unauthed ? 'high' : 'medium',
|
|
83
|
+
file, line,
|
|
84
|
+
vuln: `Cross-service flow: user-controlled data sent to ${remote.method} ${remote.path}${unauthed ? ' (unauthenticated internal endpoint)' : ''}`,
|
|
85
|
+
cwe: unauthed ? 'CWE-862' : 'CWE-668',
|
|
86
|
+
family: 'cross-service',
|
|
87
|
+
parser: 'XSERVICE',
|
|
88
|
+
description: `This client call carries user-controlled data to ${remote.method} ${remote.path}, defined in ${remote.file}. Data crosses a service boundary here${unauthed ? ', and the target route enforces no authentication — an attacker-reachable internal endpoint' : ''}. Inferred from code (no shared contract file).`,
|
|
89
|
+
remediation: unauthed
|
|
90
|
+
? 'Authenticate/authorize the internal endpoint and validate the forwarded payload at the receiving service; never trust cross-service input implicitly.'
|
|
91
|
+
: 'Validate and authorize the forwarded payload at the receiving service; treat cross-service input as untrusted.',
|
|
92
|
+
_edge: { from: file, to: remote.file, route: `${remote.method} ${remote.path}` },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return findings;
|
|
98
|
+
}
|