shieldops-cli 1.0.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.
- shieldops_cli/__init__.py +3 -0
- shieldops_cli/api_client.py +102 -0
- shieldops_cli/auth.py +64 -0
- shieldops_cli/commands/__init__.py +1 -0
- shieldops_cli/commands/analyze.py +107 -0
- shieldops_cli/commands/autofix.py +64 -0
- shieldops_cli/commands/compose_gen.py +54 -0
- shieldops_cli/commands/compose_scan.py +55 -0
- shieldops_cli/commands/config_cmd.py +46 -0
- shieldops_cli/commands/k8s_scan.py +68 -0
- shieldops_cli/commands/sbom.py +46 -0
- shieldops_cli/commands/scan_image.py +118 -0
- shieldops_cli/commands/tui.py +27 -0
- shieldops_cli/config.py +54 -0
- shieldops_cli/formatters/__init__.py +17 -0
- shieldops_cli/formatters/json_fmt.py +6 -0
- shieldops_cli/formatters/sarif.py +62 -0
- shieldops_cli/formatters/summary.py +46 -0
- shieldops_cli/formatters/table.py +260 -0
- shieldops_cli/main.py +45 -0
- shieldops_cli-1.0.0.dist-info/METADATA +351 -0
- shieldops_cli-1.0.0.dist-info/RECORD +26 -0
- shieldops_cli-1.0.0.dist-info/WHEEL +5 -0
- shieldops_cli-1.0.0.dist-info/entry_points.txt +2 -0
- shieldops_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- shieldops_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Thin wrapper around /api/ext/run and other endpoints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import requests
|
|
4
|
+
from shieldops_cli import config
|
|
5
|
+
|
|
6
|
+
_TIMEOUT = 120 # seconds
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiError(Exception):
|
|
10
|
+
def __init__(self, status: int, code: str, message: str = ""):
|
|
11
|
+
self.status = status
|
|
12
|
+
self.code = code
|
|
13
|
+
self.message = message
|
|
14
|
+
super().__init__(f"[{status}] {code}: {message}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ShieldOpsClient:
|
|
18
|
+
def __init__(self, api_url: str | None = None, api_key: str | None = None):
|
|
19
|
+
self.api_url = (api_url or config.get_api_url()).rstrip("/")
|
|
20
|
+
self.api_key = api_key or config.get_api_key()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def _headers(self) -> dict:
|
|
24
|
+
h = {"Content-Type": "application/json"}
|
|
25
|
+
if self.api_key:
|
|
26
|
+
h["X-ShieldOps-Extension-Key"] = self.api_key
|
|
27
|
+
return h
|
|
28
|
+
|
|
29
|
+
# ── Core: run task ──────────────────────────────────
|
|
30
|
+
def run_task(
|
|
31
|
+
self,
|
|
32
|
+
task: str,
|
|
33
|
+
content: str,
|
|
34
|
+
filename: str = "untitled",
|
|
35
|
+
options: dict | None = None,
|
|
36
|
+
) -> dict:
|
|
37
|
+
"""POST /api/ext/run — runs any supported task."""
|
|
38
|
+
body = {
|
|
39
|
+
"task": task,
|
|
40
|
+
"content": content,
|
|
41
|
+
"filename": filename,
|
|
42
|
+
}
|
|
43
|
+
if options:
|
|
44
|
+
body["options"] = options
|
|
45
|
+
|
|
46
|
+
resp = requests.post(
|
|
47
|
+
f"{self.api_url}/api/ext/run",
|
|
48
|
+
json=body,
|
|
49
|
+
headers=self._headers,
|
|
50
|
+
timeout=_TIMEOUT,
|
|
51
|
+
)
|
|
52
|
+
is_json = resp.headers.get("content-type", "").startswith("application/json")
|
|
53
|
+
data = resp.json() if is_json else {}
|
|
54
|
+
|
|
55
|
+
if resp.status_code == 403:
|
|
56
|
+
raise ApiError(403, data.get("error", "access_denied"),
|
|
57
|
+
"Invalid or missing API key. Run: shieldops login --key <YOUR_KEY>")
|
|
58
|
+
if resp.status_code == 429:
|
|
59
|
+
raise ApiError(429, "rate_limit",
|
|
60
|
+
f"Daily limit reached. Upgrade at {self.api_url}/pricing")
|
|
61
|
+
if resp.status_code == 413:
|
|
62
|
+
raise ApiError(413, "content_too_large", "File too large (max 250KB).")
|
|
63
|
+
if resp.status_code >= 400:
|
|
64
|
+
if not is_json:
|
|
65
|
+
raise ApiError(resp.status_code, "server_error",
|
|
66
|
+
f"Server error ({resp.status_code}) while generating report.")
|
|
67
|
+
raise ApiError(resp.status_code,
|
|
68
|
+
data.get("error", "unknown"),
|
|
69
|
+
data.get("details", resp.text[:200]))
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
# ── Capabilities ────────────────────────────────────
|
|
73
|
+
def capabilities(self) -> dict:
|
|
74
|
+
"""GET /api/ext/capabilities — returns plan features and limits."""
|
|
75
|
+
resp = requests.get(
|
|
76
|
+
f"{self.api_url}/api/ext/capabilities",
|
|
77
|
+
headers=self._headers,
|
|
78
|
+
timeout=30,
|
|
79
|
+
)
|
|
80
|
+
if resp.status_code != 200:
|
|
81
|
+
return {}
|
|
82
|
+
return resp.json()
|
|
83
|
+
|
|
84
|
+
# ── Account info ────────────────────────────────────
|
|
85
|
+
def whoami(self) -> dict:
|
|
86
|
+
"""GET /api/ext/account — returns current user info."""
|
|
87
|
+
resp = requests.get(
|
|
88
|
+
f"{self.api_url}/api/ext/account",
|
|
89
|
+
headers=self._headers,
|
|
90
|
+
timeout=15,
|
|
91
|
+
)
|
|
92
|
+
if resp.status_code != 200:
|
|
93
|
+
return {"error": "not_authenticated"}
|
|
94
|
+
return resp.json()
|
|
95
|
+
|
|
96
|
+
# ── Health check ────────────────────────────────────
|
|
97
|
+
def ping(self) -> bool:
|
|
98
|
+
try:
|
|
99
|
+
resp = requests.get(f"{self.api_url}/api/ext/health", timeout=5)
|
|
100
|
+
return resp.status_code == 200
|
|
101
|
+
except Exception:
|
|
102
|
+
return False
|
shieldops_cli/auth.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Authentication commands."""
|
|
2
|
+
import click
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from shieldops_cli import config as cfg
|
|
5
|
+
from shieldops_cli.api_client import ShieldOpsClient
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.option("--key", prompt="API Key", hide_input=True,
|
|
12
|
+
help="API key from https://shieldops-ai.onrender.com/settings/api-keys")
|
|
13
|
+
@click.option("--url", default=None, help="Override API base URL.")
|
|
14
|
+
def login(key, url):
|
|
15
|
+
"""Authenticate with your ShieldOps API key."""
|
|
16
|
+
cfg.set_key("api_key", key.strip())
|
|
17
|
+
if url:
|
|
18
|
+
cfg.set_key("api_url", url.strip().rstrip("/"))
|
|
19
|
+
|
|
20
|
+
# Verify the key
|
|
21
|
+
client = ShieldOpsClient(api_key=key.strip())
|
|
22
|
+
info = client.whoami()
|
|
23
|
+
if "error" in info:
|
|
24
|
+
console.print("[yellow]\u26a0 Key saved but could not verify. Check it at:[/yellow]")
|
|
25
|
+
console.print(f" {cfg.get_api_url()}/settings/api-keys")
|
|
26
|
+
else:
|
|
27
|
+
plan = info.get("plan", "free").title()
|
|
28
|
+
name = info.get("name", info.get("email", "User"))
|
|
29
|
+
console.print(f"[green]\u2705 Authenticated as [bold]{name}[/bold] ({plan} plan)[/green]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.command()
|
|
33
|
+
def logout():
|
|
34
|
+
"""Remove stored credentials."""
|
|
35
|
+
cfg.set_key("api_key", "")
|
|
36
|
+
console.print("[green]\u2705 Logged out. API key removed.[/green]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.command()
|
|
40
|
+
def whoami():
|
|
41
|
+
"""Show current account info and plan."""
|
|
42
|
+
api_key = cfg.get_api_key()
|
|
43
|
+
if not api_key:
|
|
44
|
+
console.print("[yellow]Not authenticated. Run: shieldops login[/yellow]")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
client = ShieldOpsClient()
|
|
48
|
+
info = client.whoami()
|
|
49
|
+
if "error" in info:
|
|
50
|
+
console.print("[red]Could not fetch account info. Check your API key.[/red]")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
console.print(f"[bold]Name:[/bold] {info.get('name', '\u2014')}")
|
|
54
|
+
console.print(f"[bold]Email:[/bold] {info.get('email', '\u2014')}")
|
|
55
|
+
console.print(f"[bold]Plan:[/bold] {info.get('plan', 'free').title()}")
|
|
56
|
+
|
|
57
|
+
caps = client.capabilities()
|
|
58
|
+
limits = caps.get("limits", {})
|
|
59
|
+
used = limits.get("daily_ai_used", 0)
|
|
60
|
+
total = limits.get("daily_ai_requests")
|
|
61
|
+
if total:
|
|
62
|
+
console.print(f"[bold]Usage:[/bold] {used}/{total} requests today")
|
|
63
|
+
else:
|
|
64
|
+
console.print("[bold]Usage:[/bold] Unlimited")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ShieldOps CLI commands."""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""shieldops analyze — Dockerfile analysis."""
|
|
2
|
+
import sys
|
|
3
|
+
import click
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from shieldops_cli.api_client import ShieldOpsClient, ApiError
|
|
8
|
+
from shieldops_cli.formatters import format_result
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
17
|
+
@click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "sarif", "summary"]),
|
|
18
|
+
default=None, help="Output format. Default: from config or 'table'.")
|
|
19
|
+
@click.option("-o", "--output", type=click.Path(), default=None,
|
|
20
|
+
help="Write output to file instead of stdout.")
|
|
21
|
+
@click.option("--open-report", is_flag=True, default=False,
|
|
22
|
+
help="Open the full report in browser after scan.")
|
|
23
|
+
@click.option("--fail-on", type=click.Choice(["critical", "high", "medium", "low", "none"]),
|
|
24
|
+
default="none", help="Exit with code 1 if issues >= severity (for CI/CD).")
|
|
25
|
+
def analyze(file, fmt, output, open_report, fail_on):
|
|
26
|
+
"""Analyze a Dockerfile for security and best-practice issues.
|
|
27
|
+
|
|
28
|
+
\b
|
|
29
|
+
Examples:
|
|
30
|
+
shieldops analyze Dockerfile
|
|
31
|
+
shieldops analyze Dockerfile --format json --output report.json
|
|
32
|
+
shieldops analyze Dockerfile --fail-on high # CI/CD gate
|
|
33
|
+
shieldops analyze Dockerfile --open-report
|
|
34
|
+
"""
|
|
35
|
+
path = Path(file)
|
|
36
|
+
content = path.read_text(encoding="utf-8")
|
|
37
|
+
filename = path.name
|
|
38
|
+
|
|
39
|
+
client = ShieldOpsClient()
|
|
40
|
+
|
|
41
|
+
with console.status("[bold blue]Analyzing...", spinner="dots"):
|
|
42
|
+
try:
|
|
43
|
+
payload = client.run_task("analyze", content, filename)
|
|
44
|
+
except ApiError as e:
|
|
45
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
46
|
+
sys.exit(2)
|
|
47
|
+
|
|
48
|
+
result = payload.get("result", {})
|
|
49
|
+
|
|
50
|
+
# ── Format output ──
|
|
51
|
+
formatted = format_result("analyze", result, fmt=fmt or "table")
|
|
52
|
+
|
|
53
|
+
if output:
|
|
54
|
+
Path(output).write_text(formatted, encoding="utf-8")
|
|
55
|
+
console.print(f"[green]\u2705 Report saved to {output}[/green]")
|
|
56
|
+
else:
|
|
57
|
+
console.print(formatted)
|
|
58
|
+
|
|
59
|
+
# ── Report URL (canonical: result.report_url → top-level route → scan_id fallback) ──
|
|
60
|
+
report_url = (
|
|
61
|
+
result.get("report_url")
|
|
62
|
+
or payload.get("route")
|
|
63
|
+
)
|
|
64
|
+
if not report_url:
|
|
65
|
+
scan_id = result.get("scan_id") or payload.get("scan_id") or ""
|
|
66
|
+
if scan_id:
|
|
67
|
+
report_url = f"/analyze/report_view?scan_id={scan_id}&origin=extension"
|
|
68
|
+
|
|
69
|
+
if report_url:
|
|
70
|
+
base = client.api_url
|
|
71
|
+
full_url = report_url if report_url.startswith("http") else f"{base}{report_url}"
|
|
72
|
+
print(f"\nFull report: {full_url}")
|
|
73
|
+
|
|
74
|
+
if open_report:
|
|
75
|
+
import webbrowser
|
|
76
|
+
webbrowser.open(full_url)
|
|
77
|
+
else:
|
|
78
|
+
print("\nNo report URL returned for this scan.")
|
|
79
|
+
|
|
80
|
+
# ── CI/CD exit code ──
|
|
81
|
+
if fail_on != "none":
|
|
82
|
+
if check_severity_gate(result, fail_on):
|
|
83
|
+
console.print(f"\n[red]\u274c Issues found at {fail_on.upper()} or above. Failing.[/red]")
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_severity_gate(result: dict, threshold: str) -> bool:
|
|
88
|
+
"""Return True if any finding meets or exceeds threshold severity."""
|
|
89
|
+
threshold_level = SEVERITY_ORDER.get(threshold, 3)
|
|
90
|
+
|
|
91
|
+
# Try report_contract first
|
|
92
|
+
contract = result.get("report_contract", {})
|
|
93
|
+
if contract:
|
|
94
|
+
for sev in SEVERITY_ORDER:
|
|
95
|
+
if SEVERITY_ORDER[sev] <= threshold_level:
|
|
96
|
+
count = int(contract.get(f"{sev}_count", 0) or 0)
|
|
97
|
+
if count > 0:
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
# Fallback: check raw findings
|
|
102
|
+
findings = result.get("findings", result.get("results", []))
|
|
103
|
+
for f in findings:
|
|
104
|
+
sev = str(f.get("severity", "")).lower()
|
|
105
|
+
if SEVERITY_ORDER.get(sev, 99) <= threshold_level:
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""shieldops autofix — AI-powered Dockerfile auto-fix."""
|
|
2
|
+
import sys
|
|
3
|
+
import click
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from shieldops_cli.api_client import ShieldOpsClient, ApiError
|
|
8
|
+
from shieldops_cli.formatters import format_result
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command()
|
|
14
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
15
|
+
@click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "summary"]),
|
|
16
|
+
default=None, help="Output format.")
|
|
17
|
+
@click.option("-o", "--output", type=click.Path(), default=None,
|
|
18
|
+
help="Write output to file.")
|
|
19
|
+
@click.option("--apply", is_flag=True, default=False,
|
|
20
|
+
help="Apply the fix directly to the original file (creates .bak backup).")
|
|
21
|
+
def autofix(file, fmt, output, apply):
|
|
22
|
+
"""Auto-fix a Dockerfile using AI.
|
|
23
|
+
|
|
24
|
+
\b
|
|
25
|
+
Examples:
|
|
26
|
+
shieldops autofix Dockerfile
|
|
27
|
+
shieldops autofix Dockerfile --format json -o fixed.json
|
|
28
|
+
shieldops autofix Dockerfile --apply
|
|
29
|
+
"""
|
|
30
|
+
path = Path(file)
|
|
31
|
+
content = path.read_text(encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
client = ShieldOpsClient()
|
|
34
|
+
|
|
35
|
+
with console.status("[bold blue]Generating fix...", spinner="dots"):
|
|
36
|
+
try:
|
|
37
|
+
payload = client.run_task("autofix", content, path.name)
|
|
38
|
+
except ApiError as e:
|
|
39
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
40
|
+
sys.exit(2)
|
|
41
|
+
|
|
42
|
+
result = payload.get("result", {})
|
|
43
|
+
fixed_content = result.get("fixed_content", "")
|
|
44
|
+
|
|
45
|
+
if apply and fixed_content:
|
|
46
|
+
# Create backup
|
|
47
|
+
backup = path.with_suffix(path.suffix + ".bak")
|
|
48
|
+
backup.write_text(content, encoding="utf-8")
|
|
49
|
+
path.write_text(fixed_content, encoding="utf-8")
|
|
50
|
+
console.print(f"[green]\u2705 Fix applied to {path}. Backup at {backup}[/green]")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
formatted = format_result("autofix", result, fmt=fmt or "table")
|
|
54
|
+
|
|
55
|
+
if output:
|
|
56
|
+
Path(output).write_text(formatted, encoding="utf-8")
|
|
57
|
+
console.print(f"[green]\u2705 Report saved to {output}[/green]")
|
|
58
|
+
else:
|
|
59
|
+
console.print(formatted)
|
|
60
|
+
|
|
61
|
+
report_url = result.get("report_url")
|
|
62
|
+
if report_url:
|
|
63
|
+
full_url = report_url if report_url.startswith("http") else f"{client.api_url}{report_url}"
|
|
64
|
+
print(f"\nFull report: {full_url}")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""shieldops compose-generate — Generate Docker Compose from Dockerfile."""
|
|
2
|
+
import sys
|
|
3
|
+
import click
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from shieldops_cli.api_client import ShieldOpsClient, ApiError
|
|
8
|
+
from shieldops_cli.formatters import format_result
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("compose-generate")
|
|
14
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
15
|
+
@click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "summary"]),
|
|
16
|
+
default=None, help="Output format.")
|
|
17
|
+
@click.option("-o", "--output", type=click.Path(), default=None,
|
|
18
|
+
help="Write generated compose to file.")
|
|
19
|
+
def compose_generate(file, fmt, output):
|
|
20
|
+
"""Generate a Docker Compose file from a Dockerfile.
|
|
21
|
+
|
|
22
|
+
\b
|
|
23
|
+
Examples:
|
|
24
|
+
shieldops compose-generate Dockerfile
|
|
25
|
+
shieldops compose-generate Dockerfile -o docker-compose.yml
|
|
26
|
+
"""
|
|
27
|
+
path = Path(file)
|
|
28
|
+
content = path.read_text(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
client = ShieldOpsClient()
|
|
31
|
+
|
|
32
|
+
with console.status("[bold blue]Generating Compose file...", spinner="dots"):
|
|
33
|
+
try:
|
|
34
|
+
payload = client.run_task("compose_generator", content, path.name)
|
|
35
|
+
except ApiError as e:
|
|
36
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
37
|
+
sys.exit(2)
|
|
38
|
+
|
|
39
|
+
result = payload.get("result", {})
|
|
40
|
+
generated = result.get("compose_content", "")
|
|
41
|
+
|
|
42
|
+
if output and generated:
|
|
43
|
+
Path(output).write_text(generated, encoding="utf-8")
|
|
44
|
+
console.print(f"[green]\u2705 Compose file saved to {output}[/green]")
|
|
45
|
+
elif output:
|
|
46
|
+
formatted = format_result("compose_generator", result, fmt=fmt or "json")
|
|
47
|
+
Path(output).write_text(formatted, encoding="utf-8")
|
|
48
|
+
console.print(f"[green]\u2705 Output saved to {output}[/green]")
|
|
49
|
+
else:
|
|
50
|
+
if generated:
|
|
51
|
+
console.print(generated)
|
|
52
|
+
else:
|
|
53
|
+
formatted = format_result("compose_generator", result, fmt=fmt or "table")
|
|
54
|
+
console.print(formatted)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""shieldops compose-scan — Docker Compose file scanner."""
|
|
2
|
+
import sys
|
|
3
|
+
import click
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from shieldops_cli.api_client import ShieldOpsClient, ApiError
|
|
8
|
+
from shieldops_cli.formatters import format_result
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("compose-scan")
|
|
14
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
15
|
+
@click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "sarif", "summary"]),
|
|
16
|
+
default=None, help="Output format.")
|
|
17
|
+
@click.option("-o", "--output", type=click.Path(), default=None,
|
|
18
|
+
help="Write output to file.")
|
|
19
|
+
@click.option("--fail-on", type=click.Choice(["critical", "high", "medium", "low", "none"]),
|
|
20
|
+
default="none", help="Exit with code 1 if issues >= severity.")
|
|
21
|
+
def compose_scan(file, fmt, output, fail_on):
|
|
22
|
+
"""Scan a Docker Compose file for security and configuration issues.
|
|
23
|
+
|
|
24
|
+
\b
|
|
25
|
+
Examples:
|
|
26
|
+
shieldops compose-scan docker-compose.yml
|
|
27
|
+
shieldops compose-scan docker-compose.yml --format json
|
|
28
|
+
shieldops compose-scan docker-compose.yml --fail-on high
|
|
29
|
+
"""
|
|
30
|
+
path = Path(file)
|
|
31
|
+
content = path.read_text(encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
client = ShieldOpsClient()
|
|
34
|
+
|
|
35
|
+
with console.status("[bold blue]Scanning Compose file...", spinner="dots"):
|
|
36
|
+
try:
|
|
37
|
+
payload = client.run_task("compose", content, path.name)
|
|
38
|
+
except ApiError as e:
|
|
39
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
40
|
+
sys.exit(2)
|
|
41
|
+
|
|
42
|
+
result = payload.get("result", {})
|
|
43
|
+
formatted = format_result("compose", result, fmt=fmt or "table")
|
|
44
|
+
|
|
45
|
+
if output:
|
|
46
|
+
Path(output).write_text(formatted, encoding="utf-8")
|
|
47
|
+
console.print(f"[green]\u2705 Report saved to {output}[/green]")
|
|
48
|
+
else:
|
|
49
|
+
console.print(formatted)
|
|
50
|
+
|
|
51
|
+
if fail_on != "none":
|
|
52
|
+
from shieldops_cli.commands.analyze import check_severity_gate
|
|
53
|
+
if check_severity_gate(result, fail_on):
|
|
54
|
+
console.print(f"\n[red]\u274c Issues at {fail_on.upper()} or above. Failing.[/red]")
|
|
55
|
+
sys.exit(1)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""shieldops config — manage CLI settings."""
|
|
2
|
+
import click
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from shieldops_cli import config as cfg
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group("config")
|
|
11
|
+
def config_group():
|
|
12
|
+
"""View or change CLI configuration."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@config_group.command("list")
|
|
17
|
+
def config_list():
|
|
18
|
+
"""Show all configuration values."""
|
|
19
|
+
data = cfg.load()
|
|
20
|
+
table = Table(title="ShieldOps CLI Configuration")
|
|
21
|
+
table.add_column("Key", style="bold")
|
|
22
|
+
table.add_column("Value")
|
|
23
|
+
for k, v in sorted(data.items()):
|
|
24
|
+
display = "***" if k == "api_key" and v else str(v)
|
|
25
|
+
table.add_row(k, display)
|
|
26
|
+
console.print(table)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@config_group.command("set")
|
|
30
|
+
@click.argument("key")
|
|
31
|
+
@click.argument("value")
|
|
32
|
+
def config_set(key, value):
|
|
33
|
+
"""Set a configuration value. E.g.: shieldops config set default_format json"""
|
|
34
|
+
cfg.set_key(key, value)
|
|
35
|
+
console.print(f"[green]\u2705 {key} = {value}[/green]")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@config_group.command("get")
|
|
39
|
+
@click.argument("key")
|
|
40
|
+
def config_get(key):
|
|
41
|
+
"""Get a configuration value."""
|
|
42
|
+
val = cfg.get(key)
|
|
43
|
+
if val is None:
|
|
44
|
+
console.print(f"[yellow]Key '{key}' not found.[/yellow]")
|
|
45
|
+
else:
|
|
46
|
+
console.print(str(val))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""shieldops k8s-scan — Kubernetes manifest scanner."""
|
|
2
|
+
import sys
|
|
3
|
+
import click
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from shieldops_cli.api_client import ShieldOpsClient, ApiError
|
|
8
|
+
from shieldops_cli.formatters import format_result
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("k8s-scan")
|
|
14
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
15
|
+
@click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "sarif", "summary"]),
|
|
16
|
+
default=None, help="Output format.")
|
|
17
|
+
@click.option("-o", "--output", type=click.Path(), default=None,
|
|
18
|
+
help="Write output to file.")
|
|
19
|
+
@click.option("--open-report", is_flag=True, default=False,
|
|
20
|
+
help="Open the full report in browser.")
|
|
21
|
+
@click.option("--fail-on", type=click.Choice(["critical", "high", "medium", "low", "none"]),
|
|
22
|
+
default="none", help="Exit with code 1 if issues >= severity.")
|
|
23
|
+
def k8s_scan(file, fmt, output, open_report, fail_on):
|
|
24
|
+
"""Scan a Kubernetes manifest (YAML) for misconfigurations.
|
|
25
|
+
|
|
26
|
+
\b
|
|
27
|
+
Examples:
|
|
28
|
+
shieldops k8s-scan deployment.yaml
|
|
29
|
+
shieldops k8s-scan k8s/ --format sarif --output k8s-report.sarif
|
|
30
|
+
shieldops k8s-scan pod.yaml --fail-on high
|
|
31
|
+
"""
|
|
32
|
+
path = Path(file)
|
|
33
|
+
content = path.read_text(encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
client = ShieldOpsClient()
|
|
36
|
+
|
|
37
|
+
with console.status("[bold blue]Scanning Kubernetes manifest...", spinner="dots"):
|
|
38
|
+
try:
|
|
39
|
+
payload = client.run_task("k8s", content, path.name)
|
|
40
|
+
except ApiError as e:
|
|
41
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
42
|
+
sys.exit(2)
|
|
43
|
+
|
|
44
|
+
result = payload.get("result", {})
|
|
45
|
+
formatted = format_result("k8s", result, fmt=fmt or "table")
|
|
46
|
+
|
|
47
|
+
if output:
|
|
48
|
+
Path(output).write_text(formatted, encoding="utf-8")
|
|
49
|
+
console.print(f"[green]\u2705 Report saved to {output}[/green]")
|
|
50
|
+
else:
|
|
51
|
+
console.print(formatted)
|
|
52
|
+
|
|
53
|
+
report_url = result.get("report_url")
|
|
54
|
+
if report_url:
|
|
55
|
+
base = client.api_url
|
|
56
|
+
full_url = report_url if report_url.startswith("http") else f"{base}{report_url}"
|
|
57
|
+
print(f"\nFull report: {full_url}")
|
|
58
|
+
|
|
59
|
+
if open_report and report_url:
|
|
60
|
+
import webbrowser
|
|
61
|
+
full_url = report_url if report_url.startswith("http") else f"{client.api_url}{report_url}"
|
|
62
|
+
webbrowser.open(full_url)
|
|
63
|
+
|
|
64
|
+
if fail_on != "none":
|
|
65
|
+
from shieldops_cli.commands.analyze import check_severity_gate
|
|
66
|
+
if check_severity_gate(result, fail_on):
|
|
67
|
+
console.print(f"\n[red]\u274c Issues at {fail_on.upper()} or above. Failing.[/red]")
|
|
68
|
+
sys.exit(1)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""shieldops sbom — Generate Software Bill of Materials."""
|
|
2
|
+
import sys
|
|
3
|
+
import click
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from shieldops_cli.api_client import ShieldOpsClient, ApiError
|
|
8
|
+
from shieldops_cli.formatters import format_result
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command()
|
|
14
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
15
|
+
@click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "summary"]),
|
|
16
|
+
default=None, help="Output format.")
|
|
17
|
+
@click.option("-o", "--output", type=click.Path(), default=None,
|
|
18
|
+
help="Write output to file.")
|
|
19
|
+
def sbom(file, fmt, output):
|
|
20
|
+
"""Generate SBOM (Software Bill of Materials) from a Dockerfile or dependency file.
|
|
21
|
+
|
|
22
|
+
\b
|
|
23
|
+
Examples:
|
|
24
|
+
shieldops sbom Dockerfile
|
|
25
|
+
shieldops sbom requirements.txt -f json -o sbom.json
|
|
26
|
+
"""
|
|
27
|
+
path = Path(file)
|
|
28
|
+
content = path.read_text(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
client = ShieldOpsClient()
|
|
31
|
+
|
|
32
|
+
with console.status("[bold blue]Generating SBOM...", spinner="dots"):
|
|
33
|
+
try:
|
|
34
|
+
payload = client.run_task("sbom", content, path.name)
|
|
35
|
+
except ApiError as e:
|
|
36
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
37
|
+
sys.exit(2)
|
|
38
|
+
|
|
39
|
+
result = payload.get("result", {})
|
|
40
|
+
formatted = format_result("sbom", result, fmt=fmt or "table")
|
|
41
|
+
|
|
42
|
+
if output:
|
|
43
|
+
Path(output).write_text(formatted, encoding="utf-8")
|
|
44
|
+
console.print(f"[green]\u2705 SBOM saved to {output}[/green]")
|
|
45
|
+
else:
|
|
46
|
+
console.print(formatted)
|