From 2e00d4dcf3a68510b5e92ef50bb174251acce696 Mon Sep 17 00:00:00 2001 From: Chaos-among-us <131064735+Chaos-among-us@users.noreply.github.com> Date: Thu, 14 May 2026 11:55:08 -0600 Subject: [PATCH] Add S3 bucket admin guardrails --- README.md | 1 + ...3-bucket-change-alerts.cloudformation.json | 110 ++++++++++++++++ docs/security/aws-s3-guardrails.md | 36 ++++++ ...-s3-service-user-permissions-boundary.json | 60 +++++++++ tools/check-aws-s3-guardrails.mjs | 118 ++++++++++++++++++ 5 files changed, 325 insertions(+) create mode 100644 docs/security/aws-s3-bucket-change-alerts.cloudformation.json create mode 100644 docs/security/aws-s3-guardrails.md create mode 100644 docs/security/aws-s3-service-user-permissions-boundary.json create mode 100644 tools/check-aws-s3-guardrails.mjs diff --git a/README.md b/README.md index eb0a5ca6..47e9a9dc 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 S3 Guardrails](docs/security/aws-s3-guardrails.md) - IAM boundary and alert templates for S3 service users ## Development diff --git a/docs/security/aws-s3-bucket-change-alerts.cloudformation.json b/docs/security/aws-s3-bucket-change-alerts.cloudformation.json new file mode 100644 index 00000000..4688bbb6 --- /dev/null +++ b/docs/security/aws-s3-bucket-change-alerts.cloudformation.json @@ -0,0 +1,110 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Alert on StudentHub S3 bucket-admin API calls captured by CloudTrail.", + "Parameters": { + "AlertSnsTopicArn": { + "Type": "String", + "Description": "SNS topic ARN for the maintainer-owned technical alert channel." + } + }, + "Resources": { + "StudentHubS3BucketAdminChangeRule": { + "Type": "AWS::Events::Rule", + "Properties": { + "Description": "Alert when service users or automation change bucket-level S3 controls.", + "EventPattern": { + "source": [ + "aws.s3" + ], + "detail-type": [ + "AWS API Call via CloudTrail" + ], + "detail": { + "eventSource": [ + "s3.amazonaws.com" + ], + "eventName": [ + "DeleteBucketCors", + "DeleteBucketPolicy", + "DeleteBucketReplication", + "DeleteBucketWebsite", + "DeletePublicAccessBlock", + "PutBucketAcl", + "PutBucketCors", + "PutBucketLifecycle", + "PutBucketLifecycleConfiguration", + "PutBucketLogging", + "PutBucketNotification", + "PutBucketPolicy", + "PutBucketReplication", + "PutBucketTagging", + "PutBucketVersioning", + "PutBucketWebsite", + "PutPublicAccessBlock" + ], + "requestParameters": { + "bucketName": [ + { + "prefix": "studenthub-" + } + ] + } + } + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Ref": "AlertSnsTopicArn" + }, + "Id": "StudentHubS3BucketAdminAlertTopic" + } + ] + } + }, + "StudentHubS3BucketAdminAlertTopicPolicy": { + "Type": "AWS::SNS::TopicPolicy", + "Properties": { + "Topics": [ + { + "Ref": "AlertSnsTopicArn" + } + ], + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowEventBridgeBucketAdminAlerts", + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com" + }, + "Action": "sns:Publish", + "Resource": { + "Ref": "AlertSnsTopicArn" + }, + "Condition": { + "ArnEquals": { + "aws:SourceArn": { + "Fn::GetAtt": [ + "StudentHubS3BucketAdminChangeRule", + "Arn" + ] + } + } + } + } + ] + } + } + } + }, + "Outputs": { + "RuleName": { + "Description": "EventBridge rule name for S3 bucket-admin change alerts.", + "Value": { + "Ref": "StudentHubS3BucketAdminChangeRule" + } + } + } +} diff --git a/docs/security/aws-s3-guardrails.md b/docs/security/aws-s3-guardrails.md new file mode 100644 index 00000000..fcf43278 --- /dev/null +++ b/docs/security/aws-s3-guardrails.md @@ -0,0 +1,36 @@ +# AWS S3 Service User Guardrails + +This repo keeps application service users away from bucket-admin powers. Use these templates when creating or rotating StudentHub S3 service users after the Civil ID incident. + +The templates are safe to review in GitHub because they contain no account IDs, ARNs for private users, access keys, secret keys, candidate data, or live bucket mutations. + +## Files + +- `docs/security/aws-s3-service-user-permissions-boundary.json` limits an S3 service user to object-level actions for the temp and permanent upload buckets, while explicitly denying bucket administration actions such as lifecycle, CORS, policy, logging, replication, public-access-block, website, ACL, and versioning changes. +- `docs/security/aws-s3-bucket-change-alerts.cloudformation.json` adds an EventBridge rule for CloudTrail S3 bucket-admin API calls and routes the events to an SNS topic provided by maintainers at deploy time. +- `tools/check-aws-s3-guardrails.mjs` validates that the templates keep the incident-sensitive denies, object-level allow list, and alert event coverage intact. + +## Deploy Sequence + +1. Provide the maintainer-owned `AlertSnsTopicArn` parameter outside this repo. +2. Create or update the SNS topic used for the technical alert channel. +3. Deploy `aws-s3-bucket-change-alerts.cloudformation.json` in the AWS account and region where CloudTrail management events are delivered. +4. Attach `aws-s3-service-user-permissions-boundary.json` as the permissions boundary for S3-only service users such as temp browser upload and permanent upload copy/delete users. +5. Keep identity policies narrower than the boundary. The boundary is a maximum allowed scope, not a replacement for per-user policies. +6. Tag each IAM user and key with `owner`, `service`, and `environment` so future CloudTrail events can be mapped to an accountable service. + +## Validation + +Run this before changing either template: + +```bash +node tools/check-aws-s3-guardrails.mjs +``` + +The check fails if a protected bucket-admin action drops out of the boundary or EventBridge pattern, if an allow statement grants `s3:*`, or if an AWS access key shaped value is committed into these templates. + +## Notes + +- This does not rotate, deactivate, or delete any live keys. +- This does not enable versioning or change bucket policies directly. +- This does not replace a CloudTrail investigation. It gives maintainers a repeatable guardrail for the root-cause class: application service users should not be able to change lifecycle, CORS, policies, logging, replication, public access settings, or other bucket-level controls. diff --git a/docs/security/aws-s3-service-user-permissions-boundary.json b/docs/security/aws-s3-service-user-permissions-boundary.json new file mode 100644 index 00000000..03aed069 --- /dev/null +++ b/docs/security/aws-s3-service-user-permissions-boundary.json @@ -0,0 +1,60 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowExpectedStudentHubBucketReadAccess", + "Effect": "Allow", + "Action": [ + "s3:GetBucketLocation", + "s3:ListBucket", + "s3:ListBucketMultipartUploads" + ], + "Resource": [ + "arn:aws:s3:::studenthub-uploads", + "arn:aws:s3:::studenthub-public-anyone-can-upload-24hr-expiry" + ] + }, + { + "Sid": "AllowExpectedStudentHubObjectAccess", + "Effect": "Allow", + "Action": [ + "s3:AbortMultipartUpload", + "s3:CopyObject", + "s3:DeleteObject", + "s3:GetObject", + "s3:ListMultipartUploadParts", + "s3:PutObject", + "s3:PutObjectAcl" + ], + "Resource": [ + "arn:aws:s3:::studenthub-uploads/*", + "arn:aws:s3:::studenthub-public-anyone-can-upload-24hr-expiry/*" + ] + }, + { + "Sid": "DenyBucketAdministrationForServiceUsers", + "Effect": "Deny", + "Action": [ + "s3:DeleteBucket", + "s3:DeleteBucketOwnershipControls", + "s3:DeleteBucketPolicy", + "s3:DeleteBucketWebsite", + "s3:PutBucketCORS", + "s3:PutBucketAcl", + "s3:PutBucketLogging", + "s3:PutBucketNotification", + "s3:PutBucketOwnershipControls", + "s3:PutBucketPolicy", + "s3:PutBucketPublicAccessBlock", + "s3:PutBucketVersioning", + "s3:PutBucketWebsite", + "s3:PutEncryptionConfiguration", + "s3:PutLifecycleConfiguration", + "s3:PutReplicationConfiguration" + ], + "Resource": [ + "arn:aws:s3:::studenthub-*" + ] + } + ] +} diff --git a/tools/check-aws-s3-guardrails.mjs b/tools/check-aws-s3-guardrails.mjs new file mode 100644 index 00000000..85a9c4c9 --- /dev/null +++ b/tools/check-aws-s3-guardrails.mjs @@ -0,0 +1,118 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const boundaryPath = resolve( + root, + "docs/security/aws-s3-service-user-permissions-boundary.json", +); +const alertsPath = resolve( + root, + "docs/security/aws-s3-bucket-change-alerts.cloudformation.json", +); + +const boundaryText = readFileSync(boundaryPath, "utf8"); +const alertsText = readFileSync(alertsPath, "utf8"); +const boundary = JSON.parse(boundaryText); +const alertsTemplate = JSON.parse(alertsText); + +const requiredDeniedActions = [ + "s3:DeleteBucketPolicy", + "s3:PutBucketCORS", + "s3:PutBucketLogging", + "s3:PutBucketPolicy", + "s3:PutBucketPublicAccessBlock", + "s3:PutBucketVersioning", + "s3:PutLifecycleConfiguration", + "s3:PutReplicationConfiguration", +]; + +const requiredAlertEvents = [ + "DeleteBucketCors", + "DeleteBucketPolicy", + "PutBucketCors", + "PutBucketLifecycle", + "PutBucketLifecycleConfiguration", + "PutBucketLogging", + "PutBucketPolicy", + "PutBucketReplication", + "PutBucketVersioning", + "PutPublicAccessBlock", +]; + +function asArray(value) { + return Array.isArray(value) ? value : [value]; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +const statements = asArray(boundary.Statement); +const denyActions = new Set( + statements + .filter((statement) => statement.Effect === "Deny") + .flatMap((statement) => asArray(statement.Action)), +); +const allowStatements = statements.filter((statement) => statement.Effect === "Allow"); +const allowActions = new Set(allowStatements.flatMap((statement) => asArray(statement.Action))); +const allowResources = new Set( + allowStatements.flatMap((statement) => asArray(statement.Resource)), +); + +for (const action of requiredDeniedActions) { + assert(denyActions.has(action), `Boundary is missing required deny: ${action}`); +} + +for (const action of allowActions) { + assert(action !== "s3:*", "Boundary allow list must not include s3:*"); + assert(!action.endsWith("*"), `Boundary allow action must be explicit: ${action}`); +} + +assert( + allowResources.has("arn:aws:s3:::studenthub-uploads") && + allowResources.has("arn:aws:s3:::studenthub-uploads/*"), + "Boundary must include permanent StudentHub upload bucket resources", +); +assert( + allowResources.has("arn:aws:s3:::studenthub-public-anyone-can-upload-24hr-expiry") && + allowResources.has("arn:aws:s3:::studenthub-public-anyone-can-upload-24hr-expiry/*"), + "Boundary must include temp StudentHub upload bucket resources", +); + +const rule = + alertsTemplate.Resources?.StudentHubS3BucketAdminChangeRule?.Properties?.EventPattern; +const eventNames = new Set(rule?.detail?.eventName ?? []); + +for (const eventName of requiredAlertEvents) { + assert(eventNames.has(eventName), `EventBridge rule is missing event: ${eventName}`); +} + +const alertTargets = + alertsTemplate.Resources?.StudentHubS3BucketAdminChangeRule?.Properties?.Targets ?? []; +assert(alertTargets.length > 0, "EventBridge rule must route alerts to a target"); +assert( + JSON.stringify(alertTargets).includes("AlertSnsTopicArn"), + "EventBridge target must use the maintainer-provided SNS topic parameter", +); + +const topicPolicy = + alertsTemplate.Resources?.StudentHubS3BucketAdminAlertTopicPolicy?.Properties?.PolicyDocument; +assert(topicPolicy, "Template must grant EventBridge permission to publish to the SNS topic"); +assert( + JSON.stringify(topicPolicy).includes("events.amazonaws.com") && + JSON.stringify(topicPolicy).includes("sns:Publish"), + "SNS topic policy must allow EventBridge to publish alerts", +); + +const combinedTemplates = `${boundaryText}\n${alertsText}`; +assert(!/AKIA[0-9A-Z]{16}/.test(combinedTemplates), "Templates must not contain AWS access keys"); +assert( + !/[0-9]{12}\.dkr\.ecr\./.test(combinedTemplates), + "Templates must not contain concrete AWS account IDs", +); + +console.log("AWS S3 guardrail templates passed validation.");