Files
personaEngine2/tests/Unit/AuditRedaction.Tests.ps1
T
dave cdc6bb33d3 Implement Stage A: rule engine, validation, audit, and safety gates
Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).

  354 offline Pester tests      PASS
  Engine purity (Principle IV)  PASS
  Sanitization (SC-013)         PASS  (156 files)
  Graph module loaded in tests  none  (SC-008 holds)

What landed
  - Four-layer configuration validation with stable finding codes, covering
    every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
  - Run loop, audit records (NDJSON through a single sink), summaries,
    reconciliation, and exit codes 0-6
  - Persistence behind a single write-body builder whose result always has
    exactly one key
  - Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
  - Six docs, two pipelines, traceability matrix, V-5a and sanitization records

Three deviations from tasks.md, each recorded in its status block

  T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
  state and the rule engine is pure; a counter there would break Principle IV.
  It lives in New-PersonaRunCounter and is applied in the run loop.

  A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
  imports the manifest, which requires Microsoft.Graph.Authentication, so a
  loop living only inside it could not run on a machine without the Graph SDK
  and SC-004 could not be proven at all. The entry script is now a thin
  wrapper and what ships is what is tested.

  The invalid-config corpus is generated by a committed script, with the
  generated fixtures committed too, so a reviewer sees the fixture in the diff.

Defects found by running the code, not by reading it

  Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
  around a comma-returned array collapsed every membership list into one
  bogus space-joined entry. That is a silent false non-match, exactly what
  FR-013 exists to prevent.

  A 403 whose status appears only in the exception message parsed as $null,
  which the retry policy treats as a transport error - five requests per
  account against a tenant already refusing. Status extraction now falls back
  to the message text, bounded to 400-599.

  The sanitization scan walked tracked files only, so it covered 34 of 156
  files and none of this phase's code. It now scans untracked non-ignored
  files too, and a negative control confirms it catches a planted leak.

  Test-Json reports one error per violating location, not first-failure-only
  as the V-5a draft claimed. Record and pin corrected.

Enforcement remains blocked on the V-4 security sign-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:48:19 -04:00

146 lines
6.0 KiB
PowerShell

#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
}
}