diff --git a/docs/security/s3-bucket-guardrail-audit.md b/docs/security/s3-bucket-guardrail-audit.md new file mode 100644 index 00000000..4c37e824 --- /dev/null +++ b/docs/security/s3-bucket-guardrail-audit.md @@ -0,0 +1,55 @@ +# S3 bucket guardrail audit helper + +This helper supports issue #55 by producing an offline evidence report for the Civil ID upload bucket. It does not need AWS credentials when reviewing a PR: maintainers can export AWS CLI JSON once, commit or attach sanitized evidence, and run the checker locally. + +## Evidence format + +Create a JSON file with the bucket name and selected AWS CLI outputs: + +```json +{ + "bucket": "studenthub-civil-id-uploads", + "publicAccessBlock": {}, + "policyStatus": {}, + "encryption": {}, + "versioning": {}, + "ownershipControls": {}, + "lifecycle": {}, + "cors": {} +} +``` + +Suggested AWS commands: + +```bash +aws s3api get-public-access-block --bucket "$BUCKET" > public-access-block.json +aws s3api get-bucket-policy-status --bucket "$BUCKET" > policy-status.json +aws s3api get-bucket-encryption --bucket "$BUCKET" > encryption.json +aws s3api get-bucket-versioning --bucket "$BUCKET" > versioning.json +aws s3api get-bucket-ownership-controls --bucket "$BUCKET" > ownership-controls.json +aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET" > lifecycle.json +aws s3api get-bucket-cors --bucket "$BUCKET" > cors.json +``` + +Copy each command output into the matching top-level property. Do not include raw object keys, IAM secrets, or full bucket policies in PR comments. + +## Run + +```bash +node tools/audit-s3-bucket-guardrails.mjs --input tools/fixtures/s3-bucket-guardrails.sample.json +node tools/audit-s3-bucket-guardrails.mjs --input tools/fixtures/s3-bucket-guardrails.sample.json --format json +node --test tools/s3-bucket-guardrails.test.mjs +``` + +## Guardrails checked + +- Bucket evidence identifies the target bucket. +- Bucket policy status is not public. +- All four S3 Block Public Access flags are enabled. +- Default server-side encryption is configured. +- Versioning is enabled or explicitly called out as a warning. +- Bucket-owner-enforced object ownership is enabled. +- Incomplete multipart uploads are cleaned up by lifecycle policy. +- CORS does not allow wildcard origins with write methods. + +The markdown report intentionally summarizes posture evidence without printing raw bucket policy statements. diff --git a/tools/audit-s3-bucket-guardrails.mjs b/tools/audit-s3-bucket-guardrails.mjs new file mode 100644 index 00000000..eb4713ab --- /dev/null +++ b/tools/audit-s3-bucket-guardrails.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const REQUIRED_PUBLIC_ACCESS_BLOCK_FLAGS = [ + "BlockPublicAcls", + "IgnorePublicAcls", + "BlockPublicPolicy", + "RestrictPublicBuckets", +] + +const statusRank = { + pass: 0, + warn: 1, + fail: 2, +} + +function normalizeStatus(status) { + if (!["pass", "warn", "fail"].includes(status)) { + throw new Error(`Unknown guardrail status: ${status}`) + } + return status +} + +function finding(id, status, title, evidence, remediation) { + return { + id, + status: normalizeStatus(status), + title, + evidence, + remediation, + } +} + +function getPublicAccessBlockFlags(input) { + return input.publicAccessBlock?.PublicAccessBlockConfiguration ?? {} +} + +function hasDefaultEncryption(input) { + const rules = input.encryption?.ServerSideEncryptionConfiguration?.Rules + return Array.isArray(rules) && rules.some((rule) => { + const encryptionDefault = rule.ApplyServerSideEncryptionByDefault + return Boolean(encryptionDefault?.SSEAlgorithm) + }) +} + +function getObjectOwnership(input) { + return input.ownershipControls?.OwnershipControls?.Rules?.[0]?.ObjectOwnership +} + +function hasMultipartAbortLifecycle(input) { + const rules = input.lifecycle?.Rules + return Array.isArray(rules) && rules.some((rule) => { + return rule.Status === "Enabled" && Boolean(rule.AbortIncompleteMultipartUpload) + }) +} + +function hasWildcardWriteCors(input) { + const rules = input.cors?.CORSRules + if (!Array.isArray(rules)) { + return false + } + + return rules.some((rule) => { + const origins = rule.AllowedOrigins ?? [] + const methods = rule.AllowedMethods ?? [] + const allowsWildcard = origins.includes("*") + const allowsWrite = methods.some((method) => ["PUT", "POST", "DELETE"].includes(method)) + return allowsWildcard && allowsWrite + }) +} + +export function auditBucketGuardrails(input) { + const bucket = input.bucket ?? input.Bucket ?? input.name ?? "" + const publicFlags = getPublicAccessBlockFlags(input) + const missingPublicFlags = REQUIRED_PUBLIC_ACCESS_BLOCK_FLAGS.filter((flag) => publicFlags[flag] !== true) + const policyIsPublic = input.policyStatus?.PolicyStatus?.IsPublic === true + const versioningStatus = input.versioning?.Status + const objectOwnership = getObjectOwnership(input) + const findings = [ + finding( + "bucket-policy-public", + policyIsPublic ? "fail" : "pass", + "Bucket policy must not be public", + policyIsPublic + ? "AWS reports PolicyStatus.IsPublic=true." + : "AWS reports PolicyStatus.IsPublic=false or no public policy status was exported.", + "Remove public principals from the bucket policy and keep Civil ID access behind authenticated backend flows.", + ), + finding( + "public-access-block", + missingPublicFlags.length === 0 ? "pass" : "fail", + "All S3 Block Public Access flags should be enabled", + missingPublicFlags.length === 0 + ? "All four public-access-block flags are true." + : `Missing or disabled flags: ${missingPublicFlags.join(", ")}.`, + "Enable BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, and RestrictPublicBuckets at bucket level.", + ), + finding( + "default-encryption", + hasDefaultEncryption(input) ? "pass" : "fail", + "Server-side encryption should be configured", + hasDefaultEncryption(input) + ? "At least one server-side encryption rule is configured." + : "Server-side encryption is not configured in the exported evidence.", + "Enable default SSE-S3 or SSE-KMS encryption for all Civil ID objects.", + ), + finding( + "versioning", + versioningStatus === "Enabled" ? "pass" : "warn", + "Versioning should be enabled for recovery evidence", + versioningStatus === "Enabled" + ? "Bucket versioning is enabled." + : `Bucket versioning is ${versioningStatus || "not configured"}.`, + "Enable versioning unless the maintainer intentionally accepts lower recovery coverage for this bucket.", + ), + finding( + "object-ownership", + objectOwnership === "BucketOwnerEnforced" ? "pass" : "fail", + "Bucket owner enforced object ownership should be enabled", + objectOwnership === "BucketOwnerEnforced" + ? "ObjectOwnership is BucketOwnerEnforced." + : `ObjectOwnership is ${objectOwnership || "not configured"}.`, + "Use BucketOwnerEnforced ownership so ACLs cannot create cross-account object access drift.", + ), + finding( + "multipart-lifecycle", + hasMultipartAbortLifecycle(input) ? "pass" : "fail", + "Incomplete multipart uploads should be cleaned up", + hasMultipartAbortLifecycle(input) + ? "An enabled lifecycle rule aborts incomplete multipart uploads." + : "No enabled AbortIncompleteMultipartUpload lifecycle rule was exported.", + "Add a lifecycle rule to abort incomplete uploads after a short retention window.", + ), + finding( + "cors-wildcard-write", + hasWildcardWriteCors(input) ? "fail" : "pass", + "CORS must not allow wildcard write access", + hasWildcardWriteCors(input) + ? "At least one CORS rule allows wildcard origins with write methods." + : "No wildcard-origin CORS rule allows PUT, POST, or DELETE.", + "Restrict CORS origins to trusted StudentHub domains and avoid wildcard write methods.", + ), + finding( + "bucket-name-present", + bucket ? "pass" : "fail", + "Bucket name is present in the evidence package", + bucket ? `Auditing bucket ${bucket}.` : "No bucket name was provided.", + "Include the S3 bucket name so remediation evidence can be traced to the right Civil ID upload bucket.", + ), + ] + + const summary = findings.reduce((acc, item) => { + acc[item.status] += 1 + return acc + }, { pass: 0, warn: 0, fail: 0 }) + + const overallStatus = findings.reduce((status, item) => { + return statusRank[item.status] > statusRank[status] ? item.status : status + }, "pass") + + return { + bucket, + overallStatus, + summary, + findings, + } +} + +export function formatMarkdownReport(report) { + const lines = [ + `# S3 Bucket Guardrail Audit: ${report.bucket || "unknown bucket"}`, + "", + `Overall status: ${report.overallStatus.toUpperCase()}`, + "", + `Summary: ${report.summary.pass} pass, ${report.summary.warn} warning, ${report.summary.fail} fail`, + "", + "| Status | Guardrail | Evidence | Remediation |", + "| --- | --- | --- | --- |", + ] + + for (const item of report.findings) { + lines.push(`| ${item.status.toUpperCase()} | ${escapeTable(item.title)} | ${escapeTable(item.evidence)} | ${escapeTable(item.remediation)} |`) + } + + lines.push("") + lines.push("This report intentionally summarizes posture evidence without printing raw bucket policies or secrets.") + return `${lines.join("\n")}\n` +} + +function escapeTable(value) { + return String(value).replaceAll("|", "\\|").replaceAll("\n", " ") +} + +function parseArgs(argv) { + const args = { + input: undefined, + format: "markdown", + } + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === "--input") { + args.input = argv[++i] + } else if (arg === "--format") { + args.format = argv[++i] + } else if (arg === "--help" || arg === "-h") { + args.help = true + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + + return args +} + +function usage() { + return [ + "Usage: node tools/audit-s3-bucket-guardrails.mjs --input [--format markdown|json]", + "", + "The evidence JSON can contain AWS CLI outputs from:", + "- s3api get-public-access-block", + "- s3api get-bucket-policy-status", + "- s3api get-bucket-encryption", + "- s3api get-bucket-versioning", + "- s3api get-bucket-ownership-controls", + "- s3api get-bucket-lifecycle-configuration", + "- s3api get-bucket-cors", + ].join("\n") +} + +function main() { + const args = parseArgs(process.argv.slice(2)) + if (args.help) { + console.log(usage()) + return + } + if (!args.input) { + throw new Error("Missing --input") + } + + const evidence = JSON.parse(readFileSync(args.input, "utf8")) + const report = auditBucketGuardrails(evidence) + + if (args.format === "json") { + console.log(JSON.stringify(report, null, 2)) + return + } + if (args.format === "markdown") { + process.stdout.write(formatMarkdownReport(report)) + return + } + + throw new Error(`Unsupported --format: ${args.format}`) +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + main() + } catch (error) { + console.error(error.message) + console.error("") + console.error(usage()) + process.exitCode = 1 + } +} diff --git a/tools/fixtures/s3-bucket-guardrails.sample.json b/tools/fixtures/s3-bucket-guardrails.sample.json new file mode 100644 index 00000000..51650811 --- /dev/null +++ b/tools/fixtures/s3-bucket-guardrails.sample.json @@ -0,0 +1,63 @@ +{ + "bucket": "studenthub-civil-id-uploads", + "publicAccessBlock": { + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "IgnorePublicAcls": true, + "BlockPublicPolicy": true, + "RestrictPublicBuckets": true + } + }, + "policyStatus": { + "PolicyStatus": { + "IsPublic": false + } + }, + "encryption": { + "ServerSideEncryptionConfiguration": { + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + } + ] + } + }, + "versioning": { + "Status": "Enabled" + }, + "ownershipControls": { + "OwnershipControls": { + "Rules": [ + { + "ObjectOwnership": "BucketOwnerEnforced" + } + ] + } + }, + "lifecycle": { + "Rules": [ + { + "ID": "abort-incomplete-civil-id-uploads", + "Status": "Enabled", + "AbortIncompleteMultipartUpload": { + "DaysAfterInitiation": 7 + } + } + ] + }, + "cors": { + "CORSRules": [ + { + "AllowedOrigins": [ + "https://staff.example.com" + ], + "AllowedMethods": [ + "GET", + "PUT" + ] + } + ] + } +} diff --git a/tools/s3-bucket-guardrails.test.mjs b/tools/s3-bucket-guardrails.test.mjs new file mode 100644 index 00000000..6f5279cd --- /dev/null +++ b/tools/s3-bucket-guardrails.test.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { dirname, join } from "node:path" +import { describe, it } from "node:test" +import { fileURLToPath } from "node:url" + +import { auditBucketGuardrails, formatMarkdownReport } from "./audit-s3-bucket-guardrails.mjs" + +const toolsDir = dirname(fileURLToPath(import.meta.url)) + +describe("auditBucketGuardrails", () => { + it("flags unsafe bucket posture without leaking raw policy contents", () => { + const report = auditBucketGuardrails({ + bucket: "studenthub-civil-id-uploads", + publicAccessBlock: { + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + IgnorePublicAcls: false, + BlockPublicPolicy: true, + RestrictPublicBuckets: false, + }, + }, + policyStatus: { PolicyStatus: { IsPublic: true } }, + encryption: {}, + versioning: { Status: "Suspended" }, + ownershipControls: { + OwnershipControls: { + Rules: [{ ObjectOwnership: "ObjectWriter" }], + }, + }, + lifecycle: {}, + cors: { + CORSRules: [ + { + AllowedOrigins: ["*"], + AllowedMethods: ["GET", "PUT", "POST"], + }, + ], + }, + }) + + assert.equal(report.summary.fail, 6) + assert.equal(report.summary.warn, 1) + assert.equal(report.summary.pass, 1) + assert.equal(report.findings[0].id, "bucket-policy-public") + + const markdown = formatMarkdownReport(report) + assert.match(markdown, /studenthub-civil-id-uploads/) + assert.match(markdown, /Server-side encryption is not configured/) + assert.doesNotMatch(markdown, /Statement/) + assert.doesNotMatch(markdown, /Principal/) + }) + + it("passes the expected private Civil ID upload bucket guardrails", () => { + const report = auditBucketGuardrails({ + bucket: "studenthub-civil-id-uploads", + publicAccessBlock: { + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + IgnorePublicAcls: true, + BlockPublicPolicy: true, + RestrictPublicBuckets: true, + }, + }, + policyStatus: { PolicyStatus: { IsPublic: false } }, + encryption: { + ServerSideEncryptionConfiguration: { + Rules: [ + { + ApplyServerSideEncryptionByDefault: { + SSEAlgorithm: "AES256", + }, + }, + ], + }, + }, + versioning: { Status: "Enabled" }, + ownershipControls: { + OwnershipControls: { + Rules: [{ ObjectOwnership: "BucketOwnerEnforced" }], + }, + }, + lifecycle: { + Rules: [ + { + Status: "Enabled", + AbortIncompleteMultipartUpload: { + DaysAfterInitiation: 7, + }, + }, + ], + }, + cors: { + CORSRules: [ + { + AllowedOrigins: ["https://staff.example.com"], + AllowedMethods: ["GET", "PUT"], + }, + ], + }, + }) + + assert.deepEqual(report.summary, { pass: 8, warn: 0, fail: 0 }) + }) + + it("prints a markdown report when executed as a CLI", () => { + const output = execFileSync(process.execPath, [ + join(toolsDir, "audit-s3-bucket-guardrails.mjs"), + "--input", + join(toolsDir, "fixtures", "s3-bucket-guardrails.sample.json"), + ], { encoding: "utf8" }) + + assert.match(output, /S3 Bucket Guardrail Audit: studenthub-civil-id-uploads/) + assert.match(output, /Overall status: PASS/) + }) +})