proofctl 0.1.7__tar.gz → 0.1.9__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 (63) hide show
  1. {proofctl-0.1.7 → proofctl-0.1.9}/PKG-INFO +1 -1
  2. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/hcl_utils.py +24 -2
  3. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/imports.py +70 -17
  4. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/terraform.py +68 -23
  5. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/cli.py +5 -1
  6. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/registry.py +20 -4
  7. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/PKG-INFO +1 -1
  8. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/SOURCES.txt +2 -0
  9. {proofctl-0.1.7 → proofctl-0.1.9}/pyproject.toml +1 -1
  10. proofctl-0.1.9/tests/test_local_packages.py +175 -0
  11. proofctl-0.1.9/tests/test_phase3_audit_fixes.py +277 -0
  12. {proofctl-0.1.7 → proofctl-0.1.9}/LICENSE +0 -0
  13. {proofctl-0.1.7 → proofctl-0.1.9}/README.md +0 -0
  14. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/__init__.py +0 -0
  15. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/_globmatch.py +0 -0
  16. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/baseline.py +0 -0
  17. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/__init__.py +0 -0
  18. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/base.py +0 -0
  19. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/dockerfile.py +0 -0
  20. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/leakage.py +0 -0
  21. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/llm_integration.py +0 -0
  22. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/placeholders.py +0 -0
  23. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/quality.py +0 -0
  24. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/security.py +0 -0
  25. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/shell_checker.py +0 -0
  26. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/terragrunt.py +0 -0
  27. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/variants.py +0 -0
  28. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/yaml_checker.py +0 -0
  29. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/config.py +0 -0
  30. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/engine.py +0 -0
  31. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/fixer.py +0 -0
  32. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/models.py +0 -0
  33. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/reporters/__init__.py +0 -0
  34. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/reporters/html_reporter.py +0 -0
  35. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/reporters/json_reporter.py +0 -0
  36. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/reporters/terminal.py +0 -0
  37. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/dependency_links.txt +0 -0
  38. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/entry_points.txt +0 -0
  39. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/requires.txt +0 -0
  40. {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/top_level.txt +0 -0
  41. {proofctl-0.1.7 → proofctl-0.1.9}/setup.cfg +0 -0
  42. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_baseline.py +0 -0
  43. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_config_proofctl.py +0 -0
  44. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_dockerfile.py +0 -0
  45. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_engine.py +0 -0
  46. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_fixer.py +0 -0
  47. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_imports.py +0 -0
  48. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_leakage.py +0 -0
  49. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_llm.py +0 -0
  50. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_phase1_audit_fixes.py +0 -0
  51. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_phase2_audit_fixes.py +0 -0
  52. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_placeholders.py +0 -0
  53. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_quality.py +0 -0
  54. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_quality_ext.py +0 -0
  55. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_security.py +0 -0
  56. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_security_ext.py +0 -0
  57. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_suppression.py +0 -0
  58. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_terraform.py +0 -0
  59. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_terraform_ext.py +0 -0
  60. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_terragrunt.py +0 -0
  61. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_variants.py +0 -0
  62. {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_yaml.py +0 -0
  63. {proofctl-0.1.7 → proofctl-0.1.9}/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.7
3
+ Version: 0.1.9
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
@@ -77,12 +77,29 @@ def parse_blocks(source: str) -> list[HclBlock]:
77
77
  return _parse(source.splitlines(), base=1, skip_outer=False)
78
78
 
79
79
 
80
+ def _is_escaped(line: str, idx: int) -> bool:
81
+ """True if `line[idx]` is preceded by an odd run of backslashes.
82
+
83
+ Counting the run is the only correct way to tell `\\"` (escaped backslash
84
+ followed by an unescaped quote) from `\"` (escaped quote). The previous
85
+ one-character lookback `line[i-1] == '\\\\'` got this wrong: in
86
+ `password = "my\\\\\\"secret"`, it treated the closing quote as escaped
87
+ and tokenisation silently broke for every rule that walked block bodies.
88
+ """
89
+ n = 0
90
+ j = idx - 1
91
+ while j >= 0 and line[j] == "\\":
92
+ n += 1
93
+ j -= 1
94
+ return n % 2 == 1
95
+
96
+
80
97
  def _strip_comment(line: str) -> str:
81
98
  in_str = False
82
99
  i = 0
83
100
  while i < len(line):
84
101
  c = line[i]
85
- if c == '"' and (i == 0 or line[i - 1] != '\\'):
102
+ if c == '"' and not _is_escaped(line, i):
86
103
  in_str = not in_str
87
104
  if not in_str:
88
105
  if c == '#':
@@ -137,7 +154,12 @@ def _parse(lines: list[str], base: int, skip_outer: bool) -> list[HclBlock]:
137
154
 
138
155
  # ── heredoc passthrough ──
139
156
  if heredoc_end:
140
- if line.strip() == heredoc_end or line.strip().rstrip("-~") == heredoc_end:
157
+ # The end label must appear on a line of its own (whitespace either
158
+ # side allowed for `<<-` indented heredocs). The previous loose
159
+ # `rstrip("-~")` matched lines like `EOF~~~` as terminators, which
160
+ # could prematurely close a heredoc whose body legitimately ended
161
+ # with `~`. C-11 fix: strict whole-line match after .strip().
162
+ if line.strip() == heredoc_end:
141
163
  heredoc_end = None
142
164
  continue
143
165
  hm = _HEREDOC_START_RE.search(line)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ import functools
4
5
  import json
5
6
  import re
6
7
  import shutil
@@ -237,26 +238,78 @@ def _stdlib_names() -> frozenset[str]:
237
238
  })
