proofctl 0.4.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {proofctl-0.4.2/proofctl.egg-info → proofctl-0.5.0}/PKG-INFO +5 -5
  2. {proofctl-0.4.2 → proofctl-0.5.0}/README.md +4 -4
  3. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/terraform.py +549 -73
  4. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/engine.py +30 -0
  5. {proofctl-0.4.2 → proofctl-0.5.0/proofctl.egg-info}/PKG-INFO +5 -5
  6. {proofctl-0.4.2 → proofctl-0.5.0}/pyproject.toml +1 -1
  7. {proofctl-0.4.2 → proofctl-0.5.0}/LICENSE +0 -0
  8. {proofctl-0.4.2 → proofctl-0.5.0}/MANIFEST.in +0 -0
  9. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/__init__.py +0 -0
  10. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/_globmatch.py +0 -0
  11. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/baseline.py +0 -0
  12. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/__init__.py +0 -0
  13. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/base.py +0 -0
  14. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/dockerfile.py +0 -0
  15. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/hcl_utils.py +0 -0
  16. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/imports.py +0 -0
  17. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/leakage.py +0 -0
  18. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/llm_integration.py +0 -0
  19. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/placeholders.py +0 -0
  20. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/quality.py +0 -0
  21. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/secrets.py +0 -0
  22. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/security.py +0 -0
  23. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/shell_checker.py +0 -0
  24. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/terragrunt.py +0 -0
  25. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/variants.py +0 -0
  26. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/yaml_checker.py +0 -0
  27. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/cli.py +0 -0
  28. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/config.py +0 -0
  29. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/fixer.py +0 -0
  30. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/models.py +0 -0
  31. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/registry.py +0 -0
  32. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/reporters/__init__.py +0 -0
  33. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/reporters/html_reporter.py +0 -0
  34. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/reporters/json_reporter.py +0 -0
  35. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/reporters/terminal.py +0 -0
  36. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl.egg-info/SOURCES.txt +0 -0
  37. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl.egg-info/dependency_links.txt +0 -0
  38. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl.egg-info/entry_points.txt +0 -0
  39. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl.egg-info/requires.txt +0 -0
  40. {proofctl-0.4.2 → proofctl-0.5.0}/proofctl.egg-info/top_level.txt +0 -0
  41. {proofctl-0.4.2 → proofctl-0.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofctl
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Zero-dependency linter for Python, Terraform, Dockerfiles, Kubernetes, and GitHub Actions — catches AI slop and security misconfigurations pre-commit
5
5
  Author-email: Kolawolu Odunola <kolawolu.o@gmail.com>
6
6
  License: MIT
@@ -28,7 +28,7 @@ Dynamic: license-file
28
28
 
29
29
  # proofctl
30
30
 
31
- A linter built for the DevOps stack — Python, Terraform, Kubernetes, Helm, Dockerfiles, GitHub Actions, Shell — that catches the specific kinds of mistakes AI coding tools introduce. **264 rules**, zero dependencies, runs in under a second.
31
+ A linter built for the DevOps stack — Python, Terraform, Kubernetes, Helm, Dockerfiles, GitHub Actions, Shell — that catches the specific kinds of mistakes AI coding tools introduce. **269 rules**, zero dependencies, runs in under a second.
32
32
 
33
33
  ```
34
34
  proofctl check .
@@ -165,7 +165,7 @@ git commit -m "chore: add proofctl baseline"
165
165
 
166
166
  ## Rules
167
167
 
168
- 264 rules across 14 families. Run `proofctl rules` after install to enumerate.
168
+ 269 rules across 14 families. Run `proofctl rules` after install to enumerate.
169
169
 
170
170
  | Family | Count | What it covers |
171
171
  |---|---|---|
@@ -173,9 +173,9 @@ git commit -m "chore: add proofctl baseline"
173
173
  | Python — Security (`S`) | 24 | OWASP Top-10 + CWE coverage: SQLi, command injection, weak crypto, JWT bypass, path traversal, XSS, SSRF, file upload, CSRF, missing authz, XPath injection, open redirect, TLS bypass |
174
174
  | Secrets (`SECRET`) | 13 | AWS / GCP / GitHub / Slack / Stripe / private keys / JWTs / hardcoded passwords / K8s Secret YAML / connection strings with embedded creds |
175
175
  | Python — Other | 12 | Placeholders, leakage (cross-language idioms, PII), import hallucination, method complexity, variant detection, LLM guardrails (OWASP LLM Top-10) |
176
- | Terraform — General + AI (`TF-T`, `TF-AI`) | 26 | required_version, sensitive outputs, IAM wildcards, mutable git refs, deprecated resources, AWS-region-in-GCP, placeholder values |
176
+ | Terraform — General + AI (`TF-T`, `TF-AI`) | 29 | required_version, sensitive outputs, IAM wildcards, mutable git refs, deprecated resources, AWS-region-in-GCP, placeholder values, image-family pinning, sensitive vars in VM metadata, unused variable declarations |
177
177
  | Terraform — AWS (`TF-A`) | 40 | EC2 IMDSv2, S3 public-access-block, EBS encryption, VPC flow logs, KMS rotation, RDS hardening, SG / NACL / EKS / DynamoDB / ElastiCache / CloudTrail / IAM |
178
- | Terraform — GCP (`TF-G`) | 50 | GKE hardening, GCS public-access prevention, IAM roles, KMS rotation, Cloud Function/Run public, BigQuery, Pub/Sub CMEK, Cloud SQL, firewall ingress |
178
+ | Terraform — GCP (`TF-G`) | 52 | GKE hardening, GCS public-access prevention, IAM roles, KMS rotation, Cloud Function/Run public, BigQuery, Pub/Sub CMEK, Cloud SQL (TLS ssl_mode aware), firewall ingress, compute access scopes, Secret Manager CMEK consistency |
179
179
  | Terraform — Azure (`TF-AZ`) | 14 | Storage public blob, SQL public network, AKS RBAC, Key Vault purge protection, NSG SSH/RDP, managed disk CMK |
180
180
  | Terraform — Mechanics + Lifecycle (`TF-M`, `TF-V`) | 12 | SG inline mixing, `force_destroy`, mutable module refs, lifecycle antipatterns |
181
181
  | Terragrunt (`TG`) | 6 | HCL inputs, mock_outputs, remote state encryption |
@@ -1,6 +1,6 @@
1
1
  # proofctl
2
2
 
3
- A linter built for the DevOps stack — Python, Terraform, Kubernetes, Helm, Dockerfiles, GitHub Actions, Shell — that catches the specific kinds of mistakes AI coding tools introduce. **264 rules**, zero dependencies, runs in under a second.
3
+ A linter built for the DevOps stack — Python, Terraform, Kubernetes, Helm, Dockerfiles, GitHub Actions, Shell — that catches the specific kinds of mistakes AI coding tools introduce. **269 rules**, zero dependencies, runs in under a second.
4
4
 
5
5
  ```
6
6
  proofctl check .
@@ -137,7 +137,7 @@ git commit -m "chore: add proofctl baseline"
137
137
 
138
138
  ## Rules
139
139
 
140
- 264 rules across 14 families. Run `proofctl rules` after install to enumerate.
140
+ 269 rules across 14 families. Run `proofctl rules` after install to enumerate.
141
141
 
142
142
  | Family | Count | What it covers |
143
143
  |---|---|---|
@@ -145,9 +145,9 @@ git commit -m "chore: add proofctl baseline"
145
145
  | Python — Security (`S`) | 24 | OWASP Top-10 + CWE coverage: SQLi, command injection, weak crypto, JWT bypass, path traversal, XSS, SSRF, file upload, CSRF, missing authz, XPath injection, open redirect, TLS bypass |
146
146
  | Secrets (`SECRET`) | 13 | AWS / GCP / GitHub / Slack / Stripe / private keys / JWTs / hardcoded passwords / K8s Secret YAML / connection strings with embedded creds |
147
147
  | Python — Other | 12 | Placeholders, leakage (cross-language idioms, PII), import hallucination, method complexity, variant detection, LLM guardrails (OWASP LLM Top-10) |
148
- | Terraform — General + AI (`TF-T`, `TF-AI`) | 26 | required_version, sensitive outputs, IAM wildcards, mutable git refs, deprecated resources, AWS-region-in-GCP, placeholder values |
148
+ | Terraform — General + AI (`TF-T`, `TF-AI`) | 29 | required_version, sensitive outputs, IAM wildcards, mutable git refs, deprecated resources, AWS-region-in-GCP, placeholder values, image-family pinning, sensitive vars in VM metadata, unused variable declarations |
149
149
  | Terraform — AWS (`TF-A`) | 40 | EC2 IMDSv2, S3 public-access-block, EBS encryption, VPC flow logs, KMS rotation, RDS hardening, SG / NACL / EKS / DynamoDB / ElastiCache / CloudTrail / IAM |
150
- | Terraform — GCP (`TF-G`) | 50 | GKE hardening, GCS public-access prevention, IAM roles, KMS rotation, Cloud Function/Run public, BigQuery, Pub/Sub CMEK, Cloud SQL, firewall ingress |
150
+ | Terraform — GCP (`TF-G`) | 52 | GKE hardening, GCS public-access prevention, IAM roles, KMS rotation, Cloud Function/Run public, BigQuery, Pub/Sub CMEK, Cloud SQL (TLS ssl_mode aware), firewall ingress, compute access scopes, Secret Manager CMEK consistency |
151
151
  | Terraform — Azure (`TF-AZ`) | 14 | Storage public blob, SQL public network, AKS RBAC, Key Vault purge protection, NSG SSH/RDP, managed disk CMK |
152
152
  | Terraform — Mechanics + Lifecycle (`TF-M`, `TF-V`) | 12 | SG inline mixing, `force_destroy`, mutable module refs, lifecycle antipatterns |
153
153
  | Terragrunt (`TG`) | 6 | HCL inputs, mock_outputs, remote state encryption |
@@ -53,44 +53,54 @@ _FIREWALL_RESOURCES = frozenset({
53
53
  })
54
54
 
55
55
 
56
- def _aws_sg_has_open_ingress(raw_lines: list[str]) -> bool:
57
- """Walk an aws_security_group block's lines and return True iff
58
- 0.0.0.0/0 appears inside an ``ingress { ... }`` sub-block.
59
-
60
- aws_security_group has both inline ``ingress`` and ``egress`` blocks;
61
- open egress is normal and should not trigger T-004. Open ingress is
62
- the actual finding.
56
+ def _aws_sg_open_ingress_lines(raw_lines: list[str]) -> list[str]:
57
+ """Return the lines from every ``ingress { ... }`` sub-block in an
58
+ ``aws_security_group`` body that contains 0.0.0.0/0.
59
+
60
+ ``aws_security_group`` declares ``ingress`` and ``egress`` as inline
61
+ sub-blocks. Open egress is normal (workloads need outbound), so T-004
62
+ must ignore the egress block entirely — including its ``protocol``
63
+ and port attributes. This helper returns only the lines from
64
+ qualifying ingress sub-blocks; the caller scopes port / all-traffic
65
+ checks to those lines.
63
66
  """
64
- depth = 0
65
- in_ingress = False
66
- in_egress = False
67
- block_depth = 0
68
67
  sub_re = re.compile(r"^\s*(ingress|egress)\s*\{")
68
+ in_kind: str | None = None
69
+ block_depth = 0
70
+ buffer: list[str] = []
71
+ result: list[str] = []
72
+ bare_root_lines: list[str] = []
73
+
69
74
  for line in raw_lines:
70
- if not in_ingress and not in_egress:
75
+ if in_kind is None:
71
76
  m = sub_re.match(line)
72
77
  if m:
73
- if m.group(1) == "ingress":
74
- in_ingress = True
75
- else:
76
- in_egress = True
78
+ in_kind = m.group(1)
77
79
  block_depth = line.count("{") - line.count("}")
80
+ buffer = [line]
78
81
  continue
79
- if in_ingress or in_egress:
80
- block_depth += line.count("{") - line.count("}")
81
- if in_ingress and re.search(r'"0\.0\.0\.0/0"', line):
82
- return True
83
- if block_depth <= 0:
84
- in_ingress = False
85
- in_egress = False
86
- block_depth = 0
82
+ # Bare 0.0.0.0/0 at resource level (rare) — collect separately.
83
+ if re.search(r'"0\.0\.0\.0/0"', line):
84
+ bare_root_lines.append(line)
87
85
  continue
88
- # Outside any sub-block — bare 0.0.0.0/0 at the resource level
89
- # is uncommon for aws_security_group, but if it appears we
90
- # conservatively treat it as ingress-relevant.
91
- if re.search(r'"0\.0\.0\.0/0"', line):
92
- return True
93
- return False
86
+
87
+ block_depth += line.count("{") - line.count("}")
88
+ buffer.append(line)
89
+ if block_depth <= 0:
90
+ if in_kind == "ingress" and any(
91
+ re.search(r'"0\.0\.0\.0/0"', ln) for ln in buffer
92
+ ):
93
+ result.extend(buffer)
94
+ in_kind = None
95
+ block_depth = 0
96
+ buffer = []
97
+
98
+ return result + bare_root_lines
99
+
100
+
101
+ def _aws_sg_has_open_ingress(raw_lines: list[str]) -> bool:
102
+ """True iff 0.0.0.0/0 appears in any ingress sub-block of an aws_security_group."""
103
+ return bool(_aws_sg_open_ingress_lines(raw_lines))
94
104
 
95
105
  # Ports that should never accept 0.0.0.0/0
96
106
  _SENSITIVE_PORTS = frozenset({22, 23, 25, 3306, 3389, 5432, 5439, 6379, 27017})
@@ -246,7 +256,7 @@ class TerraformChecker(HclFileChecker):
246
256
  out += self._a008(path, blocks)
247
257
  out += self._a009(path, blocks)
248
258
  out += self._a010(path, blocks)
249
- out += self._a011(path, blocks)
259
+ # A-011 moved to TerraformDirChecker._a011_scan_level (fires once per scan).
250
260
  out += self._a012(path, blocks)
251
261
  out += self._a013(path, blocks)
252
262
  out += self._a014(path, blocks)
@@ -294,6 +304,7 @@ class TerraformChecker(HclFileChecker):
294
304
  out += self._g044(path, blocks)
295
305
  out += self._g045(path, blocks)
296
306
  out += self._g046(path, blocks)
307
+ out += self._g047(path, blocks)
297
308
 
298
309
  # Group AZ — Azure
299
310
  out += self._az001(path, blocks)
@@ -327,6 +338,8 @@ class TerraformChecker(HclFileChecker):
327
338
  out += self._ai009(path, lines)
328
339
  out += self._ai010(path, lines)
329
340
  out += self._ai011(path, lines)
341
+ out += self._ai012(path, blocks)
342
+ out += self._ai013(path, blocks)
330
343
 
331
344
  return out
332
345
 
@@ -501,25 +514,34 @@ class TerraformChecker(HclFileChecker):
501
514
  if not b.contains(r'0\.0\.0\.0/0'):
502
515
  continue
503
516
 
504
- # Skip egress-only rules.
517
+ # Decide which lines define the open ingress for port/protocol
518
+ # checks. For aws_security_group (which has inline ingress AND
519
+ # egress sub-blocks) we MUST scope to the ingress sub-block — the
520
+ # egress block's `protocol = "-1"` would otherwise spoof an
521
+ # all-traffic verdict.
522
+ scoped_lines: list[str]
523
+
505
524
  if b.label1 == "aws_security_group_rule":
506
525
  rule_type = (b.attr_value("type") or "").strip().strip('"').lower()
507
526
  if rule_type == "egress":
508
527
  continue
528
+ scoped_lines = list(b.raw_lines)
509
529
  elif b.label1 == "google_compute_firewall":
510
530
  direction = (b.attr_value("direction") or "").strip().strip('"').upper()
511
531
  if direction == "EGRESS":
512
532
  continue
533
+ scoped_lines = list(b.raw_lines)
513
534
  elif b.label1 == "aws_security_group":
514
- # 0.0.0.0/0 in an inline egress { } block is fine — only flag
515
- # if 0.0.0.0/0 appears inside an ingress block (or there is
516
- # no egress block at all).
517
- if not _aws_sg_has_open_ingress(b.raw_lines):
535
+ scoped_lines = _aws_sg_open_ingress_lines(b.raw_lines)
536
+ if not scoped_lines:
518
537
  continue
538
+ else:
539
+ scoped_lines = list(b.raw_lines)
519
540
 
520
- # Check if sensitive ports are involved
541
+ # Check if sensitive ports are involved (scoped to ingress only
542
+ # for aws_security_group).
521
543
  ports: set[int] = set()
522
- for line in b.raw_lines:
544
+ for line in scoped_lines:
523
545
  pm = port_re.search(line)
524
546
  if pm:
525
547
  try:
@@ -527,7 +549,7 @@ class TerraformChecker(HclFileChecker):
527
549
  except ValueError:
528
550
  pass
529
551
 
530
- if all_traffic_re.search('\n'.join(b.raw_lines)):
552
+ if all_traffic_re.search('\n'.join(scoped_lines)):
531
553
  # -1 or all traffic — always sensitive
532
554
  flagged_ports = "all traffic"
533
555
  elif ports & _SENSITIVE_PORTS:
@@ -568,14 +590,33 @@ class TerraformChecker(HclFileChecker):
568
590
  return out
569
591
 
570
592
  def _t006(self, path: Path, lines: list[str]) -> list[Finding]:
571
- """T-006 — IAM wildcard in actions or resources."""
572
- # Match: actions = ["*"] or resources = ["*"] (with possible extra items)
573
- pat = re.compile(r'(?:actions|not_actions|resources|not_resources)\s*=\s*\[[^\]]*"\*"')
593
+ """T-006 — IAM wildcard in actions or resources.
594
+
595
+ Matches both shapes:
596
+
597
+ 1. Terraform-native (``data "aws_iam_policy_document"``):
598
+ ``actions = ["*"]`` / ``resources = ["*"]``
599
+ 2. AWS JSON policy embedded via ``jsonencode({ Statement = [...] })``:
600
+ ``Action = "*"`` / ``Resource = "*"`` (bare string) and
601
+ ``Action = ["*"]`` / ``Resource = ["foo", "*"]`` (list form).
602
+
603
+ The capitalised keys + bare-string value were previously uncovered.
604
+ """
605
+ # Terraform-native form (lowercase, list-only).
606
+ tf_pat = re.compile(
607
+ r'(?:actions|not_actions|resources|not_resources)\s*=\s*\[[^\]]*"\*"'
608
+ )
609
+ # AWS JSON policy form (capitalised, accepts bare "*" or list).
610
+ json_pat = re.compile(
611
+ r'(?:Action|NotAction|Resource|NotResource)\s*=\s*'
612
+ r'(?:"\*"|\[[^\]]*"\*"[^\]]*\])'
613
+ )
574
614
  out = []
575
615
  for i, line in enumerate(lines, start=1):
576
- if line.strip().startswith("#"):
616
+ stripped = line.strip()
617
+ if stripped.startswith("#") or stripped.startswith("//"):
577
618
  continue
578
- if pat.search(line):
619
+ if tf_pat.search(line) or json_pat.search(line):
579
620
  out.append(self._f(
580
621
  path, i,
581
622
  "PROOFCTL-TF-T006", "IAM wildcard permission",
@@ -1033,21 +1074,37 @@ class TerraformChecker(HclFileChecker):
1033
1074
  return out
1034
1075
 
1035
1076
  def _g009(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
1036
- """G-009 — Cloud SQL SSL not required."""
1077
+ """G-009 — Cloud SQL TLS explicitly disabled.
1078
+
1079
+ Fires on either explicit-insecure shape:
1080
+ - Legacy: ``require_ssl = false``
1081
+ - Modern (provider 5.x+): ``ssl_mode = "ALLOW_UNENCRYPTED_AND_ENCRYPTED"``
1082
+
1083
+ Does NOT fire on omission of both fields — that case is covered
1084
+ by G-043 (Cloud SQL not requiring SSL). Together the two rules
1085
+ provide orthogonal coverage of the Cloud SQL TLS posture.
1086
+ """
1037
1087
  out = []
1088
+ insecure_legacy_pat = r'require_ssl\s*=\s*false'
1089
+ insecure_modern_pat = r'ssl_mode\s*=\s*"ALLOW_UNENCRYPTED_AND_ENCRYPTED"'
1038
1090
  for b in blocks:
1039
1091
  if b.kind != "resource" or b.label1 != "google_sql_database_instance":
1040
1092
  continue
1041
- if b.contains(r'require_ssl\s*=\s*false'):
1042
- out.append(self._f(
1043
- path, b.any_line_matches(r'require_ssl\s*=\s*false') or b.start_line,
1044
- "PROOFCTL-TF-G009", "Cloud SQL SSL not required",
1045
- Severity.ERROR,
1046
- f'SQL instance "{b.label2}" has require_ssl = false',
1047
- hint="Set require_ssl = true to enforce TLS for all client connections.",
1048
- authority="CIS GCP Benchmark 6.4",
1049
- docs_url="https://www.cisecurity.org/benchmark/google_cloud_computing_platform",
1050
- ))
1093
+ legacy_line = b.any_line_matches(insecure_legacy_pat)
1094
+ modern_line = b.any_line_matches(insecure_modern_pat)
1095
+ if legacy_line is None and modern_line is None:
1096
+ continue
1097
+ line = legacy_line or modern_line
1098
+ detail = "require_ssl = false" if legacy_line else 'ssl_mode = "ALLOW_UNENCRYPTED_AND_ENCRYPTED"'
1099
+ out.append(self._f(
1100
+ path, line or b.start_line,
1101
+ "PROOFCTL-TF-G009", "Cloud SQL TLS explicitly disabled",
1102
+ Severity.ERROR,
1103
+ f'SQL instance "{b.label2}" has TLS explicitly disabled ({detail})',
1104
+ hint='Set ssl_mode = "ENCRYPTED_ONLY" (or "TRUSTED_CLIENT_CERTIFICATE_REQUIRED") to enforce TLS for all client connections.',
1105
+ authority="CIS GCP Benchmark 6.4",
1106
+ docs_url="https://cloud.google.com/sql/docs/mysql/configure-ssl-instance",
1107
+ ))
1051
1108
  return out
1052
1109
 
1053
1110
  def _g010(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
@@ -2861,19 +2918,33 @@ class TerraformChecker(HclFileChecker):
2861
2918
  return out
2862
2919
 
2863
2920
  def _g043(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
2864
- """G-043 — Cloud SQL instance without require_ssl."""
2921
+ """G-043 — Cloud SQL TLS not enforced (no enforcement attribute set).
2922
+
2923
+ Fires when NONE of the safe enforcement attributes are present:
2924
+ - Legacy: ``require_ssl = true``
2925
+ - Modern (provider 5.x+): ``ssl_mode = "ENCRYPTED_ONLY"`` OR
2926
+ ``ssl_mode = "TRUSTED_CLIENT_CERTIFICATE_REQUIRED"``
2927
+
2928
+ Without one of these, Cloud SQL accepts unencrypted connections
2929
+ by default. G-009 covers the *explicit* opt-out cases; this rule
2930
+ covers the *implicit* opt-out (omission).
2931
+ """
2865
2932
  out = []
2933
+ require_ssl_true = r'require_ssl\s*=\s*true'
2934
+ ssl_mode_enforced = (
2935
+ r'ssl_mode\s*=\s*"(?:ENCRYPTED_ONLY|TRUSTED_CLIENT_CERTIFICATE_REQUIRED)"'
2936
+ )
2866
2937
  for b in blocks:
2867
2938
  if b.kind != "resource" or b.label1 != "google_sql_database_instance":
2868
2939
  continue
2869
- if b.contains(r'require_ssl\s*=\s*true'):
2940
+ if b.contains(require_ssl_true) or b.contains(ssl_mode_enforced):
2870
2941
  continue
2871
2942
  out.append(self._f(
2872
2943
  path, b.start_line,
2873
- "PROOFCTL-TF-G043", "Cloud SQL not requiring SSL",
2944
+ "PROOFCTL-TF-G043", "Cloud SQL TLS not enforced",
2874
2945
  Severity.ERROR,
2875
- f'google_sql_database_instance "{b.label2}" does not require SSL/TLS connections',
2876
- hint="Set settings.ip_configuration.require_ssl = true.",
2946
+ f'google_sql_database_instance "{b.label2}" does not enforce TLS — no require_ssl = true or ssl_mode = ENCRYPTED_ONLY present',
2947
+ hint='Set settings.ip_configuration.ssl_mode = "ENCRYPTED_ONLY" (preferred; provider 5.x+) or settings.ip_configuration.require_ssl = true (legacy).',
2877
2948
  authority="CIS GCP Benchmark 6.4",
2878
2949
  docs_url="https://cloud.google.com/sql/docs/mysql/configure-ssl-instance",
2879
2950
  ))
@@ -2935,6 +3006,36 @@ class TerraformChecker(HclFileChecker):
2935
3006
  break
2936
3007
  return out
2937
3008
 
3009
+ def _g047(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
3010
+ """G-047 — Compute instance with `cloud-platform` access scope.
3011
+
3012
+ Access scopes are a coarse, IAM-bypassing layer on top of service-account
3013
+ permissions. `cloud-platform` grants the metadata server permission to
3014
+ mint tokens for every GCP API — so any role the SA ever acquires becomes
3015
+ immediately usable from the VM. Best practice: rely on IAM only, and
3016
+ narrow the scope to `cloud-platform.read-only` or use the default
3017
+ scopes set with explicit IAM roles.
3018
+ """
3019
+ out = []
3020
+ for b in blocks:
3021
+ if b.kind != "resource" or b.label1 != "google_compute_instance":
3022
+ continue
3023
+ for sa in b.nested("service_account"):
3024
+ # Match "cloud-platform" exactly inside the scopes list — not
3025
+ # cloud-platform.read-only or other narrower scopes.
3026
+ if sa.contains(r'"cloud-platform"'):
3027
+ out.append(self._f(
3028
+ path, sa.attr_line("scopes") or sa.start_line,
3029
+ "PROOFCTL-TF-G047", "Compute instance with cloud-platform scope",
3030
+ Severity.WARNING,
3031
+ f'google_compute_instance "{b.label2}" has access scope "cloud-platform" — broadest possible API access',
3032
+ hint='Use narrower scopes (e.g. "cloud-platform.read-only") or omit and rely on IAM bindings on the service account.',
3033
+ authority="GCP Compute Engine — Access scopes vs IAM",
3034
+ docs_url="https://cloud.google.com/compute/docs/access/service-accounts#scopes_best_practice",
3035
+ ))
3036
+ break
3037
+ return out
3038
+
2938
3039
  # ════════════════════════════════════════════════════════════════════════
2939
3040
  # Group AZ — Azure data-exfiltration rules (extended)
2940
3041
  # ════════════════════════════════════════════════════════════════════════
@@ -3328,28 +3429,244 @@ class TerraformChecker(HclFileChecker):
3328
3429
  ))
3329
3430
  return out
3330
3431
 
3432
+ # GCE boot disk that references an image *family* rather than a specific
3433
+ # image is the GCP equivalent of `:latest` — Google promotes new images
3434
+ # into a family on its own schedule. Any apply that triggers VM rebuild
3435
+ # picks up whatever's current that day. Sneaky drift / surprise OS upgrade
3436
+ # vector that AI tools default to because the canonical examples use it.
3437
+ _IMAGE_FAMILY_RE = re.compile(
3438
+ r'image\s*=\s*"[^"]*?/images/family/[^"]+"',
3439
+ )
3440
+
3441
+ def _ai012(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
3442
+ """AI-012 — GCE boot disk references an image *family* (mutable)."""
3443
+ out = []
3444
+ for b in blocks:
3445
+ if b.kind != "resource" or b.label1 != "google_compute_instance":
3446
+ continue
3447
+ for bd in b.nested("boot_disk"):
3448
+ # `image` can live either directly on boot_disk or inside
3449
+ # the nested initialize_params block.
3450
+ targets = [bd] + bd.nested("initialize_params")
3451
+ for t in targets:
3452
+ line = t.any_line_matches(self._IMAGE_FAMILY_RE.pattern)
3453
+ if line is not None:
3454
+ out.append(self._f(
3455
+ path, line,
3456
+ "PROOFCTL-TF-AI-012",
3457
+ "GCE boot disk uses image family (mutable)",
3458
+ Severity.WARNING,
3459
+ f'google_compute_instance "{b.label2}" references an image family — Google promotes new images into the family at any time',
3460
+ hint="Pin to a specific image name (e.g. ubuntu-2404-amd64-20251101) or set lifecycle { ignore_changes = [boot_disk[0].initialize_params[0].image] } to opt out of rebuilds.",
3461
+ authority="GCP Compute Engine — Image families",
3462
+ docs_url="https://cloud.google.com/compute/docs/images/image-families-best-practices",
3463
+ ))
3464
+ break # one finding per boot_disk is enough
3465
+ return out
3466
+
3467
+ # Variables likely to hold credential material — used by AI-013 to decide
3468
+ # which `var.X` references are dangerous to embed in compute metadata.
3469
+ # Aligns with the SECRET-007 name set so engineers don't have to learn
3470
+ # two parallel taxonomies.
3471
+ _SENSITIVE_VAR_NAME_RE = re.compile(
3472
+ r"\bvar\.((?:[a-zA-Z0-9_]*_)?"
3473
+ r"(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|"
3474
+ r"private[_-]?key|client[_-]?secret|encryption[_-]?key|auth[_-]?token|"
3475
+ r"bearer[_-]?token|jwt|credential|tunnel[_-]?token)"
3476
+ r"[a-zA-Z0-9_]*)\b",
3477
+ re.IGNORECASE,
3478
+ )
3479
+
3480
+ @staticmethod
3481
+ def _line_range_of_attr(
3482
+ block: HclBlock, attr_name: str
3483
+ ) -> tuple[int, int] | None:
3484
+ """Find (start_idx, end_idx) into block.raw_lines that span the attribute
3485
+ ``attr_name = <value>`` including any continuation lines opened by
3486
+ parentheses or brackets.
3487
+
3488
+ Returns indices relative to ``block.raw_lines`` (0-based), inclusive of
3489
+ the closing bracket / paren line. Returns None if the attribute is not
3490
+ present.
3491
+ """
3492
+ head = re.compile(rf"^\s*{re.escape(attr_name)}\s*=\s*(.*)$")
3493
+ for i, line in enumerate(block.raw_lines):
3494
+ m = head.match(line)
3495
+ if not m:
3496
+ continue
3497
+ rest = m.group(1)
3498
+ depth = rest.count("(") + rest.count("[") + rest.count("{")
3499
+ depth -= rest.count(")") + rest.count("]") + rest.count("}")
3500
+ if depth <= 0:
3501
+ return (i, i)
3502
+ j = i + 1
3503
+ while j < len(block.raw_lines) and depth > 0:
3504
+ ln = block.raw_lines[j]
3505
+ depth += ln.count("(") + ln.count("[") + ln.count("{")
3506
+ depth -= ln.count(")") + ln.count("]") + ln.count("}")
3507
+ j += 1
3508
+ return (i, j - 1)
3509
+ return None
3510
+
3511
+ def _ai013(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
3512
+ """AI-013 — Sensitive variable interpolated into compute metadata.
3513
+
3514
+ Pattern: `var.<secret-shaped-name>` appears inside ``metadata_startup_script``
3515
+ or any nested ``metadata { ... }`` block on a ``google_compute_instance``
3516
+ resource.
3517
+
3518
+ Risk: anything that ends up in VM metadata is readable by any caller
3519
+ with ``compute.instances.get`` and is written to plan / state. The
3520
+ Cloudflare-tunnel-in-startup-script pattern is the canonical AI-slop
3521
+ instance.
3522
+ """
3523
+ out = []
3524
+ seen: set[tuple[Path, int]] = set()
3525
+
3526
+ def _emit(b: HclBlock, line: int, var_name: str, surface: str) -> None:
3527
+ key = (path, line)
3528
+ if key in seen:
3529
+ return
3530
+ seen.add(key)
3531
+ out.append(self._f(
3532
+ path, line,
3533
+ "PROOFCTL-TF-AI-013",
3534
+ "Sensitive variable in compute metadata",
3535
+ Severity.ERROR,
3536
+ f'google_compute_instance "{b.label2}" passes var.{var_name} into {surface} — secret will be readable from VM metadata and visible in plan/state',
3537
+ hint=(
3538
+ "Mount the secret from Secret Manager inside the VM at runtime "
3539
+ "(google_secret_manager_secret_iam_member + a startup script that "
3540
+ "calls `gcloud secrets versions access`). Do not bake the value into metadata."
3541
+ ),
3542
+ authority="CIS GCP Benchmark 4.6 / OWASP A02 Cryptographic Failures",
3543
+ docs_url="https://cloud.google.com/compute/docs/metadata/overview#metadata_security_considerations",
3544
+ ))
3545
+
3546
+ for b in blocks:
3547
+ if b.kind != "resource" or b.label1 != "google_compute_instance":
3548
+ continue
3549
+ # 1. Scan metadata_startup_script line range.
3550
+ rng = self._line_range_of_attr(b, "metadata_startup_script")
3551
+ if rng is not None:
3552
+ lo, hi = rng
3553
+ for offset in range(lo, hi + 1):
3554
+ line_text = b.raw_lines[offset]
3555
+ m = self._SENSITIVE_VAR_NAME_RE.search(line_text)
3556
+ if m:
3557
+ _emit(b, b.start_line + offset, m.group(1),
3558
+ "metadata_startup_script")
3559
+ # 2. Scan nested metadata { ... } blocks (HCL block form).
3560
+ for md in b.nested("metadata"):
3561
+ for offset, line_text in enumerate(md.raw_lines):
3562
+ m = self._SENSITIVE_VAR_NAME_RE.search(line_text)
3563
+ if m:
3564
+ _emit(b, md.start_line + offset, m.group(1),
3565
+ "metadata block")
3566
+ # 3. Scan metadata = { ... } attribute (HCL object-attribute form).
3567
+ rng = self._line_range_of_attr(b, "metadata")
3568
+ if rng is not None:
3569
+ lo, hi = rng
3570
+ for offset in range(lo, hi + 1):
3571
+ line_text = b.raw_lines[offset]
3572
+ m = self._SENSITIVE_VAR_NAME_RE.search(line_text)
3573
+ if m:
3574
+ _emit(b, b.start_line + offset, m.group(1),
3575
+ "metadata block")
3576
+ return out
3577
+
3331
3578
 
3332
3579
  # ── Directory-level checker (cross-file rules) ───────────────────────────────
3333
3580
 
3334
3581
 
3335
3582
  class TerraformDirChecker(HclDirChecker):
3336
- """Cross-file Terraform checks: M-001 SG mixing, A-002 S3 public access block."""
3583
+ """Cross-file Terraform checks: M-001 SG mixing, A-002 S3 public access block,
3584
+ A-011 CloudTrail (scan-level), T-014 required_version (root-module-only).
3585
+ """
3337
3586
 
3338
3587
  def check(self, root: Path, hcl_files: list[Path]) -> list[Finding]:
3339
3588
  tf_files = [f for f in hcl_files if f.suffix == ".tf"]
3340
3589
  out: list[Finding] = []
3341
3590
  out += self._m001(tf_files)
3342
3591
  out += self._a002(tf_files)
3592
+ out += self._a011_scan_level(tf_files)
3343
3593
  out += self._t014(tf_files)
3594
+ out += self._t016(tf_files)
3595
+ out += self._g048(tf_files)
3344
3596
  return out
3345
3597
 
3346
- def _t014(self, tf_files: list[Path]) -> list[Finding]:
3347
- """T-014 (module-level) — fire once per directory if no .tf file in that dir
3348
- has a `terraform { required_version }` block.
3598
+ def _a011_scan_level(self, tf_files: list[Path]) -> list[Finding]:
3599
+ """A-011 (scan-level) — fire once for the entire scan when any AWS
3600
+ resource is present but no aws_cloudtrail exists anywhere.
3601
+
3602
+ Previously this fired once per file via the file-level dispatcher,
3603
+ which produced N-1 duplicate findings on real codebases (one per .tf
3604
+ file containing aws_* resources). CloudTrail is account-wide; a
3605
+ single finding is sufficient.
3606
+ """
3607
+ if not tf_files:
3608
+ return []
3609
+ has_aws = False
3610
+ has_trail = False
3611
+ first_aws_path: Path | None = None
3612
+ for path in sorted(tf_files):
3613
+ if _is_test_fixture_path(path):
3614
+ continue
3615
+ try:
3616
+ source = path.read_text(encoding="utf-8", errors="replace")
3617
+ except OSError:
3618
+ continue
3619
+ blocks = parse_blocks(source)
3620
+ for b in blocks:
3621
+ if b.kind != "resource":
3622
+ continue
3623
+ if b.label1.startswith("aws_cloudtrail"):
3624
+ has_trail = True
3625
+ elif b.label1.startswith("aws_") and not has_aws:
3626
+ has_aws = True
3627
+ first_aws_path = path
3628
+ if has_trail:
3629
+ return []
3630
+ if not has_aws or first_aws_path is None:
3631
+ return []
3632
+ return [self._f(
3633
+ first_aws_path, None,
3634
+ "PROOFCTL-TF-A011", "No CloudTrail resource",
3635
+ Severity.WARNING,
3636
+ "No aws_cloudtrail resource found anywhere in the scanned tree — AWS API activity is not being audited",
3637
+ hint="Add an aws_cloudtrail resource with multi_region_trail = true and log validation enabled.",
3638
+ authority="CIS AWS Benchmark 3.1",
3639
+ docs_url="https://www.cisecurity.org/benchmark/amazon_web_services",
3640
+ )]
3349
3641
 
3350
- File-level T014 was wrong: a module's `versions.tf` typically holds the
3351
- version constraint while `main.tf` declares the resources. Per-file
3352
- scanning fired on `main.tf` even when the constraint was in `versions.tf`.
3642
+ @staticmethod
3643
+ def _is_root_module(blocks: list[HclBlock]) -> bool:
3644
+ """A directory is a *root* Terraform module if it configures providers
3645
+ (`provider "X" { ... }`), persists state (`terraform { backend ... }`),
3646
+ or composes other modules (`module "X" { ... }`).
3647
+
3648
+ Child modules in `modules/<name>/` typically have only resources +
3649
+ variables + outputs + a `required_providers` block, inheriting
3650
+ `required_version` from the caller. Pinning `required_version` in a
3651
+ child module is a style choice, not a correctness requirement.
3652
+ """
3653
+ has_provider = any(b.kind == "provider" for b in blocks)
3654
+ has_module_call = any(b.kind == "module" for b in blocks)
3655
+ has_backend = any(
3656
+ b.kind == "terraform" and b.has_nested("backend")
3657
+ for b in blocks
3658
+ )
3659
+ return has_provider or has_module_call or has_backend
3660
+
3661
+ def _t014(self, tf_files: list[Path]) -> list[Finding]:
3662
+ """T-014 (root-module-level) — fire once per *root* module directory
3663
+ when no .tf file in that directory has a
3664
+ `terraform { required_version }` block.
3665
+
3666
+ Child modules (in `modules/*/`) are intentionally skipped — they
3667
+ inherit the Terraform version constraint from the calling root
3668
+ module, and pinning a duplicate version in every child module is
3669
+ not a documented best practice.
3353
3670
  """
3354
3671
  from collections import defaultdict
3355
3672
  by_dir: dict[Path, list[Path]] = defaultdict(list)
@@ -3362,6 +3679,7 @@ class TerraformDirChecker(HclDirChecker):
3362
3679
  for d, paths in by_dir.items():
3363
3680
  has_resources = False
3364
3681
  has_required_version = False
3682
+ all_blocks: list[HclBlock] = []
3365
3683
  first_path: Path | None = None
3366
3684
  for path in sorted(paths):
3367
3685
  try:
@@ -3369,6 +3687,7 @@ class TerraformDirChecker(HclDirChecker):
3369
3687
  except OSError:
3370
3688
  continue
3371
3689
  blocks = parse_blocks(source)
3690
+ all_blocks.extend(blocks)
3372
3691
  if first_path is None:
3373
3692
  first_path = path
3374
3693
  if not has_resources:
@@ -3378,14 +3697,17 @@ class TerraformDirChecker(HclDirChecker):
3378
3697
  b.kind == "terraform" and b.has_attr("required_version")
3379
3698
  for b in blocks
3380
3699
  )
