When a security team writes a custom policy for their infrastructure-as-code scanner, the hard part usually isn't the rule logic — it's the data model underneath it. A Terraform aws_s3_bucket resource, a CloudFormation AWS::S3::Bucket, and a Kubernetes Pod spec all describe infrastructure, but they don't share a syntax, a nesting convention, or even a naming scheme for the same attribute. Snyk IaC's custom rule SDK, snyk-iac-rules, solves this by giving every rule author one query surface — a normalized input.resource object evaluated with Open Policy Agent's Rego language — regardless of which IaC format produced the configuration. This post walks through how that query structure is actually built: how attributes are bound, traversed, and evaluated, and how a written rule becomes a distributable artifact a scanner can enforce. The goal is to explain the mechanics publicly documented by Snyk, not to compare tools.
What query language does Snyk IaC's custom rule SDK use?
Snyk IaC custom rules are written in Rego, the declarative policy language of Open Policy Agent (OPA), a CNCF-graduated general-purpose policy engine. Rather than inventing a proprietary DSL for infrastructure checks, Snyk built snyk-iac-rules as a Golang SDK — distributed via npm, Docker, Homebrew, Scoop, and standalone binaries — that wraps OPA's evaluation engine with commands for writing, testing, bundling, and distributing rules against IaC input. Because the underlying language is standard Rego, a rule's logical structure (variable binding, iteration, string matching, implicit AND-of-conditions inside a rule body) follows the same semantics OPA uses everywhere else it's deployed, from Kubernetes admission control to API authorization. Snyk's contribution is the input document it feeds into that engine and the SDK tooling wrapped around it, not a new query grammar.
How does the SDK let one rule query attributes across different IaC formats?
It does this by normalizing every supported format into a common input.resource object keyed by resource type before any Rego rule runs. A rule author writes resource := input.resource.aws_iam_role[name] once, and that same attribute-access pattern resolves whether the underlying source file was a Terraform .tf file, a CloudFormation template, a Kubernetes manifest, or an ARM template — as long as the resource type maps to a matching key in the normalized document. This is the core design decision that makes "resource-attribute queries" a distinct concept from "file parsing": the SDK's job is to flatten format-specific syntax into a resource-type-keyed structure first, so the Rego layer only ever has to reason about one shape of document, not four.
How does a rule bind to a specific resource and traverse its nested attributes?
Rules bind a resource to a variable with Rego's walrus operator (:=) and then walk its attributes using plain dot notation, relying on Rego's undefined-value semantics to express "this field is missing." For example, aws_iam_role_tags_missing(resource) { not resource.tags.owner } evaluates to true precisely when the tags.owner path doesn't resolve — there's no separate null check or existence function required, because in Rego an unresolved path simply evaluates as undefined and not on an undefined value is true. This is a meaningful structural choice: it lets a single expression cover both "the tags block is absent entirely" and "the tags block exists but owner was never set," which are two different YAML/HCL shapes that would otherwise need separate handling.
What does a complete custom rule look like end to end?
A complete rule pairs a deny block with a structured msg object carrying at minimum four fields: publicId (a team-defined identifier that must not start with the SNYK- prefix reserved for Snyk's own rules, e.g. CUSTOM-RULE-8), title, severity (one of low, medium, high, or critical), and msg itself, typically built with sprintf() to reference the specific resource name in the violation text. Optional fields — issue, impact, remediation, and references — let a rule carry the same contextual guidance a built-in Snyk check would show in the CLI or UI output. When the template command scaffolds a new rule, it generates the expected layout of rules/<rule_name>/main.rego alongside main_test.rego, so every rule ships with its own colocated test file from the moment it's created rather than as an afterthought.
How are these rules tested and packaged for distribution?
The SDK's test command runs the fixtures in each rule's main_test.rego before anything ships, and the build command then compiles the full set of .rego policy and data files in a rules/ directory into a single OPA bundle. That bundle is what snyk iac test consumes at scan time to apply custom logic alongside Snyk's built-in checks. For organization-wide rollout, a further push step publishes the compiled bundle as an OCI artifact — compiled to WebAssembly — to a registry such as Docker Hub, referenced by tag. Enforcement across a group's projects is then configured centrally through Snyk's Group IaC Settings API, which points scans at a specific registry reference and tag, giving teams a mechanism for versioned, gradual rollout of a rule change rather than pushing an untested policy update to every pipeline at once.
Why does this design matter for how teams write and maintain IaC policy?
It matters because it turns policy authorship into a software engineering discipline with its own build, test, and release cycle, rather than a one-off script bolted onto a scanner. Since the query interface is standard Rego over a normalized document, rule authors reuse patterns from the wider OPA ecosystem (iteration with some x in collection, string functions like endswith()) instead of learning IaC-specific syntax. And because bundles are compiled, versioned OCI artifacts pulled by tag into CI, a custom rule set behaves like any other dependency: it can be pinned, rolled back, or staged — which is also exactly why it deserves the same scrutiny as any other artifact your pipeline pulls and executes.
How Safeguard Helps
A compiled rule bundle pulled by tag from an OCI registry and executed inside your CI pipeline is, structurally, no different from any other third-party artifact in your software supply chain: something built outside your direct control that your build process trusts and runs. Safeguard helps teams apply the same provenance and integrity discipline to policy tooling that they'd apply to any dependency — verifying that the rule bundle your pipeline pulls at scan time matches what your security team actually approved, flagging unexpected tag moves or registry changes, and giving you visibility into which policy version was enforced on which build. As custom rule sets grow and get distributed across more repositories and teams, that visibility is what keeps "policy as code" from becoming an unmonitored trust boundary of its own.