|
| 1 | +import { lookup } from "node:dns/promises"; |
| 2 | +import { decryptValue, fingerprintValue, teamProvider, unwrapVaultKey, type RemoteSecret } from "@logicsrc/plugin-credential-sharing"; |
| 3 | +import { print, printColumns, type OutputFormat } from "./format.js"; |
| 4 | +import { authedClient, splitVaultName, teamEndpoint, vaultName } from "./teams.js"; |
| 5 | + |
| 6 | +/** |
| 7 | + * `logicsrc teams clean` moves stale keys out of a vault into its archive vault |
| 8 | + * (`<project>--<env>-archive`). Nothing is ever deleted outright: a key leaves |
| 9 | + * the live vault only after its copy in the archive reads back with the same |
| 10 | + * fingerprint. Dry run unless --approve. |
| 11 | + * |
| 12 | + * `--dead` finds keys that point somewhere that no longer exists: a URL whose |
| 13 | + * host has no DNS record, or a Supabase JWT whose project ref is such a host. |
| 14 | + */ |
| 15 | + |
| 16 | +export const ARCHIVE_SUFFIX = "-archive"; |
| 17 | +/** Archive entries describing where an archived key came from: `__ARCHIVE__<NAME>`. */ |
| 18 | +export const ARCHIVE_ENTRY_PREFIX = "__ARCHIVE__"; |
| 19 | + |
| 20 | +const URL_HOST = /\b[a-z][a-z0-9+.-]*:\/\/(?:[^@/\s"']+@)?([a-z0-9.-]+\.[a-z]{2,})(?=[:/?#\s"']|$)/gi; |
| 21 | +const JWT = /^eyJ[\w-]+\.([\w-]+)\.[\w-]*$/; |
| 22 | + |
| 23 | +/** Hostnames of every URL in a value, lowercased, without duplicates. */ |
| 24 | +export function urlHosts(value: string): string[] { |
| 25 | + const hosts = new Set<string>(); |
| 26 | + for (const m of value.matchAll(URL_HOST)) hosts.add(m[1]!.toLowerCase()); |
| 27 | + return [...hosts]; |
| 28 | +} |
| 29 | + |
| 30 | +/** Hosts a JWT is bound to: a Supabase key's `<ref>.supabase.co` and its `iss` host. */ |
| 31 | +export function jwtHosts(value: string): string[] { |
| 32 | + const m = JWT.exec(value.trim()); |
| 33 | + if (!m) return []; |
| 34 | + let payload: { ref?: unknown; iss?: unknown }; |
| 35 | + try { |
| 36 | + payload = JSON.parse(Buffer.from(m[1]!, "base64url").toString("utf8")); |
| 37 | + } catch { |
| 38 | + return []; |
| 39 | + } |
| 40 | + const hosts: string[] = []; |
| 41 | + if (typeof payload.ref === "string" && /^[a-z0-9]{8,40}$/.test(payload.ref)) hosts.push(`${payload.ref}.supabase.co`); |
| 42 | + if (typeof payload.iss === "string") hosts.push(...urlHosts(payload.iss)); |
| 43 | + return hosts; |
| 44 | +} |
| 45 | + |
| 46 | +export function archiveVaultName(project: string, env: string): string { |
| 47 | + return vaultName(project, `${env}${ARCHIVE_SUFFIX}`); |
| 48 | +} |
| 49 | + |
| 50 | +/** |
| 51 | + * The name a key is stored under in the archive: its own name, unless the |
| 52 | + * archive already holds a different value there, then `<KEY>__<UTC stamp>`. |
| 53 | + * `null` means the archive already holds this exact value under the key. |
| 54 | + */ |
| 55 | +export function archiveSlot(key: string, fingerprint: string, archived: Map<string, string>, now: Date): string | null { |
| 56 | + const existing = archived.get(key); |
| 57 | + if (existing === undefined) return key; |
| 58 | + if (existing === fingerprint) return null; |
| 59 | + const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, ""); |
| 60 | + let name = `${key}__${stamp}`; |
| 61 | + for (let n = 2; archived.has(name); n++) name = `${key}__${stamp}_${n}`; |
| 62 | + return name; |
| 63 | +} |
| 64 | + |
| 65 | +export type HostCheck = (host: string) => Promise<boolean>; |
| 66 | + |
| 67 | +/** True when the host resolves; only a definite "no such name" counts as dead. */ |
| 68 | +export const resolves: HostCheck = async (host) => { |
| 69 | + try { |
| 70 | + await lookup(host); |
| 71 | + return true; |
| 72 | + } catch (err) { |
| 73 | + const code = (err as NodeJS.ErrnoException).code; |
| 74 | + return !(code === "ENOTFOUND" || code === "ENODATA"); |
| 75 | + } |
| 76 | +}; |
| 77 | + |
| 78 | +/** Why each key is dead, from its value alone; keys that look fine are absent. */ |
| 79 | +export async function findDead(values: Map<string, string>, check: HostCheck = resolves): Promise<Map<string, string>> { |
| 80 | + const cache = new Map<string, Promise<boolean>>(); |
| 81 | + const alive = (h: string) => { |
| 82 | + if (!cache.has(h)) cache.set(h, check(h)); |
| 83 | + return cache.get(h)!; |
| 84 | + }; |
| 85 | + const dead = new Map<string, string>(); |
| 86 | + for (const [key, value] of values) { |
| 87 | + for (const host of urlHosts(value)) { |
| 88 | + if (!(await alive(host))) { |
| 89 | + dead.set(key, `url host ${host} does not resolve`); |
| 90 | + break; |
| 91 | + } |
| 92 | + } |
| 93 | + if (dead.has(key)) continue; |
| 94 | + for (const host of jwtHosts(value)) { |
| 95 | + if (!(await alive(host))) { |
| 96 | + dead.set(key, `token for ${host}, which does not resolve`); |
| 97 | + break; |
| 98 | + } |
| 99 | + } |
| 100 | + } |
| 101 | + return dead; |
| 102 | +} |
| 103 | + |
| 104 | +export interface CleanOptions { |
| 105 | + keys?: string[]; |
| 106 | + dead: boolean; |
| 107 | + approve: boolean; |
| 108 | + reason?: string; |
| 109 | + format: OutputFormat; |
| 110 | +} |
| 111 | + |
| 112 | +interface CleanRow { |
| 113 | + key: string; |
| 114 | + reason: string; |
| 115 | + archivedAs: string; |
| 116 | + status: "would-archive" | "archived" | "already-archived" | "failed"; |
| 117 | + error?: string; |
| 118 | +} |
| 119 | + |
| 120 | +export async function teamsCleanAction(slug: string, project: string, env: string, options: CleanOptions): Promise<void> { |
| 121 | + if (env.endsWith(ARCHIVE_SUFFIX)) throw new Error(`${vaultName(project, env)} is an archive vault; clean the live vault instead.`); |
| 122 | + if (!options.dead && !options.keys?.length) throw new Error("Say what to archive: --key NAME (repeatable) and/or --dead."); |
| 123 | + |
| 124 | + const { client, identity } = authedClient(); |
| 125 | + const source = vaultName(project, env); |
| 126 | + const archive = archiveVaultName(project, env); |
| 127 | + const { vaults } = await client.listVaults(slug); |
| 128 | + const src = vaults.find((v) => v.name === source); |
| 129 | + if (!src) throw new Error(`Vault ${slug}/${source} not found.`); |
| 130 | + if (!src.hasAccess) throw new Error(`You cannot decrypt ${slug}/${source}.`); |
| 131 | + const dek = await unwrapVaultKey((await client.getMyGrant(src.id)).wrappedDek, identity.keys); |
| 132 | + const { secrets } = await client.listSecrets(src.id); |
| 133 | + const byName = new Map<string, RemoteSecret>(secrets.map((s) => [s.name, s])); |
| 134 | + const values = new Map<string, string>(); |
| 135 | + for (const s of secrets) values.set(s.name, await decryptValue({ nonce: s.nonce, ciphertext: s.ciphertext }, dek)); |
| 136 | + |
| 137 | + const picked = new Map<string, string>(); |
| 138 | + for (const key of options.keys ?? []) { |
| 139 | + if (!byName.has(key)) throw new Error(`${key} is not in ${slug}/${source}.`); |
| 140 | + picked.set(key, options.reason ?? "named on the command line"); |
| 141 | + } |
| 142 | + if (options.dead) for (const [key, why] of await findDead(values)) if (!picked.has(key)) picked.set(key, why); |
| 143 | + if (picked.size === 0) { |
| 144 | + console.error(`Nothing to archive in ${slug}/${source}.`); |
| 145 | + print({ team: slug, vault: source, archive, keys: [] }, options.format); |
| 146 | + return; |
| 147 | + } |
| 148 | + |
| 149 | + // What the archive holds already, so a second run never stores a value twice. |
| 150 | + const arc = vaults.find((v) => v.name === archive); |
| 151 | + const archived = new Map<string, string>(); |
| 152 | + if (arc?.hasAccess) for (const s of (await client.listSecrets(arc.id)).secrets) archived.set(s.name, s.fingerprint); |
| 153 | + else if (arc) throw new Error(`${slug}/${archive} exists but you cannot decrypt it; ask a member for a grant first.`); |
| 154 | + |
| 155 | + const now = new Date(); |
| 156 | + const rows: CleanRow[] = []; |
| 157 | + const upserts: Record<string, string> = {}; |
| 158 | + for (const [key, reason] of picked) { |
| 159 | + const slot = archiveSlot(key, byName.get(key)!.fingerprint, archived, now); |
| 160 | + if (slot === null) { |
| 161 | + rows.push({ key, reason, archivedAs: key, status: "already-archived" }); |
| 162 | + continue; |
| 163 | + } |
| 164 | + rows.push({ key, reason, archivedAs: slot, status: "would-archive" }); |
| 165 | + upserts[slot] = values.get(key)!; |
| 166 | + upserts[`${ARCHIVE_ENTRY_PREFIX}${slot}`] = JSON.stringify({ from: source, key, reason, archivedAt: now.toISOString(), by: identity.email }); |
| 167 | + } |
| 168 | + |
| 169 | + if (options.approve) { |
| 170 | + try { |
| 171 | + if (Object.keys(upserts).length) { |
| 172 | + await teamProvider.write!({ endpoint: teamEndpoint(slug, archive), upserts, deletes: [], dryRun: false }); |
| 173 | + } |
| 174 | + // Read the archive back: a key leaves the live vault only once its copy is there, byte for byte. |
| 175 | + const after = (await client.listVaults(slug)).vaults.find((v) => v.name === archive); |
| 176 | + const stored = new Map((after ? (await client.listSecrets(after.id)).secrets : []).map((s) => [s.name, s.fingerprint])); |
| 177 | + const safe: string[] = []; |
| 178 | + for (const row of rows) { |
| 179 | + if (stored.get(row.archivedAs) === fingerprintValue(values.get(row.key)!)) { |
| 180 | + safe.push(row.key); |
| 181 | + if (row.status === "would-archive") row.status = "archived"; |
| 182 | + } else { |
| 183 | + row.status = "failed"; |
| 184 | + row.error = "copy not found in the archive; left in place"; |
| 185 | + } |
| 186 | + } |
| 187 | + if (safe.length) await client.putSecrets(src.id, [], safe); |
| 188 | + } catch (err) { |
| 189 | + for (const row of rows) if (row.status === "would-archive") Object.assign(row, { status: "failed", error: (err as Error).message }); |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + if (options.format === "table") { |
| 194 | + printColumns(rows.map((r) => ({ key: r.key, status: r.status, "archived as": r.archivedAs, reason: r.error ?? r.reason }))); |
| 195 | + const at = splitVaultName(archive); |
| 196 | + if (!options.approve) console.error(`\nDry run. Move them to ${slug}/${archive} with --approve.`); |
| 197 | + else console.error(`\nArchived in ${slug}/${archive}. Get them back with: logicsrc teams pull ${slug} ${at?.project} ${at?.env} --env archive.env`); |
| 198 | + } else { |
| 199 | + print({ team: slug, vault: source, archive, keys: rows }, options.format); |
| 200 | + } |
| 201 | + if (rows.some((r) => r.status === "failed")) process.exitCode = 1; |
| 202 | +} |
0 commit comments