From c9717fb746af03d97310da19a60eefbfe1057882 Mon Sep 17 00:00:00 2001 From: lpg-it Date: Fri, 15 May 2026 16:31:38 +0800 Subject: [PATCH] Add IAM access key review helper --- README.md | 2 +- docs/security/iam-access-key-review.md | 67 ++++ tools/audit-iam-access-keys.mjs | 443 ++++++++++++++++++++++ tools/check-iam-access-key-review.mjs | 39 ++ tools/fixtures/iam-access-keys.sample.csv | 6 + 5 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 docs/security/iam-access-key-review.md create mode 100755 tools/audit-iam-access-keys.mjs create mode 100755 tools/check-iam-access-key-review.mjs create mode 100644 tools/fixtures/iam-access-keys.sample.csv diff --git a/README.md b/README.md index eb0a5ca6..f317be46 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Detailed documentation is available in the `docs/` directory: - [Database Documentation](docs/database/README.md) - Database structure and diagrams - [Cron Jobs](docs/cron-jobs.md) - Scheduled tasks - [Analytics](docs/analytics.md) - Event tracking and analytics +- [IAM Access Key Review](docs/security/iam-access-key-review.md) - Offline AWS key review and evidence runbook ## Development @@ -204,4 +205,3 @@ END $$ DELIMITER; - diff --git a/docs/security/iam-access-key-review.md b/docs/security/iam-access-key-review.md new file mode 100644 index 00000000..e4197ad8 --- /dev/null +++ b/docs/security/iam-access-key-review.md @@ -0,0 +1,67 @@ +# IAM Access Key Review Runbook + +This runbook supports the StudentHub AWS remediation work in issue #55. It gives maintainers a repeatable offline review for IAM access keys without posting raw exports, full access key IDs, secret keys, account IDs, candidate data, or CloudTrail logs to GitHub. + +The helper CLI matches exposed key suffixes from the incident notes, reports stale or inactive keys, and checks that service users have `owner`, `service`, and `environment` metadata before the monthly key review. + +## Inputs + +Create a private CSV with one row per access key: + +```csv +user,access_key_id,status,created_at,last_used_at,last_used_service,last_used_region,owner,service,environment,notes +railway-s3-access,REDACTED_KEY_ID_ENDING_DOBNJ,active,2026-01-01,2026-04-17,s3,eu-west-2,platform,studenthub,production,private export +``` + +Do not commit the real CSV. Keep it in the private incident evidence folder. + +## Run + +```bash +node tools/audit-iam-access-keys.mjs \ + --keys /private/studenthub/iam-access-keys.csv \ + --suffixes DOBNJ,55KF,OFLT,XW5I,TMEZ \ + --as-of 2026-05-15 \ + --out /private/studenthub/iam-access-key-review.md +``` + +For spreadsheet review: + +```bash +node tools/audit-iam-access-keys.mjs \ + --keys /private/studenthub/iam-access-keys.csv \ + --format csv \ + --out /private/studenthub/iam-access-key-review.csv +``` + +## Review Rules + +- `rotate_and_deactivate`: active key matches a watched exposed suffix. Create a replacement key, deploy it through the runtime secret store, smoke test the related service, then deactivate the old key. +- `delete_after_evidence`: inactive key matches a watched suffix. Capture key suffix, IAM user, inactive status, and deletion timestamp for AWS Support. +- `delete_inactive_key`: inactive key has remained in IAM beyond the inactive threshold. +- `rotate_old_active_key`: active key is older than the rotation threshold. +- `review_or_disable_unused_key`: active key has no recent last-used evidence. +- `add_required_tags`: IAM user/key metadata is missing `owner`, `service`, or `environment`. + +The report redacts key IDs to suffixes only, for example `...DOBNJ`. + +## AWS Support Evidence + +For each remediated suffix, capture only the following in the support package: + +- IAM user name +- key suffix only +- old key deactivation timestamp +- old key deletion timestamp +- replacement variable name in Railway or AWS Secrets Manager +- smoke test result for the affected service + +Never include full access key IDs, secret access keys, bearer tokens, candidate records, Civil ID images, database exports, or raw CloudTrail files in public GitHub comments. + +## Local Regression Check + +```bash +node tools/check-iam-access-key-review.mjs +``` + +The check uses synthetic fixture data and verifies that Markdown and CSV reports include the expected actions without leaking full key IDs. diff --git a/tools/audit-iam-access-keys.mjs b/tools/audit-iam-access-keys.mjs new file mode 100755 index 00000000..2869b395 --- /dev/null +++ b/tools/audit-iam-access-keys.mjs @@ -0,0 +1,443 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "node:fs"; + +const DEFAULT_EXPOSED_SUFFIXES = ["DOBNJ", "55KF", "OFLT", "XW5I", "TMEZ"]; +const DEFAULT_MAX_ACTIVE_AGE_DAYS = 90; +const DEFAULT_MAX_INACTIVE_AGE_DAYS = 7; +const DEFAULT_MAX_UNUSED_DAYS = 60; +const REQUIRED_TAGS = ["owner", "service", "environment"]; + +function usage() { + return `Usage: + node tools/audit-iam-access-keys.mjs --keys [options] + +Options: + --suffixes Exposed key suffixes to match. Default: ${DEFAULT_EXPOSED_SUFFIXES.join(",")} + --suffixes-file Newline or comma-separated suffix list. + --as-of Review date. Default: today. + --format Output format. Default: markdown. + --out Write report to a file instead of stdout. + --max-active-age-days Rotation threshold for active keys. Default: ${DEFAULT_MAX_ACTIVE_AGE_DAYS} + --max-inactive-age-days Deletion threshold for inactive keys. Default: ${DEFAULT_MAX_INACTIVE_AGE_DAYS} + --max-unused-days Review threshold for unused active keys. Default: ${DEFAULT_MAX_UNUSED_DAYS} +`; +} + +function parseArgs(argv) { + const options = { + keys: null, + suffixes: [...DEFAULT_EXPOSED_SUFFIXES], + asOf: new Date().toISOString().slice(0, 10), + format: "markdown", + out: null, + maxActiveAgeDays: DEFAULT_MAX_ACTIVE_AGE_DAYS, + maxInactiveAgeDays: DEFAULT_MAX_INACTIVE_AGE_DAYS, + maxUnusedDays: DEFAULT_MAX_UNUSED_DAYS, + }; + + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + if (arg === "--help" || arg === "-h") { + options.help = true; + } else if (arg === "--keys") { + options.keys = next; + i += 1; + } else if (arg === "--suffixes") { + options.suffixes = splitSuffixes(next); + i += 1; + } else if (arg === "--suffixes-file") { + options.suffixes = splitSuffixes(readFileSync(next, "utf8")); + i += 1; + } else if (arg === "--as-of") { + options.asOf = next; + i += 1; + } else if (arg === "--format") { + options.format = next; + i += 1; + } else if (arg === "--out") { + options.out = next; + i += 1; + } else if (arg === "--max-active-age-days") { + options.maxActiveAgeDays = Number(next); + i += 1; + } else if (arg === "--max-inactive-age-days") { + options.maxInactiveAgeDays = Number(next); + i += 1; + } else if (arg === "--max-unused-days") { + options.maxUnusedDays = Number(next); + i += 1; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (options.format !== "markdown" && options.format !== "csv") { + throw new Error("--format must be markdown or csv"); + } + + if (!options.help && !options.keys) { + throw new Error("--keys is required"); + } + + if (Number.isNaN(Date.parse(`${options.asOf}T00:00:00Z`))) { + throw new Error("--as-of must be a YYYY-MM-DD date"); + } + + for (const field of ["maxActiveAgeDays", "maxInactiveAgeDays", "maxUnusedDays"]) { + if (!Number.isFinite(options[field]) || options[field] < 0) { + throw new Error(`--${field.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} must be a non-negative number`); + } + } + + return options; +} + +function splitSuffixes(value = "") { + return value + .split(/[\s,]+/) + .map((suffix) => suffix.trim().toUpperCase()) + .filter(Boolean); +} + +function parseCsv(text) { + const rows = []; + let row = []; + let cell = ""; + let inQuotes = false; + + for (let i = 0; i < text.length; i += 1) { + const char = text[i]; + const next = text[i + 1]; + + if (inQuotes) { + if (char === '"' && next === '"') { + cell += '"'; + i += 1; + } else if (char === '"') { + inQuotes = false; + } else { + cell += char; + } + continue; + } + + if (char === '"') { + inQuotes = true; + } else if (char === ",") { + row.push(cell); + cell = ""; + } else if (char === "\n") { + row.push(cell); + rows.push(row); + row = []; + cell = ""; + } else if (char !== "\r") { + cell += char; + } + } + + if (cell.length > 0 || row.length > 0) { + row.push(cell); + rows.push(row); + } + + const [headers, ...data] = rows.filter((r) => r.some((cellValue) => cellValue.trim() !== "")); + if (!headers) { + return []; + } + + return data.map((values) => { + const output = {}; + headers.forEach((header, index) => { + output[normalizeHeader(header)] = values[index]?.trim() ?? ""; + }); + return output; + }); +} + +function normalizeHeader(header) { + return header.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); +} + +function field(row, names) { + for (const name of names) { + const normalized = normalizeHeader(name); + if (row[normalized] !== undefined && row[normalized] !== "") { + return row[normalized]; + } + } + return ""; +} + +function normalizeKeyRow(row) { + const rawKeyId = field(row, ["access_key_id", "accessKeyId", "key_id", "keyId", "id"]); + const statusText = field(row, ["status", "active", "access_key_status"]).toLowerCase(); + const status = ["true", "yes", "active", "1"].includes(statusText) + ? "active" + : ["false", "no", "inactive", "0"].includes(statusText) + ? "inactive" + : statusText || "unknown"; + + return { + user: field(row, ["user", "user_name", "iam_user", "username"]) || "(unknown user)", + accessKeyId: rawKeyId, + status, + createdAt: normalizeDate(field(row, ["created_at", "create_date", "created", "last_rotated", "access_key_last_rotated"])), + lastUsedAt: normalizeDate(field(row, ["last_used_at", "last_used_date", "last_used", "access_key_last_used_date"])), + lastUsedService: field(row, ["last_used_service", "service_last_used", "access_key_last_used_service"]), + lastUsedRegion: field(row, ["last_used_region", "region_last_used", "access_key_last_used_region"]), + owner: field(row, ["owner", "tag_owner"]), + service: field(row, ["service", "tag_service", "app", "application"]), + environment: field(row, ["environment", "env", "tag_environment"]), + notes: field(row, ["notes", "note"]), + }; +} + +function normalizeDate(value) { + const trimmed = value.trim(); + if (!trimmed || ["n/a", "na", "none", "null", "no_information", "not_supported"].includes(trimmed.toLowerCase())) { + return ""; + } + + const parsed = new Date(trimmed); + return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString().slice(0, 10); +} + +function daysBetween(start, end) { + if (!start) { + return null; + } + const startTime = Date.parse(`${start}T00:00:00Z`); + const endTime = Date.parse(`${end}T00:00:00Z`); + if (Number.isNaN(startTime) || Number.isNaN(endTime)) { + return null; + } + return Math.max(0, Math.floor((endTime - startTime) / 86_400_000)); +} + +function keyLabel(accessKeyId, matchedSuffix) { + if (matchedSuffix) { + return `...${matchedSuffix}`; + } + if (!accessKeyId) { + return "(missing key id)"; + } + return `...${accessKeyId.slice(-4).toUpperCase()}`; +} + +function findMatchedSuffix(accessKeyId, suffixes) { + const upperKey = accessKeyId.toUpperCase(); + return suffixes.find((suffix) => upperKey.endsWith(suffix)) || ""; +} + +function analyzeRow(row, options) { + const matchedSuffix = findMatchedSuffix(row.accessKeyId, options.suffixes); + const ageDays = daysBetween(row.createdAt, options.asOf); + const unusedDays = daysBetween(row.lastUsedAt || row.createdAt, options.asOf); + const findings = []; + const actions = []; + + if (!row.accessKeyId) { + findings.push("missing key id in export"); + actions.push("re-export key inventory"); + } + + if (matchedSuffix && row.status === "active") { + findings.push(`matches exposed suffix ${matchedSuffix}`); + actions.push("rotate_and_deactivate"); + } else if (matchedSuffix) { + findings.push(`inactive key matches exposed suffix ${matchedSuffix}`); + actions.push("delete_after_evidence"); + } + + if (row.status === "inactive" && ageDays !== null && ageDays > options.maxInactiveAgeDays) { + findings.push(`inactive for ${ageDays} days`); + actions.push("delete_inactive_key"); + } + + if (row.status === "active" && ageDays !== null && ageDays > options.maxActiveAgeDays) { + findings.push(`active key age ${ageDays} days`); + actions.push("rotate_old_active_key"); + } + + if (row.status === "active" && !row.lastUsedAt && ageDays !== null && ageDays > options.maxInactiveAgeDays) { + findings.push("active key has no last-used evidence"); + actions.push("review_or_disable_unused_key"); + } else if (row.status === "active" && unusedDays !== null && unusedDays > options.maxUnusedDays) { + findings.push(`not used for ${unusedDays} days`); + actions.push("review_or_disable_unused_key"); + } + + const missingTags = REQUIRED_TAGS.filter((tag) => !row[tag]); + if (missingTags.length > 0) { + findings.push(`missing tags: ${missingTags.join(", ")}`); + actions.push("add_required_tags"); + } + + if (actions.length === 0) { + actions.push("no_action"); + } + + const severity = severityFor(findings, actions); + + return { + severity, + user: row.user, + key: keyLabel(row.accessKeyId, matchedSuffix), + status: row.status, + createdAt: row.createdAt || "unknown", + ageDays: ageDays === null ? "unknown" : String(ageDays), + lastUsedAt: row.lastUsedAt || "never/unknown", + lastUsedService: row.lastUsedService || "", + lastUsedRegion: row.lastUsedRegion || "", + owner: row.owner || "", + service: row.service || "", + environment: row.environment || "", + findings: findings.length > 0 ? findings : ["no findings"], + actions: Array.from(new Set(actions)), + }; +} + +function severityFor(findings, actions) { + if (actions.includes("rotate_and_deactivate")) { + return "critical"; + } + if (actions.includes("delete_after_evidence") || actions.includes("delete_inactive_key")) { + return "high"; + } + if (actions.some((action) => action !== "no_action")) { + return "medium"; + } + return "ok"; +} + +function analyze(rows, options) { + const normalizedRows = rows.map(normalizeKeyRow); + return normalizedRows.map((row) => analyzeRow(row, options)); +} + +function escapeMarkdown(value) { + return String(value).replace(/\|/g, "\\|").replace(/\n/g, " "); +} + +function renderMarkdown(results, options) { + const counts = results.reduce((acc, row) => { + acc[row.severity] = (acc[row.severity] || 0) + 1; + return acc; + }, {}); + + const rows = results + .sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || a.user.localeCompare(b.user)) + .map((row) => `| ${escapeMarkdown(row.severity)} | ${escapeMarkdown(row.user)} | ${escapeMarkdown(row.key)} | ${escapeMarkdown(row.status)} | ${escapeMarkdown(row.ageDays)} | ${escapeMarkdown(row.lastUsedAt)} | ${escapeMarkdown(row.findings.join("; "))} | ${escapeMarkdown(row.actions.join(", "))} |`); + + return `# IAM Access Key Review + +Review date: ${options.asOf} + +## Summary + +- Critical: ${counts.critical || 0} +- High: ${counts.high || 0} +- Medium: ${counts.medium || 0} +- OK: ${counts.ok || 0} +- Exposed suffix watchlist: ${options.suffixes.map((suffix) => `\`...${suffix}\``).join(", ")} + +## Findings + +| Severity | IAM user | Key suffix | Status | Age days | Last used | Findings | Recommended action | +|---|---|---:|---|---:|---|---|---| +${rows.join("\n")} + +## Evidence Package Checklist + +- Store the raw IAM export in the private incident folder, not in GitHub. +- For each \`rotate_and_deactivate\` key, capture the IAM user, key suffix, replacement key creation time, deactivation time, and smoke-test result. +- For each \`delete_after_evidence\` or \`delete_inactive_key\` key, capture the deletion timestamp before closing the AWS Support evidence package. +- Add missing \`owner\`, \`service\`, and \`environment\` tags before the next monthly key review. +`; +} + +function severityRank(severity) { + return { critical: 0, high: 1, medium: 2, ok: 3 }[severity] ?? 4; +} + +function csvCell(value) { + const text = Array.isArray(value) ? value.join("; ") : String(value); + return `"${text.replace(/"/g, '""')}"`; +} + +function renderCsv(results) { + const headers = [ + "severity", + "user", + "key_suffix", + "status", + "created_at", + "age_days", + "last_used_at", + "last_used_service", + "last_used_region", + "owner", + "service", + "environment", + "findings", + "recommended_actions", + ]; + const rows = results + .sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || a.user.localeCompare(b.user)) + .map((row) => [ + row.severity, + row.user, + row.key, + row.status, + row.createdAt, + row.ageDays, + row.lastUsedAt, + row.lastUsedService, + row.lastUsedRegion, + row.owner, + row.service, + row.environment, + row.findings, + row.actions, + ].map(csvCell).join(",")); + + return `${headers.join(",")}\n${rows.join("\n")}\n`; +} + +function main() { + try { + const options = parseArgs(process.argv); + if (options.help) { + process.stdout.write(usage()); + return; + } + + const rows = parseCsv(readFileSync(options.keys, "utf8")); + const results = analyze(rows, options); + const report = options.format === "csv" ? renderCsv(results) : renderMarkdown(results, options); + + if (options.out) { + writeFileSync(options.out, report); + } else { + process.stdout.write(report); + } + } catch (error) { + process.stderr.write(`${error.message}\n\n${usage()}`); + process.exitCode = 1; + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} + +export { + analyze, + parseCsv, + renderCsv, + renderMarkdown, + splitSuffixes, +}; diff --git a/tools/check-iam-access-key-review.mjs b/tools/check-iam-access-key-review.mjs new file mode 100755 index 00000000..172ebb27 --- /dev/null +++ b/tools/check-iam-access-key-review.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const script = join(root, "tools", "audit-iam-access-keys.mjs"); +const fixture = join(root, "tools", "fixtures", "iam-access-keys.sample.csv"); + +function run(args) { + return execFileSync(process.execPath, [script, "--keys", fixture, "--as-of", "2026-05-15", ...args], { + cwd: root, + encoding: "utf8", + }); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +const markdown = run([]); +assert(markdown.includes("rotate_and_deactivate"), "expected active exposed key rotation action"); +assert(markdown.includes("delete_after_evidence"), "expected inactive exposed key evidence action"); +assert(markdown.includes("add_required_tags"), "expected missing tag action"); +assert(markdown.includes("...DOBNJ"), "expected redacted exposed key suffix"); +assert(!markdown.includes("studenthub-temp-key-DOBNJ"), "markdown leaked full key id"); +assert(!markdown.includes("studenthub-old-key-XW5I"), "markdown leaked full inactive key id"); + +const csv = run(["--format", "csv"]); +assert(csv.startsWith("severity,user,key_suffix"), "expected CSV header"); +assert(csv.includes('"critical"'), "expected critical CSV row"); +assert(csv.includes('"high"'), "expected high CSV row"); +assert(!csv.includes("studenthub-temp-key-DOBNJ"), "CSV leaked full key id"); +assert(!csv.includes("studenthub-old-key-XW5I"), "CSV leaked full inactive key id"); + +console.log("IAM access key review check passed"); diff --git a/tools/fixtures/iam-access-keys.sample.csv b/tools/fixtures/iam-access-keys.sample.csv new file mode 100644 index 00000000..75b22558 --- /dev/null +++ b/tools/fixtures/iam-access-keys.sample.csv @@ -0,0 +1,6 @@ +user,access_key_id,status,created_at,last_used_at,last_used_service,last_used_region,owner,service,environment,notes +railway-s3-access,studenthub-temp-key-DOBNJ,active,2026-01-01,2026-04-17,s3,eu-west-2,platform,studenthub,production,synthetic exposed active key suffix +mediaconverter,studenthub-media-key-OFLT,active,2026-02-01,2026-02-15,mediaconvert,eu-west-2,media,studenthub,production,synthetic stale media key suffix +legacy-mailer,studenthub-old-key-XW5I,inactive,2025-12-01,2026-01-01,ses,eu-west-2,platform,studenthub,production,synthetic inactive exposed key suffix +untagged-worker,studenthub-worker-key-9ABC,active,2026-04-01,2026-05-01,sqs,eu-west-2,,,production,synthetic untagged key +textract-access,studenthub-textract-key-7DEF,inactive,2026-05-01,2026-05-05,textract,eu-west-2,document-processing,studenthub,production,synthetic recently inactive key