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.
- {proofctl-0.1.7 → proofctl-0.1.9}/PKG-INFO +1 -1
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/hcl_utils.py +24 -2
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/imports.py +70 -17
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/terraform.py +68 -23
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/cli.py +5 -1
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/registry.py +20 -4
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/PKG-INFO +1 -1
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/SOURCES.txt +2 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/pyproject.toml +1 -1
- proofctl-0.1.9/tests/test_local_packages.py +175 -0
- proofctl-0.1.9/tests/test_phase3_audit_fixes.py +277 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/LICENSE +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/README.md +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/__init__.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/_globmatch.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/baseline.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/__init__.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/base.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/dockerfile.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/leakage.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/llm_integration.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/placeholders.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/quality.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/security.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/shell_checker.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/terragrunt.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/variants.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/checkers/yaml_checker.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/config.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/engine.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/fixer.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/models.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/reporters/__init__.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/reporters/html_reporter.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/reporters/json_reporter.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl/reporters/terminal.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/dependency_links.txt +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/entry_points.txt +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/requires.txt +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/proofctl.egg-info/top_level.txt +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/setup.cfg +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_baseline.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_config_proofctl.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_dockerfile.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_engine.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_fixer.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_imports.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_leakage.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_llm.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_phase1_audit_fixes.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_phase2_audit_fixes.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_placeholders.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_quality.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_quality_ext.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_security.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_security_ext.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_suppression.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_terraform.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_terraform_ext.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_terragrunt.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_variants.py +0 -0
- {proofctl-0.1.7 → proofctl-0.1.9}/tests/test_yaml.py +0 -0
- {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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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(
|
|
285
|
+
if attr_re.search(stripped):
|
|
279
286
|
return True
|
|
280
287
|
continue
|
|
281
288
|
if in_lifecycle:
|
|
282
|
-
depth +=
|
|
283
|
-
if attr_re.search(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
797
|
-
if b.contains(r'service_account') and b.contains(r'cloud-platform'):
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
+
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|