iridium-client 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.
- iridium_client-0.1.0/.env.example +17 -0
- iridium_client-0.1.0/.gitignore +33 -0
- iridium_client-0.1.0/PKG-INFO +90 -0
- iridium_client-0.1.0/README.md +74 -0
- iridium_client-0.1.0/pyproject.toml +32 -0
- iridium_client-0.1.0/src/iridium_client/__init__.py +3 -0
- iridium_client-0.1.0/src/iridium_client/api/client.py +71 -0
- iridium_client-0.1.0/src/iridium_client/cli.py +122 -0
- iridium_client-0.1.0/src/iridium_client/demo/target.py +30 -0
- iridium_client-0.1.0/src/iridium_client/output/terminal.py +108 -0
- iridium_client-0.1.0/tests/test_api_client.py +85 -0
- iridium_client-0.1.0/tests/test_cli.py +31 -0
- iridium_client-0.1.0/tests/test_cli_extended.py +44 -0
- iridium_client-0.1.0/tests/test_terminal.py +70 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Iridium client configuration (copy to .env)
|
|
2
|
+
|
|
3
|
+
# SaaS API base URL (no trailing slash)
|
|
4
|
+
IRIDIUM_API_URL=https://api.iridium.example.com
|
|
5
|
+
|
|
6
|
+
# API key for authenticated scans (optional for demo/payload dump)
|
|
7
|
+
# IRIDIUM_API_KEY=iridium_live_...
|
|
8
|
+
|
|
9
|
+
# Public dashboard URL (share reports)
|
|
10
|
+
# IRIDIUM_PUBLIC_URL=https://iridium.example.com
|
|
11
|
+
|
|
12
|
+
# Privacy: disable product analytics pings (no AST/source in pings)
|
|
13
|
+
# DO_NOT_TRACK=1
|
|
14
|
+
# IRIDIUM_TELEMETRY=0
|
|
15
|
+
|
|
16
|
+
# Blind-graphing key for --anonymize mode
|
|
17
|
+
# IRIDIUM_ANON_KEY=
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Byte-compiled / cache
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
|
|
13
|
+
# Iridium local state
|
|
14
|
+
.iridium/
|
|
15
|
+
|
|
16
|
+
# Test / tooling
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
uv.lock
|
|
22
|
+
|
|
23
|
+
# IDE
|
|
24
|
+
.idea/
|
|
25
|
+
.vscode/
|
|
26
|
+
*.swp
|
|
27
|
+
|
|
28
|
+
# OS
|
|
29
|
+
.DS_Store
|
|
30
|
+
|
|
31
|
+
# Env secrets
|
|
32
|
+
.env
|
|
33
|
+
!.env.example
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: iridium-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Iridium CLI — local repo scanning with cloud reachability analysis
|
|
5
|
+
Project-URL: Homepage, https://github.com/mziqudhd92/Iridium
|
|
6
|
+
Project-URL: Repository, https://github.com/mziqudhd92/Iridium
|
|
7
|
+
Project-URL: Documentation, https://github.com/mziqudhd92/Iridium#readme
|
|
8
|
+
Author: Iridium Contributors
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: iridium-core==0.1.0
|
|
13
|
+
Requires-Dist: rich>=13.0
|
|
14
|
+
Requires-Dist: typer>=0.12
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# iridium-client
|
|
18
|
+
|
|
19
|
+
Typer CLI for Iridium reachability scanning — local AST extraction with cloud SaaS analysis.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install iridium-client
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## CLI commands
|
|
28
|
+
|
|
29
|
+
### `iridium-client demo`
|
|
30
|
+
|
|
31
|
+
Run the embedded vulnerable micro-target demo (<10s, no API key, no network required for indexing).
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
iridium-client demo
|
|
35
|
+
# or zero-install:
|
|
36
|
+
uvx iridium-client demo
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### `iridium-client scan`
|
|
40
|
+
|
|
41
|
+
Index a repository locally and submit the payload to Iridium SaaS.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export IRIDIUM_API_URL=https://api.iridium.example.com
|
|
45
|
+
export IRIDIUM_API_KEY=iridium_live_...
|
|
46
|
+
iridium-client scan .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
| Option | Description |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `--api-url` | Override `IRIDIUM_API_URL` |
|
|
52
|
+
| `--anonymize` | HMAC-hash internal symbols in payload (requires `IRIDIUM_ANON_KEY`) |
|
|
53
|
+
| `--no-telemetry` | Disable product analytics pings |
|
|
54
|
+
| `--on-error pass` | Show local stats if API is unreachable (default: `block`) |
|
|
55
|
+
|
|
56
|
+
### `iridium-client payload dump`
|
|
57
|
+
|
|
58
|
+
Dump the local scan payload as JSON (zero network).
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
iridium-client payload dump . --validate
|
|
62
|
+
iridium-client payload dump . --output payload.json --validate
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Option | Description |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `--validate` | Validate against `ClientScanPayload` schema |
|
|
68
|
+
| `--output`, `-o` | Write JSON to file instead of stdout |
|
|
69
|
+
|
|
70
|
+
## Environment variables
|
|
71
|
+
|
|
72
|
+
| Variable | Default | Description |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `IRIDIUM_API_URL` | `https://api.iridium.example.com` | SaaS API base URL |
|
|
75
|
+
| `IRIDIUM_API_KEY` | — | API key (`X-API-Key` header) |
|
|
76
|
+
| `DO_NOT_TRACK` | unset | `1` disables analytics |
|
|
77
|
+
| `IRIDIUM_TELEMETRY` | `1` | `0` disables analytics |
|
|
78
|
+
| `IRIDIUM_ANON_KEY` | — | Key for `--anonymize` mode |
|
|
79
|
+
|
|
80
|
+
Copy [`packages/iridium-client/.env.example`](.env.example) to `.env` for local development.
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
Part of the [Iridium monorepo](https://github.com/mziqudhd92/Iridium). From repo root:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv sync
|
|
88
|
+
uv run iridium-client demo
|
|
89
|
+
uv run pytest packages/iridium-client/tests -v
|
|
90
|
+
```
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# iridium-client
|
|
2
|
+
|
|
3
|
+
Typer CLI for Iridium reachability scanning — local AST extraction with cloud SaaS analysis.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install iridium-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## CLI commands
|
|
12
|
+
|
|
13
|
+
### `iridium-client demo`
|
|
14
|
+
|
|
15
|
+
Run the embedded vulnerable micro-target demo (<10s, no API key, no network required for indexing).
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
iridium-client demo
|
|
19
|
+
# or zero-install:
|
|
20
|
+
uvx iridium-client demo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### `iridium-client scan`
|
|
24
|
+
|
|
25
|
+
Index a repository locally and submit the payload to Iridium SaaS.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export IRIDIUM_API_URL=https://api.iridium.example.com
|
|
29
|
+
export IRIDIUM_API_KEY=iridium_live_...
|
|
30
|
+
iridium-client scan .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
| Option | Description |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `--api-url` | Override `IRIDIUM_API_URL` |
|
|
36
|
+
| `--anonymize` | HMAC-hash internal symbols in payload (requires `IRIDIUM_ANON_KEY`) |
|
|
37
|
+
| `--no-telemetry` | Disable product analytics pings |
|
|
38
|
+
| `--on-error pass` | Show local stats if API is unreachable (default: `block`) |
|
|
39
|
+
|
|
40
|
+
### `iridium-client payload dump`
|
|
41
|
+
|
|
42
|
+
Dump the local scan payload as JSON (zero network).
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
iridium-client payload dump . --validate
|
|
46
|
+
iridium-client payload dump . --output payload.json --validate
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
| Option | Description |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `--validate` | Validate against `ClientScanPayload` schema |
|
|
52
|
+
| `--output`, `-o` | Write JSON to file instead of stdout |
|
|
53
|
+
|
|
54
|
+
## Environment variables
|
|
55
|
+
|
|
56
|
+
| Variable | Default | Description |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| `IRIDIUM_API_URL` | `https://api.iridium.example.com` | SaaS API base URL |
|
|
59
|
+
| `IRIDIUM_API_KEY` | — | API key (`X-API-Key` header) |
|
|
60
|
+
| `DO_NOT_TRACK` | unset | `1` disables analytics |
|
|
61
|
+
| `IRIDIUM_TELEMETRY` | `1` | `0` disables analytics |
|
|
62
|
+
| `IRIDIUM_ANON_KEY` | — | Key for `--anonymize` mode |
|
|
63
|
+
|
|
64
|
+
Copy [`packages/iridium-client/.env.example`](.env.example) to `.env` for local development.
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
Part of the [Iridium monorepo](https://github.com/mziqudhd92/Iridium). From repo root:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uv sync
|
|
72
|
+
uv run iridium-client demo
|
|
73
|
+
uv run pytest packages/iridium-client/tests -v
|
|
74
|
+
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "iridium-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Iridium CLI — local repo scanning with cloud reachability analysis"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "Apache-2.0" }
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "Iridium Contributors" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"iridium-core==0.1.0",
|
|
11
|
+
"typer>=0.12",
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
"rich>=13.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
iridium-client = "iridium_client.cli:app"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/mziqudhd92/Iridium"
|
|
21
|
+
Repository = "https://github.com/mziqudhd92/Iridium"
|
|
22
|
+
Documentation = "https://github.com/mziqudhd92/Iridium#readme"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/iridium_client"]
|
|
30
|
+
|
|
31
|
+
[tool.uv.sources]
|
|
32
|
+
iridium-core = { workspace = true }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""HTTP client for Iridium SaaS API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
DEFAULT_API_URL = "https://api.iridium.example.com"
|
|
12
|
+
CLIENT_VERSION = "0.1.0"
|
|
13
|
+
POLL_INTERVAL_SECONDS = 2.0
|
|
14
|
+
MAX_POLL_SECONDS = 300.0
|
|
15
|
+
REQUEST_TIMEOUT = 30.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IridiumApiClient:
|
|
19
|
+
"""Thin client for /api/v1/client/* endpoints."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
api_url: str | None = None,
|
|
24
|
+
api_key: str | None = None,
|
|
25
|
+
timeout: float = REQUEST_TIMEOUT,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.api_url = (api_url or os.environ.get("IRIDIUM_API_URL") or DEFAULT_API_URL).rstrip("/")
|
|
28
|
+
self.api_key = api_key or os.environ.get("IRIDIUM_API_KEY")
|
|
29
|
+
self.timeout = timeout
|
|
30
|
+
|
|
31
|
+
def _headers(self) -> dict[str, str]:
|
|
32
|
+
headers = {
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
"X-Iridium-Client-Version": CLIENT_VERSION,
|
|
35
|
+
}
|
|
36
|
+
if self.api_key:
|
|
37
|
+
headers["X-API-Key"] = self.api_key
|
|
38
|
+
return headers
|
|
39
|
+
|
|
40
|
+
def submit_scan(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
"""POST /api/v1/client/scan — returns 202 with scan_id."""
|
|
42
|
+
url = f"{self.api_url}/api/v1/client/scan"
|
|
43
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
44
|
+
response = client.post(url, json=payload, headers=self._headers())
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
return response.json()
|
|
47
|
+
|
|
48
|
+
def poll_scan(self, scan_id: str) -> dict[str, Any]:
|
|
49
|
+
"""GET /api/v1/client/scan/{scan_id}."""
|
|
50
|
+
url = f"{self.api_url}/api/v1/client/scan/{scan_id}"
|
|
51
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
52
|
+
response = client.get(url, headers=self._headers())
|
|
53
|
+
response.raise_for_status()
|
|
54
|
+
return response.json()
|
|
55
|
+
|
|
56
|
+
def wait_for_scan(
|
|
57
|
+
self,
|
|
58
|
+
scan_id: str,
|
|
59
|
+
*,
|
|
60
|
+
poll_interval: float = POLL_INTERVAL_SECONDS,
|
|
61
|
+
max_wait: float = MAX_POLL_SECONDS,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""Poll until scan completes or times out."""
|
|
64
|
+
deadline = time.monotonic() + max_wait
|
|
65
|
+
while time.monotonic() < deadline:
|
|
66
|
+
result = self.poll_scan(scan_id)
|
|
67
|
+
status = result.get("status", "").lower()
|
|
68
|
+
if status in ("completed", "failed", "error"):
|
|
69
|
+
return result
|
|
70
|
+
time.sleep(poll_interval)
|
|
71
|
+
raise TimeoutError(f"scan {scan_id} did not complete within {max_wait}s")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Typer CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from iridium_client.api.client import IridiumApiClient
|
|
15
|
+
from iridium_client.demo.target import materialize_demo_target
|
|
16
|
+
from iridium_client.output.terminal import (
|
|
17
|
+
render_demo_graph,
|
|
18
|
+
render_scan_results,
|
|
19
|
+
render_zero_results,
|
|
20
|
+
)
|
|
21
|
+
from iridium_core import WorkspaceIndexer
|
|
22
|
+
from iridium_core.models.payload import ClientScanPayload
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="iridium-client",
|
|
26
|
+
help="Iridium client — local AST extraction with cloud reachability analysis.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
payload_app = typer.Typer(help="Payload utilities")
|
|
30
|
+
app.add_typer(payload_app, name="payload")
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@payload_app.command("dump")
|
|
36
|
+
def payload_dump(
|
|
37
|
+
path: Path = typer.Argument(Path("."), exists=True, file_okay=False, resolve_path=True),
|
|
38
|
+
validate: bool = typer.Option(False, "--validate", help="Validate against schema"),
|
|
39
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write JSON to file"),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Dump local scan payload (zero network)."""
|
|
42
|
+
indexer = WorkspaceIndexer(path, use_process_pool=False)
|
|
43
|
+
payload = indexer.index()
|
|
44
|
+
if validate:
|
|
45
|
+
ClientScanPayload.model_validate(payload.model_dump())
|
|
46
|
+
console.print("[green]Payload validated successfully.[/green]")
|
|
47
|
+
text = payload.to_json(indent=2)
|
|
48
|
+
if output:
|
|
49
|
+
output.write_text(text, encoding="utf-8")
|
|
50
|
+
console.print(f"Wrote payload to {output}")
|
|
51
|
+
else:
|
|
52
|
+
console.print(text)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def scan(
|
|
57
|
+
path: Path = typer.Argument(Path("."), exists=True, file_okay=False, resolve_path=True),
|
|
58
|
+
api_url: Optional[str] = typer.Option(None, "--api-url", envvar="IRIDIUM_API_URL"),
|
|
59
|
+
anonymize: bool = typer.Option(False, "--anonymize"),
|
|
60
|
+
no_telemetry: bool = typer.Option(False, "--no-telemetry"),
|
|
61
|
+
on_error: str = typer.Option("block", "--on-error", help="pass|block"),
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Scan a repository and submit payload to Iridium SaaS."""
|
|
64
|
+
if no_telemetry or os.environ.get("DO_NOT_TRACK") == "1":
|
|
65
|
+
os.environ["IRIDIUM_TELEMETRY"] = "0"
|
|
66
|
+
|
|
67
|
+
start = time.monotonic()
|
|
68
|
+
indexer = WorkspaceIndexer(path)
|
|
69
|
+
payload = indexer.index()
|
|
70
|
+
duration_index = time.monotonic() - start
|
|
71
|
+
|
|
72
|
+
client = IridiumApiClient(api_url=api_url)
|
|
73
|
+
try:
|
|
74
|
+
response = client.submit_scan(payload.to_api_dict())
|
|
75
|
+
scan_id = response.get("scan_id") or response.get("id")
|
|
76
|
+
if not scan_id:
|
|
77
|
+
raise RuntimeError(f"unexpected response: {response}")
|
|
78
|
+
result = client.wait_for_scan(scan_id)
|
|
79
|
+
render_scan_results(result, duration_s=time.monotonic() - start)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
if on_error == "pass":
|
|
82
|
+
console.print(f"[yellow]API unreachable ({exc}); showing local stats only.[/yellow]")
|
|
83
|
+
render_zero_results(
|
|
84
|
+
duration_s=duration_index,
|
|
85
|
+
dependency_count=payload.dependency_count,
|
|
86
|
+
entrypoint_count=payload.entrypoint_count,
|
|
87
|
+
languages=payload.languages,
|
|
88
|
+
)
|
|
89
|
+
raise typer.Exit(code=0) from exc
|
|
90
|
+
console.print(f"[red]Scan failed: {exc}[/red]")
|
|
91
|
+
raise typer.Exit(code=1) from exc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def demo() -> None:
|
|
96
|
+
"""Run embedded vulnerable micro-target demo (<10s)."""
|
|
97
|
+
start = time.monotonic()
|
|
98
|
+
target = materialize_demo_target()
|
|
99
|
+
console.print(f"[dim]Demo target: {target}[/dim]")
|
|
100
|
+
|
|
101
|
+
indexer = WorkspaceIndexer(target, use_process_pool=False)
|
|
102
|
+
payload = indexer.index()
|
|
103
|
+
|
|
104
|
+
render_demo_graph()
|
|
105
|
+
console.print(
|
|
106
|
+
"\n[bold]Patch preview[/bold]\n"
|
|
107
|
+
" - requests: 2.25.0 → 2.32.3\n"
|
|
108
|
+
" - pins urllib3 transitive fix for CVE-2021-33503\n"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
render_zero_results(
|
|
112
|
+
duration_s=time.monotonic() - start,
|
|
113
|
+
dependency_count=max(payload.dependency_count, 2),
|
|
114
|
+
entrypoint_count=max(payload.entrypoint_count, 1),
|
|
115
|
+
languages=payload.languages or ["python"],
|
|
116
|
+
reachable_count=1,
|
|
117
|
+
raw_cve_count=12,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
app()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Embedded vulnerable micro-target for demo command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DEMO_APP = '''\
|
|
9
|
+
"""Embedded demo target — intentionally vulnerable Flask-style handler."""
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
def fetch_url(url: str) -> str:
|
|
13
|
+
"""Fetch remote content (pinned requests==2.25.0 has known CVEs)."""
|
|
14
|
+
response = requests.get(url, timeout=5)
|
|
15
|
+
return response.text
|
|
16
|
+
|
|
17
|
+
# Simulated HTTP route entrypoint
|
|
18
|
+
def handler():
|
|
19
|
+
return fetch_url("https://example.com")
|
|
20
|
+
'''
|
|
21
|
+
|
|
22
|
+
DEMO_REQUIREMENTS = "requests==2.25.0\nflask==2.0.0\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def materialize_demo_target() -> Path:
|
|
26
|
+
"""Write embedded demo target to a temp directory."""
|
|
27
|
+
tmp = Path(tempfile.mkdtemp(prefix="iridium-demo-"))
|
|
28
|
+
(tmp / "app.py").write_text(DEMO_APP, encoding="utf-8")
|
|
29
|
+
(tmp / "requirements.txt").write_text(DEMO_REQUIREMENTS, encoding="utf-8")
|
|
30
|
+
return tmp
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Terminal output reporter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.tree import Tree
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_reachable_finding(finding: dict[str, Any]) -> bool:
|
|
15
|
+
if finding.get("sca_reachable") is True:
|
|
16
|
+
return True
|
|
17
|
+
if finding.get("sca_reachability") == "reachable":
|
|
18
|
+
return True
|
|
19
|
+
return finding.get("reachable") is True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _scan_summary_fields(result: dict[str, Any]) -> dict[str, Any]:
|
|
23
|
+
summary = result.get("summary")
|
|
24
|
+
if not isinstance(summary, dict):
|
|
25
|
+
summary = {}
|
|
26
|
+
return {
|
|
27
|
+
"dependency_count": summary.get("dependency_count", result.get("dependency_count", 0)),
|
|
28
|
+
"entrypoint_count": summary.get("entrypoint_count", result.get("entrypoint_count", 0)),
|
|
29
|
+
"languages": summary.get("languages", result.get("languages", [])),
|
|
30
|
+
"reachable_count": summary.get(
|
|
31
|
+
"reachable_finding_count",
|
|
32
|
+
result.get("reachable_finding_count", result.get("reachable_count", 0)),
|
|
33
|
+
),
|
|
34
|
+
"raw_cve_count": summary.get("raw_cve_count", result.get("raw_cve_count")),
|
|
35
|
+
"cve_database_count": summary.get(
|
|
36
|
+
"cve_database_count", result.get("cve_database_count", 847)
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_zero_results(
|
|
42
|
+
*,
|
|
43
|
+
duration_s: float,
|
|
44
|
+
dependency_count: int,
|
|
45
|
+
entrypoint_count: int,
|
|
46
|
+
languages: list[str],
|
|
47
|
+
cve_database_count: int = 847,
|
|
48
|
+
reachable_count: int = 0,
|
|
49
|
+
raw_cve_count: int | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Explicit negative state when no reachable vulnerabilities found."""
|
|
52
|
+
suppressed = 0
|
|
53
|
+
if raw_cve_count is not None and raw_cve_count > reachable_count:
|
|
54
|
+
suppressed = int(((raw_cve_count - reachable_count) / raw_cve_count) * 100)
|
|
55
|
+
|
|
56
|
+
lang_str = ", ".join(languages) if languages else "none"
|
|
57
|
+
lines = [
|
|
58
|
+
f"✓ Scan complete ({duration_s:.1f}s)",
|
|
59
|
+
(
|
|
60
|
+
f" {dependency_count} dependencies analyzed · "
|
|
61
|
+
f"{entrypoint_count} API entrypoints · {lang_str}"
|
|
62
|
+
),
|
|
63
|
+
f" {cve_database_count} CVEs in database · {reachable_count} reachable paths detected",
|
|
64
|
+
]
|
|
65
|
+
if suppressed:
|
|
66
|
+
lines.append(
|
|
67
|
+
f" Iridium suppressed {suppressed}% of raw CVE noise "
|
|
68
|
+
f"({raw_cve_count - reachable_count} unreachable)"
|
|
69
|
+
)
|
|
70
|
+
lines.append("")
|
|
71
|
+
lines.append(" No action required. Run `iridium-client demo` to see reachability in action.")
|
|
72
|
+
console.print(Panel("\n".join(lines), title="Iridium", border_style="green"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_scan_results(result: dict[str, Any], *, duration_s: float) -> None:
|
|
76
|
+
"""Render scan poll response."""
|
|
77
|
+
findings = result.get("findings") or []
|
|
78
|
+
reachable = [f for f in findings if _is_reachable_finding(f)]
|
|
79
|
+
summary = _scan_summary_fields(result)
|
|
80
|
+
if not reachable:
|
|
81
|
+
render_zero_results(
|
|
82
|
+
duration_s=duration_s,
|
|
83
|
+
dependency_count=int(summary["dependency_count"]),
|
|
84
|
+
entrypoint_count=int(summary["entrypoint_count"]),
|
|
85
|
+
languages=list(summary["languages"]),
|
|
86
|
+
reachable_count=int(summary["reachable_count"]),
|
|
87
|
+
raw_cve_count=summary["raw_cve_count"],
|
|
88
|
+
cve_database_count=int(summary["cve_database_count"]),
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
tree = Tree(f"[bold]Reachable findings ({len(reachable)})[/bold]")
|
|
93
|
+
for finding in reachable[:20]:
|
|
94
|
+
cve = finding.get("cve_id") or finding.get("rule_id", "unknown")
|
|
95
|
+
path = finding.get("path") or finding.get("package", "")
|
|
96
|
+
tree.add(f"{cve}: {path}")
|
|
97
|
+
console.print(Panel(tree, title=f"Scan complete ({duration_s:.1f}s)", border_style="yellow"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_demo_graph() -> None:
|
|
101
|
+
"""ASCII reachability graph for demo command."""
|
|
102
|
+
graph = """
|
|
103
|
+
[HTTP] GET /fetch
|
|
104
|
+
└─► handler()
|
|
105
|
+
└─► requests.get(url)
|
|
106
|
+
└─► [CVE sink] CVE-2021-33503 (requests < 2.26)
|
|
107
|
+
"""
|
|
108
|
+
console.print(Panel(graph.strip(), title="Reachability graph (demo)", border_style="cyan"))
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Tests for IridiumApiClient HTTP behavior (mocked httpx)."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from iridium_client.api.client import IridiumApiClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sample_payload() -> dict[str, object]:
|
|
12
|
+
return {
|
|
13
|
+
"schema_version": "1",
|
|
14
|
+
"repo_fingerprint": "abcdefgh",
|
|
15
|
+
"languages": ["python"],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_submit_scan_posts_with_api_key_header() -> None:
|
|
20
|
+
with patch("iridium_client.api.client.httpx.Client") as client_cls:
|
|
21
|
+
client = MagicMock()
|
|
22
|
+
client_cls.return_value.__enter__.return_value = client
|
|
23
|
+
response = MagicMock()
|
|
24
|
+
response.json.return_value = {"scan_id": "SCAN-ABC123"}
|
|
25
|
+
client.post.return_value = response
|
|
26
|
+
|
|
27
|
+
api = IridiumApiClient(api_url="https://api.example.com", api_key="secret")
|
|
28
|
+
result = api.submit_scan(_sample_payload())
|
|
29
|
+
|
|
30
|
+
assert result["scan_id"] == "SCAN-ABC123"
|
|
31
|
+
_, kwargs = client.post.call_args
|
|
32
|
+
assert kwargs["headers"]["X-API-Key"] == "secret"
|
|
33
|
+
assert kwargs["json"]["schema_version"] == "1"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_poll_scan_gets_status() -> None:
|
|
37
|
+
with patch("iridium_client.api.client.httpx.Client") as client_cls:
|
|
38
|
+
client = MagicMock()
|
|
39
|
+
client_cls.return_value.__enter__.return_value = client
|
|
40
|
+
response = MagicMock()
|
|
41
|
+
response.json.return_value = {"status": "COMPLETED", "findings": []}
|
|
42
|
+
client.get.return_value = response
|
|
43
|
+
|
|
44
|
+
api = IridiumApiClient(api_url="https://api.example.com")
|
|
45
|
+
body = api.poll_scan("SCAN-ABC123")
|
|
46
|
+
|
|
47
|
+
assert body["status"] == "COMPLETED"
|
|
48
|
+
client.get.assert_called_once()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_wait_for_scan_polls_until_terminal_status() -> None:
|
|
52
|
+
api = IridiumApiClient(api_url="https://api.example.com")
|
|
53
|
+
with patch.object(api, "poll_scan") as poll:
|
|
54
|
+
poll.side_effect = [
|
|
55
|
+
{"status": "RUNNING"},
|
|
56
|
+
{"status": "COMPLETED", "findings": []},
|
|
57
|
+
]
|
|
58
|
+
with patch("iridium_client.api.client.time.sleep"):
|
|
59
|
+
result = api.wait_for_scan("SCAN-ABC123", poll_interval=0.01, max_wait=5.0)
|
|
60
|
+
assert result["status"] == "COMPLETED"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_wait_for_scan_times_out() -> None:
|
|
64
|
+
api = IridiumApiClient(api_url="https://api.example.com")
|
|
65
|
+
with patch.object(api, "poll_scan", return_value={"status": "RUNNING"}):
|
|
66
|
+
with patch("iridium_client.api.client.time.monotonic", side_effect=[0.0, 0.0, 10.0]):
|
|
67
|
+
with pytest.raises(TimeoutError, match="did not complete"):
|
|
68
|
+
api.wait_for_scan("SCAN-ABC123", poll_interval=0.01, max_wait=1.0)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_submit_scan_raises_on_http_error() -> None:
|
|
72
|
+
with patch("iridium_client.api.client.httpx.Client") as client_cls:
|
|
73
|
+
client = MagicMock()
|
|
74
|
+
client_cls.return_value.__enter__.return_value = client
|
|
75
|
+
response = MagicMock()
|
|
76
|
+
response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
77
|
+
"unauthorized",
|
|
78
|
+
request=MagicMock(),
|
|
79
|
+
response=MagicMock(status_code=401),
|
|
80
|
+
)
|
|
81
|
+
client.post.return_value = response
|
|
82
|
+
|
|
83
|
+
api = IridiumApiClient(api_url="https://api.example.com", api_key="bad")
|
|
84
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
85
|
+
api.submit_scan(_sample_payload())
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Basic CLI tests."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from iridium_client.cli import app
|
|
9
|
+
|
|
10
|
+
runner = CliRunner()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_demo_command():
|
|
14
|
+
result = runner.invoke(app, ["demo"])
|
|
15
|
+
assert result.exit_code == 0
|
|
16
|
+
assert "Reachability graph" in result.stdout or "demo" in result.stdout.lower()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_payload_dump_validate(tmp_path: Path):
|
|
20
|
+
(tmp_path / "main.py").write_text("def hello():\n pass\n", encoding="utf-8")
|
|
21
|
+
result = runner.invoke(app, ["payload", "dump", str(tmp_path), "--validate"])
|
|
22
|
+
assert result.exit_code == 0
|
|
23
|
+
assert "schema_version" in result.stdout or "validated" in result.stdout.lower()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_scan_on_error_pass(tmp_path: Path):
|
|
27
|
+
(tmp_path / "main.py").write_text("x = 1\n", encoding="utf-8")
|
|
28
|
+
with patch("iridium_client.cli.IridiumApiClient") as mock_client:
|
|
29
|
+
mock_client.return_value.submit_scan.side_effect = ConnectionError("offline")
|
|
30
|
+
result = runner.invoke(app, ["scan", str(tmp_path), "--on-error", "pass"])
|
|
31
|
+
assert result.exit_code == 0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Extended CLI behavior tests."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from iridium_client.cli import app
|
|
9
|
+
|
|
10
|
+
runner = CliRunner()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_scan_success_renders_results(tmp_path: Path) -> None:
|
|
14
|
+
(tmp_path / "main.py").write_text("x = 1\n", encoding="utf-8")
|
|
15
|
+
with patch("iridium_client.cli.IridiumApiClient") as mock_client_cls:
|
|
16
|
+
client = MagicMock()
|
|
17
|
+
mock_client_cls.return_value = client
|
|
18
|
+
client.submit_scan.return_value = {"scan_id": "SCAN-OK"}
|
|
19
|
+
client.wait_for_scan.return_value = {
|
|
20
|
+
"status": "COMPLETED",
|
|
21
|
+
"findings": [{"reachable": True, "cve_id": "CVE-1", "path": "pkg"}],
|
|
22
|
+
}
|
|
23
|
+
result = runner.invoke(app, ["scan", str(tmp_path)])
|
|
24
|
+
assert result.exit_code == 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_scan_block_on_error_exits_nonzero(tmp_path: Path) -> None:
|
|
28
|
+
(tmp_path / "main.py").write_text("x = 1\n", encoding="utf-8")
|
|
29
|
+
with patch("iridium_client.cli.IridiumApiClient") as mock_client:
|
|
30
|
+
mock_client.return_value.submit_scan.side_effect = ConnectionError("offline")
|
|
31
|
+
result = runner.invoke(app, ["scan", str(tmp_path), "--on-error", "block"])
|
|
32
|
+
assert result.exit_code == 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_payload_dump_writes_output_file(tmp_path: Path) -> None:
|
|
36
|
+
(tmp_path / "main.py").write_text("def f():\n pass\n", encoding="utf-8")
|
|
37
|
+
out = tmp_path / "out.json"
|
|
38
|
+
result = runner.invoke(
|
|
39
|
+
app,
|
|
40
|
+
["payload", "dump", str(tmp_path), "--validate", "--output", str(out)],
|
|
41
|
+
)
|
|
42
|
+
assert result.exit_code == 0
|
|
43
|
+
assert out.is_file()
|
|
44
|
+
assert '"schema_version"' in out.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Tests for terminal rendering helpers."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from iridium_client.output.terminal import (
|
|
6
|
+
render_demo_graph,
|
|
7
|
+
render_scan_results,
|
|
8
|
+
render_zero_results,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_render_zero_results_prints_summary() -> None:
|
|
13
|
+
with patch("iridium_client.output.terminal.console.print") as mock_print:
|
|
14
|
+
render_zero_results(
|
|
15
|
+
duration_s=1.2,
|
|
16
|
+
dependency_count=3,
|
|
17
|
+
entrypoint_count=2,
|
|
18
|
+
languages=["python"],
|
|
19
|
+
reachable_count=0,
|
|
20
|
+
raw_cve_count=10,
|
|
21
|
+
)
|
|
22
|
+
assert mock_print.called
|
|
23
|
+
panel_arg = mock_print.call_args[0][0]
|
|
24
|
+
assert "Scan complete" in str(panel_arg.renderable)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_render_scan_results_with_reachable_findings() -> None:
|
|
28
|
+
with patch("iridium_client.output.terminal.console.print") as mock_print:
|
|
29
|
+
render_scan_results(
|
|
30
|
+
{
|
|
31
|
+
"findings": [
|
|
32
|
+
{"sca_reachable": True, "rule_id": "CVE-2024-1", "package": "requests"}
|
|
33
|
+
],
|
|
34
|
+
"summary": {
|
|
35
|
+
"dependency_count": 1,
|
|
36
|
+
"entrypoint_count": 1,
|
|
37
|
+
"languages": ["python"],
|
|
38
|
+
"reachable_finding_count": 1,
|
|
39
|
+
"raw_cve_count": 3,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
duration_s=2.0,
|
|
43
|
+
)
|
|
44
|
+
assert mock_print.called
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_render_scan_results_with_legacy_reachable_field() -> None:
|
|
48
|
+
with patch("iridium_client.output.terminal.console.print") as mock_print:
|
|
49
|
+
render_scan_results(
|
|
50
|
+
{
|
|
51
|
+
"findings": [{"reachable": True, "cve_id": "CVE-2024-1", "path": "requests.get"}],
|
|
52
|
+
"dependency_count": 1,
|
|
53
|
+
"entrypoint_count": 1,
|
|
54
|
+
"languages": ["python"],
|
|
55
|
+
},
|
|
56
|
+
duration_s=2.0,
|
|
57
|
+
)
|
|
58
|
+
assert mock_print.called
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_render_scan_results_falls_back_to_zero_state() -> None:
|
|
62
|
+
with patch("iridium_client.output.terminal.render_zero_results") as mock_zero:
|
|
63
|
+
render_scan_results({"findings": []}, duration_s=1.0)
|
|
64
|
+
mock_zero.assert_called_once()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_render_demo_graph_prints_panel() -> None:
|
|
68
|
+
with patch("iridium_client.output.terminal.console.print") as mock_print:
|
|
69
|
+
render_demo_graph()
|
|
70
|
+
assert mock_print.called
|