{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreib2o7s4jmybezvjiu6r7vxidvi47anah4fdwrbugp4x7b35kygmyi",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpvjra4j35d2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreid45jvl4ndk7xhjit2srfetkbvbbhdigmwrdlb3ht66wvbz7vu4ye"
},
"mimeType": "image/webp",
"size": 77586
},
"path": "/hitoshi1964/my-drift-detector-graded-every-change-and-stayed-blind-to-the-secret-that-hadnt-rotated-in-200-52oi",
"publishedAt": "2026-07-05T11:48:01.000Z",
"site": "https://dev.to",
"tags": [
"aws",
"security",
"devops",
"python",
"in Day 11",
"syncvey.com"
],
"textContent": "My drift detector is built on one idea: take two snapshots of your cloud, diff\nthem, and grade what moved. A security group opened to `0.0.0.0/0`? Critical. An\nRDS instance that flipped to public? Critical. A tag someone fat-fingered? Low.\nEvery rule is a function of an **old → new transition** — a change happened, and\nI score how bad the change is.\n\nThen I went to add \"your Secrets Manager secret hasn't rotated in too long\" and\nthe whole model fell over. Not because it's hard to detect. Because the thing I\nwanted to flag **produces no diff.**\n\n## The shape that breaks a diff\n\nHere's a secret that hasn't rotated in 200 days. I scan it Monday. I scan it\nTuesday. I diff the two snapshots:\n\n\n\n (no changes)\n\n\nOf course there are no changes — _nothing rotated_. That's the entire problem.\nScan it every minute for a week and every diff comes back empty. The dangerous\nstate of this secret is precisely the state in which **nothing is happening to\nit.**\n\nMy whole engine was wired to answer _\"what changed between two points in time?\"_\nThis risk lives in the opposite question: _\"what is true about this thing right\nnow, regardless of whether it just changed?\"_ A password that rotated 200 days\nago and a password that rotated 201 days ago look identical to a diff — but one\nof them crossed my 90-day line and the other didn't, and only absolute state\nknows that.\n\nI'd quietly assumed every risk was a _change_ risk. It isn't. Some risks are\n**standing conditions** : overdue rotation, public-by-default, encryption never\nenabled. The absence of a change _is_ the finding.\n\n## Two different questions, two different modules\n\nSo I stopped trying to force rotation through the diff path. The change-based\nrules stayed exactly as they were — they need a prior snapshot and grade the\ntransition:\n\n\n\n # rules.py — grades a field-level diff (old → new)\n if asset.raw_data_prev:\n changes = _compute_raw_diff(asset.raw_data_prev, asset.raw_data)\n if changes:\n findings.extend(assess(asset.asset_type, changes)['findings'])\n has_change = True\n\n\nRotation got its own module that grades **current state** , with no `_prev` in\nsight:\n\n\n\n # rotation.py — grades a standing condition (now, no diff)\n if (asset.raw_data or {}).get('_resource_type') == 'aws_secretsmanager_secret':\n findings.extend(assess_rotation(asset.raw_data, now, max_age)['findings'])\n\n\nBoth emit the same `{'field', 'severity', 'reason'}` shape, so the two streams\nof findings merge into one row and sort into the same severity-ranked list. The\nUI never knows one came from a diff and the other from a stopwatch.\n\n## What the grader actually looks at\n\nRotation posture isn't one boolean, it's a little ladder, and each rung is a\ndifferent severity:\n\n\n\n def assess_rotation(raw_data, now, max_age_days):\n raw = raw_data or {}\n findings = []\n\n if not _truthy(raw.get('rotation_enabled')):\n findings.append({'field': 'rotation_enabled', 'severity': HIGH,\n 'reason': 'Automatic rotation is disabled'})\n return _wrap(findings)\n\n last = _parse(raw.get('last_rotated_date'))\n if last is None:\n findings.append({'field': 'last_rotated_date', 'severity': MEDIUM,\n 'reason': 'Rotation is enabled but the secret has never rotated'})\n return _wrap(findings)\n\n age_days = (now - last).days\n if age_days >= max_age_days * 2:\n findings.append({'severity': CRITICAL, ...}) # over 2× the limit\n elif age_days >= max_age_days:\n findings.append({'severity': HIGH, ...}) # past the limit\n return _wrap(findings)\n\n\n * **Rotation disabled** → HIGH. It's not overdue, it's structurally never coming. Worse than \"late.\"\n * **Enabled but never rotated** → MEDIUM. Someone flipped the switch and walked away; the Lambda may be misconfigured.\n * **Overdue past the limit** → HIGH.\n * **Over twice the limit** → CRITICAL. 90 days late is a mistake; 180 days late is a dead process nobody's watching.\n\n\n\nIt's a pure function over a dict — no AWS calls, no models, fully testable with a\nfrozen `now`. Same discipline as the change rules: keep the judgement pure, keep\nthe I/O outside.\n\n## The scan captures posture, never the secret\n\nThe one thing I was paranoid about: a tool that _reads your secrets to check\nyour secrets_ is a worse problem than the one it solves. It never touches a\nvalue.\n\n`ListSecrets` already returns the rotation metadata — `RotationEnabled`,\n`LastRotatedDate`, `NextRotationDate` — so there's no `GetSecretValue`, not even\na `DescribeSecret`. One list call carries everything the grader needs:\n\n\n\n sm.get_paginator('list_secrets')\n # → RotationEnabled, LastRotatedDate, NextRotationDate, RotationRules ...\n # never GetSecretValue. posture only, value never leaves AWS.\n\n\nI capture whether it rotates, when it last did, and how often it's supposed to —\nand nothing that would be dangerous to store. There's a moto-backed test whose\nentire job is to assert the scanned record never contains the secret string.\n\n## The detail I nearly got wrong: \"Who changed this?\"\n\nEvery risk row has a lazy **\"Who changed this?\"** button — click it and the tool\ncalls CloudTrail to name whoever made the change (I wrote about that\nin Day 11).\n\nFor an overdue rotation, that button is a lie. There _is_ no actor. Nobody did\nanything — that's the whole finding. Asking CloudTrail \"who caused this secret to\nnot rotate?\" returns nothing, because non-events don't have culprits.\n\nSo the button is gated on the same `has_change` flag that the diff path sets:\n\n\n\n findings.extend(assess(asset.asset_type, changes)['findings'])\n has_change = True # only change-based findings get an actor\n\n\nA row that exists _only_ because of a standing condition renders without the\nattribution button. The shape of the risk decides whether \"who did it?\" is even a\ncoherent question — and for the absence of an event, it isn't.\n\n## Honest limits\n\n * The severity ladder is a heuristic. A 90-day default and a 2× critical cliff are opinions, not policy — hence the `SECRET_ROTATION_MAX_AGE_DAYS` setting.\n * It grades what `ListSecrets` reports. A rotation Lambda that \"succeeds\" while silently rotating to the same value would still look healthy. Posture, not proof.\n * Secrets Manager only. Rotation-as-a-standing-condition generalizes (IAM access keys, TLS certs, KMS key age) but I've only wired the one so far.\n\n\n\n## Takeaways\n\n * Not every risk is a _change_ risk. Diff-based detection is structurally blind to conditions that are dangerous precisely because **nothing is changing** — an overdue rotation produces no old→new transition to grade.\n * When a new risk doesn't fit your existing pipeline, that's a signal it's a _different question_ , not a harder version of the same one. Give it its own path instead of bending the diff to fit.\n * Grade standing conditions on absolute current state (`now` vs `last_rotated_date`), not on a snapshot delta.\n * Check posture without reading the secret — `ListSecrets` carries the rotation metadata, so you never call `GetSecretValue`.\n * Attribution only makes sense for events. Gate \"who did this?\" on whether a discrete change actually happened; non-events have no culprit.\n\n\n\nThis ships in a self-hosted tool that scans your live AWS, grades what drifted\n_and_ what's standing overdue, and never stores a secret value — open source\n(MIT), one `docker compose up`: syncvey.com. What's the\nmost dangerous thing in your account right now that would never show up in a\ndiff because it's been quietly _not changing_ for months?",
"title": "My drift detector graded every change — and stayed blind to the secret that hadn't rotated in 200 days"
}