cystene 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.
- cystene-0.1.0/PKG-INFO +112 -0
- cystene-0.1.0/README.md +89 -0
- cystene-0.1.0/pyproject.toml +64 -0
- cystene-0.1.0/src/cystene/__init__.py +1 -0
- cystene-0.1.0/src/cystene/_scan_flow.py +211 -0
- cystene-0.1.0/src/cystene/auth_provider.py +272 -0
- cystene-0.1.0/src/cystene/cli.py +250 -0
- cystene-0.1.0/src/cystene/client.py +160 -0
- cystene-0.1.0/src/cystene/config.py +28 -0
- cystene-0.1.0/src/cystene/credentials.py +50 -0
- cystene-0.1.0/src/cystene/server.py +59 -0
- cystene-0.1.0/src/cystene/tools/__init__.py +1 -0
- cystene-0.1.0/src/cystene/tools/context.py +74 -0
- cystene-0.1.0/src/cystene/tools/discovery.py +107 -0
- cystene-0.1.0/src/cystene/tools/execution.py +103 -0
- cystene-0.1.0/src/cystene/tools/infrastructure.py +50 -0
cystene-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cystene
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cystene CLI + MCP — scan your infrastructure from your terminal or your AI assistant
|
|
5
|
+
Keywords: cystene,security,scanner,pentest,espm,mcp,cli,ai,claude,llm
|
|
6
|
+
Author: Robert Radoslav
|
|
7
|
+
Author-email: Robert Radoslav <43938206+rbtrsv@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: click>=8.4.2
|
|
16
|
+
Requires-Dist: fastmcp>=3.4.4
|
|
17
|
+
Requires-Dist: httpx>=0.28.1
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Project-URL: Documentation, https://www.cystene.com
|
|
20
|
+
Project-URL: Homepage, https://www.cystene.com
|
|
21
|
+
Project-URL: PyPI, https://pypi.org/project/cystene/
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# cystene
|
|
25
|
+
|
|
26
|
+
**Cystene CLI + MCP** — scan your infrastructure from your terminal or your AI assistant.
|
|
27
|
+
|
|
28
|
+
One `pip install` ships three entry points over a single shared core:
|
|
29
|
+
|
|
30
|
+
- **`cystene`** — the CLI. `cystene scan <url>` runs the engines and prints ranked findings.
|
|
31
|
+
- **`cystene-mcp`** — the MCP server over stdio (Claude Desktop / Claude Code / Cursor).
|
|
32
|
+
- **`cystene-mcp-http`** — the MCP server over HTTP + OAuth (remote, `mcp.cystene.com`).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install cystene
|
|
38
|
+
# or: uv tool install cystene
|
|
39
|
+
cystene --help
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Login
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cystene login # browser (OAuth device flow)
|
|
46
|
+
cystene login --basic # email/password in the terminal (headless / CI)
|
|
47
|
+
cystene status # show current auth status
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Credentials are stored locally in `~/.cystene/credentials.json` (chmod 600). Your
|
|
51
|
+
password never reaches the AI — login happens in your browser or terminal.
|
|
52
|
+
|
|
53
|
+
## Scan from the terminal
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cystene scan https://app.example.com
|
|
57
|
+
cystene scan https://app.example.com --engines web_scan,baas_scan,secret_scan
|
|
58
|
+
cystene scan https://app.example.com --fail-on critical
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`scan` finds or creates a target for the URL, runs the external engines, prints the
|
|
62
|
+
findings ranked worst-first with severity + confidence, and exits non-zero when any
|
|
63
|
+
finding is at or above `--fail-on` (default `high`) — drop it straight into CI:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
- run: pip install cystene && cystene login --basic <<< "$CYSTENE_EMAIL\n$CYSTENE_PASSWORD"
|
|
67
|
+
- run: cystene scan "$DEPLOY_URL" --fail-on high
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Use from your AI assistant (MCP)
|
|
71
|
+
|
|
72
|
+
After `cystene login`, wire the stdio server into your client:
|
|
73
|
+
|
|
74
|
+
**Claude Code:**
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
claude mcp add cystene -- cystene-mcp
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Claude Desktop / Cursor** — add to the MCP config:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"mcpServers": {
|
|
85
|
+
"cystene": { "command": "cystene-mcp" }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Then ask: *"Scan https://app.example.com and show me the critical findings."*
|
|
91
|
+
|
|
92
|
+
## Tools (MCP)
|
|
93
|
+
|
|
94
|
+
| Group | Tools |
|
|
95
|
+
|-------|-------|
|
|
96
|
+
| **Context** | get_permissions, list_organizations, set_organization |
|
|
97
|
+
| **Infrastructure** | list_scan_targets, create_scan_target |
|
|
98
|
+
| **Execution** | scan, list_scan_templates, create_scan_template, start_scan, get_scan_job, list_scan_jobs |
|
|
99
|
+
| **Discovery** | list_findings, get_finding, list_assets, get_dashboard, generate_report |
|
|
100
|
+
|
|
101
|
+
## Security
|
|
102
|
+
|
|
103
|
+
- Passwords never reach the AI — login happens in your browser or terminal.
|
|
104
|
+
- Credentials stored locally in `~/.cystene/credentials.json` (chmod 600).
|
|
105
|
+
- Automatic token refresh on expiry.
|
|
106
|
+
- No destructive operations exposed over MCP.
|
|
107
|
+
- All API validations, permissions, subscription gating, and audit logging apply.
|
|
108
|
+
|
|
109
|
+
## Requirements
|
|
110
|
+
|
|
111
|
+
- Python 3.12+
|
|
112
|
+
- A [Cystene](https://www.cystene.com) account
|
cystene-0.1.0/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# cystene
|
|
2
|
+
|
|
3
|
+
**Cystene CLI + MCP** — scan your infrastructure from your terminal or your AI assistant.
|
|
4
|
+
|
|
5
|
+
One `pip install` ships three entry points over a single shared core:
|
|
6
|
+
|
|
7
|
+
- **`cystene`** — the CLI. `cystene scan <url>` runs the engines and prints ranked findings.
|
|
8
|
+
- **`cystene-mcp`** — the MCP server over stdio (Claude Desktop / Claude Code / Cursor).
|
|
9
|
+
- **`cystene-mcp-http`** — the MCP server over HTTP + OAuth (remote, `mcp.cystene.com`).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install cystene
|
|
15
|
+
# or: uv tool install cystene
|
|
16
|
+
cystene --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Login
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cystene login # browser (OAuth device flow)
|
|
23
|
+
cystene login --basic # email/password in the terminal (headless / CI)
|
|
24
|
+
cystene status # show current auth status
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Credentials are stored locally in `~/.cystene/credentials.json` (chmod 600). Your
|
|
28
|
+
password never reaches the AI — login happens in your browser or terminal.
|
|
29
|
+
|
|
30
|
+
## Scan from the terminal
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
cystene scan https://app.example.com
|
|
34
|
+
cystene scan https://app.example.com --engines web_scan,baas_scan,secret_scan
|
|
35
|
+
cystene scan https://app.example.com --fail-on critical
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`scan` finds or creates a target for the URL, runs the external engines, prints the
|
|
39
|
+
findings ranked worst-first with severity + confidence, and exits non-zero when any
|
|
40
|
+
finding is at or above `--fail-on` (default `high`) — drop it straight into CI:
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
- run: pip install cystene && cystene login --basic <<< "$CYSTENE_EMAIL\n$CYSTENE_PASSWORD"
|
|
44
|
+
- run: cystene scan "$DEPLOY_URL" --fail-on high
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Use from your AI assistant (MCP)
|
|
48
|
+
|
|
49
|
+
After `cystene login`, wire the stdio server into your client:
|
|
50
|
+
|
|
51
|
+
**Claude Code:**
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
claude mcp add cystene -- cystene-mcp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Claude Desktop / Cursor** — add to the MCP config:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"cystene": { "command": "cystene-mcp" }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then ask: *"Scan https://app.example.com and show me the critical findings."*
|
|
68
|
+
|
|
69
|
+
## Tools (MCP)
|
|
70
|
+
|
|
71
|
+
| Group | Tools |
|
|
72
|
+
|-------|-------|
|
|
73
|
+
| **Context** | get_permissions, list_organizations, set_organization |
|
|
74
|
+
| **Infrastructure** | list_scan_targets, create_scan_target |
|
|
75
|
+
| **Execution** | scan, list_scan_templates, create_scan_template, start_scan, get_scan_job, list_scan_jobs |
|
|
76
|
+
| **Discovery** | list_findings, get_finding, list_assets, get_dashboard, generate_report |
|
|
77
|
+
|
|
78
|
+
## Security
|
|
79
|
+
|
|
80
|
+
- Passwords never reach the AI — login happens in your browser or terminal.
|
|
81
|
+
- Credentials stored locally in `~/.cystene/credentials.json` (chmod 600).
|
|
82
|
+
- Automatic token refresh on expiry.
|
|
83
|
+
- No destructive operations exposed over MCP.
|
|
84
|
+
- All API validations, permissions, subscription gating, and audit logging apply.
|
|
85
|
+
|
|
86
|
+
## Requirements
|
|
87
|
+
|
|
88
|
+
- Python 3.12+
|
|
89
|
+
- A [Cystene](https://www.cystene.com) account
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cystene"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Cystene CLI + MCP — scan your infrastructure from your terminal or your AI assistant"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Robert Radoslav", email = "43938206+rbtrsv@users.noreply.github.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
keywords = ["cystene", "security", "scanner", "pentest", "espm", "mcp", "cli", "ai", "claude", "llm"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Intended Audience :: System Administrators",
|
|
16
|
+
"Topic :: Security",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
]
|
|
20
|
+
# Pinned to the latest stable releases on PyPI (2026-07-15), NOT the older
|
|
21
|
+
# versions the finpy/nexotype reference packages ship — we build against current.
|
|
22
|
+
dependencies = [
|
|
23
|
+
"click>=8.4.2",
|
|
24
|
+
"fastmcp>=3.4.4",
|
|
25
|
+
"httpx>=0.28.1",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://www.cystene.com"
|
|
30
|
+
Documentation = "https://www.cystene.com"
|
|
31
|
+
"PyPI" = "https://pypi.org/project/cystene/"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
cystene = "cystene.cli:main" # human CLI — cystene scan <url>, login, status
|
|
35
|
+
cystene-mcp = "cystene.server:main" # stdio — Claude Desktop/Code/Cursor
|
|
36
|
+
cystene-mcp-http = "cystene.server:main_http" # HTTP + OAuth — claude.ai / remote clients (mcp.cystene.com)
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.8.15,<0.9.0"]
|
|
40
|
+
build-backend = "uv_build"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=8.0",
|
|
45
|
+
"pytest-asyncio>=0.24",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Project test convention: filenames suffixed with `_test.py` (NOT prefixed
|
|
49
|
+
# `test_*`). Matches the server's `apps/{module}/tests/` layout. Async tests
|
|
50
|
+
# auto-await via asyncio_mode = "auto" — no @pytest.mark.asyncio needed.
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
python_files = ["*_test.py"]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
|
|
56
|
+
# Pylance/Pyright can't infer the src/ layout from the uv editable install
|
|
57
|
+
# (`.pth` file). Pointing `extraPaths` at `src` makes `from cystene...`
|
|
58
|
+
# resolve in the IDE without affecting runtime — pytest already finds it
|
|
59
|
+
# via the installed package.
|
|
60
|
+
[tool.pyright]
|
|
61
|
+
include = ["src", "tests"]
|
|
62
|
+
extraPaths = ["src"]
|
|
63
|
+
venvPath = "."
|
|
64
|
+
venv = ".venv"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Hello from cystene — CLI (`cystene`) + MCP (`cystene-mcp`) over one shared core.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cystene — scan orchestration (shared core for CLI and MCP).
|
|
3
|
+
|
|
4
|
+
The one-command `scan <url>` flow: find-or-create a ScanTarget for the URL,
|
|
5
|
+
find-or-create a reusable "CLI scan" template, start the job, poll until it
|
|
6
|
+
finishes, then return the findings. Used by BOTH the CLI `scan` command (via
|
|
7
|
+
asyncio.run) and the MCP `scan` tool (awaited directly) — one implementation,
|
|
8
|
+
zero duplication.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import ipaddress
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from .config import SCAN_MAX_WAIT, SCAN_POLL_INTERVAL
|
|
16
|
+
from .client import CysteneClient
|
|
17
|
+
|
|
18
|
+
# Default engine set for `scan <url>`: every external scanner that needs NEITHER
|
|
19
|
+
# credentials NOR active-scan consent. Excludes active_web_scan (consent),
|
|
20
|
+
# access_control_scan (creds+consent), host/cloud/ad_audit (creds), mobile_scan (upload).
|
|
21
|
+
# MAINTENANCE (re-verify when a scanner is added/renamed): mirror the SCANNERS keys in
|
|
22
|
+
# server/apps/cybersecurity/scanners/__init__.py — HOW TO CHECK: grep that file's dict keys.
|
|
23
|
+
DEFAULT_ENGINES = [
|
|
24
|
+
"port_scan", "dns_enum", "ssl_check", "web_scan", "vuln_scan", "api_scan",
|
|
25
|
+
"password_audit", "baas_scan", "secret_scan", "client_auth_scan",
|
|
26
|
+
"subdomain_takeover_scan", "dependency_scan", "cms_scan",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Reusable template name the CLI/MCP scan flow creates per target — kept idempotent.
|
|
30
|
+
CLI_TEMPLATE_NAME = "CLI scan"
|
|
31
|
+
|
|
32
|
+
# Severity ordering — drives the exit-code gate and worst-first sorting.
|
|
33
|
+
SEVERITY_RANK = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
|
|
34
|
+
|
|
35
|
+
# Job lifecycle states that mean the poll loop can stop.
|
|
36
|
+
TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ==========================================
|
|
40
|
+
# Target + template resolution (find-or-create)
|
|
41
|
+
# ==========================================
|
|
42
|
+
|
|
43
|
+
def infer_target_type(value: str) -> str:
|
|
44
|
+
"""Classify a user-supplied target into a TargetType value.
|
|
45
|
+
URL if it carries a scheme; IP if the host parses as an address; else a bare domain."""
|
|
46
|
+
v = value.strip()
|
|
47
|
+
if "://" in v:
|
|
48
|
+
return "url"
|
|
49
|
+
host = v.split("/")[0].split(":")[0]
|
|
50
|
+
try:
|
|
51
|
+
ipaddress.ip_address(host)
|
|
52
|
+
return "ip"
|
|
53
|
+
except ValueError:
|
|
54
|
+
return "domain"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _same_value(a: str | None, b: str) -> bool:
|
|
58
|
+
"""Case-insensitive exact match of two target values."""
|
|
59
|
+
return (a or "").strip().lower() == b.strip().lower()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _engine_set(scan_types: str | None) -> set[str]:
|
|
63
|
+
"""Parse a comma-separated scan_types string into a normalized engine set.
|
|
64
|
+
Why a set: template reuse must be order-insensitive — "web_scan,baas_scan" and
|
|
65
|
+
"baas_scan,web_scan" are the same template, not two."""
|
|
66
|
+
return {e.strip() for e in (scan_types or "").split(",") if e.strip()}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def find_or_create_target(client: CysteneClient, value: str) -> dict:
|
|
70
|
+
"""Return an existing ScanTarget matching `value`, or create one.
|
|
71
|
+
Why find-first: on the FREE tier (1 target) and on repeat scans we reuse the
|
|
72
|
+
same target instead of piling up rows."""
|
|
73
|
+
listing = await client.get("/cybersecurity/scan-targets/")
|
|
74
|
+
for target in listing.get("data") or []:
|
|
75
|
+
if _same_value(target.get("target_value"), value):
|
|
76
|
+
return target
|
|
77
|
+
created = await client.post(
|
|
78
|
+
"/cybersecurity/scan-targets/",
|
|
79
|
+
{"name": value, "target_type": infer_target_type(value), "target_value": value},
|
|
80
|
+
)
|
|
81
|
+
return created["data"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def find_or_create_template(client: CysteneClient, target_id: int, engines: list[str]) -> dict:
|
|
85
|
+
"""Return the reusable "CLI scan" template for this target with the requested
|
|
86
|
+
engine set, or create it. Idempotent per (target, engine-set) regardless of order."""
|
|
87
|
+
requested = _engine_set(",".join(engines))
|
|
88
|
+
scan_types = ",".join(engines)
|
|
89
|
+
listing = await client.get("/cybersecurity/scan-templates/")
|
|
90
|
+
for tpl in listing.get("data") or []:
|
|
91
|
+
if (
|
|
92
|
+
tpl.get("target_id") == target_id
|
|
93
|
+
and tpl.get("name") == CLI_TEMPLATE_NAME
|
|
94
|
+
and _engine_set(tpl.get("scan_types")) == requested
|
|
95
|
+
):
|
|
96
|
+
return tpl
|
|
97
|
+
created = await client.post(
|
|
98
|
+
"/cybersecurity/scan-templates/",
|
|
99
|
+
{"target_id": target_id, "name": CLI_TEMPLATE_NAME, "scan_types": scan_types},
|
|
100
|
+
)
|
|
101
|
+
return created["data"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ==========================================
|
|
105
|
+
# Job start + poll + findings
|
|
106
|
+
# ==========================================
|
|
107
|
+
|
|
108
|
+
async def start_scan_job(client: CysteneClient, target_id: int, template_id: int) -> dict:
|
|
109
|
+
"""Start a scan job (returns immediately with status=pending).
|
|
110
|
+
Note: /scan-jobs/start takes its arguments as query params, not a JSON body."""
|
|
111
|
+
started = await client.post(
|
|
112
|
+
"/cybersecurity/scan-jobs/start",
|
|
113
|
+
params={"target_id": target_id, "template_id": template_id},
|
|
114
|
+
)
|
|
115
|
+
return started["data"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def poll_job(client: CysteneClient, job_id: int, on_poll=None) -> dict:
|
|
119
|
+
"""Poll GET /scan-jobs/{id} until it reaches a terminal status or SCAN_MAX_WAIT.
|
|
120
|
+
`on_poll(job)` fires after each poll so callers can show live progress. On
|
|
121
|
+
timeout the job keeps running server-side; we return the last snapshot."""
|
|
122
|
+
deadline = time.monotonic() + SCAN_MAX_WAIT
|
|
123
|
+
while True:
|
|
124
|
+
job = (await client.get(f"/cybersecurity/scan-jobs/{job_id}"))["data"]
|
|
125
|
+
if on_poll:
|
|
126
|
+
on_poll(job)
|
|
127
|
+
if job.get("status") in TERMINAL_STATUSES or time.monotonic() >= deadline:
|
|
128
|
+
return job
|
|
129
|
+
await asyncio.sleep(SCAN_POLL_INTERVAL)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def get_findings(client: CysteneClient, job_id: int, severity: str | None = None) -> list[dict]:
|
|
133
|
+
"""List findings for one scan job, optionally filtered by severity."""
|
|
134
|
+
params: dict = {"scan_job_id": job_id}
|
|
135
|
+
if severity:
|
|
136
|
+
params["severity"] = severity
|
|
137
|
+
resp = await client.get("/cybersecurity/findings/", params=params)
|
|
138
|
+
return resp.get("data") or []
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def run_scan(client: CysteneClient, value: str, engines: list[str] | None = None, on_event=None) -> dict:
|
|
142
|
+
"""Full `scan <url>` orchestration. Returns {target, template, job, findings}.
|
|
143
|
+
`on_event(name, payload)` fires at each stage (target / template / job_started /
|
|
144
|
+
poll) for live CLI progress; MCP passes None (no streaming)."""
|
|
145
|
+
engines = engines or DEFAULT_ENGINES
|
|
146
|
+
emit = on_event or (lambda *_: None)
|
|
147
|
+
|
|
148
|
+
target = await find_or_create_target(client, value)
|
|
149
|
+
emit("target", target)
|
|
150
|
+
template = await find_or_create_template(client, target["id"], engines)
|
|
151
|
+
emit("template", template)
|
|
152
|
+
job = await start_scan_job(client, target["id"], template["id"])
|
|
153
|
+
emit("job_started", job)
|
|
154
|
+
job = await poll_job(client, job["id"], on_poll=lambda j: emit("poll", j))
|
|
155
|
+
findings = await get_findings(client, job["id"])
|
|
156
|
+
return {"target": target, "template": template, "job": job, "findings": findings}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ==========================================
|
|
160
|
+
# Rendering + exit-code gate (shared by CLI and MCP)
|
|
161
|
+
# ==========================================
|
|
162
|
+
|
|
163
|
+
def sort_findings(findings: list[dict]) -> list[dict]:
|
|
164
|
+
"""Worst severity first, then alphabetically by title."""
|
|
165
|
+
return sorted(
|
|
166
|
+
findings,
|
|
167
|
+
key=lambda f: (-SEVERITY_RANK.get(f.get("severity", "info"), 0), f.get("title", "")),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def worst_severity_rank(findings: list[dict]) -> int:
|
|
172
|
+
"""Highest severity rank across findings, or -1 when there are none."""
|
|
173
|
+
return max((SEVERITY_RANK.get(f.get("severity", "info"), 0) for f in findings), default=-1)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def should_fail(findings: list[dict], fail_on: str) -> bool:
|
|
177
|
+
"""True when any finding is at or above the `fail_on` severity — drives exit codes/CI."""
|
|
178
|
+
threshold = SEVERITY_RANK.get(fail_on, SEVERITY_RANK["high"])
|
|
179
|
+
return worst_severity_rank(findings) >= threshold
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def render_scan_result(result: dict) -> str:
|
|
183
|
+
"""Plain-text report of a scan result — printed by the CLI and returned by the
|
|
184
|
+
MCP `scan` tool. One renderer, no ANSI (keeps it LLM-friendly for MCP)."""
|
|
185
|
+
job = result["job"]
|
|
186
|
+
target = result["target"]
|
|
187
|
+
findings = sort_findings(result["findings"])
|
|
188
|
+
|
|
189
|
+
lines = [
|
|
190
|
+
f"Scanned {target.get('target_value')} [job #{job.get('id')}] status={job.get('status')}",
|
|
191
|
+
(
|
|
192
|
+
f"CRITICAL {job.get('critical_count', 0)} HIGH {job.get('high_count', 0)} "
|
|
193
|
+
f"MEDIUM {job.get('medium_count', 0)} LOW {job.get('low_count', 0)} "
|
|
194
|
+
f"INFO {job.get('info_count', 0)}"
|
|
195
|
+
),
|
|
196
|
+
]
|
|
197
|
+
if findings:
|
|
198
|
+
lines.append("")
|
|
199
|
+
for f in findings:
|
|
200
|
+
sev = (f.get("severity") or "info").upper()
|
|
201
|
+
conf = f.get("confidence") or "confirmed"
|
|
202
|
+
lines.append(f"[{sev} · {conf}] {f.get('title')} ({f.get('finding_type')})")
|
|
203
|
+
else:
|
|
204
|
+
lines.append("")
|
|
205
|
+
lines.append("No findings.")
|
|
206
|
+
|
|
207
|
+
score = job.get("security_score")
|
|
208
|
+
if score is not None:
|
|
209
|
+
lines.append("")
|
|
210
|
+
lines.append(f"Security score: {score}/100")
|
|
211
|
+
return "\n".join(lines)
|