proofctl 0.4.3__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.3/proofctl.egg-info → proofctl-0.5.0}/PKG-INFO +5 -5
  2. {proofctl-0.4.3 → proofctl-0.5.0}/README.md +4 -4
  3. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/terraform.py +370 -73
  4. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/engine.py +30 -0
  5. {proofctl-0.4.3 → proofctl-0.5.0/proofctl.egg-info}/PKG-INFO +5 -5
  6. {proofctl-0.4.3 → proofctl-0.5.0}/pyproject.toml +1 -1
  7. {proofctl-0.4.3 → proofctl-0.5.0}/LICENSE +0 -0
  8. {proofctl-0.4.3 → proofctl-0.5.0}/MANIFEST.in +0 -0
  9. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/__init__.py +0 -0
  10. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/_globmatch.py +0 -0
  11. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/baseline.py +0 -0
  12. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/__init__.py +0 -0
  13. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/base.py +0 -0
  14. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/dockerfile.py +0 -0
  15. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/hcl_utils.py +0 -0
  16. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/imports.py +0 -0
  17. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/leakage.py +0 -0
  18. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/llm_integration.py +0 -0
  19. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/placeholders.py +0 -0
  20. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/quality.py +0 -0
  21. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/secrets.py +0 -0
  22. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/security.py +0 -0
  23. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/shell_checker.py +0 -0
  24. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/terragrunt.py +0 -0
  25. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/variants.py +0 -0
  26. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/yaml_checker.py +0 -0
  27. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/cli.py +0 -0
  28. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/config.py +0 -0
  29. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/fixer.py +0 -0
  30. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/models.py +0 -0
  31. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/registry.py +0 -0
  32. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/reporters/__init__.py +0 -0
  33. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/reporters/html_reporter.py +0 -0
  34. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/reporters/json_reporter.py +0 -0
  35. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/reporters/terminal.py +0 -0
  36. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl.egg-info/SOURCES.txt +0 -0
  37. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl.egg-info/dependency_links.txt +0 -0
  38. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl.egg-info/entry_points.txt +0 -0
  39. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl.egg-info/requires.txt +0 -0
  40. {proofctl-0.4.3 → proofctl-0.5.0}/proofctl.egg-info/top_level.txt +0 -0
  41. {proofctl-0.4.3 → 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.3
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. **267 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
- 267 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`) | 28 | 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 |
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`) | 51 | GKE hardening, GCS public-access prevention, IAM roles, KMS rotation, Cloud Function/Run public, BigQuery, Pub/Sub CMEK, Cloud SQL, firewall ingress, compute access scopes |
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. **267 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
- 267 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`) | 28 | 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 |
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`) | 51 | GKE hardening, GCS public-access prevention, IAM roles, KMS rotation, Cloud Function/Run public, BigQuery, Pub/Sub CMEK, Cloud SQL, firewall ingress, compute access scopes |
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)
@@ -504,25 +514,34 @@ class TerraformChecker(HclFileChecker):
504
514
  if not b.contains(r'0\.0\.0\.0/0'):
505
515
  continue
506
516
 
507
- # 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
+
508
524
  if b.label1 == "aws_security_group_rule":
509
525
  rule_type = (b.attr_value("type") or "").strip().strip('"').lower()
510
526
  if rule_type == "egress":
511
527
  continue
528
+ scoped_lines = list(b.raw_lines)
512
529
  elif b.label1 == "google_compute_firewall":
513
530
  direction = (b.attr_value("direction") or "").strip().strip('"').upper()
514
531
  if direction == "EGRESS":
515
532
  continue
533
+ scoped_lines = list(b.raw_lines)
516
534
  elif b.label1 == "aws_security_group":
517
- # 0.0.0.0/0 in an inline egress { } block is fine — only flag
518
- # if 0.0.0.0/0 appears inside an ingress block (or there is
519
- # no egress block at all).
520
- 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:
521
537
  continue
538
+ else:
539
+ scoped_lines = list(b.raw_lines)
522
540
 
523
- # Check if sensitive ports are involved
541
+ # Check if sensitive ports are involved (scoped to ingress only
542
+ # for aws_security_group).
524
543
  ports: set[int] = set()
525
- for line in b.raw_lines:
544
+ for line in scoped_lines:
526
545
  pm = port_re.search(line)
527
546
  if pm:
528
547
  try:
@@ -530,7 +549,7 @@ class TerraformChecker(HclFileChecker):
530
549
  except ValueError:
531
550
  pass
532
551
 
533
- if all_traffic_re.search('\n'.join(b.raw_lines)):
552
+ if all_traffic_re.search('\n'.join(scoped_lines)):
534
553
  # -1 or all traffic — always sensitive
535
554
  flagged_ports = "all traffic"
536
555
  elif ports & _SENSITIVE_PORTS:
@@ -571,14 +590,33 @@ class TerraformChecker(HclFileChecker):
571
590
  return out
572
591
 
573
592
  def _t006(self, path: Path, lines: list[str]) -> list[Finding]:
574
- """T-006 — IAM wildcard in actions or resources."""
575
- # Match: actions = ["*"] or resources = ["*"] (with possible extra items)
576
- 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
+ )
577
614
  out = []
578
615
  for i, line in enumerate(lines, start=1):
579
- if line.strip().startswith("#"):
616
+ stripped = line.strip()
617
+ if stripped.startswith("#") or stripped.startswith("//"):
580
618
  continue
581
- if pat.search(line):
619
+ if tf_pat.search(line) or json_pat.search(line):
582
620
  out.append(self._f(
583
621
  path, i,
584
622
  "PROOFCTL-TF-T006", "IAM wildcard permission",
@@ -1036,21 +1074,37 @@ class TerraformChecker(HclFileChecker):
1036
1074
  return out
1037
1075
 
1038
1076
  def _g009(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
1039
- """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
+ """
1040
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"'
1041
1090
  for b in blocks:
1042
1091
  if b.kind != "resource" or b.label1 != "google_sql_database_instance":
1043
1092
  continue
1044
- if b.contains(r'require_ssl\s*=\s*false'):
1045
- out.append(self._f(
1046
- path, b.any_line_matches(r'require_ssl\s*=\s*false') or b.start_line,
1047
- "PROOFCTL-TF-G009", "Cloud SQL SSL not required",
1048
- Severity.ERROR,
1049
- f'SQL instance "{b.label2}" has require_ssl = false',
1050
- hint="Set require_ssl = true to enforce TLS for all client connections.",
1051
- authority="CIS GCP Benchmark 6.4",
1052
- docs_url="https://www.cisecurity.org/benchmark/google_cloud_computing_platform",
1053
- ))
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
+ ))
1054
1108
  return out
1055
1109
 
1056
1110
  def _g010(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
@@ -2864,19 +2918,33 @@ class TerraformChecker(HclFileChecker):
2864
2918
  return out
2865
2919
 
2866
2920
  def _g043(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
2867
- """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
+ """
2868
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
+ )
2869
2937
  for b in blocks:
2870
2938
  if b.kind != "resource" or b.label1 != "google_sql_database_instance":
2871
2939
  continue
2872
- if b.contains(r'require_ssl\s*=\s*true'):
2940
+ if b.contains(require_ssl_true) or b.contains(ssl_mode_enforced):
2873
2941
  continue
2874
2942
  out.append(self._f(
2875
2943
  path, b.start_line,
2876
- "PROOFCTL-TF-G043", "Cloud SQL not requiring SSL",
2944
+ "PROOFCTL-TF-G043", "Cloud SQL TLS not enforced",
2877
2945
  Severity.ERROR,
2878
- f'google_sql_database_instance "{b.label2}" does not require SSL/TLS connections',
2879
- 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).',
2880
2948
  authority="CIS GCP Benchmark 6.4",
2881
2949
  docs_url="https://cloud.google.com/sql/docs/mysql/configure-ssl-instance",
2882
2950
  ))
@@ -3512,23 +3580,93 @@ class TerraformChecker(HclFileChecker):
3512
3580
 
3513
3581
 
3514
3582
  class TerraformDirChecker(HclDirChecker):
3515
- """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
+ """
3516
3586
 
3517
3587
  def check(self, root: Path, hcl_files: list[Path]) -> list[Finding]:
3518
3588
  tf_files = [f for f in hcl_files if f.suffix == ".tf"]
3519
3589
  out: list[Finding] = []
3520
3590
  out += self._m001(tf_files)
3521
3591
  out += self._a002(tf_files)
3592
+ out += self._a011_scan_level(tf_files)
3522
3593
  out += self._t014(tf_files)
3594
+ out += self._t016(tf_files)
3595
+ out += self._g048(tf_files)
3523
3596
  return out
3524
3597
 
3525
- def _t014(self, tf_files: list[Path]) -> list[Finding]:
3526
- """T-014 (module-level) — fire once per directory if no .tf file in that dir
3527
- 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
+ )]
3641
+
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
3528
3660
 
3529
- File-level T014 was wrong: a module's `versions.tf` typically holds the
3530
- version constraint while `main.tf` declares the resources. Per-file
3531
- scanning fired on `main.tf` even when the constraint was in `versions.tf`.
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.
3532
3670
  """
3533
3671
  from collections import defaultdict
3534
3672
  by_dir: dict[Path, list[Path]] = defaultdict(list)
@@ -3541,6 +3679,7 @@ class TerraformDirChecker(HclDirChecker):
3541
3679
  for d, paths in by_dir.items():
3542
3680
  has_resources = False
3543
3681
  has_required_version = False
3682
+ all_blocks: list[HclBlock] = []
3544
3683
  first_path: Path | None = None
3545
3684
  for path in sorted(paths):
3546
3685
  try:
@@ -3548,6 +3687,7 @@ class TerraformDirChecker(HclDirChecker):
3548
3687
  except OSError:
3549
3688
  continue
3550
3689
  blocks = parse_blocks(source)
3690
+ all_blocks.extend(blocks)
3551
3691
  if first_path is None:
3552
3692
  first_path = path
3553
3693
  if not has_resources:
@@ -3557,14 +3697,17 @@ class TerraformDirChecker(HclDirChecker):
3557
3697
  b.kind == "terraform" and b.has_attr("required_version")
3558
3698
  for b in blocks
3559
3699
  )
3560
- if has_required_version:
3561
- break
3562
- 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
+ ):
3563
3706
  out.append(self._f(
3564
3707
  first_path, None,
3565
3708
  "PROOFCTL-TF-T014", "Missing required_version constraint",
3566
3709
  Severity.WARNING,
3567
- "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",
3568
3711
  hint='Add terraform { required_version = ">= 1.5" } in versions.tf or main.tf.',
3569
3712
  docs_url="https://developer.hashicorp.com/terraform/language/providers/requirements#best-practices-for-provider-versions",
3570
3713
  ))
@@ -3572,6 +3715,153 @@ class TerraformDirChecker(HclDirChecker):
3572
3715
 
3573
3716
  # ── helpers ───────────────────────────────────────────────────────────────
3574
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
+
3575
3865
  @staticmethod
3576
3866
  def _f(
3577
3867
  path: Path,
@@ -3581,13 +3871,14 @@ class TerraformDirChecker(HclDirChecker):
3581
3871
  severity: Severity,
3582
3872
  message: str,
3583
3873
  hint: str = "",
3874
+ authority: str = "",
3584
3875
  docs_url: str = "",
3585
3876
  ) -> Finding:
3586
3877
  return Finding(
3587
3878
  file=str(path), line=line, col=None,
3588
3879
  rule_id=rule_id, rule_name=rule_name,
3589
3880
  severity=severity, message=message, hint=hint,
3590
- docs_url=docs_url,
3881
+ authority=authority, docs_url=docs_url,
3591
3882
  )
3592
3883
 
3593
3884
  def _m001(self, tf_files: list[Path]) -> list[Finding]:
@@ -3646,7 +3937,13 @@ class TerraformDirChecker(HclDirChecker):
3646
3937
  buckets[d][b.label2] = (b.start_line, path)
3647
3938
  elif b.kind == "resource" and b.label1 == "aws_s3_bucket_public_access_block":
3648
3939
  bucket_val = b.attr_value("bucket") or ""
3649
- 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
+ )
3650
3947
  if ref_m:
3651
3948
  pab_refs[d].add(ref_m.group(1))
3652
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.3
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. **267 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
- 267 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`) | 28 | 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 |
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`) | 51 | GKE hardening, GCS public-access prevention, IAM roles, KMS rotation, Cloud Function/Run public, BigQuery, Pub/Sub CMEK, Cloud SQL, firewall ingress, compute access scopes |
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.3"
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