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.
- helmgate-0.1.0/PKG-INFO +9 -0
- helmgate-0.1.0/README.md +130 -0
- helmgate-0.1.0/helmgate/__init__.py +1 -0
- helmgate-0.1.0/helmgate/cli.py +120 -0
- helmgate-0.1.0/helmgate/license.py +57 -0
- helmgate-0.1.0/helmgate/report.py +55 -0
- helmgate-0.1.0/helmgate/rules/__init__.py +10 -0
- helmgate-0.1.0/helmgate/rules/base.py +32 -0
- helmgate-0.1.0/helmgate/rules/best_practices.py +516 -0
- helmgate-0.1.0/helmgate/rules/security.py +509 -0
- helmgate-0.1.0/helmgate/scanner.py +45 -0
- helmgate-0.1.0/helmgate.egg-info/PKG-INFO +9 -0
- helmgate-0.1.0/helmgate.egg-info/SOURCES.txt +18 -0
- helmgate-0.1.0/helmgate.egg-info/dependency_links.txt +1 -0
- helmgate-0.1.0/helmgate.egg-info/entry_points.txt +2 -0
- helmgate-0.1.0/helmgate.egg-info/requires.txt +4 -0
- helmgate-0.1.0/helmgate.egg-info/top_level.txt +1 -0
- helmgate-0.1.0/pyproject.toml +22 -0
- helmgate-0.1.0/setup.cfg +4 -0
- helmgate-0.1.0/tests/test_scanner.py +140 -0
helmgate-0.1.0/PKG-INFO
ADDED
helmgate-0.1.0/README.md
ADDED
|
@@ -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
|