Files
alva/felsokning/infra/aws/40-hemligheter.tf
T
Claude b147694e31 Härdning av hela TÜV-revisionen
Tio av tolv fynd stängda, två reducerade med skälen utskrivna. Varje
stängning prövas av integrationssviten mot riktig Postgres — 144
kontroller, upp från 100.

T-1  Händelsenyckeln är nu (arende_id, id). Det finns inget delat
     namnrum kvar att ockupera, så attacken saknar yta i stället för att
     vara mildrad. Inom ett ärende bedöms en kollision på klientens
     avtryck — en hash av det kanoniserade innehåll klienten skickade,
     taget före serverns egna fält och före krypteringen, eftersom
     varken raden eller nyttolasten går att jämföra. Identiskt innehåll
     är fortfarande idempotent; samma id med annat innehåll ger 409 och
     skriver ingenting alls, inte heller resten av satsen. Samma attack
     en nivå upp — ärende-id är lika förutsägbart — stängs separat: ett
     id som ägs av en annan organisation ger 409 i stället för att tyst
     låta bli, vilket tidigare lämnade offrets ärende oskapat och varje
     senare synk svarande 404.

T-2  Mätdonet slås upp i registret. Beteckning och kalibrering HÄRLEDS
     därifrån och skriver över det klienten skickade. Okänt mätdon ger
     400. Utgånget avvisas inte — mätningen gjordes — men registrets
     datum följer med, så graderingen faller på registrets uppgift. Ett
     mätvärde utan mätdon får sina påstådda uppgifter borttagna.

T-3  Reducerad, inte stängd. Personnycklarna kuverteras under en
     huvudnyckel utanför databasen. En återställd dump ger nycklar som
     inte öppnas — verifierat genom att starta om utan huvudnyckeln. Vad
     som återstår står utskrivet: en backup tagen FÖRE en radering, plus
     huvudnyckeln, återställer fortfarande uppgifterna.

Därtill: gallringen verkställs nu av ett eget jobb och grupperar på det
blindade fordonsindexet så att en delad nyckel inte gallras för tidigt
(T-4) · AI-avlästa mätvärden bär härkomst (T-5) · bcrypt kostnad 12
(T-6) · åtkomstloggen och raderingsregistret är append-only, med en
smalare regel för personnycklarna som måste kunna förstöras (T-7) ·
åtkomstloggen dokumenterad (T-8) · ett externt regelpaket utan signatur
spärrar avslut (T-9) · CORS faller inte längre öppet (T-10) · exp krävs
i token (T-11) · react-router 7 (T-12, med den kvarvarande avvikelsen
motiverad).

Den motspelande hyresgästen som revisionen efterlyste finns nu som
testform och körs i CI.

---- Vad härdningen själv avslöjade -----------------------------------

Två av rättelserna var kortvarigt fel på samma sätt som fynden, och
bägge fångades bara av att jag försökte bevisa dem:

  T-7-testet var grönt mot en TOM tabell. En radnivåtrigger har inga
  rader att fyra på, så delete lyckades och kontrollen mätte ingenting.

  T-6-testet påstod anropet, inte kostnaden. toContain("gen_salt('bf')")
  hade accepterat kostnad 6 för evigt — och gjorde det, så länge det
  fanns.

Bägge är mönstret revisionen namngav: en kontroll som är riktig i sina
egna termer och oprövad vid sin gräns. Det gäller tester lika mycket som
kod.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-06 13:05:16 +00:00

320 lines
9.3 KiB
Terraform

