199 lines
7.6 KiB
PowerShell
199 lines
7.6 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[ValidateSet('ML1','ML2','ML3','All')] [string]$Profile = 'ML1',
|
|
[string]$RulesPath = ".\rules",
|
|
[string]$ProfilesPath = ".\profiles",
|
|
[string]$OutDir = ".\out"
|
|
)
|
|
|
|
function New-Result($rule,$status,$evidence){
|
|
[pscustomobject]@{
|
|
RuleId = $rule.id
|
|
Title = $rule.title
|
|
Strategy = $rule.strategy
|
|
Level = $Profile
|
|
Status = $status # PASS / FAIL / SKIPPED / N/A / ERROR
|
|
Evidence = $evidence
|
|
Timestamp = (Get-Date).ToString("s")
|
|
}
|
|
}
|
|
|
|
function Test-Registry {
|
|
param($r)
|
|
$root = if ($r.scope -eq 'HKCU') { 'HKCU:' } else { 'HKLM:' }
|
|
$path = if ($r.path.StartsWith('\')) { Join-Path $root $r.path.Substring(1) } else { Join-Path $root $r.path }
|
|
try {
|
|
if (-not (Test-Path $path)) { return @{ok=$false; ev="Path missing: $path"} }
|
|
$prop = Get-ItemProperty -Path $path -ErrorAction Stop
|
|
if (-not $r.name -or $r.name -eq '') { return @{ ok=$true; ev="Key exists: $path" } }
|
|
if ($null -eq $prop.$($r.name)) { return @{ok=$false; ev="Value missing: $path\$($r.name)"} }
|
|
$val = $prop.$($r.name)
|
|
$op = if ($r.op) { $r.op } else { 'eq' }
|
|
$ok = $false
|
|
switch ($op) {
|
|
'eq' { $ok = ([string]$val -eq [string]$r.expected) }
|
|
'ne' { $ok = ([string]$val -ne [string]$r.expected) }
|
|
'in' { $ok = ($r.expected -contains ([string]$val)) }
|
|
'ge' { $ok = ([double]$val -ge [double]$r.expected) }
|
|
'le' { $ok = ([double]$val -le [double]$r.expected) }
|
|
default { $ok = ([string]$val -eq [string]$r.expected) }
|
|
}
|
|
@{ ok=$ok; ev="[$path] $($r.name)=$val (expect $op $($r.expected))" }
|
|
} catch { @{ ok=$false; ev=("Error: {0}" -f $_.Exception.Message) } }
|
|
}
|
|
|
|
function Test-File {
|
|
param($r)
|
|
$exists = Test-Path $r.path
|
|
$ok = if ($r.op -eq 'absent') { -not $exists } else { $exists }
|
|
@{ ok=$ok; ev="$($r.path) Exists=$exists (expect $($r.op))" }
|
|
}
|
|
|
|
function Test-Command {
|
|
param($r)
|
|
try {
|
|
$expected = if ($null -ne $r.expectedExitCode) { [int]$r.expectedExitCode } else { 0 }
|
|
$p = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -NonInteractive -Command `$ErrorActionPreference='Stop'; $($r.command)" -NoNewWindow -PassThru -Wait
|
|
$ok = ($p.ExitCode -eq $expected)
|
|
@{ ok=$ok; ev=("ExitCode={0} (expect {1})" -f $p.ExitCode, $expected) }
|
|
} catch { @{ ok=$false; ev=("Error: {0}" -f $_.Exception.Message) } }
|
|
}
|
|
|
|
function Test-ScriptBlock {
|
|
param($r)
|
|
try {
|
|
$sb = [ScriptBlock]::Create($r.script)
|
|
$res = & $sb
|
|
if ($null -eq $res) { return @{ ok=$false; ev="Script returned `$null (treat as SKIPPED)"; skipped=$true } }
|
|
$ok = [bool]$res
|
|
@{ ok=$ok; ev=("Script returned: {0}. Expected $true; return $null to SKIP." -f $res) }
|
|
} catch { @{ ok=$false; ev=("Error: {0}" -f $_.Exception.Message) } }
|
|
}
|
|
|
|
function Test-ADOptional {
|
|
param($r)
|
|
if (-not (Get-Module -ListAvailable ActiveDirectory)) {
|
|
return @{ ok=$false; ev="Skipped (ActiveDirectory module not found)"; skipped=$true }
|
|
}
|
|
try {
|
|
$sb = [ScriptBlock]::Create($r.script)
|
|
$res = & $sb
|
|
if ($null -eq $res) { return @{ ok=$false; ev="Script returned `$null (treat as SKIPPED)"; skipped=$true } }
|
|
$ok = [bool]$res
|
|
@{ ok=$ok; ev=("Script returned: {0}. Expected $true; return $null to SKIP." -f $res) }
|
|
} catch { @{ ok=$false; ev=("Error: {0}" -f $_.Exception.Message) } }
|
|
}
|
|
|
|
# Load rules
|
|
$ruleFiles = Get-ChildItem -Path $RulesPath -Filter *.json -File
|
|
$allRules = @()
|
|
foreach ($f in $ruleFiles) {
|
|
$data = Get-Content $f.FullName -Raw | ConvertFrom-Json
|
|
if ($data -is [System.Array]) { $allRules += $data } else { $allRules += ,$data }
|
|
}
|
|
|
|
# Load profile object unless All
|
|
$ProfileObj = $null
|
|
if ($Profile -ne 'All') {
|
|
$profileFile = Join-Path $ProfilesPath ("{0}.json" -f $Profile.ToLower())
|
|
if (-not (Test-Path $profileFile)) { throw "Profile not found: $profileFile" }
|
|
$ProfileObj = Get-Content $profileFile -Raw | ConvertFrom-Json
|
|
}
|
|
|
|
# Evaluate (single level or all levels)
|
|
$results = New-Object System.Collections.Generic.List[object]
|
|
|
|
# Determine rule set
|
|
$rules = $allRules
|
|
if ($Profile -ne 'All' -and $ProfileObj -and ($ProfileObj.PSObject.Properties.Name -contains 'includeRuleIds') -and $ProfileObj.includeRuleIds) {
|
|
$rules = @()
|
|
foreach ($r in $allRules) { if ($ProfileObj.includeRuleIds -contains $r.id) { $rules += ,$r } }
|
|
}
|
|
|
|
$levelsToRun = if ($Profile -eq 'All') { @('ML1','ML2','ML3') } else { @($Profile) }
|
|
$levelOrder = @{ ML1=1; ML2=2; ML3=3 }
|
|
|
|
foreach ($runLevel in $levelsToRun) {
|
|
foreach ($rule in $rules) {
|
|
switch ($rule.type) {
|
|
'registry' { $r = Test-Registry $rule }
|
|
'file' { $r = Test-File $rule }
|
|
'command' { $r = Test-Command $rule }
|
|
'scriptblock' { $r = Test-ScriptBlock $rule }
|
|
'ad-optional' { $r = Test-ADOptional $rule }
|
|
default { $r = @{ ok=$false; ev=("Unknown type {0}" -f $rule.type) } }
|
|
}
|
|
|
|
$applies = $true
|
|
if ($rule.minLevel) { $applies = ($levelOrder[$runLevel] -ge $levelOrder[$rule.minLevel]) }
|
|
|
|
if ($r.skipped) { $status = 'SKIPPED' }
|
|
else {
|
|
if (-not $applies) { $status = 'N/A' }
|
|
else { if ($r.ok) { $status = 'PASS' } else { $status = 'FAIL' } }
|
|
}
|
|
|
|
$results.Add([pscustomobject]@{
|
|
RuleId = $rule.id
|
|
Title = $rule.title
|
|
Strategy = $rule.strategy
|
|
Level = $runLevel
|
|
Status = $status
|
|
Evidence = $r.ev
|
|
Timestamp= (Get-Date).ToString("s")
|
|
})
|
|
}
|
|
}
|
|
|
|
# Score per level (avoid $pass name to prevent any oddities)
|
|
$levelScores = @{}
|
|
foreach ($lvl in $levelsToRun) {
|
|
$scored = $results | Where-Object { $_.Level -eq $lvl -and $_.Status -in 'PASS','FAIL' }
|
|
$passCount = ($scored | Where-Object { $_.Status -eq 'PASS' }).Count
|
|
$failCount = ($scored | Where-Object { $_.Status -eq 'FAIL' }).Count
|
|
$totalCount = [math]::Max(1, $scored.Count)
|
|
$pct = [math]::Round(100 * $passCount / $totalCount, 1)
|
|
$levelScores[$lvl] = @{ Pass=$passCount; Fail=$failCount; Total=$totalCount; Pct=$pct }
|
|
Write-Host ("E8-CAT {0} score: {1}% (PASS={2} / FAIL={3} / Total={4})" -f $lvl,$pct,$passCount,$failCount,$totalCount)
|
|
}
|
|
|
|
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
|
$ts = (Get-Date).ToString("yyyyMMdd-HHmmss")
|
|
$jsonPath = Join-Path $OutDir ("E8CAT-{0}-{1}.json" -f $Profile,$ts)
|
|
$csvPath = Join-Path $OutDir ("E8CAT-{0}-{1}.csv" -f $Profile,$ts)
|
|
$htmlPath = Join-Path $OutDir ("E8CAT-{0}-{1}.html" -f $Profile,$ts)
|
|
|
|
$results | ConvertTo-Json -Depth 6 | Set-Content -Path $jsonPath -Encoding UTF8
|
|
$results | Export-Csv -NoTypeInformation -Path $csvPath -Encoding UTF8
|
|
|
|
$summary = ''
|
|
foreach ($k in ('ML1','ML2','ML3')) {
|
|
if ($levelScores.ContainsKey($k)) {
|
|
$s = $levelScores[$k]
|
|
$summary += ("<li><strong>{0}</strong>: {1}% (PASS {2} / FAIL {3} / Total {4})</li>" -f $k,$s.Pct,$s.Pass,$s.Fail,$s.Total)
|
|
}
|
|
}
|
|
$summary = "<ul>" + $summary + "</ul>"
|
|
|
|
$rows = $results | ForEach-Object {
|
|
"<tr><td>$($_.RuleId)</td><td>$($_.Strategy)</td><td>$($_.Title)</td><td>$($_.Level)</td><td>$($_.Status)</td><td><pre>$($_.Evidence -replace '<','<')</pre></td></tr>"
|
|
} | Out-String
|
|
|
|
@"
|
|
<!doctype html><html><head><meta charset="utf-8"><title>E8-CAT $Profile</title>
|
|
<style>body{font-family:Segoe UI,Arial;margin:24px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px}th{background:#f5f5f5;text-align:left}td pre{white-space:pre-wrap}</style>
|
|
</head><body>
|
|
<h1>E8-CAT - Profile $Profile</h1>
|
|
<h3>Scores</h3>
|
|
$summary
|
|
<table><thead><tr><th>RuleId</th><th>Strategy</th><th>Title</th><th>Level</th><th>Status</th><th>Evidence</th></tr></thead>
|
|
<tbody>
|
|
$rows
|
|
</tbody></table>
|
|
</body></html>
|
|
"@ | Set-Content -Path $htmlPath -Encoding UTF8
|
|
|
|
Write-Host "Saved: $jsonPath"
|
|
Write-Host "Saved: $csvPath"
|
|
Write-Host "Saved: $htmlPath"
|