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.
@@ -0,0 +1,118 @@
1
+ """shieldops scan-image — Scan a Docker image for vulnerabilities (requires Trivy)."""
2
+ import sys
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ from rich.text import Text
7
+
8
+ console = Console()
9
+
10
+ SEVERITY_COLORS = {
11
+ "CRITICAL": "bold white on red",
12
+ "HIGH": "bold red",
13
+ "MEDIUM": "yellow",
14
+ "LOW": "dim",
15
+ }
16
+
17
+
18
+ @click.command("scan-image")
19
+ @click.argument("image_name")
20
+ @click.option("--severity", "-s", default="HIGH,CRITICAL",
21
+ help="Severity levels to report (e.g., HIGH,CRITICAL)")
22
+ @click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "summary"]),
23
+ default="table", help="Output format.")
24
+ @click.option("-o", "--output", type=click.Path(), default=None,
25
+ help="Write output to file.")
26
+ def scan_image(image_name, severity, fmt, output):
27
+ """Scan a Docker image from a registry for vulnerabilities.
28
+
29
+ Requires Trivy to be installed locally.
30
+
31
+ \b
32
+ Examples:
33
+ shieldops scan-image nginx:latest
34
+ shieldops scan-image python:3.11-slim --severity CRITICAL
35
+ shieldops scan-image myapp:v1 -f json -o vulns.json
36
+ """
37
+ try:
38
+ sys.path.insert(0, ".")
39
+ from src.analysis.registry_scanner import RegistryScanner
40
+
41
+ scanner = RegistryScanner()
42
+
43
+ if not scanner.is_trivy_installed():
44
+ console.print(f"[yellow]{scanner.get_installation_instructions()}[/yellow]")
45
+ return
46
+
47
+ with console.status(f"[bold blue]Scanning image: {image_name}...", spinner="dots"):
48
+ result = scanner.scan_image(image_name, severity)
49
+
50
+ if "error" in result:
51
+ console.print(f"[red]\u274c Error: {result['error']}[/red]")
52
+ if result.get("message"):
53
+ console.print(result["message"])
54
+ return
55
+
56
+ total = result["total_vulnerabilities"]
57
+
58
+ if fmt == "json":
59
+ import json
60
+ out = json.dumps(result, indent=2, ensure_ascii=False, default=str)
61
+ if output:
62
+ from pathlib import Path
63
+ Path(output).write_text(out, encoding="utf-8")
64
+ console.print(f"[green]\u2705 Report saved to {output}[/green]")
65
+ else:
66
+ console.print(out)
67
+ return
68
+
69
+ if fmt == "summary":
70
+ if total == 0:
71
+ line = f"\u2705 scan-image: No {severity} vulnerabilities in {image_name}"
72
+ else:
73
+ line = f"\u26a0\ufe0f scan-image: {total} vulnerabilities in {image_name}"
74
+ if output:
75
+ from pathlib import Path
76
+ Path(output).write_text(line, encoding="utf-8")
77
+ else:
78
+ console.print(line)
79
+ return
80
+
81
+ # Table format
82
+ if total == 0:
83
+ console.print(f"[green bold]\u2705 No {severity} vulnerabilities found in {image_name}![/green bold]")
84
+ else:
85
+ console.print(f"[red bold]\u26a0\ufe0f Found {total} vulnerabilities in {image_name}:[/red bold]")
86
+ console.print(f"Image: {result['image']}")
87
+ console.print(f"Scan Date: {result.get('scan_date', 'Unknown')}\n")
88
+
89
+ table = Table(title="Vulnerabilities", show_lines=True)
90
+ table.add_column("ID", width=18)
91
+ table.add_column("Severity", width=10)
92
+ table.add_column("Package", width=20)
93
+ table.add_column("Installed", width=12)
94
+ table.add_column("Fixed In", width=12)
95
+ table.add_column("Title", ratio=2)
96
+
97
+ for v in result["vulnerabilities"][:30]:
98
+ sev = v["severity"]
99
+ style = SEVERITY_COLORS.get(sev, "")
100
+ table.add_row(
101
+ v["vulnerability_id"],
102
+ Text(sev, style=style),
103
+ v["package_name"],
104
+ v["installed_version"],
105
+ v["fixed_version"],
106
+ v["title"][:80],
107
+ )
108
+
109
+ console.print(table)
110
+
111
+ if total > 30:
112
+ console.print(f"\n[dim]... and {total - 30} more vulnerabilities[/dim]")
113
+
114
+ except ImportError as e:
115
+ console.print(f"[red]\u274c Error importing scanner: {e}[/red]")
116
+ console.print("Make sure you're running from the project directory.")
117
+ except Exception as e:
118
+ console.print(f"[red]\u274c Unexpected error: {e}[/red]")
@@ -0,0 +1,27 @@
1
+ """TUI command — entry point for the interactive Terminal UI.
2
+
3
+ Thin Click wrapper that delegates to the shieldops_tui package.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import click
8
+
9
+
10
+ @click.command("tui")
11
+ @click.option(
12
+ "--theme",
13
+ type=click.Choice(["dark", "light"], case_sensitive=False),
14
+ default="dark",
15
+ show_default=True,
16
+ help="Color theme for the TUI.",
17
+ )
18
+ def tui(theme: str) -> None:
19
+ """Launch the interactive TUI (Claude-Code-like experience)."""
20
+ try:
21
+ from shieldops_tui.app import run
22
+ except ImportError as exc:
23
+ raise click.ClickException(
24
+ f"TUI dependencies missing: {exc}.\n\n"
25
+ "Install with: pip install 'shieldops-cli[tui]'"
26
+ )
27
+ run(theme=theme)
@@ -0,0 +1,54 @@
1
+ """Manage ~/.shieldops/config.json"""
2
+ from __future__ import annotations
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ CONFIG_DIR = Path.home() / ".shieldops"
8
+ CONFIG_FILE = CONFIG_DIR / "config.json"
9
+
10
+ DEFAULT_CONFIG = {
11
+ "api_url": "https://shieldops-ai.dev",
12
+ "api_key": "",
13
+ "default_format": "table",
14
+ "language": "en",
15
+ "color": True,
16
+ }
17
+
18
+
19
+ def load() -> dict:
20
+ if not CONFIG_FILE.exists():
21
+ return dict(DEFAULT_CONFIG)
22
+ try:
23
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
24
+ stored = json.load(f)
25
+ merged = dict(DEFAULT_CONFIG)
26
+ merged.update(stored)
27
+ return merged
28
+ except Exception:
29
+ return dict(DEFAULT_CONFIG)
30
+
31
+
32
+ def save(config: dict) -> None:
33
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
34
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
35
+ json.dump(config, f, indent=2)
36
+
37
+
38
+ def get(key: str, default=None):
39
+ return load().get(key, default)
40
+
41
+
42
+ def set_key(key: str, value) -> None:
43
+ cfg = load()
44
+ cfg[key] = value
45
+ save(cfg)
46
+
47
+
48
+ def get_api_key() -> str:
49
+ """API key priority: env var > config file."""
50
+ return os.environ.get("SHIELDOPS_API_KEY", "") or load().get("api_key", "")
51
+
52
+
53
+ def get_api_url() -> str:
54
+ return os.environ.get("SHIELDOPS_API_URL", "") or load().get("api_url", DEFAULT_CONFIG["api_url"])
@@ -0,0 +1,17 @@
1
+ """Output formatters for ShieldOps CLI."""
2
+ from shieldops_cli import config
3
+
4
+
5
+ def format_result(task: str, result: dict, fmt: str | None = None) -> str:
6
+ fmt = fmt or config.get("default_format", "table")
7
+ if fmt == "json":
8
+ from shieldops_cli.formatters.json_fmt import to_json
9
+ return to_json(result)
10
+ if fmt == "sarif":
11
+ from shieldops_cli.formatters.sarif import to_sarif
12
+ return to_sarif(task, result)
13
+ if fmt == "summary":
14
+ from shieldops_cli.formatters.summary import to_summary
15
+ return to_summary(task, result)
16
+ from shieldops_cli.formatters.table import to_table
17
+ return to_table(task, result)
@@ -0,0 +1,6 @@
1
+ """JSON output formatter."""
2
+ import json
3
+
4
+
5
+ def to_json(result: dict) -> str:
6
+ return json.dumps(result, indent=2, ensure_ascii=False, default=str)
@@ -0,0 +1,62 @@
1
+ """SARIF v2.1.0 formatter for CI/CD integration."""
2
+ import json
3
+
4
+
5
+ def to_sarif(task: str, result: dict) -> str:
6
+ findings = (
7
+ result.get("report_contract", {}).get("detailed_issues")
8
+ or result.get("results")
9
+ or result.get("findings")
10
+ or []
11
+ )
12
+
13
+ rules = {}
14
+ sarif_results = []
15
+
16
+ for f in findings:
17
+ rule_id = f.get("rule_id") or f.get("canonical_rule_id") or f"shieldops-{len(rules)}"
18
+ sev = str(f.get("severity", "info")).lower()
19
+ sarif_level = {
20
+ "critical": "error",
21
+ "high": "error",
22
+ "medium": "warning",
23
+ "low": "note",
24
+ }.get(sev, "note")
25
+
26
+ if rule_id not in rules:
27
+ rules[rule_id] = {
28
+ "id": rule_id,
29
+ "shortDescription": {"text": f.get("title") or f.get("message", rule_id)},
30
+ "defaultConfiguration": {"level": sarif_level},
31
+ }
32
+
33
+ line = int(f.get("line") or 1)
34
+ sarif_results.append({
35
+ "ruleId": rule_id,
36
+ "level": sarif_level,
37
+ "message": {"text": f.get("message") or f.get("title", "")},
38
+ "locations": [{
39
+ "physicalLocation": {
40
+ "artifactLocation": {"uri": f.get("file", "Dockerfile")},
41
+ "region": {"startLine": line},
42
+ }
43
+ }],
44
+ })
45
+
46
+ sarif_doc = {
47
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
48
+ "version": "2.1.0",
49
+ "runs": [{
50
+ "tool": {
51
+ "driver": {
52
+ "name": "ShieldOps AI",
53
+ "informationUri": "https://shieldops-ai.onrender.com",
54
+ "version": "1.0.0",
55
+ "rules": list(rules.values()),
56
+ }
57
+ },
58
+ "results": sarif_results,
59
+ }],
60
+ }
61
+
62
+ return json.dumps(sarif_doc, indent=2, ensure_ascii=False)
@@ -0,0 +1,46 @@
1
+ """One-line summary for scripts and CI logs."""
2
+ SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
3
+
4
+
5
+ def _first_present(*values):
6
+ """Return first non-None non-empty value (preserves 0 as valid)."""
7
+ for v in values:
8
+ if v is not None and v != "":
9
+ return v
10
+ return None
11
+
12
+
13
+ def _safe_score(result: dict) -> str:
14
+ contract = result.get("report_contract", {})
15
+ stats = result.get("stats", {})
16
+ score = _first_present(
17
+ result.get("security_score"),
18
+ result.get("security_score_percent"),
19
+ contract.get("security_score"),
20
+ contract.get("security_score_percent"),
21
+ contract.get("score"),
22
+ stats.get("security_score"),
23
+ stats.get("score"),
24
+ stats.get("security_score_percent"),
25
+ result.get("score"),
26
+ result.get("overall_score"),
27
+ )
28
+ return str(score) if score is not None else "N/A"
29
+
30
+
31
+ def to_summary(task: str, result: dict) -> str:
32
+ contract = result.get("report_contract", {})
33
+ findings = (
34
+ contract.get("detailed_issues")
35
+ or result.get("results")
36
+ or result.get("findings")
37
+ or []
38
+ )
39
+ total = len(findings)
40
+ critical = int(contract.get("critical_count", 0) or 0)
41
+ high = int(contract.get("high_count", 0) or 0)
42
+ score = _safe_score(result)
43
+
44
+ if total == 0:
45
+ return f"\u2705 {task}: No issues found. Score: {score}"
46
+ return f"\u26a0\ufe0f {task}: {total} issues (C:{critical} H:{high}). Score: {score}"
@@ -0,0 +1,260 @@
1
+ """Human-friendly Rich table output."""
2
+ from __future__ import annotations
3
+
4
+ from rich import box
5
+ from rich.console import Console, Group
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ SEVERITY_COLORS = {
11
+ "critical": "bold white on red",
12
+ "high": "bold red",
13
+ "medium": "yellow",
14
+ "low": "dim",
15
+ "info": "dim cyan",
16
+ }
17
+
18
+ SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
19
+
20
+
21
+ def _first_present(*values):
22
+ """Return first non-None non-empty value (preserves 0 as valid)."""
23
+ for v in values:
24
+ if v is not None and v != "":
25
+ return v
26
+ return None
27
+
28
+
29
+ def _safe_get_score(result: dict) -> tuple[object, str]:
30
+ contract = result.get("report_contract", {})
31
+ stats = result.get("stats", {})
32
+
33
+ score = _first_present(
34
+ result.get("security_score"),
35
+ result.get("security_score_percent"),
36
+ contract.get("security_score"),
37
+ contract.get("security_score_percent"),
38
+ contract.get("score"),
39
+ stats.get("security_score"),
40
+ stats.get("score"),
41
+ stats.get("security_score_percent"),
42
+ result.get("score"),
43
+ result.get("overall_score"),
44
+ )
45
+ if score is None:
46
+ score = "N/A"
47
+ grade = _first_present(
48
+ result.get("security_score_grade"),
49
+ contract.get("security_score_grade"),
50
+ contract.get("grade"),
51
+ stats.get("grade"),
52
+ result.get("grade"),
53
+ result.get("risk_grade"),
54
+ )
55
+ if grade is None:
56
+ grade = ""
57
+ return score, str(grade).strip()
58
+
59
+
60
+ def _safe_severity_counts(result: dict) -> dict[str, int]:
61
+ contract = result.get("report_contract", {})
62
+ counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
63
+ for severity in counts:
64
+ counts[severity] = int(
65
+ contract.get(f"{severity}_count", 0)
66
+ or result.get(f"{severity}_count", 0)
67
+ or 0
68
+ )
69
+ return counts
70
+
71
+
72
+ def to_table(task: str, result: dict, limit: int | None = None, **kwargs) -> str:
73
+ """Render readable plain text for files, tests, and no-color terminals."""
74
+ score, grade = _safe_get_score(result)
75
+ score_text = "Unavailable" if score == "N/A" else str(score)
76
+ if grade and score_text != "Unavailable":
77
+ score_text = f"{score_text} {grade}"
78
+
79
+ contract = result.get("report_contract", {})
80
+ findings = _get_findings(result)
81
+ counts = _safe_severity_counts(result)
82
+ total = len(findings)
83
+
84
+ lines = [
85
+ "ShieldOps AI",
86
+ _task_label(task),
87
+ f"Score: {score_text}",
88
+ f"Engine: {result.get('engine', 'ShieldOps AI')}",
89
+ "",
90
+ (
91
+ f"Total: {total} issues "
92
+ f"(C:{counts['critical']} H:{counts['high']} "
93
+ f"M:{counts['medium']} L:{counts['low']})"
94
+ ),
95
+ ]
96
+
97
+ if not findings:
98
+ lines.append("")
99
+ lines.append("OK No issues found!")
100
+ return "\n".join(lines) + "\n"
101
+
102
+ if limit is not None and limit <= 0:
103
+ lines.append("")
104
+ lines.append(f"Showing 0 of {total} findings. Use --full to see all.")
105
+ return "\n".join(lines) + "\n"
106
+
107
+ display = findings if limit is None else findings[:limit]
108
+ remaining = total - len(display) if limit else 0
109
+
110
+ lines.append("")
111
+ lines.append(f"Findings ({len(display)} of {total})" if remaining else f"Findings ({total})")
112
+ lines.append("")
113
+
114
+ for index, finding in enumerate(display, 1):
115
+ severity = _clean(finding.get("severity", "info")).upper()
116
+ rule = _clean(finding.get("rule_id") or finding.get("id") or finding.get("line") or "-")
117
+ category = _clean(finding.get("category", "-"))
118
+ message = _clean(
119
+ finding.get("message")
120
+ or finding.get("title")
121
+ or finding.get("description")
122
+ or "-"
123
+ )
124
+ fix = _clean(finding.get("fix") or finding.get("recommendation") or "-")
125
+
126
+ lines.extend(
127
+ [
128
+ f"{index}. {severity} - {rule}",
129
+ f" Category: {category}",
130
+ f" Finding: {message}",
131
+ f" Fix: {fix}",
132
+ "",
133
+ ]
134
+ )
135
+
136
+ if remaining > 0:
137
+ lines.append(f"... and {remaining} more findings. Use --full to see all.")
138
+
139
+ result_summary = result.get("summary")
140
+ if isinstance(result_summary, str) and result_summary:
141
+ lines.append("")
142
+ lines.append(result_summary)
143
+
144
+ return "\n".join(lines).rstrip() + "\n"
145
+
146
+
147
+ def render_table(task: str, result: dict, limit: int | None = None):
148
+ """Return Rich renderables for direct terminal output."""
149
+ score, grade = _safe_get_score(result)
150
+ if score == "N/A":
151
+ score_line = "Score: [bold dim]Unavailable[/bold dim]"
152
+ elif grade:
153
+ score_line = f"Score: [bold cyan]{score}[/bold cyan] {grade}"
154
+ else:
155
+ score_line = f"Score: [bold cyan]{score}[/bold cyan]"
156
+
157
+ header = Panel(
158
+ f"[bold]{_task_label(task)}[/bold]\n"
159
+ f"{score_line}\n"
160
+ f"Engine: {result.get('engine', 'ShieldOps AI')}",
161
+ title="ShieldOps AI",
162
+ border_style="blue",
163
+ box=box.ASCII,
164
+ expand=False,
165
+ )
166
+
167
+ findings = _get_findings(result)
168
+ if not findings:
169
+ return Group(header, Text("OK No issues found!", style="green"))
170
+
171
+ counts = _safe_severity_counts(result)
172
+ total = len(findings)
173
+ summary = Text.assemble(
174
+ ("Total: ", "bold"),
175
+ str(total),
176
+ " ",
177
+ (f" C:{counts['critical']} ", SEVERITY_COLORS["critical"]),
178
+ " ",
179
+ (f"H:{counts['high']}", SEVERITY_COLORS["high"]),
180
+ " ",
181
+ (f"M:{counts['medium']}", SEVERITY_COLORS["medium"]),
182
+ " ",
183
+ (f"L:{counts['low']}", SEVERITY_COLORS["low"]),
184
+ )
185
+
186
+ if limit is not None and limit <= 0:
187
+ return Group(
188
+ header,
189
+ summary,
190
+ Text(f"Showing 0 of {total} findings. Use --full to see all.", style="dim"),
191
+ )
192
+
193
+ display = findings if limit is None else findings[:limit]
194
+ remaining = total - len(display) if limit else 0
195
+
196
+ table = Table(
197
+ title=f"Findings ({len(display)} of {total})" if remaining else f"Findings ({total})",
198
+ show_lines=True,
199
+ box=box.ASCII,
200
+ expand=False,
201
+ )
202
+ table.add_column("#", justify="right", width=3, no_wrap=True)
203
+ table.add_column("Severity", width=10, no_wrap=True)
204
+ table.add_column("Rule", width=12, overflow="fold")
205
+ table.add_column("Category", width=16, overflow="fold")
206
+ table.add_column("Finding", min_width=24, ratio=3, overflow="fold")
207
+ table.add_column("Fix", min_width=24, ratio=2, overflow="fold")
208
+
209
+ for index, finding in enumerate(display, 1):
210
+ severity = str(finding.get("severity", "info")).lower()
211
+ table.add_row(
212
+ str(index),
213
+ Text(severity.upper(), style=SEVERITY_COLORS.get(severity, "")),
214
+ _clean(finding.get("rule_id") or finding.get("id") or finding.get("line") or "-"),
215
+ _clean(finding.get("category", "-")),
216
+ _clean(
217
+ finding.get("message")
218
+ or finding.get("title")
219
+ or finding.get("description")
220
+ or "-"
221
+ ),
222
+ _clean(finding.get("fix") or finding.get("recommendation") or "-"),
223
+ )
224
+
225
+ renderables = [header, summary, table]
226
+ if remaining > 0:
227
+ renderables.append(Text(f"... and {remaining} more findings. Use --full to see all.", style="dim"))
228
+
229
+ result_summary = result.get("summary")
230
+ if isinstance(result_summary, str) and result_summary:
231
+ renderables.append(Text(f"\n{result_summary}", style="dim"))
232
+
233
+ return Group(*renderables)
234
+
235
+
236
+ def _clean(value) -> str:
237
+ text = str(value if value is not None else "-").strip()
238
+ return text or "-"
239
+
240
+
241
+ def _get_findings(result: dict) -> list:
242
+ contract = result.get("report_contract", {})
243
+ return (
244
+ contract.get("detailed_issues")
245
+ or result.get("results")
246
+ or result.get("findings")
247
+ or []
248
+ )
249
+
250
+
251
+ def _task_label(task: str) -> str:
252
+ return {
253
+ "analyze": "Dockerfile Analysis",
254
+ "autofix": "AutoFix",
255
+ "sbom": "SBOM",
256
+ "compose": "Compose Scan",
257
+ "k8s": "Kubernetes Scan",
258
+ "cost": "Cloud Cost",
259
+ "compose_generator": "Compose Generator",
260
+ }.get(task, task.title())
shieldops_cli/main.py ADDED
@@ -0,0 +1,45 @@
1
+ """shieldops — CLI entry point."""
2
+ import click
3
+ from shieldops_cli import __version__
4
+
5
+
6
+ @click.group()
7
+ @click.version_option(__version__, prog_name="shieldops")
8
+ def cli():
9
+ """ShieldOps AI — Security scanner for Docker, Kubernetes, Compose, SBOM, and more."""
10
+ pass
11
+
12
+
13
+ # ── Import and register commands ──
14
+ from shieldops_cli.auth import login, logout, whoami
15
+ from shieldops_cli.commands.analyze import analyze
16
+ from shieldops_cli.commands.autofix import autofix
17
+ from shieldops_cli.commands.sbom import sbom
18
+ from shieldops_cli.commands.compose_scan import compose_scan
19
+ from shieldops_cli.commands.k8s_scan import k8s_scan
20
+ from shieldops_cli.commands.compose_gen import compose_generate
21
+ from shieldops_cli.commands.scan_image import scan_image
22
+ from shieldops_cli.commands.config_cmd import config_group
23
+
24
+ cli.add_command(login)
25
+ cli.add_command(logout)
26
+ cli.add_command(whoami)
27
+ cli.add_command(analyze)
28
+ cli.add_command(autofix)
29
+ cli.add_command(sbom)
30
+ cli.add_command(compose_scan, "compose-scan")
31
+ cli.add_command(k8s_scan, "k8s-scan")
32
+ cli.add_command(compose_generate, "compose-generate")
33
+ cli.add_command(scan_image, "scan-image")
34
+ cli.add_command(config_group, "config")
35
+
36
+ # ── TUI command (optional, requires prompt_toolkit) ──
37
+ try:
38
+ from shieldops_cli.commands.tui import tui
39
+ cli.add_command(tui)
40
+ except ImportError:
41
+ pass # TUI extra not installed; CLI works without it
42
+
43
+
44
+ if __name__ == "__main__":
45
+ cli()