cdk-real-drift
v0.22.4
Published
Detect when real AWS resources drift from your AWS CDK app - including UNDECLARED properties that cdk drift / CloudFormation drift detection never see.
Maintainers
Readme
cdk-real-drift (cdkrd)
Drift detection for AWS CDK that sees what your template can't, including the properties you never declared. Detect it, record it, or revert it.
In a nutshell:
live AWS state vs ( CFn template + AWS defaults + .cdkrd baseline )cdkrd compares your real deployed resources against your intent, which is three
things combined:
- CFn template — the properties you declared.
- AWS defaults — undeclared properties still sitting at their AWS default, subtracted automatically (you maintain nothing here).
- .cdkrd baseline — a small committed file recording the undeclared values that actually differ from the default and you've accepted.
The baseline is optional: with just the first two, cdkrd already catches
drift on your declared properties and undeclared properties that drift away from
their AWS default (plus out-of-band deletes) — no setup required. The baseline
only adds one more thing: confirming drift on undeclared values that were
non-default from the start, which your template never mentions.
Why
Someone tweaks one of your resources from the console: an extra inline policy on a role, a bucket setting you never declared. CloudFormation drift detection only compares properties that appear in your template, so it reports nothing:
$ npx cdk drift
✨ Number of resources with drift: 0cdkrd reads the full live resource model and subtracts everything
explainable, so the same change shows up:
$ npx cdkrd check
=== check: ApiStack (us-east-1) ===
[CFn-Undeclared Drift: 1] (live-only (not in your CloudFormation template), changed from your .cdkrd baseline — the differentiator)
ApiRole.Policies (AWS::IAM::Role) — appeared since record
actual =[{"PolicyName":"manual-debug-access", ...}]
─────────────────────────────────
result: 1 drift(s) (undeclared=1)
─────────────────────────────────
| Capability | cdkrd | cdk drift / CFn drift detection |
| -------------------------------------------------------------- | :-----: | :-------------------------------: |
| Drift on declared properties (+ out-of-band deletes) | ✅ | ✅ |
| Drift on undeclared properties | ✅ | ❌ |
| Added out-of-band resources (not in template) | ✅ | ❌ |
| Revert declared drift | ✅ | ✅ cdk deploy --revert-drift |
| Revert undeclared drift | ✅ | ❌ |
| Ignore / accept a drift, incl. a declared one | ✅ | ❌ |
| Record undeclared / added state as a reviewed git baseline | ✅ | ❌ |
Quick start
Install it in your CDK project, then the cdkrd bin is on your PATH (via
npx):
npm install -D cdk-real-drift # in your CDK project
npx cdkrd check # checks every stack your app definesOr run it without installing:
npx cdk-real-drift check # zero-install (resolves the cdkrd bin)
npx -p cdk-real-drift cdkrd check # same, naming the bin explicitlyThe package is cdk-real-drift and its bin is cdkrd; a bare
npx cdk-real-drift check resolves the single bin, and -p cdk-real-drift cdkrd
names it explicitly.
check is the only command you run by hand, with nothing to set up first: it
finds drift and offers Record, Revert, and Ignore inline on what it
turns up.
How to use
Your first run needs no baseline
The first time you run check on a stack, before recording anything:
=== check: ApiStack (us-east-1) ===
No baseline yet — live-only values can't be confirmed as drift, but declared drift and out-of-band deletes always can.
[CFn-Declared Drift: 1] (declared in your CloudFormation template — the live value differs)
Topic.DisplayName (AWS::SNS::Topic)
desired="prod-alerts"
actual ="test"
[Potential Drift: 2] (live-only and not yet in your .cdkrd baseline, so cdkrd can't tell whether it's intended or an out-of-band change — Record to accept it, or Revert to remove it)
Queue.RedrivePolicy (AWS::SQS::Queue)
actual ={"maxReceiveCount":5}
Role.Policies (AWS::IAM::Role)
actual =[{"PolicyName":"adhoc", ...}]
─────────────────────────────────────────────────────────────
result: 3 findings — 1 drift (declared=1) + 2 potential drift
─────────────────────────────────────────────────────────────
ApiStack: drift found — what do you want to do?
❯ Nothing (decide later)
Record undeclared (live-only) — snapshot into the .cdkrd baseline (keeps watching)
Revert — write the desired values back to AWS
Ignore — stop reporting it (writes .cdkrd/ignore.yaml)
Decide per finding — assign a different action to eachEach block above is one kind of finding, and neither needed a baseline:
[CFn-Declared Drift]: a property you declared changed out of band, so it's confirmed against your template right away (deletes of declared resources are confirmed the same way).[Potential Drift]: settings that live only on the real resource, not in your template. cdkrd detects these too: it strips the obvious noise (AWS defaults, auto-generated names) so what's left is the values most likely to be real drift. They're only potential because there's no baseline yet — and, being an unconfirmed best-effort guess, this is the one tier that can include false positives (an AWS-managed default or noise the fold tables didn't yet strip). Confirmed[CFn-Declared Drift]never guesses. If a potential-drift value is really noise, please report it so it becomes a fold-table fix.
At the prompt you act on each finding: record it (accept and watch), revert it (undo the change), or ignore it (stop reporting).
Recording
Recording snapshots those live-only values into a git-committed .cdkrd baseline,
so from then on any later out-of-band change to them is confirmed drift. That's the
day-to-day loop: run check, record what's intended, commit the baseline, and the
next out-of-band change stands out on its own.
With Role.Policies recorded, an inline policy added later out of band now
surfaces as [CFn-Undeclared Drift]: confirmed drift on a value that isn't in
your CloudFormation template, the kind cdk drift can't see:
=== check: ApiStack (us-east-1) ===
[CFn-Undeclared Drift: 1] (live-only (not in your CloudFormation template), changed from your .cdkrd baseline — the differentiator)
Role.Policies (AWS::IAM::Role) — changed since record
actual =[{"PolicyName":"adhoc", ...}, {"PolicyName":"manual-debug-access", ...}]
─────────────────────────────────
result: 1 drift(s) (undeclared=1)
─────────────────────────────────record covers live-only state only, not a [CFn-Declared Drift]; the other
verbs are in The model.
Fixing it in code instead
record / revert / ignore all treat your CDK code as fixed. The fourth remedy — often the right one for a change you want to keep — is to adopt the drift into your CDK code: if someone bumped a Lambda's memory to 2048 during an incident and that's simply correct now, declare it.
--pre-deploy is the verification loop for that edit, because
it compares live state against your local synth instead of the deployed
template:
$ npx cdkrd check # 1. drift: Handler.MemorySize desired=1024 actual=2048
# 2. the live value is right → set memorySize: 2048 in your CDK code
$ npx cdkrd check --pre-deploy # 3. finding gone: local code now matches reality
$ npx cdk deploy # 4. now a no-op for that property — nothing clobbered
$ npx cdkrd check # 5. clean against the updated deployed templateIf step 3 still shows the finding, your code doesn't match live yet (desired
is what your code would set) — fix and re-run before deploying. The deploy in
step 4 is what updates the deployed template to declare the value, so the
day-to-day check (which compares against it) comes back clean in step 5.
This also works for a [Potential / CFn-Undeclared Drift] value you'd rather
own in code than record: declaring the property moves it into the declared
tier, and step 3 confirms your declaration matches what's live.
In CI
Run npx cdkrd check --fail. It's read-only, never prompts, and exits 1 on drift;
it never writes a baseline (you record locally and commit the file).
The model: one verb you run, three it offers
cdkrd check is the entry point: on a TTY it finds drift and offers the other
three as inline actions.
All four are also standalone commands for non-TTY use (scripting / CI). Here's what each does, run on its own:
| verb | meaning | writes |
| -------------- | -------------------------------------------------------------------- | ----------------------------------- |
| cdkrd check | find drift (the one you run) | nothing; the 3 below do the writing |
| cdkrd record | "this undeclared / added state is the norm; tell me if it changes" | a git file (baseline) |
| cdkrd ignore | "stop reporting this property, ever" | a git file (ignore.yaml) |
| cdkrd revert | "this state is wrong"; write the desired value back | AWS (plan + confirm) |
The scopes differ: record is undeclared / added only, while ignore works on
any tier. It's the only in-tool way to accept a declared drift without
editing code or reverting.
check, record, and ignore never write to AWS. revert is the one mutating
verb and always confirms first (--dry-run to preview, --yes to skip the
prompt). Baselines stay a reviewed, git-committed artifact either way; CI never
writes one. It re-reads each touched resource afterward to verify it converged:
=== revert: ApiStack (us-east-1) ===
ApiRole (AWS::IAM::Role)
- Policies -> remove (undeclared, not in baseline)
Apply 1 revert op(s) to ApiStack? This WRITES to AWS. · yes
reverted: ApiRole
verifying convergence (re-reading 1 resource(s))...
ApiStack: CLEAN after revert.Picking an action lets you choose which findings it touches; after a Record or Ignore the prompt re-offers anything still drifting, so you finish in one run. Full prompt mechanics (multiselect, Decide per finding, key bindings) are under Interactive prompts.
How drift is judged
cdkrd compares the live AWS resource against your deployed CloudFormation
template (or your local synth with --pre-deploy). It's reality vs intent,
not a line-by-line diff of your CDK source the way cdk diff works, so
undeployed code changes don't show up as drift by default. --pre-deploy inverts
that, checking live state against the freshly synthesized template (see
--pre-deploy).
The kinds of drift
Named so "declared" is never ambiguous (CFn-declared means in the deployed
template, not your CDK code and not your .cdkrd baseline):
| term | source | how it's judged |
| ------------------------------ | ------------------------------------ | ----------------------------------------------------------------------- |
| CFn-declared | in the deployed template | vs the deployed template; drift from the first run, no baseline needed |
| CFn-undeclared (live-only) | on the resource, not in the template | vs your .cdkrd baseline; the key differentiator |
| Added resource | a whole resource not in the template | reconciled against the baseline like an undeclared property (see below) |
| Deleted | in the template, gone live | the most blatant drift; always fails --fail |
(CFn-undeclared is a template axis; recorded / unrecorded is a separate
baseline-file axis: whether you've snapshotted that value yet.)
The mechanics:
- Until a stack's first
record, undeclared / added state isunrecorded: informational, CLEAN, never fails--fail. Recording is what arms detection, turning a later out-of-band change into failing drift. The baseline is a git-committed JSON file at.cdkrd/baselines/<stack>.<accountId>.<region>.json(reviewable; account id + region in the name prevent cross-account collisions). - There is no watch-list to maintain. Every
checksnapshots the full live model (Cloud Control API + SDK readers for the gap types) and subtracts everything explainable: schema read-only/write-only/defaults, AWS-managed fields,aws:*tags, policy-document and ordering noise. What survives is signal. - Anything not confidently comparable is reported honestly (
readGap/unresolved/skipped), never guessed, so no false drift.
Added out-of-band resources
A whole child resource that exists live but isn't in your template (e.g. an API
Gateway ANY method added on / via the console) is the resource-level sibling
of an undeclared property, and is reconciled the same way against your baseline:
| state | reported as | | --------------------------- | ---------------------------------------------- | | added, not recorded | Potential Drift (no baseline, unconfirmed) | | recorded, unchanged | suppressed | | recorded, changed since | failing drift |
cdk drift / CFn drift detection compare only template-declared resources, so an
out-of-band addition is invisible to them. Decide it like any finding: record
snapshots its full live model and watches it, ignore accepts it, or revert
deletes it (Cloud Control DeleteResource — or a type-specific SDK delete
where Cloud Control does not support the type's DELETE action, e.g. an AppSync
api key — behind the usual confirm / --dry-run / picker; an unrecorded one
needs --remove-unrecorded).
- API Gateway REST: resources, methods, authorizers, models, request validators, gateway responses, stages
- API Gateway V2 (HTTP / WebSocket): routes, integrations, authorizers, stages
- SNS: topic subscriptions, topic access policies (an out-of-band
set-topic-attributes Policyon a topic with no declaredAWS::SNS::TopicPolicy— the AWS-default policy every topic carries is folded, so only a custom policy surfaces) - Lambda: event source mappings, function URLs, aliases, versions,
resource-policy statements (an out-of-band
add-permission) - EventBridge: bus rules
- Cognito: user pool clients, groups, resource servers
- AppSync: data sources, resolvers, functions
- CloudWatch Logs: metric filters, subscription filters
- ELBv2: listeners, listener rules
- EC2: VPC subnets, VPC endpoints, VPC route tables, VPC network ACLs, VPC
security groups (an out-of-band
create-security-group— a rogue firewall in a declared VPC; the VPC default SG and AWS-service-created SGs, e.g. an EKS cluster SG, are folded so only a user-created group surfaces), route table routes - ECS: cluster services
- KMS: key aliases, key grants (an out-of-band
kms create-granton a key — grants are not a CloudFormation type, so cdkrd surfaces them as a syntheticAWS::KMS::Grant; AWS-service-created grants, e.g. a Lambda env-encryption key's grant, are folded so only a human/IAM-principal grant surfaces) - AppConfig: application environments, configuration profiles
- EFS: file system mount targets
- RDS: database cluster instances
- S3: bucket resource policies (an out-of-band
put-bucket-policyon a bucket with no declaredAWS::S3::BucketPolicy) - SQS: queue access policies (an out-of-band
set-queue-attributes Policyon a queue with no declaredAWS::SQS::QueuePolicy) - Secrets Manager: secret resource policies (an out-of-band
put-resource-policyon a secret with no declaredAWS::SecretsManager::ResourcePolicy)
Full design and rationale: docs/ARCHITECTURE.md.
Selecting stacks
| invocation | what is checked |
| --------------------- | ------------------------------------------------------------- |
| cdkrd check | every stack the CDK app defines, each in its own env.region |
| cdkrd check 'Dev*' | glob, matched against the app's stack names |
| cdkrd check MyStack | every same-named stack (a name can repeat across regions) |
cdkrd resolves your CDK app to discover which stacks exist and to label findings by
construct path. The app comes from cdk.json (when run in the project directory) or
from --app: a command (--app "node bin/app.js") or a pre-synthesized assembly
(--app cdk.out, read not executed); $CDKRD_APP also works. The drift
comparison still reads each stack's deployed template + live state from AWS;
synth only tells cdkrd which stacks to look at.
Context lookups. Resolving a CDK app that uses fromLookup
(Vpc.fromLookup, HostedZone.fromLookup, …) runs the same live AWS context
lookups as cdk synth and caches the results in cdk.context.json in your app
directory — so an app with uncached lookups will make read-only AWS calls and
create/update that file (a git status change is expected; the cache makes
subsequent checks reproducible). cdkrd prints a one-liner when it does so. This
matches cdk synth semantics and is the only file check writes; it never writes
to AWS. If every lookup fails (leaving only an empty {}), cdkrd removes the file
it just created rather than leave that noise in your tree.
Commands & options
| command | does |
| --------------------------- | ---------------------------------------------------------------------- |
| cdkrd check [<stack>...] | compare live state vs template (declared) + baseline (undeclared) |
| cdkrd record [<stack>...] | snapshot undeclared + added state into the baseline (CI: --yes) |
| cdkrd ignore [<stack>...] | stop reporting chosen drift via .cdkrd/ignore.yaml (CI: --yes) |
| cdkrd revert [<stack>...] | write the desired value back to AWS (confirms; --dry-run to preview) |
Day to day you run only cdkrd check and act from its prompt; the standalone
record / ignore / revert are the same actions for scripting / non-TTY /
CI (with --yes).
Exit codes
checkis report-only by default: drift prints but exits0(a note names the flag).--fail(thecdk diff --fail/cdk drift --failconvention) exits1on drift and suppresses all prompts. It's the one flag for scripts and CI.--no-promptis the prompt axis, orthogonal to--fail's exit-code axis: it suppresses the interactive resolve menu (report-only) but keeps exit0on drift. Reach for it when a script runscheckattached to a terminal (both stdin+stdout are a TTY, so the TTY gate alone would still prompt and could hang) yet must not fail the job on drift. A non-TTY run (real CI, a pipe, redirected output) never prompts anyway, so this only matters for the terminal-attached case; combined with--fail,--fail's exit1wins.--strictis the orthogonal coverage axis: it exits1when a run was incomplete (a resource skipped for an ACTIONABLE reason — a CC-unsupported type with no override, a read error / throttle / AccessDenied — or a nested stack not recursed into). A custom resource (Custom::*/AWS::CloudFormation::CustomResource, e.g.Custom::S3AutoDeleteObjects/Custom::LogRetention) has no cloud-side model to read, so it is a permanent-by-nature gap that norecord/ignorecould clear — it is informational and does NOT fail--strict(otherwise the flag would exit1forever on nearly every real CDK app). The gap is always surfaced regardless (as theskipped=Nfooter line or a loudwarning:);--strictonly decides whether an actionable gap fails the build.- Errors always exit
2;revertexits1when drift remains after it. - Interrupting a run with Ctrl-C (or ESC / SIGINT) during the gather/read
phase exits
130(128 + SIGINT) for every verb, and a SIGTERM (CI cancellation,timeout, a supervisor) exits143promptly on the first signal — never0, so an abortedcheck --failcan never be mistaken for "no drift" and an abortedrecordis never a false "written". An unhandled internal error (a stray promise rejection / uncaught exception) always exits2(the error code), never0or1.
- run: npm ci # cdkrd resolves the CDK app, so its deps must be installed
- run: npx cdkrd check --fail --region us-east-1 # fails the job on drift
# or point at a prebuilt assembly artifact instead of synthesizing:
# - run: npx cdkrd check --fail --app cdk.out --region us-east-1Options
| option | meaning |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| --region <r> | a stack's own env.region ALWAYS wins; for an env-AGNOSTIC stack (no env) it falls back to --region (or $AWS_REGION / $AWS_DEFAULT_REGION), else the AWS-CLI/cdk region chain — the active profile's region, then the [default] profile's region, then EC2 IMDS |
| --profile <p> | AWS profile (else $AWS_PROFILE, else $AWS_DEFAULT_PROFILE — same order aws and cdk use) |
| -a, --app <cmd\|cdk.out> | CDK app command or pre-synthesized assembly dir (or $CDKRD_APP / cdk.json "app"); stack auto-discovery + construct paths |
| -c, --context key=value | context for synth (repeatable; cdk.json is the base layer) |
| --all | target every stack the app defines (the default when no <stack> is named; cannot be combined with named stacks — a <stack> ... --all invocation errors) |
| --json | machine-readable output (see JSON contract) |
| --fail | (check) exit 1 on drift and never prompt; for scripts/CI. Without it, check reports drift but exits 0 |
| --no-prompt | (check) never open the interactive resolve menu (report-only), but keep exit 0 on drift — the exit-0 twin of --fail's prompt suppression, for a report-only script attached to a terminal. Non-TTY never prompts anyway; with --fail, --fail's exit 1 wins |
| --strict | (check) exit 1 when coverage is incomplete. A coverage gap is always surfaced loudly; --strict makes it CI-failing. Orthogonal to --fail |
| --show-all | (check) inventory mode: show all current undeclared state, ignoring the baseline |
| --verbose / -v | (check) expand the info: footer tiers / (revert) expand the not-revertable summary to full lists; (record) itemizes nested sub-keys in the multiselect. -v means --verbose inside a verb (check -v); a bare cdkrd -v (no verb) prints the version |
| --pre-deploy | (check) compare live vs the LOCAL synth template: the declared drift your next cdk deploy would silently overwrite |
| --undeclared-only | (check) undeclared drift only: pair cdkrd with cdk drift for the declared side |
| --declared-only | (check) declared drift vs the deployed template only (undeclared tier skipped; baseline untouched). Not --pre-deploy |
| --dry-run | (revert) print the plan; make no changes |
| --wait[=DURATION] | (revert) on a transient "resource is mid-update" error (e.g. Route53Resolver RSLVR-00705) keep retrying until it settles, up to DURATION (default 10m; e.g. --wait=5m, --wait=90s). The DURATION is inline-only (--wait=5m); a separate --wait 5m is not read |
| --remove-unrecorded | (revert, and check for its inline revert) REMOVE unrecorded values + DELETE unrecorded added resources in a no-prompt run (--yes/CI); an interactive revert already lists them |
| --force | (revert) proceed even when the stack is mid-operation (*_IN_PROGRESS). By default revert re-reads the stack right before the write and refuses (to avoid fighting an in-flight deploy); --force overrides that for a knowingly-wedged stack. The mid-operation warning still prints; --force does not skip the confirm (that is --yes) |
| --yes / -y | skip confirmations (revert apply; record records all without the multiselect) |
Unknown options (--apq) and options missing their value (--app at the end of
the line) are errors (exit 2): a typo'd flag never silently becomes a stack name.
The three check scope flags — --pre-deploy, --undeclared-only, and
--declared-only — are mutually exclusive; passing two of them is an exit-2
error (they select which comparison runs, so a combination is contradictory).
Interactive prompts (TTY only, CI is never prompted)
Every option runs exactly the same code as the standalone commands. Prompts are
skipped under --json, --show-all, --pre-deploy, --fail, --no-prompt, --yes, and the
scope filters --declared-only / --undeclared-only (a filtered finding set must
never become a snapshot-complete baseline via the inline Record — use the standalone
record verb, which sees the unfiltered state, #779). A non-TTY run
never prompts: a required write decision without --yes errors with exit 2 (the
safe side). "Interactive" requires both a TTY stdin and a TTY stdout — so
redirecting or piping the output (cdkrd check > report.txt, | tee) is also
treated as non-interactive: the report is written cleanly with no prompt (which
would otherwise deadlock, waiting on stdin for a prompt written into the file) and
no spinner frames leaking into the text. --yes in a TTY skips the write
confirmation AND each verb's selection
multiselect — record records ALL, ignore ignores ALL, revert applies the full
plan. check --yes never opens the action menu (Record / Revert / Ignore / …) —
it just reports (#1054/#1185); use the standalone record --yes / ignore --yes
/ revert --yes verbs for scripted, non-interactive decisions.
checkwith drift offersRecord / Revert / Ignore / Decide per finding / Nothing(see The model). Each option appears only when it applies (no Revert if nothing is revertable; "Decide per finding" only with >1 finding). Aborting the Revert confirmation writes nothing.revertshows the plan, then a multiselect of the op(s) to write. Every op starts unselected: it's the one command that writes to AWS, so you opt in to each write. REMOVE ops (deleting a live value not in your template) are labeled(REMOVE). space toggles · → selects all · ← clears all · enter confirms.--yesapplies the full plan.recordshows a multiselect of only the delta from the existing baseline; already-recorded unchanged values are auto-kept. New undeclared values are pre-selected; a value that changed since record (a recorded value altered out of band) is shown asrecorded → liveand default UNSELECTED, so one Enter never silently blesses a changed value. A recorded value that reverted to its AWS default or was removed since record is offered as a separate default-unselected "drop from baseline?" row — leaving it keeps the watch (it stays reported bycheck); a--yesrecord preserves it and echoes what it accepted. Deselect a suspicious one and it stays reported bycheck. Nested undeclared sub-keys (a value inside an object you did declare) are listed in full alongside the top-level ones — a non-default one is a real out-of-band setting, so it is surfaced, not hidden.recordwrites only undeclared + added state; any declared / deleted drift is not written andrecordprints a note that it still stands (resolve withrevertorcdk deploy).Decide per findingassigns a different action to each finding. On a busy stack, just start typing to filter the rows (↑↓ move · space cycles a row's actions · → applies the focused action to every visible row · enter applies).- Folded inventory: if
checkfolded undeclared values out of the report, Ignore / Decide first ask whether to act on just the shown drift (default) or the folded values too, so the picker never lists values you never saw.
--pre-deploy
Normal check asks "did reality drift from what I deployed?". --pre-deploy asks
the inverse, right before a deploy: "which LIVE values would my local code
overwrite?" It compares live state against your local synth template, so a
console hot-fix made during an incident shows up before cdk deploy silently
reverts it:
$ npx cdkrd check --pre-deploy
(--pre-deploy) comparing live state against the LOCAL synth template
=== check: ApiStack (us-east-1) ===
[CFn-Declared Drift: 1]
Api/Handler.MemorySize (AWS::Lambda::Function)
desired=1024
actual =2048
───────────────────────────────
result: 1 drift(s) (declared=1)
───────────────────────────────desired is what your local code is about to set; actual is live now: someone
bumped memory to 2048 out of band. Port it into code (the step-by-step loop is in
Fixing it in code instead) or decide it should go
away before deploying. As a pipeline gate:
- run: npx cdkrd check --pre-deploy --fail # block the deploy on clobber risk
- run: npx cdk deploy --all --require-approval never--pre-deploy reports declared drift only (the undeclared tier is defined
against the deployed template) and never touches the baseline.
One caveat on template parameters: for an existing parameter a plain
cdk deploy keeps the deployed value (UsePreviousValue), so drift previewed
from a changed local Default is applied only if you pass
--parameters <key>=<value> / --no-previous-parameters — check prints a
stderr note naming each such parameter and both values.
A second caveat on server-side transforms (SAM AWS::Serverless-2016-10-31,
AWS::LanguageExtensions, macros): CloudFormation expands these in the cloud at
deploy time, but --pre-deploy compares against your local, unprocessed synth
template. So under --pre-deploy every AWS::Serverless::* resource is skipped
(its declared props are not compared) and transform-generated resources are
invisible — check prints a per-stack stderr note when it sees a transformed
template. Normal check (against the deployed, already-processed template) has
no such gap and compares transformed stacks fully; prefer it for SAM/macro apps.
Ignoring externally-managed properties
Some properties are legitimately rewritten by another system. cdkrd already folds
the most common case automatically: a property a sibling
AWS::ApplicationAutoScaling::ScalableTarget governs — an ECS Service DesiredCount,
autoscaled DynamoDB capacity, a Lambda alias's provisioned concurrency, … — is not
flagged while its live value stays within the declared MinCapacity/MaxCapacity
band (the autoscaler enforcing the template's own delegation is intent, not drift,
#688). A value pushed beyond the band still surfaces as real drift, but revert
deliberately refuses it (writing the initial value back would just be re-scaled). For
anything cdkrd does not fold — an externally-managed Lambda reserved concurrency,
a value another controller rewrites — run cdkrd ignore to pick the drift to
suppress, or hand-edit the git-committed .cdkrd/ignore.yaml. It is a
hand-edited policy file (the .gitignore / .dockerignore / .trivyignore
family), so it is YAML rather than JSON: the single most valuable hand-edit is a
# comment recording why a property is ignored, and YAML can carry it where
JSON cannot. (The companion baseline stays JSON because it is the opposite —
machine-generated, wholesale-rewritten data, not human policy.)
# cdkrd ignore rules — properties cdkrd should stop reporting as drift.
ignore:
# reserved concurrency is managed by an external controller (not AAS, so not auto-folded)
- path: '*.ReservedConcurrentExecutions'
# the common case: scope a rule to one stack
- path: '*.ReservedConcurrentExecutions'
stack: Prod*
# narrow further to a single account and/or region when the same stack
# name is deployed to several (a property may legitimately drift in only one)
- path: Fn*.ReservedConcurrentExecutions
stack: Prod*
account: '111111111111'
region: ap-northeast-1- Every rule is a mapping
{ path, stack?, account?, region? }.cdkrd ignorestamps the current stack / account / region onto each rule it writes (the same three identity axes a baseline file is keyed on), so ignoring a within-stack path on one stack never leaks to a same-named twin stack in another account/region — an unscoped{ path }was match-all (#757). The verb is comment-preserving and append-only: it keeps your existing comments and layout, and appends new rules at the end — you own the order.pathis an exact<constructPath>.<path>— the construct path WITHIN the stack (the stack/Stage prefix stripped), byte-identical to whatcdkrd checkprints, so you can copy what you see (or<logicalId>.<path>on a non-CDK stack). Rules written with the older full<stack>/<constructPath>.<path>form still match. Widening a stamped scope to a*glob (to intentionally ignore a path across every stack/account/region) stays a hand-edit. - All four fields accept
*/?globs, but thepathaxis is segment-aware while the scope axes are not. Inpath,*/?match within a single segment, bounded by.,/, and[(*.DesiredCountmatches<anyId>.DesiredCount, not a deeperTbl.Config.DesiredCount;MyApi/*matches a direct construct-path child but not a grandchildMyApi/Resource/Method) — but an explicit parentpath(e.g.MyApi/Resource) still covers its whole/-subtree (MyApi/Resource/Method.Prop) via the ancestor walk, symmetric with the.case, so the segment bound does not under-match. A leaf-pinned wildcard likeMyApi/*.Propstill does NOT cross segments (it only matches a direct child's.Prop). Inside a[...]bracket key a*/?is unbounded within that bracket (the brackets delimit the key, so a.between them is data):Alb.LoadBalancerAttributes[*]andAlb.LoadBalancerAttributes[routing.*]both match the dotted key[routing.http2.enabled]. To match a literal*/?in apath(e.g. an API GatewayMethodSettings[*]key fromHttpMethod: '*', or an S3 lifecycleIdlikeclean*tmp), backslash-escape it —\*/\?(and\\for a literal backslash). Theignoreverb writes such paths pre-escaped, so a machine-written rule matches only the finding it came from; hand-copied rules need the escape added. On stack / account / region,*/?are unbounded (those names carry no.). The three scope axes are exactly the baseline file's identity axes; each is omitted to leave that axis unscoped (match-all) — a present-but-empty""is rejected (it would match nothing), as is an all-wildcardpath(*,**,*.*) — it would silence every finding, so a rule must name at least one literal segment. Account keeps astack: "Prod*"rule from leaking into a same-named stack in another account (stack-name uniqueness only holds within one account / App); region is independent the same way — the same stack in several accounts or regions may drift in only one. - Matching findings move to the informational
ignoredtier: visible under--verbose, never exit-affecting, excluded fromrevertandrecord. A deleted resource is never ignorable.
Output
Two parts: the drift sections in full detail, then a one-line info: footer
that folds everything informational.
=== check: ApiStack (us-east-1) ===
[CFn-Declared Drift: 1]
UploadBucket.VersioningConfiguration.Status (AWS::S3::Bucket)
desired="Enabled"
actual ="Suspended"
───────────────────────────────
result: 1 drift(s) (declared=1)
───────────────────────────────
info:
- readGap=1 (declared but unverifiable — not drift: 1 write-only)
- skipped=2 (custom resource 2)
run with --verbose for the list- Drift tiers (
deleted/declared/undeclared) are always listed in full and drive the--failexit. They are the point. [Potential Drift: N]: undeclared values with no baseline yet; cdkrd can't tell an intentional setting from an out-of-band change, so they're listed in full as potential (unconfirmed) drift and don't drive the--failexit;result:points you atcdkrd recordto accept them (orrevertto remove). Once a resource is fully snapshotted, a value that appears later is real drift (appeared since record).↳origin hint: when a finding's live value has a recognizable external source — e.g. the CloudWatch Application Signals / Lambda Insights auto-instrumentation footprint (an added Insights layer + tracer execution policy, typically enabled account-wide, not per-resource) — a↳line names the likely source. It's an explanation only: the finding is still real drift and still drives the exit, so an unexpected account-wide enablement is never hidden.info:footer folds the informational tiers to per-reason counts (--verboseexpands them):
| tier | what it folds |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| atDefault | an undeclared value sitting at a known AWS default (e.g. Lambda TracingConfig: PassThrough). Equality-gated, so a change away from it re-surfaces; never recorded. |
| generated | an undeclared value AWS/CDK minted, like an auto TopicName. Equality-gated; never recorded. |
| nested | an undeclared sub-key inside a property you did declare (e.g. a CloudFront origin gaining ConnectionTimeout, or an API Gateway method's Integration.PassthroughBehavior). Surfaced in full like a top-level undeclared value — a non-default nested value is a real out-of-band setting — and recordable. (Catalogued AWS defaults are folded upstream as atDefault/generated, so only genuine settings remain.) |
| readGap / unresolved / skipped / ignored | values cdkrd can't confidently compare, reported honestly rather than guessed (never false drift). |
^result: is the greppable verdict, framed by a horizontal rule when there is
drift so it stands out from the findings above (a CLEAN stack stays compact).
Colorized on a TTY (NO_COLOR respected): red/yellow/green for the verdicts and
tiers, while explanatory prose (tier notes, the ↳ hint, the info: footer) uses
your terminal's default foreground so it stays legible on any theme — dim/gray is
reserved for picker rows you aren't on. Piped / CI / --json output is plain text.
JSON output contract
--json emits one top-level JSON array for the whole invocation — one element per
stack:
[
{ "stack": "stackA (us-east-1)", "drifted": 1, "findings": [ ... ] },
{ "stack": "stackB (us-east-1)", "drifted": 0, "findings": [] }
]The whole stdout stream is a single JSON.parse-able value. This holds even for a
single-stack run — it is an array of one (never a bare object), so a consumer's
JSON.parse always yields an array and never has to special-case the count. (Earlier
builds printed one pretty-printed {...} per stack back-to-back, which was neither one
valid JSON value nor JSONL; multi-stack --json was unparseable — issue #755.) All
note / warning / progress lines go to stderr, so stdout stays pure JSON.
Secret redaction. Values on known secret-bearing properties — Lambda
Environment.Variables, CodeBuild Environment.EnvironmentVariables[].Value,
Elastic Beanstalk env-namespace OptionSettings, EC2 LaunchTemplate UserData —
are masked as <redacted:NN chars:HHHHHHHH> in both text and --json output, so a rotated
secret surfacing as drift does not leak its plaintext into CI logs. The NN is the
value's char length and HHHHHHHH is the first 8 hex of its sha256 — a deterministic,
stable distinguisher (#1308): the same secret masks to the same placeholder across runs,
so a consumer diffing two consecutive --json runs compares them correctly, while a
rotated same-length secret now masks to a different placeholder instead of a
byte-identical one (the length-only mask erased that change). The hash is non-reversible for
a high-entropy value; a low-entropy value's short hash is brute-forceable, so it is a
change-distinguisher, not a confidentiality guarantee — but the plaintext itself is never
printed. In --json a masked finding additionally carries a "redacted": true sibling, an
out-of-band marker so a consumer can tell a masked value from a literal live string that
happens to look like the placeholder (a non-secret finding has no redacted field). The
finding still surfaces (detection is preserved) and the property/key name stays visible —
only the value is hidden. The same protection covers persistence: record writes a
hash of these values into the git-committed baseline instead of the plaintext, so
the secret never lands in a committed file while a later change still re-surfaces as
drift (an unchanged one stays recorded). Older baselines holding a plaintext value keep
comparing correctly and migrate to the hashed form on the next record (#798).
A stack that errored or was skipped before it could be checked still appears as an
element so a consumer sees which stacks ran: it carries "error": "<reason>" alongside
"drifted": 0 and an empty "findings": []. (error is absent on a
successfully-checked stack.) Its stack is "<name> (<region>)" as usual, EXCEPT
when the failure is that no region could be resolved for the stack — then stack
is the bare "<name>" (no (<region>) suffix), since there is no region to name.
A stack deleted out of band — its committed baseline proves it was once deployed but
it is now gone from CloudFormation — is the strongest drift, so it carries
"drifted": 1 and "stackDeleted": true (never "error", which is reserved for a
pre-check failure). A consumer summing drifted across stacks therefore sees the
deletion instead of a misleading zero.
If the whole invocation fails before any stack is reached (bad config, an
un-synthesizable app, or a zero-stack app), stdout is still a valid empty array
[] (exit code non-zero, the reason on stderr) — never empty bytes.
Every successfully-checked stack element of a baseline-consulting check carries a
"baseline": <bool> flag —
true when the stack has a committed .cdkrd baseline (its undeclared dimension is
watched), false when it has never been recorded. Without a baseline the
classification shifts: appeared-since-record out-of-band values read as unrecorded
and are excluded from drifted, so a QUIET never-recorded stack emits the
byte-identical "drifted": 0 of a watched-clean one. A consumer summing drifted
uses baseline to tell an UNWATCHED stack from a watched-clean one (#1095). It is
omitted whenever the baseline was not consulted: on the error / stackDeleted
shapes (never reconciled against a baseline), and on every element of a
--pre-deploy or --show-all run (both are baseline-untouched modes, so they
cannot claim false — a committed baseline may well exist; #1335). Absent means
"not consulted"; false means "consulted, never recorded".
Each stack element is { "stack": "<name> (<region>)", "drifted": <n>, "baseline": <bool>, "findings": [...] }
(error added only on a pre-check failure; stackDeleted: true only on a deleted
stack). Each finding has a stable shape:
- Always present:
tier,logicalId,resourceType,path. - Usually present:
desired,actual,note,physicalId,constructPath(any of these may be omitted when it doesn't apply — e.g. an undeclared finding has nodesired). tieris one ofdeleted|added|declared|undeclared|atDefault|generated|ignored|readGap|unresolved|skipped. The output carries every finding of every tier (including the informationalatDefault/generated/ignored/readGap/unresolved/skippedones the text report folds into itsinfo:footer), regardless of--verbose.driftedcounts only confirmed-drift tiers (deleted/declared/undeclared/added) that are notunrecorded; the informational tiers and unrecorded values are excluded.
Optional per-finding fields, present only when they apply:
hint— a non-classifying, human-facing note on where a live value likely came from (e.g. an account/region-level auto-instrumentation footprint). Display-only; it never changes the tier.unrecorded(true) — a live-only value (undeclaredoradded) with no baseline entry yet: potential drift, not confirmed drift. Excluded fromdrifted.attributeKey— for adeclareddrift inside an identity-keyed attribute bag (ELBLoadBalancerAttributes/TargetGroupAttributes), theKeyof the changed attribute (pathstays at the bag property;desired/actualare the scalar value).arrayDelta— for anundeclaredrecorded identity-keyed array that changed vs the baseline, the element-level delta ({ identityField, added, changed, removed }) so a consumer sees which element(s) differ, not the whole-array dump.nested(true) — anundeclaredvalue is a live sub-key inside a property you did declare (dottedpath).freeFormKey(true) additionally marks it as living inside a free-form map (e.g. a Lambda env var).modelReadFailed(true) — anaddedresource whose full live model could not be read this run (actualis only the enumerator's identity snippet); it exists but is not change-watchable until the next check.wholeArrayRevert({ path, value }) — internal, revert-only metadata on adeclaredper-element finding inside an unordered object-array (a set the service reorders — SecurityGroup rules, PrefixList entries, …): it carries the WHOLE declared array sorevertreplaces the array as a unit instead of index-patching a sorted position that does not map to the live index. Display-only for the report; a consumer can ignore it.siblingPolicyNames("unresolved") — internal, revert-only sentinel on adeclaredIAM RolePoliciesfinding whose siblingAWS::IAM::Policynames could not be resolved;revertreads it and refuses to act (a per-entry revert could delete a managed inline policy). The only emitted value is the"unresolved"string; a consumer can ignore it.- On an
addedfinding,desiredcarries the recorded baseline model andactualthe live one (so a recordedaddedresource that changed shows the delta), andunrecordedmarks a never-recorded one as potential drift.
record / ignore / revert also honor --json (for scripting / non-TTY use),
each emitting the same one-array-per-invocation shape — one element per stack,
all notes on stderr, [] on a top-level error. --json forces non-interactive
mode: a record / ignore that would need the selection prompt refuses without
--yes ("refused": true), and revert refuses the AWS write without --yes
("exit": 2). Per-verb element:
record→{ "stack", "recorded": <n>, "wrote": <bool>, "baselinePath"?: "<path>", "refused"?: true, "error"?: "<reason>" }—recordedis how many undeclared value(s) were written;baselinePathis the baseline file written (a path string, deliberately NOT namedbaseline— that key is check's boolean flag; #1336).ignore→{ "stack", "added": <n>, "wrote": <bool>, "config"?: ".cdkrd/ignore.yaml", "refused"?: true, "error"?: "<reason>" }—addedis how many new rules were appended.revert→{ "stack", "reverted": <n>, "failed": <n>, "aborted": <bool>, "exit": <n>, "error"?: "<reason>" }—reverted/failedcount resources;exitis that stack's contribution (0 clean / 1 drift remains / 2 failure).
After publication this shape is a backward-compatible API.
revert also refuses to write (exit 2) when the target stack is mid-operation
(*_IN_PROGRESS): it re-reads the stack status immediately before applying, so a
revert can never fight an in-flight cdk deploy / update by writing stale values
onto an updating stack — wait for the stack to settle, then re-run. For a stack
that is wedged in an *_IN_PROGRESS state that will never settle, pass
--force to override the refusal (the mid-operation warning still prints; --force
only skips this in-progress refusal — not the confirm, which is --yes). And
record / ignore / revert now surface the same stack-status warning check
prints when a stack is mid-operation or in a failed state (a record mid-update
would otherwise snapshot transient values into the committed baseline).
IAM permissions
check / record are read-only: the AWS managed ReadOnlyAccess policy
covers them. If you never run revert, cdkrd needs no write permissions at all.
cloudformation:GetTemplate,ListStackResources,DescribeStacks,DescribeType;DescribeStackResources(the sibling-ownership check for theaddedtier — see theadded-tier entry below);ListExports(only for templates usingFn::ImportValue)sts:GetCallerIdentity(resolves the current account so every verb can tell a stack never deployed in THIS account from one deleted out of band, under the multi-account baseline pattern —check/record/ignoreskip a stack pinned to a different account andrevertrefuses to write to it; allowed for any valid credentials, so no policy statement is normally required)cloudcontrol:GetResource: Cloud Control invokes each type's own read handler, so it needs that type's read permissions (this is whyReadOnlyAccessis the simple answer).revertadditionally usescloudcontrol:UpdateResource/cloudcontrol:DeleteResource(the latter to delete an out-of-bandaddedresource) andcloudcontrol:GetResourceRequestStatusto poll the async request — see the revert section below.- SDK readers for the Cloud-Control-gap types:
s3:GetBucketPolicy,sns:GetTopicAttributes,sqs:GetQueueAttributes,iam:GetRolePolicy,iam:GetUserPolicy,iam:GetGroupPolicy,iam:GetPolicy,iam:GetPolicyVersion,lambda:GetPolicy,budgets:ViewBudget,ec2:DescribeAddresses,ec2:DescribeLaunchTemplateVersions,ec2:DescribeNetworkAcls,route53:ListResourceRecordSets,route53:ListHostedZonesByName(resolves a RecordSet declared viaHostedZoneNameinstead ofHostedZoneIdto its zone id),ses:DescribeReceiptRuleSet,ses:DescribeReceiptRule,ses:ListReceiptFilters(the SES inbound receipt-rule family —ReceiptRuleSet/ReceiptRule/ReceiptFilter— has no Cloud Control handlers),acm:DescribeCertificate+acm:ListTagsForCertificate(read anAWS::CertificateManager::Certificate— the ACM registry type ships no Cloud Control read handler, so every cert was silently skipped, including an out-of-bandOptions.CertificateTransparencyLoggingPreferenceflip or an out-of-band deletion of a cert an ALB / CloudFront / API domain still references),glue:GetTable,logs:DescribeMetricFilters,scheduler:GetSchedule,cloudwatch:DescribeAnomalyDetectors(reads anAWS::CloudWatch::AnomalyDetector— NON_PROVISIONABLE, no Cloud Control handlers),dlm:GetLifecyclePolicy(reads anAWS::DLM::LifecyclePolicy— a Data Lifecycle Manager EBS-snapshot / AMI backup-schedule policy, NON_PROVISIONABLE with no Cloud Control handlers; the physical id IS the policy id, and the sameGetLifecyclePolicyresponse carries the policy'sTags— no extra call — so a declaredTagsis not false-flagged as drift),dms:DescribeEndpoints+dms:DescribeReplicationSubnetGroups+dms:DescribeReplicationInstances+dms:DescribeReplicationTasks+dms:ListTagsForResource(read anAWS::DMS::Endpoint/AWS::DMS::ReplicationSubnetGroup/AWS::DMS::ReplicationInstance/AWS::DMS::ReplicationTask— the classic DMS migration/CDC family, NON_PROVISIONABLE with no Cloud Control handlers;ListTagsForResourcereads each resource's tags, which theDescribe*responses omit — without it a declaredTagsfalse-flagged as drift on every tagged DMS resource),lakeformation:DescribeResource(reads anAWS::LakeFormation::Resource— a registered S3 data location whose Cloud ControlGetResourcereturnsUnsupportedActionException; the physical id IS the locationResourceArn, and the reader projectsRoleArn/WithFederation/HybridAccessEnabled),mediaconvert:GetQueue+mediaconvert:GetJobTemplate+mediaconvert:ListTagsForResource(read anAWS::MediaConvert::Queue/AWS::MediaConvert::JobTemplate— a video-pipeline staple, NON_PROVISIONABLE with no Cloud Control handlers; the physical id IS the resource name, andListTagsForResourcereads each resource's tags, which theGet*responses omit — without it a declaredTagsfalse-flagged as drift on every tagged MediaConvert resource),config:DescribeConfigurationRecorders+config:DescribeDeliveryChannels(read anAWS::Config::ConfigurationRecorder/AWS::Config::DeliveryChannel— the account/region compliance-baseline SINGLETONS a large fraction of accounts deploy, NON_PROVISIONABLE with Cloud ControlUnsupportedActionException; the physical id IS the recorder / channel name, and the reader projects the CFn-declarable surface — the recorder's undeclaredRecordingGroup.RecordingStrategymirror andRecordingModedefault fold to atDefault, so a fresh recorder is clean while a recording-scope change still surfaces),elasticloadbalancing:DescribeListenerCertificates(reads anAWS::ElasticLoadBalancingV2::ListenerCertificate— the SNI extra-cert attachment whose registry type ships NO read handler at all, so Cloud Control returnsUnsupportedActionExceptionand it was silently skipped; the reader derives the listener ARN from the declaredListenerArnand projects this resource's declared certs still present in the live non-default set, so a declared cert removed out of band surfaces as drift while multiple attachments on one listener stay false-positive-free),ssm:DescribeParameters(supplements the Cloud Control read of anAWS::SSM::Parameterwith its writeOnlyDescription/AllowedPattern),elasticache:DescribeReplicationGroups+elasticache:DescribeCacheClusters(supplement anAWS::ElastiCache::ReplicationGroupwith its writeOnlyPreferredMaintenanceWindow/NotificationTopicArn/EngineVersion, read from the member cache cluster),elasticache:DescribeCacheParameterGroups+elasticache:DescribeCacheParameters+elasticache:ListTagsForResource(read anAWS::ElastiCache::ParameterGroup'sPropertiesas theSource=userMODIFIED set only — the Cloud Control read returns the full effective set, so the ~60 inherited engine defaults would otherwise surface as first-run drift; an out-of-band parameter change isSource=userand still detected;ListTagsForResourcereads the group's tags, which theDescribe*responses omit — without it a declaredTagsfalse-flagged as drift on every tagged group),ecs:DescribeServices(supplements anAWS::ECS::Servicewith its writeOnlyServiceConnectConfiguration/VolumeConfigurations, read from the PRIMARY deployment),elasticache:DescribeUsers+memorydb:DescribeUsers(supplement anAWS::ElastiCache::User/AWS::MemoryDB::Userwith its writeOnlyAccessString— the Redis/Valkey ACL; an out-of-band permission grant is otherwise invisible to the Cloud Control read),memorydb:DescribeParameters+memorydb:DescribeParameterGroups(supplement anAWS::MemoryDB::ParameterGroupwith its writeOnlyParameters— folding the family-default fill by diffing the manageddefault.<family>group; the MemoryDB provider does not apply declared parameters on CREATE, so this surfaces the never-applied tuning that was otherwise an invisible readGap),redshift-serverless:GetWorkgroup(supplements anAWS::RedshiftServerless::Workgroupwith its writeOnlyConfigParameters/SecurityGroupIds/SubnetIds, which the Cloud Control read returns only inside its read-only echo attribute — an out-of-band security-group swap or config-parameter change is otherwise invisible),kafka:DescribeConfiguration+kafka:DescribeConfigurationRevision(supplement anAWS::MSK::Configurationwith its writeOnlyServerPropertiesKafka blob — an out-of-bandupdate-configurationrevision is otherwise invisible),elasticbeanstalk:DescribeConfigurationSettings(supplements anAWS::ElasticBeanstalk::Environmentwith its writeOnlyOptionSettings— the full resolved option set; an out-of-band console edit to any environment option is otherwise invisible, and the servi
