mcpsec 0.1.0 → 0.2.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 CHANGED
@@ -1,66 +1,252 @@
1
- # Sentinel MCP
1
+ # mcpsec
2
2
 
3
- **Security scanner for Model Context Protocol (MCP) servers.**
3
+ Security scanner for [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) servers. Detects tool poisoning, credential exposure, prompt injection, SSRF, and insecure transport across all your MCP configurations.
4
4
 
5
- Sentinel scans your MCP configurations for security vulnerabilities including hardcoded credentials, prompt injection, tool poisoning, SSRF, and insecure transport.
5
+ ## Why
6
+
7
+ MCP gives AI agents access to tools, files, databases, and APIs. A single malicious or misconfigured MCP server can:
8
+
9
+ - **Steal credentials** hardcoded in config files
10
+ - **Poison tool descriptions** to manipulate AI behavior
11
+ - **Shadow legitimate tools** to intercept sensitive operations
12
+ - **Exfiltrate data** via SSRF to internal services or cloud metadata endpoints
13
+ - **Inject commands** through server arguments
14
+
15
+ mcpsec finds these problems before attackers do.
6
16
 
7
17
  ## Quick Start
8
18
 
9
19
  ```bash
10
20
  # Scan all detected MCP configurations
11
- bun run src/cli/index.ts scan
21
+ npx mcpsec scan
12
22
 
13
- # JSON output for CI/CD
14
- bun run src/cli/index.ts scan --json
23
+ # Connect to running servers and scan live tools/resources
24
+ npx mcpsec scan --live
15
25
 
16
26
  # Scan a specific config file
17
- bun run src/cli/index.ts scan --path ~/.cursor/mcp.json
27
+ npx mcpsec scan --path ~/.cursor/mcp.json
28
+
29
+ # JSON output for CI/CD pipelines
30
+ npx mcpsec scan --json
31
+
32
+ # Save scan as baseline for future comparison
33
+ npx mcpsec scan --save-baseline
34
+
35
+ # Compare current scan against baseline
36
+ npx mcpsec scan --baseline
37
+
38
+ # Baseline diff as JSON (for CI/CD)
39
+ npx mcpsec scan --baseline --json
40
+ ```
41
+
42
+ > Requires [Bun](https://bun.sh) runtime (`curl -fsSL https://bun.sh/install | bash`)
43
+
44
+ ## Example Output
45
+
46
+ ```
47
+ mcpsec - MCP Security Scanner v0.1.0
48
+ ──────────────────────────────────────────────────
49
+
50
+ Configurations Found
51
+ Claude Desktop (3 servers)
52
+ ~/.config/claude/claude_desktop_config.json
53
+ └─ filesystem
54
+ └─ github
55
+ └─ slack-mcp
56
+
57
+ Security Score
58
+ 42/100 FAIL
59
+
60
+ 2 CRITICAL 1 high 1 medium
61
+
62
+ Findings
63
+ ──────────────────────────────────────────────────
64
+
65
+ CRITICAL Hardcoded API Key [CRED-001]
66
+ Server: slack-mcp
67
+ Anthropic API key found in environment variables
68
+ Evidence: ANTHROPIC_API_KEY=sk-ant-api03-****
69
+ Fix: Use a secrets manager or environment variable reference
70
+
71
+ CRITICAL SSRF Risk - Cloud Metadata [SSRF-002]
72
+ Server: internal-proxy
73
+ Server URL points to AWS metadata endpoint
74
+ Evidence: http://169.254.169.254/latest/meta-data/
75
+ Fix: Remove cloud metadata URLs from MCP configurations
76
+
77
+ HIGH Unencrypted Transport [TRANSPORT-001]
78
+ Server: github
79
+ Server uses HTTP instead of HTTPS
80
+ Fix: Switch to HTTPS or use stdio transport
81
+
82
+ MEDIUM Unverified npx Package [SUPPLY-001]
83
+ Server: slack-mcp
84
+ Package installed via npx without version pinning
85
+ Fix: Pin to a specific version: npx package@1.2.3
18
86
  ```
19
87
 
20
- ## What It Scans
88
+ ## What It Detects
89
+
90
+ ### Static Analysis (default)
21
91
 
22
92
  | Check | Category | Severity |
23
93
  |-------|----------|----------|
24
- | Hardcoded API keys (Anthropic, OpenAI, GitHub, AWS, etc.) | Credential Exposure | Critical |
25
- | Prompt injection in tool descriptions | Tool Poisoning | Critical |
26
- | Tool shadowing / hidden instructions | Tool Poisoning | Critical |
27
- | SSRF via server URLs (localhost, private IPs, cloud metadata) | SSRF | Critical |
94
+ | Hardcoded API keys (Anthropic, OpenAI, GitHub, AWS, GCP, Azure, Stripe, etc.) | Credential Exposure | Critical |
95
+ | Credentials embedded in URLs | Credential Exposure | Critical |
96
+ | Sensitive environment variable values | Credential Exposure | High |
97
+ | SSRF via cloud metadata endpoints (AWS, GCP, Azure) | SSRF | Critical |
98
+ | SSRF via localhost/private IP ranges | SSRF | High |
28
99
  | Command injection in server arguments | Command Injection | Critical |
100
+ | Prompt injection patterns in tool descriptions | Tool Poisoning | Critical |
101
+ | Hidden instructions and tool shadowing | Tool Poisoning | Critical |
102
+ | Privileged Docker containers (`--privileged`, host network) | Excessive Permissions | Critical |
29
103
  | Unencrypted HTTP transport | Insecure Transport | High |
30
- | Privileged Docker containers | Excessive Permissions | Critical |
31
- | Unverified npm packages via npx | Supply Chain | Medium |
32
- | Embedded credentials in URLs | Credential Exposure | Critical |
33
- | Sensitive environment variable values | Credential Exposure | High |
104
+ | Unverified npx packages without version pinning | Supply Chain | Medium |
105
+
106
+ ### Live Server Scanning (`--live`)
107
+
108
+ Connects to running MCP servers and inspects their actual tools, resources, and prompts:
109
+
110
+ | Check | Category | Severity |
111
+ |-------|----------|----------|
112
+ | Dangerous tool capabilities (exec, eval, sudo, bulk delete) | Dangerous Tools | High |
113
+ | Injection patterns in tool descriptions | Tool Poisoning | Critical |
114
+ | Injection patterns in resource/prompt descriptions | Tool Poisoning | High |
115
+ | SSRF in resource URI templates | SSRF | High |
116
+ | Sensitive input schemas (password/token fields) | Credential Exposure | Medium |
117
+ | Tool name shadowing across servers | Tool Shadowing | High |
118
+ | Sampling capability enabled | Excessive Permissions | Medium |
34
119
 
35
120
  ## Supported Clients
36
121
 
37
- - Claude Desktop
38
- - Claude Code
39
- - Cursor
40
- - VS Code
41
- - Windsurf
42
- - Cline
122
+ mcpsec auto-discovers configurations for:
123
+
124
+ - **Claude Desktop** - `~/.config/claude/claude_desktop_config.json`
125
+ - **Claude Code** - `~/.claude/settings.json` (project and global)
126
+ - **Cursor** - `~/.cursor/mcp.json`
127
+ - **VS Code** - `~/.vscode/settings.json`
128
+ - **Windsurf** - `~/.windsurf/mcp.json`
129
+ - **Cline** - VS Code extension settings
43
130
 
44
131
  ## Security Score
45
132
 
46
- Sentinel calculates a 0-100 security score:
133
+ mcpsec calculates a 0-100 security score:
47
134
 
48
- - **80-100**: PASS - No critical issues
49
- - **50-79**: WARN - Issues found, review recommended
50
- - **0-49**: FAIL - Critical vulnerabilities detected
135
+ | Score | Status | Impact |
136
+ |-------|--------|--------|
137
+ | 80-100 | PASS | No critical issues |
138
+ | 50-79 | WARN | Issues found, review recommended |
139
+ | 0-49 | FAIL | Critical vulnerabilities detected |
140
+
141
+ **Scoring:** Critical = -25, High = -15, Medium = -8, Low = -3, Info = 0
142
+
143
+ ## GitHub Actions
144
+
145
+ ```yaml
146
+ - name: MCP Security Scan
147
+ uses: robdtaylor/sentinel-mcp@v1
148
+ with:
149
+ config-path: path/to/mcp-config.json
150
+ fail-on: high # critical, high, medium, or none
151
+ ```
51
152
 
52
- ## Exit Codes
153
+ ### Inputs
154
+
155
+ | Input | Required | Default | Description |
156
+ |-------|----------|---------|-------------|
157
+ | `config-path` | Yes | - | Path to MCP configuration file |
158
+ | `fail-on` | No | `high` | Minimum severity to fail: `critical`, `high`, `medium`, `none` |
159
+
160
+ ### Outputs
161
+
162
+ | Output | Description |
163
+ |--------|-------------|
164
+ | `score` | Security score (0-100) |
165
+ | `status` | `pass`, `warn`, or `fail` |
166
+ | `findings` | Total number of findings |
167
+ | `critical` | Number of critical findings |
168
+ | `high` | Number of high findings |
169
+
170
+ ### Example: Fail only on critical
171
+
172
+ ```yaml
173
+ - name: MCP Security Scan
174
+ uses: robdtaylor/sentinel-mcp@v1
175
+ with:
176
+ config-path: .cursor/mcp.json
177
+ fail-on: critical
178
+ ```
179
+
180
+ ### Example: Use outputs in later steps
181
+
182
+ ```yaml
183
+ - name: MCP Security Scan
184
+ id: mcpsec
185
+ uses: robdtaylor/sentinel-mcp@v1
186
+ with:
187
+ config-path: mcp-config.json
188
+ fail-on: none
189
+
190
+ - name: Check results
191
+ run: |
192
+ echo "Score: ${{ steps.mcpsec.outputs.score }}"
193
+ echo "Critical: ${{ steps.mcpsec.outputs.critical }}"
194
+ ```
195
+
196
+ ### SARIF + GitHub Code Scanning
197
+
198
+ Upload results to GitHub's Security tab:
199
+
200
+ ```yaml
201
+ - name: MCP Security Scan
202
+ run: npx mcpsec scan --sarif --path mcp-config.json > results.sarif
203
+
204
+ - name: Upload SARIF
205
+ uses: github/codeql-action/upload-sarif@v3
206
+ with:
207
+ sarif_file: results.sarif
208
+ ```
209
+
210
+ ### CLI in CI
211
+
212
+ You can also run the CLI directly:
213
+
214
+ ```yaml
215
+ - name: MCP Security Scan
216
+ run: npx mcpsec scan --json --path mcp-config.json > report.json
217
+ ```
218
+
219
+ ### Exit Codes
53
220
 
54
221
  | Code | Meaning |
55
222
  |------|---------|
56
- | 0 | No critical/high findings |
57
- | 1 | High severity findings |
58
- | 2 | Critical severity findings |
223
+ | 0 | No critical or high findings |
224
+ | 1 | High severity findings found |
225
+ | 2 | Critical severity findings found |
226
+
227
+ ## CLI Reference
228
+
229
+ ```
230
+ mcpsec scan [options]
231
+
232
+ Options:
233
+ --live Connect to running MCP servers and scan live
234
+ --json Output results as JSON
235
+ --sarif Output results as SARIF 2.1.0 (for GitHub Code Scanning)
236
+ --path <file> Scan a specific config file
237
+ --save-baseline [file] Save scan results as baseline (default: .mcpsec-baseline.json)
238
+ --baseline [file] Compare scan against baseline and show diff
239
+ --no-color Disable colored output
240
+ --help, -h Show help
241
+ --version, -v Show version
242
+ ```
59
243
 
60
244
  ## Development
61
245
 
62
246
  ```bash
63
- # Install dependencies
247
+ # Clone and install
248
+ git clone https://github.com/robdtaylor/sentinel-mcp.git
249
+ cd sentinel-mcp
64
250
  bun install
65
251
 
66
252
  # Run tests
@@ -68,8 +254,39 @@ bun test
68
254
 
69
255
  # Type check
70
256
  bun run typecheck
257
+
258
+ # Run locally
259
+ bun run src/cli/index.ts scan
260
+ ```
261
+
262
+ ## Architecture
263
+
264
+ ```
265
+ src/
266
+ cli/index.ts CLI entry point
267
+ lib/
268
+ types.ts Core types (Finding, MCPConfigFile, Scanner)
269
+ injection-patterns.ts Prompt injection / tool poisoning patterns
270
+ url-validator.ts SSRF detection (cloud metadata, private IPs)
271
+ mcp-client.ts MCP protocol client (stdio + HTTP)
272
+ scanner/
273
+ config-scanner.ts Config-level checks (transport, docker, supply chain)
274
+ credential-scanner.ts API key and credential detection
275
+ tool-scanner.ts Injection and command injection scanning
276
+ live-scanner.ts Live server tool/resource/prompt analysis
277
+ report.ts Score calculation and report output
278
+ sarif.ts SARIF 2.1.0 output for GitHub Code Scanning
279
+ baseline.ts Baseline save/load and diff engine
71
280
  ```
72
281
 
282
+ ## Roadmap
283
+
284
+ - [x] Cross-server tool shadowing detection
285
+ - [x] GitHub Actions action (`uses: robdtaylor/sentinel-mcp@v1`)
286
+ - [ ] MCP server registry scanning
287
+ - [x] Baseline / diff mode (track changes between scans)
288
+ - [x] SARIF output format (`--sarif` for GitHub Code Scanning)
289
+
73
290
  ## License
74
291
 
75
292
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpsec",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Security scanner for MCP (Model Context Protocol) servers - detects tool poisoning, credential exposure, prompt injection, and SSRF",
5
5
  "license": "MIT",
6
6
  "author": "Rob Taylor <robdtaylor@users.noreply.github.com>",
package/src/cli/index.ts CHANGED
@@ -14,6 +14,14 @@ import { credentialScanner } from '../scanner/credential-scanner';
14
14
  import { toolScanner } from '../scanner/tool-scanner';
15
15
  import { liveScanner } from '../scanner/live-scanner';
16
16
  import { generateReport, printReport, printReportJSON } from '../scanner/report';
17
+ import { printReportSARIF } from '../scanner/sarif';
18
+ import {
19
+ loadBaseline,
20
+ saveBaseline,
21
+ diffFindings,
22
+ printBaselineDiff,
23
+ DEFAULT_BASELINE_PATH,
24
+ } from '../scanner/baseline';
17
25
  import type { Finding, MCPConfigFile } from '../lib/types';
18
26
 
19
27
  // ============================================================================
@@ -27,18 +35,24 @@ const HELP = `
27
35
  mcpsec scan [options]
28
36
 
29
37
  Options:
30
- --live Connect to running MCP servers and scan live
31
- --json Output results as JSON
32
- --path <file> Scan a specific config file
33
- --no-color Disable colored output
34
- --help, -h Show this help message
35
- --version, -v Show version
38
+ --live Connect to running MCP servers and scan live
39
+ --json Output results as JSON
40
+ --sarif Output results as SARIF 2.1.0 (for GitHub Code Scanning)
41
+ --path <file> Scan a specific config file
42
+ --save-baseline [file] Save scan results as baseline (default: ${DEFAULT_BASELINE_PATH})
43
+ --baseline [file] Compare scan against baseline and show diff
44
+ --no-color Disable colored output
45
+ --help, -h Show this help message
46
+ --version, -v Show version
36
47
 
37
48
  Examples:
38
49
  mcpsec scan
39
50
  mcpsec scan --live
40
51
  mcpsec scan --json
41
52
  mcpsec scan --path ~/.cursor/mcp.json
53
+ mcpsec scan --save-baseline
54
+ mcpsec scan --baseline
55
+ mcpsec scan --baseline --json
42
56
  `;
43
57
 
44
58
  async function main() {
@@ -51,7 +65,7 @@ async function main() {
51
65
  }
52
66
 
53
67
  if (command === '--version' || command === '-v') {
54
- console.log('mcpsec v0.1.0');
68
+ console.log('mcpsec v0.2.0');
55
69
  process.exit(0);
56
70
  }
57
71
 
@@ -63,10 +77,28 @@ async function main() {
63
77
 
64
78
  // Parse scan options
65
79
  const jsonOutput = args.includes('--json');
80
+ const sarifOutput = args.includes('--sarif');
66
81
  const liveMode = args.includes('--live');
67
82
  const pathIndex = args.indexOf('--path');
68
83
  const specificPath = pathIndex !== -1 ? args[pathIndex + 1] : undefined;
69
84
 
85
+ // Baseline flags
86
+ const saveBaselineIndex = args.indexOf('--save-baseline');
87
+ const saveBaselineMode = saveBaselineIndex !== -1;
88
+ const saveBaselinePath = saveBaselineMode
89
+ ? (args[saveBaselineIndex + 1] && !args[saveBaselineIndex + 1].startsWith('--')
90
+ ? args[saveBaselineIndex + 1]
91
+ : DEFAULT_BASELINE_PATH)
92
+ : undefined;
93
+
94
+ const baselineIndex = args.indexOf('--baseline');
95
+ const baselineMode = baselineIndex !== -1;
96
+ const baselinePath = baselineMode
97
+ ? (args[baselineIndex + 1] && !args[baselineIndex + 1].startsWith('--')
98
+ ? args[baselineIndex + 1]
99
+ : DEFAULT_BASELINE_PATH)
100
+ : undefined;
101
+
70
102
  if (args.includes('--no-color')) {
71
103
  // Disable colors by overriding environment
72
104
  process.env.NO_COLOR = '1';
@@ -135,11 +167,59 @@ async function main() {
135
167
  // Generate report
136
168
  const report = generateReport(configs, allFindings);
137
169
 
138
- // Output
139
- if (jsonOutput) {
140
- printReportJSON(report);
170
+ // Save baseline if requested
171
+ if (saveBaselineMode && saveBaselinePath) {
172
+ saveBaseline(report, saveBaselinePath);
173
+ if (!jsonOutput && !sarifOutput) {
174
+ console.log(`\n Baseline saved to ${saveBaselinePath}\n`);
175
+ }
176
+ }
177
+
178
+ // Baseline diff if requested
179
+ if (baselineMode && baselinePath) {
180
+ try {
181
+ const baseline = loadBaseline(baselinePath);
182
+ const diff = diffFindings(allFindings, baseline);
183
+ diff.scoreDelta = report.score - baseline.score;
184
+
185
+ if (jsonOutput) {
186
+ // JSON output with diff included
187
+ const safeReport = {
188
+ ...report,
189
+ configFiles: report.configFiles.map((c) => ({
190
+ path: c.path,
191
+ client: c.client,
192
+ servers: Object.keys(c.servers),
193
+ })),
194
+ diff: {
195
+ baselineTimestamp: diff.baselineTimestamp,
196
+ baselineScore: diff.baselineScore,
197
+ scoreDelta: diff.scoreDelta,
198
+ new: diff.newFindings,
199
+ fixed: diff.fixedFindings,
200
+ unchanged: diff.unchangedFindings.length,
201
+ },
202
+ };
203
+ console.log(JSON.stringify(safeReport, null, 2));
204
+ } else if (sarifOutput) {
205
+ printReportSARIF(report);
206
+ } else {
207
+ printReport(report);
208
+ printBaselineDiff(diff, report.score);
209
+ }
210
+ } catch (err) {
211
+ console.error(`Baseline error: ${(err as Error).message}`);
212
+ process.exit(1);
213
+ }
141
214
  } else {
142
- printReport(report);
215
+ // Normal output (no baseline)
216
+ if (sarifOutput) {
217
+ printReportSARIF(report);
218
+ } else if (jsonOutput) {
219
+ printReportJSON(report);
220
+ } else {
221
+ printReport(report);
222
+ }
143
223
  }
144
224
 
145
225
  // Exit code based on findings
@@ -98,6 +98,7 @@ export async function connectStdio(
98
98
  stderr: 'pipe',
99
99
  });
100
100
 
101
+ const stdin = proc.stdin as import('bun').FileSink;
101
102
  const reader = new StdioReader(proc);
102
103
  let messageId = 0;
103
104
 
@@ -112,8 +113,8 @@ export async function connectStdio(
112
113
  };
113
114
 
114
115
  const line = JSON.stringify(request) + '\n';
115
- proc!.stdin.write(line);
116
- proc!.stdin.flush();
116
+ stdin.write(line);
117
+ stdin.flush();
117
118
 
118
119
  const response = await reader.waitForResponse(id, REQUEST_TIMEOUT);
119
120
  if (response.error) {
@@ -129,8 +130,8 @@ export async function connectStdio(
129
130
  method,
130
131
  params,
131
132
  };
132
- proc!.stdin.write(JSON.stringify(notification) + '\n');
133
- proc!.stdin.flush();
133
+ stdin.write(JSON.stringify(notification) + '\n');
134
+ stdin.flush();
134
135
  };
135
136
 
136
137
  // Step 1: Initialize
@@ -139,7 +140,7 @@ export async function connectStdio(
139
140
  capabilities: {},
140
141
  clientInfo: {
141
142
  name: 'sentinel-mcp',
142
- version: '0.1.0',
143
+ version: '0.2.0',
143
144
  },
144
145
  }) as Record<string, unknown>;
145
146
 
@@ -196,7 +197,7 @@ export async function connectStdio(
196
197
  // Clean up the process
197
198
  if (proc) {
198
199
  try {
199
- proc.stdin.end();
200
+ (proc.stdin as import('bun').FileSink).end();
200
201
  proc.kill();
201
202
  } catch {
202
203
  // Process may have already exited
package/src/lib/types.ts CHANGED
@@ -49,6 +49,7 @@ export type FindingCategory =
49
49
  | 'credential-exposure'
50
50
  | 'prompt-injection'
51
51
  | 'tool-poisoning'
52
+ | 'tool-shadowing'
52
53
  | 'ssrf'
53
54
  | 'command-injection'
54
55
  | 'insecure-transport'
@@ -80,6 +81,19 @@ export interface ScanSummary {
80
81
  info: number;
81
82
  }
82
83
 
84
+ // ============================================================================
85
+ // Baseline / Diff
86
+ // ============================================================================
87
+
88
+ export interface BaselineDiff {
89
+ newFindings: Finding[];
90
+ fixedFindings: Finding[];
91
+ unchangedFindings: Finding[];
92
+ baselineTimestamp: string;
93
+ baselineScore: number;
94
+ scoreDelta: number; // current - baseline (positive = improved)
95
+ }
96
+
83
97
  // ============================================================================
84
98
  // Scanner Interface
85
99
  // ============================================================================
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Sentinel MCP - Baseline / Diff Engine
3
+ *
4
+ * Compare scan results against a saved baseline to track
5
+ * new findings, fixed findings, and score trends over time.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync } from 'fs';
9
+ import type { Finding, ScanReport, BaselineDiff } from '../lib/types';
10
+
11
+ export const DEFAULT_BASELINE_PATH = '.mcpsec-baseline.json';
12
+
13
+ // ============================================================================
14
+ // Fingerprinting
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Generate a stable fingerprint for a finding.
19
+ * Same rule + same server + same config = same finding across scans.
20
+ * Evidence is excluded because it contains rotating/masked secrets.
21
+ */
22
+ export function findingFingerprint(finding: Finding): string {
23
+ return `${finding.id}:${finding.server ?? ''}:${finding.configFile ?? ''}`;
24
+ }
25
+
26
+ // ============================================================================
27
+ // Diff Engine
28
+ // ============================================================================
29
+
30
+ /**
31
+ * Diff current findings against a baseline.
32
+ * Returns new, fixed, and unchanged findings plus score delta.
33
+ */
34
+ export function diffFindings(
35
+ current: Finding[],
36
+ baseline: ScanReport
37
+ ): BaselineDiff {
38
+ const baselineFingerprints = new Map<string, Finding>();
39
+ for (const f of baseline.findings) {
40
+ baselineFingerprints.set(findingFingerprint(f), f);
41
+ }
42
+
43
+ const currentFingerprints = new Set<string>();
44
+ const newFindings: Finding[] = [];
45
+ const unchangedFindings: Finding[] = [];
46
+
47
+ for (const f of current) {
48
+ const fp = findingFingerprint(f);
49
+ currentFingerprints.add(fp);
50
+
51
+ if (baselineFingerprints.has(fp)) {
52
+ unchangedFindings.push(f);
53
+ } else {
54
+ newFindings.push(f);
55
+ }
56
+ }
57
+
58
+ const fixedFindings: Finding[] = [];
59
+ for (const [fp, f] of baselineFingerprints) {
60
+ if (!currentFingerprints.has(fp)) {
61
+ fixedFindings.push(f);
62
+ }
63
+ }
64
+
65
+ return {
66
+ newFindings,
67
+ fixedFindings,
68
+ unchangedFindings,
69
+ baselineTimestamp: baseline.timestamp,
70
+ baselineScore: baseline.score,
71
+ scoreDelta: 0, // caller sets this after report generation
72
+ };
73
+ }
74
+
75
+ // ============================================================================
76
+ // File I/O
77
+ // ============================================================================
78
+
79
+ /**
80
+ * Load and validate a baseline JSON file.
81
+ * Throws on missing file or invalid JSON.
82
+ */
83
+ export function loadBaseline(path: string): ScanReport {
84
+ let content: string;
85
+ try {
86
+ content = readFileSync(path, 'utf-8');
87
+ } catch {
88
+ throw new Error(`Baseline file not found: ${path}`);
89
+ }
90
+
91
+ let parsed: unknown;
92
+ try {
93
+ parsed = JSON.parse(content);
94
+ } catch {
95
+ throw new Error(`Invalid JSON in baseline file: ${path}`);
96
+ }
97
+
98
+ const report = parsed as ScanReport;
99
+ if (!report.timestamp || !report.findings || typeof report.score !== 'number') {
100
+ throw new Error(`Invalid baseline format: missing required fields (timestamp, findings, score)`);
101
+ }
102
+
103
+ return report;
104
+ }
105
+
106
+ /**
107
+ * Save a scan report as baseline JSON.
108
+ * Strips raw config data to avoid leaking secrets (same as printReportJSON).
109
+ */
110
+ export function saveBaseline(report: ScanReport, path: string): void {
111
+ const safeReport = {
112
+ ...report,
113
+ configFiles: report.configFiles.map((c) => ({
114
+ path: c.path,
115
+ client: c.client,
116
+ servers: Object.keys(c.servers),
117
+ })),
118
+ };
119
+
120
+ writeFileSync(path, JSON.stringify(safeReport, null, 2) + '\n');
121
+ }
122
+
123
+ // ============================================================================
124
+ // Console Output
125
+ // ============================================================================
126
+
127
+ const COLORS = {
128
+ reset: '\x1b[0m',
129
+ bold: '\x1b[1m',
130
+ dim: '\x1b[2m',
131
+ red: '\x1b[31m',
132
+ green: '\x1b[32m',
133
+ yellow: '\x1b[33m',
134
+ cyan: '\x1b[36m',
135
+ };
136
+
137
+ /**
138
+ * Print a baseline diff report to the console.
139
+ */
140
+ export function printBaselineDiff(diff: BaselineDiff, currentScore: number): void {
141
+ const delta = diff.scoreDelta;
142
+ const deltaStr = delta > 0 ? `+${delta}` : `${delta}`;
143
+ const deltaColor = delta > 0 ? COLORS.green : delta < 0 ? COLORS.red : COLORS.dim;
144
+
145
+ const dateStr = diff.baselineTimestamp.split('T')[0];
146
+
147
+ console.log();
148
+ console.log(` ${COLORS.bold}Baseline Comparison${COLORS.reset}`);
149
+ console.log(` ${COLORS.dim}${'─'.repeat(50)}${COLORS.reset}`);
150
+ console.log(` ${COLORS.dim}Baseline:${COLORS.reset} ${DEFAULT_BASELINE_PATH} (${dateStr})`);
151
+ console.log(` ${COLORS.dim}Score:${COLORS.reset} ${diff.baselineScore} → ${currentScore} ${deltaColor}(${deltaStr})${COLORS.reset}`);
152
+ console.log();
153
+
154
+ const parts: string[] = [];
155
+ if (diff.fixedFindings.length > 0) {
156
+ parts.push(`${COLORS.green}${diff.fixedFindings.length} fixed${COLORS.reset}`);
157
+ }
158
+ if (diff.newFindings.length > 0) {
159
+ parts.push(`${COLORS.red}${diff.newFindings.length} new${COLORS.reset}`);
160
+ }
161
+ if (diff.unchangedFindings.length > 0) {
162
+ parts.push(`${COLORS.dim}${diff.unchangedFindings.length} unchanged${COLORS.reset}`);
163
+ }
164
+ console.log(` ${parts.join(' ')}`);
165
+
166
+ if (diff.fixedFindings.length > 0) {
167
+ console.log();
168
+ console.log(` ${COLORS.green}${COLORS.bold}FIXED${COLORS.reset}`);
169
+ for (const f of diff.fixedFindings) {
170
+ const server = f.server ? ` (${f.server})` : '';
171
+ console.log(` ${COLORS.green}✓${COLORS.reset} ${COLORS.dim}${f.id}${COLORS.reset} ${f.title}${COLORS.dim}${server}${COLORS.reset}`);
172
+ }
173
+ }
174
+
175
+ if (diff.newFindings.length > 0) {
176
+ console.log();
177
+ console.log(` ${COLORS.red}${COLORS.bold}NEW${COLORS.reset}`);
178
+ for (const f of diff.newFindings) {
179
+ const server = f.server ? ` (${f.server})` : '';
180
+ console.log(` ${COLORS.red}✗${COLORS.reset} ${COLORS.dim}${f.id}${COLORS.reset} ${f.title}${COLORS.dim}${server}${COLORS.reset}`);
181
+ }
182
+ }
183
+
184
+ console.log();
185
+ console.log(` ${COLORS.dim}${'─'.repeat(50)}${COLORS.reset}`);
186
+ console.log();
187
+ }
@@ -64,6 +64,9 @@ export const liveScanner: Scanner = {
64
64
  const findings: Finding[] = [];
65
65
  let findingId = 0;
66
66
 
67
+ // Track tools across all servers for cross-server shadowing detection
68
+ const globalToolRegistry = new Map<string, { server: string; configFile: string; description: string }[]>();
69
+
67
70
  for (const config of configs) {
68
71
  for (const [serverName, serverConfig] of Object.entries(config.servers)) {
69
72
  // Connect to the server
@@ -100,14 +103,23 @@ export const liveScanner: Scanner = {
100
103
  `[${toolCount} tools, ${resourceCount} resources, ${promptCount} prompts]\n`
101
104
  );
102
105
 
103
- // Scan tools
106
+ // Scan tools and register them globally
104
107
  for (const tool of info.tools) {
105
108
  const toolFindings = scanTool(tool, serverName, config.path, findingId);
106
109
  findingId += toolFindings.length;
107
110
  findings.push(...toolFindings);
111
+
112
+ // Register tool for cross-server shadowing detection
113
+ const entry = { server: serverName, configFile: config.path, description: tool.description || '' };
114
+ const existing = globalToolRegistry.get(tool.name);
115
+ if (existing) {
116
+ existing.push(entry);
117
+ } else {
118
+ globalToolRegistry.set(tool.name, [entry]);
119
+ }
108
120
  }
109
121
 
110
- // Scan for tool shadowing across servers
122
+ // Check for duplicate tool names within this server
111
123
  const toolNames = info.tools.map((t) => t.name);
112
124
  const duplicateNames = findDuplicateToolNames(toolNames);
113
125
  for (const dup of duplicateNames) {
@@ -115,11 +127,11 @@ export const liveScanner: Scanner = {
115
127
  id: `LIVE-${++findingId}`,
116
128
  severity: 'high',
117
129
  category: 'tool-poisoning',
118
- title: `Duplicate tool name: "${dup}"`,
119
- description: `Server "${serverName}" exposes multiple tools named "${dup}". This may indicate tool shadowing.`,
130
+ title: `Duplicate tool name within server: "${dup}"`,
131
+ description: `Server "${serverName}" exposes multiple tools named "${dup}". This is unusual and may indicate a compromised server.`,
120
132
  server: serverName,
121
133
  configFile: config.path,
122
- remediation: 'Investigate why the server has duplicate tool names. This is unusual and may indicate a compromised server.',
134
+ remediation: 'Investigate why the server has duplicate tool names.',
123
135
  });
124
136
  }
125
137
 
@@ -146,6 +158,10 @@ export const liveScanner: Scanner = {
146
158
  }
147
159
  }
148
160
 
161
+ // Cross-server tool shadowing detection
162
+ const shadowFindings = detectCrossServerShadowing(globalToolRegistry, findingId);
163
+ findings.push(...shadowFindings);
164
+
149
165
  return findings;
150
166
  },
151
167
  };
@@ -355,6 +371,55 @@ function scanCapabilities(
355
371
  return findings;
356
372
  }
357
373
 
374
+ // ============================================================================
375
+ // Cross-Server Tool Shadowing Detection
376
+ // ============================================================================
377
+
378
+ type ToolRegistryEntry = { server: string; configFile: string; description: string };
379
+
380
+ export function detectCrossServerShadowing(
381
+ registry: Map<string, ToolRegistryEntry[]>,
382
+ startId: number
383
+ ): Finding[] {
384
+ const findings: Finding[] = [];
385
+ let findingId = startId;
386
+
387
+ for (const [toolName, entries] of registry) {
388
+ // Only flag if the same tool name appears on 2+ different servers
389
+ const uniqueServers = new Set(entries.map((e) => e.server));
390
+ if (uniqueServers.size < 2) continue;
391
+
392
+ // Determine severity based on description similarity
393
+ // If descriptions differ significantly, it's more likely a malicious shadow
394
+ const descriptions = entries.map((e) => e.description);
395
+ const allSame = descriptions.every((d) => d === descriptions[0]);
396
+ const severity = allSame ? 'medium' : 'high';
397
+
398
+ const serverList = entries.map((e) => `"${e.server}" (${e.configFile})`).join(', ');
399
+
400
+ findings.push({
401
+ id: `LIVE-${++findingId}`,
402
+ severity,
403
+ category: 'tool-shadowing',
404
+ title: `Cross-server tool shadowing: "${toolName}"`,
405
+ description:
406
+ `Tool "${toolName}" is exposed by ${uniqueServers.size} servers: ${serverList}. ` +
407
+ `When multiple servers provide the same tool name, the client may route calls to the wrong server, ` +
408
+ `allowing a malicious server to intercept operations meant for a legitimate one.`,
409
+ server: [...uniqueServers].join(', '),
410
+ configFile: entries[0].configFile,
411
+ evidence: allSame
412
+ ? `All servers use identical descriptions.`
413
+ : `Descriptions differ across servers: ${entries.map((e) => `[${e.server}]: "${truncate(e.description, 60)}"`).join(' vs ')}`,
414
+ remediation:
415
+ 'Rename conflicting tools or remove the untrusted server. ' +
416
+ 'If both servers are trusted, use tool name prefixing to disambiguate.',
417
+ });
418
+ }
419
+
420
+ return findings;
421
+ }
422
+
358
423
  // ============================================================================
359
424
  // Helpers
360
425
  // ============================================================================
@@ -6,7 +6,7 @@
6
6
 
7
7
  import type { ScanReport, ScanSummary, Finding, MCPConfigFile, ScanStatus } from '../lib/types';
8
8
 
9
- const VERSION = '0.1.0';
9
+ const VERSION = '0.2.0';
10
10
 
11
11
  // ============================================================================
12
12
  // Score Calculation
@@ -150,7 +150,7 @@ export function printReport(report: ScanReport): void {
150
150
 
151
151
  // Header
152
152
  console.log();
153
- console.log(`${COLORS.bold}${COLORS.cyan} Sentinel MCP Security Scanner v${VERSION}${COLORS.reset}`);
153
+ console.log(`${COLORS.bold}${COLORS.cyan} mcpsec - MCP Security Scanner v${VERSION}${COLORS.reset}`);
154
154
  console.log(`${COLORS.dim} ${'─'.repeat(50)}${COLORS.reset}`);
155
155
  console.log();
156
156
 
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Sentinel MCP - SARIF Output
3
+ *
4
+ * Converts scan reports to SARIF 2.1.0 format for GitHub Code Scanning
5
+ * and other SARIF-compatible tools.
6
+ *
7
+ * Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
8
+ */
9
+
10
+ import type { ScanReport, Finding, Severity, FindingCategory } from '../lib/types';
11
+
12
+ const SARIF_SCHEMA =
13
+ 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json';
14
+ const TOOL_INFO_URI = 'https://github.com/robdtaylor/sentinel-mcp';
15
+
16
+ // ============================================================================
17
+ // SARIF Types (subset of 2.1.0 spec)
18
+ // ============================================================================
19
+
20
+ interface SarifLog {
21
+ $schema: string;
22
+ version: '2.1.0';
23
+ runs: SarifRun[];
24
+ }
25
+
26
+ interface SarifRun {
27
+ tool: {
28
+ driver: {
29
+ name: string;
30
+ version: string;
31
+ informationUri: string;
32
+ rules: SarifRule[];
33
+ };
34
+ };
35
+ results: SarifResult[];
36
+ invocations: SarifInvocation[];
37
+ }
38
+
39
+ interface SarifRule {
40
+ id: string;
41
+ name: string;
42
+ shortDescription: { text: string };
43
+ defaultConfiguration: { level: SarifLevel };
44
+ properties: { tags: string[] };
45
+ }
46
+
47
+ interface SarifResult {
48
+ ruleId: string;
49
+ level: SarifLevel;
50
+ message: { text: string };
51
+ locations: SarifLocation[];
52
+ properties: Record<string, unknown>;
53
+ }
54
+
55
+ interface SarifLocation {
56
+ physicalLocation: {
57
+ artifactLocation: { uri: string };
58
+ };
59
+ }
60
+
61
+ interface SarifInvocation {
62
+ executionSuccessful: boolean;
63
+ properties: Record<string, unknown>;
64
+ }
65
+
66
+ type SarifLevel = 'error' | 'warning' | 'note' | 'none';
67
+
68
+ // ============================================================================
69
+ // Conversion
70
+ // ============================================================================
71
+
72
+ /**
73
+ * Map mcpsec severity to SARIF level
74
+ */
75
+ function toSarifLevel(severity: Severity): SarifLevel {
76
+ switch (severity) {
77
+ case 'critical':
78
+ case 'high':
79
+ return 'error';
80
+ case 'medium':
81
+ return 'warning';
82
+ case 'low':
83
+ case 'info':
84
+ return 'note';
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Map finding category to SARIF tags
90
+ */
91
+ function categoryTags(category: FindingCategory): string[] {
92
+ const tags = ['security'];
93
+ switch (category) {
94
+ case 'credential-exposure':
95
+ tags.push('credential-exposure', 'secrets');
96
+ break;
97
+ case 'prompt-injection':
98
+ case 'tool-poisoning':
99
+ case 'tool-shadowing':
100
+ tags.push('tool-poisoning', 'ai-safety');
101
+ break;
102
+ case 'ssrf':
103
+ tags.push('ssrf', 'network');
104
+ break;
105
+ case 'command-injection':
106
+ tags.push('command-injection', 'injection');
107
+ break;
108
+ case 'insecure-transport':
109
+ tags.push('insecure-transport', 'encryption');
110
+ break;
111
+ case 'excessive-permissions':
112
+ tags.push('excessive-permissions', 'misconfiguration');
113
+ break;
114
+ case 'supply-chain':
115
+ tags.push('supply-chain', 'dependency');
116
+ break;
117
+ case 'configuration':
118
+ tags.push('misconfiguration');
119
+ break;
120
+ }
121
+ return tags;
122
+ }
123
+
124
+ /**
125
+ * Generate a stable rule name from a finding ID (e.g. "CRED-001" -> "HardcodedCredential")
126
+ */
127
+ function ruleNameFromId(id: string): string {
128
+ // Strip numeric suffix and convert to PascalCase
129
+ return id
130
+ .replace(/-\d+$/, '')
131
+ .split('-')
132
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
133
+ .join('');
134
+ }
135
+
136
+ /**
137
+ * Extract unique rules from findings
138
+ */
139
+ function extractRules(findings: Finding[]): SarifRule[] {
140
+ const seen = new Map<string, SarifRule>();
141
+
142
+ for (const finding of findings) {
143
+ const ruleId = finding.id;
144
+ if (seen.has(ruleId)) continue;
145
+
146
+ seen.set(ruleId, {
147
+ id: ruleId,
148
+ name: ruleNameFromId(ruleId),
149
+ shortDescription: { text: finding.title },
150
+ defaultConfiguration: { level: toSarifLevel(finding.severity) },
151
+ properties: { tags: categoryTags(finding.category) },
152
+ });
153
+ }
154
+
155
+ return Array.from(seen.values());
156
+ }
157
+
158
+ /**
159
+ * Convert a finding to a SARIF result
160
+ */
161
+ function toSarifResult(finding: Finding): SarifResult {
162
+ const uri = finding.configFile || 'unknown';
163
+
164
+ const message = [finding.description];
165
+ if (finding.remediation) {
166
+ message.push(`Fix: ${finding.remediation}`);
167
+ }
168
+
169
+ const properties: Record<string, unknown> = {};
170
+ if (finding.server) properties.server = finding.server;
171
+ if (finding.evidence) properties.evidence = finding.evidence;
172
+ if (finding.severity) properties.severity = finding.severity;
173
+
174
+ return {
175
+ ruleId: finding.id,
176
+ level: toSarifLevel(finding.severity),
177
+ message: { text: message.join(' ') },
178
+ locations: [
179
+ {
180
+ physicalLocation: {
181
+ artifactLocation: { uri },
182
+ },
183
+ },
184
+ ],
185
+ properties,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Convert a ScanReport to SARIF 2.1.0 log
191
+ */
192
+ export function toSarif(report: ScanReport): SarifLog {
193
+ return {
194
+ $schema: SARIF_SCHEMA,
195
+ version: '2.1.0',
196
+ runs: [
197
+ {
198
+ tool: {
199
+ driver: {
200
+ name: 'mcpsec',
201
+ version: report.version,
202
+ informationUri: TOOL_INFO_URI,
203
+ rules: extractRules(report.findings),
204
+ },
205
+ },
206
+ results: report.findings.map(toSarifResult),
207
+ invocations: [
208
+ {
209
+ executionSuccessful: true,
210
+ properties: {
211
+ score: report.score,
212
+ status: report.status,
213
+ timestamp: report.timestamp,
214
+ },
215
+ },
216
+ ],
217
+ },
218
+ ],
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Print SARIF output to stdout
224
+ */
225
+ export function printReportSARIF(report: ScanReport): void {
226
+ console.log(JSON.stringify(toSarif(report), null, 2));
227
+ }