proofctl 0.2.0__tar.gz → 0.3.1__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.
- {proofctl-0.2.0 → proofctl-0.3.1}/PKG-INFO +1 -1
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/dockerfile.py +1 -1
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/hcl_utils.py +11 -7
- proofctl-0.3.1/proofctl/checkers/secrets.py +446 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/security.py +207 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/terraform.py +1083 -37
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/terragrunt.py +21 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/yaml_checker.py +295 -2
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/config.py +59 -3
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/engine.py +21 -1
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/registry.py +1 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl.egg-info/PKG-INFO +1 -1
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl.egg-info/SOURCES.txt +2 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/pyproject.toml +1 -1
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_config_proofctl.py +31 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_dockerfile.py +2 -2
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_engine.py +68 -0
- proofctl-0.3.1/tests/test_secrets.py +362 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_security.py +140 -0
- proofctl-0.3.1/tests/test_terraform_ext.py +2806 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_terragrunt.py +54 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_yaml.py +72 -4
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_yaml_k8s_gha.py +282 -0
- proofctl-0.2.0/tests/test_terraform_ext.py +0 -1121
- {proofctl-0.2.0 → proofctl-0.3.1}/LICENSE +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/README.md +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/__init__.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/_globmatch.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/baseline.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/__init__.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/base.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/imports.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/leakage.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/llm_integration.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/placeholders.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/quality.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/shell_checker.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/checkers/variants.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/cli.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/fixer.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/models.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/reporters/__init__.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/reporters/html_reporter.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/reporters/json_reporter.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl/reporters/terminal.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl.egg-info/dependency_links.txt +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl.egg-info/entry_points.txt +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl.egg-info/requires.txt +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/proofctl.egg-info/top_level.txt +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/setup.cfg +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_baseline.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_fixer.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_imports.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_leakage.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_llm.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_local_packages.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_phase1_audit_fixes.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_phase2_audit_fixes.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_phase3_audit_fixes.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_phase4_audit_fixes.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_phase5_llm_imports_fixes.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_phase5_p_l_v_fixes.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_phase5_quality_fixes.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_phase6_audit_fixes.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_phase8_audit_fixes.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_placeholders.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_quality.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_quality_ext.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_security_ext.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_suppression.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_terraform.py +0 -0
- {proofctl-0.2.0 → proofctl-0.3.1}/tests/test_variants.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proofctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Zero-dependency linter for Python, Terraform, Dockerfiles, Kubernetes, and GitHub Actions — catches AI slop and security misconfigurations pre-commit
|
|
5
5
|
Author-email: Kolawolu Odunola <kolawolu.o@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -124,7 +124,7 @@ def _df001(instructions: list[tuple[int, str, str]], path: Path) -> list[Finding
|
|
|
124
124
|
msg = f"Base image '{image}' uses the mutable ':latest' tag"
|
|
125
125
|
hint = "Replace :latest with a pinned version and digest."
|
|
126
126
|
else:
|
|
127
|
-
sev = Severity.
|
|
127
|
+
sev = Severity.INFO
|
|
128
128
|
msg = f"Base image '{image}' is pinned to a tag but not a digest (still mutable)"
|
|
129
129
|
hint = "Add digest pinning: FROM python:3.12@sha256:<digest>"
|
|
130
130
|
|
|
@@ -3,12 +3,14 @@ from __future__ import annotations
|
|
|
3
3
|
import re
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
|
|
6
|
-
# Matches the header of a block: keyword (
|
|
6
|
+
# Matches the header of a block: keyword (label1)? (label2)? {
|
|
7
|
+
# Labels may be quoted ("aws_vpc") or bare (aws_vpc). Terraform tolerates the
|
|
8
|
+
# bare form on `resource <type> "name"`, e.g. terragoat's `resource aws_vpc`.
|
|
7
9
|
_BLOCK_HEADER_RE = re.compile(
|
|
8
|
-
r'^\s*(\w+)'
|
|
9
|
-
r'(?:\s+"([^"]*)")?'
|
|
10
|
-
r'(?:\s+"([^"]*)")?'
|
|
11
|
-
r'\s*\{'
|
|
10
|
+
r'^\s*(\w+)' # kind
|
|
11
|
+
r'(?:\s+(?:"([^"]*)"|([A-Za-z_]\w*)))?' # optional label1: quoted or bare ident
|
|
12
|
+
r'(?:\s+(?:"([^"]*)"|([A-Za-z_]\w*)))?' # optional label2: quoted or bare ident
|
|
13
|
+
r'\s*\{' # opening brace
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
_HEREDOC_START_RE = re.compile(r'<<[-~]?(\w+)\s*$')
|
|
@@ -172,8 +174,10 @@ def _parse(lines: list[str], base: int, skip_outer: bool) -> list[HclBlock]:
|
|
|
172
174
|
m = _BLOCK_HEADER_RE.match(line)
|
|
173
175
|
if m:
|
|
174
176
|
block_kind = m.group(1)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
+
# group 2 = quoted label1, group 3 = bare label1
|
|
178
|
+
block_label1 = m.group(2) or m.group(3) or ""
|
|
179
|
+
# group 4 = quoted label2, group 5 = bare label2
|
|
180
|
+
block_label2 = m.group(4) or m.group(5) or ""
|
|
177
181
|
block_start_idx = i
|
|
178
182
|
depth += delta
|
|
179
183
|
if depth == 0:
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Secret detection — PROOFCTL-SECRET-* rules. Scans any text file."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..models import Finding, Severity
|
|
9
|
+
from .base import FileChecker
|
|
10
|
+
|
|
11
|
+
# ── allowlists ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
_SKIP_SUFFIXES = (
|
|
14
|
+
".lock", ".min.js", ".bundle.js", ".map",
|
|
15
|
+
)
|
|
16
|
+
_SKIP_NAMES = frozenset({
|
|
17
|
+
"package-lock.json", "yarn.lock", "Cargo.lock", "go.sum", "pnpm-lock.yaml",
|
|
18
|
+
"composer.lock", "Pipfile.lock", "poetry.lock",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
_PLACEHOLDER_VALUES = frozenset({
|
|
22
|
+
"changeme", "change-me", "change_me", "your-password-here",
|
|
23
|
+
"your_password_here", "yourpassword", "password", "passw0rd",
|
|
24
|
+
"xxx", "xxxx", "xxxxx", "xxxxxx", "placeholder", "todo", "tbd",
|
|
25
|
+
"example", "sample", "secret", "your-secret-here", "redacted",
|
|
26
|
+
"default", "test", "testing", "dummy", "fake", "none", "null",
|
|
27
|
+
"<password>", "<secret>", "<value>", "n/a", "na",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
# ── patterns ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
_PAT_AWS_KEY = re.compile(r"AKIA[A-Z0-9]{16}")
|
|
33
|
+
_PAT_AWS_SECRET = re.compile(
|
|
34
|
+
r"""aws_secret(_access)?_key\s*=\s*["']([A-Za-z0-9/+=]{40})["']""",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
_PAT_GITHUB = re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}")
|
|
38
|
+
_PAT_SLACK = re.compile(r"xox[bpoars]-[A-Za-z0-9-]+")
|
|
39
|
+
_PAT_PRIVKEY = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")
|
|
40
|
+
_PAT_JWT = re.compile(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{20,}")
|
|
41
|
+
_PAT_GENERIC = re.compile(
|
|
42
|
+
r"""(?P<key>password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)"""
|
|
43
|
+
r"""\s*[:=]\s*["'](?P<val>[^"'$\{]{8,})["']""",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
_PAT_GOOGLE = re.compile(r"AIza[0-9A-Za-z_-]{35}")
|
|
47
|
+
_PAT_STRIPE = re.compile(r"(?:sk|pk)_(?:test|live)_[0-9a-zA-Z]{24,}")
|
|
48
|
+
_PAT_TF_ADMIN = re.compile(
|
|
49
|
+
r"""(?P<key>administrator_login_password|admin_password|root_password)"""
|
|
50
|
+
r"""\s*=\s*"(?P<val>[^"$\{][^"]{4,})\"""",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_ENV_REF_RE = re.compile(r"^\s*[\$\{][^\s]+[\}]?\s*$|^\$[A-Z_][A-Z0-9_]*$|^\$\{[^}]+\}$")
|
|
54
|
+
|
|
55
|
+
# SECRET-011: kind: Secret YAML detection
|
|
56
|
+
_PAT_K8S_KIND_SECRET = re.compile(r'^\s*kind\s*:\s*["\']?Secret["\']?\s*(#.*)?$')
|
|
57
|
+
_PAT_YAML_DOC_SEP = re.compile(r'^---\s*(#.*)?$')
|
|
58
|
+
_PAT_K8S_NAME = re.compile(r'^\s{2,}name\s*:\s*["\']?([A-Za-z0-9._-]+)["\']?\s*(#.*)?$')
|
|
59
|
+
_PAT_K8S_DATA_KEY = re.compile(r'^(\s*)(data|stringData)\s*:\s*(#.*)?$')
|
|
60
|
+
# Match indented "key: value" lines under data:/stringData:
|
|
61
|
+
_PAT_K8S_DATA_ENTRY = re.compile(
|
|
62
|
+
r'^(\s+)([A-Za-z0-9._-]+)\s*:\s*(.*?)\s*(?:#.*)?$'
|
|
63
|
+
)
|
|
64
|
+
_K8S_SKIP_PATH_TOKENS = ("/tests/", "/fixtures/", "/testdata/")
|
|
65
|
+
|
|
66
|
+
# SECRET-012: heredoc tracking + secret-shaped env assignment inside heredoc
|
|
67
|
+
_PAT_HEREDOC_OPEN = re.compile(r'<<[-~]?(\w+)\s*$')
|
|
68
|
+
_PAT_HEREDOC_SECRET = re.compile(
|
|
69
|
+
r'(?:export\s+)?([A-Z][A-Z0-9_]*(?:_KEY|_TOKEN|_SECRET|_PASSWORD|_PASSWD))'
|
|
70
|
+
r'\s*=\s*([^\s"\']+|"[^"]+"|\'[^\']+\')'
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _shannon_entropy(s: str) -> float:
|
|
75
|
+
if not s:
|
|
76
|
+
return 0.0
|
|
77
|
+
freq: dict[str, int] = {}
|
|
78
|
+
for ch in s:
|
|
79
|
+
freq[ch] = freq.get(ch, 0) + 1
|
|
80
|
+
n = len(s)
|
|
81
|
+
return -sum((c / n) * math.log2(c / n) for c in freq.values())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_placeholder(val: str) -> bool:
|
|
85
|
+
low = val.lower().strip()
|
|
86
|
+
if low in _PLACEHOLDER_VALUES:
|
|
87
|
+
return True
|
|
88
|
+
# Entirely lowercase + dashes/underscores looks like placeholder text
|
|
89
|
+
if re.fullmatch(r"[a-z][a-z0-9_\-]*", val) and any(
|
|
90
|
+
tok in low for tok in ("change", "your", "placeholder", "example", "sample")
|
|
91
|
+
):
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_env_ref(val: str) -> bool:
|
|
97
|
+
return bool(_ENV_REF_RE.match(val)) or "${" in val or val.startswith("$(")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_binary(data: bytes) -> bool:
|
|
101
|
+
if not data:
|
|
102
|
+
return False
|
|
103
|
+
if b"\x00" in data:
|
|
104
|
+
return True
|
|
105
|
+
text_chars = bytes(range(32, 127)) + b"\n\r\t\f\b"
|
|
106
|
+
nonprint = sum(1 for b in data if b not in text_chars)
|
|
107
|
+
return (nonprint / len(data)) > 0.30
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _should_skip(path: Path) -> bool:
|
|
111
|
+
name = path.name
|
|
112
|
+
if name in _SKIP_NAMES:
|
|
113
|
+
return True
|
|
114
|
+
for suf in _SKIP_SUFFIXES:
|
|
115
|
+
if name.endswith(suf):
|
|
116
|
+
return True
|
|
117
|
+
# *.lock.json
|
|
118
|
+
if name.endswith(".lock.json"):
|
|
119
|
+
return True
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── rule fns ──────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def _mk(path: Path, line: int, rid: str, name: str, sev: Severity, msg: str) -> Finding:
|
|
126
|
+
return Finding(
|
|
127
|
+
file=str(path),
|
|
128
|
+
line=line,
|
|
129
|
+
col=0,
|
|
130
|
+
rule_id=rid,
|
|
131
|
+
rule_name=name,
|
|
132
|
+
severity=sev,
|
|
133
|
+
message=msg,
|
|
134
|
+
hint="Move secret to env var, secret manager, or runtime injection. Never commit credentials.",
|
|
135
|
+
authority="OWASP ASVS V2 / CWE-798 – Use of Hard-coded Credentials",
|
|
136
|
+
docs_url="https://cwe.mitre.org/data/definitions/798.html",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _scan_line(path: Path, lineno: int, line: str) -> list[Finding]:
|
|
141
|
+
out: list[Finding] = []
|
|
142
|
+
# Track (rule_priority, secret_value) so most-specific rule wins per value.
|
|
143
|
+
# Keyed by value text → (priority, Finding)
|
|
144
|
+
by_value: dict[str, tuple[int, Finding]] = {}
|
|
145
|
+
|
|
146
|
+
def _claim(value: str, priority: int, finding: Finding) -> None:
|
|
147
|
+
existing = by_value.get(value)
|
|
148
|
+
if existing is None or priority < existing[0]:
|
|
149
|
+
by_value[value] = (priority, finding)
|
|
150
|
+
|
|
151
|
+
# SECRET-001: AWS access key
|
|
152
|
+
for m in _PAT_AWS_KEY.finditer(line):
|
|
153
|
+
v = m.group(0)
|
|
154
|
+
_claim(v, 1, _mk(path, lineno, "PROOFCTL-SECRET-001",
|
|
155
|
+
"AWS access key ID", Severity.ERROR,
|
|
156
|
+
f"Hardcoded AWS access key ID detected ({v[:8]}…)"))
|
|
157
|
+
|
|
158
|
+
# SECRET-002: AWS secret key
|
|
159
|
+
for m in _PAT_AWS_SECRET.finditer(line):
|
|
160
|
+
v = m.group(2)
|
|
161
|
+
_claim(v, 2, _mk(path, lineno, "PROOFCTL-SECRET-002",
|
|
162
|
+
"AWS secret access key", Severity.ERROR,
|
|
163
|
+
"Hardcoded AWS secret access key detected"))
|
|
164
|
+
|
|
165
|
+
# SECRET-003: GitHub token
|
|
166
|
+
for m in _PAT_GITHUB.finditer(line):
|
|
167
|
+
v = m.group(0)
|
|
168
|
+
_claim(v, 3, _mk(path, lineno, "PROOFCTL-SECRET-003",
|
|
169
|
+
"GitHub token", Severity.ERROR,
|
|
170
|
+
f"Hardcoded GitHub token detected ({v[:7]}…)"))
|
|
171
|
+
|
|
172
|
+
# SECRET-004: Slack token
|
|
173
|
+
for m in _PAT_SLACK.finditer(line):
|
|
174
|
+
v = m.group(0)
|
|
175
|
+
_claim(v, 4, _mk(path, lineno, "PROOFCTL-SECRET-004",
|
|
176
|
+
"Slack token", Severity.ERROR,
|
|
177
|
+
f"Hardcoded Slack token detected ({v[:6]}…)"))
|
|
178
|
+
|
|
179
|
+
# SECRET-005: Private key marker
|
|
180
|
+
for m in _PAT_PRIVKEY.finditer(line):
|
|
181
|
+
v = m.group(0)
|
|
182
|
+
_claim(v, 5, _mk(path, lineno, "PROOFCTL-SECRET-005",
|
|
183
|
+
"Private key", Severity.ERROR,
|
|
184
|
+
"Private key block detected"))
|
|
185
|
+
|
|
186
|
+
# SECRET-006: JWT (warning — often in tests/docs)
|
|
187
|
+
for m in _PAT_JWT.finditer(line):
|
|
188
|
+
v = m.group(0)
|
|
189
|
+
_claim(v, 6, _mk(path, lineno, "PROOFCTL-SECRET-006",
|
|
190
|
+
"JWT token", Severity.WARNING,
|
|
191
|
+
f"JWT token detected ({v[:12]}…) — confirm not a real credential"))
|
|
192
|
+
|
|
193
|
+
# SECRET-008: Google API key
|
|
194
|
+
for m in _PAT_GOOGLE.finditer(line):
|
|
195
|
+
v = m.group(0)
|
|
196
|
+
_claim(v, 8, _mk(path, lineno, "PROOFCTL-SECRET-008",
|
|
197
|
+
"Google API key", Severity.ERROR,
|
|
198
|
+
f"Hardcoded Google API key detected ({v[:8]}…)"))
|
|
199
|
+
|
|
200
|
+
# SECRET-009: Stripe key
|
|
201
|
+
for m in _PAT_STRIPE.finditer(line):
|
|
202
|
+
v = m.group(0)
|
|
203
|
+
_claim(v, 9, _mk(path, lineno, "PROOFCTL-SECRET-009",
|
|
204
|
+
"Stripe API key", Severity.ERROR,
|
|
205
|
+
f"Hardcoded Stripe key detected ({v[:8]}…)"))
|
|
206
|
+
|
|
207
|
+
# SECRET-010: Terraform/HCL admin password
|
|
208
|
+
for m in _PAT_TF_ADMIN.finditer(line):
|
|
209
|
+
v = m.group("val")
|
|
210
|
+
if _is_env_ref(v) or _is_placeholder(v):
|
|
211
|
+
continue
|
|
212
|
+
_claim(v, 10, _mk(path, lineno, "PROOFCTL-SECRET-010",
|
|
213
|
+
"Hardcoded admin password", Severity.ERROR,
|
|
214
|
+
f"Hardcoded admin password in '{m.group('key')}' assignment"))
|
|
215
|
+
|
|
216
|
+
# SECRET-007: Generic high-entropy assignment (lowest priority — fires last)
|
|
217
|
+
for m in _PAT_GENERIC.finditer(line):
|
|
218
|
+
v = m.group("val")
|
|
219
|
+
if _is_env_ref(v) or _is_placeholder(v):
|
|
220
|
+
continue
|
|
221
|
+
if v.islower() and len(v) <= 16 and re.fullmatch(r"[a-z0-9_\-]+", v):
|
|
222
|
+
# Looks like a slug/placeholder — skip
|
|
223
|
+
continue
|
|
224
|
+
if _shannon_entropy(v) < 3.5:
|
|
225
|
+
continue
|
|
226
|
+
_claim(v, 7, _mk(path, lineno, "PROOFCTL-SECRET-007",
|
|
227
|
+
"Hardcoded credential assignment", Severity.ERROR,
|
|
228
|
+
f"Likely hardcoded {m.group('key')} assignment"))
|
|
229
|
+
|
|
230
|
+
out.extend(f for _, f in by_value.values())
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── SECRET-011: kind: Secret YAML ─────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def _scan_k8s_secrets(path: Path, source: str) -> list[Finding]:
|
|
237
|
+
"""Scan YAML for `kind: Secret` documents with plaintext data:/stringData:.
|
|
238
|
+
|
|
239
|
+
Fires once per Secret document on the line of `kind: Secret`.
|
|
240
|
+
Also fires on multi-doc YAML (--- separators) per document.
|
|
241
|
+
"""
|
|
242
|
+
name_str = str(path).replace("\\", "/").lower()
|
|
243
|
+
if any(tok in name_str for tok in _K8S_SKIP_PATH_TOKENS):
|
|
244
|
+
return []
|
|
245
|
+
# Restrict to plausible YAML files (cheap guard — main.yaml, *.yml, *.yaml).
|
|
246
|
+
if not (path.name.endswith(".yaml") or path.name.endswith(".yml")):
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
lines = source.splitlines()
|
|
250
|
+
out: list[Finding] = []
|
|
251
|
+
|
|
252
|
+
# Collect document slices (line ranges) using --- separators.
|
|
253
|
+
doc_starts: list[int] = [0]
|
|
254
|
+
for idx, line in enumerate(lines):
|
|
255
|
+
if idx > 0 and _PAT_YAML_DOC_SEP.match(line):
|
|
256
|
+
doc_starts.append(idx + 1)
|
|
257
|
+
doc_starts.append(len(lines))
|
|
258
|
+
|
|
259
|
+
for di in range(len(doc_starts) - 1):
|
|
260
|
+
start, end = doc_starts[di], doc_starts[di + 1]
|
|
261
|
+
kind_line = -1
|
|
262
|
+
secret_name = "<unnamed>"
|
|
263
|
+
# Find kind: Secret in this doc
|
|
264
|
+
for i in range(start, end):
|
|
265
|
+
if _PAT_K8S_KIND_SECRET.match(lines[i]):
|
|
266
|
+
kind_line = i
|
|
267
|
+
break
|
|
268
|
+
if kind_line < 0:
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
# Find metadata.name (best-effort; first 'name:' under metadata or any 'name:' line).
|
|
272
|
+
for i in range(start, end):
|
|
273
|
+
mn = _PAT_K8S_NAME.match(lines[i])
|
|
274
|
+
if mn:
|
|
275
|
+
secret_name = mn.group(1)
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
# Walk for data:/stringData: blocks and check for any non-empty entries.
|
|
279
|
+
i = start
|
|
280
|
+
leaked = False
|
|
281
|
+
while i < end:
|
|
282
|
+
line = lines[i]
|
|
283
|
+
mkey = _PAT_K8S_DATA_KEY.match(line)
|
|
284
|
+
if not mkey:
|
|
285
|
+
i += 1
|
|
286
|
+
continue
|
|
287
|
+
base_indent = len(mkey.group(1))
|
|
288
|
+
j = i + 1
|
|
289
|
+
while j < end:
|
|
290
|
+
child = lines[j]
|
|
291
|
+
if not child.strip() or child.lstrip().startswith("#"):
|
|
292
|
+
j += 1
|
|
293
|
+
continue
|
|
294
|
+
child_indent = len(child) - len(child.lstrip())
|
|
295
|
+
if child_indent <= base_indent:
|
|
296
|
+
break
|
|
297
|
+
me = _PAT_K8S_DATA_ENTRY.match(child)
|
|
298
|
+
if me:
|
|
299
|
+
val = me.group(3).strip()
|
|
300
|
+
# Strip simple quoting
|
|
301
|
+
if (val.startswith('"') and val.endswith('"')) or (
|
|
302
|
+
val.startswith("'") and val.endswith("'")
|
|
303
|
+
):
|
|
304
|
+
val = val[1:-1]
|
|
305
|
+
# Skip empty / null / valueFrom-style references / pipe-block markers
|
|
306
|
+
if not val or val in ("''", '""', "null", "~"):
|
|
307
|
+
j += 1
|
|
308
|
+
continue
|
|
309
|
+
if val in ("|", ">", "|-", ">-", "|+", ">+"):
|
|
310
|
+
# Block scalar follows — treat as a real value if any next
|
|
311
|
+
# non-empty indented line exists.
|
|
312
|
+
k = j + 1
|
|
313
|
+
has_content = False
|
|
314
|
+
while k < end:
|
|
315
|
+
nxt = lines[k]
|
|
316
|
+
if not nxt.strip():
|
|
317
|
+
k += 1
|
|
318
|
+
continue
|
|
319
|
+
nxt_indent = len(nxt) - len(nxt.lstrip())
|
|
320
|
+
if nxt_indent <= child_indent:
|
|
321
|
+
break
|
|
322
|
+
has_content = True
|
|
323
|
+
break
|
|
324
|
+
if has_content:
|
|
325
|
+
leaked = True
|
|
326
|
+
break
|
|
327
|
+
j += 1
|
|
328
|
+
continue
|
|
329
|
+
if val.startswith("valueFrom") or val.startswith("&") or val.startswith("*"):
|
|
330
|
+
j += 1
|
|
331
|
+
continue
|
|
332
|
+
# Otherwise: a real plaintext value (b64 or stringData).
|
|
333
|
+
leaked = True
|
|
334
|
+
break
|
|
335
|
+
j += 1
|
|
336
|
+
if leaked:
|
|
337
|
+
break
|
|
338
|
+
i = j
|
|
339
|
+
|
|
340
|
+
if leaked:
|
|
341
|
+
out.append(Finding(
|
|
342
|
+
file=str(path),
|
|
343
|
+
line=kind_line + 1,
|
|
344
|
+
col=0,
|
|
345
|
+
rule_id="PROOFCTL-SECRET-011",
|
|
346
|
+
rule_name="Plaintext credentials in Kubernetes Secret manifest",
|
|
347
|
+
severity=Severity.ERROR,
|
|
348
|
+
message=(
|
|
349
|
+
f"Kubernetes Secret '{secret_name}' has plaintext credentials "
|
|
350
|
+
"in data:/stringData:"
|
|
351
|
+
),
|
|
352
|
+
hint=(
|
|
353
|
+
"Use external secret store: ExternalSecrets, Sealed Secrets, "
|
|
354
|
+
"or valueFrom.secretKeyRef from a properly-managed Secret "
|
|
355
|
+
"outside the manifest."
|
|
356
|
+
),
|
|
357
|
+
authority="OWASP ASVS V2 / CWE-798 – Use of Hard-coded Credentials",
|
|
358
|
+
docs_url="https://cwe.mitre.org/data/definitions/798.html",
|
|
359
|
+
))
|
|
360
|
+
return out
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ── SECRET-012: secret-shaped env assignment inside heredoc ───────────────────
|
|
364
|
+
|
|
365
|
+
def _scan_heredoc_secrets(
|
|
366
|
+
path: Path, source: str, already_fired_lines: set[int]
|
|
367
|
+
) -> list[Finding]:
|
|
368
|
+
"""Detect FOO_KEY/_TOKEN/_SECRET/_PASSWORD=value inside <<EOF heredocs."""
|
|
369
|
+
out: list[Finding] = []
|
|
370
|
+
heredoc_end: str | None = None
|
|
371
|
+
for i, line in enumerate(source.splitlines(), start=1):
|
|
372
|
+
if heredoc_end is None:
|
|
373
|
+
hm = _PAT_HEREDOC_OPEN.search(line)
|
|
374
|
+
if hm:
|
|
375
|
+
heredoc_end = hm.group(1)
|
|
376
|
+
continue
|
|
377
|
+
# Inside heredoc
|
|
378
|
+
stripped = line.strip()
|
|
379
|
+
if stripped == heredoc_end or stripped.rstrip("-~") == heredoc_end:
|
|
380
|
+
heredoc_end = None
|
|
381
|
+
continue
|
|
382
|
+
if i in already_fired_lines:
|
|
383
|
+
continue
|
|
384
|
+
m = _PAT_HEREDOC_SECRET.search(line)
|
|
385
|
+
if not m:
|
|
386
|
+
continue
|
|
387
|
+
key = m.group(1)
|
|
388
|
+
val = m.group(2)
|
|
389
|
+
if (val.startswith('"') and val.endswith('"')) or (
|
|
390
|
+
val.startswith("'") and val.endswith("'")
|
|
391
|
+
):
|
|
392
|
+
val = val[1:-1]
|
|
393
|
+
if len(val) < 8:
|
|
394
|
+
continue
|
|
395
|
+
if _is_env_ref(val) or val.startswith("$"):
|
|
396
|
+
continue
|
|
397
|
+
if _is_placeholder(val):
|
|
398
|
+
continue
|
|
399
|
+
out.append(Finding(
|
|
400
|
+
file=str(path),
|
|
401
|
+
line=i,
|
|
402
|
+
col=0,
|
|
403
|
+
rule_id="PROOFCTL-SECRET-012",
|
|
404
|
+
rule_name="Secret in heredoc body",
|
|
405
|
+
severity=Severity.ERROR,
|
|
406
|
+
message=f"Hardcoded secret-shaped assignment '{key}=...' inside heredoc",
|
|
407
|
+
hint=(
|
|
408
|
+
"Heredocs (e.g. user_data) are committed to state and version "
|
|
409
|
+
"control. Inject via EC2 Instance Metadata, SSM Parameter Store, "
|
|
410
|
+
"Secrets Manager, or a templated runtime variable."
|
|
411
|
+
),
|
|
412
|
+
authority="OWASP ASVS V2 / CWE-798 – Use of Hard-coded Credentials",
|
|
413
|
+
docs_url="https://cwe.mitre.org/data/definitions/798.html",
|
|
414
|
+
))
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ── main checker ──────────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
class SecretChecker(FileChecker):
|
|
421
|
+
"""Scans any text file for hardcoded secrets."""
|
|
422
|
+
|
|
423
|
+
extensions: tuple[str, ...] = ()
|
|
424
|
+
|
|
425
|
+
def check(self, path: Path, source: str, tree=None) -> list[Finding]: # type: ignore[override]
|
|
426
|
+
if _should_skip(path):
|
|
427
|
+
return []
|
|
428
|
+
# Binary sniff using the source's first 1024 chars encoded back.
|
|
429
|
+
head_bytes = source[:1024].encode("utf-8", errors="replace")
|
|
430
|
+
if _is_binary(head_bytes):
|
|
431
|
+
return []
|
|
432
|
+
|
|
433
|
+
findings: list[Finding] = []
|
|
434
|
+
for i, line in enumerate(source.splitlines(), start=1):
|
|
435
|
+
if not line:
|
|
436
|
+
continue
|
|
437
|
+
findings.extend(_scan_line(path, i, line))
|
|
438
|
+
|
|
439
|
+
# SECRET-011: K8s Secret YAML
|
|
440
|
+
findings.extend(_scan_k8s_secrets(path, source))
|
|
441
|
+
|
|
442
|
+
# SECRET-012: heredoc secrets (skip lines where SECRET-001..010 already fired)
|
|
443
|
+
already = {f.line for f in findings if f.rule_id != "PROOFCTL-SECRET-011"}
|
|
444
|
+
findings.extend(_scan_heredoc_secrets(path, source, already))
|
|
445
|
+
|
|
446
|
+
return findings
|