Files
2026-05-26 19:42:01 +02:00

414 lines
14 KiB
PowerShell

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