From cfa6fd8da076d1cb0abde646719f9ebf7c2504eb Mon Sep 17 00:00:00 2001 From: jamilahmadzai Date: Fri, 15 May 2026 00:57:33 +0200 Subject: [PATCH 1/2] tools: add CloudTrail S3 admin audit helper --- docs/security/cloudtrail-s3-admin-audit.md | 74 ++++ tools/audit-cloudtrail-s3-admin-events.mjs | 323 ++++++++++++++++++ tools/check-cloudtrail-s3-admin-audit.mjs | 43 +++ .../cloudtrail-s3-admin-events.sample.json | 65 ++++ 4 files changed, 505 insertions(+) create mode 100644 docs/security/cloudtrail-s3-admin-audit.md create mode 100755 tools/audit-cloudtrail-s3-admin-events.mjs create mode 100755 tools/check-cloudtrail-s3-admin-audit.mjs create mode 100644 tools/fixtures/cloudtrail-s3-admin-events.sample.json diff --git a/docs/security/cloudtrail-s3-admin-audit.md b/docs/security/cloudtrail-s3-admin-audit.md new file mode 100644 index 00000000..14ae3181 --- /dev/null +++ b/docs/security/cloudtrail-s3-admin-audit.md @@ -0,0 +1,74 @@ +# CloudTrail S3 Admin Event Audit + +Issue #55 asks for a CloudTrail review of service users that should not be able to change S3 bucket controls. This helper keeps that review offline: maintainers export CloudTrail JSON privately, run the script locally, and share only the redacted summary. + +The script focuses on high-risk bucket administration events, including lifecycle, CORS, policy, replication, logging, public access block, ACL, ownership, versioning, and website configuration changes. + +## Usage + +Run against one CloudTrail JSON file: + +```bash +node tools/audit-cloudtrail-s3-admin-events.mjs cloudtrail-export.json +``` + +Run against a directory of JSON exports and write CSV: + +```bash +node tools/audit-cloudtrail-s3-admin-events.mjs ./cloudtrail-exports \ + --format csv \ + --out cloudtrail-s3-admin-events.csv +``` + +By default the audit filters to: + +- buckets beginning with `studenthub-` +- IAM users `railway-s3-access`, `n8n-s3-access`, and `mediaconverter` + +Useful options: + +```bash +# Include every bucket in the export. +node tools/audit-cloudtrail-s3-admin-events.mjs ./cloudtrail-exports --all-buckets + +# Include every actor in the export. +node tools/audit-cloudtrail-s3-admin-events.mjs ./cloudtrail-exports --all-users + +# Audit an additional bucket prefix or user. +node tools/audit-cloudtrail-s3-admin-events.mjs ./cloudtrail-exports \ + --bucket-prefix studenthub- \ + --bucket-prefix plugn- \ + --user railway-s3-access \ + --user n8n-s3-access +``` + +## Supported Exports + +The helper accepts: + +- CloudTrail `Records` arrays +- raw arrays of event records +- single CloudTrail event objects +- Event History records containing a stringified `CloudTrailEvent` + +It walks directories recursively and reads `.json` files only. + +## Privacy Boundary + +Do not commit raw CloudTrail exports. They can contain source IPs, user agents, resource names, and account metadata. + +The report intentionally prints only the last four characters of any `accessKeyId`. It never needs full AWS access keys, secret keys, private candidate data, bucket object contents, or live AWS credentials. + +## Verification + +The repository includes a synthetic fixture with no real account data: + +```bash +node tools/check-cloudtrail-s3-admin-audit.mjs +``` + +Expected output: + +```text +CloudTrail S3 admin audit check passed. +``` diff --git a/tools/audit-cloudtrail-s3-admin-events.mjs b/tools/audit-cloudtrail-s3-admin-events.mjs new file mode 100755 index 00000000..32db4ab3 --- /dev/null +++ b/tools/audit-cloudtrail-s3-admin-events.mjs @@ -0,0 +1,323 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_BUCKET_PREFIXES = ["studenthub-"]; +const DEFAULT_USERS = ["railway-s3-access", "n8n-s3-access", "mediaconverter"]; + +const BUCKET_ADMIN_EVENTS = new Set([ + "DeleteBucketCors", + "DeleteBucketPolicy", + "DeleteBucketReplication", + "DeleteBucketWebsite", + "PutBucketAcl", + "PutBucketCors", + "PutBucketLifecycleConfiguration", + "PutBucketLogging", + "PutBucketOwnershipControls", + "PutBucketPolicy", + "PutBucketPublicAccessBlock", + "PutBucketReplication", + "PutBucketVersioning", + "PutBucketWebsite", + "DeletePublicAccessBlock", + "PutPublicAccessBlock", +]); + +function printUsage() { + console.log(`Usage: + node tools/audit-cloudtrail-s3-admin-events.mjs [more files] + +Options: + --format markdown|csv Output format. Defaults to markdown. + --out Write output to a file instead of stdout. + --bucket-prefix Match buckets by prefix. Repeat or comma-separate. + Defaults to studenthub-. + --all-buckets Do not filter by bucket prefix. + --user Match IAM user names. Repeat or comma-separate. + Defaults to railway-s3-access,n8n-s3-access,mediaconverter. + --all-users Do not filter by IAM user. +`); +} + +function parseArgs(argv) { + const options = { + format: "markdown", + out: null, + bucketPrefixes: [...DEFAULT_BUCKET_PREFIXES], + users: [...DEFAULT_USERS], + inputs: [], + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const readValue = () => { + const value = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : argv[++i]; + if (!value) throw new Error(`Missing value for ${arg}`); + return value; + }; + + if (arg === "--help" || arg === "-h") { + printUsage(); + process.exit(0); + } else if (arg === "--format" || arg.startsWith("--format=")) { + options.format = readValue(); + } else if (arg === "--out" || arg.startsWith("--out=")) { + options.out = readValue(); + } else if (arg === "--bucket-prefix" || arg.startsWith("--bucket-prefix=")) { + options.bucketPrefixes.push(...readValue().split(",").map((value) => value.trim()).filter(Boolean)); + } else if (arg === "--all-buckets") { + options.bucketPrefixes = []; + } else if (arg === "--user" || arg.startsWith("--user=")) { + options.users.push(...readValue().split(",").map((value) => value.trim()).filter(Boolean)); + } else if (arg === "--all-users") { + options.users = []; + } else if (arg.startsWith("-")) { + throw new Error(`Unknown option: ${arg}`); + } else { + options.inputs.push(arg); + } + } + + options.format = options.format.toLowerCase(); + if (!["markdown", "csv"].includes(options.format)) { + throw new Error("--format must be markdown or csv"); + } + + options.bucketPrefixes = unique(options.bucketPrefixes); + options.users = unique(options.users); + + return options; +} + +function unique(values) { + return [...new Set(values)]; +} + +function collectJsonFiles(inputPath) { + const resolvedPath = path.resolve(inputPath); + const stat = fs.statSync(resolvedPath); + + if (stat.isDirectory()) { + return fs + .readdirSync(resolvedPath, { withFileTypes: true }) + .flatMap((entry) => collectJsonFiles(path.join(resolvedPath, entry.name))) + .filter((filePath) => filePath.endsWith(".json")); + } + + return [resolvedPath]; +} + +function readRecords(filePath) { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + const records = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed.Records) + ? parsed.Records + : Array.isArray(parsed.events) + ? parsed.events + : [parsed]; + + return records.map((record) => normalizeRecord(record, filePath)); +} + +function normalizeRecord(record, filePath) { + if (typeof record.CloudTrailEvent === "string") { + return { + ...JSON.parse(record.CloudTrailEvent), + _sourceFile: filePath, + }; + } + + return { + ...record, + _sourceFile: filePath, + }; +} + +function bucketFromRecord(record) { + const request = record.requestParameters ?? {}; + const directBucket = request.bucketName ?? request.bucket ?? request.Bucket; + if (directBucket) return String(directBucket); + + for (const resource of record.resources ?? []) { + const resourceName = resource.resourceName ?? resource.ARN ?? resource.arn ?? ""; + const bucketMatch = String(resourceName).match(/^arn:aws:s3:::(?[^/]+)/); + if (bucketMatch?.groups?.bucket) return bucketMatch.groups.bucket; + } + + return ""; +} + +function userNameFromRecord(record) { + const identity = record.userIdentity ?? {}; + return ( + identity.userName ?? + identity.sessionContext?.sessionIssuer?.userName ?? + identity.principalId ?? + "" + ); +} + +function accessKeySuffix(record) { + const accessKeyId = record.userIdentity?.accessKeyId; + if (!accessKeyId) return ""; + return String(accessKeyId).slice(-4); +} + +function eventMatches(record, options) { + if (!BUCKET_ADMIN_EVENTS.has(record.eventName)) return false; + + const bucketName = bucketFromRecord(record); + if ( + options.bucketPrefixes.length > 0 && + !options.bucketPrefixes.some((prefix) => bucketName.startsWith(prefix)) + ) { + return false; + } + + const userName = userNameFromRecord(record); + if (options.users.length > 0 && !options.users.includes(userName)) { + return false; + } + + return true; +} + +function buildEvent(record) { + return { + eventTime: record.eventTime ?? "", + eventName: record.eventName ?? "", + userName: userNameFromRecord(record), + accessKeyIdSuffix: accessKeySuffix(record), + sourceIPAddress: record.sourceIPAddress ?? "", + userAgent: record.userAgent ?? "", + bucketName: bucketFromRecord(record), + awsRegion: record.awsRegion ?? "", + errorCode: record.errorCode ?? "", + sourceFile: record._sourceFile ?? "", + }; +} + +function compareEvents(left, right) { + return ( + String(left.eventTime).localeCompare(String(right.eventTime)) || + String(left.eventName).localeCompare(String(right.eventName)) + ); +} + +function groupBy(events, key) { + return events.reduce((groups, event) => { + const value = event[key] || "(empty)"; + groups.set(value, [...(groups.get(value) ?? []), event]); + return groups; + }, new Map()); +} + +function renderMarkdown(events, options, files) { + const lines = [ + "# CloudTrail S3 Bucket Admin Audit", + "", + `- Input files: ${files.length}`, + `- Matching events: ${events.length}`, + `- Bucket filter: ${options.bucketPrefixes.length ? options.bucketPrefixes.join(", ") : "all buckets"}`, + `- User filter: ${options.users.length ? options.users.join(", ") : "all users"}`, + "", + ]; + + if (events.length === 0) { + lines.push("No matching S3 bucket-admin events were found."); + return lines.join("\n"); + } + + lines.push("## Events By API", "", "| Event | Count | First Seen | Last Seen |", "|---|---:|---|---|"); + for (const [eventName, rows] of groupBy(events, "eventName")) { + lines.push(`| ${escapeMarkdown(eventName)} | ${rows.length} | ${escapeMarkdown(rows[0].eventTime)} | ${escapeMarkdown(rows.at(-1).eventTime)} |`); + } + + lines.push("", "## Events By User", "", "| User | Count | Key Suffixes | Event Types |", "|---|---:|---|---|"); + for (const [userName, rows] of groupBy(events, "userName")) { + lines.push( + `| ${escapeMarkdown(userName)} | ${rows.length} | ${escapeMarkdown(unique(rows.map((row) => row.accessKeyIdSuffix).filter(Boolean)).join(", "))} | ${escapeMarkdown(unique(rows.map((row) => row.eventName)).join(", "))} |`, + ); + } + + lines.push( + "", + "## Matching Events", + "", + "| Time | Event | User | Key Suffix | Source IP | User Agent | Bucket | Region | Error |", + "|---|---|---|---|---|---|---|---|---|", + ); + + for (const event of events) { + lines.push( + `| ${escapeMarkdown(event.eventTime)} | ${escapeMarkdown(event.eventName)} | ${escapeMarkdown(event.userName)} | ${escapeMarkdown(event.accessKeyIdSuffix)} | ${escapeMarkdown(event.sourceIPAddress)} | ${escapeMarkdown(event.userAgent)} | ${escapeMarkdown(event.bucketName)} | ${escapeMarkdown(event.awsRegion)} | ${escapeMarkdown(event.errorCode)} |`, + ); + } + + return lines.join("\n"); +} + +function renderCsv(events) { + const columns = [ + "eventTime", + "eventName", + "userName", + "accessKeyIdSuffix", + "sourceIPAddress", + "userAgent", + "bucketName", + "awsRegion", + "errorCode", + "sourceFile", + ]; + + return [ + columns.join(","), + ...events.map((event) => columns.map((column) => quoteCsv(event[column])).join(",")), + ].join("\n"); +} + +function escapeMarkdown(value) { + return String(value ?? "").replaceAll("|", "\\|").replace(/\s+/g, " ").trim(); +} + +function quoteCsv(value) { + return `"${String(value ?? "").replaceAll('"', '""')}"`; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.inputs.length === 0) { + printUsage(); + process.exitCode = 1; + return; + } + + const files = unique(options.inputs.flatMap(collectJsonFiles)); + const events = files + .flatMap(readRecords) + .filter((record) => eventMatches(record, options)) + .map(buildEvent) + .sort(compareEvents); + + const output = options.format === "csv" + ? renderCsv(events) + : renderMarkdown(events, options, files); + + if (options.out) { + fs.mkdirSync(path.dirname(path.resolve(options.out)), { recursive: true }); + fs.writeFileSync(options.out, `${output}\n`); + } else { + console.log(output); + } +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/tools/check-cloudtrail-s3-admin-audit.mjs b/tools/check-cloudtrail-s3-admin-audit.mjs new file mode 100755 index 00000000..de843160 --- /dev/null +++ b/tools/check-cloudtrail-s3-admin-audit.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const auditScript = path.join(__dirname, "audit-cloudtrail-s3-admin-events.mjs"); +const fixture = path.join(__dirname, "fixtures", "cloudtrail-s3-admin-events.sample.json"); + +const markdown = execFileSync(process.execPath, [auditScript, fixture], { + encoding: "utf8", +}); + +const requiredSnippets = [ + "Matching events: 2", + "PutBucketLifecycleConfiguration", + "DeleteBucketCors", + "railway-s3-access", + "n8n-s3-access", + "ODY2", + "N8N1", +]; + +for (const snippet of requiredSnippets) { + if (!markdown.includes(snippet)) { + throw new Error(`Expected markdown audit output to include: ${snippet}`); + } +} + +const csv = execFileSync(process.execPath, [auditScript, fixture, "--format", "csv"], { + encoding: "utf8", +}); + +if (!csv.startsWith("eventTime,eventName,userName,accessKeyIdSuffix")) { + throw new Error("CSV output is missing the expected header"); +} + +if (csv.includes("PutObject") || csv.includes("unrelated-audit-bucket")) { + throw new Error("Audit output included events outside the configured scope"); +} + +console.log("CloudTrail S3 admin audit check passed."); diff --git a/tools/fixtures/cloudtrail-s3-admin-events.sample.json b/tools/fixtures/cloudtrail-s3-admin-events.sample.json new file mode 100644 index 00000000..132a8845 --- /dev/null +++ b/tools/fixtures/cloudtrail-s3-admin-events.sample.json @@ -0,0 +1,65 @@ +{ + "Records": [ + { + "eventTime": "2026-04-18T09:13:12Z", + "eventName": "PutBucketLifecycleConfiguration", + "awsRegion": "eu-west-2", + "sourceIPAddress": "203.0.113.10", + "userAgent": "aws-cli/2.15.0", + "userIdentity": { + "type": "IAMUser", + "userName": "railway-s3-access", + "accessKeyId": "KEY_SUFFIX_ODY2" + }, + "requestParameters": { + "bucketName": "studenthub-uploads" + } + }, + { + "eventTime": "2026-04-18T10:44:51Z", + "eventName": "DeleteBucketCors", + "awsRegion": "eu-west-2", + "sourceIPAddress": "198.51.100.23", + "userAgent": "n8n", + "errorCode": "AccessDenied", + "userIdentity": { + "type": "IAMUser", + "userName": "n8n-s3-access", + "accessKeyId": "KEY_SUFFIX_N8N1" + }, + "requestParameters": { + "bucketName": "studenthub-public-anyone-can-upload-24hr-expiry" + } + }, + { + "eventTime": "2026-04-18T11:00:00Z", + "eventName": "PutObject", + "awsRegion": "eu-west-2", + "sourceIPAddress": "203.0.113.10", + "userAgent": "aws-sdk-php", + "userIdentity": { + "type": "IAMUser", + "userName": "railway-s3-access", + "accessKeyId": "KEY_SUFFIX_ODY2" + }, + "requestParameters": { + "bucketName": "studenthub-uploads" + } + }, + { + "eventTime": "2026-04-18T12:00:00Z", + "eventName": "PutBucketPolicy", + "awsRegion": "eu-west-2", + "sourceIPAddress": "192.0.2.8", + "userAgent": "aws-cli/2.15.0", + "userIdentity": { + "type": "IAMUser", + "userName": "railway-s3-access", + "accessKeyId": "KEY_SUFFIX_ODY2" + }, + "requestParameters": { + "bucketName": "unrelated-audit-bucket" + } + } + ] +} From d9eee09c25f2c025ca3902b17ec0f5d993c92e0f Mon Sep 17 00:00:00 2001 From: jamilahmadzai Date: Fri, 15 May 2026 02:17:29 +0200 Subject: [PATCH 2/2] tools: include file path in CloudTrail parse errors --- tools/audit-cloudtrail-s3-admin-events.mjs | 9 +++++++- tools/check-cloudtrail-s3-admin-audit.mjs | 27 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/tools/audit-cloudtrail-s3-admin-events.mjs b/tools/audit-cloudtrail-s3-admin-events.mjs index 32db4ab3..7d455f65 100755 --- a/tools/audit-cloudtrail-s3-admin-events.mjs +++ b/tools/audit-cloudtrail-s3-admin-events.mjs @@ -110,7 +110,14 @@ function collectJsonFiles(inputPath) { } function readRecords(filePath) { - const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse JSON in ${filePath}: ${message}`, { cause: error }); + } + const records = Array.isArray(parsed) ? parsed : Array.isArray(parsed.Records) diff --git a/tools/check-cloudtrail-s3-admin-audit.mjs b/tools/check-cloudtrail-s3-admin-audit.mjs index de843160..df09f169 100755 --- a/tools/check-cloudtrail-s3-admin-audit.mjs +++ b/tools/check-cloudtrail-s3-admin-audit.mjs @@ -1,6 +1,8 @@ #!/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"; import { fileURLToPath } from "node:url"; @@ -40,4 +42,29 @@ if (csv.includes("PutObject") || csv.includes("unrelated-audit-bucket")) { throw new Error("Audit output included events outside the configured scope"); } +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "studenthub-cloudtrail-")); +const invalidFixture = path.join(tempDir, "invalid.json"); +fs.writeFileSync(invalidFixture, "{not-json"); + +let invalidRunError; +try { + execFileSync(process.execPath, [auditScript, invalidFixture], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} catch (error) { + invalidRunError = error; +} finally { + fs.rmSync(tempDir, { recursive: true, force: true }); +} + +if (!invalidRunError) { + throw new Error("Expected invalid JSON input to fail"); +} + +const stderr = String(invalidRunError.stderr ?? ""); +if (!stderr.includes("Failed to parse JSON") || !stderr.includes(invalidFixture)) { + throw new Error("Invalid JSON errors should include the failing file path"); +} + console.log("CloudTrail S3 admin audit check passed.");