Skip to content

Cache Terraform HCL parses during line detection#228

Merged
whitemerch merged 2 commits into
mainfrom
chakib.hamie/cache_terraform_hcl_parse
Jul 1, 2026
Merged

Cache Terraform HCL parses during line detection#228
whitemerch merged 2 commits into
mainfrom
chakib.hamie/cache_terraform_hcl_parse

Conversation

@whitemerch

@whitemerch whitemerch commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Motivation

Terraform line detection calls hclsyntax.ParseConfig once per finding to locate resource blocks. Rules that fire thousands of times on the same files (for example team-tag checks) spend most of their decode time re-parsing identical .tf sources.

Changes

Terraform detector

Add a per-scan sync.Map on DetectKindLine that caches parsed *hclsyntax.Body by file path. locateTerraformBlock now receives a pre-parsed body instead of parsing on every call.

Inspector

Register the Terraform detector as a pointer so the cache is not copied across calls.

Author Checklist

  • I have reviewed my own PR.
  • I have added or updated relevant unit tests where necessary. If no tests are added, I've explained why.
  • All new and existing tests pass.
  • I have tested my changes on staging (if applicable).
  • I have updated any relevant documentation (if applicable).

QA Instruction

  1. go test ./pkg/detector/terraform/...
  2. Build the scanner and run a large Terraform repo.
  3. Compare scan duration and slow-rule logs before/after; findings count should be unchanged.

Blast Radius

CLI scan decode path for Terraform findings only. No change to Rego evaluation, fingerprints, or non-Terraform platforms.

Additional Notes

I submit this contribution under the Apache-2.0 license.

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Jun 30, 2026

Copy link
Copy Markdown

