CoursesCompliance as codeRemediation & prevention

Remediation & prevention

Auto-fix the safe cases; guardrail the rest.

Advanced30 min · lesson 12 of 15

A fire door propped open with a wedge is still a fire door. It stops being one at the exact moment it matters. That is what happened to a bucket called acme-prod-logs. An assessor is sampling evidence for a SOC 2 audit, short for Service Organization Control 2, the report software vendors hand their customers to show their controls hold. One control on the sample list covers S3, Simple Storage Service, Amazon's file storage: "S3 buckets must block all public access." That line is CIS AWS 2.1.5, one numbered item in the Center for Internet Security's hardening benchmark. So the assessor pulls three weeks of history out of AWS Config, the Amazon Web Services service that records how every resource is configured and how that configuration changed over time. One bucket has a gap. An on-call engineer stripped its public-access block at 2 a.m. to share a log export during an incident, and never put it back. The control failed silently for twenty-one days. The auditor then asks two questions, and those two questions are the whole of this lesson. How fast can you close a violation once something detects it? And how do you stop that entire class of violation from ever happening again?

From finding to closed control

A finding is the smoke alarm going off. Remediation is somebody actually putting the fire out, and it is what lets you mark a control resolved instead of merely observed. Compliance as code treats that second part as a program you can run, not a paragraph in a runbook nobody has opened since 2021. There are two families of it. Detect-then-fix is reactive: the resource already exists, it has drifted away from policy, and something spots it. That something is a detective control, which might be an AWS Config rule, a CSPM (Cloud Security Posture Management, a scanner that continuously checks cloud accounts against a rule set), or a scheduled query. A remediation action then pushes the resource back to baseline after the fact. Prevent-at-admission is proactive: a preventive control stands at the door, inspects every create-or-update request before the resource is ever written down, and either turns it away or repairs it on the spot, so the broken version never exists at all. One closes gaps that already opened. The other refuses to let them open. A serious program runs both, because each is blind exactly where the other sees, and knowing which one to reach for is the real skill.

Detect-then-fix: AWS Config plus SSM Automation

AWS Config is the building inspector who walks the floor every time somebody moves a wall, and also does a scheduled round anyway. It evaluates your resources against rules, event-driven when a resource's configuration changes, or on a timer. When a rule marks a resource NON_COMPLIANT, you attach a remediation configuration to that rule, and the configuration runs an SSM Automation document against whatever got flagged. SSM is AWS Systems Manager, and a document here is a runbook: an ordered list of AWS API (Application Programming Interface, the machine-facing way of telling AWS to do something) calls. AWS ships managed documents for the common fixes, so you will rarely write one yourself. Set Automatic to true and the fix fires with nobody in the loop. MaximumAutomaticAttempts and RetryAttemptSeconds put a ceiling on how hard it keeps retrying a resource that refuses to settle. The piece that makes this scale to a whole account is the RESOURCE_ID placeholder: Config drops the flagged resource's id into the document's BucketName parameter at run time, so one remediation config repairs every offender you have today and every one you acquire next quarter, rather than one hard-coded bucket. The document runs under an IAM (Identity and Access Management) role you hand it as AutomationAssumeRole. That role has to trust ssm.amazonaws.com, because Systems Manager assumes it on your behalf, and for this job it needs exactly one write permission: s3:PutBucketPublicAccessBlock. Give each rule its own role rather than sharing one, because a shared remediation role quietly collects the write permissions of every rule you attach it to until it can change most of production. Start by confirming the control really is failing, and on which resource.

confirm the control is failing
# Which resources is Config flagging for this rule right now?
aws configservice get-compliance-details-by-config-rule \
--config-rule-name s3-bucket-level-public-access-prohibited \
--compliance-types NON_COMPLIANT \
--query 'EvaluationResults[].EvaluationResultIdentifier.EvaluationResultQualifier.ResourceId' \
--output json
expected output
[
"acme-prod-logs"
]

That id is the target. Now write the remediation down as code: which SSM document to run, which IAM role it borrows, and the parameters that spell out what compliant looks like once it finishes. Reach for the bucket-level runbook, AWSConfigRemediation-ConfigureS3BucketPublicAccessBlock, whose required BucketName parameter takes the id of the flagged bucket. There is a near-identical sibling, AWSConfigRemediation-ConfigureS3PublicAccessBlock, that works at the account level and requires an AccountId. It defines no BucketName at all, so pointing a bucket rule at it does not fail quietly. The execution errors out on parameters, the bucket stays NON_COMPLIANT, and the Config console shows a failed remediation action rather than a fix that ran and did nothing. The special value RESOURCE_ID is what tells Config to inject whichever bucket the rule flagged, which is why this one file covers the whole account and not only today's offender.

remediation.json
[
{
"ConfigRuleName": "s3-bucket-level-public-access-prohibited",
"TargetType": "SSM_DOCUMENT",
"TargetId": "AWSConfigRemediation-ConfigureS3BucketPublicAccessBlock",
"TargetVersion": "1",
"Automatic": true,
"MaximumAutomaticAttempts": 5,
"RetryAttemptSeconds": 60,
"Parameters": {
"AutomationAssumeRole": {
"StaticValue": { "Values": ["arn:aws:iam::111122223333:role/ConfigRemediationRole"] }
},
"BucketName": { "ResourceValue": { "Value": "RESOURCE_ID" } },
"BlockPublicAcls": { "StaticValue": { "Values": ["true"] } },
"IgnorePublicAcls": { "StaticValue": { "Values": ["true"] } },
"BlockPublicPolicy": { "StaticValue": { "Values": ["true"] } },
"RestrictPublicBuckets": { "StaticValue": { "Values": ["true"] } }
}
}
]
register and trigger the remediation
# Attach the remediation to the rule...
aws configservice put-remediation-configurations \
--remediation-configurations file://remediation.json
# ...and force it now for the flagged bucket (Automatic would fire on next eval).
aws configservice start-remediation-execution \
--config-rule-name s3-bucket-level-public-access-prohibited \
--resource-keys resourceType=AWS::S3::Bucket,resourceId=acme-prod-logs
expected output
{
"FailedBatches": []
}
{
"FailedItems": []
}

With Automatic set to true, this would have fired on its own at the next evaluation. The start-remediation-execution call forces it right now so you are not sitting around waiting for the demo. The automation puts the public-access block back. Read the resource straight from the API to confirm the control passes. Before, the block was missing entirely. After, all four settings are true.

verify the fix landed
aws s3api get-public-access-block --bucket acme-prod-logs
expected output
{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}

To an auditor, the fix matters less than the receipt for the fix. Every remediation execution leaves two trails: the SSM Automation run history, and the Config timeline entry where the resource flips from NON_COMPLIANT back to COMPLIANT. Lay those side by side and you have a closed loop with a real number attached, a mean time to remediate you can measure, instead of somebody's verbal assurance that the team keeps an eye on things. A control that provably heals itself in minutes and can produce its own paperwork is far stronger evidence than one a human swears they watch. Stay precise about the word evidence, though. A passing check is evidence toward the control, not a certification of it, and the auditor still wants your written narrative and the sampling method behind those numbers.

Prevent-at-admission: Kyverno mutate

Detect-then-fix always leaves a window. Twenty-one days in the story above, or at best the few minutes between the drift and the next evaluation. Prevention shrinks that window to zero by moving the check to the moment of creation. Kubernetes keeps a bouncer at the door for exactly this. An admission controller sees every create and update before the object is persisted to etcd, the key-value store where a cluster keeps its real state. A validating policy turns a non-compliant manifest away outright. A mutate policy is the better tool for the safe, boring, well-understood fixes, because it patches the resource in flight, so the version that actually gets stored is the compliant one. It is the same auto-fix-the-safe-case instinct from the AWS side, applied one step earlier in the resource's life. Kyverno's +() anchor adds a field only when that field is absent, so you can inject a secure default without clobbering a team that set the value on purpose. The policy below guarantees every Pod carries a seccomp profile (seccomp is a Linux kernel feature that limits which system calls a container is allowed to make), which Pod Security Standards Restricted requires, even when whoever wrote the manifest forgot. The same requirement can shift left, too. Run kyverno apply against the manifests in CI (continuous integration, the automated build that runs on every pull request) and diff its mutated output against the source, and a Pod that shipped without the profile shows up as a patched field right there in the pull request. One catch. A mutate on its own will not fail your build, because kyverno apply exits non-zero only on a validate failure. Pair it with a validating twin when you want CI to hard-block rather than quietly repair.

