Add manual repo policy checks

Add manual repo policy checks
This commit is contained in:
2026-05-06 19:36:01 +02:00
parent 0aa8138bdd
commit 7c50e69b44
4 changed files with 531 additions and 0 deletions
+55
View File
@@ -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.
+404
View File
@@ -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
+43
View File
@@ -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"
]
}
+29
View File
@@ -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