Security Posture
How Sanctum protects user data, and what tools we use to verify we're actually doing it.
This is a living document. When the security stack changes, this file changes.
What we're protecting
The threat model is "a passion project holding real people's data":
- Battle.net OAuth refresh tokens (encrypted in Postgres) — can access a user's WoW profile
- Discord IDs — link account to real identity
- Friendship graph — who knows whom
- Application content — guild applications often include real names, ages, time zones
We are not protecting payment data, health records, or anything subject to regulatory frameworks (PCI, HIPAA, GDPR Article 9). That's why we don't run NIST or PCI Security Hub standards — they would only generate noise.
Layered defense
Audit, config drift, threat detection, vulnerability scanning, exposure, alerting, and secrets exposure are distinct problems. Each gets its own tool.
| Layer | Tool | What it catches |
|---|---|---|
| Audit trail | CloudTrail (multi-region, log file validation, CMK encryption) | Who did what, when, from where. Required for post-incident investigation. |
| Config drift | Security Hub CSPM (FSBP + CIS v3) | Public buckets, unencrypted RDS, IAM without MFA, missing CloudTrail, open security groups |
| Threat detection — network | GuardDuty | Compromised credentials, crypto mining, anomalous IAM behavior, suspicious EC2 network traffic |
| Threat detection — runtime | GuardDuty Runtime Monitoring (ECS Fargate) | Process exec, file access, network calls inside ECS task containers |
| CVE scanning | Amazon Inspector v2 | OS-level CVEs on EC2, container image CVEs in ECR, Lambda runtime CVEs |
| External exposure | IAM Access Analyzer | Resources (S3, IAM, KMS, etc.) shared publicly or cross-account |
| OS patching | SSM Patch Manager (weekly State Manager association) | Kernel and OS package CVEs on the bastion — the only EC2 host we own |
| Network visibility | VPC Flow Logs (to S3) | 5-tuple connection metadata. Provides context for GuardDuty alerts. |
| Account guardrail — public | S3 Block Public Access (account-level) | Hard ceiling above per-bucket BPA — cannot be undone at bucket level |
| Account guardrail — encrypt | EBS encryption by default (account-level) | Every new EBS volume — RDS storage, EC2 disks — encrypted at rest automatically |
| Web app firewall | AWS WAFv2 on the public ALB | OWASP-style attacks (CRS) + curated known-bad signatures + per-IP rate limit on /api/auth/* |
| Alerting | EventBridge → SNS → email for CRITICAL Security Hub findings | Findings emailed to SECURITY_ALERT_EMAIL so the stack is actually used, not just configured |
| Secrets in git | gitleaks (CI) | AWS keys, Discord tokens, Blizzard client secrets accidentally committed |
| Dependency CVEs | pnpm audit (CI) | Known vulnerabilities in production npm packages |
All AWS-side services live in infra/lib/security-stack.ts. All CI checks live in .github/workflows/security.yml.
Why Security Hub + FSBP + CIS specifically
Security Hub CSPM is AWS's native cloud security posture management. We enable two standards:
- AWS Foundational Security Best Practices (FSBP) — broad coverage across every AWS service we touch
- CIS AWS Foundations Benchmark v3.0.0 — industry-standard hardening, stricter on IAM and CloudTrail
They overlap heavily. We turn on consolidated control findings (ControlFindingGenerator: SECURITY_CONTROL) so a single check generates a single finding even when it applies to both standards.
We do not enable:
- NIST SP 800-53 / 800-171 — US federal frameworks, not applicable
- PCI DSS — we don't process payments
- Service-managed Control Tower — single-account deployment
- AWS Resource Tagging — low signal for a single-account hobby project
Where findings live
| Service | Console URL |
|---|---|
| Security Hub summary | https://us-east-2.console.aws.amazon.com/securityhub/home?region=us-east-2#/summary |
| GuardDuty findings | https://us-east-2.console.aws.amazon.com/guardduty/home?region=us-east-2 |
| Inspector findings | https://us-east-2.console.aws.amazon.com/inspector/v2/home?region=us-east-2#/findings/all |
| Access Analyzer | https://us-east-2.console.aws.amazon.com/access-analyzer/home?region=us-east-2#/analyzer |
GuardDuty, Inspector, and Access Analyzer findings all flow into Security Hub automatically, so the Security Hub summary is the single pane of glass.
Alerting
CRITICAL Security Hub findings with workflow status NEW trigger an EventBridge rule that publishes to the sanctum-security-alerts SNS topic. The topic has an email subscription to SECURITY_ALERT_EMAIL (set in infra/lib/config.ts).
On first deploy, AWS sends a subscription confirmation to that mailbox. Click the confirmation link or alerts will never arrive. To verify the subscription:
aws sns list-subscriptions-by-topic \
--topic-arn arn:aws:sns:us-east-2:357457230871:sanctum-security-alerts \
--region us-east-2
Status should be a confirmed subscription ARN, not PendingConfirmation.
To change recipients: edit SECURITY_ALERT_EMAIL in config.ts and redeploy. The old subscription remains until manually removed in the SNS console.
We deliberately do not page on High/Medium/Low — they're not "wake me up at 3am" severity. Triage them on the next regular review.
Triage workflow
When a new finding appears:
- Critical / High — fix this sprint. Open a Notion task under the Security & Infrastructure epic.
- Medium — fix when convenient. Batch into a single quarterly triage task.
- Low / Informational — review quarterly. Suppress if it's a known accepted risk (document why).
Fixes belong in CDK. We do not click-ops security remediations — they have to survive a stack redeploy.
There are two distinct suppression mechanisms, and picking the wrong one hides more than you intended:
- Disable the control (
SUPPRESSED_CONTROLSinsecurity-stack.ts) when the check does not apply to this workload at all — no EKS, no SES, no Macie. The check stops running everywhere. - Suppress per resource (
SUPPRESSION_RULESinsecurity-stack.ts, emitted asAWS::SecurityHub::AutomationRule) when the control is legitimate and we've accepted the risk on one specific resource. The check stays live, so a new offending resource still fails it.
Both live in CDK. Never click-op a suppression in the console — it is invisible to cdk diff and disappears on an account rebuild. (Three automation rules were created by hand on 2026-06-27 and had to be re-homed into CDK in July; don't repeat that.)
Automation rules re-evaluate on every finding import, which is why they beat a one-time console suppression: a control that only starts failing later — SecretsManager.4 fails once the 90-day rotation window lapses — is suppressed the moment it first appears.
Currently suppressed findings
Per-resource suppressions, all defined in SUPPRESSION_RULES:
| Rule | Controls | Resource | Why |
|---|---|---|---|
sanctum-sm-rotation-suppress-encryption | SecretsManager.1, .4 | sanctum/production/encryption | AES-256-GCM data-at-rest key; auto-rotation orphans every stored ciphertext without an envelope re-encryption migration |
sanctum-sm-rotation-suppress-app | SecretsManager.1, .4 | sanctum/production/app | Session signing secrets + VAPID keypair; ECS injects as env vars with no hot-reload, so rotation needs a coordinated redeploy |
sanctum-sm-rotation-suppress-third-party | SecretsManager.1, .4 | sanctum/production/third-party | Vendor OAuth/API creds, rotated in each vendor's console |
sanctum-iam22-breakglass-suppress | IAM.22 | john-admin | Break-glass admin. MFA enforced, zero access keys, so the only credential IAM.22 sees is a deliberately idle console password. Deleting it would leave no recovery path if cli-user is lost |
Additionally, S3.9 (server access logging) and S3.13 (lifecycle config) are suppressed on the CloudTrail and Flow Logs buckets: they are log targets written only by AWS service principals, and access-logging a log bucket is a self-referential loop with no investigative value.
Public assets via CloudFront + OAC
User-facing media is served exclusively through CloudFront. Each S3 bucket is fully private (Block Public Access = BLOCK_ALL) and readable only by its paired CloudFront distribution, authenticated via Origin Access Control (OAC) and the AWS:SourceArn condition on the bucket policy.
flowchart LR
Browser -->|HTTPS| Cdn["*.sanctumhq.gg<br/>CloudFront"]
Cdn -->|"OAC SigV4"| Bucket["sanctum-*<br/>(private, BPA BLOCK_ALL)"]
| Bucket | CDN subdomain (prod) | Dev subdomain | Contents |
|---|---|---|---|
sanctum-app | media.sanctumhq.gg | media-dev.sanctumhq.gg | User uploads — avatars, guild logos, event images, post images |
sanctum-gdl | gdl.sanctumhq.gg | gdl-dev.sanctumhq.gg | Game Data Library — journal/instance/decor icons and images |
sanctum-gcl | gcl.sanctumhq.gg | gcl-dev.sanctumhq.gg | Game Character Library — class/spec/item icons, character renders |
sanctum-cloudtrail | (internal only) | (internal only) | CloudTrail audit logs |
sanctum-flow-logs | (internal only) | (internal only) | VPC Flow Logs |
cdk-* | (internal only) | (internal only) | CDK bootstrap |
Single ACM cert in us-east-1 covers all six SAN subdomains. The CDN stack (infra/lib/cdn-stack.ts) adopts the existing buckets by name — buckets are never recreated. It creates:
- 6 CloudFront distributions (one per bucket, clean data-domain boundary)
- 6
Route53.ARecords aliased to those distributions - 6 OAC-only bucket policies plus
BlockPublicAccess(BLOCK_ALL)via a CFN custom resource
URLs are minted into the DB by apps/api/src/storage/storage.service.ts and services/gdl-node/src/shared/storage/s3.service.ts using the AWS_S3_*_CDN_BASE env vars set in infra/lib/service-stack.ts. The previous AWS_S3_PUBLIC_URL env var was removed.
For backward compatibility during the cutover, isOwnedUrl/extractKey in both services still recognize the legacy https://<bucket>.s3.<region>.amazonaws.com/... form. The one-off backfill script apps/api/scripts/backfill-s3-to-cdn.js (run via aws ecs run-task in .github/workflows/release.yml) rewrites every legacy URL in the API DB, GDL catalog DB, and GDL characters DB to the corresponding CDN URL.
Dropped unused transitive dependencies
To prevent unused transitive packages from generating CVE noise, we configure pnpm to skip installing optional peer dependencies the platform does not actually use:
.npmrcsetsauto-install-peers=false. Optional peers must be declared explicitly by the workspace that needs them (see@testing-library/dominapps/web).package.jsonlistspnpm.ignoredOptionalDependenciesfor any optional deps we want to skip even when something tries to pull them in.
This eliminated @grpc/grpc-js, @grpc/proto-loader, and the entire protobufjs 7.x tree (CVE-2026-41242) — they were pulled in as optional peers of @nestjs/microservices and @nestjs/terminus even though we use the Redis transport, not gRPC. If we ever add gRPC, add @grpc/grpc-js + @grpc/proto-loader to the affected workspace's dependencies explicitly.
Costs
Approximate monthly cost in us-east-2 for our footprint (1 EC2, 1 RDS, ~6 S3 buckets, ECS Fargate Spot, a handful of Lambda):
| Service | Estimate |
|---|---|
| Security Hub (checks + findings) | $5–10 |
| AWS Config (recording + rule eval) | $10–20 |
| GuardDuty (network) | $3–5 |
| GuardDuty Runtime Monitoring (ECS) | $1–3 (scales with task vCPU-hours) |
| Inspector v2 | $5–15 (variable on ECR push frequency) |
| CloudTrail (1 trail + S3 + KMS) | $2–5 |
| VPC Flow Logs (to S3) | $1–3 |
| SNS + EventBridge | $0 (free at this volume) |
| Access Analyzer | $0 (free) |
| S3 Block Public Access | $0 (free) |
| EBS encryption by default | $0 (free) |
| Total | ~$30–65 |
All in one region. The biggest cost driver is Config recording all global resource types; if costs balloon, the first thing to scope down is recordingGroup.includeGlobalResourceTypes in SecurityStack.
Gaps Security Hub does not cover
Things CSPM is structurally blind to. These need their own treatment:
Application-layer authorization
CASL rules in apps/api/src/auth/abilities/ability.factory.ts are not auditable by AWS. We use:
scripts/check-authorization-drift.js(pre-commit + CI) — flags inline ownership checks that should be CASL rules- Unit tests in
abilities.guard.spec.tsfor every user-owned subject - See Authorization for the full pattern
Secret encryption at rest in Postgres
Battle.net refresh tokens are encrypted in the database using a key from AWS Secrets Manager. CSPM verifies RDS is encrypted at rest (volume-level); it does not verify the column-level encryption. That's a property of our application code.
Application secrets are split by ownership and rotatability (the old monolithic sanctum/production blob was retired). Each split secret carries a documented SecretsManager.4 posture because none of them can be safely auto-rotated in the ECS env-var model (ECS injects secrets at task start with no hot-reload):
- RDS master credentials (
sanctum/production/rds) — automatic via Secrets Manager rotation (30 days, configured inDataStack). PassesSecretsManager.1and.4. - Encryption key (
sanctum/production/encryption) —ENCRYPTION_KEYonly. Auto-rotation would orphan every encrypted token at rest without an envelope/key-versioning re-encryption migration. Manual rotation only. - App secrets (
sanctum/production/app) —JWT_SECRET,SESSION_SECRET,BETTER_AUTH_SECRET, and the web-push VAPID keypair. Rotating these invalidates active sessions / push subscriptions, so it's a deliberate manual op paired with a service redeploy. - Third-party creds (
sanctum/production/third-party) — Discord / Battle.net / Warcraft Logs / Resend. Rotated in each vendor's developer console, never via a Lambda.
Both rotation controls on the three non-RDS secrets are suppressed with these documented reasons:
SecretsManager.1("automatic rotation enabled") fails immediately for an unrotated secret — suppressed directly per finding.SecretsManager.4("rotated within N days") passes on creation and only fails once the 90-day window lapses without rotation.
Security Hub automation rules (sanctum-sm-rotation-suppress-{encryption,app,third-party}) match both controls per secret ARN and auto-suppress with the reason above, so the posture survives finding regeneration and the future SecretsManager.4 failure. They are declared in SUPPRESSION_RULES in infra/lib/security-stack.ts and deployed as AWS::SecurityHub::AutomationRule resources. The ARN match is a PREFIX, not EQUALS, because Secrets Manager appends a random 6-character suffix that changes if a secret is ever recreated.
Bastion OS patching
The bastion (SanctumBastion, t4g.nano) is the only EC2 instance we own, and it is the single largest source of Inspector findings — it produced 112 of the 117 open findings in the July 2026 review, and two similar clusters before that.
AL2023 pins itself to a release version ("deterministic upgrades"), so a plain dnf update does not advance past the AMI's baked-in releasever. Patch Manager is the fix: on AL2023 it deliberately resolves against the latest repository versions regardless of the pinned system config. That is why patching runs through AWS-RunPatchBaseline rather than a shell command.
infra/lib/bastion-stack.ts tags the instance PatchGroup=sanctum-bastion and registers a State Manager association that runs AWS-RunPatchBaseline with Operation=Install and RebootOption=RebootIfNeeded every Sunday at 09:00 UTC. Approval rules come from the AWS-managed AWS-AmazonLinux2023DefaultPatchBaseline (already the account default, auto-approves security updates at 0-day delay), so there is no custom baseline to maintain.
A State Manager association rather than a maintenance window: a window needs CfnMaintenanceWindow + Target + Task plus its own IAM service role (the AWSServiceRoleForAmazonSSM SLR does not exist in this account) and buys task ordering and cutoff semantics. With one stateless host and one task there is nothing to order.
The bastion carries a 1 GiB swapfile for this reason. t4g.nano is 512 MiB. During the July 2026 catch-up patch (~100 packages including a kernel) the dnf transaction exhausted memory and the OOM killer took amazon-ssm-agent with it: EC2 status checks kept passing while SSM reported ConnectionLost, so the host was reachable but unmanageable and the patch never finished. A swapfile makes a heavy patch week slow instead of fatal. Swapping is acceptable specifically because the bastion idles except during interactive tunnels. If swap ever proves insufficient, the escalation is t4g.micro (1 GiB, roughly +$3/mo), not removing the association.
Recovery from that state is instance replacement, not repair — cdk deploy SanctumBastion re-resolves the AL2023 AMI parameter and CFN replaces the instance with a current, fully-patched image in about a minute. Note that cdk diff will not show the AMI change beforehand: the template references the SSM parameter by name and CFN only resolves it during the actual update.
To patch out of band:
aws ssm send-command \
--instance-ids <BASTION_ID> \
--document-name AWS-RunPatchBaseline \
--parameters 'Operation=Install,RebootOption=RebootIfNeeded' \
--region us-east-2
Expect 8–12 minutes: a t4g.nano has 0.5 GiB RAM and a full kernel upgrade is slow. Inspector re-scans within ~24h of the reboot.
Dependency CVEs
CI runs pnpm audit --prod --audit-level high on every PR and push to master. Dev dependency vulnerabilities are intentionally ignored — they don't ship to production and their CVE volume drowns out real signal.
To pin a transitive dependency to a patched version, add an entry under overrides: in pnpm-workspace.yaml and document the GHSA/CVE in the commit message. (pnpm 11 no longer reads the pnpm.overrides field in package.json — overrides moved to the workspace yaml. See pnpm settings.)
Current overrides patching CVE noise: uuid, file-type, @hono/node-server, js-yaml, yaml@^1. Drop them when the upstream consumers refresh their pins.
Secrets in git
CI runs gitleaks on every PR with full history. If gitleaks fires on a real leak:
- Rotate the credential immediately (do not wait for the PR to be merged or closed)
- Rewrite the commit history (
git filter-repoor BFG) - Force-push with a written-out justification
Rotating without rewriting history is not enough — the credential is in the GitHub clone cache forever.
CI/CD pipeline trust
Anyone with push access to master can deploy. We do not require signed commits or branch protection rules with mandatory reviews. For a solo project this is acceptable; add reviewers if the contributor count grows beyond 2.
Operational runbook
Initial deploy
Before deploying, confirm SECURITY_ALERT_EMAIL in infra/lib/config.ts points to a mailbox you actually check.
cd infra
pnpm build
pnpm cdk diff SanctumSecurity
pnpm cdk deploy SanctumSecurity
After deploy:
- Check the alert mailbox — there will be an SNS confirmation email. Click the link. Without confirmation, no alerts are delivered.
- Wait ~24 hours for Config to record resources and Security Hub to populate findings. Initial finding count will be high (expect 20–50). Don't panic — most are easy fixes.
- The first CRITICAL alert email may arrive immediately if findings already exist in resources the stack just started watching.
Stack updates
cd infra
pnpm cdk diff SanctumSecurity
pnpm cdk deploy SanctumSecurity
Common gotchas
- Config recorder already exists. If you ever enabled Config via the console, CDK deploy will fail because only one recorder per region is allowed. Delete the console-created recorder before deploying.
- CloudTrail already exists. Same issue — if a trail named
sanctum-auditor any other multi-region trail already exists in this account, AWS allows the new trail but you'll be paying for both. Auditaws cloudtrail list-trailsbefore deploying and delete duplicates. - SNS subscription not confirmed. The email subscription starts as
PendingConfirmation. Alerts are silently dropped until you click the link in the confirmation email. - Inspector enable propagation. Inspector can take 5–15 minutes to start scanning ECR images after enable. Findings for existing images appear on the next push or scan cycle.
- GuardDuty findings are not retroactive. GuardDuty only sees activity from when the detector is enabled forward.
- EBS encryption by default applies only to new volumes. Existing unencrypted volumes stay unencrypted. To remediate, snapshot the volume, copy the snapshot encrypted, and restore.
Monthly review
Once a month, sweep Security Hub. The goal is to keep the open-finding count near zero so a genuinely new finding is obvious rather than buried.
# Open findings by severity
for sev in CRITICAL HIGH MEDIUM LOW; do
printf '%s: ' "$sev"
aws securityhub get-findings --region us-east-2 \
--filters '{"RecordState":[{"Value":"ACTIVE","Comparison":"EQUALS"}],"WorkflowStatus":[{"Value":"NEW","Comparison":"EQUALS"}],"SeverityLabel":[{"Value":"'"$sev"'","Comparison":"EQUALS"}]}' \
--query 'length(Findings)' --output text
done
# Which resource is generating them (bastion is the usual culprit)
aws securityhub get-findings --region us-east-2 \
--filters '{"RecordState":[{"Value":"ACTIVE","Comparison":"EQUALS"}],"WorkflowStatus":[{"Value":"NEW","Comparison":"EQUALS"}]}' \
--max-items 500 \
--query 'Findings[].{Sev:Severity.Label,Control:Compliance.SecurityControlId,Product:ProductName,Resource:Resources[0].Id}' \
--output json
# Standards health — both should return zero failing enabled controls
aws securityhub describe-standards-controls \
--standards-subscription-arn "arn:aws:securityhub:us-east-2:357457230871:subscription/aws-foundational-security-best-practices/v/1.0.0" \
--region us-east-2 \
--query 'length(Controls[?ControlStatus==`ENABLED` && ComplianceStatus==`FAILED`])' --output text
# Unarchived GuardDuty findings
aws guardduty list-findings --detector-id "$(aws guardduty list-detectors --region us-east-2 --query 'DetectorIds[0]' --output text)" \
--region us-east-2 --finding-criteria '{"Criterion":{"service.archived":{"Eq":["false"]}}}' \
--query 'length(FindingIds)' --output text
Triage rules: a large Inspector cluster on the bastion means the weekly patch association failed — check aws ssm describe-instance-patch-states before assuming it's new CVEs. Anything else gets fixed in CDK or suppressed per resource with a written reason.
Quarterly review
Once a quarter:
- Walk through all open Medium findings in Security Hub — fix or suppress with rationale
- Confirm the gitleaks and pnpm audit workflows are still passing on master
- Run
pnpm cdk diff SanctumSecurityto confirm no drift between deployed state and source - Update the cost table in this file if it has shifted materially
Out of scope (deliberately)
- Multi-region replication — single-account, single-region by design
- Multi-account AWS Organization — costs more than the value for a hobby project
- Formal audit reports (Audit Manager) — we are not audited
- DDoS protection beyond AWS Shield Standard (free, auto-enabled)
- RDS Multi-AZ — single-AZ accepted as a cost/availability trade-off. See the "ADR: Accept single-AZ RDS Postgres" Notion task for the decision record. Revisit when guild scale or AWS spend justifies the ~$25/month delta.