CoursesPulumiPolicy-as-code with CrossGuard

Policy-as-code with CrossGuard

Guardrails in code, enforced on preview.

Advanced12 min · lesson 10 of 12

At the airport your bag goes through the scanner before it is loaded onto the plane, not after it lands somewhere else. CrossGuard is that scanner for infrastructure code. It is Pulumi's policy-as-code engine, and policy-as-code means your organization's rules live as a running program instead of a page on a wiki. It inspects every resource your stack is about to create or change while the plan is still a bag of objects in memory, before a single API call (application programming interface call, the request a program sends to the cloud to make something happen) reaches AWS.

The ordering is the whole point. Your rules already exist somewhere: no security group open to the world on port 22, no public buckets, everything tagged so the on-call responder can find an owner at 3am. Today they live in a wiki and in whatever a tired reviewer remembers on a Friday. Meanwhile a host with SSH (Secure Shell, the remote login protocol) exposed to the internet starts collecting brute-force attempts within minutes of booting. Cloud detection services will tell you about it eventually. Eventually is the gap an attacker lives in. CrossGuard closes that gap by refusing to let the resource exist.

A Policy Pack Is a Small Program

A policy pack is a directory holding a manifest and some code, and Pulumi launches it the same way it launches your infrastructure program. Scaffold one with the CLI (command-line interface, the pulumi command you type). The template name picks the cloud provider and the language, so aws-typescript gives you an AWS pack in TypeScript, which is JavaScript with type checking bolted on. There are aws-python and aws-opa variants too, the last one for rules written in Rego, the language of Open Policy Agent. Azure, GCP and Kubernetes templates follow the same naming.

terminal
mkdir -p ~/infra && cd ~/infra
pulumi policy new aws-typescript --dir policy
output
Created Policy Pack!
Installing dependencies...
added 214 packages in 19s
Finished installing dependencies
Your new Policy Pack is ready to go! ✨
Once you're done editing your Policy Pack:
* To run the Policy Pack against a Pulumi program, in the directory of the Pulumi program run `pulumi up --policy-pack /home/dev/infra/policy`
* To publish the Policy Pack, run `pulumi policy publish [org-name]`
policy/PulumiPolicy.yaml
# `runtime` is the only required field. Note what is NOT here: the pack's name.
# That comes from the PolicyPack("...") call in index.ts.
runtime: nodejs
version: 0.0.1
description: Baseline AWS guardrails for every Acme stack.
author: Platform Security
license: Apache-2.0

Treat version as a real release number, because Pulumi Cloud accepts each version exactly once. Publish 0.0.1 twice and the second attempt is rejected until you bump the number. That is a feature. A stack that was checked against acme-baseline version 0.0.1 last Tuesday was checked against exactly the bytes you can still download today, which is the difference between an audit answer and a shrug.

Writing Rules That Bite

A rule is a bouncer with a list. Someone walks up, the bouncer checks one thing, and either waves them through or turns them away. In CrossGuard the bouncer is a function, the person at the door is a single resource, and the list is your code. Each policy carries a name, a description, an enforcementLevel and a check. Enforcement level decides what a failure means: advisory prints a warning and lets the deployment continue, mandatory blocks it, remediate tries to fix the resource instead of failing, and disabled switches the rule off while keeping it in the file and in git history. The validateResourceOfType helper wraps your callback so it only fires for one resource class and hands you that resource's inputs already typed, which means TypeScript catches a misspelled property before Pulumi ever runs.

