orithos-cli 0.1.0__py3-none-any.whl
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.
- orithos_cli/__init__.py +6 -0
- orithos_cli/agent.py +78 -0
- orithos_cli/agent_wizard.py +255 -0
- orithos_cli/auth.py +21 -0
- orithos_cli/cli.py +82 -0
- orithos_cli/compliance.py +133 -0
- orithos_cli/config.py +114 -0
- orithos_cli/configure.py +103 -0
- orithos_cli/connection.py +69 -0
- orithos_cli/connection_wizard.py +129 -0
- orithos_cli/discovery.py +78 -0
- orithos_cli/graph.py +102 -0
- orithos_cli/guardrail.py +131 -0
- orithos_cli/mcp.py +170 -0
- orithos_cli/output.py +178 -0
- orithos_cli/probes.py +50 -0
- orithos_cli/remediation.py +111 -0
- orithos_cli/runtime.py +100 -0
- orithos_cli/scan.py +758 -0
- orithos_cli/skill.py +64 -0
- orithos_cli/skillscan/__init__.py +37 -0
- orithos_cli/skillscan/checks/__init__.py +28 -0
- orithos_cli/skillscan/checks/credentials.py +112 -0
- orithos_cli/skillscan/checks/iocs.py +95 -0
- orithos_cli/skillscan/checks/manifest.py +156 -0
- orithos_cli/skillscan/checks/network.py +117 -0
- orithos_cli/skillscan/checks/obfuscation.py +130 -0
- orithos_cli/skillscan/checks/permissions.py +108 -0
- orithos_cli/skillscan/checks/shell.py +140 -0
- orithos_cli/skillscan/collect.py +205 -0
- orithos_cli/skillscan/model.py +99 -0
- orithos_cli/skillscan/report.py +130 -0
- orithos_cli/template.py +60 -0
- orithos_cli/verify.py +85 -0
- orithos_cli/wizard.py +314 -0
- orithos_cli-0.1.0.dist-info/METADATA +99 -0
- orithos_cli-0.1.0.dist-info/RECORD +40 -0
- orithos_cli-0.1.0.dist-info/WHEEL +5 -0
- orithos_cli-0.1.0.dist-info/entry_points.txt +2 -0
- orithos_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Report renderers: text, JSON, SARIF."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from orithos_cli.skillscan.model import SEVERITY_ORDER, ScanResult
|
|
9
|
+
|
|
10
|
+
CHECK_TITLES = {
|
|
11
|
+
"manifest": "Manifest hygiene",
|
|
12
|
+
"permissions": "Permission scope",
|
|
13
|
+
"network": "Outbound network",
|
|
14
|
+
"shell": "Shell execution",
|
|
15
|
+
"credentials": "Credential access",
|
|
16
|
+
"obfuscation": "Obfuscation",
|
|
17
|
+
"iocs": "Known-bad indicators",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_SARIF_LEVEL = {
|
|
21
|
+
"critical": "error",
|
|
22
|
+
"high": "error",
|
|
23
|
+
"medium": "warning",
|
|
24
|
+
"low": "note",
|
|
25
|
+
"info": "note",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_text(result: ScanResult) -> str:
|
|
30
|
+
lines: list[str] = []
|
|
31
|
+
counts = result.counts()
|
|
32
|
+
total = len(result.findings)
|
|
33
|
+
lines.append("Orithos skill scan")
|
|
34
|
+
lines.append("=" * 60)
|
|
35
|
+
lines.append(f"Artifact: {result.source}")
|
|
36
|
+
lines.append(
|
|
37
|
+
f"Files: {result.files_scanned} scanned"
|
|
38
|
+
+ (f", {len(result.skipped)} skipped" if result.skipped else "")
|
|
39
|
+
)
|
|
40
|
+
summary = " · ".join(
|
|
41
|
+
f"{sev} {counts[sev]}"
|
|
42
|
+
for sev in sorted(counts, key=lambda s: SEVERITY_ORDER.get(s, 99))
|
|
43
|
+
)
|
|
44
|
+
lines.append(f"Findings: {total}" + (f" ({summary})" if summary else ""))
|
|
45
|
+
if result.errors:
|
|
46
|
+
lines.append(f"Errors: {len(result.errors)} (scan continued)")
|
|
47
|
+
for e in result.errors[:5]:
|
|
48
|
+
lines.append(f" ! {e}")
|
|
49
|
+
lines.append("")
|
|
50
|
+
|
|
51
|
+
if not total:
|
|
52
|
+
lines.append("No findings — artifact looks clean under the v1 rule set.")
|
|
53
|
+
return "\n".join(lines)
|
|
54
|
+
|
|
55
|
+
for f in result.sorted_findings():
|
|
56
|
+
loc = f"{f.file}:{f.line}" if f.line else f.file
|
|
57
|
+
lines.append(f"[{f.severity.upper():8}] {f.check:12} {f.title}")
|
|
58
|
+
lines.append(f" at {loc}")
|
|
59
|
+
if f.evidence:
|
|
60
|
+
lines.append(f" > {f.evidence}")
|
|
61
|
+
lines.append(f" {f.detail}")
|
|
62
|
+
lines.append("")
|
|
63
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def render_json(result: ScanResult) -> str:
|
|
67
|
+
counts = result.counts()
|
|
68
|
+
payload: dict[str, Any] = {
|
|
69
|
+
"tool": "orithos-skill-scan",
|
|
70
|
+
"version": "0.1.0",
|
|
71
|
+
"artifact": result.source,
|
|
72
|
+
"files_scanned": result.files_scanned,
|
|
73
|
+
"summary": {
|
|
74
|
+
"total": len(result.findings),
|
|
75
|
+
"by_severity": counts,
|
|
76
|
+
},
|
|
77
|
+
"findings": [f.to_dict() for f in result.sorted_findings()],
|
|
78
|
+
}
|
|
79
|
+
if result.skipped:
|
|
80
|
+
payload["skipped"] = result.skipped
|
|
81
|
+
if result.errors:
|
|
82
|
+
payload["errors"] = result.errors
|
|
83
|
+
return json.dumps(payload, indent=2)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def render_sarif(result: ScanResult) -> str:
|
|
87
|
+
rules = [
|
|
88
|
+
{
|
|
89
|
+
"id": f"ORITHOS-SKILL-{check.upper()}",
|
|
90
|
+
"name": check,
|
|
91
|
+
"shortDescription": {"text": title},
|
|
92
|
+
}
|
|
93
|
+
for check, title in CHECK_TITLES.items()
|
|
94
|
+
]
|
|
95
|
+
results: list[dict[str, Any]] = []
|
|
96
|
+
for f in result.sorted_findings():
|
|
97
|
+
entry: dict[str, Any] = {
|
|
98
|
+
"ruleId": f"ORITHOS-SKILL-{f.check.upper()}",
|
|
99
|
+
"level": _SARIF_LEVEL.get(f.severity, "note"),
|
|
100
|
+
"message": {"text": f"{f.title}: {f.detail}"},
|
|
101
|
+
"locations": [
|
|
102
|
+
{
|
|
103
|
+
"physicalLocation": {
|
|
104
|
+
"artifactLocation": {"uri": f.file},
|
|
105
|
+
"region": {"startLine": f.line or 1, "startColumn": 1},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
"properties": {"severity": f.severity, "evidence": f.evidence},
|
|
110
|
+
}
|
|
111
|
+
results.append(entry)
|
|
112
|
+
|
|
113
|
+
sarif = {
|
|
114
|
+
"version": "2.1.0",
|
|
115
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
116
|
+
"runs": [
|
|
117
|
+
{
|
|
118
|
+
"tool": {
|
|
119
|
+
"driver": {
|
|
120
|
+
"name": "orithos-skill-scan",
|
|
121
|
+
"version": "0.1.0",
|
|
122
|
+
"informationUri": "https://orithos.com",
|
|
123
|
+
"rules": rules,
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"results": results,
|
|
127
|
+
}
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
return json.dumps(sarif, indent=2)
|
orithos_cli/template.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Scan template subcommands for Orithos CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from orithos_cli.config import get_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group(name="template")
|
|
12
|
+
def template_group() -> None:
|
|
13
|
+
"""Manage scan templates."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@template_group.command("create")
|
|
18
|
+
@click.option("--name", required=True, help="Template name")
|
|
19
|
+
@click.option("--agent-id", required=True, help="Agent ID")
|
|
20
|
+
@click.option("--connection-id", default=None, help="Connection ID")
|
|
21
|
+
@click.option("--depth", default="standard", type=click.Choice(["quick", "standard", "deep", "exhaustive"]), help="Scan depth")
|
|
22
|
+
@click.option("--attack-suite", default="standard", type=click.Choice(["standard", "policy", "both"]), help="Attack suite")
|
|
23
|
+
@click.option("--remediation-mode", default="manual_only", type=click.Choice(["advisory", "auto_patch_low_medium", "manual_only"]), help="Remediation mode")
|
|
24
|
+
def create_template(name: str, agent_id: str, connection_id: str | None, depth: str, attack_suite: str, remediation_mode: str) -> None:
|
|
25
|
+
"""Create a new scan template."""
|
|
26
|
+
cfg = get_config()
|
|
27
|
+
payload = {"name": name, "agent_id": agent_id, "depth": depth, "attack_suite": attack_suite, "remediation_mode": remediation_mode}
|
|
28
|
+
if connection_id:
|
|
29
|
+
payload["connection_id"] = connection_id
|
|
30
|
+
try:
|
|
31
|
+
resp = httpx.post(f"{cfg.api_url}/v1/templates", json=payload, headers=cfg.auth_headers(), timeout=cfg.timeout)
|
|
32
|
+
resp.raise_for_status()
|
|
33
|
+
except httpx.HTTPStatusError as exc:
|
|
34
|
+
click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
|
|
35
|
+
raise SystemExit(1)
|
|
36
|
+
data = resp.json()
|
|
37
|
+
click.echo(f"Template created: {data['id'][:8]}")
|
|
38
|
+
click.echo(f" Name: {data.get('name', '?')}")
|
|
39
|
+
click.echo(f" Agent: {data.get('agent_id', '?')[:8]}")
|
|
40
|
+
click.echo(f" Depth: {data.get('depth', '?')}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@template_group.command("list")
|
|
44
|
+
def list_templates() -> None:
|
|
45
|
+
"""List all scan templates."""
|
|
46
|
+
cfg = get_config()
|
|
47
|
+
try:
|
|
48
|
+
resp = httpx.get(f"{cfg.api_url}/v1/templates", headers=cfg.auth_headers(), timeout=cfg.timeout)
|
|
49
|
+
resp.raise_for_status()
|
|
50
|
+
except httpx.HTTPStatusError as exc:
|
|
51
|
+
click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
|
|
52
|
+
raise SystemExit(1)
|
|
53
|
+
data = resp.json()
|
|
54
|
+
items = data if isinstance(data, list) else data.get("templates", [])
|
|
55
|
+
if not items:
|
|
56
|
+
click.echo("No templates found.")
|
|
57
|
+
return
|
|
58
|
+
for t in items:
|
|
59
|
+
click.echo(f"[{t['id'][:8]}] {t.get('name', '?')} — depth={t.get('depth', '?')} suite={t.get('attack_suite', '?')}")
|
|
60
|
+
click.echo(f" Agent: {t.get('agent_id', '?')[:8]} Remediation: {t.get('remediation_mode', 'manual_only')}")
|
orithos_cli/verify.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Offline evidence-package verification (`orithos verify <package.json>`).
|
|
2
|
+
|
|
3
|
+
Recomputes per-artifact SHA-256 and the Merkle root from the package's own
|
|
4
|
+
artifact content and compares against the manifest. Independent
|
|
5
|
+
verifiability — no Orithos contact required, which is what makes the
|
|
6
|
+
package acceptable as auditor-obtained evidence (AS 1105 preference for
|
|
7
|
+
directly obtained evidence; FRE 902(14) hash self-authentication basis).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sha256_bytes(data: bytes) -> str:
|
|
17
|
+
return hashlib.sha256(data).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def merkle_root(leaf_hashes: list[str]) -> str:
|
|
21
|
+
if not leaf_hashes:
|
|
22
|
+
return sha256_bytes(b"")
|
|
23
|
+
level = sorted(leaf_hashes)
|
|
24
|
+
while len(level) > 1:
|
|
25
|
+
nxt: list[str] = []
|
|
26
|
+
for i in range(0, len(level), 2):
|
|
27
|
+
left = level[i]
|
|
28
|
+
right = level[i + 1] if i + 1 < len(level) else left
|
|
29
|
+
nxt.append(sha256_bytes((left + right).encode()))
|
|
30
|
+
level = nxt
|
|
31
|
+
return level[0]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def verify_package(pkg: dict[str, Any]) -> dict[str, Any]:
|
|
35
|
+
"""Return {ok, errors, artifacts} for a parsed package dict."""
|
|
36
|
+
errors: list[str] = []
|
|
37
|
+
manifest = pkg.get("manifest", pkg)
|
|
38
|
+
artifacts = pkg.get("artifacts", {})
|
|
39
|
+
if not artifacts:
|
|
40
|
+
errors.append("no artifacts section in package")
|
|
41
|
+
return {"ok": False, "errors": errors, "artifacts": []}
|
|
42
|
+
|
|
43
|
+
computed: dict[str, str] = {}
|
|
44
|
+
for name, art in artifacts.items():
|
|
45
|
+
content = art.get("content") if isinstance(art, dict) else art
|
|
46
|
+
if content is None:
|
|
47
|
+
errors.append(f"artifact {name}: no content to hash")
|
|
48
|
+
continue
|
|
49
|
+
computed[name] = sha256_bytes(
|
|
50
|
+
json.dumps(content, sort_keys=True, default=str).encode()
|
|
51
|
+
)
|
|
52
|
+
declared = art.get("sha256") if isinstance(art, dict) else None
|
|
53
|
+
if declared and declared != computed[name]:
|
|
54
|
+
errors.append(
|
|
55
|
+
f"artifact {name}: hash mismatch (declared {declared}, computed {computed[name]})"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
expected_root = manifest.get("integrity", {}).get("merkle_root")
|
|
59
|
+
if expected_root:
|
|
60
|
+
actual_root = merkle_root(list(computed.values()))
|
|
61
|
+
if actual_root != expected_root:
|
|
62
|
+
errors.append(
|
|
63
|
+
f"merkle root mismatch (declared {expected_root}, computed {actual_root})"
|
|
64
|
+
)
|
|
65
|
+
elif "merkle_root" in manifest:
|
|
66
|
+
errors.append("merkle_root declared but integrity section missing")
|
|
67
|
+
else:
|
|
68
|
+
errors.append("no merkle root declared in manifest")
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
"ok": not errors,
|
|
72
|
+
"errors": errors,
|
|
73
|
+
"artifacts": [{"name": n, "sha256": h} for n, h in sorted(computed.items())],
|
|
74
|
+
"merkle_root": merkle_root(list(computed.values())),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def verify_package_file(path: Path) -> dict[str, Any]:
|
|
79
|
+
data = json.loads(path.read_text())
|
|
80
|
+
if "manifest" in data and "artifacts" in data:
|
|
81
|
+
return verify_package(data)
|
|
82
|
+
# single-document form: {"package": {...}} or manifest-with-embedded-artifacts
|
|
83
|
+
if "package" in data:
|
|
84
|
+
return verify_package(data["package"])
|
|
85
|
+
return verify_package(data)
|
orithos_cli/wizard.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Interactive scan wizard for Orithos CLI — 4 steps matching dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from orithos_cli.config import get_config, fmt_http_error
|
|
9
|
+
from orithos_cli.scan import _run_scan
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEPTH_OPTIONS = [
|
|
13
|
+
("quick", "Quick", "Light scan, fast results"),
|
|
14
|
+
("standard", "Standard", "Balanced coverage (default)"),
|
|
15
|
+
("deep", "Deep", "Thorough scan, longer runtime"),
|
|
16
|
+
("exhaustive", "Exhaustive", "Maximum coverage"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
REMEDIATION_OPTIONS = [
|
|
20
|
+
("advisory", "Advisory", "Recommendations only"),
|
|
21
|
+
("auto_patch_low_medium", "Auto-patch", "Automatic fixes for low/medium findings"),
|
|
22
|
+
("manual_only", "Manual only", "No auto-remediation"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
SCHEDULE_OPTIONS = [
|
|
26
|
+
("now", "Now", "Run immediately"),
|
|
27
|
+
("daily", "Daily", "Daily at 06:00 UTC"),
|
|
28
|
+
("weekly", "Weekly", "Weekly on Monday"),
|
|
29
|
+
("monthly", "Monthly", "Monthly on 1st"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
DEFAULT_PROBES = [
|
|
33
|
+
"prompt_injection",
|
|
34
|
+
"tool_misuse",
|
|
35
|
+
"scope_escalation",
|
|
36
|
+
"exfil_attempt",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _s(
|
|
41
|
+
text: str = "", fg: str | None = None, dim: bool = False, bold: bool = False
|
|
42
|
+
) -> str:
|
|
43
|
+
return click.style(text, fg=fg, dim=dim, bold=bold)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fetch_agents(cfg) -> list[dict]:
|
|
47
|
+
try:
|
|
48
|
+
r = httpx.get(
|
|
49
|
+
f"{cfg.api_url}/v1/agents", headers=cfg.auth_headers(), timeout=cfg.timeout
|
|
50
|
+
)
|
|
51
|
+
r.raise_for_status()
|
|
52
|
+
data = r.json()
|
|
53
|
+
return data if isinstance(data, list) else data.get("items", [])
|
|
54
|
+
except Exception:
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fetch_connections(cfg) -> list[dict]:
|
|
59
|
+
try:
|
|
60
|
+
r = httpx.get(
|
|
61
|
+
f"{cfg.api_url}/v1/connections",
|
|
62
|
+
headers=cfg.auth_headers(),
|
|
63
|
+
timeout=cfg.timeout,
|
|
64
|
+
)
|
|
65
|
+
r.raise_for_status()
|
|
66
|
+
data = r.json()
|
|
67
|
+
return data if isinstance(data, list) else data.get("items", [])
|
|
68
|
+
except Exception:
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _pick(options: list[tuple[str, str, str]], prompt: str, default: int = 1) -> str:
|
|
73
|
+
for i, (_, label, desc) in enumerate(options, 1):
|
|
74
|
+
click.echo(f" [{i}] {_s(label, bold=True)} \u2014 {_s(desc, dim=True)}")
|
|
75
|
+
choice = click.prompt(prompt, type=int, default=default)
|
|
76
|
+
return options[choice - 1][0]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _pick_from_list(
|
|
80
|
+
items: list[dict],
|
|
81
|
+
prompt: str,
|
|
82
|
+
name_key: str = "name",
|
|
83
|
+
detail_keys: list[str] | None = None,
|
|
84
|
+
) -> dict | None:
|
|
85
|
+
if not items:
|
|
86
|
+
click.echo(_s(" No items available.", fg="yellow"))
|
|
87
|
+
return None
|
|
88
|
+
if len(items) == 1:
|
|
89
|
+
click.echo(f" Using: {_s(items[0].get(name_key, '?'), bold=True)}")
|
|
90
|
+
return items[0]
|
|
91
|
+
for i, item in enumerate(items, 1):
|
|
92
|
+
label = item.get(name_key, "?")
|
|
93
|
+
details = ""
|
|
94
|
+
if detail_keys:
|
|
95
|
+
parts = [str(item.get(k, "")) for k in detail_keys if item.get(k)]
|
|
96
|
+
if parts:
|
|
97
|
+
details = " \u2014 " + " \u00b7 ".join(parts)
|
|
98
|
+
click.echo(f" [{i}] {_s(label, bold=True)}{_s(details, dim=True)}")
|
|
99
|
+
choice = click.prompt(prompt, type=int, default=1)
|
|
100
|
+
if choice == 0:
|
|
101
|
+
return None
|
|
102
|
+
return items[choice - 1] if 0 < choice <= len(items) else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _confirm(prompt: str, default: bool = True) -> bool:
|
|
106
|
+
return click.confirm(_s(prompt, dim=True), default=default)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def interactive_wizard() -> None:
|
|
110
|
+
cfg = get_config()
|
|
111
|
+
|
|
112
|
+
click.echo()
|
|
113
|
+
click.echo(_s("\u2726 Orithos Scan Wizard", fg="green", bold=True))
|
|
114
|
+
click.echo(_s(" Configure your scan step by step.", dim=True))
|
|
115
|
+
click.echo()
|
|
116
|
+
|
|
117
|
+
# Step 1: Agent & Connection
|
|
118
|
+
click.echo(_s("Step 1/4: Agent & Connection", fg="green"))
|
|
119
|
+
click.echo(_s(" Fetching available agents...", dim=True))
|
|
120
|
+
agents = _fetch_agents(cfg)
|
|
121
|
+
if not agents:
|
|
122
|
+
click.echo(
|
|
123
|
+
_s(" No agents found. Create one in the dashboard first.", fg="red")
|
|
124
|
+
)
|
|
125
|
+
raise SystemExit(1)
|
|
126
|
+
agent = _pick_from_list(agents, "Select agent")
|
|
127
|
+
if not agent:
|
|
128
|
+
raise SystemExit(1)
|
|
129
|
+
agent_id = agent["id"]
|
|
130
|
+
|
|
131
|
+
click.echo(_s(" Fetching available connections...", dim=True))
|
|
132
|
+
connections = _fetch_connections(cfg)
|
|
133
|
+
connection_id = None
|
|
134
|
+
|
|
135
|
+
# Check for preferred connection on agent
|
|
136
|
+
preferred_id = agent.get("preferred_connection_id")
|
|
137
|
+
preferred_conn = (
|
|
138
|
+
next((c for c in connections if c.get("id") == preferred_id), None)
|
|
139
|
+
if preferred_id and connections
|
|
140
|
+
else None
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
skip_conn_picker = False
|
|
144
|
+
if preferred_conn:
|
|
145
|
+
conn_name = preferred_conn.get("name", "?")
|
|
146
|
+
click.echo(
|
|
147
|
+
f" Using preferred connection: {_s(conn_name, bold=True)} (auto-selected from agent)"
|
|
148
|
+
)
|
|
149
|
+
if not click.confirm(
|
|
150
|
+
_s(" Choose a different connection?", dim=True), default=False
|
|
151
|
+
):
|
|
152
|
+
connection_id = preferred_conn["id"]
|
|
153
|
+
agent_name = agent.get("name", agent_id[:8])
|
|
154
|
+
click.echo(
|
|
155
|
+
f" Pairing: {_s(agent_name, bold=True)} → {_s(preferred_conn.get('base_url', '?'), dim=True)}"
|
|
156
|
+
)
|
|
157
|
+
skip_conn_picker = True
|
|
158
|
+
|
|
159
|
+
if not skip_conn_picker and connections:
|
|
160
|
+
conn = _pick_from_list(
|
|
161
|
+
connections,
|
|
162
|
+
"Select connection (0 to skip)",
|
|
163
|
+
detail_keys=["base_url", "health_status", "model_name"],
|
|
164
|
+
)
|
|
165
|
+
if conn:
|
|
166
|
+
connection_id = conn["id"]
|
|
167
|
+
agent_name = agent.get("name", agent_id[:8])
|
|
168
|
+
click.echo(
|
|
169
|
+
f" Pairing: {_s(agent_name, bold=True)} → {_s(conn.get('base_url', '?'), dim=True)}"
|
|
170
|
+
)
|
|
171
|
+
click.echo()
|
|
172
|
+
|
|
173
|
+
# Step 2: Attack Suite
|
|
174
|
+
click.echo(_s("Step 2/4: Attack Suite", fg="green"))
|
|
175
|
+
click.echo(_s(" Select scan mode:", dim=True))
|
|
176
|
+
MODE_OPTIONS = [
|
|
177
|
+
("agent", "Agent-Targeted", "Attacks derived from this agent's exact config"),
|
|
178
|
+
("standard", "Standard Library", "Standard attack vectors"),
|
|
179
|
+
("full", "Full Coverage", "Both agent-targeted and standard probes"),
|
|
180
|
+
]
|
|
181
|
+
scan_mode = _pick(MODE_OPTIONS, "Select mode")
|
|
182
|
+
click.echo()
|
|
183
|
+
|
|
184
|
+
# Step 3: Configuration
|
|
185
|
+
click.echo(_s("Step 3/4: Configuration", fg="green"))
|
|
186
|
+
|
|
187
|
+
concurrency = click.prompt(" Concurrency (probes/batch)", type=int, default=50)
|
|
188
|
+
timeout_ms = click.prompt(" Timeout (ms)", type=int, default=30000)
|
|
189
|
+
session_isolation = _confirm(" Session isolation?")
|
|
190
|
+
test_guardrails = _confirm(" Test through guardrail layers?")
|
|
191
|
+
halt_on_critical = _confirm(" Halt on CRITICAL finding?", default=False)
|
|
192
|
+
capture_tool_traces = _confirm(" Capture tool call traces?")
|
|
193
|
+
slack_notify = _confirm(" Slack notification on completion?", default=False)
|
|
194
|
+
|
|
195
|
+
click.echo(_s(" Select scan depth:", dim=True))
|
|
196
|
+
depth = _pick(DEPTH_OPTIONS, "Choose depth")
|
|
197
|
+
|
|
198
|
+
click.echo(_s(" Select remediation mode:", dim=True))
|
|
199
|
+
remediation = _pick(REMEDIATION_OPTIONS, "Choose remediation mode")
|
|
200
|
+
|
|
201
|
+
click.echo(_s(" Schedule:", dim=True))
|
|
202
|
+
schedule = _pick(SCHEDULE_OPTIONS, "Choose schedule")
|
|
203
|
+
|
|
204
|
+
config = {
|
|
205
|
+
"concurrency": concurrency,
|
|
206
|
+
"timeout": timeout_ms,
|
|
207
|
+
"session_isolation": session_isolation,
|
|
208
|
+
"test_guardrails": test_guardrails,
|
|
209
|
+
"halt_on_critical": halt_on_critical,
|
|
210
|
+
"capture_tool_traces": capture_tool_traces,
|
|
211
|
+
"slack_notify": slack_notify,
|
|
212
|
+
"remediation_mode": remediation,
|
|
213
|
+
}
|
|
214
|
+
click.echo()
|
|
215
|
+
|
|
216
|
+
# Step 4: Review & Run
|
|
217
|
+
click.echo(_s("Step 4/4: Review & Launch", fg="green"))
|
|
218
|
+
mode_label = {
|
|
219
|
+
"agent": "Agent-Targeted",
|
|
220
|
+
"standard": "Standard Library",
|
|
221
|
+
"full": "Full Coverage",
|
|
222
|
+
}.get(scan_mode, scan_mode)
|
|
223
|
+
agent_name = agent.get("name", agent_id[:8])
|
|
224
|
+
conn_name = (
|
|
225
|
+
next(
|
|
226
|
+
(c.get("name", "?") for c in connections if c.get("id") == connection_id),
|
|
227
|
+
"None",
|
|
228
|
+
)
|
|
229
|
+
if connection_id
|
|
230
|
+
else "None"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
click.echo(_s("\u250c" + "\u2500" * 47 + "\u2510", dim=True))
|
|
234
|
+
for line in [
|
|
235
|
+
f" Agent: {agent_name}",
|
|
236
|
+
f" Connection: {conn_name}",
|
|
237
|
+
f" Mode: {mode_label}",
|
|
238
|
+
f" Depth: {depth}",
|
|
239
|
+
f" Remediation: {remediation}",
|
|
240
|
+
f" Concurrency: {concurrency}",
|
|
241
|
+
f" Isolation: {'On' if session_isolation else 'Off'}",
|
|
242
|
+
f" Slack: {'On' if slack_notify else 'Off'}",
|
|
243
|
+
]:
|
|
244
|
+
click.echo(_s(f"\u2502 {line:<45}\u2502", dim=True))
|
|
245
|
+
click.echo(_s("\u2514" + "\u2500" * 47 + "\u2518", dim=True))
|
|
246
|
+
click.echo()
|
|
247
|
+
|
|
248
|
+
click.echo(_s("Choose action:", bold=True))
|
|
249
|
+
click.echo(
|
|
250
|
+
f" [{_s('1', fg='green', bold=True)}] {_s('Launch Scan', fg='green', bold=True)}"
|
|
251
|
+
)
|
|
252
|
+
click.echo(
|
|
253
|
+
f" [{_s('2', fg='yellow', bold=True)}] {_s('Save as Template', fg='yellow', bold=True)}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
action = click.prompt("Enter choice", type=int, default=1)
|
|
257
|
+
if action == 2:
|
|
258
|
+
_save_template(
|
|
259
|
+
cfg,
|
|
260
|
+
agent_id,
|
|
261
|
+
connection_id,
|
|
262
|
+
depth,
|
|
263
|
+
scan_mode,
|
|
264
|
+
remediation,
|
|
265
|
+
config,
|
|
266
|
+
agent_name,
|
|
267
|
+
)
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
click.echo()
|
|
271
|
+
click.echo(_s("\u2726 Starting scan...", fg="green", bold=True))
|
|
272
|
+
probe_str = ",".join(DEFAULT_PROBES)
|
|
273
|
+
_run_scan(agent_id, probe_str, "summary", 2, 600, None, connection_id, config)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _save_template(
|
|
277
|
+
cfg,
|
|
278
|
+
agent_id: str,
|
|
279
|
+
connection_id: str | None,
|
|
280
|
+
depth: str,
|
|
281
|
+
attack_suite: str,
|
|
282
|
+
remediation: str,
|
|
283
|
+
config: dict,
|
|
284
|
+
default_name: str,
|
|
285
|
+
) -> None:
|
|
286
|
+
name = click.prompt("Template name", default=f"Scan - {default_name}")
|
|
287
|
+
template_config = dict(config)
|
|
288
|
+
template_config["remediation_mode"] = remediation
|
|
289
|
+
payload = {
|
|
290
|
+
"name": name,
|
|
291
|
+
"agent_id": agent_id,
|
|
292
|
+
"depth": depth,
|
|
293
|
+
"attack_suite": attack_suite,
|
|
294
|
+
"config": template_config,
|
|
295
|
+
}
|
|
296
|
+
if connection_id:
|
|
297
|
+
payload["connection_id"] = connection_id
|
|
298
|
+
try:
|
|
299
|
+
r = httpx.post(
|
|
300
|
+
f"{cfg.api_url}/v1/templates",
|
|
301
|
+
json=payload,
|
|
302
|
+
headers=cfg.auth_headers(),
|
|
303
|
+
timeout=cfg.timeout,
|
|
304
|
+
)
|
|
305
|
+
r.raise_for_status()
|
|
306
|
+
except httpx.HTTPStatusError as exc:
|
|
307
|
+
click.echo(
|
|
308
|
+
_s(f"Error saving template: {fmt_http_error(exc)}", fg="red"), err=True
|
|
309
|
+
)
|
|
310
|
+
raise SystemExit(1)
|
|
311
|
+
data = r.json()
|
|
312
|
+
click.echo(
|
|
313
|
+
_s(f"\u2726 Template saved: {data.get('id', '?')[:8]}", fg="green", bold=True)
|
|
314
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: orithos-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Orithos CLI — AI agent security testing from the terminal: scan deployed agents, MCP servers, and skill/plugin artifacts.
|
|
5
|
+
Author: Orithos
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://orithos.com
|
|
8
|
+
Project-URL: Documentation, https://orithos.com/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/nishkmg/TraceShield
|
|
10
|
+
Keywords: ai-security,agent-security,red-team,mcp,llm,security
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Information Technology
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: click>=8.1.0
|
|
21
|
+
Requires-Dist: httpx>=0.27.0
|
|
22
|
+
Requires-Dist: pydantic>=2.0.0
|
|
23
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
|
|
27
|
+
Requires-Dist: types-PyYAML>=6.0; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# Orithos CLI
|
|
30
|
+
|
|
31
|
+
AI agent security testing from the terminal. Scan deployed agents and MCP
|
|
32
|
+
servers against the Orithos probe catalog, vet skill/plugin artifacts before
|
|
33
|
+
they run, and pull findings, compliance reports, and evidence packages — all
|
|
34
|
+
from the command line.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install orithos-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Point the CLI at your Orithos account (or use ORITHOS_API_KEY / ORITHOS_API_URL env vars)
|
|
46
|
+
orithos configure
|
|
47
|
+
|
|
48
|
+
# Vet a skill or plugin before installing it — no account needed, fully offline
|
|
49
|
+
orithos skill scan ./some-agent-skill
|
|
50
|
+
|
|
51
|
+
# Scan a registered agent
|
|
52
|
+
orithos scan run --agent-id <AGENT_ID>
|
|
53
|
+
orithos scan status <SCAN_ID>
|
|
54
|
+
orithos scan findings <SCAN_ID> --format sarif
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## `orithos skill scan`
|
|
58
|
+
|
|
59
|
+
Static vetting for agent skills, plugins, and MCP packages. Runs offline with
|
|
60
|
+
zero outbound calls; archives (zip/tar.gz) are unpacked with traversal
|
|
61
|
+
protection.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
orithos skill scan <path|url> [--format text|json|sarif] [--fail-on critical|high|medium|low]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Seven check classes: manifest hygiene · declared-vs-implied permissions ·
|
|
68
|
+
outbound destinations (exfil-prone endpoints, IP literals) · shell & install
|
|
69
|
+
hooks · credential access · obfuscation · known-bad signatures. Every finding
|
|
70
|
+
cites file, line, and evidence.
|
|
71
|
+
|
|
72
|
+
Exit codes: `0` clean · `1` findings at/above `--fail-on` (default `high`) ·
|
|
73
|
+
`2` error — ready for CI gates.
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
- `orithos agent ...` — register and manage agent endpoints
|
|
78
|
+
- `orithos scan ...` — run scans, fetch findings (`summary`/`json`/`sarif`/`junit`)
|
|
79
|
+
- `orithos skill scan ...` — static skill/plugin vetting
|
|
80
|
+
- `orithos mcp ...` — interact with the Orithos MCP policy server
|
|
81
|
+
- `orithos compliance ...` — compliance mappings and reports
|
|
82
|
+
- `orithos verify <package>` — offline evidence-package integrity check (hashes + Merkle root)
|
|
83
|
+
- `orithos connection / discovery / graph / guardrail / probes / remediation / runtime ...` — platform workflows
|
|
84
|
+
|
|
85
|
+
## Environment variables
|
|
86
|
+
|
|
87
|
+
| Variable | Default | Description |
|
|
88
|
+
|----------|---------|-------------|
|
|
89
|
+
| `ORITHOS_API_KEY` | — | API key (`tsk_...`); overrides the config file |
|
|
90
|
+
| `ORITHOS_API_URL` | `https://api.orithos.com` | API base URL |
|
|
91
|
+
| `ORITHOS_ORG_ID` | — | Organisation ID (optional override) |
|
|
92
|
+
| `ORITHOS_TIMEOUT` | `30` | Request timeout in seconds |
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install -e "apps/cli[dev]"
|
|
98
|
+
pytest -q
|
|
99
|
+
```
|