@clear-capabilities/agentic-security-scanner 0.78.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 +16 -16
- package/bin/.agentic-security/last-scan.json +16 -16
- package/bin/.agentic-security/last-scan.json.sig +1 -1
- package/bin/.agentic-security/scan-history.json +51 -0
- package/bin/.agentic-security/streak.json +5 -5
- package/bin/agentic-security.js +22 -7
- 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 +53 -0
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +5 -0
- package/dist/agentic-security.mjs +1 -1
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +2 -2
- package/src/dataflow/engine.js +52 -8
- package/src/engine.js +107 -6
- package/src/integrations/index.js +2 -1
- package/src/ir/callgraph.js +27 -7
- 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 +3 -2
- 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 +3 -5
- package/src/posture/validator-metrics.js +3 -6
- package/src/sast/db-taint.js +24 -0
- package/src/sast/rust.js +26 -0
- package/src/sca/binary-metadata.js +124 -0
- package/src/sca/py-package-functions.js +118 -0
- package/src/sca/vendor-detect.js +53 -0
- package/src/.agentic-security/findings.json +0 -82642
- package/src/.agentic-security/last-scan.json +0 -82642
- package/src/.agentic-security/last-scan.json.sig +0 -1
- package/src/.agentic-security/scan-history.json +0 -10054
- package/src/.agentic-security/streak.json +0 -21
- package/src/dataflow/.agentic-security/findings.json +0 -3515
- package/src/dataflow/.agentic-security/last-scan.json +0 -3515
- package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
- package/src/dataflow/.agentic-security/scan-history.json +0 -702
- package/src/dataflow/.agentic-security/streak.json +0 -22
- package/src/ir/.agentic-security/findings.json +0 -3777
- package/src/ir/.agentic-security/last-scan.json +0 -3777
- package/src/ir/.agentic-security/last-scan.json.sig +0 -1
- package/src/ir/.agentic-security/scan-history.json +0 -771
- package/src/ir/.agentic-security/streak.json +0 -21
- package/src/posture/.agentic-security/findings.json +0 -51562
- package/src/posture/.agentic-security/last-scan.json +0 -51562
- package/src/posture/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/.agentic-security/scan-history.json +0 -650
- package/src/posture/.agentic-security/streak.json +0 -20
- package/src/report/.agentic-security/findings.json +0 -80
- package/src/report/.agentic-security/last-scan.json +0 -80
- package/src/report/.agentic-security/last-scan.json.sig +0 -1
- package/src/report/.agentic-security/scan-history.json +0 -35
- package/src/report/.agentic-security/streak.json +0 -22
- package/src/sast/.agentic-security/findings.json +0 -5190
- package/src/sast/.agentic-security/last-scan.json +0 -5190
- package/src/sast/.agentic-security/last-scan.json.sig +0 -1
- package/src/sast/.agentic-security/scan-history.json +0 -408
- package/src/sast/.agentic-security/streak.json +0 -20
- package/src/sca/.agentic-security/findings.json +0 -1587
- package/src/sca/.agentic-security/last-scan.json +0 -1587
- package/src/sca/.agentic-security/last-scan.json.sig +0 -1
- package/src/sca/.agentic-security/scan-history.json +0 -36
- package/src/sca/.agentic-security/streak.json +0 -21
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
9
10
|
|
|
10
|
-
const HISTORY_FILE = '.agentic-security/scan-history.json';
|
|
11
11
|
const MAX_HISTORY = 30; // rolling window
|
|
12
12
|
|
|
13
13
|
function _readHistory(scanRoot) {
|
|
14
|
-
const histPath =
|
|
14
|
+
const histPath = statePath(scanRoot, 'scan-history.json');
|
|
15
15
|
try {
|
|
16
16
|
return JSON.parse(fs.readFileSync(histPath, 'utf8'));
|
|
17
17
|
} catch {
|
|
@@ -20,11 +20,8 @@ function _readHistory(scanRoot) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function _writeHistory(scanRoot, history) {
|
|
23
|
-
const histPath =
|
|
24
|
-
|
|
25
|
-
fs.mkdirSync(path.dirname(histPath), { recursive: true });
|
|
26
|
-
fs.writeFileSync(histPath, JSON.stringify(history.slice(-MAX_HISTORY), null, 2));
|
|
27
|
-
} catch {}
|
|
23
|
+
const histPath = statePath(scanRoot, 'scan-history.json');
|
|
24
|
+
safeWriteState(histPath, JSON.stringify(history.slice(-MAX_HISTORY), null, 2));
|
|
28
25
|
}
|
|
29
26
|
|
|
30
27
|
function _snapshotFromScan(scan, label) {
|
|
@@ -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',
|
|
@@ -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/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;
|
package/src/sast/rust.js
CHANGED
|
@@ -103,3 +103,29 @@ export function scanRust(fp, raw) {
|
|
|
103
103
|
}
|
|
104
104
|
return out;
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
export function extractRustImportMap(code) {
|
|
108
|
+
const map = new Map();
|
|
109
|
+
const globs = new Set();
|
|
110
|
+
const useRe = /\buse\s+([\w_][\w_:]*?)(?:::(\w+|\{[^}]+\}|\*))(?:\s+as\s+(\w+))?/g;
|
|
111
|
+
for (const m of code.matchAll(useRe)) {
|
|
112
|
+
const cratePath = m[1];
|
|
113
|
+
const crate = cratePath.split('::')[0];
|
|
114
|
+
const imported = m[2];
|
|
115
|
+
const alias = m[3];
|
|
116
|
+
if (imported === '*') {
|
|
117
|
+
globs.add(crate);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (imported.startsWith('{')) {
|
|
121
|
+
const items = imported.slice(1, -1).split(',').map(s => s.trim());
|
|
122
|
+
for (const item of items) {
|
|
123
|
+
const parts = item.split(/\s+as\s+/);
|
|
124
|
+
map.set(parts[1] || parts[0], crate);
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
map.set(alias || imported, crate);
|
|
129
|
+
}
|
|
130
|
+
return { map, globs };
|
|
131
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Binary artifact SCA metadata extraction.
|
|
2
|
+
//
|
|
3
|
+
// Reads dependency information from compiled artifacts:
|
|
4
|
+
// - Java JAR files: META-INF/MANIFEST.MF for version + classpath
|
|
5
|
+
// - Go binaries: embedded go.buildinfo for dependency tree
|
|
6
|
+
//
|
|
7
|
+
// Gated behind AGENTIC_SECURITY_BINARY_SCA=1 (opt-in).
|
|
8
|
+
// Does NOT execute binaries — only reads metadata sections.
|
|
9
|
+
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
export function isBinaryScaEnabled() {
|
|
15
|
+
return process.env.AGENTIC_SECURITY_BINARY_SCA === '1';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function extractJarMetadata(jarPath) {
|
|
19
|
+
if (!jarPath || !jarPath.endsWith('.jar')) return null;
|
|
20
|
+
try {
|
|
21
|
+
const out = execFileSync('jar', ['tf', jarPath], { encoding: 'utf8', timeout: 5000 });
|
|
22
|
+
const hasManifest = out.includes('META-INF/MANIFEST.MF');
|
|
23
|
+
if (!hasManifest) return null;
|
|
24
|
+
const manifest = execFileSync('jar', ['xf', jarPath, 'META-INF/MANIFEST.MF', '-C', '/tmp'], {
|
|
25
|
+
encoding: 'utf8', timeout: 5000, cwd: '/tmp',
|
|
26
|
+
});
|
|
27
|
+
const manifestPath = '/tmp/META-INF/MANIFEST.MF';
|
|
28
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
29
|
+
const content = fs.readFileSync(manifestPath, 'utf8');
|
|
30
|
+
const attrs = {};
|
|
31
|
+
for (const line of content.split('\n')) {
|
|
32
|
+
const m = line.match(/^([A-Za-z-]+):\s*(.+)$/);
|
|
33
|
+
if (m) attrs[m[1].toLowerCase()] = m[2].trim();
|
|
34
|
+
}
|
|
35
|
+
const hasPom = out.includes('pom.properties');
|
|
36
|
+
let groupId = attrs['implementation-vendor-id'] || '';
|
|
37
|
+
let artifactId = attrs['implementation-title'] || path.basename(jarPath, '.jar');
|
|
38
|
+
let version = attrs['implementation-version'] || attrs['bundle-version'] || 'unknown';
|
|
39
|
+
if (hasPom) {
|
|
40
|
+
try {
|
|
41
|
+
execFileSync('jar', ['xf', jarPath, '--', ...out.split('\n').filter(l => l.includes('pom.properties'))], {
|
|
42
|
+
timeout: 5000, cwd: '/tmp',
|
|
43
|
+
});
|
|
44
|
+
const pomFiles = out.split('\n').filter(l => l.includes('pom.properties'));
|
|
45
|
+
for (const pf of pomFiles) {
|
|
46
|
+
const pfPath = path.join('/tmp', pf);
|
|
47
|
+
if (!fs.existsSync(pfPath)) continue;
|
|
48
|
+
const props = fs.readFileSync(pfPath, 'utf8');
|
|
49
|
+
for (const line of props.split('\n')) {
|
|
50
|
+
if (line.startsWith('groupId=')) groupId = line.split('=')[1].trim();
|
|
51
|
+
if (line.startsWith('artifactId=')) artifactId = line.split('=')[1].trim();
|
|
52
|
+
if (line.startsWith('version=')) version = line.split('=')[1].trim();
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
} catch { /* pom extraction optional */ }
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
name: artifactId,
|
|
60
|
+
version,
|
|
61
|
+
group: groupId,
|
|
62
|
+
ecosystem: 'maven',
|
|
63
|
+
filePath: jarPath,
|
|
64
|
+
scope: 'required',
|
|
65
|
+
purl: `pkg:maven/${groupId}/${artifactId}@${version}`,
|
|
66
|
+
isUnpinned: false,
|
|
67
|
+
_source: 'jar-manifest',
|
|
68
|
+
};
|
|
69
|
+
} catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function extractGoBuildInfo(binPath) {
|
|
73
|
+
if (!binPath) return [];
|
|
74
|
+
try {
|
|
75
|
+
const out = execFileSync('go', ['version', '-m', binPath], { encoding: 'utf8', timeout: 5000 });
|
|
76
|
+
const deps = [];
|
|
77
|
+
for (const line of out.split('\n')) {
|
|
78
|
+
const m = line.match(/^\s*dep\s+([\w./-]+)\s+(v[\d.]+(?:-[\w.]+)?)/);
|
|
79
|
+
if (m) {
|
|
80
|
+
deps.push({
|
|
81
|
+
name: m[1],
|
|
82
|
+
version: m[2].replace(/^v/, ''),
|
|
83
|
+
group: '',
|
|
84
|
+
ecosystem: 'golang',
|
|
85
|
+
filePath: binPath,
|
|
86
|
+
scope: 'required',
|
|
87
|
+
purl: `pkg:golang/${m[1]}@${m[2]}`,
|
|
88
|
+
isUnpinned: false,
|
|
89
|
+
_source: 'go-buildinfo',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return deps;
|
|
94
|
+
} catch { return []; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function scanBinaryArtifacts(fileContents, scanRoot) {
|
|
98
|
+
if (!isBinaryScaEnabled()) return [];
|
|
99
|
+
const components = [];
|
|
100
|
+
const root = scanRoot || '.';
|
|
101
|
+
try {
|
|
102
|
+
const jarFiles = fs.readdirSync(root, { recursive: true })
|
|
103
|
+
.filter(f => f.endsWith('.jar') && !f.includes('node_modules'))
|
|
104
|
+
.slice(0, 20);
|
|
105
|
+
for (const jar of jarFiles) {
|
|
106
|
+
const meta = extractJarMetadata(path.join(root, jar));
|
|
107
|
+
if (meta) components.push(meta);
|
|
108
|
+
}
|
|
109
|
+
} catch { /* jar scan optional */ }
|
|
110
|
+
try {
|
|
111
|
+
const goBins = fs.readdirSync(root, { recursive: true })
|
|
112
|
+
.filter(f => !f.includes('.') && !f.includes('node_modules') && !f.includes('/'))
|
|
113
|
+
.slice(0, 10);
|
|
114
|
+
for (const bin of goBins) {
|
|
115
|
+
const fp = path.join(root, bin);
|
|
116
|
+
try {
|
|
117
|
+
if (fs.statSync(fp).isFile() && (fs.statSync(fp).mode & 0o111)) {
|
|
118
|
+
components.push(...extractGoBuildInfo(fp));
|
|
119
|
+
}
|
|
120
|
+
} catch { /* skip non-executable */ }
|
|
121
|
+
}
|
|
122
|
+
} catch { /* go binary scan optional */ }
|
|
123
|
+
return components;
|
|
124
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Python package function extraction via the CST parser.
|
|
2
|
+
//
|
|
3
|
+
// Locates an installed Python package in site-packages or .venv,
|
|
4
|
+
// parses its source files via the Python CST parser, and returns
|
|
5
|
+
// a map of exported function names. Used by markUsedVulnFunctions
|
|
6
|
+
// to validate that OSV-named vulnerable functions actually exist
|
|
7
|
+
// in the installed version.
|
|
8
|
+
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { parsePythonFilesBatch, probePythonAvailable } from '../ir/parser-py-cst.js';
|
|
13
|
+
|
|
14
|
+
const VENV_DIRS = ['.venv', 'venv', '.env', 'env'];
|
|
15
|
+
|
|
16
|
+
function _findSitePackages(scanRoot) {
|
|
17
|
+
for (const vdir of VENV_DIRS) {
|
|
18
|
+
const base = path.join(scanRoot || '.', vdir);
|
|
19
|
+
if (!fs.existsSync(base)) continue;
|
|
20
|
+
const lib = path.join(base, 'lib');
|
|
21
|
+
if (!fs.existsSync(lib)) continue;
|
|
22
|
+
const pydirs = fs.readdirSync(lib).filter(d => d.startsWith('python'));
|
|
23
|
+
for (const pydir of pydirs) {
|
|
24
|
+
const sp = path.join(lib, pydir, 'site-packages');
|
|
25
|
+
if (fs.existsSync(sp)) return sp;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Fallback: ask python3 directly
|
|
29
|
+
try {
|
|
30
|
+
const out = execFileSync('python3', ['-c', 'import site; print(site.getsitepackages()[0])'], {
|
|
31
|
+
encoding: 'utf8', timeout: 5000,
|
|
32
|
+
}).trim();
|
|
33
|
+
if (out && fs.existsSync(out)) return out;
|
|
34
|
+
} catch { /* no python3 or no site-packages */ }
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _findPackageDir(sitePackages, packageName) {
|
|
39
|
+
if (!sitePackages) return null;
|
|
40
|
+
const normalized = packageName.replace(/-/g, '_').toLowerCase();
|
|
41
|
+
const candidates = [
|
|
42
|
+
normalized,
|
|
43
|
+
packageName.toLowerCase(),
|
|
44
|
+
packageName,
|
|
45
|
+
];
|
|
46
|
+
for (const name of candidates) {
|
|
47
|
+
const dir = path.join(sitePackages, name);
|
|
48
|
+
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return dir;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _readPyFilesFromDir(dir, maxFiles = 50) {
|
|
54
|
+
const entries = [];
|
|
55
|
+
try {
|
|
56
|
+
const files = fs.readdirSync(dir, { recursive: true })
|
|
57
|
+
.filter(f => f.endsWith('.py'))
|
|
58
|
+
.slice(0, maxFiles);
|
|
59
|
+
for (const f of files) {
|
|
60
|
+
const fp = path.join(dir, f);
|
|
61
|
+
try {
|
|
62
|
+
const content = fs.readFileSync(fp, 'utf8');
|
|
63
|
+
if (content.length < 1_000_000) {
|
|
64
|
+
entries.push({ file: f, content });
|
|
65
|
+
}
|
|
66
|
+
} catch { /* skip unreadable files */ }
|
|
67
|
+
}
|
|
68
|
+
} catch { /* dir not readable */ }
|
|
69
|
+
return entries;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function extractPythonPackageFunctions(packageName, scanRoot) {
|
|
73
|
+
const cap = probePythonAvailable();
|
|
74
|
+
if (!cap.ok) return null;
|
|
75
|
+
|
|
76
|
+
const sitePackages = _findSitePackages(scanRoot);
|
|
77
|
+
const pkgDir = _findPackageDir(sitePackages, packageName);
|
|
78
|
+
if (!pkgDir) return null;
|
|
79
|
+
|
|
80
|
+
const pyFiles = _readPyFilesFromDir(pkgDir);
|
|
81
|
+
if (!pyFiles.length) return null;
|
|
82
|
+
|
|
83
|
+
const batch = parsePythonFilesBatch(pyFiles);
|
|
84
|
+
if (!batch || !Array.isArray(batch)) return null;
|
|
85
|
+
|
|
86
|
+
const functionMap = new Map();
|
|
87
|
+
for (const fileIR of batch) {
|
|
88
|
+
if (!fileIR || !fileIR.functions) continue;
|
|
89
|
+
for (const fn of fileIR.functions) {
|
|
90
|
+
if (fn.name && !fn.name.startsWith('_')) {
|
|
91
|
+
functionMap.set(fn.name, {
|
|
92
|
+
file: fileIR.file,
|
|
93
|
+
line: fn.line,
|
|
94
|
+
qid: fn.qid,
|
|
95
|
+
params: fn.params,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return functionMap;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function validateOsvFunctionsExist(packageName, osvFunctions, scanRoot) {
|
|
104
|
+
if (!osvFunctions || !osvFunctions.length) return { validated: [], missing: [] };
|
|
105
|
+
const fnMap = extractPythonPackageFunctions(packageName, scanRoot);
|
|
106
|
+
if (!fnMap) return { validated: osvFunctions, missing: [] };
|
|
107
|
+
const validated = [];
|
|
108
|
+
const missing = [];
|
|
109
|
+
for (const fn of osvFunctions) {
|
|
110
|
+
const shortFn = fn.includes('.') ? fn.split('.').pop() : fn;
|
|
111
|
+
if (fnMap.has(shortFn) || fnMap.has(fn)) {
|
|
112
|
+
validated.push(shortFn);
|
|
113
|
+
} else {
|
|
114
|
+
missing.push(fn);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { validated, missing };
|
|
118
|
+
}
|
package/src/sca/vendor-detect.js
CHANGED
|
@@ -87,5 +87,58 @@ export function detectVendoredLibraries(fileContents) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
+
// Pass 2: Function-body structural matching for minified/forked copies
|
|
91
|
+
const FUNCTION_BODY_SIGS = [
|
|
92
|
+
{ pkg: 'lodash', ecosystem: 'npm', fn: 'merge', paramMin: 1,
|
|
93
|
+
bodyContains: ['assignValue', 'baseFor', 'isObject', 'baseMerge'] },
|
|
94
|
+
{ pkg: 'lodash', ecosystem: 'npm', fn: 'template', paramMin: 1,
|
|
95
|
+
bodyContains: ['sourceURL', 'interpolate', 'evaluate', 'escape'] },
|
|
96
|
+
{ pkg: 'lodash', ecosystem: 'npm', fn: 'defaultsDeep', paramMin: 1,
|
|
97
|
+
bodyContains: ['baseMerge', 'isMergeableObject', 'customDefaultsMerge'] },
|
|
98
|
+
{ pkg: 'jquery', ecosystem: 'npm', fn: 'ajax', paramMin: 1,
|
|
99
|
+
bodyContains: ['XMLHttpRequest', 'ajaxSettings', 'crossDomain', 'responseFields'] },
|
|
100
|
+
{ pkg: 'handlebars', ecosystem: 'npm', fn: 'compile', paramMin: 1,
|
|
101
|
+
bodyContains: ['templateSpec', 'container', 'invokePartial', 'blockParams'] },
|
|
102
|
+
{ pkg: 'marked', ecosystem: 'npm', fn: 'parse', paramMin: 1,
|
|
103
|
+
bodyContains: ['Lexer', 'Parser', 'blockTokens', 'walkTokens'] },
|
|
104
|
+
{ pkg: 'ejs', ecosystem: 'npm', fn: 'render', paramMin: 1,
|
|
105
|
+
bodyContains: ['includeFile', 'resolveInclude', 'rethrow', 'escapeFn'] },
|
|
106
|
+
{ pkg: 'moment', ecosystem: 'npm', fn: 'format', paramMin: 0,
|
|
107
|
+
bodyContains: ['formatMoment', 'expandFormat', 'makeFormatFunction', 'localFormattingTokens'] },
|
|
108
|
+
{ pkg: 'underscore', ecosystem: 'npm', fn: 'template', paramMin: 1,
|
|
109
|
+
bodyContains: ['interpolate', 'evaluate', 'escape', 'templateSettings'] },
|
|
110
|
+
{ pkg: 'minimist', ecosystem: 'npm', fn: 'parse', paramMin: 1,
|
|
111
|
+
bodyContains: ['boolean', 'alias', 'default', 'stopEarly', 'unknown'] },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
for (const [fp, content] of Object.entries(fileContents)) {
|
|
115
|
+
if (!content || typeof content !== 'string') continue;
|
|
116
|
+
if (SKIP_DIRS.test(fp)) continue;
|
|
117
|
+
if (!/\.(?:js|mjs|cjs)$/i.test(fp)) continue;
|
|
118
|
+
if (content.length < 200 || content.length > 500_000) continue;
|
|
119
|
+
|
|
120
|
+
for (const sig of FUNCTION_BODY_SIGS) {
|
|
121
|
+
const key = `${sig.pkg}:${fp}`;
|
|
122
|
+
if (seen.has(key)) continue;
|
|
123
|
+
const fnRe = new RegExp(`(?:function\\s+${sig.fn}|(?:const|let|var)\\s+${sig.fn}\\s*=|${sig.fn}\\s*[:=]\\s*function)\\s*\\(`, 'g');
|
|
124
|
+
const m = fnRe.exec(content);
|
|
125
|
+
if (!m) continue;
|
|
126
|
+
const bodyWindow = content.slice(m.index, m.index + 2000);
|
|
127
|
+
const matchCount = sig.bodyContains.filter(kw => bodyWindow.includes(kw)).length;
|
|
128
|
+
if (matchCount < Math.ceil(sig.bodyContains.length * 0.6)) continue;
|
|
129
|
+
seen.add(key);
|
|
130
|
+
detected.push({
|
|
131
|
+
name: sig.pkg,
|
|
132
|
+
version: 'unknown',
|
|
133
|
+
ecosystem: sig.ecosystem,
|
|
134
|
+
file: fp,
|
|
135
|
+
scope: 'vendored',
|
|
136
|
+
isVendored: true,
|
|
137
|
+
_detectionMethod: 'function-body-signature',
|
|
138
|
+
_matchedKeywords: matchCount,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
90
143
|
return detected;
|
|
91
144
|
}
|