policy/index.ts
import * as aws from "@pulumi/aws";
import { PolicyPack, validateResourceOfType } from "@pulumi/policy";
// Ports that should never be reachable from the entire internet.
const adminPorts: Record<number, string> = {
22: "SSH", 3389: "RDP", 3306: "MySQL",
5432: "PostgreSQL", 6379: "Redis", 27017: "MongoDB",
};
new PolicyPack("acme-baseline", {
// Pack-wide default. Any individual policy below can override it.
enforcementLevel: "advisory",
policies: [
{
name: "no-internet-facing-admin-ports",
description: "Security groups must not expose admin ports to 0.0.0.0/0 or ::/0.",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(aws.ec2.SecurityGroup,
(sg, args, reportViolation) => {
for (const rule of sg.ingress ?? []) {
// 0.0.0.0/0 means every address on the internet; ::/0 is the
// same statement in IPv6.
const openV4 = (rule.cidrBlocks ?? []).includes("0.0.0.0/0");
const openV6 = (rule.ipv6CidrBlocks ?? []).includes("::/0");
if (!openV4 && !openV6) { continue; }
// `??` covers a field the developer left out, NOT a value the
// cloud has not computed yet (see the warning below). Absent
// bounds mean every port, so default to the widest range and
// let the rule fire instead of sliding past it.
const from = rule.fromPort ?? 0;
const to = rule.toPort ?? 65535;
for (const [port, label] of Object.entries(adminPorts)) {
const p = Number(port);
if (from <= p && p <= to) {
reportViolation(
`Ingress ${from}-${to}/${rule.protocol} is open to the internet ` +
`and covers ${label} (${p}). Use SSM Session Manager or a prefix list.`);
}
}
}
}),
},
{
name: "no-public-bucket-acl",
description: "S3 bucket ACLs must not be public-read or public-read-write.",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(aws.s3.BucketAclV2,
(bucketAcl, args, reportViolation) => {
if (bucketAcl.acl === "public-read" || bucketAcl.acl === "public-read-write") {
reportViolation(
`ACL "${bucketAcl.acl}" grants anonymous reads. Serve the objects ` +
`through CloudFront with an origin access control instead.`);
}
}),
},
{
name: "stack-must-audit-itself",
description: "Every stack must create at least one CloudTrail trail.",
enforcementLevel: "mandatory",
// A per-resource rule can never see what is MISSING. validateStack can:
// it receives the whole set of resources at once. This one asks about
// types rather than values, which is why it still works during preview.
validateStack: (stack, reportViolation) => {
const trails = stack.resources.filter(r => r.isType(aws.cloudtrail.Trail));
if (trails.length === 0) {
reportViolation(
"No aws:cloudtrail:Trail in this stack. Without one there is no API " +
"audit log to reconstruct an incident from.");
}
},
},
],
});

Two habits are worth copying from that file. First, the description says what the rule is and the reportViolation message says what to do about it, because Pulumi prints them on consecutive lines and the developer reading them is mid-deploy and impatient. SSM Session Manager, named in that message, is the AWS service that gives you a shell on an instance without an open port. Second, the third policy is a different shape entirely. validateStack runs once with every resource in the stack, which is the only way to catch absence. Nothing on a per-resource callback can notice that a trail, a log bucket or a web firewall was never declared. CloudTrail, for what it is worth, is the ledger of every API call made in your account, and an incident without it is a burglary with no doorbell footage.

Run It Before Anything Ships

Point the CLI at the pack directory and the policies run in the same evaluation as your program. Nothing is published, nothing is uploaded, and your teammates are unaffected, which is what you want while you are still tuning a rule against real stacks. A pack you cloned from git has no dependencies installed yet, so run pulumi install inside its directory once before the first preview.

terminal
# evaluate every rule against the desired state, touching nothing in AWS
pulumi preview --policy-pack ./policy
output
Previewing update (dev)
Type Name Plan Info
+ pulumi:pulumi:Stack edge-dev create 1 error
+ ├─ aws:ec2:SecurityGroup web-sg create
+ ├─ aws:s3:BucketV2 assets create
+ └─ aws:s3:BucketAclV2 assets-acl create
Policies:
[email protected] (local: policy)
- [mandatory] no-internet-facing-admin-ports (aws:ec2:SecurityGroup: web-sg)
Security groups must not expose admin ports to 0.0.0.0/0 or ::/0.
Ingress 22-22/tcp is open to the internet and covers SSH (22). Use SSM Session Manager or a prefix list.
- [mandatory] no-public-bucket-acl (aws:s3:BucketAclV2: assets-acl)
S3 bucket ACLs must not be public-read or public-read-write.
ACL "public-read" grants anonymous reads. Serve the objects through CloudFront with an origin access control instead.
- [mandatory] stack-must-audit-itself (pulumi:pulumi:Stack: edge-dev)
Every stack must create at least one CloudTrail trail.
No aws:cloudtrail:Trail in this stack. Without one there is no API audit log to reconstruct an incident from.
Diagnostics:
pulumi:pulumi:Stack (edge-dev):
error: preview failed

