Syntho commited on
Commit
5aa5d28
·
verified ·
1 Parent(s): 5cb571d

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +6 -0
  2. .editorconfig +18 -0
  3. .pre-commit-config.yaml +30 -0
  4. CHANGELOG.md +28 -0
  5. CODE_OF_CONDUCT.md +43 -0
  6. CONTRIBUTING.md +58 -0
  7. Dockerfile +14 -0
  8. LICENSE +21 -0
  9. README.md +220 -14
  10. SECURITY.md +34 -0
  11. mcpscope/__init__.py +0 -0
  12. mcpscope/__pycache__/__init__.cpython-313.pyc +0 -0
  13. mcpscope/__pycache__/cli.cpython-313.pyc +0 -0
  14. mcpscope/__pycache__/config.cpython-313.pyc +0 -0
  15. mcpscope/__pycache__/scanner.cpython-313.pyc +0 -0
  16. mcpscope/__pycache__/webhooks.cpython-313.pyc +0 -0
  17. mcpscope/api/__init__.py +3 -0
  18. mcpscope/api/__pycache__/__init__.cpython-313.pyc +0 -0
  19. mcpscope/api/__pycache__/routes.cpython-313.pyc +0 -0
  20. mcpscope/api/__pycache__/server.cpython-313.pyc +0 -0
  21. mcpscope/api/routes.py +476 -0
  22. mcpscope/api/server.py +123 -0
  23. mcpscope/cli.py +438 -0
  24. mcpscope/config.py +44 -0
  25. mcpscope/ingest/__init__.py +11 -0
  26. mcpscope/ingest/__pycache__/__init__.cpython-313.pyc +0 -0
  27. mcpscope/ingest/__pycache__/base.cpython-313.pyc +0 -0
  28. mcpscope/ingest/__pycache__/cisco_a2a.cpython-313.pyc +0 -0
  29. mcpscope/ingest/__pycache__/cisco_mcp.cpython-313.pyc +0 -0
  30. mcpscope/ingest/__pycache__/mcpscan.cpython-313.pyc +0 -0
  31. mcpscope/ingest/__pycache__/mcpwn.cpython-313.pyc +0 -0
  32. mcpscope/ingest/__pycache__/sarif.cpython-313.pyc +0 -0
  33. mcpscope/ingest/base.py +69 -0
  34. mcpscope/ingest/cisco_a2a.py +72 -0
  35. mcpscope/ingest/cisco_mcp.py +128 -0
  36. mcpscope/ingest/mcpscan.py +76 -0
  37. mcpscope/ingest/mcpwn.py +70 -0
  38. mcpscope/ingest/sarif.py +103 -0
  39. mcpscope/main.py +4 -0
  40. mcpscope/models/__init__.py +4 -0
  41. mcpscope/models/__pycache__/__init__.cpython-313.pyc +0 -0
  42. mcpscope/models/__pycache__/finding.cpython-313.pyc +0 -0
  43. mcpscope/models/__pycache__/scan.cpython-313.pyc +0 -0
  44. mcpscope/models/__pycache__/security_event.cpython-313.pyc +0 -0
  45. mcpscope/models/finding.py +41 -0
  46. mcpscope/models/scan.py +27 -0
  47. mcpscope/models/security_event.py +26 -0
  48. mcpscope/scanner.py +80 -0
  49. mcpscope/storage/__init__.py +3 -0
  50. mcpscope/storage/__pycache__/__init__.cpython-313.pyc +0 -0
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ .git
4
+ tests
5
+ *.db
6
+ .env
.editorconfig ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ root = true
2
+
3
+ [*]
4
+ indent_style = space
5
+ indent_size = 4
6
+ end_of_line = lf
7
+ charset = utf-8
8
+ trim_trailing_whitespace = true
9
+ insert_final_newline = true
10
+
11
+ [*.{yml,yaml}]
12
+ indent_size = 2
13
+
14
+ [*.md]
15
+ trim_trailing_whitespace = false
16
+
17
+ [Dockerfile]
18
+ indent_size = 4
.pre-commit-config.yaml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.11.5
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
8
+
9
+ - repo: https://github.com/pre-commit/mirrors-mypy
10
+ rev: v1.15.0
11
+ hooks:
12
+ - id: mypy
13
+ args: [--ignore-missing-imports]
14
+ additional_dependencies:
15
+ - fastapi
16
+ - uvicorn
17
+ - httpx
18
+ - jinja2
19
+ - plotly
20
+ - pydantic
21
+ - rich
22
+ files: ^mcpscope/
23
+
24
+ - repo: https://github.com/pre-commit/pre-commit-hooks
25
+ rev: v5.0.0
26
+ hooks:
27
+ - id: trailing-whitespace
28
+ - id: end-of-file-fixer
29
+ - id: check-yaml
30
+ - id: check-added-large-files
CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MCPscop
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+ - Security events API and dashboard views
7
+ - JSON logging output
8
+ - Dashboard authentication
9
+ - MCPGuard event forwarder integration
10
+
11
+ ### Fixed
12
+ - API key validation in REST endpoints
13
+ - CORS headers for cross-origin dashboard access
14
+ - HTML sanitization in scan result display
15
+
16
+ ## [0.1.0] - 2025-08-01
17
+
18
+ ### Added
19
+ - Unified dashboard for MCP/A2A scanner results
20
+ - FastAPI REST API for scan ingestion and querying
21
+ - SQLite storage backends
22
+ - Plotly-based interactive charts and visualizations
23
+ - Support for Cisco MCP/A2A scanner output
24
+ - Support for mcp-scan output
25
+ - Support for MCPwn output
26
+ - SARIF format ingestion
27
+ - Rich CLI output for terminal usage
28
+ - Scan comparison and trend analysis
CODE_OF_CONDUCT.md ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment:
18
+
19
+ - Demonstrating empathy and kindness toward other people
20
+ - Being respectful of differing opinions, viewpoints, and experiences
21
+ - Giving and gracefully accepting constructive feedback
22
+ - Accepting responsibility and apologizing to those affected by our mistakes
23
+ - Focusing on what is best for the overall community
24
+
25
+ Examples of unacceptable behavior:
26
+
27
+ - The use of sexualized language or imagery, and sexual attention or advances
28
+ - Trolling, insulting or derogatory comments, and personal or political attacks
29
+ - Public or private harassment
30
+ - Publishing others' private information without explicit permission
31
+
32
+ ## Enforcement
33
+
34
+ Project maintainers are responsible for clarifying and enforcing our standards.
35
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
36
+ reported to the project team. All complaints will be reviewed and investigated
37
+ promptly and fairly.
38
+
39
+ ## Attribution
40
+
41
+ This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
42
+ version 2.1, available at
43
+ https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
CONTRIBUTING.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ Thanks for your interest in MCP-Scope!
4
+
5
+ ## Development Setup
6
+
7
+ ```bash
8
+ git clone https://github.com/Carlos-Projects/mcpscope
9
+ cd mcpscope
10
+ pip install -e ".[dev]"
11
+ ```
12
+
13
+ ## Running Tests
14
+
15
+ ```bash
16
+ pytest -v
17
+ ```
18
+
19
+ All tests must pass before submitting a PR.
20
+
21
+ ## Code Style
22
+
23
+ - Format: `ruff format .`
24
+ - Lint: `ruff check .`
25
+ - No commented-out code
26
+ - Type hints required for all public functions
27
+
28
+ ## Pull Request Process
29
+
30
+ 1. Create a feature branch from `main`
31
+ 2. Write tests for your changes
32
+ 3. Ensure all tests pass
33
+ 4. Update `CHANGELOG.md` if applicable
34
+ 5. Open a PR with a clear description
35
+
36
+ ## Commit Messages
37
+
38
+ Follow [Conventional Commits](https://www.conventionalcommits.org/):
39
+
40
+ ```
41
+ feat: add new scanner parser
42
+ fix: handle null severity in report
43
+ deps: bump fastapi to 0.136.0
44
+ docs: update README badges
45
+ ```
46
+
47
+ ## Adding a New Scanner
48
+
49
+ 1. Create `mcpscope/ingest/new_scanner.py` extending `BaseParser`
50
+ 2. Implement `parse()` and set `SCANNER_NAME`
51
+ 3. Add to `PARSERS` dict in `cli.py`
52
+ 4. Create test fixtures in `tests/fixtures/`
53
+ 5. Add tests in `tests/test_parsers.py`
54
+ 6. Update `README.md` supported scanners table
55
+
56
+ ## Reporting Issues
57
+
58
+ Open a [GitHub Issue](https://github.com/Carlos-Projects/mcpscope/issues/new/choose) using the appropriate template.
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY pyproject.toml README.md ./
6
+ COPY mcpscope/ mcpscope/
7
+
8
+ RUN pip install --no-cache-dir -e .
9
+
10
+ EXPOSE 8080
11
+
12
+ VOLUME /root/.mcpscope
13
+
14
+ CMD ["mcpscope", "serve", "--host", "0.0.0.0", "--port", "8080"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MCP-Scope
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,19 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: MCP-Scope
3
- emoji: 📊
4
- colorFrom: blue
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
8
- app_port: 8080
9
- fullWidth: true
10
- short_description: Unified security dashboard for MCP/A2A scanner results
11
- ---
12
 
13
- # MCP-Scope 📊
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- **Unified security dashboard** for MCP/A2A scanner results.
 
 
 
16
 
17
- Consumes output from MCPGuard, MCPwn, Cisco MCP Scanner, mcp-scan, SARIF.
18
 
19
- Visit the [GitHub repo](https://github.com/Carlos-Projects/mcpscope) for usage.
 
1
+ <div align="center">
2
+
3
+ # MCP-Scope
4
+
5
+ **Unified security dashboard for MCP/A2A scanner results**
6
+
7
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://python.org)
8
+ [![Tests](https://img.shields.io/github/actions/workflow/status/Carlos-Projects/mcpscope/scan.yml?label=tests)](https://github.com/Carlos-Projects/mcpscope/actions)
9
+ [![License](https://img.shields.io/github/license/Carlos-Projects/mcpscope)](LICENSE)
10
+ [![GitHub release](https://img.shields.io/github/v/release/Carlos-Projects/mcpscope)](https://github.com/Carlos-Projects/mcpscope/releases)
11
+ [![Code style](https://img.shields.io/badge/code%20style-ruff-261230)](https://docs.astral.sh/ruff)
12
+
13
  ---
 
 
 
 
 
 
 
 
 
 
14
 
15
+ </div>
16
+
17
+ Consumes output from multiple MCP/A2A security scanners (Cisco MCP Scanner, Cisco A2A Scanner, mcp-scan, MCPwn, SARIF) and presents a consolidated view of your security posture via a web dashboard, REST API, and CLI.
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ pip install -e .
23
+
24
+ # Generate demo data and explore
25
+ mcpscope seed
26
+ mcpscope serve
27
+
28
+ # Run a scan directly against a server
29
+ mcpscope scan mcp-scan https://mcp-server.example.com/mcp
30
+
31
+ # Import results from different scanners
32
+ mcpscope import cisco-mcp results.json
33
+ mcpscope import cisco-a2a results.json
34
+ mcpscope import mcpwn results.json
35
+ mcpscope import sarif report.sarif
36
+
37
+ # Export a compliance report
38
+ mcpscope report --format csv --output report.csv
39
+
40
+ # Backup/Restore
41
+ mcpscope backup backup.db
42
+ mcpscope restore backup.db
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ | Command | Description |
48
+ |---------|-------------|
49
+ | `serve` | Start the FastAPI web dashboard |
50
+ | `scan` | Run a scanner directly against a target |
51
+ | `import` | Import scanner JSON/SARIF results into SQLite |
52
+ | `report` | Export JSON, CSV, or PDF compliance report |
53
+ | `seed` | Generate demo scan data |
54
+ | `prune` | Delete scans older than N days |
55
+ | `backup` | Backup the SQLite database |
56
+ | `restore` | Restore the SQLite database from backup |
57
+ | `config` | View or set configuration options |
58
+
59
+ ## API Endpoints
60
+
61
+ | Endpoint | Description |
62
+ |----------|-------------|
63
+ | `GET /` | Dashboard UI with filters and tabs |
64
+ | `GET /findings/{id}` | Finding detail page |
65
+ | `GET /docs` | Swagger UI |
66
+ | `GET /api/health` | Health check |
67
+ | `GET /api/scans` | List scans (paginated) |
68
+ | `GET /api/scans/{id}` | Scan details with findings |
69
+ | `GET /api/scans/{a}/diff/{b}` | Compare two scans |
70
+ | `GET /api/findings` | Query findings (paginated, filterable) |
71
+ | `GET /api/findings/{id}` | Single finding |
72
+ | `GET /api/stats/summary` | Aggregated statistics |
73
+ | `GET /api/stats/top-tools` | Most vulnerable tools |
74
+ | `GET /api/stats/severity-trend` | Findings over time |
75
+ | `GET /api/stats/duplicates` | Deduplicated findings |
76
+ | `GET /api/report/json` | Full JSON report |
77
+ | `GET /api/report/csv` | CSV export |
78
+
79
+ ## API Usage Examples
80
+
81
+ ```bash
82
+ # Health check
83
+ curl http://localhost:8080/api/health
84
+
85
+ # List scans (paginated)
86
+ curl "http://localhost:8080/api/scans?page=1&page_size=10"
87
+
88
+ # Get scan with findings
89
+ curl http://localhost:8080/api/scans/scan-id-here
90
+
91
+ # Query findings with filters
92
+ curl "http://localhost:8080/api/findings?severity=critical&page=1"
93
+
94
+ # Search findings
95
+ curl "http://localhost:8080/api/findings?search=command"
96
+
97
+ # Compare two scans
98
+ curl "http://localhost:8080/api/scans/scan-a/diff/scan-b"
99
+
100
+ # Get stats summary
101
+ curl http://localhost:8080/api/stats/summary
102
+
103
+ # Get duplicates
104
+ curl http://localhost:8080/api/stats/duplicates
105
+
106
+ # Export as CSV
107
+ curl http://localhost:8080/api/report/csv -o report.csv
108
+
109
+ # Full JSON report
110
+ curl http://localhost:8080/api/report/json
111
+
112
+ # With API key authentication
113
+ curl -H "X-API-Key: your-key" http://localhost:8080/api/scans
114
+
115
+ # Swagger docs
116
+ open http://localhost:8080/docs
117
+ ```
118
+
119
+ ## Dashboard Features
120
+
121
+ - **Overview tab** — Severity pie chart, top tools bar chart, severity trend over time
122
+ - **Findings tab** — Filterable table with severity/scanner/tool/search, pagination, clickable rows for detail view
123
+ - **Duplicates tab** — Grouped findings by tool + title + severity across scans
124
+ - **Diff tab** — Side-by-side comparison between any two scans
125
+ - **Scans tab** — Historical scan table with severity counts
126
+ - **Auto-refresh** — Configurable auto-refresh interval
127
+ - **Finding detail page** — Full details including raw JSON data
128
+
129
+ ## Supported Scanners
130
+
131
+ | Scanner | CLI Name | Format |
132
+ |---------|----------|--------|
133
+ | Cisco MCP Scanner | `cisco-mcp` | `scan_results` with analyzer-grouped findings |
134
+ | Cisco A2A Scanner | `cisco-a2a` | `findings` with AI Security Taxonomy metadata |
135
+ | mcp-scan (Invariant Labs) | `mcp-scan` / `mcpscan` | `issues` array with severity codes |
136
+ | MCPwn (ressl) | `mcpwn` | Standard findings with MCP-XXX IDs |
137
+ | MCPwn (Teycir legacy) | `mcpwn` | Legacy test-based format |
138
+ | SARIF | `sarif` | Standard SARIF 2.1 format |
139
+
140
+ ## Screenshots
141
+
142
+ | Dashboard Overview | Findings Table |
143
+ |---|---|
144
+ | ![Overview](https://via.placeholder.com/600x300/1e293b/3b82f6?text=Severity+Pie+%2B+Trend+%2B+Top+Tools) | ![Findings](https://via.placeholder.com/600x300/1e293b/3b82f6?text=Filterable+Findings+Table) |
145
+ | **Scan Diff** | **Finding Detail** |
146
+ | ![Diff](https://via.placeholder.com/600x300/1e293b/3b82f6?text=Scan+Comparison) | ![Detail](https://via.placeholder.com/600x300/1e293b/3b82f6?text=Finding+Detail+View) |
147
+
148
+ _Run `mcpscope seed && mcpscope serve` and open http://localhost:8080 to see the live dashboard._
149
+
150
+ ## CI/CD Integration
151
+
152
+ Secure the API with an API key:
153
+ ```bash
154
+ mcpscope config set api_key "your-secret-key"
155
+ mcpscope serve
156
+ # All /api/* endpoints now require: X-API-Key: your-secret-key
157
+ ```
158
+
159
+ A [GitHub Actions workflow](.github/workflows/scan.yml) is included for automated scanning.
160
+
161
+ Slack alerts for critical/high findings:
162
+ ```bash
163
+ mcpscope config set slack_webhook_url "https://hooks.slack.com/services/..."
164
+ ```
165
+
166
+ Webhook URLs for custom integrations:
167
+ ```bash
168
+ mcpscope config set webhook_urls '["https://your-server.com/webhook"]'
169
+ ```
170
+
171
+ ## Configuration
172
+
173
+ Config file at `~/.mcpscope/config.json`:
174
+
175
+ ```bash
176
+ mcpscope config show
177
+ mcpscope config set port 9090
178
+ mcpscope config set auto_refresh_seconds 60
179
+ mcpscope config set max_upload_mb 100
180
+ ```
181
+
182
+ ## Docker
183
+
184
+ ```bash
185
+ docker build -t mcpscope .
186
+ docker run -p 8080:8080 -v mcpscope-data:/root/.mcpscope mcpscope
187
+ ```
188
+
189
+ ## Development
190
+
191
+ ```bash
192
+ pip install -e ".[dev]"
193
+ pytest
194
+ ```
195
+
196
+ ## Security Events (MCPGuard Integration)
197
+
198
+ MCP-Scope can receive real-time security events from MCPGuard:
199
+
200
+ ```bash
201
+ # Configure MCPGuard's config.yaml:
202
+ mcpscop_url: http://localhost:8000
203
+
204
+ # Events appear in the "Live Events" dashboard tab
205
+ ```
206
+
207
+ | Endpoint | Method | Description |
208
+ |----------|--------|-------------|
209
+ | `/api/events` | POST | Ingest a security event |
210
+ | `/api/events` | GET | List events (filters: severity, event_type) |
211
+ | `/api/events/stats` | GET | Event statistics |
212
+ | `/api/events` | DELETE | Clear all events |
213
+
214
+ ## Related Projects
215
+
216
+ MCPscop is part of the **Carlos-Projects** security ecosystem for AI agents:
217
 
218
+ - [**MCPGuard**](https://github.com/Carlos-Projects/mcpguard) — Runtime security proxy for MCP/A2A protocols with HTMX dashboard
219
+ - [**MCPwn**](https://github.com/Carlos-Projects/mcpwn) — Offensive security testing framework for MCP servers
220
+ - [**Palisade Scanner**](https://github.com/Carlos-Projects/palisade-scanner) — Scan web content for prompt injection and adversarial content
221
+ - [**AgentGate**](https://github.com/Carlos-Projects/agentgate) — Policy-based firewall and honeypot middleware for AI agents accessing websites
222
 
223
+ ## License
224
 
225
+ [MIT](LICENSE)
SECURITY.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ If you discover a security vulnerability in MCP-Scope, please report it privately.
6
+
7
+ **Do not create a public GitHub issue.**
8
+
9
+ Send details to: **carlos@aiagentobservatory.org**
10
+
11
+ Please include:
12
+
13
+ - Description of the vulnerability
14
+ - Steps to reproduce
15
+ - Potential impact
16
+ - Any suggested mitigation (optional)
17
+
18
+ We will acknowledge receipt within 48 hours and provide a timeline for a fix.
19
+
20
+ ## Scope
21
+
22
+ - The web dashboard (FastAPI)
23
+ - The REST API
24
+ - CLI commands that process external input
25
+ - Dependencies with known CVEs
26
+
27
+ ## Out of Scope
28
+
29
+ - The SQLite database file (local by default)
30
+ - Scanner tools that MCP-Scope wraps (report issues to their respective projects)
31
+
32
+ ## Preferred Languages
33
+
34
+ English or Spanish.
mcpscope/__init__.py ADDED
File without changes
mcpscope/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (176 Bytes). View file
 
mcpscope/__pycache__/cli.cpython-313.pyc ADDED
Binary file (23.4 kB). View file
 
mcpscope/__pycache__/config.cpython-313.pyc ADDED
Binary file (3.24 kB). View file
 
mcpscope/__pycache__/scanner.cpython-313.pyc ADDED
Binary file (3.67 kB). View file
 
mcpscope/__pycache__/webhooks.cpython-313.pyc ADDED
Binary file (5.37 kB). View file
 
mcpscope/api/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .server import create_app
2
+
3
+ __all__ = ["create_app"]
mcpscope/api/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (251 Bytes). View file
 
mcpscope/api/__pycache__/routes.cpython-313.pyc ADDED
Binary file (21.7 kB). View file
 
mcpscope/api/__pycache__/server.cpython-313.pyc ADDED
Binary file (6.81 kB). View file
 
mcpscope/api/routes.py ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import csv
3
+ import hmac
4
+ import io
5
+ import re
6
+ from datetime import datetime, timezone
7
+
8
+ from fastapi import APIRouter, Request, HTTPException, Query, Response
9
+ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
10
+
11
+ from mcpscope.models.finding import Severity
12
+ from mcpscope.models.security_event import SecurityEvent
13
+ from mcp_taxonomy import (
14
+ AttackCategory, Severity as TaxSeverity, DetectionMethod,
15
+ palisade_finding_to_taxonomy,
16
+ mcpguard_event_to_taxonomy,
17
+ mcpwn_finding_to_taxonomy,
18
+ agentgate_signal_to_taxonomy,
19
+ )
20
+ from mcp_taxonomy.core import CATEGORY_SEVERITY
21
+
22
+ router = APIRouter()
23
+ PAGE_SIZE = 50
24
+
25
+ _JS_UNSAFE_RE = re.compile(r"</[sS][cC][rR][iI][pP][tT]|<!\[CDATA\[|]]>")
26
+
27
+
28
+ def _sanitize_js(val: str, max_len: int = 200) -> str:
29
+ s = str(val)
30
+ s = _JS_UNSAFE_RE.sub("", s)
31
+ s = s.replace("\0", "")
32
+ return s[:max_len]
33
+
34
+
35
+ def _sanitize_tools(tools: list[dict]) -> list[dict]:
36
+ return [
37
+ {k: _sanitize_js(v) if isinstance(v, str) else v for k, v in t.items()}
38
+ for t in tools
39
+ ]
40
+
41
+
42
+ def _sanitize_trend(trend: list[dict]) -> list[dict]:
43
+ return [
44
+ {k: _sanitize_js(v) if isinstance(v, str) else v for k, v in t.items()}
45
+ for t in trend
46
+ ]
47
+
48
+
49
+ def get_store(request: Request):
50
+ return request.app.state.store
51
+
52
+
53
+ def get_templates(request: Request):
54
+ return request.app.state.templates
55
+
56
+
57
+ @router.get("/api/health")
58
+ def health():
59
+ return {"status": "ok"}
60
+
61
+
62
+ @router.get("/api/taxonomy")
63
+ def taxonomy_info():
64
+ """Return the canonical taxonomy definition."""
65
+ return {
66
+ "attack_categories": [c.value for c in AttackCategory],
67
+ "severities": [s.value for s in TaxSeverity],
68
+ "detection_methods": [m.value for m in DetectionMethod],
69
+ "category_severity": {c.value: s.value for c, s in CATEGORY_SEVERITY.items()},
70
+ }
71
+
72
+
73
+ @router.post("/api/taxonomy/normalize")
74
+ def normalize_taxonomy(body: dict):
75
+ """Normalize a finding/event from any project into the canonical taxonomy."""
76
+ source = body.get("source", "")
77
+ raw = body.get("raw", body)
78
+ if source == "palisade-scanner":
79
+ event = palisade_finding_to_taxonomy(raw)
80
+ elif source == "mcpguard":
81
+ event = mcpguard_event_to_taxonomy(raw)
82
+ elif source == "mcpwn":
83
+ event = mcpwn_finding_to_taxonomy(raw)
84
+ elif source == "agentgate":
85
+ event = agentgate_signal_to_taxonomy(
86
+ signal_type=raw.get("signal_type", ""),
87
+ weight=raw.get("weight", 0),
88
+ action=raw.get("action", ""),
89
+ path=raw.get("path", ""),
90
+ user_agent=raw.get("userAgent", raw.get("user_agent", "")),
91
+ score=raw.get("score", 0),
92
+ )
93
+ else:
94
+ return JSONResponse({"error": f"Unknown source: {source}"}, status_code=400)
95
+ return JSONResponse({
96
+ "source": event.source,
97
+ "attack_category": event.attack_category.value,
98
+ "severity": event.severity.value,
99
+ "confidence": event.confidence.value,
100
+ "detection_method": event.detection_method.value if isinstance(event.detection_method, DetectionMethod) else event.detection_method,
101
+ "title": event.title,
102
+ "description": event.description,
103
+ "recommendation": event.recommendation,
104
+ "target": event.target,
105
+ "snippet": event.snippet[:200] if event.snippet else "",
106
+ "blocked": event.blocked,
107
+ "risk_score": event.risk_score,
108
+ })
109
+
110
+
111
+ def _session_value(password: str, client_ip: str) -> str:
112
+ h = hmac.new(password.encode(), client_ip.encode(), "sha256")
113
+ return h.hexdigest()
114
+
115
+
116
+ @router.get("/login", response_class=HTMLResponse)
117
+ def login_page(request: Request):
118
+ templates = get_templates(request)
119
+ if not templates:
120
+ return HTMLResponse("<h1>Not found</h1>", status_code=500)
121
+ return templates.TemplateResponse(request, "login.html", {})
122
+
123
+
124
+ @router.post("/api/login")
125
+ def login(request: Request, response: Response, body: dict | None = None):
126
+ cfg = request.app.state
127
+ if not cfg.dashboard_password:
128
+ return JSONResponse({"error": "Dashboard auth not configured"}, status_code=403)
129
+ password = (body or {}).get("password", "")
130
+ if not hmac.compare_digest(password, cfg.dashboard_password):
131
+ return JSONResponse({"error": "Invalid password"}, status_code=401)
132
+ ip = request.client.host if request.client else ""
133
+ session = _session_value(cfg.dashboard_password, ip)
134
+ response.set_cookie(
135
+ key="mcpscope_session",
136
+ value=session,
137
+ max_age=86400,
138
+ httponly=True,
139
+ samesite="lax",
140
+ secure=False,
141
+ )
142
+ return {"status": "ok"}
143
+
144
+
145
+ @router.post("/api/logout")
146
+ def logout(response: Response):
147
+ response.delete_cookie("mcpscope_session")
148
+ return {"status": "ok"}
149
+
150
+
151
+ @router.get("/api/scans")
152
+ def list_scans(
153
+ request: Request,
154
+ page: int = Query(1, ge=1),
155
+ page_size: int = Query(PAGE_SIZE, ge=1, le=200),
156
+ ):
157
+ store = get_store(request)
158
+ scans, total = store.get_scans_paginated(page, page_size)
159
+ return {
160
+ "scans": [s.model_dump() for s in scans],
161
+ "total": total,
162
+ "page": page,
163
+ "page_size": page_size,
164
+ "pages": (total + page_size - 1) // page_size,
165
+ }
166
+
167
+
168
+ @router.get("/api/scans/{scan_id}")
169
+ def get_scan(
170
+ request: Request,
171
+ scan_id: str,
172
+ page: int = Query(1, ge=1),
173
+ page_size: int = Query(PAGE_SIZE, ge=1, le=200),
174
+ ):
175
+ store = get_store(request)
176
+ scan = store.get_scan(scan_id)
177
+ if not scan:
178
+ raise HTTPException(404, "Scan not found")
179
+ findings, total = store.get_findings(
180
+ scan_id=scan_id, page=page, page_size=page_size
181
+ )
182
+ return {
183
+ "scan": scan.model_dump(),
184
+ "findings": [f.model_dump() for f in findings],
185
+ "total": total,
186
+ "page": page,
187
+ "page_size": page_size,
188
+ }
189
+
190
+
191
+ @router.get("/api/scans/{scan_id}/diff/{other_id}")
192
+ def diff_scans(request: Request, scan_id: str, other_id: str):
193
+ store = get_store(request)
194
+ try:
195
+ result = store.diff_scans(scan_id, other_id)
196
+ except ValueError as e:
197
+ raise HTTPException(404, str(e))
198
+ return result
199
+
200
+
201
+ @router.get("/api/findings")
202
+ def list_findings(
203
+ request: Request,
204
+ scan_id: str | None = Query(None),
205
+ severity: str | None = Query(None),
206
+ tool_name: str | None = Query(None),
207
+ scanner: str | None = Query(None),
208
+ search: str | None = Query(None),
209
+ page: int = Query(1, ge=1),
210
+ page_size: int = Query(PAGE_SIZE, ge=1, le=200),
211
+ ):
212
+ store = get_store(request)
213
+ findings, total = store.get_findings(
214
+ scan_id=scan_id,
215
+ severity=severity,
216
+ tool_name=tool_name,
217
+ scanner=scanner,
218
+ search=search,
219
+ page=page,
220
+ page_size=page_size,
221
+ )
222
+ return {
223
+ "findings": [f.model_dump() for f in findings],
224
+ "total": total,
225
+ "page": page,
226
+ "page_size": page_size,
227
+ "pages": (total + page_size - 1) // page_size,
228
+ }
229
+
230
+
231
+ @router.get("/api/findings/{finding_id}")
232
+ def get_finding(request: Request, finding_id: str):
233
+ store = get_store(request)
234
+ finding = store.get_finding(finding_id)
235
+ if not finding:
236
+ raise HTTPException(404, "Finding not found")
237
+ scan = store.get_scan(finding.scan_id)
238
+ return {
239
+ "finding": finding.model_dump(),
240
+ "scan": scan.model_dump() if scan else None,
241
+ }
242
+
243
+
244
+ @router.get("/api/stats/top-tools")
245
+ def top_tools(request: Request):
246
+ store = get_store(request)
247
+ return {"tools": store.get_top_tools()}
248
+
249
+
250
+ @router.get("/api/stats/severity-trend")
251
+ def severity_trend(request: Request):
252
+ store = get_store(request)
253
+ return {"trend": store.get_severity_trend()}
254
+
255
+
256
+ @router.get("/api/stats/scanners")
257
+ def list_scanners(request: Request):
258
+ store = get_store(request)
259
+ return {"scanners": store.get_scanners()}
260
+
261
+
262
+ @router.get("/api/stats/tool-names")
263
+ def list_tool_names(request: Request):
264
+ store = get_store(request)
265
+ return {"tools": store.get_tool_names()}
266
+
267
+
268
+ @router.get("/api/stats/duplicates")
269
+ def list_duplicates(request: Request):
270
+ store = get_store(request)
271
+ return {"duplicates": store.get_duplicates()}
272
+
273
+
274
+ @router.get("/api/stats/summary")
275
+ def summary(request: Request):
276
+ store = get_store(request)
277
+ history = store.get_scan_history()
278
+ top = store.get_top_tools()
279
+ dups = store.get_duplicates()
280
+ return {
281
+ "total_scans": len(history.scans),
282
+ "total_findings": history.total_findings,
283
+ "critical": history.total_critical,
284
+ "high": history.total_high,
285
+ "medium": history.total_medium,
286
+ "low": history.total_low,
287
+ "info": history.total_info,
288
+ "top_tools": top,
289
+ "duplicates": dups,
290
+ "scans": [s.model_dump() for s in history.scans],
291
+ }
292
+
293
+
294
+ @router.get("/api/report/csv")
295
+ def report_csv(request: Request):
296
+ store = get_store(request)
297
+ findings, _ = store.get_findings(page_size=100000)
298
+ output = io.StringIO()
299
+ writer = csv.writer(output)
300
+ writer.writerow(
301
+ [
302
+ "id",
303
+ "scan_id",
304
+ "scanner",
305
+ "tool_name",
306
+ "severity",
307
+ "title",
308
+ "description",
309
+ "recommendation",
310
+ "cvss_score",
311
+ "cve_id",
312
+ "created_at",
313
+ ]
314
+ )
315
+ for f in findings:
316
+ sev = f.severity.value if isinstance(f.severity, Severity) else str(f.severity)
317
+ writer.writerow(
318
+ [
319
+ f.id,
320
+ f.scan_id,
321
+ f.scanner,
322
+ f.tool_name,
323
+ sev,
324
+ f.title,
325
+ f.description or "",
326
+ f.recommendation or "",
327
+ f.cvss_score or "",
328
+ f.cve_id or "",
329
+ f.created_at,
330
+ ]
331
+ )
332
+ return PlainTextResponse(
333
+ output.getvalue(),
334
+ media_type="text/csv",
335
+ headers={"Content-Disposition": "attachment; filename=mcpscope-report.csv"},
336
+ )
337
+
338
+
339
+ @router.get("/api/report/json")
340
+ def report_json(request: Request):
341
+ store = get_store(request)
342
+ history = store.get_scan_history()
343
+ top = store.get_top_tools()
344
+ trend = store.get_severity_trend()
345
+ dups = store.get_duplicates()
346
+ return {
347
+ "generated_at": datetime.now(timezone.utc).isoformat(),
348
+ "summary": {
349
+ "total_scans": len(history.scans),
350
+ "total_findings": history.total_findings,
351
+ "critical": history.total_critical,
352
+ "high": history.total_high,
353
+ "medium": history.total_medium,
354
+ "low": history.total_low,
355
+ "info": history.total_info,
356
+ },
357
+ "top_tools": top,
358
+ "duplicates": dups,
359
+ "severity_trend": trend,
360
+ "scans": [s.model_dump() for s in history.scans],
361
+ }
362
+
363
+
364
+ @router.post("/api/events")
365
+ async def ingest_event(request: Request):
366
+ store = get_store(request)
367
+ body = await request.json()
368
+ event = SecurityEvent(**body)
369
+ saved = store.save_event(event)
370
+ return {"status": "ok", "id": saved.id}
371
+
372
+
373
+ @router.get("/api/events")
374
+ def list_events(
375
+ request: Request,
376
+ limit: int = Query(50, ge=1, le=500),
377
+ offset: int = Query(0, ge=0),
378
+ severity: str | None = Query(None),
379
+ event_type: str | None = Query(None),
380
+ ):
381
+ store = get_store(request)
382
+ events, total = store.get_events(
383
+ limit=limit,
384
+ offset=offset,
385
+ severity=severity,
386
+ event_type=event_type,
387
+ )
388
+ return {
389
+ "events": [e.model_dump() for e in events],
390
+ "total": total,
391
+ "limit": limit,
392
+ "offset": offset,
393
+ }
394
+
395
+
396
+ @router.get("/api/events/stats")
397
+ def event_stats(request: Request):
398
+ store = get_store(request)
399
+ return store.get_event_stats()
400
+
401
+
402
+ @router.delete("/api/events")
403
+ def clear_events(request: Request):
404
+ store = get_store(request)
405
+ store.clear_events()
406
+ return {"status": "ok"}
407
+
408
+
409
+ @router.get("/", response_class=HTMLResponse)
410
+ def dashboard(request: Request):
411
+ store = get_store(request)
412
+ history = store.get_scan_history()
413
+ top_tools_data = store.get_top_tools()
414
+ trend_data = store.get_severity_trend()
415
+ scanners = store.get_scanners()
416
+ tool_names = store.get_tool_names()
417
+ dups = store.get_duplicates()
418
+
419
+ templates = get_templates(request)
420
+ if not templates:
421
+ return HTMLResponse("<h1>Dashboard templates not found</h1>", status_code=500)
422
+
423
+ return templates.TemplateResponse(
424
+ request,
425
+ "dashboard.html",
426
+ {
427
+ "total_scans": len(history.scans),
428
+ "total_findings": history.total_findings,
429
+ "critical": history.total_critical,
430
+ "high": history.total_high,
431
+ "medium": history.total_medium,
432
+ "low": history.total_low,
433
+ "info": history.total_info,
434
+ "top_tools": _sanitize_tools(top_tools_data),
435
+ "trend_data": _sanitize_trend(trend_data),
436
+ "scans": history.scans,
437
+ "scanners": scanners,
438
+ "tool_names": tool_names,
439
+ "duplicates": dups,
440
+ "auto_refresh": getattr(request.app.state, "auto_refresh", 30),
441
+ },
442
+ )
443
+
444
+
445
+ @router.get("/findings/{finding_id}", response_class=HTMLResponse)
446
+ def finding_detail(request: Request, finding_id: str):
447
+ store = get_store(request)
448
+ finding = store.get_finding(finding_id)
449
+ if not finding:
450
+ return HTMLResponse("<h1>Finding not found</h1>", status_code=404)
451
+ scan = store.get_scan(finding.scan_id)
452
+ templates = get_templates(request)
453
+ if not templates:
454
+ return HTMLResponse("<h1>Not found</h1>", status_code=500)
455
+ return templates.TemplateResponse(
456
+ request,
457
+ "dashboard.html",
458
+ {
459
+ "total_scans": 0,
460
+ "total_findings": 0,
461
+ "critical": 0,
462
+ "high": 0,
463
+ "medium": 0,
464
+ "low": 0,
465
+ "info": 0,
466
+ "top_tools": [],
467
+ "trend_data": [],
468
+ "scans": [],
469
+ "scanners": [],
470
+ "tool_names": [],
471
+ "duplicates": [],
472
+ "detail_finding": finding,
473
+ "detail_scan": scan,
474
+ "auto_refresh": 0,
475
+ },
476
+ )
mcpscope/api/server.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import hmac
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import JSONResponse, RedirectResponse
10
+
11
+ from mcpscope.storage.store import Store
12
+ from mcpscope.config import Settings
13
+
14
+
15
+ API_KEY_HEADER = "X-API-Key"
16
+ DASHBOARD_COOKIE = "mcpscope_session"
17
+
18
+
19
+ class JSONLogFormatter(logging.Formatter):
20
+ def format(self, record: logging.LogRecord) -> str:
21
+ return json.dumps(
22
+ {
23
+ "time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
24
+ "level": record.levelname,
25
+ "logger": record.name,
26
+ "message": record.getMessage(),
27
+ "module": record.module,
28
+ "function": record.funcName,
29
+ }
30
+ )
31
+
32
+
33
+ def setup_logging(settings: Settings) -> None:
34
+ root = logging.getLogger("mcpscope")
35
+ root.setLevel(getattr(logging, settings.log_level.upper(), logging.INFO))
36
+ handler = logging.StreamHandler()
37
+ if settings.log_json:
38
+ handler.setFormatter(JSONLogFormatter())
39
+ else:
40
+ handler.setFormatter(
41
+ logging.Formatter(
42
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
43
+ datefmt="%Y-%m-%d %H:%M:%S",
44
+ )
45
+ )
46
+ root.handlers.clear()
47
+ root.addHandler(handler)
48
+
49
+
50
+ def _verify_session(request: Request, password: str) -> bool:
51
+ cookie = request.cookies.get(DASHBOARD_COOKIE, "")
52
+ if not cookie:
53
+ return False
54
+ expected = _session_value(password, request.client.host if request.client else "")
55
+ return hmac.compare_digest(cookie, expected)
56
+
57
+
58
+ def _session_value(password: str, client_ip: str) -> str:
59
+ h = hmac.new(password.encode(), client_ip.encode(), "sha256")
60
+ return h.hexdigest()
61
+
62
+
63
+ def create_app(store: Store | None = None, settings: Settings | None = None) -> FastAPI:
64
+ cfg = settings or Settings.load()
65
+ setup_logging(cfg)
66
+ logger = logging.getLogger("mcpscope.api")
67
+
68
+ app = FastAPI(
69
+ title="MCP-Scope API",
70
+ description="Unified security dashboard for MCP/A2A scanner results",
71
+ version="0.1.0",
72
+ docs_url="/docs",
73
+ redoc_url="/redoc",
74
+ openapi_url="/api/openapi.json",
75
+ )
76
+
77
+ app.add_middleware(
78
+ CORSMiddleware,
79
+ allow_origins=["*"],
80
+ allow_credentials=False,
81
+ allow_methods=["*"],
82
+ allow_headers=["*", API_KEY_HEADER],
83
+ )
84
+
85
+ app.state.store = store or Store(db_path=cfg.db_path)
86
+ app.state.auto_refresh = cfg.auto_refresh_seconds
87
+ app.state.api_key = cfg.api_key
88
+ app.state.dashboard_password = cfg.dashboard_password
89
+
90
+ @app.middleware("http")
91
+ async def auth_middleware(request: Request, call_next):
92
+ path = request.url.path
93
+
94
+ if path in ("/health", "/api/health", "/api/login", "/api/logout"):
95
+ return await call_next(request)
96
+
97
+ if cfg.api_key and path.startswith("/api/") and request.method != "OPTIONS":
98
+ req_key = request.headers.get(API_KEY_HEADER)
99
+ if not hmac.compare_digest(req_key or "", cfg.api_key):
100
+ logger.warning("API key rejected for %s", path)
101
+ return JSONResponse(
102
+ status_code=401, content={"error": "Invalid or missing API key"}
103
+ )
104
+
105
+ if cfg.dashboard_password and not path.startswith("/api/"):
106
+ if not _verify_session(request, cfg.dashboard_password):
107
+ if path != "/login":
108
+ return RedirectResponse(url="/login")
109
+
110
+ response = await call_next(request)
111
+ return response
112
+
113
+ templates_dir = Path(__file__).resolve().parent.parent / "templates"
114
+ if templates_dir.exists():
115
+ from fastapi.templating import Jinja2Templates
116
+
117
+ app.state.templates = Jinja2Templates(directory=str(templates_dir))
118
+
119
+ from mcpscope.api.routes import router
120
+
121
+ app.include_router(router)
122
+
123
+ return app
mcpscope/cli.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import sys
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.progress import Progress
9
+
10
+ from mcpscope.api.server import create_app
11
+ from mcpscope.storage.store import Store
12
+ from mcpscope.ingest.cisco_mcp import CiscoMCPParser
13
+ from mcpscope.ingest.cisco_a2a import CiscoA2AParser
14
+ from mcpscope.ingest.mcpscan import MCPScanParser
15
+ from mcpscope.ingest.mcpwn import MCPwnParser
16
+ from mcpscope.ingest.sarif import SarifParser
17
+ from mcpscope.ingest.base import ParseError
18
+ from mcpscope.models.scan import ScanRun
19
+ from mcpscope.config import Settings
20
+ from mcpscope.scanner import ScannerRunner
21
+
22
+ console = Console()
23
+ PARSERS = {
24
+ "cisco-mcp": CiscoMCPParser(),
25
+ "cisco-a2a": CiscoA2AParser(),
26
+ "mcp-scan": MCPScanParser(),
27
+ "mcpscan": MCPScanParser(),
28
+ "mcpwn": MCPwnParser(),
29
+ "sarif": SarifParser(),
30
+ }
31
+
32
+
33
+ def cmd_import(args: list[str]):
34
+ if len(args) < 2:
35
+ console.print("[red]Usage: mcpscope import <scanner> <file> [target][/red]")
36
+ sys.exit(1)
37
+
38
+ scanner_name = args[0].lower()
39
+ file_path = Path(args[1])
40
+ target = args[2] if len(args) > 2 else None
41
+
42
+ parser = PARSERS.get(scanner_name)
43
+ if not parser:
44
+ console.print(
45
+ f"[red]Unknown scanner: {scanner_name}. Available: {', '.join(PARSERS)}[/red]"
46
+ )
47
+ sys.exit(1)
48
+
49
+ if not file_path.exists():
50
+ console.print(f"[red]File not found: {file_path}[/red]")
51
+ sys.exit(1)
52
+
53
+ store = Store()
54
+
55
+ try:
56
+ with Progress() as progress:
57
+ task = progress.add_task(f"Parsing {file_path.name}...", total=None)
58
+ findings = parser.parse_file(file_path)
59
+ progress.update(task, completed=True)
60
+ except ParseError as e:
61
+ console.print(f"[red]Parse error: {e}[/red]")
62
+ if e.details:
63
+ console.print(f"[dim]{e.details}[/dim]")
64
+ sys.exit(1)
65
+
66
+ scanner_label = findings[0].scanner
67
+ scan = ScanRun(
68
+ id=file_path.stem,
69
+ scanner=scanner_label,
70
+ target=target or scanner_label,
71
+ raw_file=str(file_path.resolve()),
72
+ )
73
+ saved = store.save_scan(scan, findings)
74
+
75
+ table = Table(title=f"Imported: {file_path.name}")
76
+ table.add_column("Metric", style="cyan")
77
+ table.add_column("Value", style="green")
78
+ table.add_row("Scanner", scanner_label)
79
+ table.add_row("Findings", str(len(findings)))
80
+ table.add_row("Critical", str(saved.critical_count))
81
+ table.add_row("High", str(saved.high_count))
82
+ table.add_row("Medium", str(saved.medium_count))
83
+ table.add_row("Low", str(saved.low_count))
84
+ table.add_row("Info", str(saved.info_count))
85
+ table.add_row("Scan ID", saved.id)
86
+ console.print(table)
87
+
88
+
89
+ def cmd_serve(args: list[str]):
90
+ settings = Settings.load()
91
+ for i, a in enumerate(args):
92
+ if a == "--port" and i + 1 < len(args):
93
+ settings.port = int(args[i + 1])
94
+ elif a == "--host" and i + 1 < len(args):
95
+ settings.host = args[i + 1]
96
+ elif a == "--config" and i + 1 < len(args):
97
+ settings = Settings.load(args[i + 1])
98
+
99
+ import uvicorn
100
+
101
+ store = Store(db_path=settings.db_path)
102
+ app = create_app(store)
103
+ app.state.auto_refresh = settings.auto_refresh_seconds
104
+ console.print(
105
+ f"[green]MCP-Scope dashboard running at http://{settings.host}:{settings.port}[/green]"
106
+ )
107
+ console.print(
108
+ f"[dim]DB: {settings.db_path} | Auto-refresh: {settings.auto_refresh_seconds}s[/dim]"
109
+ )
110
+ uvicorn.run(
111
+ app, host=settings.host, port=settings.port, log_level=settings.log_level
112
+ )
113
+
114
+
115
+ def cmd_report(args: list[str]):
116
+ fmt = "json"
117
+ output = "mcpscope-report.json"
118
+ for i, a in enumerate(args):
119
+ if a == "--format" and i + 1 < len(args):
120
+ fmt = args[i + 1].lower()
121
+ elif a == "--output" and i + 1 < len(args):
122
+ output = args[i + 1]
123
+
124
+ store = Store()
125
+ history = store.get_scan_history()
126
+ top = store.get_top_tools()
127
+
128
+ report_data = {
129
+ "generated_at": __import__("datetime").datetime.utcnow().isoformat(),
130
+ "summary": {
131
+ "total_scans": len(history.scans),
132
+ "total_findings": history.total_findings,
133
+ "critical": history.total_critical,
134
+ "high": history.total_high,
135
+ "medium": history.total_medium,
136
+ "low": history.total_low,
137
+ "info": history.total_info,
138
+ },
139
+ "scans": [s.model_dump() for s in history.scans],
140
+ "top_tools": top,
141
+ }
142
+
143
+ out_path = Path(output)
144
+ if fmt == "json":
145
+ with open(out_path, "w") as f:
146
+ json.dump(report_data, f, indent=2)
147
+ console.print(f"[green]Report written to {out_path.resolve()}[/green]")
148
+
149
+ elif fmt == "csv":
150
+ import csv
151
+
152
+ store = Store()
153
+ findings, _ = store.get_findings(page_size=100000)
154
+ with open(out_path, "w", newline="") as f:
155
+ writer = csv.writer(f)
156
+ writer.writerow(
157
+ [
158
+ "id",
159
+ "scan_id",
160
+ "scanner",
161
+ "tool_name",
162
+ "severity",
163
+ "title",
164
+ "description",
165
+ "recommendation",
166
+ "created_at",
167
+ ]
168
+ )
169
+ for finding in findings:
170
+ sev = (
171
+ finding.severity.value
172
+ if hasattr(finding.severity, "value")
173
+ else finding.severity
174
+ )
175
+ writer.writerow(
176
+ [
177
+ finding.id,
178
+ finding.scan_id,
179
+ finding.scanner,
180
+ finding.tool_name,
181
+ sev,
182
+ finding.title,
183
+ (finding.description or "")[:200],
184
+ (finding.recommendation or "")[:200],
185
+ finding.created_at,
186
+ ]
187
+ )
188
+ console.print(f"[green]CSV report written to {out_path.resolve()}[/green]")
189
+
190
+ elif fmt == "pdf":
191
+ try:
192
+ from weasyprint import HTML
193
+ except ImportError:
194
+ console.print(
195
+ "[red]PDF generation requires: pip install mcpscope[pdf][/red]"
196
+ )
197
+ sys.exit(1)
198
+
199
+ html_content = _render_report_html(report_data)
200
+ HTML(string=html_content).write_pdf(str(out_path))
201
+ console.print(f"[green]PDF report written to {out_path.resolve()}[/green]")
202
+
203
+ else:
204
+ console.print(f"[red]Unsupported format: {fmt}. Use json, csv, or pdf.[/red]")
205
+ sys.exit(1)
206
+
207
+
208
+ def cmd_seed(args: list[str]):
209
+ store = Store()
210
+ store.seed_demo_data()
211
+ history = store.get_scan_history()
212
+ console.print(
213
+ f"[green]Seeded {len(history.scans)} demo scans with {history.total_findings} findings[/green]"
214
+ )
215
+
216
+
217
+ def cmd_backup(args: list[str]):
218
+ output = (
219
+ args[0]
220
+ if args
221
+ else f"mcpscope-backup-{__import__('datetime').datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
222
+ )
223
+ store = Store()
224
+ store.backup(output)
225
+ console.print(f"[green]Backup saved to {Path(output).resolve()}[/green]")
226
+
227
+
228
+ def cmd_restore(args: list[str]):
229
+ if not args:
230
+ console.print("[red]Usage: mcpscope restore <backup-file>[/red]")
231
+ sys.exit(1)
232
+ path = Path(args[0])
233
+ if not path.exists():
234
+ console.print(f"[red]Backup not found: {path}[/red]")
235
+ sys.exit(1)
236
+ store = Store()
237
+ store.restore(path)
238
+ history = store.get_scan_history()
239
+ console.print(
240
+ f"[green]Restored {len(history.scans)} scans, {history.total_findings} findings[/green]"
241
+ )
242
+
243
+
244
+ def cmd_config(args: list[str]):
245
+ settings = Settings.load()
246
+ if not args:
247
+ console.print("[bold]Current configuration:[/bold]")
248
+ for k, v in settings.as_dict().items():
249
+ console.print(f" {k}: {v}")
250
+ console.print(f"\nConfig file: {Path.home() / '.mcpscope' / 'config.json'}")
251
+ return
252
+
253
+ action = args[0]
254
+ if action == "show":
255
+ for k, v in settings.as_dict().items():
256
+ console.print(f" {k}: {v}")
257
+ elif action == "set" and len(args) >= 3:
258
+ key, value = args[1], args[2]
259
+ if hasattr(settings, key):
260
+ current = getattr(settings, key)
261
+ if value.lower() in ("none", "null", ""):
262
+ setattr(settings, key, None)
263
+ elif isinstance(current, bool):
264
+ setattr(settings, key, value.lower() in ("true", "1", "yes"))
265
+ elif isinstance(current, int):
266
+ setattr(settings, key, int(value))
267
+ elif isinstance(current, list):
268
+ import json
269
+
270
+ try:
271
+ setattr(settings, key, json.loads(value))
272
+ except (json.JSONDecodeError, TypeError):
273
+ console.print(f"[red]Expected JSON array for {key}[/red]")
274
+ sys.exit(1)
275
+ else:
276
+ setattr(settings, key, value)
277
+ settings.save()
278
+ console.print(f"[green]{key} set to {value}[/green]")
279
+ else:
280
+ console.print(f"[red]Unknown config key: {key}[/red]")
281
+ sys.exit(1)
282
+ else:
283
+ console.print("Usage: mcpscope config [show|set <key> <value>]")
284
+
285
+
286
+ def cmd_scan(args: list[str]):
287
+ if len(args) < 2:
288
+ console.print("[red]Usage: mcpscope scan <scanner> <target>[/red]")
289
+ console.print(f"Scanners: {', '.join(ScannerRunner.PARSERS)}")
290
+ sys.exit(1)
291
+
292
+ scanner_name = args[0].lower()
293
+ target = args[1]
294
+ runner = ScannerRunner()
295
+ store = Store()
296
+
297
+ try:
298
+ with Progress() as progress:
299
+ task = progress.add_task(
300
+ f"Running {scanner_name} against {target}...", total=None
301
+ )
302
+ scan = runner.scan(scanner_name, target, store=store)
303
+ progress.update(task, completed=True)
304
+ except (ValueError, RuntimeError) as e:
305
+ console.print(f"[red]{e}[/red]")
306
+ sys.exit(1)
307
+
308
+ table = Table(title=f"Scan: {scan.id[:12]}")
309
+ table.add_column("Metric", style="cyan")
310
+ table.add_column("Value", style="green")
311
+ table.add_row("Scanner", scan.scanner)
312
+ table.add_row("Target", target)
313
+ table.add_row("Findings", str(scan.findings_count))
314
+ table.add_row("Critical", str(scan.critical_count))
315
+ table.add_row("High", str(scan.high_count))
316
+ table.add_row("Medium", str(scan.medium_count))
317
+ table.add_row("Low", str(scan.low_count))
318
+ table.add_row("Info", str(scan.info_count))
319
+ table.add_row("Scan ID", scan.id)
320
+ console.print(table)
321
+
322
+
323
+ def cmd_prune(args: list[str]):
324
+ keep_days = 30
325
+ for i, a in enumerate(args):
326
+ if a == "--keep" and i + 1 < len(args):
327
+ keep_days = int(args[i + 1])
328
+
329
+ store = Store()
330
+ count = store.prune(keep_days)
331
+ if count:
332
+ console.print(
333
+ f"[green]Pruned {count} scans older than {keep_days} days[/green]"
334
+ )
335
+ else:
336
+ console.print(f"[yellow]No scans older than {keep_days} days to prune[/yellow]")
337
+
338
+
339
+ def _render_report_html(data: dict) -> str:
340
+ summary = data["summary"]
341
+ scans_rows = ""
342
+ for s in data["scans"]:
343
+ scans_rows += f"""<tr>
344
+ <td>{s["id"]}</td>
345
+ <td>{s["scanner"]}</td>
346
+ <td>{s["findings_count"]}</td>
347
+ <td>{s["critical_count"]}</td>
348
+ <td>{s["high_count"]}</td>
349
+ <td>{s["medium_count"]}</td>
350
+ <td>{s["low_count"]}</td>
351
+ <td>{s["info_count"]}</td>
352
+ <td>{s["created_at"]}</td>
353
+ </tr>"""
354
+
355
+ tools_rows = ""
356
+ for t in data.get("top_tools", []):
357
+ tools_rows += f"""<tr>
358
+ <td>{t["tool_name"]}</td>
359
+ <td>{t["total"]}</td>
360
+ <td>{t["critical_high"]}</td>
361
+ </tr>"""
362
+
363
+ return f"""<!DOCTYPE html>
364
+ <html><head><meta charset="utf-8"><title>MCP-Scope Report</title>
365
+ <style>
366
+ body {{ font-family: Helvetica, Arial, sans-serif; margin: 40px; }}
367
+ h1 {{ color: #1e293b; }}
368
+ h2 {{ color: #334155; margin-top: 30px; }}
369
+ table {{ width: 100%; border-collapse: collapse; margin: 10px 0 30px 0; }}
370
+ th, td {{ border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; }}
371
+ th {{ background: #f1f5f9; }}
372
+ .summary-grid {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; }}
373
+ .card {{ border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; }}
374
+ .card h3 {{ margin: 0 0 5px 0; color: #64748b; font-size: 14px; }}
375
+ .card .value {{ font-size: 28px; font-weight: bold; color: #0f172a; }}
376
+ </style></head><body>
377
+ <h1>MCP-Scope Security Report</h1>
378
+ <p>Generated: {data["generated_at"]}</p>
379
+ <h2>Summary</h2>
380
+ <div class="summary-grid">
381
+ <div class="card"><h3>Total Scans</h3><div class="value">{summary["total_scans"]}</div></div>
382
+ <div class="card"><h3>Total Findings</h3><div class="value">{summary["total_findings"]}</div></div>
383
+ <div class="card"><h3>Critical</h3><div class="value">{summary["critical"]}</div></div>
384
+ <div class="card"><h3>High</h3><div class="value">{summary["high"]}</div></div>
385
+ <div class="card"><h3>Medium</h3><div class="value">{summary["medium"]}</div></div>
386
+ <div class="card"><h3>Low</h3><div class="value">{summary["low"]}</div></div>
387
+ </div>
388
+ <h2>Scan History</h2>
389
+ <table><thead><tr><th>ID</th><th>Scanner</th><th>Total</th><th>Critical</th><th>High</th><th>Medium</th><th>Low</th><th>Info</th><th>Date</th></tr></thead>
390
+ <tbody>{scans_rows}</tbody></table>
391
+ <h2>Top Vulnerable Tools</h2>
392
+ <table><thead><tr><th>Tool</th><th>Total Findings</th><th>Critical/High</th></tr></thead>
393
+ <tbody>{tools_rows}</tbody></table>
394
+ </body></html>"""
395
+
396
+
397
+ def cli():
398
+ if len(sys.argv) < 2:
399
+ console.print("[bold]MCP-Scope[/bold] - Unified Security Dashboard")
400
+ console.print("Usage:")
401
+ console.print(" mcpscope serve [--port PORT] [--host HOST] [--config FILE]")
402
+ console.print(" mcpscope scan <scanner> <target>")
403
+ console.print(" mcpscope import <scanner> <file> [target]")
404
+ console.print(" mcpscope report [--format json|csv|pdf] [--output FILE]")
405
+ console.print(" mcpscope seed")
406
+ console.print(" mcpscope prune [--keep DAYS]")
407
+ console.print(" mcpscope backup [file]")
408
+ console.print(" mcpscope restore <file>")
409
+ console.print(" mcpscope config [show|set <key> <value>]")
410
+ console.print("\nScanners (import): " + ", ".join(PARSERS))
411
+ console.print("Scanners (scan): " + ", ".join(ScannerRunner.PARSERS))
412
+ return
413
+
414
+ command = sys.argv[1]
415
+ args = sys.argv[2:]
416
+
417
+ commands = {
418
+ "serve": cmd_serve,
419
+ "scan": cmd_scan,
420
+ "import": cmd_import,
421
+ "report": cmd_report,
422
+ "seed": cmd_seed,
423
+ "prune": cmd_prune,
424
+ "backup": cmd_backup,
425
+ "restore": cmd_restore,
426
+ "config": cmd_config,
427
+ }
428
+
429
+ fn = commands.get(command)
430
+ if fn:
431
+ fn(args)
432
+ else:
433
+ console.print(f"[red]Unknown command: {command}[/red]")
434
+ sys.exit(1)
435
+
436
+
437
+ if __name__ == "__main__":
438
+ cli()
mcpscope/config.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import json
3
+ from pathlib import Path
4
+ from dataclasses import dataclass, field
5
+
6
+ DEFAULT_CONFIG_PATH = Path.home() / ".mcpscope" / "config.json"
7
+
8
+
9
+ @dataclass
10
+ class Settings:
11
+ db_path: str = str(Path.home() / ".mcpscope" / "mcpscope.db")
12
+ host: str = "127.0.0.1"
13
+ port: int = 8080
14
+ log_level: str = "info"
15
+ auto_refresh_seconds: int = 30
16
+ api_key: str | None = None
17
+ webhook_urls: list[str] = field(default_factory=list)
18
+ slack_webhook_url: str | None = None
19
+ max_upload_mb: int = 50
20
+ dashboard_password: str | None = None
21
+ log_json: bool = False
22
+
23
+ @classmethod
24
+ def load(cls, path: str | Path | None = None) -> Settings:
25
+ path = Path(path) if path else DEFAULT_CONFIG_PATH
26
+ if path.exists():
27
+ try:
28
+ with open(path) as f:
29
+ data = json.load(f)
30
+ return cls(
31
+ **{k: v for k, v in data.items() if k in cls.__dataclass_fields__}
32
+ )
33
+ except (json.JSONDecodeError, TypeError):
34
+ pass
35
+ return cls()
36
+
37
+ def save(self, path: str | Path | None = None):
38
+ path = Path(path) if path else DEFAULT_CONFIG_PATH
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ with open(path, "w") as f:
41
+ json.dump(self.as_dict(), f, indent=2)
42
+
43
+ def as_dict(self) -> dict:
44
+ return {k: getattr(self, k) for k in self.__dataclass_fields__}
mcpscope/ingest/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .base import BaseParser, ParseError
2
+ from .cisco_mcp import CiscoMCPParser
3
+ from .cisco_a2a import CiscoA2AParser
4
+ from .mcpscan import MCPScanParser
5
+ from .mcpwn import MCPwnParser
6
+ from .sarif import SarifParser
7
+
8
+ __all__ = [
9
+ "BaseParser", "ParseError",
10
+ "CiscoMCPParser", "CiscoA2AParser", "MCPScanParser", "MCPwnParser", "SarifParser",
11
+ ]
mcpscope/ingest/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (535 Bytes). View file
 
mcpscope/ingest/__pycache__/base.cpython-313.pyc ADDED
Binary file (4.06 kB). View file
 
mcpscope/ingest/__pycache__/cisco_a2a.cpython-313.pyc ADDED
Binary file (3.98 kB). View file
 
mcpscope/ingest/__pycache__/cisco_mcp.cpython-313.pyc ADDED
Binary file (5.09 kB). View file
 
mcpscope/ingest/__pycache__/mcpscan.cpython-313.pyc ADDED
Binary file (4.21 kB). View file
 
mcpscope/ingest/__pycache__/mcpwn.cpython-313.pyc ADDED
Binary file (3.89 kB). View file
 
mcpscope/ingest/__pycache__/sarif.cpython-313.pyc ADDED
Binary file (4.95 kB). View file
 
mcpscope/ingest/base.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import json
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from mcpscope.models.finding import Finding
8
+ from mcpscope.config import Settings
9
+
10
+ MAX_UPLOAD_MB = Settings.load().max_upload_mb
11
+
12
+
13
+ class ParseError(Exception):
14
+ def __init__(
15
+ self, message: str, path: str | None = None, details: str | None = None
16
+ ):
17
+ self.path = path
18
+ self.details = details
19
+ super().__init__(message)
20
+
21
+
22
+ class BaseParser(ABC):
23
+ SCANNER_NAME: str = "base"
24
+
25
+ @abstractmethod
26
+ def parse(self, data: dict) -> list[Finding]: ...
27
+
28
+ def validate(self, data: Any, path: str | None = None):
29
+ if not isinstance(data, dict):
30
+ raise ParseError(
31
+ f"Expected a JSON object at root, got {type(data).__name__}",
32
+ path=path,
33
+ )
34
+
35
+ def load_json(self, path: str | Path) -> dict:
36
+ path = Path(path)
37
+ if not path.exists():
38
+ raise ParseError(f"File not found: {path}", path=str(path))
39
+ if path.suffix not in (".json", ".sarif"):
40
+ raise ParseError(
41
+ f"Unsupported file type: {path.suffix} (expected .json or .sarif)",
42
+ path=str(path),
43
+ )
44
+
45
+ size_mb = path.stat().st_size / (1024 * 1024)
46
+ if size_mb > MAX_UPLOAD_MB:
47
+ raise ParseError(
48
+ f"File too large: {size_mb:.1f}MB exceeds limit of {MAX_UPLOAD_MB}MB",
49
+ path=str(path),
50
+ )
51
+
52
+ try:
53
+ with open(path) as f:
54
+ return json.load(f)
55
+ except json.JSONDecodeError as e:
56
+ raise ParseError(
57
+ f"Invalid JSON: {e.msg} at line {e.lineno} col {e.colno}",
58
+ path=str(path),
59
+ details=str(e),
60
+ )
61
+
62
+ def parse_file(self, path: str | Path) -> list[Finding]:
63
+ path = Path(path)
64
+ raw = self.load_json(path)
65
+ self.validate(raw, path=str(path))
66
+ results = self.parse(raw)
67
+ if not results:
68
+ raise ParseError("No findings could be extracted", path=str(path))
69
+ return results
mcpscope/ingest/cisco_a2a.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from mcpscope.ingest.base import BaseParser, ParseError
3
+ from mcpscope.models.finding import Finding, Severity
4
+
5
+
6
+ class CiscoA2AParser(BaseParser):
7
+ SCANNER_NAME = "cisco-a2a"
8
+
9
+ def validate(self, data: dict, path: str | None = None):
10
+ super().validate(data, path)
11
+ if "findings" not in data and "results" not in data:
12
+ raise ParseError(
13
+ "Missing 'findings' or 'results' key — not a Cisco A2A Scanner output",
14
+ path=path,
15
+ )
16
+
17
+ def parse(self, data: dict) -> list[Finding]:
18
+ findings = []
19
+ scanner = self.SCANNER_NAME
20
+ target = data.get("target", data.get("card", data.get("endpoint", "unknown")))
21
+
22
+ raw_findings = data.get("findings", data.get("results", data.get("assessments", [])))
23
+ if not isinstance(raw_findings, list):
24
+ raise ParseError(f"Expected 'findings' to be a list, got {type(raw_findings).__name__}")
25
+
26
+ for item in raw_findings:
27
+ if not isinstance(item, dict):
28
+ continue
29
+ threat_name = item.get("threat_name", "")
30
+ sev_raw = (item.get("severity") or item.get("risk") or "info").upper()
31
+ sev = Severity.CRITICAL if sev_raw == "CRITICAL" else (
32
+ Severity.HIGH if sev_raw == "HIGH" else (
33
+ Severity.MEDIUM if sev_raw == "MEDIUM" else (
34
+ Severity.LOW if sev_raw == "LOW" else Severity.INFO)))
35
+ analyzer = item.get("analyzer", "unknown")
36
+ summary = item.get("summary", "")
37
+ description = item.get("description", summary)
38
+ aitech = item.get("aitech", "")
39
+ aitech_name = item.get("aitech_name", "")
40
+ aisubtech = item.get("aisubtech", "")
41
+ aisubtech_name = item.get("aisubtech_name", "")
42
+
43
+ location = ""
44
+ if isinstance(item.get("details"), dict):
45
+ location = item["details"].get("field", "")
46
+
47
+ title = threat_name or f"[{analyzer}] {summary[:60]}" if summary else f"{analyzer} finding"
48
+ if aitech:
49
+ title = f"[{aitech}] {aitech_name}"
50
+ if aisubtech:
51
+ title = f"[{aisubtech}] {aisubtech_name or title}"
52
+
53
+ findings.append(Finding(
54
+ scan_id=target,
55
+ scanner=scanner,
56
+ tool_name=f"a2a-{analyzer.lower()}",
57
+ severity=sev,
58
+ title=title,
59
+ description=description or summary,
60
+ raw_data={
61
+ "threat_name": threat_name,
62
+ "analyzer": analyzer,
63
+ "aitech": aitech,
64
+ "aitech_name": aitech_name,
65
+ "aisubtech": aisubtech,
66
+ "aisubtech_name": aisubtech_name,
67
+ "location": location,
68
+ "details": item.get("details"),
69
+ },
70
+ ))
71
+
72
+ return findings
mcpscope/ingest/cisco_mcp.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from mcpscope.ingest.base import BaseParser, ParseError
3
+ from mcpscope.models.finding import Finding, Severity
4
+
5
+ SEVERITY_MAP = {
6
+ "CRITICAL": Severity.CRITICAL,
7
+ "HIGH": Severity.HIGH,
8
+ "MEDIUM": Severity.MEDIUM,
9
+ "LOW": Severity.LOW,
10
+ "INFO": Severity.INFO,
11
+ "SAFE": Severity.INFO,
12
+ "UNKNOWN": Severity.INFO,
13
+ }
14
+
15
+ ANALYZER_DISPLAY_NAMES = {
16
+ "api_analyzer": "API",
17
+ "yara_analyzer": "YARA",
18
+ "llm_analyzer": "LLM",
19
+ "behavioral_analyzer": "Behavioral",
20
+ "readiness_analyzer": "Readiness",
21
+ }
22
+
23
+
24
+ class CiscoMCPParser(BaseParser):
25
+ SCANNER_NAME = "cisco-mcp"
26
+
27
+ def validate(self, data: dict, path: str | None = None):
28
+ super().validate(data, path)
29
+ if "scan_results" not in data and "results" not in data:
30
+ raise ParseError(
31
+ "Missing 'scan_results' or 'results' key — not a Cisco MCP Scanner output",
32
+ path=path,
33
+ )
34
+
35
+ def parse(self, data: dict) -> list[Finding]:
36
+ findings = []
37
+ scanner = self.SCANNER_NAME
38
+ server_url = data.get("server_url", "unknown")
39
+ scan_results = data.get("scan_results", data.get("results", [data]))
40
+
41
+ for result in scan_results:
42
+ tool_name = result.get("tool_name", result.get("prompt_name", result.get("resource_uri", "unknown")))
43
+ item_type = result.get("item_type", "tool")
44
+ status = result.get("status", "unknown")
45
+ is_safe = result.get("is_safe", True)
46
+
47
+ result_findings = result.get("findings", {})
48
+ if not isinstance(result_findings, dict):
49
+ continue
50
+
51
+ for analyzer_key, analyzer_data in result_findings.items():
52
+ analyzer_label = ANALYZER_DISPLAY_NAMES.get(analyzer_key, analyzer_key)
53
+ sev_raw = analyzer_data.get("severity", "INFO").upper()
54
+ sev = SEVERITY_MAP.get(sev_raw, Severity.INFO)
55
+
56
+ total = analyzer_data.get("total_findings", 0)
57
+
58
+ threat_names = analyzer_data.get("threat_names", [])
59
+ threat_summary = analyzer_data.get("threat_summary", "")
60
+ threats = analyzer_data.get("threats", {})
61
+
62
+ threat_items = threats.get("items", []) if isinstance(threats, dict) else []
63
+
64
+ if threat_items:
65
+ for technique in threat_items:
66
+ technique_id = technique.get("technique_id", "")
67
+ technique_name = technique.get("technique_name", "")
68
+ sub_items = technique.get("items", [])
69
+ for sub in sub_items:
70
+ sub_id = sub.get("sub_technique_id", "")
71
+ sub_name = sub.get("sub_technique_name", "")
72
+ max_sev = sub.get("max_severity", sev_raw)
73
+ desc = sub.get("description", threat_summary)
74
+
75
+ title = f"[{technique_id}] {technique_name}"
76
+ if sub_id:
77
+ title = f"[{sub_id}] {sub_name}"
78
+
79
+ findings.append(Finding(
80
+ scan_id=server_url,
81
+ scanner=scanner,
82
+ tool_name=tool_name,
83
+ severity=SEVERITY_MAP.get(max_sev.upper(), sev),
84
+ title=title,
85
+ description=desc,
86
+ raw_data={
87
+ "analyzer": analyzer_label,
88
+ "item_type": item_type,
89
+ "status": status,
90
+ "is_safe": is_safe,
91
+ "technique": technique,
92
+ "sub_technique": sub,
93
+ },
94
+ ))
95
+ elif threat_names:
96
+ for threat in threat_names:
97
+ findings.append(Finding(
98
+ scan_id=server_url,
99
+ scanner=scanner,
100
+ tool_name=tool_name,
101
+ severity=sev,
102
+ title=f"[{analyzer_label}] {threat}",
103
+ description=threat_summary,
104
+ raw_data={
105
+ "analyzer": analyzer_label,
106
+ "item_type": item_type,
107
+ "status": status,
108
+ "is_safe": is_safe,
109
+ "threat": threat,
110
+ },
111
+ ))
112
+ elif total > 0 and threat_summary:
113
+ findings.append(Finding(
114
+ scan_id=server_url,
115
+ scanner=scanner,
116
+ tool_name=tool_name,
117
+ severity=sev,
118
+ title=f"[{analyzer_label}] {threat_summary[:80]}",
119
+ description=threat_summary,
120
+ raw_data={
121
+ "analyzer": analyzer_label,
122
+ "item_type": item_type,
123
+ "status": status,
124
+ "is_safe": is_safe,
125
+ },
126
+ ))
127
+
128
+ return findings
mcpscope/ingest/mcpscan.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from mcpscope.ingest.base import BaseParser, ParseError
3
+ from mcpscope.models.finding import Finding, Severity
4
+
5
+ SEVERITY_MAP = {
6
+ "critical": Severity.CRITICAL,
7
+ "high": Severity.HIGH,
8
+ "medium": Severity.MEDIUM,
9
+ "low": Severity.LOW,
10
+ "info": Severity.INFO,
11
+ "safe": Severity.INFO,
12
+ }
13
+
14
+
15
+ class MCPScanParser(BaseParser):
16
+ SCANNER_NAME = "mcp-scan"
17
+
18
+ def validate(self, data: dict, path: str | None = None):
19
+ super().validate(data, path)
20
+ if "issues" not in data and "results" not in data:
21
+ raise ParseError(
22
+ "Missing 'issues' or 'results' key — not an mcp-scan JSON output",
23
+ path=path,
24
+ )
25
+
26
+ def parse(self, data: dict) -> list[Finding]:
27
+ findings = []
28
+ scanner = self.SCANNER_NAME
29
+ target = data.get("target", data.get("config", "unknown"))
30
+
31
+ issues = data.get("issues", data.get("results", data.get("findings", [])))
32
+ if not isinstance(issues, list):
33
+ raise ParseError(f"Expected 'issues' to be a list, got {type(issues).__name__}")
34
+
35
+ servers = data.get("servers", [])
36
+ server_map = {}
37
+ for srv in servers:
38
+ if isinstance(srv, dict):
39
+ srv_name = srv.get("name", srv.get("server_name", ""))
40
+ server_map[srv.get("id", "")] = srv_name
41
+
42
+ for issue in issues:
43
+ if not isinstance(issue, dict):
44
+ continue
45
+ code = issue.get("code", issue.get("id", ""))
46
+ sev_raw = issue.get("severity", "info").lower()
47
+ sev = SEVERITY_MAP.get(sev_raw, Severity.INFO)
48
+ message = issue.get("message", issue.get("summary", issue.get("title", "")))
49
+ description = issue.get("description", issue.get("detail", ""))
50
+ recommendation = issue.get("recommendation", issue.get("remediation", ""))
51
+ tool_name = issue.get("tool_name", issue.get("tool", ""))
52
+ server_id = issue.get("server_id", issue.get("server", ""))
53
+ server_name = issue.get("server_name", server_map.get(server_id, ""))
54
+ if not tool_name and server_name:
55
+ tool_name = f"server:{server_name}"
56
+
57
+ title = f"[{code}] {message}" if code else message
58
+ if not title:
59
+ title = f"mcp-scan {sev_raw} finding"
60
+
61
+ findings.append(Finding(
62
+ scan_id=target,
63
+ scanner=scanner,
64
+ tool_name=tool_name or "mcp-scan",
65
+ severity=sev,
66
+ title=title,
67
+ description=description or message,
68
+ recommendation=recommendation,
69
+ raw_data={
70
+ "code": code,
71
+ "server_name": server_name,
72
+ **issue,
73
+ },
74
+ ))
75
+
76
+ return findings
mcpscope/ingest/mcpwn.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from mcpscope.ingest.base import BaseParser, ParseError
3
+ from mcpscope.models.finding import Finding, Severity
4
+
5
+ SEVERITY_MAP = {
6
+ "critical": Severity.CRITICAL,
7
+ "high": Severity.HIGH,
8
+ "medium": Severity.MEDIUM,
9
+ "low": Severity.LOW,
10
+ "info": Severity.INFO,
11
+ }
12
+
13
+
14
+ class MCPwnParser(BaseParser):
15
+ SCANNER_NAME = "mcpwn"
16
+
17
+ def validate(self, data: dict, path: str | None = None):
18
+ super().validate(data, path)
19
+ if "findings" not in data and "exploits" not in data and "modules" not in data:
20
+ raise ParseError(
21
+ "Missing 'findings', 'exploits', or 'modules' key — not an MCPwn output",
22
+ path=path,
23
+ )
24
+
25
+ def parse(self, data: dict) -> list[Finding]:
26
+ findings = []
27
+ scanner = self.SCANNER_NAME
28
+ target = data.get("target", data.get("tool", data.get("card", "unknown")))
29
+
30
+ raw_findings = data.get("findings", data.get("exploits", data.get("modules", [])))
31
+ if not isinstance(raw_findings, list):
32
+ raise ParseError(f"Expected findings to be a list, got {type(raw_findings).__name__}")
33
+
34
+ for item in raw_findings:
35
+ if not isinstance(item, dict):
36
+ continue
37
+ sev_raw = item.get("severity", "info").lower()
38
+ sev = SEVERITY_MAP.get(sev_raw, Severity.INFO)
39
+ vuln_id = item.get("id", item.get("type", ""))
40
+ title = item.get("title", item.get("name", ""))
41
+ test = item.get("test", item.get("module", ""))
42
+ tool_name = item.get("tool", item.get("tool_name", ""))
43
+ if not tool_name:
44
+ tool_name = test or "mcpwn"
45
+ description = item.get("description", item.get("output", item.get("detection", "")))
46
+ recommendation = item.get("recommendation", item.get("fix", ""))
47
+ evidence = item.get("detection", item.get("evidence", ""))
48
+
49
+ if not title and vuln_id:
50
+ title = f"[{vuln_id}] {test}" if test else vuln_id
51
+ if not title and description:
52
+ title = description[:80]
53
+
54
+ findings.append(Finding(
55
+ scan_id=target,
56
+ scanner=scanner,
57
+ tool_name=tool_name,
58
+ severity=sev,
59
+ title=title or "MCPwn finding",
60
+ description=description,
61
+ recommendation=recommendation,
62
+ raw_data={
63
+ "vuln_id": vuln_id,
64
+ "test": test,
65
+ "evidence": evidence,
66
+ **item,
67
+ },
68
+ ))
69
+
70
+ return findings
mcpscope/ingest/sarif.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from mcpscope.ingest.base import BaseParser, ParseError
3
+ from mcpscope.models.finding import Finding, Severity
4
+
5
+ SEVERITY_MAP = {
6
+ "error": Severity.CRITICAL,
7
+ "warning": Severity.MEDIUM,
8
+ "note": Severity.LOW,
9
+ "none": Severity.INFO,
10
+ }
11
+
12
+
13
+ class SarifParser(BaseParser):
14
+ SCANNER_NAME = "sarif"
15
+
16
+ def validate(self, data: dict, path: str | None = None):
17
+ super().validate(data, path)
18
+ if "$schema" not in str(data.get("$schema", "")):
19
+ if "version" not in data or "runs" not in data:
20
+ raise ParseError(
21
+ "Missing '$schema'/'version' and 'runs' — not a SARIF file",
22
+ path=path,
23
+ )
24
+
25
+ def parse(self, data: dict) -> list[Finding]:
26
+ findings = []
27
+ scanner = self.SCANNER_NAME
28
+ target = data.get("target", "sarif-report")
29
+
30
+ runs = data.get("runs", [])
31
+ if not isinstance(runs, list):
32
+ raise ParseError(f"Expected 'runs' to be a list, got {type(runs).__name__}")
33
+
34
+ for run in runs:
35
+ tool_name = "unknown"
36
+ if isinstance(run.get("tool"), dict):
37
+ driver = run["tool"].get("driver", {})
38
+ tool_name = driver.get("name", driver.get("fullName", "unknown"))
39
+ tool_version = driver.get("version", "")
40
+
41
+ run_results = run.get("results", [])
42
+ if not isinstance(run_results, list):
43
+ continue
44
+
45
+ rules_map = {}
46
+ if isinstance(run.get("tool"), dict):
47
+ driver = run["tool"].get("driver", {})
48
+ rules = driver.get("rules", [])
49
+ if isinstance(rules, list):
50
+ for rule in rules:
51
+ rule_id = rule.get("id", "")
52
+ rules_map[rule_id] = {
53
+ "name": rule.get("name", ""),
54
+ "shortDescription": "",
55
+ "fullDescription": "",
56
+ }
57
+ if isinstance(rule.get("shortDescription"), dict):
58
+ rules_map[rule_id]["shortDescription"] = rule["shortDescription"].get("text", "")
59
+ if isinstance(rule.get("fullDescription"), dict):
60
+ rules_map[rule_id]["fullDescription"] = rule["fullDescription"].get("text", "")
61
+
62
+ for result in run_results:
63
+ if not isinstance(result, dict):
64
+ continue
65
+ rule_id = result.get("ruleId", "")
66
+ rule_info = rules_map.get(rule_id, {})
67
+
68
+ level = result.get("level", "warning")
69
+ sev = SEVERITY_MAP.get(level, Severity.INFO)
70
+
71
+ message = ""
72
+ if isinstance(result.get("message"), dict):
73
+ message = result["message"].get("text", "")
74
+
75
+ title = rule_info.get("name", rule_id) or rule_id
76
+ description = rule_info.get("fullDescription", rule_info.get("shortDescription", message))
77
+ if not description:
78
+ description = message
79
+
80
+ locations = result.get("locations", [])
81
+ loc_str = ""
82
+ if isinstance(locations, list) and locations:
83
+ phys = locations[0].get("physicalLocation", {})
84
+ artifact = phys.get("artifactLocation", {})
85
+ loc_str = artifact.get("uri", "")
86
+
87
+ findings.append(Finding(
88
+ scan_id=target,
89
+ scanner=scanner,
90
+ tool_name=tool_name,
91
+ tool_version=tool_version,
92
+ severity=sev,
93
+ title=title,
94
+ description=description,
95
+ raw_data={
96
+ "rule_id": rule_id,
97
+ "level": level,
98
+ "location": loc_str,
99
+ **result,
100
+ },
101
+ ))
102
+
103
+ return findings
mcpscope/main.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from mcpscope.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
mcpscope/models/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .finding import Finding, Severity
2
+ from .scan import ScanRun, ScanHistory
3
+
4
+ __all__ = ["Finding", "Severity", "ScanRun", "ScanHistory"]
mcpscope/models/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (347 Bytes). View file
 
mcpscope/models/__pycache__/finding.cpython-313.pyc ADDED
Binary file (2.37 kB). View file
 
mcpscope/models/__pycache__/scan.cpython-313.pyc ADDED
Binary file (1.83 kB). View file
 
mcpscope/models/__pycache__/security_event.cpython-313.pyc ADDED
Binary file (1.59 kB). View file
 
mcpscope/models/finding.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from enum import Enum
3
+ from datetime import datetime, timezone
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class Severity(str, Enum):
8
+ CRITICAL = "critical"
9
+ HIGH = "high"
10
+ MEDIUM = "medium"
11
+ LOW = "low"
12
+ INFO = "info"
13
+
14
+
15
+ SEVERITY_ORDER = {s: i for i, s in enumerate(Severity)}
16
+ SEVERITY_COLORS = {
17
+ Severity.CRITICAL: "#dc2626",
18
+ Severity.HIGH: "#ea580c",
19
+ Severity.MEDIUM: "#ca8a04",
20
+ Severity.LOW: "#2563eb",
21
+ Severity.INFO: "#6b7280",
22
+ }
23
+
24
+
25
+ class Finding(BaseModel):
26
+ id: str | None = None
27
+ scan_id: str
28
+ scanner: str
29
+ tool_name: str
30
+ tool_version: str | None = None
31
+ severity: Severity
32
+ title: str
33
+ description: str | None = None
34
+ recommendation: str | None = None
35
+ cvss_score: float | None = None
36
+ cve_id: str | None = None
37
+ raw_data: dict | None = None
38
+ created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
39
+
40
+ def severity_order(self) -> int:
41
+ return SEVERITY_ORDER.get(self.severity, 99)
mcpscope/models/scan.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from datetime import datetime, timezone
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class ScanRun(BaseModel):
7
+ id: str
8
+ scanner: str
9
+ target: str | None = None
10
+ findings_count: int = 0
11
+ critical_count: int = 0
12
+ high_count: int = 0
13
+ medium_count: int = 0
14
+ low_count: int = 0
15
+ info_count: int = 0
16
+ raw_file: str | None = None
17
+ created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
18
+
19
+
20
+ class ScanHistory(BaseModel):
21
+ scans: list[ScanRun] = []
22
+ total_findings: int = 0
23
+ total_critical: int = 0
24
+ total_high: int = 0
25
+ total_medium: int = 0
26
+ total_low: int = 0
27
+ total_info: int = 0
mcpscope/models/security_event.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from enum import Enum
3
+ from datetime import datetime, timezone
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class EventSeverity(str, Enum):
8
+ CRITICAL = "critical"
9
+ HIGH = "high"
10
+ MEDIUM = "medium"
11
+ LOW = "low"
12
+ INFO = "info"
13
+
14
+
15
+ class SecurityEvent(BaseModel):
16
+ id: str | None = None
17
+ event_type: str
18
+ severity: str
19
+ message: str
20
+ source: str = "mcpguard"
21
+ tool: str | None = None
22
+ details: dict | None = None
23
+ blocked: bool = True
24
+ created_at: str = Field(
25
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
26
+ )
mcpscope/scanner.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import json
3
+ import subprocess
4
+ import tempfile
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from mcpscope.ingest.base import ParseError
10
+ from mcpscope.ingest.cisco_mcp import CiscoMCPParser
11
+ from mcpscope.ingest.mcpscan import MCPScanParser
12
+ from mcpscope.ingest.mcpwn import MCPwnParser
13
+ from mcpscope.models.scan import ScanRun
14
+ from mcpscope.storage.store import Store
15
+
16
+
17
+ class ScannerRunner:
18
+ PARSERS: dict[str, dict[str, Any]] = {
19
+ "mcp-scan": {
20
+ "parser": MCPScanParser(),
21
+ "install": "pip install mcp-scan",
22
+ "cmd": ["mcp-scan", "--json"],
23
+ },
24
+ "cisco-mcp": {
25
+ "parser": CiscoMCPParser(),
26
+ "install": "pip install cisco-ai-mcp-scanner",
27
+ "cmd": ["mcp-scanner", "scan", "--output-format", "raw"],
28
+ },
29
+ "mcpwn": {
30
+ "parser": MCPwnParser(),
31
+ "install": "pip install mcpwn",
32
+ "cmd": ["mcpwn", "scan", "--format", "json"],
33
+ },
34
+ }
35
+
36
+ def scan(
37
+ self, scanner_name: str, target: str, store: Store | None = None
38
+ ) -> ScanRun:
39
+ info = self.PARSERS.get(scanner_name)
40
+ if not info:
41
+ raise ValueError(
42
+ f"Unknown scanner: {scanner_name}. Supported: {', '.join(self.PARSERS)}"
43
+ )
44
+
45
+ parser = info["parser"]
46
+ cmd = info["cmd"] + [target]
47
+
48
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as tmp:
49
+ tmp_path = tmp.name
50
+
51
+ try:
52
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
53
+ if result.returncode != 0:
54
+ raise RuntimeError(
55
+ f"Scanner failed (exit {result.returncode}): {result.stderr[:500]}"
56
+ )
57
+ raw = json.loads(result.stdout)
58
+ except FileNotFoundError:
59
+ raise RuntimeError(
60
+ f"Scanner '{scanner_name}' not found. Install it: {info['install']}"
61
+ )
62
+ except json.JSONDecodeError as e:
63
+ raise RuntimeError(f"Scanner output is not valid JSON: {e}")
64
+ except subprocess.TimeoutExpired:
65
+ raise RuntimeError("Scanner timed out (120s)")
66
+ finally:
67
+ Path(tmp_path).unlink(missing_ok=True)
68
+
69
+ findings = parser.parse(raw)
70
+ if not findings:
71
+ raise RuntimeError("Scanner completed but no findings were detected")
72
+
73
+ scan = ScanRun(
74
+ id=str(uuid.uuid4()),
75
+ scanner=findings[0].scanner,
76
+ target=target,
77
+ )
78
+ if store:
79
+ store.save_scan(scan, findings)
80
+ return scan
mcpscope/storage/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .store import Store
2
+
3
+ __all__ = ["Store"]
mcpscope/storage/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (249 Bytes). View file