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
OS patchingSSM Patch Manager (weekly State Manager association)Kernel and OS package CVEs on the bastion — the only EC2 host we own
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.

There are two distinct suppression mechanisms, and picking the wrong one hides more than you intended:

  • Disable the control (SUPPRESSED_CONTROLS in security-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_RULES in security-stack.ts, emitted as AWS::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:

RuleControlsResourceWhy
sanctum-sm-rotation-suppress-encryptionSecretsManager.1, .4sanctum/production/encryptionAES-256-GCM data-at-rest key; auto-rotation orphans every stored ciphertext without an envelope re-encryption migration
sanctum-sm-rotation-suppress-appSecretsManager.1, .4sanctum/production/appSession signing secrets + VAPID keypair; ECS injects as env vars with no hot-reload, so rotation needs a coordinated redeploy
sanctum-sm-rotation-suppress-third-partySecretsManager.1, .4sanctum/production/third-partyVendor OAuth/API creds, rotated in each vendor's console
sanctum-iam22-breakglass-suppressIAM.22john-adminBreak-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)"]
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. 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:

  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.

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:

  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.