helmgate 0.1.0__tar.gz

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,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: helmgate
3
+ Version: 0.1.0
4
+ Summary: Helm chart linter and policy enforcement CLI
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: typer>=0.12
7
+ Requires-Dist: pyyaml>=6.0
8
+ Requires-Dist: rich>=13.0
9
+ Requires-Dist: cryptography>=42.0
@@ -0,0 +1,130 @@
1
+ # helmgate
2
+
3
+ Helm chart linter and policy enforcement CLI. Scans Kubernetes Helm charts for security vulnerabilities and best-practice violations.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install helmgate
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ # Scan a chart (table output, default)
15
+ helmgate scan ./my-chart
16
+
17
+ # Pro: export as JSON
18
+ helmgate scan ./my-chart --output json
19
+ ```
20
+
21
+ ### `--fail-on` — CI/CD exit code control
22
+
23
+ Controls at which severity level the CLI exits with code `1`. Useful for blocking deployments in CI pipelines.
24
+
25
+ | Value | Behavior |
26
+ |---|---|
27
+ | `CRITICAL` | Exit 1 only if CRITICAL findings exist (default) |
28
+ | `HIGH` | Exit 1 if HIGH or above findings exist |
29
+ | `MEDIUM` | Exit 1 if MEDIUM or above findings exist |
30
+ | `LOW` | Exit 1 if LOW or above findings exist |
31
+ | `INFO` | Exit 1 if any findings exist |
32
+ | `NONE` | Never exit 1 regardless of findings |
33
+
34
+ ```bash
35
+ # Fail the build on any CRITICAL finding (default)
36
+ helmgate scan ./my-chart
37
+
38
+ # Fail the build on HIGH or above
39
+ helmgate scan ./my-chart --fail-on HIGH
40
+
41
+ # Scan without failing the build (report only)
42
+ helmgate scan ./my-chart --fail-on NONE
43
+
44
+ # JSON output without failing the build (Pro)
45
+ helmgate scan ./my-chart --output json --fail-on NONE
46
+ ```
47
+
48
+ **GitHub Actions example:**
49
+
50
+ ```yaml
51
+ - name: Scan Helm chart
52
+ run: helmgate scan ./chart --fail-on HIGH
53
+ ```
54
+
55
+ ## Free vs Pro
56
+
57
+ | Feature | Free | Pro |
58
+ |---|---|---|
59
+ | CRITICAL & HIGH rules (13 rules) | ✓ | ✓ |
60
+ | MEDIUM & LOW rules (29 rules) | — | ✓ |
61
+ | JSON output | — | ✓ |
62
+ | Price | Free | $9 one-time (lifetime) |
63
+
64
+ **To get a Pro license key**, send an email to [yunus.olgun@outlook.com](mailto:yunus.olgun@outlook.com) with the subject `helmgate Pro License`.
65
+
66
+ Once you have a key:
67
+
68
+ ```bash
69
+ helmgate activate HGATE-<your-key>
70
+ ```
71
+
72
+ Or set it as an environment variable:
73
+
74
+ ```bash
75
+ export HELMGATE_LICENSE_KEY=HGATE-<your-key>
76
+ ```
77
+
78
+ ## Rules
79
+
80
+ ### Security (SEC) — 20 rules
81
+
82
+ | ID | Severity | Description |
83
+ |---|---|---|
84
+ | SEC001 | HIGH | Container runs as root |
85
+ | SEC002 | CRITICAL | Privileged container |
86
+ | SEC003 | HIGH | Privilege escalation allowed |
87
+ | SEC004 | MEDIUM | Root filesystem not read-only |
88
+ | SEC005 | HIGH | Host network namespace shared |
89
+ | SEC006 | HIGH | Host PID namespace shared |
90
+ | SEC007 | HIGH | Linux capabilities not dropped |
91
+ | SEC008 | MEDIUM | Secret passed as plain-text env var |
92
+ | SEC009 | HIGH | Host IPC namespace shared |
93
+ | SEC010 | MEDIUM | Seccomp profile not set |
94
+ | SEC011 | HIGH | Dangerous capability added (SYS_ADMIN, NET_ADMIN, etc.) |
95
+ | SEC012 | HIGH | hostPath volume mounted |
96
+ | SEC013 | MEDIUM | Service account token auto-mounted |
97
+ | SEC014 | MEDIUM | Host port used |
98
+ | SEC015 | MEDIUM | AppArmor profile not configured |
99
+ | SEC016 | HIGH | Container runs with root group (runAsGroup: 0) |
100
+ | SEC017 | HIGH | Unsafe sysctls present |
101
+ | SEC018 | MEDIUM | shareProcessNamespace enabled |
102
+ | SEC019 | MEDIUM | subPath used in volumeMount |
103
+ | SEC020 | LOW | No pod-level securityContext |
104
+
105
+ ### Best Practices (BP) — 22 rules
106
+
107
+ | ID | Severity | Description |
108
+ |---|---|---|
109
+ | BP001 | HIGH | Missing CPU/memory limits |
110
+ | BP002 | MEDIUM | Missing CPU/memory requests |
111
+ | BP003 | HIGH | Image uses `latest` tag |
112
+ | BP004 | MEDIUM | No liveness probe |
113
+ | BP005 | MEDIUM | No readiness probe |
114
+ | BP006 | LOW | Fewer than 2 replicas |
115
+ | BP007 | MEDIUM | Image from untrusted registry |
116
+ | BP008 | LOW | Deployed to default namespace |
117
+ | BP009 | LOW | No startup probe |
118
+ | BP010 | LOW | imagePullPolicy not IfNotPresent |
119
+ | BP011 | MEDIUM | Uses default service account |
120
+ | BP012 | LOW | Missing standard labels (app.kubernetes.io/name, version) |
121
+ | BP013 | LOW | terminationGracePeriodSeconds not set |
122
+ | BP014 | LOW | Unnamed container port |
123
+ | BP015 | MEDIUM | Image not pinned to digest |
124
+ | BP016 | LOW | No pod anti-affinity defined |
125
+ | BP017 | LOW | revisionHistoryLimit not set (Deployment) |
126
+ | BP018 | LOW | progressDeadlineSeconds not set (Deployment) |
127
+ | BP019 | MEDIUM | updateStrategy not defined (StatefulSet/DaemonSet) |
128
+ | BP020 | LOW | minReadySeconds not set (Deployment) |
129
+ | BP021 | LOW | priorityClassName not set |
130
+ | BP022 | MEDIUM | CronJob concurrencyPolicy is Allow |
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,120 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import typer
5
+ from rich.console import Console
6
+
7
+ from .scanner import scan
8
+ from .report import print_report
9
+ from .license import is_pro, activate as activate_license
10
+ from . import __version__
11
+
12
+ app = typer.Typer(
13
+ name="helmgate",
14
+ help="Helm chart linter and policy enforcement tool.",
15
+ add_completion=False,
16
+ )
17
+ console = Console()
18
+
19
+
20
+ @app.command(name="scan")
21
+ def scan_cmd(
22
+ chart: Path = typer.Argument(..., help="Path to the Helm chart directory."),
23
+ fail_on: str = typer.Option(
24
+ "CRITICAL",
25
+ "--fail-on",
26
+ help="Exit with code 1 if findings at or above this severity exist. "
27
+ "Choices: CRITICAL, HIGH, MEDIUM, LOW, INFO, NONE",
28
+ ),
29
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"),
30
+ ):
31
+ """Scan a Helm chart for security and best-practice issues."""
32
+ if not chart.is_dir():
33
+ console.print(f"[red]Error:[/red] '{chart}' is not a directory.")
34
+ raise typer.Exit(1)
35
+
36
+ pro = is_pro()
37
+
38
+ if output == "json" and not pro:
39
+ console.print(
40
+ "[yellow]JSON output requires a Pro license.[/yellow]\n"
41
+ "Activate with: [bold]helmgate activate <key>[/bold]\n"
42
+ "Get a license key: [bold]yunus.olgun@outlook.com[/bold]"
43
+ )
44
+ raise typer.Exit(1)
45
+
46
+ from .rules import ALL_RULES, FREE_RULES
47
+ rules = ALL_RULES if pro else FREE_RULES
48
+
49
+ if not pro:
50
+ hidden = len(ALL_RULES) - len(FREE_RULES)
51
+ console.print(
52
+ f"[dim]Free tier: scanning with CRITICAL and HIGH rules only "
53
+ f"({hidden} MEDIUM/LOW rules hidden). "
54
+ f"Upgrade at helmgate.io/pricing[/dim]\n"
55
+ )
56
+
57
+ findings = scan(chart, rules=rules)
58
+
59
+ if output == "json":
60
+ import json
61
+ data = [
62
+ {
63
+ "rule_id": f.rule_id,
64
+ "severity": f.severity.value,
65
+ "path": f.path,
66
+ "message": f.message,
67
+ "hint": f.line_hint,
68
+ }
69
+ for f in findings
70
+ ]
71
+ print(json.dumps(data, indent=2))
72
+ else:
73
+ print_report(findings, str(chart))
74
+
75
+ if not pro and findings:
76
+ console.print(
77
+ "\n[dim]Upgrade to Pro to scan with all rules and export JSON reports.[/dim]"
78
+ )
79
+
80
+ if fail_on != "NONE":
81
+ from .rules import Severity
82
+ try:
83
+ threshold = Severity(fail_on)
84
+ except ValueError:
85
+ console.print(f"[red]Unknown severity:[/red] {fail_on}")
86
+ raise typer.Exit(1)
87
+
88
+ severity_order = list(Severity)
89
+ threshold_idx = severity_order.index(threshold)
90
+ should_fail = any(
91
+ severity_order.index(f.severity) <= threshold_idx for f in findings
92
+ )
93
+ if should_fail:
94
+ raise typer.Exit(1)
95
+
96
+
97
+ @app.command()
98
+ def activate(
99
+ key: str = typer.Argument(..., help="Your Pro license key (format: HGATE-XXXX-XXXX-XXXX-XXXX)"),
100
+ ):
101
+ """Activate a Pro license key."""
102
+ if activate_license(key):
103
+ console.print(f"[green]License activated![/green] helmgate Pro is now enabled.")
104
+ else:
105
+ console.print(
106
+ "[red]Invalid license key.[/red] "
107
+ "Check the key and try again, or visit helmgate.io/pricing"
108
+ )
109
+ raise typer.Exit(1)
110
+
111
+
112
+ @app.command()
113
+ def version():
114
+ """Show helmgate version."""
115
+ tier = "Pro" if is_pro() else "Free"
116
+ console.print(f"helmgate v{__version__} [{tier}]")
117
+
118
+
119
+ if __name__ == "__main__":
120
+ app()
@@ -0,0 +1,57 @@
1
+ import base64
2
+ import os
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from cryptography.hazmat.primitives.asymmetric import ec
7
+ from cryptography.hazmat.primitives import hashes, serialization
8
+ from cryptography.exceptions import InvalidSignature
9
+
10
+ _LICENSE_FILE = Path.home() / ".helmgate" / "license"
11
+ _KEY_PATTERN = re.compile(r"^HGATE-[A-Za-z0-9_-]{10,}$")
12
+
13
+ # Public key only — safe to ship in open source.
14
+ _PUBLIC_KEY_PEM = b"""-----BEGIN PUBLIC KEY-----
15
+ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEw1VD4zF0z2qMQTMmrVh3ViG2xiKt
16
+ dC3SHCrI1smn1dQWAvyg/tHWio97q/W2TXwMTvZm9JP6C4SO15b+IsD6cw==
17
+ -----END PUBLIC KEY-----"""
18
+
19
+ _public_key = serialization.load_pem_public_key(_PUBLIC_KEY_PEM)
20
+
21
+
22
+ def validate_key(key: str) -> bool:
23
+ """Return True if the key has a valid ECDSA signature."""
24
+ if not _KEY_PATTERN.match(key):
25
+ return False
26
+ try:
27
+ payload = base64.urlsafe_b64decode(key[6:] + "==") # strip "HGATE-"
28
+ nonce, signature = payload[:8], payload[8:]
29
+ _public_key.verify(signature, nonce, ec.ECDSA(hashes.SHA256()))
30
+ return True
31
+ except (InvalidSignature, Exception):
32
+ return False
33
+
34
+
35
+ def get_license_key() -> str | None:
36
+ """Return the license key from env var or license file, or None if not set."""
37
+ key = os.environ.get("HELMGATE_LICENSE_KEY", "").strip()
38
+ if key:
39
+ return key
40
+ if _LICENSE_FILE.exists():
41
+ return _LICENSE_FILE.read_text().strip()
42
+ return None
43
+
44
+
45
+ def is_pro() -> bool:
46
+ """Return True if a valid Pro license key is present."""
47
+ key = get_license_key()
48
+ return key is not None and validate_key(key)
49
+
50
+
51
+ def activate(key: str) -> bool:
52
+ """Save a valid license key to the license file. Returns True on success."""
53
+ if not validate_key(key):
54
+ return False
55
+ _LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
56
+ _LICENSE_FILE.write_text(key)
57
+ return True
@@ -0,0 +1,55 @@
1
+ from rich.console import Console
2
+ from rich.table import Table
3
+ from rich import box
4
+ from .rules import Finding, Severity
5
+
6
+ console = Console()
7
+
8
+ _SEVERITY_COLOR = {
9
+ Severity.CRITICAL: "bold red",
10
+ Severity.HIGH: "red",
11
+ Severity.MEDIUM: "yellow",
12
+ Severity.LOW: "cyan",
13
+ Severity.INFO: "dim",
14
+ }
15
+
16
+
17
+ def print_report(findings: list[Finding], chart_path: str) -> None:
18
+ if not findings:
19
+ console.print(f"\n[bold green]✓ No issues found in {chart_path}[/bold green]\n")
20
+ return
21
+
22
+ table = Table(box=box.ROUNDED, show_lines=True)
23
+ table.add_column("ID", style="bold", no_wrap=True)
24
+ table.add_column("Severity", no_wrap=True)
25
+ table.add_column("File", style="dim")
26
+ table.add_column("Message")
27
+ table.add_column("Hint", style="dim")
28
+
29
+ for f in sorted(findings, key=lambda x: list(Severity).index(x.severity)):
30
+ color = _SEVERITY_COLOR[f.severity]
31
+ table.add_row(
32
+ f.rule_id,
33
+ f"[{color}]{f.severity.value}[/{color}]",
34
+ f.path,
35
+ f.message,
36
+ f.line_hint,
37
+ )
38
+
39
+ console.print()
40
+ console.print(table)
41
+ _print_summary(findings)
42
+
43
+
44
+ def _print_summary(findings: list[Finding]) -> None:
45
+ counts: dict[Severity, int] = {}
46
+ for f in findings:
47
+ counts[f.severity] = counts.get(f.severity, 0) + 1
48
+
49
+ parts = []
50
+ for sev in Severity:
51
+ if sev in counts:
52
+ color = _SEVERITY_COLOR[sev]
53
+ parts.append(f"[{color}]{counts[sev]} {sev.value}[/{color}]")
54
+
55
+ console.print(f" Found {len(findings)} issue(s): " + " ".join(parts) + "\n")
@@ -0,0 +1,10 @@
1
+ from .base import Rule, Severity, Finding
2
+ from .security import SECURITY_RULES
3
+ from .best_practices import BEST_PRACTICE_RULES
4
+
5
+ ALL_RULES: list[Rule] = SECURITY_RULES + BEST_PRACTICE_RULES
6
+
7
+ FREE_SEVERITIES = {Severity.CRITICAL, Severity.HIGH}
8
+ FREE_RULES: list[Rule] = [r for r in ALL_RULES if r.severity in FREE_SEVERITIES]
9
+
10
+ __all__ = ["Rule", "Severity", "Finding", "ALL_RULES", "FREE_RULES"]
@@ -0,0 +1,32 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+
6
+ class Severity(str, Enum):
7
+ CRITICAL = "CRITICAL"
8
+ HIGH = "HIGH"
9
+ MEDIUM = "MEDIUM"
10
+ LOW = "LOW"
11
+ INFO = "INFO"
12
+
13
+
14
+ @dataclass
15
+ class Finding:
16
+ rule_id: str
17
+ severity: Severity
18
+ message: str
19
+ path: str # e.g. "templates/deployment.yaml"
20
+ line_hint: str = "" # human-readable context, not exact line number
21
+
22
+
23
+ @dataclass
24
+ class Rule:
25
+ id: str
26
+ name: str
27
+ severity: Severity
28
+ description: str
29
+
30
+ def check(self, manifest: dict[str, Any], path: str) -> list[Finding]:
31
+ """Run rule against a single parsed YAML manifest. Override in subclasses."""
32
+ raise NotImplementedError