3381
- if has_required_version:
3382
- break
3383
- if has_resources and not has_required_version and first_path is not None:
3700
+ if (
3701
+ has_resources
3702
+ and not has_required_version
3703
+ and first_path is not None
3704
+ and self._is_root_module(all_blocks)
3705
+ ):
3384
3706
  out.append(self._f(
3385
3707
  first_path, None,
3386
3708
  "PROOFCTL-TF-T014", "Missing required_version constraint",
3387
3709
  Severity.WARNING,
3388
- "No terraform { required_version } constraint in this module — Terraform version drift risk",
3710
+ "No terraform { required_version } constraint in this root module — Terraform version drift risk",
3389
3711
  hint='Add terraform { required_version = ">= 1.5" } in versions.tf or main.tf.',
3390
3712
  docs_url="https://developer.hashicorp.com/terraform/language/providers/requirements#best-practices-for-provider-versions",
3391
3713
  ))
@@ -3393,6 +3715,153 @@ class TerraformDirChecker(HclDirChecker):
3393
3715
 
3394
3716
  # ── helpers ───────────────────────────────────────────────────────────────
3395
3717
 
3718
+
3719
+ # ── T-016 / G-048 added in the 0.4.x precision sprint ────────────────────
3720
+
3721
+ _VAR_REF_RE = __import__("re").compile(r"\bvar\.([a-zA-Z_][a-zA-Z0-9_]*)")
3722
+
3723
+ def _t016(self, tf_files: list[Path]) -> list[Finding]:
3724
+ """T-016 — variable declared in a module but never referenced.
3725
+
3726
+ Walks every ``.tf`` in each module directory, collects:
3727
+ - declared variable names (from ``variable "X" { ... }`` blocks)
3728
+ - referenced names (from ``var.X`` occurrences across all files)
3729
+
3730
+ A variable in the declared set that is absent from the referenced
3731
+ set is dead module surface area — confuses callers about whether
3732
+ the field is required and accumulates as a maintenance hazard.
3733
+
3734
+ Fires once per unused variable, with the finding attached to the
3735
+ line where the variable was declared.
3736
+
3737
+ Test fixtures are skipped. Reserved names that intentionally
3738
+ document inputs without being consumed (none today) would go on a
3739
+ suppress list — none yet known.
3740
+ """
3741
+ from collections import defaultdict
3742
+ by_dir: dict[Path, list[Path]] = defaultdict(list)
3743
+ for path in tf_files:
3744
+ if _is_test_fixture_path(path):
3745
+ continue
3746
+ by_dir[path.parent].append(path)
3747
+
3748
+ out: list[Finding] = []
3749
+ for d, paths in by_dir.items():
3750
+ declared: dict[str, tuple[Path, int]] = {}
3751
+ joined_source = []
3752
+ for path in sorted(paths):
3753
+ try:
3754
+ source = path.read_text(encoding="utf-8", errors="replace")
3755
+ except OSError:
3756
+ continue
3757
+ joined_source.append(source)
3758
+ blocks = parse_blocks(source)
3759
+ for b in blocks:
3760
+ if b.kind == "variable" and b.label1:
3761
+ declared.setdefault(b.label1, (path, b.start_line))
3762
+ referenced = set()
3763
+ full_text = "\n".join(joined_source)
3764
+ for m in self._VAR_REF_RE.finditer(full_text):
3765
+ referenced.add(m.group(1))
3766
+
3767
+ for name, (path, line) in declared.items():
3768
+ if name in referenced:
3769
+ continue
3770
+ out.append(self._f(
3771
+ path, line,
3772
+ "PROOFCTL-TF-T016", "Variable declared but never used",
3773
+ Severity.WARNING,
3774
+ f'variable "{name}" is declared but never referenced via var.{name} anywhere in the module',
3775
+ hint="Remove the unused variable, or wire it into the resource that should consume it.",
3776
+ docs_url="https://developer.hashicorp.com/terraform/language/values/variables",
3777
+ ))
3778
+ return out
3779
+
3780
+ # CMEK signals across resource types. If any of these attributes appear on a
3781
+ # resource in the module, we treat the module as "CMEK-using" — secrets
3782
+ # should then also use customer_managed_encryption.
3783
+ _CMEK_USE_PATTERNS = (
3784
+ r'\bkms_key_name\b',
3785
+ r'\bencryption_key_name\b',
3786
+ r'\bcustomer_managed_encryption\b',
3787
+ r'\bkms_key_self_link\b',
3788
+ r'\bkms_key_arn\b',
3789
+ r'\bdefault_kms_key_name\b',
3790
+ )
3791
+
3792
+ def _g048(self, tf_files: list[Path]) -> list[Finding]:
3793
+ """G-048 — Secret Manager secret without CMEK in a CMEK-using module.
3794
+
3795
+ Cross-resource consistency check. If the module already uses CMEK on
3796
+ any other resource (Cloud SQL, GCS, Compute disk, etc.) but the
3797
+ ``google_secret_manager_secret`` resource uses
3798
+ ``replication { auto {} }`` (Google-managed encryption), the CMEK
3799
+ posture is undermined — the most sensitive material (credentials)
3800
+ sits under a weaker key than the data it unlocks.
3801
+
3802
+ Detection:
3803
+ 1. Scan every .tf in the module directory.
3804
+ 2. If any line matches one of _CMEK_USE_PATTERNS, the module is
3805
+ CMEK-using.
3806
+ 3. For each google_secret_manager_secret with no
3807
+ customer_managed_encryption inside its replication block, fire.
3808
+
3809
+ Skips test fixtures.
3810
+ """
3811
+ import re
3812
+ from collections import defaultdict
3813
+ by_dir: dict[Path, list[Path]] = defaultdict(list)
3814
+ for path in tf_files:
3815
+ if _is_test_fixture_path(path):
3816
+ continue
3817
+ by_dir[path.parent].append(path)
3818
+
3819
+ cmek_re = re.compile("|".join(self._CMEK_USE_PATTERNS))
3820
+ out: list[Finding] = []
3821
+
3822
+ for d, paths in by_dir.items():
3823
+ module_uses_cmek = False
3824
+ secret_blocks: list[tuple[Path, HclBlock]] = []
3825
+
3826
+ for path in sorted(paths):
3827
+ try:
3828
+ source = path.read_text(encoding="utf-8", errors="replace")
3829
+ except OSError:
3830
+ continue
3831
+ if cmek_re.search(source):
3832
+ module_uses_cmek = True
3833
+ for b in parse_blocks(source):
3834
+ if b.kind == "resource" and b.label1 == "google_secret_manager_secret":
3835
+ secret_blocks.append((path, b))
3836
+
3837
+ if not module_uses_cmek or not secret_blocks:
3838
+ continue
3839
+
3840
+ for path, b in secret_blocks:
3841
+ # The secret has CMEK iff customer_managed_encryption appears
3842
+ # inside its body (which lives in user_managed.replicas[*]).
3843
+ if b.contains(r"customer_managed_encryption"):
3844
+ continue
3845
+ line = b.any_line_matches(r"replication\s*\{") or b.start_line
3846
+ out.append(self._f(
3847
+ path, line,
3848
+ "PROOFCTL-TF-G048",
3849
+ "Secret Manager secret without CMEK in CMEK-using module",
3850
+ Severity.WARNING,
3851
+ f'google_secret_manager_secret "{b.label2}" uses Google-managed encryption while the rest of the module uses CMEK — credential is weaker-protected than the data it unlocks',
3852
+ hint=(
3853
+ 'Switch to replication { user_managed { replicas { '
3854
+ 'location = ... customer_managed_encryption '
3855
+ '{ kms_key_name = var.kms_key_id } } } } and grant '
3856
+ 'the Secret Manager service agent '
3857
+ '(service-{project_number}@gcp-sa-secretmanager.iam.gserviceaccount.com) '
3858
+ 'cryptoKeyEncrypterDecrypter on the KMS key.'
3859
+ ),
3860
+ authority="CIS GCP Benchmark / Customer-Managed Encryption Keys",
3861
+ docs_url="https://cloud.google.com/secret-manager/docs/cmek",
3862
+ ))
3863
+ return out
3864
+
3396
3865
  @staticmethod