add-seccomp-profile.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: add-seccomp-profile
annotations:
policies.kyverno.io/title: Add default seccomp profile
spec:
rules:
- name: set-seccomp-runtimedefault
match:
any:
- resources:
kinds:
- Pod
mutate:
patchStrategicMerge:
spec:
+(securityContext):
+(seccompProfile):
+(type): RuntimeDefault
apply the policy, then create a bare Pod
kubectl apply -f add-seccomp-profile.yaml
# This Pod sets NO seccompProfile of its own:
kubectl run audit-test --image=nginx:1.27 --restart=Never
# Read back what was actually stored in the cluster:
kubectl get pod audit-test -o jsonpath='{.spec.securityContext.seccompProfile.type}'
expected output
clusterpolicy.kyverno.io/add-seccomp-profile created
pod/audit-test created
RuntimeDefault

Picking a lane, and closing the loop

Neither approach wins outright. Detect-then-fix reaches everything a gate never sees: infrastructure that was already running, changes made out of band, third-party automation, somebody clicking around in the console at midnight. It also always trails reality, and it can pick a fight with an engineer making a perfectly legitimate change in the middle of an incident. Prevent-at-admission is airtight for anything that comes through the door and completely blind to everything that does not. A mutate that quietly rewrites a manifest can also leave a developer baffled about why their YAML (the text format Kubernetes manifests are written in) grew a field they never typed, so ship it with a clear policies.kyverno.io/title annotation and a validating counterpart that says out loud what the rule wants. A grown-up program runs both lanes and adds a third. Fixes with real blast radius never auto-fire: deleting a resource, revoking a role, tearing down a security group. Those become tracked tickets with a named owner and a due date, which is how time-to-remediate turns into a number you can report rather than a hope. Then any violation class that keeps coming back gets designed out with an admission policy or an SCP (Service Control Policy, an AWS guardrail that caps what an entire account is permitted to do at all). Detect, remediate, prevent. Each class fixed once, then gated shut behind you.

How to remediate a violation
A resource violates a control
Config / CSPM / admission flags it
already exists, drifted
Detect-then-fix
Config rule → SSM Automation re-applies the baseline. Safe, reversible, low-risk fixes only, and it leaves a drift window.
created through the gate
Prevent-at-admission
Kyverno mutate patches the object in flight, or validate rejects it. Zero window, but only for what flows through the API.
high blast radius
Track and ticket
Delete, revoke, tear-down: never auto-fires. Owner plus due date, human review, measured time-to-remediate.
Don't wire every finding to Automatic:true. Route by reversibility and blast radius, then design the repeat offenders out with a gate or an SCP.
Blind auto-remediation fights legitimate changes, and it can flap
Wiring Automatic:true to every finding causes outages of its own. If a remediation reverts a value that an active deploy sets on purpose, the two will flap like a pair of stubborn people fighting over one light switch: the deploy sets it, the remediation puts it back, the deploy sets it again, burning API calls and paging everyone. A rule that tidies up "unused" resources can delete a NAT (Network Address Translation) gateway that a monthly batch job still depends on. So keep auto-remediation for fixes that are reversible, low-risk and well understood. Exclude any resource carrying an approved-exception tag. Cap MaximumAutomaticAttempts so a flapping resource gives up instead of hammering the API. And test every remediation in a non-prod account before you switch it on in production.

Try this

