proofctl 0.1.10__tar.gz → 0.2.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.
Files changed (70) hide show
  1. {proofctl-0.1.10 → proofctl-0.2.0}/PKG-INFO +1 -1
  2. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/dockerfile.py +39 -13
  3. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/imports.py +73 -19
  4. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/placeholders.py +0 -2
  5. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/quality.py +7 -3
  6. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/shell_checker.py +37 -5
  7. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/terraform.py +1 -5
  8. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/yaml_checker.py +62 -53
  9. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/cli.py +25 -1
  10. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/engine.py +7 -3
  11. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/models.py +1 -1
  12. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/reporters/html_reporter.py +1 -1
  13. proofctl-0.2.0/proofctl/reporters/json_reporter.py +63 -0
  14. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/reporters/terminal.py +17 -4
  15. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/PKG-INFO +1 -1
  16. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/SOURCES.txt +2 -0
  17. {proofctl-0.1.10 → proofctl-0.2.0}/pyproject.toml +1 -1
  18. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_dockerfile.py +6 -3
  19. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_local_packages.py +64 -0
  20. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase1_audit_fixes.py +6 -2
  21. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase2_audit_fixes.py +2 -2
  22. proofctl-0.2.0/tests/test_phase6_audit_fixes.py +359 -0
  23. proofctl-0.2.0/tests/test_phase8_audit_fixes.py +328 -0
  24. proofctl-0.1.10/proofctl/reporters/json_reporter.py +0 -22
  25. {proofctl-0.1.10 → proofctl-0.2.0}/LICENSE +0 -0
  26. {proofctl-0.1.10 → proofctl-0.2.0}/README.md +0 -0
  27. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/__init__.py +0 -0
  28. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/_globmatch.py +0 -0
  29. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/baseline.py +0 -0
  30. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/__init__.py +0 -0
  31. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/base.py +0 -0
  32. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/hcl_utils.py +0 -0
  33. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/leakage.py +0 -0
  34. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/llm_integration.py +0 -0
  35. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/security.py +0 -0
  36. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/terragrunt.py +0 -0
  37. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/variants.py +0 -0
  38. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/config.py +0 -0
  39. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/fixer.py +0 -0
  40. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/registry.py +0 -0
  41. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/reporters/__init__.py +0 -0
  42. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/dependency_links.txt +0 -0
  43. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/entry_points.txt +0 -0
  44. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/requires.txt +0 -0
  45. {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/top_level.txt +0 -0
  46. {proofctl-0.1.10 → proofctl-0.2.0}/setup.cfg +0 -0
  47. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_baseline.py +0 -0
  48. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_config_proofctl.py +0 -0
  49. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_engine.py +0 -0
  50. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_fixer.py +0 -0
  51. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_imports.py +0 -0
  52. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_leakage.py +0 -0
  53. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_llm.py +0 -0
  54. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase3_audit_fixes.py +0 -0
  55. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase4_audit_fixes.py +0 -0
  56. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase5_llm_imports_fixes.py +0 -0
  57. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase5_p_l_v_fixes.py +0 -0
  58. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase5_quality_fixes.py +0 -0
  59. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_placeholders.py +0 -0
  60. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_quality.py +0 -0
  61. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_quality_ext.py +0 -0
  62. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_security.py +0 -0
  63. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_security_ext.py +0 -0
  64. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_suppression.py +0 -0
  65. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_terraform.py +0 -0
  66. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_terraform_ext.py +0 -0
  67. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_terragrunt.py +0 -0
  68. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_variants.py +0 -0
  69. {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_yaml.py +0 -0
  70. {proofctl-0.1.10 → proofctl-0.2.0}/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.10
3
+ Version: 0.2.0
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(
@@ -481,26 +481,65 @@ _PKG_SCAN_SKIP_DIRS: frozenset[str] = frozenset({
481
481
  # is depth 3).
482
482
  _PKG_SCAN_MAX_DEPTH = 8
483
483
 
484
+ # Files / directories that mark a project root for `_i001` local-package
485
+ # discovery. Kept broad on purpose: a monorepo with Airflow DAGs frequently has
486
+ # none of pyproject.toml / setup.py at the repo root, but always has `.git/`.
487
+ _PROJECT_ROOT_MARKERS: tuple[str, ...] = (
488
+ "pyproject.toml", "setup.py", "setup.cfg",
489
+ ".git",
490
+ "Pipfile", "Pipfile.lock",
491
+ "poetry.lock", "uv.lock",
492
+ "requirements.txt", "requirements-dev.txt",
493
+ "tox.ini",
494
+ "environment.yml",
495
+ ".proofctl.yaml", # the user's own marker
496
+ )
497
+
484
498
 
485
499
  @functools.lru_cache(maxsize=32)
486
500
  def _local_packages(root: Path) -> frozenset[str]:
487
501
  """Importable Python package/module names anywhere in the project.
488
502
 
489
- Walks the project subtree looking for `__init__.py` files; the parent
490
- directory of every such file becomes a known local package. Also picks up
491
- bare top-level `.py` files at the project root.
503
+ A directory under `root` is treated as a local package when **all** of:
504
+ - its name is a valid Python identifier (filters out `schema-migrations`
505
+ and similar that can never appear as an import),
506
+ - it is not in the vendor / cache / virtualenv skip set,
507
+ - it either contains an `__init__.py` (regular package) **or** at least
508
+ one direct `.py` file (PEP 420 namespace package — common in Airflow
509
+ projects where `dags_folder` is on `sys.path` and DAG sub-directories
510
+ are imported without `__init__.py`).
492
511
 
493
- The previous root-only walk missed packages nested under sub-trees
494
- (e.g. `orchestration/dags/airflow_ip_taskgroups/__init__.py`), generating
495
- spurious PROOFCTL-I-001 hallucinated-import findings against private code.
512
+ Bare top-level `.py` files at the project root are also recorded as
513
+ importable modules.
496
514
 
497
515
  The walk skips common vendor / cache / virtualenv directories, never
498
516
  follows symlinks, and caps recursion at `_PKG_SCAN_MAX_DEPTH`.
499
517
  Results are cached per-root so a 500-file scan walks the tree once.
518
+
519
+ Why we add namespace packages even without an `__init__.py`:
520
+ Airflow's standard layout has `<dags_folder>/<pkg>/<module>.py` with
521
+ `<dags_folder>` on `sys.path`; `from <pkg> import …` resolves at runtime
522
+ even though `<pkg>` is just a plain directory. Without this branch every
523
+ such directory triggered a false PROOFCTL-I-001.
524
+
525
+ Trade-off: a directory whose name matches a real PyPI package will now
526
+ shadow that package in the hallucination check (false-negative rather
527
+ than false-positive). We accept that — the previous noise made the rule
528
+ unusable on namespace-package layouts, and the identifier + py-file
529
+ filters keep the false-negative surface narrow.
500
530
  """
501
531
  names: set[str] = set()
502
532
  visited: set[Path] = set()
503
533
 
534
+ def _has_direct_py_file(p: Path) -> bool:
535
+ try:
536
+ for c in p.iterdir():
537
+ if c.is_file() and not c.is_symlink() and c.suffix == ".py":
538
+ return True
539
+ except (OSError, PermissionError):
540
+ return False
541
+ return False
542
+
504
543
  def _walk(path: Path, depth: int) -> None:
505
544
  if depth > _PKG_SCAN_MAX_DEPTH:
506
545
  return
@@ -526,8 +565,9 @@ def _local_packages(root: Path) -> frozenset[str]:
526
565
  if child.name.startswith(".") and child.name not in {".github"}:
527
566
  # Skip hidden directories — they're caches, IDE state, etc.
528
567
  continue
529
- if (child / "__init__.py").exists():
530
- names.add(child.name)
568
+ if child.name.isidentifier():
569
+ if (child / "__init__.py").exists() or _has_direct_py_file(child):
570
+ names.add(child.name)
531
571
  _walk(child, depth + 1)
532
572
  elif depth == 0 and child.suffix == ".py" and child.name != "__init__.py":
533
573
  # Bare top-level modules (e.g. `setup.py`-adjacent scripts).
@@ -558,10 +598,14 @@ class ImportChecker(FileChecker):
558
598
  local_namespaces: list[str] | None = None,
559
599
  extra_indexes: list[str] | None = None,
560
600
  check_pypi: bool = True,
601
+ scan_root: Path | None = None,
561
602
  ) -> None:
562
603
  self._local_namespaces = tuple(local_namespaces or [])
563
604
  self._check_pypi = check_pypi
564
605
  self._stdlib = _stdlib_names()
606
+ # Engine plumbs its own scan root in; falls back to walking-up
607
+ # heuristic when used standalone (e.g. unit tests).
608
+ self._scan_root = scan_root
565
609
 
566
610
  def check(self, path: Path, source: str, tree: ast.Module | None) -> list[Finding]:
567
611
  findings: list[Finding] = []
@@ -570,17 +614,27 @@ class ImportChecker(FileChecker):
570
614
  return findings
571
615
 
572
616
  def _i001(self, path: Path, tree: ast.Module) -> list[Finding]:
573
- # Infer project root by walking up to the nearest pyproject.toml / setup.py.
574
- # Stop at the filesystem root to avoid scanning /. Default to parent dir.
575
- root = path.parent
576
- candidate = root
577
- for _ in range(8): # max 8 levels up
578
- if (candidate / "pyproject.toml").exists() or (candidate / "setup.py").exists():
579
- root = candidate
580
- break
581
- if candidate.parent == candidate:
582
- break
583
- candidate = candidate.parent
617
+ # Determine the project root for local-package discovery. Priority order:
618
+ # 1. The engine's scan root, if plumbed in (`self._scan_root`).
619
+ # 2. Walk up looking for any standard project marker. We accept a wide
620
+ # set so monorepos without pyproject.toml (e.g. Airflow data
621
+ # pipelines, data-team monorepos) still resolve correctly.
622
+ # 3. Fall back to the file's parent directory.
623
+ if self._scan_root is not None:
624
+ root = self._scan_root
625
+ else:
626
+ root = path.parent
627
+ candidate = root
628
+ for _ in range(8): # max 8 levels up
629
+ if any(
630
+ (candidate / marker).exists()
631
+ for marker in _PROJECT_ROOT_MARKERS
632
+ ):
633
+ root = candidate
634
+ break
635
+ if candidate.parent == candidate:
636
+ break
637
+ candidate = candidate.parent
584
638
  local = _local_packages(root)
585
639
 
586
640
  findings = []
@@ -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
@@ -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.",
@@ -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
 
@@ -99,7 +98,11 @@ def _changed_files_all(root: Path) -> list[Path]:
99
98
  return []
100
99
 
101
100
 
102
- def _build_file_checkers(config: ProofctlConfig, families: set[str] | None) -> list:
101
+ def _build_file_checkers(
102
+ config: ProofctlConfig,
103
+ families: set[str] | None,
104
+ scan_root: Path | None = None,
105
+ ) -> list:
103
106
  checkers = []
104
107
 
105
108
  def _include(family: str) -> bool:
@@ -121,6 +124,7 @@ def _build_file_checkers(config: ProofctlConfig, families: set[str] | None) -> l
121
124
  local_namespaces=config.local_namespaces,
122
125
  extra_indexes=config.pypi_extra_indexes,
123
126
  check_pypi=config.check_pypi,
127
+ scan_root=scan_root,
124
128
  ))
125
129
  if _include("S"):
126
130
  checkers.append(SecurityChecker())
@@ -281,7 +285,7 @@ def run_check(
281
285
  by_kind[kind].append(p)
282
286
 
283
287
  # ── 4. Build per-format checker lists. ───────────────────────────────────
284
- file_checkers = _build_file_checkers(config, fam_set)
288
+ file_checkers = _build_file_checkers(config, fam_set, scan_root=root)
285
289
  dir_checkers = _build_dir_checkers(config, fam_set)
286
290
  hcl_file_checkers = _build_hcl_file_checkers(config, fam_set)
287
291
  hcl_dir_checkers = _build_hcl_dir_checkers(config, fam_set)
@@ -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>