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 |
| 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.
To suppress a finding intentionally, document the rationale in this file and use Security Hub's Workflow Status → SUPPRESSED with a note. Don't silently dismiss.
Currently suppressed findings
None.
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.
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.
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.