238
239
 
239
240
 
241
+ # Directories never worth descending into when looking for local packages.
242
+ # These are scanner / vendor / virtualenv folders, not first-party code.
243
+ _PKG_SCAN_SKIP_DIRS: frozenset[str] = frozenset({
244
+ ".venv", "venv", ".env",
245
+ "node_modules",
246
+ ".git", ".hg", ".svn",
247
+ "__pycache__",
248
+ "dist", "build",
249
+ ".terraform", ".terragrunt-cache",
250
+ ".tox", ".nox",
251
+ ".pytest_cache", ".mypy_cache", ".ruff_cache",
252
+ "site-packages",
253
+ })
254
+
255
+ # Cap the recursion depth so a runaway symlink or pathological repo can't lock
256
+ # the scanner. 8 is enough for monorepos (e.g. orchestration/dags/<pkg>/__init__.py
257
+ # is depth 3).
258
+ _PKG_SCAN_MAX_DEPTH = 8
259
+
260
+
261
+ @functools.lru_cache(maxsize=32)
240
262
  def _local_packages(root: Path) -> frozenset[str]:
241
- """Top-level importable names within the scanned project."""
263
+ """Importable Python package/module names anywhere in the project.
264
+
265
+ Walks the project subtree looking for `__init__.py` files; the parent
266
+ directory of every such file becomes a known local package. Also picks up
267
+ bare top-level `.py` files at the project root.
268
+
269
+ The previous root-only walk missed packages nested under sub-trees
270
+ (e.g. `orchestration/dags/airflow_ip_taskgroups/__init__.py`), generating
271
+ spurious PROOFCTL-I-001 hallucinated-import findings against private code.
272
+
273
+ The walk skips common vendor / cache / virtualenv directories, never
274
+ follows symlinks, and caps recursion at `_PKG_SCAN_MAX_DEPTH`.
275
+ Results are cached per-root so a 500-file scan walks the tree once.
276
+ """
242
277
  names: set[str] = set()
243
- try:
244
- children = list(root.iterdir())
245
- except (OSError, PermissionError):
246
- return frozenset()
247
- for child in children:
248
- if child.is_dir() and (child / "__init__.py").exists():
249
- names.add(child.name)
250
- elif child.suffix == ".py" and child.name != "__init__.py":
251
- names.add(child.stem)
252
- # Also check src/ layout
253
- src = root / "src"
254
- if src.is_dir():
255
- for child in src.iterdir():
256
- if child.is_dir() and (child / "__init__.py").exists():
257
- names.add(child.name)
258
- elif child.suffix == ".py":
278
+ visited: set[Path] = set()
279
+
280
+ def _walk(path: Path, depth: int) -> None:
281
+ if depth > _PKG_SCAN_MAX_DEPTH:
282
+ return
283
+ try:
284
+ real = path.resolve()
285
+ except OSError:
286
+ return
287
+ if real in visited:
288
+ return # symlink loop guard
289
+ visited.add(real)
290
+ try:
291
+ children = list(path.iterdir())
292
+ except (OSError, PermissionError):
293
+ return
294
+ for child in children:
295
+ if child.is_symlink():
296
+ continue
297
+ if child.is_dir():
298
+ if child.name in _PKG_SCAN_SKIP_DIRS:
299
+ continue
300
+ if child.name.endswith(".egg-info"):
301
+ continue
302
+ if child.name.startswith(".") and child.name not in {".github"}:
303
+ # Skip hidden directories — they're caches, IDE state, etc.
304
+ continue
305
+ if (child / "__init__.py").exists():
306
+ names.add(child.name)
307
+ _walk(child, depth + 1)
308
+ elif depth == 0 and child.suffix == ".py" and child.name != "__init__.py":
309
+ # Bare top-level modules (e.g. `setup.py`-adjacent scripts).
259
310
  names.add(child.stem)
311
+
312
+ _walk(root, 0)
260
313
  return frozenset(names)
261
314
 
262
315
 
@@ -266,21 +266,28 @@ class TerraformChecker(HclFileChecker):
266
266
 
267
267
  @staticmethod
268
268
  def _has_lifecycle_attr(block: HclBlock, attr: str) -> bool:
269
- """True if the block contains lifecycle { <attr> = true }."""
269
+ """True if the block contains lifecycle { <attr> = true }.
270
+
271
+ Strips inline comments before counting braces so a comment containing
272
+ `}` (e.g. ``# closing the lifecycle block }``) doesn't prematurely end
273
+ the lifecycle scope. The same rule applies for `{`.
274
+ """
275
+ from .hcl_utils import _strip_comment
270
276
  lc_re = re.compile(r'lifecycle\s*\{')
271
277
  attr_re = re.compile(rf'{re.escape(attr)}\s*=\s*true')
272
278
  in_lifecycle = False
273
279
  depth = 0
274
280
  for line in block.raw_lines:
275
- if not in_lifecycle and lc_re.search(line):
281
+ stripped = _strip_comment(line)
282
+ if not in_lifecycle and lc_re.search(stripped):
276
283
  in_lifecycle = True
277
284
  depth = 1