3397
3866
  def _f(
3398
3867
  path: Path,
@@ -3402,13 +3871,14 @@ class TerraformDirChecker(HclDirChecker):
3402
3871
  severity: Severity,
3403
3872
  message: str,
3404
3873
  hint: str = "",
3874
+ authority: str = "",
3405
3875
  docs_url: str = "",
3406
3876
  ) -> Finding:
3407
3877
  return Finding(
3408
3878
  file=str(path), line=line, col=None,
3409
3879
  rule_id=rule_id, rule_name=rule_name,
3410
3880
  severity=severity, message=message, hint=hint,
3411
- docs_url=docs_url,
3881
+ authority=authority, docs_url=docs_url,
3412
3882
  )
3413
3883
 
3414
3884
  def _m001(self, tf_files: list[Path]) -> list[Finding]:
@@ -3467,7 +3937,13 @@ class TerraformDirChecker(HclDirChecker):
3467
3937
  buckets[d][b.label2] = (b.start_line, path)
3468
3938
  elif b.kind == "resource" and b.label1 == "aws_s3_bucket_public_access_block":
3469
3939
  bucket_val = b.attr_value("bucket") or ""
3470
- ref_m = re.search(r'aws_s3_bucket\.(\w+)\.', bucket_val)
3940
+ # Match both `aws_s3_bucket.<name>.<attr>` and the
3941
+ # count-conditional `aws_s3_bucket.<name>[0].<attr>` /
3942
+ # for_each `aws_s3_bucket.<name>["k"].<attr>` shapes.
3943
+ ref_m = re.search(
3944
+ r'aws_s3_bucket\.(\w+)(?:\[[^\]]+\])?\.',
3945
+ bucket_val,
3946
+ )
3471
3947
  if ref_m:
3472
3948
  pab_refs[d].add(ref_m.group(1))
3473
3949
 
@@ -215,6 +215,18 @@ _DOCKERFILE_RE = re.compile(r"^Dockerfile(\..*)?$")
215
215
  _HCL_EXTS = (".tf", ".tfvars", ".hcl")
216
216
  _YAML_EXTS = (".yml", ".yaml")
217
217
  _SHELL_EXTS = (".sh", ".bash", ".zsh", ".ksh")
218
+ # Terraform template files. We classify by content: a .tftpl whose
219
+ # rendered output is a shell script (shebang #!/bin/bash, #!/bin/sh,
220
+ # /usr/bin/env bash, etc.) should be scanned by the SH rules so the
221
+ # template's shell content participates in lint / secret / pattern
222
+ # checks even though it's stored as a Terraform template.
223
+ _TFTPL_EXTS = (".tftpl",)
224
+ _SHELL_SHEBANG_RE = re.compile(
225
+ rb'^\s*#!\s*(?:'
226
+ rb'/(?:usr/local/|usr/)?bin/' # /bin/, /usr/bin/, /usr/local/bin/
227
+ rb'|/usr/bin/env\s+' # /usr/bin/env <name>
228
+ rb')(?:ba|z|k|ash|d)?sh\b' # sh / bash / zsh / ksh / ash / dash
229
+ )
218
230
 
