cdc6bb33d3
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>
146 lines
6.0 KiB
PowerShell
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
|
|
}
|
|
}
|