Skip to main content

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.

LayerToolWhat it catches
Audit trailCloudTrail (multi-region, log file validation, CMK encryption)Who did what, when, from where. Required for post-incident investigation.
Config driftSecurity Hub CSPM (FSBP + CIS v3)Public buckets, unencrypted RDS, IAM without MFA, missing CloudTrail, open security groups
Threat detection — networkGuardDutyCompromised credentials, crypto mining, anomalous IAM behavior, suspicious EC2 network traffic
Threat detection — runtimeGuardDuty Runtime Monitoring (ECS Fargate)Process exec, file access, network calls inside ECS task containers
CVE scanningAmazon Inspector v2OS-level CVEs on EC2, container image CVEs in ECR, Lambda runtime CVEs
External exposureIAM Access AnalyzerResources (S3, IAM, KMS, etc.) shared publicly or cross-account
Network visibilityVPC Flow Logs (to S3)5-tuple connection metadata. Provides context for GuardDuty alerts.
Account guardrail — publicS3 Block Public Access (account-level)Hard ceiling above per-bucket BPA — cannot be undone at bucket level
Account guardrail — encryptEBS encryption by default (account-level)Every new EBS volume — RDS storage, EC2 disks — encrypted at rest automatically
Web app firewallAWS WAFv2 on the public ALBOWASP-style attacks (CRS) + curated known-bad signatures + per-IP rate limit on /api/auth/*
AlertingEventBridge → SNS → email for CRITICAL Security Hub findingsFindings emailed to SECURITY_ALERT_EMAIL so the stack is actually used, not just configured
Secrets in gitgitleaks (CI)AWS keys, Discord tokens, Blizzard client secrets accidentally committed
Dependency CVEspnpm 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

ServiceConsole URL
Security Hub summaryhttps://us-east-2.console.aws.amazon.com/securityhub/home?region=us-east-2#/summary
GuardDuty findingshttps://us-east-2.console.aws.amazon.com/guardduty/home?region=us-east-2
Inspector findingshttps://us-east-2.console.aws.amazon.com/inspector/v2/home?region=us-east-2#/findings/all
Access Analyzerhttps://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:

  1. Critical / High — fix this sprint. Open a Notion task under the Security & Infrastructure epic.
  2. Medium — fix when convenient. Batch into a single quarterly triage task.
  3. 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)"]
BucketCDN subdomain (prod)Dev subdomainContents
sanctum-appmedia.sanctumhq.ggmedia-dev.sanctumhq.ggUser uploads — avatars, guild logos, event images, post images
sanctum-gdlgdl.sanctumhq.gggdl-dev.sanctumhq.ggGame Data Library — journal/instance/decor icons and images
sanctum-gclgcl.sanctumhq.gggcl-dev.sanctumhq.ggGame 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:

  • .npmrc sets auto-install-peers=false. Optional peers must be declared explicitly by the workspace that needs them (see @testing-library/dom in apps/web).
  • package.json lists pnpm.ignoredOptionalDependencies for 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):

ServiceEstimate
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.ts for 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 in DataStack). Passes SecretsManager.1 and .4.
  • Encryption key (sanctum/production/encryption)ENCRYPTION_KEY only. 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:

  1. Rotate the credential immediately (do not wait for the PR to be merged or closed)
  2. Rewrite the commit history (git filter-repo or BFG)
  3. 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:

  1. Check the alert mailbox — there will be an SNS confirmation email. Click the link. Without confirmation, no alerts are delivered.
  2. 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.
  3. 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-audit or any other multi-region trail already exists in this account, AWS allows the new trail but you'll be paying for both. Audit aws cloudtrail list-trails before 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:

  1. Walk through all open Medium findings in Security Hub — fix or suppress with rationale
  2. Confirm the gitleaks and pnpm audit workflows are still passing on master
  3. Run pnpm cdk diff SanctumSecurity to confirm no drift between deployed state and source
  4. 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.