agent-security-scanner-mcp 3.7.0 → 3.8.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/README.md +42 -8
- package/analyzer.py +22 -5
- package/cross_file_analyzer.py +216 -0
- package/daemon.py +179 -0
- package/index.js +279 -3
- package/package.json +19 -5
- package/packages/npm-bloom.json +1 -0
- package/pattern_matcher.py +1 -0
- package/regex_fallback.py +199 -1
- package/requirements.txt +1 -0
- package/rules/prompt-injection.security.yaml +273 -41
- package/scripts/postinstall.js +60 -0
- package/skills/openclaw/SKILL.md +102 -0
- package/skills/security-review.md +139 -0
- package/skills/security-scan-batch.md +107 -0
- package/skills/security-scanner.md +76 -0
- package/src/cli/doctor.js +29 -1
- package/src/cli/init.js +93 -0
- package/src/cli/report.js +444 -0
- package/src/config.js +247 -0
- package/src/context.js +289 -0
- package/src/daemon-client.js +233 -0
- package/src/dedup.js +129 -0
- package/src/fix-patterns.js +76 -19
- package/src/history.js +159 -0
- package/src/tools/check-package.js +36 -12
- package/src/tools/fix-security.js +32 -5
- package/src/tools/import-resolver.js +249 -0
- package/src/tools/project-context.js +365 -0
- package/src/tools/scan-action.js +489 -0
- package/src/tools/scan-mcp.js +588 -0
- package/src/tools/scan-project.js +16 -4
- package/src/tools/scan-prompt.js +292 -527
- package/src/tools/scan-security.js +37 -6
- package/src/typosquat.js +210 -0
- package/src/utils.js +215 -8
- package/templates/gitlab-ci-security.yml +225 -0
- package/templates/pre-commit-hook.sh +233 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// src/tools/import-resolver.js
|
|
2
|
+
// Import graph resolution — resolves local imports to file paths and builds dependency graphs
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
5
|
+
import { resolve, dirname, join, extname, relative, isAbsolute } from 'path';
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
import { extractImports, detectLanguage } from '../utils.js';
|
|
8
|
+
|
|
9
|
+
// In-memory cache: absPath -> { content_hash, imports, resolvedImports }
|
|
10
|
+
const importGraphCache = new Map();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Clear the import graph cache (for testing).
|
|
14
|
+
*/
|
|
15
|
+
export function clearImportGraphCache() {
|
|
16
|
+
importGraphCache.clear();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if an import specifier is a local/relative import.
|
|
21
|
+
* @param {string} importSpec - The import string (e.g., './helpers', 'express')
|
|
22
|
+
* @param {string} language - The language: 'javascript', 'typescript', 'python', 'go'
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
export function isLocalImport(importSpec, language) {
|
|
26
|
+
switch (language) {
|
|
27
|
+
case 'javascript':
|
|
28
|
+
case 'typescript':
|
|
29
|
+
return importSpec.startsWith('./') || importSpec.startsWith('../');
|
|
30
|
+
case 'python':
|
|
31
|
+
return importSpec.startsWith('.');
|
|
32
|
+
case 'go':
|
|
33
|
+
return importSpec.startsWith('./') || importSpec.startsWith('../');
|
|
34
|
+
default:
|
|
35
|
+
return importSpec.startsWith('./') || importSpec.startsWith('../');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a local import specifier to an actual file path.
|
|
41
|
+
* @param {string} importSpec - The import string (e.g., './routes/users')
|
|
42
|
+
* @param {string} fromFile - Absolute path of the file containing the import
|
|
43
|
+
* @param {string} language - The language
|
|
44
|
+
* @returns {string|null} Absolute resolved path, or null if unresolvable/package
|
|
45
|
+
*/
|
|
46
|
+
export function resolveImportPath(importSpec, fromFile, language) {
|
|
47
|
+
if (!isLocalImport(importSpec, language)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const fromDir = dirname(fromFile);
|
|
52
|
+
|
|
53
|
+
switch (language) {
|
|
54
|
+
case 'javascript':
|
|
55
|
+
case 'typescript': {
|
|
56
|
+
const base = resolve(fromDir, importSpec);
|
|
57
|
+
// Try exact path first
|
|
58
|
+
if (existsSync(base) && !isDirectory(base)) return base;
|
|
59
|
+
// Try with extensions
|
|
60
|
+
const jsExtensions = ['.js', '.ts', '.tsx', '.jsx', '.mjs'];
|
|
61
|
+
for (const ext of jsExtensions) {
|
|
62
|
+
const withExt = base + ext;
|
|
63
|
+
if (existsSync(withExt)) return withExt;
|
|
64
|
+
}
|
|
65
|
+
// Try as directory with index file
|
|
66
|
+
const indexFiles = ['index.js', 'index.ts', 'index.tsx', 'index.jsx', 'index.mjs'];
|
|
67
|
+
for (const idx of indexFiles) {
|
|
68
|
+
const indexPath = join(base, idx);
|
|
69
|
+
if (existsSync(indexPath)) return indexPath;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case 'python': {
|
|
75
|
+
// Python relative imports: . = current package, .. = parent package
|
|
76
|
+
// Convert dot-prefix to path
|
|
77
|
+
let dotCount = 0;
|
|
78
|
+
let rest = importSpec;
|
|
79
|
+
while (rest.startsWith('.')) {
|
|
80
|
+
dotCount++;
|
|
81
|
+
rest = rest.slice(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let targetDir = fromDir;
|
|
85
|
+
// Each dot beyond the first goes up one directory
|
|
86
|
+
for (let i = 1; i < dotCount; i++) {
|
|
87
|
+
targetDir = dirname(targetDir);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!rest) {
|
|
91
|
+
// Just dots — refers to __init__.py in the target dir
|
|
92
|
+
const initPath = join(targetDir, '__init__.py');
|
|
93
|
+
if (existsSync(initPath)) return initPath;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Convert module.sub to module/sub
|
|
98
|
+
const modulePath = rest.replace(/\./g, '/');
|
|
99
|
+
const base = join(targetDir, modulePath);
|
|
100
|
+
|
|
101
|
+
// Try as .py file
|
|
102
|
+
const asPy = base + '.py';
|
|
103
|
+
if (existsSync(asPy)) return asPy;
|
|
104
|
+
// Try as package (__init__.py)
|
|
105
|
+
const asInit = join(base, '__init__.py');
|
|
106
|
+
if (existsSync(asInit)) return asInit;
|
|
107
|
+
// Try as sibling .py (same directory)
|
|
108
|
+
const sibling = join(fromDir, modulePath + '.py');
|
|
109
|
+
if (sibling !== asPy && existsSync(sibling)) return sibling;
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case 'go': {
|
|
115
|
+
const base = resolve(fromDir, importSpec);
|
|
116
|
+
if (existsSync(base) && !isDirectory(base)) return base;
|
|
117
|
+
const withGo = base + '.go';
|
|
118
|
+
if (existsSync(withGo)) return withGo;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
default:
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build an import graph starting from an entry file using BFS traversal.
|
|
129
|
+
* @param {string} filePath - Absolute path to the entry file
|
|
130
|
+
* @param {string} projectRoot - Absolute path to the project root
|
|
131
|
+
* @param {{ maxDepth?: number }} [options]
|
|
132
|
+
* @returns {{ files: Record<string, object>, edges: Array, cycles: Array, unresolved: Array, summary: object }}
|
|
133
|
+
*/
|
|
134
|
+
export function resolveImportGraph(filePath, projectRoot, options = {}) {
|
|
135
|
+
const { maxDepth = 3 } = options;
|
|
136
|
+
const MAX_FILES = 100;
|
|
137
|
+
|
|
138
|
+
const absEntry = isAbsolute(filePath) ? filePath : resolve(filePath);
|
|
139
|
+
const absRoot = isAbsolute(projectRoot) ? projectRoot : resolve(projectRoot);
|
|
140
|
+
|
|
141
|
+
const files = {}; // absPath -> { imports, resolvedImports, content_hash }
|
|
142
|
+
const edges = []; // [{ from, to, importSpec }]
|
|
143
|
+
const cycles = []; // [[from, to]]
|
|
144
|
+
const unresolved = []; // [{ file, importSpec }]
|
|
145
|
+
|
|
146
|
+
// BFS
|
|
147
|
+
// Queue entries: { absPath, depth }
|
|
148
|
+
const queue = [{ absPath: absEntry, depth: 0 }];
|
|
149
|
+
const visited = new Set();
|
|
150
|
+
|
|
151
|
+
while (queue.length > 0 && Object.keys(files).length < MAX_FILES) {
|
|
152
|
+
const { absPath, depth } = queue.shift();
|
|
153
|
+
|
|
154
|
+
if (visited.has(absPath)) continue;
|
|
155
|
+
visited.add(absPath);
|
|
156
|
+
|
|
157
|
+
if (!existsSync(absPath)) continue;
|
|
158
|
+
|
|
159
|
+
const language = detectLanguage(absPath);
|
|
160
|
+
let content;
|
|
161
|
+
try {
|
|
162
|
+
content = readFileSync(absPath, 'utf-8');
|
|
163
|
+
} catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const contentHash = createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
168
|
+
|
|
169
|
+
// Check cache
|
|
170
|
+
const cached = importGraphCache.get(absPath);
|
|
171
|
+
let rawImports, resolvedImports;
|
|
172
|
+
|
|
173
|
+
if (cached && cached.content_hash === contentHash) {
|
|
174
|
+
rawImports = cached.imports;
|
|
175
|
+
resolvedImports = cached.resolvedImports;
|
|
176
|
+
} else {
|
|
177
|
+
rawImports = extractImports(content, language);
|
|
178
|
+
resolvedImports = [];
|
|
179
|
+
|
|
180
|
+
for (const spec of rawImports) {
|
|
181
|
+
if (!isLocalImport(spec, language)) continue;
|
|
182
|
+
|
|
183
|
+
const resolved = resolveImportPath(spec, absPath, language);
|
|
184
|
+
if (resolved) {
|
|
185
|
+
// Guard: resolved path must stay within projectRoot
|
|
186
|
+
const relToRoot = relative(absRoot, resolved);
|
|
187
|
+
if (relToRoot.startsWith('..') || isAbsolute(relToRoot)) {
|
|
188
|
+
unresolved.push({ file: absPath, importSpec: spec });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
resolvedImports.push({ spec, resolvedPath: resolved });
|
|
192
|
+
} else {
|
|
193
|
+
if (isLocalImport(spec, language)) {
|
|
194
|
+
unresolved.push({ file: absPath, importSpec: spec });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Update cache
|
|
200
|
+
importGraphCache.set(absPath, {
|
|
201
|
+
content_hash: contentHash,
|
|
202
|
+
imports: rawImports,
|
|
203
|
+
resolvedImports,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
files[absPath] = {
|
|
208
|
+
imports: rawImports,
|
|
209
|
+
resolvedImports,
|
|
210
|
+
content_hash: contentHash,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Build edges and enqueue resolved imports
|
|
214
|
+
for (const { spec, resolvedPath } of resolvedImports) {
|
|
215
|
+
edges.push({ from: absPath, to: resolvedPath, importSpec: spec });
|
|
216
|
+
|
|
217
|
+
if (visited.has(resolvedPath)) {
|
|
218
|
+
// Cycle detected
|
|
219
|
+
cycles.push([absPath, resolvedPath]);
|
|
220
|
+
} else if (depth < maxDepth) {
|
|
221
|
+
queue.push({ absPath: resolvedPath, depth: depth + 1 });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const maxDepthReached = queue.some(item => item.depth >= maxDepth);
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
files,
|
|
230
|
+
edges,
|
|
231
|
+
cycles,
|
|
232
|
+
unresolved,
|
|
233
|
+
summary: {
|
|
234
|
+
total_files: Object.keys(files).length,
|
|
235
|
+
total_edges: edges.length,
|
|
236
|
+
has_cycles: cycles.length > 0,
|
|
237
|
+
max_depth_reached: maxDepthReached || false,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Helper: check if a path is a directory
|
|
243
|
+
function isDirectory(p) {
|
|
244
|
+
try {
|
|
245
|
+
return statSync(p).isDirectory();
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// src/tools/project-context.js
|
|
2
|
+
// Project security profile discovery — detects frameworks, middleware, and security libraries
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import { join, basename } from 'path';
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
|
|
8
|
+
// Cache: projectRoot -> { contentHash, result }
|
|
9
|
+
const projectContextCache = new Map();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Clear the project context cache (for testing).
|
|
13
|
+
*/
|
|
14
|
+
export function clearProjectContextCache() {
|
|
15
|
+
projectContextCache.clear();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Framework detection: package name → { name, ecosystem }
|
|
19
|
+
const FRAMEWORK_MAP = {
|
|
20
|
+
// Node.js
|
|
21
|
+
'express': { name: 'Express', ecosystem: 'node' },
|
|
22
|
+
'koa': { name: 'Koa', ecosystem: 'node' },
|
|
23
|
+
'fastify': { name: 'Fastify', ecosystem: 'node' },
|
|
24
|
+
'@hapi/hapi': { name: 'Hapi', ecosystem: 'node' },
|
|
25
|
+
'hapi': { name: 'Hapi', ecosystem: 'node' },
|
|
26
|
+
'@nestjs/core': { name: 'NestJS', ecosystem: 'node' },
|
|
27
|
+
'next': { name: 'Next.js', ecosystem: 'node' },
|
|
28
|
+
'nuxt': { name: 'Nuxt', ecosystem: 'node' },
|
|
29
|
+
// Python
|
|
30
|
+
'django': { name: 'Django', ecosystem: 'python' },
|
|
31
|
+
'flask': { name: 'Flask', ecosystem: 'python' },
|
|
32
|
+
'fastapi': { name: 'FastAPI', ecosystem: 'python' },
|
|
33
|
+
'tornado': { name: 'Tornado', ecosystem: 'python' },
|
|
34
|
+
'sanic': { name: 'Sanic', ecosystem: 'python' },
|
|
35
|
+
'starlette': { name: 'Starlette', ecosystem: 'python' },
|
|
36
|
+
// Ruby
|
|
37
|
+
'rails': { name: 'Rails', ecosystem: 'ruby' },
|
|
38
|
+
'sinatra': { name: 'Sinatra', ecosystem: 'ruby' },
|
|
39
|
+
// Go (module paths)
|
|
40
|
+
'github.com/gin-gonic/gin': { name: 'Gin', ecosystem: 'go' },
|
|
41
|
+
'github.com/labstack/echo': { name: 'Echo', ecosystem: 'go' },
|
|
42
|
+
'github.com/gofiber/fiber': { name: 'Fiber', ecosystem: 'go' },
|
|
43
|
+
// Java
|
|
44
|
+
'spring-boot-starter-web': { name: 'Spring Boot', ecosystem: 'java' },
|
|
45
|
+
'spring-boot-starter-security': { name: 'Spring Security', ecosystem: 'java' },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Security middleware/library detection: package name → capability category
|
|
49
|
+
const SECURITY_MAP = {
|
|
50
|
+
// Node.js security middleware
|
|
51
|
+
'helmet': 'http-headers',
|
|
52
|
+
'cors': 'cors',
|
|
53
|
+
'csurf': 'csrf',
|
|
54
|
+
'express-rate-limit': 'rate-limiting',
|
|
55
|
+
'rate-limiter-flexible': 'rate-limiting',
|
|
56
|
+
'dompurify': 'xss-sanitization',
|
|
57
|
+
'isomorphic-dompurify': 'xss-sanitization',
|
|
58
|
+
'xss': 'xss-sanitization',
|
|
59
|
+
'sanitize-html': 'xss-sanitization',
|
|
60
|
+
'express-validator': 'input-validation',
|
|
61
|
+
'joi': 'input-validation',
|
|
62
|
+
'zod': 'input-validation',
|
|
63
|
+
'yup': 'input-validation',
|
|
64
|
+
'hpp': 'http-parameter-pollution',
|
|
65
|
+
'express-mongo-sanitize': 'nosql-injection',
|
|
66
|
+
'sqlstring': 'sql-sanitization',
|
|
67
|
+
// Node.js auth
|
|
68
|
+
'passport': 'authentication',
|
|
69
|
+
'jsonwebtoken': 'jwt-auth',
|
|
70
|
+
'express-jwt': 'jwt-auth',
|
|
71
|
+
'bcrypt': 'password-hashing',
|
|
72
|
+
'bcryptjs': 'password-hashing',
|
|
73
|
+
'argon2': 'password-hashing',
|
|
74
|
+
'oauth2-server': 'oauth',
|
|
75
|
+
// Python security
|
|
76
|
+
'django-cors-headers': 'cors',
|
|
77
|
+
'flask-cors': 'cors',
|
|
78
|
+
'flask-wtf': 'csrf',
|
|
79
|
+
'flask-limiter': 'rate-limiting',
|
|
80
|
+
'bleach': 'xss-sanitization',
|
|
81
|
+
'python-jose': 'jwt-auth',
|
|
82
|
+
'pyjwt': 'jwt-auth',
|
|
83
|
+
'passlib': 'password-hashing',
|
|
84
|
+
'django-ratelimit': 'rate-limiting',
|
|
85
|
+
'django-csp': 'csp',
|
|
86
|
+
'django-axes': 'brute-force-protection',
|
|
87
|
+
'cerberus': 'input-validation',
|
|
88
|
+
'marshmallow': 'input-validation',
|
|
89
|
+
'pydantic': 'input-validation',
|
|
90
|
+
// Ruby security
|
|
91
|
+
'rack-cors': 'cors',
|
|
92
|
+
'devise': 'authentication',
|
|
93
|
+
'pundit': 'authorization',
|
|
94
|
+
'brakeman': 'sast',
|
|
95
|
+
'rack-attack': 'rate-limiting',
|
|
96
|
+
'secure_headers': 'http-headers',
|
|
97
|
+
// Go security
|
|
98
|
+
'github.com/gorilla/csrf': 'csrf',
|
|
99
|
+
'golang.org/x/crypto': 'crypto',
|
|
100
|
+
'github.com/rs/cors': 'cors',
|
|
101
|
+
'github.com/ulule/limiter': 'rate-limiting',
|
|
102
|
+
'github.com/golang-jwt/jwt': 'jwt-auth',
|
|
103
|
+
// Java security
|
|
104
|
+
'spring-security-core': 'authentication',
|
|
105
|
+
'spring-security-web': 'csrf',
|
|
106
|
+
'spring-security-oauth2': 'oauth',
|
|
107
|
+
'owasp-java-html-sanitizer': 'xss-sanitization',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Test framework detection
|
|
111
|
+
const TEST_FRAMEWORK_MAP = {
|
|
112
|
+
'jest': 'jest', 'vitest': 'vitest', 'mocha': 'mocha', 'chai': 'chai',
|
|
113
|
+
'jasmine': 'jasmine', 'ava': 'ava', 'tap': 'tap',
|
|
114
|
+
'pytest': 'pytest', 'unittest': 'unittest', 'nose2': 'nose2',
|
|
115
|
+
'rspec': 'rspec', 'minitest': 'minitest',
|
|
116
|
+
'testing': 'go-testing',
|
|
117
|
+
'junit': 'junit', 'testng': 'testng',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Dependency file parsers
|
|
121
|
+
|
|
122
|
+
function parsePackageJson(projectRoot) {
|
|
123
|
+
const filePath = join(projectRoot, 'package.json');
|
|
124
|
+
if (!existsSync(filePath)) return null;
|
|
125
|
+
try {
|
|
126
|
+
const pkg = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
127
|
+
const allDeps = {
|
|
128
|
+
...pkg.dependencies,
|
|
129
|
+
...pkg.devDependencies,
|
|
130
|
+
};
|
|
131
|
+
return { language: 'javascript', deps: Object.keys(allDeps), file: 'package.json' };
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseRequirementsTxt(projectRoot) {
|
|
138
|
+
const filePath = join(projectRoot, 'requirements.txt');
|
|
139
|
+
if (!existsSync(filePath)) return null;
|
|
140
|
+
try {
|
|
141
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
142
|
+
const deps = content.split('\n')
|
|
143
|
+
.map(line => line.trim())
|
|
144
|
+
.filter(line => line && !line.startsWith('#') && !line.startsWith('-'))
|
|
145
|
+
.map(line => line.split(/[>=<!~\[;]/)[0].trim().toLowerCase());
|
|
146
|
+
return { language: 'python', deps, file: 'requirements.txt' };
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parsePyprojectToml(projectRoot) {
|
|
153
|
+
const filePath = join(projectRoot, 'pyproject.toml');
|
|
154
|
+
if (!existsSync(filePath)) return null;
|
|
155
|
+
try {
|
|
156
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
157
|
+
// Simple extraction of dependencies from pyproject.toml
|
|
158
|
+
const deps = [];
|
|
159
|
+
const depRegex = /["']([a-zA-Z0-9_-]+)(?:[>=<!~\[].*)?["']/g;
|
|
160
|
+
const inDeps = content.match(/dependencies\s*=\s*\[([^\]]*)\]/s);
|
|
161
|
+
if (inDeps) {
|
|
162
|
+
let match;
|
|
163
|
+
while ((match = depRegex.exec(inDeps[1])) !== null) {
|
|
164
|
+
deps.push(match[1].toLowerCase());
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return deps.length > 0 ? { language: 'python', deps, file: 'pyproject.toml' } : null;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseGoMod(projectRoot) {
|
|
174
|
+
const filePath = join(projectRoot, 'go.mod');
|
|
175
|
+
if (!existsSync(filePath)) return null;
|
|
176
|
+
try {
|
|
177
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
178
|
+
const deps = [];
|
|
179
|
+
const requireBlock = content.match(/require\s*\(([\s\S]*?)\)/);
|
|
180
|
+
if (requireBlock) {
|
|
181
|
+
const lines = requireBlock[1].split('\n');
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
const trimmed = line.trim();
|
|
184
|
+
if (trimmed && !trimmed.startsWith('//')) {
|
|
185
|
+
const parts = trimmed.split(/\s+/);
|
|
186
|
+
if (parts[0]) deps.push(parts[0]);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Also match single-line requires
|
|
191
|
+
const singleRequires = content.matchAll(/require\s+(\S+)\s+/g);
|
|
192
|
+
for (const match of singleRequires) {
|
|
193
|
+
deps.push(match[1]);
|
|
194
|
+
}
|
|
195
|
+
return deps.length > 0 ? { language: 'go', deps, file: 'go.mod' } : null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseGemfile(projectRoot) {
|
|
202
|
+
const filePath = join(projectRoot, 'Gemfile');
|
|
203
|
+
if (!existsSync(filePath)) return null;
|
|
204
|
+
try {
|
|
205
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
206
|
+
const deps = [];
|
|
207
|
+
const gemRegex = /gem\s+['"]([a-zA-Z0-9_-]+)['"]/g;
|
|
208
|
+
let match;
|
|
209
|
+
while ((match = gemRegex.exec(content)) !== null) {
|
|
210
|
+
deps.push(match[1].toLowerCase());
|
|
211
|
+
}
|
|
212
|
+
return deps.length > 0 ? { language: 'ruby', deps, file: 'Gemfile' } : null;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parsePomXml(projectRoot) {
|
|
219
|
+
const filePath = join(projectRoot, 'pom.xml');
|
|
220
|
+
if (!existsSync(filePath)) return null;
|
|
221
|
+
try {
|
|
222
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
223
|
+
const deps = [];
|
|
224
|
+
const artifactRegex = /<artifactId>([^<]+)<\/artifactId>/g;
|
|
225
|
+
let match;
|
|
226
|
+
while ((match = artifactRegex.exec(content)) !== null) {
|
|
227
|
+
deps.push(match[1].toLowerCase());
|
|
228
|
+
}
|
|
229
|
+
return deps.length > 0 ? { language: 'java', deps, file: 'pom.xml' } : null;
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Dependency files to check for cache hashing
|
|
236
|
+
const DEP_FILES = ['package.json', 'requirements.txt', 'pyproject.toml', 'go.mod', 'Gemfile', 'pom.xml'];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Hash the contents of dependency files in a project root for cache invalidation.
|
|
240
|
+
*/
|
|
241
|
+
function hashDependencyFiles(projectRoot) {
|
|
242
|
+
const hash = createHash('sha256');
|
|
243
|
+
let hasContent = false;
|
|
244
|
+
for (const depFile of DEP_FILES) {
|
|
245
|
+
const filePath = join(projectRoot, depFile);
|
|
246
|
+
if (existsSync(filePath)) {
|
|
247
|
+
try {
|
|
248
|
+
hash.update(depFile + ':' + readFileSync(filePath, 'utf-8'));
|
|
249
|
+
hasContent = true;
|
|
250
|
+
} catch {
|
|
251
|
+
// ignore unreadable files
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return hasContent ? hash.digest('hex').slice(0, 16) : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Discover project security context from dependency files (with caching).
|
|
260
|
+
* Results are cached by projectRoot and invalidated when dependency file contents change.
|
|
261
|
+
* @param {string} projectRoot - Path to project root directory
|
|
262
|
+
* @returns {{ language: string, framework: string|null, security_middleware: string[], sanitizers: string[], auth_libraries: string[], test_frameworks: string[], dependency_file: string|null, raw_security_deps: Record<string, string> }}
|
|
263
|
+
*/
|
|
264
|
+
export function discoverProjectContext(projectRoot) {
|
|
265
|
+
const contentHash = hashDependencyFiles(projectRoot);
|
|
266
|
+
const cached = projectContextCache.get(projectRoot);
|
|
267
|
+
if (cached && cached.contentHash === contentHash) {
|
|
268
|
+
return cached.result;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const result = _discoverProjectContextUncached(projectRoot);
|
|
272
|
+
projectContextCache.set(projectRoot, { contentHash, result });
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Internal: discover project security context without caching.
|
|
278
|
+
*/
|
|
279
|
+
function _discoverProjectContextUncached(projectRoot) {
|
|
280
|
+
const parsers = [
|
|
281
|
+
parsePackageJson,
|
|
282
|
+
parseRequirementsTxt,
|
|
283
|
+
parsePyprojectToml,
|
|
284
|
+
parseGoMod,
|
|
285
|
+
parseGemfile,
|
|
286
|
+
parsePomXml,
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
let parsed = null;
|
|
290
|
+
for (const parser of parsers) {
|
|
291
|
+
parsed = parser(projectRoot);
|
|
292
|
+
if (parsed) break;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!parsed) {
|
|
296
|
+
return {
|
|
297
|
+
language: 'unknown',
|
|
298
|
+
framework: null,
|
|
299
|
+
security_middleware: [],
|
|
300
|
+
sanitizers: [],
|
|
301
|
+
auth_libraries: [],
|
|
302
|
+
test_frameworks: [],
|
|
303
|
+
dependency_file: null,
|
|
304
|
+
raw_security_deps: {},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const { language, deps, file } = parsed;
|
|
309
|
+
|
|
310
|
+
// Detect framework
|
|
311
|
+
let framework = null;
|
|
312
|
+
for (const dep of deps) {
|
|
313
|
+
const depLower = dep.toLowerCase();
|
|
314
|
+
if (FRAMEWORK_MAP[depLower]) {
|
|
315
|
+
framework = FRAMEWORK_MAP[depLower].name;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
// Partial match for Java spring artifacts
|
|
319
|
+
if (depLower.includes('spring-boot-starter-web')) {
|
|
320
|
+
framework = 'Spring Boot';
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Detect security packages by category
|
|
326
|
+
const securityMiddleware = [];
|
|
327
|
+
const sanitizers = [];
|
|
328
|
+
const authLibraries = [];
|
|
329
|
+
const testFrameworks = [];
|
|
330
|
+
const rawSecurityDeps = {};
|
|
331
|
+
|
|
332
|
+
for (const dep of deps) {
|
|
333
|
+
const depLower = dep.toLowerCase();
|
|
334
|
+
|
|
335
|
+
// Security middleware
|
|
336
|
+
const capability = SECURITY_MAP[depLower] || SECURITY_MAP[dep];
|
|
337
|
+
if (capability) {
|
|
338
|
+
rawSecurityDeps[dep] = capability;
|
|
339
|
+
|
|
340
|
+
if (['xss-sanitization', 'sql-sanitization', 'nosql-injection'].includes(capability)) {
|
|
341
|
+
sanitizers.push(dep);
|
|
342
|
+
} else if (['authentication', 'jwt-auth', 'password-hashing', 'oauth', 'authorization'].includes(capability)) {
|
|
343
|
+
authLibraries.push(dep);
|
|
344
|
+
} else {
|
|
345
|
+
securityMiddleware.push(dep);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Test frameworks
|
|
350
|
+
if (TEST_FRAMEWORK_MAP[depLower]) {
|
|
351
|
+
testFrameworks.push(TEST_FRAMEWORK_MAP[depLower]);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
language,
|
|
357
|
+
framework,
|
|
358
|
+
security_middleware: securityMiddleware,
|
|
359
|
+
sanitizers,
|
|
360
|
+
auth_libraries: authLibraries,
|
|
361
|
+
test_frameworks: testFrameworks,
|
|
362
|
+
dependency_file: file,
|
|
363
|
+
raw_security_deps: rawSecurityDeps,
|
|
364
|
+
};
|
|
365
|
+
}
|