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
113 changes: 113 additions & 0 deletions .github/workflows/published.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: Published consumers
on:
workflow_run:
workflows: [v2]
types: [completed]
branches: [master]
workflow_dispatch:
inputs:
version:
description: Published v2 tag to validate
required: true
type: string
permissions:
contents: read
attestations: read
jobs:
release:
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_repository.full_name == github.repository)
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.pin.outputs.version }}
revision: ${{ steps.pin.outputs.revision }}
steps:
- id: pin
env:
GH_TOKEN: ${{ github.token }}
REQUESTED_VERSION: ${{ inputs.version }}
SOURCE_REVISION: ${{ github.event.workflow_run.head_sha }}
shell: python
run: |
import json, os, re, subprocess
repo = os.environ['GITHUB_REPOSITORY']
def api(path):
return json.loads(subprocess.check_output(['gh', 'api', f'repos/{repo}/{path}'], text=True))
version = os.environ['REQUESTED_VERSION']
if version:
assert re.fullmatch(r'v2\.\d+\.\d+(?:-[a-z]+\.\d+)?', version)
release = api('releases/tags/' + version)
else:
releases = api('releases?per_page=100')
matches = [r for r in releases if not r['draft'] and r['target_commitish'] == os.environ['SOURCE_REVISION']]
assert len(matches) == 1, 'expected one published release for successful master commit'
release = matches[0]
version = release['tag_name']
assert not release['draft']
revision = api('commits/' + version)['sha']
if os.environ.get('SOURCE_REVISION'):
assert revision == os.environ['SOURCE_REVISION']
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f'version={version}\nrevision={revision}\n')
native:
needs: release
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15-intel, macos-15, windows-2025, windows-11-arm]
go: ['1.26.x', '1.27.x']
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ matrix.go }}
cache: false
- name: Verify actual public module and CLI from fresh caches
# Launch directly from the runner, without Git Bash's token environment.
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ needs.release.outputs.version }}
RELEASE_REVISION: ${{ needs.release.outputs.revision }}
shell: python
run: |
import os, subprocess, sys
subprocess.run([sys.executable, 'scripts/check-published.py', os.environ['RELEASE_VERSION'], os.environ['RELEASE_REVISION']], check=True)
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: published-${{ matrix.os }}-go-${{ matrix.go }}
path: published-evidence
if-no-files-found: error
musl:
needs: release
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, ubuntu-24.04-arm]
go: ['1.26', '1.27']
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Verify public consumers on native Alpine
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ needs.release.outputs.version }}
RELEASE_REVISION: ${{ needs.release.outputs.revision }}
GO_VERSION: ${{ matrix.go }}
run: |
docker build --build-arg GO_VERSION="$GO_VERSION" -f scripts/published.Dockerfile -t published-consumer .
mkdir published-evidence
sudo chown 1000:1000 published-evidence
docker run --rm -e GH_TOKEN -v "$PWD/published-evidence:/evidence" published-consumer "$RELEASE_VERSION" "$RELEASE_REVISION" /evidence/results
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: published-musl-${{ matrix.os }}-go-${{ matrix.go }}
path: published-evidence
if-no-files-found: error
28 changes: 28 additions & 0 deletions .github/workflows/v2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,34 @@ jobs:
if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/test_installer.ps1
- name: PowerShell restricted-token and PostgreSQL lifecycle regressions
if: runner.os == 'Windows'
shell: pwsh
run: |
$env:EP_SUPERVISOR = Join-Path $PWD 'ci-output/consumer-postgres.exe'
$env:EP_TEST_BIN = Join-Path $PWD 'pg/bin'
$env:EP_TEST_VERSION = '18.6.1'
foreach ($version in @('go1.26.9', 'go1.27.2')) {
$env:GOTOOLCHAIN = $version
# Match the shipped executable: no coverage instrumentation whose
# missing GOCOVERDIR warning would contaminate psql probe output.
go build -o $env:EP_SUPERVISOR ./cmd/embedded-postgres
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
go test -v -count=1 -p=1 -timeout=4m -run '^(TestWindowsRestrictedChildCanOpenItsToken|TestWindowsNativeFailuresReleaseHandles|TestWindowsUserDACLRejectsInvalidToken|TestParallelLifecycle|TestLifetimeCancellation|TestPersistentDataNeverErased|TestCLIParentPipe|TestCLIBackground|TestCLIExec|TestWindowsExecCancellation)$' . ./internal/platform ./internal/cli
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
if: runner.os == 'Windows'
with:
node-version: '24'
package-manager-cache: false
- name: Detached hidden Node launch from PowerShell
if: runner.os == 'Windows'
shell: pwsh
run: |
$env:EP_SUPERVISOR = Join-Path $PWD 'ci-output/consumer-postgres.exe'
$env:EP_TEST_BIN = Join-Path $PWD 'pg/bin'
node scripts/test-node-launch.mjs
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ matrix.os }}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,5 @@ main
/dist/
/embedded-postgres
/scripts/__pycache__/
/scripts/testdata/published/__pycache__/
/published-evidence/
7 changes: 7 additions & 0 deletions docs/non-root.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ Explicit IDs must fit Go's native `int`: at most 2147483647 on 32-bit hosts.
Windows uses restricted security tokens plus a kill-on-close Job Object; UID/GID
selection is a Unix feature.

