[CmdletBinding(DefaultParameterSetName = 'SqlCredential')] param( [Parameter(Mandatory = $true)] [ValidateLength(1, 260)] [string]$Server, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$')] [string]$Database, [Parameter(Mandatory = $true, ParameterSetName = 'SqlCredential')] [Management.Automation.PSCredential]$Credential, [Parameter(Mandatory = $true, ParameterSetName = 'WindowsCredential')] [switch]$UseWindowsAuthentication, [Parameter(Mandatory = $true)] [string]$ProfilePath, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedProfileSha256, [Parameter(Mandatory = $true)] [string]$CliPath, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedCliSha256, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string]$ExpectedSignerThumbprint, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedCollectorSha256, [Parameter(Mandatory = $true)] [string]$SnapshotOutputPath, [Parameter(Mandatory = $true)] [string]$ReportOutputPath, [ValidateRange(5, 60)] [int]$ConnectionTimeoutSeconds = 15, [ValidateRange(5, 120)] [int]$CommandTimeoutSeconds = 60 ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or [string]$PSVersionTable.PSEdition -ne 'Desktop' -or [string]::IsNullOrWhiteSpace($env:SystemRoot)) { throw 'lserp_select_only_profile_preflight_failed:windows_powershell_51_required' } $utf8 = [Text.UTF8Encoding]::new($false, $true) $maximumProfileBytes = 1024 * 1024 $maximumCollectorBytes = 2 * 1024 * 1024 $maximumCliBytes = 64 * 1024 * 1024 $maximumResponseCharacters = 4 * 1024 * 1024 $maximumReportBytes = 1024 * 1024 $safeSha256 = '^[a-f0-9]{64}$' $safeCorrelationId = '^[A-Za-z0-9_.:-]{8,128}$' $profileLock = $null $collectorLock = $null $cliLock = $null $snapshotFull = $null $reportFull = $null $snapshotCreated = $false $reportCreated = $false $reportPublished = $false $cliText = $null function Throw-ProfilePreflightError([string]$Code) { throw ('lserp_select_only_profile_preflight_failed:' + $Code) } function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) { try { $current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory)) while ($null -ne $current) { if (-not $current.Exists -or (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Throw-ProfilePreflightError $Code } $current = $current.Parent } } catch { if ($_.Exception.Message.StartsWith( 'lserp_select_only_profile_preflight_failed:')) { throw } Throw-ProfilePreflightError $Code } } function Resolve-RegularFile( [string]$Path, [long]$MaximumBytes, [string]$Code ) { try { $full = [IO.Path]::GetFullPath($Path) if (-not [IO.File]::Exists($full)) { Throw-ProfilePreflightError $Code } $item = Get-Item -LiteralPath $full -Force if ($item.PSIsContainer -or $item.Length -le 0 -or $item.Length -gt $MaximumBytes -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Throw-ProfilePreflightError $Code } Assert-NoReparseDirectoryChain $item.DirectoryName $Code return $full } catch { if ($_.Exception.Message.StartsWith( 'lserp_select_only_profile_preflight_failed:')) { throw } Throw-ProfilePreflightError $Code } } function Resolve-NewJsonPath([string]$Path, [string]$Code) { try { $full = [IO.Path]::GetFullPath($Path) if ([IO.Path]::GetExtension($full) -ine '.json' -or [IO.File]::Exists($full) -or [IO.Directory]::Exists($full)) { Throw-ProfilePreflightError $Code } $directory = [IO.Path]::GetDirectoryName($full) if ([string]::IsNullOrWhiteSpace($directory)) { Throw-ProfilePreflightError $Code } Assert-NoReparseDirectoryChain $directory $Code return $full } catch { if ($_.Exception.Message.StartsWith( 'lserp_select_only_profile_preflight_failed:')) { throw } Throw-ProfilePreflightError $Code } } function Get-Sha256FromOpenStream([IO.FileStream]$Stream) { $algorithm = [Security.Cryptography.SHA256]::Create() try { $Stream.Position = 0 $hash = $algorithm.ComputeHash($Stream) $Stream.Position = 0 return ([BitConverter]::ToString($hash)).Replace( '-', '').ToLowerInvariant() } finally { $algorithm.Dispose() } } function Get-Sha256Bytes([byte[]]$Bytes) { $algorithm = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString( $algorithm.ComputeHash($Bytes))).Replace( '-', '').ToLowerInvariant() } finally { $algorithm.Dispose() } } function Test-ExactProperties([object]$Value, [string[]]$Expected) { if ($null -eq $Value) { return $false } $names = @($Value.PSObject.Properties | ForEach-Object { $_.Name }) if ($names.Count -ne $Expected.Count) { return $false } foreach ($name in $Expected) { if ($names -cnotcontains $name) { return $false } } return $true } function Assert-Boolean([object]$Value, [string]$Code) { if ($Value -isnot [bool]) { Throw-ProfilePreflightError $Code } } function Assert-Sha256([object]$Value, [string]$Code) { if ($Value -isnot [string] -or ([string]$Value) -cnotmatch $safeSha256) { Throw-ProfilePreflightError $Code } } function Read-VerifiedCliEnvelope([string]$Text, [int]$ExitCode) { try { if ([string]::IsNullOrWhiteSpace($Text) -or $Text.Length -gt $maximumResponseCharacters) { Throw-ProfilePreflightError 'cli_response_invalid' } $document = $Text | ConvertFrom-Json if (-not (Test-ExactProperties $document ` @('ok', 'correlationId', 'data')) -or $document.ok -isnot [bool] -or -not $document.ok -or ([string]$document.correlationId) -cnotmatch $safeCorrelationId -or $null -eq $document.data -or $ExitCode -notin @(0, 6)) { Throw-ProfilePreflightError 'cli_response_invalid' } $expectedDataProperties = @( 'schemaVersion', 'verificationType', 'snapshotSha256', 'profileSha256', 'catalogDatabaseScopeFingerprint', 'toolSha256Verified', 'freshnessVerified', 'permissionGateVerified', 'permissionRecheckVerified', 'safetyVerified', 'toolSourceBytesStableVerified', 'databaseIdentityMatches', 'databaseMetadataMatches', 'criticalCatalogContractMatches', 'missingCriticalCatalogEntryCount', 'missingCriticalCatalogEntrySha256', 'catalogSetSha256', 'onlineMetadataMatches', 'registrationReady') if (-not (Test-ExactProperties $document.data ` $expectedDataProperties) -or [string]$document.data.schemaVersion -cne '1.1' -or [string]$document.data.verificationType -cne 'select_only_catalog_snapshot') { Throw-ProfilePreflightError 'cli_response_invalid' } foreach ($name in @( 'toolSha256Verified', 'freshnessVerified', 'permissionGateVerified', 'permissionRecheckVerified', 'safetyVerified', 'toolSourceBytesStableVerified', 'databaseIdentityMatches', 'databaseMetadataMatches', 'criticalCatalogContractMatches', 'onlineMetadataMatches', 'registrationReady')) { Assert-Boolean $document.data.$name 'cli_response_invalid' } foreach ($name in @( 'snapshotSha256', 'profileSha256', 'catalogDatabaseScopeFingerprint', 'catalogSetSha256')) { Assert-Sha256 $document.data.$name 'cli_response_invalid' } $missing = @($document.data.missingCriticalCatalogEntrySha256) $missingCount = 0 if (-not [int]::TryParse( [string]$document.data.missingCriticalCatalogEntryCount, [ref]$missingCount) -or $missingCount -lt 0 -or $missingCount -gt 4096 -or $missing.Count -ne $missingCount) { Throw-ProfilePreflightError 'cli_response_invalid' } $prior = $null foreach ($hash in $missing) { Assert-Sha256 $hash 'cli_response_invalid' if ($null -ne $prior -and [string]::CompareOrdinal($prior, [string]$hash) -ge 0) { Throw-ProfilePreflightError 'cli_response_invalid' } $prior = [string]$hash } $expectedOnline = [bool]$document.data.databaseIdentityMatches -and [bool]$document.data.databaseMetadataMatches -and [bool]$document.data.criticalCatalogContractMatches if (-not $document.data.toolSha256Verified -or -not $document.data.freshnessVerified -or -not $document.data.permissionGateVerified -or -not $document.data.permissionRecheckVerified -or -not $document.data.safetyVerified -or -not $document.data.toolSourceBytesStableVerified -or $document.data.registrationReady -or [bool]$document.data.onlineMetadataMatches -ne $expectedOnline -or (($ExitCode -eq 0) -ne $expectedOnline) -or (($ExitCode -eq 6) -ne (-not $expectedOnline))) { Throw-ProfilePreflightError 'cli_response_invalid' } return $document } catch { if ($_.Exception.Message.StartsWith( 'lserp_select_only_profile_preflight_failed:')) { throw } Throw-ProfilePreflightError 'cli_response_invalid' } } function New-RestrictedFileSecurity { try { $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User $localSystem = [Security.Principal.SecurityIdentifier]::new( [Security.Principal.WellKnownSidType]::LocalSystemSid, $null) $security = New-Object Security.AccessControl.FileSecurity $security.SetOwner($currentUser) $security.SetAccessRuleProtection($true, $false) $allow = [Security.AccessControl.AccessControlType]::Allow foreach ($identity in @($currentUser, $localSystem)) { $rule = [Security.AccessControl.FileSystemAccessRule]::new( $identity, [Security.AccessControl.FileSystemRights]::FullControl, $allow) [void]$security.AddAccessRule($rule) } return $security } catch { Throw-ProfilePreflightError 'report_acl_invalid' } } function Publish-RestrictedReport([string]$Path, [object]$Report) { $stream = $null $writer = $null try { $json = ConvertTo-Json -InputObject $Report -Depth 8 -Compress $byteCount = $utf8.GetByteCount($json) if ($byteCount -le 0 -or $byteCount -gt $maximumReportBytes) { Throw-ProfilePreflightError 'report_size_invalid' } $security = New-RestrictedFileSecurity $stream = [IO.FileStream]::new( $Path, [IO.FileMode]::CreateNew, [Security.AccessControl.FileSystemRights]::Write, [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough, $security) $script:reportCreated = $true $writer = [IO.StreamWriter]::new($stream, $utf8, 4096, $false) $stream = $null $writer.Write($json) $writer.Flush() $writer.Dispose() $writer = $null $item = Get-Item -LiteralPath $Path -Force $acl = [IO.File]::GetAccessControl($Path) if ($item.Length -le 0 -or $item.Length -gt $maximumReportBytes -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or -not $acl.AreAccessRulesProtected) { Throw-ProfilePreflightError 'report_publish_invalid' } } finally { if ($null -ne $writer) { $writer.Dispose() } if ($null -ne $stream) { $stream.Dispose() } } } try { $collectorFull = Resolve-RegularFile (Join-Path $PSScriptRoot ` 'Invoke-LserpSelectOnlyCatalogSnapshot.ps1') ` $maximumCollectorBytes 'collector_file_invalid' $profileFull = Resolve-RegularFile $ProfilePath ` $maximumProfileBytes 'profile_file_invalid' $cliFull = Resolve-RegularFile $CliPath ` $maximumCliBytes 'cli_file_invalid' if ([IO.Path]::GetFileName($cliFull) -cne 'lserp-cli.exe') { Throw-ProfilePreflightError 'cli_file_invalid' } $snapshotFull = Resolve-NewJsonPath $SnapshotOutputPath ` 'snapshot_output_invalid' $reportFull = Resolve-NewJsonPath $ReportOutputPath ` 'report_output_invalid' if ($snapshotFull -ceq $reportFull) { Throw-ProfilePreflightError 'output_paths_conflict' } $profileLock = [IO.File]::Open( $profileFull, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $collectorLock = [IO.File]::Open( $collectorFull, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $cliLock = [IO.File]::Open( $cliFull, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $profileSha256 = Get-Sha256FromOpenStream $profileLock $collectorSha256 = Get-Sha256FromOpenStream $collectorLock $cliSha256 = Get-Sha256FromOpenStream $cliLock if ($profileSha256 -cne $ExpectedProfileSha256.ToLowerInvariant()) { Throw-ProfilePreflightError 'profile_hash_mismatch' } if ($collectorSha256 -cne $ExpectedCollectorSha256.ToLowerInvariant()) { Throw-ProfilePreflightError 'collector_hash_mismatch' } if ($cliSha256 -cne $ExpectedCliSha256.ToLowerInvariant()) { Throw-ProfilePreflightError 'cli_hash_mismatch' } $signature = Get-AuthenticodeSignature -LiteralPath $cliFull $actualSignerThumbprint = if ($null -eq $signature.SignerCertificate) { '' } else { ([string]$signature.SignerCertificate.Thumbprint).Replace( ' ', '').ToUpperInvariant() } if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid -or $actualSignerThumbprint -cne $ExpectedSignerThumbprint.ToUpperInvariant()) { Throw-ProfilePreflightError 'cli_signature_invalid' } $collectorArguments = @{ Server = $Server Database = $Database OutputPath = $snapshotFull ConnectionTimeoutSeconds = $ConnectionTimeoutSeconds CommandTimeoutSeconds = $CommandTimeoutSeconds } if ($PSCmdlet.ParameterSetName -ceq 'SqlCredential') { $collectorArguments.Credential = $Credential } else { $collectorArguments.UseWindowsAuthentication = $true } & $collectorFull @collectorArguments | Out-Null if (-not [IO.File]::Exists($snapshotFull)) { Throw-ProfilePreflightError 'snapshot_not_created' } $snapshotCreated = $true $snapshotSha256 = (Get-FileHash -LiteralPath $snapshotFull ` -Algorithm SHA256).Hash.ToLowerInvariant() $correlationId = 'catalog-preflight-' + [Guid]::NewGuid().ToString('N') $cliOutput = @(& $cliFull ` 'adapters' 'verify-catalog-snapshot' ` '--input' $snapshotFull ` '--profile' $profileFull ` '--tool-sha256' $collectorSha256 ` '--correlation-id' $correlationId 2>&1) $cliExitCode = $LASTEXITCODE $cliText = (($cliOutput | ForEach-Object { [string]$_ }) -join ` [Environment]::NewLine) $envelope = Read-VerifiedCliEnvelope $cliText $cliExitCode $data = $envelope.data if ([string]$data.snapshotSha256 -cne $snapshotSha256 -or [string]$data.profileSha256 -cne $profileSha256) { Throw-ProfilePreflightError 'cli_artifact_binding_invalid' } if ((Get-Sha256FromOpenStream $profileLock) -cne $profileSha256 -or (Get-Sha256FromOpenStream $collectorLock) -cne $collectorSha256 -or (Get-Sha256FromOpenStream $cliLock) -cne $cliSha256) { Throw-ProfilePreflightError 'trusted_input_changed' } $passed = [bool]$data.onlineMetadataMatches $report = [ordered]@{ schemaVersion = '1.0' reportType = 'select_only_profile_preflight' generatedAtUtc = [DateTime]::UtcNow.ToString( 'yyyy-MM-ddTHH:mm:ss.fffffffZ', [Globalization.CultureInfo]::InvariantCulture) passed = $passed code = if ($passed) { 'ok' } else { 'catalog_metadata_mismatch' } snapshotSha256 = $snapshotSha256 profileSha256 = $profileSha256 collectorSha256 = $collectorSha256 cliSha256 = $cliSha256 cliSignerThumbprint = $actualSignerThumbprint cliResponseSha256 = Get-Sha256Bytes ($utf8.GetBytes($cliText)) catalogDatabaseScopeFingerprint = ` [string]$data.catalogDatabaseScopeFingerprint permissionGateVerified = [bool]$data.permissionGateVerified permissionRecheckVerified = [bool]$data.permissionRecheckVerified safetyVerified = [bool]$data.safetyVerified toolSourceBytesStableVerified = ` [bool]$data.toolSourceBytesStableVerified databaseIdentityMatches = [bool]$data.databaseIdentityMatches databaseMetadataMatches = [bool]$data.databaseMetadataMatches criticalCatalogContractMatches = ` [bool]$data.criticalCatalogContractMatches missingCriticalCatalogEntryCount = ` [int]$data.missingCriticalCatalogEntryCount missingCriticalCatalogEntrySha256 = ` @($data.missingCriticalCatalogEntrySha256) databaseSafety = [ordered]@{ applicationIntent = 'ReadOnly' effectivePrincipalSelectOnly = $true businessRowsRead = $false storedProceduresExecuted = $false writesAttempted = $false } registrationReady = $false note = '本报告只证明 SELECT-only 目录与画像的当前匹配状态,不启用任何业务写命令。' } Publish-RestrictedReport $reportFull $report $reportPublished = $true [pscustomobject][ordered]@{ schemaVersion = '1.0' passed = $passed code = [string]$report.code snapshotSha256 = $snapshotSha256 reportSha256 = (Get-FileHash -LiteralPath $reportFull ` -Algorithm SHA256).Hash.ToLowerInvariant() reportPath = $reportFull registrationReady = $false } if (-not $passed) { Throw-ProfilePreflightError 'catalog_metadata_mismatch' } } catch { if ($_.Exception.Message.StartsWith( 'lserp_select_only_profile_preflight_failed:')) { throw } Throw-ProfilePreflightError 'profile_preflight_failed' } finally { $cliText = $null if ($null -ne $cliLock) { $cliLock.Dispose() } if ($null -ne $collectorLock) { $collectorLock.Dispose() } if ($null -ne $profileLock) { $profileLock.Dispose() } if (-not $reportPublished) { if ($reportCreated -and $null -ne $reportFull -and [IO.File]::Exists($reportFull)) { try { [IO.File]::Delete($reportFull) } catch { } } if ($snapshotCreated -and $null -ne $snapshotFull -and [IO.File]::Exists($snapshotFull)) { try { [IO.File]::Delete($snapshotFull) } catch { } } } }