Skip to content

Commit 63622fa

Browse files
author
e2e
committed
fix(windows): verify compiler integrity from raw security entries
1 parent 575cb39 commit 63622fa

3 files changed

Lines changed: 91 additions & 15 deletions

File tree

scripts/release/windows-smoke-environment.test.mjs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import assert from 'node:assert/strict';
2+
import { execFile } from 'node:child_process';
23
import fs from 'node:fs/promises';
34
import os from 'node:os';
45
import path from 'node:path';
56
import test from 'node:test';
7+
import { promisify } from 'node:util';
68

79
import { verifyWindowsSmokeEnvironment, windowsSmokeEnvironment } from './windows-payload.mjs';
810

@@ -61,6 +63,38 @@ test('Add-Type probe uses constant UTF-16LE encoded source and returns bounded n
6163
});
6264
});
6365

66+
test('Windows raw mandatory ACE validator handles labels independently of SDDL formatting', {
67+
skip: process.platform !== 'win32', timeout: 45_000,
68+
}, async () => {
69+
const { tsImport } = await import('tsx/esm/api');
70+
const { windowsCodeDomLabelValidationScript } = await tsImport(new URL('../../server/gjc-windows-job.ts', import.meta.url).href, import.meta.url);
71+
const cases = [
72+
{ name: 'high', sddl: 'S:(ML;OI;NW;;;HI)', expected: true, count: 1 },
73+
{ name: 'numeric high SID', sddl: 'S:(ML;OICI;NW;;;S-1-16-12288)', expected: true, count: 1 },
74+
{ name: 'additional restrictions', sddl: 'S:(ML;OI;NWNR;;;HI)', expected: true, count: 1 },
75+
{ name: 'medium', sddl: 'S:(ML;OI;NW;;;ME)', expected: false, count: 1 },
76+
{ name: 'missing no-write-up', sddl: 'S:(ML;OI;NR;;;HI)', expected: false, count: 1 },
77+
{ name: 'inherit-only', sddl: 'S:(ML;OIIO;NW;;;HI)', expected: false, count: 1 },
78+
{ name: 'missing SACL', sddl: 'D:(A;;FA;;;BA)', expected: false, count: 0 },
79+
{ name: 'empty SACL', sddl: 'D:(A;;FA;;;BA)S:AI', expected: false, count: 0 },
80+
{ name: 'audit ACE is not a label', sddl: 'S:(AU;SA;FA;;;S-1-16-12288)', expected: false, count: 1 },
81+
];
82+
const source = `$ErrorActionPreference = 'Stop'
83+
${windowsCodeDomLabelValidationScript()}
84+
foreach ($case in ($env:GAJAE_LABEL_FIXTURES | ConvertFrom-Json)) {
85+
$state = Get-GajaeCompilerLabelState ([Security.AccessControl.RawSecurityDescriptor]::new($case.sddl))
86+
[Console]::Out.WriteLine((@{ name = $case.name; valid = $state.hasHighLabel; count = $state.saclCount; aces = $state.aces } | ConvertTo-Json -Compress -Depth 4))
87+
}`;
88+
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
89+
const { stdout } = await promisify(execFile)(path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [
90+
'-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64'),
91+
], { env: { ...process.env, GAJAE_LABEL_FIXTURES: JSON.stringify(cases) }, windowsHide: true, shell: false, timeout: 30_000 });
92+
const results = stdout.trim().split(/\r?\n/).map(line => JSON.parse(line));
93+
assert.deepEqual(results.map(({ name, valid, count }) => ({ name, valid, count })),
94+
cases.map(({ name, expected, count }) => ({ name, valid: expected, count })));
95+
assert.deepEqual(results[0].aces, [{ type: 0x11, size: 20, flags: 1, mask: 1, sid: 'S-1-16-12288' }]);
96+
});
97+
6498
test('real Windows Add-Type works with baseline and isolated Unicode profile, cwd and temp', {
6599
skip: process.platform !== 'win32', timeout: 140_000,
66100
}, async t => {
@@ -92,7 +126,10 @@ test('real Windows Add-Type works with baseline and isolated Unicode profile, cw
92126
if (result.elevated) {
93127
assert.match(result.compilerSddl, /\(D;OI;SD;;;/);
94128
assert.match(result.compilerSddl, /\(A;OICI;FA;;;BA\)/);
95-
assert.match(result.compilerSddl, /\(ML;[^;]*;NW;;;HI\)/);
129+
assert.equal(result.hasHighLabel, true);
130+
assert.ok(result.compilerSaclCount > 0);
131+
assert.ok(result.compilerSaclAces.some(ace => ace.type === 0x11
132+
&& ace.sid === 'S-1-16-12288' && (ace.mask & 1) !== 0 && (ace.flags & 8) === 0));
96133
}
97134
await assert.rejects(fs.access(result.compilerTemp), { code: 'ENOENT' });
98135
if (label === 'isolated Unicode') {

server/gjc-windows-job.test.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
GJC_WINDOWS_JOB_GUARD_READY,
1212
quoteWindowsArgument,
1313
windowsCodeDomCompileScript,
14+
windowsCodeDomLabelValidationScript,
1415
} from './gjc-windows-job.js';
1516

1617
test('quotes Windows argv values without losing quotes or trailing slashes', () => {
@@ -43,28 +44,29 @@ test('CodeDom compilation uses explicit private temp files with the original ele
4344
assert.ok(script.indexOf('$compilerParameters.TempFiles.Delete()') < script.indexOf('[IO.Directory]::Delete'));
4445
});
4546

46-
test('generated PowerShell label regex matches SDDL and diagnostics precede rejection', () => {
47+
test('generated PowerShell enforces raw mandatory ACE fields and reports diagnostics before rejection', () => {
4748
const diagnosticScript = windowsCodeDomCompileScript('public class LabelRegexFixture {}', true);
4849
const launch = createWindowsJobLaunch('node.exe', [], { SystemRoot: 'C:\\Windows' }, 'C:\\');
4950
const loader = Buffer.from(launch.args.at(-1)!, 'base64').toString('utf16le');
5051
const compressed = loader.match(/FromBase64String\('([^']+)'\)/u)?.[1];
5152
assert.ok(compressed);
5253
const guardScript = gunzipSync(Buffer.from(compressed, 'base64')).toString('utf8');
5354
for (const script of [diagnosticScript, guardScript]) {
54-
const pattern = script.match(/\$compilerActualSddl -notmatch '([^']+)'/u)?.[1];
55-
assert.equal(pattern, String.raw`\(ML;[^;]*;NW;;;HI\)`);
56-
const regex = new RegExp(pattern!);
57-
for (const high of ['S:(ML;OI;NW;;;HI)', 'D:(A;OICI;FA;;;BA)S:(ML;;NW;;;HI)', 'S:(ML;OICI;NW;;;HI)']) {
58-
assert.equal(regex.test(high), true, high);
59-
}
60-
for (const rejected of ['S:(ML;OI;NW;;;ME)', 'D:(A;OICI;FA;;;BA)', 'S:(ML;OI;NR;;;HI)']) {
61-
assert.equal(regex.test(rejected), false, rejected);
62-
}
55+
assert.ok(script.includes(windowsCodeDomLabelValidationScript()));
56+
assert.match(script, /\$ace.AceType -eq 0x11/);
57+
assert.match(script, /ToUInt32\(\$bytes, 4\)/);
58+
assert.match(script, /SecurityIdentifier\]::new\(\$bytes, 8\)/);
59+
assert.match(script, /\$entry.sid -eq 'S-1-16-12288'/);
60+
assert.match(script, /\(\$entry.mask -band 1\) -ne 0/);
61+
assert.match(script, /\(\$entry.flags -band 8\) -eq 0/);
62+
assert.doesNotMatch(script, /\$compilerActualSddl -notmatch/);
63+
assert.match(script, /GetFileSecurityW\(\$compilerTemp, 0x14/);
6364
assert.match(script, /requestedCompilerSddl = \$compilerSddl; compilerSddl = \$compilerActualSddl/);
65+
assert.match(script, /compilerSaclCount = \$compilerActualLabels.saclCount; compilerSaclAces = \$compilerActualLabels.aces/);
6466
assert.match(script, /high-integrity label was not preserved\. ' \+ \$compilerSecurityReport/);
6567
}
6668
assert.ok(diagnosticScript.indexOf('[Console]::Out.WriteLine($compilerSecurityReport)')
67-
< diagnosticScript.indexOf('$compilerActualSddl -notmatch'));
69+
< diagnosticScript.indexOf('$compilerElevated -and -not $compilerActualLabels.hasHighLabel'));
6870
});
6971

7072
test('builds a guard that atomically creates the worker inside a Windows job', () => {

server/gjc-windows-job.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,43 @@ const REAP_ENV = 'GAJAE_INTERNAL_JOB_REAP';
1313
export const GJC_WINDOWS_JOB_GUARD_READY = 'gajae-job-guard-ready-v1';
1414
export const GJC_WINDOWS_JOB_GUARD_ACK = 'gajae-job-guard-ack-v1';
1515

16+
/** Inspect raw mandatory ACEs: GetSddlForm(All) does not request label output. */
17+
export function windowsCodeDomLabelValidationScript(): string {
18+
return String.raw`
19+
function Get-GajaeCompilerLabelState([Security.AccessControl.RawSecurityDescriptor]$security) {
20+
$entries = @()
21+
$count = 0
22+
$hasHighLabel = $false
23+
$malformedLabel = $false
24+
if ($null -ne $security.SystemAcl) { $count = $security.SystemAcl.Count }
25+
foreach ($ace in $security.SystemAcl) {
26+
$entry = @{ type = [int]$ace.AceType; size = $ace.BinaryLength; flags = [int]$ace.AceFlags }
27+
if ([int]$ace.AceType -eq 0x11) {
28+
try {
29+
# SYSTEM_MANDATORY_LABEL_ACE: header at 0, mask at 4, SID at 8.
30+
if ($ace.BinaryLength -lt 16) { throw 'Truncated mandatory-label ACE.' }
31+
$bytes = [byte[]]::new($ace.BinaryLength)
32+
$ace.GetBinaryForm($bytes, 0)
33+
$entry.mask = [BitConverter]::ToUInt32($bytes, 4)
34+
$entry.sid = [Security.Principal.SecurityIdentifier]::new($bytes, 8).Value
35+
# Inherit-only ACEs do not protect this directory itself.
36+
if ($entry.sid -eq 'S-1-16-12288' -and ($entry.mask -band 1) -ne 0 -and ($entry.flags -band 8) -eq 0) { $hasHighLabel = $true }
37+
} catch {
38+
$malformedLabel = $true
39+
$entry.error = $_.Exception.Message
40+
}
41+
}
42+
$entries += $entry
43+
}
44+
return @{ hasHighLabel = ($hasHighLabel -and -not $malformedLabel); saclCount = $count; aces = $entries }
45+
}
46+
`.trim();
47+
}
48+
1649
/** Compiles trusted constant C# without CodeDom's ANSI elevated-temp helper. */
1750
export function windowsCodeDomCompileScript(typeDefinition: string, diagnostics = false): string {
1851
return String.raw`
52+
${windowsCodeDomLabelValidationScript()}
1953
$compilerIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
2054
$compilerSid = $compilerIdentity.User.Value
2155
$compilerPrincipal = [Security.Principal.WindowsPrincipal]::new($compilerIdentity)
@@ -87,10 +121,13 @@ try {
87121
}
88122
$compilerActualSecurity = [Security.AccessControl.RawSecurityDescriptor]::new($compilerActualBytes, 0)
89123
$compilerActualSddl = $compilerActualSecurity.GetSddlForm([Security.AccessControl.AccessControlSections]::All)
90-
$compilerSecurityReport = (@{ compilerTemp = $compilerTemp; elevated = $compilerElevated; requestedCompilerSddl = $compilerSddl; compilerSddl = $compilerActualSddl } | ConvertTo-Json -Compress)
124+
$compilerRequestedLabels = Get-GajaeCompilerLabelState $compilerSecurity
125+
$compilerActualLabels = Get-GajaeCompilerLabelState $compilerActualSecurity
126+
$compilerSecurityReport = (@{ compilerTemp = $compilerTemp; elevated = $compilerElevated; requestedCompilerSddl = $compilerSddl; compilerSddl = $compilerActualSddl; requestedSaclCount = $compilerRequestedLabels.saclCount; requestedSaclAces = $compilerRequestedLabels.aces; compilerSaclCount = $compilerActualLabels.saclCount; compilerSaclAces = $compilerActualLabels.aces; hasHighLabel = $compilerActualLabels.hasHighLabel } | ConvertTo-Json -Compress -Depth 4)
91127
${diagnostics ? '[Console]::Out.WriteLine($compilerSecurityReport)' : ''}
92-
# String.raw preserves the single backslash required by PowerShell/.NET.
93-
if ($compilerElevated -and $compilerActualSddl -notmatch '\(ML;[^;]*;NW;;;HI\)') {
128+
# GetSddlForm(All) serializes auditing SACL flags, not LABEL_SECURITY_INFORMATION.
129+
# Enforce the label from the returned raw ACE fields instead of its SDDL text.
130+
if ($compilerElevated -and -not $compilerActualLabels.hasHighLabel) {
94131
throw ('Compiler directory high-integrity label was not preserved. ' + $compilerSecurityReport)
95132
}
96133
$compilerParameters = [CodeDom.Compiler.CompilerParameters]::new()

0 commit comments

Comments
 (0)