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 +248 -31
- package/package.json +1 -1
- package/src/cli/index.ts +91 -11
- package/src/lib/mcp-client.ts +7 -6
- package/src/lib/types.ts +14 -0
- package/src/scanner/baseline.ts +187 -0
- package/src/scanner/live-scanner.ts +70 -5
- package/src/scanner/report.ts +2 -2
- package/src/scanner/sarif.ts +227 -0
package/README.md
CHANGED
|
@@ -1,66 +1,252 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mcpsec
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
npx mcpsec scan
|
|
12
22
|
|
|
13
|
-
#
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
|
26
|
-
|
|
|
27
|
-
| SSRF via
|
|
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
|
-
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
-
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
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
|
-
|
|
133
|
+
mcpsec calculates a 0-100 security score:
|
|
47
134
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
31
|
-
--json
|
|
32
|
-
--
|
|
33
|
-
--
|
|
34
|
-
--
|
|
35
|
-
--
|
|
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.
|
|
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
|
-
//
|
|
139
|
-
if (
|
|
140
|
-
|
|
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
|
-
|
|
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
|
package/src/lib/mcp-client.ts
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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
|
// ============================================================================
|
package/src/scanner/report.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ScanReport, ScanSummary, Finding, MCPConfigFile, ScanStatus } from '../lib/types';
|
|
8
8
|
|
|
9
|
-
const VERSION = '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}
|
|
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
|
+
}
|