Ferrule

Private, on-device Terraform security review for Chrome

View the Project on GitHub Erya-Labs/Ferrule

Ferrule rule reference

Generated from the scanner rule registry (src/lib/scanner/rules.ts) by scripts/generate-rules-doc.ts. Do not edit by hand: a test fails whenever this file is out of sync with the registry, and npm run docs:rules regenerates it.

52 rules: 9 critical, 17 high, 19 medium, 7 low.

Index

Rule Severity Title
FR-AWS-APIGW-001 low API Gateway stage has no access logging
FR-AWS-CLOUDFRONT-001 medium CloudFront distribution accepts TLS below 1.2
FR-AWS-CLOUDTRAIL-001 medium CloudTrail trail does not cover every region
FR-AWS-CLOUDWATCH-001 low CloudWatch log group has no retention period
FR-AWS-CRED-001 critical Hardcoded credentials in provider block
FR-AWS-DYNAMODB-001 medium DynamoDB table has no point-in-time recovery
FR-AWS-EBS-001 high EBS volume is not encrypted at rest
FR-AWS-EC2-001 medium EC2 instance is given a public IP address
FR-AWS-EC2-002 high EC2 instance metadata service still answers unauthenticated (IMDSv1) requests
FR-AWS-ECR-001 medium ECR repository does not scan images on push
FR-AWS-EFS-001 high EFS file system is not encrypted at rest
FR-AWS-EKS-001 high EKS cluster API endpoint is open to the internet
FR-AWS-ELB-001 high Load balancer listener accepts plaintext HTTP
FR-AWS-ELB-002 low Load balancer has no access logging
FR-AWS-IAM-001 critical IAM policy grants wildcard action on wildcard resource
FR-AWS-IAM-002 high IAM policy grants wildcard actions
FR-AWS-IAM-003 critical IAM role trust policy lets any AWS account assume the role
FR-AWS-IAM-004 high Long-lived IAM access key is created in Terraform
FR-AWS-KMS-001 medium KMS key does not have automatic rotation enabled
FR-AWS-LAMBDA-001 high Lambda function URL requires no authentication
FR-AWS-RDS-001 critical RDS instance is publicly accessible
FR-AWS-RDS-002 high RDS storage is not encrypted at rest
FR-AWS-RDS-003 medium RDS instance has no backup retention configured
FR-AWS-RDS-004 medium Database has no deletion protection
FR-AWS-S3-001 critical S3 bucket ACL grants public access
FR-AWS-S3-002 medium S3 bucket has no versioning configured
FR-AWS-S3-003 low S3 bucket has no access logging configured
FR-AWS-S3-004 medium S3 bucket has no server-side encryption configuration
FR-AWS-S3-005 high S3 bucket has no effective public access block
FR-AWS-S3-006 low Versioned S3 bucket never expires noncurrent versions
FR-AWS-SG-001 high Security group allows ingress from the entire internet
FR-AWS-SG-002 medium Security group allows unrestricted egress to the internet
FR-AWS-SG-003 medium Default security group is left with traffic rules
FR-AWS-SNS-001 medium SNS topic is not encrypted at rest
FR-AWS-SQS-001 medium SQS queue has no server-side encryption configured
FR-AZURE-KV-001 medium Key vault can be permanently deleted
FR-AZURE-NSG-001 critical Network security group exposes an admin port to the internet
FR-AZURE-SQL-001 high Azure database server is reachable from the public internet
FR-AZURE-STORAGE-001 critical Azure storage container allows anonymous public access
FR-AZURE-STORAGE-002 high Azure storage account accepts plaintext HTTP
FR-AZURE-VM-001 medium Virtual machine’s managed disks are not encrypted at host
FR-GCP-COMPUTE-001 medium Compute Engine instance is given a public IP address
FR-GCP-FIREWALL-001 critical Firewall rule exposes an admin port to the internet
FR-GCP-IAM-001 high Service account key is created in Terraform
FR-GCP-SQL-001 high Cloud SQL instance has a public IP address
FR-GCP-STORAGE-001 high GCS bucket IAM grants public access (allUsers/allAuthenticatedUsers)
FR-GCP-STORAGE-002 medium GCS bucket still allows per-object ACLs
FR-SECRET-001 critical Plaintext secret embedded in resource configuration
FR-TF-001 low Terraform block does not pin a required_version
FR-TF-002 low Provider version is not constrained
FR-TF-003 medium local-exec provisioner runs shell commands on whoever applies the plan
FR-TF-004 high S3 state backend does not ask for encryption