Run both lanes end to end on things you can throw away: a sandbox AWS account and a cluster you are happy to delete. For the AWS half, turn on the s3-bucket-level-public-access-prohibited Config rule, then create a bucket and strip its public-access block to reproduce the 2 a.m. mistake from the story. Now work the walkthrough above in order: get-compliance-details-by-config-rule to watch it get flagged, put-remediation-configurations with your own remediation role in place of the 111122223333 example, start-remediation-execution to force the fix, and get-public-access-block to confirm all four settings came back true. For the Kubernetes half, install Kyverno, apply add-seccomp-profile.yaml, run the bare Pod, and read the stored seccompProfile back. Then break each one on purpose. Point TargetId at the account-level sibling while leaving BucketName in place, and read the parameter error the failed execution hands back. Drop the +() anchors from the mutate and watch it overwrite a profile a team set deliberately.

reproduce the drift on a sandbox bucket
# A bucket-level public access block is on by default, so remove it to open the gap.
BUCKET=acme-sandbox-logs-$(date +%s)
aws s3api create-bucket --bucket "$BUCKET" --region us-east-1
aws s3api delete-public-access-block --bucket "$BUCKET"
# Config re-evaluates on its own schedule; nudge it if you do not want to wait.
aws configservice start-config-rules-evaluation \
--config-rule-names s3-bucket-level-public-access-prohibited

Takeaway

Sort every control by what the wrong fix would cost, not by how easy it is to automate. Reversible and well understood belongs in detect-then-fix. Anything that arrives through an admission gate belongs in prevent-at-admission, where the broken version never gets written down at all. Anything that deletes, revokes or tears down belongs on a ticket with a name and a date on it. Write that sorting down beside your rule list before you switch anything to Automatic:true, so the argument your team has is which lane a control belongs in, and not why the automation removed a gateway at 3 a.m.

Quick check
01A teammate finds the RESOURCE_ID placeholder confusing and edits remediation.json so BucketName carries a StaticValue of acme-prod-logs instead. The rule later flags a second bucket that shipped without its public-access block. What happens when the remediation runs on that one?
Correct — A hard-coded id points every run at the same bucket. RESOURCE_ID is what makes one remediation config cover today's offender and the ones you acquire next quarter.
Incorrect — StaticValue is already how the four Block settings are written in that same file, so a literal value is a legal shape. The trouble is what the literal points at, not the syntax.
Incorrect — That injection is precisely the job RESOURCE_ID was doing. Replace it with a literal and Config has nothing left to substitute at run time.
Incorrect — MaximumAutomaticAttempts and RetryAttemptSeconds only bound how hard Config retries a resource that will not settle. Repeating a fix aimed at the wrong bucket changes nothing.
02Your add-seccomp-profile ClusterPolicy is live, and kubectl get pod on anything created since then reads back RuntimeDefault. An audit of the same cluster still lists Pods with no seccompProfile at all. What accounts for those Pods?
Incorrect — The anchors are nested, so each missing level is added on its own. A Pod that sets some other securityContext field still picks up the profile underneath it.
Incorrect — That is true about the exit code, and it explains a green build rather than a stored Pod. The admission webhook patches at creation whatever CI did or did not say.
Incorrect — A validating counterpart is worth shipping so the rule states out loud what it wants and CI can hard-block. The mutate patches on its own without one.
Correct — A gate only sees what comes through the door. Anything already running needs the other lane: something that detects the drift and a fix that runs after the fact.
03Two remediations pass their test in a sandbox account: one puts a missing public-access block back on a bucket, the other detaches an IAM role that a rule marks over-privileged. Which do you switch to Automatic:true in production?
Incorrect — Testing in a non-prod account is the price of entry, not a clearance. The caps limit how often a fix retries, not the damage one successful run can do.
Incorrect — How serious the finding is and how expensive the wrong fix is are two different measurements. Sort by what an incorrect automatic run would break, then pick the lane.
Correct — Putting a block back is reversible and well understood, so it is safe to fire unattended. Pulling access can lock out something legitimate, so a person owns it with a date attached.
Incorrect — Kyverno sits in the Kubernetes admission path and never sees an S3 bucket or an IAM role. Neither of these can be gated that way.

Related