On Windows, supplied cache and workspace directories must grant access to the
actual user account. An ACL that grants access only through Administrators or
administrator ownership will not work after that group is disabled. The library
does not rewrite caller-owned directory permissions. Restricted child tokens and
the objects they create grant access to that user and SYSTEM, so tools can reopen
their own tokens without restoring administrator or power-user membership.

The library creates a private workspace, assigns its ownership, and copies the
immutable binary installation and supervisor there when RunAs is used. This avoids
requiring access through `/root` or changing permissions on a caller's home/cache.
Expand Down
19 changes: 19 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,25 @@ same commit resumes its draft or leaves an already published release unchanged.
PR, manual and scheduled workflows do not publish. Alpha releases do not replace
v1 as GitHub's latest stable release.

After publication, the public **Published consumers** workflow installs the exact
Go module from the public proxy, verifies its source revision and checksum,
downloads the automatic matching CLI, and checks the CLI's checksum and build
attestation. Fresh-cache SQL, offline reuse and cleanup run on all six native
hosts with both supported Go versions, plus native x64/ARM64 Alpine. Windows
checks start outside Git Bash. Logs and pinned identities remain public workflow
artifacts; this validation does not depend on a private repository.

To reproduce one host locally with Go, Python and an authenticated GitHub CLI:

```bash
python scripts/check-published.py RELEASE_TAG FULL_SOURCE_COMMIT
```

The command writes `published-evidence/`, requires an unused evidence directory,
and uses temporary caches. It changes permissions only on its own new Windows
test directory so the actual user retains access after administrator groups are
disabled. It never changes an existing consumer directory's ACL.

Release settings live in [`.github/release.json`](../.github/release.json):

- `base: "2.0.0"`, `prerelease: "alpha"` produces `v2.0.0-alpha.1`, `.2`, and so on.
Expand Down
58 changes: 56 additions & 2 deletions internal/platform/process_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,59 @@ type windowsCalls struct {
openToken func(syscall.Handle, uint32, *syscall.Token) error
sid func(string) (*syscall.SID, error)
restrict func(syscall.Token, []syscall.SIDAndAttributes, *syscall.Token) (uintptr, error)
secure func(syscall.Token) error
create func() (uintptr, error)
limit func(uintptr, *extendedLimits) (uintptr, error)
assign func(uintptr, syscall.Handle) (uintptr, error)
}

var nativeWindows = windowsCalls{syscall.OpenProcessToken, syscall.StringToSid, nativeRestrict, nativeCreate, nativeLimit, nativeAssign}
var nativeWindows = windowsCalls{syscall.OpenProcessToken, syscall.StringToSid, nativeRestrict, nativeUserDACL, nativeCreate, nativeLimit, nativeAssign}

// A token created by an elevated PowerShell process can inherit an
// administrators-only DACL. Once that SID is disabled, the child cannot reopen
// its own token (as initdb must). Grant only the actual user and SYSTEM access
// to this token and to objects it creates. No caller token or directory ACL is
// modified, and the administrator/power-user SIDs remain deny-only.
func nativeUserDACL(token syscall.Token) error {
user, err := token.GetTokenUser()
if err != nil {
return fmt.Errorf("query token user: %w", err)
}
sid, err := user.User.Sid.String()
if err != nil {
return err
}
sddl, err := syscall.UTF16PtrFromString("D:P(A;;GA;;;SY)(A;;GA;;;" + sid + ")")
if err != nil {
return err
}
var descriptor uintptr
ok, _, callErr := advapi.NewProc("ConvertStringSecurityDescriptorToSecurityDescriptorW").Call(uintptr(unsafe.Pointer(sddl)), 1, uintptr(unsafe.Pointer(&descriptor)), 0)
runtime.KeepAlive(sddl)
if ok == 0 {
return fmt.Errorf("convert user security descriptor: %w", callErr)
}
defer syscall.LocalFree(syscall.Handle(descriptor))
var present, defaulted uint32
var dacl unsafe.Pointer
ok, _, callErr = advapi.NewProc("GetSecurityDescriptorDacl").Call(descriptor, uintptr(unsafe.Pointer(&present)), uintptr(unsafe.Pointer(&dacl)), uintptr(unsafe.Pointer(&defaulted)))
if ok == 0 {
return fmt.Errorf("read user DACL: %w", callErr)
}
if present == 0 || dacl == nil {
return errors.New("restricted token requires a non-null user DACL")
}
// TOKEN_DEFAULT_DACL consists of one ACL pointer.
ok, _, callErr = advapi.NewProc("SetTokenInformation").Call(uintptr(token), syscall.TokenDefaultDacl, uintptr(unsafe.Pointer(&dacl)), unsafe.Sizeof(dacl))
if ok == 0 {
return fmt.Errorf("set token default DACL: %w", callErr)
}
ok, _, callErr = advapi.NewProc("SetKernelObjectSecurity").Call(uintptr(token), 4 /* DACL_SECURITY_INFORMATION */, descriptor)
if ok == 0 {
return fmt.Errorf("set token object DACL: %w", callErr)
}
return nil
}

