#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# Principle V: no token, Authorization header, secret, or raw Graph response can appear in an audit record. The strongest guarantee here is structural rather than filtered. New-PersonaAuditRecord accepts only named, typed values from the decision result and the counters - there is no pass-through of an arbitrary object, so there is nothing for a secret to ride in on. These tests assert that property holds, and that it still holds when a caller actively tries to smuggle one in. #> BeforeAll { $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent . (Join-Path $repoRoot 'tests/TestHelpers.ps1') foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file } $script:target = 'extension__' # Values that must never survive into a record, each distinctive enough to find in # a serialized blob. $script:secrets = @( 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.SYNTHETIC.TOKEN' 'Bearer SYNTHETIC-ACCESS-TOKEN-VALUE' 'SYNTHETIC-CLIENT-SECRET-VALUE' ) $script:forbiddenKeys = @('authorization', 'accesstoken', 'access_token', 'token', 'clientsecret', 'client_secret', 'secret', 'password', 'rawresponse', 'credential') } Describe 'Audit records contain no credential material' { BeforeAll { Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) } Mock Write-Host { } Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } } $info = $null $null = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` -TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') ` -AuditParameters @{ Destination = 'stream' } ` -IsEnforcing -ShouldProcessGate { param($t, $d) $true } ` -InformationVariable info $script:records = Get-CapturedAuditRecord -Captured $info } It 'produced records to inspect' { $script:records.Count | Should -BeGreaterThan 0 } It 'contains no field whose name suggests credential material' { foreach ($record in $script:records) { foreach ($key in $record.Keys) { $key.ToLowerInvariant() | Should -Not -BeIn $script:forbiddenKeys } } } It 'contains nothing that looks like a JWT once serialized' { foreach ($record in $script:records) { ($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match 'eyJ[A-Za-z0-9_-]{10,}' } } It 'contains no Bearer prefix once serialized' { foreach ($record in $script:records) { ($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match '(?i)bearer\s' } } } Describe 'A record cannot be made to carry a secret through the decision result' { It 'ignores unexpected fields on the decision result' { # A result object carrying an extra field - as it would if some future adapter # attached a raw response - must not propagate it. The builder reads named # fields only. $result = [pscustomobject]@{ AccountObjectId = '00000000-0000-0000-0000-000000000101' UserPrincipalName = 'a@example.invalid' Outcome = 'Matched' MatchedRuleId = 'RULE-0010' CalculatedPersona = 'Employee' StoredPersona = 'Employee' Action = 'Unchanged' EvaluationErrorReason = $null RulesEvaluated = 1 DurationMs = 2 ConditionTrace = $null AccessToken = $script:secrets[1] RawGraphResponse = @{ Authorization = $script:secrets[0] } } $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result $record.Contains('AccessToken') | Should -BeFalse $record.Contains('RawGraphResponse') | Should -BeFalse ($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match 'SYNTHETIC' } It 'keeps the UPN and Object ID, which are approved for logs' { # Redaction must not go so far that the record stops being useful. NFR-005 # requires both fields on every user event. $result = [pscustomobject]@{ AccountObjectId = '00000000-0000-0000-0000-000000000101' UserPrincipalName = 'a@example.invalid' Outcome = 'Matched'; MatchedRuleId = 'RULE-0010' CalculatedPersona = 'Employee'; StoredPersona = 'Employee' Action = 'Unchanged'; EvaluationErrorReason = $null RulesEvaluated = 1; DurationMs = 2; ConditionTrace = $null } $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result $record['userPrincipalName'] | Should -Be 'a@example.invalid' $record['accountObjectId'] | Should -Be '00000000-0000-0000-0000-000000000101' } } Describe 'The Graph request helper never logs credential material' { It 'does not write the request body or headers to the verbose stream' { Mock Invoke-MgGraphRequest { @{ value = @() } } $captured = Invoke-PersonaGraphRequest -Uri '/v1.0/users' -Body @{ secret = $script:secrets[2] } -Method 'PATCH' -Verbose 4>&1 | Out-String $captured | Should -Not -Match 'Bearer' $captured | Should -Not -Match 'eyJ' $captured | Should -Not -Match 'SYNTHETIC' } It 'reports a failure without echoing the response body' { Mock Invoke-MgGraphRequest { throw 'Response status code does not indicate success: 403 (Forbidden).' } { Invoke-PersonaGraphRequest -Uri '/v1.0/users' } | Should -Throw # A 403 is never retried, so the message surfaces once, unchanged, and carries # only what Graph put in the status line. Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly } }