From c2fdd22c1274b63efe9ca496d7ca7a10eb8f56d4 Mon Sep 17 00:00:00 2001 From: Souf Date: Fri, 15 May 2026 22:10:26 +0200 Subject: [PATCH 1/4] Add CloudTrail S3 admin audit helper --- docs/cloudtrail-s3-admin-audit.md | 82 +++++ tools/audit-cloudtrail-s3-admin-events.mjs | 409 +++++++++++++++++++++ tools/check-cloudtrail-s3-admin-audit.mjs | 97 +++++ 3 files changed, 588 insertions(+) create mode 100644 docs/cloudtrail-s3-admin-audit.md create mode 100644 tools/audit-cloudtrail-s3-admin-events.mjs create mode 100644 tools/check-cloudtrail-s3-admin-audit.mjs diff --git a/docs/cloudtrail-s3-admin-audit.md b/docs/cloudtrail-s3-admin-audit.md new file mode 100644 index 00000000..3851fb58 --- /dev/null +++ b/docs/cloudtrail-s3-admin-audit.md @@ -0,0 +1,82 @@ +# CloudTrail S3 Admin Event Audit + +Issue #55 Phase 7 asks the team to export CloudTrail events for the suspicious Apr 17-19 window and review whether service IAM users performed bucket-admin operations that should not be callable by application credentials. + +This helper works on offline exports only. It does not call AWS, mutate IAM, rotate keys, or require contributors to see production credentials. + +## Events Flagged + +The audit filters the exact S3 bucket-admin event names called out in the incident plan: + +- `PutBucketLifecycleConfiguration` +- `DeleteBucketCors` +- `PutBucketCors` +- `DeleteBucketPolicy` +- `PutBucketPolicy` +- `PutBucketReplicationConfiguration` +- `PutBucketLogging` +- `PutPublicAccessBlock` +- `DeletePublicAccessBlock` + +## Inputs + +Export the CloudTrail rows for the incident window and service users under review: + +- `railway-s3-access` +- `n8n-s3-access` +- `mediaconverter` + +The script accepts: + +- CloudTrail JSON with a top-level `Records` array +- JSON arrays +- JSONL +- CSV exports with columns such as `eventTime`, `eventName`, `userName`, `accessKeyId`, `sourceIPAddress`, `userAgent`, `bucketName`, `region`, and `errorCode` + +Do not commit raw CloudTrail exports to this repository. + +## Usage + +Generate a Markdown report: + +```bash +node tools/audit-cloudtrail-s3-admin-events.mjs \ + --input /secure/path/cloudtrail-apr17-apr19.json +``` + +Generate CSV for filtering: + +```bash +node tools/audit-cloudtrail-s3-admin-events.mjs \ + --input /secure/path/cloudtrail-apr17-apr19.json \ + --format csv +``` + +Add another watched service user: + +```bash +node tools/audit-cloudtrail-s3-admin-events.mjs \ + --input /secure/path/cloudtrail-apr17-apr19.json \ + --watch-user custom-s3-automation-user +``` + +## Output + +The report includes: + +- Matching bucket-admin event counts +- Critical/high event counts +- Breakdowns by event name, IAM user, and bucket +- Per-event rows with `eventTime`, severity, event name, IAM user, access-key suffix, source IP, bucket, region, error code, and review note + +Full access key IDs and secret-shaped values are redacted from output. The report keeps only the last four access-key characters so maintainers can match key suffixes already referenced in the incident plan. + +## Verification + +Run the synthetic fixture test: + +```bash +node tools/check-cloudtrail-s3-admin-audit.mjs +``` + +The check verifies JSON and CSV input handling, suspicious-event filtering, severity classification, non-StudentHub bucket notes, failed-event notes, and key redaction. diff --git a/tools/audit-cloudtrail-s3-admin-events.mjs b/tools/audit-cloudtrail-s3-admin-events.mjs new file mode 100644 index 00000000..66611e15 --- /dev/null +++ b/tools/audit-cloudtrail-s3-admin-events.mjs @@ -0,0 +1,409 @@ +#!/usr/bin/env node + +import {readFileSync} from 'node:fs'; +import {basename} from 'node:path'; + +const SUSPICIOUS_EVENTS = new Set([ + 'PutBucketLifecycleConfiguration', + 'DeleteBucketCors', + 'PutBucketCors', + 'DeleteBucketPolicy', + 'PutBucketPolicy', + 'PutBucketReplicationConfiguration', + 'PutBucketLogging', + 'PutPublicAccessBlock', + 'DeletePublicAccessBlock', +]); + +const DEFAULT_USERS = new Set(['railway-s3-access', 'n8n-s3-access', 'mediaconverter']); +const ACCESS_KEY_PATTERN = /AKIA[0-9A-Z]{12,20}/g; +const SECRET_SHAPED_PATTERN = /(? normalizeEvent(event, file)); + } catch (error) { + return raw + .split(/\r?\n/) + .filter(Boolean) + .map((line) => normalizeEvent(JSON.parse(line), file)); + } +} + +function parseCsvEvents(raw, file) { + const lines = raw.split(/\r?\n/).filter(Boolean); + if (lines.length === 0) { + return []; + } + + const headers = parseCsvLine(lines[0]).map((header) => header.trim()); + return lines.slice(1).map((line) => { + const values = parseCsvLine(line); + const row = Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ''])); + return normalizeEvent(row, file); + }); +} + +function parseCsvLine(line) { + const cells = []; + let cell = ''; + let quoted = false; + + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + const next = line[i + 1]; + + if (quoted && char === '"' && next === '"') { + cell += '"'; + i += 1; + } else if (char === '"') { + quoted = !quoted; + } else if (!quoted && char === ',') { + cells.push(cell); + cell = ''; + } else { + cell += char; + } + } + + cells.push(cell); + return cells.map((value) => value.trim()); +} + +function normalizeEvent(event, file) { + const requestParameters = parseMaybeJson(event.requestParameters ?? event['request parameters'] ?? {}); + const userIdentity = parseMaybeJson(event.userIdentity ?? event['user identity'] ?? {}); + const resources = parseMaybeJson(event.resources ?? []); + const eventName = event.eventName ?? event['event name'] ?? event.EventName ?? ''; + const userName = event.userName ?? userIdentity.userName ?? userIdentity.sessionContext?.sessionIssuer?.userName ?? ''; + const accessKeyId = event.accessKeyId ?? userIdentity.accessKeyId ?? event['access key id'] ?? ''; + const bucketName = event.bucketName ?? requestParameters.bucketName ?? requestParameters.bucket ?? findBucketFromResources(resources) ?? ''; + + return { + sourceFile: basename(file), + eventTime: event.eventTime ?? event['event time'] ?? event.EventTime ?? '', + eventName, + userName, + accessKeySuffix: suffix(accessKeyId), + accessKeyId, + sourceIPAddress: event.sourceIPAddress ?? event['source ip address'] ?? event.SourceIPAddress ?? '', + userAgent: event.userAgent ?? event['user agent'] ?? event.UserAgent ?? '', + bucketName, + region: event.awsRegion ?? event.region ?? event.Region ?? '', + errorCode: event.errorCode ?? event['error code'] ?? '', + raw: event, + }; +} + +function parseMaybeJson(value) { + if (!value || typeof value !== 'string') { + return value; + } + + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function findBucketFromResources(resources) { + if (!Array.isArray(resources)) { + return ''; + } + + for (const resource of resources) { + const name = resource?.ARN ?? resource?.arn ?? resource?.resourceName ?? resource?.name; + const match = typeof name === 'string' ? name.match(/arn:aws:s3:::([^/]+)/) : null; + if (match?.[1]) { + return match[1]; + } + } + + return ''; +} + +function suffix(accessKeyId) { + return typeof accessKeyId === 'string' && accessKeyId.length >= 4 ? accessKeyId.slice(-4) : ''; +} + +function classifyEvents(events, watchUsers) { + return events + .filter((event) => SUSPICIOUS_EVENTS.has(event.eventName)) + .map((event) => ({ + ...event, + watchedUser: watchUsers.has(event.userName), + severity: severityFor(event), + reviewNote: reviewNoteFor(event, watchUsers), + })) + .sort((a, b) => String(a.eventTime).localeCompare(String(b.eventTime))); +} + +function severityFor(event) { + if (event.errorCode) { + return 'low'; + } + + if (event.eventName === 'PutBucketLifecycleConfiguration') { + return 'critical'; + } + + if (['PutBucketPolicy', 'DeleteBucketPolicy', 'PutPublicAccessBlock', 'DeletePublicAccessBlock'].includes(event.eventName)) { + return 'high'; + } + + return 'medium'; +} + +function reviewNoteFor(event, watchUsers) { + const notes = []; + + if (watchUsers.has(event.userName)) { + notes.push('watched service user'); + } + + if (event.bucketName && !event.bucketName.startsWith('studenthub-')) { + notes.push('non-StudentHub bucket'); + } + + if (event.errorCode) { + notes.push(`failed with ${event.errorCode}`); + } + + return notes.join('; ') || 'review source IP and automation owner'; +} + +function summarize(events) { + const summary = { + total: events.length, + byEvent: new Map(), + byUser: new Map(), + byBucket: new Map(), + criticalOrHigh: 0, + }; + + for (const event of events) { + increment(summary.byEvent, event.eventName); + increment(summary.byUser, event.userName || '(unknown)'); + increment(summary.byBucket, event.bucketName || '(unknown)'); + if (['critical', 'high'].includes(event.severity)) { + summary.criticalOrHigh += 1; + } + } + + return summary; +} + +function increment(map, key) { + map.set(key, (map.get(key) ?? 0) + 1); +} + +function toCsv(events) { + const headers = ['event_time', 'severity', 'event_name', 'user_name', 'access_key_suffix', 'source_ip', 'user_agent', 'bucket', 'region', 'error_code', 'review_note', 'source_file']; + return [ + headers.join(','), + ...events.map((event) => + [ + event.eventTime, + event.severity, + event.eventName, + event.userName, + event.accessKeySuffix, + event.sourceIPAddress, + event.userAgent, + event.bucketName, + event.region, + event.errorCode, + event.reviewNote, + event.sourceFile, + ] + .map((value) => csvEscape(redact(value))) + .join(','), + ), + ].join('\n'); +} + +function toMarkdown(events) { + const summary = summarize(events); + const lines = [ + '# CloudTrail S3 Admin Event Audit', + '', + 'This report is generated from offline CloudTrail exports. It does not call AWS, mutate IAM, or include full access keys.', + '', + '## Summary', + '', + `- Matching bucket-admin events: ${summary.total}`, + `- Critical/high events: ${summary.criticalOrHigh}`, + '', + '### By Event', + '', + ...mapToBullets(summary.byEvent), + '', + '### By User', + '', + ...mapToBullets(summary.byUser), + '', + '### By Bucket', + '', + ...mapToBullets(summary.byBucket), + '', + '## Events', + '', + '| Time | Severity | Event | User | Key | Source IP | Bucket | Region | Error | Note |', + '|-|-|-|-|-|-|-|-|-|-|', + ]; + + for (const event of events) { + lines.push( + [ + event.eventTime, + event.severity, + event.eventName, + event.userName || '-', + event.accessKeySuffix || '-', + event.sourceIPAddress || '-', + event.bucketName || '-', + event.region || '-', + event.errorCode || '-', + event.reviewNote, + ] + .map((value) => markdownCell(redact(value))) + .join('|') + .replace(/^/, '|') + .replace(/$/, '|'), + ); + } + + return lines.join('\n'); +} + +function mapToBullets(map) { + if (map.size === 0) { + return ['- None']; + } + + return [...map.entries()] + .sort(([, a], [, b]) => b - a) + .map(([key, count]) => `- ${redact(key)}: ${count}`); +} + +function redact(value) { + return String(value ?? '') + .replace(ACCESS_KEY_PATTERN, (match) => `AKIA…${match.slice(-4)}`) + .replace(SECRET_SHAPED_PATTERN, '[REDACTED]'); +} + +function csvEscape(value) { + const stringValue = String(value ?? ''); + + if (/[",\n]/.test(stringValue)) { + return `"${stringValue.replaceAll('"', '""')}"`; + } + + return stringValue; +} + +function markdownCell(value) { + return String(value ?? '').replaceAll('|', '\\|'); +} + +function main() { + try { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printUsage(); + return; + } + + const events = args.inputs.flatMap((input) => readEvents(input)); + const suspiciousEvents = classifyEvents(events, args.watchUsers); + console.log(args.format === 'csv' ? toCsv(suspiciousEvents) : toMarkdown(suspiciousEvents)); + } catch (error) { + console.error(`Error: ${error.message}`); + console.error(''); + printUsage(); + process.exitCode = 1; + } +} + +main(); diff --git a/tools/check-cloudtrail-s3-admin-audit.mjs b/tools/check-cloudtrail-s3-admin-audit.mjs new file mode 100644 index 00000000..92298ea4 --- /dev/null +++ b/tools/check-cloudtrail-s3-admin-audit.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {mkdtempSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; + +const root = new URL('..', import.meta.url).pathname; +const script = join(root, 'tools/audit-cloudtrail-s3-admin-events.mjs'); +const dir = mkdtempSync(join(tmpdir(), 'cloudtrail-s3-admin-audit-')); +const jsonInput = join(dir, 'cloudtrail.json'); +const csvInput = join(dir, 'cloudtrail.csv'); + +writeFileSync( + jsonInput, + JSON.stringify( + { + Records: [ + { + eventTime: '2026-04-18T09:00:00Z', + eventName: 'PutBucketLifecycleConfiguration', + userIdentity: { + userName: 'railway-s3-access', + accessKeyId: 'AKIA000000000000WCUM', + }, + sourceIPAddress: '203.0.113.10', + userAgent: 'aws-cli/2.15.0', + awsRegion: 'eu-west-2', + requestParameters: { + bucketName: 'studenthub-uploads', + }, + }, + { + eventTime: '2026-04-18T09:05:00Z', + eventName: 'PutObject', + userIdentity: { + userName: 'railway-s3-access', + accessKeyId: 'AKIA000000000000WCUM', + }, + requestParameters: { + bucketName: 'studenthub-uploads', + }, + }, + { + eventTime: '2026-04-18T10:00:00Z', + eventName: 'DeleteBucketPolicy', + userIdentity: { + userName: 'mediaconverter', + accessKeyId: 'AKIA000000000000OFLT', + }, + sourceIPAddress: '198.51.100.9', + userAgent: 'console.amazonaws.com', + awsRegion: 'eu-west-2', + requestParameters: { + bucketName: 'wallet-uploads', + }, + errorCode: 'AccessDenied', + }, + ], + }, + null, + 2, + ), +); + +writeFileSync( + csvInput, + [ + 'eventTime,eventName,userName,accessKeyId,sourceIPAddress,userAgent,bucketName,region,errorCode', + '2026-04-19T08:00:00Z,PutBucketCors,n8n-s3-access,AKIA000000000000N8N1,192.0.2.22,n8n,studenthub-public-anyone-can-upload-24hr-expiry,eu-west-2,', + ].join('\n'), +); + +const markdown = execFileSync(process.execPath, [script, '--input', jsonInput, '--input', csvInput], {encoding: 'utf8'}); + +assert.match(markdown, /Matching bucket-admin events: 3/); +assert.match(markdown, /Critical\/high events: 1/); +assert.match(markdown, /PutBucketLifecycleConfiguration: 1/); +assert.match(markdown, /DeleteBucketPolicy: 1/); +assert.match(markdown, /PutBucketCors: 1/); +assert.match(markdown, /railway-s3-access: 1/); +assert.match(markdown, /mediaconverter: 1/); +assert.match(markdown, /n8n-s3-access: 1/); +assert.match(markdown, /wallet-uploads/); +assert.match(markdown, /watched service user; non-StudentHub bucket; failed with AccessDenied/); +assert.doesNotMatch(markdown, /AKIA000000000000WCUM/); +assert.match(markdown, /\|2026-04-18T09:00:00Z\|critical\|PutBucketLifecycleConfiguration\|railway-s3-access\|WCUM\|/); + +const csv = execFileSync(process.execPath, [script, '--input', jsonInput, '--format', 'csv'], {encoding: 'utf8'}); + +assert.match(csv, /event_time,severity,event_name,user_name,access_key_suffix/); +assert.match(csv, /2026-04-18T10:00:00Z,low,DeleteBucketPolicy,mediaconverter,OFLT/); +assert.doesNotMatch(csv, /AKIA000000000000OFLT/); +assert.doesNotMatch(csv, /PutObject/); + +console.log('CloudTrail S3 admin audit helper check passed.'); From 444d36300ea6e09ef0c7b9a3ed8b79357585999a Mon Sep 17 00:00:00 2001 From: Souf Date: Sat, 16 May 2026 01:16:57 +0200 Subject: [PATCH 2/4] Use file URL conversion in CloudTrail audit check --- tools/check-cloudtrail-s3-admin-audit.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/check-cloudtrail-s3-admin-audit.mjs b/tools/check-cloudtrail-s3-admin-audit.mjs index 92298ea4..3001c711 100644 --- a/tools/check-cloudtrail-s3-admin-audit.mjs +++ b/tools/check-cloudtrail-s3-admin-audit.mjs @@ -5,8 +5,9 @@ import {execFileSync} from 'node:child_process'; import {mkdtempSync, writeFileSync} from 'node:fs'; import {join} from 'node:path'; import {tmpdir} from 'node:os'; +import {fileURLToPath} from 'node:url'; -const root = new URL('..', import.meta.url).pathname; +const root = fileURLToPath(new URL('..', import.meta.url)); const script = join(root, 'tools/audit-cloudtrail-s3-admin-events.mjs'); const dir = mkdtempSync(join(tmpdir(), 'cloudtrail-s3-admin-audit-')); const jsonInput = join(dir, 'cloudtrail.json'); From 0cbbbca36804df4759b667e7a93734a7ba236811 Mon Sep 17 00:00:00 2001 From: Souf Date: Sat, 16 May 2026 01:46:11 +0200 Subject: [PATCH 3/4] Tighten CloudTrail audit review details --- docs/cloudtrail-s3-admin-audit.md | 2 +- tools/audit-cloudtrail-s3-admin-events.mjs | 5 +---- tools/check-cloudtrail-s3-admin-audit.mjs | 12 ++++++------ 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/docs/cloudtrail-s3-admin-audit.md b/docs/cloudtrail-s3-admin-audit.md index 3851fb58..aff3a3ab 100644 --- a/docs/cloudtrail-s3-admin-audit.md +++ b/docs/cloudtrail-s3-admin-audit.md @@ -1,6 +1,6 @@ # CloudTrail S3 Admin Event Audit -Issue #55 Phase 7 asks the team to export CloudTrail events for the suspicious Apr 17-19 window and review whether service IAM users performed bucket-admin operations that should not be callable by application credentials. +Issue #55 Phase 7 asks the team to export CloudTrail events for the suspicious 2026-04-17T00:00:00Z to 2026-04-19T23:59:59Z UTC window and review whether service IAM users performed bucket-admin operations that should not be callable by application credentials. This helper works on offline exports only. It does not call AWS, mutate IAM, rotate keys, or require contributors to see production credentials. diff --git a/tools/audit-cloudtrail-s3-admin-events.mjs b/tools/audit-cloudtrail-s3-admin-events.mjs index 66611e15..13a08b4b 100644 --- a/tools/audit-cloudtrail-s3-admin-events.mjs +++ b/tools/audit-cloudtrail-s3-admin-events.mjs @@ -16,7 +16,7 @@ const SUSPICIOUS_EVENTS = new Set([ ]); const DEFAULT_USERS = new Set(['railway-s3-access', 'n8n-s3-access', 'mediaconverter']); -const ACCESS_KEY_PATTERN = /AKIA[0-9A-Z]{12,20}/g; +const ACCESS_KEY_PATTERN = /AKIA[2-7A-Z]{16}/g; const SECRET_SHAPED_PATTERN = /(? Date: Sat, 16 May 2026 02:07:24 +0200 Subject: [PATCH 4/4] Harden CloudTrail audit fixture coverage --- tools/audit-cloudtrail-s3-admin-events.mjs | 43 ++++++++---- tools/check-cloudtrail-s3-admin-audit.mjs | 81 ++++++++++++---------- 2 files changed, 77 insertions(+), 47 deletions(-) diff --git a/tools/audit-cloudtrail-s3-admin-events.mjs b/tools/audit-cloudtrail-s3-admin-events.mjs index 13a08b4b..8d888ae3 100644 --- a/tools/audit-cloudtrail-s3-admin-events.mjs +++ b/tools/audit-cloudtrail-s3-admin-events.mjs @@ -89,21 +89,40 @@ function readEvents(file) { } function parseJsonEvents(raw, file) { + let parsed; + try { - const parsed = JSON.parse(raw); - const records = Array.isArray(parsed) ? parsed : parsed.Records; + parsed = JSON.parse(raw); + } catch (error) { + return parseJsonlEvents(raw, file, error); + } - if (!Array.isArray(records)) { - throw new Error('JSON input must be an array or an object with a Records array'); - } + const records = Array.isArray(parsed) ? parsed : parsed.Records; - return records.map((event) => normalizeEvent(event, file)); - } catch (error) { - return raw - .split(/\r?\n/) - .filter(Boolean) - .map((line) => normalizeEvent(JSON.parse(line), file)); + if (!Array.isArray(records)) { + throw new Error('JSON input must be an array or an object with a Records array'); } + + return records.map((event) => normalizeEvent(event, file)); +} + +function parseJsonlEvents(raw, file, originalError) { + const lines = raw + .split(/\r?\n/) + .map((line, index) => ({line: line.trim(), lineNumber: index + 1})) + .filter(({line}) => line); + + if (lines.length <= 1 || !lines.every(({line}) => line.startsWith('{'))) { + throw new Error(`Invalid JSON input in ${file}: ${originalError.message}`); + } + + return lines.map(({line, lineNumber}) => { + try { + return normalizeEvent(JSON.parse(line), file); + } catch (error) { + throw new Error(`Invalid JSONL input in ${file} at line ${lineNumber}: ${error.message}`); + } + }); } function parseCsvEvents(raw, file) { @@ -366,7 +385,7 @@ function mapToBullets(map) { function redact(value) { return String(value ?? '') .replace(ACCESS_KEY_PATTERN, (match) => `AKIA…${match.slice(-4)}`) - .replace(SECRET_SHAPED_PATTERN, '[REDACTED]'); + .replace(SECRET_SHAPED_PATTERN, (match) => (/[0-9/+=]/.test(match) ? '[REDACTED]' : match)); } function csvEscape(value) { diff --git a/tools/check-cloudtrail-s3-admin-audit.mjs b/tools/check-cloudtrail-s3-admin-audit.mjs index 47229aac..fb047dd5 100644 --- a/tools/check-cloudtrail-s3-admin-audit.mjs +++ b/tools/check-cloudtrail-s3-admin-audit.mjs @@ -12,26 +12,41 @@ const script = join(root, 'tools/audit-cloudtrail-s3-admin-events.mjs'); const dir = mkdtempSync(join(tmpdir(), 'cloudtrail-s3-admin-audit-')); const jsonInput = join(dir, 'cloudtrail.json'); const csvInput = join(dir, 'cloudtrail.csv'); +const invalidJsonInput = join(dir, 'invalid-cloudtrail.json'); +const invalidJsonlInput = join(dir, 'invalid-cloudtrail.jsonl'); + +const riskyEvents = [ + 'PutBucketLifecycleConfiguration', + 'DeleteBucketCors', + 'PutBucketCors', + 'DeleteBucketPolicy', + 'PutBucketPolicy', + 'PutBucketReplicationConfiguration', + 'PutBucketLogging', + 'PutPublicAccessBlock', + 'DeletePublicAccessBlock', +]; writeFileSync( jsonInput, JSON.stringify( { Records: [ - { - eventTime: '2026-04-18T09:00:00Z', - eventName: 'PutBucketLifecycleConfiguration', + ...riskyEvents.map((eventName, index) => ({ + eventTime: `2026-04-18T09:${String(index).padStart(2, '0')}:00Z`, + eventName, userIdentity: { - userName: 'railway-s3-access', - accessKeyId: 'AKIA222222222222WCUM', + userName: index % 2 === 0 ? 'railway-s3-access' : 'n8n-s3-access', + accessKeyId: `AKIA222222222222${String(index).padStart(4, '0')}`, }, - sourceIPAddress: '203.0.113.10', + sourceIPAddress: `203.0.113.${index + 10}`, userAgent: 'aws-cli/2.15.0', awsRegion: 'eu-west-2', requestParameters: { - bucketName: 'studenthub-uploads', + bucketName: index === 3 ? 'wallet-uploads' : 'studenthub-uploads', }, - }, + ...(index === 3 ? {errorCode: 'AccessDenied'} : {}), + })), { eventTime: '2026-04-18T09:05:00Z', eventName: 'PutObject', @@ -43,21 +58,6 @@ writeFileSync( bucketName: 'studenthub-uploads', }, }, - { - eventTime: '2026-04-18T10:00:00Z', - eventName: 'DeleteBucketPolicy', - userIdentity: { - userName: 'mediaconverter', - accessKeyId: 'AKIA222222222222OFLT', - }, - sourceIPAddress: '198.51.100.9', - userAgent: 'console.amazonaws.com', - awsRegion: 'eu-west-2', - requestParameters: { - bucketName: 'wallet-uploads', - }, - errorCode: 'AccessDenied', - }, ], }, null, @@ -73,26 +73,37 @@ writeFileSync( ].join('\n'), ); +writeFileSync(invalidJsonInput, JSON.stringify({Records: {eventName: 'PutBucketPolicy'}})); +writeFileSync(invalidJsonlInput, '{"eventName": "PutBucketPolicy"}\n{"eventName":'); + const markdown = execFileSync(process.execPath, [script, '--input', jsonInput, '--input', csvInput], {encoding: 'utf8'}); -assert.match(markdown, /Matching bucket-admin events: 3/); -assert.match(markdown, /Critical\/high events: 1/); -assert.match(markdown, /PutBucketLifecycleConfiguration: 1/); -assert.match(markdown, /DeleteBucketPolicy: 1/); -assert.match(markdown, /PutBucketCors: 1/); -assert.match(markdown, /railway-s3-access: 1/); -assert.match(markdown, /mediaconverter: 1/); -assert.match(markdown, /n8n-s3-access: 1/); +assert.match(markdown, /Matching bucket-admin events: 10/); +assert.match(markdown, /Critical\/high events: 4/); +for (const eventName of riskyEvents) { + const expectedCount = eventName === 'PutBucketCors' ? 2 : 1; + assert.match(markdown, new RegExp(`${eventName}: ${expectedCount}`)); +} +assert.match(markdown, /railway-s3-access: 5/); +assert.match(markdown, /n8n-s3-access: 5/); assert.match(markdown, /wallet-uploads/); assert.match(markdown, /watched service user; non-StudentHub bucket; failed with AccessDenied/); -assert.doesNotMatch(markdown, /AKIA222222222222WCUM/); -assert.match(markdown, /\|2026-04-18T09:00:00Z\|critical\|PutBucketLifecycleConfiguration\|railway-s3-access\|WCUM\|/); +assert.doesNotMatch(markdown, /AKIA2222222222220000/); +assert.match(markdown, /\|2026-04-18T09:00:00Z\|critical\|PutBucketLifecycleConfiguration\|railway-s3-access\|0000\|/); const csv = execFileSync(process.execPath, [script, '--input', jsonInput, '--format', 'csv'], {encoding: 'utf8'}); assert.match(csv, /event_time,severity,event_name,user_name,access_key_suffix/); -assert.match(csv, /2026-04-18T10:00:00Z,low,DeleteBucketPolicy,mediaconverter,OFLT/); -assert.doesNotMatch(csv, /AKIA222222222222OFLT/); +assert.match(csv, /2026-04-18T09:03:00Z,low,DeleteBucketPolicy,n8n-s3-access,0003/); +assert.doesNotMatch(csv, /AKIA2222222222220003/); assert.doesNotMatch(csv, /PutObject/); +const csvFromCsvInput = execFileSync(process.execPath, [script, '--input', csvInput, '--format', 'csv'], {encoding: 'utf8'}); + +assert.match(csvFromCsvInput, /2026-04-19T08:00:00Z,medium,PutBucketCors,n8n-s3-access,NANA/); +assert.doesNotMatch(csvFromCsvInput, /AKIA222222222222NANA/); + +assert.throws(() => execFileSync(process.execPath, [script, '--input', invalidJsonInput], {encoding: 'utf8', stdio: 'pipe'}), /Records array/); +assert.throws(() => execFileSync(process.execPath, [script, '--input', invalidJsonlInput], {encoding: 'utf8', stdio: 'pipe'}), /Invalid JSONL.*line 2/); + console.log('CloudTrail S3 admin audit helper check passed.');