Security¶
This page documents the security posture of the platform template — what has been hardened, where the remaining gaps are, and the rationale behind a few decisions that are not obvious from the code alone.
For vulnerability disclosure, see SECURITY.md at the repo root.
Dependency vulnerability remediation¶
In May 2026 a full Dependabot sweep flagged 91 alerts across the Backstage frontend/backend, MCP servers, and scaffolded service templates. The current state is 90 remediated, 2 dismissed (no upstream fix).
| Bucket | Result |
|---|---|
| Backstage core | Bumped to v1.50.4 via yarn backstage-cli versions:bump — pulled patched versions of 104+ @backstage/* packages and their transitives |
@backstage/plugin-scaffolder-node |
Bumped ^0.9.0 → ^0.13.2 |
| Transitive npm deps | 27 resolutions pinned in backstage/app/package.json covering axios, tar, undici, minimatch, protobufjs, jsonpath-plus, form-data, lodash, koa, dompurify, postcss, fast-uri, uuid, @octokit/*, qs, cookie, fast-xml-parser (^5.7.0), @tootallnate/once (^3.0.1) |
vm2 |
Replaced with a local shim (backstage/app/vm2-shim/) — see below |
services/idp-mcp-server |
hono → 4.12.19, ip-address → 10.2.0 |
react-frontend scaffold skeleton |
vite ^5.4.0 → ^6.4.2 |
| Dismissed | elliptic — no upstream fix available; request — deprecated and not exercised by our code paths |
The two commits implementing this are 5ff7971 (88 of 91) and 678d201 (final 2).
Why vm2 was replaced rather than upgraded¶
vm2 was reached transitively via typescript-json-schema ← @backstage/config-loader. Upstream vm2 was abandoned with no plan to ship a fix for the known sandbox-escape CVEs. The shim at backstage/app/vm2-shim/index.js is a ~30-line wrapper around Node's built-in vm module that exposes the same surface (VM, NodeVM) used by typescript-json-schema. It is copied into the Backstage image before yarn workspaces focus runs (see backstage/Dockerfile, commit b98c924), so the focus step resolves the shim instead of trying to download the abandoned package.
Dependabot policy¶
Automated version-update PRs are disabled in .github/dependabot.yml (open-pull-requests-limit: 0 for the npm and Docker ecosystems). Security alerts still surface.
The reason is incident-driven: two auto-bump PRs broke the working configuration in successive weeks —
uuidv9 → v10 silently dropped its default export, crashing every Backstage page that rendered a@material-table/coretable.@backstage/plugin-scaffolder-nodemoved an extension-point export from the main entry to/alpha, which caused our custom scaffold actions to crash at startup and left the catalog refresh loop stuck with 0 entities.
Both required manual intervention (a yarn patch and an import-path revert). Until we have a CI signal that can catch these before the PR is merged, version-update PRs are off and dependency upgrades happen as scoped, reviewed batches.
Crossplane IAM: least-privilege provider roles¶
The Crossplane IRSA role (terraform/iam-crossplane.tf) was previously
attached to five AWS-managed *FullAccess policies. These have been replaced
with scoped inline policies — one per resource family:
| Policy | Scope |
|---|---|
crossplane_s3 |
Bucket create/delete/configure — restricted to arn:aws:s3:::idp-* |
crossplane_rds |
Instance + subnet-group lifecycle — restricted to arn:aws:rds:*:*:db:idp-* |
crossplane_kafka |
Topic lifecycle on arn:aws:kafka:*:*:cluster/idp-*/* |
crossplane_dynamodb |
Table lifecycle — restricted to arn:aws:dynamodb:*:*:table/idp-* |
crossplane_sqs |
Queue lifecycle — restricted to arn:aws:sqs:*:*:idp-* |
crossplane_tagging |
tag:* on * (required by the Resource Groups Tagging API) |
The idp-* prefix constraint aligns with the pattern validations in each XRD, so a Claim can never request a resource name that falls outside the policy scope.
Per-team secret isolation (IRSA)¶
Each team provisioned via the Provision Team Namespace scaffold gets its own
IRSA role (terraform/iam-team-secret-store.tf):
- The role is trusted only by
team-<name>:team-<name>-eso-sa(namespace-scoped ServiceAccount) - The inline policy allows only
secretsmanager:GetSecretValueandsecretsmanager:DescribeSecretonarn:aws:secretsmanager:*:*:secret:<teamName>/* - Cross-team reads are blocked at the IAM level — no policy override inside the cluster can grant access to another team's secrets
The team's namespace-scoped SecretStore references only this role, so even if a team
member can kubectl apply in their namespace, they cannot read secrets outside /<team>/*.
Compare with the global ClusterSecretStore (aws-secretsmanager) which is used only
by platform-owned services (backstage, dora-exporter, kagent) and is tied to the platform-team IRSA role.
GitHub App vs PAT security posture¶
The IDP uses a GitHub App (configured in app-config.aws.yaml and .github/workflows/auto-merge-onboarding.yml) rather than a Personal Access Token:
| Property | PAT | GitHub App |
|---|---|---|
| Scope | All repos the user can access | Only repos the App is installed on |
| Expiry | 90 days / 1 year | Never (private key rotates independently) |
| Rotation blast radius | Must update everywhere the PAT is used | Rotate private key; App ID/installation unchanged |
| Rate limit | 5 000 req/hr total | 5 000 req/hr per installation |
| CI self-approval | Blocked by GitHub | Allowed (App is a distinct identity) |
To set up, see docs/github-app-setup.md.
Crossplane data-safety defaults¶
All five Compositions (aws/crossplane/compositions/*/composition.yaml) ship
with these safety defaults:
deletionPolicy: Orphan— deleting a Claim never deletes the underlying AWS resource. Accidentalkubectl deletecannot cause data loss. Full decommission requires an explicit manual step.skipFinalSnapshot: false(RDS) — a final snapshot is created before any RDS instance is deleted, even when triggered bycleanup.sh.backupRetentionDays: 30(RDS, configurable) — automated backups kept for 30 days by default.storageEncrypted: true(RDS) andsqsManagedSseEnabled: true(SQS) — encryption at rest is non-optional.
Container & supply-chain hardening¶
- Backstage
Dockerfileruns as non-root, uses a distroless-style runtime stage, and pins all base-image digests. - GitHub Actions CI uses
aws-actions/configure-aws-credentialswith OIDC — no long-lived AWS keys in repo secrets. - Trivy scans every image build; Cosign signs images pushed to ECR/GHCR.
- OPA/Gatekeeper policies in
kubernetes/policies/reject pods that pull:latest, lack resource limits, or omit cost-allocation labels. - RDS security group restricts ingress to the VPC CIDR (no
0.0.0.0/0). - All namespaces enforce Pod Security Standards (
restrictedwhere possible,baselinefor system namespaces).
Production Backstage Hardening¶
The following hardening steps were applied to the AWS Backstage deployment in commit e4e00dc:
Authentication¶
- Guest auth removed — the
dangerouslyAllowOutsideDevelopmentguest provider is no longer present inbackstage/app-config.aws.yaml. Production requires GitHub OAuth (auth.providers.github). - Sign-in restricted to GitHub Org members — both GitHub auth resolvers set
dangerouslyAllowSignInWithoutUserInCatalog: false. A matching catalogUserentity only exists for real members of the org configured incatalog.providers.githubOrg(synced by@backstage/plugin-catalog-backend-module-github-org), so sign-in now fails closed for anyone outside that org instead of falling through to an unresolved identity. See ADR-0004. - Session secret from Secrets Manager —
AUTH_SESSION_SECRETis injected at pod startup via the External Secrets Operator; there is no static fallback value in the config file.
Database¶
- TLS cert validation enabled —
rejectUnauthorized: trueis set for the PostgreSQL connection inbackstage/app-config.aws.yaml. The cluster CA is mounted from a Kubernetes secret.
Infrastructure¶
- No hardcoded AWS Account ID —
aws/backstage/deployment.yamluses${AWS_ACCOUNT_ID}and${AWS_REGION}environment variable placeholders substituted at deploy time bybootstrap.sh, rather than the literal account ID.
These settings are in effect only in the AWS config. Local Kind development still uses guest auth (see Local-only relaxations below).
Ingress TLS is not configured¶
Status: open. Tracked in #310. Read this before putting real users on the platform.
The TLS hardening above covers the database connection. It does not cover ingress. As shipped, every ingress on the AWS cluster serves plain HTTP on port 80 — no alb.ingress.kubernetes.io/certificate-arn annotation, no spec.tls block, and no cert-manager installed:
- Backstage is exposed as a bare
LoadBalancerService on port 80 (aws/backstage/deployment.yaml), withAPP_BASE_URLset to anhttp://ALB hostname. - ArgoCD runs with
--insecureandserver.insecure: true(aws/argocd/argocd-helm-values.yaml), withtls: []on its Ingress. The comment there — "Remove when TLS is terminated at ALB" — anticipates the work but does not do it. - The MCP services, Grafana, MLflow and Langfuse ingresses are all likewise HTTP-only.
The practical consequence: GitHub OAuth tokens, the ArgoCD session cookie, and every MCP call cross the network in cleartext. That is acceptable for a demo or an evaluation cluster on a private VPC. It is not acceptable for a shared or internet-facing deployment.
Recommended remediation¶
The pieces are mostly present already — terraform/acm.tf provisions a wildcard ACM certificate with Route53 DNS validation. It is simply scoped to the monitoring ALBs (Grafana, Prometheus, Alertmanager) and gated behind var.domain_name, so it never reaches Backstage or ArgoCD.
- Set
var.domain_nameinterraform/terraform.tfvarsto a domain with a Route53 hosted zone in the same account, and apply. This is the prerequisite for everything below — without a real domain there is nothing for ACM to validate. - Extend
terraform/acm.tfbeyond the monitoring ingresses so the wildcard certificate covers the Backstage and ArgoCD hostnames. - Convert the Backstage Service to an ALB Ingress and annotate it:
1 2 3
alb.ingress.kubernetes.io/certificate-arn: <acm-cert-arn> alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' alb.ingress.kubernetes.io/ssl-redirect: '443' - Flip the URLs to
https://—APP_BASE_URLandapp.baseUrl/backend.baseUrlinbackstage/app-config.aws.yaml. - Drop ArgoCD's plaintext flags — remove
--insecurefromextraArgsand setserver.insecure: false. - Only then narrow the CSP: set
upgrade-insecure-requests: trueand restrictconnect-src(#316). Doing this before HTTPS is live will break the app.
Plan for the cutover¶
This is not a transparent change. The Backstage URL moves from the ALB hostname to your domain, which means:
- the GitHub OAuth App callback URL must be re-registered (see github-app-setup.md) — users cannot sign in until it is;
- any bookmarked ALB hostnames stop working;
catalog-info.yamlannotations or dashboards that hardcode the old host need updating.
Schedule it as a maintenance window rather than a routine deploy.
Local-only relaxations¶
These exist to make the developer-loop experience usable on Kind/Rancher Desktop and must not be ported to a production cluster:
app-config.local.yaml:backend.auth.dangerouslyDisableDefaultAuthPolicy: true— prevents a 401 flash before guest sign-in completes (Backstage v1.29+).kubernetes/kagent/ingress*.yaml: plain HTTP withssl-redirect: "false".- Local registry
localhost:5003is HTTP and unauthenticated.
The readiness checklist calls these out before promoting to a real environment.
Reporting¶
See SECURITY.md for the private-disclosure process.