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.
- {proofctl-0.4.3/proofctl.egg-info → proofctl-0.5.0}/PKG-INFO +5 -5
- {proofctl-0.4.3 → proofctl-0.5.0}/README.md +4 -4
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/terraform.py +370 -73
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/engine.py +30 -0
- {proofctl-0.4.3 → proofctl-0.5.0/proofctl.egg-info}/PKG-INFO +5 -5
- {proofctl-0.4.3 → proofctl-0.5.0}/pyproject.toml +1 -1
- {proofctl-0.4.3 → proofctl-0.5.0}/LICENSE +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/MANIFEST.in +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/__init__.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/_globmatch.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/baseline.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/__init__.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/base.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/dockerfile.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/hcl_utils.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/imports.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/leakage.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/llm_integration.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/placeholders.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/quality.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/secrets.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/security.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/shell_checker.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/terragrunt.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/variants.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/checkers/yaml_checker.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/cli.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/config.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/fixer.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/models.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/registry.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/reporters/__init__.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/reporters/html_reporter.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/reporters/json_reporter.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl/reporters/terminal.py +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl.egg-info/SOURCES.txt +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl.egg-info/dependency_links.txt +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl.egg-info/entry_points.txt +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl.egg-info/requires.txt +0 -0
- {proofctl-0.4.3 → proofctl-0.5.0}/proofctl.egg-info/top_level.txt +0 -0
- {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.
|
|
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)
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
518
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
|
|
616
|
+
stripped = line.strip()
|
|
617
|
+
if stripped.startswith("#") or stripped.startswith("//"):
|
|
580
618
|
continue
|
|
581
|
-
if
|
|
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
|
|
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
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
|
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(
|
|
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
|
|
2944
|
+
"PROOFCTL-TF-G043", "Cloud SQL TLS not enforced",
|
|
2877
2945
|
Severity.ERROR,
|
|
2878
|
-
f'google_sql_database_instance "{b.label2}" does not
|
|
2879
|
-
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).',
|
|
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
|
|
3526
|
-
"""
|
|
3527
|
-
|
|
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
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
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
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
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
|
-
|
|
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.
|
|
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
|