@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.
Files changed (76) hide show
  1. package/bin/.agentic-security/findings.json +16 -16
  2. package/bin/.agentic-security/last-scan.json +16 -16
  3. package/bin/.agentic-security/last-scan.json.sig +1 -1
  4. package/bin/.agentic-security/scan-history.json +51 -0
  5. package/bin/.agentic-security/streak.json +5 -5
  6. package/bin/agentic-security.js +22 -7
  7. package/dist/178.index.js +1 -1
  8. package/dist/384.index.js +1 -1
  9. package/dist/476.index.js +5 -5
  10. package/dist/637.index.js +1 -1
  11. package/dist/700.index.js +138 -0
  12. package/dist/718.index.js +53 -0
  13. package/dist/838.index.js +1 -1
  14. package/dist/985.index.js +5 -0
  15. package/dist/agentic-security.mjs +1 -1
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +2 -2
  18. package/src/dataflow/engine.js +52 -8
  19. package/src/engine.js +107 -6
  20. package/src/integrations/index.js +2 -1
  21. package/src/ir/callgraph.js +27 -7
  22. package/src/llm-validator/index.js +7 -5
  23. package/src/mcp/audit.js +5 -0
  24. package/src/posture/calibration-drift.js +2 -1
  25. package/src/posture/calibration.js +3 -2
  26. package/src/posture/fix-history.js +8 -2
  27. package/src/posture/profile.js +4 -5
  28. package/src/posture/rule-overrides.js +2 -3
  29. package/src/posture/rule-pack-signing.js +2 -3
  30. package/src/posture/rule-synthesis.js +5 -6
  31. package/src/posture/security-trend.js +4 -7
  32. package/src/posture/state-dir.js +124 -0
  33. package/src/posture/streak.js +3 -0
  34. package/src/posture/suppressions.js +5 -8
  35. package/src/posture/triage.js +3 -5
  36. package/src/posture/validator-metrics.js +3 -6
  37. package/src/sast/db-taint.js +24 -0
  38. package/src/sast/rust.js +26 -0
  39. package/src/sca/binary-metadata.js +124 -0
  40. package/src/sca/py-package-functions.js +118 -0
  41. package/src/sca/vendor-detect.js +53 -0
  42. package/src/.agentic-security/findings.json +0 -82642
  43. package/src/.agentic-security/last-scan.json +0 -82642
  44. package/src/.agentic-security/last-scan.json.sig +0 -1
  45. package/src/.agentic-security/scan-history.json +0 -10054
  46. package/src/.agentic-security/streak.json +0 -21
  47. package/src/dataflow/.agentic-security/findings.json +0 -3515
  48. package/src/dataflow/.agentic-security/last-scan.json +0 -3515
  49. package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
  50. package/src/dataflow/.agentic-security/scan-history.json +0 -702
  51. package/src/dataflow/.agentic-security/streak.json +0 -22
  52. package/src/ir/.agentic-security/findings.json +0 -3777
  53. package/src/ir/.agentic-security/last-scan.json +0 -3777
  54. package/src/ir/.agentic-security/last-scan.json.sig +0 -1
  55. package/src/ir/.agentic-security/scan-history.json +0 -771
  56. package/src/ir/.agentic-security/streak.json +0 -21
  57. package/src/posture/.agentic-security/findings.json +0 -51562
  58. package/src/posture/.agentic-security/last-scan.json +0 -51562
  59. package/src/posture/.agentic-security/last-scan.json.sig +0 -1
  60. package/src/posture/.agentic-security/scan-history.json +0 -650
  61. package/src/posture/.agentic-security/streak.json +0 -20
  62. package/src/report/.agentic-security/findings.json +0 -80
  63. package/src/report/.agentic-security/last-scan.json +0 -80
  64. package/src/report/.agentic-security/last-scan.json.sig +0 -1
  65. package/src/report/.agentic-security/scan-history.json +0 -35
  66. package/src/report/.agentic-security/streak.json +0 -22
  67. package/src/sast/.agentic-security/findings.json +0 -5190
  68. package/src/sast/.agentic-security/last-scan.json +0 -5190
  69. package/src/sast/.agentic-security/last-scan.json.sig +0 -1
  70. package/src/sast/.agentic-security/scan-history.json +0 -408
  71. package/src/sast/.agentic-security/streak.json +0 -20
  72. package/src/sca/.agentic-security/findings.json +0 -1587
  73. package/src/sca/.agentic-security/last-scan.json +0 -1587
  74. package/src/sca/.agentic-security/last-scan.json.sig +0 -1
  75. package/src/sca/.agentic-security/scan-history.json +0 -36
  76. 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 = scanRoot ? path.join(scanRoot, HISTORY_FILE) : HISTORY_FILE;
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 = scanRoot ? path.join(scanRoot, HISTORY_FILE) : HISTORY_FILE;
24
- try {
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
+ }
@@ -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 = path.join(scanRoot || process.cwd(), VIBECODER_PATH);
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 = path.join(scanRoot || process.cwd(), VIBECODER_PATH);
35
- fs.mkdirSync(path.dirname(fp), { recursive: true });
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 = path.join(scanRoot || process.cwd(), PRO_PATH);
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'));
@@ -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 path.join(scanRoot || process.cwd(), STORE_PATH);
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
- fs.mkdirSync(path.dirname(fp), { recursive: true });
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 path.join(scanRoot || process.cwd(), FILE); }
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
- try {
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; }
@@ -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
+ }
@@ -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
  }