Private/Manifest.ps1
|
function Test-ICObjectProperty { [CmdletBinding()] param( [AllowNull()] [object]$InputObject, [Parameter(Mandatory)] [string]$Name ) if ($null -eq $InputObject) { return $false } if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject.Contains($Name) } return $null -ne $InputObject.PSObject.Properties[$Name] } function Resolve-ICSafeRelativePath { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$RelativePath, [Parameter(Mandatory)] [string]$RootPath, [string]$Description = 'Path' ) if ([string]::IsNullOrWhiteSpace($RelativePath)) { throw "$Description cannot be empty." } if ($RelativePath.Length -gt 1024) { throw "$Description exceeds the 1024-character safety limit." } if ($RelativePath.IndexOf([char]0) -ge 0) { throw "$Description contains a NUL character." } if ([System.IO.Path]::IsPathRooted($RelativePath) -or $RelativePath -match '^[A-Za-z]:') { throw "$Description '$RelativePath' must be relative." } $normalized = $RelativePath.Replace([char]'\', [char]'/') if ($normalized.StartsWith('/') -or $normalized.EndsWith('/')) { throw "$Description '$RelativePath' has an empty path segment." } $segments = @($normalized -split '/') if ($segments.Count -eq 0) { throw "$Description '$RelativePath' is invalid." } foreach ($segment in $segments) { if ([string]::IsNullOrEmpty($segment) -or $segment -eq '.' -or $segment -eq '..') { throw "$Description '$RelativePath' contains an unsafe path segment." } if ($segment.Length -gt 255) { throw "$Description '$RelativePath' contains a path segment longer than 255 characters." } if ($segment -match '[<>:"|?*\x00-\x1f]' -or $segment -match '[\. ]$') { throw "$Description '$RelativePath' contains characters that are unsafe on Windows." } if ($segment -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$') { throw "$Description '$RelativePath' contains a reserved Windows device name." } } $rootFull = [System.IO.Path]::GetFullPath($RootPath).TrimEnd([char]'\', [char]'/') $candidate = [System.IO.Path]::GetFullPath((Join-Path $rootFull ($normalized -replace '/', [System.IO.Path]::DirectorySeparatorChar))) $rootPrefix = $rootFull + [System.IO.Path]::DirectorySeparatorChar if (-not $candidate.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "$Description '$RelativePath' resolves outside '$rootFull'." } return [pscustomobject][ordered]@{ RelativePath = $normalized FullPath = $candidate Segments = @($segments) } } function Assert-ICNoReparsePoint { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$RootPath, [Parameter(Mandatory)] [string]$Path ) $rootFull = [System.IO.Path]::GetFullPath($RootPath).TrimEnd([char]'\', [char]'/') $pathFull = [System.IO.Path]::GetFullPath($Path) $rootPrefix = $rootFull + [System.IO.Path]::DirectorySeparatorChar if ($pathFull -ne $rootFull -and -not $pathFull.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Path '$pathFull' is outside capsule root '$rootFull'." } $paths = New-Object System.Collections.Generic.List[string] $paths.Add($rootFull) if ($pathFull -ne $rootFull) { $relative = $pathFull.Substring($rootPrefix.Length) $current = $rootFull foreach ($segment in @($relative -split '[\\/]')) { $current = Join-Path $current $segment $paths.Add($current) } } foreach ($candidate in $paths) { if (-not (Test-Path -LiteralPath $candidate)) { break } $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop if (([int]$item.Attributes -band [int][System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Reparse point '$candidate' is not permitted inside a capsule." } } } function Get-ICSafeCapsuleFile { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CapsuleRoot ) $rootFull = [System.IO.Path]::GetFullPath($CapsuleRoot).TrimEnd([char]'\', [char]'/') Assert-ICNoReparsePoint -RootPath $rootFull -Path $rootFull $directories = New-Object 'System.Collections.Generic.Queue[string]' $directories.Enqueue($rootFull) $files = New-Object System.Collections.ArrayList while ($directories.Count -gt 0) { $directory = $directories.Dequeue() foreach ($item in Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop) { if (([int]$item.Attributes -band [int][System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Reparse point '$($item.FullName)' is not permitted inside a capsule." } if ($item.PSIsContainer) { $directories.Enqueue($item.FullName) } else { [void]$files.Add($item) } } } return @($files) } function Get-ICManifestFiles { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CapsuleRoot ) $excluded = @('metadata/manifest.json', 'metadata/manifest.sha256', 'metadata/manifest.sha256.p7s') return @( Get-ICSafeCapsuleFile -CapsuleRoot $CapsuleRoot | ForEach-Object { $relative = Get-ICRelativePath -BasePath $CapsuleRoot -Path $_.FullName if ($relative -notin $excluded) { [pscustomobject]@{ File = $_ RelativePath = $relative } } } | Sort-Object RelativePath ) } function New-ICManifest { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CapsuleRoot, [Parameter(Mandatory)] [string]$CapsuleId ) $metadataDirectory = Join-Path $CapsuleRoot 'metadata' if (-not (Test-Path -LiteralPath $metadataDirectory)) { New-Item -ItemType Directory -Path $metadataDirectory -Force | Out-Null } Assert-ICNoReparsePoint -RootPath $CapsuleRoot -Path $metadataDirectory $entries = New-Object System.Collections.ArrayList foreach ($entry in Get-ICManifestFiles -CapsuleRoot $CapsuleRoot) { $hash = Get-FileHash -LiteralPath $entry.File.FullName -Algorithm SHA256 -ErrorAction Stop [void]$entries.Add([ordered]@{ path = $entry.RelativePath length = [int64]$entry.File.Length lastWriteTimeUtc = $entry.File.LastWriteTimeUtc.ToString('o') sha256 = $hash.Hash.ToLowerInvariant() }) } $manifest = [ordered]@{ '$schema' = $script:ICManifestSchema schemaVersion = $script:ICSchemaVersion capsuleId = $CapsuleId algorithm = 'SHA-256' createdAtUtc = [datetime]::UtcNow.ToString('o') files = @($entries) } $jsonPath = Join-Path $metadataDirectory 'manifest.json' $textPath = Join-Path $metadataDirectory 'manifest.sha256' [void](Write-ICJsonFile -Path $jsonPath -InputObject $manifest -Depth 8) $checksumLines = @($entries | ForEach-Object { '{0} {1}' -f $_.sha256, $_.path }) [void](Write-ICUtf8File -Path $textPath -Content (($checksumLines -join [Environment]::NewLine) + [Environment]::NewLine)) return [pscustomobject][ordered]@{ ManifestPath = $jsonPath TextPath = $textPath FileCount = $entries.Count } } function New-ICArchive { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CapsuleRoot ) Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop $rootFull = [System.IO.Path]::GetFullPath($CapsuleRoot).TrimEnd([char]'\', [char]'/') $archivePath = "$rootFull.zip" $sidecarPath = "$archivePath.sha256" if (Test-Path -LiteralPath $archivePath) { throw "Archive '$archivePath' already exists." } if (Test-Path -LiteralPath $sidecarPath) { throw "Archive sidecar '$sidecarPath' already exists." } $archiveDirectory = [System.IO.Path]::GetDirectoryName($archivePath) $temporaryPath = Join-Path $archiveDirectory ('.{0}.{1}.partial' -f [System.IO.Path]::GetFileName($archivePath), [guid]::NewGuid().ToString('N')) try { $files = @(Get-ICSafeCapsuleFile -CapsuleRoot $rootFull | Sort-Object FullName) $archive = [System.IO.Compression.ZipFile]::Open($temporaryPath, [System.IO.Compression.ZipArchiveMode]::Create) try { foreach ($file in $files) { Assert-ICNoReparsePoint -RootPath $rootFull -Path $file.FullName $relativePath = Get-ICRelativePath -BasePath $rootFull -Path $file.FullName $zipEntry = $archive.CreateEntry($relativePath, [System.IO.Compression.CompressionLevel]::Optimal) if ($file.LastWriteTimeUtc.Year -ge 1980 -and $file.LastWriteTimeUtc.Year -le 2107) { $zipEntry.LastWriteTime = New-Object System.DateTimeOffset($file.LastWriteTimeUtc) } $sourceStream = $null $destinationStream = $null try { $sourceStream = New-Object System.IO.FileStream( $file.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read ) $destinationStream = $zipEntry.Open() $sourceStream.CopyTo($destinationStream) } finally { if ($null -ne $sourceStream) { $sourceStream.Dispose() } if ($null -ne $destinationStream) { $destinationStream.Dispose() } } } } finally { $archive.Dispose() } $hash = Get-FileHash -LiteralPath $temporaryPath -Algorithm SHA256 -ErrorAction Stop [System.IO.File]::Move($temporaryPath, $archivePath) $line = '{0} {1}' -f $hash.Hash.ToLowerInvariant(), (Split-Path -Leaf $archivePath) [void](Write-ICUtf8File -Path $sidecarPath -Content ($line + [Environment]::NewLine)) return [pscustomobject][ordered]@{ ArchivePath = $archivePath SidecarPath = $sidecarPath SHA256 = $hash.Hash.ToLowerInvariant() } } finally { if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } } } function Find-ICManifestRoot { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path ) $root = [System.IO.Path]::GetFullPath($Path).TrimEnd([char]'\', [char]'/') Assert-ICNoReparsePoint -RootPath $root -Path $root $direct = Join-Path $root 'metadata/manifest.json' Assert-ICNoReparsePoint -RootPath $root -Path $direct if (Test-Path -LiteralPath $direct -PathType Leaf) { return $root } $files = @(Get-ICSafeCapsuleFile -CapsuleRoot $root) $manifests = @($files | Where-Object { $_.Name -eq 'manifest.json' -and (Split-Path -Leaf (Split-Path -Parent $_.FullName)) -eq 'metadata' }) if ($manifests.Count -ne 1) { throw "Expected exactly one metadata/manifest.json below '$root'; found $($manifests.Count)." } $manifestRoot = Split-Path -Parent (Split-Path -Parent $manifests[0].FullName) $manifestRootFull = [System.IO.Path]::GetFullPath($manifestRoot).TrimEnd([char]'\', [char]'/') $manifestRootPrefix = $manifestRootFull + [System.IO.Path]::DirectorySeparatorChar $outsideFiles = @($files | Where-Object { -not $_.FullName.StartsWith($manifestRootPrefix, [System.StringComparison]::OrdinalIgnoreCase) }) if ($outsideFiles.Count -gt 0) { throw "Found $($outsideFiles.Count) file(s) outside the directory containing metadata/manifest.json." } return $manifestRootFull } function Read-ICValidatedManifest { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CapsuleRoot ) $root = [System.IO.Path]::GetFullPath($CapsuleRoot).TrimEnd([char]'\', [char]'/') $manifestPath = Join-Path $root 'metadata/manifest.json' Assert-ICNoReparsePoint -RootPath $root -Path $manifestPath if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw "Manifest not found at '$manifestPath'." } try { $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { throw "Manifest '$manifestPath' is not valid JSON: $($_.Exception.Message)" } if ($null -eq $manifest -or $manifest -is [System.Array]) { throw "Manifest '$manifestPath' must contain one JSON object." } if (-not (Test-ICObjectProperty -InputObject $manifest -Name 'schemaVersion')) { throw "Manifest '$manifestPath' does not contain a schemaVersion." } $schemaVersion = [string](Get-ICPropertyValue -InputObject $manifest -Name 'schemaVersion') if ($schemaVersion -notin @('1.0', '1.1', '1.2')) { throw "Manifest '$manifestPath' uses unsupported schema version '$schemaVersion'." } if (-not (Test-ICObjectProperty -InputObject $manifest -Name 'algorithm') -or [string]$manifest.algorithm -ne 'SHA-256') { throw "Manifest '$manifestPath' must specify the SHA-256 algorithm." } if (-not (Test-ICObjectProperty -InputObject $manifest -Name 'capsuleId') -or [string]::IsNullOrWhiteSpace([string]$manifest.capsuleId)) { throw "Manifest '$manifestPath' does not contain a valid capsuleId." } if (-not (Test-ICObjectProperty -InputObject $manifest -Name 'files')) { throw "Manifest '$manifestPath' does not contain a files array." } $rawFiles = $manifest.PSObject.Properties['files'].Value if ($null -eq $rawFiles -or $rawFiles -isnot [System.Array]) { throw "Manifest '$manifestPath' files value must be an array." } $validatedEntries = New-Object System.Collections.ArrayList $expectedPaths = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) $index = -1 foreach ($entry in @($rawFiles)) { $index++ if ($null -eq $entry -or $entry -is [string]) { throw "Manifest file entry $index must be an object." } foreach ($required in @('path', 'length', 'lastWriteTimeUtc', 'sha256')) { if (-not (Test-ICObjectProperty -InputObject $entry -Name $required)) { throw "Manifest file entry $index is missing '$required'." } } $validatedPath = Resolve-ICSafeRelativePath -RelativePath ([string]$entry.path) -RootPath $root -Description "Manifest path at index $index" if ($validatedPath.RelativePath -in @('metadata/manifest.json', 'metadata/manifest.sha256', 'metadata/manifest.sha256.p7s')) { throw "Manifest file entry $index references a manifest control file." } if (-not $expectedPaths.Add($validatedPath.RelativePath)) { throw "Manifest contains a duplicate or case-colliding path '$($validatedPath.RelativePath)'." } $lengthValue = Get-ICPropertyValue -InputObject $entry -Name 'length' if ($lengthValue -isnot [byte] -and $lengthValue -isnot [int16] -and $lengthValue -isnot [int32] -and $lengthValue -isnot [int64] -and $lengthValue -isnot [uint16] -and $lengthValue -isnot [uint32]) { throw "Manifest file entry '$($validatedPath.RelativePath)' has a non-integer length." } if ([int64]$lengthValue -lt 0) { throw "Manifest file entry '$($validatedPath.RelativePath)' has a negative length." } $hash = [string](Get-ICPropertyValue -InputObject $entry -Name 'sha256') if ($hash -cnotmatch '^[a-fA-F0-9]{64}$') { throw "Manifest file entry '$($validatedPath.RelativePath)' has an invalid SHA-256 value." } $lastWrite = [string](Get-ICPropertyValue -InputObject $entry -Name 'lastWriteTimeUtc') $parsedDate = [datetime]::MinValue if (-not [datetime]::TryParse( $lastWrite, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind, [ref]$parsedDate )) { throw "Manifest file entry '$($validatedPath.RelativePath)' has an invalid lastWriteTimeUtc value." } [void]$validatedEntries.Add([pscustomobject][ordered]@{ Path = $validatedPath.RelativePath FullPath = $validatedPath.FullPath ExpectedLength = [int64]$lengthValue ExpectedSHA256 = $hash.ToLowerInvariant() }) } return [pscustomobject][ordered]@{ Manifest = $manifest Entries = @($validatedEntries) } } function Test-ICChecksumList { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CapsuleRoot, [Parameter(Mandatory)] [object[]]$ManifestEntries ) $checksumPath = Join-Path $CapsuleRoot 'metadata/manifest.sha256' if (-not (Test-Path -LiteralPath $checksumPath -PathType Leaf)) { return $false } Assert-ICNoReparsePoint -RootPath $CapsuleRoot -Path $checksumPath $expected = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([System.StringComparer]::OrdinalIgnoreCase) foreach ($entry in @($ManifestEntries)) { $path = [string](Get-ICPropertyValue -InputObject $entry -Name 'Path' -Default (Get-ICPropertyValue -InputObject $entry -Name 'path')) $hash = [string](Get-ICPropertyValue -InputObject $entry -Name 'ExpectedSHA256' -Default (Get-ICPropertyValue -InputObject $entry -Name 'sha256')) $validated = Resolve-ICSafeRelativePath -RelativePath $path -RootPath $CapsuleRoot -Description 'Checksum-list path' if ($hash -cnotmatch '^[a-fA-F0-9]{64}$' -or $expected.ContainsKey($validated.RelativePath)) { return $false } $expected.Add($validated.RelativePath, $hash.ToLowerInvariant()) } $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) foreach ($line in Get-Content -LiteralPath $checksumPath -Encoding UTF8) { if ([string]::IsNullOrWhiteSpace($line)) { continue } if ($line -notmatch '^([a-fA-F0-9]{64}) (.+)$') { return $false } $hash = $Matches[1].ToLowerInvariant() try { $validated = Resolve-ICSafeRelativePath -RelativePath $Matches[2] -RootPath $CapsuleRoot -Description 'Checksum-list path' } catch { return $false } $path = $validated.RelativePath if (-not $seen.Add($path)) { return $false } if (-not $expected.ContainsKey($path) -or $expected[$path] -ne $hash) { return $false } } return ($seen.Count -eq $expected.Count) } function Test-ICDirectoryIntegrity { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CapsuleRoot ) $root = [System.IO.Path]::GetFullPath($CapsuleRoot).TrimEnd([char]'\', [char]'/') $validatedManifest = Read-ICValidatedManifest -CapsuleRoot $root $manifest = $validatedManifest.Manifest $entries = @($validatedManifest.Entries) $fileResults = New-Object System.Collections.ArrayList $expected = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) foreach ($entry in $entries) { [void]$expected.Add($entry.Path) Assert-ICNoReparsePoint -RootPath $root -Path $entry.FullPath if (-not (Test-Path -LiteralPath $entry.FullPath -PathType Leaf)) { [void]$fileResults.Add([pscustomobject][ordered]@{ Path = $entry.Path Status = 'Missing' ExpectedSHA256 = $entry.ExpectedSHA256 ActualSHA256 = $null ExpectedLength = $entry.ExpectedLength ActualLength = $null }) continue } $item = Get-Item -LiteralPath $entry.FullPath -Force -ErrorAction Stop $actualHash = (Get-FileHash -LiteralPath $entry.FullPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() $status = if ($actualHash -eq $entry.ExpectedSHA256 -and [int64]$item.Length -eq $entry.ExpectedLength) { 'Valid' } else { 'Modified' } [void]$fileResults.Add([pscustomobject][ordered]@{ Path = $entry.Path Status = $status ExpectedSHA256 = $entry.ExpectedSHA256 ActualSHA256 = $actualHash ExpectedLength = $entry.ExpectedLength ActualLength = [int64]$item.Length }) } $excluded = @('metadata/manifest.json', 'metadata/manifest.sha256', 'metadata/manifest.sha256.p7s') foreach ($file in Get-ICSafeCapsuleFile -CapsuleRoot $root) { $relative = Get-ICRelativePath -BasePath $root -Path $file.FullName if ($relative -in $excluded) { continue } if (-not $expected.Contains($relative)) { [void]$fileResults.Add([pscustomobject][ordered]@{ Path = $relative Status = 'Unexpected' ExpectedSHA256 = $null ActualSHA256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() ExpectedLength = $null ActualLength = [int64]$file.Length }) } } $checksumListValid = Test-ICChecksumList -CapsuleRoot $root -ManifestEntries $entries $missing = @($fileResults | Where-Object Status -eq 'Missing').Count $modified = @($fileResults | Where-Object Status -eq 'Modified').Count $unexpected = @($fileResults | Where-Object Status -eq 'Unexpected').Count $valid = @($fileResults | Where-Object Status -eq 'Valid').Count $signaturePresent = $false $signatureValid = $null $signatureChainValid = $null $signerSubject = $null $signerThumbprint = $null $signaturePath = Join-Path $root 'metadata/manifest.sha256.p7s' if (Test-Path -LiteralPath $signaturePath -PathType Leaf) { Assert-ICNoReparsePoint -RootPath $root -Path $signaturePath $signaturePresent = $true $signature = Test-ICManifestSignature -ManifestTextPath (Join-Path $root 'metadata/manifest.sha256') -SignaturePath $signaturePath $signatureValid = [bool]$signature.SignatureValid $signatureChainValid = $signature.ChainValid $signerSubject = $signature.SignerSubject $signerThumbprint = $signature.SignerThumbprint } return [pscustomobject][ordered]@{ PSTypeName = 'IncidentCapsule.IntegrityResult' Path = $root SourceType = 'Directory' CapsuleId = [string]$manifest.capsuleId SchemaVersion = [string]$manifest.schemaVersion Algorithm = [string]$manifest.algorithm IsValid = ($missing -eq 0 -and $modified -eq 0 -and $unexpected -eq 0 -and $checksumListValid -and ($signatureValid -ne $false)) ArchiveHashValid = $null ChecksumListValid = $checksumListValid SignaturePresent = $signaturePresent SignatureValid = $signatureValid SignatureChainValid = $signatureChainValid SignerSubject = $signerSubject SignerThumbprint = $signerThumbprint FilesExpected = $entries.Count FilesValid = $valid FilesMissing = $missing FilesModified = $modified FilesUnexpected = $unexpected FileResults = @($fileResults) } } function Test-ICArchiveEntryType { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Entry ) $attributeBytes = [System.BitConverter]::GetBytes([int]$Entry.ExternalAttributes) $attributes = [System.BitConverter]::ToUInt32($attributeBytes, 0) $unixType = (($attributes -shr 16) -band 0xF000) $windowsAttributes = ($attributes -band 0xFFFF) if ($unixType -eq 0xA000 -or (($windowsAttributes -band [int][System.IO.FileAttributes]::ReparsePoint) -ne 0)) { throw "Archive entry '$($Entry.FullName)' represents a symbolic link or reparse point." } } function Get-ICValidatedZipEntry { [CmdletBinding()] param( [Parameter(Mandatory)] [System.IO.Compression.ZipArchive]$Archive, [Parameter(Mandatory)] [string]$DestinationRoot, [Parameter(Mandatory)] [int]$MaximumArchiveEntries, [Parameter(Mandatory)] [int64]$MaximumArchiveEntryBytes, [Parameter(Mandatory)] [int64]$MaximumArchiveExpandedBytes, [Parameter(Mandatory)] [double]$MaximumArchiveCompressionRatio ) if ($Archive.Entries.Count -gt $MaximumArchiveEntries) { throw "Archive contains $($Archive.Entries.Count) entries; the limit is $MaximumArchiveEntries." } $validated = New-Object System.Collections.ArrayList $entryTypes = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([System.StringComparer]::OrdinalIgnoreCase) [int64]$totalLength = 0 [int64]$totalCompressedLength = 0 foreach ($entry in $Archive.Entries) { Test-ICArchiveEntryType -Entry $entry $rawName = [string]$entry.FullName if ([string]::IsNullOrWhiteSpace($rawName)) { throw 'Archive contains an entry with an empty name.' } $isDirectory = $rawName.EndsWith('/') -or $rawName.EndsWith('\') $pathForValidation = if ($isDirectory) { $rawName.TrimEnd([char]'/', [char]'\') } else { $rawName } $safePath = Resolve-ICSafeRelativePath -RelativePath $pathForValidation -RootPath $DestinationRoot -Description 'Archive entry' if ($entryTypes.ContainsKey($safePath.RelativePath)) { throw "Archive contains duplicate or case-colliding entry '$($safePath.RelativePath)'." } $segments = @($safePath.Segments) for ($index = 1; $index -lt $segments.Count; $index++) { $ancestor = ($segments[0..($index - 1)] -join '/') if ($entryTypes.ContainsKey($ancestor) -and $entryTypes[$ancestor] -eq 'File') { throw "Archive entry '$($safePath.RelativePath)' is nested below file '$ancestor'." } } if (-not $isDirectory) { $descendantPrefix = $safePath.RelativePath + '/' foreach ($knownPath in @($entryTypes.Keys)) { if ($knownPath.StartsWith($descendantPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Archive file '$($safePath.RelativePath)' collides with child entry '$knownPath'." } } } if ($isDirectory -and ([int64]$entry.Length -ne 0 -or [int64]$entry.CompressedLength -ne 0)) { throw "Archive directory entry '$($safePath.RelativePath)' contains data." } if (-not $isDirectory) { if ([int64]$entry.Length -gt $MaximumArchiveEntryBytes) { throw "Archive entry '$($safePath.RelativePath)' exceeds the $MaximumArchiveEntryBytes-byte per-entry size limit." } if ([int64]$entry.Length -gt $MaximumArchiveExpandedBytes - $totalLength) { throw "Archive uncompressed size (expanded) exceeds the $MaximumArchiveExpandedBytes-byte limit." } $totalLength += [int64]$entry.Length $totalCompressedLength += [int64]$entry.CompressedLength $entryRatio = if ([int64]$entry.Length -eq 0) { 0.0 } elseif ([int64]$entry.CompressedLength -eq 0) { [double]::PositiveInfinity } else { [double]$entry.Length / [double]$entry.CompressedLength } if ($entryRatio -gt $MaximumArchiveCompressionRatio) { throw "Archive entry '$($safePath.RelativePath)' has compression ratio $([math]::Round($entryRatio, 2)); the limit is $MaximumArchiveCompressionRatio." } } $entryTypes.Add($safePath.RelativePath, $(if ($isDirectory) { 'Directory' } else { 'File' })) [void]$validated.Add([pscustomobject][ordered]@{ ZipEntry = $entry RelativePath = $safePath.RelativePath FullPath = $safePath.FullPath Segments = @($safePath.Segments) IsDirectory = $isDirectory Length = [int64]$entry.Length }) } $aggregateRatio = if ($totalLength -eq 0) { 0.0 } elseif ($totalCompressedLength -eq 0) { [double]::PositiveInfinity } else { [double]$totalLength / [double]$totalCompressedLength } if ($aggregateRatio -gt $MaximumArchiveCompressionRatio) { throw "Archive aggregate compression ratio $([math]::Round($aggregateRatio, 2)) exceeds the limit of $MaximumArchiveCompressionRatio." } return [pscustomobject][ordered]@{ Entries = @($validated) EntryCount = $Archive.Entries.Count ExpandedBytes = $totalLength UncompressedBytes = $totalLength CompressionRatio = $aggregateRatio } } function New-ICSafeExtractionDirectory { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$DestinationRoot, [Parameter(Mandatory)] [string[]]$Segments ) $current = [System.IO.Path]::GetFullPath($DestinationRoot).TrimEnd([char]'\', [char]'/') Assert-ICNoReparsePoint -RootPath $current -Path $current foreach ($segment in $Segments) { $current = Join-Path $current $segment if (Test-Path -LiteralPath $current) { if (-not (Test-Path -LiteralPath $current -PathType Container)) { throw "Archive extraction path '$current' collides with a file." } } else { New-Item -ItemType Directory -Path $current -ErrorAction Stop | Out-Null } Assert-ICNoReparsePoint -RootPath $DestinationRoot -Path $current } return $current } function Expand-ICZipArchiveSafely { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ArchivePath, [Parameter(Mandatory)] [string]$DestinationPath, [int]$MaximumArchiveEntries = 20000, [int64]$MaximumArchiveEntryBytes = 1073741824L, [Alias('MaximumArchiveUncompressedBytes')] [int64]$MaximumArchiveExpandedBytes = 21474836480L, [double]$MaximumArchiveCompressionRatio = 250 ) Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop if (-not (Test-Path -LiteralPath $DestinationPath -PathType Container)) { New-Item -ItemType Directory -Path $DestinationPath -Force -ErrorAction Stop | Out-Null } Assert-ICNoReparsePoint -RootPath $DestinationPath -Path $DestinationPath $stream = New-Object System.IO.FileStream( $ArchivePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read ) $archive = $null try { $archive = New-Object System.IO.Compression.ZipArchive( $stream, [System.IO.Compression.ZipArchiveMode]::Read, $false ) $validation = Get-ICValidatedZipEntry ` -Archive $archive ` -DestinationRoot $DestinationPath ` -MaximumArchiveEntries $MaximumArchiveEntries ` -MaximumArchiveEntryBytes $MaximumArchiveEntryBytes ` -MaximumArchiveExpandedBytes $MaximumArchiveExpandedBytes ` -MaximumArchiveCompressionRatio $MaximumArchiveCompressionRatio [int64]$extractedBytes = 0 foreach ($entry in @($validation.Entries)) { if ($entry.IsDirectory) { [void](New-ICSafeExtractionDirectory -DestinationRoot $DestinationPath -Segments $entry.Segments) continue } $parentSegments = @() if ($entry.Segments.Count -gt 1) { $parentSegments = @($entry.Segments[0..($entry.Segments.Count - 2)]) } if ($parentSegments.Count -gt 0) { [void](New-ICSafeExtractionDirectory -DestinationRoot $DestinationPath -Segments $parentSegments) } Assert-ICNoReparsePoint -RootPath $DestinationPath -Path ([System.IO.Path]::GetDirectoryName($entry.FullPath)) $inputStream = $null $output = $null [int64]$entryBytes = 0 try { $inputStream = $entry.ZipEntry.Open() $output = New-Object System.IO.FileStream( $entry.FullPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None ) $buffer = New-Object byte[] 65536 while (($read = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { if ( $entryBytes + $read -gt $entry.Length -or $entryBytes + $read -gt $MaximumArchiveEntryBytes -or $extractedBytes + $read -gt $MaximumArchiveExpandedBytes ) { throw "Archive entry '$($entry.RelativePath)' exceeded its validated extraction bound." } $output.Write($buffer, 0, $read) $entryBytes += $read $extractedBytes += $read } $output.Flush($true) } finally { if ($null -ne $inputStream) { $inputStream.Dispose() } if ($null -ne $output) { $output.Dispose() } } if ($entryBytes -ne $entry.Length) { throw "Archive entry '$($entry.RelativePath)' length changed during extraction." } } return [pscustomobject][ordered]@{ EntryCount = $validation.EntryCount ExpandedBytes = $extractedBytes UncompressedBytes = $extractedBytes CompressionRatio = $validation.CompressionRatio MaximumEntries = $MaximumArchiveEntries MaximumEntryBytes = $MaximumArchiveEntryBytes MaximumExpandedBytes = $MaximumArchiveExpandedBytes MaximumCompressionRatio = $MaximumArchiveCompressionRatio } } finally { if ($null -ne $archive) { $archive.Dispose() } else { $stream.Dispose() } } } function Test-ICArchiveIntegrity { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ArchivePath, [switch]$RequireSidecar, [switch]$RequireSignature, [int]$MaximumArchiveEntries = 20000, [int64]$MaximumArchiveEntryBytes = 1073741824L, [Alias('MaximumArchiveUncompressedBytes')] [int64]$MaximumArchiveExpandedBytes = 21474836480L, [double]$MaximumArchiveCompressionRatio = 250 ) $resolvedArchive = (Resolve-Path -LiteralPath $ArchivePath -ErrorAction Stop).Path $archiveHashValid = $null $sidecarPath = "$resolvedArchive.sha256" if (Test-Path -LiteralPath $sidecarPath -PathType Leaf) { $line = [string](Get-Content -LiteralPath $sidecarPath -Encoding UTF8 -ErrorAction Stop | Select-Object -First 1) if ($line -notmatch '^\s*([a-fA-F0-9]{64})(?:\s+(.+?))?\s*$') { throw "Sidecar '$sidecarPath' does not contain a valid SHA-256 value." } $expectedHash = $Matches[1].ToLowerInvariant() if (-not [string]::IsNullOrWhiteSpace([string]$Matches[2])) { $expectedName = ([string]$Matches[2]).Trim().TrimStart([char]'*') if ($expectedName -ne (Split-Path -Leaf $resolvedArchive)) { throw "Sidecar '$sidecarPath' names '$expectedName' instead of the selected archive." } } $actualHash = (Get-FileHash -LiteralPath $resolvedArchive -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() $archiveHashValid = $actualHash -eq $expectedHash } elseif ($RequireSidecar) { throw "Required archive sidecar '$sidecarPath' was not found." } $temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("incident-capsule-verify-{0}" -f [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $temporaryRoot -Force -ErrorAction Stop | Out-Null try { $archivePolicy = Expand-ICZipArchiveSafely ` -ArchivePath $resolvedArchive ` -DestinationPath $temporaryRoot ` -MaximumArchiveEntries $MaximumArchiveEntries ` -MaximumArchiveEntryBytes $MaximumArchiveEntryBytes ` -MaximumArchiveExpandedBytes $MaximumArchiveExpandedBytes ` -MaximumArchiveCompressionRatio $MaximumArchiveCompressionRatio $root = Find-ICManifestRoot -Path $temporaryRoot $result = Test-ICDirectoryIntegrity -CapsuleRoot $root if ($RequireSignature -and -not $result.SignaturePresent) { throw "Required manifest signature 'metadata/manifest.sha256.p7s' was not found in the archive." } $result.Path = $resolvedArchive $result.SourceType = 'Archive' $result.ArchiveHashValid = $archiveHashValid $result | Add-Member -NotePropertyName ArchivePolicy -NotePropertyValue $archivePolicy $result | Add-Member -NotePropertyName ArchiveEntryCount -NotePropertyValue $archivePolicy.EntryCount $result | Add-Member -NotePropertyName ArchiveUncompressedBytes -NotePropertyValue $archivePolicy.ExpandedBytes $result | Add-Member -NotePropertyName ArchiveCompressionRatio -NotePropertyValue $archivePolicy.CompressionRatio if ($archiveHashValid -eq $false) { $result.IsValid = $false } return $result } finally { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue } } function Write-ICVerificationReceipt { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ArchivePath, [Parameter(Mandatory)] [object]$Verification ) $receiptPath = "$ArchivePath.verification.json" $receipt = [ordered]@{ '$schema' = $script:ICVerificationReceiptSchema schemaVersion = $script:ICSchemaVersion verifiedAtUtc = [datetime]::UtcNow.ToString('o') verifier = [ordered]@{ name = $script:ICName version = $script:ICVersion } archive = Split-Path -Leaf $ArchivePath capsuleId = $Verification.CapsuleId isValid = [bool]$Verification.IsValid archiveHashValid = $Verification.ArchiveHashValid checksumListValid = [bool]$Verification.ChecksumListValid filesExpected = $Verification.FilesExpected filesValid = $Verification.FilesValid filesMissing = $Verification.FilesMissing filesModified = $Verification.FilesModified filesUnexpected = $Verification.FilesUnexpected signaturePresent = [bool](Get-ICPropertyValue -InputObject $Verification -Name 'SignaturePresent' -Default $false) signatureValid = Get-ICPropertyValue -InputObject $Verification -Name 'SignatureValid' signerSubject = Get-ICPropertyValue -InputObject $Verification -Name 'SignerSubject' signerThumbprint = Get-ICPropertyValue -InputObject $Verification -Name 'SignerThumbprint' archivePolicy = $Verification.ArchivePolicy } [void](Write-ICJsonFile -Path $receiptPath -InputObject $receipt -Depth 12) return $receiptPath } |