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 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
@@ -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)