414 lines
14 KiB
PowerShell
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
|