278
- if attr_re.search(line):
285
+ if attr_re.search(stripped):
279
286
  return True
280
287
  continue
281
288
  if in_lifecycle:
282
- depth += line.count('{') - line.count('}')
283
- if attr_re.search(line):
289
+ depth += stripped.count('{') - stripped.count('}')
290
+ if attr_re.search(stripped):
284
291
  return True
285
292
  if depth <= 0:
286
293
  in_lifecycle = False
@@ -699,9 +706,15 @@ class TerraformChecker(HclFileChecker):
699
706
  hint='Pin to a commit SHA or semver tag: source = "git::https://…?ref=v1.2.3"',
700
707
  ))
701
708
  else:
702
- # Extract the ref value (stop at / & or end)
709
+ # Extract the ref value (stop at & or end of query string).
710
+ # URL-decode so percent-encoded chars (`v1%2E0` → `v1.0`,
711
+ # `release%2Fv1` → `release/v1`) are evaluated as the user
712
+ # intended; without this, `?ref=v1%2E0` failed both the SHA
713
+ # and semver patterns and reported as a mutable ref.
714
+ from urllib.parse import unquote
703
715
  ref_part = src.split("?ref=", 1)[1]
704
- ref = re.split(r'[/&?]', ref_part)[0]
716
+ ref_raw = re.split(r'[&?]', ref_part)[0]
717
+ ref = unquote(ref_raw)
705
718
  if _GIT_SHA_RE.match(ref) or _SEMVER_TAG_RE.match(ref):
706
719
  continue # immutable SHA or semver tag — clean
