Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/scripts/Queue-CiFixAzdoValidation.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,7 @@ Describe 'Invoke-AzdoPipelineQueue retry safety' {
'Test-IsDispatcherBudgetException',
'Get-DispatcherHttpTimeoutSeconds',
'Invoke-DispatcherSleep',
'Get-AzdoQueueFailureMessage',
'Get-CiFixPipelineDefinitions',
'Test-IsTransientHttpException',
'Get-HttpStatusCode',
Expand Down Expand Up @@ -1127,6 +1128,81 @@ Describe 'Invoke-AzdoPipelineQueue retry safety' {
Should -Invoke Start-Sleep -ModuleName QueueCiFixAzdoValidationTest -Times 0 -Exactly
}

It 'includes bounded Azure response details for a nontransient POST failure' {
$exception = [System.Net.Http.HttpRequestException]::new(
'Response status code does not indicate success: 400 (Bad Request).',
$null,
[System.Net.HttpStatusCode]::BadRequest)
$errorRecord = [System.Management.Automation.ErrorRecord]::new(
$exception,
'AzdoQueueFailure',
[System.Management.Automation.ErrorCategory]::InvalidOperation,
$null)
$errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new(
'{"message":"The build request is invalid for this source provider.","typeKey":"BuildRequestValidationFailed"}')

Mock Invoke-RestMethod {
throw $errorRecord
} -ModuleName QueueCiFixAzdoValidationTest
Mock Get-AzdoDuplicateBuild {
throw 'Duplicate lookup must not run for a nontransient failure.'
} -ModuleName QueueCiFixAzdoValidationTest

{
Invoke-QueueTestAzdoPipelineQueue `
-DefinitionId 313 `
-Context $script:queueContext `
-AuthToken test-token
} | Should -Throw '*400 (Bad Request)*Azure response: The build request is invalid for this source provider. (Azure type: BuildRequestValidationFailed)*'

Should -Invoke Invoke-RestMethod -ModuleName QueueCiFixAzdoValidationTest -Times 1 -Exactly -ParameterFilter {
$Method -eq 'Post'
}
Should -Invoke Get-AzdoDuplicateBuild -ModuleName QueueCiFixAzdoValidationTest -Times 0 -Exactly
}

It 'redacts and truncates non-JSON Azure response details' {
$exception = [System.InvalidOperationException]::new('invalid request')
$errorRecord = [System.Management.Automation.ErrorRecord]::new(
$exception,
'AzdoQueueFailure',
[System.Management.Automation.ErrorCategory]::InvalidOperation,
$null)
$errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new(
('Bearer abc.def.ghi access_token=sensitive ' + ('x' * 400)))

$message = Get-QueueTestAzdoQueueFailureMessage `
-ErrorRecord $errorRecord `
-MaximumDetailLength 256

$message | Should -Match 'Bearer \[redacted\]'
$message | Should -Match 'access_token=\[redacted\]'
$message | Should -Match '\.\.\. \[truncated\]$'
$message | Should -Not -Match 'sensitive'
$message.Length | Should -BeLessOrEqual 300
}

It 'redacts quoted JSON credential fields while preserving safe fallback details' {
$exception = [System.InvalidOperationException]::new('invalid request')
$errorRecord = [System.Management.Automation.ErrorRecord]::new(
$exception,
'AzdoQueueFailure',
[System.Management.Automation.ErrorCategory]::InvalidOperation,
$null)
$errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new(
'{"access_token":"sensitive","nested":{"client_assertion":"assertion"},"ID_TOKEN" : "id-secret","refresh_token":"refresh","token":"general","requestId":"safe-id"}')

$message = Get-QueueTestAzdoQueueFailureMessage -ErrorRecord $errorRecord

$message | Should -Match '"access_token":"\[redacted\]"'
$message | Should -Match '"client_assertion":"\[redacted\]"'
$message | Should -Match '"ID_TOKEN" : "\[redacted\]"'
$message | Should -Match '"refresh_token":"\[redacted\]"'
$message | Should -Match '"token":"\[redacted\]"'
$message | Should -Match '"requestId":"safe-id"'
$message | Should -Not -Match ':\s*"(?:sensitive|assertion|id-secret|refresh|general)"'
}

It 'caps HTTP timeouts and retry sleeps to the shared remaining budget' {
Mock Get-DispatcherElapsedSeconds { return 455 } -ModuleName QueueCiFixAzdoValidationTest

Expand Down
50 changes: 49 additions & 1 deletion .github/scripts/Queue-CiFixAzdoValidation.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,54 @@ function Get-ObjectPropertyValue {
return $property.Value
}

function Get-AzdoQueueFailureMessage {
param(
[Parameter(Mandatory = $true)][System.Management.Automation.ErrorRecord]$ErrorRecord,
[ValidateRange(256, 4096)][int]$MaximumDetailLength = 1024
)

$exceptionMessage = $ErrorRecord.Exception.Message
$responseBody = [string](Get-ObjectPropertyValue -InputObject $ErrorRecord.ErrorDetails -Name 'Message')
if ([string]::IsNullOrWhiteSpace($responseBody)) {
return $exceptionMessage
}

$detail = $responseBody
try {
$errorResponse = $responseBody | ConvertFrom-Json -Depth 10 -ErrorAction Stop
$azureMessage = [string](Get-ObjectPropertyValue -InputObject $errorResponse -Name 'message')
$typeKey = [string](Get-ObjectPropertyValue -InputObject $errorResponse -Name 'typeKey')
if (-not [string]::IsNullOrWhiteSpace($azureMessage)) {
$detail = if ([string]::IsNullOrWhiteSpace($typeKey)) {
$azureMessage
}
else {
"$azureMessage (Azure type: $typeKey)"
}
}
}
catch [System.ArgumentException] {
# Preserve non-JSON provider diagnostics after applying the same bounds and redaction.
}

$detail = $detail -replace '[\r\n\t]+', ' '
$detail = $detail -replace '(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+', '$1 [redacted]'
$detail = $detail -replace '(?i)("(?:client_assertion|access_token|id_token|refresh_token|token)"\s*:\s*)"(?:\\.|[^"\\])*"', '$1"[redacted]"'
$detail = $detail -replace '(?i)\b(client_assertion|access_token|id_token|refresh_token|token)=([^&\s]+)', '$1=[redacted]'
Comment thread
Copilot marked this conversation as resolved.
$detail = $detail -replace '\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b', '[redacted-jwt]'
$detail = $detail.Trim()
if ($detail.Length -gt $MaximumDetailLength) {
$truncationSuffix = '... [truncated]'
$detail = $detail.Substring(0, $MaximumDetailLength - $truncationSuffix.Length) + $truncationSuffix
}

if ([string]::IsNullOrWhiteSpace($detail)) {
return $exceptionMessage
}

return "$exceptionMessage Azure response: $detail"
}

function Test-CiFixPrFingerprint {
param(
[Parameter(Mandatory = $true)][string]$Repository,
Expand Down Expand Up @@ -777,7 +825,7 @@ function Invoke-AzdoPipelineQueue {
}
catch {
if (-not (Test-IsTransientHttpException -Exception $_.Exception)) {
throw
throw (Get-AzdoQueueFailureMessage -ErrorRecord $_)
}
$queueStatusCode = Get-HttpStatusCode -Exception $_.Exception

Expand Down
Loading