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 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
+ )
@@ -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