198 lines
10 KiB
PowerShell
198 lines
10 KiB
PowerShell
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function Has-Property {
|
|
param([object]$Object, [string]$Name)
|
|
return $null -ne $Object -and ($Object.PSObject.Properties.Name -contains $Name)
|
|
}
|
|
|
|
function Require-Property {
|
|
param([object]$Object, [string]$Name, [string]$Context)
|
|
if (-not (Has-Property $Object $Name)) { throw "$Context missing field: $Name" }
|
|
return $Object.$Name
|
|
}
|
|
|
|
function Require-NonEmptyString {
|
|
param([object]$Object, [string]$Name, [string]$Context)
|
|
$value = Require-Property $Object $Name $Context
|
|
if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace($value)) { throw "$Context.$Name must be a non-empty string" }
|
|
return $value
|
|
}
|
|
|
|
function Require-Array {
|
|
param([object]$Object, [string]$Name, [string]$Context)
|
|
$value = Require-Property $Object $Name $Context
|
|
return @($value)
|
|
}
|
|
|
|
function Require-NonNegativeInteger {
|
|
param([object]$Object, [string]$Name, [string]$Context)
|
|
$value = Require-Property $Object $Name $Context
|
|
if ($value -is [bool] -or $value -isnot [ValueType]) { throw "$Context.$Name must be a non-negative integer" }
|
|
$number = [double]$value
|
|
if ($number -lt 0 -or [Math]::Truncate($number) -ne $number) { throw "$Context.$Name must be a non-negative integer" }
|
|
return [int64]$number
|
|
}
|
|
|
|
function Require-Enum {
|
|
param([object]$Object, [string]$Name, [string[]]$Allowed, [string]$Context)
|
|
$value = Require-NonEmptyString $Object $Name $Context
|
|
if ($value -notin $Allowed) { throw "invalid $Context.$Name`: $value" }
|
|
return $value
|
|
}
|
|
|
|
function Require-Rfc3339Utc {
|
|
param([string]$Value, [string]$Context)
|
|
try { $parsed = [DateTimeOffset]::Parse($Value) } catch { throw "$Context must be RFC3339" }
|
|
if ($parsed.Offset -ne [TimeSpan]::Zero) { throw "$Context must use UTC" }
|
|
}
|
|
|
|
$resolvedPath = (Resolve-Path -LiteralPath $Path).Path
|
|
$raw = [IO.File]::ReadAllText($resolvedPath, (New-Object Text.UTF8Encoding($false, $true)))
|
|
if ($raw.Contains([char]0xfffd) -or $raw.Contains([char]0)) { throw 'JSON contains encoding control characters' }
|
|
Add-Type -AssemblyName System.Web.Extensions
|
|
try { $shape = (New-Object Web.Script.Serialization.JavaScriptSerializer).DeserializeObject($raw) } catch { throw "invalid JSON report: $($_.Exception.Message)" }
|
|
if ($shape -isnot [Collections.IDictionary]) { throw 'report root must be an object' }
|
|
foreach ($name in @('top_actions', 'findings', 'limitations')) {
|
|
if (-not $shape.ContainsKey($name) -or $shape[$name] -isnot [array]) { throw "report.$name must be an array" }
|
|
}
|
|
if (-not $shape.ContainsKey('counts') -or $shape['counts'] -isnot [Collections.IDictionary]) { throw 'report.counts must be an object' }
|
|
if (-not $shape.ContainsKey('scope') -or $shape['scope'] -isnot [Collections.IDictionary]) { throw 'report.scope must be an object' }
|
|
if ($shape.ContainsKey('evidence') -and $shape['evidence'] -isnot [array]) { throw 'report.evidence must be an array' }
|
|
foreach ($item in @($shape['top_actions'])) {
|
|
if ($item -isnot [Collections.IDictionary] -or -not $item.ContainsKey('evidence') -or $item['evidence'] -isnot [array]) { throw 'top_action.evidence must be an array' }
|
|
}
|
|
foreach ($item in @($shape['findings'])) {
|
|
if ($item -isnot [Collections.IDictionary] -or -not $item.ContainsKey('evidence') -or $item['evidence'] -isnot [array]) { throw 'finding.evidence must be an array' }
|
|
}
|
|
try { $report = $raw | ConvertFrom-Json } catch { throw "invalid JSON report: $($_.Exception.Message)" }
|
|
if ($null -eq $report -or $report -is [array] -or $report -isnot [psobject]) { throw 'report root must be an object' }
|
|
|
|
$schemaVersion = Require-NonEmptyString $report 'schema_version' 'report'
|
|
if ($schemaVersion -ne '1.0') { throw "unsupported schema_version: $schemaVersion" }
|
|
$mode = Require-Enum $report 'mode' @('executive', 'standard', 'full') 'report'
|
|
$decision = Require-Enum $report 'decision' @('merge', 'action_required', 'reorder', 'observe', 'blocked') 'report'
|
|
$severity = Require-Enum $report 'severity' @('blocking', 'high', 'medium', 'low') 'report'
|
|
$securityGate = Require-Enum $report 'security_gate' @('passed', 'failed', 'partial', 'not_run', 'not_applicable') 'report'
|
|
$verification = Require-Enum $report 'verification' @('complete', 'passed', 'failed', 'partial', 'not_run', 'stale', 'not_applicable') 'report'
|
|
|
|
$counts = Require-Property $report 'counts' 'report'
|
|
if ($null -eq $counts -or $counts -is [array] -or $counts -isnot [psobject]) { throw 'report.counts must be an object' }
|
|
$countValues = [ordered]@{}
|
|
foreach ($name in @('blocking', 'high', 'medium', 'low')) {
|
|
$countValues[$name] = Require-NonNegativeInteger $counts $name 'report.counts'
|
|
}
|
|
|
|
$scope = Require-Property $report 'scope' 'report'
|
|
if ($null -eq $scope -or $scope -is [array] -or $scope -isnot [psobject]) { throw 'report.scope must be an object' }
|
|
Require-NonEmptyString $scope 'owner' 'report.scope' | Out-Null
|
|
Require-NonEmptyString $scope 'repo' 'report.scope' | Out-Null
|
|
Require-NonNegativeInteger $scope 'items' 'report.scope' | Out-Null
|
|
|
|
$topActions = Require-Array $report 'top_actions' 'report'
|
|
$findings = Require-Array $report 'findings' 'report'
|
|
$limitations = Require-Array $report 'limitations' 'report'
|
|
if ($mode -eq 'executive' -and $topActions.Count -gt 5) { throw 'executive report has more than five top actions' }
|
|
foreach ($item in $limitations) {
|
|
if ($item -isnot [string] -or [string]::IsNullOrWhiteSpace($item)) { throw 'report.limitations entries must be non-empty strings' }
|
|
}
|
|
|
|
$expectedFindingCount = $countValues.blocking + $countValues.high + $countValues.medium + $countValues.low
|
|
if ($expectedFindingCount -ne $findings.Count) { throw "report.counts total $expectedFindingCount does not match findings count $($findings.Count)" }
|
|
$expectedSeverity = if ($countValues.blocking -gt 0) { 'blocking' } elseif ($countValues.high -gt 0) { 'high' } elseif ($countValues.medium -gt 0) { 'medium' } else { 'low' }
|
|
if ($severity -ne $expectedSeverity) { throw "report.severity $severity does not match highest finding severity $expectedSeverity" }
|
|
|
|
$findingIds = @{}
|
|
$actualFindingCounts = @{ blocking = 0; high = 0; medium = 0; low = 0 }
|
|
foreach ($finding in $findings) {
|
|
$id = Require-NonEmptyString $finding 'id' 'finding'
|
|
if ($findingIds.ContainsKey($id)) { throw "duplicate finding id: $id" }
|
|
$findingIds[$id] = $true
|
|
$findingSeverity = Require-Enum $finding 'severity' @('blocking', 'high', 'medium', 'low') "finding[$id]"
|
|
$actualFindingCounts[$findingSeverity]++
|
|
Require-Enum $finding 'status' @('open', 'resolved', 'accepted', 'candidate', 'stale') "finding[$id]" | Out-Null
|
|
Require-NonEmptyString $finding 'summary' "finding[$id]" | Out-Null
|
|
$findingEvidence = Require-Array $finding 'evidence' "finding[$id]"
|
|
if ($findingEvidence.Count -eq 0) { throw "finding has no evidence: $id" }
|
|
if ($countValues[$findingSeverity] -le 0) { throw "finding severity $findingSeverity is not represented in report.counts" }
|
|
}
|
|
foreach ($name in @('blocking', 'high', 'medium', 'low')) {
|
|
if ($actualFindingCounts[$name] -ne $countValues[$name]) {
|
|
throw "report.counts.$name $($countValues[$name]) does not match $($actualFindingCounts[$name]) findings"
|
|
}
|
|
}
|
|
|
|
$actionIds = @{}
|
|
foreach ($action in $topActions) {
|
|
$id = Require-NonEmptyString $action 'id' 'top_action'
|
|
if ($actionIds.ContainsKey($id)) { throw "duplicate top action id: $id" }
|
|
$actionIds[$id] = $true
|
|
Require-NonEmptyString $action 'owner' "top_action[$id]" | Out-Null
|
|
Require-NonEmptyString $action 'action' "top_action[$id]" | Out-Null
|
|
$actionEvidence = Require-Array $action 'evidence' "top_action[$id]"
|
|
if ($actionEvidence.Count -eq 0) { throw "top action has no evidence: $id" }
|
|
if (Has-Property $action 'severity') {
|
|
Require-Enum $action 'severity' @('blocking', 'high', 'medium', 'low') "top_action[$id]" | Out-Null
|
|
}
|
|
}
|
|
|
|
if (Has-Property $report 'run') {
|
|
$run = $report.run
|
|
Require-NonEmptyString $run 'run_id' 'report.run' | Out-Null
|
|
Require-Enum $run 'trigger' @('pull_request_opened', 'pull_request_synchronized', 'review_submitted', 'schedule', 'manual') 'report.run' | Out-Null
|
|
$asOf = Require-NonEmptyString $run 'as_of' 'report.run'
|
|
Require-Rfc3339Utc $asOf 'report.run.as_of'
|
|
if (Has-Property $run 'started_at') {
|
|
Require-Rfc3339Utc (Require-NonEmptyString $run 'started_at' 'report.run') 'report.run.started_at'
|
|
}
|
|
}
|
|
|
|
if (Has-Property $report 'evidence') {
|
|
$evidence = Require-Array $report 'evidence' 'report'
|
|
$evidenceIds = @{}
|
|
foreach ($item in $evidence) {
|
|
$id = Require-NonEmptyString $item 'id' 'evidence'
|
|
if ($evidenceIds.ContainsKey($id)) { throw "duplicate evidence id: $id" }
|
|
$evidenceIds[$id] = $true
|
|
Require-Enum $item 'kind' @('pr_api', 'diff', 'review', 'ci', 'local_checkout', 'test_output', 'contract_test', 'integration_test', 'cli_help', 'queue_snapshot', 'human_policy') "evidence[$id]" | Out-Null
|
|
Require-Enum $item 'status' @('complete', 'partial', 'failed', 'not_run', 'stale') "evidence[$id]" | Out-Null
|
|
Require-NonEmptyString $item 'source' "evidence[$id]" | Out-Null
|
|
Require-NonEmptyString $item 'ref' "evidence[$id]" | Out-Null
|
|
Require-NonEmptyString $item 'scope' "evidence[$id]" | Out-Null
|
|
if (Has-Property $item 'observed_at') {
|
|
Require-Rfc3339Utc (Require-NonEmptyString $item 'observed_at' "evidence[$id]") "evidence[$id].observed_at"
|
|
}
|
|
}
|
|
|
|
foreach ($finding in $findings) {
|
|
foreach ($reference in @(Require-Array $finding 'evidence' "finding[$($finding.id)]")) {
|
|
if ($reference -isnot [string] -or -not $evidenceIds.ContainsKey($reference)) {
|
|
throw "finding[$($finding.id)] references unknown evidence: $reference"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Has-Property $report 'next_run') {
|
|
Require-NonEmptyString $report.next_run 'reason' 'report.next_run' | Out-Null
|
|
Require-NonNegativeInteger $report.next_run 'after_minutes' 'report.next_run' | Out-Null
|
|
}
|
|
|
|
if ($decision -eq 'merge') {
|
|
if ($securityGate -ne 'passed') { throw 'merge decision requires security_gate=passed' }
|
|
if ($verification -notin @('complete', 'passed')) { throw 'merge decision requires completed verification' }
|
|
if ($countValues.blocking -gt 0 -or $countValues.high -gt 0) { throw 'merge decision cannot contain blocking or high findings' }
|
|
}
|
|
if (($securityGate -eq 'failed' -or $verification -eq 'failed' -or $countValues.blocking -gt 0) -and $decision -eq 'merge') {
|
|
throw 'failed gate cannot produce merge decision'
|
|
}
|
|
|
|
if ($raw -match "`e\[|<span|</span>") { throw 'JSON contains presentation markers' }
|
|
if ($raw -match '(?i)(authorization|bearer)\s+[A-Za-z0-9._-]{20,}') { throw 'JSON contains a credential-like value' }
|
|
|
|
Write-Output "maintenance report contract passed: $resolvedPath"
|