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.
Files changed (40) hide show
  1. orithos_cli/__init__.py +6 -0
  2. orithos_cli/agent.py +78 -0
  3. orithos_cli/agent_wizard.py +255 -0
  4. orithos_cli/auth.py +21 -0
  5. orithos_cli/cli.py +82 -0
  6. orithos_cli/compliance.py +133 -0
  7. orithos_cli/config.py +114 -0
  8. orithos_cli/configure.py +103 -0
  9. orithos_cli/connection.py +69 -0
  10. orithos_cli/connection_wizard.py +129 -0
  11. orithos_cli/discovery.py +78 -0
  12. orithos_cli/graph.py +102 -0
  13. orithos_cli/guardrail.py +131 -0
  14. orithos_cli/mcp.py +170 -0
  15. orithos_cli/output.py +178 -0
  16. orithos_cli/probes.py +50 -0
  17. orithos_cli/remediation.py +111 -0
  18. orithos_cli/runtime.py +100 -0
  19. orithos_cli/scan.py +758 -0
  20. orithos_cli/skill.py +64 -0
  21. orithos_cli/skillscan/__init__.py +37 -0
  22. orithos_cli/skillscan/checks/__init__.py +28 -0
  23. orithos_cli/skillscan/checks/credentials.py +112 -0
  24. orithos_cli/skillscan/checks/iocs.py +95 -0
  25. orithos_cli/skillscan/checks/manifest.py +156 -0
  26. orithos_cli/skillscan/checks/network.py +117 -0
  27. orithos_cli/skillscan/checks/obfuscation.py +130 -0
  28. orithos_cli/skillscan/checks/permissions.py +108 -0
  29. orithos_cli/skillscan/checks/shell.py +140 -0
  30. orithos_cli/skillscan/collect.py +205 -0
  31. orithos_cli/skillscan/model.py +99 -0
  32. orithos_cli/skillscan/report.py +130 -0
  33. orithos_cli/template.py +60 -0
  34. orithos_cli/verify.py +85 -0
  35. orithos_cli/wizard.py +314 -0
  36. orithos_cli-0.1.0.dist-info/METADATA +99 -0
  37. orithos_cli-0.1.0.dist-info/RECORD +40 -0
  38. orithos_cli-0.1.0.dist-info/WHEEL +5 -0
  39. orithos_cli-0.1.0.dist-info/entry_points.txt +2 -0
  40. orithos_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,6 @@