Read that Policies: block carefully, because it is the thing you will stare at every day. The mark on the pack line is a summary: a green tick when nothing fired, a warning triangle when only advisory rules fired, a red cross the moment anything mandatory fired. The (local: policy) suffix tells you this pack came off your disk and not from your organization, which is your clue that a colleague running the same command without the flag would sail straight through. Each finding names the policy, then the resource type and name in parentheses. The stack-level finding is pinned to the root stack resource, since it belongs to the whole deployment rather than to any one resource. Notice also that the failing resources themselves show nothing in the Info column. Policy findings are counted separately from ordinary diagnostics, so the tree is not where you go looking for them.

terminal
pulumi preview --policy-pack ./policy > /dev/null 2>&1
echo "exit code: $?"
output
exit code: 1

That non-zero exit is the entire continuous-integration story. Run the preview in your pull-request job and let the pipeline fail on the exit code. No extra parsing, no grepping logs. Prefer preview over up for the check, and not only because it changes nothing in the account. During a preview, a mandatory finding does not mark the resource invalid, so evaluation carries on and every rule gets its turn against every resource. During an update, the first mandatory finding makes that resource invalid and the deployment stops right there. A developer who gets all three findings at once fixes them in one commit. A developer who gets them one deploy at a time learns to hate you.

What happens inside a single pulumi preview
1Your program runs
Pulumi resolves the desired inputs for every resource
2Remediations go first
policies set to remediate rewrite inputs before anyone inspects them
3Each resource is inspected
validateResource sees that one resource's inputs, as written
4The whole set is inspected
validateStack runs once, after registration, over every resource
5Findings are collected
preview keeps going and reports all of them; up bails on the first mandatory one
6Nothing reaches the cloud
a mandatory finding ends the run with a non-zero exit code

The Values Preview Cannot See

Now break it on purpose. Give a security group a port that is copied from a database instance that does not exist yet. The number is real, it is simply not decided until AWS creates the database, so at preview time Pulumi marks it unknown.

terminal
# db-sg copies its port from an RDS instance in the same stack:
# ingress: [{ protocol: "tcp", fromPort: db.port, toPort: db.port,
# cidrBlocks: ["0.0.0.0/0"] }]
pulumi preview --policy-pack ./policy
output
Previewing update (dev)
Type Name Plan Info
+ pulumi:pulumi:Stack edge-dev create
+ ├─ aws:cloudtrail:Trail audit create
+ ├─ aws:rds:Instance orders-db create
+ └─ aws:ec2:SecurityGroup db-sg create
Policies:
⚠️ [email protected] (local: policy)
- [advisory] no-internet-facing-admin-ports (aws:ec2:SecurityGroup: db-sg)
can't run policy 'no-internet-facing-admin-ports' from policy pack '[email protected]' during preview: number value at .ingress.0.fromPort can't be known during preview
Resources:
+ 4 to create
An unknown value turns your mandatory rule into a warning
That security group is wide open to the internet on a database port, and the preview exits 0. Here is why. The policy SDK does not hand your callback the raw inputs. It wraps them in a proxy, a stand-in object that intercepts every property read, and reading a value the cloud has not computed yet throws instead of returning anything. CrossGuard catches that throw and reports the whole policy, for that one resource, as a single advisory line saying it could not run. Your rule did not fail. It never executed. This is why rule.toPort ?? 65535 does not save you: ?? only fills in a field the developer left out, and the read itself throws before any default can apply. Two things keep you honest. The same rule runs for real during pulumi up, because by then the value exists, so the guardrail still holds at the moment of creation. And rules that ask about types rather than values, like the CloudTrail check, keep working in preview because they never read a property. Treat every can't run policy ... during preview line as a check you have not done yet.

One Pack, Different Thresholds

Rules need a thermostat, not a fork of the whole heating system. Production wants three required tags, the sandbox account wants one, and copying the pack to change a string array is how you end up with four packs that disagree. A policy declares a configSchema, which is a JSON Schema describing what settings it accepts, and reads the values back with args.getConfig(). Every policy also gets enforcementLevel as a free config property, so the same published bytes can block in one place and warn in another.

