residoo 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,834 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
8
+
9
+ /**
10
+ * The rotation exit-path: turn every finding into a next step.
11
+ *
12
+ * Detection without rotation is theater, in the field's own numbers: 64% of
13
+ * secrets leaked in 2022 were still valid years later, 88% of re-verified
14
+ * leaked AWS keys still authenticated, and the median remediation time for
15
+ * GitHub-leaked secrets is 94 days (residoo-research/NIGHT-RESEARCH-2026-09-02.md,
16
+ * P3). This module maps every detection rule to the vendor's real rotation
17
+ * path, and keeps a local pending/acknowledged ledger so "found it" can
18
+ * become "closed it".
19
+ *
20
+ * URL DISCIPLINE (the bar every entry below was held to): a `rotateUrl` is
21
+ * present ONLY if that exact URL was fetched during development (2026-09-02)
22
+ * and confirmed to document rotation/revocation of that credential type; the
23
+ * per-entry comment says what was checked. Where the vendor's management
24
+ * surface is login-walled, bot-walled, or client-rendered (unverifiable end
25
+ * to end), the entry ships a `consolePath` in words instead, with the
26
+ * corroboration noted. A dead link in a security tool's remediation advice
27
+ * is a credibility wound; an honest console path is not.
28
+ *
29
+ * STATE FILE WRITE DISCIPLINE (~/.residoo/rotations.json):
30
+ * - This is the ONLY file residoo ever writes outside an explicit --seal.
31
+ * It is residoo's own state file, in residoo's own directory; the
32
+ * CONTRIBUTING.md rule that nothing modifies an existing file is about
33
+ * the user's files, and this carve-out is stated here in the open rather
34
+ * than slipped past it.
35
+ * - It never contains a raw secret. Keys are fingerprints (hashes of
36
+ * already-redacted material, see fingerprintFinding); user-supplied ack
37
+ * notes are run through PATTERNS plus NOISY_PATTERNS with redact, the
38
+ * same pipeline previews get, so even a note with a pasted secret in it
39
+ * is stored redacted. The noisy rules are included here even though
40
+ * scans only run them behind --include-noisy: a user acking a noisy
41
+ * finding is exactly the user likely to paste that value into a note.
42
+ * - Writes are atomic: full content to a temp file in the same directory,
43
+ * then rename over the target. A crash mid-write leaves the old state
44
+ * intact, never a half-written JSON.
45
+ * - A corrupt or unreadable state file degrades to "no acks" with a note
46
+ * on stderr, never a crash and never a silent pretend-empty. The next
47
+ * successful ack starts a fresh store; the stderr note is the user's
48
+ * cue that prior acks were lost to corruption.
49
+ *
50
+ * Everything else here is pure data in, pure data out: renderRotation()
51
+ * returns a structure for the report layer to print, it prints nothing
52
+ * itself.
53
+ */
54
+
55
+ // ── ordering advisory ───────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * For the report layer to show whenever one scan carries BOTH integrity
59
+ * warnings and secret findings. Evidence: the ChainDrop/keyv campaign
60
+ * (Aug 2026, 400+ npm packages) included a token monitor that fires an
61
+ * attacker payload at the moment the stolen GitHub token is revoked, which
62
+ * makes remediation ORDER safety-critical. Source: residoo-research/
63
+ * NIGHT-RESEARCH-2026-09-02.md section 2, and its sources-digest.json entry
64
+ * "The ChainDrop npm attack" (eon.io/blog/chaindrop-npm-supply-chain-attack,
65
+ * StepSecurity finding). Naive "rotate everything now" advice can itself
66
+ * trigger the damage.
67
+ */
68
+ const ROTATION_ORDER_ADVISORY =
69
+ "This scan found both integrity warnings and leaked credentials. Remove the " +
70
+ "planted persistence BEFORE rotating anything: the ChainDrop campaign " +
71
+ "(Aug 2026) shipped a token monitor that fires an attacker payload the " +
72
+ "moment the stolen GitHub token is revoked. Review and remove the flagged " +
73
+ "hooks, tasks, and scripts first; rotate credentials second, starting with " +
74
+ "any GitHub token.";
75
+
76
+ // ── rotation guidance map ───────────────────────────────────────────────────
77
+
78
+ /**
79
+ * One entry per rule id in src/patterns.js (all 35 of PATTERNS, plus the two
80
+ * NOISY_PATTERNS ids so an --include-noisy run still renders guidance).
81
+ * Shape: { label, rotateUrl?, consolePath?, steps: [1..3 strings],
82
+ * revokeNote, generic? }. `generic: true` marks entries that cannot name a
83
+ * vendor because the pattern itself cannot (a JWT, a bearer header); their
84
+ * guidance says so honestly instead of pretending precision.
85
+ */
86
+ const ROTATION_GUIDANCE = {
87
+ // Fetched https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
88
+ // (2026-09-02): "Manage access keys for IAM users", links "Update access
89
+ // keys" for the deactivate-then-delete flow.
90
+ aws_access_key_id: {
91
+ label: "AWS IAM access key",
92
+ rotateUrl: "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html",
93
+ steps: [
94
+ "Console: IAM > Users > your user > Security credentials > Access keys",
95
+ "Create a replacement key and switch your tooling to it",
96
+ "Deactivate the leaked key, verify nothing broke, then delete it",
97
+ ],
98
+ revokeNote: "Deactivate before delete: a deactivated key can be re-enabled while you hunt down stragglers, a deleted one cannot.",
99
+ },
100
+ // Fetched https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html
101
+ // (2026-09-02): "Revoke IAM role temporary security credentials", console
102
+ // path IAM > Roles > role > Revoke sessions tab.
103
+ aws_session_token: {
104
+ label: "AWS temporary credentials (STS session)",
105
+ rotateUrl: "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html",
106
+ steps: [
107
+ "Temporary credentials expire on their own, but do not wait if leaked",
108
+ "Console: IAM > Roles > the role > Revoke sessions > Revoke active sessions",
109
+ "Then rotate whatever long-term credential minted the session",
110
+ ],
111
+ revokeNote: "Revoking sessions denies every session issued for that role before now; legitimate users re-authenticate and continue.",
112
+ },
113
+ // No vendor: a PEM block does not say what trusts it. Guidance names the
114
+ // three common cases instead of guessing one.
115
+ private_key_block: {
116
+ label: "Private key (PEM block)",
117
+ generic: true,
118
+ consolePath: "Depends on the key type: read the PEM header and the surrounding context to identify it",
119
+ steps: [
120
+ "SSH key: generate a new pair, replace the public key everywhere it is authorized (GitHub, GitLab, servers), remove the old one",
121
+ "TLS key: reissue the certificate and revoke the old one at your CA",
122
+ "Cloud service-account key: delete the key in that provider's IAM console and mint a new one",
123
+ ],
124
+ revokeNote: "A private key cannot be rotated in place; every system trusting its public half needs the update.",
125
+ },
126
+ // Fetched https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
127
+ // (2026-09-02): documents creating and deleting PATs, Settings > Developer
128
+ // settings > Personal access tokens.
129
+ github_pat: {
130
+ label: "GitHub personal access token",
131
+ rotateUrl: "https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens",
132
+ steps: [
133
+ "github.com > Settings > Developer settings > Personal access tokens",
134
+ "Delete the leaked token; create a fine-grained replacement with the narrowest scopes",
135
+ "Review the account's security log for activity you do not recognize",
136
+ ],
137
+ revokeNote: "If this scan also raised integrity warnings, clean those FIRST: ChainDrop's monitor fires when the stolen token is revoked.",
138
+ },
139
+ // Fetched https://docs.gitlab.com/user/profile/personal_access_tokens/
140
+ // (2026-09-02): sections "Rotate a personal access token" and "Revoke a
141
+ // personal access token", path avatar > Edit profile > Access.
142
+ gitlab_pat: {
143
+ label: "GitLab personal access token",
144
+ rotateUrl: "https://docs.gitlab.com/user/profile/personal_access_tokens/",
145
+ steps: [
146
+ "GitLab > avatar > Edit profile > Access > Personal access tokens",
147
+ "Use the token row's menu to Rotate (or Revoke) it",
148
+ "Update everything that used the old value",
149
+ ],
150
+ revokeNote: "Rotate revokes the old token and issues its replacement in one step.",
151
+ },
152
+ // Fetched https://docs.slack.dev/reference/methods/auth.revoke (2026-09-02):
153
+ // "This method revokes an access token." (api.slack.com/methods/auth.revoke
154
+ // now 302s here.) App-level management lives at api.slack.com/apps.
155
+ slack_token: {
156
+ label: "Slack token",
157
+ rotateUrl: "https://docs.slack.dev/reference/methods/auth.revoke",
158
+ steps: [
159
+ "Revoke the token via the auth.revoke API method, or from your app's settings at api.slack.com/apps",
160
+ "Reinstall the app to mint fresh tokens",
161
+ "Review the workspace access logs for use you do not recognize",
162
+ ],
163
+ revokeNote: "Revoking a bot token deactivates that bot user and drops its channel memberships; the app itself stays installed.",
164
+ },
165
+ // Fetched https://docs.stripe.com/keys (2026-09-02): "Rotate an API key"
166
+ // section, Dashboard API keys page, overflow menu > Rotate key.
167
+ stripe_key: {
168
+ label: "Stripe API key",
169
+ rotateUrl: "https://docs.stripe.com/keys",
170
+ steps: [
171
+ "Dashboard > Developers > API keys",
172
+ "Overflow menu on the key > Rotate key; choose expiration Now for a compromised key",
173
+ "Update your servers with the replacement value",
174
+ ],
175
+ revokeNote: "Rotating with expiration Now kills the old key immediately; a scheduled rotation keeps both valid for up to 7 days for zero-downtime migration.",
176
+ },
177
+ // help.openai.com articles 5112595 and 8304786 exist (surfaced by search)
178
+ // but the help center serves HTTP 403 to this project's fetcher, so no URL
179
+ // is shipped: unverifiable end to end fails the bar above.
180
+ openai_key: {
181
+ label: "OpenAI API key",
182
+ consolePath: "OpenAI Platform (platform.openai.com) > API keys",
183
+ steps: [
184
+ "Sign in to the OpenAI Platform and open the API keys page",
185
+ "Delete the leaked key and create a replacement",
186
+ "Check usage for activity you do not recognize",
187
+ ],
188
+ revokeNote: "OpenAI disables keys it finds published on the public internet on its own; treat that as a backstop, not the fix.",
189
+ },
190
+ // Fetched https://support.claude.com/en/articles/9767949-api-key-best-practices-keeping-your-keys-safe-and-secure
191
+ // (2026-09-02): official article, quotes the Console path (API keys page,
192
+ // three-dots menu, Delete API Key) and the rotate-by-replace advice.
193
+ anthropic_key: {
194
+ label: "Anthropic API key",
195
+ rotateUrl: "https://support.claude.com/en/articles/9767949-api-key-best-practices-keeping-your-keys-safe-and-secure",
196
+ steps: [
197
+ "Claude Console (console.anthropic.com) > API keys",
198
+ "Three-dots menu next to the key > Delete API Key",
199
+ "Create a replacement and update your configs",
200
+ ],
201
+ revokeNote: "Deletion is immediate; anything still sending the old key starts failing authentication at once.",
202
+ },
203
+ // Fetched https://docs.cloud.google.com/docs/authentication/api-keys
204
+ // (2026-09-02; cloud.google.com/docs/authentication/api-keys 301s here):
205
+ // documents rotate-by-replace and delete, Credentials console page.
206
+ google_api_key: {
207
+ label: "Google / Firebase API key",
208
+ rotateUrl: "https://docs.cloud.google.com/docs/authentication/api-keys",
209
+ steps: [
210
+ "Console: APIs & Services > Credentials (console.cloud.google.com/apis/credentials)",
211
+ "Create a replacement key with the same restrictions and move apps to it",
212
+ "Delete the leaked key (restorable for 30 days if that turns out wrong)",
213
+ ],
214
+ revokeNote: "A Firebase web API key is a Google Cloud API key; even if it must stay public by design, apply application restrictions to it.",
215
+ },
216
+ // Fetched https://docs.npmjs.com/revoking-access-tokens (2026-09-02):
217
+ // "Revoking tokens on the website" (profile > Access Tokens) and the
218
+ // token CLI flow.
219
+ npm_token: {
220
+ label: "npm access token",
221
+ rotateUrl: "https://docs.npmjs.com/revoking-access-tokens",
222
+ steps: [
223
+ "npmjs.com > profile > Access Tokens > delete the leaked token",
224
+ "Or CLI: npm token list, then npm token revoke <id>",
225
+ "Mint a granular replacement with a short expiry",
226
+ ],
227
+ revokeNote: "Check your packages' recent publishes afterward: a leaked npm token is a supply-chain foothold, not just an account problem.",
228
+ },
229
+ // Fetched https://www.twilio.com/docs/sendgrid/ui/account-and-settings/api-keys
230
+ // (2026-09-02): Settings > API Keys, action menu > Delete API Key,
231
+ // delete-then-recreate as the regeneration flow.
232
+ sendgrid_key: {
233
+ label: "SendGrid API key",
234
+ rotateUrl: "https://www.twilio.com/docs/sendgrid/ui/account-and-settings/api-keys",
235
+ steps: [
236
+ "SendGrid dashboard > Settings > API Keys",
237
+ "Action menu on the key > Delete API Key",
238
+ "Create a minimal-scope replacement and update your senders",
239
+ ],
240
+ revokeNote: "Deletion is immediate; sends using the old key fail from the moment you confirm.",
241
+ },
242
+ // Fetched https://www.twilio.com/docs/iam/api-keys/keys-in-console
243
+ // (2026-09-02): create/delete flows, path Settings > Account settings >
244
+ // API keys & auth tokens.
245
+ twilio_key: {
246
+ label: "Twilio API key",
247
+ rotateUrl: "https://www.twilio.com/docs/iam/api-keys/keys-in-console",
248
+ steps: [
249
+ "Console: Settings > Account settings > API keys & auth tokens",
250
+ "Delete the leaked key",
251
+ "Create a replacement (Standard or Restricted) and update your apps",
252
+ ],
253
+ revokeNote: "If the account's Auth Token leaked alongside the key, it has its own rotation flow on the same console page.",
254
+ },
255
+ // No vendor: the pattern matches a shape, not an issuer.
256
+ jwt: {
257
+ label: "JWT (issuer unknown)",
258
+ generic: true,
259
+ consolePath: "Identify the issuing service first; a JWT names its issuer in the payload",
260
+ steps: [
261
+ "Base64-decode the token's middle segment and read the iss and aud claims (it already leaked; decoding it locally adds no exposure)",
262
+ "Revoke the session, grant, or signing key at that issuer",
263
+ "If a refresh token leaked alongside it, treat that as the primary leak",
264
+ ],
265
+ revokeNote: "A JWT usually cannot be revoked by itself; the issuer invalidates whatever produced it, and short expiry is not revocation.",
266
+ },
267
+ // No single vendor: the URL scheme names the engine, the host names the
268
+ // operator.
269
+ connection_string_with_password: {
270
+ label: "Database connection string",
271
+ generic: true,
272
+ consolePath: "The URL scheme names the engine (postgres, mysql, mongodb); rotate at that database",
273
+ steps: [
274
+ "Change that database user's password, or drop and recreate the user",
275
+ "Update every consumer of the connection string",
276
+ "Review the database's auth logs for connections you do not recognize",
277
+ ],
278
+ revokeNote: "If the host is a managed service (RDS, Atlas, Supabase, and the like), its console has a reset-credentials flow; use that.",
279
+ },
280
+ // No vendor: residoo saw the header shape, not who accepts it.
281
+ bearer_header: {
282
+ label: "Bearer token (service unknown)",
283
+ generic: true,
284
+ consolePath: "Identify the service from the code or transcript around the match; the request URL usually names it",
285
+ steps: [
286
+ "Find the request the header was attached to; its host is the issuer",
287
+ "Rotate or revoke at that service's credential settings",
288
+ "If it is an OAuth access token, revoke the grant, not just the token",
289
+ ],
290
+ revokeNote: "Generic by design: an Authorization header match cannot name its vendor, so this guidance cannot either.",
291
+ },
292
+ // No vendor: a "refresh_token" JSON field could come from any OAuth
293
+ // provider.
294
+ refresh_token_field: {
295
+ label: "OAuth refresh token",
296
+ generic: true,
297
+ consolePath: "Revoke the OAuth grant at the provider that issued it (its connected-apps or authorized-applications page)",
298
+ steps: [
299
+ "Identify the provider from the surrounding transcript or config",
300
+ "Revoke the application grant; that invalidates refresh and access tokens together",
301
+ "Re-authorize the app to mint fresh tokens",
302
+ ],
303
+ revokeNote: "A refresh token outlives every access token it mints; rotating only access tokens leaves the leak alive.",
304
+ },
305
+ access_token_field: {
306
+ label: "OAuth access token",
307
+ generic: true,
308
+ consolePath: "Revoke at the issuing provider; the token's surroundings usually name it",
309
+ steps: [
310
+ "Identify the provider from the surrounding transcript or config",
311
+ "Revoke the token or its parent grant at that provider",
312
+ "If a refresh_token leaked alongside, treat that as the primary leak",
313
+ ],
314
+ revokeNote: "Access tokens expire, but expiry is not revocation; do not wait it out.",
315
+ },
316
+
317
+ // ── AI / LLM providers ────────────────────────────────────────────────
318
+ // Fetched https://console.groq.com/docs/production-readiness/security-onboarding
319
+ // (2026-09-02): "Revoke the key immediately from the Groq Console", keys
320
+ // page console.groq.com/keys. (console.groq.com/docs/api-keys is a 404.)
321
+ groq_key: {
322
+ label: "Groq API key",
323
+ rotateUrl: "https://console.groq.com/docs/production-readiness/security-onboarding",
324
+ steps: [
325
+ "console.groq.com/keys (API Keys page)",
326
+ "Revoke the leaked key and create a replacement",
327
+ "Redeploy the new secret everywhere the old one lived",
328
+ ],
329
+ revokeNote: "Key values are unrecoverable after creation, so the replacement must be re-copied everywhere; nothing can read the old one back.",
330
+ },
331
+ // Fetched https://docs.x.ai/console/faq/security (2026-09-02): compromise
332
+ // flow is console API Keys > three-dots > Disable key / Delete key.
333
+ xai_key: {
334
+ label: "xAI (Grok) API key",
335
+ rotateUrl: "https://docs.x.ai/console/faq/security",
336
+ steps: [
337
+ "xAI Console (console.x.ai) > API Keys",
338
+ "Three-dots menu on the key > Disable key, then Delete key once confirmed",
339
+ "Create a replacement and update your configs",
340
+ ],
341
+ revokeNote: "Disable takes effect immediately and is reversible; delete once you are sure nothing legitimate still uses the key.",
342
+ },
343
+ // Fetched https://openrouter.ai/docs/api-keys (2026-09-02): compromised-key
344
+ // advice is "immediately visit your key settings page to delete the
345
+ // compromised key and create a new one" (openrouter.ai/settings/keys).
346
+ openrouter_key: {
347
+ label: "OpenRouter API key",
348
+ rotateUrl: "https://openrouter.ai/docs/api-keys",
349
+ steps: [
350
+ "openrouter.ai/settings/keys",
351
+ "Delete the compromised key and create a replacement",
352
+ "Check credit usage for spend you do not recognize",
353
+ ],
354
+ revokeNote: "OpenRouter emails you when it detects an exposed key; that detection is a backstop, not the remediation.",
355
+ },
356
+ // Fetched https://huggingface.co/docs/hub/security-tokens (2026-09-02):
357
+ // manage/delete/refresh at settings/tokens; documents the anonymous
358
+ // POST /api/credentials/revoke endpoint for someone else's leaked token.
359
+ huggingface_token: {
360
+ label: "Hugging Face access token",
361
+ rotateUrl: "https://huggingface.co/docs/hub/security-tokens",
362
+ steps: [
363
+ "huggingface.co/settings/tokens",
364
+ "Manage > invalidate and refresh (or delete) the leaked token",
365
+ "Found someone else's token? The docs' POST /api/credentials/revoke endpoint kills it without needing their account",
366
+ ],
367
+ revokeNote: "Refresh invalidates the old value immediately; prefer a fine-grained replacement so the next leak is scoped.",
368
+ },
369
+ // Fetched https://docs.pinecone.io/guides/projects/manage-api-keys
370
+ // (2026-09-02): console > project > API keys tab > ellipsis > Delete,
371
+ // confirm by typing the key name.
372
+ pinecone_key: {
373
+ label: "Pinecone API key",
374
+ rotateUrl: "https://docs.pinecone.io/guides/projects/manage-api-keys",
375
+ steps: [
376
+ "Pinecone console > your project > API keys tab",
377
+ "Actions column > ellipsis menu > Delete (typing the key name confirms)",
378
+ "Create a replacement and update clients",
379
+ ],
380
+ revokeNote: "Deletion is irreversible and cuts off applications using the key the moment you confirm.",
381
+ },
382
+ // The help-center article ("API settings", article 10352995) exists but
383
+ // perplexity.ai serves HTTP 403 to this fetcher, so no URL is shipped. The
384
+ // settings/api path is the one every integration guide agrees on, the same
385
+ // multi-source bar patterns.js already argues for this vendor.
386
+ perplexity_key: {
387
+ label: "Perplexity API key",
388
+ consolePath: "perplexity.ai > Settings > API (perplexity.ai/settings/api) > API Keys",
389
+ steps: [
390
+ "Open the API settings page and delete the leaked key",
391
+ "Generate a replacement (shown once, store it safely)",
392
+ "Update every client with the new value",
393
+ ],
394
+ revokeNote: "Key values are shown once at creation; there is nothing to re-copy later, only replace.",
395
+ },
396
+ // Fetched https://replicate.com/docs/topics/security/api-tokens
397
+ // (2026-09-02): "you can disable it from the web interface", management at
398
+ // replicate.com/account/api-tokens.
399
+ replicate_token: {
400
+ label: "Replicate API token",
401
+ rotateUrl: "https://replicate.com/docs/topics/security/api-tokens",
402
+ steps: [
403
+ "replicate.com/account/api-tokens",
404
+ "Disable the exposed token",
405
+ "Create a replacement and update your applications",
406
+ ],
407
+ revokeNote: "Disabling stops all API requests with that token immediately.",
408
+ },
409
+
410
+ // ── Cloud / infra ─────────────────────────────────────────────────────
411
+ // docs.digitalocean.com/reference/api/create-personal-access-token/
412
+ // resolves but its body is client-rendered and unreadable to this fetcher,
413
+ // so no URL is shipped. The control-panel location is DigitalOcean's own:
414
+ // their blog "Updated API Management Tokens" names
415
+ // cloud.digitalocean.com/account/api/tokens as where tokens are deleted.
416
+ digitalocean_token: {
417
+ label: "DigitalOcean access token",
418
+ consolePath: "cloud.digitalocean.com > API > Tokens (cloud.digitalocean.com/account/api/tokens)",
419
+ steps: [
420
+ "Open the control panel's API > Tokens page",
421
+ "Delete the leaked personal access token",
422
+ "Generate a replacement with the narrowest scopes and a short expiry",
423
+ ],
424
+ revokeNote: "DigitalOcean auto-revokes tokens it detects published publicly; treat that as a backstop, not the fix.",
425
+ },
426
+ // supabase.com/docs/guides/platform/access-control (fetched 2026-09-02)
427
+ // points at supabase.com/dashboard/account/tokens as the PAT location; the
428
+ // dashboard itself is login-walled, so the path ships in words.
429
+ supabase_token: {
430
+ label: "Supabase personal access token",
431
+ consolePath: "supabase.com/dashboard > Account > Access Tokens (supabase.com/dashboard/account/tokens)",
432
+ steps: [
433
+ "Open the account Access Tokens page",
434
+ "Delete the leaked token and generate a replacement for your tooling",
435
+ "Review recent project changes made via the API",
436
+ ],
437
+ revokeNote: "This is the account-level token (sbp_); a project's anon and service_role keys rotate separately in that project's API settings.",
438
+ },
439
+ // Fetched https://developer.hashicorp.com/vault/docs/commands/token/revoke
440
+ // (2026-09-02): "token revoke revokes authentication tokens and their
441
+ // children", -accessor and -mode flags.
442
+ vault_token: {
443
+ label: "HashiCorp Vault service token",
444
+ rotateUrl: "https://developer.hashicorp.com/vault/docs/commands/token/revoke",
445
+ steps: [
446
+ "vault token revoke <token>, or -accessor <accessor> if you only have that",
447
+ "Revocation cascades to the token's children by default",
448
+ "Audit what the token touched via Vault's audit log",
449
+ ],
450
+ revokeNote: "If the token was long-lived or highly privileged, rotate the secrets it could READ as well, not just the token.",
451
+ },
452
+ // Fetched https://www.1password.dev/service-accounts/manage-service-accounts/
453
+ // (2026-09-02; developer.1password.com 301s here): Rotate Token and Revoke
454
+ // Token flows, path Developer > Service accounts.
455
+ onepassword_service_token: {
456
+ label: "1Password service account token",
457
+ rotateUrl: "https://www.1password.dev/service-accounts/manage-service-accounts/",
458
+ steps: [
459
+ "1Password.com > Developer > Service accounts > the account",
460
+ "Rotate Token (expire the old one immediately) or Revoke Token",
461
+ "Update the workloads that used it",
462
+ ],
463
+ revokeNote: "Revoking immediately removes the token's access to every vault the service account could reach.",
464
+ },
465
+
466
+ // ── Comms / SaaS ──────────────────────────────────────────────────────
467
+ // The user-facing support article (support.discord.com article 228383668)
468
+ // serves HTTP 403 to this fetcher. The developer docs below WERE fetched
469
+ // (2026-09-02; discord.com/developers/docs/resources/webhook 301s to
470
+ // docs.discord.com) and document the Delete Webhook endpoint.
471
+ discord_webhook: {
472
+ label: "Discord webhook URL",
473
+ rotateUrl: "https://docs.discord.com/developers/resources/webhook",
474
+ steps: [
475
+ "Server Settings (or the channel's settings) > Integrations > Webhooks",
476
+ "Delete the leaked webhook; creating a new one issues a new URL",
477
+ "Programmatic alternative: the Delete Webhook API endpoint (requires MANAGE_WEBHOOKS)",
478
+ ],
479
+ revokeNote: "The URL is the entire credential: anyone holding it can post to the channel until the webhook is deleted.",
480
+ },
481
+ // Fetched https://core.telegram.org/bots/features (2026-09-02): "If your
482
+ // existing token is compromised or you lost it for some reason, use the
483
+ // /token command to generate a new one."
484
+ telegram_bot_token: {
485
+ label: "Telegram bot token",
486
+ rotateUrl: "https://core.telegram.org/bots/features",
487
+ steps: [
488
+ "Message @BotFather in Telegram",
489
+ "Send /token and select the bot to issue a replacement; treat the old value as dead",
490
+ "Update your bot's config with the new token",
491
+ ],
492
+ revokeNote: "BotFather is the only management surface for bot tokens; there is no web console.",
493
+ },
494
+ // help.mailgun.com serves HTTP 403 to this fetcher, so no URL is shipped.
495
+ // The path is corroborated by Mailgun's own blog ("Swap Out Your API Keys
496
+ // With No Downtime") and two help-center articles surfaced in search:
497
+ // profile menu > API Security is where keys are regenerated.
498
+ mailgun_key: {
499
+ label: "Mailgun API key",
500
+ consolePath: "app.mailgun.com > profile menu (top right) > API Security",
501
+ steps: [
502
+ "Open API Security in the Mailgun control panel",
503
+ "Delete or regenerate the compromised key; create a scoped replacement",
504
+ "Update senders and check sending logs for abuse",
505
+ ],
506
+ revokeNote: "A Mailgun key can send mail as your domains; check outbound activity, not just the key itself.",
507
+ },
508
+ // Fetched https://developers.notion.com/guides/get-started/internal-connections
509
+ // (2026-09-02): "If your token is accidentally exposed, you can refresh it
510
+ // from the connection's Configuration tab."
511
+ notion_token: {
512
+ label: "Notion integration token",
513
+ rotateUrl: "https://developers.notion.com/guides/get-started/internal-connections",
514
+ steps: [
515
+ "notion.so/my-integrations > select the integration",
516
+ "Configuration tab > refresh the secret (the old value dies immediately)",
517
+ "Update everything that used the old secret",
518
+ ],
519
+ revokeNote: "Notion has changed its token format before; whatever the prefix, the refresh flow is the same.",
520
+ },
521
+ // Fetched https://linear.app/docs/api-and-webhooks (2026-09-02): personal
522
+ // API keys are created under Settings > Account > Security & Access, and
523
+ // existing keys can be viewed and revoked.
524
+ linear_key: {
525
+ label: "Linear API key",
526
+ rotateUrl: "https://linear.app/docs/api-and-webhooks",
527
+ steps: [
528
+ "Linear > Settings > Account > Security & Access",
529
+ "Revoke the leaked personal API key",
530
+ "Create a replacement restricted to the permissions and teams it needs",
531
+ ],
532
+ revokeNote: "A personal key inherits its creator's workspace access; a workspace admin can also revoke members' keys.",
533
+ },
534
+ // Fetched https://docs.sentry.io/account/auth-tokens/ (2026-09-02):
535
+ // organization tokens under Settings > Developer Settings > Organization
536
+ // Tokens, personal tokens under the account dropdown; both revocable.
537
+ sentry_token: {
538
+ label: "Sentry auth token",
539
+ rotateUrl: "https://docs.sentry.io/account/auth-tokens/",
540
+ steps: [
541
+ "Organization token (sntrys_): Settings > Developer Settings > Organization Tokens",
542
+ "Personal token (sntryu_): Account dropdown > Personal Tokens",
543
+ "Revoke the leaked token and create a replacement",
544
+ ],
545
+ revokeNote: "The redacted preview cannot distinguish the two prefixes; check the original file for sntrys_ (organization) vs sntryu_ (personal).",
546
+ },
547
+
548
+ // ── NOISY_PATTERNS (only reachable via --include-noisy) ───────────────
549
+ generic_password_assignment: {
550
+ label: "Password assignment (noisy rule)",
551
+ generic: true,
552
+ consolePath: "Rotate wherever that password authenticates",
553
+ steps: [
554
+ "Confirm it is a real credential and not a placeholder (this rule false-positives by design)",
555
+ "Change the password at the system it belongs to",
556
+ "Move it to a secrets manager so it stops living in config text",
557
+ ],
558
+ revokeNote: "Low-confidence match: verify before rotating anything.",
559
+ },
560
+ generic_secret_assignment: {
561
+ label: "Secret / API key assignment (noisy rule)",
562
+ generic: true,
563
+ consolePath: "Rotate at whatever service issued the value",
564
+ steps: [
565
+ "Confirm it is a real credential and not a placeholder (this rule false-positives by design)",
566
+ "Identify the issuing service from the variable name and surrounding code",
567
+ "Rotate or revoke there and move the value to a secrets manager",
568
+ ],
569
+ revokeNote: "Low-confidence match: verify before rotating anything.",
570
+ },
571
+ };
572
+ Object.freeze(ROTATION_GUIDANCE);
573
+
574
+ /**
575
+ * Guidance for a rule id, with an honest fallback for ids this map does not
576
+ * know (a future pattern added without a guidance entry). The fallback SAYS
577
+ * it is a gap; silently generic guidance under a vendor rule's name would be
578
+ * the false-all-clear failure mode in remediation clothing.
579
+ */
580
+ function guidanceFor(ruleId) {
581
+ const g = ROTATION_GUIDANCE[ruleId];
582
+ if (g) return g;
583
+ return {
584
+ label: String(ruleId || "unknown rule"),
585
+ generic: true,
586
+ consolePath: "No rotation guidance is shipped for this rule id yet",
587
+ steps: [
588
+ "Identify the issuing service from the finding's file and context",
589
+ "Rotate or revoke the credential there",
590
+ ],
591
+ revokeNote: "This is a guidance gap in residoo, not a judgment that rotation is unneeded. Please open an issue naming the rule id.",
592
+ };
593
+ }
594
+
595
+ // ── fingerprints ────────────────────────────────────────────────────────────
596
+
597
+ // Mirrors stripControlChars in patterns.js (not exported there; integrity.js
598
+ // makes the same copy for the same reason: adding an export would touch a
599
+ // shared file).
600
+ function stripControlChars(s) { return s.replace(/[\x00-\x1f\x7f]/g, ""); }
601
+
602
+ /**
603
+ * Stable identity for a finding, derived ONLY from already-redacted
604
+ * material: ruleId + the redacted preview + the source file's basename.
605
+ * Deliberately NOT derived from the raw secret (which never leaves scan())
606
+ * and NOT from the line number (a transcript that gets appended to shifts
607
+ * every line, and an ack must survive that). Tradeoffs, stated: two distinct
608
+ * secrets of the same rule, length, first four and last four characters in
609
+ * the same file collapse into one fingerprint (undercounts pending rotations
610
+ * by merging near-twins); and because the basename is in the identity, ONE
611
+ * secret present in two differently-named files is two fingerprints, so the
612
+ * Rotation section can count more pending rotations than scan's raw-value
613
+ * distinctCounts says there are distinct values. Both are the price of the
614
+ * alternative being worse: hashing raw secrets into a state file would put
615
+ * secret-derived material on disk, which rule 4 exists to prevent.
616
+ */
617
+ function fingerprintFinding(finding) {
618
+ if (!finding || typeof finding !== "object") {
619
+ throw new TypeError("fingerprintFinding expects a finding object");
620
+ }
621
+ // relFile is scan.js's basename convention; fall back to computing it so a
622
+ // caller holding only {ruleId, preview, file} still gets the same identity.
623
+ const base = finding.relFile != null
624
+ ? String(finding.relFile)
625
+ : path.basename(String(finding.file || ""));
626
+ const material = [String(finding.ruleId || ""), String(finding.preview || ""), base].join("\n");
627
+ const h = crypto.createHash("sha256").update(material, "utf-8").digest("hex");
628
+ // 128 bits is plenty for a local dedup key; the rf1- prefix versions the
629
+ // scheme so a future change can coexist with old state files.
630
+ return "rf1-" + h.slice(0, 32);
631
+ }
632
+
633
+ const FINGERPRINT_RE = /^rf1-[0-9a-f]{32}$/;
634
+
635
+ // ── ack state (~/.residoo/rotations.json) ───────────────────────────────────
636
+
637
+ function statePath() {
638
+ return path.join(os.homedir(), ".residoo", "rotations.json");
639
+ }
640
+
641
+ /**
642
+ * Load the ack map: { "<fingerprint>": { at, note } }. Missing file is the
643
+ * normal first-run case and returns {} silently. A corrupt or unreadable
644
+ * file returns {} too, but LOUDLY: one note on stderr, because "your acks
645
+ * are gone" must never be silent, and because the next ackFinding() will
646
+ * start a fresh store over the corrupt one.
647
+ */
648
+ function loadAcks({ file = statePath() } = {}) {
649
+ let text;
650
+ try {
651
+ text = fs.readFileSync(file, "utf-8");
652
+ } catch (err) {
653
+ if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return {};
654
+ process.stderr.write(`residoo: rotation state ${path.basename(file)} could not be read; continuing with no acknowledgements\n`);
655
+ return {};
656
+ }
657
+ let parsed;
658
+ try {
659
+ parsed = JSON.parse(text);
660
+ } catch {
661
+ process.stderr.write(`residoo: rotation state ${path.basename(file)} is corrupt; continuing with no acknowledgements (a new acknowledgement will start a fresh store)\n`);
662
+ return {};
663
+ }
664
+ if (!parsed || typeof parsed !== "object" || parsed.v !== 1 || !parsed.acks || typeof parsed.acks !== "object" || Array.isArray(parsed.acks)) {
665
+ process.stderr.write(`residoo: rotation state ${path.basename(file)} has an unrecognized shape; continuing with no acknowledgements\n`);
666
+ return {};
667
+ }
668
+ // Only well-formed entries under well-formed keys survive: state written
669
+ // by a future version (or hand-edited) degrades per-entry, not per-file.
670
+ const acks = {};
671
+ for (const [fp, v] of Object.entries(parsed.acks)) {
672
+ if (!FINGERPRINT_RE.test(fp)) continue;
673
+ if (!v || typeof v !== "object") continue;
674
+ // Control bytes stripped on READ as well as on write: this file sits on
675
+ // disk between the two, and a hand-edited or foreign ledger must not be
676
+ // able to put a terminal escape into the report via an ack note.
677
+ acks[fp] = {
678
+ at: typeof v.at === "string" ? stripControlChars(v.at) : null,
679
+ note: typeof v.note === "string" ? stripControlChars(v.note) : null,
680
+ };
681
+ }
682
+ return acks;
683
+ }
684
+
685
+ /**
686
+ * An ack note is user-supplied free text headed for a plaintext state file,
687
+ * so it goes through the same discipline as every preview: control bytes
688
+ * stripped, anything matching a detection pattern redacted (a user pasting
689
+ * the leaked key into their own note must not re-leak it into this file),
690
+ * bounded by code point.
691
+ */
692
+ function sanitizeNote(note) {
693
+ if (note == null) return null;
694
+ let s = stripControlChars(String(note));
695
+ // NOISY_PATTERNS included on purpose: a note like `password = "..."` from
696
+ // someone acking an --include-noisy finding must not land on disk raw.
697
+ for (const rule of PATTERNS.concat(NOISY_PATTERNS)) {
698
+ rule.re.lastIndex = 0;
699
+ s = s.replace(rule.re, (m) => redact(m));
700
+ }
701
+ const cps = Array.from(s);
702
+ return cps.length > 500 ? cps.slice(0, 500).join("") + "…" : s;
703
+ }
704
+
705
+ /**
706
+ * Record that the user acknowledged (rotated / accepted) one finding.
707
+ * Atomic: temp file in the same directory, then rename; 0o600 on the file,
708
+ * 0o700 on the directory, since even a redacted rotation ledger is nobody
709
+ * else's business.
710
+ *
711
+ * Atomic is not serialized: two concurrent `residoo ack` runs each
712
+ * load-modify-write, and the last rename wins, silently dropping the other
713
+ * run's ack. Accepted as a single-writer design: acks are typed by a human
714
+ * one at a time, the ledger is per-user state, and the failure direction is
715
+ * fail-safe (a dropped ack reverts that finding to pending, never the
716
+ * reverse). A lockfile would add a stale-lock recovery path for a race that
717
+ * a person cannot realistically produce.
718
+ */
719
+ function ackFinding(fp, note, { file = statePath() } = {}) {
720
+ if (typeof fp !== "string" || !FINGERPRINT_RE.test(fp)) {
721
+ throw new TypeError("ackFinding expects a fingerprint from fingerprintFinding() (rf1-<32 hex>)");
722
+ }
723
+ const acks = loadAcks({ file });
724
+ const entry = { at: new Date().toISOString(), note: sanitizeNote(note) };
725
+ acks[fp] = entry;
726
+
727
+ const dir = path.dirname(file);
728
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
729
+ const tmp = path.join(dir, `.rotations.json.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
730
+ const body = JSON.stringify({ v: 1, acks }, null, 2) + "\n";
731
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
732
+ try {
733
+ fs.renameSync(tmp, file);
734
+ } catch (err) {
735
+ // The rename failing must not strand a temp file next to the state.
736
+ try { fs.unlinkSync(tmp); } catch {}
737
+ throw err;
738
+ }
739
+ return { fingerprint: fp, ...entry, file };
740
+ }
741
+
742
+ // ── summaries for the report layer ──────────────────────────────────────────
743
+
744
+ /**
745
+ * Counts plus per-finding status. `statuses[i]` describes `findings[i]`;
746
+ * counts are over DISTINCT fingerprints, because five re-echoes of one token
747
+ * across a transcript are one rotation to do, not five (the same
748
+ * distinct-vs-re-exposed reasoning scan.js applies to counting).
749
+ */
750
+ function pendingSummary(findings, acks) {
751
+ const list = Array.isArray(findings) ? findings : [];
752
+ const ackMap = acks && typeof acks === "object" ? acks : {};
753
+ const statuses = [];
754
+ const distinct = new Map();
755
+ for (const f of list) {
756
+ const fp = fingerprintFinding(f);
757
+ const ack = ackMap[fp] || null;
758
+ statuses.push({
759
+ fingerprint: fp,
760
+ status: ack ? "acked" : "pending",
761
+ ackedAt: ack ? ack.at : null,
762
+ ackNote: ack ? ack.note : null,
763
+ });
764
+ if (!distinct.has(fp)) distinct.set(fp, !!ack);
765
+ }
766
+ let acked = 0;
767
+ for (const isAcked of distinct.values()) if (isAcked) acked++;
768
+ return {
769
+ counts: {
770
+ findings: list.length,
771
+ distinct: distinct.size,
772
+ pending: distinct.size - acked,
773
+ acked,
774
+ },
775
+ statuses,
776
+ };
777
+ }
778
+
779
+ /**
780
+ * Pure data for the report layer: one entry per distinct fingerprint, with
781
+ * rotation guidance attached and pending entries first. Prints nothing.
782
+ */
783
+ function renderRotation(findings, acks) {
784
+ const list = Array.isArray(findings) ? findings : [];
785
+ const { counts, statuses } = pendingSummary(list, acks);
786
+
787
+ const byFp = new Map();
788
+ for (let i = 0; i < list.length; i++) {
789
+ const f = list[i];
790
+ const st = statuses[i];
791
+ let e = byFp.get(st.fingerprint);
792
+ if (!e) {
793
+ e = {
794
+ fingerprint: st.fingerprint,
795
+ ruleId: String(f.ruleId || ""),
796
+ label: String(f.label || f.ruleId || ""),
797
+ preview: String(f.preview || ""),
798
+ occurrences: 0,
799
+ files: [],
800
+ sources: [],
801
+ guidance: guidanceFor(f.ruleId),
802
+ status: st.status,
803
+ ackedAt: st.ackedAt,
804
+ ackNote: st.ackNote,
805
+ };
806
+ byFp.set(st.fingerprint, e);
807
+ }
808
+ e.occurrences++;
809
+ const rel = f.relFile != null ? String(f.relFile) : path.basename(String(f.file || ""));
810
+ if (rel && !e.files.includes(rel)) e.files.push(rel);
811
+ const src = f.source != null ? String(f.source) : null;
812
+ if (src && !e.sources.includes(src)) e.sources.push(src);
813
+ }
814
+
815
+ const entries = [...byFp.values()].sort((a, b) => {
816
+ if (a.status !== b.status) return a.status === "pending" ? -1 : 1;
817
+ if (a.ruleId !== b.ruleId) return a.ruleId < b.ruleId ? -1 : 1;
818
+ return a.fingerprint < b.fingerprint ? -1 : 1;
819
+ });
820
+
821
+ return { counts, entries };
822
+ }
823
+
824
+ module.exports = {
825
+ ROTATION_GUIDANCE,
826
+ ROTATION_ORDER_ADVISORY,
827
+ guidanceFor,
828
+ fingerprintFinding,
829
+ statePath,
830
+ loadAcks,
831
+ ackFinding,
832
+ pendingSummary,
833
+ renderRotation,
834
+ };