1
+ """Orithos CLI — AI Agent Security Testing Platform."""
2
+
3
+ from orithos_cli.cli import main
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["main"]
orithos_cli/agent.py ADDED
@@ -0,0 +1,78 @@
1
+ """Agent management subcommands for Orithos CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import click
7
+
8
+ from orithos_cli.config import get_config
9
+
10
+
11
+ @click.group(name="agent")
12
+ def agent_group() -> None:
13
+ """Manage Orithos agents."""
14
+ pass
15
+
16
+
17
+ @agent_group.command("new")
18
+ @click.option("--name", default="", help="Agent name (omit for interactive wizard)")
19
+ def new_agent(name: str) -> None:
20
+ """Interactive agent setup — configure identity, prompt, tools, memory, guardrails."""
21
+ from orithos_cli.agent_wizard import interactive_agent_wizard
22
+
23
+ interactive_agent_wizard()
24
+
25
+
26
+ @agent_group.command("create")
27
+ @click.option("--name", default="", help="Agent name (omit for interactive wizard)")
28
+ def create_agent(name: str) -> None:
29
+ """Create a new agent — interactive wizard by default."""
30
+ from orithos_cli.agent_wizard import interactive_agent_wizard
31
+
32
+ interactive_agent_wizard()
33
+
34
+
35
+ @agent_group.command("list")
36
+ def list_agents() -> None:
37
+ """List all registered agents."""
38
+ cfg = get_config()
39
+
40
+ try:
41
+ response = httpx.get(
42
+ f"{cfg.api_url}/v1/agents",
43
+ headers=cfg.auth_headers(),
44
+ timeout=cfg.timeout,
45
+ )
46
+ response.raise_for_status()
47
+ except httpx.HTTPStatusError as exc:
48
+ click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
49
+ raise SystemExit(1)
50
+ except httpx.RequestError as exc:
51
+ click.echo(f"Connection error: {exc}", err=True)
52
+ raise SystemExit(1)
53
+
54
+ agents = response.json()
55
+ if not agents:
56
+ click.echo("No agents found.")
57
+ return
58
+
59
+ for agent in agents:
60
+ click.echo(f"[{agent['id']}] {agent['name']} — {agent['endpoint_url']}")
61
+
62
+
63
+ @agent_group.command("delete")
64
+ @click.argument("agent-id")
65
+ def delete_agent(agent_id: str) -> None:
66
+ """Delete an agent by ID."""
67
+ cfg = get_config()
68
+ try:
69
+ response = httpx.delete(
70
+ f"{cfg.api_url}/v1/agents/{agent_id}",
71
+ headers=cfg.auth_headers(),
72
+ timeout=cfg.timeout,
73
+ )
74
+ response.raise_for_status()
75
+ except httpx.HTTPStatusError as exc:
76
+ click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
77
+ raise SystemExit(1)
78
+ click.echo(f"Agent {agent_id[:8]} deleted.")
@@ -0,0 +1,255 @@
1
+ """Interactive agent wizard for Orithos CLI — 5 steps matching dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import httpx
7
+ import click
8
+
9
+ from orithos_cli.config import get_config, fmt_http_error
10
+
11
+
12
+ def _s(text: str = "", fg: str | None = None, dim: bool = False, bold: bool = False) -> str:
13
+ return click.style(text, fg=fg, dim=dim, bold=bold)
14
+
15
+
16
+ PROVIDER_OPTIONS = [
17
+ ("Pinecone", "Pinecone", "Vector database"),
18
+ ("Weaviate", "Weaviate", "Vector database"),
19
+ ("Chroma", "Chroma", "Vector database"),
20
+ ("Milvus", "Milvus", "Vector database"),
21
+ ("Qdrant", "Qdrant", "Vector database"),
22
+ ("Pgvector", "Pgvector", "PostgreSQL vector extension"),
23
+ ]
24
+
25
+ RAG_TYPE_OPTIONS = [
26
+ ("Vector Database", "Vector Database", "Embedding-based retrieval"),
27
+ ("Document Store", "Document Store", "Raw document storage"),
28
+ ("Knowledge Graph", "Knowledge Graph", "Graph-based retrieval"),
29
+ ]
30
+
31
+ GUARDRAIL_PROVIDERS = [
32
+ ("NVIDIA NeMo Guardrails", "NeMo Guardrails", "NeMo"),
33
+ ("Llama Guard", "Llama Guard", "Meta safety model"),
34
+ ("Custom Classifier", "Custom Classifier", "Custom classifier endpoint"),
35
+ ("OpenAI Moderation", "OpenAI Moderation", "OpenAI moderation API"),
36
+ ]
37
+
38
+ STAGE_OPTIONS = [
39
+ ("pre", "Pre", "Before agent inference"),
40
+ ("post", "Post", "After agent response"),
41
+ ("both", "Both", "Pre and post"),
42
+ ]
43
+
44
+ TOOL_PARAM_TYPES = ["string", "number", "integer", "boolean", "object", "array"]
45
+
46
+
47
+ def _pick(options: list[tuple[str, str, str]], prompt: str, default: int = 1) -> str:
48
+ for i, (_, label, desc) in enumerate(options, 1):
49
+ click.echo(f" [{i}] {_s(label, bold=True)} \u2014 {_s(desc, dim=True)}")
50
+ choice = click.prompt(prompt, type=int, default=default)
51
+ if choice < 1 or choice > len(options):
52
+ choice = default
53
+ return options[choice - 1][0]
54
+
55
+
56
+ def _confirm(prompt: str, default: bool = True) -> bool:
57
+ return click.confirm(_s(prompt, dim=True), default=default)
58
+
59
+
60
+ def _risk_class(name: str, desc: str) -> str:
61
+ low = (name + " " + desc).lower()
62
+ critical_kw = ["refund", "payment", "transfer", "charge", "delete", "remove", "purge",
63
+ "destroy", "create_user", "add_user", "grant", "elevate", "permission"]
64
+ write_kw = critical_kw + ["send", "message", "email", "notify", "write", "create",
65
+ "update", "post", "admin", "access", "install", "deploy"]
66
+ if any(w in low for w in critical_kw):
67
+ return "write"
68
+ if any(w in low for w in write_kw):
69
+ return "write"
70
+ return "read"
71
+
72
+
73
+ def interactive_agent_wizard() -> None:
74
+ cfg = get_config()
75
+
76
+ click.echo()
77
+ click.echo(_s("\u2726 Orithos Agent Wizard", fg="green", bold=True))
78
+ click.echo(_s(" Configure your agent step by step.", dim=True))
79
+ click.echo()
80
+
81
+ # === Step 1: Identity ===
82
+ click.echo(_s("Step 1/5: Identity", fg="green"))
83
+ name = click.prompt(" Agent name", default="")
84
+ if not name:
85
+ click.echo(_s(" Agent name is required.", fg="red"))
86
+ raise SystemExit(1)
87
+ version = click.prompt(" Version", default="1.0")
88
+ description = click.prompt(" Description", default="")
89
+ click.echo()
90
+
91
+ # === Step 2: System Prompt ===
92
+ click.echo(_s("Step 2/5: System Prompt", fg="green"))
93
+ click.echo(_s(" Paste the system prompt. Enter '.' on a blank line to finish.", dim=True))
94
+ lines: list[str] = []
95
+ while True:
96
+ line = input()
97
+ if line == ".":
98
+ break
99
+ lines.append(line)
100
+ system_prompt = "\n".join(lines).strip()
101
+
102
+ if system_prompt:
103
+ click.echo(_s(" Analysing prompt...", dim=True), nl=False)
104
+ try:
105
+ ar = httpx.post(
106
+ f"{cfg.api_url}/v1/agents/analyze-prompt",
107
+ json={"text": system_prompt},
108
+ headers=cfg.auth_headers(),
109
+ timeout=30,
110
+ )
111
+ if ar.status_code == 200:
112
+ analysis = ar.json()
113
+ constraints = analysis.get("constraints_count", 0)
114
+ identifiers = analysis.get("identifiers_count", 0)
115
+ probes = analysis.get("summary", {}).get("estimated_total_probes", 0)
116
+ leakage = analysis.get("leakage_risk", "low")
117
+ click.echo(_s(f" \u2713", fg="green"))
118
+ click.echo(_s(f" \u21b3 {constraints} constraints \u00b7 {identifiers} identifiers \u00b7 ~{probes} probes \u00b7 leakage: {leakage}", dim=True))
119
+ else:
120
+ click.echo(_s(" \u2716 (analysis unavailable)", fg="yellow"))
121
+ except httpx.RequestError:
122
+ click.echo(_s(" \u2716 (analysis unavailable)", fg="yellow"))
123
+ click.echo()
124
+
125
+ # === Step 3: Tools ===
126
+ click.echo(_s("Step 3/5: Tools (optional)", fg="green"))
127
+ tools: list[dict] = []
128
+ if _confirm(" Add tools?"):
129
+ while True:
130
+ t_name = click.prompt(" Tool name", default="")
131
+ if not t_name:
132
+ break
133
+ t_desc = click.prompt(" Description", default="")
134
+ params: list[dict] = []
135
+ click.echo(_s(" Parameters (enter blank name to stop):", dim=True))
136
+ pi = 1
137
+ while True:
138
+ p_name = click.prompt(f" param {pi} name", default="")
139
+ if not p_name:
140
+ break
141
+ p_type = click.prompt(f" param {pi} type", type=click.Choice(TOOL_PARAM_TYPES), default="string", show_choices=False)
142
+ params.append({"name": p_name, "type": p_type})
143
+ pi += 1
144
+
145
+ access = _risk_class(t_name, t_desc)
146
+ tools.append({
147
+ "name": t_name,
148
+ "description": t_desc,
149
+ "parameters": params,
150
+ "access_level": access,
151
+ })
152
+ click.echo(f" Risk: {_s(access, dim=True)}")
153
+ if not _confirm(" Add another tool?", default=False):
154
+ break
155
+ click.echo()
156
+
157
+ # === Step 4: Memory & Guardrails ===
158
+ click.echo(_s("Step 4/5: Memory & Guardrails (optional)", fg="green"))
159
+ memory_sources: list[dict] = []
160
+ guardrails: list[dict] = []
161
+
162
+ if _confirm(" Add RAG source?"):
163
+ while True:
164
+ rag_name = click.prompt(" Source name", default="knowledge-base")
165
+ rag_provider = _pick(PROVIDER_OPTIONS, " Provider")
166
+ rag_type = _pick(RAG_TYPE_OPTIONS, " Type", default=1)
167
+ rag_docs = click.prompt(" Document count", type=int, default=0)
168
+ memory_sources.append({
169
+ "name": rag_name,
170
+ "type": rag_type,
171
+ "provider": rag_provider,
172
+ "doc_count": rag_docs,
173
+ "risk": "supply_chain",
174
+ })
175
+ if not _confirm(" Add another RAG source?", default=False):
176
+ break
177
+
178
+ if _confirm(" Add guardrail?"):
179
+ while True:
180
+ g_name = click.prompt(" Guardrail name", default="")
181
+ g_provider = _pick(GUARDRAIL_PROVIDERS, " Provider")
182
+ g_stage = _pick(STAGE_OPTIONS, " Stage")
183
+ guardrails.append({
184
+ "name": g_name,
185
+ "type": g_provider,
186
+ "stage": g_stage,
187
+ "enabled": True,
188
+ })
189
+ if not _confirm(" Add another guardrail?", default=False):
190
+ break
191
+ click.echo()
192
+
193
+ # === Step 5: Confirm & Save ===
194
+ click.echo(_s("Step 5/5: Confirm & Save", fg="green"))
195
+ tool_count = len(tools)
196
+ tool_names = ", ".join(t.get("name", "?") for t in tools[:3])
197
+ if tool_count > 3:
198
+ tool_names += f" +{tool_count - 3} more"
199
+ prompt_preview = system_prompt[:60].replace("\n", "\\n") + ("..." if len(system_prompt) > 60 else "")
200
+ prompt_tokens = max(1, len(system_prompt) // 4)
201
+
202
+ click.echo(_s("\u250c" + "\u2500" * 47 + "\u2510", dim=True))
203
+ for line in [
204
+ f" Name: {name}",
205
+ f" Version: {version}",
206
+ f" Description: {description[:40] or '(none)'}",
207
+ f" Prompt: {prompt_tokens} tokens",
208
+ f" Tools: {tool_names or 'none'}",
209
+ f" RAG sources: {len(memory_sources)}",
210
+ f" Guardrails: {len(guardrails)}",
211
+ ]:
212
+ click.echo(_s(f"\u2502 {line:<45}\u2502", dim=True))
213
+ click.echo(_s("\u2514" + "\u2500" * 47 + "\u2518", dim=True))
214
+ click.echo()
215
+
216
+ if not _confirm(" Save agent?", default=True):
217
+ click.echo(_s(" Cancelled.", fg="yellow"))
218
+ return
219
+
220
+ payload: dict = {
221
+ "name": name,
222
+ "version": version,
223
+ "endpoint_url": "",
224
+ }
225
+ if description:
226
+ payload["description"] = description
227
+ if system_prompt:
228
+ payload["system_prompt"] = system_prompt
229
+ if tools:
230
+ payload["tools_config"] = tools
231
+ if memory_sources:
232
+ payload["memory_sources"] = memory_sources
233
+ if guardrails:
234
+ payload["guardrails"] = guardrails
235
+
236
+ try:
237
+ r = httpx.post(
238
+ f"{cfg.api_url}/v1/agents",
239
+ json=payload,
240
+ headers=cfg.auth_headers(),
241
+ timeout=cfg.timeout,
242
+ )
243
+ r.raise_for_status()
244
+ except httpx.HTTPStatusError as exc:
245
+ click.echo(_s(f"Error creating agent: {fmt_http_error(exc)}", fg="red"), err=True)
246
+ raise SystemExit(1)
247
+ except httpx.RequestError as exc:
248
+ click.echo(_s(f"Connection error: {exc}", fg="red"), err=True)
249
+ raise SystemExit(1)
250
+
251
+ data = r.json()
252
+ aid = data.get("id", "?")[:8]
253
+ click.echo(_s(f"\u2726 Agent created: {aid}", fg="green", bold=True))
254
+ click.echo(_s(f" Name: {data.get('name', '?')}", dim=True))
255
+ click.echo(_s(f" Tools: {len(tools)} RAG: {len(memory_sources)} Guardrails: {len(guardrails)}", dim=True))
orithos_cli/auth.py ADDED
@@ -0,0 +1,21 @@
1
+ """Authentication and request signing utilities for Orithos CLI."""
2
+
3
+ import os
4
+ import hmac
5
+ import hashlib
6
+ import time
7
+
8
+
9
+ def sign_request(method: str, path: str, body: bytes = b"") -> dict:
10
+ """Add HMAC-SHA256 signature headers to a request."""
11
+ key = os.environ.get("TRACESHIELD_REQUEST_SIGNING_KEY", "")
12
+ if not key:
13
+ return {} # No signing in dev mode
14
+
15
+ timestamp = int(time.time())
16
+ payload = f"{timestamp}.{method}.{path}.{hashlib.sha256(body).hexdigest()}".encode()
17
+ signature = hmac.new(key.encode(), payload, hashlib.sha256).hexdigest()
18
+ return {
19
+ "X-Orithos-Timestamp": str(timestamp),
20
+ "X-Orithos-Signature": f"t={timestamp},v1={signature}",
21
+ }
orithos_cli/cli.py ADDED
@@ -0,0 +1,82 @@
1
+ """Orithos CLI — main entry point."""
2
+
3
+ import click
4
+
5
+ from orithos_cli.configure import configure
6
+ from orithos_cli.agent import agent_group
7
+ from orithos_cli.compliance import compliance_group
8
+ from orithos_cli.connection import connection_group
9
+ from orithos_cli.discovery import discovery_group
10
+ from orithos_cli.graph import graph_group
11
+ from orithos_cli.guardrail import guardrail_group
12
+ from orithos_cli.mcp import mcp_group
13
+ from orithos_cli.probes import probes_group
14
+ from orithos_cli.remediation import remediation_group
15
+ from orithos_cli.runtime import alerting_group, runtime_group
16
+ from orithos_cli.scan import scan_group
17
+ from orithos_cli.skill import skill_group
18
+ from orithos_cli.template import template_group
19
+ from orithos_cli.verify import verify_package_file
20
+
21
+
22
+ @click.group()
23
+ @click.version_option(version="0.1.0", prog_name="orithos")
24
+ def main() -> None:
25
+ """Orithos CLI — AI Agent Security Testing Platform.
26
+
27
+ Quick start: orithos configure
28
+
29
+ Environment variables (override config file):
30
+ ORITHOS_API_KEY API key (tsk_...)
31
+ ORITHOS_API_URL API base URL (overrides default https://api.orithos.com)
32
+ ORITHOS_ORG_ID Organisation ID (optional override)
33
+ ORITHOS_TIMEOUT Request timeout in seconds (default: 30)
34
+ """
35
+ pass
36
+
37
+
38
+ @main.command("verify")
39
+ @click.argument("package_file", type=click.Path(exists=True, dir_okay=False))
40
+ def verify_cmd(package_file: str) -> None:
41
+ """Verify an evidence package offline (hashes + Merkle root).
42
+
43
+ Recomputes every artifact hash and the Merkle root from the package
44
+ content and compares against the declared manifest. No network access,
45
+ no Orithos contact — independent verifiability for auditors.
46
+ """
47
+ from pathlib import Path
48
+
49
+ result = verify_package_file(Path(package_file))
50
+ if result["ok"]:
51
+ click.echo("OK — package integrity verified")
52
+ else:
53
+ click.echo("FAIL — package integrity check failed")
54
+ for err in result["errors"]:
55
+ click.echo(f" - {err}")
56
+ for art in result.get("artifacts", []):
57
+ click.echo(f" {art['name']}: {art['sha256'][:16]}…")
58
+ if result.get("merkle_root"):
59
+ click.echo(f" merkle root: {result['merkle_root'][:16]}…")
60
+ if not result["ok"]:
61
+ raise SystemExit(1)
62
+
63
+
64
+ main.add_command(configure)
65
+ main.add_command(agent_group)
66
+ main.add_command(scan_group)
67
+ main.add_command(remediation_group)
68
+ main.add_command(guardrail_group)
69
+ main.add_command(graph_group)
70
+ main.add_command(runtime_group)
71
+ main.add_command(alerting_group)
72
+ main.add_command(discovery_group)
73
+ main.add_command(connection_group)
74
+ main.add_command(template_group)
75
+ main.add_command(probes_group)
76
+ main.add_command(compliance_group)
77
+ main.add_command(mcp_group)
78
+ main.add_command(skill_group)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
@@ -0,0 +1,133 @@
1
+ """Compliance mapping and reporting commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+ import httpx
9
+
10
+ from orithos_cli.config import get_config
11
+
12
+
13
+ @click.group(name="compliance")
14
+ def compliance_group() -> None:
15
+ """View compliance mappings and generate compliance reports."""
16
+ pass
17
+
18
+
19
+ @compliance_group.command("mappings")
20
+ @click.option(
21
+ "--framework", help="Filter by framework (OWASP_LLM, NIST_AI_RMF, MITRE_ATLAS, CWE)"
22
+ )
23
+ @click.option(
24
+ "--format", "output_format", type=click.Choice(["table", "json"]), default="table"
25
+ )
26
+ def list_mappings(framework: str | None, output_format: str) -> None:
27
+ """List all compliance mappings for the organisation."""
28
+ cfg = get_config()
29
+
30
+ try:
31
+ response = httpx.get(
32
+ f"{cfg.api_url}/v1/compliance/mappings",
33
+ headers=cfg.auth_headers(),
34
+ timeout=cfg.timeout,
35
+ )
36
+ response.raise_for_status()
37
+ except httpx.HTTPStatusError as exc:
38
+ click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
39
+ raise SystemExit(1)
40
+ except httpx.RequestError as exc:
41
+ click.echo(f"Connection error: {exc}", err=True)
42
+ raise SystemExit(1)
43
+
44
+ data = response.json()
45
+ # GET /v1/compliance/mappings returns {"mappings": [...], "total": N}.
46
+ mappings = (data.get("mappings") or []) if isinstance(data, dict) else data
47
+
48
+ if framework:
49
+ mappings = [m for m in mappings if m.get("framework") == framework]
50
+
51
+ if output_format == "json":
52
+ click.echo(json.dumps(mappings, indent=2))
53
+ return
54
+
55
+ frameworks = sorted(set(m["framework"] for m in mappings))
56
+ click.echo(
57
+ f"Compliance Mappings ({len(mappings)} total across {len(frameworks)} frameworks)"
58
+ )
59
+ click.echo(f"{'=' * 70}")
60
+
61
+ for fw in frameworks:
62
+ fw_mappings = [m for m in mappings if m["framework"] == fw]
63
+ click.echo(f"\n{fw} ({len(fw_mappings)} controls)")
64
+ click.echo("-" * 50)
65
+ for m in fw_mappings:
66
+ click.echo(f" [{m['control_id']}] {m['control_name']}")
67
+ click.echo(f" Severity mapping: {m['severity_mapping']}")
68
+ click.echo(f" {m['description'][:80]}...")
69
+
70
+
71
+ @compliance_group.command("report")
72
+ @click.argument("scan_id")
73
+ @click.option(
74
+ "--framework", help="Filter by framework (OWASP_LLM, NIST_AI_RMF, MITRE_ATLAS, CWE)"
75
+ )
76
+ @click.option(
77
+ "--format",
78
+ "output_format",
79
+ type=click.Choice(["summary", "json"]),
80
+ default="summary",
81
+ )
82
+ def compliance_report(scan_id: str, framework: str | None, output_format: str) -> None:
83
+ """Generate a compliance report for a completed scan."""
84
+ cfg = get_config()
85
+
86
+ try:
87
+ response = httpx.get(
88
+ f"{cfg.api_url}/v1/compliance/report/{scan_id}",
89
+ headers=cfg.auth_headers(),
90
+ timeout=cfg.timeout,
91
+ )
92
+ response.raise_for_status()
93
+ except httpx.HTTPStatusError as exc:
94
+ click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
95
+ raise SystemExit(1)
96
+ except httpx.RequestError as exc:
97
+ click.echo(f"Connection error: {exc}", err=True)
98
+ raise SystemExit(1)
99
+
100
+ report = response.json()
101
+
102
+ if output_format == "json":
103
+ click.echo(json.dumps(report, indent=2))
104
+ return
105
+
106
+ summary = report.get("summary", {})
107
+ click.echo(f"Orithos Compliance Report — Scan {scan_id}")
108
+ click.echo(f"{'=' * 60}")
109
+ click.echo(f"Total findings: {summary.get('total_findings', 0)}")
110
+ click.echo(f"High-risk findings: {summary.get('high_risk_findings', 0)}")
111
+ click.echo(
112
+ f"Frameworks with findings: {', '.join(summary.get('frameworks_with_findings', [])) or 'None'}"
113
+ )
114
+ click.echo("")
115
+
116
+ by_framework = report.get("by_framework", {})
117
+ if framework:
118
+ frameworks_to_show = [framework] if framework in by_framework else []
119
+ else:
120
+ frameworks_to_show = sorted(by_framework.keys())
121
+
122
+ for fw in frameworks_to_show:
123
+ controls = by_framework.get(fw, [])
124
+ if not controls:
125
+ continue
126
+ click.echo(f"\n{fw} ({len(controls)} findings)")
127
+ click.echo("-" * 50)
128
+ for c in controls:
129
+ # get_compliance_report returns control_id/control_name/our_severity
130
+ # (no remediation_guidance — removed with the regex-mapping rewrite).
131
+ click.echo(
132
+ f" [{c['control_id']}] {c['control_name']} — severity: {c.get('our_severity', 'N/A')}"
133
+ )
orithos_cli/config.py ADDED
@@ -0,0 +1,114 @@
1
+ """Configuration management for Orithos CLI.
2
+
3
+ Read order: CLI flag > env var > config file > default.
4
+ Config file: ~/.orithos/config.json
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import httpx
13
+
14
+
15
+ CONFIG_DIR = Path.home() / ".orithos"
16
+ CONFIG_FILE = CONFIG_DIR / "config.json"
17
+ DEFAULT_API_URL = "https://api.orithos.com"
18
+
19
+
20
+ @dataclass
21
+ class CLIConfig:
22
+ api_url: str
23
+ org_id: str
24
+ timeout: float
25
+ api_key: str
26
+
27
+ @classmethod
28
+ def from_env(cls) -> "CLIConfig":
29
+ file_cfg = _read_config_file()
30
+ return cls(
31
+ api_url=(
32
+ os.environ.get("ORITHOS_API_URL")
33
+ or os.environ.get("TRACESHIELD_API_URL")
34
+ or file_cfg.get("api_url", "")
35
+ or DEFAULT_API_URL
36
+ ),
37
+ org_id=(
38
+ os.environ.get("ORITHOS_ORG_ID")
39
+ or os.environ.get("TRACESHIELD_ORG_ID")
40
+ or file_cfg.get("org_id", "org_demo")
41
+ ),
42
+ timeout=float(
43
+ os.environ.get("ORITHOS_TIMEOUT")
44
+ or os.environ.get("TRACESHIELD_TIMEOUT")
45
+ or file_cfg.get("timeout", "30")
46
+ ),
47
+ api_key=(
48
+ os.environ.get("ORITHOS_API_KEY")
49
+ or os.environ.get("TRACESHIELD_INTERNAL_API_KEY")
50
+ or file_cfg.get("api_key", "")
51
+ ),
52
+ )
53
+
54
+ def auth_headers(self) -> dict[str, str]:
55
+ headers = {"x-org-id": self.org_id}
56
+ if self.api_key:
57
+ headers["Authorization"] = f"Bearer {self.api_key}"
58
+ return headers
59
+
60
+ def to_dict(self) -> dict[str, str]:
61
+ return {
62
+ "api_url": self.api_url,
63
+ "org_id": self.org_id,
64
+ "timeout": str(self.timeout),
65
+ "api_key": self.api_key,
66
+ }
67
+
68
+ @classmethod
69
+ def from_dict(cls, d: dict[str, str]) -> "CLIConfig":
70
+ return cls(
71
+ api_url=d.get("api_url", DEFAULT_API_URL),
72
+ org_id=d.get("org_id", "org_demo"),
73
+ timeout=float(d.get("timeout", "30")),
74
+ api_key=d.get("api_key", ""),
75
+ )
76
+
77
+
78
+ CONFIG: CLIConfig | None = None
79
+
80
+
81
+ def get_config() -> CLIConfig:
82
+ global CONFIG
83
+ if CONFIG is None:
84
+ CONFIG = CLIConfig.from_env()
85
+ return CONFIG
86
+
87
+
88
+ def _read_config_file() -> dict[str, str]:
89
+ try:
90
+ if CONFIG_FILE.exists():
91
+ with open(CONFIG_FILE) as f:
92
+ return json.load(f)
93
+ except (json.JSONDecodeError, OSError):
94
+ pass
95
+ return {}
96
+
97
+
98
+ def write_config(cfg: CLIConfig) -> Path:
99
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
100
+ with open(CONFIG_FILE, "w") as f:
101
+ json.dump(cfg.to_dict(), f, indent=2)
102
+ return CONFIG_FILE
103
+
104
+
105
+ def fmt_http_error(exc: httpx.HTTPStatusError) -> str:
106
+ """Extract a clean detail message from an HTTP error response."""
107
+ try:
108
+ body = exc.response.json()
109
+ msg = body.get("detail") or body.get("message") or body.get("error") or ""
110
+ if msg:
111
+ return f"{exc.response.status_code} — {msg}"
112
+ except (json.JSONDecodeError, AttributeError):
113
+ pass
114
+ return f"{exc.response.status_code} — {exc.response.text[:200]}"