Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,59 @@ jobs:
- run: python tools/run_validation.py
- run: python -m pip install --no-deps .
- run: trace-gc --version
windows-fence:
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.12', '3.13']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: python -m pip install -r requirements.txt
- name: Run the nine native Windows admission-fence cases
shell: bash
run: |
set -o pipefail
python -m unittest discover -s tests -p "test_paper_pilot_phase_cutover_guarded_v2.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-fence.log"
# A bare "OK" means every case executed; skips would print "OK (skipped=N)".
grep -Eq '^Ran 9 tests in ' "$RUNNER_TEMP/windows-fence.log"
grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-fence.log"
- name: Run the fourteen Windows SID/SDDL trustee-alias regression cases
if: always()
shell: bash
run: |
set -o pipefail
python -m unittest discover -s tests -p "test_paper_pilot_phase_cutover_guarded_v2_trustees.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-fence-trustees.log"
grep -Eq '^Ran 14 tests in ' "$RUNNER_TEMP/windows-fence-trustees.log"
grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-fence-trustees.log"
- name: Run the thirteen owned-fixture ACL stabilization cases (nine contract, four native)
if: always()
shell: bash
run: |
set -o pipefail
python -m unittest discover -s tests -p "test_windows_fixture_acl.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-fixture-acl.log"
grep -Eq '^Ran 13 tests in ' "$RUNNER_TEMP/windows-fixture-acl.log"
grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-fixture-acl.log"
- name: Run the ten strict signed-head DACL fence cases (five contract, five native)
if: always()
shell: bash
run: |
set -o pipefail
python -m unittest discover -s tests -p "test_paper_pilot_phase_cutover_guarded_v2_strict_dacl.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-fence-strict-dacl.log"
grep -Eq '^Ran 10 tests in ' "$RUNNER_TEMP/windows-fence-strict-dacl.log"
grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-fence-strict-dacl.log"
- name: Run the eighteen diagnostics-tool sanitization, trace and cleanup cases
if: always()
shell: bash
run: |
set -o pipefail
python -m unittest discover -s tests -p "test_windows_fence_diagnostics.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-fence-diagnostics-tests.log"
grep -Eq '^Ran 18 tests in ' "$RUNNER_TEMP/windows-fence-diagnostics-tests.log"
grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-fence-diagnostics-tests.log"
- name: Sanitized Windows trustee serialization diagnostics
if: always()
continue-on-error: true
run: python tools/windows_fence_diagnostics.py
49 changes: 42 additions & 7 deletions MANIFEST.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
"hash_algorithm": "SHA-256",
"manifest_self_excluded": true,
"source_only": true,
"file_count": 702,
"file_count": 709,
"files": [
{
"path": ".github/workflows/test.yml",
"bytes": 615,
"sha256": "ba3c6064c1fb430eda6f7fedf60561637be3daedfff4d1d533a61e5ef851ed2b"
"bytes": 3589,
"sha256": "9c147d3ff67e4bc298c4376bcef076390c1a86d25ee9333ddf1d01ff861f4b23"
},
{
"path": ".gitignore",
Expand Down Expand Up @@ -2297,8 +2297,8 @@
},
{
"path": "src/paper_pilot_phase_cutover_guarded_v2.py",
"bytes": 16677,
"sha256": "618cb280f69353aba783b68c34b3c8344a3d04dd4442fd805cf96b964f4d9af7"
"bytes": 19252,
"sha256": "eebb08bc51209e23a50d3b8e89afc84b834ac89d86c6a54e52f03f8ab06ee2d8"
},
{
"path": "src/paper_pilot_phase_cutover_guarded_v4.py",
Expand Down Expand Up @@ -2632,8 +2632,23 @@
},
{
"path": "tests/test_paper_pilot_phase_cutover_guarded_v2.py",
"bytes": 11173,
"sha256": "c1fab4e9adbe973c53d01fa2d0340559ffef0dc4d482e2b5b8bb68cfa9f43c33"
"bytes": 13300,
"sha256": "f7c72b60e3e0de9fc2b5dc14056ce88dd27d2db8a82157c07250f088049f30c7"
},
{
"path": "tests/test_paper_pilot_phase_cutover_guarded_v2_simulated_acl.py",
"bytes": 46860,
"sha256": "eec4d28a522f207bcd28ccf5a1ffcaaaf526144d07c0bd551ef717e9ad6deb72"
},
{
"path": "tests/test_paper_pilot_phase_cutover_guarded_v2_strict_dacl.py",
"bytes": 18172,
"sha256": "f714325983bec8b08ced75008a9e4c22cc59b76a0e3524ee720f621f8ca7b154"
},
{
"path": "tests/test_paper_pilot_phase_cutover_guarded_v2_trustees.py",
"bytes": 18461,
"sha256": "13ac20e781fb6580636252871137743a834af56dbe2f031f2208550b94d70611"
},
{
"path": "tests/test_paper_pilot_phase_cutover_guarded_v4.py",
Expand Down Expand Up @@ -2940,6 +2955,16 @@
"bytes": 7256,
"sha256": "6164938f20b9a3aff039df703d8adc20d6ad5c0ead9f7eaba2ce5b458da5d227"
},
{
"path": "tests/test_windows_fence_diagnostics.py",
"bytes": 20559,
"sha256": "c99e5d60eb8deb0bb20f1a5b64d6888f7f7134841ba3016f6991266e9498bab4"
},
{
"path": "tests/test_windows_fixture_acl.py",
"bytes": 11977,
"sha256": "438d68653cd88d60f4376f1bda7cb33e443d32059e0b86ef0f32b788ba5b4563"
},
{
"path": "tools/build_schemas.py",
"bytes": 12978,
Expand Down Expand Up @@ -3015,6 +3040,16 @@
"bytes": 21568,
"sha256": "d461d0c14365d97630ea216608cdbb095e3f8806e76a0a954ea2dfcdbbee655a"
},
{
"path": "tools/windows_fence_diagnostics.py",
"bytes": 21532,
"sha256": "9dabba98d9af2c92b77231e597194204c5a3918c214b319736a2d8ccad371471"
},
{
"path": "tools/windows_fixture_acl.py",
"bytes": 4355,
"sha256": "e435337541de6216b0f1634da1a0e36a36915d2897414db80ba5b17cad7272f6"
},
{
"path": "tools/windows_image_ocr.ps1",
"bytes": 5451,
Expand Down
103 changes: 83 additions & 20 deletions src/paper_pilot_phase_cutover_guarded_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

from __future__ import annotations

import ctypes
from contextlib import ExitStack
import functools
import hashlib
import json
import os
Expand Down Expand Up @@ -73,8 +75,71 @@ def _icacls(path: Path, *args: str) -> None:
require(result.returncode == 0, "PHASE_FENCE", "Windows DACL update failed")


_NUMERIC_SID = re.compile(r"S-1-\d+(?:-\d+)+")


@functools.lru_cache(maxsize=None)
def _canonical_trustee(trustee: str) -> str:
"""Return a trustee spelled exactly as Windows serializes it in a DACL string.

Windows writes some numeric SIDs as SDDL aliases (for example a machine's
built-in Administrator, RID 500, is written ``LA``). Round-tripping through
the same Windows conversion that produced the DACL text makes the numeric
SID reviewed by the caller comparable to what the DACL reader returns,
without changing the serialized DACL or any receipt.
"""
if _NUMERIC_SID.fullmatch(trustee) is None:
return trustee
advapi = ctypes.WinDLL("advapi32", use_last_error=True)
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.LocalFree.argtypes = (ctypes.c_void_p,)
kernel.LocalFree.restype = ctypes.c_void_p
parse = advapi.ConvertStringSecurityDescriptorToSecurityDescriptorW
parse.argtypes = (ctypes.c_wchar_p, ctypes.c_uint32,
ctypes.POINTER(ctypes.c_void_p), ctypes.c_void_p)
parse.restype = ctypes.c_int
encode = advapi.ConvertSecurityDescriptorToStringSecurityDescriptorW
encode.argtypes = (ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32,
ctypes.POINTER(ctypes.c_wchar_p), ctypes.c_void_p)
encode.restype = ctypes.c_int
descriptor = ctypes.c_void_p()
require(bool(parse("D:(D;;DCLC;;;" + trustee + ")", 1, ctypes.byref(descriptor), None)),
"PHASE_FENCE", "cannot parse SID trustee")
try:
value = ctypes.c_wchar_p()
require(bool(encode(descriptor, 1, 4, ctypes.byref(value), None)),
"PHASE_FENCE", "cannot encode SID trustee")
try:
text = value.value
finally:
kernel.LocalFree(ctypes.cast(value, ctypes.c_void_p))
finally:
kernel.LocalFree(descriptor)
match = re.fullmatch(r"D:\(D;;DCLC;;;([^;()]+)\)", text)
require(match is not None, "PHASE_FENCE", "unexpected SID trustee encoding")
return match.group(1)


def _canonical_ace(ace: str) -> str:
# Only the trustee field is normalized; type, flags, rights, object fields
# and ACE order must still match exactly.
fields = ace[1:-1].split(";")
if len(fields) != 6:
return ace
fields[5] = _canonical_trustee(fields[5])
return "(" + ";".join(fields) + ")"


def _aces(sddl: str) -> list[str]:
return [_canonical_ace(ace) for ace in re.findall(r"\([^)]*\)", sddl)]


def _has_ace(sddl: str, template: str, sid: str) -> bool:
return _canonical_ace(template.format(sid=sid)) in _aces(sddl)


def _file_deny(path: Path, sid: str) -> bool:
return DENY_FILE_ACE.format(sid=sid) in directory_dacl_sddl(path)
return _has_ace(directory_dacl_sddl(path), DENY_FILE_ACE, sid)


def _readable_pins(root: Path, expected: dict[str, str]) -> None:
Expand All @@ -94,15 +159,13 @@ def _same_effective_dacl(before: str, after: str) -> bool:
# protection (P) must still match exactly; no broadened access is accepted.
before_flags = before.split("(", 1)[0].removeprefix("D:").replace("AI", "")
after_flags = after.split("(", 1)[0].removeprefix("D:").replace("AI", "")
return (before_flags == after_flags and
re.findall(r"\([^)]*\)", before) ==
re.findall(r"\([^)]*\)", after))
return before_flags == after_flags and _aces(before) == _aces(after)


def _only_admission_dacl_added(original: str, current: str, sid: str) -> bool:
old_aces = re.findall(r"\([^)]*\)", original)
now_aces = re.findall(r"\([^)]*\)", current)
deny = DENY_FILE_ACE.format(sid=sid)
old_aces = _aces(original)
now_aces = _aces(current)
deny = _canonical_ace(DENY_FILE_ACE.format(sid=sid))
if deny in now_aces:
now_aces.remove(deny)
old_flags = original.split("(", 1)[0].removeprefix("D:").replace("AI", "")
Expand All @@ -111,14 +174,15 @@ def _only_admission_dacl_added(original: str, current: str, sid: str) -> bool:


def _owner_deny_present(sddl: str, sid: str) -> bool:
return any(ace.startswith("(D;") and ace.endswith(";;;" + sid + ")")
for ace in re.findall(r"\([^)]*\)", sddl))
trustee = _canonical_trustee(sid)
return any(ace.startswith("(D;") and ace.endswith(";;;" + trustee + ")")
for ace in _aces(sddl))


def _only_parent_admission_added(original: str, current: str, sid: str) -> bool:
old_aces = re.findall(r"\([^)]*\)", original)
now_aces = re.findall(r"\([^)]*\)", current)
deny = DENY_PARENT_ACE.format(sid=sid)
old_aces = _aces(original)
now_aces = _aces(current)
deny = _canonical_ace(DENY_PARENT_ACE.format(sid=sid))
if deny in now_aces:
now_aces.remove(deny)
old_flags = original.split("(", 1)[0].removeprefix("D:").replace("AI", "")
Expand Down Expand Up @@ -205,7 +269,7 @@ def fence_closed_old_phase_guarded(*, old_root: str | Path,
{name: bytes_digest(value.encode()) for name, value in
original.items()} == expected_file_dacl_sha256,
"PHASE_FENCE", "original Windows DACL identity changed")
require(all(DENY_FILE_ACE.format(sid=deny_sid) not in value
require(all(not _has_ace(value, DENY_FILE_ACE, deny_sid)
for value in original.values()),
"PHASE_FENCE", "unreviewed preexisting file deny")
recorded = {"version": "paper-pilot-admission-barrier-v1",
Expand Down Expand Up @@ -252,11 +316,11 @@ def fence_closed_old_phase_guarded(*, old_root: str | Path,
require(_file_deny(path, deny_sid), "PHASE_FENCE",
"new old-state write open not denied")
parent_sddl = directory_dacl_sddl(old.parent)
if DENY_PARENT_ACE.format(sid=deny_sid) not in parent_sddl:
if not _has_ace(parent_sddl, DENY_PARENT_ACE, deny_sid):
_icacls(old.parent, "/deny", "*" + deny_sid + ":(AD)")
require(DENY_PARENT_ACE.format(sid=deny_sid) in
directory_dacl_sddl(old.parent), "PHASE_FENCE",
"old state recreation remains possible")
require(_has_ace(directory_dacl_sddl(old.parent), DENY_PARENT_ACE,
deny_sid), "PHASE_FENCE",
"old state recreation remains possible")
require(_only_parent_admission_added(
recorded["original_parent_dacl_sddl"],
directory_dacl_sddl(old.parent), deny_sid),
Expand Down Expand Up @@ -285,9 +349,8 @@ def fence_closed_old_phase_guarded(*, old_root: str | Path,
require(not _file_deny(path, deny_sid), "PHASE_FENCE",
"archived readback still denied")
_readable_pins(archived, expected_file_sha256)
require(DENY_PARENT_ACE.format(sid=deny_sid) in
directory_dacl_sddl(old.parent), "PHASE_FENCE",
"old fixed path admission reopened")
require(_has_ace(directory_dacl_sddl(old.parent), DENY_PARENT_ACE, deny_sid),
"PHASE_FENCE", "old fixed path admission reopened")
require(_only_parent_admission_added(
recorded["original_parent_dacl_sddl"],
directory_dacl_sddl(old.parent), deny_sid),
Expand Down
Loading
Loading