@clear-capabilities/agentic-security-scanner 0.77.0 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/.agentic-security/findings.json +1907 -0
- package/bin/.agentic-security/last-scan.json +1907 -0
- package/bin/.agentic-security/last-scan.json.sig +1 -0
- package/bin/.agentic-security/scan-history.json +166 -0
- package/bin/.agentic-security/streak.json +20 -0
- package/bin/agentic-security.js +55 -9
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/476.index.js +5 -5
- package/dist/637.index.js +1 -1
- package/dist/700.index.js +138 -0
- package/dist/718.index.js +159 -0
- package/dist/824.index.js +126 -0
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +5 -0
- package/dist/agentic-security.mjs +32 -32
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/dataflow/async-sequencing.js +16 -7
- package/src/dataflow/builtin-summaries.js +131 -0
- package/src/dataflow/catalog.js +107 -0
- package/src/dataflow/cross-repo.js +75 -1
- package/src/dataflow/engine.js +181 -8
- package/src/dataflow/implicit-flow.js +24 -6
- package/src/dataflow/stub-aware-filter.js +69 -11
- package/src/dataflow/summaries.js +28 -3
- package/src/engine-parallel.js +70 -0
- package/src/engine.js +270 -19
- package/src/integrations/index.js +2 -1
- package/src/ir/callgraph.js +27 -7
- package/src/ir/index.js +22 -1
- package/src/ir/parser-go.js +403 -0
- package/src/ir/parser-js.js +2 -0
- package/src/ir/parser-php.js +330 -0
- package/src/ir/parser-py.helper.py +137 -11
- package/src/ir/parser-rb.js +309 -0
- package/src/llm-validator/index.js +7 -5
- package/src/mcp/audit.js +5 -0
- package/src/posture/calibration-drift.js +2 -1
- package/src/posture/calibration.js +16 -1
- package/src/posture/fix-history.js +8 -2
- package/src/posture/profile.js +4 -5
- package/src/posture/rule-overrides.js +2 -3
- package/src/posture/rule-pack-signing.js +2 -3
- package/src/posture/rule-synthesis.js +5 -6
- package/src/posture/security-trend.js +4 -7
- package/src/posture/state-dir.js +124 -0
- package/src/posture/streak.js +3 -0
- package/src/posture/suppressions.js +5 -8
- package/src/posture/triage.js +16 -5
- package/src/posture/validator-metrics.js +3 -6
- package/src/report/index.js +23 -2
- package/src/sast/cache-poisoning.js +77 -0
- package/src/sast/comparison-safety.js +73 -0
- package/src/sast/db-taint.js +78 -0
- package/src/sast/graphql.js +127 -0
- package/src/sast/llm-stored-prompt.js +57 -0
- package/src/sast/mutation-xss.js +43 -0
- package/src/sast/nosql-injection.js +5 -0
- package/src/sast/null-byte-injection.js +76 -0
- package/src/sast/redos-nfa.js +338 -0
- package/src/sast/rust.js +26 -0
- package/src/sast/sensitive-data-logging.js +73 -0
- package/src/sast/weak-password-hash.js +77 -0
- package/src/sast/weak-randomness.js +100 -0
- package/src/sca/binary-metadata.js +124 -0
- package/src/sca/llm-function-extract.js +107 -0
- package/src/sca/py-package-functions.js +118 -0
- package/src/sca/vendor-detect.js +144 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Project-root resolver for .agentic-security/ state.
|
|
2
|
+
//
|
|
3
|
+
// BUG HISTORY: Previously, state-writing code used the pattern
|
|
4
|
+
// `path.join(scanRoot || process.cwd(), '.agentic-security', ...)`
|
|
5
|
+
// When `scanRoot` was null/undefined and the scanner was invoked from
|
|
6
|
+
// a subdirectory (e.g., migrations/, config/), this created
|
|
7
|
+
// `.agentic-security/` folders inside those subdirectories — breaking
|
|
8
|
+
// the user's build (one report: DB migration system saw the folder as
|
|
9
|
+
// a migration file). One user uninstalled the plugin entirely.
|
|
10
|
+
//
|
|
11
|
+
// FIX: All state writes go through this module. process.cwd() is NEVER
|
|
12
|
+
// trusted directly. We walk upward from cwd looking for project markers
|
|
13
|
+
// (.git, package.json, etc.) and write state there. A safety check
|
|
14
|
+
// refuses to write if no marker is found.
|
|
15
|
+
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
|
|
19
|
+
const PROJECT_MARKERS = [
|
|
20
|
+
'.git',
|
|
21
|
+
'package.json',
|
|
22
|
+
'pyproject.toml',
|
|
23
|
+
'go.mod',
|
|
24
|
+
'Cargo.toml',
|
|
25
|
+
'pom.xml',
|
|
26
|
+
'build.gradle',
|
|
27
|
+
'composer.json',
|
|
28
|
+
'Gemfile',
|
|
29
|
+
'.agentic-security',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function _findProjectRoot(startDir) {
|
|
33
|
+
let dir = path.resolve(startDir);
|
|
34
|
+
const visited = new Set();
|
|
35
|
+
while (dir && !visited.has(dir)) {
|
|
36
|
+
visited.add(dir);
|
|
37
|
+
for (const m of PROJECT_MARKERS) {
|
|
38
|
+
try {
|
|
39
|
+
if (fs.existsSync(path.join(dir, m))) return dir;
|
|
40
|
+
} catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
const parent = path.dirname(dir);
|
|
43
|
+
if (parent === dir) break;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveProjectRoot(scanRoot) {
|
|
50
|
+
// Prefer caller-provided scanRoot when it points to an existing directory
|
|
51
|
+
if (scanRoot && typeof scanRoot === 'string') {
|
|
52
|
+
try {
|
|
53
|
+
const resolved = path.resolve(scanRoot);
|
|
54
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
55
|
+
return resolved;
|
|
56
|
+
}
|
|
57
|
+
} catch { /* fall through */ }
|
|
58
|
+
}
|
|
59
|
+
// Walk upward from cwd looking for project markers
|
|
60
|
+
const fromCwd = _findProjectRoot(process.cwd());
|
|
61
|
+
if (fromCwd) return fromCwd;
|
|
62
|
+
// No project markers found — return cwd but caller should check via isSafeStateDir
|
|
63
|
+
return process.cwd();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function stateDir(scanRoot) {
|
|
67
|
+
return path.join(resolveProjectRoot(scanRoot), '.agentic-security');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function statePath(scanRoot, ...parts) {
|
|
71
|
+
return path.join(stateDir(scanRoot), ...parts);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Safety check: refuse to create .agentic-security/ unless the parent
|
|
75
|
+
// directory has at least one project marker. Prevents littering when
|
|
76
|
+
// resolution falls through to a non-project directory.
|
|
77
|
+
export function isSafeStateDir(dir) {
|
|
78
|
+
if (!dir) return false;
|
|
79
|
+
const parent = path.dirname(dir);
|
|
80
|
+
for (const m of PROJECT_MARKERS) {
|
|
81
|
+
if (m === '.agentic-security') continue; // would be circular
|
|
82
|
+
try {
|
|
83
|
+
if (fs.existsSync(path.join(parent, m))) return true;
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Safe mkdir: only creates .agentic-security/ if the parent has a project marker.
|
|
90
|
+
// Returns the dir on success, null if refused. Logs a warning when refused.
|
|
91
|
+
export function ensureStateDir(scanRoot) {
|
|
92
|
+
const dir = stateDir(scanRoot);
|
|
93
|
+
if (!isSafeStateDir(dir)) {
|
|
94
|
+
if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
|
|
95
|
+
process.stderr.write(`[agentic-security] refusing to create state dir at ${dir} — no project marker in parent\n`);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
return dir;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Safe write: only writes if isSafeStateDir(parent) returns true.
|
|
108
|
+
// Returns true on success, false if refused or errored.
|
|
109
|
+
export function safeWriteState(filePath, content) {
|
|
110
|
+
const dir = path.dirname(filePath);
|
|
111
|
+
if (!isSafeStateDir(dir)) {
|
|
112
|
+
if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
|
|
113
|
+
process.stderr.write(`[agentic-security] refusing to write state file at ${filePath} — no project marker in parent of ${dir}\n`);
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
119
|
+
fs.writeFileSync(filePath, content);
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/posture/streak.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from 'node:fs';
|
|
13
13
|
import * as path from 'node:path';
|
|
14
|
+
import { isSafeStateDir } from './state-dir.js';
|
|
14
15
|
|
|
15
16
|
function _streakPath(stateDir) {
|
|
16
17
|
return path.join(stateDir, 'streak.json');
|
|
@@ -115,6 +116,7 @@ function _computeAchievements(streak, scan) {
|
|
|
115
116
|
|
|
116
117
|
// Public — invoked by the CLI after every full scan.
|
|
117
118
|
export function recordScan(stateDir, scan) {
|
|
119
|
+
if (!isSafeStateDir(stateDir)) return null;
|
|
118
120
|
try { fs.mkdirSync(stateDir, { recursive: true }); } catch {}
|
|
119
121
|
const prev = loadStreak(stateDir);
|
|
120
122
|
const today = _todayUTC();
|
|
@@ -182,6 +184,7 @@ export function recordScan(stateDir, scan) {
|
|
|
182
184
|
|
|
183
185
|
// Mark "launch check passed 10/10" — called from /security-launch-check
|
|
184
186
|
export function markLaunchCheckPassed(stateDir) {
|
|
187
|
+
if (!isSafeStateDir(stateDir)) return null;
|
|
185
188
|
const prev = loadStreak(stateDir);
|
|
186
189
|
const next = { ...prev, launchCheckPassedAt: new Date().toISOString() };
|
|
187
190
|
next.achievements = _computeAchievements(next, { findings: [] });
|
|
@@ -8,9 +8,7 @@
|
|
|
8
8
|
import * as fs from 'node:fs';
|
|
9
9
|
import * as path from 'node:path';
|
|
10
10
|
import * as yaml from 'js-yaml';
|
|
11
|
-
|
|
12
|
-
const VIBECODER_PATH = '.agentic-security/accepted.json';
|
|
13
|
-
const PRO_PATH = '.agentic-security/suppressions.yml';
|
|
11
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
14
12
|
|
|
15
13
|
const MS_PER_DAY = 86400000;
|
|
16
14
|
const SOFT_TTL_DAYS = 30;
|
|
@@ -22,7 +20,7 @@ function _dateOnly(iso) {
|
|
|
22
20
|
}
|
|
23
21
|
|
|
24
22
|
export function loadSoftAccepted(scanRoot) {
|
|
25
|
-
const fp =
|
|
23
|
+
const fp = statePath(scanRoot, 'accepted.json');
|
|
26
24
|
if (!fs.existsSync(fp)) return [];
|
|
27
25
|
try {
|
|
28
26
|
const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
@@ -31,9 +29,8 @@ export function loadSoftAccepted(scanRoot) {
|
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
export function saveSoftAccepted(scanRoot, items) {
|
|
34
|
-
const fp =
|
|
35
|
-
|
|
36
|
-
fs.writeFileSync(fp, JSON.stringify({ accepted: items }, null, 2));
|
|
32
|
+
const fp = statePath(scanRoot, 'accepted.json');
|
|
33
|
+
safeWriteState(fp, JSON.stringify({ accepted: items }, null, 2));
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
export function addSoftAcceptance(scanRoot, finding, reason) {
|
|
@@ -53,7 +50,7 @@ export function addSoftAcceptance(scanRoot, finding, reason) {
|
|
|
53
50
|
}
|
|
54
51
|
|
|
55
52
|
export function loadProSuppressions(scanRoot) {
|
|
56
|
-
const fp =
|
|
53
|
+
const fp = statePath(scanRoot, 'suppressions.yml');
|
|
57
54
|
if (!fs.existsSync(fp)) return [];
|
|
58
55
|
try {
|
|
59
56
|
const parsed = yaml.load(fs.readFileSync(fp, 'utf8'));
|
package/src/posture/triage.js
CHANGED
|
@@ -4,13 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import * as path from 'node:path';
|
|
7
|
-
|
|
8
|
-
const STORE_PATH = '.agentic-security/triage.json';
|
|
7
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
9
8
|
|
|
10
9
|
export const STATES = ['open', 'in-progress', 'fixed', 'wont-fix', 'false-positive'];
|
|
11
10
|
|
|
12
11
|
function _storePath(scanRoot) {
|
|
13
|
-
return
|
|
12
|
+
return statePath(scanRoot, 'triage.json');
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
export function loadTriage(scanRoot) {
|
|
@@ -22,8 +21,7 @@ export function loadTriage(scanRoot) {
|
|
|
22
21
|
|
|
23
22
|
function _save(scanRoot, data) {
|
|
24
23
|
const fp = _storePath(scanRoot);
|
|
25
|
-
|
|
26
|
-
fs.writeFileSync(fp, JSON.stringify(data, null, 2));
|
|
24
|
+
safeWriteState(fp, JSON.stringify(data, null, 2));
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
// Sync the triage store with the latest scan: new findings become 'open',
|
|
@@ -144,3 +142,16 @@ export function trend(scanRoot, sinceDays = 30) {
|
|
|
144
142
|
totalOpen: findings.filter(f => f.state === 'open' || f.state === 'in-progress').length,
|
|
145
143
|
};
|
|
146
144
|
}
|
|
145
|
+
|
|
146
|
+
export function exportTriageMetrics(scanRoot) {
|
|
147
|
+
const triage = loadTriage(scanRoot);
|
|
148
|
+
const findings = Object.values(triage.findings || {});
|
|
149
|
+
const families = {};
|
|
150
|
+
for (const f of findings) {
|
|
151
|
+
const fam = f.family || 'unknown';
|
|
152
|
+
if (!families[fam]) families[fam] = { tp: 0, fp: 0 };
|
|
153
|
+
if (f.state === 'fixed' || f.state === 'open' || f.state === 'in-progress') families[fam].tp++;
|
|
154
|
+
else if (f.state === 'false-positive') families[fam].fp++;
|
|
155
|
+
}
|
|
156
|
+
return { families };
|
|
157
|
+
}
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
|
|
25
25
|
import * as fs from 'node:fs';
|
|
26
26
|
import * as path from 'node:path';
|
|
27
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
27
28
|
|
|
28
|
-
const FILE = '.agentic-security/validator-metrics.json';
|
|
29
29
|
const HISTORY_CAP = 100;
|
|
30
30
|
|
|
31
|
-
function _filePath(scanRoot) { return
|
|
31
|
+
function _filePath(scanRoot) { return statePath(scanRoot, 'validator-metrics.json'); }
|
|
32
32
|
|
|
33
33
|
function _read(scanRoot) {
|
|
34
34
|
const fp = _filePath(scanRoot);
|
|
@@ -39,10 +39,7 @@ function _read(scanRoot) {
|
|
|
39
39
|
|
|
40
40
|
function _write(scanRoot, data) {
|
|
41
41
|
const fp = _filePath(scanRoot);
|
|
42
|
-
|
|
43
|
-
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
44
|
-
fs.writeFileSync(fp, JSON.stringify(data, null, 2));
|
|
45
|
-
} catch { /* swallow — telemetry is best-effort */ }
|
|
42
|
+
safeWriteState(fp, JSON.stringify(data, null, 2));
|
|
46
43
|
}
|
|
47
44
|
|
|
48
45
|
function _round(n) { return Math.round(n * 10000) / 10000; }
|
package/src/report/index.js
CHANGED
|
@@ -322,6 +322,7 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
322
322
|
// threw and were skipped. The findings still ship; downstream consumers
|
|
323
323
|
// see the gap.
|
|
324
324
|
annotatorErrors: Array.isArray(scan.annotatorErrors) ? scan.annotatorErrors : [],
|
|
325
|
+
_scanMeta: scan._scanMeta || null,
|
|
325
326
|
};
|
|
326
327
|
if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
|
|
327
328
|
return out;
|
|
@@ -560,11 +561,31 @@ export function toSARIF(scan, meta={}){
|
|
|
560
561
|
...(scan && scan._rulesetVersionMismatch ? { rulesetVersionMismatch: scan._rulesetVersionMismatch } : {}),
|
|
561
562
|
},
|
|
562
563
|
}],
|
|
563
|
-
results: findings.map(f =>
|
|
564
|
+
results: findings.map(f => {
|
|
565
|
+
const chain = Array.isArray(f.chain) ? f.chain : [];
|
|
566
|
+
const codeFlows = chain.length >= 2 ? [{
|
|
567
|
+
threadFlows: [{
|
|
568
|
+
locations: chain.map((hop, idx) => ({
|
|
569
|
+
location: {
|
|
570
|
+
physicalLocation: {
|
|
571
|
+
artifactLocation: { uri: hop.file || f.file },
|
|
572
|
+
region: { startLine: Math.max(1, hop.line || 1) },
|
|
573
|
+
},
|
|
574
|
+
message: { text: hop.label || (idx === 0 ? 'source' : idx === chain.length - 1 ? 'sink' : 'propagation') },
|
|
575
|
+
},
|
|
576
|
+
})),
|
|
577
|
+
}],
|
|
578
|
+
}] : undefined;
|
|
579
|
+
const fixes = f.remediation ? [{
|
|
580
|
+
description: { text: f.remediation.slice(0, 500) },
|
|
581
|
+
}] : undefined;
|
|
582
|
+
return {
|
|
564
583
|
ruleId: f.vuln ? f.vuln.replace(/[^a-zA-Z0-9]/g, '_') : 'unknown',
|
|
565
584
|
level: SEV_TO_SARIF[f.severity] || 'warning',
|
|
566
585
|
message: { text: f.fix?.description || f.vuln || 'Security finding' },
|
|
567
586
|
locations: [{ physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: Math.max(1, f.line||1) } } }],
|
|
587
|
+
...(codeFlows ? { codeFlows } : {}),
|
|
588
|
+
...(fixes ? { fixes } : {}),
|
|
568
589
|
// Phase-1 (Sentinel-parity) fingerprint: stableId persists across
|
|
569
590
|
// refactors. Keep partialFingerprints intact for tools that key on
|
|
570
591
|
// the line-hash; add a 'stableId' fingerprint for tools that respect
|
|
@@ -595,7 +616,7 @@ export function toSARIF(scan, meta={}){
|
|
|
595
616
|
...(f._unsigned ? { unsigned: true } : {}),
|
|
596
617
|
...(f._passThroughSigning ? { passThroughSigning: true } : {}),
|
|
597
618
|
},
|
|
598
|
-
})
|
|
619
|
+
};}),
|
|
599
620
|
}],
|
|
600
621
|
};
|
|
601
622
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Cache poisoning via tainted response headers.
|
|
2
|
+
//
|
|
3
|
+
// Detects reflected request headers in responses that CDNs/proxies key on.
|
|
4
|
+
// X-Forwarded-Host, X-Forwarded-Proto, X-Original-URL reflected into
|
|
5
|
+
// response headers or HTML body enables web cache poisoning attacks.
|
|
6
|
+
|
|
7
|
+
function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
|
|
8
|
+
|
|
9
|
+
const CACHE_KEY_HEADERS = /x-forwarded-host|x-forwarded-proto|x-original-url|x-rewrite-url|x-forwarded-for|x-host/i;
|
|
10
|
+
const TAINT_SOURCES = /req\.headers|request\.headers|request\.META|getHeader|get_header|\$_SERVER/;
|
|
11
|
+
|
|
12
|
+
export function scanCachePoisoning(fp, raw) {
|
|
13
|
+
if (!fp || !raw || typeof raw !== 'string') return [];
|
|
14
|
+
if (raw.length > 500_000) return [];
|
|
15
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py|go|rb|php|phtml)$/i.test(fp)) return [];
|
|
16
|
+
|
|
17
|
+
const findings = [];
|
|
18
|
+
|
|
19
|
+
// Pattern 1: Reflected cache-key header in response
|
|
20
|
+
// res.setHeader('X-Forwarded-Host', req.headers['x-forwarded-host'])
|
|
21
|
+
const headerReflectRe = /(?:setHeader|set|header|add_header|Header\.Set|Header\.Add)\s*\(\s*['"]([^'"]+)['"]\s*,\s*[^)]*(?:req\.headers|request\.headers|request\.META|\$_SERVER)/g;
|
|
22
|
+
for (const m of raw.matchAll(headerReflectRe)) {
|
|
23
|
+
const headerName = m[1];
|
|
24
|
+
if (!CACHE_KEY_HEADERS.test(headerName)) continue;
|
|
25
|
+
const line = _line(raw, m.index);
|
|
26
|
+
findings.push({
|
|
27
|
+
id: `cache-poison-reflect:${fp}:${line}`,
|
|
28
|
+
file: fp, line,
|
|
29
|
+
vuln: `Cache Poisoning — ${headerName} reflected from request to response`,
|
|
30
|
+
severity: 'high',
|
|
31
|
+
family: 'cache-poisoning',
|
|
32
|
+
cwe: 'CWE-349',
|
|
33
|
+
parser: 'CACHE-POISON',
|
|
34
|
+
confidence: 0.75,
|
|
35
|
+
description: `The ${headerName} request header is reflected in the response. If a CDN or reverse proxy caches this response, an attacker can poison the cache for all users by sending a crafted ${headerName} value.`,
|
|
36
|
+
remediation: `Don't reflect ${headerName} into the response. If you need the value for redirects, validate it against an allow-list of trusted hosts.`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pattern 2: Cache-Control or Vary set from user input
|
|
41
|
+
const cacheControlTaintRe = /(?:Cache-Control|Vary)\s*['"]\s*,\s*[^)]*(?:req\.|request\.|params|query|body|\$_GET|\$_REQUEST)/g;
|
|
42
|
+
for (const m of raw.matchAll(cacheControlTaintRe)) {
|
|
43
|
+
const line = _line(raw, m.index);
|
|
44
|
+
findings.push({
|
|
45
|
+
id: `cache-poison-control:${fp}:${line}`,
|
|
46
|
+
file: fp, line,
|
|
47
|
+
vuln: 'Cache Poisoning — Cache-Control or Vary header set from user input',
|
|
48
|
+
severity: 'high',
|
|
49
|
+
family: 'cache-poisoning',
|
|
50
|
+
cwe: 'CWE-349',
|
|
51
|
+
parser: 'CACHE-POISON',
|
|
52
|
+
confidence: 0.65,
|
|
53
|
+
description: 'Cache-Control or Vary response header is derived from user input. An attacker can manipulate caching behavior to serve stale or poisoned content.',
|
|
54
|
+
remediation: 'Set Cache-Control and Vary headers from server-side constants, never from user input.',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Pattern 3: Host header used in URL generation (redirect/link)
|
|
59
|
+
const hostRedirectRe = /(?:req\.headers\.host|request\.get_host|request\.host|request\.META\s*\[\s*['"]HTTP_HOST['"])\s*[^;\n]*(?:redirect|location|href|url|link)/gi;
|
|
60
|
+
for (const m of raw.matchAll(hostRedirectRe)) {
|
|
61
|
+
const line = _line(raw, m.index);
|
|
62
|
+
findings.push({
|
|
63
|
+
id: `cache-poison-host:${fp}:${line}`,
|
|
64
|
+
file: fp, line,
|
|
65
|
+
vuln: 'Cache Poisoning — Host header used in URL/redirect generation',
|
|
66
|
+
severity: 'medium',
|
|
67
|
+
family: 'cache-poisoning',
|
|
68
|
+
cwe: 'CWE-644',
|
|
69
|
+
parser: 'CACHE-POISON',
|
|
70
|
+
confidence: 0.60,
|
|
71
|
+
description: 'The Host request header is used to generate URLs or redirects. Combined with caching, an attacker can redirect all cached users to a malicious host.',
|
|
72
|
+
remediation: 'Use a server-configured base URL instead of the Host header. Validate Host against an allow-list.',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return findings;
|
|
77
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Timing-safe comparison and type-coercion safety detector.
|
|
2
|
+
//
|
|
3
|
+
// Flags:
|
|
4
|
+
// 1. Direct === / == on HMAC/signature/token/OTP values (timing attack)
|
|
5
|
+
// 2. Loose == in authorization checks (type coercion)
|
|
6
|
+
// 3. Missing timingSafeEqual / hmac.compare_digest in verification functions
|
|
7
|
+
|
|
8
|
+
const TIMING_SENSITIVE = /\b(hmac|signature|digest|hash|mac|checksum|token|otp|apiKey|api_key|secret_key|webhook_secret|signing_key)\b/i;
|
|
9
|
+
const AUTH_CONTEXT = /\b(role|permission|isAdmin|is_admin|accessLevel|access_level|privilege|authorization|auth_level)\b/i;
|
|
10
|
+
|
|
11
|
+
function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
|
|
12
|
+
|
|
13
|
+
export function scanComparisonSafety(fp, raw) {
|
|
14
|
+
if (!fp || !raw || typeof raw !== 'string') return [];
|
|
15
|
+
if (raw.length > 500_000) return [];
|
|
16
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py|go)$/i.test(fp)) return [];
|
|
17
|
+
|
|
18
|
+
const findings = [];
|
|
19
|
+
|
|
20
|
+
// 1. Timing-unsafe comparison: x === y where x or y is named like a secret
|
|
21
|
+
for (const m of raw.matchAll(/(\w+)\s*===?\s*(\w+)/g)) {
|
|
22
|
+
const left = m[1], right = m[2];
|
|
23
|
+
if (!TIMING_SENSITIVE.test(left) && !TIMING_SENSITIVE.test(right)) continue;
|
|
24
|
+
const line = _line(raw, m.index);
|
|
25
|
+
const lineStart = raw.lastIndexOf('\n', m.index) + 1;
|
|
26
|
+
const lineText = raw.slice(lineStart, raw.indexOf('\n', m.index));
|
|
27
|
+
// Skip if timingSafeEqual or compare_digest is nearby
|
|
28
|
+
const context = raw.slice(Math.max(0, m.index - 200), m.index + 200);
|
|
29
|
+
if (/timingSafeEqual|compare_digest|ConstantTimeCompare|constant_time_compare/i.test(context)) continue;
|
|
30
|
+
// Skip if inside a comment
|
|
31
|
+
if (/^\s*\/\/|^\s*#|^\s*\*/.test(lineText)) continue;
|
|
32
|
+
findings.push({
|
|
33
|
+
id: `timing-unsafe:${fp}:${line}`,
|
|
34
|
+
file: fp, line,
|
|
35
|
+
vuln: 'Timing-Unsafe Comparison — secret/HMAC compared with === instead of timingSafeEqual',
|
|
36
|
+
severity: 'medium',
|
|
37
|
+
family: 'timing-attack',
|
|
38
|
+
cwe: 'CWE-208',
|
|
39
|
+
parser: 'COMPARISON',
|
|
40
|
+
confidence: 0.70,
|
|
41
|
+
description: `String === comparison on "${TIMING_SENSITIVE.exec(left + ' ' + right)?.[1] || 'secret'}" leaks timing information. An attacker can measure response times to guess the value byte-by-byte.`,
|
|
42
|
+
remediation: 'Use crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) in Node.js, hmac.compare_digest(a, b) in Python, or subtle.ConstantTimeCompare(a, b) in Go.',
|
|
43
|
+
snippet: lineText.trim().slice(0, 100),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Loose equality == in auth context
|
|
48
|
+
for (const m of raw.matchAll(/(\w+)\s*==\s*(?!=)(\w+|['"][^'"]+['"])/g)) {
|
|
49
|
+
const left = m[1], right = m[2];
|
|
50
|
+
if (!AUTH_CONTEXT.test(left) && !AUTH_CONTEXT.test(right)) continue;
|
|
51
|
+
const line = _line(raw, m.index);
|
|
52
|
+
const lineStart = raw.lastIndexOf('\n', m.index) + 1;
|
|
53
|
+
const lineText = raw.slice(lineStart, raw.indexOf('\n', m.index));
|
|
54
|
+
if (/^\s*\/\/|^\s*#|^\s*\*/.test(lineText)) continue;
|
|
55
|
+
// Only flag JS/TS (Python and Go use == for equality, not coercion)
|
|
56
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) continue;
|
|
57
|
+
findings.push({
|
|
58
|
+
id: `loose-equality-auth:${fp}:${line}`,
|
|
59
|
+
file: fp, line,
|
|
60
|
+
vuln: 'Loose Equality in Auth Check — == allows type coercion bypass',
|
|
61
|
+
severity: 'high',
|
|
62
|
+
family: 'type-coercion',
|
|
63
|
+
cwe: 'CWE-697',
|
|
64
|
+
parser: 'COMPARISON',
|
|
65
|
+
confidence: 0.65,
|
|
66
|
+
description: `Loose equality (==) on "${AUTH_CONTEXT.exec(left + ' ' + right)?.[1] || 'auth field'}" allows type coercion. "1" == 1 is true; an attacker may bypass checks by sending a different type.`,
|
|
67
|
+
remediation: 'Use strict equality (===) for all authorization checks.',
|
|
68
|
+
snippet: lineText.trim().slice(0, 100),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return findings;
|
|
73
|
+
}
|
package/src/sast/db-taint.js
CHANGED
|
@@ -76,6 +76,30 @@ function _lang(fp) {
|
|
|
76
76
|
* Walk the file once collecting WRITE pairs (model, field, line) where the
|
|
77
77
|
* written value is a user source.
|
|
78
78
|
*/
|
|
79
|
+
function _buildModelTableMap(code, lang) {
|
|
80
|
+
const map = new Map();
|
|
81
|
+
if (lang === 'js') {
|
|
82
|
+
for (const m of code.matchAll(/(?:sequelize\.define|\.init)\s*\(\s*['"](\w+)['"][\s\S]*?tableName\s*:\s*['"](\w+)['"]/g))
|
|
83
|
+
map.set(m[1], m[2]);
|
|
84
|
+
for (const m of code.matchAll(/class\s+(\w+)\s+extends\s+Model[\s\S]*?tableName\s*:\s*['"](\w+)['"]/g))
|
|
85
|
+
map.set(m[1], m[2]);
|
|
86
|
+
for (const m of code.matchAll(/@@map\s*\(\s*['"](\w+)['"]\s*\)/g))
|
|
87
|
+
map.set('_prisma_', m[1]);
|
|
88
|
+
} else if (lang === 'py') {
|
|
89
|
+
for (const m of code.matchAll(/class\s+(\w+)[\s\S]*?class\s+Meta[\s\S]*?db_table\s*=\s*['"](\w+)['"]/g))
|
|
90
|
+
map.set(m[1], m[2]);
|
|
91
|
+
for (const m of code.matchAll(/__tablename__\s*=\s*['"](\w+)['"]/g))
|
|
92
|
+
map.set('_sqla_', m[1]);
|
|
93
|
+
}
|
|
94
|
+
return map;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _resolveTableName(model, tableMap) {
|
|
98
|
+
if (!model) return model;
|
|
99
|
+
if (tableMap.has(model)) return tableMap.get(model);
|
|
100
|
+
return model.toLowerCase() + 's';
|
|
101
|
+
}
|
|
102
|
+
|
|
79
103
|
function _findTaintedWrites(code, lang) {
|
|
80
104
|
const writes = [];
|
|
81
105
|
const patterns = lang === 'js' ? WRITE_PATTERNS_JS : WRITE_PATTERNS_PY;
|
|
@@ -213,3 +237,57 @@ export function scanDbTaint(fp, raw) {
|
|
|
213
237
|
}
|
|
214
238
|
return findings;
|
|
215
239
|
}
|
|
240
|
+
|
|
241
|
+
// ── Cross-file stored injection ────────────────────────────────────────────
|
|
242
|
+
//
|
|
243
|
+
// Extends the same-file detector to work across all files in the project.
|
|
244
|
+
// Collects ORM writes and render-of-reads across all files, then matches
|
|
245
|
+
// by field name to find stored XSS / stored injection paths.
|
|
246
|
+
|
|
247
|
+
export function scanDbTaintCrossFile(fileContents) {
|
|
248
|
+
if (!fileContents || typeof fileContents !== 'object') return [];
|
|
249
|
+
const allWrites = [];
|
|
250
|
+
const allReads = [];
|
|
251
|
+
for (const [fp, raw] of Object.entries(fileContents)) {
|
|
252
|
+
if (!raw || typeof raw !== 'string' || raw.length > 500_000) continue;
|
|
253
|
+
const lang = _lang(fp);
|
|
254
|
+
if (!lang) continue;
|
|
255
|
+
const code = blankComments(raw, lang === 'py' ? 'py' : undefined);
|
|
256
|
+
const writes = _findTaintedWrites(code, lang);
|
|
257
|
+
for (const w of writes) allWrites.push({ ...w, file: fp });
|
|
258
|
+
const reads = _findRendersOfReads(code, lang);
|
|
259
|
+
for (const r of reads) allReads.push({ ...r, file: fp });
|
|
260
|
+
}
|
|
261
|
+
const findings = [];
|
|
262
|
+
const seen = new Set();
|
|
263
|
+
for (const w of allWrites) {
|
|
264
|
+
for (const r of allReads) {
|
|
265
|
+
if (w.file === r.file) continue;
|
|
266
|
+
if (w.field !== r.field) continue;
|
|
267
|
+
const id = `db-taint-xfile:${w.file}:${w.line}->${r.file}:${r.line}:${w.field}`;
|
|
268
|
+
if (seen.has(id)) continue;
|
|
269
|
+
seen.add(id);
|
|
270
|
+
findings.push({
|
|
271
|
+
id,
|
|
272
|
+
file: r.file, line: r.line,
|
|
273
|
+
vuln: `Stored XSS via DB round-trip — cross-file (${w.model || '?'}.${w.field})`,
|
|
274
|
+
severity: 'high',
|
|
275
|
+
cwe: 'CWE-79',
|
|
276
|
+
family: 'stored-xss',
|
|
277
|
+
stride: 'Tampering',
|
|
278
|
+
remediation:
|
|
279
|
+
`User content written to ${w.model || '?'}.${w.field} at ${w.file}:${w.line} is later read at ${r.file}:${r.line} and rendered. ` +
|
|
280
|
+
'Sanitize at write time (HTML-escape), escape at render time, and use CSP to block inline scripts.',
|
|
281
|
+
parser: 'DB-TAINT-XFILE',
|
|
282
|
+
confidence: 0.60,
|
|
283
|
+
source: { file: w.file, line: w.line, label: `${w.model || '?'}.${w.field} write` },
|
|
284
|
+
sink: { file: r.file, line: r.line, label: `${w.field} render` },
|
|
285
|
+
trace: [
|
|
286
|
+
{ file: w.file, line: w.line, kind: 'db-write', sourceLabel: `${w.model || '?'}.${w.field} ← user input` },
|
|
287
|
+
{ file: r.file, line: r.line, kind: 'db-read', sourceLabel: `${w.field} → render sink` },
|
|
288
|
+
],
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return findings;
|
|
293
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// GraphQL security detector.
|
|
2
|
+
//
|
|
3
|
+
// Coverage:
|
|
4
|
+
// 1. Query injection — string-concat/template building GraphQL queries from user input
|
|
5
|
+
// 2. Depth/complexity DoS — ApolloServer/express-graphql without depth limiting
|
|
6
|
+
// 3. Introspection in production — introspection enabled or not explicitly disabled
|
|
7
|
+
// 4. Batching DoS — missing batch-size limits
|
|
8
|
+
// 5. Field suggestions — error messages leaking field names
|
|
9
|
+
|
|
10
|
+
function _line(raw, idx) {
|
|
11
|
+
return raw.slice(0, idx).split('\n').length;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function scanGraphQL(fp, raw) {
|
|
15
|
+
if (!fp || !raw || typeof raw !== 'string') return [];
|
|
16
|
+
if (raw.length > 500_000) return [];
|
|
17
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py|go|rb)$/i.test(fp)) return [];
|
|
18
|
+
|
|
19
|
+
const findings = [];
|
|
20
|
+
|
|
21
|
+
// 1. Query injection: string concat/template into GraphQL query strings
|
|
22
|
+
const queryConcat = /(?:query|mutation)\s*[:=]\s*(?:`[^`]*\$\{[^}]*\}|["'][^"']*["']\s*\+\s*\w)/g;
|
|
23
|
+
for (const m of raw.matchAll(queryConcat)) {
|
|
24
|
+
const ln = _line(raw, m.index);
|
|
25
|
+
const after = raw.slice(m.index, m.index + 300);
|
|
26
|
+
if (/\b(?:gql|graphql|\.query|\.mutate)\b/i.test(after) || /\b(?:query|mutation)\s*\{/.test(after)) {
|
|
27
|
+
findings.push({
|
|
28
|
+
id: `graphql-injection:${fp}:${ln}`,
|
|
29
|
+
file: fp, line: ln,
|
|
30
|
+
vuln: 'GraphQL Query Injection — user input concatenated into query string',
|
|
31
|
+
severity: 'high',
|
|
32
|
+
family: 'graphql-injection',
|
|
33
|
+
cwe: 'CWE-943',
|
|
34
|
+
parser: 'GRAPHQL',
|
|
35
|
+
confidence: 0.75,
|
|
36
|
+
description: 'GraphQL query is built via string concatenation or template interpolation with variables that may contain user input. An attacker can inject additional fields, aliases, or mutations.',
|
|
37
|
+
remediation: 'Use parameterized GraphQL queries with variables: `query GetUser($id: ID!) { user(id: $id) { name } }` and pass variables separately.',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Depth/complexity DoS: ApolloServer/createYoga/express-graphql without depth limit
|
|
43
|
+
if (/\b(?:ApolloServer|createYoga|graphqlHTTP|makeExecutableSchema)\b/.test(raw)) {
|
|
44
|
+
if (!/\b(?:depthLimit|graphql-depth-limit|graphql-validation-complexity|costAnalysis|maxDepth|queryDepthLimit)\b/.test(raw)) {
|
|
45
|
+
const m = raw.match(/\b(ApolloServer|createYoga|graphqlHTTP)\b/);
|
|
46
|
+
if (m) {
|
|
47
|
+
findings.push({
|
|
48
|
+
id: `graphql-depth-dos:${fp}:${_line(raw, m.index)}`,
|
|
49
|
+
file: fp, line: _line(raw, m.index),
|
|
50
|
+
vuln: 'GraphQL Depth/Complexity DoS — no depth limiting configured',
|
|
51
|
+
severity: 'medium',
|
|
52
|
+
family: 'graphql-dos',
|
|
53
|
+
cwe: 'CWE-400',
|
|
54
|
+
parser: 'GRAPHQL',
|
|
55
|
+
confidence: 0.70,
|
|
56
|
+
description: 'GraphQL server is configured without query depth or complexity limits. An attacker can send deeply nested queries that exhaust server resources.',
|
|
57
|
+
remediation: 'Add graphql-depth-limit or graphql-query-complexity to validationRules: new ApolloServer({ validationRules: [depthLimit(10)] }).',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. Introspection in production
|
|
64
|
+
if (/\bintrospection\s*:\s*true\b/.test(raw)) {
|
|
65
|
+
const m = raw.match(/\bintrospection\s*:\s*true\b/);
|
|
66
|
+
if (m) {
|
|
67
|
+
findings.push({
|
|
68
|
+
id: `graphql-introspection:${fp}:${_line(raw, m.index)}`,
|
|
69
|
+
file: fp, line: _line(raw, m.index),
|
|
70
|
+
vuln: 'GraphQL Introspection Enabled — schema exposed to clients',
|
|
71
|
+
severity: 'medium',
|
|
72
|
+
family: 'graphql-introspection',
|
|
73
|
+
cwe: 'CWE-200',
|
|
74
|
+
parser: 'GRAPHQL',
|
|
75
|
+
confidence: 0.80,
|
|
76
|
+
description: 'Introspection is explicitly enabled. Attackers can query __schema to discover all types, fields, and mutations — accelerating further attacks.',
|
|
77
|
+
remediation: 'Disable introspection in production: new ApolloServer({ introspection: process.env.NODE_ENV !== "production" }).',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4. Batching DoS: missing batch limits
|
|
83
|
+
if (/\b(?:ApolloServer|ApolloGateway)\b/.test(raw)) {
|
|
84
|
+
if (!/\b(?:allowBatchedHttpRequests\s*:\s*false|maxBatchSize|batching\s*:\s*false)\b/.test(raw)) {
|
|
85
|
+
const m = raw.match(/\b(ApolloServer|ApolloGateway)\b/);
|
|
86
|
+
if (m) {
|
|
87
|
+
const after = raw.slice(m.index, m.index + 500);
|
|
88
|
+
if (/allowBatchedHttpRequests\s*:\s*true/.test(after) && !/maxBatchSize/.test(after)) {
|
|
89
|
+
findings.push({
|
|
90
|
+
id: `graphql-batch-dos:${fp}:${_line(raw, m.index)}`,
|
|
91
|
+
file: fp, line: _line(raw, m.index),
|
|
92
|
+
vuln: 'GraphQL Batch DoS — batching enabled without size limit',
|
|
93
|
+
severity: 'medium',
|
|
94
|
+
family: 'graphql-dos',
|
|
95
|
+
cwe: 'CWE-400',
|
|
96
|
+
parser: 'GRAPHQL',
|
|
97
|
+
confidence: 0.65,
|
|
98
|
+
description: 'Batched HTTP requests are enabled without a maxBatchSize limit. Attackers can send thousands of operations in a single HTTP request.',
|
|
99
|
+
remediation: 'Set allowBatchedHttpRequests: false, or add maxBatchSize: 10 to limit batch size.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 5. Field suggestions leaking schema info
|
|
107
|
+
if (/\b(?:includeStacktraceInErrorResponses|formatError|debug\s*:\s*true)\b/.test(raw)) {
|
|
108
|
+
if (/\b(?:ApolloServer|graphqlHTTP)\b/.test(raw)) {
|
|
109
|
+
for (const m of raw.matchAll(/\bdebug\s*:\s*true\b/g)) {
|
|
110
|
+
findings.push({
|
|
111
|
+
id: `graphql-debug:${fp}:${_line(raw, m.index)}`,
|
|
112
|
+
file: fp, line: _line(raw, m.index),
|
|
113
|
+
vuln: 'GraphQL Debug Mode — error details exposed to clients',
|
|
114
|
+
severity: 'low',
|
|
115
|
+
family: 'graphql-introspection',
|
|
116
|
+
cwe: 'CWE-209',
|
|
117
|
+
parser: 'GRAPHQL',
|
|
118
|
+
confidence: 0.70,
|
|
119
|
+
description: 'Debug mode exposes stack traces and field suggestion in error responses, leaking internal schema structure.',
|
|
120
|
+
remediation: 'Set debug: false or includeStacktraceInErrorResponses: false in production.',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return findings;
|
|
127
|
+
}
|