mirror of https://github.com/raandree/NTFSSecurity
Browse Source
Test-MatrixCleanup.ps1 now counts and repairs the probe folders (C:\NtfsProbeRecreation and C:\NtfsProbeModules), the local NtfsProbe* users with their profiles, and the NtfsProbe* objects of the directory. The scripts of the matrix default to the client OSWin11E. Export-CellTimeline.ps1 writes one row for every cell, edition, and Admin role: the module, the account and its relative ID, the times, and the three effective-access tests. Test-StaleAuthzModel.ps1 replays such a timeline against a model of the failures: the fit, a listing of the runs, the cells of a controller that reuses the account name, and a permutation test. The comment in the controller says what the replay showed. The code of the controller is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant <ai@example.com>pull/119/head
6 changed files with 288 additions and 17 deletions
@ -0,0 +1,112 @@ |
|||||
|
[CmdletBinding()] |
||||
|
param ( |
||||
|
[Parameter(Mandatory)] [string] $MatrixRoot, |
||||
|
[Parameter(Mandatory)] [string[]] $Label, |
||||
|
[Parameter(Mandatory)] [string] $OutputPath |
||||
|
) |
||||
|
|
||||
|
# The timeline of the cells of the operating-system matrix (Decision 24): for each cell and edition, in the order in which the cells ran, the module under |
||||
|
# test, the account of case 3 (its name and its relative ID, which tells two accounts of one name apart within a domain), when the previous fixture was |
||||
|
# removed, when the accounts were created, when the Admin role started, the minutes between |
||||
|
# them, and the three effective-access tests of case 3 in the Admin role (result, milliseconds, and the rights that a failing test received). A failing |
||||
|
# effective-access test of the Admin role is easy to blame on the module or on the environment; this table puts it beside the module, the position of |
||||
|
# the cell in the sequence, and the age of the accounts. The times come from the logs of Run-MatrixSequence.ps1 and of the controller (UTC). It reads |
||||
|
# files only; Windows PowerShell 5.1 or PowerShell 7. |
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
# -File passes an array as one string, so a list may arrive as 'A,B'. |
||||
|
$Label = @($Label | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
||||
|
function Get-LogTime { |
||||
|
param ([string[]] $Lines, [string] $Pattern) |
||||
|
$line = $Lines | Where-Object -FilterScript { $_ -match $Pattern } | Select-Object -First 1 |
||||
|
if ($line -and $line -match '^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)Z?\]') { |
||||
|
[DateTime]::ParseExact($Matches[1], 'yyyy-MM-dd HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$cells = New-Object -TypeName 'System.Collections.Generic.List[object]' |
||||
|
foreach ($name in $Label) { |
||||
|
$sequenceLog = Join-Path -Path $MatrixRoot -ChildPath ('{0}-sequence.log' -f $name) |
||||
|
if (-not (Test-Path -LiteralPath $sequenceLog)) { continue } |
||||
|
$candidate = if ((Get-Content -LiteralPath $sequenceLog -TotalCount 1) -match 'candidate-(\w+)') { $Matches[1] } else { '' } |
||||
|
foreach ($folder in (Get-ChildItem -LiteralPath $MatrixRoot -Directory -Filter ('{0}-*' -f $name))) { |
||||
|
$runLog = Join-Path -Path $folder.FullName -ChildPath 'run.log' |
||||
|
if (-not (Test-Path -LiteralPath $runLog)) { continue } |
||||
|
$run = @(Get-Content -LiteralPath $runLog) |
||||
|
$removeLog = Join-Path -Path $folder.FullName -ChildPath 'cleanup-2-remove.log' |
||||
|
$removed = if (Test-Path -LiteralPath $removeLog) { Get-LogTime -Lines @(Get-Content -LiteralPath $removeLog) -Pattern 'Removed the live tests' } |
||||
|
$configuration = Get-ChildItem -LiteralPath (Join-Path -Path $folder.FullName -ChildPath 'Results') -Recurse -Filter 'local-*.json' -ErrorAction SilentlyContinue | |
||||
|
Where-Object -FilterScript { $_.Name -match '-\d{14}\.json$' } | Select-Object -First 1 |
||||
|
$subject = if ($configuration) { (Get-Content -LiteralPath $configuration.FullName -Raw | ConvertFrom-Json).Accounts.Subject } else { $null } |
||||
|
$cells.Add([pscustomobject]@{ |
||||
|
Run = $name |
||||
|
Candidate = $candidate |
||||
|
FileServer = $folder.Name.Substring($name.Length + 1) |
||||
|
Folder = $folder.FullName |
||||
|
Lines = $run |
||||
|
Subject = $(if ($subject) { $subject.Name } else { '' }) |
||||
|
SubjectRid = $(if ($subject) { ($subject.Sid -split '-')[-1] } else { '' }) |
||||
|
Started = Get-LogTime -Lines $run -Pattern 'START live tests' |
||||
|
Created = Get-LogTime -Lines $run -Pattern 'Preparing the accounts' |
||||
|
Removed = $removed |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$rows = New-Object -TypeName 'System.Collections.Generic.List[object]' |
||||
|
$previous = $null |
||||
|
foreach ($cell in ($cells | Sort-Object -Property Started)) { |
||||
|
foreach ($edition in 'Desktop', 'Core') { |
||||
|
$adminStart = Get-LogTime -Lines $cell.Lines -Pattern "local-$edition-\d+: role Admin$" |
||||
|
if (-not $adminStart) { continue } |
||||
|
$tests = @{ T1 = ''; T2 = ''; T3 = '' } |
||||
|
$failures = 0 |
||||
|
$adminLog = Get-ChildItem -LiteralPath (Join-Path -Path $cell.Folder -ChildPath 'Results') -Recurse -Filter "local-$edition-*-Admin.log" | Select-Object -First 1 |
||||
|
if ($adminLog) { |
||||
|
$text = @(Get-Content -LiteralPath $adminLog.FullName) |
||||
|
$start = ($text | Select-String -Pattern 'Describing Get-NTFSEffectiveAccess for a domain account on a share folder' | Select-Object -First 1).LineNumber |
||||
|
$seen = 0 |
||||
|
for ($index = $start; $start -and $index -lt $text.Count -and $seen -lt 3; $index++) { |
||||
|
if ($text[$index] -notmatch '^\s+\[([+-])\] (.+?) (\d+(?:\.\d+)?m?s) \(') { continue } |
||||
|
$outcome = $Matches[1]; $title = $Matches[2]; $duration = $Matches[3] |
||||
|
$received = '' |
||||
|
if ($outcome -eq '-') { |
||||
|
$failures++ |
||||
|
for ($next = $index + 1; $next -lt [Math]::Min($index + 12, $text.Count); $next++) { |
||||
|
if ($text[$next] -match "But was:\s+'(0x\w+)'") { $received = ' ' + $Matches[1]; break } |
||||
|
if ($text[$next] -match 'Expected \$null or empty') { $received = ' errors'; break } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$key = if ($title -match 'with -ServerName, without') { 'T1' } elseif ($title -match 'without -ServerName') { 'T2' } else { 'T3' } |
||||
|
$tests[$key] = '{0} {1}{2}' -f $(if ($outcome -eq '+') { 'pass' } else { 'FAIL' }), $duration, $received |
||||
|
$seen++ |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$hasPrevious = $null -ne $previous -and $null -ne $previous.Removed |
||||
|
$rows.Add([pscustomobject][ordered]@{ |
||||
|
Run = $cell.Run |
||||
|
Candidate = $cell.Candidate |
||||
|
FileServer = $cell.FileServer |
||||
|
Edition = $edition |
||||
|
Subject = $cell.Subject |
||||
|
SubjectRid = $cell.SubjectRid |
||||
|
SameSubjectAsPreviousCell = [bool] ($null -ne $previous -and $cell.Subject -and $previous.Subject -eq $cell.Subject) |
||||
|
PreviousRemoval = $(if ($hasPrevious) { '{0:yyyy-MM-dd HH:mm:ss}' -f $previous.Removed }) |
||||
|
AccountsCreated = '{0:yyyy-MM-dd HH:mm:ss}' -f $cell.Created |
||||
|
AdminRoleStarted = '{0:yyyy-MM-dd HH:mm:ss}' -f $adminStart |
||||
|
MinutesRemovalToCreation = $(if ($hasPrevious) { '{0:N1}' -f ($cell.Created - $previous.Removed).TotalMinutes }) |
||||
|
MinutesCreationToAdmin = '{0:N1}' -f ($adminStart - $cell.Created).TotalMinutes |
||||
|
MinutesRemovalToAdmin = $(if ($hasPrevious) { '{0:N1}' -f ($adminStart - $previous.Removed).TotalMinutes }) |
||||
|
T1ServerNameFileServer = $tests.T1 |
||||
|
T2DefaultServerName = $tests.T2 |
||||
|
T3UnreachableServerName = $tests.T3 |
||||
|
EffectiveAccessFailures = $failures |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
$previous = [pscustomobject]@{ Removed = $cell.Removed; Subject = $cell.Subject } |
||||
|
} |
||||
|
|
||||
|
$rows | Export-Csv -LiteralPath $OutputPath -NoTypeInformation -Encoding ASCII |
||||
|
'{0} rows for {1} cells written to {2}' -f $rows.Count, $cells.Count, $OutputPath |
||||
@ -0,0 +1,129 @@ |
|||||
|
[CmdletBinding()] |
||||
|
param ( |
||||
|
[Parameter(Mandatory)] [string] $Timeline, |
||||
|
[ValidateRange(1, 60)] [double] $FromMinutes = 3, |
||||
|
[ValidateRange(1, 60)] [double] $ToMinutes = 16, |
||||
|
[ValidateRange(0.01, 5)] [double] $StepMinutes = 0.25, |
||||
|
[ValidateRange(1, 60)] [double] $Lifetime, |
||||
|
[ValidateRange(0, 100000)] [int] $Permutations = 0, |
||||
|
[switch] $AsIfSameSubject, |
||||
|
[switch] $ShowMismatches |
||||
|
) |
||||
|
|
||||
|
# Replays the Admin roles of a timeline (Export-CellTimeline.ps1) against a model of the failures that the effective-access tests of the Admin role showed in |
||||
|
# the cells of the operating-system matrix (Decision 24). The model is a description of the observations, not an explanation of Windows: |
||||
|
# |
||||
|
# A remote authorization manager (the one of the client for the default -ServerName, the one of the file server for its own name) computes the groups of an |
||||
|
# account at the first request for the account name and answers from that result for L minutes, also when the account was deleted and created again under |
||||
|
# the same name in the meantime, with a new SID and new group memberships. The answer then has no access through the groups (0x100000, Synchronize only). |
||||
|
# |
||||
|
# For each L from -FromMinutes to -ToMinutes, the script walks the Admin roles in time order, keeps one entry per computer and account name, and predicts |
||||
|
# whether the first (file server) and the second (client) test pass: a test passes when no entry that is younger than L minutes and was made for another |
||||
|
# account instance exists. It reports how many of the observed outcomes each L predicts. A fit says that the position of a cell in the sequence is enough to |
||||
|
# explain the failures, whichever module was under test; it doesn't say how Windows does it, or that the lifetime is a constant. A run whose account name is |
||||
|
# new always passes, which is what the controller relies on since 1dec389. It reads files only; Windows PowerShell 5.1 or PowerShell 7. |
||||
|
# |
||||
|
# -Lifetime L lists every run with the observed and the predicted outcome of both tests for that one L instead of searching for the best L. -AsIfSameSubject |
||||
|
# gives every run the same account name: for the cells of a controller that gives each fixture a new name (rc7l and later), the listing then shows where a |
||||
|
# controller that reuses the name would have met a stale entry. -Permutations N asks how often a random assignment of the observed outcomes to the runs |
||||
|
# (the same number of failures, a fixed random seed) reaches the best agreement of the real outcomes for some L: if the position of a cell decides the |
||||
|
# outcome, it should almost never. |
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
$random = New-Object -TypeName 'System.Random' -ArgumentList 20261010 |
||||
|
$rows = @(Import-Csv -LiteralPath $Timeline | ForEach-Object -Process { |
||||
|
[pscustomobject]@{ |
||||
|
Time = [DateTime]::ParseExact($_.AdminRoleStarted, 'yyyy-MM-dd HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) |
||||
|
Run = $_.Run |
||||
|
Candidate = $_.Candidate |
||||
|
Server = $_.FileServer |
||||
|
Edition = $_.Edition |
||||
|
Subject = if ($AsIfSameSubject) { 'one name' } else { $_.Subject } |
||||
|
Sid = $_.SubjectRid |
||||
|
Test1 = $_.T1ServerNameFileServer -like 'pass*' |
||||
|
Test2 = $_.T2DefaultServerName -like 'pass*' |
||||
|
} |
||||
|
} | Sort-Object -Property Time) |
||||
|
|
||||
|
function Test-Model { |
||||
|
param ([double] $Minutes, [ValidateSet('Test1', 'Test2')] [string] $Test) |
||||
|
|
||||
|
$entries = @{} |
||||
|
$mismatch = New-Object -TypeName 'System.Collections.Generic.List[string]' |
||||
|
$predictions = New-Object -TypeName 'System.Collections.Generic.List[bool]' |
||||
|
$agree = 0 |
||||
|
foreach ($row in $rows) { |
||||
|
$scope = if ($Test -eq 'Test2') { 'client|' + $row.Subject } else { $row.Server + '|' + $row.Subject } |
||||
|
$entry = $entries[$scope] |
||||
|
if ($entry -and ($row.Time - $entry.Created).TotalMinutes -lt $Minutes) { |
||||
|
$predicted = $entry.Sid -eq $row.Sid |
||||
|
} |
||||
|
else { |
||||
|
$entries[$scope] = @{ Created = $row.Time; Sid = $row.Sid } |
||||
|
$predicted = $true |
||||
|
} |
||||
|
|
||||
|
$predictions.Add($predicted) |
||||
|
$observed = $row.$Test |
||||
|
if ($predicted -eq $observed) { |
||||
|
$agree++ |
||||
|
} |
||||
|
else { |
||||
|
$mismatch.Add(('{0:HH:mm} {1} {2} {3} [{4}]: observed {5}, model {6}' -f $row.Time, $row.Run, $row.Server, $row.Edition, $row.Candidate, |
||||
|
$(if ($observed) { 'pass' } else { 'FAIL' }), $(if ($predicted) { 'pass' } else { 'FAIL' }))) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[pscustomobject]@{ Minutes = $Minutes; Agree = $agree; Mismatch = $mismatch; Predictions = $predictions } |
||||
|
} |
||||
|
|
||||
|
if ($PSBoundParameters.ContainsKey('Lifetime')) { |
||||
|
$first = Test-Model -Minutes $Lifetime -Test Test1 |
||||
|
$second = Test-Model -Minutes $Lifetime -Test Test2 |
||||
|
$word = { param ($Passed) if ($Passed) { 'pass' } else { 'FAIL' } } |
||||
|
for ($index = 0; $index -lt $rows.Count; $index++) { |
||||
|
$row = $rows[$index] |
||||
|
[pscustomobject]@{ |
||||
|
Time = $row.Time.ToString('MM-dd HH:mm:ss') |
||||
|
Run = $row.Run |
||||
|
Server = $row.Server |
||||
|
Edition = $row.Edition |
||||
|
Candidate = $row.Candidate |
||||
|
Subject = $row.Subject |
||||
|
Test1 = & $word $row.Test1 |
||||
|
Test1Model = & $word $first.Predictions[$index] |
||||
|
Test2 = & $word $row.Test2 |
||||
|
Test2Model = & $word $second.Predictions[$index] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return |
||||
|
} |
||||
|
|
||||
|
foreach ($test in 'Test1', 'Test2') { |
||||
|
$results = for ($minutes = $FromMinutes; $minutes -le $ToMinutes; $minutes += $StepMinutes) { Test-Model -Minutes $minutes -Test $test } |
||||
|
$best = ($results | Measure-Object -Property Agree -Maximum).Maximum |
||||
|
$bestResults = @($results | Where-Object -FilterScript { $_.Agree -eq $best }) |
||||
|
$failures = @($rows | Where-Object -FilterScript { -not $_.$test }).Count |
||||
|
'{0} ({1}): the model predicts {2} of {3} outcomes for L from {4:N2} to {5:N2} minutes; {6} runs failed' -f $test, |
||||
|
$(if ($test -eq 'Test1') { 'the name of the file server' } else { 'the default server name, the client' }), $best, $rows.Count, $bestResults[0].Minutes, $bestResults[-1].Minutes, $failures |
||||
|
if ($ShowMismatches) { foreach ($line in $bestResults[0].Mismatch) { ' mismatch: ' + $line } } |
||||
|
if ($Permutations -gt 0) { |
||||
|
$observed = [bool[]] @($rows | ForEach-Object -Process { $_.$test }) |
||||
|
$reached = 0 |
||||
|
$highest = 0 |
||||
|
for ($shuffle = 0; $shuffle -lt $Permutations; $shuffle++) { |
||||
|
$shuffled = [bool[]] @($observed | Sort-Object -Property { $random.Next() }) |
||||
|
$agreement = 0 |
||||
|
foreach ($result in $results) { |
||||
|
$agree = 0 |
||||
|
for ($index = 0; $index -lt $shuffled.Count; $index++) { if ($result.Predictions[$index] -eq $shuffled[$index]) { $agree++ } } |
||||
|
if ($agree -gt $agreement) { $agreement = $agree } |
||||
|
} |
||||
|
|
||||
|
if ($agreement -ge $best) { $reached++ } |
||||
|
if ($agreement -gt $highest) { $highest = $agreement } |
||||
|
} |
||||
|
|
||||
|
' {0} of {1} random assignments of the outcomes to the runs reach {2} of {3} for some L; the best of them reaches {4}' -f $reached, $Permutations, $best, $rows.Count, $highest |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue