Files
lserp_cs_6.0/插件库/Lskj.AgentBridge/Deployment/Invoke-LserpSelectOnlyProfilePreflight.ps1
2026-08-14 14:28:28 +08:00

527 lines
20 KiB
PowerShell

[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 { }
}
}
}