FR-AWS-APIGW-001 — API Gateway stage has no access logging

Severity: low

A stage without access logs keeps no record of which caller hit which route with what result, so abuse of the API is invisible and rate disputes come down to guesswork.

Recommendation: Add an access_log_settings block with a destination_arn and format so every request is recorded to CloudWatch.

References:

FR-AWS-CLOUDFRONT-001 — CloudFront distribution accepts TLS below 1.2

Severity: medium

TLS 1.0 and 1.1 are formally deprecated and accept ciphers with practical attacks, so a viewer connection can be negotiated down to a protocol the industry has retired.

Recommendation: Set minimum_protocol_version = “TLSv1.2_2021” in viewer_certificate, which requires a dedicated certificate rather than the default CloudFront one.

References:

FR-AWS-CLOUDTRAIL-001 — CloudTrail trail does not cover every region

Severity: medium

Activity in the regions the trail does not cover is invisible, and an unused region is exactly where an intruder prefers to work.

Recommendation: Set is_multi_region_trail = true so the trail follows every region, including ones the account does not otherwise use.

References:

FR-AWS-CLOUDWATCH-001 — CloudWatch log group has no retention period

Severity: low

Logs kept forever are paid for forever, and an unbounded group usually means nobody decided how far back an investigation should be able to reach.

Recommendation: Set retention_in_days to how long the logs are actually needed; unset and 0 both mean “never expire”.

References:

FR-AWS-CRED-001 — Hardcoded credentials in provider block

Severity: critical

Credentials written into a provider block are committed to version control, copied into every clone, and shared with everyone who can read the repository.

Recommendation: Remove access_key/secret_key; use environment variables, IAM roles, or a secrets manager.

References:

FR-AWS-DYNAMODB-001 — DynamoDB table has no point-in-time recovery

Severity: medium

Without point-in-time recovery a corrupting write or an accidental delete can only be undone from whatever on-demand backup happens to exist.

Recommendation: Add a point_in_time_recovery block with enabled = true so the table can be restored to any second in the retention window.

References:

FR-AWS-EBS-001 — EBS volume is not encrypted at rest

Severity: high

Encryption can only be chosen when the volume is created, so an unencrypted volume stays unencrypted — as does every snapshot and image taken from it.

Recommendation: Set encrypted = true (ideally with a customer-managed KMS key).

References:

FR-AWS-EC2-001 — EC2 instance is given a public IP address

Severity: medium

A public IP exposes every port the instance’s security groups allow directly to the internet, with no load balancer or NAT gateway in between.

Recommendation: Set associate_public_ip_address = false and reach the instance through a load balancer, NAT gateway or SSM Session Manager instead.

References:

FR-AWS-EC2-002 — EC2 instance metadata service still answers unauthenticated (IMDSv1) requests

Severity: high

IMDSv1 answers any process that can make the instance issue a plain GET, so one SSRF bug in the application hands out the instance role’s credentials.

Recommendation: Add metadata_options { http_tokens = “required” }, which makes IMDS answer only a request that first obtained a token by PUT.

References:

FR-AWS-ECR-001 — ECR repository does not scan images on push

Severity: medium

Without scan-on-push, images land in the registry carrying whatever known vulnerabilities their layers hold, and nothing ever looks at them again.

Recommendation: Add image_scanning_configuration { scan_on_push = true }, or move scanning to the registry level with an aws_ecr_registry_scanning_configuration.

References:

FR-AWS-EFS-001 — EFS file system is not encrypted at rest

Severity: high

Encryption at rest can only be chosen at creation, so fixing this later means creating a second file system and copying the data across.

Recommendation: Set encrypted = true (ideally with a customer-managed KMS key).

References:

FR-AWS-EKS-001 — EKS cluster API endpoint is open to the internet

Severity: high

A public API endpoint with unrestricted CIDRs puts the cluster control plane in front of the whole internet, one credential or authentication flaw away from cluster admin.

Recommendation: Set endpoint_public_access = false and use the private endpoint, or at least restrict public_access_cidrs to trusted ranges.

References:

FR-AWS-ELB-001 — Load balancer listener accepts plaintext HTTP

Severity: high

A plaintext listener hands session cookies, tokens and credentials to anyone on the path between the client and the load balancer.

