Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +6 -0
- .editorconfig +18 -0
- .pre-commit-config.yaml +30 -0
- CHANGELOG.md +28 -0
- CODE_OF_CONDUCT.md +43 -0
- CONTRIBUTING.md +58 -0
- Dockerfile +14 -0
- LICENSE +21 -0
- README.md +220 -14
- SECURITY.md +34 -0
- mcpscope/__init__.py +0 -0
- mcpscope/__pycache__/__init__.cpython-313.pyc +0 -0
- mcpscope/__pycache__/cli.cpython-313.pyc +0 -0
- mcpscope/__pycache__/config.cpython-313.pyc +0 -0
- mcpscope/__pycache__/scanner.cpython-313.pyc +0 -0
- mcpscope/__pycache__/webhooks.cpython-313.pyc +0 -0
- mcpscope/api/__init__.py +3 -0
- mcpscope/api/__pycache__/__init__.cpython-313.pyc +0 -0
- mcpscope/api/__pycache__/routes.cpython-313.pyc +0 -0
- mcpscope/api/__pycache__/server.cpython-313.pyc +0 -0
- mcpscope/api/routes.py +476 -0
- mcpscope/api/server.py +123 -0
- mcpscope/cli.py +438 -0
- mcpscope/config.py +44 -0
- mcpscope/ingest/__init__.py +11 -0
- mcpscope/ingest/__pycache__/__init__.cpython-313.pyc +0 -0
- mcpscope/ingest/__pycache__/base.cpython-313.pyc +0 -0
- mcpscope/ingest/__pycache__/cisco_a2a.cpython-313.pyc +0 -0
- mcpscope/ingest/__pycache__/cisco_mcp.cpython-313.pyc +0 -0
- mcpscope/ingest/__pycache__/mcpscan.cpython-313.pyc +0 -0
- mcpscope/ingest/__pycache__/mcpwn.cpython-313.pyc +0 -0
- mcpscope/ingest/__pycache__/sarif.cpython-313.pyc +0 -0
- mcpscope/ingest/base.py +69 -0
- mcpscope/ingest/cisco_a2a.py +72 -0
- mcpscope/ingest/cisco_mcp.py +128 -0
- mcpscope/ingest/mcpscan.py +76 -0
- mcpscope/ingest/mcpwn.py +70 -0
- mcpscope/ingest/sarif.py +103 -0
- mcpscope/main.py +4 -0
- mcpscope/models/__init__.py +4 -0
- mcpscope/models/__pycache__/__init__.cpython-313.pyc +0 -0
- mcpscope/models/__pycache__/finding.cpython-313.pyc +0 -0
- mcpscope/models/__pycache__/scan.cpython-313.pyc +0 -0
- mcpscope/models/__pycache__/security_event.cpython-313.pyc +0 -0
- mcpscope/models/finding.py +41 -0
- mcpscope/models/scan.py +27 -0
- mcpscope/models/security_event.py +26 -0
- mcpscope/scanner.py +80 -0
- mcpscope/storage/__init__.py +3 -0
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
**
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
|
| 3 |
+
# MCP-Scope
|
| 4 |
+
|
| 5 |
+
**Unified security dashboard for MCP/A2A scanner results**
|
| 6 |
+
|
| 7 |
+
[](https://python.org)
|
| 8 |
+
[](https://github.com/Carlos-Projects/mcpscope/actions)
|
| 9 |
+
[](LICENSE)
|
| 10 |
+
[](https://github.com/Carlos-Projects/mcpscope/releases)
|
| 11 |
+
[](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 |
+
|  |  |
|
| 145 |
+
| **Scan Diff** | **Finding Detail** |
|
| 146 |
+
|  |  |
|
| 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
|
|
|