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." } } if ($service.Image -match ':[Ll]atest(?:[-@]|$)') { if (($service.Image -match '@sha256:') -and (Test-IdentityMatch -Service $service -Candidates $Exceptions.allowed_mutable_tag_identities)) { Add-Finding -Findings $Findings -Severity 'info' -Code 'IMAGE002' -Target $targetBase -Message 'Image uses a latest tag but is digest-pinned and documented as an exception.' } else { Add-Finding -Findings $Findings -Severity 'warning' -Code 'IMAGE001' -Target $targetBase -Message 'Image uses a latest tag. Prefer a concrete version tag, even when a digest is present.' } } $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_mutable_tag_identities = @($exceptionsRaw.allowed_mutable_tag_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