219
231
 
220
232
  def _classify(path: Path) -> str | None:
@@ -227,11 +239,29 @@ def _classify(path: Path) -> str | None:
227
239
  return "yaml"
228
240
  if path.suffix in _SHELL_EXTS:
229
241
  return "shell"
242
+ if path.suffix in _TFTPL_EXTS and _is_shell_template(path):
243
+ return "shell"
230
244
  if _DOCKERFILE_RE.match(path.name):
231
245
  return "dockerfile"
232
246
  return None
233
247
 
234
248
 
249
+ def _is_shell_template(path: Path) -> bool:
250
+ """True iff a `.tftpl` file looks like a shell-script template.
251
+
252
+ Cheapest possible check: read up to 128 bytes and match a shell
253
+ shebang. We don't try to actually render the template — variables
254
+ like ``${var.x}`` may sit between the shebang and the script body,
255
+ but the shebang itself is on line 1 either way.
256
+ """
257
+ try:
258
+ with path.open("rb") as f:
259
+ head = f.read(128)
260
+ except OSError:
261
+ return False
262
+ return bool(_SHELL_SHEBANG_RE.match(head))
263
+
264
+
235
265
  def _internal_finding(
236
266
  *, scope: str, path: Path | None, exc: BaseException, strict: bool,
237
267
  ) -> Finding:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofctl
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Zero-dependency linter for Python, Terraform, Dockerfiles, Kubernetes, and GitHub Actions — catches AI slop and security misconfigurations pre-commit
5
5
  Author-email: Kolawolu Odunola <kolawolu.o@gmail.com>
6
6
  License: MIT
@@ -28,7 +28,7 @@ Dynamic: license-file
28
28
 
29
29
  # proofctl
30
30
 
31
- A linter built for the DevOps stack — Python, Terraform, Kubernetes, Helm, Dockerfiles, GitHub Actions, Shell — that catches the specific kinds of mistakes AI coding tools introduce. **264 rules**, zero dependencies, runs in under a second.
31
+ A linter built for the DevOps stack — Python, Terraform, Kubernetes, Helm, Dockerfiles, GitHub Actions, Shell — that catches the specific kinds of mistakes AI coding tools introduce. **269 rules**, zero dependencies, runs in under a second.
32
32
 
33
33
  ```
34
34
  proofctl check .
@@ -165,7 +165,7 @@ git commit -m "chore: add proofctl baseline"
165
165
 
166
166
  ## Rules
167
167
 
168
- 264 rules across 14 families. Run `proofctl rules` after install to enumerate.
168
+ 269 rules across 14 families. Run `proofctl rules` after install to enumerate.
169
169
 
170
170
  | Family | Count | What it covers |
171
171
  |---|---|---|
@@ -173,9 +173,9 @@ git commit -m "chore: add proofctl baseline"
173
173
  | Python — Security (`S`) | 24 | OWASP Top-10 + CWE coverage: SQLi, command injection, weak crypto, JWT bypass, path traversal, XSS, SSRF, file upload, CSRF, missing authz, XPath injection, open redirect, TLS bypass |
174
174
  | Secrets (`SECRET`) | 13 | AWS / GCP / GitHub / Slack / Stripe / private keys / JWTs / hardcoded passwords / K8s Secret YAML / connection strings with embedded creds |
175
175
  | Python — Other | 12 | Placeholders, leakage (cross-language idioms, PII), import hallucination, method complexity, variant detection, LLM guardrails (OWASP LLM Top-10) |
176
- | Terraform — General + AI (`TF-T`, `TF-AI`) | 26 | required_version, sensitive outputs, IAM wildcards, mutable git refs, deprecated resources, AWS-region-in-GCP, placeholder values |
176
+ | Terraform — General + AI (`TF-T`, `TF-AI`) | 29 | required_version, sensitive outputs, IAM wildcards, mutable git refs, deprecated resources, AWS-region-in-GCP, placeholder values, image-family pinning, sensitive vars in VM metadata, unused variable declarations |
177
177
  | Terraform — AWS (`TF-A`) | 40 | EC2 IMDSv2, S3 public-access-block, EBS encryption, VPC flow logs, KMS rotation, RDS hardening, SG / NACL / EKS / DynamoDB / ElastiCache / CloudTrail / IAM |
178
- | Terraform — GCP (`TF-G`) | 50 | GKE hardening, GCS public-access prevention, IAM roles, KMS rotation, Cloud Function/Run public, BigQuery, Pub/Sub CMEK, Cloud SQL, firewall ingress |
178
+ | Terraform — GCP (`TF-G`) | 52 | GKE hardening, GCS public-access prevention, IAM roles, KMS rotation, Cloud Function/Run public, BigQuery, Pub/Sub CMEK, Cloud SQL (TLS ssl_mode aware), firewall ingress, compute access scopes, Secret Manager CMEK consistency |
179
179
  | Terraform — Azure (`TF-AZ`) | 14 | Storage public blob, SQL public network, AKS RBAC, Key Vault purge protection, NSG SSH/RDP, managed disk CMK |
180
180
  | Terraform — Mechanics + Lifecycle (`TF-M`, `TF-V`) | 12 | SG inline mixing, `force_destroy`, mutable module refs, lifecycle antipatterns |
181
181
  | Terragrunt (`TG`) | 6 | HCL inputs, mock_outputs, remote state encryption |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofctl"
7
- version = "0.4.2"
7
+ version = "0.5.0"
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" }
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