diff --git a/README.md b/README.md index eb0a5ca6..1befdeca 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 +- [AWS Support Evidence Package](docs/security/aws-support-evidence-package.md) - Redacted incident evidence bundle for S3/IAM remediation ## Development @@ -204,4 +205,3 @@ END $$ DELIMITER; - diff --git a/docs/security/aws-support-evidence-package.md b/docs/security/aws-support-evidence-package.md new file mode 100644 index 00000000..6fe3589c --- /dev/null +++ b/docs/security/aws-support-evidence-package.md @@ -0,0 +1,44 @@ +# AWS Support Evidence Package + +Issue #55 includes a final evidence package for requesting AWS restriction review after the StudentHub S3/IAM remediation is complete. Keep raw screenshots, CloudTrail exports, IAM CSV files, Railway screens, candidate records, and full key IDs in a private incident folder. This helper only produces a redacted Markdown summary that can be reviewed before sending to AWS Support. + +## Usage + +Prepare a private JSON manifest using the sample shape in `tools/fixtures/aws-support-evidence-package.sample.json`, then run: + +```bash +node tools/build-aws-support-evidence-package.mjs private/aws-support-evidence.json > private/aws-support-evidence.md +``` + +The generated report redacts: + +- 12-digit AWS account IDs +- AWS access-key-looking values +- long secret-like token strings + +It preserves short key suffixes such as `FZMN`, `4T67K`, `ODY2X`, and `WCUM` because issue #55 uses suffixes as the public-safe reference format. + +## Manifest Sections + +The manifest should contain only evidence summaries and local/private file references: + +- `deletedKeys`: inactive key suffixes deleted after screenshots were captured +- `rotatedKeys`: exposed or over-permissioned key suffixes rotated or replaced +- `environmentVariables`: replacement variable names configured in Railway/secret stores +- `bucketControls`: S3 lifecycle, CORS, versioning, logging, replication, and public-access evidence +- `smokeTests`: post-remediation upload, remove, replace, staff-view, and key-deactivation tests +- `cloudTrail`: investigation summaries with source IP, user agent, event, bucket, and key suffix only +- `iamReviews`: least-privilege/access-analyzer review summaries +- `supportNotes`: remaining limitations, private attachment locations, or follow-up owner notes + +Do not put full keys, secret keys, bearer tokens, raw CloudTrail JSON, candidate Civil ID values/images, phone numbers, account IDs, or payment/tax data into the manifest. + +## Validation + +Run the local regression check before committing changes to this helper: + +```bash +node tools/check-aws-support-evidence-package.mjs +node --check tools/build-aws-support-evidence-package.mjs +node --check tools/check-aws-support-evidence-package.mjs +``` diff --git a/tools/build-aws-support-evidence-package.mjs b/tools/build-aws-support-evidence-package.mjs new file mode 100644 index 00000000..df45f00b --- /dev/null +++ b/tools/build-aws-support-evidence-package.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +import fs from "node:fs"; + +const [, , manifestPath] = process.argv; + +if (!manifestPath) { + console.error( + "Usage: node tools/build-aws-support-evidence-package.mjs ", + ); + process.exit(1); +} + +let manifest; +try { + manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +} catch (error) { + console.error(`Failed to load manifest at ${manifestPath}: ${error.message}`); + process.exit(1); +} + +function redact(value) { + if (value == null) { + return ""; + } + + return String(value) + .replace(/\b\d{12}\b/g, "[aws-account-id-redacted]") + .replace(/\bA(KIA|SIA|GPA|IDA)[A-Z0-9]{16}\b/g, "[aws-access-key-redacted]") + .replace( + /\b(?=[A-Za-z0-9+/=_-]{32,}\b)(?=.*[A-Z])(?=.*[a-z])(?=.*\d)[A-Za-z0-9+/=_-]{32,}\b/g, + "[secret-like-value-redacted]", + ); +} + +function line(value) { + return redact(value || "not provided") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, " "); +} + +function list(values) { + if (!Array.isArray(values) || values.length === 0) { + return "- None provided\n"; + } + + return values.map((value) => `- ${line(value)}`).join("\n") + "\n"; +} + +function table(headers, rows) { + const safeRows = Array.isArray(rows) ? rows : []; + const header = `| ${headers.join(" | ")} |`; + const divider = `| ${headers.map(() => "---").join(" | ")} |`; + const body = safeRows.map( + (row) => `| ${headers.map((key) => line(row[key])).join(" | ")} |`, + ); + + return [header, divider, ...body].join("\n") + "\n"; +} + +function envTable(rows) { + const safeRows = Array.isArray(rows) ? rows : []; + return table( + ["service", "names", "evidence"], + safeRows.map((row) => ({ + service: row.service, + names: Array.isArray(row.names) ? row.names.join(", ") : row.names, + evidence: row.evidence, + })), + ); +} + +const incident = manifest.incident || {}; + +const sections = [ + `# ${line(incident.title || "AWS Support Evidence Package")}`, + "", + `Prepared by: ${line(incident.preparedBy)}`, + `Prepared at: ${line(incident.preparedAt)}`, + `Private evidence folder: ${line(incident.privateEvidenceFolder)}`, + "", + "## Deleted Inactive Keys", + table(["suffix", "iamUser", "status", "deletedAt", "evidence"], manifest.deletedKeys), + "## Rotated Or Deactivated Keys", + table(["suffix", "iamUser", "replacement", "status", "evidence"], manifest.rotatedKeys), + "## Replacement Environment Variables", + envTable(manifest.environmentVariables), + "## Bucket Controls", + table(["bucket", "control", "status", "evidence"], manifest.bucketControls), + "## Smoke Tests", + table(["name", "status", "evidence"], manifest.smokeTests), + "## CloudTrail Review Summary", + table( + [ + "eventTime", + "eventName", + "userName", + "accessKeySuffix", + "sourceIPAddress", + "userAgent", + "bucketName", + "result", + ], + manifest.cloudTrail, + ), + "## IAM Review Summary", + table(["iamUser", "status", "evidence"], manifest.iamReviews), + "## Support Notes", + list(manifest.supportNotes), + "## Public Safety Checklist", + "- Full access keys are redacted or represented by suffix only.", + "- Secret-like values are redacted.", + "- AWS account IDs are redacted.", + "- Candidate Civil ID images, values, phone numbers, raw exports, and payment/tax data are not included.", + "", +]; + +process.stdout.write(sections.join("\n")); diff --git a/tools/check-aws-support-evidence-package.mjs b/tools/check-aws-support-evidence-package.mjs new file mode 100644 index 00000000..66ff9994 --- /dev/null +++ b/tools/check-aws-support-evidence-package.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const fixturePath = "tools/fixtures/aws-support-evidence-package.sample.json"; +const manifest = JSON.parse(fs.readFileSync(fixturePath, "utf8")); +manifest.supportNotes = [ + ...(manifest.supportNotes || []), + `Synthetic redaction probe: key ${"AKIA"}${"ABCDEFGHIJKLMNOP"} must not render.`, + "Synthetic Markdown probe: support note with | pipe and\nnewline must remain on one bullet.", +]; +manifest.bucketControls = [ + ...(manifest.bucketControls || []), + { + bucket: "studenthub-uploads", + control: "demo control with | pipe", + status: "complete", + evidence: "screenshots/multiline\npath.png", + }, +]; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aws-support-evidence-")); +try { + const tmpManifest = path.join(tmpDir, "manifest.json"); + fs.writeFileSync(tmpManifest, JSON.stringify(manifest, null, 2)); + + const output = execFileSync(process.execPath, [ + "tools/build-aws-support-evidence-package.mjs", + tmpManifest, + ], { encoding: "utf8", timeout: 10000 }); + + const required = [ + "Deleted Inactive Keys", + "Rotated Or Deactivated Keys", + "Replacement Environment Variables", + "Bucket Controls", + "Smoke Tests", + "CloudTrail Review Summary", + "IAM Review Summary", + "Public Safety Checklist", + "FZMN", + "4T67K", + "ODY2X", + "WCUM", + "demo control with \\| pipe", + "screenshots/multiline path.png", + "support note with \\| pipe and newline must remain on one bullet", + ]; + + for (const text of required) { + if (!output.includes(text)) { + throw new Error(`Expected generated report to include: ${text}`); + } + } + + const forbidden = [ + /\b\d{12}\b/, + /\bA(KIA|SIA|GPA|IDA)[A-Z0-9]{16}\b/, + /Abcdefghijklmnopqrstuvwxyz123456/, + /support note with \\\| pipe and\nnewline/, + /screenshots\/multiline\npath\.png/, + ]; + + for (const pattern of forbidden) { + if (pattern.test(output)) { + throw new Error(`Generated report leaked forbidden pattern: ${pattern}`); + } + } + + console.log("AWS Support evidence package check passed."); +} finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); +} diff --git a/tools/fixtures/aws-support-evidence-package.sample.json b/tools/fixtures/aws-support-evidence-package.sample.json new file mode 100644 index 00000000..2e57a2c9 --- /dev/null +++ b/tools/fixtures/aws-support-evidence-package.sample.json @@ -0,0 +1,103 @@ +{ + "incident": { + "title": "StudentHub S3/IAM remediation evidence", + "preparedBy": "Security team", + "preparedAt": "2026-05-15T10:30:00Z", + "privateEvidenceFolder": "private/aws-support-2026-05" + }, + "deletedKeys": [ + { + "suffix": "FZMN", + "iamUser": "textract-access", + "status": "deleted", + "deletedAt": "2026-05-15T09:00:00Z", + "evidence": "screenshots/fzmn-deleted.png" + }, + { + "suffix": "4T67K", + "iamUser": "public-environment-s3-access", + "status": "deleted", + "deletedAt": "2026-05-15T09:05:00Z", + "evidence": "screenshots/4t67k-deleted.png" + } + ], + "rotatedKeys": [ + { + "suffix": "ODY2X", + "iamUser": "public-environment-s3-access", + "replacement": "AWS_TEMP_BUCKET_KEY / AWS_TEMP_BUCKET_SECRET", + "status": "rotated and deactivated", + "evidence": "screenshots/ody2x-rotated.png" + }, + { + "suffix": "WCUM", + "iamUser": "railway-s3-access", + "replacement": "AWS_PERMANENT_S3_ACCESS_KEY_ID / AWS_PERMANENT_S3_SECRET_ACCESS_KEY", + "status": "rotated and deactivated", + "evidence": "screenshots/wcum-rotated.png" + } + ], + "environmentVariables": [ + { + "service": "Railway production", + "names": [ + "AWS_TEMP_BUCKET_KEY", + "AWS_TEMP_BUCKET_SECRET", + "AWS_PERMANENT_S3_ACCESS_KEY_ID", + "AWS_PERMANENT_S3_SECRET_ACCESS_KEY" + ], + "evidence": "screenshots/railway-env-names-only.png" + } + ], + "bucketControls": [ + { + "bucket": "studenthub-uploads", + "control": "lifecycle rule PressureDelete3Days disabled", + "status": "complete", + "evidence": "screenshots/studenthub-uploads-lifecycle-disabled.png" + }, + { + "bucket": "studenthub-uploads", + "control": "versioning enabled", + "status": "complete", + "evidence": "screenshots/studenthub-uploads-versioning.png" + } + ], + "smokeTests": [ + { + "name": "new candidate signup with Civil ID front/back", + "status": "passed", + "evidence": "smoke-tests/new-candidate-civil-id.md" + }, + { + "name": "existing candidate Civil ID remove", + "status": "passed", + "evidence": "smoke-tests/remove-civil-id.md" + } + ], + "cloudTrail": [ + { + "eventTime": "2026-04-18T11:22:33Z", + "eventName": "PutBucketLifecycleConfiguration", + "userName": "railway-s3-access", + "accessKeySuffix": "WCUM", + "sourceIPAddress": "203.0.113.10", + "userAgent": "aws-sdk-php/3.x", + "bucketName": "studenthub-uploads", + "result": "reviewed with expected automation owner" + } + ], + "iamReviews": [ + { + "iamUser": "railway-s3-access", + "status": "restricted to object-level S3 actions", + "evidence": "screenshots/railway-s3-policy-after.png" + } + ], + "supportNotes": [ + "Raw screenshots and exports remain in the private evidence folder.", + "Synthetic redaction probe: account 123456789012 must not render.", + "Synthetic redaction probe: token Abcdefghijklmnopqrstuvwxyz123456 must not render.", + "No full access keys or candidate Civil ID data are included in this public manifest." + ] +}