When a Terraform configuration calls a module — whether it's a two-line local reference or a pinned version from the public Registry — a static analysis tool has to make a choice: fetch and expand that module to see what it actually creates, or infer its contents from the source alone. That choice determines whether a misconfigured S3 bucket or an over-permissioned IAM role buried three modules deep ever shows up in scan results. Snyk IaC, one of the most widely used infrastructure-as-code scanners, handles this differently depending on whether you're scanning raw .tf source or a generated Terraform plan. The distinction matters a lot more than most teams realize, because it directly affects whether remote modules — the ones pulled from GitHub, the Terraform Registry, or an internal Git server — get evaluated at all. This post walks through the mechanics publicly documented by Snyk and Terraform itself, and where the resulting gaps tend to show up.
How does Snyk IaC read Terraform files without running Terraform?
Snyk IaC's default mode is a static parse: it reads .tf and .tfvars files directly off disk and builds an internal resource graph without invoking terraform init, terraform plan, or any provider plugin. This is what lets snyk iac test run against a bare repository checkout in seconds, with no cloud credentials and no need to download provider binaries. The parser walks HCL syntax, resolves variable references and locals blocks, and evaluates resource attributes that are statically determinable — a hardcoded acl = "public-read" on an aws_s3_bucket, for instance, is trivial to catch this way. The tradeoff is that anything requiring actual Terraform execution — data source lookups, provider-side defaults, or values computed at apply time — is invisible to a purely static pass. Snyk's own documentation describes this as scanning the "Terraform configuration" rather than the "Terraform state," and that distinction is the root of everything else in how module resolution behaves.
What happens when a Terraform module is referenced locally?
Local modules — anything using a relative path like source = "./modules/vpc" — get parsed inline because Snyk IaC scans the whole directory tree, not just the file you point it at. Since the module's source files sit right next to the calling configuration in the same repository, the static parser can open them, walk their resource blocks, and merge them into the same resource graph as the root module. This works well for the common pattern of a company keeping its own reusable modules in a modules/ subdirectory of the same repo, or in a submodule checked out alongside it. Variable values passed into the module from the caller are substituted where they're statically resolvable, so a misconfiguration inside a local module — say, a security group ingress rule that allows 0.0.0.0/0 — is reported with the correct file path and line number, not just flagged against the wrapper that calls it.
How does Snyk IaC handle remote module sources like the Registry or Git?
Remote sources — Terraform Registry addresses like terraform-aws-modules/vpc/aws, or Git URLs such as git::https://github.com/org/repo.git//modules/foo?ref=v3.2.0 — are not fetched or downloaded during a static scan, because that would require the same network access and initialization step (terraform init) that Snyk's static mode is designed to avoid. In practice this means the module call itself — its name, source string, version constraint, and the input variables passed to it — is visible and can be linted, but the actual resources the remote module provisions are not expanded into the graph. If a popular VPC module on the Registry defaults to enabling flow logs or leaving a subnet auto-assigning public IPs, that behavior lives inside code Snyk's static parser never opens. This is consistent with how most static-only IaC scanners work generally, since resolving a remote source correctly requires the same dependency-fetching Terraform itself does before it can build a plan.
Why do scan results change when you feed Snyk a Terraform plan instead of source code?
Scanning a generated plan (via terraform show -json output, or Snyk's own plan-based workflow) produces a materially different result set because the plan already reflects a fully resolved configuration — Terraform has already run init, downloaded every module and provider, resolved every variable, and in the case of a full apply-preview, queried data sources. Feeding that JSON into snyk iac test (or the equivalent CI integration) means every resource created by a remote module, at every level of nesting, shows up as a concrete, fully-populated resource with real attribute values, not a module reference. This is the documented reason Snyk recommends plan-based scanning for accuracy-sensitive pipelines: a team that only scans source code on terraform-aws-modules/eks/aws version 19.x, for example, would see zero findings inside that module's 40-plus nested resources, while a plan-based scan of the same configuration surfaces every one of them, including any IAM policies or security group rules the module attaches by default.
Does version pinning or .terraform.lock.hcl affect what Snyk IaC scans?
No — the lock file and version constraints are metadata that Snyk IaC's static parser can read, but they don't cause it to fetch the pinned artifact. A required_providers block or a version = "~> 3.0" constraint on a module tells Terraform which release to download during init; Snyk's source-level scan simply records that constraint as a string attribute of the module call rather than resolving it to an actual codebase. That means two commits pinning version = "3.14.0" versus version = "5.1.2" of the same module will look identical to a static Snyk scan even though the underlying resources — and their default security posture — may have changed significantly between those releases. Only a plan generated after Terraform has actually resolved and downloaded the pinned version will reflect which release is in effect.
What blind spots does this create for supply-chain risk in Terraform?
The practical gap is that static-only scanning treats every remote module as a trust boundary it cannot see across, which is exactly the boundary that matters most for supply chain risk. Terraform Registry modules and third-party Git sources are, functionally, external code dependencies — no different in kind from an npm package or a container base image — and a compromised or maliciously altered module version can introduce a backdoor IAM policy, an exfiltration-friendly network rule, or a disabled encryption default without a single line changing in the calling repository's own .tf files. A static scan of the caller sees only the source string and version pin; it has no visibility into whether that referenced commit, tag, or Registry release was quietly swapped for something malicious. This mirrors a well-known class of software supply chain incidents in package ecosystems, applied to infrastructure code instead, and it's why plan-based or state-based scanning — despite requiring credentials and a full init/plan cycle — is the more complete option when third-party modules make up a meaningful share of an environment's infrastructure.
How Safeguard Helps
Safeguard's supply chain security platform is built around the same principle this post keeps circling back to: a dependency you can't see fully resolved is a dependency you can't actually assess. For infrastructure-as-code specifically, Safeguard tracks the provenance and version history of third-party Terraform modules the same way it tracks package and container dependencies — flagging when a pinned module version changes, when a source moves from a verified Registry namespace to an unverified fork, or when a module's resolved resource graph introduces new IAM permissions or network exposure compared to the last known-good version. Rather than treating remote modules as an opaque reference to trust, Safeguard correlates module source metadata against its own provenance signals so teams get visibility into what a source string actually points to today, not just what the constraint string says. Combined with SBOM and dependency graph monitoring across the rest of the software supply chain, this gives security teams a single place to catch the kind of quiet upstream change that a source-only IaC scan, by design, can't surface on its own.