Pulumi CrossGuard Policies for Cloud Guardrails
Write Pulumi CrossGuard packs that deny public IPs at preview, how they differ from Gatekeeper, and how to run them in CI without a second OPA tutorial.
pulumi up succeeded. The stack grew an aws.ec2.Eip because a developer copied a tutorial that set associatePublicIpAddress: true. Preview showed a green diff. Pulumi CrossGuard is the layer that turns that preview red before the IP exists.
This page is policy-as-code inside Pulumi: packs, a public-IP deny, how that differs from OPA Gatekeeper, and CI wiring. It is not an OPA language tutorial and not a post-deploy scanner bake-off. Official pack APIs live in the Pulumi CrossGuard docs. After apply, leftover exposure still belongs in AWS security best practices and a graph, not in a second copy of the same deny written in Rego.
Developer
→ pulumi preview (+ policy pack)
→ deny | warn | allow
→ pulumi up (same pack, mandatory)
→ cloud API
If the pack is not on the preview that humans actually look at, it is documentation.
Policy as code in Pulumi
A policy pack is a small program (TypeScript, Python, or Go) that Pulumi runs against the resource graph of a stack. Two hooks matter:
| Hook | When it runs | What it sees |
|---|---|---|
validateResource |
Each resource in the preview | Type, inputs, sometimes prior state |
validateStack |
Once per preview | The whole planned graph |
enforcementLevel is advisory (warn, do not fail) or mandatory (fail preview/up). Packs can ship with the repo (--policy-pack ./policy) or attach to an organization in Pulumi Cloud so every stack inherits them.
Failure mode: the pack lives in a security/policy repo that application teams never pass on the CLI. Org-level enforcement in Pulumi Cloud is the only way a forgotten --policy-pack flag cannot skip the deny. If you are CLI-only, the CI job must be the only path to up on protected stacks.
Resource validation is typed. validateResourceOfType(aws.ec2.Instance, ...) will not fire on aws.lb.LoadBalancer. Teams that write one “no public anything” function and attach it only to Instance miss EIPs, internet-facing ALBs, Azure PublicIp, and GCE accessConfig. List the types you care about, or use a stack validation that walks args.resources by type string.
Tags and Pulumi config are available; do not use them as a silent bypass. pulumi:skip-guardrail=true on a resource is an exception with no owner unless CI also requires a matching ticket id in the stack config.
Example deny public IP
Start with the types that actually allocate a public address, not with a CIS control id.
TypeScript sketch for AWS EC2 (inputs, not tags):
import { PolicyPack, validateResourceOfType } from "@pulumi/policy";
import * as aws from "@pulumi/aws";
new PolicyPack("public-ip-guardrails", {
policies: [
{
name: "ec2-no-associate-public-ip",
description: "Instances must not request a public IPv4 address.",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(
aws.ec2.Instance,
(inst, _args, reportViolation) => {
if (inst.associatePublicIpAddress === true) {
reportViolation(
"associatePublicIpAddress is true; use a private subnet and a load balancer or SSM."
);
}
}
),
},
{
name: "no-standalone-eip",
description: "Do not allocate Elastic IPs in workload stacks.",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(
aws.ec2.Eip,
(_eip, args, reportViolation) => {
const allowed = new Set(["urn:pulumi:prod::edge::aws:ec2/eip:Eip::nat-a"]);
if (!allowed.has(args.urn)) {
reportViolation(`EIP ${args.urn} is not on the NAT allow-list.`);
}
}
),
},
],
});
Same idea, different APIs:
- Azure:
azure.network.PublicIpwithsku/ allocation that is not the documented ingress exception. - GCP:
gcp.compute.InstancenetworkInterfaces[].accessConfigsnon-empty;gcp.compute.AddresswithaddressType: EXTERNAL.
Failure mode: you deny associatePublicIpAddress and someone ships an internet-facing NLB in a public subnet. Add aws.lb.LoadBalancer scheme === "internet-facing" as a separate policy with an allow-list of stack names (edge, ingress). Do not fold NAT, ALB, and “this API must be public” into one boolean.
Preview locally:
pulumi preview --policy-pack ./policy --stack prod.payments
If preview is clean and up in CI uses a different pack path, you tested theater.
vs OPA Gatekeeper
CrossGuard and Gatekeeper share a slogan (policy as code) and almost no runtime.
| Pulumi CrossGuard | OPA Gatekeeper | |
|---|---|---|
| Object | Pulumi resource inputs | Kubernetes AdmissionReview |
| Time | Preview / update | API server admit |
| Authoring | Pulumi Policy SDK | ConstraintTemplate + Constraint |
| Misses | ClickOps, other IaC, kubectl |
Cloud resources that never become K8s objects |
Do not translate a CrossGuard pack into Rego “for consistency” unless a Kubernetes object is the thing you want to stop. Denying Service type: LoadBalancer in Gatekeeper does not deny aws.ec2.Eip. Denying the EIP in CrossGuard does not deny a Helm chart that creates a public Service in a cluster this stack does not manage.
If you already run Gatekeeper or Kyverno for unsigned images and hostPath, keep them. Point CrossGuard at cloud resource types. The conceptual overlap is “fail the change before it is live,” not a shared policy language. A generic OPA-for-everything tutorial belongs elsewhere; this split is the operator decision.
After both gates, a public IP that still exists is drift or a second control plane. Rank that as exposure, not as a policy-engine bake-off (how to prioritize cloud vulnerabilities).
CI wiring
The pack must run on the same revision as the stack code.
- PR:
pulumi preview --policy-pack ./policy --diffagainst a short-lived stack or against prod with--expect-no-changeswhere that is honest. Fail the job on mandatory violations. - Merge: only the CD identity may
pulumi up. That identity’s cloud role should not be able to create EIPs if the pack is skipped—defense in depth, not a substitute for the pack. - Pack version: pin the pack as a git submodule or a versioned artifact. “Clone main of the security repo” on every build means a pack change can break every stack on Tuesday with no PR on the app repo. Version the pack; bump it like a provider.
- Pulumi Cloud: attach the pack to the organization or to a stack tag (
env:prod). CLI--policy-packon laptops is optional then.
# GitHub Actions sketch — pack lives in the same repo
- run: pulumi preview --stack ${{ vars.STACK }} --policy-pack ./policy --non-interactive
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
Failure modes:
- Preview uses the pack;
upuses--refreshwithout it. - Developers
pulumi upfrom laptops with admin cloud credentials. - Advisory forever because mandatory blocked the NAT EIP that is supposed to exist—allow-list that URN instead of disabling the policy.
Checklist
- Pack lists every public-IP type you use (EC2, EIP, ALB scheme, Azure PublicIp, GCE accessConfig), not only
Instance - NAT / ingress exceptions are URNs or stack names with owners, not a global skip tag
-
enforcementLevel: mandatoryon new stacks; advisory only while inventorying existing EIPs - CI preview and CD up use the same pack version
- Org attachment in Pulumi Cloud or laptop
upis blocked for prod - ClickOps public IPs still go to CSPM; the pack does not claim to see them
Related: AWS security best practices · Cloud-native application security · How to prioritize cloud vulnerabilities