Recommendation: Use protocol = “HTTPS” with an ACM certificate, or make the HTTP listener redirect to HTTPS.

References:

FR-AWS-ELB-002 — Load balancer has no access logging

Severity: low

Without access logs there is no record of who sent what through the load balancer, so reconstructing an attack or an outage starts from nothing.

Recommendation: Add an access_logs block with a bucket and enabled = true so every request is recorded to S3.

References:

FR-AWS-IAM-001 — IAM policy grants wildcard action on wildcard resource

Severity: critical

Action “” on Resource “” is account administrator: whoever holds it can read every store, delete every log, and grant themselves more.

Recommendation: Scope Action and Resource to the minimum set the principal requires.

References:

FR-AWS-IAM-002 — IAM policy grants wildcard actions

Severity: high

A service wildcard grants every action that service has today plus every one AWS adds to it later, which is far more than the workload was reviewed for.

Recommendation: List the specific actions the principal calls instead of a service wildcard (s3:, iam:) or an Allow on NotAction; scoping Resource does not limit what may be done to it — s3:* still includes DeleteBucket and PutBucketPolicy.

References:

FR-AWS-IAM-003 — IAM role trust policy lets any AWS account assume the role

Severity: critical

A trust policy open to “*” lets any AWS account in the world assume the role and use every permission attached to it.

Recommendation: Name the accounts, services or federated identities that may assume this role. If cross-account access is intended, pair the principal with a Condition on sts:ExternalId or aws:PrincipalOrgID.

References:

FR-AWS-IAM-004 — Long-lived IAM access key is created in Terraform

Severity: high

A static key never expires, and its secret is written to Terraform state in plaintext — which makes the state file itself a credential store.

Recommendation: Delete the aws_iam_access_key resource and give the workload a role instead (instance profile, IRSA, or OIDC federation).

References:

FR-AWS-KMS-001 — KMS key does not have automatic rotation enabled

Severity: medium

Without rotation one key protects the data for the whole life of the key, so a single disclosure exposes everything ever encrypted under it.

Recommendation: Set enable_key_rotation = true.

References:

FR-AWS-LAMBDA-001 — Lambda function URL requires no authentication

Severity: high

With authorization_type “NONE” anyone who finds the URL can invoke the function: it runs with its execution role and bills to the account, unauthenticated, from anywhere.

Recommendation: Set authorization_type = “AWS_IAM”, or put the function behind an API Gateway or CloudFront layer that authenticates callers.

References:

FR-AWS-RDS-001 — RDS instance is publicly accessible

Severity: critical

A publicly accessible instance is reachable from the whole internet, leaving the database’s own login as the only thing between it and an attacker.

Recommendation: Set publicly_accessible = false and place the instance in private subnets.

References:

FR-AWS-RDS-002 — RDS storage is not encrypted at rest

Severity: high

Storage encryption cannot be turned on after creation, and it is inherited by the instance’s snapshots and read replicas — so the gap propagates.

Recommendation: Set storage_encrypted = true.

References:

FR-AWS-RDS-003 — RDS instance has no backup retention configured

Severity: medium

At zero retention there are no automated backups and no point-in-time restore, so a bad migration or a ransom event has nothing to roll back to.

Recommendation: Set backup_retention_period to at least 7 days so point-in-time recovery is possible.

References:

FR-AWS-RDS-004 — Database has no deletion protection

Severity: medium

Without deletion protection one terraform destroy, one console click or one bad automation run drops the database, and recovery becomes a restore instead of a non-event.

Recommendation: Set deletion_protection = true on databases whose data would be missed.

References:

FR-AWS-S3-001 — S3 bucket ACL grants public access

Severity: critical

A public ACL serves every object in the bucket to anonymous callers, and bucket names are guessable enough to be found by scanners.

Recommendation: Set acl to “private” and add an aws_s3_bucket_public_access_block.

References:

FR-AWS-S3-002 — S3 bucket has no versioning configured

Severity: medium

Without versioning an overwrite or a delete is final, including one made by ransomware or by a mistaken sync from a developer’s laptop.

Recommendation: Add an aws_s3_bucket_versioning resource with status = “Enabled”.

References:

FR-AWS-S3-003 — S3 bucket has no access logging configured

Severity: low

With no access log there is no way to answer what was read from the bucket, which is the first question asked after an exposure.

Recommendation: Add an aws_s3_bucket_logging resource pointing at a log-archive bucket.

References:

FR-AWS-S3-004 — S3 bucket has no server-side encryption configuration

