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.
@@ -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, runAnalyzer, generateFix, toSarif } from '../utils.js';
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
- const issues = runAnalyzer(file_path);
68
+ // Load project configuration
69
+ const config = loadConfig(file_path);
61
70
 
62
- if (issues.error) {
71
+ // Check file exclusion
72
+ if (shouldExcludeFile(file_path, config)) {
63
73
  return {
64
- content: [{ type: "text", text: JSON.stringify(issues) }]
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] || '';
@@ -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 result = execFileSync('python3', [analyzerPath, filePath], {
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
- return {
59
- description: template.description,
60
- original: line,
61
- fixed: template.fix(line, language)
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: '3.1.0',
352
+ version: _packageVersion,
146
353
  informationUri: 'https://github.com/sinewaveai/agent-security-scanner-mcp',
147
354
  rules: Array.from(rulesMap.values())
148
355
  }