// Typed pointer arguments keep pointer lifetimes visible to Go across the
// injectable boundary. Convert to uintptr only at the annotated syscall call.
Expand Down Expand Up @@ -68,7 +115,10 @@ func configureWindows(cmd *exec.Cmd, id *Identity, calls windowsCalls) (func(),
current, _ := syscall.GetCurrentProcess() // Go returns the constant current-process pseudo-handle.
var err error
var original syscall.Token
if err = calls.openToken(current, syscall.TOKEN_DUPLICATE|syscall.TOKEN_ASSIGN_PRIMARY|syscall.TOKEN_QUERY, &original); err != nil {
// CreateRestrictedToken preserves the original handle's access mask. Request
// the rights needed to secure the new token; the original is never modified.
const writeDACL = 0x00040000 // WRITE_DAC
if err = calls.openToken(current, syscall.TOKEN_DUPLICATE|syscall.TOKEN_ASSIGN_PRIMARY|syscall.TOKEN_QUERY|syscall.TOKEN_ADJUST_DEFAULT|writeDACL, &original); err != nil {
return nil, err
}
defer original.Close()
Expand All @@ -86,6 +136,10 @@ func configureWindows(cmd *exec.Cmd, id *Identity, calls windowsCalls) (func(),
if ok == 0 {
return nil, fmt.Errorf("create restricted token: %w", callErr)
}
if err := calls.secure(token); err != nil {
token.Close()
return nil, fmt.Errorf("secure restricted token: %w", err)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Token: token, CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, HideWindow: true}
return func() { _ = token.Close() }, nil
}
Expand Down
47 changes: 46 additions & 1 deletion internal/platform/process_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,55 @@ package platform

import (
"errors"
"os"
"os/exec"
"strconv"
"syscall"
"testing"
"unsafe"
)

func TestWindowsRestrictedChildCanOpenItsToken(t *testing.T) {
depth, _ := strconv.Atoi(os.Getenv("EP_WINDOWS_TOKEN_DEPTH"))
if depth > 0 {
current, _ := syscall.GetCurrentProcess()
var token syscall.Token
if err := syscall.OpenProcessToken(current, syscall.TOKEN_ALL_ACCESS, &token); err != nil {
t.Fatal("restricted child cannot reopen its own token", depth, err)
}
token.Close()
for _, value := range []string{"S-1-5-32-544", "S-1-5-32-547"} {
sid, err := syscall.StringToSid(value)
if err != nil {
t.Fatal(err)
}
var member uint32
ok, _, err := advapi.NewProc("CheckTokenMembership").Call(0, uintptr(unsafe.Pointer(sid)), uintptr(unsafe.Pointer(&member)))
if ok == 0 || member != 0 {
t.Fatal("restricted child retained privileged group membership", value, member, err)
}
}
}
if depth == 2 {
return
}
cmd, release, err := Command(t.Context(), nil, os.Args[0], "-test.run=^TestWindowsRestrictedChildCanOpenItsToken$", "-test.v")
if err != nil {
t.Fatal(err)
}
defer release()
cmd.Env = append(cmd.Env, "EP_WINDOWS_TOKEN_DEPTH="+strconv.Itoa(depth+1))
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("nested restricted child failed: %v\n%s", err, out)
}
}

func TestWindowsUserDACLRejectsInvalidToken(t *testing.T) {
if err := nativeUserDACL(0); err == nil {
t.Fatal("invalid token accepted")
}
}

func TestWindowsNativeFailuresReleaseHandles(t *testing.T) {
failure := syscall.ERROR_ACCESS_DENIED

Expand All @@ -25,7 +68,7 @@ func TestWindowsNativeFailuresReleaseHandles(t *testing.T) {
}
before := handleCount()
for range 3 {
for _, kind := range []string{"open", "admin-sid", "power-sid", "restrict"} {
for _, kind := range []string{"open", "admin-sid", "power-sid", "restrict", "secure"} {
calls := nativeWindows
switch kind {
case "open":
Expand All @@ -39,6 +82,8 @@ func TestWindowsNativeFailuresReleaseHandles(t *testing.T) {
}
case "restrict":
calls.restrict = func(syscall.Token, []syscall.SIDAndAttributes, *syscall.Token) (uintptr, error) { return 0, failure }
case "secure":
calls.secure = func(syscall.Token) error { return failure }
}
release, e := configureWindows(exec.Command("unused"), nil, calls)
if e == nil {
Expand Down
Loading
Loading