Private/IO.ps1
|
function Get-ICPropertyValue { [CmdletBinding()] param( [AllowNull()] [object]$InputObject, [Parameter(Mandatory)] [string]$Name, [AllowNull()] [object]$Default = $null ) if ($null -eq $InputObject) { return $Default } if ($InputObject -is [System.Collections.IDictionary] -and $InputObject.Contains($Name)) { return $InputObject[$Name] } $property = $InputObject.PSObject.Properties[$Name] if ($null -eq $property) { return $Default } return $property.Value } function ConvertTo-ICIso8601 { [CmdletBinding()] param( [AllowNull()] [object]$Value ) if ($null -eq $Value) { return $null } try { return ([datetime]$Value).ToUniversalTime().ToString('o') } catch { return [string]$Value } } function Get-ICShortHash { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Value, [ValidateRange(4, 32)] [int]$Length = 10 ) $algorithm = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) $hash = $algorithm.ComputeHash($bytes) $hex = -join ($hash | ForEach-Object { $_.ToString('x2') }) return $hex.Substring(0, $Length) } finally { $algorithm.Dispose() } } function ConvertTo-ICSafeFileName { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Value, [ValidateRange(8, 200)] [int]$MaximumLength = 100 ) $candidate = $Value.Trim() foreach ($character in [System.IO.Path]::GetInvalidFileNameChars()) { $candidate = $candidate.Replace([string]$character, '_') } $candidate = $candidate -replace '[\\/:*?"<>|\s]+', '_' $candidate = $candidate.Trim('.', '_') if ([string]::IsNullOrWhiteSpace($candidate)) { $candidate = 'unnamed' } if ($candidate.Length -gt $MaximumLength) { $digest = Get-ICShortHash -Value $Value -Length 10 $prefixLength = $MaximumLength - $digest.Length - 1 $candidate = '{0}-{1}' -f $candidate.Substring(0, $prefixLength), $digest } return $candidate } function Get-ICRelativePath { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$BasePath, [Parameter(Mandatory)] [string]$Path ) $baseFull = [System.IO.Path]::GetFullPath($BasePath).TrimEnd([char]'\', [char]'/') $pathFull = [System.IO.Path]::GetFullPath($Path) $prefix = $baseFull + [System.IO.Path]::DirectorySeparatorChar if (-not $pathFull.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Path '$pathFull' is outside base path '$baseFull'." } return $pathFull.Substring($prefix.Length).Replace([System.IO.Path]::DirectorySeparatorChar, '/') } function Write-ICUtf8File { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [AllowEmptyString()] [string]$Content ) $fullPath = [System.IO.Path]::GetFullPath($Path) $directory = [System.IO.Path]::GetDirectoryName($fullPath) if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $encoding = New-Object System.Text.UTF8Encoding($false) $temporaryPath = Join-Path $directory ('.{0}.{1}.partial' -f [System.IO.Path]::GetFileName($fullPath), [guid]::NewGuid().ToString('N')) $backupPath = Join-Path $directory ('.{0}.{1}.backup' -f [System.IO.Path]::GetFileName($fullPath), [guid]::NewGuid().ToString('N')) $stream = $null try { $bytes = $encoding.GetBytes($Content) $stream = New-Object System.IO.FileStream( $temporaryPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None ) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) $stream.Dispose() $stream = $null if ([System.IO.File]::Exists($fullPath)) { [System.IO.File]::Replace($temporaryPath, $fullPath, $backupPath) [System.IO.File]::Delete($backupPath) } else { [System.IO.File]::Move($temporaryPath, $fullPath) } } finally { if ($null -ne $stream) { $stream.Dispose() } if ([System.IO.File]::Exists($temporaryPath)) { [System.IO.File]::Delete($temporaryPath) } if ([System.IO.File]::Exists($backupPath)) { if ([System.IO.File]::Exists($fullPath)) { [System.IO.File]::Delete($backupPath) } else { [System.IO.File]::Move($backupPath, $fullPath) } } } return $fullPath } function Write-ICJsonFile { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [AllowNull()] [object]$InputObject, [ValidateRange(2, 100)] [int]$Depth = 12 ) $json = ConvertTo-Json -InputObject $InputObject -Depth $Depth return Write-ICUtf8File -Path $Path -Content ($json + [Environment]::NewLine) } function ConvertTo-ICSpreadsheetSafeValue { [CmdletBinding()] param( [AllowNull()] [object]$Value ) if ($Value -isnot [string]) { return $Value } $text = [string]$Value if ($text -match '^[\x00-\x20]*[=+\-@]') { return "'$text" } return $text } function ConvertTo-ICSpreadsheetSafeRow { [CmdletBinding()] param( [AllowNull()] [object[]]$InputObject, [bool]$SpreadsheetSafe = $true ) $rows = New-Object System.Collections.ArrayList foreach ($item in @($InputObject)) { if ($null -eq $item) { [void]$rows.Add($null) continue } $row = [ordered]@{} if ($item -is [System.Collections.IDictionary]) { foreach ($key in $item.Keys) { $value = $item[$key] $row[[string]$key] = if ($SpreadsheetSafe) { ConvertTo-ICSpreadsheetSafeValue -Value $value } else { $value } } } else { foreach ($property in $item.PSObject.Properties) { if (-not $property.IsGettable -or $property.MemberType -notin @('NoteProperty', 'Property', 'AliasProperty', 'ScriptProperty')) { continue } $row[$property.Name] = if ($SpreadsheetSafe) { ConvertTo-ICSpreadsheetSafeValue -Value $property.Value } else { $property.Value } } } if ($row.Count -eq 0) { $value = if ($SpreadsheetSafe) { ConvertTo-ICSpreadsheetSafeValue -Value $item } else { $item } [void]$rows.Add($value) } else { [void]$rows.Add([pscustomobject]$row) } } return @($rows) } function Write-ICCsvFile { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [AllowNull()] [object[]]$InputObject, [bool]$SpreadsheetSafe = $true ) $items = @(ConvertTo-ICSpreadsheetSafeRow -InputObject @($InputObject) -SpreadsheetSafe $SpreadsheetSafe) $content = if ($items.Count -gt 0) { ($items | ConvertTo-Csv -NoTypeInformation) -join [Environment]::NewLine } else { '' } return Write-ICUtf8File -Path $Path -Content ($content + [Environment]::NewLine) } function Write-ICLog { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Context, [Parameter(Mandatory)] [ValidateSet('DEBUG', 'INFO', 'WARN', 'ERROR')] [string]$Level, [Parameter(Mandatory)] [string]$Component, [Parameter(Mandatory)] [string]$Message ) if ($Context.Frozen) { return } $safeMessage = $Message -replace "`r?`n", ' | ' $line = '{0} [{1}] [{2}] {3}' -f [datetime]::UtcNow.ToString('o'), $Level, $Component, $safeMessage $encoding = New-Object System.Text.UTF8Encoding($false) $bytes = $encoding.GetBytes($line + [Environment]::NewLine) $stream = New-Object System.IO.FileStream($Context.LogPath, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read) try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Dispose() } } function New-ICCollectorResultData { [CmdletBinding()] param( [object[]]$OutputFiles = @(), [string[]]$Warnings = @(), [System.Collections.IDictionary]$Metrics = ([ordered]@{}) ) return [pscustomobject][ordered]@{ OutputFiles = @($OutputFiles) Warnings = @($Warnings) Metrics = $Metrics } } function Export-ICCollectorData { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Context, [Parameter(Mandatory)] [string]$Collector, [Parameter(Mandatory)] [string]$RelativePath, [AllowNull()] [object]$Data, [switch]$Csv ) $jsonPath = Join-Path $Context.RootPath ($RelativePath -replace '/', [System.IO.Path]::DirectorySeparatorChar) $payload = [ordered]@{ '$schema' = $script:ICCollectorSchema schemaVersion = $script:ICSchemaVersion capsuleId = $Context.CapsuleId collector = $Collector capturedAtUtc = [datetime]::UtcNow.ToString('o') host = $Context.HostName data = $Data } $written = New-Object System.Collections.ArrayList [void]$written.Add((Write-ICJsonFile -Path $jsonPath -InputObject $payload -Depth 20)) if ($Csv) { $csvPath = [System.IO.Path]::ChangeExtension($jsonPath, '.csv') $spreadsheetSafe = $true if ($null -ne $Context.Configuration -and $Context.Configuration.Contains('SpreadsheetSafeCsv')) { $spreadsheetSafe = [bool]$Context.Configuration.SpreadsheetSafeCsv } [void]$written.Add((Write-ICCsvFile -Path $csvPath -InputObject @($Data) -SpreadsheetSafe $spreadsheetSafe)) } return @($written) } function Test-ICCommandAvailable { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Name ) return $null -ne (Get-Command -Name $Name -ErrorAction SilentlyContinue) } function Invoke-ICNativeCommand { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$FilePath, [string[]]$ArgumentList = @(), [AllowNull()] [object]$Context, [ValidateRange(0, 86400)] [int]$TimeoutSeconds = 0, [ValidateRange(0, 2147483647)] [int64]$MaximumOutputBytes = 0 ) if ($TimeoutSeconds -eq 0) { $configuration = Get-ICPropertyValue -InputObject $Context -Name 'Configuration' $TimeoutSeconds = [int](Get-ICPropertyValue -InputObject $configuration -Name 'NativeCommandTimeoutSeconds' -Default 120) } if ($MaximumOutputBytes -eq 0) { $configuration = Get-ICPropertyValue -InputObject $Context -Name 'Configuration' $MaximumOutputBytes = [int64](Get-ICPropertyValue -InputObject $configuration -Name 'MaximumNativeOutputBytes' -Default 16777216) } if ($TimeoutSeconds -lt 1 -or $TimeoutSeconds -gt 86400) { throw 'Native command timeout must be between 1 and 86400 seconds.' } if ($MaximumOutputBytes -lt 1 -or $MaximumOutputBytes -gt 2147483647) { throw 'Native command output limit must be between 1 and 2147483647 bytes.' } $output = @() $exitCode = $null $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() try { if ($null -eq ('IncidentCapsule.Runtime.NativeCommandRunner' -as [type])) { Add-Type -TypeDefinition @' using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; namespace IncidentCapsule.Runtime { public sealed class NativeCommandCapture { public int? ExitCode { get; set; } public string StandardOutput { get; set; } public string StandardError { get; set; } public bool TimedOut { get; set; } public bool OutputTruncated { get; set; } public long OutputBytes { get; set; } } internal sealed class BoundedCaptureState { private readonly object sync = new object(); private readonly long maximumBytes; private long capturedBytes; internal readonly Process Process; internal bool LimitExceeded; internal BoundedCaptureState(Process process, long maximumBytes) { Process = process; this.maximumBytes = maximumBytes; } internal long CapturedBytes { get { lock (sync) { return capturedBytes; } } } internal void Copy(Stream source, MemoryStream destination) { byte[] buffer = new byte[8192]; try { int read; while ((read = source.Read(buffer, 0, buffer.Length)) > 0) { int accepted; bool terminate = false; lock (sync) { long remaining = maximumBytes - capturedBytes; accepted = remaining <= 0 ? 0 : (int)Math.Min((long)read, remaining); capturedBytes += accepted; if (accepted < read) { LimitExceeded = true; terminate = true; } } if (accepted > 0) { destination.Write(buffer, 0, accepted); } if (terminate) { try { Process.Kill(); } catch { } break; } } } catch (IOException) { } catch (ObjectDisposedException) { } } } public static class NativeCommandRunner { private static string QuoteArgument(string value) { if (value == null) { return "\"\""; } if (value.Length > 0 && value.IndexOfAny(new[] { ' ', '\t', '\n', '\v', '"' }) < 0) { return value; } StringBuilder builder = new StringBuilder(); builder.Append('"'); int backslashes = 0; foreach (char character in value) { if (character == '\\') { backslashes++; continue; } if (character == '"') { builder.Append('\\', backslashes * 2 + 1); builder.Append('"'); backslashes = 0; continue; } builder.Append('\\', backslashes); backslashes = 0; builder.Append(character); } builder.Append('\\', backslashes * 2); builder.Append('"'); return builder.ToString(); } public static NativeCommandCapture Run(string filePath, string[] arguments, int timeoutSeconds, long maximumOutputBytes) { List<string> quoted = new List<string>(); if (arguments != null) { foreach (string argument in arguments) { quoted.Add(QuoteArgument(argument)); } } ProcessStartInfo info = new ProcessStartInfo(); info.FileName = filePath; info.Arguments = string.Join(" ", quoted.ToArray()); info.UseShellExecute = false; info.CreateNoWindow = true; info.RedirectStandardOutput = true; info.RedirectStandardError = true; using (Process process = new Process()) using (MemoryStream standardOutput = new MemoryStream()) using (MemoryStream standardError = new MemoryStream()) { process.StartInfo = info; if (!process.Start()) { throw new InvalidOperationException("Native process did not start."); } Encoding outputEncoding = process.StandardOutput.CurrentEncoding; Encoding errorEncoding = process.StandardError.CurrentEncoding; BoundedCaptureState state = new BoundedCaptureState(process, maximumOutputBytes); Task outputTask = Task.Factory.StartNew( () => state.Copy(process.StandardOutput.BaseStream, standardOutput), CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); Task errorTask = Task.Factory.StartNew( () => state.Copy(process.StandardError.BaseStream, standardError), CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); bool exited = process.WaitForExit(checked(timeoutSeconds * 1000)); bool timedOut = !exited; if (timedOut) { try { process.Kill(); } catch { } } try { if (!timedOut) { process.WaitForExit(); } else { process.WaitForExit(5000); } } catch { } try { Task.WaitAll(new[] { outputTask, errorTask }, 5000); } catch { } int? exitCode = null; try { if (process.HasExited) { exitCode = process.ExitCode; } } catch { } return new NativeCommandCapture { ExitCode = exitCode, StandardOutput = outputEncoding.GetString(standardOutput.ToArray()), StandardError = errorEncoding.GetString(standardError.ToArray()), TimedOut = timedOut, OutputTruncated = state.LimitExceeded, OutputBytes = state.CapturedBytes }; } } } } '@ -ErrorAction Stop } $capture = [IncidentCapsule.Runtime.NativeCommandRunner]::Run( $FilePath, @($ArgumentList), $TimeoutSeconds, $MaximumOutputBytes ) $exitCode = $capture.ExitCode $capturedStreams = @($capture.StandardOutput; $capture.StandardError) | Where-Object { -not [string]::IsNullOrEmpty($_) } $combined = $capturedStreams -join [Environment]::NewLine if (-not [string]::IsNullOrEmpty($combined)) { $output = @([regex]::Split($combined.TrimEnd("`r", "`n"), '\r\n|\n|\r')) } $errorMessage = $null if ($capture.TimedOut) { $errorMessage = "Native command timed out after $TimeoutSeconds second(s)." } elseif ($capture.OutputTruncated) { $errorMessage = "Native command exceeded the $MaximumOutputBytes-byte output limit and was terminated." } return [pscustomobject][ordered]@{ FilePath = $FilePath Arguments = @($ArgumentList) ExitCode = $exitCode Output = $output Error = $errorMessage TimedOut = [bool]$capture.TimedOut OutputTruncated = [bool]$capture.OutputTruncated OutputBytes = [int64]$capture.OutputBytes DurationMilliseconds = [math]::Round($stopwatch.Elapsed.TotalMilliseconds, 0) } } catch { return [pscustomobject][ordered]@{ FilePath = $FilePath Arguments = @($ArgumentList) ExitCode = $exitCode Output = $output Error = $_.Exception.Message TimedOut = $false OutputTruncated = $false OutputBytes = 0 DurationMilliseconds = [math]::Round($stopwatch.Elapsed.TotalMilliseconds, 0) } } finally { $stopwatch.Stop() } } function Export-ICNativeCommandOutput { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Context, [Parameter(Mandatory)] [string]$RelativePath, [Parameter(Mandatory)] [string]$FilePath, [string[]]$ArgumentList = @() ) $result = Invoke-ICNativeCommand -FilePath $FilePath -ArgumentList $ArgumentList -Context $Context $lines = New-Object System.Collections.Generic.List[string] $lines.Add("# capturedAtUtc: $([datetime]::UtcNow.ToString('o'))") $lines.Add("# command: $FilePath $($ArgumentList -join ' ')") $lines.Add("# exitCode: $($result.ExitCode)") $lines.Add("# timedOut: $($result.TimedOut)") $lines.Add("# outputTruncated: $($result.OutputTruncated)") $lines.Add("# outputBytes: $($result.OutputBytes)") if ($null -ne $result.Error) { $lines.Add("# error: $($result.Error)") } $lines.Add('') foreach ($line in $result.Output) { $lines.Add([string]$line) } $path = Join-Path $Context.RootPath ($RelativePath -replace '/', [System.IO.Path]::DirectorySeparatorChar) [void](Write-ICUtf8File -Path $path -Content (($lines -join [Environment]::NewLine) + [Environment]::NewLine)) return [pscustomobject][ordered]@{ Path = $path ExitCode = $result.ExitCode Error = $result.Error TimedOut = $result.TimedOut OutputTruncated = $result.OutputTruncated OutputBytes = $result.OutputBytes } } function Get-ICRegistryValues { [CmdletBinding()] param( [Parameter(Mandatory)] [string[]]$Path, [string[]]$ValueName ) $results = New-Object System.Collections.ArrayList foreach ($registryPath in $Path) { if (-not (Test-Path -LiteralPath $registryPath)) { continue } try { $key = Get-Item -LiteralPath $registryPath -ErrorAction Stop $properties = Get-ItemProperty -LiteralPath $registryPath -ErrorAction Stop $names = if ($null -ne $ValueName -and $ValueName.Count -gt 0) { @($ValueName) } else { @($properties.PSObject.Properties.Name | Where-Object { $_ -notmatch '^PS(Path|ParentPath|ChildName|Drive|Provider)$' }) } foreach ($name in $names) { $property = $properties.PSObject.Properties[$name] if ($null -eq $property) { continue } $kind = $null try { $kind = $key.GetValueKind($name).ToString() } catch { $kind = $null } [void]$results.Add([pscustomobject][ordered]@{ Key = $registryPath ValueName = $name ValueType = $kind Data = $property.Value }) } } catch { [void]$results.Add([pscustomobject][ordered]@{ Key = $registryPath ValueName = $null ValueType = $null Data = $null Error = $_.Exception.Message }) } } return @($results) } function Get-ICFileMetadata { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [switch]$Hash ) try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop $sha256 = $null if ($Hash -and -not $item.PSIsContainer) { try { $sha256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() } catch { $sha256 = $null } } return [pscustomobject][ordered]@{ Path = $item.FullName Name = $item.Name IsDirectory = [bool]$item.PSIsContainer Length = if ($item.PSIsContainer) { $null } else { $item.Length } CreationTimeUtc = ConvertTo-ICIso8601 -Value $item.CreationTimeUtc LastWriteTimeUtc = ConvertTo-ICIso8601 -Value $item.LastWriteTimeUtc Attributes = [string]$item.Attributes SHA256 = $sha256 } } catch { return [pscustomobject][ordered]@{ Path = $Path Error = $_.Exception.Message } } } function ConvertTo-ICHtml { [CmdletBinding()] param( [AllowNull()] [object]$Value ) if ($null -eq $Value) { return '' } return [System.Net.WebUtility]::HtmlEncode([string]$Value) } |