Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a606f2ae52 | ||
|
|
b1c7c27d3c | ||
|
|
e7c90802e9 | ||
|
|
d85e0fffe4 | ||
|
|
37e61613d9 |
No files matched your search
@@ -875,6 +875,7 @@ jobs:
|
||||
tests/boundary/process/packageNpmInvocation.test.ts
|
||||
tests/boundary/process/packedBridgeVerification.test.ts
|
||||
tests/boundary/process/windowsOutputCapture.test.ts
|
||||
tests/boundary/process/windowsPrivateRuntimeAclObserver.test.ts
|
||||
tests/acceptance/webModuleCli.test.ts
|
||||
tests/acceptance/webSourceLocationCli.test.ts
|
||||
tests/boundary/cli/referenceSourceImportStatus.test.ts
|
||||
|
||||
+10
-3
@@ -708,9 +708,16 @@ artifacts, dependency installs, package archives, and runtime verification remai
|
||||
host-local. Keep platform-dependent outputs out of this shared cache.
|
||||
|
||||
The Windows native verifier's private-runtime ACL observer reads SID rules once
|
||||
for both trustee validation and counting. It deliberately rejects the convenience
|
||||
`Access` property, which resolves account names. Its `private-runtime-acl` stderr
|
||||
markers report completed phases and elapsed time; inspect these on a timeout
|
||||
for both trustee validation and counting, using .NET APIs and a fixed JSON
|
||||
projection of booleans and a count. Module autoloading is disabled; the observer
|
||||
does not need PowerShell utility cmdlets or account-name resolution. A separate
|
||||
PowerShell regression checks the shared projection with utility cmdlet traps
|
||||
and strict property access on a language-defined ACL fixture that has no `Access`
|
||||
convenience property. Fixture setup also avoids utility cmdlets. It covers
|
||||
foreign trustees, different owners, an unprotected DACL, and empty/single rules;
|
||||
these substitutes do not establish real Windows ACL behavior. The observer's
|
||||
`private-runtime-acl` stderr markers report completed phases and elapsed time;
|
||||
inspect these on a timeout
|
||||
before attributing it to startup, ACL reads, or identity lookup. The original
|
||||
15-second deadline and all ownership, DACL, and readonly assertions remain in
|
||||
force. A passing retry alone does not explain a previous timeout.
|
||||
|
||||
@@ -118,6 +118,7 @@ export const SCOPES = {
|
||||
"tests/boundary/process/releaseArtifactCanary.test.ts",
|
||||
"src/windows/**",
|
||||
"scripts/lib/windows-owned-command.mjs",
|
||||
"scripts/lib/windows-private-runtime-acl.ps1",
|
||||
"scripts/run-exclusive-command.mjs",
|
||||
"tests/process-global/runnerCancellation.test.ts",
|
||||
"tests/process-global/windowsOwnedCommand.test.ts",
|
||||
@@ -170,6 +171,7 @@ export const SCOPES = {
|
||||
"tests/boundary/process/packageNpmInvocation.test.ts",
|
||||
"tests/boundary/process/packedBridgeVerification.test.ts",
|
||||
"tests/boundary/process/windowsOutputCapture.test.ts",
|
||||
"tests/boundary/process/windowsPrivateRuntimeAclObserver.test.ts",
|
||||
"tests/acceptance/webModuleCli.test.ts",
|
||||
"tests/acceptance/webSourceLocationCli.test.ts",
|
||||
"tests/boundary/cli/referenceSourceImportStatus.test.ts",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
param($Acl, [string]$UserSid, [string]$Path, [Diagnostics.Stopwatch]$Clock)
|
||||
|
||||
# Keep this independent observer on .NET SID APIs. PowerShell convenience
|
||||
# properties and utility cmdlets can introduce account lookup/module loading.
|
||||
$PSModuleAutoLoadingPreference = 'None'
|
||||
function Trace-AclPhase([string]$phase) {
|
||||
[Console]::Error.WriteLine('private-runtime-acl: ' + $phase + ' ' + $Clock.ElapsedMilliseconds + 'ms')
|
||||
}
|
||||
$owner = $Acl.GetOwner([Security.Principal.SecurityIdentifier])
|
||||
Trace-AclPhase 'owner-sid-read'
|
||||
$rules = @($Acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]))
|
||||
Trace-AclPhase 'rule-sids-read'
|
||||
$trusteesOnlyUserAndSystem = $true
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if ($sid -ne $UserSid -and $sid -ne 'S-1-5-18') {
|
||||
$trusteesOnlyUserAndSystem = $false
|
||||
}
|
||||
}
|
||||
[IO.File]::SetAttributes($Path, [IO.FileAttributes]([int][IO.File]::GetAttributes($Path) -bor 1))
|
||||
Trace-AclPhase 'readonly-set'
|
||||
|
||||
# The wire values are exclusively booleans and a collection count, so no
|
||||
# arbitrary strings need JSON escaping or a general-purpose serializer.
|
||||
$values = @(
|
||||
([bool]$Acl.AreAccessRulesProtected).ToString().ToLowerInvariant(),
|
||||
($owner.Value -eq $UserSid).ToString().ToLowerInvariant(),
|
||||
$trusteesOnlyUserAndSystem.ToString().ToLowerInvariant(),
|
||||
$rules.Count.ToString([Globalization.CultureInfo]::InvariantCulture),
|
||||
(([int][IO.File]::GetAttributes($Path) -band 1) -ne 0).ToString().ToLowerInvariant()
|
||||
)
|
||||
[Console]::WriteLine(('{{"protected":{0},"ownerMatchesUser":{1},"trusteesOnlyUserAndSystem":{2},"count":{3},"rootReadonly":{4}}}' -f $values))
|
||||
Trace-AclPhase 'complete'
|
||||
@@ -48,6 +48,10 @@ const { PrivateRuntimeRootUnavailableError } = await import(
|
||||
join(packageRoot, "dist/process/PrivateRuntimeRootUnavailableError.js"),
|
||||
)
|
||||
);
|
||||
const privateRuntimeAclObserver = await readFile(
|
||||
new URL("./lib/windows-private-runtime-acl.ps1", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-native-conformance-"));
|
||||
// A pwsh caller can supply module paths incompatible with Windows PowerShell.
|
||||
// Each independent observer must discover its own module path.
|
||||
@@ -371,35 +375,17 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
"-Command",
|
||||
String.raw`
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSModuleAutoLoadingPreference = 'None'
|
||||
$clock = [Diagnostics.Stopwatch]::StartNew()
|
||||
function Trace-AclPhase([string]$phase) {
|
||||
[Console]::Error.WriteLine('private-runtime-acl: ' + $phase + ' ' + $clock.ElapsedMilliseconds + 'ms')
|
||||
}
|
||||
Trace-AclPhase 'started'
|
||||
$p = $env:REA_TEST_PRIVATE_RUNTIME_PATH
|
||||
$a = Get-Acl -LiteralPath $p
|
||||
$a = ([IO.DirectoryInfo]::new($p)).GetAccessControl()
|
||||
Trace-AclPhase 'acl-read'
|
||||
$u = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$owner = $a.GetOwner([Security.Principal.SecurityIdentifier])
|
||||
Trace-AclPhase 'owner-sid-read'
|
||||
# Guard the independent observer against accidental account-name resolution.
|
||||
# Access is a PowerShell convenience property that requests NTAccount rules.
|
||||
Update-TypeData -TypeName ($a.GetType().FullName) -MemberType ScriptProperty -MemberName Access -Value { throw 'ACL observation must use SID rules' } -Force
|
||||
if ($a.PSObject.Properties['Access'].MemberType -ne [Management.Automation.PSMemberTypes]::ScriptProperty) {
|
||||
throw 'ACL observation Access guard was not installed'
|
||||
}
|
||||
$rules = @($a.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]))
|
||||
Trace-AclPhase 'rule-sids-read'
|
||||
[IO.File]::SetAttributes($p, [IO.FileAttributes]([int][IO.File]::GetAttributes($p) -bor 1))
|
||||
Trace-AclPhase 'readonly-set'
|
||||
@{
|
||||
protected = $a.AreAccessRulesProtected
|
||||
ownerMatchesUser = ($owner.Value -eq $u.Value)
|
||||
trusteesOnlyUserAndSystem = (@($rules | Where-Object { $_.IdentityReference.Value -ne $u.Value -and $_.IdentityReference.Value -ne 'S-1-5-18' }).Count -eq 0)
|
||||
count = $rules.Count
|
||||
rootReadonly = (([int][IO.File]::GetAttributes($p) -band 1) -ne 0)
|
||||
} | ConvertTo-Json -Compress
|
||||
Trace-AclPhase 'complete'
|
||||
& { ${privateRuntimeAclObserver} } -Acl $a -UserSid $u.Value -Path $p -Clock $clock
|
||||
`,
|
||||
],
|
||||
{
|
||||
|
||||
@@ -761,6 +761,14 @@ it.each([
|
||||
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
|
||||
{ code: true, package: true, runtime: true, apple: false },
|
||||
],
|
||||
[
|
||||
"scripts/lib/windows-private-runtime-acl.ps1",
|
||||
{ code: true, package: true, runtime: true, apple: false },
|
||||
],
|
||||
[
|
||||
"tests/boundary/process/windowsPrivateRuntimeAclObserver.test.ts",
|
||||
{ code: true, package: true, runtime: true, apple: false },
|
||||
],
|
||||
[
|
||||
"tests/boundary/filesystem/clientConfigurationEnvironment.test.ts",
|
||||
{ code: true, package: true, runtime: true, apple: false },
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
const shell = process.platform === "win32" ? "powershell.exe" : "pwsh";
|
||||
const shellError =
|
||||
process.platform === "win32"
|
||||
? undefined
|
||||
: spawnSync(
|
||||
shell,
|
||||
["-NoProfile", "-NonInteractive", "-Command", "exit 0"],
|
||||
{
|
||||
timeout: 15_000,
|
||||
},
|
||||
).error;
|
||||
const missingShell =
|
||||
shellError !== undefined &&
|
||||
"code" in shellError &&
|
||||
shellError.code === "ENOENT";
|
||||
|
||||
it.skipIf(missingShell)(
|
||||
"observes ACL facts without account lookup or PowerShell utility cmdlets",
|
||||
async () => {
|
||||
const observer = await readFile(
|
||||
new URL(
|
||||
"../../../scripts/lib/windows-private-runtime-acl.ps1",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const user = "S-1-5-21-123";
|
||||
const system = "S-1-5-18";
|
||||
const cases = [
|
||||
{ owner: user, protected: true, trustees: [user, system] },
|
||||
{ owner: system, protected: true, trustees: [user, system] },
|
||||
{ owner: user, protected: false, trustees: [user, system] },
|
||||
{
|
||||
owner: user,
|
||||
protected: true,
|
||||
trustees: [user, system, "S-1-5-32-544"],
|
||||
},
|
||||
{ owner: user, protected: true, trustees: [user] },
|
||||
{ owner: user, protected: true, trustees: [] },
|
||||
];
|
||||
const quotePowerShell = (value: string) =>
|
||||
`'${value.replaceAll("'", "''")}'`;
|
||||
const powerShellCases = cases
|
||||
.map(
|
||||
({ owner, protected: protectedAcl, trustees }) =>
|
||||
`[ReaAclFixture]::new(${quotePowerShell(owner)}, $${protectedAcl}, [string[]]@(${trustees.map(quotePowerShell).join(", ")}))`,
|
||||
)
|
||||
.join("\n");
|
||||
const path = await mkdtemp(join(tmpdir(), "rea acl 観測-"));
|
||||
try {
|
||||
const output = execFileSync(
|
||||
shell,
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
String.raw`
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSModuleAutoLoadingPreference = 'None'
|
||||
Set-StrictMode -Version Latest
|
||||
function Update-TypeData { throw 'Utility cmdlet invocation is forbidden' }
|
||||
function ConvertTo-Json { throw 'Utility cmdlet invocation is forbidden' }
|
||||
function ConvertFrom-Json { throw 'Utility cmdlet invocation is forbidden' }
|
||||
function Add-Member { throw 'Utility cmdlet invocation is forbidden' }
|
||||
class ReaAclFixture {
|
||||
[string]$OwnerSid
|
||||
[bool]$AreAccessRulesProtected
|
||||
[object[]]$Rules
|
||||
ReaAclFixture([string]$owner, [bool]$protected, [string[]]$trustees) {
|
||||
$this.OwnerSid = $owner
|
||||
$this.AreAccessRulesProtected = $protected
|
||||
$this.Rules = @(foreach ($sid in $trustees) {
|
||||
[PSCustomObject]@{ IdentityReference = [PSCustomObject]@{ Value = $sid } }
|
||||
})
|
||||
}
|
||||
[object] GetOwner([type]$type) {
|
||||
if ($type -ne [Security.Principal.SecurityIdentifier]) { throw 'Owner must request SID form' }
|
||||
return [PSCustomObject]@{ Value = $this.OwnerSid }
|
||||
}
|
||||
[object[]] GetAccessRules([bool]$explicit, [bool]$inherited, [type]$type) {
|
||||
if (!$explicit -or !$inherited -or $type -ne [Security.Principal.SecurityIdentifier]) {
|
||||
throw 'Inspect all rules in SID form'
|
||||
}
|
||||
return $this.Rules
|
||||
}
|
||||
}
|
||||
$acls = @(
|
||||
${powerShellCases}
|
||||
)
|
||||
foreach ($acl in $acls) {
|
||||
[IO.File]::SetAttributes($env:REA_ACL_PATH, [IO.FileAttributes]::Directory)
|
||||
& { ${observer} } -Acl $acl -UserSid 'S-1-5-21-123' -Path $env:REA_ACL_PATH -Clock ([Diagnostics.Stopwatch]::StartNew())
|
||||
if (([int][IO.File]::GetAttributes($env:REA_ACL_PATH) -band 1) -eq 0) { throw 'Observer did not set readonly' }
|
||||
}
|
||||
`,
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
env: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([name]) => name.toLowerCase() !== "psmodulepath",
|
||||
),
|
||||
),
|
||||
REA_ACL_PATH: path,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
output
|
||||
.trim()
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual([
|
||||
{
|
||||
protected: true,
|
||||
ownerMatchesUser: true,
|
||||
trusteesOnlyUserAndSystem: true,
|
||||
count: 2,
|
||||
rootReadonly: true,
|
||||
},
|
||||
{
|
||||
protected: true,
|
||||
ownerMatchesUser: false,
|
||||
trusteesOnlyUserAndSystem: true,
|
||||
count: 2,
|
||||
rootReadonly: true,
|
||||
},
|
||||
{
|
||||
protected: false,
|
||||
ownerMatchesUser: true,
|
||||
trusteesOnlyUserAndSystem: true,
|
||||
count: 2,
|
||||
rootReadonly: true,
|
||||
},
|
||||
{
|
||||
protected: true,
|
||||
ownerMatchesUser: true,
|
||||
trusteesOnlyUserAndSystem: false,
|
||||
count: 3,
|
||||
rootReadonly: true,
|
||||
},
|
||||
{
|
||||
protected: true,
|
||||
ownerMatchesUser: true,
|
||||
trusteesOnlyUserAndSystem: true,
|
||||
count: 1,
|
||||
rootReadonly: true,
|
||||
},
|
||||
{
|
||||
protected: true,
|
||||
ownerMatchesUser: true,
|
||||
trusteesOnlyUserAndSystem: true,
|
||||
count: 0,
|
||||
rootReadonly: true,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
25_000,
|
||||
);
|
||||
Reference in new issue
Block a user