# Hemligheterna och vem som får läsa dem.
#
# Secrets Manager i stället för Kubernetes-secrets som sanningskälla:
# rotation, revisionsspår per läsning och kryptering med vår egen nyckel.
# Klustret hämtar dem via IRSA — plattformens tjänstekonto får en egen
# roll, och grannpodden på samma nod får ingenting på köpet.
resource "aws_secretsmanager_secret" "databas" {
name = "${local.namn}/databas"
description = "Aurora-uppgifter för plattformstjänsten."
kms_key_id = aws_kms_key.data.arn
recovery_window_in_days = 30
}
resource "aws_secretsmanager_secret_version" "databas" {
secret_id = aws_secretsmanager_secret.databas.id
secret_string = jsonencode({
username = aws_rds_cluster.denna.master_username
password = random_password.databas.result
host = aws_rds_cluster.denna.endpoint
port = 5432
dbname = aws_rds_cluster.denna.database_name
# Färdig anslutningssträng — plattformstjänsten läser DATABASE_URL.
url = "postgresql://${aws_rds_cluster.denna.master_username}:${random_password.databas.result}@${aws_rds_cluster.denna.endpoint}:5432/${aws_rds_cluster.denna.database_name}?sslmode=require"
})
}
# Applikationens egna hemligheter. Värdena sätts EN gång — Terraform
# äger inte innehållet efteråt, så en rotation i Secrets Manager inte
# skrivs över av nästa apply.
resource "aws_secretsmanager_secret" "app" {
name = "${local.namn}/app"
description = "Claude-nyckel, JWT-hemlighet, krypteringsnyckel för kundernas leverantörsuppgifter."
kms_key_id = aws_kms_key.data.arn
recovery_window_in_days = 30
}
resource "random_password" "jwt" {
length = 64
special = false
}
resource "random_id" "integration" {
byte_length = 32
}
# Huvudnyckeln som personnycklarna kuverteras under (TÜV T-3).
#
# Den ligger i Secrets Manager och INTE i databasen — det är hela poängen.
# Låg nyckeln kvar bredvid chiffertexten var krypto-shredding bara "radera
# en rad i samma databas", och en återställd dump gav uppgifterna i
# läsbart skick igen.
resource "random_id" "personnyckel_huvud" {
byte_length = 32
}
resource "aws_secretsmanager_secret_version" "app" {
secret_id = aws_secretsmanager_secret.app.id
secret_string = jsonencode({
# Fylls i efter första apply — Terraform ska inte känna Claude-nyckeln.
anthropic_api_key = "ERSATT-MIG"
jwt_secret = random_password.jwt.result
integration_nyckel = random_id.integration.hex
personnyckel_huvud = random_id.personnyckel_huvud.hex
})
lifecycle {
ignore_changes = [secret_string]
}
}
# ---- Rollen plattformstjänsten kör som ----------------------------------
resource "aws_iam_role" "plattform" {
name = "${local.namn}-plattform"
assume_role_policy = local.irsa_fortroende["plattform"]
}
resource "aws_iam_role_policy" "plattform" {
name = "las-hemligheter-och-bilagor"
role = aws_iam_role.plattform.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "LasEgnaHemligheter"
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"]
Resource = [
aws_secretsmanager_secret.databas.arn,
aws_secretsmanager_secret.app.arn,
]
},
{
Sid = "Bilagor"
Effect = "Allow"
# Ingen DeleteObject: bilagor hör till en append-only
# händelselogg och tjänsten har ingen anledning att radera dem.
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "${aws_s3_bucket.bilagor.arn}/*"
},
{
Effect = "Allow"
Action = ["s3:ListBucket"]
Resource = aws_s3_bucket.bilagor.arn
},
{
Sid = "KrypteringForOvanstaende"
Effect = "Allow"
Action = ["kms:Decrypt", "kms:GenerateDataKey"]
Resource = aws_kms_key.data.arn
},
]
})
}
# ---- Lastbalanserar-kontrollern -----------------------------------------
#
# Kör i klustret och skapar ALB:er ur Ingress-objekt. Rättigheterna är
# breda till sin natur — den måste kunna skapa och ta bort
# lastbalanserare — men begränsade till den här VPC:n där det går.
resource "aws_iam_role" "lastbalanserare" {
name = "${local.namn}-lastbalanserare"
assume_role_policy = local.irsa_fortroende["lastbalanserare"]
}
resource "aws_iam_role_policy" "lastbalanserare" {
name = "hantera-lastbalanserare"
role = aws_iam_role.lastbalanserare.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"elasticloadbalancing:*",
"ec2:Describe*",
"ec2:CreateSecurityGroup",
"ec2:CreateTags",
"ec2:DeleteTags",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:RevokeSecurityGroupIngress",
"ec2:DeleteSecurityGroup",
"acm:DescribeCertificate",
"acm:ListCertificates",
"iam:CreateServiceLinkedRole",
"cognito-idp:DescribeUserPoolClient",
"wafv2:GetWebACL",
"wafv2:AssociateWebACL",
"shield:GetSubscriptionState",
]
Resource = "*"
},
]
})
}
# ---- Rollerna bygget och driftsättningen antar --------------------------
#
# Byggrunnern får publicera till ECR men inte röra klustret.
# Driftsättningen får röra klustret men inte publicera bilder. Två
# separata roller så ett komprometterat bygge inte kan driftsätta.
resource "aws_iam_role" "bygg" {
name = "${local.namn}-bygg"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.denna.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"${local.oidc_utfardare}:aud" = "sts.amazonaws.com"
"${local.oidc_utfardare}:sub" = "system:serviceaccount:gitea:runner"
}
}
}]
})
}
resource "aws_iam_role_policy" "bygg" {
name = "publicera-till-ecr"
role = aws_iam_role.bygg.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "ecr:GetAuthorizationToken"
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:CompleteLayerUpload",
"ecr:InitiateLayerUpload",
"ecr:PutImage",
"ecr:UploadLayerPart",
"ecr:BatchGetImage",
]
Resource = [for r in aws_ecr_repository.denna : r.arn]
},
{
Effect = "Allow"
Action = ["kms:Encrypt", "kms:GenerateDataKey", "kms:Decrypt"]
Resource = aws_kms_key.data.arn
},
]
})
}
resource "aws_iam_role" "drift" {
name = "${local.namn}-drift"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.denna.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"${local.oidc_utfardare}:aud" = "sts.amazonaws.com"
"${local.oidc_utfardare}:sub" = "system:serviceaccount:gitea:drift"
}
}
}]
})
}
resource "aws_iam_role_policy" "drift" {
name = "driftsatt"
role = aws_iam_role.drift.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["eks:DescribeCluster", "eks:ListClusters"]
Resource = aws_eks_cluster.denna.arn
},
{
# Terraform-tillståndet.
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
Resource = [aws_s3_bucket.tillstand.arn, "${aws_s3_bucket.tillstand.arn}/*"]
},
]
})
}
# Driftsättningen behöver också rättigheter i klustret, inte bara i IAM.
resource "aws_eks_access_entry" "drift" {
cluster_name = aws_eks_cluster.denna.name
principal_arn = aws_iam_role.drift.arn
type = "STANDARD"
}
resource "aws_eks_access_policy_association" "drift" {
cluster_name = aws_eks_cluster.denna.name
principal_arn = aws_iam_role.drift.arn
policy_arn = "arn:${data.aws_partition.denna.partition}:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
access_scope {
type = "cluster"
}
depends_on = [aws_eks_access_entry.drift]
}
# ---- Terraform-tillståndet ----------------------------------------------
resource "aws_s3_bucket" "tillstand" {
bucket = "${local.namn}-tfstate-${data.aws_caller_identity.denna.account_id}"
tags = { Name = "${local.namn}-tfstate" }
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_public_access_block" "tillstand" {
bucket = aws_s3_bucket.tillstand.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "tillstand" {
bucket = aws_s3_bucket.tillstand.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tillstand" {
bucket = aws_s3_bucket.tillstand.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.data.arn
}
}
}