Severity: medium

S3 applies SSE-S3 to new objects by default, but only an explicit configuration pins the algorithm and lets a customer-managed key gate the data.

Recommendation: Add an aws_s3_bucket_server_side_encryption_configuration resource; use aws:kms with a customer-managed key where the data warrants it.

References:

FR-AWS-S3-005 — S3 bucket has no effective public access block

Severity: high

Unless all four flags are on, one future ACL or bucket policy — set by anyone with write access, or by a module — can make the bucket public.

Recommendation: Add an aws_s3_bucket_public_access_block for the bucket with block_public_acls, block_public_policy, ignore_public_acls and restrict_public_buckets all set to true.

References:

FR-AWS-S3-006 — Versioned S3 bucket never expires noncurrent versions

Severity: low

On a versioned bucket every overwrite and delete keeps the old copy forever, so storage grows without bound until someone purges it by hand.

Recommendation: Add an aws_s3_bucket_lifecycle_configuration rule with a noncurrent_version_expiration (or transition to a colder class) matching how long old versions are actually needed.

References:

FR-AWS-SG-001 — Security group allows ingress from the entire internet

Severity: high

A rule open to 0.0.0.0/0 puts the port in front of the internet’s continuous background scanning, which finds it within minutes of the apply.

Recommendation: Restrict cidr_blocks to specific trusted ranges; never use 0.0.0.0/0 for admin ports.

References:

FR-AWS-SG-002 — Security group allows unrestricted egress to the internet

Severity: medium

Unrestricted egress is what turns a foothold into a breach: it is the path a dropper downloads over and the path the data leaves by.

Recommendation: Restrict egress cidr_blocks to the destinations the workload actually needs.

References:

FR-AWS-SG-003 — Default security group is left with traffic rules

Severity: medium

Every network interface that names no security group lands in the default group, so rules left on it apply to resources nobody chose them for.

Recommendation: Declare aws_default_security_group with no ingress or egress blocks so the default group denies all traffic, and attach purpose-built groups instead.

References:

FR-AWS-SNS-001 — SNS topic is not encrypted at rest

Severity: medium

Unlike SQS, SNS applies no encryption by default, so a topic without a KMS key buffers every message it handles in plaintext.

Recommendation: Set kms_master_key_id — alias/aws/sns at minimum, a customer-managed key where the messages warrant it.

References:

FR-AWS-SQS-001 — SQS queue has no server-side encryption configured

Severity: medium

SQS encrypts new queues with its own keys by default, but only an explicit setting pins that on, and only a customer-managed KMS key lets access to the messages be gated and audited.

Recommendation: Set kms_master_key_id to a customer-managed key where the messages warrant it, or sqs_managed_sse_enabled = true to pin SQS-owned-key encryption.

References:

FR-AZURE-KV-001 — Key vault can be permanently deleted

Severity: medium

Without purge protection a deleted vault can be purged on the spot, and everything its keys encrypted is unrecoverable the moment they are gone.

Recommendation: Set purge_protection_enabled = true (with soft_delete_retention_days) so a deleted vault or key survives its retention window instead of being purged on the spot.

References:

FR-AZURE-NSG-001 — Network security group exposes an admin port to the internet

Severity: critical

SSH and RDP reachable from the internet are the two ports credential-stuffing bots scan for first, and a VM answers them with a password prompt.

Recommendation: Set source_address_prefix to the specific ranges that need SSH/RDP, or drop the rule entirely and reach the VM through Azure Bastion or a just-in-time access policy.

References:

FR-AZURE-SQL-001 — Azure database server is reachable from the public internet

Severity: high

A public endpoint makes the server reachable from any network, leaving its firewall rules and its login as the only defence.

Recommendation: Set public_network_access_enabled = false and reach the server over a private endpoint or a VNet service endpoint; the setting defaults to true, so leaving it out publishes the server.

References:

FR-AZURE-STORAGE-001 — Azure storage container allows anonymous public access

Severity: critical

A container set to blob or container access serves its objects to anonymous callers over the internet, with no key or SAS token required.

Recommendation: Set container_access_type = “private”.

References:

FR-AZURE-STORAGE-002 — Azure storage account accepts plaintext HTTP

Severity: high

With HTTPS-only off, the blobs themselves, any SAS token in the query string, and a replayable signed request all travel in cleartext.

