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,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)
|
shieldops_cli/config.py
ADDED
|
@@ -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,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()
|