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.
@@ -63,24 +63,68 @@ export const FIX_TEMPLATES = {
63
63
  // COMMAND INJECTION
64
64
  // ===========================================
65
65
  "child-process-exec": {
66
- description: "Use execFile() or spawn() with shell: false",
67
- fix: (line) => line.replace(/\bexec\s*\(/, 'execFile(')
66
+ description: "Use execFile() with separate command and arguments array",
67
+ fix: (line) => {
68
+ // Match: exec("cmd " + arg) -> execFile("cmd", [arg])
69
+ const concatMatch = line.match(/\bexec\s*\(\s*["'](\S+)\s+["']\s*\+\s*(\w+)/);
70
+ if (concatMatch) {
71
+ return line.replace(/\bexec\s*\(\s*["'](\S+)\s+["']\s*\+\s*(\w+)\s*\)/, 'execFile("$1", [$2])');
72
+ }
73
+ // Match: exec(`cmd ${arg}`) -> execFile("cmd", [arg])
74
+ const templateMatch = line.match(/\bexec\s*\(\s*`(\S+)\s+\$\{(\w+)\}`/);
75
+ if (templateMatch) {
76
+ return line.replace(/\bexec\s*\(\s*`(\S+)\s+\$\{(\w+)\}`\s*\)/, 'execFile("$1", [$2])');
77
+ }
78
+ // Match: exec(variable) -> comment with guidance (cannot safely auto-fix)
79
+ const varMatch = line.match(/\bexec\s*\(\s*(\w+)\s*\)/);
80
+ if (varMatch) {
81
+ return '// SECURITY: Replace exec() with execFile(command, [args], {shell: false})\n// ' + line.trim();
82
+ }
83
+ // Fallback: comment with guidance
84
+ return '// SECURITY: Use execFile(command, [args]) instead of exec() - ' + line.trim();
85
+ }
68
86
  },
69
87
  "spawn-shell": {
70
88
  description: "Use spawn with shell: false",
71
89
  fix: (line) => line.replace(/shell\s*:\s*true/i, 'shell: false')
72
90
  },
73
91
  "dangerous-subprocess": {
74
- description: "Use subprocess.run with list arguments",
75
- fix: (line) => line.replace(/subprocess\.(call|run|Popen)\s*\(\s*["'](.+?)["']\s*,\s*shell\s*=\s*True/, 'subprocess.$1(["$2".split()], shell=False')
92
+ description: "Use subprocess.run with list arguments and shell=False",
93
+ fix: (line) => {
94
+ // Replace shell=True with shell=False
95
+ let fixed = line.replace(/shell\s*=\s*True/, 'shell=False');
96
+ // Replace string command with shlex.split() for safe list form
97
+ fixed = fixed.replace(
98
+ /subprocess\.(call|run|Popen)\s*\(\s*["'](.+?)["']/,
99
+ 'subprocess.$1(shlex.split("$2")'
100
+ );
101
+ return fixed;
102
+ }
76
103
  },
77
104
  "dangerous-system-call": {
78
105
  description: "Use subprocess.run instead of os.system",
79
- fix: (line) => line.replace(/os\.system\s*\(/, 'subprocess.run([')
106
+ fix: (line) => {
107
+ const match = line.match(/os\.system\s*\(\s*(.+?)\s*\)/);
108
+ if (match) {
109
+ return line.replace(
110
+ /os\.system\s*\(\s*(.+?)\s*\)/,
111
+ 'subprocess.run(shlex.split($1), shell=False)'
112
+ );
113
+ }
114
+ return '# SECURITY: Replace os.system() with subprocess.run(shlex.split(cmd), shell=False)\n# ' + line.trim();
115
+ }
80
116
  },
81
117
  "command-injection-exec": {
82
- description: "Use exec.Command with separate arguments",
83
- fix: (line) => line.replace(/exec\.Command\s*\(\s*["'](\w+)\s+/, 'exec.Command("$1", ')
118
+ description: "Use exec.Command with separate arguments (no shell)",
119
+ fix: (line) => {
120
+ // Match: exec.Command("sh", "-c", "echo "+input) -> exec.Command("echo", input)
121
+ const shellMatch = line.match(/exec\.Command\s*\(\s*["']sh["']\s*,\s*["']-c["']\s*,\s*["'](\w+)\s+["']\s*\+\s*(\w+)/);
122
+ if (shellMatch) {
123
+ return line.replace(/exec\.Command\s*\(\s*["']sh["']\s*,\s*["']-c["']\s*,\s*["'](\w+)\s+["']\s*\+\s*(\w+)\s*\)/, 'exec.Command("$1", $2)');
124
+ }
125
+ // Match: exec.Command("cmd " + arg) -> exec.Command("cmd", arg)
126
+ return line.replace(/exec\.Command\s*\(\s*["'](\w+)\s+["']\s*\+\s*(\w+)\s*\)/, 'exec.Command("$1", $2)');
127
+ }
84
128
  },
85
129
  "runtime-exec": {
86
130
  description: "Use ProcessBuilder with separate arguments",
@@ -197,8 +241,11 @@ export const FIX_TEMPLATES = {
197
241
  // INSECURE DESERIALIZATION
198
242
  // ===========================================
199
243
  "pickle": {
200
- description: "Use JSON instead of pickle",
201
- fix: (line) => line.replace(/pickle\.(load|loads)\s*\(/, 'json.$1(')
244
+ description: "Use JSON instead of pickle for untrusted data",
245
+ fix: (line) => {
246
+ const fixed = line.replace(/pickle\.(load|loads)\s*\(/, 'json.$1(');
247
+ return fixed + ' # NOTE: data must be JSON-formatted';
248
+ }
202
249
  },
203
250
  "yaml-load": {
204
251
  description: "Use yaml.safe_load()",
@@ -209,8 +256,8 @@ export const FIX_TEMPLATES = {
209
256
  fix: (line) => line.replace(/marshal\.(load|loads)\s*\(/, 'json.$1(')
210
257
  },
211
258
  "shelve": {
212
- description: "Use JSON or SQLite instead of shelve",
213
- fix: (line) => line.replace(/shelve\.open\s*\(/, 'json.load(open(')
259
+ description: "Use JSON or SQLite instead of shelve for safe storage",
260
+ fix: (line) => '# SECURITY: Replace shelve with json or sqlite3 for safe storage\n# ' + line.trim()
214
261
  },
215
262
  "node-serialize": {
216
263
  description: "Use JSON.parse instead of node-serialize",
@@ -257,12 +304,12 @@ export const FIX_TEMPLATES = {
257
304
  // PATH TRAVERSAL
258
305
  // ===========================================
259
306
  "path-traversal": {
260
- description: "Sanitize file paths and use basename",
307
+ description: "Resolve real path and validate prefix to prevent traversal",
261
308
  fix: (line, lang) => {
262
- if (lang === 'python') return line.replace(/open\s*\(\s*(\w+)/, 'open(os.path.basename($1)');
263
- if (lang === 'go') return line.replace(/os\.Open\s*\(\s*(\w+)/, 'os.Open(filepath.Base($1)');
264
- if (lang === 'java') return line.replace(/new File\s*\(\s*(\w+)/, 'new File(new File($1).getName()');
265
- return line.replace(/readFileSync\s*\(\s*(\w+)/, 'readFileSync(path.basename($1)');
309
+ if (lang === 'python') return line.replace(/open\s*\(\s*(\w+)/, 'open(os.path.realpath($1) # TODO: validate path prefix');
310
+ if (lang === 'go') return line.replace(/os\.Open\s*\(\s*(\w+)/, 'os.Open(filepath.Clean($1) // TODO: validate path prefix');
311
+ if (lang === 'java') return line.replace(/new File\s*\(\s*(\w+)/, 'new File($1).getCanonicalFile( // TODO: validate path prefix');
312
+ return line.replace(/readFileSync\s*\(\s*(\w+)/, 'readFileSync(path.resolve($1) // TODO: validate path prefix');
266
313
  }
267
314
  },
268
315
 
@@ -407,7 +454,14 @@ export const FIX_TEMPLATES = {
407
454
  // ===========================================
408
455
  "prototype-pollution": {
409
456
  description: "Validate object keys before assignment",
410
- fix: (line) => line.replace(/(\w+)\[(\w+)\]\s*=/, 'if (!["__proto__", "constructor", "prototype"].includes($2)) $1[$2] =')
457
+ fix: (line) => {
458
+ // Only fix simple single-line assignments like: obj[key] = value
459
+ // Reject lines with multiple bracket accesses or chained assignments
460
+ if (/^\s*\w+\[\w+\]\s*=\s*[^[=]+$/.test(line)) {
461
+ return line.replace(/(\w+)\[(\w+)\]\s*=/, 'if (!["__proto__", "constructor", "prototype"].includes($2)) $1[$2] =');
462
+ }
463
+ return '// SECURITY: Validate key is not __proto__/constructor/prototype before assignment\n// ' + line.trim();
464
+ }
411
465
  },
412
466
 
413
467
  // ===========================================
@@ -443,7 +497,7 @@ export const FIX_TEMPLATES = {
443
497
  // ===========================================
444
498
  "helmet-missing": {
445
499
  description: "Add helmet middleware for security headers",
446
- fix: (line) => 'app.use(helmet()); // Add security headers\n' + line
500
+ fix: (line) => '// TODO: Add app.use(helmet()) after Express app initialization\n' + line
447
501
  },
448
502
 
449
503
  // ===========================================
@@ -495,7 +549,10 @@ export const FIX_TEMPLATES = {
495
549
  },
496
550
  "run-shell-form": {
497
551
  description: "Use exec form for RUN commands",
498
- fix: (line) => line.replace(/RUN\s+(.+)$/, 'RUN ["/bin/sh", "-c", "$1"]')
552
+ fix: (line) => line.replace(/RUN\s+(.+)$/, (_, cmd) => {
553
+ const escaped = cmd.replace(/"/g, '\\"');
554
+ return `RUN ["/bin/sh", "-c", "${escaped}"]`;
555
+ })
499
556
  },
500
557
  "sudo-in-dockerfile": {
501
558
  description: "Avoid sudo in Dockerfile - use USER directive",
package/src/history.js ADDED
@@ -0,0 +1,159 @@
1
+ // src/history.js — Scan history tracking for team dashboard.
2
+ // Stores results in .scanner/results/ within the scanned project directory.
3
+
4
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
5
+ import { join, basename } from 'path';
6
+
7
+ const RESULTS_DIR = '.scanner/results';
8
+
9
+ // Format a Date as YYYY-MM-DDTHH-MM-SS (filesystem-safe ISO timestamp)
10
+ function formatTimestamp(date) {
11
+ const pad = (n) => String(n).padStart(2, '0');
12
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
13
+ }
14
+
15
+ // Parse a YYYY-MM-DDTHH-MM-SS filename back into a Date
16
+ function parseTimestamp(filename) {
17
+ // Extract timestamp from filename like "2024-01-15T10-30-45.json"
18
+ const name = basename(filename, '.json');
19
+ const match = name.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})$/);
20
+ if (!match) return null;
21
+ const [, year, month, day, hour, min, sec] = match.map(Number);
22
+ return new Date(year, month - 1, day, hour, min, sec);
23
+ }
24
+
25
+ /**
26
+ * Save a scan result to .scanner/results/YYYY-MM-DDTHH-MM-SS.json
27
+ *
28
+ * @param {string} dirPath - The scanned project directory
29
+ * @param {object} scanResult - The scan result object from scanProject (parsed JSON output)
30
+ * @returns {string} Path to the saved result file
31
+ */
32
+ export function saveResult(dirPath, scanResult) {
33
+ const resultsDir = join(dirPath, RESULTS_DIR);
34
+ mkdirSync(resultsDir, { recursive: true });
35
+
36
+ const now = new Date();
37
+ const timestamp = formatTimestamp(now);
38
+ const filename = `${timestamp}.json`;
39
+ const filePath = join(resultsDir, filename);
40
+
41
+ const historyEntry = {
42
+ timestamp: now.toISOString(),
43
+ directory: scanResult.directory || dirPath,
44
+ grade: scanResult.grade || 'A',
45
+ files_scanned: scanResult.files_scanned || 0,
46
+ issues_count: scanResult.issues_count || scanResult.total || 0,
47
+ by_severity: scanResult.by_severity || { error: 0, warning: 0, info: 0 },
48
+ issues: scanResult.issues || [],
49
+ };
50
+
51
+ writeFileSync(filePath, JSON.stringify(historyEntry, null, 2) + '\n');
52
+ return filePath;
53
+ }
54
+
55
+ /**
56
+ * Load scan results from .scanner/results/ within the last N days.
57
+ *
58
+ * @param {string} dirPath - The scanned project directory
59
+ * @param {number} days - Number of days to look back (default: 90)
60
+ * @returns {object[]} Array of history entries, sorted oldest-first
61
+ */
62
+ export function loadHistory(dirPath, days = 90) {
63
+ const resultsDir = join(dirPath, RESULTS_DIR);
64
+ if (!existsSync(resultsDir)) return [];
65
+
66
+ const cutoff = new Date();
67
+ cutoff.setDate(cutoff.getDate() - days);
68
+
69
+ let files;
70
+ try {
71
+ files = readdirSync(resultsDir).filter(f => f.endsWith('.json'));
72
+ } catch {
73
+ return [];
74
+ }
75
+
76
+ const results = [];
77
+ for (const file of files) {
78
+ const fileDate = parseTimestamp(file);
79
+ if (!fileDate || fileDate < cutoff) continue;
80
+
81
+ try {
82
+ const content = readFileSync(join(resultsDir, file), 'utf-8');
83
+ const entry = JSON.parse(content);
84
+ results.push(entry);
85
+ } catch {
86
+ // Skip corrupt files
87
+ continue;
88
+ }
89
+ }
90
+
91
+ // Sort oldest-first by timestamp
92
+ results.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
93
+ return results;
94
+ }
95
+
96
+ /**
97
+ * Get trend data from scan history.
98
+ *
99
+ * @param {string} dirPath - The scanned project directory
100
+ * @param {number} days - Number of days to look back (default: 90)
101
+ * @returns {{ grades: Array<{date: string, grade: string}>, issues: Array<{date: string, total: number, critical: number, warning: number, info: number}> }}
102
+ */
103
+ export function getTrends(dirPath, days = 90) {
104
+ const history = loadHistory(dirPath, days);
105
+
106
+ const grades = history.map(entry => ({
107
+ date: entry.timestamp,
108
+ grade: entry.grade,
109
+ }));
110
+
111
+ const issues = history.map(entry => {
112
+ const severity = entry.by_severity || {};
113
+ return {
114
+ date: entry.timestamp,
115
+ total: entry.issues_count || 0,
116
+ critical: severity.error || 0,
117
+ warning: severity.warning || 0,
118
+ info: severity.info || 0,
119
+ };
120
+ });
121
+
122
+ return { grades, issues };
123
+ }
124
+
125
+ /**
126
+ * Compare two scan results to find new, fixed, and unchanged issues.
127
+ * Issues are compared by ruleId + file + line.
128
+ *
129
+ * @param {object} current - Current scan result (must have .issues array)
130
+ * @param {object} previous - Previous scan result (must have .issues array)
131
+ * @returns {{ new_issues: object[], fixed_issues: object[], unchanged: number }}
132
+ */
133
+ export function diffResults(current, previous) {
134
+ const currentIssues = current.issues || [];
135
+ const previousIssues = previous.issues || [];
136
+
137
+ // Build a key for each issue: ruleId + file + line
138
+ function issueKey(issue) {
139
+ return `${issue.ruleId || ''}::${issue.file || ''}::${issue.line || 0}`;
140
+ }
141
+
142
+ const currentKeys = new Set(currentIssues.map(issueKey));
143
+ const previousKeys = new Set(previousIssues.map(issueKey));
144
+
145
+ // New issues: in current but not in previous
146
+ const newIssues = currentIssues.filter(i => !previousKeys.has(issueKey(i)));
147
+
148
+ // Fixed issues: in previous but not in current
149
+ const fixedIssues = previousIssues.filter(i => !currentKeys.has(issueKey(i)));
150
+
151
+ // Unchanged: in both
152
+ const unchanged = currentIssues.filter(i => previousKeys.has(issueKey(i))).length;
153
+
154
+ return {
155
+ new_issues: newIssues,
156
+ fixed_issues: fixedIssues,
157
+ unchanged,
158
+ };
159
+ }
@@ -4,6 +4,7 @@ import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import bloomFilters from "bloom-filters";
6
6
  const { BloomFilter } = bloomFilters;
7
+ import { findSimilarPackages, checkDependencyConfusion } from '../typosquat.js';
7
8
 
8
9
  // Handle both ESM and CJS bundling (Smithery bundles to CJS)
9
10
  let __dirname;
@@ -149,21 +150,44 @@ export async function checkPackage({ package_name, ecosystem }) {
149
150
  const confidence = result.bloomFilter ? "medium" : "high";
150
151
  const totalPackages = getTotalPackages(ecosystem);
151
152
 
153
+ // Enhanced response with typosquatting and dependency confusion checks
154
+ const response = {
155
+ package: package_name,
156
+ ecosystem,
157
+ legitimate: exists,
158
+ hallucinated: !exists,
159
+ confidence,
160
+ bloom_filter: !!result.bloomFilter,
161
+ total_known_packages: totalPackages,
162
+ recommendation: exists
163
+ ? "Package exists in registry - safe to use"
164
+ : "POTENTIAL HALLUCINATION - Package not found in registry. Verify before using!"
165
+ };
166
+
167
+ // If package not found, check for typosquatting
168
+ if (!exists) {
169
+ const similar = findSimilarPackages(package_name, ecosystem);
170
+ if (similar.length > 0) {
171
+ response.typosquatting = {
172
+ similar_packages: similar,
173
+ warning: `Package '${package_name}' is similar to known packages. This could be a typosquatting attack.`
174
+ };
175
+ }
176
+ }
177
+
178
+ // Check for dependency confusion risk regardless of existence
179
+ const confusionCheck = checkDependencyConfusion(package_name);
180
+ if (confusionCheck.risk) {
181
+ response.dependency_confusion = {
182
+ risk: true,
183
+ warning: confusionCheck.warning
184
+ };
185
+ }
186
+
152
187
  return {
153
188
  content: [{
154
189
  type: "text",
155
- text: JSON.stringify({
156
- package: package_name,
157
- ecosystem,
158
- legitimate: exists,
159
- hallucinated: !exists,
160
- confidence,
161
- bloom_filter: !!result.bloomFilter,
162
- total_known_packages: totalPackages,
163
- recommendation: exists
164
- ? "Package exists in registry - safe to use"
165
- : "⚠️ POTENTIAL HALLUCINATION - Package not found in registry. Verify before using!"
166
- }, null, 2)
190
+ text: JSON.stringify(response, null, 2)
167
191
  }]
168
192
  };
169
193
  }
@@ -1,7 +1,10 @@
1
1
  // src/tools/fix-security.js
2
2
  import { z } from "zod";
3
3
  import { existsSync, readFileSync } from "fs";
4
- import { detectLanguage, runAnalyzer, generateFix } from '../utils.js';
4
+ import { detectLanguage, runAnalyzerAsync, generateFix } 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 fixSecuritySchema = {
7
10
  file_path: z.string().describe("Path to the file to fix"),
@@ -47,24 +50,48 @@ export async function fixSecurity({ file_path, verbosity }) {
47
50
  };
48
51
  }
49
52
 
50
- const issues = runAnalyzer(file_path);
53
+ // Load project configuration
54
+ const config = loadConfig(file_path);
51
55
 
52
- if (issues.error || !Array.isArray(issues) || issues.length === 0) {
56
+ // Check file exclusion
57
+ if (shouldExcludeFile(file_path, config)) {
58
+ return {
59
+ content: [{ type: "text", text: JSON.stringify({ file: file_path, message: "File excluded by configuration", fixes_applied: 0 }) }]
60
+ };
61
+ }
62
+
63
+ const rawIssues = await runAnalyzerAsync(file_path);
64
+
65
+ if (rawIssues.error || !Array.isArray(rawIssues) || rawIssues.length === 0) {
53
66
  return {
54
67
  content: [{
55
68
  type: "text",
56
69
  text: JSON.stringify({
57
- message: issues.error ? "Error scanning file" : "No security issues found",
58
- details: issues
70
+ message: rawIssues.error ? "Error scanning file" : "No security issues found",
71
+ details: rawIssues
59
72
  })
60
73
  }]
61
74
  };
62
75
  }
63
76
 
77
+ // Cross-engine deduplication
78
+ const dedupedIssues = deduplicateFindings(rawIssues);
79
+
64
80
  // Read and fix the file
65
81
  const content = readFileSync(file_path, 'utf-8');
66
82
  const lines = content.split('\n');
67
83
  const language = detectLanguage(file_path);
84
+
85
+ // Context-aware filtering (suppress known module imports)
86
+ const contextFiltered = applyContextFilter(dedupedIssues, file_path, language);
87
+
88
+ // Framework-aware severity adjustment
89
+ const frameworks = detectFrameworks(file_path, language);
90
+ const frameworkAdjusted = applyFrameworkAdjustments(contextFiltered, frameworks);
91
+
92
+ // Apply .scannerrc configuration (rule suppression, severity/confidence thresholds)
93
+ const issues = applyConfig(frameworkAdjusted, file_path, config);
94
+
68
95
  const fixes = [];
69
96
 
70
97
  // Apply fixes (process in reverse order to preserve line numbers)