agent-security-scanner-mcp 3.7.0 → 3.9.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 +156 -10
- 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 +922 -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
- package/src/tools/garak-bridge.js +0 -209
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// src/tools/scan-security.js
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { existsSync, readFileSync } from "fs";
|
|
4
|
-
import { detectLanguage,
|
|
4
|
+
import { detectLanguage, runAnalyzerAsync, generateFix, toSarif, getEngineMode } from '../utils.js';
|
|
5
|
+
import { deduplicateFindings } from '../dedup.js';
|
|
6
|
+
import { applyContextFilter, detectFrameworks, applyFrameworkAdjustments } from '../context.js';
|
|
7
|
+
import { loadConfig, shouldExcludeFile, applyConfig } from '../config.js';
|
|
5
8
|
|
|
6
9
|
export const scanSecuritySchema = {
|
|
7
10
|
file_path: z.string().describe("Path to the file to scan"),
|
|
8
11
|
output_format: z.enum(['json', 'sarif']).optional().describe("Output format: 'json' (default) or 'sarif' for GitHub/GitLab integration"),
|
|
9
|
-
verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level: 'minimal' (counts only), 'compact' (default, actionable info), 'full' (complete metadata)")
|
|
12
|
+
verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level: 'minimal' (counts only), 'compact' (default, actionable info), 'full' (complete metadata)"),
|
|
13
|
+
engine: z.enum(['auto', 'ast', 'regex']).optional().describe("Analysis engine: 'auto' (default, AST with regex fallback), 'ast' (tree-sitter only), 'regex' (regex only)")
|
|
10
14
|
};
|
|
11
15
|
|
|
12
16
|
// Verbosity formatters
|
|
@@ -16,6 +20,7 @@ function formatMinimal(file_path, language, issues) {
|
|
|
16
20
|
return {
|
|
17
21
|
file: file_path,
|
|
18
22
|
language,
|
|
23
|
+
engine_mode: getEngineMode(),
|
|
19
24
|
total: issues.length,
|
|
20
25
|
critical: bySeverity.error,
|
|
21
26
|
warning: bySeverity.warning,
|
|
@@ -30,11 +35,13 @@ function formatCompact(file_path, language, issues) {
|
|
|
30
35
|
return {
|
|
31
36
|
file: file_path,
|
|
32
37
|
language,
|
|
38
|
+
engine_mode: getEngineMode(),
|
|
33
39
|
issues_count: issues.length,
|
|
34
40
|
issues: issues.map(i => ({
|
|
35
41
|
line: i.line + 1,
|
|
36
42
|
ruleId: i.ruleId,
|
|
37
43
|
severity: i.severity,
|
|
44
|
+
confidence: i.confidence || 'MEDIUM',
|
|
38
45
|
message: i.message,
|
|
39
46
|
fix: i.suggested_fix?.fixed ? i.suggested_fix.fixed.trim() : null
|
|
40
47
|
}))
|
|
@@ -45,31 +52,55 @@ function formatFull(file_path, language, issues) {
|
|
|
45
52
|
return {
|
|
46
53
|
file: file_path,
|
|
47
54
|
language,
|
|
55
|
+
engine_mode: getEngineMode(),
|
|
48
56
|
issues_count: issues.length,
|
|
49
57
|
issues: issues
|
|
50
58
|
};
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
export async function scanSecurity({ file_path, output_format, verbosity }) {
|
|
61
|
+
export async function scanSecurity({ file_path, output_format, verbosity, engine }) {
|
|
54
62
|
if (!existsSync(file_path)) {
|
|
55
63
|
return {
|
|
56
64
|
content: [{ type: "text", text: JSON.stringify({ error: "File not found" }) }]
|
|
57
65
|
};
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
|
|
68
|
+
// Load project configuration
|
|
69
|
+
const config = loadConfig(file_path);
|
|
61
70
|
|
|
62
|
-
|
|
71
|
+
// Check file exclusion
|
|
72
|
+
if (shouldExcludeFile(file_path, config)) {
|
|
63
73
|
return {
|
|
64
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
74
|
+
content: [{ type: "text", text: JSON.stringify({ file: file_path, message: "File excluded by configuration", issues_count: 0 }) }]
|
|
65
75
|
};
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
const rawIssues = await runAnalyzerAsync(file_path, engine || 'auto');
|
|
79
|
+
|
|
80
|
+
if (rawIssues.error) {
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: "text", text: JSON.stringify(rawIssues) }]
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Cross-engine deduplication
|
|
87
|
+
const dedupedIssues = deduplicateFindings(rawIssues);
|
|
88
|
+
|
|
68
89
|
// Read file content for fix suggestions
|
|
69
90
|
const content = readFileSync(file_path, 'utf-8');
|
|
70
91
|
const lines = content.split('\n');
|
|
71
92
|
const language = detectLanguage(file_path);
|
|
72
93
|
|
|
94
|
+
// Context-aware filtering (suppress known module imports)
|
|
95
|
+
const contextFiltered = applyContextFilter(dedupedIssues, file_path, language);
|
|
96
|
+
|
|
97
|
+
// Framework-aware severity adjustment
|
|
98
|
+
const frameworks = detectFrameworks(file_path, language);
|
|
99
|
+
const frameworkAdjusted = applyFrameworkAdjustments(contextFiltered, frameworks);
|
|
100
|
+
|
|
101
|
+
// Apply .scannerrc configuration (rule suppression, severity/confidence thresholds)
|
|
102
|
+
const issues = applyConfig(frameworkAdjusted, file_path, config);
|
|
103
|
+
|
|
73
104
|
// Enhance issues with fix suggestions
|
|
74
105
|
const enhancedIssues = issues.map(issue => {
|
|
75
106
|
const line = lines[issue.line] || '';
|
package/src/typosquat.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Typosquatting detection for package hallucination
|
|
2
|
+
// Checks suspicious package names against known popular packages per ecosystem
|
|
3
|
+
|
|
4
|
+
// Top popular packages per ecosystem for typosquat comparison
|
|
5
|
+
const TOP_PACKAGES = {
|
|
6
|
+
npm: [
|
|
7
|
+
'express', 'react', 'lodash', 'axios', 'chalk', 'commander', 'debug',
|
|
8
|
+
'moment', 'request', 'uuid', 'bluebird', 'async', 'underscore', 'semver',
|
|
9
|
+
'glob', 'minimist', 'yargs', 'mkdirp', 'rimraf', 'colors', 'webpack',
|
|
10
|
+
'babel-core', 'typescript', 'eslint', 'jest', 'mocha', 'chai', 'sinon',
|
|
11
|
+
'prettier', 'next', 'vue', 'angular', 'svelte', 'dotenv', 'cors',
|
|
12
|
+
'helmet', 'mongoose', 'redis', 'pg', 'mysql2', 'socket.io', 'ws',
|
|
13
|
+
'jsonwebtoken', 'bcrypt', 'passport', 'nodemon', 'pm2', 'gulp', 'grunt',
|
|
14
|
+
'bower'
|
|
15
|
+
],
|
|
16
|
+
pypi: [
|
|
17
|
+
'requests', 'flask', 'django', 'numpy', 'pandas', 'scipy', 'boto3',
|
|
18
|
+
'setuptools', 'pip', 'wheel', 'six', 'urllib3', 'certifi', 'idna',
|
|
19
|
+
'chardet', 'pyyaml', 'jinja2', 'cryptography', 'pillow', 'matplotlib',
|
|
20
|
+
'sqlalchemy', 'celery', 'redis', 'pytest', 'click', 'rich', 'fastapi',
|
|
21
|
+
'pydantic', 'httpx', 'aiohttp', 'tornado', 'gunicorn', 'uvicorn',
|
|
22
|
+
'black', 'mypy', 'pylint', 'flake8', 'tox', 'coverage', 'sphinx',
|
|
23
|
+
'beautifulsoup4', 'scrapy', 'selenium', 'paramiko', 'fabric', 'ansible',
|
|
24
|
+
'tensorflow', 'pytorch', 'scikit-learn'
|
|
25
|
+
],
|
|
26
|
+
rubygems: [
|
|
27
|
+
'rails', 'rake', 'bundler', 'rspec', 'sinatra', 'puma', 'unicorn',
|
|
28
|
+
'devise', 'pundit', 'sidekiq', 'redis', 'pg', 'mysql2', 'activerecord',
|
|
29
|
+
'actionpack', 'activesupport', 'nokogiri', 'httparty', 'faraday',
|
|
30
|
+
'rest-client', 'json', 'minitest', 'capybara', 'factory_bot', 'faker',
|
|
31
|
+
'rubocop', 'solargraph', 'pry', 'byebug', 'dotenv', 'figaro', 'jwt',
|
|
32
|
+
'bcrypt', 'omniauth', 'paperclip', 'carrierwave', 'aws-sdk', 'stripe',
|
|
33
|
+
'graphql', 'grape'
|
|
34
|
+
],
|
|
35
|
+
crates: [
|
|
36
|
+
'serde', 'tokio', 'clap', 'rand', 'log', 'reqwest', 'hyper',
|
|
37
|
+
'actix-web', 'regex', 'lazy_static', 'chrono', 'uuid', 'futures',
|
|
38
|
+
'async-std', 'anyhow', 'thiserror', 'tracing', 'env_logger', 'config',
|
|
39
|
+
'diesel', 'sqlx', 'sea-orm', 'rocket', 'axum', 'warp', 'tower',
|
|
40
|
+
'bytes', 'url', 'http', 'serde_json', 'toml', 'base64', 'sha2',
|
|
41
|
+
'ring', 'rustls', 'rayon', 'crossbeam', 'parking_lot', 'dashmap',
|
|
42
|
+
'once_cell'
|
|
43
|
+
]
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute the Levenshtein edit distance between two strings.
|
|
48
|
+
* Uses a standard dynamic programming approach with O(min(m,n)) space.
|
|
49
|
+
* @param {string} a - First string
|
|
50
|
+
* @param {string} b - Second string
|
|
51
|
+
* @returns {number} The edit distance between a and b
|
|
52
|
+
*/
|
|
53
|
+
export function levenshteinDistance(a, b) {
|
|
54
|
+
// Ensure a is the shorter string to optimize space usage
|
|
55
|
+
if (a.length > b.length) {
|
|
56
|
+
[a, b] = [b, a];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const m = a.length;
|
|
60
|
+
const n = b.length;
|
|
61
|
+
|
|
62
|
+
// Early termination: if one string is empty, distance is the other's length
|
|
63
|
+
if (m === 0) return n;
|
|
64
|
+
|
|
65
|
+
// Use single row with rolling updates (O(min(m,n)) space)
|
|
66
|
+
let prev = new Array(m + 1);
|
|
67
|
+
let curr = new Array(m + 1);
|
|
68
|
+
|
|
69
|
+
// Initialize first row
|
|
70
|
+
for (let i = 0; i <= m; i++) {
|
|
71
|
+
prev[i] = i;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (let j = 1; j <= n; j++) {
|
|
75
|
+
curr[0] = j;
|
|
76
|
+
for (let i = 1; i <= m; i++) {
|
|
77
|
+
if (a[i - 1] === b[j - 1]) {
|
|
78
|
+
curr[i] = prev[i - 1];
|
|
79
|
+
} else {
|
|
80
|
+
curr[i] = 1 + Math.min(
|
|
81
|
+
prev[i], // deletion
|
|
82
|
+
curr[i - 1], // insertion
|
|
83
|
+
prev[i - 1] // substitution
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Swap rows
|
|
88
|
+
[prev, curr] = [curr, prev];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return prev[m];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Find popular packages that are similar to the given (possibly misspelled) package name.
|
|
96
|
+
* Used to detect potential typosquatting attacks where a malicious package has a name
|
|
97
|
+
* very close to a legitimate popular package.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} packageName - The package name to check (not found in registry)
|
|
100
|
+
* @param {string} ecosystem - The package ecosystem: 'npm', 'pypi', 'rubygems', or 'crates'
|
|
101
|
+
* @param {number} [maxDistance=2] - Maximum Levenshtein distance to consider a match
|
|
102
|
+
* @param {number} [limit=5] - Maximum number of similar packages to return
|
|
103
|
+
* @returns {Array<{name: string, distance: number, warning: string}>} Similar packages sorted by distance
|
|
104
|
+
*/
|
|
105
|
+
export function findSimilarPackages(packageName, ecosystem, maxDistance = 2, limit = 5) {
|
|
106
|
+
const knownPackages = TOP_PACKAGES[ecosystem];
|
|
107
|
+
if (!knownPackages) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const normalizedInput = packageName.toLowerCase();
|
|
112
|
+
const matches = [];
|
|
113
|
+
|
|
114
|
+
for (const known of knownPackages) {
|
|
115
|
+
const normalizedKnown = known.toLowerCase();
|
|
116
|
+
|
|
117
|
+
// Skip exact matches -- the package exists, not a typosquat
|
|
118
|
+
if (normalizedInput === normalizedKnown) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Quick length-based pruning: if length difference exceeds maxDistance,
|
|
123
|
+
// the edit distance must be at least that large
|
|
124
|
+
if (Math.abs(normalizedInput.length - normalizedKnown.length) > maxDistance) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const distance = levenshteinDistance(normalizedInput, normalizedKnown);
|
|
129
|
+
|
|
130
|
+
if (distance >= 1 && distance <= maxDistance) {
|
|
131
|
+
matches.push({
|
|
132
|
+
name: known,
|
|
133
|
+
distance,
|
|
134
|
+
warning: `Did you mean '${known}'? Possible typosquatting attack (edit distance: ${distance})`
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Sort by distance (closest first), then alphabetically for stable ordering
|
|
140
|
+
matches.sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name));
|
|
141
|
+
|
|
142
|
+
return matches.slice(0, limit);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Common internal/private naming prefixes that may indicate dependency confusion risk
|
|
146
|
+
const INTERNAL_PREFIXES = [
|
|
147
|
+
'internal-',
|
|
148
|
+
'private-',
|
|
149
|
+
'priv-',
|
|
150
|
+
'corp-',
|
|
151
|
+
'company-',
|
|
152
|
+
'org-',
|
|
153
|
+
'dev-',
|
|
154
|
+
'local-'
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
// Pattern for scoped package names that look like company-internal packages
|
|
158
|
+
const SCOPED_PACKAGE_RE = /^@([a-z0-9-]+)\//;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check whether a package name shows signs of dependency confusion risk.
|
|
162
|
+
* Dependency confusion attacks exploit the case where an internal (private) package
|
|
163
|
+
* name is also published on a public registry, allowing an attacker to trick
|
|
164
|
+
* package managers into installing the malicious public version.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} packageName - The package name to check
|
|
167
|
+
* @returns {{ risk: boolean, warning: string | null }} Risk assessment
|
|
168
|
+
*/
|
|
169
|
+
export function checkDependencyConfusion(packageName) {
|
|
170
|
+
// Check for scoped packages (@company/X) -- the unscoped name X could exist publicly
|
|
171
|
+
const scopedMatch = packageName.match(SCOPED_PACKAGE_RE);
|
|
172
|
+
if (scopedMatch) {
|
|
173
|
+
const scope = scopedMatch[1];
|
|
174
|
+
const unscopedName = packageName.replace(SCOPED_PACKAGE_RE, '');
|
|
175
|
+
|
|
176
|
+
// Check if the unscoped portion matches a known popular package
|
|
177
|
+
for (const ecosystem of Object.keys(TOP_PACKAGES)) {
|
|
178
|
+
const knownPackages = TOP_PACKAGES[ecosystem];
|
|
179
|
+
if (knownPackages.includes(unscopedName)) {
|
|
180
|
+
return {
|
|
181
|
+
risk: true,
|
|
182
|
+
warning: `Scoped package '${packageName}' contains unscoped name '${unscopedName}' which is a known public package. Verify this is the intended package to avoid dependency confusion.`
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Even without a known match, scoped names with common company-like scopes
|
|
188
|
+
// are worth flagging as they follow internal naming patterns
|
|
189
|
+
return {
|
|
190
|
+
risk: true,
|
|
191
|
+
warning: `Scoped package '${packageName}' follows an internal naming pattern (@${scope}/...). Ensure the scope is authentic and the package is not a dependency confusion attack targeting an internal package.`
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check for internal-looking prefixes
|
|
196
|
+
const lowerName = packageName.toLowerCase();
|
|
197
|
+
for (const prefix of INTERNAL_PREFIXES) {
|
|
198
|
+
if (lowerName.startsWith(prefix)) {
|
|
199
|
+
const baseName = lowerName.slice(prefix.length);
|
|
200
|
+
if (baseName.length > 0) {
|
|
201
|
+
return {
|
|
202
|
+
risk: true,
|
|
203
|
+
warning: `Package '${packageName}' uses the '${prefix}' prefix which suggests an internal/private package. If this is intended to be a public package, it may be a dependency confusion attack targeting the internal '${baseName}' package.`
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { risk: false, warning: null };
|
|
210
|
+
}
|
package/src/utils.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { execFileSync } from "child_process";
|
|
2
|
+
import { createHash } from "crypto";
|
|
2
3
|
import { readFileSync, existsSync } from "fs";
|
|
3
4
|
import { dirname, join, extname, basename } from "path";
|
|
4
5
|
import { fileURLToPath } from "url";
|
|
5
6
|
import { FIX_TEMPLATES } from './fix-patterns.js';
|
|
7
|
+
import { getDaemonClient, shutdownDaemon } from './daemon-client.js';
|
|
6
8
|
|
|
7
9
|
// Handle both ESM and CJS bundling (Smithery bundles to CJS)
|
|
8
10
|
let __dirname;
|
|
@@ -12,6 +14,16 @@ try {
|
|
|
12
14
|
__dirname = process.cwd();
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
// Read version from package.json at module load time
|
|
18
|
+
const _packageVersion = (() => {
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
21
|
+
return pkg.version || '0.0.0';
|
|
22
|
+
} catch {
|
|
23
|
+
return '0.0.0';
|
|
24
|
+
}
|
|
25
|
+
})();
|
|
26
|
+
|
|
15
27
|
// Detect language from file extension
|
|
16
28
|
export function detectLanguage(filePath) {
|
|
17
29
|
// Check basename first for extensionless files like Dockerfile
|
|
@@ -35,11 +47,43 @@ export function detectLanguage(filePath) {
|
|
|
35
47
|
return langMap[ext] || 'generic';
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
// Detect which analysis engine is available
|
|
51
|
+
export function detectEngineMode() {
|
|
52
|
+
try {
|
|
53
|
+
execFileSync('python3', ['-c', 'import tree_sitter; print("ast")'], {
|
|
54
|
+
encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
|
|
55
|
+
});
|
|
56
|
+
return 'ast';
|
|
57
|
+
} catch {
|
|
58
|
+
try {
|
|
59
|
+
execFileSync('python3', ['--version'], {
|
|
60
|
+
encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
|
|
61
|
+
});
|
|
62
|
+
return 'regex';
|
|
63
|
+
} catch {
|
|
64
|
+
return 'regex-only';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Cached engine mode (detected once per process)
|
|
70
|
+
let _cachedEngineMode = null;
|
|
71
|
+
export function getEngineMode() {
|
|
72
|
+
if (_cachedEngineMode === null) {
|
|
73
|
+
_cachedEngineMode = detectEngineMode();
|
|
74
|
+
}
|
|
75
|
+
return _cachedEngineMode;
|
|
76
|
+
}
|
|
77
|
+
|
|
38
78
|
// Run the Python analyzer
|
|
39
|
-
export function runAnalyzer(filePath) {
|
|
79
|
+
export function runAnalyzer(filePath, engine = 'auto') {
|
|
40
80
|
try {
|
|
41
81
|
const analyzerPath = join(__dirname, '..', 'analyzer.py');
|
|
42
|
-
const
|
|
82
|
+
const args = [analyzerPath, filePath];
|
|
83
|
+
if (engine !== 'auto') {
|
|
84
|
+
args.push('--engine', engine);
|
|
85
|
+
}
|
|
86
|
+
const result = execFileSync('python3', args, {
|
|
43
87
|
encoding: 'utf-8',
|
|
44
88
|
timeout: 30000
|
|
45
89
|
});
|
|
@@ -49,17 +93,111 @@ export function runAnalyzer(filePath) {
|
|
|
49
93
|
}
|
|
50
94
|
}
|
|
51
95
|
|
|
96
|
+
// Async analyzer — tries daemon first, falls back to sync execFileSync
|
|
97
|
+
export async function runAnalyzerAsync(filePath, engine = 'auto') {
|
|
98
|
+
try {
|
|
99
|
+
const client = getDaemonClient();
|
|
100
|
+
if (client.isAvailable) {
|
|
101
|
+
return await client.analyze(filePath, engine);
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// Daemon failed — fall through to sync
|
|
105
|
+
}
|
|
106
|
+
return runAnalyzer(filePath, engine);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Async cross-file analyzer — tries daemon first, falls back to sync
|
|
110
|
+
export async function runCrossFileAnalyzerAsync(filePaths) {
|
|
111
|
+
try {
|
|
112
|
+
const client = getDaemonClient();
|
|
113
|
+
if (client.isAvailable) {
|
|
114
|
+
const result = await client.crossFileAnalyze(filePaths);
|
|
115
|
+
return Array.isArray(result)
|
|
116
|
+
? result.filter(f => f.ruleId === 'cross-file-taint-warning')
|
|
117
|
+
: [];
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Daemon failed — fall through to sync
|
|
121
|
+
}
|
|
122
|
+
return runCrossFileAnalyzer(filePaths);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export { shutdownDaemon };
|
|
126
|
+
|
|
127
|
+
// Patterns that indicate an unsafe fix (user input still concatenated into dangerous sinks)
|
|
128
|
+
const UNSAFE_FIX_PATTERNS = [
|
|
129
|
+
// execFile/exec with string concatenation of user input
|
|
130
|
+
/\bexecFile\s*\(\s*["'][^"']*["']\s*\+\s*\w+/,
|
|
131
|
+
/\bexecFile\s*\(\s*`[^`]*\$\{/,
|
|
132
|
+
// spawn/exec with shell: true still present alongside user input
|
|
133
|
+
/\bspawn\s*\(.*shell\s*:\s*true/,
|
|
134
|
+
// subprocess.run with shell=True still present
|
|
135
|
+
/subprocess\.\w+\(.*shell\s*=\s*True/,
|
|
136
|
+
// os.system still in a "fix"
|
|
137
|
+
/\bos\.system\s*\(/,
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
// Validate that a fix produces syntactically reasonable and safe output
|
|
141
|
+
export function validateFix(original, fixed) {
|
|
142
|
+
if (!fixed || fixed === original) return false;
|
|
143
|
+
|
|
144
|
+
// Strip escaped quotes for bracket/quote counting
|
|
145
|
+
const unescaped = fixed.replace(/\\["'`]/g, '');
|
|
146
|
+
|
|
147
|
+
// Check balanced quotes (single pass)
|
|
148
|
+
const singleQ = (unescaped.match(/'/g) || []).length;
|
|
149
|
+
const doubleQ = (unescaped.match(/"/g) || []).length;
|
|
150
|
+
const backtickQ = (unescaped.match(/`/g) || []).length;
|
|
151
|
+
if (singleQ % 2 !== 0 || doubleQ % 2 !== 0 || backtickQ % 2 !== 0) return false;
|
|
152
|
+
|
|
153
|
+
// Check balanced brackets
|
|
154
|
+
const brackets = { '(': 0, '[': 0, '{': 0 };
|
|
155
|
+
const closers = { ')': '(', ']': '[', '}': '{' };
|
|
156
|
+
for (const char of unescaped) {
|
|
157
|
+
if (brackets[char] !== undefined) brackets[char]++;
|
|
158
|
+
if (closers[char]) {
|
|
159
|
+
brackets[closers[char]]--;
|
|
160
|
+
if (brackets[closers[char]] < 0) return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (Object.values(brackets).some(v => v !== 0)) return false;
|
|
164
|
+
|
|
165
|
+
// Reject fixes that still contain unsafe patterns
|
|
166
|
+
for (const pattern of UNSAFE_FIX_PATTERNS) {
|
|
167
|
+
if (pattern.test(fixed)) return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
52
173
|
// Generate fix suggestion for an issue
|
|
53
174
|
export function generateFix(issue, line, language) {
|
|
54
175
|
const ruleId = issue.ruleId.toLowerCase();
|
|
55
176
|
|
|
56
177
|
for (const [pattern, template] of Object.entries(FIX_TEMPLATES)) {
|
|
57
178
|
if (ruleId.includes(pattern)) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
fixed
|
|
62
|
-
|
|
179
|
+
try {
|
|
180
|
+
const fixed = template.fix(line, language);
|
|
181
|
+
// Validate the fix produces reasonable output
|
|
182
|
+
if (fixed && !validateFix(line, fixed)) {
|
|
183
|
+
return {
|
|
184
|
+
description: template.description + " (manual fix required)",
|
|
185
|
+
original: line,
|
|
186
|
+
fixed: null
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
description: template.description,
|
|
191
|
+
original: line,
|
|
192
|
+
fixed: fixed
|
|
193
|
+
};
|
|
194
|
+
} catch {
|
|
195
|
+
return {
|
|
196
|
+
description: template.description + " (manual fix required)",
|
|
197
|
+
original: line,
|
|
198
|
+
fixed: null
|
|
199
|
+
};
|
|
200
|
+
}
|
|
63
201
|
}
|
|
64
202
|
}
|
|
65
203
|
|
|
@@ -70,6 +208,26 @@ export function generateFix(issue, line, language) {
|
|
|
70
208
|
};
|
|
71
209
|
}
|
|
72
210
|
|
|
211
|
+
// Run cross-file taint analysis
|
|
212
|
+
export function runCrossFileAnalyzer(filePaths) {
|
|
213
|
+
try {
|
|
214
|
+
const analyzerPath = join(__dirname, '..', 'cross_file_analyzer.py');
|
|
215
|
+
if (!existsSync(analyzerPath)) return [];
|
|
216
|
+
const result = execFileSync('python3', [analyzerPath, ...filePaths], {
|
|
217
|
+
encoding: 'utf-8',
|
|
218
|
+
timeout: 120000,
|
|
219
|
+
maxBuffer: 10 * 1024 * 1024
|
|
220
|
+
});
|
|
221
|
+
const parsed = JSON.parse(result);
|
|
222
|
+
// Return only cross-file warnings (per-file findings are handled by scanSecurity)
|
|
223
|
+
return Array.isArray(parsed)
|
|
224
|
+
? parsed.filter(f => f.ruleId === 'cross-file-taint-warning')
|
|
225
|
+
: [];
|
|
226
|
+
} catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
73
231
|
// Convert issues to SARIF 2.1.0 format
|
|
74
232
|
export function toSarif(file_path, language, issues) {
|
|
75
233
|
const severityToLevel = {
|
|
@@ -113,6 +271,55 @@ export function toSarif(file_path, language, issues) {
|
|
|
113
271
|
}]
|
|
114
272
|
};
|
|
115
273
|
|
|
274
|
+
// Add partial fingerprints for cross-run deduplication
|
|
275
|
+
if (issue.line_content) {
|
|
276
|
+
result.partialFingerprints = {
|
|
277
|
+
primaryLocationLineHash: createHash('sha256')
|
|
278
|
+
.update(issue.line_content)
|
|
279
|
+
.digest('hex')
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Add code flows for taint analysis findings
|
|
284
|
+
if (issue.taint_source && issue.taint_sink) {
|
|
285
|
+
result.codeFlows = [{
|
|
286
|
+
threadFlows: [{
|
|
287
|
+
locations: [
|
|
288
|
+
{
|
|
289
|
+
location: {
|
|
290
|
+
physicalLocation: {
|
|
291
|
+
artifactLocation: { uri: file_path },
|
|
292
|
+
region: { startLine: issue.taint_source.line || 1 }
|
|
293
|
+
},
|
|
294
|
+
message: { text: issue.taint_source.label || 'Source' }
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
location: {
|
|
299
|
+
physicalLocation: {
|
|
300
|
+
artifactLocation: { uri: file_path },
|
|
301
|
+
region: { startLine: issue.taint_sink.line || 1 }
|
|
302
|
+
},
|
|
303
|
+
message: { text: issue.taint_sink.label || 'Sink' }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
]
|
|
307
|
+
}]
|
|
308
|
+
}];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Add related locations if present
|
|
312
|
+
if (issue.related_file) {
|
|
313
|
+
result.relatedLocations = [{
|
|
314
|
+
id: 0,
|
|
315
|
+
physicalLocation: {
|
|
316
|
+
artifactLocation: { uri: issue.related_file },
|
|
317
|
+
region: { startLine: issue.related_line || 1 }
|
|
318
|
+
},
|
|
319
|
+
message: { text: issue.related_message || 'Related location' }
|
|
320
|
+
}];
|
|
321
|
+
}
|
|
322
|
+
|
|
116
323
|
// Add fix if available
|
|
117
324
|
if (issue.suggested_fix && issue.suggested_fix.fixed) {
|
|
118
325
|
result.fixes = [{
|
|
@@ -142,7 +349,7 @@ export function toSarif(file_path, language, issues) {
|
|
|
142
349
|
tool: {
|
|
143
350
|
driver: {
|
|
144
351
|
name: 'agent-security-scanner-mcp',
|
|
145
|
-
version:
|
|
352
|
+
version: _packageVersion,
|
|
146
353
|
informationUri: 'https://github.com/sinewaveai/agent-security-scanner-mcp',
|
|
147
354
|
rules: Array.from(rulesMap.values())
|
|
148
355
|
}
|