Containers, Kubernetes and infrastructure as code
33 container rules and a policy table of 1,082 controls, over the files that describe where your code runs. Cordon reads the definitions -- it never contacts a cluster, a cloud account or a registry to do it.
┌──────────────────────────────────────────────────────────────────────────┐ │ WHAT THIS TUTORIAL COVERS WHAT IT DOES NOT │ ├──────────────────────────────────────────────────────────────────────────┤ │ Dockerfile, Containerfile pulling an image from a registry │ │ docker-compose / compose a running container │ │ Kubernetes manifests a live cluster │ │ Helm charts and values a host's installed packages │ │ Terraform (.tf, .tfvars) a cloud account's real state │ │ CloudFormation templates │ │ Ansible playbooks and roles `scan image.tar` reads the dpkg, │ │ apk and RPM databases in an image. │ └──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐ │ A path glob is a guess about somebody's naming convention. │ │ │ │ **/k8s/**/*.yaml finds k8s/prod/pod.yaml │ │ misses deploy/pod.yaml │ │ misses manifests/pod.yaml │ │ misses deployment.yaml at the root │ │ │ │ So the globs are a FAST PATH and the marker is the answer: │ │ │ │ any .yaml/.yml ──▶ does the first 4 KB contain │ │ `apiVersion` ──▶ Kubernetes │ │ `AWSTemplateFormatVersion` ──▶ CloudFormation │ │ `hosts:` + a task key ──▶ Ansible │ │ │ │ A byte-identical privileged pod manifest is therefore found │ │ wherever it sits, which is the thing most tools get wrong. │ └──────────────────────────────────────────────────────────────────────────┘
The 33 container rules above match a pattern against a file: they answer "does this contain something alarming". Most infrastructure policy is the other question -- this resource is missing that setting -- and a regex cannot express absence over a region it has no notion of.
A FILE IS READ INTO BLOCKS FIRST resource "aws_db_instance" "prod" { ... } one block kind: Deployment one block Resources: { Bucket: { Type: AWS::S3::Bucket one block services: { web: ... one block AND EACH POLICY ASKS ONE OF TWO THINGS INSIDE IT forbid the block says something insecure acl = "public-read" require the block does not say something it storage_encrypted must -- which is the provider's absent means false default, and is written nowhere
storage_encrypted absent from an aws_db_instance is an unencrypted database, and the file does not mention it. That is the half the pattern rules could not reach, and it is where most of the 1,082 controls live: encryption at rest and in transit, public exposure, logging, backups, deletion protection, obsolete TLS, and the Kubernetes and Compose settings that hand a container the node.
220 of them are written by hand, and 862 are generated. The rest are generated from the providers' own schemas, because which resources have storage_encrypted is a fact rather than a memory, and a policy naming an attribute a provider does not have can never fire -- it looks exactly like a clean scan.
terraform providers schema -json -> 3,845 resources, with every attribute and its type a control table -> what deletion_protection means, and why its absence matters scripts/build_iac_policies.py -> one policy per resource that actually has the attribute
Every policy, written or generated, ships with the block it must report and the block it must not, and the suite runs both on every push -- a control that stops matching fails the build rather than quietly reporting nothing.
cordon-scanner rules list | grep IAC # the pattern rules and the policies┌──────────────────────────────────────────────────────────────────────────┐ │ POLICY.CONTAINER.UNPINNED_BASE.001 │ │ FROM node:20 a tag the publisher can move │ │ FROM node@sha256:... a digest they cannot │ │ │ │ SUSPECT.CONTAINER.FETCH_EXEC.001 │ │ RUN curl -sL https://x | sh │ │ unreviewed code, baked into the image, as root │ │ │ │ SUSPECT.CONTAINER.BUILD_SECRET.001 │ │ ARG NPM_TOKEN / ENV AWS_SECRET_ACCESS_KEY=... │ │ a build arg lands in the layer history and ships with it │ │ │ │ SUSPECT.K8S.CAPABILITIES.001 SYS_ADMIN, NET_RAW, SYS_PTRACE │ │ SUSPECT.K8S.RBAC_WILDCARD.001 verbs: ["*"] on resources: ["*"] │ │ POLICY.K8S.NET_ADMIN.001 hostNetwork / NET_ADMIN │ │ POLICY.K8S.SERVICE_ACCOUNT_TOKEN.001 │ │ automount of a token nothing needs │ │ SUSPECT.HELM.UNTRUSTED_REPOSITORY.001 │ │ a chart from a repo nobody pinned │ └──────────────────────────────────────────────────────────────────────────┘
A Deployment, StatefulSet, DaemonSet, Job, CronJob or Pod names images, and so does a Kustomization's images: override. Each one is a dependency like a package, read from the manifest Kubernetes would apply:
┌──────────────────────────────────────────────────────────────────────────┐ │ POLICY.CONTAINER.UNPINNED_WORKLOAD_IMAGE.001 │ │ image: ghcr.io/acme/api:1.4 a tag; whoever can push moves it │ │ image: ghcr.io/acme/api@sha256: a digest; what was reviewed runs │ │ │ │ POLICY.CONTAINER.UNSIGNED_IMAGE.001 (with --online) │ │ the registry holds no signature or build attestation for it │ └──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐ │ SUSPECT.IAC.PRIVILEGED.001 privileged: true │ │ the container boundary, removed │ │ │ │ SUSPECT.IAC.HOST_MOUNT.001 hostPath: / or /var/run/docker.sock│ │ the host filesystem, or the daemon │ │ that can start a container as root │ │ │ │ SUSPECT.IAC.IAM_WILDCARD.001 Action: "*" Resource: "*" │ │ a role that can do anything │ │ │ │ SUSPECT.IAC.PUBLIC_INGRESS.001 0.0.0.0/0 to a port that is not │ │ 80 or 443 -- a public service on │ │ 443 is a website; on 22 it is not │ │ │ │ SUSPECT.IAC.ANSIBLE_FETCH_EXEC.001 │ │ get_url/uri then command/shell │ └──────────────────────────────────────────────────────────────────────────┘
Cordon's default gate fails a build on compromise, and reports posture without failing it.
Every rule in this tutorial sits in the container or infrastructure threat domain, and both are in policy.advisory_domains by default. You will see them in the report; they will not turn the build red. That is deliberate -- these findings are frequently correct and intentional, and a gate that fails on them gets a blanket exception added, at which point it protects nothing.
Two lines make them blocking:
policy: advisory_domains: []
# everything, including manifests wherever they livecordon-scanner scan .# just this groundcordon-scanner scan . -f json:out.jsonjq '.findings[] | select(.rule_id | test("CONTAINER|K8S|IAC|HELM")) | {rule_id, path: .location.path}' out.json# make posture blocking -- config only, there is no CLI flag# cordon.yaml:# policy:# advisory_domains: []cordon-scanner scan . --config strict.yaml --fail-on medium
┌──────────────────────────────────────────────────────────────────────────┐ │ deploy/api.yaml (not in a k8s/ directory) │ ├──────────────────────────────────────────────────────────────────────────┤ │ apiVersion: apps/v1 ◀── the marker. This is a manifest. │ │ kind: Deployment │ │ spec: │ │ template: │ │ spec: │ │ hostNetwork: true ──▶ POLICY.K8S.NET_ADMIN.001 │ │ containers: │ │ - image: internal/api:latest ──▶ POLICY.CONTAINER. │ │ UNPINNED_BASE.001 │ │ securityContext: │ │ privileged: true ──▶ SUSPECT.IAC.PRIVILEGED.001 │ │ capabilities: │ │ add: ["SYS_ADMIN"] ──▶ SUSPECT.K8S.CAPABILITIES.001│ │ volumeMounts: │ │ - mountPath: /host ──▶ SUSPECT.IAC.HOST_MOUNT.001 │ └──────────────────────────────────────────────────────────────────────────┘
Next: 09 · The advisory database.