npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

cdk-real-drift (cdkrd)

npm License

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: 0

cdkrd 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)
─────────────────────────────────

cdkrd finds an out-of-band inline policy that cdk drift reports as zero

| 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 defines

Or 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 explicitly

The 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 each

Each 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 template

If 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 is unrecorded: 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 check snapshots 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 Policy on a topic with no declared AWS::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-grant on a key — grants are not a CloudFormation type, so cdkrd surfaces them as a synthetic AWS::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-policy on a bucket with no declared AWS::S3::BucketPolicy)
  • SQS: queue access policies (an out-of-band set-queue-attributes Policy on a queue with no declared AWS::SQS::QueuePolicy)
  • Secrets Manager: secret resource policies (an out-of-band put-resource-policy on a secret with no declared AWS::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

  • check is report-only by default: drift prints but exits 0 (a note names the flag).
  • --fail (the cdk diff --fail / cdk drift --fail convention) exits 1 on drift and suppresses all prompts. It's the one flag for scripts and CI.
  • --no-prompt is the prompt axis, orthogonal to --fail's exit-code axis: it suppresses the interactive resolve menu (report-only) but keeps exit 0 on drift. Reach for it when a script runs check attached 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 exit 1 wins.
  • --strict is the orthogonal coverage axis: it exits 1 when 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 no record / ignore could clear — it is informational and does NOT fail --strict (otherwise the flag would exit 1 forever on nearly every real CDK app). The gap is always surfaced regardless (as the skipped=N footer line or a loud warning:); --strict only decides whether an actionable gap fails the build.
  • Errors always exit 2; revert exits 1 when 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) exits 143 promptly on the first signal — never 0, so an aborted check --fail can never be mistaken for "no drift" and an aborted record is never a false "written". An unhandled internal error (a stray promise rejection / uncaught exception) always exits 2 (the error code), never 0 or 1.
- 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-1

Options

