diff --git a/docs/cloudtrail-s3-admin-audit.md b/docs/cloudtrail-s3-admin-audit.md new file mode 100644 index 00000000..aff3a3ab --- /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 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. + +## 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..8d888ae3 --- /dev/null +++ b/tools/audit-cloudtrail-s3-admin-events.mjs @@ -0,0 +1,425 @@ +#!/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[2-7A-Z]{16}/g; +const SECRET_SHAPED_PATTERN = /(? 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) { + 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, (match) => (/[0-9/+=]/.test(match) ? '[REDACTED]' : match)); +} + +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..fb047dd5 --- /dev/null +++ b/tools/check-cloudtrail-s3-admin-audit.mjs @@ -0,0 +1,109 @@ +#!/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'; +import {fileURLToPath} from 'node:url'; + +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'); +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: [ + ...riskyEvents.map((eventName, index) => ({ + eventTime: `2026-04-18T09:${String(index).padStart(2, '0')}:00Z`, + eventName, + userIdentity: { + userName: index % 2 === 0 ? 'railway-s3-access' : 'n8n-s3-access', + accessKeyId: `AKIA222222222222${String(index).padStart(4, '0')}`, + }, + sourceIPAddress: `203.0.113.${index + 10}`, + userAgent: 'aws-cli/2.15.0', + awsRegion: 'eu-west-2', + requestParameters: { + bucketName: index === 3 ? 'wallet-uploads' : 'studenthub-uploads', + }, + ...(index === 3 ? {errorCode: 'AccessDenied'} : {}), + })), + { + eventTime: '2026-04-18T09:05:00Z', + eventName: 'PutObject', + userIdentity: { + userName: 'railway-s3-access', + accessKeyId: 'AKIA222222222222WCUM', + }, + requestParameters: { + bucketName: 'studenthub-uploads', + }, + }, + ], + }, + null, + 2, + ), +); + +writeFileSync( + csvInput, + [ + 'eventTime,eventName,userName,accessKeyId,sourceIPAddress,userAgent,bucketName,region,errorCode', + '2026-04-19T08:00:00Z,PutBucketCors,n8n-s3-access,AKIA222222222222NANA,192.0.2.22,n8n,studenthub-public-anyone-can-upload-24hr-expiry,eu-west-2,', + ].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: 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, /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-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.');