proofctl 0.1.11__tar.gz → 0.2.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 (70) hide show
  1. {proofctl-0.1.11 → proofctl-0.2.1}/PKG-INFO +1 -1
  2. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/dockerfile.py +39 -13
  3. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/placeholders.py +0 -2
  4. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/quality.py +7 -3
  5. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/shell_checker.py +37 -5
  6. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/terraform.py +9 -6
  7. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/yaml_checker.py +62 -53
  8. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/cli.py +25 -1
  9. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/config.py +59 -3
  10. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/engine.py +18 -2
  11. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/models.py +1 -1
  12. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/reporters/html_reporter.py +1 -1
  13. proofctl-0.2.1/proofctl/reporters/json_reporter.py +63 -0
  14. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/reporters/terminal.py +17 -4
  15. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/PKG-INFO +1 -1
  16. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/SOURCES.txt +2 -0
  17. {proofctl-0.1.11 → proofctl-0.2.1}/pyproject.toml +1 -1
  18. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_config_proofctl.py +31 -0
  19. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_dockerfile.py +6 -3
  20. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_engine.py +68 -0
  21. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase1_audit_fixes.py +6 -2
  22. proofctl-0.2.1/tests/test_phase6_audit_fixes.py +359 -0
  23. proofctl-0.2.1/tests/test_phase8_audit_fixes.py +328 -0
  24. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_terraform_ext.py +30 -0
  25. proofctl-0.1.11/proofctl/reporters/json_reporter.py +0 -22
  26. {proofctl-0.1.11 → proofctl-0.2.1}/LICENSE +0 -0
  27. {proofctl-0.1.11 → proofctl-0.2.1}/README.md +0 -0
  28. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/__init__.py +0 -0
  29. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/_globmatch.py +0 -0
  30. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/baseline.py +0 -0
  31. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/__init__.py +0 -0
  32. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/base.py +0 -0
  33. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/hcl_utils.py +0 -0
  34. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/imports.py +0 -0
  35. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/leakage.py +0 -0
  36. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/llm_integration.py +0 -0
  37. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/security.py +0 -0
  38. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/terragrunt.py +0 -0
  39. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/variants.py +0 -0
  40. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/fixer.py +0 -0
  41. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/registry.py +0 -0
  42. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/reporters/__init__.py +0 -0
  43. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/dependency_links.txt +0 -0
  44. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/entry_points.txt +0 -0
  45. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/requires.txt +0 -0
  46. {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/top_level.txt +0 -0
  47. {proofctl-0.1.11 → proofctl-0.2.1}/setup.cfg +0 -0
  48. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_baseline.py +0 -0
  49. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_fixer.py +0 -0
  50. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_imports.py +0 -0
  51. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_leakage.py +0 -0
  52. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_llm.py +0 -0
  53. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_local_packages.py +0 -0
  54. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase2_audit_fixes.py +0 -0
  55. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase3_audit_fixes.py +0 -0
  56. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase4_audit_fixes.py +0 -0
  57. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase5_llm_imports_fixes.py +0 -0
  58. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase5_p_l_v_fixes.py +0 -0
  59. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase5_quality_fixes.py +0 -0
  60. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_placeholders.py +0 -0
  61. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_quality.py +0 -0
  62. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_quality_ext.py +0 -0
  63. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_security.py +0 -0
  64. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_security_ext.py +0 -0
  65. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_suppression.py +0 -0
  66. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_terraform.py +0 -0
  67. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_terragrunt.py +0 -0
  68. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_variants.py +0 -0
  69. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_yaml.py +0 -0
  70. {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_yaml_k8s_gha.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofctl
3
- Version: 0.1.11
3
+ Version: 0.2.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
@@ -9,7 +9,7 @@ from .base import DockerfileChecker
9
9
 
10
10
  # ── helpers ───────────────────────────────────────────────────────────────────
11
11
 
12
- _SHA_DIGEST_RE = re.compile(r"@sha256:[a-f0-9]{64}")
12
+ _SHA_DIGEST_RE = re.compile(r"@sha(?:256:[a-f0-9]{64}|384:[a-f0-9]{96}|512:[a-f0-9]{128})")
13
13
  _HAS_TAG_RE = re.compile(r":[a-zA-Z0-9][\w.\-]*$")
14
14
 
15
15
  # DF-007: curl/wget piped to a shell
@@ -330,16 +330,26 @@ def _df008(instructions: list[tuple[int, str, str]], path: Path) -> list[Finding
330
330
  if "--checksum=sha256:" in args:
331
331
  continue
332
332
 
333
- # Check the next 3 instructions for a verification RUN
334
- window = instructions[idx + 1: idx + 4]
335
- verified = any(
336
- next_instr == "RUN" and (
333
+ # Walk the full continuation chain inspect every subsequent RUN
334
+ # instruction (each already collapsed across `\` continuations by
335
+ # the parser) until we hit a non-RUN instruction. This catches
336
+ # `RUN wget && sha256sum -c && tar xz` chains and multi-RUN
337
+ # verification sequences that overflowed the old 3-instruction
338
+ # window.
339
+ verified = False
340
+ for _, next_instr, next_args in instructions[idx + 1:]:
341
+ if next_instr != "RUN":
342
+ break
343
+ if (
337
344
  "sha256sum" in next_args
345
+ or "sha512sum" in next_args
338
346
  or "shasum -a 256" in next_args
347
+ or "shasum -a 512" in next_args
339
348
  or "gpg --verify" in next_args
340
- )
341
- for _, next_instr, next_args in window
342
- )
349
+ or "gpg2 --verify" in next_args
350
+ ):
351
+ verified = True
352
+ break
343
353
  if verified:
344
354
  continue
345
355
 
@@ -359,18 +369,30 @@ def _df008(instructions: list[tuple[int, str, str]], path: Path) -> list[Finding
359
369
 
360
370
 
361
371
  def _df009(instructions: list[tuple[int, str, str]], path: Path) -> list[Finding]:
362
- """PROOFCTL-DF-009 — COPY . . before dependency installation."""
372
+ """PROOFCTL-DF-009 — COPY . . before dependency installation.
373
+
374
+ Multi-stage context: in a Dockerfile with multiple FROM stages,
375
+ early/builder stages routinely COPY . to compile or vendor
376
+ dependencies; the cache-invalidation antipattern only matters in
377
+ the final (runtime) stage. Skip the rule entirely for non-final
378
+ stages.
379
+ """
363
380
  findings: list[Finding] = []
364
381
 
365
- # Broad COPY destinations that indicate a full source-tree copy
366
- _broad_dests = {"/app", "/src", "/code", "/workspace", "."}
382
+ # Determine the line range of the FINAL build stage. Anything
383
+ # before that range belongs to an earlier (e.g. builder) stage.
384
+ from_linenos = [ln for ln, instr, _ in instructions if instr == "FROM"]
385
+ is_multistage = len(from_linenos) > 1
386
+ final_stage_start = from_linenos[-1] if from_linenos else 0
367
387
 
368
- # Find the first broad COPY (source is bare ".")
388
+ # Find the first broad COPY (source is bare ".") in the FINAL stage.
369
389
  first_broad_copy: tuple[int, str, str] | None = None
370
390
  for item in instructions:
371
391
  lineno, instr, args = item
372
392
  if instr != "COPY":
373
393
  continue
394
+ if is_multistage and lineno < final_stage_start:
395
+ continue # earlier stage — not subject to DF-009
374
396
  tokens = args.split()
375
397
  if not tokens:
376
398
  continue
@@ -384,10 +406,14 @@ def _df009(instructions: list[tuple[int, str, str]], path: Path) -> list[Finding
384
406
 
385
407
  copy_lineno = first_broad_copy[0]
386
408
 
387
- # Find the first dependency-install RUN
409
+ # Find the first dependency-install RUN — only consider RUNs in
410
+ # the final stage (otherwise we'd cross-match a builder-stage RUN
411
+ # with a final-stage COPY).
388
412
  for lineno, instr, args in instructions:
389
413
  if instr != "RUN":
390
414
  continue
415
+ if is_multistage and lineno < final_stage_start:
416
+ continue
391
417
  if _DEP_INSTALL_RE.search(args):
392
418
  if copy_lineno < lineno:
393
419
  findings.append(Finding(
@@ -2,8 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import ast
4
4
  import re
5
- import tokenize
6
- import io
7
5
  from pathlib import Path
8
6
 
9
7
  from ..models import Finding, Severity
@@ -489,9 +489,13 @@ class _Q009Visitor(ast.NodeVisitor):
489
489
  return False
490
490
  left, right = test.left, test.comparators[0]
491
491
  # Match either side: `__name__ == "__main__"` or `"__main__" == __name__`.
492
- is_dunder = lambda n: isinstance(n, ast.Name) and n.id == "__name__"
493
- is_main = lambda n: isinstance(n, ast.Constant) and n.value == "__main__"
494
- return (is_dunder(left) and is_main(right)) or (is_main(left) and is_dunder(right))
492
+ def _is_dunder(n: ast.expr) -> bool:
493
+ return isinstance(n, ast.Name) and n.id == "__name__"
494
+
495
+ def _is_main(n: ast.expr) -> bool:
496
+ return isinstance(n, ast.Constant) and n.value == "__main__"
497
+
498
+ return (_is_dunder(left) and _is_main(right)) or (_is_main(left) and _is_dunder(right))
495
499
 
496
500
  def visit_If(self, node: ast.If) -> None:
497
501
  if self._is_main_guard(node):
@@ -9,8 +9,12 @@ from .base import ShellFileChecker
9
9
 
10
10
  # ── patterns ──────────────────────────────────────────────────────────────────
11
11
 
12
- _SHEBANG_RE = re.compile(r"^#!\s*/(?:usr/(?:local/)?)?bin/(?:env\s+)?(?:bash|sh|zsh|ksh)\b")
13
- _BASH_SHEBANG_RE = re.compile(r"^#!\s*/(?:usr/(?:local/)?)?bin/(?:env\s+)?bash\b")
12
+ _SHEBANG_RE = re.compile(
13
+ r"^#!\s*/(?:usr/(?:local/)?)?bin/(?:env\s+)?(?:bash|sh|zsh|ksh)\b(?:\s.*)?$"
14
+ )
15
+ _BASH_SHEBANG_RE = re.compile(
16
+ r"^#!\s*/(?:usr/(?:local/)?)?bin/(?:env\s+)?bash\b(?:\s.*)?$"
17
+ )
14
18
 
15
19
  _SET_E_RE = re.compile(
16
20
  r"(?:^|\s)set\s+(?:-[a-zA-Z]*e[a-zA-Z]*|-o\s+errexit)", re.MULTILINE
@@ -21,7 +25,24 @@ _PIPE_RE = re.compile(r"[^|]\|[^|]")
21
25
  _CURL_BASH_RE = re.compile(
22
26
  r"(?:curl|wget)\s+[^\n|]+\|\s*(?:sudo\s+)?(?:bash|sh|zsh)\b", re.MULTILINE
23
27
  )
24
- _RM_RF_VAR_RE = re.compile(r"\brm\s+-rf\s+/\$[{]?[A-Za-z_]", re.MULTILINE)
28
+ _RM_RF_VAR_RE = re.compile(
29
+ r"\brm\s+-rf\s+\"?/\$\{?(?P<var>[A-Za-z_][A-Za-z0-9_]*)\}?",
30
+ re.MULTILINE,
31
+ )
32
+
33
+ # Detect a guarding non-empty test of $VAR on the same line or up to
34
+ # 5 lines above. Matches both [[ -n "$VAR" ]] and [ -n "$VAR" ] forms,
35
+ # with optional braces around the variable name.
36
+ def _has_guard(lines: list[str], line_idx: int, var: str) -> bool:
37
+ """Return True if a non-empty guard for `var` precedes line_idx."""
38
+ pattern = re.compile(
39
+ r"\[\[?\s*-n\s+\"?\$\{?" + re.escape(var) + r"\}?\"?\s*\]\]?"
40
+ )
41
+ start = max(0, line_idx - 5)
42
+ for i in range(start, line_idx + 1):
43
+ if pattern.search(lines[i]):
44
+ return True
45
+ return False
25
46
 
26
47
  # Placeholder variable assignments
27
48
  _PLACEHOLDER_VAR_RE = re.compile(
@@ -152,7 +173,7 @@ def _sh004_curl_bash(path: Path, source: str, lines: list[str]) -> list[Finding]
152
173
  path, lineno,
153
174
  "PROOFCTL-SH-004", "Remote code execution via curl|bash",
154
175
  Severity.ERROR,
155
- f"curl/wget piped directly to bash — executes unverified remote code",
176
+ "curl/wget piped directly to bash — executes unverified remote code",
156
177
  hint="Download to a temp file, verify its SHA256 checksum, then execute.",
157
178
  authority="Google Shell Style Guide / CWE-78",
158
179
  docs_url="https://cwe.mitre.org/data/definitions/78.html",
@@ -161,10 +182,21 @@ def _sh004_curl_bash(path: Path, source: str, lines: list[str]) -> list[Finding]
161
182
 
162
183
 
163
184
  def _sh005_rm_rf_var(path: Path, source: str, lines: list[str]) -> list[Finding]:
164
- """PROOFCTL-SH-005 — rm -rf /$VARIABLE (root wipe if var is empty)."""
185
+ """PROOFCTL-SH-005 — rm -rf /$VARIABLE (root wipe if var is empty).
186
+
187
+ Skip the finding when a non-empty guard for the same variable
188
+ appears on the same logical line or within a few lines above —
189
+ e.g. ``[[ -n "$VAR" ]] && rm -rf "/$VAR"`` or an enclosing
190
+ ``if [ -n "$VAR" ]; then rm -rf "/$VAR"; fi`` block.
191
+ """
165
192
  findings: list[Finding] = []
166
193
  for m in _RM_RF_VAR_RE.finditer(source):
167
194
  lineno = source[: m.start()].count("\n") + 1
195
+ var = m.group("var")
196
+ # 0-based index for `lines`
197
+ idx = lineno - 1
198
+ if 0 <= idx < len(lines) and _has_guard(lines, idx, var):
199
+ continue
168
200
  findings.append(_f(
169
201
  path, lineno,
170
202
  "PROOFCTL-SH-005", "Dangerous rm -rf on variable path",
@@ -304,7 +304,7 @@ class TerraformChecker(HclFileChecker):
304
304
  if b.kind != "resource":
305
305
  continue
306
306
  inner = b.raw_lines[1:-1] if len(b.raw_lines) > 2 else []
307
- content = [l for l in inner if l.strip() and not l.strip().startswith("#")]
307
+ content = [line for line in inner if line.strip() and not line.strip().startswith("#")]
308
308
  if not content:
309
309
  out.append(self._f(
310
310
  path, b.start_line,
@@ -394,7 +394,6 @@ class TerraformChecker(HclFileChecker):
394
394
  def _t004(self, path: Path, blocks: list[HclBlock], lines: list[str]) -> list[Finding]:
395
395
  """T-004 — Open ingress on sensitive ports (0.0.0.0/0)."""
396
396
  out = []
397
- any_cidr = re.compile(r'(?:cidr_blocks|cidr_ranges|source_ranges)\s*=\s*\[.*"0\.0\.0\.0/0"')
398
397
  port_re = re.compile(r'(?:from_port|to_port|port)\s*=\s*(\d+)')
399
398
  all_traffic_re = re.compile(r'(?:protocol|ip_protocol)\s*=\s*"-1"|all\s*=\s*true')
400
399
 
@@ -653,9 +652,6 @@ class TerraformChecker(HclFileChecker):
653
652
  def _v001(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
654
653
  """V-001 — force_destroy / deletion_protection=false on prod-named resource."""
655
654
  out = []
656
- dangerous = re.compile(
657
- r'(?:force_destroy\s*=\s*true|deletion_protection\s*=\s*false)', re.IGNORECASE
658
- )
659
655
  for b in blocks:
660
656
  if b.kind != "resource":
661
657
  continue
@@ -2001,8 +1997,15 @@ class TerraformChecker(HclFileChecker):
2001
1997
 
2002
1998
  def _t015(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
2003
1999
  """T-015 — Output with sensitive-looking name but sensitive = true not set."""
2000
+ # "key" is excluded when followed by measurement/metadata suffixes
2001
+ # (e.g. max_key_age_days, key_count, key_rotation_period) — those are
2002
+ # not sensitive values, just numeric descriptors that happen to contain
2003
+ # the word "key". Other terms (password, secret, token, credential)
2004
+ # are always sensitive regardless of suffix.
2004
2005
  _sensitive_name_pat = re.compile(
2005
- r'(?:password|secret|token|key|credential)',
2006
+ r'(?:password|secret|token|credential'
2007
+ r'|key(?!_age|_days|_hours|_seconds|_count|_max|_min|_num'
2008
+ r'|_threshold|_expir|_rotation|_version|_size|_length))',
2006
2009
  re.IGNORECASE,
2007
2010
  )
2008
2011
  out = []
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import re
5
5
  from pathlib import Path
6
- from typing import Any, Iterator
6
+ from typing import Iterator
7
7
 
8
8
  import yaml
9
9
 
@@ -1400,67 +1400,76 @@ class _YamlCheckerImpl(YamlFileChecker):
1400
1400
  def check(self, path: Path, source: str) -> list[Finding]:
1401
1401
  findings: list[Finding] = []
1402
1402
 
1403
- # ── duplicate key detection ───────────────────────────────────────────
1404
- loader = _DupKeyLoader(source)
1405
- loader._dup_findings = [] # type: ignore[attr-defined]
1403
+ # ── duplicate key detection (multi-doc aware) ─────────────────────────
1404
+ # Use a fresh loader per document so _dup_findings does not leak
1405
+ # from doc N into doc N+1 in a `---` separated stream.
1406
+ all_dup_findings: list[tuple[str, int]] = []
1406
1407
  try:
1407
- loader.get_single_data()
1408
+ loader = _DupKeyLoader(source)
1409
+ loader._dup_findings = [] # type: ignore[attr-defined]
1410
+ try:
1411
+ while loader.check_data():
1412
+ loader.get_data()
1413
+ all_dup_findings.extend(loader._dup_findings) # type: ignore[attr-defined]
1414
+ # Reset between documents — findings already harvested.
1415
+ loader._dup_findings = [] # type: ignore[attr-defined]
1416
+ finally:
1417
+ loader.dispose()
1408
1418
  except yaml.YAMLError:
1409
1419
  pass
1410
- finally:
1411
- loader.dispose()
1412
- findings.extend(_yaml002_findings(loader._dup_findings, path)) # type: ignore[attr-defined]
1420
+ findings.extend(_yaml002_findings(all_dup_findings, path))
1413
1421
 
1414
- # ── node-based checks ─────────────────────────────────────────────────
1422
+ # ── node-based checks (multi-doc aware) ───────────────────────────────
1415
1423
  try:
1416
- root = yaml.compose(source, Loader=yaml.SafeLoader)
1424
+ roots = list(yaml.compose_all(source, Loader=yaml.SafeLoader))
1417
1425
  except yaml.YAMLError:
1418
1426
  return findings # unparseable — dup findings are still valid
1419
1427
 
1420
- if root is None:
1421
- return findings
1428
+ for root in roots:
1429
+ if root is None:
1430
+ continue
1422
1431
 
1423
- findings.extend(_yaml001(root, path))
1424
-
1425
- if _is_k8s(root):
1426
- assert isinstance(root, yaml.MappingNode)
1427
- findings.extend(_yaml003_k8s(root, path))
1428
- findings.extend(_yaml004_k8s(root, path))
1429
- findings.extend(_yaml005_k8s(root, path))
1430
- findings.extend(_yaml006_image_latest(root, path, "PROOFCTL-YAML-006"))
1431
- findings.extend(_yaml009_k8s(root, path))
1432
- findings.extend(_yaml010_k8s(root, path))
1433
- findings.extend(_yaml011_k8s(root, path))
1434
- findings.extend(_yaml012_k8s(root, path))
1435
- findings.extend(_yaml013_k8s(root, path))
1436
- findings.extend(_yaml014_k8s(root, path))
1437
- findings.extend(_yaml015_k8s(root, path))
1438
- findings.extend(_yaml016_017_k8s(root, path))
1439
- findings.extend(_yaml018_k8s(root, path))
1440
- findings.extend(_yaml020_k8s(root, path))
1441
- findings.extend(_yaml021_k8s(root, path))
1442
- # AI-slop: removed apiVersions, PSP, explicit root, deprecated annotations
1443
- findings.extend(_yaml_ai001_k8s_removed_api(root, path))
1444
- findings.extend(_yaml_ai002_pod_security_policy(root, path))
1445
- findings.extend(_yaml_ai003_run_as_user_root(root, path))
1446
- findings.extend(_yaml_ai006_ingress_class_annotation(root, path))
1447
-
1448
- elif _is_compose(root):
1449
- findings.extend(_yaml006_image_latest(root, path, "PROOFCTL-YAML-006"))
1450
- findings.extend(_yaml008_compose_privileged(root, path))
1451
-
1452
- if _is_gha(path) or _is_gha_content(root):
1453
- findings.extend(_yaml007_gha(root, path))
1454
- if isinstance(root, yaml.MappingNode):
1455
- findings.extend(_yaml_gha001(root, path))
1456
- findings.extend(_yaml_gha002(root, path))
1457
- findings.extend(_yaml_gha003(root, path))
1458
- findings.extend(_yaml_gha004(root, path))
1459
- findings.extend(_yaml_gha005(root, path))
1460
- findings.extend(_yaml_gha006(root, path))
1461
- # AI-slop: deprecated workflow commands, stale action versions
1462
- findings.extend(_yaml_ai004_gha_deprecated_commands(root, path))
1463
- findings.extend(_yaml_ai005_old_action_versions(root, path))
1432
+ findings.extend(_yaml001(root, path))
1433
+
1434
+ if _is_k8s(root):
1435
+ assert isinstance(root, yaml.MappingNode)
1436
+ findings.extend(_yaml003_k8s(root, path))
1437
+ findings.extend(_yaml004_k8s(root, path))
1438
+ findings.extend(_yaml005_k8s(root, path))
1439
+ findings.extend(_yaml006_image_latest(root, path, "PROOFCTL-YAML-006"))
1440
+ findings.extend(_yaml009_k8s(root, path))
1441
+ findings.extend(_yaml010_k8s(root, path))
1442
+ findings.extend(_yaml011_k8s(root, path))
1443
+ findings.extend(_yaml012_k8s(root, path))
1444
+ findings.extend(_yaml013_k8s(root, path))
1445
+ findings.extend(_yaml014_k8s(root, path))
1446
+ findings.extend(_yaml015_k8s(root, path))
1447
+ findings.extend(_yaml016_017_k8s(root, path))
1448
+ findings.extend(_yaml018_k8s(root, path))
1449
+ findings.extend(_yaml020_k8s(root, path))
1450
+ findings.extend(_yaml021_k8s(root, path))
1451
+ # AI-slop: removed apiVersions, PSP, explicit root, deprecated annotations
1452
+ findings.extend(_yaml_ai001_k8s_removed_api(root, path))
1453
+ findings.extend(_yaml_ai002_pod_security_policy(root, path))
1454
+ findings.extend(_yaml_ai003_run_as_user_root(root, path))
1455
+ findings.extend(_yaml_ai006_ingress_class_annotation(root, path))
1456
+
1457
+ elif _is_compose(root):
1458
+ findings.extend(_yaml006_image_latest(root, path, "PROOFCTL-YAML-006"))
1459
+ findings.extend(_yaml008_compose_privileged(root, path))
1460
+
1461
+ if _is_gha(path) or _is_gha_content(root):
1462
+ findings.extend(_yaml007_gha(root, path))
1463
+ if isinstance(root, yaml.MappingNode):
1464
+ findings.extend(_yaml_gha001(root, path))
1465
+ findings.extend(_yaml_gha002(root, path))
1466
+ findings.extend(_yaml_gha003(root, path))
1467
+ findings.extend(_yaml_gha004(root, path))
1468
+ findings.extend(_yaml_gha005(root, path))
1469
+ findings.extend(_yaml_gha006(root, path))
1470
+ # AI-slop: deprecated workflow commands, stale action versions
1471
+ findings.extend(_yaml_ai004_gha_deprecated_commands(root, path))
1472
+ findings.extend(_yaml_ai005_old_action_versions(root, path))
1464
1473
 
1465
1474
  return findings
1466
1475
 
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import sys
3
4
  from enum import Enum
4
5
  from pathlib import Path
5
- from typing import List, Optional
6
+ from typing import Optional
6
7
 
7
8
  import typer
8
9
 
@@ -10,6 +11,29 @@ from .config import load_config
10
11
  from .engine import run_check
11
12
  from .registry import RULES, families_help_string, is_hidden_family
12
13
 
14
+
15
+ def _ensure_utf8_io() -> None:
16
+ """Force stdout/stderr to UTF-8.
17
+
18
+ Rich-rendered tables embed box-drawing characters (─ │ ╭),
19
+ arrows (→), and emoji (📖 ✓) that can't be encoded under
20
+ Windows' default cp1252 locale — proofctl crashes mid-render with
21
+ UnicodeEncodeError. Reconfiguring at startup makes output portable
22
+ regardless of host locale; `errors="replace"` is a defensive fallback
23
+ so a misconfigured stream never aborts the run.
24
+ """
25
+ for stream_name in ("stdout", "stderr"):
26
+ stream = getattr(sys, stream_name, None)
27
+ if stream is None or not hasattr(stream, "reconfigure"):
28
+ continue
29
+ try:
30
+ stream.reconfigure(encoding="utf-8", errors="replace")
31
+ except (OSError, ValueError):
32
+ pass
33
+
34
+
35
+ _ensure_utf8_io()
36
+
13
37
  app = typer.Typer(
14
38
  name="proofctl",
15
39
  help="Detects AI-generated slop: hallucinated imports, phantom methods, placeholder code, cross-language leakage, and variant file debris.",
@@ -9,16 +9,72 @@ import yaml
9
9
  from .models import Severity
10
10
 
11
11
  _DEFAULT_EXCLUDES: list[str] = [
12
+ # ── Python project environments ───────────────────────────────────────────
12
13
  "**/.venv/**",
13
14
  "**/venv/**",
14
- "**/node_modules/**",
15
- "**/.git/**",
16
15
  "**/__pycache__/**",
17
16
  "**/*.egg-info/**",
18
17
  "**/dist/**",
19
18
  "**/build/**",
20
- "**/.terragrunt-cache/**",
19
+ # ── Universal VCS / package managers ─────────────────────────────────────
20
+ "**/.git/**",
21
+ "**/node_modules/**",
22
+ # ── Terraform / Terragrunt caches ─────────────────────────────────────────
21
23
  "**/.terraform/**",
24
+ "**/.terragrunt-cache/**",
25
+ # Terraform/OpenTofu state and plan artifacts — never hand-authored;
26
+ # may contain sensitive resource outputs and secrets.
27
+ "**/*.tfstate",
28
+ "**/*.tfstate.backup",
29
+ "**/*.tfplan",
30
+ # ── Flux CD ───────────────────────────────────────────────────────────────
31
+ # Both files are auto-generated by `flux bootstrap` and contain the comment
32
+ # "# This manifest was generated by flux. DO NOT EDIT."
33
+ # gotk-components.yaml carries wildcard RBAC + cluster-admin bindings that
34
+ # are intentional Flux design; gotk-sync.yaml wires the GitRepository/
35
+ # Kustomization that reconciles flux-system itself. Neither is user policy.
36
+ "**/flux-system/gotk-components.yaml",
37
+ "**/flux-system/gotk-sync.yaml",
38
+ # ── ArgoCD Autopilot ──────────────────────────────────────────────────────
39
+ # Files generated by `argocd-autopilot repo bootstrap`. The kustomization
40
+ # references upstream install.yaml which carries a cluster-admin binding for
41
+ # argocd-application-controller — intentional, not a misconfiguration.
42
+ "**/bootstrap/argo-cd/kustomization.yaml",
43
+ "**/bootstrap/argo-cd.yaml",
44
+ "**/bootstrap/cluster-resources/in-cluster/**",
45
+ # ── AWS CDK / CDK for Terraform ───────────────────────────────────────────
46
+ # cdk.out/ is synthesized CloudFormation JSON from `cdk synth`.
47
+ # .gen/ and cdktf.out/ are CDKTF provider bindings and synth output.
48
+ "**/cdk.out/**",
49
+ "**/.gen/**",
50
+ "**/cdktf.out/**",
51
+ # ── Helm ─────────────────────────────────────────────────────────────────
52
+ # Chart tarballs downloaded by `helm dep update` into charts/ subdirectories.
53
+ "**/charts/*.tgz",
54
+ # ── Pulumi ───────────────────────────────────────────────────────────────
55
+ # Local backend state written by `pulumi up` when using file:// backend.
56
+ "**/.pulumi/**",
57
+ # ── Other IaC tool caches ─────────────────────────────────────────────────
58
+ "**/packer_cache/**", # Packer: ISO/VM template cache (default ./packer_cache/)
59
+ "**/.vagrant/**", # Vagrant: machine state, private keys, VM references
60
+ "**/.crossplane/**", # Crossplane CLI: provider OCI layer / CRD schema cache
61
+ # ── Ansible Galaxy ────────────────────────────────────────────────────────
62
+ # Collections installed via `ansible-galaxy collection install`.
63
+ "**/collections/ansible_collections/**",
64
+ # ── Go vendor ─────────────────────────────────────────────────────────────
65
+ # Third-party module source vendored by `go mod vendor`. Contains .go files
66
+ # that can trigger false-positives in import / quality rules.
67
+ "**/vendor/**",
68
+ # ── Python tool caches ────────────────────────────────────────────────────
69
+ # These directories contain .py files and can surface false-positives if
70
+ # scanned (e.g. generated test helpers, mypy stubs, hypothesis examples).
71
+ "**/.tox/**",
72
+ "**/.nox/**",
73
+ "**/.pytest_cache/**",
74
+ "**/.mypy_cache/**",
75
+ "**/.ruff_cache/**",
76
+ "**/.hypothesis/**",
77
+ "**/htmlcov/**",
22
78
  ]
23
79
 
24
80
  # Top-level config keys recognised by `.proofctl.yaml`. Anything else triggers
@@ -17,7 +17,6 @@ import ast
17
17
  import dataclasses
18
18
  import re
19
19
  import subprocess
20
- import traceback
21
20
  from pathlib import Path
22
21
  from typing import Iterable
23
22
 
@@ -412,7 +411,24 @@ def run_check(
412
411
  floor = Severity.from_str(min_severity)
413
412
  findings = [f for f in findings if f.severity >= floor]
414
413
 
415
- return sorted(findings, key=lambda x: (-x.severity, x.file, x.line or 0))
414
+ sorted_findings = sorted(findings, key=lambda x: (-x.severity, x.file, x.line or 0))
415
+
416
+ # ── 8. Module-level rule deduplication. ───────────────────────────────────
417
+ # Rules like T014 (missing required_version) represent a per-module property,
418
+ # not a per-file one. A module with 10 .tf files should produce one finding,
419
+ # not ten. After sorting we keep the first occurrence per (dir, rule_id) so
420
+ # the preserved finding is always the alphabetically-first file in the module.
421
+ _MODULE_LEVEL_RULES: frozenset[str] = frozenset({"PROOFCTL-TF-T014"})
422
+ seen_module_dirs: set[tuple[str, str]] = set()
423
+ result: list[Finding] = []
424
+ for f in sorted_findings:
425
+ if f.rule_id in _MODULE_LEVEL_RULES:
426
+ dir_key = (str(Path(f.file).parent), f.rule_id)
427
+ if dir_key in seen_module_dirs:
428
+ continue
429
+ seen_module_dirs.add(dir_key)
430
+ result.append(f)
431
+ return result
416
432
 
417
433
 
418
434
  def _normalise(f: Finding, root: Path, config: ProofctlConfig) -> Finding | None:
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass, field
3
+ from dataclasses import dataclass
4
4
  from enum import IntEnum
5
5
 
6
6
 
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import html
4
- import json
5
4
  from collections import Counter
6
5
  from datetime import datetime, timezone
7
6
 
@@ -160,6 +159,7 @@ class HtmlReporter:
160
159
  <head>
161
160
  <meta charset="UTF-8">
162
161
  <meta name="viewport" content="width=device-width,initial-scale=1">
162
+ <meta name="proofctl:schema_version" content="1">
163
163
  <title>proofctl report</title>
164
164
  <style>{_CSS}</style>
165
165
  </head>
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections import Counter
5
+
6
+ from ..models import Finding, Severity
7
+
8
+ # Bumped in lockstep with documented JSON schema changes. Consumers can branch
9
+ # on this field. Start at 1 since this is the first formally-stable schema.
10
+ SCHEMA_VERSION = 1
11
+
12
+ # Stable field order for each finding object. Matches the order documented in
13
+ # the README and used by external tooling.
14
+ _FINDING_FIELDS = (
15
+ "file",
16
+ "line",
17
+ "col",
18
+ "rule_id",
19
+ "rule_name",
20
+ "severity",
21
+ "message",
22
+ "hint",
23
+ "authority",
24
+ "docs_url",
25
+ "fixable",
26
+ )
27
+
28
+
29
+ class JsonReporter:
30
+ def render(self, findings: list[Finding]) -> str:
31
+ counts = Counter(f.severity for f in findings)
32
+
33
+ # Stable summary order: total, ERROR, WARNING, INFO (matches README).
34
+ summary = {
35
+ "total": len(findings),
36
+ "ERROR": counts[Severity.ERROR],
37
+ "WARNING": counts[Severity.WARNING],
38
+ "INFO": counts[Severity.INFO],
39
+ }
40
+
41
+ serialisable = []
42
+ for f in findings:
43
+ d = {
44
+ "file": f.file,
45
+ "line": f.line,
46
+ "col": f.col,
47
+ "rule_id": f.rule_id,
48
+ "rule_name": f.rule_name,
49
+ "severity": f.severity.label(),
50
+ "message": f.message,
51
+ "hint": f.hint,
52
+ "authority": f.authority,
53
+ "docs_url": f.docs_url,
54
+ "fixable": f.fixable,
55
+ }
56
+ serialisable.append(d)
57
+
58
+ payload = {
59
+ "schema_version": SCHEMA_VERSION,
60
+ "summary": summary,
61
+ "findings": serialisable,
62
+ }
63
+ return json.dumps(payload, indent=2)