Recommendation: Remove the flag or set it to true (https_traffic_only_enabled on azurerm 4.x, enable_https_traffic_only before it) so blobs, files and queues can only be read over TLS.

References:

FR-AZURE-VM-001 — Virtual machine’s managed disks are not encrypted at host

Severity: medium

Server-side encryption covers the managed disk itself but not the VM host, so without encryption at host the temp disk and the disks’ caches hold plaintext on shared hardware.

Recommendation: Set encryption_at_host_enabled = true (the subscription must have the EncryptionAtHost feature registered, and the VM size must support it).

References:

FR-GCP-COMPUTE-001 — Compute Engine instance is given a public IP address

Severity: medium

An access_config block is the external IP: with one, the VM is on the internet and only its firewall rules stand between it and a scanner.

Recommendation: Drop the access_config block so the instance has no external IP, and reach it through Cloud NAT for egress, an HTTPS load balancer for ingress, and IAP TCP forwarding for SSH.

References:

FR-GCP-FIREWALL-001 — Firewall rule exposes an admin port to the internet

Severity: critical

An ingress rule reaching 22 or 3389 from 0.0.0.0/0 puts SSH or RDP in front of the internet’s continuous scanning for exactly those ports.

Recommendation: Restrict source_ranges to the ranges that need SSH/RDP, or remove the rule and use IAP TCP forwarding (which needs only 35.235.240.0/20) so the port is never reachable from the internet.

References:

FR-GCP-IAM-001 — Service account key is created in Terraform

Severity: high

A service account key never expires, and one generated by Terraform has its private key written into state in plaintext — so whoever can read state holds the identity for good.

Recommendation: Delete the google_service_account_key resource and use workload identity federation or an attached service account; if an external system truly needs a key, create it outside Terraform so the private key never touches state.

References:

FR-GCP-SQL-001 — Cloud SQL instance has a public IP address

Severity: high

A public IP makes the database reachable from any network, leaving the authorized-networks list and the password as the only defence.

Recommendation: Set settings.ip_configuration.ipv4_enabled = false and give the instance a private_network instead; ipv4_enabled defaults to true, so leaving it out puts the database on a public address.

References:

FR-GCP-STORAGE-001 — GCS bucket IAM grants public access (allUsers/allAuthenticatedUsers)

Severity: high

allUsers publishes the bucket to the internet, and allAuthenticatedUsers means any Google account in existence — not any account in this organization.

Recommendation: Remove allUsers/allAuthenticatedUsers members from the bucket IAM binding.

References:

FR-GCP-STORAGE-002 — GCS bucket still allows per-object ACLs

Severity: medium

While object ACLs are live, a single object can be shared publicly without the bucket’s IAM policy ever saying so, which makes access unauditable.

Recommendation: Set uniform_bucket_level_access = true so access is decided by IAM alone.

References:

FR-SECRET-001 — Plaintext secret embedded in resource configuration

Severity: critical

A literal secret in configuration is in version control, in every clone of it, and in Terraform state — so rotating it is the only real fix.

Recommendation: Store secrets in AWS Secrets Manager / Vault and reference them; never inline literals.

References:

FR-TF-001 — Terraform block does not pin a required_version

Severity: low

Unpinned, one configuration can be applied by a laptop, a colleague and CI with three different Terraform versions — one of which upgrades the state format.

Recommendation: Add required_version (e.g. “>= 1.9.0, < 2.0.0”) to the terraform block; any one block in the directory carrying it is enough.

References:

FR-TF-002 — Provider version is not constrained

Severity: low

Unconstrained, terraform init takes whatever the registry serves that day, so a provider release can change what an unmodified configuration does to production.

Recommendation: Declare the provider in required_providers with a version constraint (e.g. version = “~> 5.0”), and commit the resulting lock file.

References:

FR-TF-003 — local-exec provisioner runs shell commands on whoever applies the plan

Severity: medium

local-exec runs on the applying machine with its credentials and filesystem, is invisible in terraform plan, and logs its command line — secrets included.

Recommendation: Replace the provisioner with a real resource, user_data / cloud-init, or an explicit pipeline step — a failing local-exec also leaves the resource tainted.

References:

FR-TF-004 — S3 state backend does not ask for encryption

Severity: high

State records every attribute of every resource in plaintext — database passwords, generated private keys, certificate bodies — and the backend is what protects it.

Recommendation: Set encrypt = true on the backend “s3” block, or name a kms_key_id, so the state object is at least as protected as the resources it describes.

References: