From 7c50e69b44c5f9b7f1a6f6ac43b3b60caf04e7b1 Mon Sep 17 00:00:00 2001 From: Micha Date: Wed, 6 May 2026 19:36:01 +0200 Subject: [PATCH] Add manual repo policy checks Add manual repo policy checks --- ops/policy-checks/README.md | 55 ++++ ops/policy-checks/check_repo.ps1 | 404 ++++++++++++++++++++++++++++++ ops/policy-checks/exceptions.json | 43 ++++ ops/policy-checks/last-report.md | 29 +++ 4 files changed, 531 insertions(+) create mode 100644 ops/policy-checks/README.md create mode 100644 ops/policy-checks/check_repo.ps1 create mode 100644 ops/policy-checks/exceptions.json create mode 100644 ops/policy-checks/last-report.md diff --git a/ops/policy-checks/README.md b/ops/policy-checks/README.md new file mode 100644 index 0000000..29c7160 --- /dev/null +++ b/ops/policy-checks/README.md @@ -0,0 +1,55 @@ +# Policy Checks + +Manuelle, read-only Repo-Pruefungen fuer `homelab-infra`. + +Ziel: + +- offensichtliche Fehler vor einem Push oder Deploy erkennen +- dokumentierte Ausnahmen sichtbar halten, aber nicht als Fehlalarm behandeln +- keine Container aendern, keine Deploys ausloesen, keine Dateien ausserhalb des Reports schreiben + +## Start + +Aus dem Repo-Root: + +```powershell +powershell -ExecutionPolicy Bypass -File .\ops\policy-checks\check_repo.ps1 +``` + +Mit Report-Datei: + +```powershell +powershell -ExecutionPolicy Bypass -File .\ops\policy-checks\check_repo.ps1 -ReportPath .\ops\policy-checks\last-report.md +``` + +## Was geprueft wird + +- `docker compose config --quiet` fuer alle Compose-Dateien +- SHA256-Digests nur dann, wenn ein Digest im Image steht +- keine echten `.env`- oder `stack.env`-Dateien im Repo +- Datenbank-/Cache-Dienste nicht im `frontend_net` +- `security_opt: no-new-privileges:true` +- Host-Port-Mappings +- Traefik-Router mit `Host(...)` und Middleware-Standard fuer geschuetzte Admin-/Ops-Dienste +- sichtbare Report-Punkte fuer dokumentierte Ausnahmen wie `user: "0"`, `privileged: true` oder `network_mode: host` + +## Wichtige Betriebsregel + +Dieses Script ist absichtlich erstmal nur ein manuelles Werkzeug. + +- kein Cronjob +- keine taegliche Automatik +- keine CI-Pflicht im ersten Schritt + +Empfohlene Nutzung: + +- vor Pushes mit Compose-/Traefik-/Netzwerk-Aenderungen +- vor neuen Stacks +- vor groesseren Hardening-Sprints + +## Exit-Code + +- `0`: keine kritischen Findings +- `1`: mindestens ein kritisches Finding + +Warnings brechen den Lauf nicht. diff --git a/ops/policy-checks/check_repo.ps1 b/ops/policy-checks/check_repo.ps1 new file mode 100644 index 0000000..160647e --- /dev/null +++ b/ops/policy-checks/check_repo.ps1 @@ -0,0 +1,404 @@ +param( + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path, + [string]$ReportPath = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function New-Finding { + param( + [string]$Severity, + [string]$Code, + [string]$Target, + [string]$Message + ) + + [pscustomobject]@{ + Severity = $Severity + Code = $Code + Target = $Target + Message = $Message + } +} + +function Add-Finding { + param( + [System.Collections.Generic.List[object]]$Findings, + [string]$Severity, + [string]$Code, + [string]$Target, + [string]$Message + ) + + $Findings.Add((New-Finding -Severity $Severity -Code $Code -Target $Target -Message $Message)) +} + +function Get-RelativePath { + param( + [string]$BasePath, + [string]$FullPath + ) + + $baseUri = [System.Uri]((Resolve-Path $BasePath).Path.TrimEnd('\') + '\') + $fullUri = [System.Uri](Resolve-Path $FullPath).Path + [System.Uri]::UnescapeDataString($baseUri.MakeRelativeUri($fullUri).ToString()).Replace('/', '\') +} + +function Test-IdentityMatch { + param( + [object]$Service, + [string[]]$Candidates + ) + + if (-not $Candidates) { + return $false + } + + foreach ($candidate in $Candidates) { + if ($candidate -and ($candidate -eq $Service.ServiceName -or $candidate -eq $Service.ContainerName)) { + return $true + } + } + + return $false +} + +function Get-AllowedPorts { + param( + [object]$Service, + [hashtable]$AllowedHostPorts + ) + + $allPorts = @() + + if ($AllowedHostPorts.ContainsKey($Service.ServiceName)) { + $allPorts += $AllowedHostPorts[$Service.ServiceName] + } + + if ($Service.ContainerName -and $AllowedHostPorts.ContainsKey($Service.ContainerName)) { + $allPorts += $AllowedHostPorts[$Service.ContainerName] + } + + $allPorts | Select-Object -Unique +} + +function Get-ComposeFiles { + param([string]$Root) + + Get-ChildItem -LiteralPath $Root -Recurse -File | + Where-Object { + $_.FullName -notmatch '\\.git\\' -and ( + $_.Name -eq 'docker-compose.yml' -or + $_.Name -eq 'docker-compose.yaml' -or + $_.Name -eq 'compose.yml' -or + $_.Name -eq 'compose.yaml' + ) + } | + Sort-Object FullName +} + +function Parse-ComposeServices { + param([string]$Path) + + $lines = Get-Content -LiteralPath $Path + $services = New-Object System.Collections.Generic.List[object] + $current = $null + $inServices = $false + $currentSection = "" + + foreach ($line in $lines) { + if (-not $inServices) { + if ($line -match '^services:\s*$') { + $inServices = $true + } + continue + } + + if ($line -match '^[A-Za-z0-9_.-]+:\s*$' -and $line -notmatch '^ ') { + if ($current) { + $services.Add([pscustomobject]$current) + $current = $null + } + $inServices = $false + $currentSection = "" + continue + } + + if ($line -match '^ ([A-Za-z0-9_.-]+):\s*$') { + if ($current) { + $services.Add([pscustomobject]$current) + } + + $current = @{ + FilePath = $Path + ServiceName = $Matches[1] + ContainerName = $Matches[1] + Image = "" + Ports = New-Object System.Collections.Generic.List[string] + Networks = New-Object System.Collections.Generic.List[string] + Labels = New-Object System.Collections.Generic.List[string] + HasNoNewPrivileges = $false + Privileged = $false + User = "" + NetworkMode = "" + } + $currentSection = "" + continue + } + + if (-not $current) { + continue + } + + if ($line -match '^ ([A-Za-z0-9_.-]+):\s*(.*)$') { + $currentSection = $Matches[1] + $value = $Matches[2].Trim() + + switch ($currentSection) { + 'container_name' { if ($value) { $current.ContainerName = $value.Trim('"''') } } + 'image' { $current.Image = $value.Trim('"''') } + 'user' { $current.User = $value.Trim('"''') } + 'network_mode' { $current.NetworkMode = $value.Trim('"''') } + 'privileged' { + if ($value -match '^(true|True)$') { + $current.Privileged = $true + } + } + } + continue + } + + if ($line -match '^\s{6}-\s*(.+?)\s*$') { + $item = $Matches[1].Trim().Trim('"''') + switch ($currentSection) { + 'ports' { $current.Ports.Add($item) } + 'networks' { $current.Networks.Add($item) } + 'labels' { $current.Labels.Add($item) } + 'security_opt' { + if ($item -eq 'no-new-privileges:true') { + $current.HasNoNewPrivileges = $true + } + } + } + continue + } + } + + if ($current) { + $services.Add([pscustomobject]$current) + } + + $services +} + +function Test-ComposeConfig { + param( + [string]$FilePath, + [System.Collections.Generic.List[object]]$Findings, + [string]$RepoBase + ) + + $relative = Get-RelativePath -BasePath $RepoBase -FullPath $FilePath + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $output = & docker compose -f $FilePath config --quiet 2>&1 + $ErrorActionPreference = $previousPreference + if ($LASTEXITCODE -ne 0) { + Add-Finding -Findings $Findings -Severity 'critical' -Code 'COMPOSE001' -Target $relative -Message ("docker compose config --quiet failed: " + (($output | Out-String).Trim())) + } +} + +function Test-SecretHygiene { + param( + [string]$Root, + [System.Collections.Generic.List[object]]$Findings + ) + + $forbiddenFiles = Get-ChildItem -LiteralPath $Root -Recurse -File | + Where-Object { + $_.FullName -notmatch '\\.git\\' -and ( + $_.Name -eq '.env' -or + $_.Name -eq 'stack.env' + ) + } + + foreach ($file in $forbiddenFiles) { + $relative = Get-RelativePath -BasePath $Root -FullPath $file.FullName + Add-Finding -Findings $Findings -Severity 'critical' -Code 'SECRET001' -Target $relative -Message 'Runtime env file is committed. Keep only example files in Git.' + } +} + +function Test-ServicePolicies { + param( + [object[]]$Services, + [hashtable]$Exceptions, + [System.Collections.Generic.List[object]]$Findings, + [string]$RepoBase + ) + + foreach ($service in $Services) { + $relative = Get-RelativePath -BasePath $RepoBase -FullPath $service.FilePath + $targetBase = "$relative :: $($service.ServiceName)" + + if (-not $service.HasNoNewPrivileges) { + Add-Finding -Findings $Findings -Severity 'warning' -Code 'SEC001' -Target $targetBase -Message 'Missing security_opt no-new-privileges:true.' + } + + if ($service.Image -match '@sha256:([0-9a-fA-F]+)$') { + $digest = $Matches[1] + if ($digest.Length -ne 64) { + Add-Finding -Findings $Findings -Severity 'critical' -Code 'DIGEST001' -Target $targetBase -Message "Digest length is $($digest.Length), expected 64 hex characters." + } + } + + $isDataService = $false + $identityText = ($service.ServiceName + ' ' + $service.ContainerName + ' ' + $service.Image).ToLowerInvariant() + foreach ($needle in @('postgres', 'redis', 'mongo', 'mysql', 'mariadb', 'influxdb')) { + if ($identityText.Contains($needle)) { + $isDataService = $true + break + } + } + + if ($isDataService -and $service.Networks -contains 'frontend_net') { + Add-Finding -Findings $Findings -Severity 'critical' -Code 'NET001' -Target $targetBase -Message 'Data service is attached to frontend_net.' + } + + foreach ($port in $service.Ports) { + $allowedPorts = Get-AllowedPorts -Service $service -AllowedHostPorts $Exceptions.allowed_host_port_identities + if ($allowedPorts -contains $port) { + Add-Finding -Findings $Findings -Severity 'info' -Code 'PORT001' -Target $targetBase -Message "Allowed host port mapping: $port" + } else { + Add-Finding -Findings $Findings -Severity 'warning' -Code 'PORT002' -Target $targetBase -Message "Host port mapping present: $port" + } + } + + $hostRuleLabels = @($service.Labels | Where-Object { $_ -match 'traefik\.http\.routers\..*\.rule=Host\(' }) + $middlewareLabels = @($service.Labels | Where-Object { $_ -match 'traefik\.http\.routers\..*\.middlewares=' }) + + if ($hostRuleLabels.Count -gt 0 -and -not (Test-IdentityMatch -Service $service -Candidates $Exceptions.middleware_exempt_identities)) { + if ($middlewareLabels.Count -eq 0) { + Add-Finding -Findings $Findings -Severity 'warning' -Code 'TRAEFIK001' -Target $targetBase -Message 'Traefik Host router is missing middleware definition.' + } else { + $middlewareJoined = ($middlewareLabels -join ' ') + if ($middlewareJoined -notmatch 'authelia@file' -or $middlewareJoined -notmatch 'secure-headers@file') { + Add-Finding -Findings $Findings -Severity 'warning' -Code 'TRAEFIK002' -Target $targetBase -Message 'Traefik middleware does not match the standard authelia@file + secure-headers@file.' + } + } + } + + if ($service.User -eq '0' -or $service.User -eq '"0"') { + if (Test-IdentityMatch -Service $service -Candidates $Exceptions.allowed_root_identities) { + Add-Finding -Findings $Findings -Severity 'warning' -Code 'USER001' -Target $targetBase -Message 'Runs as user 0. Documented exception, keep visible for hardening.' + } else { + Add-Finding -Findings $Findings -Severity 'warning' -Code 'USER002' -Target $targetBase -Message 'Runs as user 0.' + } + } + + if ($service.Privileged) { + if (Test-IdentityMatch -Service $service -Candidates $Exceptions.allowed_privileged_identities) { + Add-Finding -Findings $Findings -Severity 'info' -Code 'PRIV001' -Target $targetBase -Message 'Privileged mode is a documented exception.' + } else { + Add-Finding -Findings $Findings -Severity 'warning' -Code 'PRIV002' -Target $targetBase -Message 'Privileged mode is enabled.' + } + } + + if ($service.NetworkMode -eq 'host') { + if (Test-IdentityMatch -Service $service -Candidates $Exceptions.allowed_host_network_identities) { + Add-Finding -Findings $Findings -Severity 'info' -Code 'HOSTNET001' -Target $targetBase -Message 'network_mode: host is a documented exception.' + } else { + Add-Finding -Findings $Findings -Severity 'warning' -Code 'HOSTNET002' -Target $targetBase -Message 'network_mode: host is enabled.' + } + } + } +} + +function Get-ReportText { + param( + [object[]]$Findings, + [int]$ComposeCount + ) + + $critical = @($Findings | Where-Object { $_.Severity -eq 'critical' }) + $warning = @($Findings | Where-Object { $_.Severity -eq 'warning' }) + $info = @($Findings | Where-Object { $_.Severity -eq 'info' }) + + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add('# Policy Check Report') + $lines.Add('') + $lines.Add('## Summary') + $lines.Add("- Compose files checked: $ComposeCount") + $lines.Add("- Critical findings: $($critical.Count)") + $lines.Add("- Warnings: $($warning.Count)") + $lines.Add("- Info findings: $($info.Count)") + $lines.Add('') + + foreach ($bucket in @( + @{ Name = 'Critical'; Items = $critical }, + @{ Name = 'Warnings'; Items = $warning }, + @{ Name = 'Info'; Items = $info } + )) { + $lines.Add("## $($bucket.Name)") + if ($bucket.Items.Count -eq 0) { + $lines.Add('- none') + } else { + foreach ($finding in $bucket.Items) { + $lines.Add("- [$($finding.Code)] $($finding.Target): $($finding.Message)") + } + } + $lines.Add('') + } + + $lines -join [Environment]::NewLine +} + +$exceptionsPath = Join-Path $PSScriptRoot 'exceptions.json' +$exceptionsRaw = Get-Content -LiteralPath $exceptionsPath -Raw | ConvertFrom-Json +$exceptions = @{ + middleware_exempt_identities = @($exceptionsRaw.middleware_exempt_identities) + allowed_root_identities = @($exceptionsRaw.allowed_root_identities) + allowed_privileged_identities = @($exceptionsRaw.allowed_privileged_identities) + allowed_host_network_identities = @($exceptionsRaw.allowed_host_network_identities) + allowed_host_port_identities = @{} +} + +foreach ($property in $exceptionsRaw.allowed_host_port_identities.PSObject.Properties) { + $exceptions.allowed_host_port_identities[$property.Name] = @($property.Value) +} + +$findings = New-Object 'System.Collections.Generic.List[object]' +$composeFiles = @(Get-ComposeFiles -Root $RepoRoot) +$allServices = New-Object System.Collections.Generic.List[object] + +Test-SecretHygiene -Root $RepoRoot -Findings $findings + +foreach ($composeFile in $composeFiles) { + Test-ComposeConfig -FilePath $composeFile.FullName -Findings $findings -RepoBase $RepoRoot + $services = @(Parse-ComposeServices -Path $composeFile.FullName) + foreach ($service in $services) { + $allServices.Add($service) + } +} + +Test-ServicePolicies -Services $allServices.ToArray() -Exceptions $exceptions -Findings $findings -RepoBase $RepoRoot + +$report = Get-ReportText -Findings $findings.ToArray() -ComposeCount $composeFiles.Count + +if ($ReportPath) { + $resolvedPath = if ([System.IO.Path]::IsPathRooted($ReportPath)) { $ReportPath } else { Join-Path $RepoRoot $ReportPath } + $parent = Split-Path -Parent $resolvedPath + if ($parent) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null + } + Set-Content -LiteralPath $resolvedPath -Value $report +} + +$report + +$criticalCount = @($findings | Where-Object { $_.Severity -eq 'critical' }).Count +if ($criticalCount -gt 0) { + exit 1 +} + +exit 0 diff --git a/ops/policy-checks/exceptions.json b/ops/policy-checks/exceptions.json new file mode 100644 index 0000000..4c53aff --- /dev/null +++ b/ops/policy-checks/exceptions.json @@ -0,0 +1,43 @@ +{ + "middleware_exempt_identities": [ + "authelia", + "gitea", + "immich-server", + "immich_server", + "komodo-core", + "mealie", + "nextcloud", + "ntfy", + "paperless", + "paperless-ngx", + "vaultwarden" + ], + "allowed_host_port_identities": { + "adguard": [ + "53:53/tcp", + "53:53/udp", + "8082:80" + ], + "gitea": [ + "222:22" + ], + "influxdb3-core": [ + "${INFLUXDB_BIND_IP:-127.0.0.1}:8181:8181" + ], + "traefik": [ + "80:80", + "443:443" + ] + }, + "allowed_root_identities": [ + "grafana", + "influxdb3-core" + ], + "allowed_privileged_identities": [ + "scrutiny" + ], + "allowed_host_network_identities": [ + "tailscale", + "Tailscale-Docker" + ] +} diff --git a/ops/policy-checks/last-report.md b/ops/policy-checks/last-report.md new file mode 100644 index 0000000..2a6ef5d --- /dev/null +++ b/ops/policy-checks/last-report.md @@ -0,0 +1,29 @@ +# Policy Check Report + +## Summary +- Compose files checked: 30 +- Critical findings: 0 +- Warnings: 5 +- Info findings: 9 + +## Critical +- none + +## Warnings +- [SEC001] infra\ddns-updater\docker-compose.yml :: ddns-updater: Missing security_opt no-new-privileges:true. +- [SEC001] ops\backrest\docker-compose.yml :: backrest: Missing security_opt no-new-privileges:true. +- [USER001] ops\grafana-influxdb\docker-compose.yml :: grafana: Runs as user 0. Documented exception, keep visible for hardening. +- [USER001] ops\grafana-influxdb\docker-compose.yml :: influxdb3-core: Runs as user 0. Documented exception, keep visible for hardening. +- [SEC001] ops\scrutiny\docker-compose.yml :: scrutiny: Missing security_opt no-new-privileges:true. + +## Info +- [PORT001] core\gitea\docker-compose.yml :: gitea: Allowed host port mapping: 222:22 +- [PORT001] host-services\Adguard\docker-compose.yml :: adguard: Allowed host port mapping: 53:53/tcp +- [PORT001] host-services\Adguard\docker-compose.yml :: adguard: Allowed host port mapping: 53:53/udp +- [PORT001] host-services\Adguard\docker-compose.yml :: adguard: Allowed host port mapping: 8082:80 +- [HOSTNET001] host-services\tailscale\docker-compose.yml :: tailscale: network_mode: host is a documented exception. +- [PORT001] ops\grafana-influxdb\docker-compose.yml :: influxdb3-core: Allowed host port mapping: ${INFLUXDB_BIND_IP:-127.0.0.1}:8181:8181 +- [PRIV001] ops\scrutiny\docker-compose.yml :: scrutiny: Privileged mode is a documented exception. +- [PORT001] traefik\docker-compose.yml :: traefik: Allowed host port mapping: 80:80 +- [PORT001] traefik\docker-compose.yml :: traefik: Allowed host port mapping: 443:443 +