🎯 Code Coverage (details)
Patch Coverage: 44.44%
Overall Coverage: 50.29% (-0.02%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: d1bdfd7 | Docs | Datadog PR Page | Give us feedback!

@whitemerch
whitemerch force-pushed the chakib.hamie/cache_terraform_hcl_parse branch from c2cc25c to 2cff01a Compare June 30, 2026 18:10
@whitemerch
whitemerch marked this pull request as ready for review July 1, 2026 08:08
@whitemerch
whitemerch requested a review from a team as a code owner July 1, 2026 08:08

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2cff01a3b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +41 to +42
if cached, ok := d.hclCache.Load(filePath); ok {
return cached.(*hclsyntax.Body), nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset the Terraform parse cache for each scan

When the same Inspector/DetectKindLine is reused for a later Inspect call over the same path after the file content changes, this cache hit returns an AST parsed from the previous content because the key is only filePath and the cache is never cleared. The search logic still uses the current OriginalData/LinesOriginalData, so locateTerraformBlock can report stale block ranges or even panic while slicing the new lines if the old cached block extends past the new file. Clear this cache per Inspect call or include a per-file identity/content hash in the key.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As currently designed, there is no path in the codebase today where stale cache entries can be read. The cache in DetectKindLine starts empty for every scan, populated only within that scan's single Inspect() call, then discarded when the Inspector is GC'd

@NathanGallet-dd

Copy link
Copy Markdown
Contributor

I think this cache is a good local improvement, but it looks like we are still parsing Terraform twice in the full scan pipeline.

The first parse happens during ingestion in pkg/parser/terraform/terraform.go:227 (Parser.Parse). The actual HCL parse is at pkg/parser/terraform/terraform.go:240:

file, diagnostics := hclsyntax.ParseConfig(resolved, filepath.Base(path), hcl.Pos{Byte: 0, Line: 1, Column: 1})

That parsed *hcl.File / *hclsyntax.Body is then converted into a generic model.Document at pkg/parser/terraform/terraform.go:259-260:

fc, parseErr := p.convertFunc(ctx, file, inputVariables)
json, err := addExtraInfo(ctx, []model.Document{fc}, path)

After Parser.Parse returns, the HCL syntax tree is not retained. runner.sink starts at pkg/runner/sink.go:32, calls the parser at pkg/runner/sink.go:50, and then stores only the JSON-like document and source text in model.FileMetadata at pkg/runner/sink.go:89-100:

Document:          preparedDocument,
LineInfoDocument:  document,
OriginalData:      documents.Content,
LinesOriginalData: utils.SplitLines(documents.Content),

Later, when building vulnerability line/block locations, pkg/detector/terraform/terraform_detect.go:59 (DetectLine) needs HCL range information. The new cache/reparse call is at pkg/detector/terraform/terraform_detect.go:109, and block lookup happens in locateTerraformBlock at pkg/detector/terraform/terraform_detect.go:148. That code uses range information at pkg/detector/terraform/terraform_detect.go:162-168, such as:

block.TypeRange.Start
block.Body.SrcRange.End
attr.SrcRange.Start
attr.SrcRange.End

Attribute ranges are also used when finding nested structures at pkg/detector/terraform/terraform_detect.go:258-270.

Those ranges are not available from model.Document, _dd_lines, OriginalData, or ResolvedFiles. FileMetadata is defined at pkg/model/model.go:154-168, and ResolvedFile only stores path/line content at pkg/model/model.go:360-363; it does not carry the HCL AST. Because of that, the detector reparses the file with hclsyntax.ParseConfig. This PR improves that by caching the second parse per file, but it does not eliminate the duplicate parse between ingestion and line detection.

Could we investigate a one-time parse design? For example, the Terraform parser could retain/share the parsed *hclsyntax.Body in a per-scan sidecar cache keyed by file ID/content/path, and the Terraform detector could read from that cache instead of reparsing OriginalData.

A few things to be careful about:

  • The parser interface is shared by all languages (pkg/parser/parser.go:26-34), so Terraform-specific syntax data should probably be optional/internal rather than part of the OPA Document.
  • The HCL AST should not be included in Document, reports, or OPA input.
  • The detector needs ranges matching the user-visible source text. Today Terraform Resolve returns the original content at pkg/parser/terraform/terraform.go:73-85, but if that ever changes, we need to ensure the cached AST and OriginalData/LinesOriginalData refer to the same bytes.
  • The cache key should avoid stale parses if an Inspector or detector is reused across scans. A content hash or file ID + content hash may be safer than path alone.

So the current PR is useful, but it may be worth exploring whether the first Terraform parse can be preserved and reused for block/remediation location detection.

…igh-volume rules avoid re-parsing the same source on every finding.
@whitemerch
whitemerch force-pushed the chakib.hamie/cache_terraform_hcl_parse branch from c5419cd to 0e1f000 Compare July 1, 2026 12:13
@NathanGallet-dd

NathanGallet-dd commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

I checked the server mode path for the Terraform HCL cache lifetime.

Current behavior looks safe. Even though serve is a long-lived process, it does not keep a long-lived Inspector between /ide/v1/iac/analyze calls. Each analyze request builds a new request-local scan object graph:

  • Server.analyze starts the request scan path at pkg/server/analyze.go:162.
  • Server.analyze builds a new in-memory filesystem from the pushed request files at pkg/server/analyze.go:167.
  • Server.analyze creates a new scan.Client at pkg/server/analyze.go:209.
  • Client.Scan calls executeScan at pkg/scan/client.go:189.
  • executeScan calls initScan at pkg/scan/scan.go:160.
  • initScan creates a new engine.NewInspector at pkg/scan/scan.go:88.
  • NewInspector creates a new hclcache.Cache at pkg/engine/inspector.go:195.
  • NewInspector stores that cache on the inspector at pkg/engine/inspector.go:208.
  • createService wires the same inspector cache into the Terraform parser at pkg/scan/scan.go:278 and pkg/scan/scan.go:280.

So with the current architecture, the Terraform HCL cache is effectively scoped to one analyze request / one scan. A second server analyze request with the same file path but changed content should not see a stale *hclsyntax.Body from the previous request.

That said, I think we should add a regression test in pkg/server/analyze_test.go to lock this behavior down. The important invariant is: the HCL cache must not persist between two server analyze requests.

A good test would:

  1. Send an analyze request with infra/main.tf containing a Terraform resource.
  2. Send a second analyze request to the same server with the same infra/main.tf path but different content, ideally moving/changing the resource block enough that stale HCL ranges would be visible.
  3. Assert that the second finding's line, block location, or resource source comes from the second request's content, not from the first request's cached parse.

This matters for future server optimizations. Keeping the inspector or rules warm between requests would be attractive for latency, but with the current path-only cache key it could make stale parses possible unless the cache is also reset or keyed by content.

Side note, not mandatory for this PR: I am also a little worried about the memory footprint. The cache stores one parsed HCL body per Terraform file for the duration of the scan/request. For large repos this may be fine, but a lightweight metric or debug log could be useful later, for example number of cached HCL bodies, cache hit/miss count, and maybe total source bytes represented. That would help us validate the CPU win against the memory cost.

@whitemerch
whitemerch force-pushed the chakib.hamie/cache_terraform_hcl_parse branch from d317044 to d1bdfd7 Compare July 1, 2026 14:36
@whitemerch

Copy link
Copy Markdown
Contributor Author

I checked the server mode path for the Terraform HCL cache lifetime.

Current behavior looks safe. Even though serve is a long-lived process, it does not keep a long-lived Inspector between /ide/v1/iac/analyze calls. Each analyze request builds a new request-local scan object graph:

  • Server.analyze starts the request scan path at pkg/server/analyze.go:162.
  • Server.analyze builds a new in-memory filesystem from the pushed request files at pkg/server/analyze.go:167.
  • Server.analyze creates a new scan.Client at pkg/server/analyze.go:209.
  • Client.Scan calls executeScan at pkg/scan/client.go:189.
  • executeScan calls initScan at pkg/scan/scan.go:160.
  • initScan creates a new engine.NewInspector at pkg/scan/scan.go:88.
  • NewInspector creates a new hclcache.Cache at pkg/engine/inspector.go:195.
  • NewInspector stores that cache on the inspector at pkg/engine/inspector.go:208.
  • createService wires the same inspector cache into the Terraform parser at pkg/scan/scan.go:278 and pkg/scan/scan.go:280.

So with the current architecture, the Terraform HCL cache is effectively scoped to one analyze request / one scan. A second server analyze request with the same file path but changed content should not see a stale *hclsyntax.Body from the previous request.

That said, I think we should add a regression test in pkg/server/analyze_test.go to lock this behavior down. The important invariant is: the HCL cache must not persist between two server analyze requests.

A good test would:

  1. Send an analyze request with infra/main.tf containing a Terraform resource.
  2. Send a second analyze request to the same server with the same infra/main.tf path but different content, ideally moving/changing the resource block enough that stale HCL ranges would be visible.
  3. Assert that the second finding's line, block location, or resource source comes from the second request's content, not from the first request's cached parse.

This matters for future server optimizations. Keeping the inspector or rules warm between requests would be attractive for latency, but with the current path-only cache key it could make stale parses possible unless the cache is also reset or keyed by content.

Side note, not mandatory for this PR: I am also a little worried about the memory footprint. The cache stores one parsed HCL body per Terraform file for the duration of the scan/request. For large repos this may be fine, but a lightweight metric or debug log could be useful later, for example number of cached HCL bodies, cache hit/miss count, and maybe total source bytes represented. That would help us validate the CPU win against the memory cost.

@NathanGallet-dd

Thanks for the review.

On the ingestion-sharing idea (reusing the parser's HCL AST instead of reparsing in the detector): I prototyped it and benchmarked all three variants on cloud-inventory with a full-platform scan:

main detector lazy cache only + ingestion sharing
Wall clock 104s 73s 79s
User CPU 353s 283s 287s
Max RSS 3.45 GiB 3.18 GiB 3.71 GiB

The lazy detector-side cache (DetectKindLine with a per-scan sync.Map, populated on first finding per file) captures essentially all of the CPU win. Ingestion sharing only eliminates the single remaining parse for files that already have findings, but it retains parsed bodies for every ingested .tf file (including ~20k+ with zero findings). That added ~540 MB peak RSS with no measurable further speedup (actually slightly slower in our runs).

So I dropped the ingestion-sharing commit and kept the original logic.

The ingestion sharing is logical if the bottleneck is “we parse every file twice.” After the lazy cache, the bottleneck is gone; what’s left is “we parse files-with-findings one extra time on the first finding,” which is cheap. Paying to retain every file’s AST up front buys almost no CPU and costs a lot of RAM.

On the optional hit/miss/byte telemetry, agree it would be useful for operational visibility; happy to follow up separately if we want it.

@whitemerch
whitemerch merged commit 79fb45b into main Jul 1, 2026
19 checks passed
@whitemerch
whitemerch deleted the chakib.hamie/cache_terraform_hcl_parse branch July 1, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants