proofctl 0.1.8__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.8 → proofctl-0.1.9}/PKG-INFO +1 -1
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/hcl_utils.py +24 -2
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/terraform.py +68 -23
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl.egg-info/PKG-INFO +1 -1
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl.egg-info/SOURCES.txt +1 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/pyproject.toml +1 -1
- proofctl-0.1.9/tests/test_phase3_audit_fixes.py +277 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/LICENSE +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/README.md +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/__init__.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/_globmatch.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/baseline.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/__init__.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/base.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/dockerfile.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/imports.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/leakage.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/llm_integration.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/placeholders.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/quality.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/security.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/shell_checker.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/terragrunt.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/variants.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/checkers/yaml_checker.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/cli.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/config.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/engine.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/fixer.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/models.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/registry.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/reporters/__init__.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/reporters/html_reporter.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/reporters/json_reporter.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl/reporters/terminal.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl.egg-info/dependency_links.txt +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl.egg-info/entry_points.txt +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl.egg-info/requires.txt +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/proofctl.egg-info/top_level.txt +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/setup.cfg +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_baseline.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_config_proofctl.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_dockerfile.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_engine.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_fixer.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_imports.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_leakage.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_llm.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_local_packages.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_phase1_audit_fixes.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_phase2_audit_fixes.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_placeholders.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_quality.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_quality_ext.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_security.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_security_ext.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_suppression.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_terraform.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_terraform_ext.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_terragrunt.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_variants.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.9}/tests/test_yaml.py +0 -0
- {proofctl-0.1.8 → 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)
|
|
@@ -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",
|
|
@@ -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
|
|
@@ -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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|