policy/index.ts
{
name: "required-tags",
description: "Resources must carry the tags your responders search on.",
enforcementLevel: "mandatory",
configSchema: {
properties: {
tags: { type: "array", items: { type: "string" } },
},
required: ["tags"],
},
validateResource: validateResourceOfType(aws.s3.BucketV2,
(bucket, args, reportViolation) => {
const { tags } = args.getConfig<{ tags: string[] }>();
const missing = tags.filter(t => bucket.tags?.[t] === undefined);
if (missing.length > 0) {
reportViolation(`Missing required tag(s): ${missing.join(", ")}.`);
}
}),
}
policy-config.json
{
"required-tags": {
"tags": ["Environment", "Owner", "DataClassification"]
},
"stack-must-audit-itself": {
"enforcementLevel": "advisory"
}
}
terminal
# the security group and the ACL are fixed now; re-run with config applied
pulumi preview --policy-pack ./policy --policy-pack-config ./policy-config.json
output
Previewing update (dev)
Type Name Plan Info
+ pulumi:pulumi:Stack edge-dev create 1 error
+ ├─ aws:ec2:SecurityGroup web-sg create
+ ├─ aws:s3:BucketV2 assets create
+ └─ aws:s3:BucketAclV2 assets-acl create
Policies:
[email protected] (local: policy)
- [mandatory] required-tags (aws:s3:BucketV2: assets)
Resources must carry the tags your responders search on.
Missing required tag(s): Owner, DataClassification.
- [advisory] stack-must-audit-itself (pulumi:pulumi:Stack: edge-dev)
Every stack must create at least one CloudTrail trail.
No aws:cloudtrail:Trail in this stack. Without one there is no API audit log to reconstruct an incident from.
Diagnostics:
pulumi:pulumi:Stack (edge-dev):
error: preview failed

The CloudTrail rule dropped to [advisory] without a single line of the pack changing. That override is your rollout lever: ship a new rule as advisory everywhere, watch what it catches for a week, then promote it. The --policy-pack-config flag pairs positionally with --policy-pack, so with two packs on the command line you pass two config files in the same order. When the pack lives in the cloud rather than on disk, check the file against the pack's schema first with pulumi policy validate-config acme-corp/acme-baseline 0.0.1 --config ./policy-config.json, which either prints Policy Pack configuration is valid. or fails loudly on a typo instead of quietly ignoring an unknown key.

Fix It Instead of Failing It

An inspector who only writes citations makes you do the job twice. One who carries a screwdriver and tightens the loose bracket on the spot gets everyone home earlier. A remediation is the screwdriver. You build it with remediateResourceOfType, hang it off the policy's remediateResource property, and set enforcementLevel: "remediate" so it actually runs, because the engine skips remediations on any other level. Remediations execute before any validator, so the corrected values are what the rest of the pack sees. There is one sharp edge in the API: whatever object you return replaces the resource's entire property bag, so start from the properties you were handed.

policy/index.ts
import { remediateResourceOfType } from "@pulumi/policy";
{
name: "default-owner-tag",
description: "Stamp an Owner tag on buckets that arrive without one.",
enforcementLevel: "remediate", // advisory or mandatory would skip the fix
remediateResource: remediateResourceOfType(aws.s3.BucketV2, (bucket, args) => {
if (bucket.tags?.["Owner"] !== undefined) {
return undefined; // nothing to change
}
// The returned object REPLACES every property, so spread the originals in.
// Returning bare { tags: ... } would erase the rest of the bucket's config.
return { ...bucket, tags: { ...bucket.tags, Owner: "unassigned" } };
}),
}
terminal
pulumi up --policy-pack ./policy --policy-pack-config ./policy-config.json --yes
output
Updating (dev)
Type Name Status
+ pulumi:pulumi:Stack edge-dev created (18s)
+ ├─ aws:cloudtrail:Trail audit created (6s)
+ ├─ aws:ec2:SecurityGroup web-sg created (4s)
+ ├─ aws:s3:BucketV2 assets created (3s)
+ └─ aws:s3:BucketAclV2 assets-acl created (1s)
Policies:
[email protected] (local: policy)
- [remediate] default-owner-tag (1 resource)
Resources:
+ 5 created
Duration: 22s

