DedSec Application Security ·
Consider a pull request that adds a module call for a static-assets bucket, sourced from a registry module. Two inputs decide whether the bucket is public: a bucket policy document built from a data source, and a flag that controls the public access block, set from a root variable with no default. The production value of that variable is in a tfvars file that the deployment pipeline passes with -var-file, and the file lives in another repository. The IaC scan on the pull request reports nothing. Two weeks later an external check finds the bucket returning its object listing to anyone who asks.
The scanner did not malfunction. It evaluated what it could see, and the value that decided the outcome was somewhere it could not see. Many Terraform scanning surprises come from that gap, between the configuration in the repository and the configuration Terraform actually applies. This post covers where that gap opens, what a check does when it reaches it, what scanning the plan instead fixes and what it does not, and how inline suppressions behave across both.
Everything below comes from HashiCorp's Terraform CLI and language documentation, the AWS provider's resource documentation and version 4 upgrade guide, the Amazon S3 user guide, and the Checkov, Trivy and tfsec documentation. The tools are named only where their syntax or documented behaviour is being cited.
A Terraform scanner reading .tf files does more than pattern-match. It parses HCL, builds the graph of resources, locals, variables and module calls, and evaluates whatever expressions it can: literals, locals built from literals, variable defaults, functions over known values, and any variable files it has been given. Trivy's documentation describes this directly: it scans the Terraform files it finds and evaluates variables, imports and other elements to detect misconfigurations.
What it cannot do is run Terraform's own value assembly. Terraform takes a variable's value from, in order of precedence: -var and -var-file options on the command line and variables set in HCP Terraform; any *.auto.tfvars or *.auto.tfvars.json files, in lexical order; terraform.tfvars.json; terraform.tfvars; TF_VAR_ environment variables; and finally the variable's default, which has the lowest precedence. Of those, a scanner in a pull request typically sees the files committed to the repository and the defaults. Command-line files from the pipeline, workspace variables and environment variables are not in the checkout.
Values that are commonly unknown at source-scan time:
When a check reaches a value it cannot resolve, it has three options, and each fails in its own way.
Skip it. The check cannot decide, so it reports nothing. A missing result and a pass look identical in most output, so the bucket in the opening example passes without ever having been evaluated. This is the infrastructure version of the problem in the post on scans that pass without running: the absence of a finding is being read as a finding of absence.
Assume a value. The check substitutes the variable's default, or a fallback. Trivy's documentation says expressions depending on data blocks or computed attributes may be evaluated as unknown or may fall back to default values, for example when the try function is used. Assuming the default is reasonable when the default is what ships. It is wrong when the default is the safe development value and production overrides it, which is a common reason for a variable to exist in the first place.
Flag it. The check treats "cannot prove it is safe" as unsafe. That catches the opening example, but it raises a finding on every module input that is genuinely set correctly in the pipeline. Engineers learn to suppress the check, and a suppression written to silence noise does not distinguish the noisy cases from the real one.
Trivy's documentation states the consequence plainly: these limits can lead to false positives or false negatives. The practical requirement is that you know which of the three your scanner does for each check, because the result means something different in each case. The best behaviour, as general practice, is a fourth outcome: report the check as unresolved, name the input it could not resolve, and let the gate decide what an unresolved security check is worth.
A variable is the most common way that the answer to a security question leaves the repository. A list of allowed ingress CIDRs, a flag that enables public access, a KMS key ARN, an instance's subnet: each can be a variable whose real value is set per environment.
Two patterns produce wrong results from a source scan:
Both tools cited here accept variable files, so the fix for the second pattern is to give the scanner the same inputs the pipeline uses. Checkov takes --var-file to load variable files in addition to the default files; its CLI reference says this is supported for source Terraform and Helm scans, and requires --directory rather than --file. Trivy takes --tf-vars, for example trivy config --tf-vars dev.terraform.tfvars ./infrastructure/tf. Scan once per environment, with that environment's files. This is the same discipline as rendering a Helm chart with each environment's values before reviewing it, which the Kubernetes RBAC post covers.
That still leaves values that are not in any file: workspace variables and secrets injected as environment variables. Those are resolved only by Terraform itself, which is the case for scanning the plan.
A module block with a remote source moves resources out of the repository. module "assets" with a registry source and four inputs may create a bucket, a policy, a public access block and a CloudFront distribution, and none of them is in the files under review.
Terraform installs module source code during terraform init, retrieving it from the location given in each source argument. Three details of that installation matter for scanning:
So a module that was scanned and found clean is clean at the version the scanner fetched, which need not be the version that gets applied.
Scanners handle remote modules differently. Checkov downloads external modules from public Git repositories and the Terraform registry when run with --download-external-modules true, into a folder named .external_modules, and reads private-registry and private-repository tokens from environment variables. It also has an experimental mode, CHECKOV_EXPERIMENTAL_TERRAFORM_MANAGED_MODULES=True, that reuses the modules terraform init already placed in .terraform, for scans of the root folder where init was run.
Trivy downloads modules with remote sources by default, and its documentation says this cannot be turned off from the CLI. It also gives a warning worth repeating: scanning an untrusted configuration can cause network requests to hosts chosen by its author, including services reachable from the scanning environment. A module source is a URL. A scanner that fetches it from inside your CI network can be pointed at an internal service, the way a server-side request forgery would. Trivy's advice is to allow outbound traffic only to trusted registries and repositories and to block sensitive internal services such as cloud instance metadata endpoints, or to run terraform init in an environment you control and include the resulting .terraform/modules directory, which Trivy reuses. The same applies to any scanner that resolves module sources, and to terraform init itself when it runs on an untrusted pull request.
Trivy's --tf-exclude-downloaded-modules flag keeps downloaded modules evaluated but marks their findings as ignored. That is useful for separating your code's findings from a third party's, as long as nobody reads the shorter report as a cleaner estate. A misconfiguration inside a module is often caused by the inputs you passed it, and the fix is usually in your module block, not in theirs.
Terraform's documentation says it attempts to read data sources during planning, and may defer reading until apply when an argument refers to a value it cannot predict during the plan, such as an attribute of a managed resource that is changing in the same plan. A static scanner does not read them at all. An ingress rule whose CIDR comes from a data source, a bucket policy built from a data source's output, an AMI chosen by a lookup: the check sees a reference, not a value.
Computed attributes are unknown for a different reason: the object does not exist yet. Most cross-resource links in Terraform are references to computed attributes, for example bucket = aws_s3_bucket.assets.id on a companion resource. A source scanner can still connect the two, because the reference expression is in the code even though its value is not. That distinction comes back when we look at the plan.
Many security-relevant settings in the AWS provider are not arguments of the resource they apply to. A check that reads only the main resource block is reading part of the configuration.
S3 buckets. Version 4.0.0 of the AWS provider made a set of aws_s3_bucket arguments read-only, to distribute bucket settings across standalone resources. The upgrade guide maps them:
Version 4.9.0 reversed the breaking part: the arguments became configurable on aws_s3_bucket again, and the current resource documentation marks each of them Deprecated, to be removed in a future major version. The public access block is not in that table. It has no argument on aws_s3_bucket at all and is managed only through aws_s3_bucket_public_access_block, with an account-wide equivalent in aws_s3_account_public_access_block.
What that does to a check:
Defaults that changed underneath the checks. The S3 user guide says new buckets, access points and objects do not allow public access by default, and that new buckets are created with the Bucket owner enforced setting, which disables ACLs. It also says that since January 5, 2023, SSE-S3 encryption is applied to all new buckets and to existing buckets that did not already have default encryption, and new object uploads are encrypted automatically. Two consequences. A bucket with no encryption resource is not unencrypted, so a check that reports it as such describes the API before 2023; the question that remains is whether SSE-S3 is the right key management for that data. And under Bucket owner enforced, the user guide says ACLs no longer affect permissions and requests to set them fail. The provider's aws_s3_bucket_acl examples accordingly declare an aws_s3_bucket_ownership_controls resource with BucketOwnerPreferred and make the ACL depend on it. A public-read ACL in the code only means something together with ownership controls that enable ACLs, which is a third resource the check has to read.
A drift trap in the same change. The v4 upgrade guide documents that from 4.9.0, Terraform performs drift detection on those deprecated aws_s3_bucket arguments only when a value is configured. If one was configured and is then removed from the block entirely, Terraform does not pick up the change, and the guide shows the plan reporting "No changes. Your infrastructure matches the configuration." The code no longer has the CORS rule or the ACL; the bucket still does. A source scan reads the code and reports it clean.
Security groups. An ingress rule open to the internet can be written three ways: an inline ingress block on aws_security_group, an aws_security_group_rule, or an aws_vpc_security_group_ingress_rule. The provider documentation calls the last of these, with one CIDR block per rule, the current best practice, and warns against mixing the inline form with either standalone form because it may cause rule conflicts, perpetual differences and overwritten rules. A check for open ingress has to read all three resource types and the attribute names each uses:
And a rule open to 0.0.0.0/0 is a fact about a security group, not about exposure. Whether anything internet-routable is attached to the group is a question about network interfaces, load balancers and subnets, several resources away and often in another state file.
The plan is Terraform's own resolution of the configuration, so it fills most of the gaps above. The sequence is three commands:
Both cited scanners accept the result: Checkov with checkov -f tfplan.json, and Trivy with trivy config tfplan.json, or trivy config tfplan on the binary plan file directly. HashiCorp documents the JSON output format, and a few of its fields do most of the work for a security check:
Credentials and state. A plan reads current state from the backend, refreshes remote objects and reads data sources, so it needs initialised backend access and provider credentials. That is why it belongs in the pipeline stage that already holds them, not on an untrusted fork's pull request. terraform show -json on a plan also requires that the plan was created without -refresh=false.
The plan file is a secret. HashiCorp's plan documentation says the saved file contains your full configuration, all values associated with planned changes and all plan options including input variables, and that sensitive data, even if obscured in terminal output, is saved in cleartext. The show documentation adds that with -json, sensitive values are displayed in plain text. Checkov's plan-scanning documentation advises running plan evaluation in a secure CI/CD pipeline for the same reason. In practice: do not publish tfplan or tfplan.json as a downloadable build artifact, do not paste either into a pull request comment, keep retention short, and treat a plan file that reaches a repository as a leaked secret. The post on secrets in git history covers why deleting it afterwards is not enough. Terraform 1.10 and later can mark variables and child module outputs as ephemeral, and Terraform 1.11 adds write-only arguments on managed resources; HashiCorp documents both as ways to keep values out of state and plan files.
Values known after apply. A plan cannot know the ID of a bucket it has not created. In planned_values those leaves are simply omitted, and in resource_changes they are absent from after and marked true in after_unknown. A check that reads a missing attribute as false or empty will misjudge it. A check has to consult after_unknown and treat unknown as unknown.
Deferred data sources. If a data source depends on a resource changing in the same plan, its read is deferred to apply, and its values are unknown in the plan too.
Some references. Trivy's documentation records that the plan does not carry complete reference information for expressions that use each or count. Its example is an aws_s3_bucket_acl with for_each whose bucket argument is aws_s3_bucket.this[each.key].id: the plan does not say which attribute of the bucket the ACL refers to, so the cross-resource link that the split-resource pattern depends on can be lost. Checkov's documentation makes a similar point about locals, and offers --repo-root-for-plan-enrichment together with --deep-analysis to combine the plan with the source graph.
Anything that is not stored in the plan. Checkov's documentation lists checks it ignores on plan files because they evaluate the lifecycle block, which is not stored in the plan. And comments are not in the plan, which matters for the next section.
One environment at a time. A plan is for one workspace with one set of inputs. Production has its own plan, and a clean staging plan says nothing about it.
Line numbers. A plan finding names a resource address such as module.assets.aws_s3_bucket.this, not a file and line. Mapping it back to a line in the pull request needs the source.
Terraform configuration describes intended state. A plan describes intended state compared with Terraform's view of the resources it manages. Neither describes what is deployed.
The only view that is independent of Terraform's records is the outside one: what actually answers on the internet, under which names, with which configuration. That view cannot tell you which file to change, and an IaC scan cannot tell you what is exposed. Each covers what the other misses.
Every Terraform scanner has an in-code way to say "this finding is accepted here". The syntax differs, and so does what it records.
Checkov. The comment checkov:skip=<check_id>:<suppression_comment> goes inside the scope of the Terraform block it applies to. The documentation's own example is checkov:skip=CKV_AWS_20:The bucket is a public static content host, written as a comment inside the aws_s3_bucket block. The suppression comment is optional, and when present it is printed in the output alongside SKIPPED for that resource.
Trivy. The comment trivy:ignore:<rule> goes immediately after the line-comment token, adjacent to the resource definition. The rule can be the check's ID (for example AVD-AWS-0089, not case-sensitive), one of its aliases, or its long ID in the form <provider>-<service>-<short-code>, such as aws-s3-enable-logging. Several ignores can share one line. An expiry date can be appended as exp:yyyy-mm-dd, after which the ignore stops applying. An ignore can be narrowed to resource instances by attribute, for example aws-ec2-no-public-ingress-sgr[from_port=3306] on a rule created with for_each, scoped to a workspace with ws:, and placed above a module block to cover findings inside a third-party module. trivy:ignore:* ignores every check. The syntax has no field for a reason, so the reason has to be written in an ordinary comment next to it.
tfsec. The comment tfsec:ignore:<rule> goes on the offending line, on the line above the block, or on the module block to cover a module. It also accepts :exp:yyyy-mm-dd. tfsec's README now encourages users to move to Trivy, describing it as the same Terraform scanning engine.
Why a suppression without a reason is a liability:
The general practice that follows: every suppression carries a reason that names the condition it depends on, an owner, and where the tool supports it, an expiry. Suppression decisions are counted and reviewed, not just accumulated. The post on suppression at scale covers why that number tells you more about a backlog than the count of detections does.
None of these scans is sufficient alone, and they cost different amounts. A workable order, as general practice:
AppSecD runs Terraform, CloudFormation, Helm, Ansible and Pulumi rules, and its results land in the same deduplicated finding model as the platform's other scanning, so an issue is tracked once no matter which engine found it. Pull requests and commits can be gated by policy, and the lifecycle supports maker-checker approval, so a finding marked fixed or false positive can require a second person to confirm it. Maphra works from the outside in, starting from a domain name with no agent and no credential into your estate, and continuously discovers and attributes internet-facing assets, including cloud endpoints and the shadow IT nobody registered. It does not read Terraform or compare against Terraform state, which is what makes its inventory an independent outside-in view for step 3. The scanning order, plan handling and suppression practice in this post are general practice, not a description of how AppSecD's checks are implemented, and nothing here claims that AppSecD scans plan JSON, resolves remote modules or detects drift.
A clean Terraform scan says the configuration it could resolve contains no violations of the checks it ran. Whether the infrastructure is safe also depends on the inputs it never saw, the modules it never fetched, and the resources that were never in Terraform at all.