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.
- {proofctl-0.4.2/proofctl.egg-info → proofctl-0.5.0}/PKG-INFO +5 -5
- {proofctl-0.4.2 → proofctl-0.5.0}/README.md +4 -4
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/terraform.py +549 -73
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/engine.py +30 -0
- {proofctl-0.4.2 → proofctl-0.5.0/proofctl.egg-info}/PKG-INFO +5 -5
- {proofctl-0.4.2 → proofctl-0.5.0}/pyproject.toml +1 -1
- {proofctl-0.4.2 → proofctl-0.5.0}/LICENSE +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/MANIFEST.in +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/__init__.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/_globmatch.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/baseline.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/__init__.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/base.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/dockerfile.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/hcl_utils.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/imports.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/leakage.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/llm_integration.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/placeholders.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/quality.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/secrets.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/security.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/shell_checker.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/terragrunt.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/variants.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/checkers/yaml_checker.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/cli.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/config.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/fixer.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/models.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/registry.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/reporters/__init__.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/reporters/html_reporter.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/reporters/json_reporter.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl/reporters/terminal.py +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl.egg-info/SOURCES.txt +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl.egg-info/dependency_links.txt +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl.egg-info/entry_points.txt +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl.egg-info/requires.txt +0 -0
- {proofctl-0.4.2 → proofctl-0.5.0}/proofctl.egg-info/top_level.txt +0 -0
- {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.
|
|
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. **
|
|
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
|
-
|
|
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`) |
|
|
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`) |
|
|
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. **
|
|
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
|
-
|
|
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`) |
|
|
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`) |
|
|
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
|
|
57
|
-
"""
|
|
58
|
-
0.0.0.0/0
|
|
59
|
-
|
|
60
|
-
aws_security_group
|
|
61
|
-
|
|
62
|
-
the
|
|
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
|
|
75
|
+
if in_kind is None:
|
|
71
76
|
m = sub_re.match(line)
|
|
72
77
|
if m:
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
515
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
573
|
-
|
|
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
|
-
|
|
616
|
+
stripped = line.strip()
|
|
617
|
+
if stripped.startswith("#") or stripped.startswith("//"):
|
|
577
618
|
continue
|
|
578
|
-
if
|
|
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
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
|
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(
|
|
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
|
|
2944
|
+
"PROOFCTL-TF-G043", "Cloud SQL TLS not enforced",
|
|
2874
2945
|
Severity.ERROR,
|
|
2875
|
-
f'google_sql_database_instance "{b.label2}" does not
|
|
2876
|
-
hint=
|
|
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
|
|
3347
|
-
"""
|
|
3348
|
-
|
|
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
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
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
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
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
|
-
|
|
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.
|
|
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. **
|
|
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
|
-
|
|
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`) |
|
|
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`) |
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|