The bucket in that run still shipped without an Owner tag in the source, and required-tags still passed. That is the ordering doing its work: the remediation stamped the tag first, and the validator saw the repaired inputs. By default Pulumi summarizes remediations as a count, so add --show-policy-remediations when you want to see which properties changed on which resource. Keep remediations boring and additive: default tags, a log retention period, an encryption flag that should have been on. Never let one silently rewrite an access rule, because a security group that ends up different from what the program says is a security group nobody can reason about at 3am. Two behaviors are worth knowing. Stack-level policies cannot be remediated, so a remediate level on one is treated as mandatory. And if a resource still breaks the rule after the fix runs, the finding is reported as mandatory and the deployment stops, which makes remediate the strictest level rather than the softest.

From Opt-In to Platform Default

A guardrail that depends on someone remembering a flag is a suggestion. Publish the pack to your Pulumi Cloud organization and it stops being optional: every stack in the matching policy group is checked on every deployment anyone runs, including the colleague deploying from a laptop at midnight. Publishing uploads a version. Enabling binds a version to a policy group, which is a named set of stacks. Publish from inside the pack directory, since that is where the manifest lives.

terminal
cd ~/infra/policy
# 1. upload this version to the org
pulumi policy publish acme-corp
# 2. turn it on for one group, with its config
cd ~/infra
pulumi policy enable acme-corp/acme-baseline latest \
--policy-group staging \
--config ./policy-config.json
# 3. confirm what the org now has
pulumi policy ls acme-corp
output
Obtaining policy metadata from policy plugin
Compressing policy pack
Uploading policy pack to Pulumi service
Permalink: https://app.pulumi.com/acme-corp/policypacks/acme-baseline/0.0.1
NAME VERSIONS
acme-baseline 0.0.1

enable prints nothing when it works, which trips people up; the ls output is your confirmation. The version argument takes latest or a specific number, and pinning a number is the kinder choice for a production group, because a pack you publish on Friday then reaches production only when a human moves the pin. Before you enable anything as mandatory across the organization, put the escape hatch in your runbook: pulumi policy disable acme-corp/acme-baseline --policy-group production. A mandatory rule with a bad comparison in it blocks every deployment in the group, and one of the deployments it blocks is the fix.

Quick check
01A mandatory rule reads fromPort on a security group, but in this stack that port is copied from a database instance the deployment has not created yet. What does pulumi preview --policy-pack ./policy do with it?
Correct — Reading an unknown value throws inside the SDK's proxy, and CrossGuard downgrades that policy to an advisory note for that one resource.
Incorrect — An unknown value is not a violation. The rule is skipped for that resource and reported as advisory, so nothing blocks.
Incorrect — The property read itself throws, so the ?? never runs. Defaults only cover fields the developer left out of the program.
Incorrect — Configuration sets thresholds and enforcement levels; it has nothing to do with values the cloud has not computed yet.
02You want a CrossGuard rule that fails any stack which never declares a CloudTrail trail. Why can't a per-resource validateResource rule express this, and what can?
Incorrect — A validateResource callback fires once per existing resource and is never called for something that was never declared, so it cannot count zero.
Correct — validateStack runs once over every resource in the stack, which is the only way to catch a missing resource such as an absent CloudTrail trail.
Incorrect — Absence is detectable: validateStack sees the whole resource set, so no external script is needed.
Incorrect — A stack-level rule can report the missing trail as a violation, and stack-level policies cannot be remediated anyway.
03Your remediation for missing tags returns { tags: { ...bucket.tags, Owner: "unassigned" } } and nothing else. After it runs, what happens to the rest of the bucket's configuration?
Correct — a remediation's return value replaces the whole property bag, so omitting the other fields deletes them; start from ...bucket to keep them.
Incorrect — Remediations replace rather than merge, so returning only tags does not leave the other properties untouched.
Incorrect — Pulumi does not validate completeness; it takes your object as the new properties, which is why the missing fields vanish silently.
Incorrect — Returning a partial object does not change enforcement level; it changes the resource's properties, dropping everything you left out.

Start by running the pack against your three noisiest stacks and reading every line it prints, advisory lines first, because those are the checks that did not happen. If a rule fires on something you meant to allow, fix it with a config knob or a narrower resource type, never by loosening the comparison, because a comparison that stops matching stops protecting everything else too. Then publish it as advisory, count the findings for a week, and promote the ones that were right every single time.

Try this

Run pulumi policy new aws-typescript --dir policy on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: an unknown value turns your mandatory rule into a warning. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related