shieldctl 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.
- shieldctl/__init__.py +0 -0
- shieldctl/cli.py +154 -0
- shieldctl/config.py +59 -0
- shieldctl/dispatcher.py +170 -0
- shieldctl/install.py +224 -0
- shieldctl/models.py +38 -0
- shieldctl/reporters/__init__.py +0 -0
- shieldctl/reporters/html_reporter.py +164 -0
- shieldctl/reporters/json_reporter.py +22 -0
- shieldctl/reporters/terminal.py +108 -0
- shieldctl/scanners/__init__.py +0 -0
- shieldctl/scanners/base.py +42 -0
- shieldctl/scanners/bash.py +159 -0
- shieldctl/scanners/dockerfile.py +61 -0
- shieldctl/scanners/gha.py +111 -0
- shieldctl/scanners/kubernetes.py +76 -0
- shieldctl/scanners/pip_audit.py +65 -0
- shieldctl/scanners/python_scanner.py +100 -0
- shieldctl/scanners/secrets.py +49 -0
- shieldctl/scanners/terraform.py +240 -0
- shieldctl/scanners/yaml_scanner.py +126 -0
- shieldctl-0.1.0.dist-info/METADATA +500 -0
- shieldctl-0.1.0.dist-info/RECORD +27 -0
- shieldctl-0.1.0.dist-info/WHEEL +5 -0
- shieldctl-0.1.0.dist-info/entry_points.txt +2 -0
- shieldctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- shieldctl-0.1.0.dist-info/top_level.txt +1 -0
shieldctl/__init__.py
ADDED
|
File without changes
|
shieldctl/cli.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .config import load_config
|
|
10
|
+
from .dispatcher import run_scan
|
|
11
|
+
from .models import Severity
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="shieldctl",
|
|
15
|
+
help="Infrastructure security scanner for Terraform, shell, YAML, Dockerfiles, and Python.",
|
|
16
|
+
add_completion=False,
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OutputFormat(str, Enum):
|
|
22
|
+
terminal = "terminal"
|
|
23
|
+
json = "json"
|
|
24
|
+
html = "html"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FailOn(str, Enum):
|
|
28
|
+
INFO = "INFO"
|
|
29
|
+
LOW = "LOW"
|
|
30
|
+
MEDIUM = "MEDIUM"
|
|
31
|
+
HIGH = "HIGH"
|
|
32
|
+
CRITICAL = "CRITICAL"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command()
|
|
36
|
+
def scan(
|
|
37
|
+
path: Path = typer.Argument(Path("."), help="Path to scan"),
|
|
38
|
+
fail_on: FailOn = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
"--fail-on",
|
|
41
|
+
help="Minimum severity that causes a non-zero exit. Overrides .shieldctl.yaml.",
|
|
42
|
+
),
|
|
43
|
+
format: OutputFormat = typer.Option(OutputFormat.terminal, "--format", "-f"),
|
|
44
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write report to file"),
|
|
45
|
+
changed_only: bool = typer.Option(
|
|
46
|
+
False, "--changed-only", help="Only scan files changed vs git HEAD / main"
|
|
47
|
+
),
|
|
48
|
+
scanner: Optional[str] = typer.Option(
|
|
49
|
+
None, "--scanner", help="Run a single scanner by name (e.g. terraform, bash)"
|
|
50
|
+
),
|
|
51
|
+
strict: bool = typer.Option(
|
|
52
|
+
False,
|
|
53
|
+
"--strict",
|
|
54
|
+
help="Fail immediately if a required scanner tool is not installed.",
|
|
55
|
+
),
|
|
56
|
+
) -> None:
|
|
57
|
+
root = path.resolve()
|
|
58
|
+
if not root.exists():
|
|
59
|
+
typer.echo(f"Error: path '{root}' does not exist", err=True)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
config = load_config(root)
|
|
63
|
+
if fail_on is not None:
|
|
64
|
+
config.fail_on = Severity.from_str(fail_on.value)
|
|
65
|
+
|
|
66
|
+
from .scanners.base import ScannerError
|
|
67
|
+
try:
|
|
68
|
+
findings = run_scan(
|
|
69
|
+
root, config,
|
|
70
|
+
changed_only=changed_only,
|
|
71
|
+
scanner_filter=scanner,
|
|
72
|
+
strict=strict,
|
|
73
|
+
)
|
|
74
|
+
except ScannerError as e:
|
|
75
|
+
typer.echo(f"Error: {e}", err=True)
|
|
76
|
+
raise typer.Exit(4)
|
|
77
|
+
|
|
78
|
+
# Select reporter
|
|
79
|
+
if format == OutputFormat.terminal:
|
|
80
|
+
from .reporters.terminal import TerminalReporter
|
|
81
|
+
reporter = TerminalReporter()
|
|
82
|
+
elif format == OutputFormat.json:
|
|
83
|
+
from .reporters.json_reporter import JsonReporter
|
|
84
|
+
reporter = JsonReporter()
|
|
85
|
+
else:
|
|
86
|
+
from .reporters.html_reporter import HtmlReporter
|
|
87
|
+
reporter = HtmlReporter()
|
|
88
|
+
|
|
89
|
+
content = reporter.render(findings)
|
|
90
|
+
|
|
91
|
+
if output:
|
|
92
|
+
output.write_text(content)
|
|
93
|
+
typer.echo(f"Report written to {output}")
|
|
94
|
+
elif format != OutputFormat.terminal:
|
|
95
|
+
typer.echo(content)
|
|
96
|
+
# terminal reporter prints directly to stdout via rich
|
|
97
|
+
|
|
98
|
+
# Exit code based on highest severity found
|
|
99
|
+
if not findings:
|
|
100
|
+
raise typer.Exit(0)
|
|
101
|
+
|
|
102
|
+
max_sev = max(f.severity for f in findings)
|
|
103
|
+
if max_sev >= config.fail_on:
|
|
104
|
+
raise typer.Exit(3)
|
|
105
|
+
elif max_sev >= Severity.MEDIUM:
|
|
106
|
+
raise typer.Exit(2)
|
|
107
|
+
else:
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command("install-tools")
|
|
112
|
+
def install_tools(
|
|
113
|
+
dry_run: bool = typer.Option(
|
|
114
|
+
False, "--dry-run", help="Show what would be installed without doing it."
|
|
115
|
+
),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Install all scanner dependencies (checkov, tfsec, shellcheck, etc.)."""
|
|
118
|
+
from rich.console import Console
|
|
119
|
+
from rich.table import Table
|
|
120
|
+
from rich import box
|
|
121
|
+
from .install import install_all
|
|
122
|
+
|
|
123
|
+
con = Console()
|
|
124
|
+
con.print("\n[bold]Installing shieldctl scanner dependencies…[/bold]\n")
|
|
125
|
+
|
|
126
|
+
results = install_all(dry_run=dry_run)
|
|
127
|
+
|
|
128
|
+
table = Table(box=box.SIMPLE_HEAD)
|
|
129
|
+
table.add_column("Tool", width=14)
|
|
130
|
+
table.add_column("Status", width=10)
|
|
131
|
+
table.add_column("Note")
|
|
132
|
+
|
|
133
|
+
status_style = {"ok": "green", "skipped": "dim", "failed": "red"}
|
|
134
|
+
failed = []
|
|
135
|
+
for r in results:
|
|
136
|
+
style = status_style.get(r.status, "")
|
|
137
|
+
table.add_row(r.tool, f"[{style}]{r.status}[/{style}]", r.note)
|
|
138
|
+
if r.status == "failed":
|
|
139
|
+
failed.append(r.tool)
|
|
140
|
+
|
|
141
|
+
con.print(table)
|
|
142
|
+
|
|
143
|
+
if failed:
|
|
144
|
+
con.print(f"\n[red]Failed to install: {', '.join(failed)}[/red]")
|
|
145
|
+
con.print("Install them manually — see each tool's documentation.")
|
|
146
|
+
raise typer.Exit(1)
|
|
147
|
+
else:
|
|
148
|
+
con.print("[green]All tools ready.[/green]\n")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command(hidden=True)
|
|
152
|
+
def version() -> None:
|
|
153
|
+
"""Print version."""
|
|
154
|
+
typer.echo("shieldctl 0.1.0")
|
shieldctl/config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from .models import Severity
|
|
9
|
+
|
|
10
|
+
_DEFAULTS = {
|
|
11
|
+
"fail_on": "HIGH",
|
|
12
|
+
"exclude_paths": [
|
|
13
|
+
"**/.terraform/**",
|
|
14
|
+
"**/.terragrunt-cache/**",
|
|
15
|
+
"**/node_modules/**",
|
|
16
|
+
"**/.git/**",
|
|
17
|
+
],
|
|
18
|
+
"suppress": [],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Suppression:
|
|
24
|
+
rule: str
|
|
25
|
+
reason: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ShieldConfig:
|
|
30
|
+
fail_on: Severity
|
|
31
|
+
exclude_paths: list[str]
|
|
32
|
+
suppress: list[Suppression]
|
|
33
|
+
|
|
34
|
+
def is_suppressed(self, rule: str) -> bool:
|
|
35
|
+
return any(s.rule == rule for s in self.suppress)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_config(path: Path) -> ShieldConfig:
|
|
39
|
+
raw = dict(_DEFAULTS)
|
|
40
|
+
config_file = path / ".shieldctl.yaml"
|
|
41
|
+
if config_file.exists():
|
|
42
|
+
try:
|
|
43
|
+
with config_file.open() as f:
|
|
44
|
+
override = yaml.safe_load(f) or {}
|
|
45
|
+
except yaml.YAMLError as e:
|
|
46
|
+
raise ValueError(f"Invalid .shieldctl.yaml: {e}")
|
|
47
|
+
raw.update(override)
|
|
48
|
+
|
|
49
|
+
suppressions = [
|
|
50
|
+
Suppression(rule=s["rule"], reason=s.get("reason", ""))
|
|
51
|
+
for s in raw.get("suppress", [])
|
|
52
|
+
if s.get("rule")
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
return ShieldConfig(
|
|
56
|
+
fail_on=Severity.from_str(raw["fail_on"]),
|
|
57
|
+
exclude_paths=raw["exclude_paths"],
|
|
58
|
+
suppress=suppressions,
|
|
59
|
+
)
|
shieldctl/dispatcher.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import fnmatch
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import ShieldConfig
|
|
9
|
+
from .models import Finding
|
|
10
|
+
from .scanners.base import BaseScanner, ScannerError
|
|
11
|
+
from .scanners.terraform import TerraformScanner
|
|
12
|
+
from .scanners.secrets import SecretsScanner
|
|
13
|
+
from .scanners.bash import BashScanner
|
|
14
|
+
from .scanners.yaml_scanner import YamlScanner
|
|
15
|
+
from .scanners.dockerfile import DockerfileScanner
|
|
16
|
+
from .scanners.python_scanner import PythonScanner
|
|
17
|
+
from .scanners.kubernetes import KubernetesScanner
|
|
18
|
+
from .scanners.gha import GhaScanner
|
|
19
|
+
from .scanners.pip_audit import PipAuditScanner
|
|
20
|
+
|
|
21
|
+
import rich.console
|
|
22
|
+
|
|
23
|
+
console = rich.console.Console(stderr=True)
|
|
24
|
+
|
|
25
|
+
_ALL_SCANNERS: list[BaseScanner] = [
|
|
26
|
+
TerraformScanner(),
|
|
27
|
+
SecretsScanner(),
|
|
28
|
+
BashScanner(),
|
|
29
|
+
YamlScanner(),
|
|
30
|
+
DockerfileScanner(),
|
|
31
|
+
PythonScanner(),
|
|
32
|
+
KubernetesScanner(),
|
|
33
|
+
GhaScanner(),
|
|
34
|
+
PipAuditScanner(),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_excluded(path: Path, root: Path, patterns: list[str]) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
rel = path.relative_to(root)
|
|
41
|
+
except ValueError:
|
|
42
|
+
return False
|
|
43
|
+
rel_str = str(rel)
|
|
44
|
+
parts = rel.parts
|
|
45
|
+
for pat in patterns:
|
|
46
|
+
if fnmatch.fnmatch(rel_str, pat):
|
|
47
|
+
return True
|
|
48
|
+
# Handle ** glob patterns: extract non-wildcard segments and check
|
|
49
|
+
# that each one matches at least one actual path component.
|
|
50
|
+
# e.g. "**/.terraform/**" → segment ".terraform" must appear in parts.
|
|
51
|
+
if "**" in pat:
|
|
52
|
+
segments = [s for s in pat.replace("\\", "/").split("/")
|
|
53
|
+
if s and s != "**"]
|
|
54
|
+
if segments and all(
|
|
55
|
+
any(fnmatch.fnmatch(part, seg) for part in parts)
|
|
56
|
+
for seg in segments
|
|
57
|
+
):
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _changed_files(root: Path) -> list[Path]:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["git", "diff", "--name-only", "HEAD"],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
cwd=root,
|
|
68
|
+
)
|
|
69
|
+
if result.returncode != 0:
|
|
70
|
+
# Fall back to diff against main/master
|
|
71
|
+
for base in ("main", "master", "origin/main", "origin/master"):
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["git", "diff", "--name-only", base],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
cwd=root,
|
|
77
|
+
)
|
|
78
|
+
if result.returncode == 0:
|
|
79
|
+
break
|
|
80
|
+
files = [root / f.strip() for f in result.stdout.splitlines() if f.strip()]
|
|
81
|
+
return [f for f in files if f.exists()]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_scan(
|
|
85
|
+
root: Path,
|
|
86
|
+
config: ShieldConfig,
|
|
87
|
+
changed_only: bool = False,
|
|
88
|
+
scanner_filter: str | None = None,
|
|
89
|
+
strict: bool = False,
|
|
90
|
+
) -> list[Finding]:
|
|
91
|
+
if changed_only:
|
|
92
|
+
all_files = _changed_files(root)
|
|
93
|
+
console.print(f"[dim]--changed-only: {len(all_files)} file(s) in scope[/dim]")
|
|
94
|
+
else:
|
|
95
|
+
all_files = [
|
|
96
|
+
p for p in root.rglob("*")
|
|
97
|
+
if p.is_file() and not _is_excluded(p, root, config.exclude_paths)
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
scanners = _ALL_SCANNERS
|
|
101
|
+
if scanner_filter:
|
|
102
|
+
name = scanner_filter.lower()
|
|
103
|
+
# Match by class name prefix (e.g. "terraform" matches TerraformScanner)
|
|
104
|
+
scanners = [s for s in scanners if type(s).__name__.lower().startswith(name)]
|
|
105
|
+
if not scanners:
|
|
106
|
+
console.print(f"[yellow]No scanner matches '{scanner_filter}'[/yellow]")
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
findings: list[Finding] = []
|
|
110
|
+
seen: set[tuple] = set()
|
|
111
|
+
|
|
112
|
+
for scanner in scanners:
|
|
113
|
+
if not scanner.available():
|
|
114
|
+
tool = scanner._primary_tool() or type(scanner).__name__
|
|
115
|
+
if strict:
|
|
116
|
+
console.print(
|
|
117
|
+
f"[bold red]--strict: tool '{tool}' not found. "
|
|
118
|
+
f"Run 'shieldctl install-tools' to install all dependencies.[/bold red]"
|
|
119
|
+
)
|
|
120
|
+
raise ScannerError(f"Required tool '{tool}' not found (--strict mode).")
|
|
121
|
+
console.print(
|
|
122
|
+
f"[dim]Skipping {type(scanner).__name__} ({tool} not found)[/dim]"
|
|
123
|
+
)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if scanner.extensions:
|
|
127
|
+
targets = [
|
|
128
|
+
f for f in all_files
|
|
129
|
+
if f.suffix in scanner.extensions
|
|
130
|
+
or f.name in scanner.extensions
|
|
131
|
+
or any(f.name.startswith(e.lstrip("*")) for e in scanner.extensions if "*" in e)
|
|
132
|
+
]
|
|
133
|
+
if not targets:
|
|
134
|
+
continue
|
|
135
|
+
else:
|
|
136
|
+
# Repo-wide scanner — pass root
|
|
137
|
+
targets = [root]
|
|
138
|
+
|
|
139
|
+
label = scanner._primary_tool() or type(scanner).__name__
|
|
140
|
+
if len(targets) == 1:
|
|
141
|
+
t = targets[0]
|
|
142
|
+
target_summary = t.name if t.is_file() else f"{t.name}/"
|
|
143
|
+
else:
|
|
144
|
+
target_summary = f"{len(targets)} files"
|
|
145
|
+
console.print(f"[dim]→ {label} {target_summary}[/dim]")
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
raw = scanner.run(targets)
|
|
149
|
+
except ScannerError as e:
|
|
150
|
+
console.print(f"[red]Scanner error:[/red] {e}")
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
for f in raw:
|
|
154
|
+
# Drop findings in remote/downloaded modules — not actionable
|
|
155
|
+
if f.file.startswith("git::"):
|
|
156
|
+
continue
|
|
157
|
+
if config.is_suppressed(f.rule):
|
|
158
|
+
continue
|
|
159
|
+
# Normalize to path relative to scan root
|
|
160
|
+
try:
|
|
161
|
+
rel = str(Path(f.file).relative_to(root))
|
|
162
|
+
f = dataclasses.replace(f, file=rel)
|
|
163
|
+
except ValueError:
|
|
164
|
+
pass # file not under root — leave as-is
|
|
165
|
+
key = f.dedup_key()
|
|
166
|
+
if key not in seen:
|
|
167
|
+
seen.add(key)
|
|
168
|
+
findings.append(f)
|
|
169
|
+
|
|
170
|
+
return findings
|
shieldctl/install.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tarfile
|
|
10
|
+
import tempfile
|
|
11
|
+
import urllib.request
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
# Python tools installed via the same pip that installed shieldctl
|
|
21
|
+
_PIP_TOOLS: dict[str, str] = {
|
|
22
|
+
"checkov": "checkov",
|
|
23
|
+
"yamllint": "yamllint",
|
|
24
|
+
"bandit": "bandit",
|
|
25
|
+
"safety": "safety",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Brew package name for each binary tool (macOS)
|
|
29
|
+
_BREW_PACKAGES: dict[str, str] = {
|
|
30
|
+
"tfsec": "tfsec",
|
|
31
|
+
"shellcheck": "shellcheck",
|
|
32
|
+
"hadolint": "hadolint",
|
|
33
|
+
"gitleaks": "gitleaks",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# GitHub release asset patterns per tool and arch (Linux)
|
|
37
|
+
# {ver} is replaced with the tag version (without leading 'v' where noted)
|
|
38
|
+
_GITHUB_ASSETS: dict[str, dict] = {
|
|
39
|
+
"tfsec": {
|
|
40
|
+
"repo": "aquasecurity/tfsec",
|
|
41
|
+
"amd64": "tfsec-linux-amd64",
|
|
42
|
+
"arm64": "tfsec-linux-arm64",
|
|
43
|
+
"extract": False,
|
|
44
|
+
},
|
|
45
|
+
"shellcheck": {
|
|
46
|
+
"repo": "koalaman/shellcheck",
|
|
47
|
+
"amd64": "shellcheck-{vtag}.linux.x86_64.tar.xz",
|
|
48
|
+
"arm64": "shellcheck-{vtag}.linux.aarch64.tar.xz",
|
|
49
|
+
"extract": True,
|
|
50
|
+
"binary_in_archive": "shellcheck-{vtag}/shellcheck",
|
|
51
|
+
},
|
|
52
|
+
"hadolint": {
|
|
53
|
+
"repo": "hadolint/hadolint",
|
|
54
|
+
"amd64": "hadolint-Linux-x86_64",
|
|
55
|
+
"arm64": "hadolint-Linux-arm64",
|
|
56
|
+
"extract": False,
|
|
57
|
+
},
|
|
58
|
+
"gitleaks": {
|
|
59
|
+
"repo": "gitleaks/gitleaks",
|
|
60
|
+
"amd64": "gitleaks_{ver}_linux_x64.tar.gz",
|
|
61
|
+
"arm64": "gitleaks_{ver}_linux_arm64.tar.gz",
|
|
62
|
+
"extract": True,
|
|
63
|
+
"binary_in_archive": "gitleaks",
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class InstallResult:
|
|
70
|
+
tool: str
|
|
71
|
+
status: str # "ok", "skipped", "failed"
|
|
72
|
+
note: str = ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _arch() -> str:
|
|
76
|
+
machine = platform.machine().lower()
|
|
77
|
+
if machine in ("x86_64", "amd64"):
|
|
78
|
+
return "amd64"
|
|
79
|
+
if machine in ("aarch64", "arm64"):
|
|
80
|
+
return "arm64"
|
|
81
|
+
return machine
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _pip_executable() -> str:
|
|
85
|
+
# Use the pip that belongs to the current Python (same venv as shieldctl)
|
|
86
|
+
return str(Path(sys.executable).parent / "pip")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _install_bin_dir() -> Path:
|
|
90
|
+
"""Return a writable directory on PATH for binary installs."""
|
|
91
|
+
for candidate in ("/usr/local/bin", str(Path.home() / ".local" / "bin")):
|
|
92
|
+
p = Path(candidate)
|
|
93
|
+
try:
|
|
94
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
# Check writability
|
|
96
|
+
test = p / ".shieldctl_write_test"
|
|
97
|
+
test.touch()
|
|
98
|
+
test.unlink()
|
|
99
|
+
return p
|
|
100
|
+
except (PermissionError, OSError):
|
|
101
|
+
continue
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
"No writable bin directory found. "
|
|
104
|
+
"Try running with sudo or add ~/.local/bin to your PATH."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _latest_github_release(repo: str) -> str:
|
|
109
|
+
url = f"https://api.github.com/repos/{repo}/releases/latest"
|
|
110
|
+
req = urllib.request.Request(url, headers={"User-Agent": "shieldctl"})
|
|
111
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
112
|
+
import json
|
|
113
|
+
data = json.load(resp)
|
|
114
|
+
return data["tag_name"] # e.g. "v1.2.3"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _download(url: str, dest: Path) -> None:
|
|
118
|
+
req = urllib.request.Request(url, headers={"User-Agent": "shieldctl"})
|
|
119
|
+
with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
|
|
120
|
+
while chunk := resp.read(65536):
|
|
121
|
+
f.write(chunk)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _install_github_binary(tool: str, dry_run: bool) -> InstallResult:
|
|
125
|
+
spec = _GITHUB_ASSETS[tool]
|
|
126
|
+
arch = _arch()
|
|
127
|
+
if arch not in ("amd64", "arm64"):
|
|
128
|
+
return InstallResult(tool, "failed", f"Unsupported architecture: {arch}")
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
vtag = _latest_github_release(spec["repo"])
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return InstallResult(tool, "failed", f"Could not fetch latest release: {e}")
|
|
134
|
+
|
|
135
|
+
ver = vtag.lstrip("v")
|
|
136
|
+
asset_name = spec[arch].format(vtag=vtag, ver=ver)
|
|
137
|
+
download_url = f"https://github.com/{spec['repo']}/releases/download/{vtag}/{asset_name}"
|
|
138
|
+
|
|
139
|
+
if dry_run:
|
|
140
|
+
return InstallResult(tool, "ok", f"would download {download_url}")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
bin_dir = _install_bin_dir()
|
|
144
|
+
except RuntimeError as e:
|
|
145
|
+
return InstallResult(tool, "failed", str(e))
|
|
146
|
+
|
|
147
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
148
|
+
tmp = Path(tmpdir)
|
|
149
|
+
download_path = tmp / asset_name
|
|
150
|
+
try:
|
|
151
|
+
_download(download_url, download_path)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
return InstallResult(tool, "failed", f"Download failed: {e}")
|
|
154
|
+
|
|
155
|
+
dest = bin_dir / tool
|
|
156
|
+
|
|
157
|
+
if spec.get("extract"):
|
|
158
|
+
archive_member = spec.get("binary_in_archive", tool).format(vtag=vtag, ver=ver)
|
|
159
|
+
try:
|
|
160
|
+
if asset_name.endswith(".tar.xz") or asset_name.endswith(".tar.gz"):
|
|
161
|
+
with tarfile.open(download_path) as tf:
|
|
162
|
+
member = tf.getmember(archive_member)
|
|
163
|
+
extracted = tf.extractfile(member)
|
|
164
|
+
if extracted:
|
|
165
|
+
dest.write_bytes(extracted.read())
|
|
166
|
+
else:
|
|
167
|
+
return InstallResult(tool, "failed", f"Unknown archive format: {asset_name}")
|
|
168
|
+
except Exception as e:
|
|
169
|
+
return InstallResult(tool, "failed", f"Extraction failed: {e}")
|
|
170
|
+
else:
|
|
171
|
+
shutil.copy2(download_path, dest)
|
|
172
|
+
|
|
173
|
+
dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
174
|
+
|
|
175
|
+
return InstallResult(tool, "ok", f"installed to {bin_dir / tool}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _install_via_brew(tool: str, pkg: str, dry_run: bool) -> InstallResult:
|
|
179
|
+
if not shutil.which("brew"):
|
|
180
|
+
return InstallResult(tool, "failed", "brew not found")
|
|
181
|
+
if dry_run:
|
|
182
|
+
return InstallResult(tool, "ok", f"would run: brew install {pkg}")
|
|
183
|
+
result = subprocess.run(["brew", "install", pkg], capture_output=True, text=True)
|
|
184
|
+
if result.returncode != 0:
|
|
185
|
+
lines = result.stderr.strip().splitlines()
|
|
186
|
+
return InstallResult(tool, "failed", lines[-1] if lines else "brew error")
|
|
187
|
+
return InstallResult(tool, "ok", f"brew install {pkg}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _install_via_pip(tool: str, pkg: str, dry_run: bool) -> InstallResult:
|
|
191
|
+
pip = _pip_executable()
|
|
192
|
+
if dry_run:
|
|
193
|
+
return InstallResult(tool, "ok", f"would run: pip install {pkg}")
|
|
194
|
+
result = subprocess.run([pip, "install", "--quiet", pkg], capture_output=True, text=True)
|
|
195
|
+
if result.returncode != 0:
|
|
196
|
+
lines = result.stderr.strip().splitlines()
|
|
197
|
+
return InstallResult(tool, "failed", lines[-1] if lines else "pip error")
|
|
198
|
+
return InstallResult(tool, "ok", f"pip install {pkg}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def install_all(dry_run: bool = False) -> list[InstallResult]:
|
|
202
|
+
is_macos = platform.system() == "Darwin"
|
|
203
|
+
results: list[InstallResult] = []
|
|
204
|
+
|
|
205
|
+
# Python tools — always via pip
|
|
206
|
+
for tool, pkg in _PIP_TOOLS.items():
|
|
207
|
+
if shutil.which(tool):
|
|
208
|
+
results.append(InstallResult(tool, "skipped", "already installed"))
|
|
209
|
+
continue
|
|
210
|
+
console.print(f" Installing [bold]{tool}[/bold] via pip...")
|
|
211
|
+
results.append(_install_via_pip(tool, pkg, dry_run))
|
|
212
|
+
|
|
213
|
+
# Binary tools
|
|
214
|
+
for tool in _GITHUB_ASSETS:
|
|
215
|
+
if shutil.which(tool):
|
|
216
|
+
results.append(InstallResult(tool, "skipped", "already installed"))
|
|
217
|
+
continue
|
|
218
|
+
console.print(f" Installing [bold]{tool}[/bold]...")
|
|
219
|
+
if is_macos:
|
|
220
|
+
results.append(_install_via_brew(tool, _BREW_PACKAGES[tool], dry_run))
|
|
221
|
+
else:
|
|
222
|
+
results.append(_install_github_binary(tool, dry_run))
|
|
223
|
+
|
|
224
|
+
return results
|
shieldctl/models.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Severity(IntEnum):
|
|
8
|
+
INFO = 0
|
|
9
|
+
LOW = 1
|
|
10
|
+
MEDIUM = 2
|
|
11
|
+
HIGH = 3
|
|
12
|
+
CRITICAL = 4
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_str(cls, value: str) -> "Severity":
|
|
16
|
+
try:
|
|
17
|
+
return cls[value.upper()]
|
|
18
|
+
except KeyError:
|
|
19
|
+
valid = ", ".join(m.name for m in cls)
|
|
20
|
+
raise ValueError(f"Invalid severity '{value}'. Valid values: {valid}")
|
|
21
|
+
|
|
22
|
+
def label(self) -> str:
|
|
23
|
+
return self.name
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Finding:
|
|
28
|
+
file: str
|
|
29
|
+
line: int | None
|
|
30
|
+
rule: str
|
|
31
|
+
severity: Severity
|
|
32
|
+
message: str
|
|
33
|
+
remediation: str
|
|
34
|
+
scanner: str
|
|
35
|
+
owasp: str | None = field(default=None)
|
|
36
|
+
|
|
37
|
+
def dedup_key(self) -> tuple:
|
|
38
|
+
return (self.file, self.line, self.rule, self.scanner)
|
|
File without changes
|