707
720
  out.append(self._f(
@@ -788,24 +801,43 @@ class TerraformChecker(HclFileChecker):
788
801
  return out
789
802
 
790
803
  def _g004(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
791
- """G-004 — Default service account with cloud-platform scope."""
792
- out = []
804
+ """G-004 — Default service account with cloud-platform scope.
805
+
806
+ The real default Compute SA email format is
807
+ ``<PROJECT_NUMBER>-compute@developer.gserviceaccount.com``. The
808
+ previous regex looked for ``compute@developer`` which only matched
809
+ the suffix in isolation; legitimate user-typed default-SA emails
810
+ like ``123456-compute@developer.gserviceaccount.com`` were missed.
811
+ Match the canonical suffix instead.
812
+ """
813
+ out = []
814
+ # Canonical default-SA suffix (project-scoped Compute Engine default SA).
815
+ default_sa_re = re.compile(
816
+ r'email\s*=\s*"(?:"|[\d]+-compute@developer\.gserviceaccount\.com)',
817
+ )
818
+ empty_email_re = re.compile(r'email\s*=\s*""')
819
+ any_email_re = re.compile(r'email\s*=')
793
820
  for b in blocks:
794
821
  if b.kind != "resource" or b.label1 != "google_compute_instance":
795
822
  continue
796
- # Detect: service_account block with empty email + cloud-platform scope
797
- if b.contains(r'service_account') and b.contains(r'cloud-platform'):
798
- # Check email is empty or uses the default SA pattern
799
- if b.contains(r'email\s*=\s*(?:""|compute@developer)') or not b.contains(r'email\s*='):
800
- out.append(self._f(
801
- path, b.any_line_matches(r'cloud-platform') or b.start_line,
802
- "PROOFCTL-TF-G004", "Default SA with cloud-platform scope",
803
- Severity.ERROR,
804
- f'Instance "{b.label2}" uses the default service account with cloud-platform scope',
805
- hint="Create a dedicated SA with only required roles and use granular scopes.",
806
- authority="CIS GCP Benchmark 4.1",
807
- docs_url="https://www.cisecurity.org/benchmark/google_cloud_computing_platform",
808
- ))
823
+ block_src = "\n".join(b.raw_lines)
824
+ if not (b.contains(r'service_account') and b.contains(r'cloud-platform')):
825
+ continue
826
+ uses_default_sa = (
827
+ default_sa_re.search(block_src) is not None
828
+ or empty_email_re.search(block_src) is not None
829
+ or any_email_re.search(block_src) is None # implicit default
830
+ )
831
+ if uses_default_sa:
832
+ out.append(self._f(
833
+ path, b.any_line_matches(r'cloud-platform') or b.start_line,
834
+ "PROOFCTL-TF-G004", "Default SA with cloud-platform scope",
835
+ Severity.ERROR,
836
+ f'Instance "{b.label2}" uses the default service account with cloud-platform scope',
837
+ hint="Create a dedicated SA with only required roles and use granular scopes.",
838
+ authority="CIS GCP Benchmark 4.1",
839
+ docs_url="https://www.cisecurity.org/benchmark/google_cloud_computing_platform",
840
+ ))
809
841
  return out
810
842
 
811
843
  def _g005(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
@@ -2018,13 +2050,26 @@ class TerraformChecker(HclFileChecker):
2018
2050
  return out
2019
2051
 
2020
2052
  def _ai002(self, path: Path, lines: list[str], blocks: list[HclBlock]) -> list[Finding]:
2021
- """AI-002 — AWS region format in a GCP-context file."""
2053
+ """AI-002 — AWS region format in a GCP-context file.
2054
+
2055
+ Ignores matches that fall on comment lines (``#`` or ``//``) so legitimate
2056
+ migration notes like ``# was us-east-1, now us-east1`` don't false-positive.
2057
+ """
2022
2058
  source = "\n".join(lines)
2023
2059
  if not _GCP_RESOURCE_RE.search(source):
2024
2060
  return []
2025
2061
  out = []
2062
+ from .hcl_utils import _strip_comment
2026
2063
  for m in _AWS_REGION_RE.finditer(source):
2027
2064
  lineno = source[: m.start()].count("\n") + 1
2065
+ full_line = lines[lineno - 1] if lineno <= len(lines) else ""
2066
+ # Compute the column where the match starts within the line.
2067
+ line_start = source.rfind("\n", 0, m.start()) + 1
2068
+ col = m.start() - line_start
2069
+ # If the match falls inside a comment portion of the line, skip it.
2070
+ non_comment = _strip_comment(full_line)
2071
+ if col >= len(non_comment):
2072
+ continue
2028
2073
  out.append(self._f(
2029
2074
  path, lineno,
2030
2075
  "PROOFCTL-TF-AI-002", "AWS region format in GCP context",
@@ -8,7 +8,7 @@ import typer
8
8
 
9
9
  from .config import load_config
10
10
  from .engine import run_check
11
- from .registry import RULES, families_help_string
11
+ from .registry import RULES, families_help_string, is_hidden_family
12
12
 
13
13
  app = typer.Typer(
14
14
  name="proofctl",
@@ -240,7 +240,11 @@ def rules(
240
240
  # Sort by (family, rule_id) so related rules cluster.
241
241
  specs = sorted(RULES.values(), key=lambda s: (s.family, s.rule_id))
242
242
  if fam_filter:
243
+ # Explicit filter — show even hidden families like INTERNAL for debugging.
243
244
  specs = [s for s in specs if s.family == fam_filter]
245
+ else:
246
+ # Default listing — hide engine-internal plumbing families.
247
+ specs = [s for s in specs if not is_hidden_family(s.family)]
244
248
 
245
249
  if not specs:
246
250
  typer.echo(f"No rules found for family '{family}'.", err=True)
@@ -43,6 +43,10 @@ FAMILY_LABELS: dict[str, str] = {
43
43
  "INTERNAL": "Engine internal",
44
44
  }
45
45
 
46
+ # Families that exist for plumbing reasons but shouldn't clutter user-facing
47
+ # lists. `proofctl rules --family INTERNAL` still works for debugging.
48
+ _HIDDEN_FAMILIES: frozenset[str] = frozenset({"INTERNAL"})
49
+
46
50
  # Default severities for rules whose Finding(...) construction uses a computed
47
51
  # expression rather than a literal Severity.X. Listed as the worst case the rule
48
52
  # can fire at, since that is what `proofctl rules` should communicate.
@@ -221,12 +225,24 @@ def _build_registry() -> dict[str, RuleSpec]:
221
225
  RULES: dict[str, RuleSpec] = _build_registry()
222
226
 
223
227
 
224
- def family_tokens() -> list[str]:
225
- """All family tokens that have at least one rule, in alphabetical order."""
226
- return sorted({spec.family for spec in RULES.values()})
228
+ def family_tokens(*, include_hidden: bool = False) -> list[str]:
229
+ """All family tokens that have at least one rule, in alphabetical order.
230
+
231
+ Hidden families (currently: INTERNAL) are omitted unless explicitly
232
+ requested. They still resolve through `RULES[…]` so explicit filtering
233
+ by `proofctl rules --family INTERNAL` continues to work.
234
+ """
235
+ toks = {spec.family for spec in RULES.values()}
236
+ if not include_hidden:
237
+ toks -= _HIDDEN_FAMILIES
238
+ return sorted(toks)
239
+
240
+
241
+ def is_hidden_family(token: str) -> bool:
242
+ return token in _HIDDEN_FAMILIES
227
243
 
228
244
 
229
245
  def families_help_string() -> str:
230
- """Human-readable list of families for `--families` help text."""
246
+ """Human-readable list of user-facing families for `--families` help."""
231
247
  parts = [f"{tok} ({FAMILY_LABELS[tok]})" for tok in family_tokens() if tok in FAMILY_LABELS]
232
248
  return "Comma-separated rule families: " + ", ".join(parts) + ". Default: all."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofctl
3
- Version: 0.1.7
3
+ Version: 0.1.9
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
@@ -43,8 +43,10 @@ tests/test_fixer.py
43
43
  tests/test_imports.py
44
44
  tests/test_leakage.py
45
45
  tests/test_llm.py
46
+ tests/test_local_packages.py
46
47
  tests/test_phase1_audit_fixes.py
47
48
  tests/test_phase2_audit_fixes.py
49
+ tests/test_phase3_audit_fixes.py
48
50
  tests/test_placeholders.py
49
51
  tests/test_quality.py
50
52
  tests/test_quality_ext.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofctl"
7
- version = "0.1.7"
7
+ version = "0.1.9"
8
8
  description = "Zero-dependency linter for Python, Terraform, Dockerfiles, Kubernetes, and GitHub Actions — catches AI slop and security misconfigurations pre-commit"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,175 @@
1
+ """Tests for `_local_packages` — deep-tree discovery of importable packages.
2
+
3
+ The original implementation only walked the project root and `src/`, which
4
+ caused PROOFCTL-I-001 to fire on legitimate private packages nested under
5
+ sub-trees (e.g. Airflow monorepos with `orchestration/dags/<pkg>/__init__.py`).
6
+
7
+ These tests pin down the new subtree-walking behaviour and the safety rails
8
+ (skip dirs, depth cap, symlink loop protection).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ from proofctl.checkers.imports import _local_packages, _PKG_SCAN_MAX_DEPTH
18
+
19
+
20
+ def _mkpkg(root: Path, *parts: str) -> Path:
21
+ """Create a Python package directory at root/parts/__init__.py."""
22
+ pkg_dir = root.joinpath(*parts)
23
+ pkg_dir.mkdir(parents=True, exist_ok=True)
24
+ (pkg_dir / "__init__.py").write_text("")
25
+ return pkg_dir
26
+
27
+
28
+ def _clear_cache():
29
+ """The function is lru_cache-wrapped; clear between tests so tmp_path
30
+ isolation isn't defeated by accidental cache hits across runs."""
31
+ _local_packages.cache_clear()
32
+
33
+
34
+ def test_top_level_package_discovered(tmp_path):
35
+ _clear_cache()
36
+ _mkpkg(tmp_path, "myapp")
37
+ assert "myapp" in _local_packages(tmp_path)
38
+
39
+
40
+ def test_src_layout_package_discovered(tmp_path):
41
+ _clear_cache()
42
+ _mkpkg(tmp_path, "src", "myapp")
43
+ assert "myapp" in _local_packages(tmp_path)
44
+
45
+
46
+ def test_deeply_nested_package_discovered(tmp_path):
47
+ """Regression: data_team layout — orchestration/dags/<pkg>/__init__.py."""
48
+ _clear_cache()
49
+ _mkpkg(tmp_path, "orchestration", "dags", "airflow_ip_taskgroups")
50
+ _mkpkg(tmp_path, "orchestration", "dags", "reports_delivery")
51
+ _mkpkg(tmp_path, "orchestration", "dags", "ip_run_output")
52
+ pkgs = _local_packages(tmp_path)
53
+ assert "airflow_ip_taskgroups" in pkgs
54
+ assert "reports_delivery" in pkgs
55
+ assert "ip_run_output" in pkgs
56
+
57
+
58
+ def test_top_level_module_discovered(tmp_path):
59
+ _clear_cache()
60
+ (tmp_path / "myscript.py").write_text("x = 1\n")
61
+ assert "myscript" in _local_packages(tmp_path)
62
+
63
+
64
+ def test_init_only_counts_at_top_level(tmp_path):
65
+ """A bare .py at depth>0 is a module, not a package — don't add its name.
66
+ The previous code also followed this rule; preserving it."""
67
+ _clear_cache()
68
+ sub = tmp_path / "subdir"
69
+ sub.mkdir()
70
+ (sub / "helper.py").write_text("x = 1\n")
71
+ assert "helper" not in _local_packages(tmp_path)
72
+
73
+
74
+ # ── safety rails ──────────────────────────────────────────────────────────────
75
+
76
+ def test_venv_packages_ignored(tmp_path):
77
+ """A package vendored under .venv must not be treated as first-party."""
78
+ _clear_cache()
79
+ _mkpkg(tmp_path, ".venv", "lib", "python3.11", "site-packages", "requests")
80
+ assert "requests" not in _local_packages(tmp_path)
81
+
82
+
83
+ def test_node_modules_ignored(tmp_path):
84
+ _clear_cache()
85
+ _mkpkg(tmp_path, "node_modules", "some_python_pkg")
86
+ assert "some_python_pkg" not in _local_packages(tmp_path)
87
+
88
+
89
+ def test_pycache_ignored(tmp_path):
90
+ _clear_cache()
91
+ _mkpkg(tmp_path, "__pycache__", "fakepkg")
92
+ assert "fakepkg" not in _local_packages(tmp_path)
93
+
94
+
95
+ def test_egg_info_ignored(tmp_path):
96
+ _clear_cache()
97
+ pkg = tmp_path / "proofctl.egg-info"
98
+ pkg.mkdir()
99
+ (pkg / "__init__.py").write_text("")
100
+ assert "proofctl.egg-info" not in _local_packages(tmp_path)
101
+
102
+
103
+ def test_hidden_directories_skipped(tmp_path):
104
+ """Hidden dirs other than .github are caches/IDE state — skip."""
105
+ _clear_cache()
106
+ _mkpkg(tmp_path, ".idea", "fakepkg")
107
+ _mkpkg(tmp_path, ".vscode", "fakepkg2")
108
+ pkgs = _local_packages(tmp_path)
109
+ assert "fakepkg" not in pkgs
110
+ assert "fakepkg2" not in pkgs
111
+
112
+
113
+ def test_symlink_loop_does_not_hang(tmp_path):
114
+ """A symlink that points back into the tree must not trigger infinite recursion."""
115
+ _clear_cache()
116
+ _mkpkg(tmp_path, "real_pkg")
117
+ # Create a symlink: tmp_path/loop -> tmp_path
118
+ loop = tmp_path / "loop"
119
+ try:
120
+ os.symlink(tmp_path, loop)
121
+ except (OSError, NotImplementedError):
122
+ pytest.skip("symlinks not supported on this platform")
123
+ # Should complete without recursion error.
124
+ pkgs = _local_packages(tmp_path)
125
+ assert "real_pkg" in pkgs
126
+
127
+
128
+ def test_depth_cap_does_not_explode(tmp_path):
129
+ """Going deeper than _PKG_SCAN_MAX_DEPTH is silently truncated, not crashed."""
130
+ _clear_cache()
131
+ deep = tmp_path
132
+ for i in range(_PKG_SCAN_MAX_DEPTH + 4):
133
+ deep = deep / f"d{i}"
134
+ deep.mkdir(parents=True)
135
+ (deep / "__init__.py").write_text("")
136
+ # Doesn't crash. Package at exactly depth N may or may not be found
137
+ # (since it's beyond the cap), but the call must return.
138
+ pkgs = _local_packages(tmp_path)
139
+ assert isinstance(pkgs, frozenset)
140
+
141
+
142
+ def test_cache_avoids_repeated_walks(tmp_path):
143
+ """Calling twice with the same root must hit the lru_cache."""
144
+ _clear_cache()
145
+ _mkpkg(tmp_path, "myapp")
146
+ pkgs1 = _local_packages(tmp_path)
147
+ pkgs2 = _local_packages(tmp_path)
148
+ assert pkgs1 is pkgs2 # identity, not just equality — confirms cache hit
149
+
150
+
151
+ # ── integration with PROOFCTL-I-001 ───────────────────────────────────────────
152
+
153
+ def test_i001_no_false_positive_on_nested_local_package(tmp_path):
154
+ """End-to-end: a file importing a deeply-nested local package must not
155
+ trigger PROOFCTL-I-001 even with PyPI lookups disabled."""
156
+ _clear_cache()
157
+ # Layout mirrors data_team:
158
+ # tmp_path/pyproject.toml
159
+ # tmp_path/orchestration/dags/airflow_ip_taskgroups/__init__.py
160
+ # tmp_path/orchestration/dags/hdm.py (imports airflow_ip_taskgroups)
161
+ (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
162
+ _mkpkg(tmp_path, "orchestration", "dags", "airflow_ip_taskgroups")
163
+ importer = tmp_path / "orchestration" / "dags" / "hdm.py"
164
+ importer.write_text("from airflow_ip_taskgroups import build\n")
165
+
166
+ from proofctl.engine import run_check
167
+ from proofctl.config import ProofctlConfig
168
+
169
+ cfg = ProofctlConfig()
170
+ cfg.check_pypi = False # don't even try PyPI; relying on local discovery
171
+ findings = run_check(tmp_path, cfg, families=["I"])
172
+ rule_ids = [f.rule_id for f in findings]
173
+ assert "PROOFCTL-I-001" not in rule_ids, (
174
+ f"Expected I-001 NOT to fire on local nested package; got: {rule_ids}"
175
+ )
@@ -0,0 +1,277 @@
1
+ """Regression tests for AUDIT_AND_ROADMAP Phase 3 (HCL parser hardening).
2
+
3
+ Covers:
4
+ C-10: even-backslash check for escaped-quote handling in `_strip_comment`
5
+ C-11: strict heredoc end-marker matching (no `rstrip("-~")`)
6
+ C-12: brace counter respects string state and `${}` interpolations
7
+ C-13: `_t004` port parse handles variable references without crashing
8
+ HCL HIGH: `_v004` URL-decode, `_g004` canonical default-SA, `_ai002` skips comments
9
+ HCL MEDIUM: `_has_lifecycle_attr` strips comments
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ from proofctl.checkers.hcl_utils import (
18
+ HclBlock,
19
+ _brace_delta,
20
+ _is_escaped,
21
+ _strip_comment,
22
+ parse_blocks,
23
+ )
24
+ from proofctl.checkers.terraform import TerraformChecker
25
+ from proofctl.config import ProofctlConfig
26
+ from proofctl.engine import run_check
27
+
28
+
29
+ # ── C-10: escaped-quote handling ──────────────────────────────────────────────
30
+
31
+ def test_c10_is_escaped_recognises_odd_run():
32
+ # \" — single backslash before quote → escaped
33
+ assert _is_escaped(r'"\""', 2) is True
34
+
35
+
36
+ def test_c10_is_escaped_recognises_even_run():
37
+ # \\" — escaped backslash followed by an unescaped quote → not escaped
38
+ line = r'"\\""' # opens, escaped backslash, closes, opens again
39
+ # Index 3 is the quote that closes the string after the escaped backslash
40
+ assert _is_escaped(line, 3) is False
41
+
42
+
43
+ def test_c10_strip_comment_does_not_eat_string_with_escaped_backslash():
44
+ """The previous one-char lookback treated the closing quote in
45
+ `password = "my\\\\\\"secret#actualpassword"` as escaped, so `#` looked
46
+ like the start of a comment and tokenisation broke. The fix counts the
47
+ run of backslashes."""
48
+ line = 'password = "my\\\\\\"secret#actualpassword" # real comment'
49
+ stripped = _strip_comment(line)
50
+ # The `#` inside the string must NOT be treated as a comment start.
51
+ assert "secret#actualpassword" in stripped
52
+ # The trailing real comment IS stripped.
53
+ assert "real comment" not in stripped
54
+
55
+
56
+ def test_c10_strip_comment_simple_escaped_quote_still_works():
57
+ """Sanity: a single `\\"` inside a string keeps the string open."""
58
+ line = 'msg = "say \\"hi\\" please" # comment'
59
+ stripped = _strip_comment(line)
60
+ assert "comment" not in stripped
61
+ assert 'say \\"hi\\"' in stripped
62
+
63
+
64
+ # ── C-11: heredoc end-marker matching ─────────────────────────────────────────
65
+
66
+ def test_c11_heredoc_strict_end_only_label_alone_terminates():
67
+ """A line `EOF~~~` must NOT terminate a heredoc opened with `<<EOF`.
68
+ The previous `rstrip("-~")` accepted any line whose stripped suffix
69
+ matched the label."""
70
+ src = '''locals {
71
+ policy = <<EOF
72
+ {
73
+ "trailing_tilde_in_body": "EOF~~~"
74
+ }
75
+ EOF
76
+ }
77
+ resource "aws_s3_bucket" "after" {
78
+ bucket = "test"
79
+ }
80
+ '''
81
+ blocks = parse_blocks(src)
82
+ # If the heredoc was prematurely closed at `EOF~~~`, the brace counter
83
+ # would have desynced and we'd lose either the `locals` or the
84
+ # `aws_s3_bucket` block.
85
+ kinds = sorted((b.kind, b.label1) for b in blocks)
86
+ assert ("locals", "") in kinds
87
+ assert ("resource", "aws_s3_bucket") in kinds
88
+
89
+
90
+ def test_c11_indented_heredoc_end_with_leading_whitespace_works():
91
+ """`<<-EOF` form allows the end label to be indented; we must still close it."""
92
+ src = '''locals {
93
+ policy = <<-EOF
94
+ {"hello": "world"}
95
+ EOF
96
+ }
97
+ resource "aws_s3_bucket" "after" {
98
+ bucket = "x"
99
+ }
100
+ '''
101
+ blocks = parse_blocks(src)
102
+ kinds = sorted((b.kind, b.label1) for b in blocks)
103
+ assert ("resource", "aws_s3_bucket") in kinds
104
+
105
+
106
+ # ── C-12: brace counter handles ${} interpolations ────────────────────────────
107
+
108
+ @pytest.mark.parametrize("line,expected", [
109
+ ('a = "x${count.index}y"', 0), # interpolation inside string
110
+ ('tags = { Name = "x${i}" }', 0), # block with interpolation
111
+ ('description = "value with } brace"', 0),
112
+ ('foo = bar', 0),
113
+ ('resource "x" "y" {', 1),
114
+ ('}', -1),
115
+ ('resource "x" "y" { foo = "bar" }', 0), # single-line block
116
+ ])
117
+ def test_c12_brace_delta(line: str, expected: int):
118
+ assert _brace_delta(line) == expected
119
+
120
+
121
+ def test_c12_block_parser_handles_interpolation_in_tags():
122
+ """A nested map with string interpolations was the audit's worry case."""
123
+ src = '''resource "aws_instance" "x" {
124
+ tags = {
125
+ Name = "ec2-${var.env}-app"
126
+ Env = var.env
127
+ }
128
+ }
129
+ '''
130
+ blocks = parse_blocks(src)
131
+ assert len(blocks) == 1
132
+ assert blocks[0].kind == "resource"
133
+ assert blocks[0].label2 == "x"
134
+
135
+
136
+ # ── C-13: _t004 port parse stays defensive on variable refs ───────────────────
137
+
138
+ def test_c13_t004_no_crash_on_variable_port(tmp_path):
139
+ """`from_port = var.ssh_port` must not crash _t004; the rule should
140
+ silently skip blocks where ports aren't literal."""
141
+ (tmp_path / "main.tf").write_text(
142
+ 'resource "aws_security_group_rule" "open" {\n'
143
+ ' type = "ingress"\n'
144
+ ' from_port = var.ssh_port\n'
145
+ ' to_port = var.ssh_port\n'
146
+ ' cidr_blocks = ["0.0.0.0/0"]\n'
147
+ '}\n'
148
+ )
149
+ findings = run_check(tmp_path, ProofctlConfig(), families=["TF"])
150
+ # No PROOFCTL-INTERNAL-001 (would mean a checker crashed).
151
+ assert all(f.rule_id != "PROOFCTL-INTERNAL-001" for f in findings), (
152
+ f"engine crashed: {[(f.rule_id, f.message) for f in findings if f.rule_id == 'PROOFCTL-INTERNAL-001']}"
153
+ )
154
+
155
+
156
+ def test_c13_t004_still_fires_on_literal_sensitive_port(tmp_path):
157
+ """Sanity: a literal sensitive port + 0.0.0.0/0 still triggers T-004."""
158
+ (tmp_path / "main.tf").write_text(
159
+ 'resource "aws_security_group_rule" "open" {\n'
160
+ ' type = "ingress"\n'
161
+ ' from_port = 22\n'
162
+ ' to_port = 22\n'
163
+ ' cidr_blocks = ["0.0.0.0/0"]\n'
164
+ '}\n'
165
+ )
166
+ findings = run_check(tmp_path, ProofctlConfig(), families=["TF"])
167
+ assert any(f.rule_id == "PROOFCTL-TF-T004" for f in findings)
168
+
169
+
170
+ # ── HCL HIGH: _v004 URL-decode ────────────────────────────────────────────────
171
+
172
+ def test_v004_percent_encoded_semver_recognised_as_immutable(tmp_path):
173
+ """`?ref=v1%2E2%2E3` must decode to `v1.2.3` and pass the semver check."""
174
+ (tmp_path / "main.tf").write_text(
175
+ 'module "x" {\n'
176
+ ' source = "git::https://github.com/foo/bar.git?ref=v1%2E2%2E3"\n'
177
+ '}\n'
178
+ )
179
+ findings = run_check(tmp_path, ProofctlConfig(), families=["TF"])
180
+ assert all(f.rule_id != "PROOFCTL-TF-V004" for f in findings), (
181
+ f"V-004 should accept percent-encoded semver; got: {[(f.rule_id, f.message) for f in findings]}"
182
+ )
183
+
184
+
185
+ def test_v004_branch_ref_still_flagged(tmp_path):
186
+ """A branch ref (mutable) is still flagged."""
187
+ (tmp_path / "main.tf").write_text(
188
+ 'module "x" {\n'
189
+ ' source = "git::https://github.com/foo/bar.git?ref=main"\n'
190
+ '}\n'
191
+ )
192
+ findings = run_check(tmp_path, ProofctlConfig(), families=["TF"])
193
+ assert any(f.rule_id == "PROOFCTL-TF-V004" for f in findings)
194
+
195
+
196
+ # ── HCL HIGH: _g004 canonical default-SA suffix ───────────────────────────────
197
+
198
+ def test_g004_canonical_default_sa_email_recognised(tmp_path):
199
+ """The fully-qualified default-SA email
200
+ `<projnum>-compute@developer.gserviceaccount.com` must trigger G-004
201
+ when paired with cloud-platform scope."""
202
+ (tmp_path / "main.tf").write_text(
203
+ 'resource "google_compute_instance" "x" {\n'
204
+ ' service_account {\n'
205
+ ' email = "123456-compute@developer.gserviceaccount.com"\n'
206
+ ' scopes = ["cloud-platform"]\n'
207
+ ' }\n'
208
+ '}\n'
209
+ )
210
+ findings = run_check(tmp_path, ProofctlConfig(), families=["TF"])
211
+ assert any(f.rule_id == "PROOFCTL-TF-G004" for f in findings)
212
+
213
+
214
+ def test_g004_dedicated_sa_does_not_trigger(tmp_path):
215
+ """A non-default SA email must not fire G-004."""
216
+ (tmp_path / "main.tf").write_text(
217
+ 'resource "google_compute_instance" "x" {\n'
218
+ ' service_account {\n'
219
+ ' email = "my-app-sa@my-project.iam.gserviceaccount.com"\n'
220
+ ' scopes = ["cloud-platform"]\n'
221
+ ' }\n'
222
+ '}\n'
223
+ )
224
+ findings = run_check(tmp_path, ProofctlConfig(), families=["TF"])
225
+ assert all(f.rule_id != "PROOFCTL-TF-G004" for f in findings)
226
+
227
+
228
+ # ── HCL HIGH: _ai002 ignores comment lines ────────────────────────────────────
229
+
230
+ def test_ai002_ignores_aws_region_in_comment(tmp_path):
231
+ """A `# was us-east-1` migration note must not trigger AI-002 inside a
232
+ GCP-context file."""
233
+ (tmp_path / "main.tf").write_text(
234
+ '# Migrated from us-east-1 to us-central1 on 2026-01-15\n'
235
+ 'resource "google_compute_instance" "x" {\n'
236
+ ' zone = "us-central1-a"\n'
237
+ '}\n'
238
+ )
239
+ findings = run_check(tmp_path, ProofctlConfig(), families=["TF"])
240
+ assert all(f.rule_id != "PROOFCTL-TF-AI-002" for f in findings), (
241
+ f"AI-002 should ignore comments; got: {[(f.rule_id, f.message) for f in findings]}"
242
+ )
243
+
244
+
245
+ def test_ai002_still_fires_on_real_aws_region_in_gcp_file(tmp_path):
246
+ """Sanity: a non-comment AWS region in a GCP file still fires AI-002."""
247
+ (tmp_path / "main.tf").write_text(
248
+ 'resource "google_compute_instance" "x" {\n'
249
+ ' zone = "us-east-1"\n'
250
+ '}\n'
251
+ )
252
+ findings = run_check(tmp_path, ProofctlConfig(), families=["TF"])
253
+ assert any(f.rule_id == "PROOFCTL-TF-AI-002" for f in findings)
254
+
255
+
256
+ # ── HCL MEDIUM: _has_lifecycle_attr strips comments ───────────────────────────
257
+
258
+ def test_has_lifecycle_attr_ignores_brace_in_comment(tmp_path):
259
+ """A `}` inside a comment must not be counted as ending the lifecycle block."""
260
+ (tmp_path / "main.tf").write_text(
261
+ 'resource "aws_s3_bucket" "x" {\n'
262
+ ' bucket = "stateful"\n'
263
+ ' lifecycle {\n'
264
+ ' # the closing brace below is real, this comment has } in it\n'
265
+ ' prevent_destroy = true\n'
266
+ ' }\n'
267
+ '}\n'
268
+ )
269
+ findings = run_check(tmp_path, ProofctlConfig(), families=["TF"])
270
+ # If the lifecycle scope was prematurely ended by the `}` in the comment,
271
+ # `_has_lifecycle_attr(b, "prevent_destroy")` would have returned False
272
+ # and a "stateful resource without prevent_destroy" finding would surface.
273
+ # Confirm we DON'T see that finding.
274
+ msgs = [f.message for f in findings]
275
+ assert not any("prevent_destroy" in m and "stateful" in m.lower() for m in msgs), (
276
+ f"unexpected stateful finding: {msgs}"
277
+ )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes