proofctl 0.2.1__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.
Files changed (72) hide show
  1. {proofctl-0.2.1 → proofctl-0.3.1}/PKG-INFO +1 -1
  2. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/dockerfile.py +1 -1
  3. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/hcl_utils.py +11 -7
  4. proofctl-0.3.1/proofctl/checkers/secrets.py +446 -0
  5. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/security.py +207 -0
  6. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/terraform.py +1075 -36
  7. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/terragrunt.py +21 -0
  8. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/yaml_checker.py +295 -2
  9. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/engine.py +19 -16
  10. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/registry.py +1 -0
  11. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl.egg-info/PKG-INFO +1 -1
  12. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl.egg-info/SOURCES.txt +2 -0
  13. {proofctl-0.2.1 → proofctl-0.3.1}/pyproject.toml +1 -1
  14. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_dockerfile.py +2 -2
  15. proofctl-0.3.1/tests/test_secrets.py +362 -0
  16. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_security.py +140 -0
  17. proofctl-0.3.1/tests/test_terraform_ext.py +2806 -0
  18. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_terragrunt.py +54 -0
  19. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_yaml.py +72 -4
  20. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_yaml_k8s_gha.py +282 -0
  21. proofctl-0.2.1/tests/test_terraform_ext.py +0 -1151
  22. {proofctl-0.2.1 → proofctl-0.3.1}/LICENSE +0 -0
  23. {proofctl-0.2.1 → proofctl-0.3.1}/README.md +0 -0
  24. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/__init__.py +0 -0
  25. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/_globmatch.py +0 -0
  26. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/baseline.py +0 -0
  27. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/__init__.py +0 -0
  28. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/base.py +0 -0
  29. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/imports.py +0 -0
  30. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/leakage.py +0 -0
  31. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/llm_integration.py +0 -0
  32. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/placeholders.py +0 -0
  33. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/quality.py +0 -0
  34. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/shell_checker.py +0 -0
  35. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/checkers/variants.py +0 -0
  36. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/cli.py +0 -0
  37. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/config.py +0 -0
  38. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/fixer.py +0 -0
  39. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/models.py +0 -0
  40. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/reporters/__init__.py +0 -0
  41. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/reporters/html_reporter.py +0 -0
  42. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/reporters/json_reporter.py +0 -0
  43. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl/reporters/terminal.py +0 -0
  44. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl.egg-info/dependency_links.txt +0 -0
  45. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl.egg-info/entry_points.txt +0 -0
  46. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl.egg-info/requires.txt +0 -0
  47. {proofctl-0.2.1 → proofctl-0.3.1}/proofctl.egg-info/top_level.txt +0 -0
  48. {proofctl-0.2.1 → proofctl-0.3.1}/setup.cfg +0 -0
  49. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_baseline.py +0 -0
  50. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_config_proofctl.py +0 -0
  51. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_engine.py +0 -0
  52. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_fixer.py +0 -0
  53. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_imports.py +0 -0
  54. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_leakage.py +0 -0
  55. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_llm.py +0 -0
  56. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_local_packages.py +0 -0
  57. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_phase1_audit_fixes.py +0 -0
  58. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_phase2_audit_fixes.py +0 -0
  59. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_phase3_audit_fixes.py +0 -0
  60. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_phase4_audit_fixes.py +0 -0
  61. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_phase5_llm_imports_fixes.py +0 -0
  62. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_phase5_p_l_v_fixes.py +0 -0
  63. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_phase5_quality_fixes.py +0 -0
  64. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_phase6_audit_fixes.py +0 -0
  65. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_phase8_audit_fixes.py +0 -0
  66. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_placeholders.py +0 -0
  67. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_quality.py +0 -0
  68. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_quality_ext.py +0 -0
  69. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_security_ext.py +0 -0
  70. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_suppression.py +0 -0
  71. {proofctl-0.2.1 → proofctl-0.3.1}/tests/test_terraform.py +0 -0
  72. {proofctl-0.2.1 → 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.2.1
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.WARNING
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 ("label1")? ("label2")? {
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+)' # kind
9
- r'(?:\s+"([^"]*)")?' # optional label1
10
- r'(?:\s+"([^"]*)")?' # optional label2
11
- r'\s*\{' # opening brace (rest of line may follow)
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
- block_label1 = m.group(2) or ""
176
- block_label2 = m.group(3) or ""
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