aevrin 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aevrin-0.1.0/.gitignore +45 -0
- aevrin-0.1.0/.python-version +1 -0
- aevrin-0.1.0/PKG-INFO +86 -0
- aevrin-0.1.0/README.md +70 -0
- aevrin-0.1.0/pyproject.toml +47 -0
- aevrin-0.1.0/src/aevrin_cli/__init__.py +0 -0
- aevrin-0.1.0/src/aevrin_cli/main.py +116 -0
- aevrin-0.1.0/src/aevrin_cli/output.py +100 -0
- aevrin-0.1.0/src/aevrin_cli/target_detection.py +46 -0
- aevrin-0.1.0/src/aevrin_cli/upload.py +66 -0
- aevrin-0.1.0/tests/test_target_detection.py +49 -0
- aevrin-0.1.0/uv.lock +612 -0
aevrin-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Secrets — never commit
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
env.txt
|
|
6
|
+
infra/defectdojo/secrets.generated.md
|
|
7
|
+
*.pem
|
|
8
|
+
*.key
|
|
9
|
+
|
|
10
|
+
# Node
|
|
11
|
+
node_modules/
|
|
12
|
+
.next/
|
|
13
|
+
out/
|
|
14
|
+
dist/
|
|
15
|
+
build/
|
|
16
|
+
.turbo/
|
|
17
|
+
npm-debug.log*
|
|
18
|
+
yarn-error.log*
|
|
19
|
+
|
|
20
|
+
# Python
|
|
21
|
+
__pycache__/
|
|
22
|
+
*.pyc
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.pytest_cache/
|
|
27
|
+
.mypy_cache/
|
|
28
|
+
.ruff_cache/
|
|
29
|
+
.uv-cache/
|
|
30
|
+
|
|
31
|
+
# OS / editor
|
|
32
|
+
.DS_Store
|
|
33
|
+
.vscode/*
|
|
34
|
+
!.vscode/extensions.json
|
|
35
|
+
*.swp
|
|
36
|
+
|
|
37
|
+
# Playwright
|
|
38
|
+
test-results/
|
|
39
|
+
playwright-report/
|
|
40
|
+
blob-report/
|
|
41
|
+
.playwright-mcp/
|
|
42
|
+
|
|
43
|
+
# Misc
|
|
44
|
+
*.log
|
|
45
|
+
.cache/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
aevrin-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aevrin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Aevrin MCP Security Scanner CLI — scan a GitHub repo, local path, or live MCP server.
|
|
5
|
+
Project-URL: Homepage, https://github.com/aevrin-projects/aevrin-mcp-scanner
|
|
6
|
+
Project-URL: Repository, https://github.com/aevrin-projects/aevrin-mcp-scanner
|
|
7
|
+
Author: Aevrin
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: mcp,model-context-protocol,scanner,security
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: aevrin-scanner-core>=0.1.0
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: rich>=13.9
|
|
14
|
+
Requires-Dist: typer>=0.15
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# aevrin
|
|
18
|
+
|
|
19
|
+
Aevrin MCP Security Scanner CLI. Wraps the same open-source scanner binaries and normalization logic (`aevrin-scanner-core`) that the Aevrin backend uses, run locally against your own machine — no network call required unless you pass `--upload`.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install aevrin
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires Docker (each scanner runs in its own disposable container — see the main repo README for why).
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
aevrin scan ./my-mcp-server
|
|
33
|
+
aevrin scan github.com/owner/repo
|
|
34
|
+
aevrin scan https://my-live-server.example.com --json
|
|
35
|
+
aevrin scan ./my-mcp-server --fail-on high
|
|
36
|
+
aevrin scan ./my-mcp-server --upload # requires AEVRIN_API_KEY env var
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Target type is auto-detected: a `github.com` URL scans the full pipeline (static analysis, secrets, dependencies, tool-description checks); any other `http(s)://` URL is treated as a live MCP server (manifest-level checks only); anything that exists on disk is scanned as a local path (full pipeline, no cloning).
|
|
40
|
+
|
|
41
|
+
### Flags
|
|
42
|
+
|
|
43
|
+
| Flag | Behavior |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `--json` | Machine-readable JSON on stdout instead of a formatted table. |
|
|
46
|
+
| `--upload` | Pushes the result to your Aevrin account. Requires `AEVRIN_API_KEY`, set from your account's API keys settings page — never required for a local-only scan. |
|
|
47
|
+
| `--fail-on <severity>` | Minimum severity that causes a non-zero exit code. One of `critical`, `high`, `medium`, `low`, `info`. Defaults to `high` (both `critical` and `high` findings fail the build). |
|
|
48
|
+
|
|
49
|
+
### Exit codes
|
|
50
|
+
|
|
51
|
+
| Code | Meaning |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `0` | Clean — no findings at or above the `--fail-on` threshold. |
|
|
54
|
+
| `1` | Findings at or above the `--fail-on` threshold were found. |
|
|
55
|
+
| `2` | Misuse — bad arguments, a target that couldn't be resolved, every scan stage failed to run, or `--upload` failed. |
|
|
56
|
+
|
|
57
|
+
Results go to stdout; stage progress and diagnostics go to stderr — safe to pipe `--json` output without stage-progress noise mixed in.
|
|
58
|
+
|
|
59
|
+
### Example output
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
[✓] static analysis
|
|
63
|
+
[✓] secrets
|
|
64
|
+
[✓] dependencies
|
|
65
|
+
[✓] tool description check
|
|
66
|
+
[✓] aggregating
|
|
67
|
+
|
|
68
|
+
Target: ./my-mcp-server
|
|
69
|
+
Score: 62/100 Significant risk — do not deploy as-is
|
|
70
|
+
|
|
71
|
+
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
|
|
72
|
+
┃ Severity ┃ Title ┃ OWASP category ┃ Tool ┃
|
|
73
|
+
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
|
|
74
|
+
│ CRITICAL │ Hardcoded secret │ MCP01: Token Mismanagement │ trivy │
|
|
75
|
+
│ HIGH │ subprocess shell true│ MCP05: Command Injection, ... │ semgrep │
|
|
76
|
+
└──────────┴──────────────────────┴──────────────────────────────────────┴─────────┘
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uv sync
|
|
83
|
+
uv run pytest tests -v
|
|
84
|
+
uv run ruff check .
|
|
85
|
+
uv run mypy src
|
|
86
|
+
```
|
aevrin-0.1.0/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# aevrin
|
|
2
|
+
|
|
3
|
+
Aevrin MCP Security Scanner CLI. Wraps the same open-source scanner binaries and normalization logic (`aevrin-scanner-core`) that the Aevrin backend uses, run locally against your own machine — no network call required unless you pass `--upload`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install aevrin
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Docker (each scanner runs in its own disposable container — see the main repo README for why).
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
aevrin scan ./my-mcp-server
|
|
17
|
+
aevrin scan github.com/owner/repo
|
|
18
|
+
aevrin scan https://my-live-server.example.com --json
|
|
19
|
+
aevrin scan ./my-mcp-server --fail-on high
|
|
20
|
+
aevrin scan ./my-mcp-server --upload # requires AEVRIN_API_KEY env var
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Target type is auto-detected: a `github.com` URL scans the full pipeline (static analysis, secrets, dependencies, tool-description checks); any other `http(s)://` URL is treated as a live MCP server (manifest-level checks only); anything that exists on disk is scanned as a local path (full pipeline, no cloning).
|
|
24
|
+
|
|
25
|
+
### Flags
|
|
26
|
+
|
|
27
|
+
| Flag | Behavior |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `--json` | Machine-readable JSON on stdout instead of a formatted table. |
|
|
30
|
+
| `--upload` | Pushes the result to your Aevrin account. Requires `AEVRIN_API_KEY`, set from your account's API keys settings page — never required for a local-only scan. |
|
|
31
|
+
| `--fail-on <severity>` | Minimum severity that causes a non-zero exit code. One of `critical`, `high`, `medium`, `low`, `info`. Defaults to `high` (both `critical` and `high` findings fail the build). |
|
|
32
|
+
|
|
33
|
+
### Exit codes
|
|
34
|
+
|
|
35
|
+
| Code | Meaning |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `0` | Clean — no findings at or above the `--fail-on` threshold. |
|
|
38
|
+
| `1` | Findings at or above the `--fail-on` threshold were found. |
|
|
39
|
+
| `2` | Misuse — bad arguments, a target that couldn't be resolved, every scan stage failed to run, or `--upload` failed. |
|
|
40
|
+
|
|
41
|
+
Results go to stdout; stage progress and diagnostics go to stderr — safe to pipe `--json` output without stage-progress noise mixed in.
|
|
42
|
+
|
|
43
|
+
### Example output
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
[✓] static analysis
|
|
47
|
+
[✓] secrets
|
|
48
|
+
[✓] dependencies
|
|
49
|
+
[✓] tool description check
|
|
50
|
+
[✓] aggregating
|
|
51
|
+
|
|
52
|
+
Target: ./my-mcp-server
|
|
53
|
+
Score: 62/100 Significant risk — do not deploy as-is
|
|
54
|
+
|
|
55
|
+
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
|
|
56
|
+
┃ Severity ┃ Title ┃ OWASP category ┃ Tool ┃
|
|
57
|
+
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
|
|
58
|
+
│ CRITICAL │ Hardcoded secret │ MCP01: Token Mismanagement │ trivy │
|
|
59
|
+
│ HIGH │ subprocess shell true│ MCP05: Command Injection, ... │ semgrep │
|
|
60
|
+
└──────────┴──────────────────────┴──────────────────────────────────────┴─────────┘
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv sync
|
|
67
|
+
uv run pytest tests -v
|
|
68
|
+
uv run ruff check .
|
|
69
|
+
uv run mypy src
|
|
70
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aevrin"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Aevrin MCP Security Scanner CLI — scan a GitHub repo, local path, or live MCP server."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Aevrin" }]
|
|
9
|
+
keywords = ["mcp", "security", "scanner", "model-context-protocol"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"aevrin-scanner-core>=0.1.0",
|
|
12
|
+
"typer>=0.15",
|
|
13
|
+
"rich>=13.9",
|
|
14
|
+
"httpx>=0.27",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
aevrin = "aevrin_cli.main:app"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/aevrin-projects/aevrin-mcp-scanner"
|
|
22
|
+
Repository = "https://github.com/aevrin-projects/aevrin-mcp-scanner"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=8.3",
|
|
27
|
+
"ruff>=0.7",
|
|
28
|
+
"mypy>=1.13",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.uv.sources]
|
|
32
|
+
aevrin-scanner-core = { path = "../scanner-core", editable = true }
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/aevrin_cli"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
target-version = "py311"
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
python_version = "3.11"
|
|
47
|
+
strict = true
|
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from aevrin_scanner_core import Finding, ScanStage, Severity, StageStatus
|
|
9
|
+
from aevrin_scanner_core.pipeline import PipelineConfig, run_pipeline
|
|
10
|
+
|
|
11
|
+
from . import output
|
|
12
|
+
from .target_detection import TargetDetectionError, detect_target
|
|
13
|
+
from .upload import UploadError, upload_scan
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="aevrin",
|
|
17
|
+
help="Scan MCP servers for vulnerabilities using established open-source security tools.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_SEVERITY_RANK: dict[Severity, int] = {
|
|
22
|
+
Severity.INFO: 0,
|
|
23
|
+
Severity.LOW: 1,
|
|
24
|
+
Severity.MEDIUM: 2,
|
|
25
|
+
Severity.HIGH: 3,
|
|
26
|
+
Severity.CRITICAL: 4,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def scan(
|
|
32
|
+
target: Annotated[str, typer.Argument(help="GitHub URL, local path, or live MCP server URL.")],
|
|
33
|
+
json_output: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output.")] = False,
|
|
34
|
+
upload: Annotated[
|
|
35
|
+
bool, typer.Option("--upload", help="Upload results to your Aevrin account (requires AEVRIN_API_KEY).")
|
|
36
|
+
] = False,
|
|
37
|
+
fail_on: Annotated[
|
|
38
|
+
str, typer.Option("--fail-on", help="Minimum severity that causes a non-zero exit code.")
|
|
39
|
+
] = "high",
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Run the full Aevrin scan pipeline against TARGET."""
|
|
42
|
+
try:
|
|
43
|
+
fail_on_severity = Severity(fail_on.lower())
|
|
44
|
+
except ValueError:
|
|
45
|
+
output.print_error(
|
|
46
|
+
f"Invalid --fail-on value '{fail_on}'. Expected one of: "
|
|
47
|
+
f"{', '.join(s.value for s in Severity)}."
|
|
48
|
+
)
|
|
49
|
+
raise typer.Exit(code=2) from None
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
target_type, normalized_target = detect_target(target)
|
|
53
|
+
except TargetDetectionError as exc:
|
|
54
|
+
output.print_error(str(exc))
|
|
55
|
+
raise typer.Exit(code=2) from None
|
|
56
|
+
|
|
57
|
+
config = PipelineConfig(github_token=os.environ.get("GITHUB_TOKEN"))
|
|
58
|
+
|
|
59
|
+
def on_stage(stage: ScanStage) -> None:
|
|
60
|
+
if not json_output:
|
|
61
|
+
output.print_stage_update(stage.name.value, stage.status.value, stage.error)
|
|
62
|
+
|
|
63
|
+
def on_findings(findings: list[Finding]) -> None:
|
|
64
|
+
pass # collected on the returned Scan object; nothing to stream for the CLI
|
|
65
|
+
|
|
66
|
+
result = run_pipeline(
|
|
67
|
+
target_type=target_type,
|
|
68
|
+
target=normalized_target,
|
|
69
|
+
config=config,
|
|
70
|
+
on_stage=on_stage,
|
|
71
|
+
on_findings=on_findings,
|
|
72
|
+
scan_id=uuid4(),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if json_output:
|
|
76
|
+
output.print_json_report(result)
|
|
77
|
+
else:
|
|
78
|
+
output.print_terminal_report(result)
|
|
79
|
+
|
|
80
|
+
if upload:
|
|
81
|
+
try:
|
|
82
|
+
upload_scan(result)
|
|
83
|
+
if not json_output:
|
|
84
|
+
output.stderr_console.print("[green]Uploaded to your Aevrin account.[/green]")
|
|
85
|
+
except UploadError as exc:
|
|
86
|
+
output.print_error(str(exc))
|
|
87
|
+
raise typer.Exit(code=2) from None
|
|
88
|
+
|
|
89
|
+
all_stages_failed = result.stages and all(s.status == StageStatus.FAILED for s in result.stages)
|
|
90
|
+
if all_stages_failed:
|
|
91
|
+
raise typer.Exit(code=2)
|
|
92
|
+
|
|
93
|
+
worst = max(
|
|
94
|
+
(f.severity for f in result.findings if not f.not_tested),
|
|
95
|
+
key=lambda s: _SEVERITY_RANK[s],
|
|
96
|
+
default=None,
|
|
97
|
+
)
|
|
98
|
+
if worst is not None and _SEVERITY_RANK[worst] >= _SEVERITY_RANK[fail_on_severity]:
|
|
99
|
+
raise typer.Exit(code=1)
|
|
100
|
+
raise typer.Exit(code=0)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def version() -> None:
|
|
105
|
+
"""Print the installed aevrin CLI version."""
|
|
106
|
+
from importlib.metadata import version as pkg_version
|
|
107
|
+
|
|
108
|
+
typer.echo(pkg_version("aevrin"))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main() -> None:
|
|
112
|
+
app()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Terminal + JSON rendering. Severity colors match the website's dedicated
|
|
2
|
+
severity tokens (critical/high/medium/low get distinct, consistent colors
|
|
3
|
+
used nowhere else), approximated in the 256-color terminal palette.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from aevrin_scanner_core import Scan, Severity, category_label, verdict
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
stdout_console = Console()
|
|
16
|
+
stderr_console = Console(stderr=True)
|
|
17
|
+
|
|
18
|
+
_SEVERITY_STYLE: dict[Severity, str] = {
|
|
19
|
+
Severity.CRITICAL: "bold red",
|
|
20
|
+
Severity.HIGH: "bold dark_orange",
|
|
21
|
+
Severity.MEDIUM: "bold yellow",
|
|
22
|
+
Severity.LOW: "bold blue",
|
|
23
|
+
Severity.INFO: "dim",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_stage_update(name: str, status: str, error: str | None = None) -> None:
|
|
28
|
+
icon = {"running": "…", "done": "✓", "failed": "✗", "skipped": "–"}.get(status, "?")
|
|
29
|
+
line = f"[dim]\\[{icon}][/dim] {name.replace('_', ' ')}"
|
|
30
|
+
if error:
|
|
31
|
+
line += f" [red]({error})[/red]"
|
|
32
|
+
stderr_console.print(line)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def print_terminal_report(scan: Scan) -> None:
|
|
36
|
+
stdout_console.print()
|
|
37
|
+
stdout_console.print(f"[bold]Target:[/bold] {scan.target}")
|
|
38
|
+
score = scan.score if scan.score is not None else 0
|
|
39
|
+
score_style = "bold green" if score >= 90 else "bold yellow" if score >= 40 else "bold red"
|
|
40
|
+
stdout_console.print(f"[bold]Score:[/bold] [{score_style}]{score}/100[/{score_style}] {verdict(score)}")
|
|
41
|
+
stdout_console.print()
|
|
42
|
+
|
|
43
|
+
real_findings = [f for f in scan.findings if not f.not_tested]
|
|
44
|
+
not_tested = [f for f in scan.findings if f.not_tested]
|
|
45
|
+
|
|
46
|
+
if not real_findings:
|
|
47
|
+
stdout_console.print("[green]No findings — clean scan.[/green]")
|
|
48
|
+
else:
|
|
49
|
+
table = Table(show_lines=False)
|
|
50
|
+
table.add_column("Severity")
|
|
51
|
+
table.add_column("Title")
|
|
52
|
+
table.add_column("OWASP category")
|
|
53
|
+
table.add_column("Tool")
|
|
54
|
+
for f in sorted(real_findings, key=lambda f: list(Severity).index(f.severity)):
|
|
55
|
+
style = _SEVERITY_STYLE[f.severity]
|
|
56
|
+
table.add_row(
|
|
57
|
+
f"[{style}]{f.severity.value.upper()}[/{style}]",
|
|
58
|
+
f.title,
|
|
59
|
+
category_label(f.owasp_category),
|
|
60
|
+
f.tool.value,
|
|
61
|
+
)
|
|
62
|
+
stdout_console.print(table)
|
|
63
|
+
|
|
64
|
+
for f in not_tested:
|
|
65
|
+
stdout_console.print()
|
|
66
|
+
stdout_console.print(f"[dim]Note: {f.description}[/dim]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def print_json_report(scan: Scan) -> None:
|
|
70
|
+
payload: dict[str, Any] = {
|
|
71
|
+
"target": scan.target,
|
|
72
|
+
"target_type": scan.target_type.value,
|
|
73
|
+
"status": scan.status.value,
|
|
74
|
+
"score": scan.score,
|
|
75
|
+
"verdict": verdict(scan.score) if scan.score is not None else None,
|
|
76
|
+
"findings": [
|
|
77
|
+
{
|
|
78
|
+
"id": str(f.id),
|
|
79
|
+
"tool": f.tool.value,
|
|
80
|
+
"owasp_category": f.owasp_category.value,
|
|
81
|
+
"owasp_category_label": category_label(f.owasp_category),
|
|
82
|
+
"severity": f.severity.value,
|
|
83
|
+
"title": f.title,
|
|
84
|
+
"description": f.description,
|
|
85
|
+
"file_path": f.location.file_path,
|
|
86
|
+
"line_start": f.location.line_start,
|
|
87
|
+
"line_end": f.location.line_end,
|
|
88
|
+
"manifest_field": f.location.manifest_field,
|
|
89
|
+
"remediation": f.remediation,
|
|
90
|
+
"verified": f.verified,
|
|
91
|
+
"not_tested": f.not_tested,
|
|
92
|
+
}
|
|
93
|
+
for f in scan.findings
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
print(json.dumps(payload, indent=2))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def print_error(message: str) -> None:
|
|
100
|
+
stderr_console.print(f"[bold red]Error:[/bold red] {message}")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Auto-detects whether a CLI target is a GitHub URL, a local path, or a
|
|
2
|
+
live MCP server URL — the three modes Section 7 of the spec lists for
|
|
3
|
+
`aevrin scan <target>`. Website Screen 1's fourth mode (paste config) is a
|
|
4
|
+
browser-only affordance, not something the CLI needs to detect from a
|
|
5
|
+
single string argument.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from aevrin_scanner_core import TargetType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TargetDetectionError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_target(raw: str) -> tuple[TargetType, str]:
|
|
20
|
+
"""Returns (target_type, normalized_target)."""
|
|
21
|
+
target = raw.strip()
|
|
22
|
+
if not target:
|
|
23
|
+
raise TargetDetectionError("Target must not be empty.")
|
|
24
|
+
|
|
25
|
+
if target.startswith(("http://", "https://")):
|
|
26
|
+
host = target.split("://", 1)[1].split("/", 1)[0]
|
|
27
|
+
if host in ("github.com", "www.github.com"):
|
|
28
|
+
return TargetType.GITHUB_REPO, _normalize_github_url(target)
|
|
29
|
+
return TargetType.LIVE_MCP_SERVER, target
|
|
30
|
+
|
|
31
|
+
if target.startswith("github.com/"):
|
|
32
|
+
return TargetType.GITHUB_REPO, _normalize_github_url(f"https://{target}")
|
|
33
|
+
|
|
34
|
+
if os.path.exists(target):
|
|
35
|
+
return TargetType.LOCAL_PATH, os.path.abspath(target)
|
|
36
|
+
|
|
37
|
+
raise TargetDetectionError(
|
|
38
|
+
f"Could not determine target type for '{raw}'. Expected a GitHub URL "
|
|
39
|
+
"(https://github.com/owner/repo), a live server URL (https://...), "
|
|
40
|
+
"or a local path that exists on disk."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _normalize_github_url(url: str) -> str:
|
|
45
|
+
normalized = url.replace("://www.github.com", "://github.com")
|
|
46
|
+
return normalized.removesuffix("/").removesuffix(".git")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from aevrin_scanner_core import Scan
|
|
7
|
+
|
|
8
|
+
DEFAULT_API_URL = "https://api.aevrin.dev"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UploadError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def upload_scan(scan: Scan) -> None:
|
|
16
|
+
api_key = os.environ.get("AEVRIN_API_KEY")
|
|
17
|
+
if not api_key:
|
|
18
|
+
raise UploadError(
|
|
19
|
+
"--upload requires AEVRIN_API_KEY. Get one from your account settings "
|
|
20
|
+
"(Aevrin dashboard → API keys) and set it in your environment."
|
|
21
|
+
)
|
|
22
|
+
api_url = os.environ.get("AEVRIN_API_URL", DEFAULT_API_URL)
|
|
23
|
+
|
|
24
|
+
body = {
|
|
25
|
+
"target_type": scan.target_type.value,
|
|
26
|
+
"target": scan.target,
|
|
27
|
+
"score": scan.score if scan.score is not None else 0,
|
|
28
|
+
"findings": [
|
|
29
|
+
{
|
|
30
|
+
"id": str(f.id),
|
|
31
|
+
"tool": f.tool.value,
|
|
32
|
+
"owasp_category": f.owasp_category.value,
|
|
33
|
+
"severity": f.severity.value,
|
|
34
|
+
"title": f.title,
|
|
35
|
+
"description": f.description,
|
|
36
|
+
"file_path": f.location.file_path,
|
|
37
|
+
"line_start": f.location.line_start,
|
|
38
|
+
"line_end": f.location.line_end,
|
|
39
|
+
"manifest_field": f.location.manifest_field,
|
|
40
|
+
"tool_name_in_manifest": f.location.tool_name_in_manifest,
|
|
41
|
+
"remediation": f.remediation,
|
|
42
|
+
"verified": f.verified,
|
|
43
|
+
"not_tested": f.not_tested,
|
|
44
|
+
"raw": None, # don't upload raw tool output — keep the payload small and predictable
|
|
45
|
+
}
|
|
46
|
+
for f in scan.findings
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
resp = httpx.post(
|
|
52
|
+
f"{api_url}/cli/upload",
|
|
53
|
+
json=body,
|
|
54
|
+
headers={"X-API-Key": api_key},
|
|
55
|
+
timeout=30,
|
|
56
|
+
)
|
|
57
|
+
except httpx.HTTPError as exc:
|
|
58
|
+
raise UploadError(f"Could not reach {api_url}: {exc}") from exc
|
|
59
|
+
|
|
60
|
+
if resp.status_code >= 400:
|
|
61
|
+
detail = resp.text
|
|
62
|
+
try:
|
|
63
|
+
detail = resp.json().get("detail", detail)
|
|
64
|
+
except ValueError:
|
|
65
|
+
pass
|
|
66
|
+
raise UploadError(f"Upload failed ({resp.status_code}): {detail}")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from aevrin_scanner_core import TargetType
|
|
3
|
+
|
|
4
|
+
from aevrin_cli.target_detection import TargetDetectionError, detect_target
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_detects_github_https_url():
|
|
8
|
+
target_type, normalized = detect_target("https://github.com/owner/repo")
|
|
9
|
+
assert target_type == TargetType.GITHUB_REPO
|
|
10
|
+
assert normalized == "https://github.com/owner/repo"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_detects_github_shorthand():
|
|
14
|
+
target_type, normalized = detect_target("github.com/owner/repo")
|
|
15
|
+
assert target_type == TargetType.GITHUB_REPO
|
|
16
|
+
assert normalized == "https://github.com/owner/repo"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_normalizes_trailing_slash_and_git_suffix():
|
|
20
|
+
_, normalized = detect_target("https://github.com/owner/repo.git/")
|
|
21
|
+
assert normalized == "https://github.com/owner/repo"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_normalizes_www_github():
|
|
25
|
+
target_type, normalized = detect_target("https://www.github.com/owner/repo")
|
|
26
|
+
assert target_type == TargetType.GITHUB_REPO
|
|
27
|
+
assert normalized == "https://github.com/owner/repo"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_detects_live_server_url():
|
|
31
|
+
target_type, normalized = detect_target("https://my-mcp-server.example.com")
|
|
32
|
+
assert target_type == TargetType.LIVE_MCP_SERVER
|
|
33
|
+
assert normalized == "https://my-mcp-server.example.com"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_detects_local_path(tmp_path):
|
|
37
|
+
target_type, normalized = detect_target(str(tmp_path))
|
|
38
|
+
assert target_type == TargetType.LOCAL_PATH
|
|
39
|
+
assert normalized == str(tmp_path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_rejects_empty_target():
|
|
43
|
+
with pytest.raises(TargetDetectionError):
|
|
44
|
+
detect_target(" ")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_rejects_nonexistent_path_and_non_url():
|
|
48
|
+
with pytest.raises(TargetDetectionError):
|
|
49
|
+
detect_target("./definitely-does-not-exist-xyz")
|