Files
personaEngine2/tests/Unit/AuditRedaction.Tests.ps1
T

146 lines
6.0 KiB
PowerShell
Raw Normal View History

#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_<EXTENSION-APP-ID>_<PERSONA>'
# 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
}
}