| 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.

  • check with drift offers Record / 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.
  • revert shows 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. --yes applies the full plan.
  • record shows 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 as recorded → live and 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 by check); a --yes record preserves it and echoes what it accepted. Deselect a suspicious one and it stays reported by check. 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. record writes only undeclared + added state; any declared / deleted drift is not written and record prints a note that it still stands (resolve with revert or cdk deploy).
  • Decide per finding assigns 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 check folded 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-parameterscheck 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 ignore stamps 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. path is an exact <constructPath>.<path> — the construct path WITHIN the stack (the stack/Stage prefix stripped), byte-identical to what cdkrd check prints, 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 the path axis is segment-aware while the scope axes are not. In path, * / ? match within a single segment, bounded by ., /, and [ (*.DesiredCount matches <anyId>.DesiredCount, not a deeper Tbl.Config.DesiredCount; MyApi/* matches a direct construct-path child but not a grandchild MyApi/Resource/Method) — but an explicit parent path (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 like MyApi/*.Prop still 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[*] and Alb.LoadBalancerAttributes[routing.*] both match the dotted key [routing.http2.enabled]. To match a literal * / ? in a path (e.g. an API Gateway MethodSettings[*] key from HttpMethod: '*', or an S3 lifecycle Id like clean*tmp), backslash-escape it — \* / \? (and \\ for a literal backslash). The ignore verb 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-wildcard path (*, **, *.*) — it would silence every finding, so a rule must name at least one literal segment. Account keeps a stack: "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 ignored tier: visible under --verbose, never exit-affecting, excluded from revert and record. 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 --fail exit. 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 --fail exit; result: points you at cdkrd record to accept them (or revert to 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 (--verbose expands 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 no desired).
  • tier is one of deleted | added | declared | undeclared | atDefault | generated | ignored | readGap | unresolved | skipped. The output carries every finding of every tier (including the informational atDefault / generated / ignored / readGap / unresolved / skipped ones the text report folds into its info: footer), regardless of --verbose.
  • drifted counts only confirmed-drift tiers (deleted / declared / undeclared / added) that are not unrecorded; 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 (undeclared or added) with no baseline entry yet: potential drift, not confirmed drift. Excluded from drifted.
  • attributeKey — for a declared drift inside an identity-keyed attribute bag (ELB LoadBalancerAttributes / TargetGroupAttributes), the Key of the changed attribute (path stays at the bag property; desired/actual are the scalar value).
  • arrayDelta — for an undeclared recorded 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) — an undeclared value is a live sub-key inside a property you did declare (dotted path). freeFormKey (true) additionally marks it as living inside a free-form map (e.g. a Lambda env var).
  • modelReadFailed (true) — an added resource whose full live model could not be read this run (actual is 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 a declared per-element finding inside an unordered object-array (a set the service reorders — SecurityGroup rules, PrefixList entries, …): it carries the WHOLE declared array so revert replaces 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 a declared IAM Role Policies finding whose sibling AWS::IAM::Policy names could not be resolved; revert reads 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 added finding, desired carries the recorded baseline model and actual the live one (so a recorded added resource that changed shows the delta), and unrecorded marks 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>" }recorded is how many undeclared value(s) were written; baselinePath is the baseline file written (a path string, deliberately NOT named baseline — that key is check's boolean flag; #1336).
  • ignore{ "stack", "added": <n>, "wrote": <bool>, "config"?: ".cdkrd/ignore.yaml", "refused"?: true, "error"?: "<reason>" }added is how many new rules were appended.
  • revert{ "stack", "reverted": <n>, "failed": <n>, "aborted": <bool>, "exit": <n>, "error"?: "<reason>" }reverted / failed count resources; exit is 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 the added tier — see the added-tier entry below); ListExports (only for templates using Fn::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/ignore skip a stack pinned to a different account and revert refuses 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 why ReadOnlyAccess is the simple answer). revert additionally uses cloudcontrol:UpdateResource / cloudcontrol:DeleteResource (the latter to delete an out-of-band added resource) and cloudcontrol:GetResourceRequestStatus to 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 via HostedZoneName instead of HostedZoneId to 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 an AWS::CertificateManager::Certificate — the ACM registry type ships no Cloud Control read handler, so every cert was silently skipped, including an out-of-band Options.CertificateTransparencyLoggingPreference flip 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 an AWS::CloudWatch::AnomalyDetector — NON_PROVISIONABLE, no Cloud Control handlers), dlm:GetLifecyclePolicy (reads an AWS::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 same GetLifecyclePolicy response carries the policy's Tags — no extra call — so a declared Tags is not false-flagged as drift), dms:DescribeEndpoints + dms:DescribeReplicationSubnetGroups + dms:DescribeReplicationInstances + dms:DescribeReplicationTasks + dms:ListTagsForResource (read an AWS::DMS::Endpoint / AWS::DMS::ReplicationSubnetGroup / AWS::DMS::ReplicationInstance / AWS::DMS::ReplicationTask — the classic DMS migration/CDC family, NON_PROVISIONABLE with no Cloud Control handlers; ListTagsForResource reads each resource's tags, which the Describe* responses omit — without it a declared Tags false-flagged as drift on every tagged DMS resource), lakeformation:DescribeResource (reads an AWS::LakeFormation::Resource — a registered S3 data location whose Cloud Control GetResource returns UnsupportedActionException; the physical id IS the location ResourceArn, and the reader projects RoleArn / WithFederation / HybridAccessEnabled), mediaconvert:GetQueue + mediaconvert:GetJobTemplate + mediaconvert:ListTagsForResource (read an AWS::MediaConvert::Queue / AWS::MediaConvert::JobTemplate — a video-pipeline staple, NON_PROVISIONABLE with no Cloud Control handlers; the physical id IS the resource name, and ListTagsForResource reads each resource's tags, which the Get* responses omit — without it a declared Tags false-flagged as drift on every tagged MediaConvert resource), config:DescribeConfigurationRecorders + config:DescribeDeliveryChannels (read an AWS::Config::ConfigurationRecorder / AWS::Config::DeliveryChannel — the account/region compliance-baseline SINGLETONS a large fraction of accounts deploy, NON_PROVISIONABLE with Cloud Control UnsupportedActionException; the physical id IS the recorder / channel name, and the reader projects the CFn-declarable surface — the recorder's undeclared RecordingGroup.RecordingStrategy mirror and RecordingMode default fold to atDefault, so a fresh recorder is clean while a recording-scope change still surfaces), elasticloadbalancing:DescribeListenerCertificates (reads an AWS::ElasticLoadBalancingV2::ListenerCertificate — the SNI extra-cert attachment whose registry type ships NO read handler at all, so Cloud Control returns UnsupportedActionException and it was silently skipped; the reader derives the listener ARN from the declared ListenerArn and 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 an AWS::SSM::Parameter with its writeOnly Description / AllowedPattern), elasticache:DescribeReplicationGroups + elasticache:DescribeCacheClusters (supplement an AWS::ElastiCache::ReplicationGroup with its writeOnly PreferredMaintenanceWindow / NotificationTopicArn / EngineVersion, read from the member cache cluster), elasticache:DescribeCacheParameterGroups + elasticache:DescribeCacheParameters + elasticache:ListTagsForResource (read an AWS::ElastiCache::ParameterGroup's Properties as the Source=user MODIFIED 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 is Source=user and still detected; ListTagsForResource reads the group's tags, which the Describe* responses omit — without it a declared Tags false-flagged as drift on every tagged group), ecs:DescribeServices (supplements an AWS::ECS::Service with its writeOnly ServiceConnectConfiguration / VolumeConfigurations, read from the PRIMARY deployment), elasticache:DescribeUsers + memorydb:DescribeUsers (supplement an AWS::ElastiCache::User / AWS::MemoryDB::User with its writeOnly AccessString — the Redis/Valkey ACL; an out-of-band permission grant is otherwise invisible to the Cloud Control read), memorydb:DescribeParameters + memorydb:DescribeParameterGroups (supplement an AWS::MemoryDB::ParameterGroup with its writeOnly Parameters — folding the family-default fill by diffing the managed default.<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 an AWS::RedshiftServerless::Workgroup with its writeOnly ConfigParameters / 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 an AWS::MSK::Configuration with its writeOnly ServerProperties Kafka blob — an out-of-band update-configuration revision is otherwise invisible), elasticbeanstalk:DescribeConfigurationSettings (supplements an AWS::ElasticBeanstalk::Environment with its writeOnly OptionSettings — the full resolved option set; an out-of-band console edit to any environment option is otherwise invisible, and the servi