mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
144
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f2cdb5eeb | ||
|
|
5d080ef46b | ||
|
|
1d233c0a9b | ||
|
|
de1443b2ed | ||
|
|
84c1b15540 | ||
|
|
1767f2dacf | ||
|
|
6393a90ed6 | ||
|
|
34129dfb5a | ||
|
|
cd3e0b0a9f | ||
|
|
aa9ba8412e | ||
|
|
a97db8832f | ||
|
|
703884b6a1 | ||
|
|
bf096f05a7 | ||
|
|
422e0d3c82 | ||
|
|
bcddd92eae | ||
|
|
16e30a9f18 | ||
|
|
281bc65c71 | ||
|
|
df9989eba1 | ||
|
|
56ce081107 | ||
|
|
378b38c363 | ||
|
|
738881bc11 | ||
|
|
17f55f4e5e | ||
|
|
6e980a8aa6 | ||
|
|
9b72d0da6a | ||
|
|
c753df3020 | ||
|
|
d3aab2c113 | ||
|
|
3ef649094d | ||
|
|
b7e97c7645 | ||
|
|
b0ffba6391 | ||
|
|
b9d86cee59 | ||
|
|
7330a17f99 | ||
|
|
926a1ebc41 | ||
|
|
5de72bfa4a | ||
|
|
c38fcabe67 | ||
|
|
8319484383 | ||
|
|
48f6a88fe4 | ||
|
|
ecbedb9112 | ||
|
|
08005ef5b3 | ||
|
|
4d3f5dc8cd | ||
|
|
76d607ecdc | ||
|
|
a2e9558262 | ||
|
|
8b7ffa4f46 | ||
|
|
a45d4000e1 | ||
|
|
b12b2d71a4 | ||
|
|
9abab7318d | ||
|
|
d376b7f2f0 | ||
|
|
95c8ac6245 | ||
|
|
c887961869 | ||
|
|
a186eb46d7 | ||
|
|
8099409f7c | ||
|
|
4234357774 | ||
|
|
9608817f47 | ||
|
|
673b84d644 | ||
|
|
69c68b18a2 | ||
|
|
daf58c8962 | ||
|
|
a8b295def3 | ||
|
|
30d3c4791e | ||
|
|
26958ac6dd | ||
|
|
7920d3be3d | ||
|
|
4320d08bae | ||
|
|
bd7e98c05c | ||
|
|
5a43074c30 | ||
|
|
0904d88b26 | ||
|
|
2925a6768b | ||
|
|
faa07e3937 | ||
|
|
834c184d4a | ||
|
|
8da16b455f | ||
|
|
c3e5e4bdfc | ||
|
|
89b2e6dfbc | ||
|
|
cd7805062f | ||
|
|
6f0d45323b | ||
|
|
d6b4e73e0e | ||
|
|
3574f62c2f | ||
|
|
fffc780dc3 | ||
|
|
272acb9324 | ||
|
|
d736aae19a | ||
|
|
129800b995 | ||
|
|
474f6e9b08 | ||
|
|
2a765446cb | ||
|
|
81c8326072 | ||
|
|
b9ba54a948 | ||
|
|
b97e744989 | ||
|
|
55023a9805 | ||
|
|
9800065f65 | ||
|
|
18ffd02b93 | ||
|
|
bfa40a79ca | ||
|
|
6e342cee45 | ||
|
|
03c7783905 | ||
|
|
9fa86240be | ||
|
|
282a29f610 | ||
|
|
602b21fab4 | ||
|
|
8c242fc33e | ||
|
|
9cb9139b77 | ||
|
|
8dd74e23d1 | ||
|
|
d0a0506fb4 | ||
|
|
47f26f65a1 | ||
|
|
229a6acdf6 | ||
|
|
05d7c2f62c | ||
|
|
f96b8b2f38 | ||
|
|
d43362aca7 | ||
|
|
2ca6ac69d5 | ||
|
|
a18523ad65 | ||
|
|
b5553577fc | ||
|
|
c3d041b0af | ||
|
|
d2968aef28 | ||
|
|
6a1b4562d0 | ||
|
|
011c4dad15 | ||
|
|
c2cf7b8f3a | ||
|
|
2d5e1f7885 | ||
|
|
bcae716a07 | ||
|
|
7f45a7cd54 | ||
|
|
cae96b2830 | ||
|
|
f18e645c8b | ||
|
|
949ee3d761 | ||
|
|
97c8c98068 | ||
|
|
7070993ef9 | ||
|
|
d685d83df6 | ||
|
|
7e996fa4fd | ||
|
|
6b54ac0814 | ||
|
|
8d42270e30 | ||
|
|
0b5950e882 | ||
|
|
c6921d4145 | ||
|
|
227f4ed6ef | ||
|
|
8a84471dc9 | ||
|
|
6ff26014d5 | ||
|
|
700e749e30 | ||
|
|
73fc1c07ae | ||
|
|
f0bc1a3821 | ||
|
|
2175366d4e | ||
|
|
fd27716dfc | ||
|
|
206046bc04 | ||
|
|
e8a0642c58 | ||
|
|
17d5674d61 | ||
|
|
17879dc480 | ||
|
|
a527ffc040 | ||
|
|
07f6492528 | ||
|
|
949e104844 | ||
|
|
0b8fcf4b1e | ||
|
|
3e238cc5ea | ||
|
|
645976d215 | ||
|
|
273bdede2a | ||
|
|
8dbe818934 | ||
|
|
ea7c04cdbb | ||
|
|
17aa51b530 |
No files matched your search
@@ -12,7 +12,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -158,7 +158,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.9+.
|
||||
Source builds require Go 1.26.10+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -136,7 +136,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.9+。
|
||||
源码构建需要 Go 1.26.10+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
|
||||
+2
-2
@@ -190,7 +190,7 @@ Every release so far declares the module path `github.com/Gitlawb/zero`, so
|
||||
fails with "module declares its path as" until a release is tagged with the new
|
||||
module path.
|
||||
|
||||
This builds from source, so it needs Go 1.26.9+ and it does not go through the
|
||||
This builds from source, so it needs Go 1.26.10+ and it does not go through the
|
||||
release archives. Two consequences worth knowing before you pick it:
|
||||
|
||||
- On Linux, Bubblewrap is still required for native sandboxing. Zero resolves
|
||||
@@ -218,7 +218,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.9+.
|
||||
Source builds require Go 1.26.10+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Twigpine/zero
|
||||
|
||||
go 1.26.9
|
||||
go 1.26.10
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
|
||||
@@ -54,10 +54,10 @@ func TestRunExecImageAttachmentHistory(t *testing.T) {
|
||||
{name: "stream and file images", model: "gpt-4.1", fileImage: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"} {
|
||||
t.Setenv(name, root)
|
||||
}
|
||||
// The shared isolation also turns off the models.dev refresh that
|
||||
// runExec starts in the background. Without it, the refresh downloads
|
||||
// the catalog into this HOME and races the TempDir cleanup.
|
||||
isolateCLIUserState(t)
|
||||
cwd := t.TempDir()
|
||||
png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, 0x49, 0x48, 0x44, 0x52}
|
||||
encoded := base64.StdEncoding.EncodeToString(png)
|
||||
@@ -148,6 +148,7 @@ func TestRunExecImageAttachmentHistory(t *testing.T) {
|
||||
// user turn). Before the fix, stream-json images were parsed and validated but
|
||||
// silently dropped — never threaded into the agent run.
|
||||
func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
cwd := t.TempDir()
|
||||
|
||||
// A minimal PNG, base64-encoded for the stream-json image payload.
|
||||
@@ -217,6 +218,7 @@ func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
|
||||
// the fix, ResolvePrompt's "must include at least one prompt or user message
|
||||
// event" error was returned before images were considered, rejecting the turn.
|
||||
func TestRunExecStreamJSONImageOnlyMessageProceeds(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
cwd := t.TempDir()
|
||||
|
||||
png := []byte{
|
||||
|
||||
+11
-1
@@ -22,7 +22,7 @@ const permissionProfileScopeNote = "permissionProfile is derived from this proce
|
||||
|
||||
func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.")
|
||||
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy`, `zero sandbox exec`, or `zero sandbox grants list`.")
|
||||
}
|
||||
switch args[0] {
|
||||
case "-h", "--help", "help":
|
||||
@@ -36,6 +36,8 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return runSandboxSetup(args[1:], stdout, stderr, deps)
|
||||
case "check":
|
||||
return runSandboxCheck(args[1:], stdout, stderr, deps)
|
||||
case "exec":
|
||||
return runSandboxExec(args[1:], stdout, stderr, deps)
|
||||
case "grants":
|
||||
return runSandboxGrants(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
@@ -174,10 +176,17 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if !setupHelper.Available() {
|
||||
return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider)
|
||||
}
|
||||
// Resolved here, in the shell the user typed `zero sandbox setup` into, and
|
||||
// carried in the args. The helper may be launched elevated, and an elevated
|
||||
// process does not inherit this shell's environment. Stated explicitly rather
|
||||
// than left nil (which resolves the same way) because this is the call site
|
||||
// the opt-in is about.
|
||||
principalOptIn := zeroSandbox.WindowsSandboxPrincipalOptIn(nil)
|
||||
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
PrincipalOptIn: &principalOptIn,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -646,6 +655,7 @@ Commands:
|
||||
policy Inspect active sandbox policy and platform backend
|
||||
setup Run native platform sandbox setup
|
||||
check Evaluate the sandbox decision for a hypothetical tool action
|
||||
exec Run one command through the real sandbox
|
||||
grants Manage persistent sandbox grants
|
||||
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// runSandboxExec runs ONE command through the real sandbox and exits with its
|
||||
// status.
|
||||
//
|
||||
// This exists because until now the sandbox could only be exercised through a
|
||||
// full agent turn with a model in the loop. `zero sandbox policy` reports what
|
||||
// the posture would be and `zero sandbox check` evaluates a hypothetical
|
||||
// decision, but nothing actually ran a command and let you look at what
|
||||
// happened on disk afterwards. The practical result is that enforcement is
|
||||
// covered almost entirely by tests asserting the shape of an ACL plan, and
|
||||
// almost not at all by tests asserting a write was refused.
|
||||
//
|
||||
// A plan can be perfectly correct and never reach the filesystem. That is not
|
||||
// hypothetical here: the .git rename guard was emitted correctly by the planner
|
||||
// and silently skipped by the applier, and four tests covering the plan all
|
||||
// passed while the ACE was absent from disk. Something that runs the real
|
||||
// binary and then stats the file is the only thing that catches that class.
|
||||
//
|
||||
// Deliberately NOT a debug curiosity: it takes the same path a shell tool
|
||||
// takes, the engine's decision first and then the engine's command plan, so
|
||||
// what it proves is what users get. It prints the decision, then the resolved
|
||||
// backend and enforcement level, to stderr before running, so a harness can
|
||||
// assert the sandbox was actually engaged rather than quietly downgraded.
|
||||
func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
command, err := parseSandboxExecArgs(args)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSandboxExecHelp) {
|
||||
if writeErr := writeSandboxExecHelp(stdout); writeErr != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
|
||||
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, fmt.Sprintf("resolve sandbox write roots: %v", err), exitCrash)
|
||||
}
|
||||
|
||||
// Through the ENGINE, not a SandboxManager built here.
|
||||
//
|
||||
// The engine is what a real tool call goes through, and it does more than
|
||||
// hand the manager a request: it resolves the permission profile, calls
|
||||
// prepareSandboxRuntime, and folds the runtime state into that profile
|
||||
// before planning. Building a manager directly skipped all of it, so the
|
||||
// command ran without the runtime write root, and cache or temp writes could
|
||||
// pass or fail differently from the sandboxed command this exists to imitate.
|
||||
// A harness that exercises the wrong path is worse than no harness, because
|
||||
// its result still reads as evidence.
|
||||
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Scope: scope,
|
||||
Backend: deps.selectSandboxBackend(zeroSandbox.BackendOptions{}),
|
||||
// The same scrub list a real tool call gets. Without it the engine keeps an
|
||||
// empty set, and only the hardcoded names plus the provider catalog's own
|
||||
// AuthEnvVars are removed from the child's environment: a key named by
|
||||
// `apiKeyEnv` in the user's config would be scrubbed for every sandboxed
|
||||
// tool call and handed to this one. Reproducing the production environment
|
||||
// is the entire point of the command, and a credential is the last part of
|
||||
// it that may differ.
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
})
|
||||
|
||||
// The CLI's shared shutdown context, so Ctrl+C and a directed SIGTERM both
|
||||
// arrive here rather than only at whatever the terminal happens to signal.
|
||||
runCtx, stopSignals := signalContext()
|
||||
defer stopSignals()
|
||||
|
||||
// THE DECISION COMES BEFORE THE PLAN, as it does for every real tool call.
|
||||
//
|
||||
// A shell tool is evaluated by the engine and only then planned
|
||||
// (tools.Registry.RunWithOptions). This went straight to BuildCommandPlan, so
|
||||
// it took "the same path a shell tool takes" only from the second step on, and
|
||||
// every refusal that lives in Evaluate did not exist here. The one that
|
||||
// matters most is the nested-repository guard: a workspace governed by an
|
||||
// ancestor repository carries NO git carveouts, on the strength of that guard
|
||||
// refusing to create a repository there, and `zero sandbox exec -- git init`
|
||||
// created one under the plain workspace grant with a writable config and
|
||||
// hooks. A harness that runs what production would have refused does not
|
||||
// prove what users get. Reported by @jatmn.
|
||||
//
|
||||
// PermissionGranted is true because the person at the terminal typed the
|
||||
// command, which is the approval a prompt asks a session user for. It lifts
|
||||
// exactly what an approval lifts in a session and nothing else: a denial is
|
||||
// still a denial, and an approved network command still runs inside a sandbox
|
||||
// that has the network off.
|
||||
decision := sandboxExecDecision(runCtx, engine, workspaceRoot, command)
|
||||
fmt.Fprintf(stderr, "sandbox: decision=%s reason=%s\n", decision.Action, decision.Reason)
|
||||
if decision.Action == zeroSandbox.ActionDeny {
|
||||
return writeAppError(stderr, decision.ErrorString(), exitCrash)
|
||||
}
|
||||
|
||||
plan, err := engine.BuildCommandPlan(zeroSandbox.CommandSpec{
|
||||
Name: command[0],
|
||||
Args: command[1:],
|
||||
Dir: workspaceRoot,
|
||||
Env: os.Environ(),
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, fmt.Sprintf("build sandbox command plan: %v", err), exitCrash)
|
||||
}
|
||||
// The plan owns resources beyond its construction: on Linux it allocates a
|
||||
// policy-report file and registers the removal here, so without this every
|
||||
// invocation leaves a /tmp/zero-sandbox-report-* behind.
|
||||
defer plan.Cleanup()
|
||||
|
||||
// Printed before the command runs and on stderr, so it survives a command
|
||||
// that writes to stdout and stays greppable by a test harness. A downgrade
|
||||
// is reported loudly for the same reason: a smoke test that passes because
|
||||
// the sandbox quietly stood down is worse than no smoke test.
|
||||
fmt.Fprintf(stderr, "sandbox: backend=%s enforcement=%s wrapped=%t workspace=%s\n",
|
||||
plan.Backend.Name, plan.EnforcementLevel, plan.Wrapped, plan.WorkspaceRoot)
|
||||
if strings.TrimSpace(plan.DowngradeReason) != "" {
|
||||
fmt.Fprintf(stderr, "sandbox: DOWNGRADED: %s\n", plan.DowngradeReason)
|
||||
}
|
||||
|
||||
return runSandboxPlannedCommand(runCtx, plan, stdout, stderr)
|
||||
}
|
||||
|
||||
// sandboxExecToolName is the tool a session would have run this command with.
|
||||
// Grants and the engine's shell rules are keyed by it.
|
||||
const sandboxExecToolName = "bash"
|
||||
|
||||
// sandboxExecDecision asks the engine the question a session asks before it
|
||||
// plans a shell command, for the argv this invocation was given.
|
||||
func sandboxExecDecision(ctx context.Context, engine *zeroSandbox.Engine, workspaceRoot string, argv []string) zeroSandbox.Decision {
|
||||
return engine.Evaluate(ctx, zeroSandbox.Request{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
ToolName: sandboxExecToolName,
|
||||
SideEffect: zeroSandbox.SideEffectShell,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: zeroSandbox.PermissionModeAsk,
|
||||
Args: map[string]any{"command": sandboxExecCommandText(argv)},
|
||||
Reason: "zero sandbox exec",
|
||||
})
|
||||
}
|
||||
|
||||
// sandboxExecCommandText renders argv as the command text the engine analyses.
|
||||
//
|
||||
// The engine classifies a shell tool by parsing its command string, so argv has
|
||||
// to become one without changing what it says. A word made only of characters
|
||||
// the shell does not interpret is left bare, which keeps `git init` reading as a
|
||||
// session would have written it. Anything else is single-quoted, so an argument
|
||||
// such as "git init" stays ONE word and is not mistaken for a command, and a
|
||||
// payload handed to `sh -c` reaches the analyser as the payload it is.
|
||||
func sandboxExecCommandText(argv []string) string {
|
||||
words := make([]string, 0, len(argv))
|
||||
for _, word := range argv {
|
||||
words = append(words, sandboxExecQuoteWord(word))
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
func sandboxExecQuoteWord(word string) string {
|
||||
if word == "" {
|
||||
return "''"
|
||||
}
|
||||
bare := true
|
||||
for _, r := range word {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case strings.ContainsRune("_@%+=:,./-", r):
|
||||
default:
|
||||
bare = false
|
||||
}
|
||||
}
|
||||
if bare {
|
||||
return word
|
||||
}
|
||||
// A single quote cannot appear inside single quotes, so it closes them, is
|
||||
// written escaped, and opens them again.
|
||||
return "'" + strings.ReplaceAll(word, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
const (
|
||||
// sandboxExecShutdownGrace is how long a cancelled child has to exit on its
|
||||
// own after the graceful request, before it is killed outright.
|
||||
sandboxExecShutdownGrace = 5 * time.Second
|
||||
// sandboxExecShutdownPoll is how often that wait re-checks, matching the
|
||||
// background package's termination policy.
|
||||
sandboxExecShutdownPoll = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
func runSandboxPlannedCommand(ctx context.Context, plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int {
|
||||
// CANCELLING THE WRAPPER HAS TO REACH THE COMMAND.
|
||||
//
|
||||
// This started the backend wrapper with a bare exec.Command().Run(): no
|
||||
// context, no forwarding, no shutdown path. A terminal masks it, because
|
||||
// terminals signal the whole foreground process group, but a supervisor or
|
||||
// task runner that sends SIGTERM to the zero sandbox exec PID killed only
|
||||
// Zero. The wrapper and everything under it kept running, doing filesystem
|
||||
// and network work after the caller considered the task cancelled, and the
|
||||
// deferred plan cleanup never ran because the process was gone.
|
||||
//
|
||||
// DELIBERATELY NOT execution.ConfigureProcessGroup. Putting the child in its
|
||||
// own process group closes this hole and opens a worse one: it severs every
|
||||
// kernel-delivered group signal, so a supervisor escalating to a group kill,
|
||||
// and a terminal delivering Ctrl+C to its foreground group, would stop
|
||||
// reaching the child. That trades a path that works today for one that does
|
||||
// not. Keeping the child in Zero's group leaves group delivery unchanged,
|
||||
// while the directed-signal case, which reached nothing at all before, now
|
||||
// reaches the child.
|
||||
process := exec.CommandContext(ctx, plan.Name, plan.Args...)
|
||||
// TWO PHASES, AND THE FIRST ONE HAS TO EXIST.
|
||||
//
|
||||
// The comments here used to describe a graceful signal followed by escalation
|
||||
// while the code did neither: Cancel called KillProcessTree, which is SIGKILL
|
||||
// on Unix and taskkill /T /F on Windows, and WaitDelay only starts counting
|
||||
// AFTER Cancel returns, so it could never postpone a kill that had already
|
||||
// happened. A directed SIGTERM reached the child as 137 rather than 143, with
|
||||
// no chance to flush output, drop a lock file, or run a shutdown handler.
|
||||
//
|
||||
// TerminateProcessTree is the two-phase policy the background package already
|
||||
// uses: SIGTERM, poll for the grace, then SIGKILL what is still alive. On
|
||||
// Windows it is the single-phase kill, which is correct rather than lazy,
|
||||
// because there is no signal to send a child that owns no console.
|
||||
process.Cancel = func() error {
|
||||
return execution.TerminateProcessTree(process.Process.Pid, sandboxExecShutdownGrace, sandboxExecShutdownPoll)
|
||||
}
|
||||
// A backstop only. Cancel above already waits out the grace and escalates, so
|
||||
// this bounds the case where the tree is gone but Wait is still blocked on an
|
||||
// inherited pipe a grandchild holds open.
|
||||
process.WaitDelay = sandboxExecShutdownGrace + sandboxExecShutdownPoll
|
||||
process.Dir = plan.Dir
|
||||
if process.Dir == "" {
|
||||
process.Dir = plan.WorkspaceRoot
|
||||
}
|
||||
// SPECIFIED-EMPTY IS NOT UNSPECIFIED.
|
||||
//
|
||||
// exec.Cmd treats a nil Env as "inherit this process's entire environment",
|
||||
// which is a different statement from "run with no variables". The plan owns
|
||||
// its environment: directCommandEnv and scrubSensitiveEnv return a slice they
|
||||
// built, and that slice is non-nil with length zero when every entry was
|
||||
// sensitive. Testing length collapsed those two states and turned the strictest
|
||||
// possible answer into the loosest one.
|
||||
//
|
||||
// Not reachable through `zero sandbox exec` today, because the child
|
||||
// environment is os.Environ() and an environment holding only sensitive keys
|
||||
// has no %AppData%, so config resolution fails before the planner runs. Fixed
|
||||
// anyway: the guard is one assignment, and the next caller that hands the plan
|
||||
// a deliberately narrow environment would inherit everything instead, silently.
|
||||
if plan.Env != nil {
|
||||
process.Env = plan.Env
|
||||
}
|
||||
process.Stdin = os.Stdin
|
||||
process.Stdout = stdout
|
||||
process.Stderr = stderr
|
||||
|
||||
if err := process.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
// The command's own status, not ours. A harness asserting "the write
|
||||
// was refused" needs the refusal's exit code, not a wrapper's.
|
||||
// A SIGNALED CHILD HAS NO EXIT CODE TO REPORT. ExitCode() answers -1
|
||||
// there, and os.Exit truncates that to 255, so a child that took SIGTERM
|
||||
// became indistinguishable from one that chose to exit 255. Fold the
|
||||
// signal into the conventional 128+n a shell would report, which needs
|
||||
// the ProcessState rather than the integer.
|
||||
if status, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
|
||||
return status
|
||||
}
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
fmt.Fprintf(stderr, "sandbox exec: %v\n", err)
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
var errSandboxExecHelp = errors.New("help requested")
|
||||
|
||||
// parseSandboxExecArgs takes everything after `--` as the command, so the
|
||||
// command's own flags are never mistaken for ours.
|
||||
func parseSandboxExecArgs(args []string) ([]string, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
|
||||
}
|
||||
// Only the FIRST token is ours to interpret. Everything from the second
|
||||
// onwards belongs to the child, help flags included.
|
||||
//
|
||||
// Written as a straight-line decision on args[0] rather than a loop, because
|
||||
// a loop here is a lie. An earlier version scanned for `--` "up front" and
|
||||
// said so in its comment, but every branch of its body returned, so it only
|
||||
// ever examined index 0 and the promised scan did not exist. The rule below
|
||||
// is what that code actually implemented, and it is the rule we want: it is
|
||||
// the wrapper's own prefix that can ask for the wrapper's help, and the
|
||||
// prefix is at most one token long.
|
||||
switch args[0] {
|
||||
case "--":
|
||||
// Everything after the separator is the command, verbatim, including
|
||||
// `--help`. That is the documented contract: `zero sandbox exec -- cmd
|
||||
// --help` must run cmd's help, not ours.
|
||||
command := args[1:]
|
||||
if len(command) == 0 {
|
||||
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
|
||||
}
|
||||
return command, nil
|
||||
case "-h", "--help", "help":
|
||||
return nil, errSandboxExecHelp
|
||||
}
|
||||
// The separator-less form, tolerated for interactive use: the first token is
|
||||
// the command, so nothing after it is ours to read. Without this,
|
||||
// `zero sandbox exec mycmd --help` printed OUR help and never ran mycmd,
|
||||
// which is the same contract break as reading past `--`. The help text still
|
||||
// shows the separator, because anything with a leading dash needs it.
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func writeSandboxExecHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox exec -- <command> [args...]
|
||||
|
||||
Runs one command through the real sandbox and exits with its status.
|
||||
|
||||
Everything after the -- separator is the command, so its own flags are not
|
||||
parsed as Zero's. The command is evaluated the way a session evaluates a shell
|
||||
tool call before anything is planned: the decision is written to stderr, and a
|
||||
command a session refuses is refused here too, with the same reason. Running it
|
||||
from a terminal counts as the approval a session would prompt for, and lifts
|
||||
only what that approval lifts. The resolved backend and enforcement level are
|
||||
then written to stderr before the command runs, and a downgrade is reported
|
||||
there explicitly.
|
||||
|
||||
Examples:
|
||||
zero sandbox exec -- cmd /c echo hello
|
||||
zero sandbox exec -- powershell -Command "Set-Content out.txt x"
|
||||
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// longLivedPlan returns a plan whose child outlives the test unless something
|
||||
// terminates it.
|
||||
func longLivedPlan(t *testing.T) zeroSandbox.CommandPlan {
|
||||
t.Helper()
|
||||
plan := zeroSandbox.CommandPlan{Dir: t.TempDir()}
|
||||
if runtime.GOOS == "windows" {
|
||||
plan.Name = "cmd.exe"
|
||||
plan.Args = []string{"/c", "ping -n 120 127.0.0.1 >NUL"}
|
||||
return plan
|
||||
}
|
||||
plan.Name = "/bin/sh"
|
||||
plan.Args = []string{"-c", "sleep 120"}
|
||||
return plan
|
||||
}
|
||||
|
||||
// CANCELLING THE WRAPPER HAS TO REACH THE COMMAND.
|
||||
//
|
||||
// The sandboxed command was started with a bare exec.Command().Run(): no
|
||||
// context, no forwarding, no shutdown path. A terminal masks that, because it
|
||||
// signals the whole foreground process group, but a supervisor or task runner
|
||||
// that sends SIGTERM to the wrapper's PID killed only Zero. The command kept
|
||||
// running, doing filesystem and network work after the caller considered the
|
||||
// task cancelled, and the deferred plan cleanup never ran.
|
||||
//
|
||||
// Driven with a real long-lived child and a real cancellation, asserting that
|
||||
// the call actually returns rather than that a field is set.
|
||||
func TestCancellingTheWrapperTerminatesTheSandboxedCommand(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runSandboxPlannedCommand(ctx, longLivedPlan(t), io.Discard, io.Discard)
|
||||
}()
|
||||
|
||||
// Let the child actually start, or cancelling proves nothing.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("SETUP INVALID: the child exited on its own, so cancellation was not exercised")
|
||||
default:
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(sandboxExecShutdownGrace + 10*time.Second):
|
||||
t.Fatal("cancelling the wrapper did not terminate the sandboxed command; it would keep running after the caller gave up")
|
||||
}
|
||||
}
|
||||
|
||||
// And an uncancelled command still runs to completion and reports its own
|
||||
// status, or the fix above would be "kill everything immediately".
|
||||
func TestAnUncancelledSandboxedCommandStillReportsItsStatus(t *testing.T) {
|
||||
plan := zeroSandbox.CommandPlan{Dir: t.TempDir()}
|
||||
if runtime.GOOS == "windows" {
|
||||
plan.Name = "cmd.exe"
|
||||
plan.Args = []string{"/c", "exit 3"}
|
||||
} else {
|
||||
plan.Name = "/bin/sh"
|
||||
plan.Args = []string{"-c", "exit 3"}
|
||||
}
|
||||
if code := runSandboxPlannedCommand(context.Background(), plan, io.Discard, os.Stderr); code != 3 {
|
||||
t.Fatalf("exit code = %d, want the child's own 3", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// governedNestedWorkspace is a workspace inside somebody else's repository: an
|
||||
// ancestor carries .git and the workspace itself does not. Setup plans no git
|
||||
// carveouts for it, which is why a repository must not be created there.
|
||||
func governedNestedWorkspace(t *testing.T) (ancestor, workspace string) {
|
||||
t.Helper()
|
||||
ancestor = t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(ancestor, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace = filepath.Join(ancestor, "packages", "app")
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "sub dir"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ancestor, workspace
|
||||
}
|
||||
|
||||
func sandboxExecTestDeps(workspace string) appDeps {
|
||||
return appDeps{
|
||||
getwd: func() (string, error) { return workspace, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
// No native runner. A command that got past the decision would run
|
||||
// unwrapped, which is exactly what makes a missing refusal visible on
|
||||
// disk instead of only in a return value.
|
||||
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendUnavailable, Fallback: true, Message: "no native sandbox in this test"}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// `zero sandbox exec` REFUSES WHAT A SESSION WOULD REFUSE.
|
||||
//
|
||||
// The command is documented as taking the path a shell tool takes. A shell tool
|
||||
// is evaluated by the engine and then planned; this went straight to the plan,
|
||||
// so nothing that lives in Evaluate applied. In a workspace governed by an
|
||||
// ancestor repository that is the nested-repository guard, and that guard is the
|
||||
// only thing standing in for the git carveouts setup deliberately does not plan
|
||||
// there. `zero sandbox exec -- git init` created the repository, writable config
|
||||
// and hooks included.
|
||||
//
|
||||
// Driven through the real CLI entry point, with a backend that would run the
|
||||
// command unwrapped if it were let through, and checked on disk: the refusal has
|
||||
// to come with the reason an operator can act on, and no repository may exist
|
||||
// afterwards. Reported by @jatmn.
|
||||
func TestSandboxExecRefusesToCreateARepositoryInAGovernedWorkspace(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
argv []string
|
||||
made []string
|
||||
}{
|
||||
{name: "git init", argv: []string{"git", "init"}, made: []string{".git"}},
|
||||
{name: "git init behind -C and a quoted directory", argv: []string{"git", "-C", "sub dir", "init"}, made: []string{"sub dir/.git"}},
|
||||
{name: "git init behind a shell launcher", argv: []string{"sh", "-c", "git init"}, made: []string{".git"}},
|
||||
{name: "git init behind env", argv: []string{"env", "GIT_TRACE=0", "git", "init"}, made: []string{".git"}},
|
||||
{name: "git clone", argv: []string{"git", "clone", "https://example.invalid/repo.git", "vendored"}, made: []string{"vendored"}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, workspace := governedNestedWorkspace(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := append([]string{"sandbox", "exec", "--"}, testCase.argv...)
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, sandboxExecTestDeps(workspace))
|
||||
|
||||
for _, made := range testCase.made {
|
||||
if _, err := os.Lstat(filepath.Join(workspace, filepath.FromSlash(made))); err == nil {
|
||||
t.Errorf("%s exists after the command: the repository was created under the plain workspace grant", made)
|
||||
}
|
||||
}
|
||||
if exitCode == exitSuccess {
|
||||
t.Errorf("exit = %d for a command a session refuses; stderr:\n%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "decision=deny") {
|
||||
t.Errorf("stderr does not report the decision:\n%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "sits inside an existing git repository") {
|
||||
t.Errorf("the refusal does not carry the nested-repository reason and its remedy:\n%s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), "backend=") {
|
||||
t.Errorf("a command plan was built for a refused command:\n%s", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The decision is the engine's, asked the way a session asks it. These are the
|
||||
// controls: what must NOT be refused, so the gate above is not simply refusing
|
||||
// git, or refusing this workspace.
|
||||
func TestSandboxExecDecisionLeavesOrdinaryCommandsAlone(t *testing.T) {
|
||||
_, governed := governedNestedWorkspace(t)
|
||||
standalone := t.TempDir()
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
workspace string
|
||||
argv []string
|
||||
}{
|
||||
{name: "an ordinary git command in the governed workspace", workspace: governed, argv: []string{"git", "status"}},
|
||||
{name: "the words git init as one argument", workspace: governed, argv: []string{"echo", "git init"}},
|
||||
{name: "git init in a workspace nobody governs", workspace: standalone, argv: []string{"git", "init"}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: testCase.workspace,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Fallback: true},
|
||||
})
|
||||
decision := sandboxExecDecision(context.Background(), engine, testCase.workspace, testCase.argv)
|
||||
if decision.Action == sandbox.ActionDeny {
|
||||
t.Fatalf("%q was refused: %s", strings.Join(testCase.argv, " "), decision.ErrorString())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ARGV BECOMES COMMAND TEXT WITHOUT CHANGING WHAT IT SAYS. The engine classifies
|
||||
// a shell command by parsing its text, so the rendering decides what the
|
||||
// classifier sees. One argument has to stay one word, and a payload handed to a
|
||||
// shell launcher has to arrive as that payload.
|
||||
func TestSandboxExecCommandTextKeepsArgvIntact(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
argv []string
|
||||
want string
|
||||
gitInit bool
|
||||
}{
|
||||
{name: "bare words stay bare", argv: []string{"git", "init"}, want: "git init", gitInit: true},
|
||||
{name: "a spaced argument is one word", argv: []string{"echo", "git init"}, want: "echo 'git init'"},
|
||||
{name: "a launcher payload is analysed as a payload", argv: []string{"sh", "-c", "git init"}, want: "sh -c 'git init'", gitInit: true},
|
||||
{name: "a quote inside an argument", argv: []string{"printf", "it's"}, want: `printf 'it'\''s'`},
|
||||
{name: "substitution syntax is inert", argv: []string{"echo", "$(git init)"}, want: "echo '$(git init)'"},
|
||||
{name: "an empty argument survives", argv: []string{"printf", ""}, want: "printf ''"},
|
||||
{name: "a directory with a space", argv: []string{"git", "-C", "sub dir", "init"}, want: "git -C 'sub dir' init", gitInit: true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
text := sandboxExecCommandText(testCase.argv)
|
||||
if text != testCase.want {
|
||||
t.Fatalf("command text = %q, want %q", text, testCase.want)
|
||||
}
|
||||
analysis := sandbox.AnalyzeCommand(text)
|
||||
if analysis.TooComplex {
|
||||
t.Fatalf("%q does not parse, so the classifier would treat a plain argv as obfuscated", text)
|
||||
}
|
||||
if analysis.GitInit != testCase.gitInit {
|
||||
t.Errorf("%q: GitInit = %v, want %v", text, analysis.GitInit, testCase.gitInit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// envPrinterPlan returns a plan whose child prints its own environment, so the
|
||||
// assertion is about what the child received rather than about a field value.
|
||||
func envPrinterPlan(t *testing.T, env []string) zeroSandbox.CommandPlan {
|
||||
t.Helper()
|
||||
plan := zeroSandbox.CommandPlan{Dir: t.TempDir(), Env: env}
|
||||
if runtime.GOOS == "windows" {
|
||||
plan.Name = "cmd.exe"
|
||||
plan.Args = []string{"/c", "set"}
|
||||
return plan
|
||||
}
|
||||
plan.Name = "/bin/sh"
|
||||
plan.Args = []string{"-c", "env"}
|
||||
return plan
|
||||
}
|
||||
|
||||
// A PLAN THAT SPECIFIES NO VARIABLES MUST NOT INHERIT EVERY VARIABLE.
|
||||
//
|
||||
// exec.Cmd reads a nil Env as "inherit this process's entire environment", which
|
||||
// is a different statement from "run with no variables". The plan owns its
|
||||
// environment: directCommandEnv and scrubSensitiveEnv return a slice they built,
|
||||
// and that slice is non-nil with length zero when every entry was sensitive.
|
||||
// Testing its length collapsed the two states and turned the strictest possible
|
||||
// answer into the loosest one.
|
||||
//
|
||||
// Not reachable through `zero sandbox exec` today, because the child environment
|
||||
// is os.Environ() and an environment holding only sensitive keys has no
|
||||
// %AppData%, so config resolution fails before the planner runs. Pinned anyway,
|
||||
// because the next caller that hands the plan a deliberately narrow environment
|
||||
// would silently inherit everything instead.
|
||||
func TestAnEmptyPlannedEnvironmentDoesNotInherit(t *testing.T) {
|
||||
const marker = "ZZ_SANDBOX_ENV_MARKER"
|
||||
t.Setenv(marker, "leaked-value")
|
||||
|
||||
var out strings.Builder
|
||||
// Specified, and deliberately empty.
|
||||
code := runSandboxPlannedCommand(context.Background(), envPrinterPlan(t, []string{}), &out, os.Stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("child exited %d: %s", code, out.String())
|
||||
}
|
||||
if strings.Contains(out.String(), marker) {
|
||||
t.Fatalf("a plan specifying no environment leaked the caller's %s to the child:\n%s", marker, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// And a nil environment still inherits, which is the meaning the planner relies
|
||||
// on when it did not build one. Without this the fix above could be "never pass
|
||||
// the environment", which would break every ordinary command.
|
||||
func TestANilPlannedEnvironmentStillInherits(t *testing.T) {
|
||||
const marker = "ZZ_SANDBOX_ENV_MARKER"
|
||||
t.Setenv(marker, "inherited-value")
|
||||
|
||||
var out strings.Builder
|
||||
code := runSandboxPlannedCommand(context.Background(), envPrinterPlan(t, nil), &out, os.Stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("child exited %d: %s", code, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), marker) {
|
||||
t.Fatalf("a nil planned environment stopped inheriting, which changes the meaning the planner relies on:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// THE GRACEFUL PHASE HAS TO ACTUALLY HAPPEN.
|
||||
//
|
||||
// The shutdown comments described a graceful signal followed by escalation while
|
||||
// the code did neither: Cancel called KillProcessTree, which is SIGKILL here, and
|
||||
// WaitDelay only starts counting after Cancel returns so it could never postpone
|
||||
// a kill that had already happened. A directed SIGTERM therefore reached the
|
||||
// child as 137 rather than 143, with no chance to flush output, drop a lock file,
|
||||
// or run a shutdown handler.
|
||||
//
|
||||
// Driven with a child that traps the signal and records that its handler ran, so
|
||||
// the assertion is about the observed lifecycle rather than about a configured
|
||||
// duration.
|
||||
func TestACancelledCommandGetsAChanceToCleanUp(t *testing.T) {
|
||||
marker := filepath.Join(t.TempDir(), "cleanup-ran")
|
||||
// Exits 42 on the graceful signal: a distinctive code the child could only
|
||||
// have chosen itself, so the status proves the handler ran rather than that
|
||||
// something plausible happened.
|
||||
script := "trap 'echo yes > " + marker + "; exit 42' TERM; while :; do sleep 0.05; done"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", script},
|
||||
Dir: t.TempDir(),
|
||||
}, io.Discard, io.Discard)
|
||||
}()
|
||||
|
||||
// The trap has to be installed before the signal, or this proves nothing.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("SETUP INVALID: the child exited on its own")
|
||||
default:
|
||||
}
|
||||
|
||||
cancel()
|
||||
var status int
|
||||
select {
|
||||
case status = <-done:
|
||||
case <-time.After(sandboxExecShutdownGrace + 10*time.Second):
|
||||
t.Fatal("the cancelled command never terminated")
|
||||
}
|
||||
|
||||
// The status the caller sees is the child's own, so a harness can tell an
|
||||
// orderly shutdown from a kill.
|
||||
if status != 42 {
|
||||
t.Errorf("status = %d, want 42; the child chose that code in its TERM handler, so anything else means it never got to finish", status)
|
||||
}
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("the child was killed outright instead of being asked to stop, so its cleanup handler never ran: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And a child that ignores the request is still force-killed, after the bound
|
||||
// rather than before it. Without this the fix could be "only ever ask nicely".
|
||||
func TestAStubbornCancelledCommandIsStillKilled(t *testing.T) {
|
||||
// Traps TERM and keeps running: only escalation can end this.
|
||||
script := "trap '' TERM; while :; do sleep 0.05; done"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", script},
|
||||
Dir: t.TempDir(),
|
||||
}, io.Discard, io.Discard)
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
started := time.Now()
|
||||
cancel()
|
||||
var status int
|
||||
select {
|
||||
case status = <-done:
|
||||
case <-time.After(sandboxExecShutdownGrace + 15*time.Second):
|
||||
t.Fatal("a child ignoring the graceful request was never force-killed")
|
||||
}
|
||||
// After the bound, not before: a kill that lands immediately is the defect
|
||||
// this replaced.
|
||||
if elapsed := time.Since(started); elapsed < sandboxExecShutdownGrace {
|
||||
t.Fatalf("the child was killed after %s, before the %s grace it was owed", elapsed, sandboxExecShutdownGrace)
|
||||
}
|
||||
// And escalation really was a kill, so "ask nicely and give up" cannot pass.
|
||||
if want := 128 + int(syscall.SIGKILL); status != want {
|
||||
t.Errorf("status = %d, want %d after escalation", status, want)
|
||||
}
|
||||
}
|
||||
|
||||
// AND THE STATUS HAS TO SAY WHICH SIGNAL ARRIVED.
|
||||
//
|
||||
// 137 rather than 143 was the reported symptom, and it is the only part of this
|
||||
// a caller can see. A child that does not trap anything must come back as
|
||||
// 128+SIGTERM: if the first signal were still SIGKILL, this would be 137 and the
|
||||
// two cases above would both still pass, since one traps and the other ignores.
|
||||
func TestACancelledCommandReportsTheGracefulSignal(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", "while :; do sleep 0.05; done"},
|
||||
Dir: t.TempDir(),
|
||||
}, io.Discard, io.Discard)
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
var status int
|
||||
select {
|
||||
case status = <-done:
|
||||
case <-time.After(sandboxExecShutdownGrace + 10*time.Second):
|
||||
t.Fatal("the cancelled command never terminated")
|
||||
}
|
||||
if want := 128 + int(syscall.SIGTERM); status != want {
|
||||
t.Fatalf("status = %d, want %d; %d means the child was SIGKILLed with no graceful phase", status, want, 128+int(syscall.SIGKILL))
|
||||
}
|
||||
}
|
||||
|
||||
// TERMINATION IS COMPLETE BEFORE THE FUNCTION RETURNS.
|
||||
//
|
||||
// `zero sandbox exec` releases the plan with a defer around this call, so plan
|
||||
// cleanup is ordered after termination only if nothing survives the return. A
|
||||
// future refactor that lets Wait come back while the tree is still up would move
|
||||
// cleanup on top of a live child, deleting the policy-report file it is using.
|
||||
//
|
||||
// The stubborn child is the one that can outlive the call, since it ignores the
|
||||
// graceful request and only escalation ends it.
|
||||
func TestACancelledCommandIsFullyReapedBeforeCleanupCouldRun(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "child.pid")
|
||||
script := "echo $$ > " + pidFile + "; trap '' TERM; while :; do sleep 0.05; done"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", script},
|
||||
Dir: t.TempDir(),
|
||||
}, io.Discard, io.Discard)
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
raw, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: the child never recorded its pid, so there is nothing to check for survival: %v", err)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil || pid <= 0 {
|
||||
t.Fatalf("SETUP INVALID: unusable child pid %q: %v", raw, err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(sandboxExecShutdownGrace + 15*time.Second):
|
||||
t.Fatal("a child ignoring the graceful request was never force-killed")
|
||||
}
|
||||
|
||||
// Signal 0 is the existence probe: no signal is sent, only the permission and
|
||||
// liveness checks run, so ESRCH is the answer for a reaped process.
|
||||
if err := syscall.Kill(pid, 0); err == nil {
|
||||
t.Fatalf("the child (pid %d) was still alive when the run returned; plan cleanup is deferred around this call and would delete the policy-report file underneath it", pid)
|
||||
} else if err != syscall.ESRCH {
|
||||
t.Skipf("cannot probe pid %d here: %v", pid, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// signaledExitStatus reports the conventional shell status for a child that was
|
||||
// terminated by a signal, which exec.ExitError cannot represent.
|
||||
//
|
||||
// ExitCode() returns -1 for a signaled child, and the top level hands that to
|
||||
// os.Exit, which truncates it to 255. A child that takes SIGTERM is then
|
||||
// indistinguishable from one that exited 255 of its own accord, which breaks the
|
||||
// documented status contract for a command whose whole job is to report the
|
||||
// child's own status faithfully. 128+signal is what a shell reports and what a
|
||||
// harness comparing statuses expects.
|
||||
func signaledExitStatus(state *os.ProcessState) (int, bool) {
|
||||
if state == nil {
|
||||
return 0, false
|
||||
}
|
||||
status, ok := state.Sys().(syscall.WaitStatus)
|
||||
if !ok || !status.Signaled() {
|
||||
return 0, false
|
||||
}
|
||||
return 128 + int(status.Signal()), true
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A SIGNALED CHILD MUST NOT LOOK LIKE ONE THAT CHOSE TO EXIT 255.
|
||||
//
|
||||
// `sandbox exec` exists to report the child's own status faithfully, and
|
||||
// exec.ExitError cannot represent signal termination: ExitCode() answers -1, and
|
||||
// the top level hands that to os.Exit, which truncates it to 255. So a child
|
||||
// killed by SIGTERM was indistinguishable from an ordinary exit of 255, and a
|
||||
// harness comparing statuses could not tell a refusal from a kill.
|
||||
//
|
||||
// Driven with a real subprocess and a real signal rather than a synthetic
|
||||
// WaitStatus, because the mapping is only correct if the OS agrees with it.
|
||||
func TestSignaledChildReportsTheConventionalStatus(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
script string
|
||||
want int
|
||||
}{
|
||||
{name: "SIGTERM", script: "kill -TERM $$; sleep 5", want: 128 + int(syscall.SIGTERM)},
|
||||
{name: "SIGINT", script: "kill -INT $$; sleep 5", want: 128 + int(syscall.SIGINT)},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := exec.Command("/bin/sh", "-c", testCase.script).Run()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
|
||||
}
|
||||
// The behaviour being corrected: the integer alone cannot say this.
|
||||
if code := exitErr.ExitCode(); code != -1 {
|
||||
t.Fatalf("SETUP INVALID: a signaled child reported exit code %d, expected -1 on this platform", code)
|
||||
}
|
||||
status, signaled := signaledExitStatus(exitErr.ProcessState)
|
||||
if !signaled {
|
||||
t.Fatal("a signaled child was not recognised as signaled, so it would be reported as exit 255")
|
||||
}
|
||||
if status != testCase.want {
|
||||
t.Fatalf("status = %d, want %d (128 + signal), which is what a shell reports", status, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary non-zero exit is untouched: it has a real code and must keep it.
|
||||
func TestOrdinaryExitIsNotTreatedAsSignaled(t *testing.T) {
|
||||
err := exec.Command("/bin/sh", "-c", "exit 3").Run()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
|
||||
}
|
||||
if _, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
|
||||
t.Fatal("an ordinary exit was reported as signaled, which would rewrite its status")
|
||||
}
|
||||
if code := exitErr.ExitCode(); code != 3 {
|
||||
t.Fatalf("exit code = %d, want the child's own 3", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import "os"
|
||||
|
||||
// signaledExitStatus has no Windows counterpart: a process there ends with an
|
||||
// exit code, and there is no signal to fold into one. Windows keeps the exit
|
||||
// code exec.ExitError already reports.
|
||||
func signaledExitStatus(*os.ProcessState) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parseSandboxExecArgs sits on the boundary between Zero's flags and a child
|
||||
// command's, and it had no tests at all: not for the reported defect, not even
|
||||
// for the happy case. The contract it has to keep is that everything after the
|
||||
// separator belongs to the child, help flags included, so the wrapper can never
|
||||
// answer for a command it was supposed to be running.
|
||||
func TestParseSandboxExecArgs(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
command []string
|
||||
help bool
|
||||
usage bool
|
||||
}{
|
||||
{
|
||||
// The reported defect: --help after the separator is the CHILD's.
|
||||
name: "help after the separator belongs to the child",
|
||||
args: []string{"--", "cmd", "--help"},
|
||||
command: []string{"cmd", "--help"},
|
||||
},
|
||||
{
|
||||
name: "a second separator is the child's too",
|
||||
args: []string{"--", "cmd", "--", "inner"},
|
||||
command: []string{"cmd", "--", "inner"},
|
||||
},
|
||||
{
|
||||
name: "a flag-looking command survives the separator",
|
||||
args: []string{"--", "--weird-binary"},
|
||||
command: []string{"--weird-binary"},
|
||||
},
|
||||
{
|
||||
// The separator-less form. The first token is the command, so its own
|
||||
// flags are not ours to read either.
|
||||
name: "help after a separatorless command belongs to the child",
|
||||
args: []string{"cmd", "--help"},
|
||||
command: []string{"cmd", "--help"},
|
||||
},
|
||||
{
|
||||
// A separator arriving AFTER the command has already started is part of
|
||||
// the child's argv, not a delimiter we get a second go at.
|
||||
name: "a separator after the command is the child's argument",
|
||||
args: []string{"ls", "--", "foo"},
|
||||
command: []string{"ls", "--", "foo"},
|
||||
},
|
||||
{name: "bare help", args: []string{"--help"}, help: true},
|
||||
{name: "short help", args: []string{"-h"}, help: true},
|
||||
{name: "help subcommand", args: []string{"help"}, help: true},
|
||||
{name: "help in the prefix wins", args: []string{"--help", "--", "cmd"}, help: true},
|
||||
{name: "no arguments", args: nil, usage: true},
|
||||
{name: "separator with nothing after it", args: []string{"--"}, usage: true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
command, err := parseSandboxExecArgs(testCase.args)
|
||||
switch {
|
||||
case testCase.help:
|
||||
if !errors.Is(err, errSandboxExecHelp) {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want the help sentinel", testCase.args, command, err)
|
||||
}
|
||||
case testCase.usage:
|
||||
if err == nil || errors.Is(err, errSandboxExecHelp) {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want a usage error", testCase.args, command, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "usage:") {
|
||||
t.Errorf("a usage error must show the usage, got %q", err.Error())
|
||||
}
|
||||
default:
|
||||
if err != nil {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) returned %v", testCase.args, err)
|
||||
}
|
||||
if strings.Join(command, "\x00") != strings.Join(testCase.command, "\x00") {
|
||||
t.Errorf("parseSandboxExecArgs(%q) = %q, want %q", testCase.args, command, testCase.command)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The wrapper must never consume a token it then fails to hand over. Whatever it
|
||||
// returns as the command has to be a trailing slice of what it was given, with
|
||||
// at most a leading separator removed, or an argument has gone missing on its
|
||||
// way to the child.
|
||||
func TestParseSandboxExecArgsNeverDropsAChildArgument(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--", "cmd", "--help", "-x", "--"},
|
||||
{"cmd", "--help"},
|
||||
{"ls", "--", "foo"},
|
||||
{"--", "--weird-binary", "arg"},
|
||||
} {
|
||||
command, err := parseSandboxExecArgs(args)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) returned %v", args, err)
|
||||
}
|
||||
if len(command) == 0 {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) returned an empty command", args)
|
||||
}
|
||||
suffix := strings.Join(args[len(args)-len(command):], "\x00")
|
||||
if strings.Join(command, "\x00") != suffix {
|
||||
t.Errorf("parseSandboxExecArgs(%q) = %q, which is not a trailing slice of the input; an argument was dropped or reordered", args, command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,10 +100,17 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
|
||||
}
|
||||
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
|
||||
setupConfig := sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
// Same augmentation the setup args and the command plan apply, so doctor
|
||||
// fingerprints what a real command fingerprints. Safe here: doctor runs in
|
||||
// the operator's shell, not behind the sandbox's TEMP redirection.
|
||||
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}),
|
||||
// Same opt-in a command would resolve, so doctor reports the principal
|
||||
// mismatch as out-of-date setup instead of passing a check the next command
|
||||
// will fail.
|
||||
PrincipalOptIn: sandbox.WindowsSandboxPrincipalOptIn(nil),
|
||||
}
|
||||
if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil {
|
||||
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{
|
||||
@@ -115,6 +122,53 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
|
||||
})
|
||||
return &result
|
||||
}
|
||||
// Setup is valid and the opt-in is set, so report which principal a command
|
||||
// will actually run as.
|
||||
//
|
||||
// This used to warn that the principal stood down under the DEFAULT
|
||||
// network-deny policy, which was true while network denial could only be
|
||||
// expressed on the restricted token: a principal token cannot carry the
|
||||
// synthetic offline-marker SID the WFP filters matched. Dual-role provisioning
|
||||
// removed that limit by giving each workspace an offline account whose real
|
||||
// group membership the same filters match, so deny mode now runs on a
|
||||
// principal exactly as allow mode does. Leaving the warning would have doctor
|
||||
// telling operators their reads are unconfined while they are confined.
|
||||
//
|
||||
// Naming the role rather than just saying "active" is the useful part. The two
|
||||
// accounts differ only in that membership, so an operator debugging network
|
||||
// behaviour needs to know which one this workspace picks.
|
||||
if setupConfig.PrincipalOptIn {
|
||||
// Asked of the shared rule rather than re-derived here, so doctor and the
|
||||
// runtime cannot disagree about which account a command runs as. Two
|
||||
// copies drifting is what produced the wrong report this replaces.
|
||||
role := sandbox.WindowsSandboxPrincipalRoleForNetwork(profile.Network.Mode)
|
||||
// SETUP IS CURRENT IS NOT THE SAME AS THE PRINCIPAL IS LIVE, and this used
|
||||
// to claim the second from the first. Marker validation compares serialized
|
||||
// plans and hashes; it names no account. Delete the selected account or its
|
||||
// secret, or drop the offline account out of ZeroSandboxOffline, and the
|
||||
// marker still validates while the runtime either falls back to the weaker
|
||||
// restricted-token path or fails outright. Doctor asserting read
|
||||
// confinement in that state is worse than saying nothing, because it is the
|
||||
// surface an operator checks precisely when they are unsure.
|
||||
//
|
||||
// Verifying liveness needs the account, a usable secret and (for offline)
|
||||
// the group membership, all Windows-only live queries this package cannot
|
||||
// make. So the claim is narrowed to what the marker actually proves, and
|
||||
// the role is still named because that part IS derived rather than assumed
|
||||
// and is what an operator debugging network behaviour needs.
|
||||
result := check("sandbox.principal", "Sandbox principal", StatusPass,
|
||||
fmt.Sprintf("Sandbox principal setup is current; a command in this workspace selects the %s principal. Setup state does not confirm the account is usable.", role), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"optIn": true,
|
||||
// Deliberately not "active": nothing here checked the account, the
|
||||
// secret or the group membership.
|
||||
"setupCurrent": true,
|
||||
"role": role,
|
||||
"networkMode": string(profile.Network.Mode),
|
||||
})
|
||||
return &result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// A VALID MARKER IS NOT A LIVE PRINCIPAL. Marker validation compares serialized
|
||||
// plans and hashes and names no account, so it stays valid after the selected
|
||||
// account is deleted, its secret is removed, or the offline account is dropped
|
||||
// out of ZeroSandboxOffline. In each of those states the runtime falls back to
|
||||
// the restricted-token path with no read confinement, or fails outright.
|
||||
//
|
||||
// Doctor is the surface an operator checks precisely when they are unsure, so
|
||||
// claiming read confinement it has not verified is worse than reporting less.
|
||||
// This pins the narrower claim: setup is current, the role is named because it
|
||||
// is derived rather than assumed, and nothing asserts the account is usable.
|
||||
func TestPrincipalCheckDoesNotClaimAnUnverifiedPrincipalIsActive(t *testing.T) {
|
||||
sandboxHome := t.TempDir()
|
||||
workspaceRoot := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", sandboxHome)
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_IDENTITY", "1")
|
||||
|
||||
sandboxConfig := config.SandboxConfig{}
|
||||
scope, err := sandbox.NewScope(workspaceRoot, sandboxConfig.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
|
||||
|
||||
// Mirrors what windowsSandboxSetupCheck derives, so the marker it writes is
|
||||
// the one the check will validate against. Nothing here provisions an
|
||||
// account, which is the whole point: the marker is valid and the principal
|
||||
// does not exist.
|
||||
if _, err := sandbox.WriteWindowsSandboxSetupMarker(sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}),
|
||||
PrincipalOptIn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
|
||||
result := windowsSandboxSetupCheck("windows", sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}, workspaceRoot, sandboxConfig)
|
||||
if result == nil {
|
||||
t.Fatal("expected a principal check once the marker validates and the opt-in is set")
|
||||
}
|
||||
if result.ID != "sandbox.principal" {
|
||||
t.Fatalf("check = %q (%s): the marker did not validate, so this test is not exercising the principal branch", result.ID, result.Message)
|
||||
}
|
||||
|
||||
lowered := strings.ToLower(result.Message)
|
||||
for _, claim := range []string{"is active", "commands run as"} {
|
||||
if strings.Contains(lowered, claim) {
|
||||
t.Errorf("doctor asserts %q about a principal it never checked: %q", claim, result.Message)
|
||||
}
|
||||
}
|
||||
if active, ok := result.Details["active"]; ok {
|
||||
t.Errorf("details carry active=%v, which nothing here verified", active)
|
||||
}
|
||||
|
||||
// The role is still worth reporting: it is derived from the network mode by
|
||||
// the shared rule rather than assumed, and it is what an operator debugging
|
||||
// network behaviour needs.
|
||||
if role, ok := result.Details["role"]; !ok || strings.TrimSpace(role.(string)) == "" {
|
||||
t.Errorf("the selected role should still be reported: %#v", result.Details)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,7 @@ func TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering(t *testing.T) {
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
|
||||
if err := RefreshModelsDevCache(t.Context()); err == nil {
|
||||
t.Fatal("bad body must return an error")
|
||||
@@ -211,6 +212,9 @@ func TestCachedModelsDevProvidersIgnoresStaleCache(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
// The variable turns the overlay off too, which would pass this test
|
||||
// without the staleness check ever running.
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
EnableModelsDevOverlay()
|
||||
@@ -226,6 +230,9 @@ func TestCachedModelsDevProvidersRequiresOptIn(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
// As above: with the variable set, a nil result says nothing about the
|
||||
// opt-in.
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
|
||||
@@ -242,6 +249,7 @@ func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
EnableModelsDevOverlay()
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// secretBytes is the AES-256 key length kept in the per-user secret file.
|
||||
secretBytes = 32
|
||||
|
||||
secretRetryAttempts = 500
|
||||
secretRetryDelay = 2 * time.Millisecond
|
||||
)
|
||||
|
||||
// aesGCMCrypter encrypts the token file at rest with AES-256-GCM under a
|
||||
// per-user random secret persisted (0600) beside the token file. The on-disk
|
||||
// blob is nonce || ciphertext; GCM provides confidentiality AND tamper
|
||||
// detection, so a corrupted/forged file fails closed on open. This is the
|
||||
// opt-in "encrypted-file" storage backend; the default backend writes the
|
||||
// 0600 plaintext JSON unchanged.
|
||||
type aesGCMCrypter struct {
|
||||
secretPath string
|
||||
}
|
||||
|
||||
func newAESGCMCrypter(secretPath string) *aesGCMCrypter {
|
||||
return &aesGCMCrypter{secretPath: secretPath}
|
||||
}
|
||||
|
||||
// aead loads (or, when create is set, generates) the secret and returns the GCM
|
||||
// AEAD. open passes create=false so a missing secret is a hard error rather than
|
||||
// silently minting a new key that could never decrypt the existing file.
|
||||
func (c *aesGCMCrypter) aead(create bool) (cipher.AEAD, error) {
|
||||
secret, err := loadOrCreateSecret(c.secretPath, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: build cipher: %w", err)
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// seal encrypts plaintext, prefixing a fresh random nonce. It creates the secret
|
||||
// on first use.
|
||||
func (c *aesGCMCrypter) seal(plaintext []byte) ([]byte, error) {
|
||||
gcm, err := c.aead(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("oauth: generate nonce: %w", err)
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// open decrypts a nonce||ciphertext blob, failing closed on a missing secret,
|
||||
// a short blob, or a failed authentication tag (tampering / wrong key).
|
||||
func (c *aesGCMCrypter) open(blob []byte) ([]byte, error) {
|
||||
gcm, err := c.aead(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blob) < gcm.NonceSize() {
|
||||
return nil, errors.New("oauth: encrypted token file is too short")
|
||||
}
|
||||
nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: decrypt token file (wrong secret or tampered): %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// loadOrCreateSecret reads the 32-byte secret at path. When create is set and
|
||||
// the file is absent, it generates a random secret and creates the file
|
||||
// atomically (0600). A wrong-sized existing secret fails closed (corruption).
|
||||
func loadOrCreateSecret(path string, create bool) ([]byte, error) {
|
||||
if data, err := readSecretFileRetry(path); err == nil {
|
||||
return data, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if !create {
|
||||
return nil, fmt.Errorf("oauth: token secret %s is missing; cannot decrypt the token file", path)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return createSecretFile(path)
|
||||
}
|
||||
|
||||
func createSecretFile(path string) ([]byte, error) {
|
||||
lockPath := path + ".lock"
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
|
||||
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_ = lock.Close()
|
||||
defer os.Remove(lockPath)
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
return data, nil
|
||||
} else if !errors.Is(rerr, os.ErrNotExist) {
|
||||
return nil, rerr
|
||||
}
|
||||
return writeNewSecretFile(path)
|
||||
}
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry too — mirroring acquireFileLock in lock.go —
|
||||
// otherwise concurrent secret creation spuriously fails on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
||||
}
|
||||
// Remember the lock-creation error itself rather than a subsequent
|
||||
// "secret file doesn't exist yet" read error -- otherwise a real
|
||||
// contention/ACL problem is masked by the expected-while-waiting
|
||||
// ErrNotExist once the retries are exhausted.
|
||||
lastErr = err
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
return data, nil
|
||||
}
|
||||
time.Sleep(secretRetryDelay)
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
|
||||
}
|
||||
|
||||
func writeNewSecretFile(path string) ([]byte, error) {
|
||||
secret := make([]byte, secretBytes)
|
||||
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
|
||||
return nil, fmt.Errorf("oauth: generate token secret: %w", err)
|
||||
}
|
||||
// The encryption key is published exactly like the token blob: through the
|
||||
// protected per-store directory, under a random name a sandboxed process
|
||||
// cannot guess, wait for, or rename away.
|
||||
tmp, tmpPath, err := createPublicationFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: create token secret temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
if _, werr := tmp.Write(secret); werr != nil {
|
||||
_ = tmp.Close()
|
||||
return nil, fmt.Errorf("oauth: write token secret: %w", werr)
|
||||
}
|
||||
if cerr := tmp.Close(); cerr != nil {
|
||||
return nil, fmt.Errorf("oauth: write token secret: %w", cerr)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return nil, fmt.Errorf("oauth: publish token secret: %w", err)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func readSecretFileRetry(path string) ([]byte, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
|
||||
data, err := readSecretFile(path)
|
||||
if !isTransientSecretAccessError(err) {
|
||||
return data, err
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(secretRetryDelay)
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
|
||||
}
|
||||
|
||||
// readSecretFile reads and validates the secret at path, returning a wrapped
|
||||
// os.ErrNotExist when it is absent so callers can branch on creation.
|
||||
func readSecretFile(path string) ([]byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: read token secret: %w", err)
|
||||
}
|
||||
if len(data) != secretBytes {
|
||||
return nil, fmt.Errorf("oauth: token secret at %s has unexpected size %d", path, len(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,103 +1,13 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadOrCreateSecretConcurrentConverges(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tok.json.secret")
|
||||
const n = 16
|
||||
secrets := make([][]byte, n)
|
||||
errs := make([]error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
secrets[i], errs[i] = loadOrCreateSecret(path, true)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
// Exactly one creator wins; every racer must converge on the same on-disk
|
||||
// secret rather than reading a half-published file or orphaning its own.
|
||||
for i := 0; i < n; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("goroutine %d: %v", i, errs[i])
|
||||
}
|
||||
if !bytes.Equal(secrets[i], secrets[0]) {
|
||||
t.Fatalf("goroutine %d got a divergent secret; concurrent create did not converge", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESGCMCrypterRoundTripAndTamper(t *testing.T) {
|
||||
secretPath := filepath.Join(t.TempDir(), "tok.json.secret")
|
||||
c := newAESGCMCrypter(secretPath)
|
||||
plaintext := []byte(`{"schemaVersion":1,"tokens":{}}`)
|
||||
blob, err := c.seal(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
if bytes.Contains(blob, plaintext) {
|
||||
t.Fatal("sealed blob must not contain the plaintext")
|
||||
}
|
||||
got, err := c.open(blob)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Fatalf("round-trip mismatch: %q", got)
|
||||
}
|
||||
// Tamper: flipping any byte breaks the GCM tag.
|
||||
tampered := append([]byte(nil), blob...)
|
||||
tampered[len(tampered)-1] ^= 0xff
|
||||
if _, err := c.open(tampered); err == nil {
|
||||
t.Fatal("open must reject a tampered blob (GCM auth)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateSecret(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tok.json.secret")
|
||||
// Missing + create=false => fail closed (can't decrypt without the secret).
|
||||
if _, err := loadOrCreateSecret(path, false); err == nil {
|
||||
t.Fatal("missing secret with create=false must error")
|
||||
}
|
||||
secret, err := loadOrCreateSecret(path, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if len(secret) != secretBytes {
|
||||
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
|
||||
}
|
||||
// Stable across reads.
|
||||
again, err := loadOrCreateSecret(path, false)
|
||||
if err != nil || !bytes.Equal(again, secret) {
|
||||
t.Fatalf("secret not stable: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat secret: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("secret file mode = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
// Wrong-sized secret fails closed.
|
||||
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
|
||||
t.Fatalf("corrupt secret: %v", err)
|
||||
}
|
||||
if _, err := loadOrCreateSecret(path, true); err == nil {
|
||||
t.Fatal("wrong-sized secret must error")
|
||||
}
|
||||
}
|
||||
|
||||
func newEncryptedStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
@@ -202,3 +112,36 @@ func TestEncryptedStoreMissingSecretFailsClosed(t *testing.T) {
|
||||
t.Fatal("missing secret must fail closed, not return empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptedStoreReclaimsStaleSecretLock reproduces a crash while the
|
||||
// encrypted-file backend was creating its AES key. The key-creation lock is an
|
||||
// O_EXCL ownership marker, not a kernel lock, so a process killed between
|
||||
// creating and removing it leaves a file that every later Save waits on and
|
||||
// then rejects forever. A stale marker must be reclaimed instead.
|
||||
func TestEncryptedStoreReclaimsStaleSecretLock(t *testing.T) {
|
||||
s, path := newEncryptedStore(t)
|
||||
lockPath := path + ".secret.lock"
|
||||
if err := os.WriteFile(lockPath, []byte("crashed-holder"), 0o600); err != nil {
|
||||
t.Fatalf("write stale secret lock: %v", err)
|
||||
}
|
||||
stale := time.Now().Add(-2 * time.Minute)
|
||||
if err := os.Chtimes(lockPath, stale, stale); err != nil {
|
||||
t.Fatalf("backdate stale secret lock: %v", err)
|
||||
}
|
||||
|
||||
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "after-crash"}); err != nil {
|
||||
t.Fatalf("Save must reclaim a stale secret lock, got: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(lockPath); err == nil {
|
||||
t.Fatal("stale secret lock was not cleaned up")
|
||||
}
|
||||
|
||||
s2, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
token, ok, err := s2.Load(ProviderKey("demo"))
|
||||
if err != nil || !ok || token.AccessToken != "after-crash" {
|
||||
t.Fatalf("Load after stale-lock recovery = %+v ok=%v err=%v", token, ok, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileBlobWriteSyncsFileAndDirectory(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
blob := fileBlob{path: path}
|
||||
origFile, origDir := syncOAuthFileFn, syncOAuthDirFn
|
||||
t.Cleanup(func() {
|
||||
syncOAuthFileFn, syncOAuthDirFn = origFile, origDir
|
||||
})
|
||||
fileSynced, dirSynced := false, false
|
||||
syncOAuthFileFn = func(file *os.File) error {
|
||||
fileSynced = true
|
||||
return origFile(file)
|
||||
}
|
||||
syncOAuthDirFn = func(dir string) error {
|
||||
dirSynced = true
|
||||
return origDir(dir)
|
||||
}
|
||||
|
||||
if err := blob.write([]byte("payload")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if !fileSynced {
|
||||
t.Fatal("token blob was not synced before rename")
|
||||
}
|
||||
if !dirSynced {
|
||||
t.Fatal("token store directory was not synced after rename")
|
||||
}
|
||||
data, ok, err := blob.read()
|
||||
if err != nil || !ok || !bytes.Equal(data, []byte("payload")) {
|
||||
t.Fatalf("read = %q ok=%v err=%v", data, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileBlobWriteDirectorySyncFailureLeavesPublishedBlob pins the post-rename
|
||||
// path: the directory barrier runs after the atomic replace, so a barrier
|
||||
// failure reports an error but must not hide the already-published token.
|
||||
func TestFileBlobWriteDirectorySyncFailureLeavesPublishedBlob(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
blob := fileBlob{path: path}
|
||||
if err := blob.write([]byte("old")); err != nil {
|
||||
t.Fatalf("seed write: %v", err)
|
||||
}
|
||||
|
||||
origDir := syncOAuthDirFn
|
||||
t.Cleanup(func() { syncOAuthDirFn = origDir })
|
||||
syncErr := errors.New("directory sync failed")
|
||||
syncOAuthDirFn = func(string) error { return syncErr }
|
||||
|
||||
err := blob.write([]byte("new"))
|
||||
if !errors.Is(err, syncErr) {
|
||||
t.Fatalf("write error = %v, want it to wrap %v", err, syncErr)
|
||||
}
|
||||
data, ok, err := blob.read()
|
||||
if err != nil || !ok || !bytes.Equal(data, []byte("new")) {
|
||||
t.Fatalf("published blob was not retained: data=%q ok=%v err=%v", data, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileBlobWriteSyncFailurePreservesPrevious(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
blob := fileBlob{path: path}
|
||||
if err := blob.write([]byte("old")); err != nil {
|
||||
t.Fatalf("seed write: %v", err)
|
||||
}
|
||||
|
||||
origFile := syncOAuthFileFn
|
||||
t.Cleanup(func() { syncOAuthFileFn = origFile })
|
||||
syncErr := errors.New("sync failed")
|
||||
syncOAuthFileFn = func(*os.File) error { return syncErr }
|
||||
|
||||
err := blob.write([]byte("new"))
|
||||
if !errors.Is(err, syncErr) {
|
||||
t.Fatalf("write error = %v, want it to wrap %v", err, syncErr)
|
||||
}
|
||||
data, ok, err := blob.read()
|
||||
if err != nil || !ok || !bytes.Equal(data, []byte("old")) {
|
||||
t.Fatalf("previous blob was not preserved: data=%q ok=%v err=%v", data, ok, err)
|
||||
}
|
||||
entries, err := os.ReadDir(PublicationDir(path))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir publication dir: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), "publish-") {
|
||||
t.Fatalf("failed write left publication temp %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package oauth
|
||||
|
||||
func isTransientSecretAccessError(error) bool {
|
||||
return false
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func isTransientSecretAccessError(err error) bool {
|
||||
return errors.Is(err, windows.ERROR_SHARING_VIOLATION) ||
|
||||
errors.Is(err, windows.ERROR_LOCK_VIOLATION)
|
||||
}
|
||||
+53
-9
@@ -18,7 +18,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/fsutil"
|
||||
"github.com/Twigpine/zero/internal/keyring"
|
||||
"github.com/Twigpine/zero/internal/securefile"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -155,7 +157,7 @@ const (
|
||||
// is AES-256-GCM ciphertext at rest.
|
||||
type Store struct {
|
||||
blob blobStore
|
||||
crypter *aesGCMCrypter // nil => plaintext blob
|
||||
crypter *securefile.Crypter // nil => plaintext blob
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
@@ -244,8 +246,15 @@ func NewStore(options StoreOptions) (*Store, error) {
|
||||
return nil, err
|
||||
}
|
||||
// The file blob holds AES-256-GCM ciphertext; the per-user secret lives in
|
||||
// a sibling ".secret" file (see encrypt.go).
|
||||
return &Store{blob: fileBlob{path: path}, crypter: newAESGCMCrypter(path + ".secret"), now: now}, nil
|
||||
// a sibling ".secret" file. The secret publishes through the sandbox-denied
|
||||
// ".secret.publish" directory so a same-user sandboxed process cannot race
|
||||
// the plaintext key name.
|
||||
secretPath := path + ".secret"
|
||||
return &Store{
|
||||
blob: fileBlob{path: path},
|
||||
crypter: securefile.NewCrypterWithPublicationDir(secretPath, PublicationDir(secretPath)),
|
||||
now: now,
|
||||
}, nil
|
||||
case "keyring":
|
||||
kr := options.Keyring
|
||||
if kr == nil {
|
||||
@@ -405,7 +414,7 @@ func (s *Store) readState() (storeFile, error) {
|
||||
}
|
||||
if s.crypter != nil {
|
||||
// Encrypted backend: the blob is AES-256-GCM ciphertext, not JSON.
|
||||
data, err = s.crypter.open(data)
|
||||
data, err = s.crypter.Open(data)
|
||||
if err != nil {
|
||||
return storeFile{}, fmt.Errorf("oauth: decrypt token store at %s: %w", s.blob.location(), err)
|
||||
}
|
||||
@@ -437,9 +446,9 @@ func (s *Store) writeState(state storeFile) error {
|
||||
// writes opaque ciphertext instead.
|
||||
payload := append(data, '\n')
|
||||
if s.crypter != nil {
|
||||
payload, err = s.crypter.seal(data)
|
||||
payload, err = s.crypter.Seal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("oauth: encrypt token store: %w", err)
|
||||
}
|
||||
}
|
||||
return s.blob.write(payload)
|
||||
@@ -511,6 +520,31 @@ func (b fileBlob) read() ([]byte, bool, error) {
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// Sync seams allow tests to inject persistence failures at each barrier.
|
||||
var (
|
||||
syncOAuthFileFn = (*os.File).Sync
|
||||
syncOAuthDirFn = syncOAuthDir
|
||||
)
|
||||
|
||||
// syncOAuthDir fsyncs the directory that now holds the published token store.
|
||||
// Windows cannot sync directory handles, so the rename is best-effort durable
|
||||
// there. A directory the user can write but not read cannot be opened for
|
||||
// syncing either; that is also best effort, because the write it follows
|
||||
// already succeeded. Every other failure is reported.
|
||||
func syncOAuthDir(dir string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errors.Join(d.Sync(), d.Close())
|
||||
}
|
||||
|
||||
func (b fileBlob) write(data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
|
||||
return err
|
||||
@@ -524,10 +558,20 @@ func (b fileBlob) write(data []byte) error {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
if err := syncOAuthFileFn(temp); err != nil {
|
||||
_ = temp.Close()
|
||||
return fmt.Errorf("oauth: sync token store: %w", err)
|
||||
}
|
||||
return os.Rename(tempPath, b.path)
|
||||
if err := temp.Close(); err != nil {
|
||||
return fmt.Errorf("oauth: write token store: %w", err)
|
||||
}
|
||||
if err := fsutil.RenameWithRetry(tempPath, b.path, nil); err != nil {
|
||||
return fmt.Errorf("oauth: publish token store: %w", err)
|
||||
}
|
||||
if err := syncOAuthDirFn(filepath.Dir(b.path)); err != nil {
|
||||
return fmt.Errorf("oauth: sync token store directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublicationDirSuffix names the per-store directory a token store publishes new
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) error {
|
||||
func EnsurePrivateDir(path string) error {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) error {
|
||||
func EnsurePrivateDir(path string) error {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -22,7 +22,13 @@ func ensurePrivateDir(path string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close(parentFD)
|
||||
// THE DESCRIPTOR THIS FUNCTION OWNS CHANGES ON EVERY ITERATION. The walk
|
||||
// closes parentFD and replaces it with the child it just opened, so a defer
|
||||
// that captured the first number would close a descriptor this function no
|
||||
// longer owns (or one the kernel has since reused) and leave the last one it
|
||||
// does own open. Close whatever is current when we leave, exactly once, on
|
||||
// every path.
|
||||
defer func() { unix.Close(parentFD) }()
|
||||
for index, component := range components {
|
||||
if component == "" {
|
||||
continue
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
package peermsg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
|
||||
@@ -18,7 +21,7 @@ func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(link); err == nil {
|
||||
if err := EnsurePrivateDir(link); err == nil {
|
||||
t.Fatal("expected symlink runtime directory to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -33,7 +36,67 @@ func TestEnsurePrivateDirRejectsSymlinkedParent(t *testing.T) {
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
t.Fatal("expected symlinked parent to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// THE DESCRIPTOR THE WALK ENDS ON HAS TO BE CLOSED TOO.
|
||||
//
|
||||
// EnsurePrivateDir walks the path one component at a time, closing each parent
|
||||
// as it opens the child. A deferred close that captured the first descriptor
|
||||
// closed a number this function no longer owned and left the last one open, so
|
||||
// every fallback-runtime preparation leaked a descriptor. The lowest free
|
||||
// descriptor is the watermark: dup(0) hands it back, and a leak per cycle walks
|
||||
// it upward. Several depths, because the kernel reuses numbers and a single
|
||||
// shallow fixture can hide the leak by accident.
|
||||
func TestEnsurePrivateDirClosesTheDescriptorItOwns(t *testing.T) {
|
||||
lowestFree := func() int {
|
||||
fd, err := unix.Dup(0)
|
||||
if err != nil {
|
||||
t.Fatalf("dup: %v", err)
|
||||
}
|
||||
unix.Close(fd)
|
||||
return fd
|
||||
}
|
||||
// The physical path: on macOS t.TempDir() sits under /var, which is itself
|
||||
// a symlink to /private/var, and this walk refuses a symlink component by
|
||||
// design. Production resolves its temp root the same way before calling.
|
||||
base, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("resolve temp dir: %v", err)
|
||||
}
|
||||
for _, depth := range []int{1, 3, 6} {
|
||||
parts := []string{base}
|
||||
for i := 0; i < depth; i++ {
|
||||
parts = append(parts, fmt.Sprintf("level%d", i))
|
||||
}
|
||||
target := filepath.Join(parts...)
|
||||
|
||||
before := lowestFree()
|
||||
for cycle := 0; cycle < 40; cycle++ {
|
||||
if err := EnsurePrivateDir(target); err != nil {
|
||||
t.Fatalf("depth %d cycle %d: %v", depth, cycle, err)
|
||||
}
|
||||
}
|
||||
if after := lowestFree(); after != before {
|
||||
t.Fatalf("depth %d: lowest free descriptor moved from %d to %d over 40 successful walks, so each one leaks", depth, before, after)
|
||||
}
|
||||
|
||||
// And the failing walk: a symlink component is refused partway down.
|
||||
linked := filepath.Join(base, fmt.Sprintf("linked%d", depth))
|
||||
if err := os.Symlink(target, linked); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
failing := filepath.Join(linked, "below")
|
||||
before = lowestFree()
|
||||
for cycle := 0; cycle < 40; cycle++ {
|
||||
if err := EnsurePrivateDir(failing); err == nil {
|
||||
t.Fatalf("depth %d: a symlinked component was not refused", depth)
|
||||
}
|
||||
}
|
||||
if after := lowestFree(); after != before {
|
||||
t.Fatalf("depth %d: lowest free descriptor moved from %d to %d over 40 refused walks", depth, before, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) (resultErr error) {
|
||||
func EnsurePrivateDir(path string) (resultErr error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestEnsurePrivateDirAppliesOwnerOnlyProtectedDACL(t *testing.T) {
|
||||
if !windowsDirectoryDACLContains(t, path, worldSID) {
|
||||
t.Fatal("test setup did not grant the broad Everyone ACE")
|
||||
}
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
if err := EnsurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
descriptor, err := windows.GetNamedSecurityInfo(
|
||||
@@ -149,7 +149,7 @@ func windowsDirectoryDACLContains(t *testing.T, path string, wanted *windows.SID
|
||||
|
||||
func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private")
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
if err := EnsurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL|windows.WRITE_DAC)
|
||||
@@ -172,7 +172,7 @@ func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
|
||||
|
||||
func TestSecurePrivateDirectoryReportsDACLWriteFailure(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private")
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
if err := EnsurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL)
|
||||
@@ -206,7 +206,7 @@ func TestEnsurePrivateDirRejectsWindowsReparseParent(t *testing.T) {
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("cannot create Windows directory symlink: %v", err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
t.Fatal("expected reparse-point parent to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func TestEnsurePrivateDirRejectsWindowsFileComponent(t *testing.T) {
|
||||
if err := os.WriteFile(file, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(file, "peers")); err == nil {
|
||||
if err := EnsurePrivateDir(filepath.Join(file, "peers")); err == nil {
|
||||
t.Fatal("expected file path component to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func New(options Options) (*Service, error) {
|
||||
}
|
||||
|
||||
// canonicalRuntimePath normalizes aliases in the existing prefix. It does not
|
||||
// establish a security boundary; ensurePrivateDir validates that boundary when
|
||||
// establish a security boundary; EnsurePrivateDir validates that boundary when
|
||||
// the service starts.
|
||||
func canonicalRuntimePath(path string) (string, error) {
|
||||
missing := make([]string, 0, 4)
|
||||
@@ -219,10 +219,10 @@ func (service *Service) Start(handler Handler) error {
|
||||
if service.closed {
|
||||
return errors.New("peer messaging: service is closed")
|
||||
}
|
||||
if err := ensurePrivateDir(service.root); err != nil {
|
||||
if err := EnsurePrivateDir(service.root); err != nil {
|
||||
return fmt.Errorf("peer messaging: create runtime directory: %w", err)
|
||||
}
|
||||
if err := ensurePrivateDir(service.registryDir()); err != nil {
|
||||
if err := EnsurePrivateDir(service.registryDir()); err != nil {
|
||||
return fmt.Errorf("peer messaging: create registry: %w", err)
|
||||
}
|
||||
endpoint, err := service.transport.Endpoint(service.root, service.nonce, service.pid)
|
||||
|
||||
@@ -44,7 +44,7 @@ func canonicalPrivateDir(path string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := ensurePrivateDir(canonical); err != nil {
|
||||
if err := EnsurePrivateDir(canonical); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonical, nil
|
||||
|
||||
+241
-31
@@ -112,6 +112,42 @@ var textSecretPrefixes = [][]string{
|
||||
{"eyJ"},
|
||||
}
|
||||
|
||||
type secretTrigger struct {
|
||||
literal string
|
||||
// shape indexes the anchoredSecretPatterns entry the trigger belongs to,
|
||||
// so a cut can chain the bytes after it against the shape the trigger
|
||||
// actually starts.
|
||||
shape int
|
||||
}
|
||||
|
||||
// secretTriggers flattens textSecretPrefixes into one sorted list for the
|
||||
// swallowed-trigger search below. Sorted so cuts are discovered earliest-first
|
||||
// regardless of which pattern list they came from.
|
||||
var secretTriggers = func() []secretTrigger {
|
||||
pairs := []secretTrigger{}
|
||||
for shape, prefixes := range textSecretPrefixes {
|
||||
for _, prefix := range prefixes {
|
||||
pairs = append(pairs, secretTrigger{literal: prefix, shape: shape})
|
||||
}
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool { return pairs[i].literal < pairs[j].literal })
|
||||
return pairs
|
||||
}()
|
||||
|
||||
// longestTrigger bounds how far past a swallowed-scored span the trigger
|
||||
// inspection may need to look: an occurrence whose last bytes run past the
|
||||
// span end (a greedy body stops at a byte outside its class, and several
|
||||
// triggers end in one).
|
||||
var longestTrigger = func() int {
|
||||
longest := 0
|
||||
for _, trigger := range secretTriggers {
|
||||
if len(trigger.literal) > longest {
|
||||
longest = len(trigger.literal)
|
||||
}
|
||||
}
|
||||
return longest
|
||||
}()
|
||||
|
||||
// anchoredSecretPatterns mirrors textSecretPatterns with the leading \b
|
||||
// replaced by \A, so each shape can be tested at an exact byte offset. A
|
||||
// credential that starts exactly where a redacted span ends (two AWS keys
|
||||
@@ -129,52 +165,226 @@ var anchoredSecretPatterns = func() []*regexp.Regexp {
|
||||
return out
|
||||
}()
|
||||
|
||||
// redactAdjacent redacts every match of pattern, plus any match of anchored
|
||||
// that begins exactly where a redacted span ends. This catches credentials
|
||||
// glued end to end (AKIA...AKIA...) without weakening the leading-boundary
|
||||
// rule elsewhere: a mid-word occurrence that does not abut a redacted span
|
||||
// still does not match. Chained runs (three or more glued credentials) are
|
||||
// followed to the end.
|
||||
// A credential's body can run into the trigger prefix of the NEXT one when
|
||||
// both are glued end to end. For fixed-length bodies this cannot happen — the
|
||||
// `{16}` stops the body exactly where the next AKIA...AKIA... key begins,
|
||||
// which is the case the anchored end-chaining always covered. An unbounded
|
||||
// body ({36,} on a github key, {20,} on an API key) swallows whichever trigger
|
||||
// bytes sit inside its class — "ghp" after a github key is all lowercase
|
||||
// letters — so the first match ends PAST the next credential's trigger, and a
|
||||
// scan that re-anchors at the span end finds nothing: the second credential
|
||||
// is never redacted.
|
||||
//
|
||||
// redactedSpan is one region of a redaction: the slice s[start:end] and the
|
||||
// bytes to write in its place. Chained runs can link shapes together, so a
|
||||
// span says which shape produced it rather than assuming the caller's pattern
|
||||
// covered it.
|
||||
type redactedSpan struct {
|
||||
start int
|
||||
end int
|
||||
// text replaces s[start:end]. Every entry in textSecretPatterns captures
|
||||
// nothing, so this is the replacement itself unless a custom replacement
|
||||
// template references a group; anchoring the expansion to the shape that
|
||||
// matched keeps that working for cross-shape chains.
|
||||
text string
|
||||
}
|
||||
|
||||
// redactAdjacent redacts every match of pattern, plus every credential glued
|
||||
// into the runs those matches start. Runs follow three loss-of-boundary
|
||||
// mechanisms: end-chaining (a credential glued exactly at the previous span's
|
||||
// end, the AKIA...AKIA... case), all-shape chaining (a glued credential of a
|
||||
// different shape), and the swallowed-trigger cut (one credential's greedy
|
||||
// body runs into the next one's trigger prefix, hiding it from any scan that
|
||||
// re-anchors at a span end). A run ends at the first byte boundary where no
|
||||
// shape matches; a mid-word lookalike never matches and never starts one.
|
||||
func redactAdjacent(s string, pattern, anchored *regexp.Regexp, replacement string) string {
|
||||
spans := pattern.FindAllStringIndex(s, -1)
|
||||
// Bound the outer loop to the original match count. The inner loop
|
||||
// appends chained spans to the same slice; re-reading len(spans) would
|
||||
// re-chain every appended span, growing the span count (and the
|
||||
// FindStringIndex calls) exponentially on long glued runs.
|
||||
n := len(spans)
|
||||
for i := 0; i < n; i++ {
|
||||
end := spans[i][1]
|
||||
for end < len(s) {
|
||||
loc := anchored.FindStringIndex(s[end:])
|
||||
if loc == nil || loc[0] != 0 || loc[1] <= 0 {
|
||||
break
|
||||
}
|
||||
newEnd := end + loc[1]
|
||||
spans = append(spans, []int{end, newEnd})
|
||||
end = newEnd
|
||||
}
|
||||
plain := pattern.FindAllStringIndex(s, -1)
|
||||
if len(plain) == 0 {
|
||||
return s
|
||||
}
|
||||
// Bound the outer loop to the original match count. Every span collects
|
||||
// its own run; re-reading len(plain) after appends would re-run every
|
||||
// appended credential and grow the span count (with the anchored scans
|
||||
// each drags) exponentially on long glued runs.
|
||||
n := len(plain)
|
||||
spans := make([]redactedSpan, 0, n+4)
|
||||
for i := 0; i < n; i++ {
|
||||
spans = appendCredentialRun(s, plain[i][0], plain[i][1], replacement, spans)
|
||||
}
|
||||
return writeRedactedSpans(s, spans)
|
||||
}
|
||||
|
||||
func writeRedactedSpans(s string, spans []redactedSpan) string {
|
||||
if len(spans) == 0 {
|
||||
return s
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i][0] < spans[j][0] })
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
|
||||
var out strings.Builder
|
||||
pos := 0
|
||||
for _, sp := range spans {
|
||||
if sp[0] < pos {
|
||||
if sp.start < pos {
|
||||
continue // overlapping span, already covered
|
||||
}
|
||||
out.WriteString(s[pos:sp[0]])
|
||||
// s[sp[0]:sp[1]] is a match (anchored spans match at \b when taken
|
||||
// alone), so ReplaceAllString expands $ references exactly as the
|
||||
// plain loop below would.
|
||||
out.WriteString(pattern.ReplaceAllString(s[sp[0]:sp[1]], replacement))
|
||||
pos = sp[1]
|
||||
out.WriteString(s[pos:sp.start])
|
||||
out.WriteString(sp.text)
|
||||
pos = sp.end
|
||||
}
|
||||
out.WriteString(s[pos:])
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// appendCredentialRun follows the path that begins exactly at start, whose
|
||||
// first plain match ended at end. Every step finds the shape that matches at
|
||||
// the current boundary, tries to re-bind that match at a swallowed trigger,
|
||||
// and otherwise consumes the whole match; the run ends where no shape does.
|
||||
func appendCredentialRun(s string, start, end int, replacement string, spans []redactedSpan) []redactedSpan {
|
||||
cur := start
|
||||
for cur < len(s) {
|
||||
shape, matchEnd := firstShapeAt(s, cur)
|
||||
if shape < 0 || matchEnd == cur {
|
||||
break
|
||||
}
|
||||
if cutStart, cut := earliestSwallowedCut(s, cur, matchEnd, shape); cut {
|
||||
// The bytes before the trigger are the credential that matched;
|
||||
// from the trigger on the run continues under the next shape.
|
||||
spans = append(spans, redactedSpan{start: cur, end: cutStart, text: shapeReplacement(shape, s[cur:cutStart], replacement)})
|
||||
cur = cutStart
|
||||
continue
|
||||
}
|
||||
spans = append(spans, redactedSpan{start: cur, end: matchEnd, text: shapeReplacement(shape, s[cur:matchEnd], replacement)})
|
||||
cur = matchEnd
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
func shapeReplacement(shape int, slice, replacement string) string {
|
||||
return anchoredSecretPatterns[shape].ReplaceAllString(slice, replacement)
|
||||
}
|
||||
|
||||
// firstShapeAt returns the first anchored shape that matches exactly at off,
|
||||
// with the end of its match. Shapes are tried in declaration order, so the
|
||||
// strict JWT form wins over the loose one; both redact the same bytes, so the
|
||||
// tie-break only decides which regex expanded the replacement.
|
||||
func firstShapeAt(s string, off int) (shape int, end int) {
|
||||
for shape, anchored := range anchoredSecretPatterns {
|
||||
loc := anchored.FindStringIndex(s[off:])
|
||||
if loc != nil && loc[0] == 0 && loc[1] > 0 {
|
||||
return shape, off + loc[1]
|
||||
}
|
||||
}
|
||||
return -1, off
|
||||
}
|
||||
|
||||
// earliestSwallowedCut searches the matched span [start, end) of shape for a
|
||||
// trigger literal that begins a valid credential right there: the head (the
|
||||
// bytes before it) still matches the shape as a whole, and the run chained
|
||||
// from the trigger reaches past the span. The earliest such trigger is the
|
||||
// cut a boundary at that byte would have produced.
|
||||
//
|
||||
// The head requirement and the past-the-end requirement keep the cut honest:
|
||||
// a trigger spelled mid-body that does not begin a credential leaves the span
|
||||
// alone, and a cut that stayed inside the original span recovers no bytes —
|
||||
// it would only split one redaction in two.
|
||||
//
|
||||
// The candidate set is the trigger occurrences inside the span, bounded by
|
||||
// the span plus one trigger length; every occurrence of every trigger in that
|
||||
// window is a candidate, earliest-first.
|
||||
func earliestSwallowedCut(s string, start, end, shape int) (int, bool) {
|
||||
if end-start <= 1 {
|
||||
return 0, false
|
||||
}
|
||||
lookahead := end + longestTrigger
|
||||
if lookahead > len(s) {
|
||||
lookahead = len(s)
|
||||
}
|
||||
window := s[start+1 : lookahead]
|
||||
|
||||
// A trigger occurrence whose bytes begin inside the span may extend past
|
||||
// its end: the greedy body stops at one byte outside its class, and
|
||||
// several triggers end in exactly such a byte ("ghp_"'s underscore,
|
||||
// "sk-ant-" plus a hyphen).
|
||||
anchored := anchoredSecretPatterns[shape]
|
||||
examined := 0
|
||||
for _, q := range triggerOccurrences(window, start+1, end) {
|
||||
if examined >= maxTriggerOccurrences {
|
||||
// Occurrences are earliest-first, so the ones past the cap are the
|
||||
// farthest into the span; a cut among them is missed and the
|
||||
// greedy span stands, as it did before the cut existed.
|
||||
break
|
||||
}
|
||||
examined++
|
||||
// The bytes before the trigger must still be a complete match of this
|
||||
// shape: a trigger spelling inside a body whose head does not
|
||||
// validate is prose-shaped, not a swallowed credential.
|
||||
loc := anchored.FindStringIndex(s[start:q])
|
||||
if loc == nil || loc[0] != 0 || loc[1] != q-start {
|
||||
continue
|
||||
}
|
||||
if runReach(s, q) > end {
|
||||
return q, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// maxTriggerOccurrences bounds how many trigger occurrences one span is
|
||||
// examined for, after sorting. Real glued credentials announce their trigger
|
||||
// within reach of the span's own start, so a distant lookalike run does not
|
||||
// get walked to its end; the split path makes the same trade ("at most two
|
||||
// passes and no more"). A cut found past the cap is missed and the greedy
|
||||
// span stands, as it did before the cut existed.
|
||||
const maxTriggerOccurrences = 64
|
||||
|
||||
// triggerOccurrences returns every occurrence of every trigger literal that
|
||||
// begins inside [windowStart, windowEnd), where window is the slice rooted at
|
||||
// windowStart that the caller bounded so occurrences could reach one trigger
|
||||
// length past its end. Earliest first, duplicates collapsed.
|
||||
func triggerOccurrences(window string, windowStart, windowEnd int) []int {
|
||||
occurrences := make([]int, 0, 4)
|
||||
for _, trigger := range secretTriggers {
|
||||
at := 0
|
||||
for {
|
||||
found := strings.Index(window[at:], trigger.literal)
|
||||
if found < 0 {
|
||||
break
|
||||
}
|
||||
q := windowStart + at + found
|
||||
if q >= windowEnd {
|
||||
break // later occurrences for this trigger start even later
|
||||
}
|
||||
occurrences = append(occurrences, q)
|
||||
at += found + 1
|
||||
}
|
||||
}
|
||||
sort.Ints(occurrences)
|
||||
// In-place deduplicate: duplicates come only from triggers shared between
|
||||
// shapes ("eyJ"), and zero is never an occurrence — the scan starts past it.
|
||||
unique := occurrences[:0]
|
||||
for i, q := range occurrences {
|
||||
if i > 0 && q == occurrences[i-1] {
|
||||
continue
|
||||
}
|
||||
unique = append(unique, q)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// runReach reports how far the credential run beginning exactly at off
|
||||
// extends: each boundary picks the first shape that matches there, until one
|
||||
// does not. The run is followed, not redacted; this is the shared-size check
|
||||
// that keeps a cut honest.
|
||||
func runReach(s string, off int) int {
|
||||
cur := off
|
||||
for cur < len(s) {
|
||||
_, matchEnd := firstShapeAt(s, cur)
|
||||
if matchEnd == cur {
|
||||
break
|
||||
}
|
||||
cur = matchEnd
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
var (
|
||||
privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`)
|
||||
jsonStringPattern = regexp.MustCompile(`("([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*)"([^"\\]*(?:\\.[^"\\]*)*)"`)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package redaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A GREEDY BODY SWALLOWS THE NEXT CREDENTIAL'S TRIGGER, SO THE END-CHAIN
|
||||
// NEVER SEES IT.
|
||||
//
|
||||
// The anchored chain re-binds a redacted span at its END; it can only catch a
|
||||
// credential that begins exactly where the previous one stops. That is true
|
||||
// for fixed-length bodies (AKIA{16} stops at the next key) and false for
|
||||
// unbounded ones: github's body class [A-Za-z0-9] happily eats the literal
|
||||
// "ghp" of the token glued behind it, so the first match ends past the second
|
||||
// trigger and the plain chain has nothing left to match. Every unbounded shape
|
||||
// below leaked its second credential before the swallowed-trigger cut.
|
||||
func TestRedactStringRedactsCredentialsSwallowedByAGluedRun(t *testing.T) {
|
||||
// Assembled so no secret-shaped literal appears in source.
|
||||
// gitleaks:allow -- synthetic redaction fixtures below
|
||||
firstGitHub := "ghp_" + strings.Repeat("a", 36)
|
||||
secondGitHub := "ghp_" + strings.Repeat("b", 36)
|
||||
|
||||
t.Run("two github tokens glued end to end", func(t *testing.T) {
|
||||
got := RedactString(firstGitHub+secondGitHub, Options{})
|
||||
if got != RedactedSecret+RedactedSecret {
|
||||
t.Fatalf("second token survived a glued github pair: got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two JWTs glued end to end", func(t *testing.T) {
|
||||
got := RedactString(jwtToken+otherJWT, Options{})
|
||||
if got != RedactedSecret+RedactedSecret {
|
||||
t.Fatalf("second token survived a glued JWT pair: got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("third github token in the same run", func(t *testing.T) {
|
||||
thirdGitHub := "ghp_" + strings.Repeat("c", 36)
|
||||
got := RedactString(firstGitHub+secondGitHub+thirdGitHub, Options{})
|
||||
if got != strings.Repeat(RedactedSecret, 3) {
|
||||
t.Fatalf("glued github run left residue between markers: got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// THE SWALLOWED TRIGGER CAN BELONG TO A DIFFERENT SHAPE. The chained run from
|
||||
// a cut must try every shape at each boundary, not only the one that matched
|
||||
// the swallowed span.
|
||||
//
|
||||
// Two outcomes are both fully redacted and the tests accept either. A github
|
||||
// body stops at the hyphen an anthropic trigger carries, so the cut recovers
|
||||
// the boundary and each key becomes its own marker. An anthropic body class
|
||||
// spans [A-Za-z0-9_-], wide enough to cover the next trigger and the
|
||||
// credential behind it whole, so the single match already consumed them and
|
||||
// the requirement is only that nothing of either key survives.
|
||||
func TestRedactStringRedactsACrossShapedCredentialGluedOntoARun(t *testing.T) {
|
||||
// gitleaks:allow -- synthetic redaction fixtures below
|
||||
github := "ghp_" + strings.Repeat("a", 36)
|
||||
anthropic := "sk-ant-" + strings.Repeat("b", 20)
|
||||
secondAnthropic := "sk-ant-" + strings.Repeat("d", 20)
|
||||
|
||||
// keptOut fails when any trigger or credential-shaped residue survives the
|
||||
// redaction, the one property a glued run can never relax.
|
||||
keptOut := func(t *testing.T, got string, pieces ...string) {
|
||||
t.Helper()
|
||||
for _, mark := range []string{"sk-ant-", "ghp_"} {
|
||||
if strings.Contains(got, mark) {
|
||||
t.Fatalf("a trigger survived a glued run: %q in %q", mark, got)
|
||||
}
|
||||
}
|
||||
for _, piece := range pieces {
|
||||
if strings.Contains(got, piece) {
|
||||
t.Fatalf("a credential survived a glued run: parts of %q… in %q", piece[:8], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("anthropic glued behind github", func(t *testing.T) {
|
||||
value := github + anthropic
|
||||
got := RedactString(value, Options{})
|
||||
if got != RedactedSecret+RedactedSecret {
|
||||
t.Fatalf("anthropic key survived behind a github key: got %q", got)
|
||||
}
|
||||
keptOut(t, got, github, anthropic)
|
||||
})
|
||||
|
||||
t.Run("anthropic glued behind anthropic, then github", func(t *testing.T) {
|
||||
value := anthropic + secondAnthropic + github
|
||||
got := RedactString(value, Options{})
|
||||
if got == value {
|
||||
t.Fatal("SETUP INVALID: the glued run is not redacted at all")
|
||||
}
|
||||
keptOut(t, got, anthropic, secondAnthropic, github)
|
||||
})
|
||||
|
||||
t.Run("github glued behind anthropic", func(t *testing.T) {
|
||||
value := anthropic + github
|
||||
got := RedactString(value, Options{})
|
||||
if got == value {
|
||||
t.Fatal("SETUP INVALID: the glued run is not redacted at all")
|
||||
}
|
||||
keptOut(t, got, anthropic, github)
|
||||
})
|
||||
}
|
||||
|
||||
// THE CUT MUST NOT WEAKEN WHAT ALREADY WORKED.
|
||||
//
|
||||
// A trigger spelled inside a matched span that starts no credential there
|
||||
// leaves the span whole: the bytes before it do not re-validate as their
|
||||
// shape, and the run chained from it stays inside the span. The end-chaining
|
||||
// for fixed-length bodies and every single-credential behavior the suite
|
||||
// already pins must be untouched.
|
||||
func TestRedactStringSwallowedTriggerCutKeepsExistingBehavior(t *testing.T) {
|
||||
// gitleaks:allow -- synthetic redaction fixtures below
|
||||
github := "ghp_" + strings.Repeat("a", 36)
|
||||
|
||||
t.Run("two fixed-length AWS keys still chain at the end", func(t *testing.T) {
|
||||
got := RedactString("AKIAIOSFODNN7EXAMPLE"+"ASIAIOSFODNN7EXAMPLE", Options{})
|
||||
if got != RedactedSecret+RedactedSecret {
|
||||
t.Fatalf("glued AWS keys regressed: got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an interior trigger spelling starts no credential of its own", func(t *testing.T) {
|
||||
// A JWT payload can contain "eyJ" away from a segment start. The strict
|
||||
// form needs the segment to open with it and the loose form needs the
|
||||
// three-segment skeleton, so a payload fragment is prose to both and the
|
||||
// token stays one marker.
|
||||
token := "eyJhbGciOiJIUzI1NiJ9." + "aaaaeyJaaaaaa" + ".QWxpY2VCb2JDYXJvbA"
|
||||
if got := RedactString(token, Options{}); got != RedactedSecret {
|
||||
t.Fatalf("a JWT with an interior trigger spelling changed shape: got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a single token is one marker, not two", func(t *testing.T) {
|
||||
got := RedactString(github, Options{})
|
||||
if got != RedactedSecret {
|
||||
t.Fatalf("a lone token changed shape: got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A SPAM OF TRIGGER SPELLINGS INSIDE ONE BODY MUST COST WHAT THE SPAN COSTS.
|
||||
//
|
||||
// Every trigger occurrence inside a matched span is a cut candidate, but only
|
||||
// the first maxTriggerOccurrences are examined, so the scan stays linear in
|
||||
// the span: a body stuffed with lookalikes must not walk the span once per
|
||||
// candidate. Measured as allocation against the same bar the split path's
|
||||
// linearity test uses, the one an index-per-byte or a walk-per-credential
|
||||
// explosion offends.
|
||||
func TestRedactStringTriggerLookalikeSpamStaysBounded(t *testing.T) {
|
||||
// No digit appears anywhere in the fixture, so the openai shape's filter
|
||||
// rejects it as kebab-case prose and the anthropic shape still matches it
|
||||
// whole: every "sk-ant-" inside the span is a cut candidate, and none
|
||||
// validates because the run chained from any of them reaches only to the
|
||||
// span's own end.
|
||||
// gitleaks:allow -- synthetic redaction fixture below
|
||||
value := "sk-ant-" + strings.Repeat("sk-ant-", (64<<10)/len("sk-ant-"))
|
||||
if got := RedactString(value, Options{}); got != RedactedSecret {
|
||||
t.Fatalf("SETUP INVALID: the lookalike span was not redacted whole: got %q", got)
|
||||
}
|
||||
// Sixty-four candidates, each re-checked against the span, cost a small
|
||||
// constant multiple of the span. A walk over every occurrence quadruples
|
||||
// the cost when the span doubles, and the split path measured that walk
|
||||
// at seventeen gigabytes on a one-megabyte chain.
|
||||
limit := int64(200 * len(value))
|
||||
if used := redactAllocBytes(value); used > limit {
|
||||
t.Fatalf("redacting a %d byte lookalike span allocated %d bytes, over the %d byte limit", len(value), used, limit)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ type AnalysisResult struct {
|
||||
TooComplex bool
|
||||
// Programs lists the distinct top-level command names found, for diagnostics.
|
||||
Programs []string
|
||||
// GitInit is set when the script would create a git repository. It is a
|
||||
// separate signal from Destructive and Network because the decision it feeds
|
||||
// depends on WHERE the workspace sits, which the analyzer cannot see.
|
||||
GitInit bool
|
||||
}
|
||||
|
||||
// destructivePrograms are commands that can irrecoverably destroy data.
|
||||
@@ -183,6 +187,9 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
|
||||
if commandUsesNetwork(prog, rest) {
|
||||
result.Network = true
|
||||
}
|
||||
if commandCreatesGitRepository(call, prog, rest) {
|
||||
result.GitInit = true
|
||||
}
|
||||
if destructivePrograms[prog] ||
|
||||
(prog == "rm" && hasRecursiveForce(rest)) ||
|
||||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
|
||||
@@ -277,8 +284,77 @@ func packageManagerOffline(words []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// gitGlobalOptionsTakingValue are git's own options that consume the NEXT word.
|
||||
//
|
||||
// firstSubcommand skips words beginning with "-" but not the value that follows
|
||||
// one, so "git -C sub clone <url>" made it answer "sub" and the command was
|
||||
// classified as touching no network at all. The "--opt=value" spelling is one
|
||||
// token and needs no entry here.
|
||||
//
|
||||
// Keys are lowercase because the analyzer lowercases every word before it gets
|
||||
// here, which also means -C and -c arrive identically. That is fine: both
|
||||
// consume a value, and nothing below needs to tell them apart.
|
||||
var gitGlobalOptionsTakingValue = map[string]bool{
|
||||
"-c": true, "-C": true,
|
||||
"--git-dir": true, "--work-tree": true, "--namespace": true,
|
||||
"--exec-path": true, "--config-env": true, "--super-prefix": true,
|
||||
"--attr-source": true,
|
||||
}
|
||||
|
||||
// gitSubcommand returns git's subcommand, skipping global options AND the values
|
||||
// they consume. Returns "" when the words carry no subcommand.
|
||||
func gitSubcommand(words []string) string {
|
||||
for index := 0; index < len(words); index++ {
|
||||
word := words[index]
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(word, "-") {
|
||||
return word
|
||||
}
|
||||
// "--opt=value" is one token and carries its own value.
|
||||
if strings.HasPrefix(word, "--") && strings.Contains(word, "=") {
|
||||
continue
|
||||
}
|
||||
if gitGlobalOptionsTakingValue[word] {
|
||||
index++
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commandCreatesGitRepository reports whether this call would create a git
|
||||
// repository. It shares gitSubcommand with the network gate deliberately, so the
|
||||
// global options that already bypassed that gate cannot bypass this one either:
|
||||
// "git -C sub init" and "git -c k=v init" both resolve to init.
|
||||
//
|
||||
// init-db is git's original spelling and still works today. clone creates a
|
||||
// repository as surely as init does, with its config and hooks written by the
|
||||
// fetch rather than by hand: "git clone <url> ." into a workspace governed by an
|
||||
// ancestor repository lands a root .git whose carveouts setup never planned, so
|
||||
// it is the same nested-repository decision. The network prompt is a separate
|
||||
// gate and granting it does not answer this one. Reported by @gnanam1990.
|
||||
//
|
||||
// The subcommand is the one git runs after alias resolution, which git_alias.go
|
||||
// models: definitions from the command line and from the environment the call
|
||||
// sets, expanded with git's own quoting and its in-alias option handling. A
|
||||
// definition this analyzer cannot read makes the command unclassifiable, and
|
||||
// under this guard that is a refusal.
|
||||
func commandCreatesGitRepository(call *syntax.CallExpr, prog string, args []*syntax.Word) bool {
|
||||
if prog != "git" {
|
||||
return false
|
||||
}
|
||||
words := literalWordTexts(args)
|
||||
aliases := gitAliasTable{}
|
||||
if !gitEnvironmentAliases(aliases, callAssignments(call, args)) {
|
||||
return true
|
||||
}
|
||||
gitInlineAliases(aliases, words)
|
||||
return gitSubcommandCreatesRepository(gitSubcommand(words), aliases)
|
||||
}
|
||||
|
||||
func gitUsesNetwork(words []string) bool {
|
||||
switch firstSubcommand(words, nil) {
|
||||
switch gitSubcommand(words) {
|
||||
case "clone", "fetch", "pull", "push", "ls-remote", "archive":
|
||||
return true
|
||||
default:
|
||||
|
||||
@@ -394,6 +394,19 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
return deny(request, risk, block.Code, block.Path, block.Reason, false)
|
||||
}
|
||||
}
|
||||
// BEFORE THE NETWORK GATE, because this one cannot be approved away.
|
||||
//
|
||||
// `git clone` is both a network command and a repository-creating one. With
|
||||
// the network gate first, a clone in a governed nested workspace under the
|
||||
// default NetworkDeny came back as a network prompt: the operator was asked
|
||||
// to approve network access for a command that approval could never make
|
||||
// runnable, and the reason that actually decides it was never shown. The
|
||||
// refusal below does not depend on policy, permission or mode, so it is the
|
||||
// answer whatever the network gate would have said, and it is the one with a
|
||||
// remedy the operator can act on. Reported by @jatmn.
|
||||
if HasRiskCategory(risk, "nested_git_init") {
|
||||
return deny(request, risk, BlockNestedGitInit, "", ReasonNestedGitInit, false)
|
||||
}
|
||||
netMode := engine.effectiveNetworkMode(policy)
|
||||
if netMode == NetworkDeny && HasRiskCategory(risk, "network") && !engine.toolNetworkExempt(request) {
|
||||
if request.SideEffect == SideEffectShell && request.PermissionMode != PermissionUnsafe {
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
// THE NESTED-REPOSITORY GUARD CLASSIFIES THE COMMAND GIT RUNS, NOT THE ONE
|
||||
// TYPED. Git rewrites its argument vector before choosing a builtin: an alias
|
||||
// defined on the command line, in the environment or through --config-env is
|
||||
// split with shell-style quoting, its own leading global options are applied,
|
||||
// and the result is looked up again until a builtin is reached. Reading the
|
||||
// literal token, or the first whitespace-separated field of an alias value,
|
||||
// let `git -c alias.a='-c alias.b=init b' a .` and `git -c 'alias.a="init"' a .`
|
||||
// create a repository the workspace profile carves nothing out for. What
|
||||
// follows models that rewrite, and where the model runs out it refuses rather
|
||||
// than guesses: under this guard the safe answer to "could this make a
|
||||
// repository" is yes. Reported by @gnanam1990.
|
||||
|
||||
// gitAliasTable maps a lowercased alias name to every definition the command
|
||||
// supplies for it. Git keeps one value per key, the last one written, and the
|
||||
// order in which the environment, -c and --config-env feed that list is not
|
||||
// something this guard needs to reproduce: a definition git would discard but
|
||||
// which reaches init here costs one refusal, while the opposite mistake costs
|
||||
// a repository. So every definition is kept, and any of them reaching init
|
||||
// refuses. For the same reason an alias that shadows a builtin, which git
|
||||
// ignores, is followed here rather than skipped: this file carries no list of
|
||||
// git's commands, and refusing `-c alias.status=init status` loses nothing.
|
||||
type gitAliasTable map[string][]gitAliasDefinition
|
||||
|
||||
// gitAliasDefinition is one alias value. unknown marks a definition whose text
|
||||
// cannot be read statically, such as --config-env=alias.x=VAR or a
|
||||
// GIT_CONFIG_VALUE_n written as $var: git will run whatever it holds, and
|
||||
// this guard cannot say what that is.
|
||||
type gitAliasDefinition struct {
|
||||
text string
|
||||
unknown bool
|
||||
}
|
||||
|
||||
func (table gitAliasTable) define(name, text string) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
table[name] = append(table[name], gitAliasDefinition{text: strings.ToLower(text)})
|
||||
}
|
||||
|
||||
func (table gitAliasTable) defineUnknown(name string) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
table[name] = append(table[name], gitAliasDefinition{unknown: true})
|
||||
}
|
||||
|
||||
// defineSetting records a `key=value` config setting when key names an alias.
|
||||
// readable is false when the value is only known by reference, which makes the
|
||||
// alias unknown rather than absent. A setting without "=" is a boolean in git
|
||||
// (`alias.x` expands to "true") and defines nothing worth following.
|
||||
func (table gitAliasTable) defineSetting(setting string, readable bool) {
|
||||
name, value, found := strings.Cut(setting, "=")
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
alias, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(name)), "alias.")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !readable {
|
||||
table.defineUnknown(alias)
|
||||
return
|
||||
}
|
||||
table.define(alias, value)
|
||||
}
|
||||
|
||||
// splitGitAliasExpansion tokenizes an alias value the way git's split_cmdline
|
||||
// does: whitespace separates words, a single or double quote opens a quoted
|
||||
// span anywhere inside a word, and a backslash outside single quotes escapes
|
||||
// the next byte. An unclosed quote or a trailing backslash is a fatal error in
|
||||
// git ("bad alias string"), so ok is false and nothing would have run.
|
||||
func splitGitAliasExpansion(text string) (argv []string, ok bool) {
|
||||
var current strings.Builder
|
||||
var quoted byte
|
||||
inWord := false
|
||||
for index := 0; index < len(text); index++ {
|
||||
c := text[index]
|
||||
switch {
|
||||
case quoted == 0 && isGitSpace(c):
|
||||
if inWord {
|
||||
argv = append(argv, current.String())
|
||||
current.Reset()
|
||||
inWord = false
|
||||
}
|
||||
case quoted == 0 && (c == '\'' || c == '"'):
|
||||
quoted = c
|
||||
inWord = true
|
||||
case c == quoted:
|
||||
quoted = 0
|
||||
default:
|
||||
if c == '\\' && quoted != '\'' {
|
||||
index++
|
||||
if index >= len(text) {
|
||||
return nil, false
|
||||
}
|
||||
c = text[index]
|
||||
}
|
||||
current.WriteByte(c)
|
||||
inWord = true
|
||||
}
|
||||
}
|
||||
if quoted != 0 {
|
||||
return nil, false
|
||||
}
|
||||
if inWord {
|
||||
argv = append(argv, current.String())
|
||||
}
|
||||
return argv, true
|
||||
}
|
||||
|
||||
func isGitSpace(c byte) bool {
|
||||
switch c {
|
||||
case ' ', '\t', '\n', '\r', '\v', '\f':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gitAliasExpansionSubcommand runs a split expansion through git's own
|
||||
// handling of global options inside an alias and returns the subcommand git
|
||||
// would look up next. Inside an expansion git accepts -c and --config-env,
|
||||
// which push settings the next lookup sees (that is how `-c alias.b=init b`
|
||||
// reaches init), and the pager switches, which change nothing that runs. An
|
||||
// option that changes the environment (-C, --git-dir, --bare, ...) is fatal
|
||||
// inside an alias, and an expansion made only of options is an empty alias;
|
||||
// both return ok false. So does an option this guard does not model, because
|
||||
// "git would refuse this" and "this guard cannot read this" end the same way.
|
||||
func gitAliasExpansionSubcommand(argv []string, aliases gitAliasTable) (subcommand string, ok bool) {
|
||||
for index := 0; index < len(argv); index++ {
|
||||
word := argv[index]
|
||||
if word == "" {
|
||||
return "", false
|
||||
}
|
||||
if !strings.HasPrefix(word, "-") {
|
||||
return word, true
|
||||
}
|
||||
switch {
|
||||
case word == "-c", word == "--config-env":
|
||||
if index+1 >= len(argv) {
|
||||
return "", false
|
||||
}
|
||||
index++
|
||||
aliases.defineSetting(argv[index], word == "-c")
|
||||
case strings.HasPrefix(word, "--config-env="):
|
||||
aliases.defineSetting(strings.TrimPrefix(word, "--config-env="), false)
|
||||
case word == "-p", word == "--paginate", word == "-P", word == "--no-pager":
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// gitSubcommandCreatesRepository reports whether subcommand, resolved through
|
||||
// aliases the way git resolves it, is init, init-db or clone. The chain is
|
||||
// followed to its end with the names on the current path kept, so a cycle is a
|
||||
// refusal here the way "alias loop detected" is fatal in git. A shell alias
|
||||
// runs a program this analyzer cannot classify; an expansion git cannot split,
|
||||
// cannot run, or that leaves no subcommand behind is the same case, and so is
|
||||
// a definition whose text is not readable. Every one of those refuses.
|
||||
func gitSubcommandCreatesRepository(subcommand string, aliases gitAliasTable) bool {
|
||||
return gitAliasPathCreatesRepository(subcommand, aliases, nil)
|
||||
}
|
||||
|
||||
func gitAliasPathCreatesRepository(subcommand string, aliases gitAliasTable, path []string) bool {
|
||||
switch subcommand {
|
||||
case "init", "init-db", "clone":
|
||||
return true
|
||||
}
|
||||
definitions, ok := aliases[subcommand]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, seen := range path {
|
||||
if seen == subcommand {
|
||||
return true
|
||||
}
|
||||
}
|
||||
path = append(path, subcommand)
|
||||
for _, definition := range definitions {
|
||||
if definition.unknown || strings.HasPrefix(definition.text, "!") {
|
||||
return true
|
||||
}
|
||||
argv, ok := splitGitAliasExpansion(definition.text)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
next, ok := gitAliasExpansionSubcommand(argv, aliases)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if gitAliasPathCreatesRepository(next, aliases, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gitInlineAliases records the alias definitions supplied ahead of the
|
||||
// subcommand: `-c alias.NAME=EXPANSION`, and `--config-env alias.NAME=VAR` in
|
||||
// both spellings, whose value lives in a variable this analyzer does not read.
|
||||
// Git accepts no attached `-cNAME=VALUE` spelling ("unknown option"), so none
|
||||
// is recognised here. Other global options that take a value are stepped over
|
||||
// the way gitSubcommand steps over them, so a value that happens to look like
|
||||
// a subcommand does not end the scan early.
|
||||
func gitInlineAliases(aliases gitAliasTable, words []string) {
|
||||
for index := 0; index < len(words); index++ {
|
||||
word := words[index]
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(word, "-") {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case word == "-c", word == "--config-env":
|
||||
if index+1 < len(words) {
|
||||
index++
|
||||
aliases.defineSetting(words[index], word == "-c")
|
||||
}
|
||||
case strings.HasPrefix(word, "--config-env="):
|
||||
aliases.defineSetting(strings.TrimPrefix(word, "--config-env="), false)
|
||||
case strings.HasPrefix(word, "--") && strings.Contains(word, "="):
|
||||
case gitGlobalOptionsTakingValue[word]:
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shellAssignment is one NAME=value the command hands to git's environment.
|
||||
// readable is false when the value is an expansion this analyzer cannot see.
|
||||
type shellAssignment struct {
|
||||
value string
|
||||
readable bool
|
||||
}
|
||||
|
||||
// callAssignments collects the assignments in force for the program: the
|
||||
// shell prefix (FOO=bar git ...) and the NAME=value words an env wrapper
|
||||
// forwards (env FOO=bar git ...), which sit ahead of the program word.
|
||||
func callAssignments(call *syntax.CallExpr, rest []*syntax.Word) map[string]shellAssignment {
|
||||
assignments := map[string]shellAssignment{}
|
||||
if call == nil {
|
||||
return assignments
|
||||
}
|
||||
for _, assign := range call.Assigns {
|
||||
if assign == nil || assign.Name == nil {
|
||||
continue
|
||||
}
|
||||
readable := !assign.Append && assign.Array == nil && assign.Index == nil &&
|
||||
(assign.Value == nil || isLiteralWord(assign.Value))
|
||||
assignments[assign.Name.Value] = shellAssignment{value: wordText(assign.Value), readable: readable}
|
||||
}
|
||||
program := len(call.Args) - len(rest) - 1
|
||||
for index := 0; index < program && index < len(call.Args); index++ {
|
||||
name, value, found := strings.Cut(wordText(call.Args[index]), "=")
|
||||
if !found || !isEnvName(name) {
|
||||
continue
|
||||
}
|
||||
assignments[name] = shellAssignment{value: value, readable: isLiteralWord(call.Args[index])}
|
||||
}
|
||||
return assignments
|
||||
}
|
||||
|
||||
func isEnvName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for index, r := range name {
|
||||
switch {
|
||||
case r == '_', r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9' && index > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// gitEnvironmentAliases records the alias definitions the command hands git
|
||||
// through its environment, which git reads exactly as it reads -c:
|
||||
// GIT_CONFIG_COUNT with GIT_CONFIG_KEY_n and GIT_CONFIG_VALUE_n, and
|
||||
// GIT_CONFIG_PARAMETERS. It reports false when a definition this guard needs
|
||||
// cannot be read: a count, a key or the parameter list written as an
|
||||
// expansion, or the value of an alias key written that way. A count git would
|
||||
// reject, or a numbered key with no value, is reported the same way; git dies
|
||||
// on those, so refusing them costs nothing.
|
||||
func gitEnvironmentAliases(aliases gitAliasTable, env map[string]shellAssignment) bool {
|
||||
if parameters, ok := env["GIT_CONFIG_PARAMETERS"]; ok {
|
||||
if !parameters.readable {
|
||||
return false
|
||||
}
|
||||
settings, ok := splitGitConfigParameters(parameters.value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, setting := range settings {
|
||||
aliases.defineSetting(setting, true)
|
||||
}
|
||||
}
|
||||
count, ok := env["GIT_CONFIG_COUNT"]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if !count.readable {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(count.value))
|
||||
if err != nil || n < 0 || n > 1024 {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < n; index++ {
|
||||
key, ok := env["GIT_CONFIG_KEY_"+strconv.Itoa(index)]
|
||||
if !ok || !key.readable {
|
||||
return false
|
||||
}
|
||||
value, ok := env["GIT_CONFIG_VALUE_"+strconv.Itoa(index)]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
aliases.defineSetting(key.value+"="+value.value, value.readable)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// splitGitConfigParameters dequotes GIT_CONFIG_PARAMETERS the way git does:
|
||||
// whitespace-separated entries, each single-quoted, with `'\”` and `'\!'`
|
||||
// standing for a quote or a bang inside one. Anything else is "bogus format"
|
||||
// in git, a fatal error, so ok is false.
|
||||
func splitGitConfigParameters(text string) (settings []string, ok bool) {
|
||||
index := 0
|
||||
for {
|
||||
for index < len(text) && isGitSpace(text[index]) {
|
||||
index++
|
||||
}
|
||||
if index >= len(text) {
|
||||
return settings, true
|
||||
}
|
||||
if text[index] != '\'' {
|
||||
return nil, false
|
||||
}
|
||||
index++
|
||||
var current strings.Builder
|
||||
for {
|
||||
if index >= len(text) {
|
||||
return nil, false
|
||||
}
|
||||
c := text[index]
|
||||
index++
|
||||
if c != '\'' {
|
||||
current.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
if index+2 < len(text) && text[index] == '\\' && (text[index+1] == '\'' || text[index+1] == '!') && text[index+2] == '\'' {
|
||||
current.WriteByte(text[index+1])
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
if index >= len(text) || isGitSpace(text[index]) {
|
||||
settings = append(settings, current.String())
|
||||
break
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tokenizer has to agree with git's split_cmdline on the forms that decide
|
||||
// which subcommand runs: quotes anywhere in a word, backslash escapes outside
|
||||
// single quotes, and the two errors git dies on.
|
||||
func TestSplitGitAliasExpansionMatchesGitQuoting(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
expansion string
|
||||
want []string
|
||||
ok bool
|
||||
}{
|
||||
{"init", []string{"init"}, true},
|
||||
{`"init"`, []string{"init"}, true},
|
||||
{`in"it"`, []string{"init"}, true},
|
||||
{`in\it`, []string{"init"}, true},
|
||||
{`'in\it'`, []string{`in\it`}, true},
|
||||
{`"in\"it"`, []string{`in"it`}, true},
|
||||
{`-c alias.b=init b`, []string{"-c", "alias.b=init", "b"}, true},
|
||||
{`-c "alias.b=init x" b`, []string{"-c", "alias.b=init x", "b"}, true},
|
||||
{`'-c' 'alias.b=init' b`, []string{"-c", "alias.b=init", "b"}, true},
|
||||
{" init \t ", []string{"init"}, true},
|
||||
{"", nil, true},
|
||||
{`"init`, nil, false},
|
||||
{`'init`, nil, false},
|
||||
{`init\`, nil, false},
|
||||
} {
|
||||
got, ok := splitGitAliasExpansion(tc.expansion)
|
||||
if ok != tc.ok || !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("splitGitAliasExpansion(%q) = %q, %v; want %q, %v", tc.expansion, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GIT_CONFIG_PARAMETERS is git's own single-quoted list; an entry git would
|
||||
// call bogus makes the whole variable unreadable rather than partly read.
|
||||
func TestSplitGitConfigParametersDequotesLikeGit(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
want []string
|
||||
ok bool
|
||||
}{
|
||||
{`'alias.a=init'`, []string{"alias.a=init"}, true},
|
||||
{`'alias.a=init' 'x=y'`, []string{"alias.a=init", "x=y"}, true},
|
||||
{` 'alias.a=in'\''it' `, []string{"alias.a=in'it"}, true},
|
||||
{`'alias.a=in'\!'it'`, []string{"alias.a=in!it"}, true},
|
||||
{"", nil, true},
|
||||
{`alias.a=init`, nil, false},
|
||||
{`'alias.a=init`, nil, false},
|
||||
{`'a'x`, nil, false},
|
||||
} {
|
||||
got, ok := splitGitConfigParameters(tc.text)
|
||||
if ok != tc.ok || !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("splitGitConfigParameters(%q) = %q, %v; want %q, %v", tc.text, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution applies git's in-alias option handling: a -c inside an expansion
|
||||
// defines the alias the next lookup uses, pager switches are stepped over, an
|
||||
// environment-changing option or an unreadable definition refuses, and only a
|
||||
// chain that ends in a real repository-creating command is creation.
|
||||
func TestGitAliasResolutionFollowsExpansionOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
subcommand string
|
||||
aliases map[string]string
|
||||
unknown []string
|
||||
creates bool
|
||||
}{
|
||||
{name: "option inside expansion defines the next alias", subcommand: "a", aliases: map[string]string{"a": "-c alias.b=init b"}, creates: true},
|
||||
{name: "quoted expansion", subcommand: "a", aliases: map[string]string{"a": `"init"`}, creates: true},
|
||||
{name: "pager switch does not hide init", subcommand: "a", aliases: map[string]string{"a": "--no-pager init"}, creates: true},
|
||||
{name: "environment-changing option refuses", subcommand: "a", aliases: map[string]string{"a": "-C . init"}, creates: true},
|
||||
{name: "unclosed quote refuses", subcommand: "a", aliases: map[string]string{"a": `"in`}, creates: true},
|
||||
{name: "options only refuses", subcommand: "a", aliases: map[string]string{"a": "-c x=y"}, creates: true},
|
||||
{name: "config-env inside expansion is unreadable", subcommand: "a", aliases: map[string]string{"a": "--config-env=alias.b=var b"}, creates: true},
|
||||
{name: "redefinition loop refuses", subcommand: "a", aliases: map[string]string{"a": "-c alias.a=init a"}, creates: true},
|
||||
{name: "unknown definition refuses", subcommand: "a", unknown: []string{"a"}, creates: true},
|
||||
{name: "harmless option-bearing alias", subcommand: "st", aliases: map[string]string{"st": "-c color.ui=always status"}, creates: false},
|
||||
{name: "harmless quoted alias", subcommand: "st", aliases: map[string]string{"st": `"status"`}, creates: false},
|
||||
{name: "harmless pager alias", subcommand: "l", aliases: map[string]string{"l": "--no-pager log --oneline"}, creates: false},
|
||||
{name: "harmless chain through quotes", subcommand: "s", aliases: map[string]string{"s": "st", "st": `sta"tus"`}, creates: false},
|
||||
{name: "not an alias", subcommand: "status", creates: false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
table := gitAliasTable{}
|
||||
for name, expansion := range tc.aliases {
|
||||
table.define(name, expansion)
|
||||
}
|
||||
for _, name := range tc.unknown {
|
||||
table.defineUnknown(name)
|
||||
}
|
||||
if got := gitSubcommandCreatesRepository(tc.subcommand, table); got != tc.creates {
|
||||
t.Fatalf("gitSubcommandCreatesRepository(%q) = %v, want %v", tc.subcommand, got, tc.creates)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
// GIT'S GLOBAL OPTIONS CONSUME THE NEXT WORD, AND THE CLASSIFIER HAS TO KNOW.
|
||||
//
|
||||
// firstSubcommand skips words beginning with "-" but not the value that follows
|
||||
// one. So "git -C sub clone <url>" made it answer "sub", the command classified
|
||||
// as touching no network at all, and the network gate never fired. Two spellings
|
||||
// an ordinary user types by habit walked straight through it.
|
||||
//
|
||||
// Driven through AnalyzeCommand rather than the helper, because the gate reads
|
||||
// the analysis and a helper-level assertion would not have caught this.
|
||||
func TestGitGlobalOptionsDoNotHideTheSubcommand(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
script string
|
||||
want bool
|
||||
}{
|
||||
{script: "git clone https://example.com/x", want: true},
|
||||
{script: "git -C sub clone https://example.com/x", want: true},
|
||||
{script: "git -c user.name=x clone https://example.com/x", want: true},
|
||||
{script: "git -c a=b -C sub clone https://example.com/x", want: true},
|
||||
{script: "git --git-dir=/tmp/g clone https://example.com/x", want: true},
|
||||
{script: "git --namespace ns fetch origin", want: true},
|
||||
{script: "git -C sub push origin main", want: true},
|
||||
// Local-only work stays local, or the fix would be "call everything network".
|
||||
{script: "git status", want: false},
|
||||
{script: "git -C sub status", want: false},
|
||||
{script: "git -c a=b commit -m x", want: false},
|
||||
{script: "git init", want: false},
|
||||
} {
|
||||
if got := AnalyzeCommand(testCase.script).Network; got != testCase.want {
|
||||
t.Errorf("AnalyzeCommand(%q).Network = %v, want %v", testCase.script, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The subcommand parser itself, including the option that consumes a value and
|
||||
// the self-contained "--opt=value" spelling that does not.
|
||||
func TestGitSubcommandSkipsOptionValues(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
words []string
|
||||
want string
|
||||
}{
|
||||
{words: []string{"clone", "url"}, want: "clone"},
|
||||
{words: []string{"-c", "sub", "clone"}, want: "clone"},
|
||||
{words: []string{"--git-dir=/tmp/g", "clone"}, want: "clone"},
|
||||
{words: []string{"--namespace", "ns", "fetch"}, want: "fetch"},
|
||||
{words: []string{"-c", "a=b", "-c", "sub", "push"}, want: "push"},
|
||||
{words: []string{"--no-pager", "status"}, want: "status"},
|
||||
{words: []string{}, want: ""},
|
||||
{words: []string{"-c", "a=b"}, want: ""},
|
||||
} {
|
||||
if got := gitSubcommand(testCase.words); got != testCase.want {
|
||||
t.Errorf("gitSubcommand(%v) = %q, want %q", testCase.words, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// nestedGitWorkspace returns an ancestor repository and a workspace inside it
|
||||
// that carries no git metadata of its own, which is the shape the whole refusal
|
||||
// is about.
|
||||
func nestedGitWorkspace(t *testing.T) (ancestor string, workspace string) {
|
||||
t.Helper()
|
||||
ancestor = t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(ancestor, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace = filepath.Join(ancestor, "packages", "app")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(workspace, ".git")); err == nil {
|
||||
t.Fatal("SETUP INVALID: the workspace carries its own .git, which is the protected case, not this one")
|
||||
}
|
||||
return ancestor, workspace
|
||||
}
|
||||
|
||||
func gitCommandRequest(workspace string, command string) Request {
|
||||
return Request{
|
||||
ToolName: "bash",
|
||||
SideEffect: SideEffectShell,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
WorkspaceRoot: workspace,
|
||||
Args: map[string]any{"command": command},
|
||||
}
|
||||
}
|
||||
|
||||
func gitWorkspaceEngine(t *testing.T, workspace string) *Engine {
|
||||
t.Helper()
|
||||
return NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: nativeWrappingBackend,
|
||||
})
|
||||
}
|
||||
|
||||
// THE LIFECYCLE, END TO END: the workspace is protected at write time AND the
|
||||
// command that would create a repository there is refused with an explanation.
|
||||
//
|
||||
// The setup guard is still load-bearing, but it now asserts the opposite of
|
||||
// what it once did. The carveouts were restored for a nested workspace, because
|
||||
// skipping them left nothing denying writes to <root>/.git and a repository
|
||||
// could be assembled there without running git at all. So the refusal is no
|
||||
// longer the only thing standing between a sandboxed command and a writable
|
||||
// config; it is the layer that tells an operator why, while the deny ACE is
|
||||
// what stops it. If the carveouts disappear again this fails here, because the
|
||||
// refusal on its own does not cover mkdir.
|
||||
func TestNestedWorkspaceRefusesGitInit(t *testing.T) {
|
||||
ancestor, workspace := nestedGitWorkspace(t)
|
||||
|
||||
carved := map[string]bool{}
|
||||
for _, carveout := range gitMetadataWriteCarveouts(workspace) {
|
||||
carved[filepath.Base(carveout)] = true
|
||||
}
|
||||
if !carved["config"] || !carved["hooks"] {
|
||||
t.Fatalf("SETUP INVALID: the nested workspace planned %v, so config and hooks are not denied and the refusal below is the only protection again", gitMetadataWriteCarveouts(workspace))
|
||||
}
|
||||
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git init"))
|
||||
if decision.Action != ActionDeny {
|
||||
t.Fatalf("git init inside the repository at %s was %s, not denied; the repository it creates gets a writable config and hooks under the workspace grant", ancestor, decision.Action)
|
||||
}
|
||||
if decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
|
||||
t.Fatalf("block = %#v, want code %s so the operator gets the nested-repository explanation and not a generic denial", decision.Block, BlockNestedGitInit)
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal sits ahead of every allow path in Evaluate, so a tool already
|
||||
// cleared by permissions does not walk through it.
|
||||
func TestNestedGitInitRefusalOutranksAnAllowPermission(t *testing.T) {
|
||||
_, workspace := nestedGitWorkspace(t)
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
|
||||
request := gitCommandRequest(workspace, "git init")
|
||||
request.Permission = PermissionAllow
|
||||
if decision := engine.Evaluate(context.Background(), request); decision.Action != ActionDeny {
|
||||
t.Fatalf("an allow permission carried git init through: %#v", decision)
|
||||
}
|
||||
|
||||
request = gitCommandRequest(workspace, "git init")
|
||||
request.PermissionMode = PermissionUnsafe
|
||||
request.PermissionGranted = true
|
||||
if decision := engine.Evaluate(context.Background(), request); decision.Action != ActionDeny {
|
||||
t.Fatalf("unsafe mode carried git init through: %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
// The global options that bypassed the network gate must not bypass this one.
|
||||
// These are the exact spellings that classified a clone as network=false before
|
||||
// gitSubcommand learned which options take a value.
|
||||
func TestNestedGitInitRefusalSurvivesGitGlobalOptions(t *testing.T) {
|
||||
_, workspace := nestedGitWorkspace(t)
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
|
||||
for _, command := range []string{
|
||||
"git init",
|
||||
"git init-db",
|
||||
"git -C packages/app init",
|
||||
"git -c core.hooksPath=/tmp/h init",
|
||||
"git --git-dir=/tmp/g init",
|
||||
"git --namespace ns init",
|
||||
"sh -c \"git init\"",
|
||||
"sudo git init",
|
||||
"git status && git init",
|
||||
} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, command))
|
||||
if decision.Action != ActionDeny {
|
||||
t.Fatalf("%q was %s, not denied", command, decision.Action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: ordinary git in a nested workspace is untouched.
|
||||
//
|
||||
// Discovery still walks up to the ancestor and every command that uses it keeps
|
||||
// working; only creation is refused. Without this, returning deny for anything
|
||||
// containing the word git would pass the tests above.
|
||||
func TestNestedWorkspaceStillRunsOrdinaryGitCommands(t *testing.T) {
|
||||
_, workspace := nestedGitWorkspace(t)
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
|
||||
for _, command := range []string{
|
||||
"git status",
|
||||
"git add .",
|
||||
"git commit -m init",
|
||||
"git log --oneline",
|
||||
"git worktree list",
|
||||
"git submodule update --init",
|
||||
} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, command))
|
||||
if decision.Action == ActionDeny {
|
||||
t.Fatalf("%q was denied in a nested workspace: %s", command, decision.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: a standalone workspace still creates repositories.
|
||||
//
|
||||
// It keeps its materialized config and hooks carveouts, so the protection the
|
||||
// refusal stands in for is actually there and the command is allowed.
|
||||
func TestStandaloneWorkspaceStillAllowsGitInit(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
if gitMetadataGovernedByAncestor(workspace) {
|
||||
t.Skip("this temp directory sits inside a repository, so it is not the standalone case")
|
||||
}
|
||||
if len(gitMetadataWriteCarveouts(workspace)) == 0 {
|
||||
t.Fatal("SETUP INVALID: a standalone workspace planned no carveouts, so it has no protection to allow git init against")
|
||||
}
|
||||
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git init"))
|
||||
if decision.Action == ActionDeny {
|
||||
t.Fatalf("git init was refused in a standalone workspace: %s", decision.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: a linked worktree is a workspace whose .git is a FILE.
|
||||
//
|
||||
// It owns git metadata, keeps its pointer-file carveout, and is not somebody
|
||||
// else's repository, so nothing here is refused. Zero's own development
|
||||
// worktrees are this shape, so getting it wrong would refuse git init for the
|
||||
// people writing this code.
|
||||
func TestLinkedWorktreeWorkspaceStillAllowsGitInit(t *testing.T) {
|
||||
ancestor := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(ancestor, ".git", "worktrees", "w"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Join(ancestor, "checkout")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pointer := filepath.Join(workspace, ".git")
|
||||
if err := os.WriteFile(pointer, []byte("gitdir: ../.git/worktrees/w\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// SETUP: the pointer carveout is intact, or this proves nothing about
|
||||
// worktrees keeping their protection.
|
||||
specs := gitMetadataWriteCarveoutSpecs(workspace)
|
||||
if len(specs) != 1 || specs[0].Path != pointer || !specs[0].IsFile {
|
||||
t.Fatalf("SETUP INVALID: linked worktree carveouts = %+v, want the file-shaped pointer %s", specs, pointer)
|
||||
}
|
||||
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
if decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git init")); decision.Action == ActionDeny {
|
||||
t.Fatalf("git init was refused in a linked worktree, which owns its git metadata: %s", decision.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// And the workspace-level question is asked of the workspace itself, so a
|
||||
// workspace that already carries .git is never treated as nested no matter what
|
||||
// is above it.
|
||||
func TestWorkspaceOwningGitIsNotTreatedAsNested(t *testing.T) {
|
||||
ancestor := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(ancestor, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Join(ancestor, "vendored")
|
||||
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if workspaceGovernedByAncestorRepository(workspace) {
|
||||
t.Fatal("a workspace that owns .git was reported as governed by its ancestor, so its own protection would be refused instead of applied")
|
||||
}
|
||||
}
|
||||
|
||||
// CLONE IS CREATION TOO, AND THE NETWORK GRANT DOES NOT ANSWER FOR IT.
|
||||
//
|
||||
// "git clone <url> ." into a workspace governed by an ancestor repository lands
|
||||
// a root .git whose config and hooks setup never planned carveouts for, exactly
|
||||
// the state git init produces, but the guard only knew the init spelling. With
|
||||
// network explicitly allowed and the shell grant already given, the clone came
|
||||
// back as an ordinary allow. The repository-creation refusal is a separate
|
||||
// decision from the network prompt and has to stand on its own. Reported by
|
||||
// @gnanam1990.
|
||||
func TestNestedWorkspaceRefusesGitCloneIntoTheRoot(t *testing.T) {
|
||||
ancestor, workspace := nestedGitWorkspace(t)
|
||||
policy := DefaultPolicy()
|
||||
policy.Network = NetworkAllow
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: policy, Backend: nativeWrappingBackend})
|
||||
for _, command := range []string{
|
||||
"git clone https://example.invalid/repo.git .",
|
||||
"git -C . clone https://example.invalid/repo.git .",
|
||||
"git clone --depth 1 https://example.invalid/repo.git vendor/dep",
|
||||
} {
|
||||
request := gitCommandRequest(workspace, command)
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
decision := engine.Evaluate(context.Background(), request)
|
||||
if decision.Action != ActionDeny {
|
||||
t.Fatalf("%q inside the repository at %s was %s, not denied; the repository it creates gets a writable config and hooks under the workspace grant", command, ancestor, decision.Action)
|
||||
}
|
||||
if decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
|
||||
t.Fatalf("%q block = %#v, want code %s", command, decision.Block, BlockNestedGitInit)
|
||||
}
|
||||
}
|
||||
// CONTROLS: ordinary git operations and the submodule init form stay clear
|
||||
// of this guard, and a standalone workspace is not governed by anything.
|
||||
for _, command := range []string{"git status", "git submodule update --init", "git fetch origin"} {
|
||||
request := gitCommandRequest(workspace, command)
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
if decision := engine.Evaluate(context.Background(), request); decision.Action == ActionDeny && decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
|
||||
t.Fatalf("%q was refused as nested repository creation; it creates no repository", command)
|
||||
}
|
||||
}
|
||||
standalone := t.TempDir()
|
||||
if workspaceGovernedByAncestorRepository(standalone) {
|
||||
t.Fatalf("SETUP INVALID: %s is governed by an ancestor repository", standalone)
|
||||
}
|
||||
request := gitCommandRequest(standalone, "git clone https://example.invalid/repo.git .")
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
if decision := gitWorkspaceEngine(t, standalone).Evaluate(context.Background(), request); decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
|
||||
t.Fatalf("clone into a standalone workspace was refused as nested: %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
// AN INLINE ALIAS IS STILL INIT. The refusal used to read the literal
|
||||
// subcommand token, and `git -c alias.bootstrap=init bootstrap` passed it with
|
||||
// shell and network granted and created a repository the profile carves nothing
|
||||
// out for. The alias is resolved to the subcommand git runs; a chained alias
|
||||
// follows the chain; a shell alias, which this analyzer cannot classify, counts
|
||||
// as creation under this guard and nowhere else.
|
||||
func TestNestedWorkspaceRefusesAnInlineGitAliasForInit(t *testing.T) {
|
||||
ancestor, workspace := nestedGitWorkspace(t)
|
||||
policy := DefaultPolicy()
|
||||
policy.Network = NetworkAllow
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: policy, Backend: nativeWrappingBackend})
|
||||
for _, command := range []string{
|
||||
"git init",
|
||||
"git -c alias.bootstrap=init bootstrap",
|
||||
"git -c alias.mk=init-db mk",
|
||||
"git -c alias.get=clone get https://example.invalid/repo.git .",
|
||||
"git -c alias.two=bootstrap -c alias.bootstrap=init two",
|
||||
"git -c alias.a=b -c alias.b=c -c alias.c=d -c alias.d=e -c alias.e=init a .",
|
||||
"git -c alias.a=b -c alias.b=a a",
|
||||
"git -c alias.dangling=nowhere -c alias.nowhere= dangling",
|
||||
"git -C . -c alias.bootstrap=init bootstrap",
|
||||
"git -c alias.sh=!sh -c 'git init' sh",
|
||||
// THE EXPANSION IS PARSED THE WAY GIT PARSES IT. A -c inside the
|
||||
// expansion defines the alias the next lookup uses, and quotes are
|
||||
// stripped before the subcommand is read; both of the first two
|
||||
// spellings created a repository while only the first whitespace
|
||||
// field of the expansion was classified.
|
||||
"git -c alias.a='-c alias.b=init b' a .",
|
||||
"git -c 'alias.a=\"init\"' a .",
|
||||
"git -c 'alias.a=in\"it\"' a",
|
||||
"git -c 'alias.a=in\\it' a",
|
||||
"git -c alias.a='--no-pager init' a",
|
||||
"git -c alias.a='-c alias.a=init a' a",
|
||||
// Git dies on these before running anything; refusing them costs
|
||||
// nothing and keeps the rule that an expansion this analyzer cannot
|
||||
// read is not evidence of safety.
|
||||
"git -c 'alias.a=\"in' a",
|
||||
"git -c 'alias.a=-C . init' a",
|
||||
// An alias defined by reference, or through the environment the call
|
||||
// sets, is the same command with its definition moved out of sight.
|
||||
"git --config-env=alias.a=zz a .",
|
||||
"git --config-env alias.a=zz a .",
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=init git a .",
|
||||
"env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=init git a .",
|
||||
"GIT_CONFIG_PARAMETERS=\"'alias.a=init'\" git a .",
|
||||
"GIT_CONFIG_PARAMETERS=$params git a .",
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=$cmd git a .",
|
||||
} {
|
||||
request := gitCommandRequest(workspace, command)
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
decision := engine.Evaluate(context.Background(), request)
|
||||
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
|
||||
t.Fatalf("%q inside the repository at %s was not refused as nested repository creation: action=%s block=%#v", command, ancestor, decision.Action, decision.Block)
|
||||
}
|
||||
}
|
||||
// CONTROLS: a harmless alias, an unrelated -c setting, ordinary use, and
|
||||
// the quoted, option-bearing and environment-defined spellings of an alias
|
||||
// that runs something harmless.
|
||||
for _, command := range []string{
|
||||
"git -c alias.st=status st",
|
||||
"git -c core.autocrlf=false status",
|
||||
"git -c alias.bootstrap=init status",
|
||||
"git -c alias.lg=log lg --oneline",
|
||||
"git -c alias.a=b -c alias.b=c -c alias.c=d -c alias.d=e -c alias.e=status a",
|
||||
"git -c alias.st='-c color.ui=always status' st",
|
||||
"git -c 'alias.st=\"status\"' st",
|
||||
"git -c 'alias.st=sta\"tus\"' st",
|
||||
"git -c alias.l='--no-pager log --oneline' l",
|
||||
"git -c alias.s=st -c 'alias.st=\"status\"' s",
|
||||
"git --config-env=core.pager=p status",
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.st GIT_CONFIG_VALUE_0=status git st",
|
||||
"GIT_CONFIG_PARAMETERS=\"'alias.st=status'\" git st",
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.pager GIT_CONFIG_VALUE_0=$pager git log",
|
||||
} {
|
||||
request := gitCommandRequest(workspace, command)
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
if decision := engine.Evaluate(context.Background(), request); decision.Action == ActionDeny && decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
|
||||
t.Fatalf("%q was refused as nested repository creation; it creates no repository", command)
|
||||
}
|
||||
}
|
||||
// And a standalone workspace is not governed at all, alias or not.
|
||||
standalone := t.TempDir()
|
||||
for _, command := range []string{
|
||||
"git -c alias.bootstrap=init bootstrap",
|
||||
"git -c alias.a='-c alias.b=init b' a .",
|
||||
"git -c 'alias.a=\"init\"' a .",
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=init git a .",
|
||||
} {
|
||||
request := gitCommandRequest(standalone, command)
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
if decision := gitWorkspaceEngine(t, standalone).Evaluate(context.Background(), request); decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
|
||||
t.Fatalf("%q in a standalone workspace was refused as nested: %#v", command, decision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE REFUSAL THAT CANNOT BE APPROVED IS THE ONE THE OPERATOR HAS TO SEE.
|
||||
//
|
||||
// `git clone` carries both risks: it uses the network and it creates a
|
||||
// repository. The clone test above sets NetworkAllow, which is the only policy
|
||||
// under which the nested-repository branch was ever reached. Under the DEFAULT
|
||||
// policy the network gate ran first and answered with a prompt, so the operator
|
||||
// was asked to approve network access for a command no approval could make
|
||||
// runnable, and ReasonNestedGitInit, the reason with a remedy in it, was never
|
||||
// shown. Reported by @jatmn.
|
||||
func TestNestedCloneIsRefusedAsNestedUnderTheDefaultNetworkPolicy(t *testing.T) {
|
||||
_, workspace := nestedGitWorkspace(t)
|
||||
policy := DefaultPolicy()
|
||||
if policy.Network != NetworkDeny {
|
||||
t.Fatalf("SETUP INVALID: the default network policy is %q, so this does not exercise the ordering", policy.Network)
|
||||
}
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: policy, Backend: nativeWrappingBackend})
|
||||
|
||||
for _, granted := range []bool{false, true} {
|
||||
request := gitCommandRequest(workspace, "git clone https://example.invalid/repo.git vendor/dep")
|
||||
request.PermissionGranted = granted
|
||||
decision := engine.Evaluate(context.Background(), request)
|
||||
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
|
||||
t.Fatalf("granted=%v: clone in a governed workspace was %s (block %#v, reason %q), want a %s denial", granted, decision.Action, decision.Block, decision.Reason, BlockNestedGitInit)
|
||||
}
|
||||
if decision.Reason != ReasonNestedGitInit {
|
||||
t.Errorf("granted=%v: reason = %q, want the nested-repository reason and its remedy", granted, decision.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROLS. A network command that creates no repository still gets the
|
||||
// network answer, so the reordering did not swallow that gate, and a clone
|
||||
// into a standalone workspace is a network question and nothing else.
|
||||
fetch := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git fetch origin"))
|
||||
if fetch.Action != ActionPrompt || fetch.Reason != ReasonNetworkBlocked {
|
||||
t.Errorf("git fetch in the governed workspace was %s (%q), want the network prompt", fetch.Action, fetch.Reason)
|
||||
}
|
||||
standalone := t.TempDir()
|
||||
if workspaceGovernedByAncestorRepository(standalone) {
|
||||
t.Fatalf("SETUP INVALID: %s is governed by an ancestor repository", standalone)
|
||||
}
|
||||
standaloneEngine := NewEngine(EngineOptions{WorkspaceRoot: standalone, Policy: policy, Backend: nativeWrappingBackend})
|
||||
clone := standaloneEngine.Evaluate(context.Background(), gitCommandRequest(standalone, "git clone https://example.invalid/repo.git ."))
|
||||
if clone.Action != ActionPrompt || clone.Reason != ReasonNetworkBlocked {
|
||||
t.Errorf("clone into a standalone workspace was %s (%q), want the network prompt", clone.Action, clone.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// THE LIMIT OF THIS GUARD, WRITTEN DOWN SO IT IS NOT MISTAKEN FOR COVERAGE.
|
||||
//
|
||||
// The nested-repository rule recognises git creating a repository. It does not
|
||||
// recognise a repository assembled by other means, and it cannot: mkdir and a
|
||||
// few writes, an archive, or a script in any language all produce the same
|
||||
// directory, and none of them is a git command. These legs are allowed today.
|
||||
// They are here so that anyone reading the rule as containment of the .git
|
||||
// pathname finds a test saying otherwise, and so that a change which DOES close
|
||||
// it at the decision layer has to come and update this deliberately.
|
||||
//
|
||||
// It is not a statement that the behaviour is acceptable. A repository built
|
||||
// this way carries whatever config its author wrote, and a plain `git status`
|
||||
// outside the sandbox honours it. That has to be closed where the carveouts
|
||||
// live, at write time. Reported by @jatmn.
|
||||
func TestHandAssembledRepositoryIsNotWhatThisGuardCatches(t *testing.T) {
|
||||
_, workspace := nestedGitWorkspace(t)
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
for _, command := range []string{
|
||||
"mkdir .git",
|
||||
"mkdir -p .git/objects .git/refs && printf 'ref: refs/heads/main\n' > .git/HEAD",
|
||||
"cp -r ../template-repo/.git .git",
|
||||
"python3 -c \"import os; os.makedirs('.git/refs')\"",
|
||||
"tar -xf repo.tar",
|
||||
} {
|
||||
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, command))
|
||||
if decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
|
||||
t.Errorf("%q is now refused as nested repository creation. If that is deliberate, this test and the WHAT THIS IS NOT note in risk.go both need to say what the rule covers now", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A WORKSPACE INSIDE ANOTHER REPOSITORY KEEPS ITS CARVEOUTS.
|
||||
//
|
||||
// This branch skipped them for a while, so that Zero would not create a .git in
|
||||
// a repository it does not own. The cost was worse than the thing avoided:
|
||||
// nothing then denied writes to <root>/.git, so a sandboxed command could build
|
||||
// a repository there by hand and put core.fsmonitor in its config, and Zero runs
|
||||
// git outside the sandbox against that workspace. The nested-repository refusal
|
||||
// recognises git creating a repository; it cannot recognise mkdir.
|
||||
//
|
||||
// TestCarveoutsInsideAnAncestorRepositoryDoNotCaptureDiscovery measures the
|
||||
// reason the skip existed and shows it does not hold.
|
||||
func TestCarveoutsAreSynthesizedInsideAnAncestorRepository(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(parent, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Join(parent, "sub", "project")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// SETUP: the workspace itself has no .git, or this is a different case.
|
||||
if _, err := os.Lstat(filepath.Join(workspace, ".git")); err == nil {
|
||||
t.Fatal("SETUP INVALID: the workspace already carries .git")
|
||||
}
|
||||
if !gitMetadataGovernedByAncestor(workspace) {
|
||||
t.Fatal("SETUP INVALID: the ancestor is not detected, so this proves nothing")
|
||||
}
|
||||
|
||||
specs := gitMetadataWriteCarveoutSpecs(workspace)
|
||||
want := map[string]bool{
|
||||
filepath.Join(workspace, ".git", "config"): true,
|
||||
filepath.Join(workspace, ".git", "hooks"): true,
|
||||
}
|
||||
for _, spec := range specs {
|
||||
delete(want, spec.Path)
|
||||
}
|
||||
for path := range want {
|
||||
t.Errorf("a workspace inside another repository gets no carveout for %s, so nothing denies writing it", path)
|
||||
}
|
||||
}
|
||||
|
||||
// The reason the skip existed, measured rather than argued: materializing only
|
||||
// config and hooks does not make the directory a repository, so git's discovery
|
||||
// walk still reaches the ancestor and a config planted there is never read.
|
||||
// If a future git changes that, this fails and the trade has to be reopened.
|
||||
func TestCarveoutsInsideAnAncestorRepositoryDoNotCaptureDiscovery(t *testing.T) {
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Skip("no git on PATH")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
run := func(dir string, args ...string) (string, error) {
|
||||
command := exec.Command(git, args...)
|
||||
command.Dir = dir
|
||||
command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+parent, "USERPROFILE="+parent)
|
||||
out, err := command.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
if out, err := run(parent, "init", "-q", "."); err != nil {
|
||||
t.Skipf("cannot create the ancestor repository: %v: %s", err, out)
|
||||
}
|
||||
workspace := filepath.Join(parent, "sub", "project")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ancestor, err := run(workspace, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
t.Skipf("cannot resolve the ancestor: %v: %s", err, ancestor)
|
||||
}
|
||||
|
||||
// Materialize exactly what the plan materializes, and make the config as
|
||||
// hostile as a sandboxed command would if it could write one.
|
||||
for _, spec := range gitMetadataWriteCarveoutSpecs(workspace) {
|
||||
if spec.IsFile {
|
||||
if err := os.MkdirAll(filepath.Dir(spec.Path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(spec.Path, []byte("[core]\n\tfsmonitor = \"exit 9\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(spec.Path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
after, err := run(workspace, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
t.Fatalf("git stopped working in the workspace after the carveouts were materialized: %v: %s", err, after)
|
||||
}
|
||||
if after != ancestor {
|
||||
t.Fatalf("discovery moved from %s to %s: the materialized carveouts captured the walk, which is the trade this test exists to watch", ancestor, after)
|
||||
}
|
||||
if out, err := run(workspace, "status", "--porcelain"); err != nil {
|
||||
t.Fatalf("git status failed in the workspace, so the planted config was read: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// And a standalone directory keeps the materialized carveouts, or the guard
|
||||
// above would be satisfied by returning nothing everywhere and the protection
|
||||
// would be gone for the case it was written for.
|
||||
func TestCarveoutsStillCoverAStandaloneDirectory(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
if gitMetadataGovernedByAncestor(workspace) {
|
||||
t.Skip("this temp directory sits inside a repository, so it is not the standalone case")
|
||||
}
|
||||
|
||||
specs := gitMetadataWriteCarveoutSpecs(workspace)
|
||||
wantConfig := filepath.Join(workspace, ".git", "config")
|
||||
wantHooks := filepath.Join(workspace, ".git", "hooks")
|
||||
var sawConfig, sawHooks bool
|
||||
for _, spec := range specs {
|
||||
switch spec.Path {
|
||||
case wantConfig:
|
||||
sawConfig = true
|
||||
if !spec.IsFile {
|
||||
t.Errorf("%s is planned as a directory; creating .git/config as a directory makes git init fail", spec.Path)
|
||||
}
|
||||
case wantHooks:
|
||||
sawHooks = true
|
||||
}
|
||||
}
|
||||
if !sawConfig || !sawHooks {
|
||||
t.Fatalf("a standalone directory lost its carveouts: config=%t hooks=%t specs=%+v", sawConfig, sawHooks, specs)
|
||||
}
|
||||
}
|
||||
|
||||
// A linked worktree is unchanged: the pointer file is denied and nothing beneath
|
||||
// it is named, which is what keeps elevated setup from descending through a
|
||||
// regular file.
|
||||
func TestCarveoutsStillDenyTheLinkedWorktreePointer(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
pointer := filepath.Join(workspace, ".git")
|
||||
if err := os.WriteFile(pointer, []byte("gitdir: ../real/.git/worktrees/w\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs := gitMetadataWriteCarveoutSpecs(workspace)
|
||||
if len(specs) != 1 || specs[0].Path != pointer || !specs[0].IsFile {
|
||||
t.Fatalf("linked worktree carveouts = %+v, want exactly the file-shaped pointer %s", specs, pointer)
|
||||
}
|
||||
}
|
||||
|
||||
// The ancestor test itself has to distinguish shapes, because a linked worktree
|
||||
// or submodule ancestor carries .git as a FILE and still owns this directory.
|
||||
func TestAncestorDetectionAcceptsBothGitShapes(t *testing.T) {
|
||||
for _, shape := range []struct {
|
||||
name string
|
||||
make func(t *testing.T, parent string)
|
||||
}{
|
||||
{name: "directory", make: func(t *testing.T, parent string) {
|
||||
if err := os.MkdirAll(filepath.Join(parent, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "pointer file", make: func(t *testing.T, parent string) {
|
||||
if err := os.WriteFile(filepath.Join(parent, ".git"), []byte("gitdir: elsewhere\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(shape.name, func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
shape.make(t, parent)
|
||||
workspace := filepath.Join(parent, "nested", "deep")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !gitMetadataGovernedByAncestor(workspace) {
|
||||
t.Fatalf("an ancestor carrying .git as a %s was not recognised as governing %s", shape.name, workspace)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A NESTED WORKSPACE THAT OWNS ITS OWN REPOSITORY KEEPS ITS OWN CARVEOUTS.
|
||||
//
|
||||
// The ancestor branch above exists so Zero does not synthesize a control
|
||||
// directory inside somebody else's repository. It was asked unconditionally,
|
||||
// so a workspace that already HAS its own .git lost the hooks and config
|
||||
// carveouts too — and those are its own metadata, not the ancestor's. Nothing
|
||||
// compensated: workspaceGovernedByAncestorRepository answers the same question
|
||||
// by testing for a local .git, so it reported this workspace as not governed
|
||||
// and the initialization refusal never fired. Commands then received plain
|
||||
// workspace write access over a live .git/config and .git/hooks, which is
|
||||
// exactly the configuration that decides what git executes. Reported by
|
||||
// @jatmn.
|
||||
//
|
||||
// Asserted through the profile as well as the spec helper, because the spec
|
||||
// list is only load-bearing if it reaches ReadOnlySubpaths.
|
||||
func TestNestedWorkspaceOwningGitKeepsItsOwnCarveouts(t *testing.T) {
|
||||
outer := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(outer, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Join(outer, "project")
|
||||
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantHooks := filepath.Join(workspace, ".git", "hooks")
|
||||
wantConfig := filepath.Join(workspace, ".git", "config")
|
||||
specs := gitMetadataWriteCarveoutSpecs(workspace)
|
||||
foundHooks, foundConfig := false, false
|
||||
for _, spec := range specs {
|
||||
switch {
|
||||
case spec.Path == wantHooks && !spec.IsFile:
|
||||
foundHooks = true
|
||||
case spec.Path == wantConfig && spec.IsFile:
|
||||
foundConfig = true
|
||||
}
|
||||
}
|
||||
if !foundHooks || !foundConfig {
|
||||
t.Fatalf("a workspace owning its own .git lost its carveouts: hooks=%v config=%v specs=%+v",
|
||||
foundHooks, foundConfig, specs)
|
||||
}
|
||||
|
||||
// The specs only matter if they reach the profile the runner enforces.
|
||||
profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil)
|
||||
var root *WritableRoot
|
||||
for index := range profile.FileSystem.WriteRoots {
|
||||
if sameGitTestPath(profile.FileSystem.WriteRoots[index].Root, workspace) {
|
||||
root = &profile.FileSystem.WriteRoots[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if root == nil {
|
||||
t.Fatalf("no writable root for the workspace: %+v", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
for _, want := range []string{wantHooks, wantConfig} {
|
||||
covered := false
|
||||
for _, sub := range root.ReadOnlySubpaths {
|
||||
if sameGitTestPath(sub, want) {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
t.Errorf("%s is not read-only in the workspace write root: %+v", want, root.ReadOnlySubpaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compared through the profile's own canonicalization. PermissionProfileFromPolicy
|
||||
// stores normalizeProfilePath(root), which resolves symlinks, so a raw t.TempDir()
|
||||
// path does not match it on a runner where the temp directory is reached through
|
||||
// one: macOS /var -> /private/var, and the Windows short-name expansion of the
|
||||
// runner's profile directory. Comparing cleaned strings passed locally and failed
|
||||
// on both CI runners for that reason alone.
|
||||
func sameGitTestPath(a, b string) bool {
|
||||
return normalizeProfilePath(a) == normalizeProfilePath(b)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func pointerWorkspace(t *testing.T) (workspace string, pointer string) {
|
||||
t.Helper()
|
||||
workspace = t.TempDir()
|
||||
pointer = filepath.Join(workspace, ".git")
|
||||
if err := os.WriteFile(pointer, []byte("gitdir: ../real/.git/worktrees/w\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return workspace, pointer
|
||||
}
|
||||
|
||||
func pointerPlanConfig(t *testing.T, workspace string) WindowsSandboxCommandConfig {
|
||||
t.Helper()
|
||||
return WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// THE SHAPE HAS TO SURVIVE THE STAGE BOUNDARY, NOT JUST BE CORRECT AT STAGE ONE.
|
||||
//
|
||||
// gitMetadataWriteCarveoutSpecs types a linked worktree's .git as a file. That
|
||||
// typed result was flattened to a path before ACL planning, and the planner tried
|
||||
// to recover the shape by deriving suffixes under a sentinel root whose .git never
|
||||
// exists. The derivation therefore always took the directory branch, and a bare
|
||||
// <root>\.git could never match as a file.
|
||||
//
|
||||
// The window that turns a wrong plan into damage is the caller-to-elevated-helper
|
||||
// gap: the profile is built in the user's shell and the plan is applied in a
|
||||
// separately launched helper, across the UAC prompt. If the pointer goes away in
|
||||
// between, the applier materializes a DIRECTORY at the pointer path and the
|
||||
// worktree is broken, persistently, because the runner discards the rollback.
|
||||
//
|
||||
// Driven through BuildWindowsACLPlan and applyWindowsACLPlan with the pointer
|
||||
// removed after planning, which is the only place this is visible. A stage-one
|
||||
// assertion on specs[0].IsFile passed throughout the whole time the bug was live.
|
||||
func TestALinkedWorktreePointerIsNeverMaterializedAsADirectory(t *testing.T) {
|
||||
workspace, pointer := pointerWorkspace(t)
|
||||
plan, err := BuildWindowsACLPlan(pointerPlanConfig(t, workspace))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
|
||||
// SETUP: the plan really does carry the pointer, typed as a file.
|
||||
var found bool
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Path == pointer && entry.Action == WindowsACLDenyWrite {
|
||||
found = true
|
||||
if !entry.MaterializeFile {
|
||||
t.Fatalf("the plan types %s as a directory; applying it would replace the worktree pointer with a directory", pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("SETUP INVALID: no deny-write entry for the pointer %s: %+v", pointer, plan.Entries)
|
||||
}
|
||||
|
||||
// The window: the pointer goes away between planning and applying.
|
||||
if err := os.Remove(pointer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
t.Skipf("cannot apply an ACL plan here: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Lstat(pointer)
|
||||
if err != nil {
|
||||
// Nothing recreated is also acceptable: what must not happen is a directory.
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Fatalf("a directory was created at the worktree pointer %s, which breaks the worktree", pointer)
|
||||
}
|
||||
}
|
||||
|
||||
// Control: with the pointer present at apply, it stays a file and is not
|
||||
// replaced. Without this, the assertion above could be satisfied by an apply
|
||||
// that does nothing at all.
|
||||
func TestALinkedWorktreePointerSurvivesAnApply(t *testing.T) {
|
||||
workspace, pointer := pointerWorkspace(t)
|
||||
plan, err := BuildWindowsACLPlan(pointerPlanConfig(t, workspace))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
t.Skipf("cannot apply an ACL plan here: %v", err)
|
||||
}
|
||||
info, err := os.Lstat(pointer)
|
||||
if err != nil {
|
||||
t.Fatalf("the pointer vanished across the apply: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Fatalf("the pointer at %s became a directory", pointer)
|
||||
}
|
||||
body, err := os.ReadFile(pointer)
|
||||
if err != nil || len(body) == 0 {
|
||||
t.Fatalf("the pointer lost its contents: body=%q err=%v", body, err)
|
||||
}
|
||||
}
|
||||
+195
-14
@@ -96,28 +96,209 @@ var protectedMetadataNames = []string{".git", ".zero", ".agents"}
|
||||
// gitMetadataWriteCarveouts below.
|
||||
var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"}
|
||||
|
||||
// gitMetadataWriteCarveouts returns the .git paths that stay write-denied under
|
||||
// the OS-level sandbox even though the rest of .git is writable to git
|
||||
// subprocesses. Worktrees and submodules store .git as a regular pointer file,
|
||||
// so they protect that file itself: constructing .git/hooks below it makes
|
||||
// bwrap fail before the command starts. All other entry and error states retain
|
||||
// the legacy child paths. The pointer is deliberately not parsed here;
|
||||
// repository-controlled metadata must not redirect sandbox rules elsewhere.
|
||||
// sandboxRenameProtectedMetadataName is the metadata directory that cannot be
|
||||
// fully write-protected but must still not be REPLACEABLE.
|
||||
//
|
||||
// It is deliberately not in the list above. That list denies write, and git has
|
||||
// to write index, objects and refs. But the carveouts guarding it are attached
|
||||
// to .git/config and .git/hooks as objects, so a principal that renames .git and
|
||||
// recreates it gets fresh paths inheriting the workspace allow with no denies,
|
||||
// which restores credential.helper and core.hooksPath. The Windows ACL plan
|
||||
// therefore denies DELETE on this directory alone, uninherited.
|
||||
const sandboxRenameProtectedMetadataName = ".git"
|
||||
|
||||
// gitMetadataWriteCarveouts returns the .git subpaths that stay write-denied
|
||||
// under the OS-level sandbox even though the rest of .git is writable to git
|
||||
// subprocesses. Nonexistent paths are harmless no-ops in every backend's
|
||||
// enforcement (seatbelt regex, bwrap ro-bind, Windows ACL deny entry).
|
||||
func gitMetadataWriteCarveouts(root string) []string {
|
||||
return gitMetadataWriteCarveoutsWithLstat(root, os.Lstat)
|
||||
}
|
||||
|
||||
// gitMetadataWriteCarveoutsWithLstat is the seam the worktree tests drive.
|
||||
// Which carveouts apply is decided by the SHAPE of .git, and a test cannot
|
||||
// create every shape it needs to cover: an lstat that fails is a real state
|
||||
// (a racing checkout, a permission error) with its own required answer, and
|
||||
// there is no filesystem it can be staged on.
|
||||
func gitMetadataWriteCarveoutsWithLstat(root string, lstat func(string) (os.FileInfo, error)) []string {
|
||||
specs := gitMetadataWriteCarveoutSpecsWithLstat(root, lstat)
|
||||
out := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
out = append(out, spec.Path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gitMetadataCarveout is a write-denied .git path together with the shape git
|
||||
// expects it to have. The shape matters to exactly one backend: the Windows ACL
|
||||
// plan creates a missing carveout so the deny ACE is in place before git first
|
||||
// runs, and creating .git/config as a directory makes `git init` fail outright.
|
||||
// Every other backend only ever names the path, so it can ignore IsFile.
|
||||
type gitMetadataCarveout struct {
|
||||
Path string
|
||||
IsFile bool
|
||||
}
|
||||
|
||||
// gitMetadataWriteCarveoutSpecs is the single source of truth for the carveout
|
||||
// set. gitMetadataWriteCarveouts derives its list from this so a path can never
|
||||
// be added in one place and have its shape forgotten in the other.
|
||||
func gitMetadataWriteCarveoutSpecs(root string) []gitMetadataCarveout {
|
||||
return gitMetadataWriteCarveoutSpecsWithLstat(root, os.Lstat)
|
||||
}
|
||||
|
||||
func gitMetadataWriteCarveoutSpecsWithLstat(root string, lstat func(string) (os.FileInfo, error)) []gitMetadataCarveout {
|
||||
gitPath := filepath.Join(root, ".git")
|
||||
children := []string{
|
||||
filepath.Join(gitPath, "hooks"),
|
||||
filepath.Join(gitPath, "config"),
|
||||
// A LINKED WORKTREE OR SUBMODULE HAS .git AS A FILE, NOT A DIRECTORY.
|
||||
//
|
||||
// It holds a `gitdir:` pointer to the real control directory, which lives
|
||||
// outside this write root. Naming .git/config and .git/hooks there asks the
|
||||
// Windows plan to materialize them by descending through .git as a directory,
|
||||
// which cannot open a child beneath a regular file, so opted-in elevated setup
|
||||
// aborts and the sandbox is unusable in any worktree. Zero's own development
|
||||
// worktrees are exactly this shape.
|
||||
//
|
||||
// Deny the pointer file itself instead. It is the right protection rather than
|
||||
// a lesser one: a principal that can rewrite `gitdir:` repoints the whole
|
||||
// repository at a control directory it chooses, which subsumes editing config
|
||||
// or dropping a hook. The real control directory is outside the write root, so
|
||||
// the principal has no inherited access to it and needs no carveout there.
|
||||
//
|
||||
// Stat, not a lexical guess. Whether .git is a file is a property of the
|
||||
// checkout, and the two layouts want different ACEs. A missing .git (git has
|
||||
// not run yet) keeps the directory-shaped carveouts, which is what makes them
|
||||
// materialize before git first runs.
|
||||
//
|
||||
// A REGULAR FILE, NOT MERELY A NON-DIRECTORY. The pointer this recognises is
|
||||
// the `gitdir:` file git writes for a linked worktree or submodule. A symlink
|
||||
// named .git, dangling or not, and a special entry such as a fifo are neither
|
||||
// that nor a checkout git will use, so they keep the legacy child carveouts
|
||||
// rather than being handed the stronger single-object protection on the
|
||||
// strength of not being a directory.
|
||||
gitInfo, gitErr := lstat(gitPath)
|
||||
if gitErr == nil && gitInfo.Mode().IsRegular() {
|
||||
return []gitMetadataCarveout{{Path: gitPath, IsFile: true}}
|
||||
}
|
||||
info, err := lstat(gitPath)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return children
|
||||
// EVERY WRITE ROOT GETS THE CARVEOUTS, INCLUDING ONE INSIDE ANOTHER REPOSITORY.
|
||||
//
|
||||
// The directory-shaped carveouts below are materialized by the Windows plan so
|
||||
// the deny ACE is in place before git first runs. For a while this branch
|
||||
// skipped them when an ancestor owned the git metadata, on the argument that
|
||||
// creating .git/config and .git/hooks there synthesizes a control directory
|
||||
// competing with the ancestor's for git's discovery walk. Measured against the
|
||||
// git this repository builds against, it does not: a .git holding only config
|
||||
// and hooks is not a valid repository, so `rev-parse --show-toplevel` still
|
||||
// answers with the ancestor, `status` still works, and a core.fsmonitor
|
||||
// written into that config is never executed. The test named below drives real
|
||||
// git and fails if that ever stops being true.
|
||||
//
|
||||
// Skipping them cost much more than it saved. With no carveout on this root
|
||||
// nothing denies writes to <root>/.git at all, so a sandboxed command can
|
||||
// assemble a repository there by hand, with mkdir and a few file writes and
|
||||
// not one git command among them, and put core.fsmonitor in its config. Zero
|
||||
// then runs `git -C <workspace> status` OUTSIDE the sandbox (scratch_files.go,
|
||||
// internal/repoinfo, internal/zerogit) and git executes it. The nested-repository
|
||||
// refusal in risk.go does not cover that and cannot: no static reading of a
|
||||
// shell command enumerates the ways to create a directory. It stays as the
|
||||
// layer that explains itself to an operator, not as the boundary.
|
||||
//
|
||||
// So the boundary is here, where it is enforced at write time: config and
|
||||
// hooks are denied on every write root, whatever owns the metadata above it.
|
||||
return []gitMetadataCarveout{
|
||||
{Path: filepath.Join(root, ".git", "hooks")},
|
||||
{Path: filepath.Join(root, ".git", "config"), IsFile: true},
|
||||
}
|
||||
return []string{gitPath}
|
||||
}
|
||||
|
||||
// workspaceGovernedByAncestorRepository is the same question the carveouts ask,
|
||||
// named for the caller that asks it about a whole workspace rather than about
|
||||
// one write root. Sharing the rule is the point: the reason a workspace gets no
|
||||
// carveouts is exactly the reason it may not create a repository.
|
||||
func workspaceGovernedByAncestorRepository(workspaceRoot string) bool {
|
||||
cleaned := strings.TrimSpace(workspaceRoot)
|
||||
if cleaned == "" {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(cleaned, ".git")); err == nil {
|
||||
// It already owns git metadata, so setup protected it and nothing here is
|
||||
// being created inside a repository that is not this workspace's.
|
||||
return false
|
||||
}
|
||||
return gitMetadataGovernedByAncestor(cleaned)
|
||||
}
|
||||
|
||||
// gitMetadataGovernedByAncestor reports whether an ancestor of root carries git
|
||||
// metadata, which is git's own discovery rule: the nearest ancestor with a .git
|
||||
// entry owns this directory.
|
||||
//
|
||||
// Lstat rather than a resolved walk, because a .git of either shape answers the
|
||||
// question: a directory is an ordinary repository root and a file is a linked
|
||||
// worktree or submodule pointer. Both mean the metadata is not ours to create.
|
||||
func gitMetadataGovernedByAncestor(root string) bool {
|
||||
current := filepath.Clean(strings.TrimSpace(root))
|
||||
if current == "" || current == "." {
|
||||
return false
|
||||
}
|
||||
for {
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(parent, ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
// gitMetadataCarveoutSuffixBase is a sentinel root used only to recover the
|
||||
// trailing segments of the carveout specs. It is never touched on disk.
|
||||
const gitMetadataCarveoutSuffixBase = string(filepath.Separator) + "zero-carveout-base"
|
||||
|
||||
// gitMetadataCarveoutIsFile reports whether path names a carveout git expects
|
||||
// to be a file.
|
||||
//
|
||||
// It matches on the trailing segments rather than on a whole reconstructed
|
||||
// path. The subpaths reaching the ACL plan are already normalized — resolved
|
||||
// through EvalSymlinks where that succeeds — while a rebuilt spec path cannot
|
||||
// be, because .git/config does not exist yet at setup and resolution falls back
|
||||
// to a plain Clean. On a host where two spellings of the same path differ (an
|
||||
// 8.3 short name, different casing) a whole-path equality check silently misses
|
||||
// and the carveout is created as a directory again, which is the original bug
|
||||
// reintroduced quietly. The suffix cannot drift from the spec list because it
|
||||
// is derived from it.
|
||||
func gitMetadataCarveoutIsFile(path string) bool {
|
||||
candidate := strings.ToLower(filepath.Clean(strings.TrimSpace(path)))
|
||||
if candidate == "" {
|
||||
return false
|
||||
}
|
||||
// A BARE .git CARVEOUT IS THE LINKED-WORKTREE POINTER, AND ONLY THAT.
|
||||
//
|
||||
// The suffix derivation below runs specs against a sentinel root whose .git
|
||||
// never exists, so it always takes the directory branch and yields exactly one
|
||||
// file-shaped suffix, .gitconfig. A bare <root>.git could therefore never
|
||||
// match, and both planners emitted MaterializeFile:false for a path the profile
|
||||
// stage had already typed correctly as a file. With the pointer absent at apply
|
||||
// time, the plan then created a DIRECTORY at the pointer path and broke the
|
||||
// worktree.
|
||||
//
|
||||
// The carveout SET already carries the answer. gitMetadataWriteCarveoutSpecs is
|
||||
// the only producer of ReadOnlySubpaths, and it emits a bare .git in exactly one
|
||||
// case: the linked-worktree or submodule pointer. The directory case emits
|
||||
// .githooks and .gitconfig and never the parent. So the shape is not being
|
||||
// guessed from a pathname here, it is being read off which carveout this is.
|
||||
if strings.EqualFold(filepath.Base(candidate), ".git") {
|
||||
return true
|
||||
}
|
||||
for _, spec := range gitMetadataWriteCarveoutSpecs(gitMetadataCarveoutSuffixBase) {
|
||||
if !spec.IsFile {
|
||||
continue
|
||||
}
|
||||
suffix := strings.ToLower(strings.TrimPrefix(spec.Path, gitMetadataCarveoutSuffixBase))
|
||||
if suffix != "" && strings.HasSuffix(candidate, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A LINKED WORKTREE HAS .git AS A FILE.
|
||||
//
|
||||
// The Windows principal plan materializes every carveout, and it does so by
|
||||
// descending through .git as a directory. Against a `gitdir:` pointer file that
|
||||
// walk cannot open a child, so opted-in elevated setup aborts and the sandbox
|
||||
// cannot be used in a worktree at all. Zero's own development worktrees have
|
||||
// exactly this layout, so this is the common case for anyone working on the
|
||||
// sandbox itself.
|
||||
func TestGitCarveoutsHandleAWorktreeGitfile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
gitFile := filepath.Join(root, ".git")
|
||||
// The real shape git writes for a linked worktree.
|
||||
if err := os.WriteFile(gitFile, []byte("gitdir: "+filepath.Join(root, "..", "main", ".git", "worktrees", "wt")+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
specs := gitMetadataWriteCarveoutSpecs(root)
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("carveout specs = %#v, want only the .git pointer file", specs)
|
||||
}
|
||||
if filepath.Clean(specs[0].Path) != filepath.Clean(gitFile) {
|
||||
t.Fatalf("carveout path = %s, want the .git pointer file %s", specs[0].Path, gitFile)
|
||||
}
|
||||
if !specs[0].IsFile {
|
||||
t.Fatal("the .git pointer carveout is not marked as a file, so materialization would create a directory where git needs a file")
|
||||
}
|
||||
// Nothing may name a path BENEATH the pointer file, which is what forces the
|
||||
// directory descent that aborts setup.
|
||||
for _, path := range gitMetadataWriteCarveouts(root) {
|
||||
rest := strings.TrimPrefix(filepath.Clean(path), filepath.Clean(gitFile))
|
||||
if rest != "" {
|
||||
t.Errorf("carveout %s descends through the .git pointer file, which cannot have children", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary repository layout must keep the config and hooks carveouts. They
|
||||
// are the whole reason the set exists: without them a principal reinstates
|
||||
// credential.helper or core.hooksPath through inherited workspace write access.
|
||||
func TestGitCarveoutsKeepConfigAndHooksForARealGitDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
paths := gitMetadataWriteCarveouts(root)
|
||||
for _, want := range []string{filepath.Join(root, ".git", "hooks"), filepath.Join(root, ".git", "config")} {
|
||||
found := false
|
||||
for _, got := range paths {
|
||||
if filepath.Clean(got) == filepath.Clean(want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("carveout %s missing for an ordinary .git directory: %#v", want, paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace where git has never run keeps the directory-shaped carveouts. That
|
||||
// is what lets the Windows plan create them before git first runs, so the deny
|
||||
// ACEs are already in place rather than being applied to paths git will create
|
||||
// later with inherited write access.
|
||||
func TestGitCarveoutsKeepConfigAndHooksWhenGitIsAbsent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := gitMetadataWriteCarveouts(root)
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("carveouts = %#v, want the config and hooks pair when .git is absent", paths)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,45 @@ func classifyWithScope(request Request, scope *Scope) Risk {
|
||||
if analysis.Destructive {
|
||||
add("destructive", RiskCritical)
|
||||
}
|
||||
// A REPOSITORY CREATED INSIDE SOMEBODY ELSE'S IS NOT PROTECTABLE LATER.
|
||||
//
|
||||
// gitMetadataWriteCarveoutSpecs returns NOTHING for a workspace governed by
|
||||
// an ancestor repository, on every backend. That is deliberate: naming
|
||||
// <root>/.git/config and <root>/.git/hooks makes the Windows plan create a
|
||||
// control directory, and the bubblewrap helper mount one, that competes with
|
||||
// the ancestor's for git's discovery walk inside a repository Zero does not
|
||||
// own. So the workspace carries no config or hooks protection at all.
|
||||
//
|
||||
// A repository created here DURING the command therefore lands under the
|
||||
// plain workspace write grant with nothing denying credential.helper or
|
||||
// core.hooksPath, and on Windows nothing denying DELETE on .git either. The
|
||||
// serialized plan never changed, so the cached setup marker stays valid and
|
||||
// the next run will not notice.
|
||||
//
|
||||
// Refused rather than protected, because the protection would have to be
|
||||
// established at a moment the sandbox is no longer at.
|
||||
//
|
||||
// WHAT THIS IS NOT. It refuses GIT creating a repository, by every spelling
|
||||
// git offers. It is not containment of the .git pathname. A command that
|
||||
// assembles a repository by other means (mkdir and a few file writes, an
|
||||
// archive, a script in any language) is not recognised here, and no static
|
||||
// reading of a shell command can recognise all of them, so this rule must
|
||||
// not be described or relied on as the boundary. A repository assembled
|
||||
// that way carries whatever config its author wrote, and an ordinary
|
||||
// `git status` run outside the sandbox honours it, core.fsmonitor included.
|
||||
// Closing that is the carveouts' job, at write time, not this rule's.
|
||||
// TestHandAssembledRepositoryIsNotWhatThisGuardCatches pins the limit so it
|
||||
// cannot be mistaken for coverage. Reported by @jatmn.
|
||||
//
|
||||
// The condition is the WORKSPACE, not the directory the command names.
|
||||
// Resolving that directory would mean tracking -C and cwd through the
|
||||
// script, which is precisely the option-parsing surface that let
|
||||
// "git -C sub clone" past the network gate. Refusing a git init outside the
|
||||
// workspace too is the conservative side of that trade, and the reason text
|
||||
// says so.
|
||||
if analysis.GitInit && workspaceGovernedByAncestorRepository(request.WorkspaceRoot) {
|
||||
add("nested_git_init", RiskCritical)
|
||||
}
|
||||
if analysis.TooComplex {
|
||||
add("unparseable_command", RiskHigh)
|
||||
}
|
||||
|
||||
@@ -36,10 +36,14 @@ func TestWindowsRestrictedTokenRealSandboxSmoke(t *testing.T) {
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
config := WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
PermissionProfile: profile,
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
// The setup builder folds the runtime roots into its profile before the
|
||||
// helper sees it; the command builder does not. Passing the bare profile
|
||||
// here made every command disagree with the marker setup wrote, nine
|
||||
// entries against five, before the first write probe ran.
|
||||
PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{root}),
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
}
|
||||
runWindowsRealSmokeSetup(t, setupExe, WindowsSandboxSetupArgsOptions{
|
||||
@@ -393,6 +397,15 @@ func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) strin
|
||||
|
||||
func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSandboxSetupArgsOptions) {
|
||||
t.Helper()
|
||||
// options.PrincipalOptIn is deliberately left nil by both call sites, which
|
||||
// makes BuildWindowsSandboxSetupArgs resolve the opt-in from this process's
|
||||
// environment — the same value the command half resolves, since the smoke
|
||||
// WindowsSandboxCommandArgsOptions carries no explicit entry either. Do not
|
||||
// "fix" this by setting it to false: anyone running this suite with
|
||||
// ZERO_WINDOWS_SANDBOX_IDENTITY=1 (the only way to exercise the principal
|
||||
// backend) would then serialize `--sandbox-principal 0`, disagree with the
|
||||
// command half, and fail every command at marker validation instead of
|
||||
// testing the sandbox.
|
||||
args, err := BuildWindowsSandboxSetupArgs(options)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// physicalTempDir returns os.TempDir with every symlink in its ancestry
|
||||
// resolved, so the fallback anchor's parent chain is physical and only the
|
||||
// anchor itself is a new component.
|
||||
//
|
||||
// peermsg.EnsurePrivateDir walks from the root with O_NOFOLLOW and refuses any
|
||||
// component that is a link. That is right for the owned tail and wrong for the
|
||||
// ancestors above it: on macOS os.TempDir lives under /var, which is a symlink
|
||||
// to /private/var, so the first version of this fix refused every fallback on
|
||||
// every Mac with "refusing non-directory or symlink runtime path component
|
||||
// var". The constraint jatmn stated for #901 applies here identically:
|
||||
// redirected cache and TEMP locations above the owned tail are the operator's
|
||||
// business and must keep working; the restriction is on what Zero owns.
|
||||
//
|
||||
// EvalSymlinks is the correct resolver off Windows, where the only reparse
|
||||
// shape is a symlink and it traverses them.
|
||||
func physicalTempDir() (string, error) {
|
||||
return physicalDir(os.TempDir())
|
||||
}
|
||||
|
||||
// physicalDir resolves any directory the same way, for the other places that
|
||||
// have to hand EnsurePrivateDir a physical parent.
|
||||
func physicalDir(path string) (string, error) {
|
||||
return filepath.EvalSymlinks(path)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// physicalTempDir returns os.TempDir as the object it actually names, so the
|
||||
// fallback anchor's parent chain is physical and only the anchor itself is a
|
||||
// new component.
|
||||
//
|
||||
// peermsg.EnsurePrivateDir walks from the volume root with OBJ_DONT_REPARSE and
|
||||
// refuses any component that is a reparse point. That is right for the owned
|
||||
// tail and wrong for the ancestors above it: a redirected %LOCALAPPDATA% is an
|
||||
// ordinary Windows configuration, and TEMP sits beneath it. The constraint
|
||||
// jatmn stated for #901 applies here identically: redirected cache and TEMP
|
||||
// locations above the owned tail must keep working; the restriction is on what
|
||||
// Zero owns.
|
||||
//
|
||||
// GetFinalPathNameByHandle rather than filepath.EvalSymlinks, because
|
||||
// EvalSymlinks does not traverse a junction on Windows: it returns the
|
||||
// junction's own path and the walker then refuses it. The handle answers where
|
||||
// the directory actually is. Same recipe as verifyWindowsACLTargetNotRedirected,
|
||||
// which is why the flag constants and the prefix trim are shared.
|
||||
func physicalTempDir() (string, error) {
|
||||
return physicalDir(os.TempDir())
|
||||
}
|
||||
|
||||
// physicalDir resolves any directory through a handle, for the other places
|
||||
// that have to hand EnsurePrivateDir a physical parent. GetFinalPathNameByHandle
|
||||
// rather than EvalSymlinks, because EvalSymlinks does not traverse a junction.
|
||||
func physicalDir(path string) (string, error) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode directory %s: %w", path, err)
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open directory %s: %w", path, err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
buffer := make([]uint16, windows.MAX_LONG_PATH)
|
||||
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), windowsFileNameNormalized|windowsVolumeNameDOS)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve directory %s: %w", path, err)
|
||||
}
|
||||
if int(n) < len(buffer) {
|
||||
buffer = buffer[:n]
|
||||
}
|
||||
return trimWindowsExtendedPathPrefix(windows.UTF16ToString(buffer)), nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// DETERMINISM IS NOT OWNERSHIP.
|
||||
//
|
||||
// The fallback runtime root moved from a fresh os.MkdirTemp parent to a
|
||||
// predictable name so that elevated setup and the later command agree on it.
|
||||
// That is required. But a predictable path beneath the shared system temp is
|
||||
// not a private allocation: whatever another local user, or a stray earlier
|
||||
// process, has already placed at that name is what preparation then leases,
|
||||
// creates, chmods and cleans through. The anchor beneath which the tree lives
|
||||
// must therefore be proven to be this user's private directory before anything
|
||||
// is built under it, on BOTH paths that can hand back a fallback root.
|
||||
//
|
||||
// Junctions need no privilege on Windows, so the "attacker-precreated parent"
|
||||
// jatmn described is modelled as a junction at the anchor pointing into a
|
||||
// directory the test owns and watches. Either trigger must refuse, and the
|
||||
// watched directory must stay empty.
|
||||
|
||||
// fallbackAnchorFixture redirects TEMP so the anchor lands inside a directory
|
||||
// this test owns, plants a junction at the anchor into a watched target, and
|
||||
// returns the target. The junction is the shape that defeats a pathname walk:
|
||||
// it is not a symlink to os.ModeSymlink, and EvalSymlinks will not traverse it.
|
||||
func fallbackAnchorFixture(t *testing.T) (target string) {
|
||||
t.Helper()
|
||||
tempRoot := t.TempDir()
|
||||
target = t.TempDir()
|
||||
t.Setenv("TMP", tempRoot)
|
||||
t.Setenv("TEMP", tempRoot)
|
||||
|
||||
// The anchor's parent is the PHYSICAL temp dir, so compare against the
|
||||
// resolved form of the redirect rather than its spelling: t.TempDir can
|
||||
// come back as an 8.3 short name or in a different case from what the
|
||||
// handle reports, and a prefix check on the raw string would fail for a
|
||||
// correct anchor.
|
||||
anchor := fallbackRuntimeAnchor()
|
||||
resolvedTempRoot, err := physicalTempDir()
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: cannot resolve the redirected TEMP %s: %v", tempRoot, err)
|
||||
}
|
||||
if !pathWithinRoot(resolvedTempRoot, anchor) {
|
||||
t.Fatalf("SETUP INVALID: anchor %s is not under the redirected TEMP %s (resolved %s)", anchor, tempRoot, resolvedTempRoot)
|
||||
}
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", anchor, target).CombinedOutput(); err != nil {
|
||||
t.Fatalf("mklink /J: %v\n%s", err, out)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func assertNothingUnder(t *testing.T, target string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Errorf("preparation built %v beneath the junction target, which is somebody else's directory", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger one: the user cache resolves INSIDE the workspace, so
|
||||
// sandboxRuntimeRootFor itself returns the fallback root and the first lease
|
||||
// attempt is already against it.
|
||||
func TestFallbackAnchorIsRefusedWhenTheCacheLandsInsideTheWorkspace(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
target := fallbackAnchorFixture(t)
|
||||
workspace := t.TempDir()
|
||||
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
|
||||
|
||||
_, release, err := prepareSandboxRuntime(workspace)
|
||||
if err == nil {
|
||||
release()
|
||||
t.Fatal("preparation went through a junction at the fallback anchor and reported success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "fallback anchor") {
|
||||
t.Errorf("the refusal does not name the anchor, so the operator cannot act on it: %v", err)
|
||||
}
|
||||
assertNothingUnder(t, target)
|
||||
}
|
||||
|
||||
// Trigger two: the cache root cannot be leased (a FILE sits where the cache
|
||||
// runtime tree would go), so preparation falls back explicitly.
|
||||
func TestFallbackAnchorIsRefusedWhenTheCacheRootCannotBeLeased(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
target := fallbackAnchorFixture(t)
|
||||
workspace := t.TempDir()
|
||||
|
||||
// Make the cache-derived root unleasable at its ROOT: the user cache is a
|
||||
// regular file, so nothing beneath it can be created and the first lease
|
||||
// fails, which is the path that asks for the fallback explicitly. The
|
||||
// first draft planted the file at the derived leaf instead, and the
|
||||
// preparation failed on that mkdir before ever reaching the fallback
|
||||
// branch, so the trigger under test never fired.
|
||||
cache := filepath.Join(t.TempDir(), "cache-is-a-file")
|
||||
if err := os.WriteFile(cache, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return cache, nil }
|
||||
|
||||
_, release, err := prepareSandboxRuntime(workspace)
|
||||
if err == nil {
|
||||
release()
|
||||
t.Fatal("preparation fell back through a junction at the anchor and reported success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "fallback anchor") {
|
||||
t.Errorf("the refusal does not name the anchor: %v", err)
|
||||
}
|
||||
assertNothingUnder(t, target)
|
||||
}
|
||||
|
||||
// And the honest control: with nothing planted, the fallback prepares, the
|
||||
// anchor is a real directory this test can see, and the runtime tree sits
|
||||
// beneath it.
|
||||
func TestFallbackAnchorIsCreatedPrivatelyWhenNothingIsInTheWay(t *testing.T) {
|
||||
tempRoot := t.TempDir()
|
||||
t.Setenv("TMP", tempRoot)
|
||||
t.Setenv("TEMP", tempRoot)
|
||||
workspace := t.TempDir()
|
||||
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("a clean fallback was refused: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
anchor := fallbackRuntimeAnchor()
|
||||
info, err := os.Lstat(anchor)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("the anchor was not created as a directory: err=%v", err)
|
||||
}
|
||||
if !pathWithinRoot(anchor, runtimeState.Root) {
|
||||
t.Errorf("runtime root %s is not beneath the anchor %s", runtimeState.Root, anchor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SETUP AND EVERY COMMAND RUNNER MUST DERIVE THE SAME FALLBACK ROOT.
|
||||
//
|
||||
// They are separate processes. The fallback used to call os.MkdirTemp and cache
|
||||
// the answer in a process-global map, so elevated setup granted the sandbox
|
||||
// principal write access to the directory IT made, and each later
|
||||
// __windows-command-runner made a different one and pointed TMP, GOCACHE and npm
|
||||
// there. Those directories are created by the calling user and carry no ACE for
|
||||
// the principal, so ordinary cache writes failed with a bare ACCESS_DENIED.
|
||||
//
|
||||
// A repeated call is what a second process looks like from here: no shared
|
||||
// state, same inputs. The old implementation could not pass this, because the
|
||||
// only thing that made repeat calls agree was the in-process map.
|
||||
func TestFallbackRuntimeRootIsTheSameForEveryProcess(t *testing.T) {
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
|
||||
first, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("fallbackSandboxRuntimeRoot: %v", err)
|
||||
}
|
||||
second, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("fallbackSandboxRuntimeRoot (second process): %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("two processes derived different runtime roots, so setup grants one and commands write to another:\n %s\n %s", first, second)
|
||||
}
|
||||
if first == "" {
|
||||
t.Fatal("no runtime root derived")
|
||||
}
|
||||
}
|
||||
|
||||
// Different workspaces must not share a runtime tree, or one workspace's
|
||||
// principal would hold an ACE on another's caches.
|
||||
func TestFallbackRuntimeRootIsPerWorkspace(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
first, err := fallbackSandboxRuntimeRoot(filepath.Join(base, "alpha"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := fallbackSandboxRuntimeRoot(filepath.Join(base, "beta"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatalf("two workspaces share the runtime root %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
// It must NAME the tree without creating it. Teardown asks for this path on its
|
||||
// way out, and materializing a directory there would leave one behind on every
|
||||
// teardown.
|
||||
func TestFallbackRuntimeRootCreatesNothing(t *testing.T) {
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
root, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pathExists(root) {
|
||||
t.Errorf("naming the runtime root created %s", root)
|
||||
}
|
||||
}
|
||||
|
||||
// The root must land outside the workspace, which is the entire reason this
|
||||
// branch exists.
|
||||
func TestFallbackRuntimeRootStaysOutsideTheWorkspace(t *testing.T) {
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
root, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pathWithinRoot(workspace, root) {
|
||||
t.Errorf("runtime root %q is inside workspace %q", root, workspace)
|
||||
}
|
||||
if !strings.Contains(root, "zero") {
|
||||
t.Errorf("runtime root %q does not look like a zero-owned tree", root)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,18 @@ func acquireSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
|
||||
return &sandboxRuntimeLease{handle: handle}, nil
|
||||
}
|
||||
|
||||
// acquireSandboxRuntimeLeaseIn takes the shared lease named name inside parent,
|
||||
// which the caller has just validated: the directory is opened without
|
||||
// following a link at its final component and the lease file is created or
|
||||
// opened relative to that handle, never by a fresh pathname walk.
|
||||
func acquireSandboxRuntimeLeaseIn(parent, name string) (*sandboxRuntimeLease, error) {
|
||||
handle, err := acquireSharedRuntimeLeaseIn(parent, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire sandbox runtime lease: %w", err)
|
||||
}
|
||||
return &sandboxRuntimeLease{handle: handle}, nil
|
||||
}
|
||||
|
||||
func tryAcquireSandboxRuntimeCleanupLease(root string) (*sandboxRuntimeLease, bool, error) {
|
||||
handle, inUse, err := tryAcquireExclusiveRuntimeLease(sandboxRuntimeLeasePath(root))
|
||||
if err != nil || inUse {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE REUSABLE CHILD OF THE FALLBACK ANCHOR IS VALIDATED BEFORE THE LEASE.
|
||||
//
|
||||
// The anchor was checked, but its reusable "v1" child was created by pathname
|
||||
// and the lease opened beside it, so a symlink planted at v1 by an earlier
|
||||
// same-user sandboxed command sent the .lease into another directory before
|
||||
// validation ever ran. With the fallback root now predictable and persistent,
|
||||
// that state carried across processes. Preparation must refuse without any
|
||||
// effect in the redirected target.
|
||||
func TestPrepareSandboxRuntimeRefusesARedirectedFallbackChild(t *testing.T) {
|
||||
_, tempRoot := isolateSandboxRuntimeRoots(t)
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Force the fallback: the cache root inside the workspace is unusable.
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
anchor := fallbackRuntimeAnchor()
|
||||
// Compared physically: the anchor is derived from the resolved temp root,
|
||||
// and on macOS the fixture temp under /var resolves to /private/var.
|
||||
physicalTemp, err := filepath.EvalSymlinks(tempRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve fixture temp: %v", err)
|
||||
}
|
||||
if rel, err := filepath.Rel(physicalTemp, anchor); err != nil || rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, "..") {
|
||||
t.Fatalf("SETUP INVALID: fallback anchor %s is not under the fixture temp %s", anchor, physicalTemp)
|
||||
}
|
||||
if err := os.MkdirAll(anchor, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elsewhere := filepath.Join(t.TempDir(), "elsewhere")
|
||||
if err := os.MkdirAll(elsewhere, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(elsewhere, filepath.Join(anchor, "v1")); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
|
||||
_, release, err := prepareSandboxRuntime(workspace)
|
||||
if release != nil {
|
||||
release()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("preparation followed a redirected v1 instead of refusing")
|
||||
}
|
||||
entries, readErr := os.ReadDir(elsewhere)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("preparation left %v in the redirected target before refusing", names)
|
||||
}
|
||||
|
||||
// CONTROL: with v1 an ordinary directory, or absent, preparation works and
|
||||
// its lease lands beside the root it selected.
|
||||
if err := os.Remove(filepath.Join(anchor, "v1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("ordinary preparation: %v", err)
|
||||
}
|
||||
defer release()
|
||||
if filepath.Dir(state.Root) != filepath.Join(anchor, "v1") {
|
||||
t.Fatalf("selected root %s is not under the fallback anchor's v1", state.Root)
|
||||
}
|
||||
if _, err := os.Lstat(sandboxRuntimeLeasePath(state.Root)); err != nil {
|
||||
t.Fatalf("lease was not created beside the selected root: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE REUSABLE CHILD OF THE FALLBACK ANCHOR IS VALIDATED BEFORE THE LEASE.
|
||||
//
|
||||
// The anchor was checked, but its reusable "v1" child was created by pathname
|
||||
// and the lease opened beside it, so a junction planted at v1 sent the .lease
|
||||
// into another directory before validation ever ran. Preparation must refuse
|
||||
// without any effect in the redirected target. A junction, not a symlink,
|
||||
// because that is what an unprivileged account on this platform can create.
|
||||
func TestPrepareSandboxRuntimeRefusesARedirectedFallbackChild(t *testing.T) {
|
||||
_, tempRoot := isolateSandboxRuntimeRoots(t)
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Force the fallback: the cache root inside the workspace is unusable.
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
anchor := fallbackRuntimeAnchor()
|
||||
if !strings.HasPrefix(strings.ToLower(anchor), strings.ToLower(tempRoot)) {
|
||||
physical, _ := physicalDir(tempRoot)
|
||||
if physical == "" || !strings.HasPrefix(strings.ToLower(anchor), strings.ToLower(physical)) {
|
||||
t.Fatalf("SETUP INVALID: fallback anchor %s is not under the fixture temp %s", anchor, tempRoot)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(anchor, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elsewhere := filepath.Join(t.TempDir(), "elsewhere")
|
||||
if err := os.MkdirAll(elsewhere, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
makeJunction(t, filepath.Join(anchor, "v1"), elsewhere)
|
||||
|
||||
_, release, err := prepareSandboxRuntime(workspace)
|
||||
if release != nil {
|
||||
release()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("preparation followed a redirected v1 instead of refusing")
|
||||
}
|
||||
entries, readErr := os.ReadDir(elsewhere)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("preparation left %v in the redirected target before refusing", names)
|
||||
}
|
||||
|
||||
// CONTROL: with the junction gone, preparation works and its lease lands
|
||||
// beside the root it selected.
|
||||
if err := os.Remove(filepath.Join(anchor, "v1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("ordinary preparation: %v", err)
|
||||
}
|
||||
defer release()
|
||||
if !strings.EqualFold(filepath.Dir(state.Root), filepath.Join(anchor, "v1")) {
|
||||
t.Fatalf("selected root %s is not under the fallback anchor's v1", state.Root)
|
||||
}
|
||||
if _, err := os.Lstat(sandboxRuntimeLeasePath(state.Root)); err != nil {
|
||||
t.Fatalf("lease was not created beside the selected root: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
@@ -13,6 +15,24 @@ type runtimeLeaseHandle struct {
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func acquireSharedRuntimeLeaseIn(parent, name string) (runtimeLeaseHandle, error) {
|
||||
dirFD, err := unix.Open(parent, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, fmt.Errorf("open sandbox runtime parent %s: %w", parent, err)
|
||||
}
|
||||
defer unix.Close(dirFD)
|
||||
fd, err := unix.Openat(dirFD, name, unix.O_CREAT|unix.O_RDWR|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, fmt.Errorf("open sandbox runtime lease %s: %w", name, err)
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), filepath.Join(parent, name))
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, nil
|
||||
}
|
||||
|
||||
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,7 +4,10 @@ package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
@@ -14,6 +17,58 @@ type runtimeLeaseHandle struct {
|
||||
overlapped windows.Overlapped
|
||||
}
|
||||
|
||||
// acquireSharedRuntimeLeaseIn opens or creates the lease relative to a handle on
|
||||
// its parent that refuses a reparse point at the final component, so the
|
||||
// pathname the caller just validated cannot be swapped underneath the open.
|
||||
func acquireSharedRuntimeLeaseIn(parent, name string) (runtimeLeaseHandle, error) {
|
||||
dir, err := openWindowsACLDirectoryNoFollow(parent)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, fmt.Errorf("open sandbox runtime parent: %w", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(dir) }()
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, fmt.Errorf("encode sandbox runtime lease %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: dir,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
var handle windows.Handle
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN_IF,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
return runtimeLeaseHandle{}, fmt.Errorf("open sandbox runtime lease %s: %w", name, err)
|
||||
}
|
||||
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), filepath.Join(parent, name))
|
||||
lease := runtimeLeaseHandle{file: file}
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &lease.overlapped); err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
return lease, nil
|
||||
}
|
||||
|
||||
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,10 +7,14 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/peermsg"
|
||||
)
|
||||
|
||||
var sandboxUserCacheDir = os.UserCacheDir
|
||||
@@ -21,11 +25,6 @@ const (
|
||||
sandboxRuntimeMaxRoots = 64
|
||||
)
|
||||
|
||||
var fallbackSandboxRuntimes = struct {
|
||||
sync.Mutex
|
||||
roots map[string]string
|
||||
}{roots: make(map[string]string)}
|
||||
|
||||
type SandboxRuntime struct {
|
||||
Root string `json:"root,omitempty"`
|
||||
Cache string `json:"cache,omitempty"`
|
||||
@@ -33,8 +32,37 @@ type SandboxRuntime struct {
|
||||
Temp string `json:"temp,omitempty"`
|
||||
}
|
||||
|
||||
// sandboxRuntimeRootFor derives the per-workspace runtime root. It is separated
|
||||
// from prepareSandboxRuntime because the elevated Windows setup path needs the
|
||||
// same answer WITHOUT taking a lease or creating anything: a sandbox principal
|
||||
// is a separate account with no inherited rights under the user cache, so setup
|
||||
// has to grant it write access to this tree before any command runs.
|
||||
//
|
||||
// Both callers must agree exactly. If they ever drift, setup grants the ACE on
|
||||
// one directory while commands write to another, and the failure is a bare
|
||||
// ACCESS_DENIED from npm or go build with nothing pointing at the sandbox.
|
||||
func sandboxRuntimeRootFor(workspaceRoot string, cacheRoot string) (string, error) {
|
||||
if root, ok := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot); ok {
|
||||
return root, nil
|
||||
}
|
||||
return fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
}
|
||||
|
||||
// deterministicSandboxRuntimeRoot returns the cache-derived runtime root and
|
||||
// whether it is usable, meaning it lands outside the workspace.
|
||||
//
|
||||
// Neither this nor the fallback creates anything now, so a caller that only
|
||||
// needs to NAME the tree can safely go through sandboxRuntimeRootFor and get the
|
||||
// answer commands will actually use. This remains separate for callers that need
|
||||
// to distinguish the cache-derived root from the temp-derived one.
|
||||
func deterministicSandboxRuntimeRoot(workspaceRoot string, cacheRoot string) (string, bool) {
|
||||
digest := sha256.Sum256([]byte(workspaceRoot))
|
||||
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
|
||||
return root, !pathWithinRoot(workspaceRoot, root)
|
||||
}
|
||||
|
||||
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
|
||||
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
|
||||
workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot)
|
||||
if workspaceRoot == "" || workspaceRoot == "." {
|
||||
return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root")
|
||||
}
|
||||
@@ -42,17 +70,30 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err)
|
||||
}
|
||||
cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot))
|
||||
// Canonicalized the SAME way as the workspace root, because
|
||||
// sandboxRuntimeRootFor compares the two: it falls back to a private temp
|
||||
// tree when the derived runtime root would land inside the workspace.
|
||||
// Normalizing only one side made that comparison run on two different
|
||||
// spellings of the same path — /var vs /private/var on macOS, an 8.3 short
|
||||
// name vs its long form on Windows — so the containment check missed and the
|
||||
// fallback never fired.
|
||||
cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot)
|
||||
if cacheRoot == "" || cacheRoot == "." {
|
||||
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(workspaceRoot))
|
||||
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
|
||||
if pathWithinRoot(workspaceRoot, root) {
|
||||
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
// THE ANCHOR IS PROVEN BEFORE THE TREE IS BUILT, on both paths that can
|
||||
// hand back a fallback root: sandboxRuntimeRootFor returns one itself when
|
||||
// the cache lands inside the workspace, and the branch below asks for one
|
||||
// when the cache root cannot be leased. A deterministic name under the
|
||||
// shared temp is only safe beneath a directory this user owns; if the anchor
|
||||
// is a link, or exists and belongs to someone else, this refuses rather than
|
||||
// leasing, creating and chmod-ing through it.
|
||||
if err := ensureFallbackRuntimeAnchor(root); err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
lease, err := prepareSandboxRuntimeLease(root)
|
||||
if err != nil {
|
||||
@@ -60,6 +101,9 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
if err := ensureFallbackRuntimeAnchor(root); err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
lease, err = prepareSandboxRuntimeLease(root)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
@@ -90,13 +134,13 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
|
||||
filepath.Join(runtimeState.Data, "go-mod"),
|
||||
filepath.Join(runtimeState.Data, "cargo"),
|
||||
}
|
||||
for _, directory := range directories {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", directory, err)
|
||||
}
|
||||
if err := os.Chmod(directory, 0o700); err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err)
|
||||
}
|
||||
// Handle-relative and no-follow below the operator-owned base. The tree is
|
||||
// persistent input from the previous sandboxed command, which is granted write
|
||||
// access to cache, data and tmp and can leave a link in place of one of them.
|
||||
// os.MkdirAll and os.Chmod both follow, so the host process created package
|
||||
// caches wherever that command pointed them. See ensureRuntimeTreeDirs.
|
||||
if err := ensureRuntimeTreeDirs(runtimeState.Root, directories); err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
now := sandboxRuntimeNow()
|
||||
if err := os.Chtimes(runtimeState.Root, now, now); err != nil {
|
||||
@@ -108,10 +152,20 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
|
||||
}
|
||||
|
||||
func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create sandbox runtime parent: %w", err)
|
||||
// THE OWNED TAIL IS VALIDATED BEFORE THE FIRST EFFECT, AND THE LEASE IS
|
||||
// OPENED THROUGH THAT VALIDATION. The anchor check above this covers the
|
||||
// anchor; the reusable "v1" child beneath it is the runtime's too, and
|
||||
// creating it by pathname followed whatever was sitting at that name.
|
||||
// Redirect v1 into another directory and the old sequence created a .lease
|
||||
// there and only then failed validation. EnsurePrivateDir walks each
|
||||
// component without following links and creates what is missing in place;
|
||||
// the lease is then opened relative to a no-follow handle on that parent, so
|
||||
// a component swapped in after the check cannot move the open either.
|
||||
parent := filepath.Dir(root)
|
||||
if err := peermsg.EnsurePrivateDir(parent); err != nil {
|
||||
return nil, fmt.Errorf("sandbox runtime parent %s is not a private directory owned by this user: %w", parent, err)
|
||||
}
|
||||
return acquireSandboxRuntimeLease(root)
|
||||
return acquireSandboxRuntimeLeaseIn(parent, filepath.Base(root)+sandboxRuntimeLeaseSuffix)
|
||||
}
|
||||
|
||||
// cleanupSandboxRuntimeRoots applies a conservative age/count policy. Cleanup
|
||||
@@ -174,22 +228,109 @@ func combineSandboxCleanups(cleanups ...func()) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackRuntimeAnchor is the per-user directory the deterministic fallback
|
||||
// runtime tree lives beneath.
|
||||
//
|
||||
// DETERMINISM IS NOT OWNERSHIP. The fallback moved from a fresh, private
|
||||
// os.MkdirTemp parent to a predictable name so that elevated setup and the
|
||||
// later command process agree on the runtime root. That agreement is required
|
||||
// and must stay. But a predictable path directly under the shared system temp
|
||||
// is not a private allocation: on an ordinary shared /tmp another local user
|
||||
// can create /tmp/zero with mode 0700 first, and every affected user then fails
|
||||
// before the sandbox command starts; a pre-existing redirected component makes
|
||||
// host-side preparation operate somewhere other than the derived tree.
|
||||
//
|
||||
// So the tree is rooted beneath an anchor that names THIS user, and the anchor
|
||||
// is created and validated by peermsg.EnsurePrivateDir before anything is
|
||||
// built under it: handle-relative descent, no-follow, refused if any component
|
||||
// is a link or the leaf is not owned by the current user, mode 0700. On Unix
|
||||
// the uid is in the name because /tmp is shared between users; on Windows
|
||||
// os.TempDir is already per-user, and the private descriptor plus the owner
|
||||
// check do the rest.
|
||||
func fallbackRuntimeAnchor() string {
|
||||
tag := "user"
|
||||
if runtime.GOOS != "windows" {
|
||||
tag = strconv.Itoa(os.Getuid())
|
||||
}
|
||||
// PHYSICAL PARENT, OWNED LEAF. The validator walks every component no-follow
|
||||
// and refuses links, which is right for the anchor Zero owns and wrong for
|
||||
// the operator's ancestors above it: macOS puts TempDir under /var, a
|
||||
// symlink to /private/var, and a redirected %LOCALAPPDATA% is ordinary on
|
||||
// Windows. The first version handed the validator the unresolved path and
|
||||
// refused every fallback on every Mac. Resolving the parent first means the
|
||||
// only new component the validator sees is the anchor itself.
|
||||
//
|
||||
// On a resolve error the unresolved path is used, deliberately: the
|
||||
// validator is still the fail-closed check, and refusing there names the
|
||||
// real component rather than hiding it behind a resolver failure.
|
||||
base := os.TempDir()
|
||||
if physical, err := physicalTempDir(); err == nil && physical != "" {
|
||||
base = physical
|
||||
}
|
||||
return filepath.Join(base, "zero-runtime-"+tag)
|
||||
}
|
||||
|
||||
// isFallbackRuntimeRoot reports whether root was derived by
|
||||
// fallbackSandboxRuntimeRoot, so the caller knows the anchor must be validated
|
||||
// before the tree beneath it is prepared. Both candidates a command derives can
|
||||
// be fallback roots: sandboxRuntimeRootFor returns one itself when the cache
|
||||
// lands inside the workspace, and prepareSandboxRuntime asks for one when the
|
||||
// cache root cannot be leased.
|
||||
func isFallbackRuntimeRoot(root string) bool {
|
||||
// pathWithinRoot(parent, child): is root beneath the anchor. The first
|
||||
// draft had these reversed, which asked whether the anchor was beneath the
|
||||
// root, answered false every time, and skipped the validation entirely
|
||||
// while every test that depended on it reported success through a
|
||||
// junction. Argument order is the whole function.
|
||||
return pathWithinRoot(fallbackRuntimeAnchor(), root)
|
||||
}
|
||||
|
||||
// ensureFallbackRuntimeAnchor validates the anchor beneath which a fallback
|
||||
// runtime tree is about to be built, and fails closed. Called only when the
|
||||
// root is a fallback root; the cache-derived root lives under the user cache,
|
||||
// which is already the user's own directory.
|
||||
func ensureFallbackRuntimeAnchor(root string) error {
|
||||
if !isFallbackRuntimeRoot(root) {
|
||||
return nil
|
||||
}
|
||||
anchor := fallbackRuntimeAnchor()
|
||||
if err := peermsg.EnsurePrivateDir(anchor); err != nil {
|
||||
return fmt.Errorf("sandbox runtime fallback anchor %s is not a private directory owned by this user: %w", anchor, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fallbackSandboxRuntimeRoot returns the runtime root for a workspace whose
|
||||
// cache-derived root would land inside itself.
|
||||
//
|
||||
// DERIVED, not minted, and that is the whole of the fix. It used to call
|
||||
// os.MkdirTemp and remember the answer in a process-global map, which made the
|
||||
// result private to whichever process asked first. Elevated Windows setup
|
||||
// granted the sandbox principal write access to the directory IT created, then
|
||||
// every later __windows-command-runner process created a DIFFERENT one and
|
||||
// pointed TMP, GOCACHE, npm and the rest at it. Those directories are created by
|
||||
// the calling user and carry no ACE for the principal, so ordinary cache and
|
||||
// temp writes failed with a bare ACCESS_DENIED and nothing naming the sandbox.
|
||||
//
|
||||
// sandboxRuntimeRootFor already documents that both callers must agree exactly.
|
||||
// Hashing the workspace, the same way the cache-derived root does, is what makes
|
||||
// that true for this branch too: every process reaches the same path without
|
||||
// having to share any state.
|
||||
//
|
||||
// It creates nothing, so deterministicSandboxRuntimeRoot's promise about naming
|
||||
// a tree without materializing it now holds for the fallback as well.
|
||||
func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) {
|
||||
fallbackSandboxRuntimes.Lock()
|
||||
defer fallbackSandboxRuntimes.Unlock()
|
||||
if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" {
|
||||
return root, nil
|
||||
}
|
||||
parent, err := os.MkdirTemp("", "zero-runtime-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create fallback sandbox runtime: %w", err)
|
||||
}
|
||||
root := filepath.Join(parent, "runtime")
|
||||
digest := sha256.Sum256([]byte(workspaceRoot))
|
||||
root := filepath.Join(fallbackRuntimeAnchor(), "v1", hex.EncodeToString(digest[:8]))
|
||||
if pathWithinRoot(workspaceRoot, root) {
|
||||
_ = os.RemoveAll(parent)
|
||||
return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot)
|
||||
// Both candidates land inside the workspace, so there is nowhere left to
|
||||
// put a runtime tree the workspace's own policy does not govern. Refused
|
||||
// rather than pointed somewhere arbitrary: a runtime root inside the
|
||||
// workspace makes the sandbox's own cache writes indistinguishable from
|
||||
// the work it is meant to be confining.
|
||||
return "", fmt.Errorf("sandbox runtime root %q would fall inside workspace %q; "+
|
||||
"open the workspace somewhere other than the cache or temp directory", root, workspaceRoot)
|
||||
}
|
||||
fallbackSandboxRuntimes.roots[workspaceRoot] = root
|
||||
return root, nil
|
||||
}
|
||||
|
||||
@@ -226,3 +367,59 @@ func permissionProfileWithRuntime(profile PermissionProfile, runtimeState Sandbo
|
||||
profile.FileSystem.WriteRoots = append(profile.FileSystem.WriteRoots, WritableRoot{Root: runtimeState.Root})
|
||||
return profile
|
||||
}
|
||||
|
||||
// canonicalSandboxWorkspaceRoot normalizes a workspace root the way
|
||||
// Engine.resolveCommandDir already does — clean, absolutize, then resolve
|
||||
// symlinks — so every derivation keyed to a workspace agrees on the string.
|
||||
//
|
||||
// The runtime root is a hash of this, and the elevated Windows setup grants the
|
||||
// principal that tree while commands derive it again. Cleaning alone was not
|
||||
// enough for the two to agree, and it does not take a symlink for them to
|
||||
// differ: a path opened in different casing, or through an 8.3 short name (what
|
||||
// a Windows CI runner's TEMP looks like), resolves to a different spelling.
|
||||
// Setup then granted one tree and every command used another, so the grant that
|
||||
// makes npm/go/pip caches writable landed where nothing reads and surfaced as a
|
||||
// bare ACCESS_DENIED.
|
||||
//
|
||||
// Resolution failing is not an error: an unresolvable root still needs a stable
|
||||
// key, and falling back to the cleaned absolute path is what the command path
|
||||
// does too.
|
||||
func canonicalSandboxWorkspaceRoot(root string) string {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(root))
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(cleaned) {
|
||||
if absolute, err := filepath.Abs(cleaned); err == nil {
|
||||
cleaned = absolute
|
||||
}
|
||||
}
|
||||
// EvalSymlinks fails outright when the LEAF does not exist, which is the
|
||||
// normal case for a cache or runtime root that has not been created yet. A
|
||||
// plain call therefore resolved an existing workspace while leaving a
|
||||
// not-yet-created cache root unresolved, and the two were compared against
|
||||
// each other — the containment check that decides whether the runtime tree
|
||||
// must move out of the workspace then ran on /private/var/... versus
|
||||
// /var/..., missed, and left the tree inside the workspace.
|
||||
//
|
||||
// Resolve the longest existing ancestor and re-append the rest, so a path
|
||||
// normalizes the same way whether or not its final segments exist yet.
|
||||
remainder := ""
|
||||
current := cleaned
|
||||
for {
|
||||
if resolved, err := filepath.EvalSymlinks(current); err == nil {
|
||||
if remainder == "" {
|
||||
return resolved
|
||||
}
|
||||
return filepath.Join(resolved, remainder)
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
// Nothing along the path resolved; the cleaned absolute form is the
|
||||
// best stable key available.
|
||||
return cleaned
|
||||
}
|
||||
remainder = filepath.Join(filepath.Base(current), remainder)
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
original := sandboxUserCacheDir
|
||||
@@ -39,6 +40,7 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
@@ -70,6 +72,7 @@ func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
workspace := t.TempDir()
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
|
||||
@@ -89,6 +92,7 @@ func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing
|
||||
}
|
||||
|
||||
func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
@@ -329,3 +333,106 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
|
||||
}
|
||||
cleanupLease.release()
|
||||
}
|
||||
|
||||
// sandboxRuntimeRootFor compares the workspace root against the derived runtime
|
||||
// root to decide whether to fall back to a private temp tree. Both sides
|
||||
// therefore have to be the same spelling of the same path.
|
||||
//
|
||||
// Canonicalizing only the workspace root broke this on CI: the workspace
|
||||
// resolved (/var to /private/var on macOS, an 8.3 short name to its long form
|
||||
// on Windows) while the cache root kept its original spelling, so the
|
||||
// containment check compared two different strings, the fallback never fired,
|
||||
// and the runtime tree was placed inside the workspace it exists to stay out of.
|
||||
//
|
||||
// A symlink is the portable way to produce a spelling that only resolution
|
||||
// reconciles — Clean cannot see through one. Windows refuses to create symlinks
|
||||
// without privilege, so this skips there; the platforms that CI caught the bug
|
||||
// on are the ones that run it.
|
||||
func TestPrepareSandboxRuntimeNormalizesTheCacheRootBeforeComparingIt(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
workspace := t.TempDir()
|
||||
link := filepath.Join(t.TempDir(), "workspace-link")
|
||||
if err := os.Symlink(workspace, link); err != nil {
|
||||
t.Skipf("cannot create a symlink here: %v", err)
|
||||
}
|
||||
// The cache root reaches us spelled through the symlink; the workspace does
|
||||
// not. Resolved, it is plainly inside the workspace and the fallback must
|
||||
// fire. Unresolved, the two strings share no prefix and it does not.
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(link, ".cache"), nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
if release != nil {
|
||||
defer release()
|
||||
}
|
||||
// Resolve before comparing. Spelled through the link the runtime root shares
|
||||
// no textual prefix with the workspace, so an unresolved comparison would
|
||||
// call it "outside" while it sits physically inside — the test would pass
|
||||
// against the very bug it exists for.
|
||||
resolved := runtimeState.Root
|
||||
if actual, err := filepath.EvalSymlinks(runtimeState.Root); err == nil {
|
||||
resolved = actual
|
||||
}
|
||||
if pathWithinRoot(workspace, resolved) {
|
||||
t.Fatalf("runtime root %q resolves to %q, inside workspace %q; the containment check did not see through the cache root's spelling", runtimeState.Root, resolved, workspace)
|
||||
}
|
||||
}
|
||||
|
||||
// A path whose final segments do not exist yet must still normalize the same
|
||||
// way as one that does. This is the shape macOS CI hit: t.TempDir() sits under
|
||||
// /var, a symlink to /private/var, and the cache root it derives has not been
|
||||
// created when it is first normalized. Resolving only the workspace left the
|
||||
// two sides of the containment check spelled differently.
|
||||
func TestCanonicalSandboxWorkspaceRootResolvesThroughAMissingLeaf(t *testing.T) {
|
||||
real := t.TempDir()
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Skipf("cannot create a symlink here: %v", err)
|
||||
}
|
||||
|
||||
existing := canonicalSandboxWorkspaceRoot(link)
|
||||
if existing != canonicalSandboxWorkspaceRoot(real) {
|
||||
t.Fatalf("an existing symlinked dir did not resolve: %q vs %q", existing, canonicalSandboxWorkspaceRoot(real))
|
||||
}
|
||||
|
||||
// The leaf, and its parent, do not exist.
|
||||
missing := filepath.Join(link, ".cache", "zero")
|
||||
got := canonicalSandboxWorkspaceRoot(missing)
|
||||
want := filepath.Join(existing, ".cache", "zero")
|
||||
if got != want {
|
||||
t.Errorf("missing leaf normalized to %q, want %q — the ancestor was not resolved", got, want)
|
||||
}
|
||||
if !pathWithinRoot(existing, got) {
|
||||
t.Errorf("%q should be inside %q once both are canonical", got, existing)
|
||||
}
|
||||
}
|
||||
|
||||
// isolateSandboxRuntimeRoots gives a test its own cache root AND its own temp
|
||||
// root, so every runtime tree the production code can select, the preferred
|
||||
// cache-derived one and the temp-derived fallback, lands under the fixture.
|
||||
// Workspace isolation is not runtime-storage isolation: those paths come from
|
||||
// separate resolver inputs, and a test that redirected only the workspace ran
|
||||
// real setup, preparation and reclamation against the developer's own cache
|
||||
// and temp. Both roots are returned for assertions on where things landed.
|
||||
func isolateSandboxRuntimeRoots(t *testing.T) (cacheRoot, tempRoot string) {
|
||||
t.Helper()
|
||||
cacheRoot = filepath.Join(t.TempDir(), "cache")
|
||||
tempRoot = filepath.Join(t.TempDir(), "temp")
|
||||
for _, dir := range []string{cacheRoot, tempRoot} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
previous := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
// os.TempDir reads these at call time on every platform this runs on.
|
||||
t.Setenv("TMP", tempRoot)
|
||||
t.Setenv("TEMP", tempRoot)
|
||||
t.Setenv("TMPDIR", tempRoot)
|
||||
return cacheRoot, tempRoot
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ensureRuntimeTreeDirs creates every directory of the runtime tree without ever
|
||||
// following a link below the operator-owned base.
|
||||
//
|
||||
// THE TREE IS PERSISTENT INPUT FROM THE PREVIOUS SANDBOXED COMMAND.
|
||||
//
|
||||
// The runtime root is deterministic so setup and later runners agree on one
|
||||
// path, and the sandboxed command is granted write access to cache, data and
|
||||
// tmp. So the command can replace one of them with a symlink or a Windows
|
||||
// junction on its way out. The next preparation ran os.MkdirAll and os.Chmod on
|
||||
// raw pathnames, which follow, and the HOST Zero process, the ordinary user
|
||||
// rather than the confined principal, then created package-cache directories
|
||||
// inside a target the previous command chose.
|
||||
//
|
||||
// The anchor check is not enough on its own: it proves the per-user anchor and
|
||||
// says nothing about the reusable root or any descendant beneath it.
|
||||
//
|
||||
// NOT peermsg.EnsurePrivateDir. That ends in a protected-DACL write, which on
|
||||
// Windows strips the sandbox principal's grant that elevated setup installed on
|
||||
// the runtime tree and brings back the bare access denials from npm and go that
|
||||
// the grant exists to prevent. This descent validates and creates; it does not
|
||||
// re-secure.
|
||||
func ensureRuntimeTreeDirs(root string, directories []string) error {
|
||||
// Canonicalized the same way the rest of the runtime state is, because
|
||||
// runtimeCandidateBase decides ownership with a containment test and that
|
||||
// test runs on spellings. An 8.3 short name (C:UsersRUNNER~1) or a
|
||||
// symlinked temp compares unequal to the long form of the same directory,
|
||||
// which reads as "no operator-owned base" for a perfectly ordinary tree.
|
||||
if canonical := canonicalSandboxWorkspaceRoot(root); canonical != "" {
|
||||
root = canonical
|
||||
}
|
||||
base, ok := runtimeCandidateBase(root)
|
||||
if !ok || strings.TrimSpace(base) == "" {
|
||||
return fmt.Errorf("sandbox runtime root %s has no operator-owned base to descend from", root)
|
||||
}
|
||||
// Physical, so a redirected cache or TEMP above the tree stays the operator's
|
||||
// business while everything Zero owns below it is addressed no-follow.
|
||||
physical, err := physicalDir(base)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve the sandbox runtime base %s: %w", base, err)
|
||||
}
|
||||
for _, directory := range directories {
|
||||
tail, err := runtimeTreeTail(physical, base, directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureRuntimeTreeDir(physical, tail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runtimeTreeTail returns the components of directory below the base, which are
|
||||
// the ones Zero owns and the ones that must not be links.
|
||||
func runtimeTreeTail(physicalBase string, base string, directory string) ([]string, error) {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(directory))
|
||||
relative, err := filepath.Rel(base, cleaned)
|
||||
if err != nil || relative == "." || strings.HasPrefix(relative, "..") {
|
||||
// Try the physical spelling too: the caller's path may already be physical.
|
||||
relative, err = filepath.Rel(physicalBase, cleaned)
|
||||
if err != nil || relative == "." || strings.HasPrefix(relative, "..") {
|
||||
return nil, fmt.Errorf("sandbox runtime directory %s does not sit under its base %s", directory, base)
|
||||
}
|
||||
}
|
||||
parts := strings.Split(relative, string(filepath.Separator))
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
if part == ".." {
|
||||
return nil, fmt.Errorf("sandbox runtime directory %s escapes its base %s", directory, base)
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("sandbox runtime directory %s resolves to its own base", directory)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ensureRuntimeTreeDir creates the owned tail beneath a physical base using
|
||||
// openat and mkdirat with O_NOFOLLOW, so a symlink planted at any component is
|
||||
// refused rather than followed.
|
||||
//
|
||||
// fchmod on the retained descriptor rather than chmod on the pathname: chmod(2)
|
||||
// follows symlinks, which is the half of this defect that actually applies here.
|
||||
// Every owned component gets the mode, not only the leaf, because every one of
|
||||
// them is ours.
|
||||
func ensureRuntimeTreeDir(physicalBase string, tail []string) error {
|
||||
parent, err := unix.Open(physicalBase, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open the sandbox runtime base %s: %w", physicalBase, err)
|
||||
}
|
||||
defer func() { _ = unix.Close(parent) }()
|
||||
|
||||
current := physicalBase
|
||||
for _, name := range tail {
|
||||
current = filepath.Join(current, name)
|
||||
if err := unix.Mkdirat(parent, name, 0o700); err != nil && err != unix.EEXIST {
|
||||
return fmt.Errorf("create sandbox runtime directory %s: %w", current, err)
|
||||
}
|
||||
child, openErr := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
if openErr != nil {
|
||||
if openErr == unix.ELOOP || openErr == unix.ENOTDIR {
|
||||
return fmt.Errorf("sandbox runtime component %s is a link or not a directory, so a previous sandboxed command may have redirected it: %w", current, os.ErrInvalid)
|
||||
}
|
||||
return fmt.Errorf("open sandbox runtime directory %s: %w", current, openErr)
|
||||
}
|
||||
if err := unix.Fchmod(child, 0o700); err != nil {
|
||||
_ = unix.Close(child)
|
||||
return fmt.Errorf("secure sandbox runtime directory %s: %w", current, err)
|
||||
}
|
||||
_ = unix.Close(parent)
|
||||
parent = child
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A PREVIOUS SANDBOXED COMMAND CAN LEAVE A REDIRECT BEHIND.
|
||||
//
|
||||
// The runtime root is deterministic so setup and later runners agree on one
|
||||
// path, and the sandboxed command is granted write access to cache, data and
|
||||
// tmp. So it can replace one of them with a junction on its way out. The next
|
||||
// preparation used os.MkdirAll and os.Chmod on raw pathnames, which follow, and
|
||||
// the HOST Zero process, the ordinary user rather than the confined principal,
|
||||
// then created package-cache directories inside a target the previous command
|
||||
// chose.
|
||||
//
|
||||
// The anchor check does not cover this: it proves the per-user anchor and says
|
||||
// nothing about the reusable root or anything beneath it.
|
||||
//
|
||||
// A junction needs no privilege, so the previous command's half runs here on an
|
||||
// ordinary unelevated box.
|
||||
func TestRuntimeTreePreparationRefusesARedirectedDescendant(t *testing.T) {
|
||||
base := runtimeTreeTestBase(t)
|
||||
target := t.TempDir()
|
||||
root := filepath.Join(base, "zero", "runtime", "v1", "abcdef0123456789")
|
||||
cache := filepath.Join(root, "cache")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The previous lifetime leaves a junction where cache used to be.
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", cache, target).CombinedOutput(); err != nil {
|
||||
t.Skipf("mklink /J unavailable here: %v\n%s", err, out)
|
||||
}
|
||||
// SETUP: the junction really redirects, or a clean target proves nothing.
|
||||
probe := filepath.Join(cache, "probe.txt")
|
||||
if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil {
|
||||
t.Skipf("the junction does not accept writes here: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "probe.txt")); err != nil {
|
||||
t.Skipf("SETUP: the junction does not redirect on this filesystem: %v", err)
|
||||
}
|
||||
if err := os.Remove(probe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ensureRuntimeTreeDirs(root, []string{root, cache, filepath.Join(cache, "npm")})
|
||||
if err == nil {
|
||||
t.Fatal("preparation descended through a junction the previous sandboxed command planted")
|
||||
}
|
||||
|
||||
entries, readErr := os.ReadDir(target)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("preparation created %v beneath the redirected target", names)
|
||||
}
|
||||
}
|
||||
|
||||
// And an ordinary tree is still created, or the refusal above would be satisfied
|
||||
// by a preparation that refuses everything.
|
||||
func TestRuntimeTreePreparationStillCreatesAnOrdinaryTree(t *testing.T) {
|
||||
base := runtimeTreeTestBase(t)
|
||||
root := filepath.Join(base, "zero", "runtime", "v1", "abcdef0123456789")
|
||||
cache := filepath.Join(root, "cache")
|
||||
npm := filepath.Join(cache, "npm")
|
||||
|
||||
if err := ensureRuntimeTreeDirs(root, []string{root, cache, npm}); err != nil {
|
||||
t.Fatalf("an ordinary runtime tree was refused: %v", err)
|
||||
}
|
||||
for _, dir := range []string{root, cache, npm} {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("%s was not created: err=%v", dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reusing an existing tree is the common case and must not be refused.
|
||||
func TestRuntimeTreePreparationIsIdempotent(t *testing.T) {
|
||||
base := runtimeTreeTestBase(t)
|
||||
root := filepath.Join(base, "zero", "runtime", "v1", "abcdef0123456789")
|
||||
dirs := []string{root, filepath.Join(root, "cache"), filepath.Join(root, "cache", "npm")}
|
||||
if err := ensureRuntimeTreeDirs(root, dirs); err != nil {
|
||||
t.Fatalf("first preparation: %v", err)
|
||||
}
|
||||
if err := ensureRuntimeTreeDirs(root, dirs); err != nil {
|
||||
t.Fatalf("second preparation over an existing tree was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AND THE PRODUCTION ENTRY POINT USES IT, WHICH THE HELPER TESTS CANNOT SEE.
|
||||
//
|
||||
// The three tests above call ensureRuntimeTreeDirs directly, so reverting
|
||||
// prepareSandboxRuntime to the old pathname loop leaves every one of them green.
|
||||
// The defect was in what the entry point called, so this drives the entry point.
|
||||
func TestPrepareSandboxRuntimeRefusesARedirectedDescendant(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
cacheRoot := t.TempDir()
|
||||
target := t.TempDir()
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
|
||||
workspace := t.TempDir()
|
||||
root, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: no deterministic runtime root for %s", workspace)
|
||||
}
|
||||
cache := filepath.Join(root, "cache")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", cache, target).CombinedOutput(); err != nil {
|
||||
t.Skipf("mklink /J unavailable here: %v\n%s", err, out)
|
||||
}
|
||||
probe := filepath.Join(cache, "probe.txt")
|
||||
if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil {
|
||||
t.Skipf("the junction does not accept writes here: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "probe.txt")); err != nil {
|
||||
t.Skipf("SETUP: the junction does not redirect on this filesystem: %v", err)
|
||||
}
|
||||
if err := os.Remove(probe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, release, err := prepareSandboxRuntime(workspace)
|
||||
if release != nil {
|
||||
release()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("prepareSandboxRuntime descended through a junction the previous sandboxed command planted")
|
||||
}
|
||||
|
||||
entries, readErr := os.ReadDir(target)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("prepareSandboxRuntime created %v beneath the redirected target", names)
|
||||
}
|
||||
}
|
||||
|
||||
// And an ordinary workspace still prepares through the entry point.
|
||||
func TestPrepareSandboxRuntimeStillPreparesAnOrdinaryTree(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
cacheRoot := t.TempDir()
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("an ordinary workspace was refused: %v", err)
|
||||
}
|
||||
defer release()
|
||||
for _, dir := range []string{runtimeState.Root, runtimeState.Cache, runtimeState.Data, runtimeState.Temp} {
|
||||
info, statErr := os.Stat(dir)
|
||||
if statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("%s was not created: err=%v", dir, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeTreeTestBase makes the operator-owned base deterministic.
|
||||
//
|
||||
// runtimeCandidateBase answers by containment against the user cache directory,
|
||||
// so a bare t.TempDir() only works where temp happens to sit under it. On a CI
|
||||
// runner it does not, or its 8.3 short name compares unequal to the long form,
|
||||
// and the tree is reported as having no owned base. Stubbing the cache root is
|
||||
// what makes these tests about the descent rather than about where temp lives.
|
||||
func runtimeTreeTestBase(t *testing.T) string {
|
||||
t.Helper()
|
||||
base := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return base, nil }
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ensureRuntimeTreeDir creates the owned tail beneath a physical base, one
|
||||
// component at a time, from retained handles.
|
||||
//
|
||||
// Every open and create goes through the NtCreateFile helpers with
|
||||
// OBJ_DONT_REPARSE and FILE_OPEN_REPARSE_POINT, so a junction planted at any
|
||||
// component is seen as the link it is and refused rather than descended. No
|
||||
// component name is resolved twice, so there is no interval for a swap to land
|
||||
// in.
|
||||
//
|
||||
// No chmod. On Windows os.Chmod only toggles READONLY, and against a junction it
|
||||
// lands on the link rather than the target, so it bought nothing here. The
|
||||
// directory's protection is the ACL that elevated setup applied, which this
|
||||
// descent deliberately does not touch.
|
||||
func ensureRuntimeTreeDir(physicalBase string, tail []string) error {
|
||||
parent, err := openWindowsACLDirectoryNoFollow(physicalBase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open the sandbox runtime base %s: %w", physicalBase, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
for _, name := range tail {
|
||||
child, _, createErr := createWindowsACLChildDirectory(parent, name)
|
||||
if createErr != nil {
|
||||
existing, openErr := openWindowsACLChildDirectory(parent, name)
|
||||
if openErr != nil {
|
||||
return fmt.Errorf("prepare the sandbox runtime component %s beneath %s: %w", name, physicalBase, createErr)
|
||||
}
|
||||
child = existing
|
||||
}
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = child
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -104,12 +104,21 @@ const (
|
||||
BlockNetwork BlockCode = "network"
|
||||
BlockDestructiveCommand BlockCode = "destructive_command"
|
||||
BlockPersistentDeny BlockCode = "persistent_deny"
|
||||
BlockNestedGitInit BlockCode = "nested_git_init"
|
||||
// BlockDenied is the catch-all for a denied decision that carries no more
|
||||
// specific block code.
|
||||
BlockDenied BlockCode = "denied"
|
||||
)
|
||||
|
||||
const ReasonNetworkBlocked = "network access requires approval"
|
||||
|
||||
// ReasonNestedGitInit explains a refusal the operator can act on: the remedy is
|
||||
// to run git init outside the sandbox, or to open the repository itself as the
|
||||
// workspace rather than a subdirectory of it.
|
||||
const ReasonNestedGitInit = "this workspace sits inside an existing git repository, so the sandbox has no git protection here to extend to a new one: " +
|
||||
"a repository created now would carry a writable config and hooks. " +
|
||||
"Every git init in the command is refused, because the sandbox does not resolve which directory each one targets. " +
|
||||
"Run git init outside the sandbox, or open the repository root as the workspace"
|
||||
const ReasonEscalatedSandboxRequired = "unsandboxed shell command requires approval"
|
||||
|
||||
const (
|
||||
|
||||
@@ -7,12 +7,72 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isWindowsVolumeRoot reports whether a cleaned path is the top of a volume,
|
||||
// with nothing above it: `C:\`, a bare separator, or a UNC share root.
|
||||
//
|
||||
// Detected structurally rather than by pattern matching drive letters, because
|
||||
// filepath.Dir of a root is that same root and of anything else is strictly
|
||||
// shorter. That holds for drive-qualified paths, for the separator alone, and
|
||||
// for UNC roots, on either build host.
|
||||
func isWindowsVolumeRoot(path string) bool {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(path))
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return false
|
||||
}
|
||||
return filepath.Dir(cleaned) == cleaned
|
||||
}
|
||||
|
||||
// validateWindowsACLComponent rejects anything that is not a single path
|
||||
// component.
|
||||
//
|
||||
// This is load-bearing in two places, which is why it lives in the portable file
|
||||
// rather than beside either of them.
|
||||
//
|
||||
// At apply time NtCreateFile happily resolves a RELATIVE name containing
|
||||
// separators, and it resolves it the ordinary way, so an intermediate junction
|
||||
// inside that name is followed and the object lands outside the pinned parent.
|
||||
// A name with a separator reopens exactly the hole the parent handle exists to
|
||||
// close.
|
||||
//
|
||||
// At plan time the same shape escapes the write root: a name is joined onto the
|
||||
// root to place a deny ACE, so ".." or a separator puts that ACE on a directory
|
||||
// outside the workspace entirely.
|
||||
//
|
||||
// The separators are checked explicitly rather than via filepath.Base, because
|
||||
// these are Windows paths whatever the build host is, and on Linux
|
||||
// filepath.Base leaves a backslash-joined name untouched and would wave it
|
||||
// through. A colon is rejected too: it names an alternate data stream or a
|
||||
// drive, neither of which is a child.
|
||||
func validateWindowsACLComponent(name string) error {
|
||||
switch {
|
||||
case name == "":
|
||||
return errors.New("windows ACL path component is empty")
|
||||
case name == "." || name == "..":
|
||||
return fmt.Errorf("windows ACL path component %q is a relative reference, not a child", name)
|
||||
case strings.ContainsAny(name, `\/`):
|
||||
return fmt.Errorf("windows ACL path component %q contains a separator, so it would resolve through intermediate directories instead of staying a child", name)
|
||||
case strings.Contains(name, ":"):
|
||||
return fmt.Errorf("windows ACL path component %q contains a colon, which names a stream or a drive rather than a child", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WindowsACLAction string
|
||||
|
||||
const (
|
||||
WindowsACLAllowWrite WindowsACLAction = "allow-write"
|
||||
WindowsACLDenyRead WindowsACLAction = "deny-read"
|
||||
WindowsACLDenyWrite WindowsACLAction = "deny-write"
|
||||
// WindowsACLDenyDelete denies removing or renaming the object it names,
|
||||
// WITHOUT denying writes to it or inside it, and without inheriting.
|
||||
//
|
||||
// It exists for .git. The write-denied carveouts live on .git/config and
|
||||
// .git/hooks as objects, so replacing the .git directory discards them: the
|
||||
// recreated config and hooks inherit the workspace allow with no deny, which
|
||||
// restores credential.helper and core.hooksPath. .git cannot simply join
|
||||
// sandboxFullyProtectedMetadataNames, because DenyWrite's mask includes
|
||||
// FILE_GENERIC_WRITE and git must write index, objects and refs.
|
||||
WindowsACLDenyDelete WindowsACLAction = "deny-delete"
|
||||
// WindowsACLRevokeCapability removes any existing ACE (allow or deny) for
|
||||
// Capability at Path, without itself granting or denying anything (applied
|
||||
// via SetEntriesInAclW's SET_ACCESS mode with a zero mask, not
|
||||
@@ -41,6 +101,11 @@ type WindowsACLEntry struct {
|
||||
// sandbox. Empty for an operator-named path, which has no owned tail.
|
||||
Anchor string `json:"anchor,omitempty"`
|
||||
Materialize bool `json:"materialize,omitempty"`
|
||||
// MaterializeFile makes Materialize create an empty FILE instead of a
|
||||
// directory. Only meaningful with Materialize. .git/config is the case that
|
||||
// forces the distinction: created as a directory it does not merely carry
|
||||
// the wrong ACL, it makes `git init` fail outright.
|
||||
MaterializeFile bool `json:"materializeFile,omitempty"`
|
||||
}
|
||||
|
||||
type WindowsACLPlan struct {
|
||||
@@ -62,6 +127,34 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
|
||||
Path: capability.Root,
|
||||
Capability: capability.SID,
|
||||
})
|
||||
// The grant above carries DELETE, so the same object guard the principal
|
||||
// plan has must come with it: not DenyWrite (git writes index, objects and
|
||||
// refs), not materialized (git creates .git, and an empty one breaks
|
||||
// git init), and not inherited, so everything underneath stays writable.
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyDelete,
|
||||
Path: windowsRenameProtectedObject(capability.Root),
|
||||
Capability: capability.SID,
|
||||
})
|
||||
// MATERIALIZED, LIKE THE PRINCIPAL PLAN'S, AND FOR TWO REASONS.
|
||||
//
|
||||
// First, a guard attached to an object that does not exist is not applied.
|
||||
// On a workspace that had no .git when setup ran, the first pass skipped all
|
||||
// three of these as missing and the deferred pass skipped them again,
|
||||
// because nothing else in this plan created them. Setup still recorded
|
||||
// success. A later git init then created .git, config and hooks beneath the
|
||||
// already-granted workspace, where they inherit the allow, DELETE included,
|
||||
// with no object-specific deny of their own: the sandboxed command could
|
||||
// rename .git aside, recreate it, and get credential.helper and
|
||||
// core.hooksPath back.
|
||||
//
|
||||
// Second, materializing these is what lets the applier's deferred pass land
|
||||
// the deny-delete on .git above, since creating .gitconfig and .githooks
|
||||
// creates .git as their parent.
|
||||
//
|
||||
// The principal planner has done this from the start. Sharing the shape
|
||||
// rather than only the pathname is the point: this tier is the DEFAULT
|
||||
// backend, so the weaker of the two rules was the one almost everyone got.
|
||||
for _, path := range capability.ProtectedWriteDenyPaths {
|
||||
// ANCHORED ONLY WHERE THE ANCHOR IS TRUE. Most of these are derived
|
||||
// from the root and sit under it, and holding those inside it is the
|
||||
@@ -75,10 +168,58 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
|
||||
anchor = capability.Root
|
||||
}
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: path,
|
||||
Capability: capability.SID,
|
||||
Anchor: anchor,
|
||||
Materialize: true,
|
||||
MaterializeFile: gitMetadataCarveoutIsFile(path),
|
||||
})
|
||||
}
|
||||
}
|
||||
// Read roots, granted to the read-capability SID.
|
||||
//
|
||||
// A profile carrying DenyRead runs the command on a strict token rather than a
|
||||
// WRITE_RESTRICTED one, and the strict token applies the restricted-SID check
|
||||
// to reads. Read roots reached that check granted only to the principal's own
|
||||
// account SID, which the write jail keeps out of the restricting set on
|
||||
// purpose, so every read failed it — including opening the executable. The
|
||||
// principal plan still grants the account SID; this is the other half of the
|
||||
// same grant, so the allow-list and the restriction now come from one place.
|
||||
// AND IT REFUSES TO EXPRESS "READ EVERYWHERE" AS AN ACE ON THE VOLUME ROOT.
|
||||
//
|
||||
// Production profiles seed ReadRoots with the bare filesystem root, so this
|
||||
// loop used to add an inheritable allow-read for the synthetic read SID at
|
||||
// "C:\". SetSecurityInfo propagates inheritable ACEs onto existing children,
|
||||
// so that is not one sandbox-owned object being changed: it walks and rewrites
|
||||
// DACL inheritance across unrelated system, application and user trees on the
|
||||
// drive, and a locked or exclusively opened descendant makes the result depend
|
||||
// on ambient filesystem state.
|
||||
//
|
||||
// It is not even complete after paying that price. A bare root resolves on one
|
||||
// volume, while the executables, DLLs and tool installations a command needs
|
||||
// can sit on another without appearing as their own read roots, so the strict
|
||||
// token still fails before its executable starts. Measured: with the read SID
|
||||
// granted nowhere the strict token cannot open C:WindowsSystem32cmd.exe.
|
||||
//
|
||||
// So the profile that needs this is refused, at both setup tiers, rather than
|
||||
// half-served by a persistent volume-wide ACL edit. That leaves denyRead
|
||||
// unavailable on Windows until there is a bounded authorization model covering
|
||||
// the real platform, runtime and executable dependencies on every relevant
|
||||
// volume, which is what #869 tracks.
|
||||
readSID, err := windowsReadAllowCapabilitySID(config)
|
||||
if err != nil {
|
||||
return WindowsACLPlan{}, err
|
||||
}
|
||||
if readSID != "" {
|
||||
for _, path := range config.PermissionProfile.FileSystem.ReadRoots {
|
||||
if path = strings.TrimSpace(path); path == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLAllowRead,
|
||||
Path: path,
|
||||
Capability: capability.SID,
|
||||
Anchor: anchor,
|
||||
Capability: readSID,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -114,6 +255,102 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
|
||||
return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil
|
||||
}
|
||||
|
||||
// WindowsACLPlanReadGrantRefusal reports why a plan must not be applied, or "".
|
||||
//
|
||||
// ONE ANSWER FOR EVERY TIER THAT APPLIES A PLAN. This started inside the
|
||||
// unelevated tier, because that is where it was first observed: an ordinary user
|
||||
// cannot write the volume root's DACL, so every command failed there with a
|
||||
// generic diagnosis. Elevated setup CAN write it, and that is worse rather than
|
||||
// better. SetSecurityInfo propagates inheritable ACEs to existing children, so
|
||||
// applying it rewrites DACL inheritance across unrelated trees, and it still does
|
||||
// not cover a second volume, so the strict token can fail to open its own
|
||||
// executable after all that.
|
||||
//
|
||||
// KEYED ON THE READ GRANT, NOT ON THE VOLUME ROOT. Checking for a volume root
|
||||
// tested a symptom of the production profile rather than the thing that is
|
||||
// unsafe. permissionProfileReadRoots happens to seed the bare filesystem root, so
|
||||
// that check covered production by coincidence; a profile with a narrowed read
|
||||
// list and a denyRead still put an inheritable ACE on C:Windows and passed. The
|
||||
// grant itself is what cannot be applied safely to a directory Zero does not own,
|
||||
// and the grant exists only for a denyRead profile, so that is what is refused.
|
||||
func WindowsACLPlanReadGrantRefusal(plan WindowsACLPlan) string {
|
||||
root := windowsPlanReadGrantTarget(plan)
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return "this sandbox profile configures denyRead, which selects a fully restricted token, and that token applies its restricted-SID check to reads as well as writes. " +
|
||||
"Serving it needs the read capability granted on directories Zero does not own, starting at " + root + ", which cannot be done safely: the grant is inheritable, so applying it rewrites permissions across unrelated system, application and user directories, " +
|
||||
"and it still would not cover executables or libraries on another volume. " +
|
||||
"denyRead is therefore not available on Windows yet (see #869). Remove denyRead from the sandbox configuration to run on a write-restricted token, which keeps the workspace write jail intact"
|
||||
}
|
||||
|
||||
// windowsPlanReadGrantTarget returns the broadest path the plan would grant the
|
||||
// read capability on that Zero does not already own, preferring a volume root so
|
||||
// the diagnostic names the worst of them.
|
||||
//
|
||||
// A read grant on a write root is Zero's own directory and is not the problem;
|
||||
// the objection is to writing an inheritable ACE onto somebody else's tree. So
|
||||
// the write roots are excluded, and an empty answer means every read grant lands
|
||||
// on a directory this sandbox already governs, which needs no refusal.
|
||||
func windowsPlanReadGrantTarget(plan WindowsACLPlan) string {
|
||||
owned := make(map[string]struct{})
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLAllowWrite {
|
||||
owned[strings.ToLower(normalizeProfilePath(entry.Path))] = struct{}{}
|
||||
}
|
||||
}
|
||||
first := ""
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action != WindowsACLAllowRead {
|
||||
continue
|
||||
}
|
||||
normalized := normalizeProfilePath(entry.Path)
|
||||
if _, ours := owned[strings.ToLower(normalized)]; ours {
|
||||
continue
|
||||
}
|
||||
if isWindowsVolumeRoot(normalized) {
|
||||
return entry.Path
|
||||
}
|
||||
if first == "" {
|
||||
first = entry.Path
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// windowsPlanVolumeRootGrant returns the first volume root the plan would have
|
||||
// to change permissions on, or empty when it needs none.
|
||||
//
|
||||
// Only elevated setup can write a volume-root DACL, so this is the one entry
|
||||
// that decides whether a plan is applicable by the unelevated tier at all. The
|
||||
// check is on the PLAN rather than on the profile because the plan is what gets
|
||||
// applied: a future entry that lands at a volume root for some other reason is
|
||||
// caught by the same test.
|
||||
func windowsPlanVolumeRootGrant(plan WindowsACLPlan) string {
|
||||
for _, entry := range plan.Entries {
|
||||
if isWindowsVolumeRoot(normalizeProfilePath(entry.Path)) {
|
||||
return entry.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// windowsRenameProtectedObject names the object whose DELETE must be denied on
|
||||
// a write root, whatever trustee holds the grant.
|
||||
//
|
||||
// ONE DERIVATION, BECAUSE TWO PLANNERS CONSUME IT. The allow-write mask both
|
||||
// backends share includes DELETE, and it inherits from the write root onto .git.
|
||||
// The carveouts that actually protect git live on .git/config and .git/hooks as
|
||||
// OBJECTS, so renaming .git aside and recreating it discards them: the fresh
|
||||
// config and hooks inherit the workspace allow with no deny of their own, which
|
||||
// hands back credential.helper and core.hooksPath. The principal planner denied
|
||||
// DELETE here and the capability planner did not, so the default restricted-token
|
||||
// backend was missing the guard entirely. Deriving it in one place is what stops
|
||||
// a change to the shared mask from updating one consumer and not the other.
|
||||
func windowsRenameProtectedObject(root string) string {
|
||||
return filepath.Join(root, sandboxRenameProtectedMetadataName)
|
||||
}
|
||||
|
||||
type windowsWriteRootCapability struct {
|
||||
Root string
|
||||
SID string
|
||||
@@ -175,17 +412,32 @@ func windowsWriteCapabilitySIDs(capabilities []windowsWriteRootCapability) []str
|
||||
}
|
||||
|
||||
func windowsReadDenyCapabilitySIDs(config WindowsSandboxCommandConfig, writeSIDs []string) ([]string, error) {
|
||||
if len(writeSIDs) > 0 {
|
||||
return writeSIDs, nil
|
||||
}
|
||||
if len(config.PermissionProfile.FileSystem.DenyRead) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome)
|
||||
// The read-capability SID must be denied here as well as granted above. It
|
||||
// holds read on the read roots, and those start at the filesystem root, so a
|
||||
// carveout sitting inside one of them would stay readable through that grant
|
||||
// and the deny list would quietly stop meaning anything.
|
||||
// Every SID the token can carry has to be denied, not just the newest one.
|
||||
// With no write roots the token's only capability is ReadOnly, so dropping it
|
||||
// here would leave the deny naming a SID the command never holds.
|
||||
denySIDs := append([]string{}, writeSIDs...)
|
||||
if len(denySIDs) == 0 {
|
||||
caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
denySIDs = append(denySIDs, caps.ReadOnly)
|
||||
}
|
||||
readSID, err := windowsReadAllowCapabilitySID(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{caps.ReadOnly}, nil
|
||||
if readSID != "" {
|
||||
denySIDs = append(denySIDs, readSID)
|
||||
}
|
||||
return denySIDs, nil
|
||||
}
|
||||
|
||||
func planWindowsDenyReadPaths(paths []string) []string {
|
||||
@@ -232,3 +484,19 @@ func dedupeWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// windowsReadAllowCapabilitySID returns the read-capability SID when this profile
|
||||
// will run on a strict token, and "" otherwise.
|
||||
//
|
||||
// Gated on DenyRead because that is exactly what selects the strict token in the
|
||||
// runner: with no DenyRead the token stays WRITE_RESTRICTED, reads skip the
|
||||
// restricted-SID check entirely, and granting read on roots as broad as the
|
||||
// filesystem root would add ACEs that buy nothing. Derived purely from the
|
||||
// profile, so the setup half and the command half reach the same answer without
|
||||
// consulting anything ambient.
|
||||
func windowsReadAllowCapabilitySID(config WindowsSandboxCommandConfig) (string, error) {
|
||||
if len(config.PermissionProfile.FileSystem.DenyRead) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return WindowsReadAllowSID(config.SandboxHome)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"unsafe"
|
||||
@@ -26,28 +28,128 @@ type windowsACLPathGroup struct {
|
||||
Path string
|
||||
// Anchor is the write root Path was derived from, empty when the operator
|
||||
// named the path. See verifyWindowsACLHandleUnderAnchor.
|
||||
Anchor string
|
||||
Entries []WindowsACLEntry
|
||||
Materialize bool
|
||||
Anchor string
|
||||
Entries []WindowsACLEntry
|
||||
Materialize bool
|
||||
MaterializeFile bool
|
||||
}
|
||||
|
||||
// windowsACLChainStep is one directory component beneath the anchor, and
|
||||
// whether THIS run created it. The flag comes from the kernel rather than from
|
||||
// "it was missing when we looked", because something else can win the gap
|
||||
// between the probe and the create, and removing a directory the sandbox did not
|
||||
// make is how a rollback deletes a user's data.
|
||||
type windowsACLChainStep struct {
|
||||
Name string
|
||||
Made bool
|
||||
// ID is the identity of the directory this run created at Name, read from
|
||||
// the handle that created it. Rollback removes only an object with this
|
||||
// identity: a created name says the parent is unchanged, not that the
|
||||
// child has not been renamed aside and replaced since.
|
||||
ID windowsFileIdentity
|
||||
}
|
||||
|
||||
// windowsACLMaterialization is exactly what materialization created, recorded in
|
||||
// the shape rollback needs to undo it without resolving a single pathname below
|
||||
// the anchor.
|
||||
//
|
||||
// The anchor is the deepest directory that already existed, and it is the ONLY
|
||||
// pathname rollback re-resolves. Everything under it is a list of single
|
||||
// components walked one handle at a time, because a name containing a separator
|
||||
// is resolved the ordinary way by the kernel and would follow an intermediate
|
||||
// junction straight out of the approved tree.
|
||||
type windowsACLMaterialization struct {
|
||||
AnchorPath string
|
||||
AnchorID windowsFileIdentity
|
||||
// Chain is every component between the anchor and the target, shallow to
|
||||
// deep. All of them are needed to descend at rollback time; only the ones
|
||||
// with Made set are removed.
|
||||
Chain []windowsACLChainStep
|
||||
// File is the leaf file component created inside the deepest Chain entry,
|
||||
// for the .git/config carveout. Empty when the target is a directory.
|
||||
File string
|
||||
FileMade bool
|
||||
// FileID identifies the leaf file this run created, for the same reason.
|
||||
FileID windowsFileIdentity
|
||||
}
|
||||
|
||||
func (materialization windowsACLMaterialization) createdAnything() bool {
|
||||
if materialization.FileMade {
|
||||
return true
|
||||
}
|
||||
for _, step := range materialization.Chain {
|
||||
if step.Made {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// windowsACLRestoreHook observes restore order. Nil in production; the ordering
|
||||
// it exposes is load-bearing and was previously pinned by nothing at all,
|
||||
// despite a comment claiming otherwise.
|
||||
var windowsACLRestoreHook func(path string)
|
||||
|
||||
type windowsACLSnapshot struct {
|
||||
Path string
|
||||
Descriptor *windows.SECURITY_DESCRIPTOR
|
||||
Materialized bool
|
||||
Path string
|
||||
Descriptor *windows.SECURITY_DESCRIPTOR
|
||||
Created windowsACLMaterialization
|
||||
// TargetID is the object the DACL was read from, so the restore can prove it
|
||||
// is writing back to that same object rather than to whatever the pathname
|
||||
// resolves to at rollback time.
|
||||
//
|
||||
// A no-follow re-open catches a REPARSE POINT swapped in since apply, and
|
||||
// that is what the restore relied on. It cannot catch the other
|
||||
// substitution: rename the target aside and put an ordinary directory of the
|
||||
// same name in its place. Nothing about that decoy is a reparse point, so the
|
||||
// re-open succeeds and the old DACL is written onto the attacker's object
|
||||
// while the real one keeps the aborted setup's ACEs.
|
||||
//
|
||||
// Volume serial plus file index is the same identity the materialization
|
||||
// unwind already anchors on. See reopenWindowsACLDirectoryAsIdentity.
|
||||
TargetID windowsFileIdentity
|
||||
}
|
||||
|
||||
func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) {
|
||||
groups := groupWindowsACLPlanByPath(plan)
|
||||
snapshots := make([]windowsACLSnapshot, 0, len(groups))
|
||||
abort := func(err error) (func() error, error) {
|
||||
if rollbackErr := rollbackWindowsACLSnapshots(snapshots); rollbackErr != nil {
|
||||
return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var deferred []windowsACLPathGroup
|
||||
for _, group := range groups {
|
||||
snapshot, applied, err := applyWindowsACLPathGroup(group)
|
||||
if err != nil {
|
||||
rollbackErr := rollbackWindowsACLSnapshots(snapshots)
|
||||
if rollbackErr != nil {
|
||||
return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr)
|
||||
}
|
||||
return nil, err
|
||||
return abort(err)
|
||||
}
|
||||
if applied {
|
||||
snapshots = append(snapshots, snapshot)
|
||||
continue
|
||||
}
|
||||
deferred = append(deferred, group)
|
||||
}
|
||||
|
||||
// A group that applied to nothing was skipped because its target did not
|
||||
// exist and the group does not materialize one. A LATER group can still
|
||||
// create it, so the skip has to be retried rather than treated as final.
|
||||
//
|
||||
// The .git rename guard is exactly this shape and was silently absent
|
||||
// because of it. .git must NOT be materialized, since an empty one breaks
|
||||
// `git init`, so its deny-delete group carries no Materialize. But .git does
|
||||
// get created, as the parent chain of the .git\config carveout, and that
|
||||
// group sorts AFTER it. So on every workspace that did not already have a
|
||||
// .git, the guard was skipped, the directory appeared moments later, and
|
||||
// nothing went back for it: the principal could then rename .git aside and
|
||||
// shed the config and hooks carveouts, which is the escape the guard exists
|
||||
// to stop. Groups that are genuinely absent simply skip again here.
|
||||
for _, group := range deferred {
|
||||
snapshot, applied, err := applyWindowsACLPathGroup(group)
|
||||
if err != nil {
|
||||
return abort(err)
|
||||
}
|
||||
if applied {
|
||||
snapshots = append(snapshots, snapshot)
|
||||
@@ -72,6 +174,7 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup {
|
||||
}
|
||||
group.Entries = append(group.Entries, entry)
|
||||
group.Materialize = group.Materialize || entry.Materialize
|
||||
group.MaterializeFile = group.MaterializeFile || entry.MaterializeFile
|
||||
// One path is derived from at most one root, so the first anchor seen is
|
||||
// the anchor. Taking it rather than overwriting keeps a later
|
||||
// operator-named duplicate of the same path from clearing it.
|
||||
@@ -101,8 +204,42 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
|
||||
// elevated setup a lower-privileged local user could swap the target for a
|
||||
// symlink/junction between operations and redirect the ACL change onto a
|
||||
// system object it never validated (issue #728, a TOCTOU privilege boundary).
|
||||
materialized := false
|
||||
handle, isDir, err := openWindowsACLTarget(path)
|
||||
// Reject a malformed group BEFORE touching the filesystem. This validation
|
||||
// used to run after materialization, so a bad SID or an unknown action
|
||||
// created a directory chain and only then failed, leaving the error path to
|
||||
// unwind work that never needed doing. Nothing here depends on isDir: that
|
||||
// argument only selects the inheritance flag, while the errors come from the
|
||||
// action lookup and the SID parse.
|
||||
if _, _, err := prepareWindowsACLPathGroupEntries(group.Entries, false, nil); err != nil {
|
||||
return windowsACLSnapshot{}, false, err
|
||||
}
|
||||
|
||||
var created windowsACLMaterialization
|
||||
var handle windows.Handle
|
||||
// Every exit from here goes through one closure, because there are now two
|
||||
// things to undo rather than one: the open handle, and whatever
|
||||
// materialization created. Both are captured by reference and both start
|
||||
// zero, so calling this before either is set is safe and does nothing.
|
||||
//
|
||||
// The unwind is handle-relative. It must never fall back to a pathname
|
||||
// delete: the failure being cleaned up here can BE the path swap, and
|
||||
// os.RemoveAll on a swapped ancestor is precisely the recursive elevated
|
||||
// delete outside the workspace that this cleanup is supposed to prevent.
|
||||
fail := func(err error) (windowsACLSnapshot, bool, error) {
|
||||
if handle != 0 {
|
||||
_ = windows.CloseHandle(handle)
|
||||
}
|
||||
// The apply itself is failing, so nothing has been left with a DACL to
|
||||
// restore; only the removal outcome matters here.
|
||||
if _, unwindErr := rollbackWindowsACLMaterialization(created); unwindErr != nil {
|
||||
return windowsACLSnapshot{}, false, fmt.Errorf("%w; cleanup failed: %v", err, unwindErr)
|
||||
}
|
||||
return windowsACLSnapshot{}, false, err
|
||||
}
|
||||
|
||||
var isDir bool
|
||||
var err error
|
||||
handle, isDir, err = openWindowsACLTargetFinalComponent(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return windowsACLSnapshot{}, false, err
|
||||
@@ -118,26 +255,20 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
|
||||
// elevated setup create the directory outside the write root and only the
|
||||
// containment check below would notice, after the fact.
|
||||
if err := verifyWindowsACLPathUnderAnchor(group.Anchor, path); err != nil {
|
||||
return windowsACLSnapshot{}, false, err
|
||||
return fail(err)
|
||||
}
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
return windowsACLSnapshot{}, false, fmt.Errorf("materialize windows ACL target %s: %w", path, err)
|
||||
}
|
||||
materialized = true
|
||||
handle, isDir, err = openWindowsACLTarget(path)
|
||||
// created is assigned even on failure: materialization reports what it
|
||||
// managed to make before it stopped, and fail() unwinds exactly that.
|
||||
created, err = materializeWindowsACLTarget(path, group.MaterializeFile)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(path)
|
||||
return windowsACLSnapshot{}, false, fmt.Errorf("open materialized windows ACL target %s: %w", path, err)
|
||||
return fail(fmt.Errorf("materialize windows ACL target %s: %w", path, err))
|
||||
}
|
||||
}
|
||||
// From here the handle is open; every early return must close it first (and
|
||||
// remove a freshly materialized target) so a failure leaks neither.
|
||||
fail := func(err error) (windowsACLSnapshot, bool, error) {
|
||||
_ = windows.CloseHandle(handle)
|
||||
if materialized {
|
||||
_ = os.RemoveAll(path)
|
||||
handle, isDir, err = openWindowsACLTargetFinalComponent(path)
|
||||
if err != nil {
|
||||
// This is the branch that fires when the post-create verify catches a
|
||||
// swap, so it is the single most important cleanup in the file.
|
||||
return fail(fmt.Errorf("open materialized windows ACL target %s: %w", path, err))
|
||||
}
|
||||
return windowsACLSnapshot{}, false, err
|
||||
}
|
||||
// THE OBJECT HAS TO BE WHERE THE PLAN SAID IT WOULD BE. The open above
|
||||
// refuses a reparse point at the final component and resolves every one
|
||||
@@ -176,8 +307,14 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
|
||||
// The handle has served its purpose (read+write bound to one object) and is
|
||||
// closed now — rollback re-opens no-follow rather than holding a handle for
|
||||
// the whole sandbox lifetime, since one caller discards the rollback closure.
|
||||
// Captured BEFORE the handle closes, because that handle is the only thing
|
||||
// that names the object rather than the path.
|
||||
targetID, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("read windows ACL target identity for %s: %w", path, err))
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized}, true, nil
|
||||
return windowsACLSnapshot{Path: path, Descriptor: descriptor, Created: created, TargetID: targetID}, true, nil
|
||||
}
|
||||
|
||||
// openWindowsACLTarget opens path for reading and rewriting its DACL without
|
||||
@@ -189,6 +326,30 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
|
||||
// is exactly the redirection this guard exists to prevent. A missing target is
|
||||
// surfaced as os.ErrNotExist so the caller's materialize path still fires.
|
||||
func openWindowsACLTarget(path string) (windows.Handle, bool, error) {
|
||||
handle, isDir, err := openWindowsACLTargetFinalComponent(path)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
// Ancestors are resolved by CreateFile even with FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
// so the final-component check is not enough on its own here: this opener
|
||||
// serves the rollback re-open and the runtime ACE, which know no anchor and
|
||||
// must not be redirected by anything on the way down.
|
||||
if err := verifyWindowsACLTargetNotRedirected(handle, path); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, false, err
|
||||
}
|
||||
return handle, isDir, nil
|
||||
}
|
||||
|
||||
// openWindowsACLTargetFinalComponent is the open without the ancestor check.
|
||||
// The plan apply uses it: a reparse point ABOVE a target is the operator's
|
||||
// business (a workspace on a junction or a dev drive is a configuration that
|
||||
// works), and a reparse point on a tail this package derived from a write root
|
||||
// is caught by verifyWindowsACLHandleUnderAnchor, which knows the anchor and
|
||||
// tolerates exactly the aliases above it. That is main's rule from #1040 and
|
||||
// the two containment tests pin it; the blanket refusal above is kept for the
|
||||
// callers that have no anchor to reason with.
|
||||
func openWindowsACLTargetFinalComponent(path string) (windows.Handle, bool, error) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("encode windows ACL target %s: %w", path, err)
|
||||
@@ -270,13 +431,27 @@ func prepareWindowsACLPathGroupEntries(entries []WindowsACLEntry, isDir bool, ol
|
||||
return nil, nil, err
|
||||
}
|
||||
inheritance := uint32(0)
|
||||
if isDir && !entry.NoInherit {
|
||||
// DenyDelete governs the object it names and nothing beneath it. Inherited
|
||||
// onto .git's children it would deny DELETE on every file inside, so git
|
||||
// could not remove a lock file, a ref, or anything else it rewrites, and
|
||||
// the guard would read as a broken repository rather than as a blocked
|
||||
// rename. Decided here, not in the planner, so no plan can make it inherit.
|
||||
if isDir && !entry.NoInherit && entry.Action != WindowsACLDenyDelete {
|
||||
inheritance = windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT
|
||||
}
|
||||
entryInheritance := inheritance
|
||||
// DenyDelete governs the object it names and nothing beneath it. Inherited
|
||||
// onto .git's children it would deny DELETE on every file inside, so git
|
||||
// could not remove a lock file, a ref, or anything else it rewrites, and
|
||||
// the guard would read as a broken repository rather than as a blocked
|
||||
// rename.
|
||||
if entry.Action == WindowsACLDenyDelete {
|
||||
entryInheritance = windows.NO_INHERITANCE
|
||||
}
|
||||
out = append(out, windows.EXPLICIT_ACCESS{
|
||||
AccessPermissions: permissions,
|
||||
AccessMode: accessMode,
|
||||
Inheritance: inheritance,
|
||||
Inheritance: entryInheritance,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_GROUP,
|
||||
@@ -585,13 +760,61 @@ func windowsAceSID(ace *windows.ACCESS_ALLOWED_ACE) (sid *windows.SID, ok bool)
|
||||
func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACCESS_MASK, error) {
|
||||
switch action {
|
||||
case WindowsACLAllowWrite:
|
||||
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE, nil
|
||||
// DELETE is part of the grant, not an extra. FILE_GENERIC_WRITE covers
|
||||
// creating and modifying but not removing or renaming, and a rename needs
|
||||
// delete access on the source. Under the old same-user token that gap was
|
||||
// invisible, because the caller already held inherited rights on its own
|
||||
// tree; a sandbox principal is a separate account with no such
|
||||
// inheritance, so without DELETE it can write a file it can never delete.
|
||||
// Ordinary editing and most git operations rewrite files by replacing
|
||||
// them, so the omission fails normal work rather than an edge case.
|
||||
//
|
||||
// FILE_DELETE_CHILD is deliberately NOT granted, for the same reason
|
||||
// WRITE_DAC and WRITE_OWNER are not. On a parent it authorises deleting a
|
||||
// child whatever the child's own DACL says, so granting it on a write root
|
||||
// hands back the write-denied carve-outs underneath it: a principal could
|
||||
// delete .git/config and recreate it, and the replacement inherits this
|
||||
// grant with no deny of its own — restoring exactly the credential.helper
|
||||
// and core.hooksPath control the carve-out exists to prevent.
|
||||
//
|
||||
// It was granted here originally to keep the mask symmetric with
|
||||
// WindowsACLDenyWrite, which does treat FILE_DELETE_CHILD as part of
|
||||
// write. Symmetry is the wrong goal: denying a capability is not a reason
|
||||
// to grant it. DELETE alone covers removing and renaming files the
|
||||
// principal owns inside its roots, which is what the grant is for.
|
||||
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE | windows.DELETE, nil
|
||||
case WindowsACLAllowRead:
|
||||
// Read and traverse without write. A sandbox principal is a separate
|
||||
// account with no inherent access to the caller's tree, so a read-only
|
||||
// root has to be granted rather than assumed. Deliberately omits
|
||||
// FILE_GENERIC_WRITE, DELETE and WRITE_DAC.
|
||||
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil
|
||||
case windowsACLRevoke:
|
||||
// REVOKE_ACCESS drops every ACE naming the trustee regardless of the mask,
|
||||
// so the mask is ignored here. Used to retire a principal without having
|
||||
// to remember which access each path was granted.
|
||||
return windows.REVOKE_ACCESS, 0, nil
|
||||
case WindowsACLDenyRead:
|
||||
return windows.DENY_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil
|
||||
case WindowsACLDenyWrite:
|
||||
return windows.DENY_ACCESS, (windows.FILE_GENERIC_WRITE | windows.DELETE | windowsFileDeleteChild | windows.WRITE_DAC | windows.WRITE_OWNER) &^ windows.SYNCHRONIZE, nil
|
||||
case WindowsACLDenyDelete:
|
||||
// Deny removing or RENAMING the object itself, nothing more. Renaming a
|
||||
// directory needs DELETE on that directory, so denying DELETE is what
|
||||
// stops .git being moved aside and recreated without its carveouts.
|
||||
//
|
||||
// WRITE_DAC and WRITE_OWNER come along because a guard the principal can
|
||||
// rewrite, or take ownership of and then rewrite, is not a guard.
|
||||
//
|
||||
// FILE_GENERIC_WRITE is deliberately absent: git writes index, objects and
|
||||
// refs constantly, and denying it would break every commit rather than the
|
||||
// rename. FILE_DELETE_CHILD is absent for the same reason one level down,
|
||||
// since git deletes its own lock files and refs. Neither is needed here:
|
||||
// this ACE does not inherit (see prepareWindowsACLPathGroupEntries), so it
|
||||
// governs the .git directory object alone.
|
||||
return windows.DENY_ACCESS, windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER, nil
|
||||
case WindowsACLRevokeCapability:
|
||||
// Handled specially in windowsExplicitAccessEntries (preserve DenyRead).
|
||||
// Handled specially in prepareWindowsACLPathGroupEntries (preserve DenyRead).
|
||||
return windows.SET_ACCESS, 0, nil
|
||||
default:
|
||||
return 0, 0, fmt.Errorf("unsupported windows ACL action %q", action)
|
||||
@@ -600,29 +823,87 @@ func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACC
|
||||
|
||||
func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error {
|
||||
var errs []error
|
||||
// Reverse order is load-bearing twice over. Sorted ascending by path key, an
|
||||
// ancestor key is always a proper prefix of its descendants, so walking
|
||||
// backwards unwinds descendant before ancestor: a materialized directory is
|
||||
// therefore empty by the time its own removal is attempted. Restoring ACLs in
|
||||
// the same order is independently right, because SetSecurityInfo propagates
|
||||
// inheritable ACEs down, so the ancestor must go last.
|
||||
// TestRollbackUnwindsDescendantsBeforeAncestors pins it.
|
||||
//
|
||||
// SORTED HERE, NOT ASSUMED. The groups are sorted, but the snapshots are in
|
||||
// APPLY order, and applyWindowsACLPlan's deferred pass appends a group whose
|
||||
// target only appeared during the first pass, which is typically an ancestor
|
||||
// of first-pass groups (.git after .git\config). Walking that list backwards
|
||||
// restored the ancestor first. Reported by CodeRabbit;
|
||||
// TestRollbackUnwindsADeferredAncestorAfterItsDescendants pins it.
|
||||
snapshots = slices.Clone(snapshots)
|
||||
sort.SliceStable(snapshots, func(i, j int) bool {
|
||||
return windowsCapabilityPathKey(snapshots[i].Path) < windowsCapabilityPathKey(snapshots[j].Path)
|
||||
})
|
||||
for index := len(snapshots) - 1; index >= 0; index-- {
|
||||
snapshot := snapshots[index]
|
||||
if snapshot.Materialized {
|
||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove materialized windows ACL target %s: %w", snapshot.Path, err))
|
||||
if snapshot.Created.createdAnything() {
|
||||
removed, err := rollbackWindowsACLMaterialization(snapshot.Created)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
// Only skip the restore when the object carrying the DACL is actually
|
||||
// gone. A racer can win the leaf while this run made its parent, and
|
||||
// then the aggregate says "ours" about a file that is not: the parent
|
||||
// cannot be removed because it is non-empty, and skipping here left a
|
||||
// pre-existing file wearing an aborted setup's ACL. Falling through
|
||||
// reaches the same no-follow, TargetID-guarded restore as any other
|
||||
// snapshot, which refuses on a mismatch rather than forcing.
|
||||
if removed {
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
dacl, _, err := snapshot.Descriptor.DACL()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %w", snapshot.Path, err))
|
||||
continue
|
||||
}
|
||||
// Re-open no-follow rather than restoring by pathname: the restore must
|
||||
// land on the real object, not a reparse point swapped in since apply. The
|
||||
// residual window is small because the target is ACL-restricted by now, but
|
||||
// a handle keeps the restore honest. On a materialized-target rollback we
|
||||
// remove it above, so only the restore-existing path opens here.
|
||||
handle, _, err := openWindowsACLTarget(snapshot.Path)
|
||||
// Re-open no-follow AND prove it is the same object. The no-follow open
|
||||
// alone only rules out a reparse point swapped in since apply; a target
|
||||
// renamed aside and replaced by an ordinary directory of the same name
|
||||
// passes it, and the old DACL would land on the decoy while the real
|
||||
// object kept the aborted setup's ACEs. See windowsACLSnapshot.TargetID.
|
||||
handle, _, err := openWindowsACLTargetFinalComponent(snapshot.Path)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("re-open windows ACL target %s for rollback: %w", snapshot.Path, err))
|
||||
continue
|
||||
}
|
||||
// An identity to compare against makes the pathname route irrelevant: the
|
||||
// object either is the one the DACL was read from or it is not. Without
|
||||
// one, the route is all there is, so keep the blanket redirection check.
|
||||
if snapshot.TargetID.empty() {
|
||||
if err := verifyWindowsACLTargetNotRedirected(handle, snapshot.Path); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
errs = append(errs, fmt.Errorf("re-open windows ACL target %s for rollback: %w", snapshot.Path, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !snapshot.TargetID.empty() {
|
||||
got, identityErr := windowsIdentityOfHandle(handle)
|
||||
if identityErr != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
errs = append(errs, fmt.Errorf("read rollback identity for %s: %w", snapshot.Path, identityErr))
|
||||
continue
|
||||
}
|
||||
if got != snapshot.TargetID {
|
||||
// REFUSED, not forced through. Leaving the real object with the
|
||||
// aborted setup's ACEs is the safe direction: the caller is failing
|
||||
// anyway, and writing a stale DACL onto an object setup never
|
||||
// touched is the one outcome rollback must not produce.
|
||||
_ = windows.CloseHandle(handle)
|
||||
errs = append(errs, fmt.Errorf("refusing to restore windows ACL for %s: it is no longer the object setup applied to", snapshot.Path))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if windowsACLRestoreHook != nil {
|
||||
windowsACLRestoreHook(snapshot.Path)
|
||||
}
|
||||
if err := windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil {
|
||||
errs = append(errs, fmt.Errorf("rollback windows ACL for %s: %w", snapshot.Path, err))
|
||||
}
|
||||
@@ -630,3 +911,280 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error {
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// materializeWindowsACLTarget creates a missing ACL target with the shape the
|
||||
// owning tool expects. A directory target is created whole; a file target gets
|
||||
// its parent chain created and then an empty file, because creating it as a
|
||||
// directory would break the tool that owns it rather than just mis-ACL it.
|
||||
// The returned record is meaningful even when the error is non-nil: a chain that
|
||||
// got three levels deep and then failed still has three levels to unwind.
|
||||
func materializeWindowsACLTarget(path string, asFile bool) (windowsACLMaterialization, error) {
|
||||
directory := path
|
||||
leaf := ""
|
||||
if asFile {
|
||||
directory = filepath.Dir(path)
|
||||
leaf = filepath.Base(path)
|
||||
}
|
||||
created, parent, err := makeWindowsACLDirChainNoFollow(directory)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
if !asFile {
|
||||
return created, nil
|
||||
}
|
||||
// windowsACLRacedLeafHook fires at the ONE instant a racer can win the leaf
|
||||
// while this run already owns the chain above it: the directories exist and
|
||||
// the file does not yet. That mixed ownership is what made the old rollback
|
||||
// skip its restore, and it is unreachable from the anchor-stage hook, which
|
||||
// fires before the chain exists at all. Always nil in production.
|
||||
if windowsACLRacedLeafHook != nil {
|
||||
windowsACLRacedLeafHook(path)
|
||||
}
|
||||
// A racing creator winning is still fine: the target exists, which is all
|
||||
// materialization needed. createWindowsACLChildFile reports that as
|
||||
// created=false, so rollback will not delete a file the sandbox did not make.
|
||||
created.File = leaf
|
||||
madeFile, fileID, err := createWindowsACLChildFile(parent, leaf)
|
||||
created.FileMade = madeFile
|
||||
if madeFile {
|
||||
created.FileID = fileID
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
|
||||
// rollbackWindowsACLMaterialization removes exactly what materialization
|
||||
// created, deepest first, without resolving any pathname below the anchor.
|
||||
//
|
||||
// This is the other half of the pathname problem. The old cleanup called
|
||||
// os.RemoveAll on the target pathname, which re-resolves every ancestor at the
|
||||
// moment it runs, so an ancestor swapped to a junction after the object was
|
||||
// created sent a recursive elevated delete into an unrelated tree. It also only
|
||||
// ever removed the final component, leaving every intermediate directory the
|
||||
// chain had created behind.
|
||||
//
|
||||
// The anchor is the one pathname that has to be resolved again, and it is
|
||||
// checked by file identity rather than by name: replacing a directory with
|
||||
// another REAL directory of the same name needs no reparse point at all and
|
||||
// would otherwise pass every no-follow check there is.
|
||||
//
|
||||
// Residue is preferable to over-deletion throughout. When something cannot be
|
||||
// removed safely this reports it and leaves it, and never falls back to a
|
||||
// pathname delete.
|
||||
// rollbackWindowsACLMaterialization removes what this run created and reports
|
||||
// whether the ACL-BEARING TARGET is among what it removed.
|
||||
//
|
||||
// AGGREGATE OWNERSHIP IS NOT THE TARGET'S DISPOSITION. The caller used to treat
|
||||
// createdAnything() as "this run owns the object it applied a DACL to", which is
|
||||
// an OR across the whole materialization record. When this run creates the
|
||||
// parent chain and a racer wins the leaf, that is true while the ACL-bearing
|
||||
// file belongs to somebody else. The caller then skipped the identity-validated
|
||||
// restore and left a pre-existing file carrying an aborted setup's ACL.
|
||||
//
|
||||
// The target is the file leaf when FileMade is set, and the deepest chain entry
|
||||
// otherwise. Anything this cannot prove it removed reports false, which routes
|
||||
// the caller to a restore that is already no-follow and TargetID-guarded, so the
|
||||
// conservative direction is the default.
|
||||
func rollbackWindowsACLMaterialization(materialization windowsACLMaterialization) (targetRemoved bool, err error) {
|
||||
if !materialization.createdAnything() {
|
||||
return false, nil
|
||||
}
|
||||
anchor, anchorErr := reopenWindowsACLDirectoryAsIdentity(materialization.AnchorPath, materialization.AnchorID)
|
||||
if anchorErr != nil {
|
||||
if errors.Is(anchorErr, os.ErrNotExist) {
|
||||
// The anchor is gone, so everything created beneath it is gone too,
|
||||
// the target included. Nothing to undo, and no way to undo it.
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, anchorErr)
|
||||
}
|
||||
|
||||
// One handle per level: handles[i] is the parent of Chain[i], which is what
|
||||
// deleting Chain[i] relative to a pinned parent requires.
|
||||
handles := []windows.Handle{anchor}
|
||||
defer func() {
|
||||
for _, handle := range handles {
|
||||
if handle != 0 {
|
||||
_ = windows.CloseHandle(handle)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Descend only as far as is actually required. Removing a directory needs its
|
||||
// PARENT's handle, not its own, so the deepest component is opened only when
|
||||
// a file leaf lives inside it. This is not just economy: the deepest
|
||||
// component is usually the ACL target itself, so it may already carry the
|
||||
// deny-read ACE this rollback is undoing, and opening it would be refused by
|
||||
// the very ACL being unwound.
|
||||
needed := len(materialization.Chain)
|
||||
if !materialization.FileMade && needed > 0 {
|
||||
needed--
|
||||
}
|
||||
depth := 0
|
||||
for ; depth < needed; depth++ {
|
||||
child, openErr := openWindowsACLChildDirectory(handles[depth], materialization.Chain[depth].Name)
|
||||
if openErr != nil {
|
||||
// Already removed by something else. Stop descending; whatever is
|
||||
// below it is gone with it.
|
||||
if isWindowsNotExist(openErr) {
|
||||
break
|
||||
}
|
||||
return false, fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, openErr)
|
||||
}
|
||||
// A component this run created has to still be the object it created
|
||||
// before anything beneath it is touched: an ordinary directory renamed
|
||||
// into its place beneath the unchanged anchor is not ours to descend
|
||||
// into, let alone delete.
|
||||
if step := materialization.Chain[depth]; step.Made {
|
||||
got, identityErr := windowsIdentityOfHandle(child)
|
||||
if identityErr != nil {
|
||||
_ = windows.CloseHandle(child)
|
||||
return false, fmt.Errorf("unwind windows ACL materialization under %s: identify %s: %w", materialization.AnchorPath, step.Name, identityErr)
|
||||
}
|
||||
if got != step.ID {
|
||||
_ = windows.CloseHandle(child)
|
||||
return false, fmt.Errorf("refusing to unwind windows ACL materialization under %s: %s is no longer the directory setup created", materialization.AnchorPath, step.Name)
|
||||
}
|
||||
}
|
||||
handles = append(handles, child)
|
||||
}
|
||||
|
||||
var errs []error
|
||||
// An ancestor vanished mid-descent, so the target went with it.
|
||||
targetRemoved = depth < needed
|
||||
// The file leaf lives inside the deepest chain directory, so it goes first
|
||||
// and only if the descent actually reached that far.
|
||||
if materialization.FileMade && depth == needed {
|
||||
if err := deleteWindowsACLChildFileAs(handles[len(handles)-1], materialization.File, materialization.FileID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove materialized windows ACL file %s: %w", materialization.File, err))
|
||||
} else {
|
||||
targetRemoved = true
|
||||
}
|
||||
}
|
||||
// Chain[i] is removed through handles[i], so the deepest one that can be
|
||||
// removed is bounded by how far the descent actually got.
|
||||
deepest := len(handles) - 1
|
||||
if last := len(materialization.Chain) - 1; deepest > last {
|
||||
deepest = last
|
||||
}
|
||||
for index := deepest; index >= 0; index-- {
|
||||
// Close the child's own handle, if the descent opened one, before asking
|
||||
// its parent to remove it. A directory with a live handle open still
|
||||
// counts as present, so the parent's delete would come back as non-empty.
|
||||
if index+1 < len(handles) && handles[index+1] != 0 {
|
||||
_ = windows.CloseHandle(handles[index+1])
|
||||
handles[index+1] = 0
|
||||
}
|
||||
if !materialization.Chain[index].Made {
|
||||
continue
|
||||
}
|
||||
if err := deleteWindowsACLChildDirectoryAs(handles[index], materialization.Chain[index].Name, materialization.Chain[index].ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove materialized windows ACL directory %s: %w", materialization.Chain[index].Name, err))
|
||||
continue
|
||||
}
|
||||
// The deepest chain entry IS the ACL target for a directory
|
||||
// materialization, and it is the leaf's parent when a racer made the leaf,
|
||||
// so removing it means the target is gone either way.
|
||||
if !materialization.FileMade && index == len(materialization.Chain)-1 {
|
||||
targetRemoved = true
|
||||
}
|
||||
}
|
||||
return targetRemoved, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// windowsACLMaterializeSwapHook is a test seam and nothing else. It fires inside
|
||||
// makeWindowsACLDirChainNoFollow at the exact instant the race used to be
|
||||
// exploitable: the anchor is verified and pinned, and nothing has been created
|
||||
// yet. A race reproducible only by luck is not a regression test, so the instant
|
||||
// is made addressable rather than hoped for. Always nil in production.
|
||||
var windowsACLMaterializeSwapHook func(anchor string)
|
||||
|
||||
// windowsACLRacedLeafHook fires after the directory chain is created and before
|
||||
// the file leaf is. See materializeWindowsACLTarget. Always nil in production.
|
||||
var windowsACLRacedLeafHook func(path string)
|
||||
|
||||
// makeWindowsACLDirChainNoFollow is an os.MkdirAll that never resolves a
|
||||
// pathname below its anchor.
|
||||
//
|
||||
// It walks UP to the deepest ancestor that already exists and opens it
|
||||
// no-follow. Because GetFinalPathNameByHandle answers for the whole resolved
|
||||
// path, that single check clears every ancestor above it too. Then it walks back
|
||||
// DOWN, creating one component at a time relative to the HANDLE of the level
|
||||
// above, so the tree it descends is pinned to objects rather than named by
|
||||
// strings.
|
||||
//
|
||||
// That is the difference that matters. This used to verify a component by
|
||||
// pathname, close the handle, and then hand the same string to os.Mkdir: two
|
||||
// independent kernel resolutions with a gap between them. A workspace owner who
|
||||
// swapped the verified ancestor for a junction inside that gap got the component
|
||||
// created outside the approved tree, as Administrator, and verifying again
|
||||
// afterwards cannot un-create it. Junctions need no privilege, so this was
|
||||
// reachable by exactly the unprivileged user the sandbox exists to contain.
|
||||
//
|
||||
// It deliberately does NOT re-verify each created component by pathname. A child
|
||||
// created relative to a pinned parent is in the right place by construction, so
|
||||
// comparing pathnames afterwards would add nothing and would reject correct
|
||||
// creates whenever the tree was legitimately renamed mid-setup.
|
||||
//
|
||||
// Returns what it created, plus an open handle to the deepest directory which
|
||||
// the caller must close.
|
||||
func makeWindowsACLDirChainNoFollow(dir string) (windowsACLMaterialization, windows.Handle, error) {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(dir))
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return windowsACLMaterialization{}, 0, fmt.Errorf("materialize windows ACL target: empty directory path %q", dir)
|
||||
}
|
||||
|
||||
// Walk up to the deepest ancestor that exists, collecting the component
|
||||
// NAMES that are missing. Names, not paths: everything below the anchor is
|
||||
// addressed relative to a handle from here on.
|
||||
var missing []string
|
||||
current := cleaned
|
||||
var anchor windows.Handle
|
||||
var anchorID windowsFileIdentity
|
||||
for {
|
||||
handle, identity, err := openWindowsACLDirectoryNoFollowWithIdentity(current)
|
||||
if err == nil {
|
||||
anchor, anchorID = handle, identity
|
||||
break
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return windowsACLMaterialization{}, 0, err
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return windowsACLMaterialization{}, 0, fmt.Errorf("materialize windows ACL target %s: no existing ancestor to anchor on", dir)
|
||||
}
|
||||
missing = append(missing, filepath.Base(current))
|
||||
current = parent
|
||||
}
|
||||
|
||||
created := windowsACLMaterialization{AnchorPath: current, AnchorID: anchorID}
|
||||
|
||||
if hook := windowsACLMaterializeSwapHook; hook != nil {
|
||||
hook(current)
|
||||
}
|
||||
|
||||
// Walk back down, one component per handle. The anchor handle is released as
|
||||
// soon as its child is open, so at most two levels are held at once.
|
||||
parent := anchor
|
||||
for index := len(missing) - 1; index >= 0; index-- {
|
||||
name := missing[index]
|
||||
child, madeNow, err := createWindowsACLChildDirectory(parent, name)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(parent)
|
||||
return created, 0, err
|
||||
}
|
||||
step := windowsACLChainStep{Name: name, Made: madeNow}
|
||||
if madeNow {
|
||||
if step.ID, err = windowsIdentityOfHandle(child); err != nil {
|
||||
_ = windows.CloseHandle(parent)
|
||||
_ = windows.CloseHandle(child)
|
||||
return created, 0, fmt.Errorf("identify materialized windows ACL directory %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
created.Chain = append(created.Chain, step)
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = child
|
||||
}
|
||||
return created, parent, nil
|
||||
}
|
||||
@@ -37,8 +37,15 @@ func TestApplyWindowsACLPathGroupHandleBasedRoundTrip(t *testing.T) {
|
||||
if !applied {
|
||||
t.Fatal("applied = false, want true for an existing directory target")
|
||||
}
|
||||
if snapshot.Path != dir || snapshot.Materialized {
|
||||
t.Fatalf("snapshot = %#v, want Path=%q Materialized=false", snapshot, dir)
|
||||
if snapshot.Path != dir {
|
||||
t.Fatalf("snapshot.Path = %q, want %q", snapshot.Path, dir)
|
||||
}
|
||||
// The target already existed, so nothing was created and rollback must have
|
||||
// nothing to remove. Asserting the chain rather than a bool matters: a
|
||||
// rewiring that recorded the walked components instead of only the created
|
||||
// ones would make rollback delete a directory the sandbox never made.
|
||||
if snapshot.Created.createdAnything() {
|
||||
t.Fatalf("snapshot recorded %#v as created for a target that already existed", snapshot.Created)
|
||||
}
|
||||
if snapshot.Descriptor == nil {
|
||||
t.Fatal("snapshot has no captured descriptor to roll back to")
|
||||
@@ -110,8 +117,21 @@ func TestApplyWindowsACLPathGroupMaterializes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPathGroup: %v", err)
|
||||
}
|
||||
if !applied || !snapshot.Materialized {
|
||||
t.Fatalf("applied=%v materialized=%v, want both true", applied, snapshot.Materialized)
|
||||
if !applied {
|
||||
t.Fatal("applied = false, want true for a materialized target")
|
||||
}
|
||||
// Exactly one component was missing, so exactly one must be recorded as
|
||||
// created, and it must be the leaf's own name rather than a path, carrying
|
||||
// the identity of the directory it made so rollback can prove it is still
|
||||
// deleting that object.
|
||||
if len(snapshot.Created.Chain) != 1 || snapshot.Created.Chain[0].Name != "created" || !snapshot.Created.Chain[0].Made {
|
||||
t.Fatalf("created chain = %#v, want one step {created true}", snapshot.Created.Chain)
|
||||
}
|
||||
if snapshot.Created.Chain[0].ID.empty() {
|
||||
t.Fatalf("created chain step carries no identity: %#v", snapshot.Created.Chain[0])
|
||||
}
|
||||
if snapshot.Created.AnchorPath != filepath.Dir(target) {
|
||||
t.Fatalf("anchor = %q, want the existing parent %q", snapshot.Created.AnchorPath, filepath.Dir(target))
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("materialized target not created: %v", err)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// gitGuardDenyDeleteApplied reports whether the applied DACL on path carries a
|
||||
// DENY ace granting DELETE to sid. Read back from the object rather than from
|
||||
// the plan, because the plan is what was already wrong.
|
||||
func gitGuardDenyDeleteApplied(t *testing.T, path string, sid string) bool {
|
||||
t.Helper()
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("read the DACL of %s: %v", path, err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil || dacl == nil {
|
||||
t.Fatalf("read the DACL of %s: %v", path, err)
|
||||
}
|
||||
wanted, err := windows.StringToSid(sid)
|
||||
if err != nil {
|
||||
t.Fatalf("parse the capability SID %q: %v", sid, err)
|
||||
}
|
||||
// GetAce hands back a generic header that is reinterpreted here. Sound only
|
||||
// for the fixed-layout ACE types: an object ACE carries Flags and two GUIDs
|
||||
// ahead of the trustee, so SidStart would land mid-structure. Allowed and
|
||||
// denied ACEs share that fixed layout, and nothing under test builds an
|
||||
// object ACE, so a type this does not recognise is skipped rather than
|
||||
// decoded into a nonsense SID.
|
||||
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, index, &ace); err != nil {
|
||||
continue
|
||||
}
|
||||
if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE {
|
||||
continue
|
||||
}
|
||||
aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
if !aceSID.Equals(wanted) {
|
||||
continue
|
||||
}
|
||||
if ace.Mask&windows.DELETE != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// THE GUARD BELONGS TO THE GRANT, NOT TO ONE PLANNER.
|
||||
//
|
||||
// The allow-write mask both backends share includes DELETE, and it inherits from
|
||||
// the write root onto .git. What protects git is attached to .git/config and
|
||||
// .git/hooks as OBJECTS, so renaming .git aside and recreating it discards those
|
||||
// carveouts: the fresh config and hooks inherit the workspace allow with no deny
|
||||
// of their own, which hands back credential.helper and core.hooksPath.
|
||||
//
|
||||
// The principal planner denied DELETE on .git; the capability planner did not,
|
||||
// and the capability backend is the default. So on the reachable path the
|
||||
// caller's own token and the capability restricting SID could both authorise the
|
||||
// rename.
|
||||
//
|
||||
// Read back from the applied object, not asserted on the plan, because the plan
|
||||
// is exactly what was wrong. DACL edits on a user-owned directory need no
|
||||
// Administrator rights, so this runs unelevated.
|
||||
func TestCapabilityPlanDeniesDeleteOnGit(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
if err := os.MkdirAll(gitDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
},
|
||||
},
|
||||
}
|
||||
plan, err := BuildWindowsACLPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
sid, err := windowsCapabilitySIDForWriteRoot(config, workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve the workspace capability SID: %v", err)
|
||||
}
|
||||
// SETUP: the plan must actually grant this root, or the guard below would be
|
||||
// vacuously satisfied by a plan that grants nothing.
|
||||
granted := false
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLAllowWrite && entry.Path == workspace {
|
||||
granted = true
|
||||
}
|
||||
}
|
||||
if !granted {
|
||||
t.Fatalf("SETUP INVALID: the capability plan does not grant write on %s", workspace)
|
||||
}
|
||||
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
t.Skipf("cannot apply an ACL plan here: %v", err)
|
||||
}
|
||||
|
||||
if !gitGuardDenyDeleteApplied(t, gitDir, sid) {
|
||||
t.Fatalf(".git carries no deny-DELETE ace for the capability SID %s, so the sandboxed token can rename it and recreate it without the config and hooks carveouts", sid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeJunction points link at target, skipping the test when the environment
|
||||
// refuses to create one. A junction needs no privilege, which is exactly why
|
||||
// this attack is reachable by an ordinary workspace owner.
|
||||
func makeJunction(t *testing.T, link, target string) {
|
||||
t.Helper()
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
|
||||
t.Skipf("cannot create a junction here: %v: %s", err, out)
|
||||
}
|
||||
// Assert it actually redirects. A junction that silently did nothing would
|
||||
// green this test while proving nothing.
|
||||
probe := filepath.Join(link, "redirect-probe")
|
||||
if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("write through junction: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "redirect-probe")); err != nil {
|
||||
t.Fatalf("junction does not redirect, so this test would prove nothing: %v", err)
|
||||
}
|
||||
if err := os.Remove(probe); err != nil {
|
||||
t.Fatalf("clean probe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Elevated setup must not create anything through a reparse-point ancestor.
|
||||
//
|
||||
// FILE_FLAG_OPEN_REPARSE_POINT only stops the FINAL component being followed.
|
||||
// materializeWindowsACLTarget used os.MkdirAll/os.OpenFile on the pathname, both
|
||||
// of which resolve ancestors, so a workspace owner who turned .git into a
|
||||
// junction before setup ran got objects created at a location of their choosing
|
||||
// — as Administrator — and the no-follow check only rejected it afterwards, far
|
||||
// too late to un-create them.
|
||||
func TestMaterializeRefusesAncestorJunctionBeforeCreating(t *testing.T) {
|
||||
for name, asFile := range map[string]bool{"file target": true, "directory target": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
external := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
makeJunction(t, gitDir, external)
|
||||
|
||||
target := filepath.Join(gitDir, "hooks", "config")
|
||||
_, err := materializeWindowsACLTarget(target, asFile)
|
||||
if err == nil {
|
||||
t.Fatalf("materialized %s through a junction ancestor instead of refusing", target)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse") {
|
||||
t.Fatalf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
// Nothing may survive on the other side of the junction. The old code
|
||||
// left every intermediate directory MkdirAll had created.
|
||||
leaked, lerr := os.ReadDir(external)
|
||||
if lerr != nil {
|
||||
t.Fatalf("read external dir: %v", lerr)
|
||||
}
|
||||
if len(leaked) != 0 {
|
||||
names := make([]string, 0, len(leaked))
|
||||
for _, entry := range leaked {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("created %v outside the workspace through the junction", names)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary path still works: no reparse point anywhere, target gets made.
|
||||
func TestMaterializeStillCreatesOrdinaryTargets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, asFile := range map[string]bool{"file target": true, "directory target": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
target := filepath.Join(root, name, "nested", "deeper", "target")
|
||||
if _, err := materializeWindowsACLTarget(target, asFile); err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatalf("target was not created: %v", err)
|
||||
}
|
||||
if info.IsDir() == asFile {
|
||||
t.Fatalf("target isDir=%v, wanted file=%v", info.IsDir(), asFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// swapAncestorAside moves approved out of the way and leaves a junction wearing
|
||||
// its name, pointing at elsewhere. This is the whole attack in three lines, and
|
||||
// it needs no privilege: junctions are creatable by any user, which is exactly
|
||||
// why an unprivileged workspace owner can aim elevated setup wherever they like.
|
||||
//
|
||||
// Returns the path the real directory now lives at.
|
||||
func swapAncestorAside(t *testing.T, approved, elsewhere string) string {
|
||||
t.Helper()
|
||||
moved := approved + "-moved"
|
||||
if err := os.Rename(approved, moved); err != nil {
|
||||
t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err)
|
||||
}
|
||||
makeJunction(t, approved, elsewhere)
|
||||
return moved
|
||||
}
|
||||
|
||||
// requireNothingEscaped fails when anything at all was created on the far side
|
||||
// of the junction.
|
||||
func requireNothingEscaped(t *testing.T, elsewhere string) {
|
||||
t.Helper()
|
||||
leaked, err := os.ReadDir(elsewhere)
|
||||
if err != nil {
|
||||
t.Fatalf("read the decoy directory: %v", err)
|
||||
}
|
||||
if len(leaked) == 0 {
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(leaked))
|
||||
for _, entry := range leaked {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("ESCAPED: created %v outside the approved tree, as Administrator", names)
|
||||
}
|
||||
|
||||
// THE MATERIALIZATION RACE, ON THE PRODUCTION CALL PATH.
|
||||
//
|
||||
// The existing junction test plants its junction before the walk even starts, so
|
||||
// the very first check sees it and refuses. That proves the easy half. The half
|
||||
// the reviewer filed is the swap that happens AFTER a component has been
|
||||
// verified and BEFORE it is used, and no test reached it: a race nobody can
|
||||
// trigger on demand is not a regression test, so makeWindowsACLDirChainNoFollow
|
||||
// carries a seam that fires at exactly that instant.
|
||||
//
|
||||
// The control arm matters as much as the fixed one. It performs the identical
|
||||
// swap and then does what this code used to do, creating by pathname, and
|
||||
// asserts that the object DOES escape. Without it, the fixed arm passing proves
|
||||
// only that some code ran, not that the hole it closes was ever open.
|
||||
func TestMaterializeSurvivesAnAncestorSwappedMidWalk(t *testing.T) {
|
||||
t.Run("control: creating by pathname escapes", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
elsewhere := filepath.Join(root, "OUTSIDE")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
// Verified, exactly as the walk verifies its anchor.
|
||||
if err := verifyWindowsACLPathComponentNotRedirected(approved); err != nil {
|
||||
t.Fatalf("anchor did not verify before the swap: %v", err)
|
||||
}
|
||||
moved := swapAncestorAside(t, approved, elsewhere)
|
||||
|
||||
// The old create: a pathname, re-resolved by the kernel right now.
|
||||
if err := os.MkdirAll(filepath.Join(approved, "a", "b"), 0o700); err != nil {
|
||||
t.Fatalf("pathname create: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "a", "b")); err != nil {
|
||||
t.Fatalf("the control arm did not reproduce the escape, so the fixed arm below proves nothing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(moved, "a", "b")); err == nil {
|
||||
t.Error("the control arm created inside the verified directory, which is not the behaviour being contrasted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixed: creating through the pinned handle stays put", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
elsewhere := filepath.Join(root, "OUTSIDE")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
var moved string
|
||||
swapped := false
|
||||
windowsACLMaterializeSwapHook = func(anchor string) {
|
||||
if swapped {
|
||||
return
|
||||
}
|
||||
swapped = true
|
||||
if anchor != approved {
|
||||
t.Errorf("anchored on %q, want the deepest existing ancestor %q", anchor, approved)
|
||||
}
|
||||
moved = swapAncestorAside(t, approved, elsewhere)
|
||||
}
|
||||
t.Cleanup(func() { windowsACLMaterializeSwapHook = nil })
|
||||
|
||||
created, err := materializeWindowsACLTarget(filepath.Join(approved, "a", "b"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
if !swapped {
|
||||
t.Fatal("the seam never fired, so the swap never happened and this test proved nothing")
|
||||
}
|
||||
requireNothingEscaped(t, elsewhere)
|
||||
if _, err := os.Stat(filepath.Join(moved, "a", "b")); err != nil {
|
||||
t.Errorf("the target did not land in the directory that was verified: %v", err)
|
||||
}
|
||||
// And the record must describe what to unwind, in components rather than
|
||||
// paths, shallowest first.
|
||||
if len(created.Chain) != 2 || created.Chain[0].Name != "a" || created.Chain[1].Name != "b" {
|
||||
t.Fatalf("created chain = %#v, want [a b] shallow to deep", created.Chain)
|
||||
}
|
||||
for _, step := range created.Chain {
|
||||
if !step.Made {
|
||||
t.Errorf("component %q was not recorded as created, so rollback would leave it behind", step.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The FILE target has the same race, and it is the one that matters most:
|
||||
// .git/config is materialized as a file on every stock setup, and it is the file
|
||||
// whose credential.helper is worth stealing.
|
||||
func TestMaterializeFileSurvivesAnAncestorSwappedMidWalk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
elsewhere := filepath.Join(root, "OUTSIDE")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
var moved string
|
||||
swapped := false
|
||||
windowsACLMaterializeSwapHook = func(string) {
|
||||
if swapped {
|
||||
return
|
||||
}
|
||||
swapped = true
|
||||
moved = swapAncestorAside(t, approved, elsewhere)
|
||||
}
|
||||
t.Cleanup(func() { windowsACLMaterializeSwapHook = nil })
|
||||
|
||||
created, err := materializeWindowsACLTarget(filepath.Join(approved, ".git", "config"), true)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
if !swapped {
|
||||
t.Fatal("the seam never fired, so this test proved nothing")
|
||||
}
|
||||
requireNothingEscaped(t, elsewhere)
|
||||
|
||||
landed := filepath.Join(moved, ".git", "config")
|
||||
info, err := os.Stat(landed)
|
||||
if err != nil {
|
||||
t.Fatalf("the file did not land in the directory that was verified: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Error("materialized .git/config as a directory, which breaks git init")
|
||||
}
|
||||
if !created.FileMade || created.File != "config" {
|
||||
t.Errorf("file record = %q made=%v, want config/true", created.File, created.FileMade)
|
||||
}
|
||||
}
|
||||
|
||||
// THE ROLLBACK RACE. The ancestor is swapped AFTER the target was created, which
|
||||
// is the window the teardown path lives in: minutes or hours, not microseconds.
|
||||
//
|
||||
// The bystander is the point. If the unwind resolves by pathname it walks into
|
||||
// the decoy and deletes what it finds there, recursively and elevated. Its
|
||||
// survival is the only thing that proves the unwind did not.
|
||||
func TestRollbackDoesNotFollowAnAncestorSwappedAfterCreation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
elsewhere := filepath.Join(root, "OUTSIDE")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
created, err := materializeWindowsACLTarget(filepath.Join(approved, "a", "b"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
|
||||
// A bystander tree under the decoy, shaped exactly like what we created, so a
|
||||
// pathname unwind would find something to destroy at every level.
|
||||
bystander := filepath.Join(elsewhere, "a", "b")
|
||||
if err := os.MkdirAll(bystander, 0o700); err != nil {
|
||||
t.Fatalf("seed bystander: %v", err)
|
||||
}
|
||||
witness := filepath.Join(bystander, "irreplaceable.txt")
|
||||
if err := os.WriteFile(witness, []byte("not yours to delete"), 0o600); err != nil {
|
||||
t.Fatalf("seed witness: %v", err)
|
||||
}
|
||||
|
||||
moved := swapAncestorAside(t, approved, elsewhere)
|
||||
|
||||
// The anchor pathname now names the decoy, and the decoy is a junction, so
|
||||
// the unwind must refuse rather than proceed. Either way it must not delete.
|
||||
_, err = rollbackWindowsACLMaterialization(created)
|
||||
|
||||
if _, statErr := os.Stat(witness); statErr != nil {
|
||||
t.Fatalf("DESTRUCTIVE: rollback followed the junction and deleted a tree outside the approved directory: %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(bystander); statErr != nil {
|
||||
t.Fatalf("DESTRUCTIVE: rollback removed the bystander directory outside the approved directory: %v", statErr)
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("rollback reported success while unwinding through a swapped ancestor; it must say it could not")
|
||||
}
|
||||
// Residue inside the real tree is the accepted price: leaving it is strictly
|
||||
// better than a recursive delete through a path someone else controls.
|
||||
if _, statErr := os.Stat(filepath.Join(moved, "a", "b")); statErr != nil {
|
||||
t.Logf("note: the real tree was also unwound (%v); leaving it would be acceptable too", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// A real directory wearing the anchor's name is a swap with NO reparse point
|
||||
// anywhere, so every no-follow check in this package passes it. Only the file
|
||||
// identity notices.
|
||||
func TestRollbackRefusesAnAnchorReplacedByARealDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
if err := os.Mkdir(approved, 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
created, err := materializeWindowsACLTarget(filepath.Join(approved, "a"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(approved, approved+"-moved"); err != nil {
|
||||
t.Skipf("cannot rename here: %v", err)
|
||||
}
|
||||
// An ordinary directory. Nothing is a link; nothing is a reparse point.
|
||||
if err := os.Mkdir(approved, 0o700); err != nil {
|
||||
t.Fatalf("plant the replacement: %v", err)
|
||||
}
|
||||
decoy := filepath.Join(approved, "a")
|
||||
if err := os.Mkdir(decoy, 0o700); err != nil {
|
||||
t.Fatalf("plant the decoy child: %v", err)
|
||||
}
|
||||
|
||||
_, err = rollbackWindowsACLMaterialization(created)
|
||||
if err == nil {
|
||||
t.Error("rollback accepted a different directory wearing the anchor's name")
|
||||
} else if !strings.Contains(err.Error(), "no longer the directory") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(decoy); statErr != nil {
|
||||
t.Errorf("rollback deleted a directory it never created: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback removes ONLY what this run created. A pre-existing ancestor is walked
|
||||
// through and left alone.
|
||||
func TestRollbackLeavesDirectoriesItDidNotCreate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
existing := filepath.Join(root, "ws", "already-here")
|
||||
if err := os.MkdirAll(existing, 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
target := filepath.Join(existing, "made", "deeper")
|
||||
|
||||
created, err := materializeWindowsACLTarget(target, false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
if created.AnchorPath != existing {
|
||||
t.Fatalf("anchor = %q, want the deepest pre-existing directory %q", created.AnchorPath, existing)
|
||||
}
|
||||
if _, err := rollbackWindowsACLMaterialization(created); err != nil {
|
||||
t.Fatalf("rollbackWindowsACLMaterialization: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(existing, "made")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("created directory survived rollback: stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(existing); err != nil {
|
||||
t.Errorf("rollback removed a directory that already existed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole apply path, through the closure callers actually hold, rather than
|
||||
// through rollbackWindowsACLSnapshots directly. Every other rollback test in
|
||||
// this package calls the unwind by hand, which cannot catch applyWindowsACLPlan
|
||||
// failing to carry the created record into the snapshots it hands over.
|
||||
func TestApplyWindowsACLPlanClosureRemovesWhatItMaterialized(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
directoryTarget := filepath.Join(root, "ws", "hooks")
|
||||
fileTarget := filepath.Join(root, "ws", "config")
|
||||
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLDenyWrite, Path: directoryTarget, Capability: "S-1-1-0", Materialize: true},
|
||||
{Action: WindowsACLDenyWrite, Path: fileTarget, Capability: "S-1-1-0", Materialize: true, MaterializeFile: true},
|
||||
}}
|
||||
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlan: %v", err)
|
||||
}
|
||||
for _, path := range []string{directoryTarget, fileTarget} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("%s was not materialized: %v", path, err)
|
||||
}
|
||||
}
|
||||
if err := rollback(); err != nil {
|
||||
t.Fatalf("rollback closure: %v", err)
|
||||
}
|
||||
for _, path := range []string{directoryTarget, fileTarget} {
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("%s survived the rollback closure: stat err = %v", path, err)
|
||||
}
|
||||
}
|
||||
// The shared prefix both targets needed must go too, and it is created by
|
||||
// whichever group runs first rather than being owned by both.
|
||||
if _, err := os.Stat(filepath.Join(root, "ws")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("the shared parent survived: stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A rollback that cannot remove something must SAY so. This is the regression
|
||||
// guard for the trap a naive handle-relative port walks straight into:
|
||||
// FILE_DELETE_ON_CLOSE reports success on a non-empty directory and leaves it
|
||||
// there, which turns a loud failure into a silent lie. The directory being
|
||||
// populated is not adversarial; .git/hooks fills up the moment git runs.
|
||||
func TestRollbackReportsWhatItCouldNotRemove(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "ws", "hooks")
|
||||
created, err := materializeWindowsACLTarget(target, false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "pre-commit"), []byte("#!/bin/sh\n"), 0o600); err != nil {
|
||||
t.Fatalf("populate: %v", err)
|
||||
}
|
||||
|
||||
_, err = rollbackWindowsACLMaterialization(created)
|
||||
if err == nil {
|
||||
t.Fatal("rollback reported success on a directory it could not empty, so callers cannot tell teardown failed")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "hooks") {
|
||||
t.Errorf("the error does not name what was left behind: %v", err)
|
||||
}
|
||||
// Left in place deliberately. Removing it would mean recursing, and recursion
|
||||
// through a path the workspace owner controls is the thing being avoided.
|
||||
if _, statErr := os.Stat(target); statErr != nil {
|
||||
t.Errorf("rollback recursed into a populated directory instead of reporting it: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// The primitives take a single component and the walk relies on that. A joined
|
||||
// name is resolved the ordinary way by the kernel, so an intermediate junction
|
||||
// inside it is followed and the object lands outside the anchor: the pinned
|
||||
// parent buys nothing if the name itself walks.
|
||||
func TestChildOperationsRefuseNamesThatAreNotSingleComponents(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
for _, name := range []string{`sub\child`, "sub/child", "..", ".", "", `C:\absolute`, "stream:name"} {
|
||||
t.Run("create dir "+name, func(t *testing.T) {
|
||||
handle, _, err := createWindowsACLChildDirectory(parent, name)
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatalf("accepted %q, which the kernel would resolve through intermediate directories", name)
|
||||
}
|
||||
})
|
||||
t.Run("delete dir "+name, func(t *testing.T) {
|
||||
if err := deleteWindowsACLChildDirectory(parent, name); err == nil {
|
||||
t.Fatalf("accepted %q for deletion", name)
|
||||
}
|
||||
})
|
||||
t.Run("create file "+name, func(t *testing.T) {
|
||||
if _, _, err := createWindowsACLChildFile(parent, name); err == nil {
|
||||
t.Fatalf("accepted %q for file creation", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A junction sitting where a chain component should be must be refused when it
|
||||
// is OPENED, not merely when it is created. FILE_OPEN_REPARSE_POINT hands back a
|
||||
// handle to the junction itself, and using that as the next parent puts every
|
||||
// deeper create on the far side of it.
|
||||
func TestChildOperationsRefuseAnExistingJunction(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
elsewhere := t.TempDir()
|
||||
makeJunction(t, filepath.Join(root, "hop"), elsewhere)
|
||||
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
handle, _, err := createWindowsACLChildDirectory(parent, "hop")
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Error("createWindowsACLChildDirectory returned a junction as the next parent in the walk")
|
||||
}
|
||||
opened, err := openWindowsACLChildDirectory(parent, "hop")
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(opened)
|
||||
t.Error("openWindowsACLChildDirectory returned a junction to descend through")
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback descends; it must never create. If a component was removed by
|
||||
// something else in the meantime, re-making it and then deleting it would remove
|
||||
// a directory the sandbox never made.
|
||||
func TestRollbackDescentNeverCreatesAMissingComponent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
handle, err := openWindowsACLChildDirectory(parent, "never-existed")
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatal("the descent open created a directory that did not exist")
|
||||
}
|
||||
if !isWindowsNotExist(err) {
|
||||
t.Errorf("a missing component reported %v, which rollback cannot distinguish from a real failure", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(root, "never-existed")); statErr == nil {
|
||||
t.Error("a directory appeared on disk from an open that should never create")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// racedLeafDenyMask reads the DENY mask a path carries for sid.
|
||||
func racedLeafDenyMask(t *testing.T, path string, sid string) uint32 {
|
||||
t.Helper()
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("read the DACL of %s: %v", path, err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil || dacl == nil {
|
||||
t.Fatalf("read the DACL of %s: %v", path, err)
|
||||
}
|
||||
wanted, err := windows.StringToSid(sid)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", sid, err)
|
||||
}
|
||||
var mask uint32
|
||||
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if windows.GetAce(dacl, index, &ace) != nil {
|
||||
continue
|
||||
}
|
||||
if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE {
|
||||
continue
|
||||
}
|
||||
if !(*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(wanted) {
|
||||
continue
|
||||
}
|
||||
mask |= uint32(ace.Mask)
|
||||
}
|
||||
return mask
|
||||
}
|
||||
|
||||
// OWNING THE PARENT IS NOT OWNING THE TARGET.
|
||||
//
|
||||
// rollbackWindowsACLSnapshots branched on createdAnything(), an OR across the
|
||||
// whole materialization record. When this run creates the parent chain and a
|
||||
// racer wins the leaf, that is true while the ACL-bearing file belongs to
|
||||
// somebody else: Chain carries Made:true and FileMade is false.
|
||||
//
|
||||
// The rollback then could not remove the parent, because it is not empty, and
|
||||
// the unconditional skip meant the existing no-follow, identity-validated
|
||||
// restore never ran either. A file that existed before setup started was left
|
||||
// wearing an aborted setup's DACL.
|
||||
//
|
||||
// Driven through applyWindowsACLPlan with the leaf created inside the swap hook,
|
||||
// which fires after the anchor is pinned and before anything is made, so the
|
||||
// race is deterministic rather than hoped for.
|
||||
func TestAbortRestoresALeafARacerCreated(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
parent := filepath.Join(workspace, "materialized")
|
||||
leaf := filepath.Join(parent, "config")
|
||||
|
||||
previous := windowsACLRacedLeafHook
|
||||
t.Cleanup(func() { windowsACLRacedLeafHook = previous })
|
||||
raced := false
|
||||
windowsACLRacedLeafHook = func(string) {
|
||||
// The chain above already belongs to this run; the racer wins only the
|
||||
// file. That mixed ownership is the whole case.
|
||||
if raced {
|
||||
return
|
||||
}
|
||||
raced = true
|
||||
_ = os.WriteFile(leaf, []byte("pre-existing content"), 0o600)
|
||||
}
|
||||
|
||||
sid := testPrincipalSID
|
||||
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: leaf,
|
||||
Capability: sid,
|
||||
Materialize: true,
|
||||
MaterializeFile: true,
|
||||
}}}
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
t.Skipf("cannot apply an ACL plan here: %v", err)
|
||||
}
|
||||
|
||||
if !raced {
|
||||
t.Fatal("SETUP INVALID: the leaf hook never fired, so the mixed-ownership case was not reproduced")
|
||||
}
|
||||
// SETUP: the racer really won, and the apply really landed on its file.
|
||||
if _, statErr := os.Stat(leaf); statErr != nil {
|
||||
t.Skipf("SETUP: the racer's leaf is not present, so this is not the mixed-ownership case: %v", statErr)
|
||||
}
|
||||
applied := racedLeafDenyMask(t, leaf, sid)
|
||||
if applied == 0 {
|
||||
t.Skipf("SETUP: the apply did not put a deny ACE on the raced leaf, so there is nothing to restore")
|
||||
}
|
||||
|
||||
// The setup aborts.
|
||||
if rollback != nil {
|
||||
_ = rollback()
|
||||
}
|
||||
|
||||
// The racer's file must not be left wearing this run's ACL.
|
||||
if got := racedLeafDenyMask(t, leaf, sid); got != 0 {
|
||||
t.Fatalf("a file the racer created still carries the aborted setup's deny ACE (mask=%#x); rollback skipped its restore because an ancestor happened to be ours", got)
|
||||
}
|
||||
// And it must still exist: a raced leaf is never ours to delete.
|
||||
body, readErr := os.ReadFile(leaf)
|
||||
if readErr != nil {
|
||||
t.Fatalf("rollback removed a file it did not create: %v", readErr)
|
||||
}
|
||||
if !strings.Contains(string(body), "pre-existing") {
|
||||
t.Fatalf("the raced leaf's contents changed: %q", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Handle-relative directory operations.
|
||||
//
|
||||
// WHY THESE EXIST. Every pathname-based call re-resolves the whole path inside
|
||||
// the kernel at the moment it runs. So verifying a component and then creating
|
||||
// through it are two separate resolutions of the same string, and a workspace
|
||||
// owner can swap an ancestor for a junction in the gap between them: setup
|
||||
// verifies, the attacker swaps, setup creates, and the object lands outside the
|
||||
// approved tree. Checking again afterwards is too late, because the thing has
|
||||
// already been created somewhere it should not be.
|
||||
//
|
||||
// A HANDLE pins the object rather than the name. Once a directory is open, that
|
||||
// handle keeps referring to the same directory however the path is later
|
||||
// rearranged, so creating a child relative to it cannot be redirected. os.Mkdir,
|
||||
// os.OpenFile and os.RemoveAll are pathname-based by construction with no
|
||||
// relative form on Windows, which is why this drops to NtCreateFile with
|
||||
// OBJECT_ATTRIBUTES.RootDirectory.
|
||||
|
||||
// IO_STATUS_BLOCK.Information values for a create/open, named because "2 means
|
||||
// it was created" is not something a reader should have to look up.
|
||||
const (
|
||||
windowsFileOpened uintptr = 1
|
||||
windowsFileCreated uintptr = 2
|
||||
)
|
||||
|
||||
// windowsACLDirectoryShare is the share mode every open here uses. A sandbox
|
||||
// tree is live, so refusing to share would fail on any directory something else
|
||||
// happens to have open: a denial of service on ourselves rather than a security
|
||||
// property.
|
||||
const windowsACLDirectoryShare = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE
|
||||
|
||||
// windowsFileIdentity is the kernel's own answer to "is this the same object",
|
||||
// independent of what it is currently called.
|
||||
//
|
||||
// It exists because rollback cannot hold the anchor handle open for the whole
|
||||
// sandbox lifetime (see rollbackWindowsACLSnapshots), so it has to re-open the
|
||||
// anchor by pathname, and a pathname can be made to name a different object.
|
||||
// Crucially that substitution needs NO reparse point: rename the real directory
|
||||
// aside and create an ordinary directory wearing its name, and every no-follow
|
||||
// check still passes because nothing anywhere is a link. Comparing the volume
|
||||
// and file index catches it, because those identify the object the kernel
|
||||
// actually opened.
|
||||
type windowsFileIdentity struct {
|
||||
Volume uint32
|
||||
IndexHigh uint32
|
||||
IndexLow uint32
|
||||
}
|
||||
|
||||
func (identity windowsFileIdentity) empty() bool {
|
||||
return identity == windowsFileIdentity{}
|
||||
}
|
||||
|
||||
// windowsIdentityOfHandle reads the identity of an already-open object.
|
||||
func windowsIdentityOfHandle(handle windows.Handle) (windowsFileIdentity, error) {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return windowsFileIdentity{}, fmt.Errorf("read windows file identity: %w", err)
|
||||
}
|
||||
return windowsFileIdentity{
|
||||
Volume: info.VolumeSerialNumber,
|
||||
IndexHigh: info.FileIndexHigh,
|
||||
IndexLow: info.FileIndexLow,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openWindowsACLDirectoryNoFollow opens an existing directory by pathname,
|
||||
// refusing to traverse or land on a reparse point.
|
||||
//
|
||||
// This is the ANCHOR for a handle-relative walk: the one pathname resolution
|
||||
// that has to happen, with everything below it relative to the handle it
|
||||
// returns. FILE_FLAG_OPEN_REPARSE_POINT stops the final component being
|
||||
// followed, and verifyWindowsACLTargetNotRedirected then confirms no ancestor
|
||||
// redirected either, because GetFinalPathNameByHandle answers for the whole
|
||||
// resolved path.
|
||||
func openWindowsACLDirectoryNoFollow(path string) (windows.Handle, error) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode windows ACL directory %s: %w", path, err)
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
windows.FILE_LIST_DIRECTORY|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
|
||||
windowsACLDirectoryShare,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
// Errno.Is maps the not-found codes to os.ErrNotExist, so a caller
|
||||
// walking up to find the deepest existing ancestor keeps working.
|
||||
return 0, fmt.Errorf("open windows ACL directory %s: %w", path, err)
|
||||
}
|
||||
if err := verifyWindowsACLHandleIsCleanDirectory(handle, path); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// openWindowsACLDirectoryNoFollowWithIdentity is the anchor open plus the
|
||||
// identity a later rollback needs in order to prove it re-opened the same
|
||||
// object.
|
||||
func openWindowsACLDirectoryNoFollowWithIdentity(path string) (windows.Handle, windowsFileIdentity, error) {
|
||||
handle, err := openWindowsACLDirectoryNoFollow(path)
|
||||
if err != nil {
|
||||
return 0, windowsFileIdentity{}, err
|
||||
}
|
||||
identity, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, windowsFileIdentity{}, fmt.Errorf("identify windows ACL directory %s: %w", path, err)
|
||||
}
|
||||
return handle, identity, nil
|
||||
}
|
||||
|
||||
// reopenWindowsACLDirectoryAsIdentity re-opens an anchor by pathname and refuses
|
||||
// it unless the kernel says it is the object that was opened before.
|
||||
//
|
||||
// Used only by rollback. See windowsFileIdentity for why the pathname alone is
|
||||
// not enough, and rollbackWindowsACLSnapshots for why a handle cannot simply be
|
||||
// held instead.
|
||||
func reopenWindowsACLDirectoryAsIdentity(path string, want windowsFileIdentity) (windows.Handle, error) {
|
||||
handle, err := openWindowsACLDirectoryNoFollow(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if want.empty() {
|
||||
return handle, nil
|
||||
}
|
||||
got, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("identify windows ACL directory %s: %w", path, err)
|
||||
}
|
||||
if got != want {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("refusing to unwind under %s: it is no longer the directory setup created into, so something replaced it since (possible path swap during elevated setup)", path)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// createWindowsACLChildDirectory creates one directory directly beneath parent,
|
||||
// or opens it when it already exists, and reports which happened.
|
||||
//
|
||||
// name must be a single component; see validateWindowsACLComponent for why that
|
||||
// is enforced rather than assumed. The kernel resolves it relative to the parent
|
||||
// HANDLE, so nothing above it is consulted and nothing above it can be swapped
|
||||
// underneath us.
|
||||
//
|
||||
// created is true only when this call made the directory, which the rollback
|
||||
// needs: removing one that already existed would delete a user's data over a
|
||||
// failure that had nothing to do with it.
|
||||
func createWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, created bool, err error) {
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("encode windows ACL directory component %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.FILE_LIST_DIRECTORY|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE|windows.DELETE,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_DIRECTORY,
|
||||
windowsACLDirectoryShare,
|
||||
windows.FILE_OPEN_IF,
|
||||
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
return 0, false, fmt.Errorf("create windows ACL directory component %s: %w", name, err)
|
||||
}
|
||||
// FILE_OPEN_IF means an EXISTING child is opened rather than created, and
|
||||
// FILE_OPEN_REPARSE_POINT means a junction is opened AS the junction. Without
|
||||
// this check that junction becomes the parent of the next level and every
|
||||
// create beneath it lands wherever it points, which is the mid-walk swap this
|
||||
// whole file exists to stop.
|
||||
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, false, err
|
||||
}
|
||||
return handle, status.Information == windowsFileCreated, nil
|
||||
}
|
||||
|
||||
// openWindowsACLChildDirectory opens an EXISTING directory beneath parent and
|
||||
// never creates one.
|
||||
//
|
||||
// Rollback walks back down the chain it created, and it must not conjure a
|
||||
// component that has since been removed: FILE_OPEN_IF would recreate it, and
|
||||
// then the unwind would delete a directory setup never made. FILE_OPEN is the
|
||||
// whole difference from createWindowsACLChildDirectory.
|
||||
func openWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, err error) {
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode windows ACL directory component %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
// Ask for the least that still allows passing through and checking the
|
||||
// reparse attribute. This runs during rollback, on directories whose ACEs
|
||||
// have already been applied, so every extra right is another way for the
|
||||
// unwind to be refused by the very ACL it is unwinding. Notably absent:
|
||||
// SYNCHRONIZE, and with it FILE_SYNCHRONOUS_IO_NONALERT, since nothing is
|
||||
// read or written through this handle.
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.FILE_TRAVERSE|windows.FILE_READ_ATTRIBUTES,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_DIRECTORY,
|
||||
windowsACLDirectoryShare,
|
||||
windows.FILE_OPEN,
|
||||
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf("open windows ACL directory component %s: %w", name, err)
|
||||
}
|
||||
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// createWindowsACLChildFile creates an empty file directly beneath parent, or
|
||||
// opens it when it already exists, and reports which happened.
|
||||
//
|
||||
// The counterpart to createWindowsACLChildDirectory for the one materialized
|
||||
// target that must be a FILE: .git/config, where creating a directory instead
|
||||
// would break `git init` outright rather than merely mis-ACL it.
|
||||
//
|
||||
// FILE_OPEN_IF rather than FILE_CREATE deliberately. The pathname version this
|
||||
// replaces used O_CREATE|O_EXCL and then tolerated os.ErrExist, so a racing
|
||||
// creator winning was fine. FILE_CREATE's collision status is
|
||||
// STATUS_OBJECT_NAME_COLLISION, which errors.Is(err, os.ErrExist) does NOT
|
||||
// match, so porting it literally would have turned that tolerated race into a
|
||||
// hard failure. FILE_OPEN_IF keeps the old behaviour and reports the truth in
|
||||
// created, which rollback needs so it never deletes a file it did not make.
|
||||
func createWindowsACLChildFile(parent windows.Handle, name string) (created bool, identity windowsFileIdentity, err error) {
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return false, windowsFileIdentity{}, err
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return false, windowsFileIdentity{}, fmt.Errorf("encode windows ACL file component %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
var handle windows.Handle
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
windowsACLDirectoryShare,
|
||||
windows.FILE_OPEN_IF,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
return false, windowsFileIdentity{}, fmt.Errorf("create windows ACL file component %s: %w", name, err)
|
||||
}
|
||||
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return false, windowsFileIdentity{}, err
|
||||
}
|
||||
createdNow := status.Information == windowsFileCreated
|
||||
identity, err = windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return createdNow, windowsFileIdentity{}, fmt.Errorf("identify windows ACL file component %s: %w", name, err)
|
||||
}
|
||||
if err := windows.CloseHandle(handle); err != nil {
|
||||
return createdNow, identity, fmt.Errorf("close windows ACL file component %s: %w", name, err)
|
||||
}
|
||||
return createdNow, identity, nil
|
||||
}
|
||||
|
||||
// deleteWindowsACLChildDirectory removes one directory directly beneath parent.
|
||||
//
|
||||
// The counterpart to the create above, and the reason rollback cannot use
|
||||
// os.RemoveAll: that takes a pathname, so an ancestor swapped to a junction
|
||||
// AFTER the object was created sends the recursive delete somewhere else and
|
||||
// takes unrelated trees with it. Resolving relative to the parent handle makes
|
||||
// that impossible.
|
||||
//
|
||||
// A missing child is not an error: rollback runs on failure paths where the
|
||||
// object may never have been created.
|
||||
func deleteWindowsACLChildDirectory(parent windows.Handle, name string) error {
|
||||
return deleteWindowsACLChild(parent, name, true, windowsFileIdentity{})
|
||||
}
|
||||
|
||||
// deleteWindowsACLChildDirectoryAs and deleteWindowsACLChildFileAs remove the
|
||||
// child only if the object at name is still want. The identity is read from
|
||||
// the very handle the delete disposition is set on, so a replacement renamed
|
||||
// in between a check and the unlink cannot be the thing removed.
|
||||
func deleteWindowsACLChildDirectoryAs(parent windows.Handle, name string, want windowsFileIdentity) error {
|
||||
return deleteWindowsACLChild(parent, name, true, want)
|
||||
}
|
||||
|
||||
func deleteWindowsACLChildFileAs(parent windows.Handle, name string, want windowsFileIdentity) error {
|
||||
return deleteWindowsACLChild(parent, name, false, want)
|
||||
}
|
||||
|
||||
// deleteWindowsACLChildFile removes one file directly beneath parent, for the
|
||||
// materialized .git/config carveout. Rollback picks between this and the
|
||||
// directory form from the shape recorded at materialization time rather than by
|
||||
// stat-ing the pathname, because a stat is another pathname resolution and this
|
||||
// whole file exists to avoid those.
|
||||
func deleteWindowsACLChildFile(parent windows.Handle, name string) error {
|
||||
return deleteWindowsACLChild(parent, name, false, windowsFileIdentity{})
|
||||
}
|
||||
|
||||
// deleteWindowsACLChild opens one child relative to parent and deletes it by
|
||||
// SETTING ITS DISPOSITION, not with FILE_DELETE_ON_CLOSE.
|
||||
//
|
||||
// That distinction is the whole point, and it was measured rather than assumed.
|
||||
// FILE_DELETE_ON_CLOSE defers the removal to cleanup, where a non-empty
|
||||
// directory makes it fail with nothing to report it to: NtCreateFile returns
|
||||
// success, CloseHandle returns success, and the directory is still there. A
|
||||
// rollback built on it would report success while leaving materialized state on
|
||||
// disk, which is strictly worse than the os.RemoveAll it replaces, because
|
||||
// os.RemoveAll at least removed it.
|
||||
//
|
||||
// NtSetInformationFile answers synchronously and to the caller, so a non-empty
|
||||
// directory comes back as STATUS_DIRECTORY_NOT_EMPTY. Leaving residue is
|
||||
// acceptable, since the alternative is a recursive delete through a pathname an
|
||||
// attacker may control; lying about having removed it is not.
|
||||
//
|
||||
// FILE_DIRECTORY_FILE / FILE_NON_DIRECTORY_FILE also make the open refuse an
|
||||
// object of the wrong shape rather than deleting it.
|
||||
func deleteWindowsACLChild(parent windows.Handle, name string, directory bool, want windowsFileIdentity) error {
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return err
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode windows ACL component %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
shapeOption := uint32(windows.FILE_NON_DIRECTORY_FILE)
|
||||
shapeAttribute := uint32(windows.FILE_ATTRIBUTE_NORMAL)
|
||||
if directory {
|
||||
shapeOption = windows.FILE_DIRECTORY_FILE
|
||||
shapeAttribute = windows.FILE_ATTRIBUTE_DIRECTORY
|
||||
}
|
||||
|
||||
// DELETE alone. Asking for SYNCHRONIZE as well would make this fail on any
|
||||
// object already carrying a deny-read ACE, because FILE_GENERIC_READ and
|
||||
// FILE_GENERIC_EXECUTE both include SYNCHRONIZE, and rollback exists
|
||||
// precisely to undo objects that have just been ACL'd. Nothing is read or
|
||||
// written through this handle, so synchronous IO is not needed either.
|
||||
var handle windows.Handle
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.DELETE|windows.FILE_READ_ATTRIBUTES,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
shapeAttribute,
|
||||
windowsACLDirectoryShare,
|
||||
windows.FILE_OPEN,
|
||||
shapeOption|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
if isWindowsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("open windows ACL component %s for delete: %w", name, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
if !want.empty() {
|
||||
got, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("identify windows ACL component %s before delete: %w", name, err)
|
||||
}
|
||||
if got != want {
|
||||
return fmt.Errorf("refusing to remove %s: it is no longer the object setup created", name)
|
||||
}
|
||||
}
|
||||
|
||||
// One BOOLEAN: FILE_DISPOSITION_INFORMATION.DeleteFile = TRUE.
|
||||
disposition := byte(1)
|
||||
var setStatus windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtSetInformationFile(
|
||||
handle,
|
||||
&setStatus,
|
||||
&disposition,
|
||||
uint32(unsafe.Sizeof(disposition)),
|
||||
windows.FileDispositionInformation,
|
||||
); err != nil {
|
||||
return fmt.Errorf("delete windows ACL component %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectWindowsACLReparseHandle refuses a handle that landed on a reparse point.
|
||||
//
|
||||
// Deliberately NOT verifyWindowsACLTargetNotRedirected: that one compares the
|
||||
// handle's resolved path against an expected pathname, which is exactly the
|
||||
// pathname dependency the handle-relative walk removes. A child opened relative
|
||||
// to a pinned parent is in the right place by construction even when the
|
||||
// pathname no longer leads there, so comparing paths would reject correct, safe
|
||||
// creates whenever the tree was legitimately renamed. The attribute is the only
|
||||
// thing worth checking here.
|
||||
func rejectWindowsACLReparseHandle(handle windows.Handle, name string) error {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("inspect windows ACL component %s: %w", name, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("refusing to work through reparse-point component %s: possible path swap during elevated setup", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyWindowsACLHandleIsCleanDirectory rejects a handle that landed on a
|
||||
// reparse point, or on an object other than the path asked for.
|
||||
func verifyWindowsACLHandleIsCleanDirectory(handle windows.Handle, path string) error {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("inspect windows ACL directory %s: %w", path, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("refusing to materialize under reparse-point path component %s: possible path swap during elevated setup", path)
|
||||
}
|
||||
return verifyWindowsACLTargetNotRedirected(handle, path)
|
||||
}
|
||||
|
||||
// isWindowsNotExist reports a missing-object error from either side of the API.
|
||||
// NtCreateFile returns NTSTATUS values, which do not map to os.ErrNotExist the
|
||||
// way the Win32 error codes do.
|
||||
func isWindowsNotExist(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
var status windows.NTStatus
|
||||
if errors.As(err, &status) {
|
||||
return status == windows.STATUS_OBJECT_NAME_NOT_FOUND || status == windows.STATUS_OBJECT_PATH_NOT_FOUND
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE SWAP THAT PATHNAMES CANNOT SURVIVE.
|
||||
//
|
||||
// This is the race behind the materialization P1. A pathname-based create
|
||||
// resolves the whole path again at the moment it runs, so an ancestor replaced
|
||||
// between the verification and the create sends the new directory somewhere else
|
||||
// entirely. Verifying afterwards is too late: the object already exists in the
|
||||
// wrong place.
|
||||
//
|
||||
// A handle pins the OBJECT, not the name. This test performs the swap for real,
|
||||
// in the window that used to be exploitable, and asserts the child still lands
|
||||
// in the directory that was verified.
|
||||
//
|
||||
// Worth noting for anyone extending this: the fix is what makes the race
|
||||
// testable at all. Against the old code the swap had to be threaded into the
|
||||
// middle of a function; here it is three ordinary lines between an open and a
|
||||
// create, because the handle is held across them.
|
||||
func TestChildCreationFollowsTheHandleNotThePath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "approved")
|
||||
elsewhere := filepath.Join(root, "elsewhere")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verified once, exactly as setup does before it materializes anything.
|
||||
parent, err := openWindowsACLDirectoryNoFollow(approved)
|
||||
if err != nil {
|
||||
t.Fatalf("open approved directory: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
// THE SWAP, in the window that used to be exploitable: move the verified
|
||||
// directory aside and leave a junction to somewhere else wearing its name.
|
||||
moved := filepath.Join(root, "approved-moved")
|
||||
if err := os.Rename(approved, moved); err != nil {
|
||||
t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err)
|
||||
}
|
||||
makeJunction(t, approved, elsewhere)
|
||||
|
||||
// The create resolves against the handle, so it must ignore the junction now
|
||||
// sitting at the original pathname.
|
||||
child, created, err := createWindowsACLChildDirectory(parent, "materialized")
|
||||
if err != nil {
|
||||
t.Fatalf("create child relative to the pinned handle: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(child) }()
|
||||
if !created {
|
||||
t.Error("created = false for a directory that did not exist")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(moved, "materialized")); err != nil {
|
||||
t.Errorf("the child did not land in the verified directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "materialized")); err == nil {
|
||||
t.Fatal("ESCAPED: the child was created through the junction, outside the approved tree")
|
||||
}
|
||||
}
|
||||
|
||||
// Materialization runs on trees that may already be half-built, so creating an
|
||||
// existing directory has to be a no-op rather than a failure. created must still
|
||||
// report the truth, because rollback deletes only what this call made.
|
||||
func TestCreatingAnExistingChildOpensItInstead(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "already"), 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
handle, created, err := createWindowsACLChildDirectory(parent, "already")
|
||||
if err != nil {
|
||||
t.Fatalf("open existing child: %v", err)
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
if created {
|
||||
t.Error("created = true for a directory that already existed; rollback would delete a user's data")
|
||||
}
|
||||
}
|
||||
|
||||
// The rollback counterpart. os.RemoveAll on a pathname whose ancestor has since
|
||||
// become a junction recurses outside the workspace and deletes unrelated trees,
|
||||
// which is the second P1. Resolving relative to the parent handle cannot.
|
||||
func TestDeleteFollowsTheHandleNotThePath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "approved")
|
||||
elsewhere := filepath.Join(root, "elsewhere")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
// A bystander under the decoy: if the delete ever resolves by pathname it is
|
||||
// reachable, and its survival is what proves the delete did not.
|
||||
if err := os.Mkdir(filepath.Join(elsewhere, "victim"), 0o700); err != nil {
|
||||
t.Fatalf("seed victim: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(approved, "victim"), 0o700); err != nil {
|
||||
t.Fatalf("seed target: %v", err)
|
||||
}
|
||||
|
||||
parent, err := openWindowsACLDirectoryNoFollow(approved)
|
||||
if err != nil {
|
||||
t.Fatalf("open approved directory: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
moved := filepath.Join(root, "approved-moved")
|
||||
if err := os.Rename(approved, moved); err != nil {
|
||||
t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err)
|
||||
}
|
||||
makeJunction(t, approved, elsewhere)
|
||||
|
||||
if err := deleteWindowsACLChildDirectory(parent, "victim"); err != nil {
|
||||
t.Fatalf("delete relative to the pinned handle: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "victim")); err != nil {
|
||||
t.Fatal("DESTRUCTIVE: rollback followed the junction and deleted a directory outside the approved tree")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(moved, "victim")); err == nil {
|
||||
t.Error("the directory inside the approved tree was not removed")
|
||||
}
|
||||
}
|
||||
|
||||
// A delete that cannot happen must SAY so.
|
||||
//
|
||||
// This is the gap that let a silent bug ship in the first version of this file.
|
||||
// It deleted with FILE_DELETE_ON_CLOSE, which defers the removal to cleanup,
|
||||
// where a non-empty directory makes it fail with nowhere to report it: the open
|
||||
// returned success, the close returned success, and the directory was still
|
||||
// there. The only test covering deletion used an EMPTY directory, so it passed
|
||||
// throughout. Rollback built on that would have reported success while leaving
|
||||
// materialized state on disk, which is worse than the os.RemoveAll it replaced,
|
||||
// because os.RemoveAll actually removed it.
|
||||
func TestDeletingANonEmptyDirectoryIsReported(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
populated := filepath.Join(root, "populated")
|
||||
if err := os.Mkdir(populated, 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(populated, "occupant"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("seed occupant: %v", err)
|
||||
}
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
err = deleteWindowsACLChildDirectory(parent, "populated")
|
||||
if _, statErr := os.Stat(populated); statErr != nil {
|
||||
t.Fatalf("the directory was removed with its contents, which this delete must never do: %v", statErr)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("reported success while leaving the directory in place")
|
||||
}
|
||||
}
|
||||
|
||||
// The directory form must refuse a file rather than delete it, and the file form
|
||||
// must handle the one materialized target that is a file.
|
||||
func TestDeleteDistinguishesFilesFromDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "config"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
if err := deleteWindowsACLChildDirectory(parent, "config"); err == nil {
|
||||
t.Error("the directory delete accepted a file")
|
||||
}
|
||||
if err := deleteWindowsACLChildFile(parent, "config"); err != nil {
|
||||
t.Fatalf("deleteWindowsACLChildFile: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "config")); err == nil {
|
||||
t.Error("the file survived its delete")
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback runs on failure paths where the object may never have been created,
|
||||
// so a missing child is success rather than an error to report.
|
||||
func TestDeletingAMissingChildIsNotAnError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
if err := deleteWindowsACLChildDirectory(parent, "never-existed"); err != nil {
|
||||
t.Fatalf("deleting a missing child reported an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The anchor open is the one pathname resolution in the walk, so it has to
|
||||
// refuse a junction itself rather than leaving it to a later check.
|
||||
func TestOpeningAJunctionAnchorIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
real := filepath.Join(root, "real")
|
||||
if err := os.Mkdir(real, 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
link := filepath.Join(root, "link")
|
||||
makeJunction(t, link, real)
|
||||
|
||||
handle, err := openWindowsACLDirectoryNoFollow(link)
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatal("opened a junction as the materialization anchor; every create beneath it would land outside the approved tree")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// GetFinalPathNameByHandle flags. x/sys/windows does not export these.
|
||||
const (
|
||||
windowsFileNameNormalized uint32 = 0x0
|
||||
windowsVolumeNameDOS uint32 = 0x0
|
||||
)
|
||||
|
||||
// verifyWindowsACLTargetNotRedirected fails when an opened handle resolved
|
||||
// somewhere other than the requested path, which means a component along the way
|
||||
// is a reparse point.
|
||||
//
|
||||
// openWindowsACLTarget's FILE_FLAG_OPEN_REPARSE_POINT check covers the FINAL
|
||||
// component only; CreateFile still resolves ANCESTORS. A user who controls the
|
||||
// workspace can turn an ancestor — .git, say — into a junction before elevated
|
||||
// setup runs, and setup would then apply its DACL change to an object outside
|
||||
// the approved tree while the final-component check still passed. Junctions need
|
||||
// no privilege to create, unlike symlinks, so this is reachable by exactly the
|
||||
// unprivileged user the sandbox exists to contain.
|
||||
//
|
||||
// GetFinalPathNameByHandle answers where the handle actually landed, covering
|
||||
// every component in one call rather than walking the path and re-checking each
|
||||
// component (which would also race between the checks).
|
||||
//
|
||||
// The comparison basis must NEVER follow a reparse point, and getting that wrong
|
||||
// is what made this guard mostly ornamental.
|
||||
//
|
||||
// It used to compare against canonicalSandboxWorkspaceRoot(path), which runs
|
||||
// filepath.EvalSymlinks — the same resolution the kernel just performed. So for a
|
||||
// directory SYMLINK the two sides agreed precisely BECAUSE the redirect happened:
|
||||
// GetFinalPathNameByHandle followed the link to produce one side, EvalSymlinks
|
||||
// followed the same link to produce the other, and elevated setup went on to
|
||||
// rewrite the DACL of an object outside the workspace.
|
||||
//
|
||||
// Junctions were rejected, but by accident rather than by design. Go reports a
|
||||
// junction as ModeIrregular rather than ModeSymlink, so EvalSymlinks refuses it,
|
||||
// canonicalSandboxWorkspaceRoot falls back to the lexical path, and the mismatch
|
||||
// surfaces. That is a property of the standard library's mode bits, not of this
|
||||
// check: a Go release that resolved mount points would silently disarm the one
|
||||
// case this was known to catch.
|
||||
//
|
||||
// So the expected side is now normalized WITHOUT resolving anything. Casing and
|
||||
// 8.3 short names still have to compare equal, since a legitimate target can be
|
||||
// spelled either way and still be the same object — EqualFold covers the first
|
||||
// and GetLongPathName the second, and neither follows a link.
|
||||
//
|
||||
// This does mean a target deliberately spelled through a symlink is now refused.
|
||||
// That is the intended direction: the whole question here is whether the object
|
||||
// the handle landed on is the object that was named, and a caller that wants a
|
||||
// link's target should name the target.
|
||||
func verifyWindowsACLTargetNotRedirected(handle windows.Handle, path string) error {
|
||||
buffer := make([]uint16, windows.MAX_LONG_PATH)
|
||||
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), windowsFileNameNormalized|windowsVolumeNameDOS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve windows ACL target %s: %w", path, err)
|
||||
}
|
||||
if int(n) < len(buffer) {
|
||||
buffer = buffer[:n]
|
||||
}
|
||||
actual := trimWindowsExtendedPathPrefix(windows.UTF16ToString(buffer))
|
||||
expected := trimWindowsExtendedPathPrefix(windowsACLComparablePath(path))
|
||||
if !strings.EqualFold(filepath.Clean(actual), filepath.Clean(expected)) {
|
||||
return fmt.Errorf("refusing to apply ACL to %s: it resolves to %s, so a parent directory is a reparse point (possible path swap during elevated setup)", path, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// windowsACLComparablePath normalizes a path for comparison against where a
|
||||
// handle actually landed, WITHOUT resolving any reparse point.
|
||||
//
|
||||
// The two things that must still compare equal are casing and 8.3 short names,
|
||||
// because either can spell the same object. GetLongPathName expands the short
|
||||
// form by reading directory entries; it does not follow a symlink or a junction
|
||||
// to its target, which is the entire reason it is used here instead of
|
||||
// EvalSymlinks. Casing is left to the caller's EqualFold.
|
||||
//
|
||||
// Every failure degrades to the lexically cleaned path rather than to a resolved
|
||||
// one. A path this cannot expand is compared as written, which can only produce
|
||||
// a spurious MISMATCH — a refusal — and never a spurious match. That is the
|
||||
// direction a redirect check has to fail in.
|
||||
func windowsACLComparablePath(path string) string {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(path))
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return cleaned
|
||||
}
|
||||
if absolute, err := filepath.Abs(cleaned); err == nil {
|
||||
cleaned = absolute
|
||||
}
|
||||
wide, err := windows.UTF16PtrFromString(cleaned)
|
||||
if err != nil {
|
||||
return cleaned
|
||||
}
|
||||
buffer := make([]uint16, windows.MAX_LONG_PATH)
|
||||
n, err := windows.GetLongPathName(wide, &buffer[0], uint32(len(buffer)))
|
||||
if err != nil || n == 0 || int(n) >= len(buffer) {
|
||||
return cleaned
|
||||
}
|
||||
return filepath.Clean(windows.UTF16ToString(buffer[:n]))
|
||||
}
|
||||
|
||||
// trimWindowsExtendedPathPrefix strips the \?\ form GetFinalPathNameByHandle
|
||||
// returns so it can be compared with an ordinary path.
|
||||
func trimWindowsExtendedPathPrefix(path string) string {
|
||||
// Built from filepath.Separator rather than written as literals so the
|
||||
// backslashes cannot be miscounted by whatever writes this file.
|
||||
sep := string(filepath.Separator)
|
||||
devicePrefix := sep + sep + "?" + sep
|
||||
uncPrefix := devicePrefix + "UNC" + sep
|
||||
if strings.HasPrefix(path, uncPrefix) {
|
||||
return sep + sep + strings.TrimPrefix(path, uncPrefix)
|
||||
}
|
||||
return strings.TrimPrefix(path, devicePrefix)
|
||||
}
|
||||
|
||||
// verifyWindowsACLPathComponentNotRedirected opens one path component no-follow
|
||||
// and refuses it if it is a reparse point or resolves anywhere other than its own
|
||||
// pathname. Because GetFinalPathNameByHandle answers for the WHOLE resolved path,
|
||||
// verifying a single existing component also clears every ancestor above it.
|
||||
//
|
||||
// A missing component surfaces as os.ErrNotExist so the caller can walk further
|
||||
// up. Only FILE_READ_ATTRIBUTES is requested: this inspects, it never writes, and
|
||||
// asking for more would fail on ancestors the setup process has no rights on.
|
||||
func verifyWindowsACLPathComponentNotRedirected(path string) error {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode windows ACL path component %s: %w", path, err)
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
windows.FILE_READ_ATTRIBUTES,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
// syscall.Errno.Is maps ERROR_FILE_NOT_FOUND/ERROR_PATH_NOT_FOUND to
|
||||
// os.ErrNotExist, so the caller's errors.Is check keeps working.
|
||||
return fmt.Errorf("open windows ACL path component %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("inspect windows ACL path component %s: %w", path, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("refusing to materialize under reparse-point path component %s: possible path swap during elevated setup", path)
|
||||
}
|
||||
return verifyWindowsACLTargetNotRedirected(handle, path)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// An unprivileged user who controls the workspace can turn an ancestor of a
|
||||
// configured ACL target into a junction before elevated setup runs. CreateFile
|
||||
// resolves ancestors even with FILE_FLAG_OPEN_REPARSE_POINT, so the
|
||||
// final-component check passes and setup would rewrite the DACL of an object
|
||||
// outside the approved tree.
|
||||
//
|
||||
// Junctions, unlike symlinks, need no privilege — which is what makes this
|
||||
// reachable by exactly the user the sandbox is containing.
|
||||
func TestOpenWindowsACLTargetRefusesAJunctionAncestor(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
workspace := filepath.Join(base, "workspace")
|
||||
outside := filepath.Join(base, "OUTSIDE")
|
||||
for _, dir := range []string{workspace, outside} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
// The object the attacker wants setup to touch.
|
||||
victim := filepath.Join(outside, "hooks")
|
||||
if err := os.MkdirAll(victim, 0o700); err != nil {
|
||||
t.Fatalf("mkdir victim: %v", err)
|
||||
}
|
||||
|
||||
// .git is the ancestor, and it is a junction to OUTSIDE.
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", gitDir, outside).CombinedOutput(); err != nil {
|
||||
t.Skipf("cannot create a junction here: %v %s", err, out)
|
||||
}
|
||||
|
||||
// This is the path setup would be configured with.
|
||||
target := filepath.Join(gitDir, "hooks")
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("precondition: the junction should make %s reachable: %v", target, err)
|
||||
}
|
||||
|
||||
handle, _, err := openWindowsACLTarget(target)
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatal("opened an ACL target through a junction ancestor; setup would have rewritten a DACL outside the workspace")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse point") {
|
||||
t.Errorf("error = %v, want it to name the reparse point", err)
|
||||
}
|
||||
t.Logf("refused as expected: %v", err)
|
||||
}
|
||||
|
||||
// The guard must not reject ordinary targets, including ones spelled
|
||||
// non-canonically — a differently-cased path is the same object, not a redirect.
|
||||
func TestOpenWindowsACLTargetAcceptsAnOrdinaryTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
nested := filepath.Join(dir, "Nested")
|
||||
if err := os.MkdirAll(nested, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
for _, spelling := range []string{nested, strings.ToLower(nested)} {
|
||||
handle, isDir, err := openWindowsACLTarget(spelling)
|
||||
if err != nil {
|
||||
t.Fatalf("openWindowsACLTarget(%q): %v", spelling, err)
|
||||
}
|
||||
if !isDir {
|
||||
t.Errorf("%q reported as not a directory", spelling)
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE RESTORE MUST LAND ON THE OBJECT THE DACL CAME FROM.
|
||||
//
|
||||
// Creation and the materialization unwind were both bound to handles, and the
|
||||
// restore branch was left re-resolving the target by pathname. Its comment
|
||||
// justified that with a no-follow re-open, which rules out a REPARSE POINT
|
||||
// swapped in since apply and nothing else. The other substitution passes it
|
||||
// untouched: rename the target aside and put an ordinary directory of the same
|
||||
// name in its place. Nothing in that decoy is a link, so the no-follow open
|
||||
// succeeds, the pre-apply DACL is written onto the attacker's object, and the
|
||||
// real one keeps the ACEs from the setup that just aborted.
|
||||
func TestRollbackRefusesToRestoreOntoAReplacedTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
target := filepath.Join(approved, "target")
|
||||
if err := os.MkdirAll(target, 0o700); err != nil {
|
||||
t.Fatalf("seed the approved tree: %v", err)
|
||||
}
|
||||
|
||||
handle, _, err := openWindowsACLTarget(target)
|
||||
if err != nil {
|
||||
t.Fatalf("open the target: %v", err)
|
||||
}
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatalf("read the target DACL: %v", err)
|
||||
}
|
||||
identity, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatalf("read the target identity: %v", err)
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
|
||||
// The swap, exactly as it would happen between apply and rollback. Ordinary
|
||||
// directories throughout: no links, no reparse points.
|
||||
if err := os.Rename(target, target+"-moved"); err != nil {
|
||||
t.Skipf("cannot rename here: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatalf("plant the replacement: %v", err)
|
||||
}
|
||||
|
||||
snapshot := windowsACLSnapshot{Path: target, Descriptor: descriptor, TargetID: identity}
|
||||
err = rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot})
|
||||
if err == nil {
|
||||
t.Fatal("rollback wrote the pre-apply DACL onto a different object wearing the target's name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no longer the object") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And an untouched target still restores, or the guard above would be satisfied
|
||||
// by a rollback that refuses everything.
|
||||
func TestRollbackRestoresAnUnchangedTarget(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "target")
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatalf("seed the target: %v", err)
|
||||
}
|
||||
|
||||
handle, _, err := openWindowsACLTarget(target)
|
||||
if err != nil {
|
||||
t.Fatalf("open the target: %v", err)
|
||||
}
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatalf("read the target DACL: %v", err)
|
||||
}
|
||||
identity, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatalf("read the target identity: %v", err)
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
|
||||
snapshot := windowsACLSnapshot{Path: target, Descriptor: descriptor, TargetID: identity}
|
||||
if err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}); err != nil {
|
||||
t.Fatalf("an unchanged target was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// THE COMMENT AT rollbackWindowsACLSnapshots CLAIMED A TEST THAT DID NOT EXIST.
|
||||
//
|
||||
// It said "TestRollbackUnwindsDescendantsBeforeAncestors pins it" about the
|
||||
// reverse iteration order, and grepping the repo found only that sentence. The
|
||||
// ordering is load-bearing twice over: a materialized directory must be empty
|
||||
// before its own removal is attempted, and SetSecurityInfo propagates
|
||||
// inheritable ACEs downward so the ancestor has to be restored last. Neither
|
||||
// was pinned by anything.
|
||||
func TestRollbackUnwindsDescendantsBeforeAncestors(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
ancestor := filepath.Join(root, "ws")
|
||||
descendant := filepath.Join(ancestor, "child")
|
||||
if err := os.MkdirAll(descendant, 0o700); err != nil {
|
||||
t.Fatalf("seed the tree: %v", err)
|
||||
}
|
||||
|
||||
snapshotFor := func(path string) windowsACLSnapshot {
|
||||
t.Helper()
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("read the DACL for %s: %v", path, err)
|
||||
}
|
||||
identity, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
t.Fatalf("read the identity for %s: %v", path, err)
|
||||
}
|
||||
return windowsACLSnapshot{Path: path, Descriptor: descriptor, TargetID: identity}
|
||||
}
|
||||
|
||||
// Ascending by path key, the order applyWindowsACLPlan produces.
|
||||
snapshots := []windowsACLSnapshot{snapshotFor(ancestor), snapshotFor(descendant)}
|
||||
|
||||
var order []string
|
||||
restore := windowsACLRestoreHook
|
||||
windowsACLRestoreHook = func(path string) { order = append(order, path) }
|
||||
t.Cleanup(func() { windowsACLRestoreHook = restore })
|
||||
|
||||
if err := rollbackWindowsACLSnapshots(snapshots); err != nil {
|
||||
t.Fatalf("rollbackWindowsACLSnapshots: %v", err)
|
||||
}
|
||||
if len(order) != 2 {
|
||||
t.Fatalf("restored %d targets, want 2: %v", len(order), order)
|
||||
}
|
||||
if order[0] != descendant || order[1] != ancestor {
|
||||
t.Errorf("restore order was %v; the descendant must be restored before its ancestor, because SetSecurityInfo propagates inheritable ACEs downward", order)
|
||||
}
|
||||
}
|
||||
|
||||
// The snapshots arrive in APPLY order, not path order. applyWindowsACLPlan's
|
||||
// deferred pass appends a group whose target only appeared during the first
|
||||
// pass, typically an ancestor (.git after .git\config), so the ancestor is LAST
|
||||
// in the list. Rollback must still unwind the descendant first.
|
||||
func TestRollbackUnwindsADeferredAncestorAfterItsDescendants(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
ancestor := filepath.Join(root, ".git")
|
||||
descendant := filepath.Join(ancestor, "config")
|
||||
if err := os.MkdirAll(descendant, 0o700); err != nil {
|
||||
t.Fatalf("seed the tree: %v", err)
|
||||
}
|
||||
|
||||
snapshotFor := func(path string) windowsACLSnapshot {
|
||||
t.Helper()
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("read the DACL for %s: %v", path, err)
|
||||
}
|
||||
identity, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
t.Fatalf("read the identity for %s: %v", path, err)
|
||||
}
|
||||
return windowsACLSnapshot{Path: path, Descriptor: descriptor, TargetID: identity}
|
||||
}
|
||||
|
||||
// First pass applied the descendant; the deferred pass applied the ancestor.
|
||||
snapshots := []windowsACLSnapshot{snapshotFor(descendant), snapshotFor(ancestor)}
|
||||
|
||||
var order []string
|
||||
restore := windowsACLRestoreHook
|
||||
windowsACLRestoreHook = func(path string) { order = append(order, path) }
|
||||
t.Cleanup(func() { windowsACLRestoreHook = restore })
|
||||
|
||||
if err := rollbackWindowsACLSnapshots(snapshots); err != nil {
|
||||
t.Fatalf("rollbackWindowsACLSnapshots: %v", err)
|
||||
}
|
||||
if len(order) != 2 {
|
||||
t.Fatalf("restored %d targets, want 2: %v", len(order), order)
|
||||
}
|
||||
if order[0] != descendant || order[1] != ancestor {
|
||||
t.Errorf("restore order was %v; a deferred ancestor must still be restored after its descendant", order)
|
||||
}
|
||||
if snapshots[0].Path != descendant || snapshots[1].Path != ancestor {
|
||||
t.Errorf("rollback reordered the caller's slice: %v, %v", snapshots[0].Path, snapshots[1].Path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ROLLBACK DELETES THE OBJECT IT CREATED, NOT WHATEVER NOW WEARS ITS NAME.
|
||||
//
|
||||
// The anchor's identity was verified, but the created children were recorded
|
||||
// by name only, so after setup materialized .git/config another process could
|
||||
// move it aside and put an ordinary file at that name beneath the unchanged
|
||||
// anchor, and a later setup failure deleted the replacement. Each created
|
||||
// component now carries the identity read from the handle that created it,
|
||||
// and the delete disposition is set on a handle whose identity was checked.
|
||||
func TestRollbackRefusesAReplacedMaterializedChild(t *testing.T) {
|
||||
for name, asFile := range map[string]bool{"file target": true, "directory target": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
target := filepath.Join(workspace, ".git", "hooks")
|
||||
if asFile {
|
||||
target = filepath.Join(workspace, ".git", "config")
|
||||
}
|
||||
created, err := materializeWindowsACLTarget(target, asFile)
|
||||
if err != nil {
|
||||
t.Fatalf("materialize: %v", err)
|
||||
}
|
||||
if !created.createdAnything() {
|
||||
t.Fatal("SETUP INVALID: nothing was materialized")
|
||||
}
|
||||
|
||||
// Another process moves the created object aside and puts an
|
||||
// ordinary one of the same shape at its name. The anchor above is
|
||||
// untouched, so the anchor identity check cannot see this.
|
||||
aside := target + ".aside"
|
||||
if err := os.Rename(target, aside); err != nil {
|
||||
t.Fatalf("rename aside: %v", err)
|
||||
}
|
||||
if asFile {
|
||||
if err := os.WriteFile(target, []byte("theirs"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "keep"), []byte("theirs"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
removed, err := rollbackWindowsACLMaterialization(created)
|
||||
// The replacement is the whole point: it has to survive, whatever the
|
||||
// rollback reported about itself.
|
||||
if _, statErr := os.Lstat(target); statErr != nil {
|
||||
t.Fatalf("rollback deleted the replacement at %s, which it never created: %v", target, statErr)
|
||||
}
|
||||
if !asFile {
|
||||
if _, statErr := os.Lstat(filepath.Join(target, "keep")); statErr != nil {
|
||||
t.Fatalf("the replacement directory's contents are gone: %v", statErr)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("rollback left a replacement it never created without saying why")
|
||||
}
|
||||
if removed {
|
||||
t.Fatal("rollback reported the target removed after refusing the replacement")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: the unreplaced case still unwinds completely, so the identity check
|
||||
// cannot pass by refusing everything.
|
||||
func TestRollbackRemovesTheChildrenItActuallyCreated(t *testing.T) {
|
||||
for name, asFile := range map[string]bool{"file target": true, "directory target": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
target := filepath.Join(workspace, ".git", "hooks")
|
||||
if asFile {
|
||||
target = filepath.Join(workspace, ".git", "config")
|
||||
}
|
||||
created, err := materializeWindowsACLTarget(target, asFile)
|
||||
if err != nil {
|
||||
t.Fatalf("materialize: %v", err)
|
||||
}
|
||||
removed, err := rollbackWindowsACLMaterialization(created)
|
||||
if err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Fatal("rollback did not report the target removed")
|
||||
}
|
||||
if _, statErr := os.Lstat(filepath.Join(workspace, ".git")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf(".git survived a rollback of what created it: %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE COMPARISON BASIS MUST NOT FOLLOW WHAT IT IS CHECKING FOR.
|
||||
//
|
||||
// verifyWindowsACLTargetNotRedirected asks GetFinalPathNameByHandle where the
|
||||
// handle landed and compares that against the requested path. It used to build
|
||||
// the second side with filepath.EvalSymlinks, which is the same resolution the
|
||||
// kernel had just performed, so for a directory symlink the two sides agreed
|
||||
// BECAUSE the redirect happened, and elevated setup rewrote the DACL of an
|
||||
// object outside the workspace.
|
||||
//
|
||||
// Junctions were caught, but only because Go reports one as ModeIrregular and
|
||||
// EvalSymlinks declines it, leaving the lexical path to disagree. That is a
|
||||
// standard-library detail, not a property of the guard.
|
||||
|
||||
// The property, testable with no privilege at all: the normalizer must not
|
||||
// resolve a reparse point.
|
||||
//
|
||||
// HONEST LIMIT, because it would be easy to read more into this than it proves.
|
||||
// A junction is used because creating one needs no privilege, and EvalSymlinks
|
||||
// does not resolve a junction either — so this test passes against BOTH the old
|
||||
// basis and the new one, and reverting the fix does not fail it. It is an
|
||||
// invariant test, not a regression for this change: it pins that normalization
|
||||
// leaves a reparse point alone, which is what would break first if a future Go
|
||||
// started resolving mount points and silently disarmed the junction case.
|
||||
//
|
||||
// The case that actually separates the two bases is the directory symlink below,
|
||||
// and that one skips without privilege. On this machine it skipped.
|
||||
func TestACLComparablePathDoesNotResolveAReparsePoint(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := filepath.Join(base, "outside")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "hooks"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir outside: %v", err)
|
||||
}
|
||||
link := filepath.Join(base, "link")
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, outside).CombinedOutput(); err != nil {
|
||||
t.Skipf("cannot create a junction here: %v (%s)", err, out)
|
||||
}
|
||||
|
||||
through := filepath.Join(link, "hooks")
|
||||
comparable := windowsACLComparablePath(through)
|
||||
|
||||
// Compared against the normalizer's OWN answer for each path, never against
|
||||
// the raw input. GetLongPathName legitimately rewrites the string: on a CI
|
||||
// runner the temp directory is an 8.3 short name, so expanding RUNNER~1 to
|
||||
// runneradmin makes the result differ from what was passed in while naming
|
||||
// exactly the same object. An earlier version of this test asserted equality
|
||||
// with the input and failed on Windows CI for that reason, which was the test
|
||||
// being wrong rather than the code.
|
||||
target := windowsACLComparablePath(filepath.Join(outside, "hooks"))
|
||||
|
||||
// The whole property: normalizing a path THROUGH the reparse point must not
|
||||
// produce the target's normalization. If it did, the guard would be comparing
|
||||
// a redirected handle against a redirected expectation and matching itself.
|
||||
if strings.EqualFold(filepath.Clean(comparable), filepath.Clean(target)) {
|
||||
t.Errorf("windowsACLComparablePath resolved through the reparse point:\n through %s\n target %s", comparable, target)
|
||||
}
|
||||
// And it still has to name the link component, or it resolved to something
|
||||
// else entirely rather than leaving the path alone.
|
||||
if !strings.Contains(strings.ToLower(comparable), "link") {
|
||||
t.Errorf("windowsACLComparablePath lost the link component: %s", comparable)
|
||||
}
|
||||
|
||||
// The old basis is checked alongside for contrast: where it resolves, it
|
||||
// could not have been the comparison basis.
|
||||
if resolved := canonicalSandboxWorkspaceRoot(through); strings.EqualFold(filepath.Clean(resolved), filepath.Clean(filepath.Join(outside, "hooks"))) {
|
||||
t.Logf("confirmed: canonicalSandboxWorkspaceRoot resolves %s to %s, which is why it cannot be the basis", through, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
// The end-to-end symlink case the finding names. Directory symlinks need either
|
||||
// Developer Mode or SeCreateSymbolicLinkPrivilege, so this skips where it cannot
|
||||
// build the fixture rather than passing vacuously.
|
||||
func TestOpenWindowsACLTargetRefusesASymlinkAncestor(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := filepath.Join(base, "outside")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "hooks"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir outside: %v", err)
|
||||
}
|
||||
workspace := filepath.Join(base, "workspace")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatalf("mkdir workspace: %v", err)
|
||||
}
|
||||
|
||||
// A DIRECTORY symlink, not a junction: os.Symlink maps to CreateSymbolicLinkW
|
||||
// and the directory flag is inferred from the existing target.
|
||||
link := filepath.Join(workspace, ".git")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("cannot create a directory symlink here (needs Developer Mode or SeCreateSymbolicLinkPrivilege): %v", err)
|
||||
}
|
||||
|
||||
target := filepath.Join(link, "hooks")
|
||||
handle, _, err := openWindowsACLTarget(target)
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatal("elevated setup opened a target through a symlinked ancestor; its DACL change would land outside the workspace")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse point") {
|
||||
t.Errorf("refusal does not name the cause: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,14 @@ func TestBuildWindowsACLPlanForWorkspaceWriteProfile(t *testing.T) {
|
||||
|
||||
assertWindowsACLEntry(t, plan, WindowsACLAllowWrite, `C:\workspace`, workspaceSID, false)
|
||||
assertWindowsACLEntry(t, plan, WindowsACLAllowWrite, `D:\cache`, cacheSID, false)
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\vendor`, workspaceSID, false)
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\.git`, workspaceSID, false)
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\.zero`, workspaceSID, false)
|
||||
// Materialized, matching the principal plan. A guard attached to an object
|
||||
// that does not exist yet is never applied: on a workspace with no .git at
|
||||
// setup time both passes skipped these while setup still recorded success.
|
||||
// Creating them is also what lets the deferred pass reach the deny-delete on
|
||||
// .git, since config and hooks create .git as their parent.
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\vendor`, workspaceSID, true)
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\.git`, workspaceSID, true)
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\.zero`, workspaceSID, true)
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\secret-write`, workspaceSID, false)
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\secret-write`, cacheSID, false)
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, workspaceSID, true)
|
||||
@@ -354,3 +359,37 @@ func TestBuildWindowsACLPlanLeavesAnOutOfRootSubpathUnanchored(t *testing.T) {
|
||||
t.Errorf("out-of-root subpath carries anchor %q, want none: the apply would refuse a config that works today", outsideAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
caps, err := LoadOrCreateWindowsCapabilitySIDs(home)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err)
|
||||
}
|
||||
plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
DenyRead: []string{`C:\workspace\secret-read`},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
// Two deny entries, one per SID the token can carry here. ReadOnly is the
|
||||
// only capability a profile with no write roots gets, and ReadAllow is the
|
||||
// read grant a DenyRead profile's strict token carries: denying just one of
|
||||
// them leaves the carveout readable through the other.
|
||||
if len(plan.Entries) != 2 {
|
||||
t.Fatalf("ACL entries = %#v, want a deny-read entry per capability SID", plan.Entries)
|
||||
}
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, caps.ReadOnly, true)
|
||||
readSID, err := WindowsReadAllowSID(home)
|
||||
if err != nil {
|
||||
t.Fatalf("WindowsReadAllowSID: %v", err)
|
||||
}
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, readSID, true)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ELEVATED SETUP MUST NOT CREATE THROUGH A JUNCTION THE CALLER PLANTED.
|
||||
//
|
||||
// ensureWindowsSandboxRuntimeCandidates runs as Administrator, and os.MkdirAll
|
||||
// follows links. Both candidate ancestries are prepared by the invoking user, so
|
||||
// a junction at a not-yet-created component below the cache directory — or at the
|
||||
// fallback anchor — made elevated setup build the runtime tree beneath a
|
||||
// redirected, Administrator-writable target. The ACL applier's no-follow check
|
||||
// runs later and spots the redirection, but the privileged create has already
|
||||
// happened by then, outside its rollback boundary.
|
||||
//
|
||||
// A junction needs no privilege on Windows, so this reproduces the caller's half
|
||||
// of the race on an ordinary unelevated box.
|
||||
func TestRuntimeCandidateCreationRefusesAJunctionedComponent(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
target := t.TempDir()
|
||||
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
|
||||
workspace := t.TempDir()
|
||||
candidate, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: no deterministic candidate for %s under %s", workspace, cacheRoot)
|
||||
}
|
||||
// The component the caller controls: the first one Zero owns, below the
|
||||
// operator's cache directory.
|
||||
owned := filepath.Join(canonicalSandboxWorkspaceRoot(cacheRoot), "zero")
|
||||
if !strings.HasPrefix(strings.ToLower(candidate), strings.ToLower(owned)) {
|
||||
t.Fatalf("SETUP INVALID: candidate %s does not sit under the owned component %s", candidate, owned)
|
||||
}
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", owned, target).CombinedOutput(); err != nil {
|
||||
t.Fatalf("SETUP INVALID: mklink /J: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
err := ensureRuntimeCandidateDir(candidate)
|
||||
if err == nil {
|
||||
t.Fatal("elevated setup created the runtime tree through a junction the caller planted")
|
||||
}
|
||||
|
||||
// The whole point: nothing may appear beneath the redirected target.
|
||||
entries, readErr := os.ReadDir(target)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("setup created %v beneath the redirected target", names)
|
||||
}
|
||||
}
|
||||
|
||||
// And an ordinary cache directory still gets its candidate, or the refusal above
|
||||
// would be satisfied by a creator that refuses everything.
|
||||
func TestRuntimeCandidateCreationStillCreatesAnOrdinaryTree(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
|
||||
workspace := t.TempDir()
|
||||
candidate, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: no deterministic candidate for %s", workspace)
|
||||
}
|
||||
if err := ensureRuntimeCandidateDir(candidate); err != nil {
|
||||
t.Fatalf("an ordinary cache tree was refused: %v", err)
|
||||
}
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("the candidate was not created: err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
|
||||
@@ -23,6 +26,15 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
// The marker attests to a PLAN. This attests to the OBJECT that plan was
|
||||
// applied to, which cleanup can reclaim and an ordinary run then recreates
|
||||
// without the capability ACE. Checked before the token is minted, so the
|
||||
// operator is told to rerun setup instead of watching every sandboxed write
|
||||
// fail with a bare access-denied.
|
||||
if err := verifyWindowsRuntimeRootCapability(config); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
case WindowsSandboxLevelUnelevated:
|
||||
if err := ensureWindowsUnelevatedSetup(config); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
@@ -84,6 +96,115 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
|
||||
// DenyRead is rejected above for both runner levels rather than launching a
|
||||
// fully restricted narrow-SID token that cannot execute normal tools.
|
||||
writeRestricted := len(config.PermissionProfile.FileSystem.DenyRead) == 0
|
||||
|
||||
// A provisioned sandbox principal replaces the restricted token entirely: it
|
||||
// is a separate account, so reads outside its granted roots are denied by the
|
||||
// filesystem rather than left open the way a same-user restricted token has
|
||||
// to leave them (#662). Absent, unprovisioned or opted-out, ok is false and
|
||||
// the restricted-token backend below runs exactly as before.
|
||||
principalToken, ok, err := windowsSandboxPrincipalToken(config)
|
||||
if err != nil {
|
||||
// The one path here that does not fall back, because a provisioned but
|
||||
// unusable principal means the sandbox is broken rather than absent. Say
|
||||
// how to get out of it, since the whole backend is opt-in.
|
||||
fmt.Fprintf(stderr, "%s: sandbox principal is provisioned but unusable: %v. Re-run `zero sandbox setup` from an elevated terminal, or unset %s to fall back to the restricted-token sandbox.\n",
|
||||
WindowsSandboxCommandRunnerName, err, windowsSandboxIdentityEnv)
|
||||
return 1
|
||||
}
|
||||
if ok {
|
||||
defer principalToken.Close()
|
||||
// The principal gets its own identity AND the write jail, not one or the
|
||||
// other. Its ACEs confine reads; without the restricted token it would
|
||||
// still hold every write its ambient memberships grant, so a profile
|
||||
// permitting writes only to the workspace could still write anywhere
|
||||
// BATCH or BUILTIN\Users may — C:\Users\Public\Documents, for one.
|
||||
//
|
||||
// The principal's own SID is deliberately NOT a restricting SID, and that
|
||||
// is the whole of the write jail on this path.
|
||||
//
|
||||
// WRITE_RESTRICTED allows a write only when BOTH the normal token and the
|
||||
// restricting-SID list allow it. The account SID is already enabled in the
|
||||
// normal token, so listing it as a restricting SID makes the second check
|
||||
// a formality for anything granted to that account: every path carrying a
|
||||
// direct principal ACE passes both halves and is writable wherever it
|
||||
// sits. The principal's own profile directory, which Windows creates on
|
||||
// first logon with exactly such an ACE, is outside every configured write
|
||||
// root and was writable for that reason.
|
||||
//
|
||||
// Same defect as the World SID in the restricted list (#865), with the
|
||||
// account SID in place of Everyone: a SID already carried by the normal
|
||||
// token cannot also serve as the restriction on it.
|
||||
//
|
||||
// Confining to the capability SIDs alone restores the intersection the
|
||||
// jail is supposed to be. A configured write root carries BOTH a principal
|
||||
// ACE, from the principal plan, satisfying the normal token, AND a
|
||||
// capability ACE, from BuildWindowsACLPlan which setup applies on every
|
||||
// path, satisfying the restriction. So the principal keeps the tree it
|
||||
// owns, while anything holding only a principal ACE now fails the
|
||||
// restricted check.
|
||||
principalUser, err := principalToken.GetTokenUser()
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": read sandbox principal SID: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
jailSIDs := windowsPrincipalJailSIDs(tokenSIDs, principalUser.User.Sid.String())
|
||||
// A strict token restricts READS too, and the account SID that carries the
|
||||
// read roots was just removed above. Without the read capability the jail
|
||||
// stops being a write jail and becomes a total one: no read root, no
|
||||
// executable, nothing. BuildWindowsACLPlan grants this same SID on every
|
||||
// read root and denies it on every DenyRead path, so the allow-list and the
|
||||
// restriction stay one decision.
|
||||
if !writeRestricted {
|
||||
readSID, err := WindowsReadAllowSID(config.SandboxHome)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": resolve sandbox read capability: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
jailSIDs = append(jailSIDs, readSID)
|
||||
}
|
||||
jailedToken, err := restrictWindowsTokenForCapabilitySIDs(principalToken, jailSIDs, writeRestricted)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
defer jailedToken.Close()
|
||||
// CreateProcessAsUser below is about to be handed a token for a DIFFERENT
|
||||
// account, which forfeits the own-token exemption the ordinary restricted
|
||||
// path relies on. Enable what that needs, and refuse with a specific reason
|
||||
// when this process cannot, rather than letting an unelevated run surface a
|
||||
// bare "Access is denied" that reads as the command being rejected.
|
||||
//
|
||||
// Refusing rather than falling back: an operator who set the opt-in to
|
||||
// confine reads must not be handed the same-user restricted token while
|
||||
// believing otherwise.
|
||||
if err := enableWindowsPrincipalLaunchPrivileges(); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
// Stop the child describing the CALLER. Everything identifying the account
|
||||
// survived from the invoking user's environment, so a principal command
|
||||
// resolved its per-user state through paths inside a profile it cannot
|
||||
// open. Applied here, on the principal path only, because this is the first
|
||||
// point that knows which account the command is about to run as.
|
||||
// The role has to be the one the token above was minted for, or the child
|
||||
// is told it is the other account and resolves its per-user state under a
|
||||
// profile it cannot open.
|
||||
config.Env = windowsPrincipalIdentityEnvironment(
|
||||
config.Env,
|
||||
windowsSandboxUserName(
|
||||
windowsSandboxPrincipalKey(config),
|
||||
windowsSandboxRoleForNetwork(config.PermissionProfile.Network.Mode),
|
||||
),
|
||||
config.PermissionProfile.Runtime,
|
||||
)
|
||||
exitCode, err := runWindowsCommandAsUser(jailedToken, config)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
@@ -117,12 +238,127 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if marker.contains(applied) {
|
||||
// THE MARKER ATTESTS TO A PLAN. THE COMMAND CONSUMES AN OBJECT.
|
||||
//
|
||||
// The marker fingerprints pathnames and actions, and cleanup is allowed to
|
||||
// reclaim the deterministic runtime directory that plan was realized on. An
|
||||
// ordinary later run recreates the same pathname with the caller-private DACL
|
||||
// and no capability ACE, and the serialized plan is unchanged, so this fast
|
||||
// path returned before anything looked at the directory. The restricted child
|
||||
// still carried the capability SID; the new object simply did not grant it, and
|
||||
// every temp, package-cache and build-cache write failed after launch with a
|
||||
// bare ACCESS_DENIED and nothing pointing at setup.
|
||||
//
|
||||
// This tier owns its plan and needs no elevation, so the answer here is to
|
||||
// reapply rather than to refuse. Refusing would print advice to run elevated
|
||||
// setup, which is unnecessary on this tier and unfollowable for a user with no
|
||||
// Administrator account. The restricted-token tier keeps its refusal, because
|
||||
// an ordinary user cannot restore principal provisioning.
|
||||
if marker.contains(applied) && verifyWindowsRuntimeRootCapability(config) == nil {
|
||||
return nil
|
||||
}
|
||||
// THE TIER BOUNDARY, STATED BEFORE ANY MUTATION IS ATTEMPTED.
|
||||
//
|
||||
// One plan is consumed by two tiers with different authority. A profile that
|
||||
// carries DenyRead runs on a strict token, and the strict token applies the
|
||||
// restricted-SID check to READS, so the read capability has to be granted
|
||||
// wherever the command reads from -- including the volume root that
|
||||
// permissionProfileReadRoots seeds. Elevated setup can write that DACL. An
|
||||
// ordinary user cannot, and the common opener asks for WRITE_DAC on every
|
||||
// entry, so this tier fails on that one root every time.
|
||||
//
|
||||
// Dropping the root ACE instead is not an option: the strict token would then
|
||||
// fail its own read check for the executable and every ambient read. So the
|
||||
// tier is refused explicitly, naming the root and the reason, rather than
|
||||
// discovered as an ACCESS_DENIED after the fact. The real smoke test misses
|
||||
// this because it substitutes a user-owned temporary directory for the
|
||||
// production read root.
|
||||
if refusal := WindowsACLPlanReadGrantRefusal(plan); refusal != "" {
|
||||
return errors.New("unelevated sandbox setup cannot apply this plan: " + refusal)
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
// Refusing to run is right: without these ACEs the write jail does not
|
||||
// exist, so continuing would run the command believing it is sandboxed
|
||||
// when it is not. What was wrong was the diagnosis. Every failure got the
|
||||
// same "the workspace may be on a filesystem you do not own" guess, and
|
||||
// the suggested remedy was elevated setup, which does not help at all
|
||||
// when the real problem is one root in the plan that nobody can ACL.
|
||||
//
|
||||
// Being precise matters because this failure repeats: the success marker
|
||||
// is only recorded on success, so the same plan fails identically on
|
||||
// every later command until the offending root leaves it. A reader who
|
||||
// cannot tell which root is at fault has no way out of that.
|
||||
// Every remedy named below is one the reader can actually carry out.
|
||||
// Both messages used to offer `--sandbox forbid`, which is not an option
|
||||
// at all: SandboxPreferenceForbid is an internal engine state with no flag
|
||||
// behind it, so acting on it produced an unknown option and left the reader
|
||||
// stuck on the failure they had just been told how to clear. Advice that
|
||||
// does not work costs more than none, since finding that out takes time.
|
||||
if denied := windowsACLPlanDeniedPath(err); denied != "" {
|
||||
return fmt.Errorf("apply unelevated workspace ACLs: %w; %s cannot have its permissions changed by this user, "+
|
||||
"so the sandbox cannot enforce a write boundary there and will not run the command. "+
|
||||
"That path is one of this workspace's sandbox roots, usually a system directory that arrived via TEMP or TMP. "+
|
||||
"Check those, or turn the sandbox off in your user config with "+
|
||||
`"sandbox": {"enabled": false}. `+
|
||||
"Running `zero sandbox setup` elevated will NOT fix this", err, denied)
|
||||
}
|
||||
return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+
|
||||
"run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err)
|
||||
"run `zero sandbox setup` from an elevated (Administrator) terminal, "+
|
||||
`or turn the sandbox off in your user config with "sandbox": {"enabled": false}`, err)
|
||||
}
|
||||
return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied)
|
||||
}
|
||||
|
||||
// windowsACLPlanDeniedPath pulls the target path out of an apply failure that
|
||||
// was an access denial, and returns "" for anything else.
|
||||
//
|
||||
// applyWindowsACLPathGroup already wraps the path into its error, so this reads
|
||||
// the message rather than threading a typed error through four layers for one
|
||||
// diagnostic. The string it matches is produced in the same package by
|
||||
// openWindowsACLTarget, and a test pins the pairing so the two cannot drift
|
||||
// apart silently.
|
||||
func windowsACLPlanDeniedPath(err error) string {
|
||||
if err == nil || !errors.Is(err, os.ErrPermission) {
|
||||
return ""
|
||||
}
|
||||
const marker = "open windows ACL target "
|
||||
message := err.Error()
|
||||
start := strings.Index(message, marker)
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
// Colon-SPACE, not colon. The wrapper is "...target %s: %w", and on Windows
|
||||
// the path itself starts with a drive colon, so splitting on the first colon
|
||||
// returns "C". A drive colon is always followed by a separator, never a
|
||||
// space, which makes ": " the only unambiguous boundary here.
|
||||
rest := message[start+len(marker):]
|
||||
end := strings.Index(rest, ": ")
|
||||
if end <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
|
||||
// windowsPrincipalJailSIDs returns the restricting SIDs for a principal command.
|
||||
//
|
||||
// A named function with its own test rather than an inline slice literal,
|
||||
// because the defect it encodes was invisible: the production composition added
|
||||
// the account's own SID while the only jail test granted a GROUP, so the
|
||||
// configuration that shipped was never the configuration under test.
|
||||
//
|
||||
// accountSID is taken and DELIBERATELY EXCLUDED rather than simply not passed.
|
||||
// Naming it here makes the exclusion the function's stated contract instead of an
|
||||
// omission a later edit could undo without noticing, and it also removes the SID
|
||||
// should it ever arrive through capabilitySIDs, which is the same route the World
|
||||
// SID took in #865.
|
||||
func windowsPrincipalJailSIDs(capabilitySIDs []string, accountSID string) []string {
|
||||
account := strings.TrimSpace(accountSID)
|
||||
jail := make([]string, 0, len(capabilitySIDs))
|
||||
for _, sid := range capabilitySIDs {
|
||||
if account != "" && strings.EqualFold(strings.TrimSpace(sid), account) {
|
||||
continue
|
||||
}
|
||||
jail = append(jail, sid)
|
||||
}
|
||||
return jail
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The outer rollback must only delete principals this run created.
|
||||
//
|
||||
// Dual-role setup provisions offline then online. If the offline role adopts an
|
||||
// account that already existed and anything afterwards fails, the outer rollback
|
||||
// used to delete it anyway, turning a partial re-setup failure into the loss of
|
||||
// a principal that was working before the run started. Two roles make that the
|
||||
// common case rather than an unlucky one: the first role usually succeeds, so
|
||||
// there is nearly always something for a later failure to destroy.
|
||||
func TestDualRoleSetupRollbackSparesAdoptedPrincipals(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
createdByRole map[windowsSandboxRole]bool
|
||||
wantRemoved []windowsSandboxRole
|
||||
}{
|
||||
"offline adopted, online created": {
|
||||
createdByRole: map[windowsSandboxRole]bool{
|
||||
windowsSandboxRoleOffline: false,
|
||||
windowsSandboxRoleOnline: true,
|
||||
},
|
||||
// Only the one this run made. Deleting the adopted offline principal
|
||||
// is the data loss this guards against.
|
||||
wantRemoved: []windowsSandboxRole{windowsSandboxRoleOnline},
|
||||
},
|
||||
"both adopted": {
|
||||
createdByRole: map[windowsSandboxRole]bool{
|
||||
windowsSandboxRoleOffline: false,
|
||||
windowsSandboxRoleOnline: false,
|
||||
},
|
||||
wantRemoved: nil,
|
||||
},
|
||||
"both created": {
|
||||
createdByRole: map[windowsSandboxRole]bool{
|
||||
windowsSandboxRoleOffline: true,
|
||||
windowsSandboxRoleOnline: true,
|
||||
},
|
||||
wantRemoved: []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var removed []windowsSandboxRole
|
||||
restoreDualRoleSeams(t, testCase.createdByRole, &removed)
|
||||
|
||||
// Fail on the SECOND role, not the first. ACL application happens per
|
||||
// role inside the loop, so failing the first aborts before the second
|
||||
// is provisioned at all and the rollback never has more than one
|
||||
// principal to consider. The case worth testing is the one review
|
||||
// described: the first role succeeds, the second fails, and the
|
||||
// question is whether the first gets destroyed on the way out.
|
||||
// Count GRANT plans, not ACL calls. applyWindowsPrincipalACLs revokes
|
||||
// this trustee's existing ACEs before applying the new plan, so there
|
||||
// are two calls per role now. The contract under test is "the second
|
||||
// ROLE fails"; counting raw calls would fail the first role's grant
|
||||
// instead and prove something else entirely.
|
||||
grants := 0
|
||||
applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) {
|
||||
if len(plan.Entries) > 0 && plan.Entries[0].Action == windowsACLRevoke {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
grants++
|
||||
if grants < 2 {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, errors.New("ACL apply refused")
|
||||
}
|
||||
|
||||
if _, err := setupWindowsSandboxPrincipal(windowsSandboxTestConfig(t)); err == nil {
|
||||
t.Fatal("setup reported success despite an injected ACL failure")
|
||||
}
|
||||
assertRolesEqual(t, removed, testCase.wantRemoved)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The same contract on the success path: the rollback handed back to the caller
|
||||
// for a LATER setup step to invoke must be just as reluctant.
|
||||
func TestDualRoleSetupReturnedRollbackSparesAdoptedPrincipals(t *testing.T) {
|
||||
var removed []windowsSandboxRole
|
||||
restoreDualRoleSeams(t, map[windowsSandboxRole]bool{
|
||||
windowsSandboxRoleOffline: false,
|
||||
windowsSandboxRoleOnline: true,
|
||||
}, &removed)
|
||||
|
||||
rollback, err := setupWindowsSandboxPrincipal(windowsSandboxTestConfig(t))
|
||||
if err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
if len(removed) != 0 {
|
||||
t.Fatalf("setup removed principals before anything failed: %v", removed)
|
||||
}
|
||||
if err := rollback(); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
assertRolesEqual(t, removed, []windowsSandboxRole{windowsSandboxRoleOnline})
|
||||
}
|
||||
|
||||
func restoreDualRoleSeams(t *testing.T, createdByRole map[windowsSandboxRole]bool, removed *[]windowsSandboxRole) {
|
||||
t.Helper()
|
||||
prevProvision := provisionWindowsSandboxPrincipalForSetupFn
|
||||
prevApply := applyWindowsACLPlanFn
|
||||
prevRemove := removeWindowsSandboxPrincipalForSetupFn
|
||||
t.Cleanup(func() {
|
||||
provisionWindowsSandboxPrincipalForSetupFn = prevProvision
|
||||
applyWindowsACLPlanFn = prevApply
|
||||
removeWindowsSandboxPrincipalForSetupFn = prevRemove
|
||||
})
|
||||
|
||||
provisionWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) (windowsSandboxIdentity, bool, error) {
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
return windowsSandboxIdentity{}, false, err
|
||||
}
|
||||
return windowsSandboxIdentity{Username: "zero-sbx-test", SID: sid}, createdByRole[role], nil
|
||||
}
|
||||
applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
removeWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) error {
|
||||
*removed = append(*removed, role)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// windowsSandboxTestConfig hands the production setup path test-owned roots and
|
||||
// nothing else. The principal and ACL seams intercept the identity work, not the
|
||||
// filesystem work: setupWindowsSandboxPrincipal materializes runtime candidates
|
||||
// under the user cache and TEMP, and applyWindowsPrincipalACLs writes ledgers
|
||||
// below the sandbox home, so the fixed C:\sandboxhome and C:\ws these tests used
|
||||
// to name meant an ordinary run wrote to the live cache, failed on drive-root
|
||||
// permissions, or collided with host state. Everything setup touches is
|
||||
// redirected to t.TempDir, and windowsSandboxTestRootsUntouched proves the
|
||||
// redirect took. Reported by @gnanam1990.
|
||||
func windowsSandboxTestConfig(t *testing.T) WindowsSandboxCommandConfig {
|
||||
t.Helper()
|
||||
home := filepath.Join(t.TempDir(), "sandboxhome")
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cache := t.TempDir()
|
||||
prevCache := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cache, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = prevCache })
|
||||
temp := t.TempDir()
|
||||
t.Setenv("TMP", temp)
|
||||
t.Setenv("TEMP", temp)
|
||||
before := windowsFixedTestRootsState()
|
||||
t.Cleanup(func() { windowsSandboxTestRootsUntouched(t, cache, before) })
|
||||
return WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// windowsFixedRootState is what a run may not change about a path outside the
|
||||
// test-owned roots: whether it exists, and if it does, when its entries last
|
||||
// changed. A directory's modification time moves whenever something is created
|
||||
// or removed inside it, which is exactly what setup would do to a home it was
|
||||
// still writing ledgers into.
|
||||
type windowsFixedRootState struct {
|
||||
exists bool
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
var windowsFixedTestRoots = []string{`C:\sandboxhome`, `C:\ws`}
|
||||
|
||||
func windowsFixedTestRootsState() map[string]windowsFixedRootState {
|
||||
states := make(map[string]windowsFixedRootState, len(windowsFixedTestRoots))
|
||||
for _, fixed := range windowsFixedTestRoots {
|
||||
info, err := os.Stat(fixed)
|
||||
if err != nil {
|
||||
states[fixed] = windowsFixedRootState{}
|
||||
continue
|
||||
}
|
||||
states[fixed] = windowsFixedRootState{exists: true, modTime: info.ModTime()}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// windowsSandboxTestRootsUntouched asserts the production setup path stayed
|
||||
// inside the test-owned roots. Not "the fixed paths are absent": a box that ran
|
||||
// the old form of these tests still has C:\sandboxhome from them, which is the
|
||||
// host state the finding was about, so the assertion is that THIS run neither
|
||||
// created nor changed them. And the cache the run was redirected to must not be
|
||||
// the live one, or the redirect proved nothing.
|
||||
func windowsSandboxTestRootsUntouched(t *testing.T, cache string, before map[string]windowsFixedRootState) {
|
||||
t.Helper()
|
||||
after := windowsFixedTestRootsState()
|
||||
for _, fixed := range windowsFixedTestRoots {
|
||||
was, now := before[fixed], after[fixed]
|
||||
switch {
|
||||
case !was.exists && now.exists:
|
||||
t.Errorf("setup under test created %s, a path outside the test-owned roots", fixed)
|
||||
case was.exists && now.exists && !now.modTime.Equal(was.modTime):
|
||||
t.Errorf("setup under test wrote into %s, a path outside the test-owned roots", fixed)
|
||||
}
|
||||
}
|
||||
if live, err := os.UserCacheDir(); err == nil && live == cache {
|
||||
t.Fatalf("SETUP INVALID: the redirected cache %s is the live one", cache)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRolesEqual(t *testing.T, got []windowsSandboxRole, want []windowsSandboxRole) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("removed %v, want %v", got, want)
|
||||
}
|
||||
seen := map[windowsSandboxRole]bool{}
|
||||
for _, role := range got {
|
||||
seen[role] = true
|
||||
}
|
||||
for _, role := range want {
|
||||
if !seen[role] {
|
||||
t.Fatalf("removed %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// appliedDenyMask returns the DENY mask an applied DACL carries for sid, read
|
||||
// back off the object rather than asserted on the plan, because the plan is what
|
||||
// was wrong.
|
||||
func appliedDenyMask(t *testing.T, path string, sid string) uint32 {
|
||||
t.Helper()
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("read the DACL of %s: %v", path, err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil || dacl == nil {
|
||||
t.Fatalf("read the DACL of %s: %v", path, err)
|
||||
}
|
||||
wanted, err := windows.StringToSid(sid)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", sid, err)
|
||||
}
|
||||
var mask uint32
|
||||
// Fixed-layout ACE types only; an object ACE puts GUIDs ahead of the trustee
|
||||
// so SidStart would land mid-structure. Nothing here builds one, and an
|
||||
// unrecognised type is skipped rather than decoded into a nonsense SID.
|
||||
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, index, &ace); err != nil {
|
||||
continue
|
||||
}
|
||||
if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE {
|
||||
continue
|
||||
}
|
||||
if !(*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(wanted) {
|
||||
continue
|
||||
}
|
||||
mask |= uint32(ace.Mask)
|
||||
}
|
||||
return mask
|
||||
}
|
||||
|
||||
// A GUARD ON AN OBJECT THAT DOES NOT EXIST YET IS NOT A GUARD.
|
||||
//
|
||||
// The capability planner emitted the deny-delete on .git and the deny-writes on
|
||||
// .git\config and .git\hooks without Materialize. On a workspace that had no
|
||||
// .git when setup ran, the first apply pass skipped all three as missing and the
|
||||
// deferred pass skipped them again, because nothing else in the capability plan
|
||||
// created them. Setup recorded success anyway.
|
||||
//
|
||||
// A later git init then created .git, config and hooks beneath the
|
||||
// already-granted workspace. They inherit the workspace allow, which carries
|
||||
// DELETE, with no object-specific deny of their own, so the sandboxed command
|
||||
// could rename .git aside, recreate it, and get credential.helper and
|
||||
// core.hooksPath back.
|
||||
//
|
||||
// This is the DEFAULT backend, so the weaker of the two planners' rules was the
|
||||
// one almost every Windows user got. The principal plan was never affected: its
|
||||
// carveouts are materialized, which both applies them and creates .git as their
|
||||
// parent so the deferred retry lands the deny-delete.
|
||||
//
|
||||
// Read back off the applied object, and driven through the same
|
||||
// BuildWindowsACLPlan + applyWindowsACLPlan pair both setup tiers use. DACL edits
|
||||
// on a user-owned directory need no Administrator rights.
|
||||
func TestCapabilityGitGuardReachesAWorkspaceThatGetsGitLater(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
|
||||
// SETUP: no .git at setup time. That is the entire case.
|
||||
if _, err := os.Stat(gitDir); err == nil {
|
||||
t.Fatal("SETUP INVALID: .git already exists, so this is the covered case, not the uncovered one")
|
||||
}
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
plan, err := BuildWindowsACLPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
sid, err := windowsCapabilitySIDForWriteRoot(config, workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve the workspace capability SID: %v", err)
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
t.Skipf("cannot apply an ACL plan here: %v", err)
|
||||
}
|
||||
|
||||
// The workspace grant really landed, so a missing deny below is a missing
|
||||
// deny and not an apply that did nothing.
|
||||
if appliedAllowMask(t, workspace, sid) == 0 {
|
||||
t.Fatal("SETUP INVALID: the workspace carries no allow ACE for the capability SID, so the plan did not apply")
|
||||
}
|
||||
|
||||
// Now git arrives, the way it does for a scaffold or a clone after setup.
|
||||
if out, err := exec.Command("git", "init", workspace).CombinedOutput(); err != nil {
|
||||
t.Skipf("git init unavailable here: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
if mask := appliedDenyMask(t, gitDir, sid); mask&uint32(windows.DELETE) == 0 {
|
||||
t.Fatalf(".git carries no deny-DELETE for the capability SID after git init (mask=%#x); the sandboxed token can rename it aside and recreate it without the config and hooks carveouts", mask)
|
||||
}
|
||||
hooks := filepath.Join(gitDir, "hooks")
|
||||
if _, err := os.Stat(hooks); err == nil {
|
||||
if mask := appliedDenyMask(t, hooks, sid); mask == 0 {
|
||||
t.Error(".git\\hooks carries no deny for the capability SID, so core.hooksPath is writable from inside the sandbox")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// appliedAllowMask is the allow-side companion, used only as a setup guard.
|
||||
func appliedAllowMask(t *testing.T, path string, sid string) uint32 {
|
||||
t.Helper()
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("read the DACL of %s: %v", path, err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil || dacl == nil {
|
||||
t.Fatalf("read the DACL of %s: %v", path, err)
|
||||
}
|
||||
wanted, err := windows.StringToSid(sid)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", sid, err)
|
||||
}
|
||||
var mask uint32
|
||||
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, index, &ace); err != nil {
|
||||
continue
|
||||
}
|
||||
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
|
||||
continue
|
||||
}
|
||||
if !(*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(wanted) {
|
||||
continue
|
||||
}
|
||||
mask |= uint32(ace.Mask)
|
||||
}
|
||||
return mask
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The git control-plane carveouts are two different shapes: .git/hooks is a
|
||||
// directory, .git/config is a FILE. Materialization has to respect that.
|
||||
//
|
||||
// On a fresh workspace neither exists yet, which is precisely the case
|
||||
// Materialize was added for — so this is the common path, not a corner. Creating
|
||||
// .git/config as a directory does not just mis-ACL it: it makes the workspace
|
||||
// permanently unusable, because git refuses to initialise over a directory
|
||||
// where its config file belongs.
|
||||
func TestPrincipalACLPlanMaterializesGitConfigAsFile(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
// Guests, deliberately: the deny-write ACE must land on the sandbox
|
||||
// principal, not on whoever runs the test. With Everyone (S-1-1-0) the
|
||||
// ACE denies the test process too and `git init` fails with "Permission
|
||||
// denied" for a reason that has nothing to do with the shape bug.
|
||||
PrincipalSID: "S-1-5-32-546",
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlan: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if rollback != nil {
|
||||
_ = rollback()
|
||||
}
|
||||
})
|
||||
|
||||
configPath := filepath.Join(workspace, ".git", "config")
|
||||
if info, err := os.Stat(configPath); err == nil && info.IsDir() {
|
||||
t.Errorf(".git/config was materialized as a directory; git requires a file")
|
||||
}
|
||||
hooksPath := filepath.Join(workspace, ".git", "hooks")
|
||||
if info, err := os.Stat(hooksPath); err == nil && !info.IsDir() {
|
||||
t.Errorf(".git/hooks was materialized as a file; git requires a directory")
|
||||
}
|
||||
|
||||
// The failure users would actually hit.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not on PATH; shape assertions above still ran")
|
||||
}
|
||||
cmd := exec.Command("git", "init")
|
||||
cmd.Dir = workspace
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Errorf("git init failed on a workspace after sandbox setup: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// windowsPathDeniesDelete reports whether the object's real DACL carries a deny
|
||||
// ACE covering DELETE for the given SID.
|
||||
//
|
||||
// The plan-shape tests assert what the planner emits. This reads what actually
|
||||
// landed on disk, which is the gap that let the guard go missing: the plan was
|
||||
// right the whole time and the apply silently dropped it.
|
||||
func windowsPathDeniesDelete(t *testing.T, path, sid string) bool {
|
||||
t.Helper()
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("read DACL for %s: %v", path, err)
|
||||
}
|
||||
// SDDL rather than walking the ACE buffer by hand: this x/sys does not export
|
||||
// an ACE enumerator, and unsafe pointer arithmetic in a test that exists to
|
||||
// catch a security regression is its own hazard.
|
||||
//
|
||||
// An ACE renders as (type;flags;rights;guid;inherit_guid;sid), so the deny
|
||||
// entries are the ones whose type field is D. Rights come back as SDDL
|
||||
// abbreviations when they fit and as a hex mask when they do not, so both are
|
||||
// accepted; SD is the abbreviation for DELETE.
|
||||
for _, ace := range strings.Split(descriptor.String(), "(") {
|
||||
fields := strings.Split(strings.TrimSuffix(strings.TrimSpace(ace), ")"), ";")
|
||||
if len(fields) != 6 || fields[0] != "D" || !strings.EqualFold(fields[5], sid) {
|
||||
continue
|
||||
}
|
||||
rights := fields[2]
|
||||
if strings.Contains(rights, "SD") {
|
||||
return true
|
||||
}
|
||||
if mask, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(rights), "0x"), 16, 32); err == nil {
|
||||
if uint32(mask)&uint32(windows.DELETE) != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// THE GUARD MUST REACH DISK ON A WORKSPACE THAT HAD NO .git.
|
||||
//
|
||||
// This is the case the whole plan is built around and it was the one case where
|
||||
// the guard was absent. .git deliberately carries no Materialize, because an
|
||||
// empty .git breaks `git init`. Groups are applied in ascending path order, so
|
||||
// the .git group ran while .git did not yet exist, was skipped as a
|
||||
// non-materializing missing target, and nothing revisited it once the
|
||||
// .git\config carveout created .git as its parent chain moments later.
|
||||
//
|
||||
// Four tests already covered the deny-delete mask and the plan emitting it.
|
||||
// None of them applied the plan and looked at the object, which is exactly why
|
||||
// a green suite said nothing.
|
||||
func TestGitRenameGuardReachesDiskOnAWorkspaceWithoutGit(t *testing.T) {
|
||||
const principal = testPrincipalSID // Unaliased, so it renders literally in SDDL.
|
||||
workspace := t.TempDir()
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
if _, err := os.Stat(gitDir); err == nil {
|
||||
t.Fatal("the workspace already has .git, so this proves nothing")
|
||||
}
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: principal,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlan: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if rollback != nil {
|
||||
_ = rollback()
|
||||
}
|
||||
})
|
||||
|
||||
// The carveouts create .git on the way to .git\config.
|
||||
if _, err := os.Stat(gitDir); err != nil {
|
||||
t.Fatalf(".git was never created by the carveout materialization: %v", err)
|
||||
}
|
||||
if !windowsPathDeniesDelete(t, gitDir, principal) {
|
||||
t.Fatal("no deny-delete ACE on .git after applying the plan: the principal can rename it aside and recreate it without the config and hooks carveouts")
|
||||
}
|
||||
}
|
||||
|
||||
// The same guard on a workspace that already had .git must keep working. This
|
||||
// case was already correct, and it is kept so a fix aimed at the fresh
|
||||
// workspace cannot quietly trade one for the other.
|
||||
func TestGitRenameGuardStillReachesDiskWhenGitAlreadyExists(t *testing.T) {
|
||||
const principal = testPrincipalSID
|
||||
workspace := t.TempDir()
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
if err := os.Mkdir(gitDir, 0o700); err != nil {
|
||||
t.Fatalf("seed .git: %v", err)
|
||||
}
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: principal,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlan: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if rollback != nil {
|
||||
_ = rollback()
|
||||
}
|
||||
})
|
||||
|
||||
if !windowsPathDeniesDelete(t, gitDir, principal) {
|
||||
t.Fatal("no deny-delete ACE on a .git that already existed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A sandbox principal must not be able to REPLACE .git.
|
||||
//
|
||||
// The write-denied carveouts are attached to .git/config and .git/hooks as
|
||||
// objects. Rename .git aside, recreate it, and those objects are gone: the fresh
|
||||
// config and hooks inherit the workspace allow with no deny of their own, which
|
||||
// hands back credential.helper and core.hooksPath, and with them arbitrary code
|
||||
// execution on the next git command.
|
||||
//
|
||||
// .git cannot join sandboxFullyProtectedMetadataNames to fix this, because that
|
||||
// list emits DenyWrite, whose mask includes FILE_GENERIC_WRITE. Git has to write
|
||||
// index, objects and refs. So the directory needs DELETE denied on itself while
|
||||
// staying writable underneath.
|
||||
func TestThePrincipalCannotRenameTheGitDirectory(t *testing.T) {
|
||||
root := filepath.FromSlash("/ws/repo")
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-1-2-3-1001",
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: root,
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(root),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
|
||||
// The plan normalizes every write root before it names an ACE, so the
|
||||
// expected path has to be normalized too or this compares two spellings of
|
||||
// the same directory. Hardcoding a drive letter instead would pass on
|
||||
// Windows and fail everywhere else, since the builder is portable code.
|
||||
gitDir := filepath.Join(normalizeProfilePath(root), ".git")
|
||||
var denyDelete *WindowsACLEntry
|
||||
for index := range plan.Entries {
|
||||
if plan.Entries[index].Action == WindowsACLDenyDelete && plan.Entries[index].Path == gitDir {
|
||||
denyDelete = &plan.Entries[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if denyDelete == nil {
|
||||
t.Fatalf("no deny-delete entry for %s, so the principal can rename .git and recreate it without the carveouts:\n%#v", gitDir, plan.Entries)
|
||||
}
|
||||
if denyDelete.Capability != "S-1-5-21-1-2-3-1001" {
|
||||
t.Errorf("deny-delete names %q, want the principal SID", denyDelete.Capability)
|
||||
}
|
||||
}
|
||||
|
||||
// The guard must not become a write ban. Git writes constantly inside .git, so a
|
||||
// deny that reached the children would break every commit rather than just the
|
||||
// rename. It also must not be materialized into existence: .git is git's to
|
||||
// create, and an empty .git directory made by setup breaks `git init`.
|
||||
func TestTheGitRenameGuardDoesNotBlockGitsOwnWrites(t *testing.T) {
|
||||
root := filepath.FromSlash("/ws/repo")
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-1-2-3-1001",
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: root,
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(root),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
|
||||
gitDir := filepath.Join(normalizeProfilePath(root), ".git")
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Path != gitDir {
|
||||
continue
|
||||
}
|
||||
if entry.Action == WindowsACLDenyWrite {
|
||||
t.Errorf("deny-write on %s would stop git writing index/objects/refs", gitDir)
|
||||
}
|
||||
if entry.Action == WindowsACLDenyDelete && entry.Materialize {
|
||||
t.Errorf("the rename guard materializes %s; git must create it, an empty .git breaks git init", gitDir)
|
||||
}
|
||||
}
|
||||
|
||||
// The existing carveouts must survive unchanged.
|
||||
for _, want := range []string{filepath.Join(gitDir, "config"), filepath.Join(gitDir, "hooks")} {
|
||||
found := false
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLDenyWrite && entry.Path == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the write-deny carveout for %s disappeared", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The mask is the whole point of the action, so it is asserted bit by bit.
|
||||
//
|
||||
// Renaming a directory needs DELETE on the directory itself, so denying DELETE
|
||||
// is what stops .git being replaced. Everything else in the mask is there to
|
||||
// stop the principal removing the guard: WRITE_DAC would let it rewrite the
|
||||
// DACL, WRITE_OWNER would let it take ownership and then rewrite the DACL.
|
||||
//
|
||||
// What must NOT be in it matters just as much. FILE_GENERIC_WRITE would stop git
|
||||
// writing index, objects and refs. FILE_DELETE_CHILD would stop git deleting its
|
||||
// own lock files and refs. Either one turns a rename guard into a broken repo.
|
||||
func TestDenyDeleteMaskStopsRenameWithoutStoppingGit(t *testing.T) {
|
||||
mode, mask, err := windowsACLAccess(WindowsACLDenyDelete)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsACLAccess(deny-delete): %v", err)
|
||||
}
|
||||
if mode != windows.DENY_ACCESS {
|
||||
t.Fatalf("access mode = %v, want DENY_ACCESS", mode)
|
||||
}
|
||||
|
||||
for _, required := range []struct {
|
||||
name string
|
||||
bit windows.ACCESS_MASK
|
||||
}{
|
||||
{"DELETE", windows.DELETE},
|
||||
{"WRITE_DAC", windows.WRITE_DAC},
|
||||
{"WRITE_OWNER", windows.WRITE_OWNER},
|
||||
} {
|
||||
if mask&required.bit == 0 {
|
||||
t.Errorf("mask %#x is missing %s, so the guard can be removed or bypassed", mask, required.name)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []struct {
|
||||
name string
|
||||
bit windows.ACCESS_MASK
|
||||
breaks string
|
||||
}{
|
||||
{"FILE_GENERIC_WRITE", windows.FILE_GENERIC_WRITE, "git writing index/objects/refs"},
|
||||
{"FILE_DELETE_CHILD", windowsFileDeleteChild, "git deleting its own lock files and refs"},
|
||||
} {
|
||||
if mask&forbidden.bit != 0 {
|
||||
t.Errorf("mask %#x includes %s, which breaks %s", mask, forbidden.name, forbidden.breaks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inheritance is the other half. An inherited deny would reach every file inside
|
||||
// .git and stop git deleting anything at all, so this ACE has to apply to the
|
||||
// directory object alone while the other actions keep inheriting as before.
|
||||
func TestDenyDeleteDoesNotInheritWhileOtherActionsStillDo(t *testing.T) {
|
||||
entries := []WindowsACLEntry{
|
||||
{Action: WindowsACLDenyDelete, Path: `C:\work\repo\.git`, Capability: "S-1-5-32-9999"},
|
||||
{Action: WindowsACLDenyWrite, Path: `C:\work\repo\.zero`, Capability: "S-1-5-32-9999"},
|
||||
{Action: WindowsACLAllowWrite, Path: `C:\work\repo`, Capability: "S-1-5-32-9999"},
|
||||
}
|
||||
|
||||
_, access, err := prepareWindowsACLPathGroupEntries(entries, true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareWindowsACLPathGroupEntries: %v", err)
|
||||
}
|
||||
if len(access) != len(entries) {
|
||||
t.Fatalf("got %d access entries, want %d", len(access), len(entries))
|
||||
}
|
||||
if access[0].Inheritance != windows.NO_INHERITANCE {
|
||||
t.Errorf("deny-delete inheritance = %#x, want NO_INHERITANCE; an inherited deny would stop git deleting inside .git", access[0].Inheritance)
|
||||
}
|
||||
for index, entry := range entries[1:] {
|
||||
if got := access[index+1].Inheritance; got != windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT {
|
||||
t.Errorf("%s inheritance = %#x, want the directory default to be unchanged", entry.Action, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A NAME IS NOT PROOF OF PROVENANCE, for the USERS group specifically.
|
||||
//
|
||||
// ensureWindowsLocalGroup covers both managed groups, and the sibling test in
|
||||
// windows_offline_group_ownership_windows_test.go pins the offline one. This
|
||||
// pins ZeroSandboxUsers, which is the group anandh8x reported: it holds every
|
||||
// sandbox principal, so adopting a same-named group created by an installer or
|
||||
// by policy would hand the principal whatever that group already grants.
|
||||
//
|
||||
// Driven through the seams so it needs no Administrator and leaves no real local
|
||||
// group on the machine running the suite.
|
||||
func TestAdoptingAForeignUsersGroupIsRefused(t *testing.T) {
|
||||
for _, status := range []uintptr{nerrGroupExists, errorAliasExists} {
|
||||
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
|
||||
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
|
||||
|
||||
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return status, nil }
|
||||
windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { return false, nil }
|
||||
|
||||
err := ensureWindowsSandboxGroup()
|
||||
if err == nil {
|
||||
t.Fatalf("status %d adopted a group Zero did not create, so the principal inherits whatever it already grants", status)
|
||||
}
|
||||
if !strings.Contains(err.Error(), windowsSandboxGroupName) {
|
||||
t.Errorf("the refusal must name the group in the way, got %q", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Our own group is still adopted, or re-running setup would fail on the group it
|
||||
// created a moment ago and provisioning would never converge.
|
||||
func TestAdoptingOurOwnUsersGroupSucceeds(t *testing.T) {
|
||||
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
|
||||
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
|
||||
|
||||
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrGroupExists, nil }
|
||||
windowsLocalGroupOwnedByZeroFn = func(name, comment string) (bool, error) {
|
||||
if name != windowsSandboxGroupName || comment != windowsSandboxGroupComment {
|
||||
t.Errorf("ownership probed for %q/%q, want the users group", name, comment)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if err := ensureWindowsSandboxGroup(); err != nil {
|
||||
t.Errorf("refused a group carrying Zero's own comment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A freshly created group is ours by construction, so no ownership probe runs.
|
||||
func TestCreatingTheUsersGroupDoesNotProbeOwnership(t *testing.T) {
|
||||
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
|
||||
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
|
||||
|
||||
probed := false
|
||||
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrSuccess, nil }
|
||||
windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { probed = true; return false, nil }
|
||||
|
||||
if err := ensureWindowsSandboxGroup(); err != nil {
|
||||
t.Fatalf("a successful create was rejected: %v", err)
|
||||
}
|
||||
if probed {
|
||||
t.Error("ownership was probed for a group we had just created ourselves")
|
||||
}
|
||||
}
|
||||
|
||||
// A failed ownership probe is not read as "ours" and not as "not ours" either.
|
||||
func TestAnUnreadableUsersGroupIsNotGuessedEitherWay(t *testing.T) {
|
||||
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
|
||||
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
|
||||
|
||||
sentinel := errors.New("NetLocalGroupGetInfo: status 5")
|
||||
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrGroupExists, nil }
|
||||
windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { return false, sentinel }
|
||||
|
||||
if err := ensureWindowsSandboxGroup(); !errors.Is(err, sentinel) {
|
||||
t.Fatalf("a failed ownership probe was swallowed, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package sandbox
|
||||
|
||||
// ACLs for a sandbox principal.
|
||||
//
|
||||
// The capability-SID model this sits beside starts from "the caller can already
|
||||
// read everything" and narrows writes, because the sandboxed child runs as the
|
||||
// caller. A principal inverts that: a separate local account has no access to
|
||||
// the caller's profile at all, so the interesting direction is what to GRANT.
|
||||
//
|
||||
// That inversion is the point. Credential stores under the user's profile are
|
||||
// unreachable because the principal is a different account, not because a deny
|
||||
// rule enumerated them, which is what makes this able to close #662 and #675 on
|
||||
// Windows where a deny-read ACE against the caller's own SID never could. Deny
|
||||
// rules stay useful only for objects that are readable by everyone.
|
||||
//
|
||||
// Grants are explicit and narrow: the workspace and any extra write roots get
|
||||
// read+write, declared read-only roots get read, and the protected metadata
|
||||
// carve-outs the profile already defines stay denied so .git internals and
|
||||
// .zero/.agents cannot be rewritten from inside the sandbox.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// WindowsACLAllowRead grants read and execute without write. It exists for the
|
||||
// principal model, where a read root must be granted rather than assumed.
|
||||
const WindowsACLAllowRead WindowsACLAction = "allow-read"
|
||||
|
||||
// windowsPrincipalACLInput is everything needed to describe a principal's
|
||||
// access. It is deliberately a plain struct rather than the full command config
|
||||
// so the plan can be built and tested without a live sandbox.
|
||||
type windowsPrincipalACLInput struct {
|
||||
// PrincipalSID is the string SID of the sandbox account every ACE names.
|
||||
PrincipalSID string
|
||||
// WriteRoots receive read+write+execute. The workspace lives here.
|
||||
WriteRoots []WritableRoot
|
||||
// ReadRoots receive read+execute only.
|
||||
ReadRoots []string
|
||||
// DenyRead covers objects a principal could otherwise reach because they are
|
||||
// world-readable; per-user secrets need no entry.
|
||||
DenyRead []string
|
||||
// DenyWrite carries the policy's own deny-write paths. The capability plan
|
||||
// has always emitted these; the principal plan denied write only on
|
||||
// protected metadata and read-only subpaths inside write roots, so a policy
|
||||
// deny sitting anywhere else was simply not enforced once the runner used a
|
||||
// principal token, and a shell child could write where the restricted-token
|
||||
// backend would have blocked it.
|
||||
DenyWrite []string
|
||||
}
|
||||
|
||||
// buildWindowsPrincipalACLPlan turns a principal's access into ACL entries.
|
||||
//
|
||||
// Ordering matters at apply time: deny entries are emitted before allows so a
|
||||
// carve-out inside a granted root survives, which mirrors how Windows evaluates
|
||||
// an explicit DACL (deny ACEs first).
|
||||
func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPlan, error) {
|
||||
if input.PrincipalSID == "" {
|
||||
return WindowsACLPlan{}, errors.New("windows principal ACL plan requires a principal SID")
|
||||
}
|
||||
if len(input.WriteRoots) == 0 && len(input.ReadRoots) == 0 {
|
||||
return WindowsACLPlan{}, errors.New("windows principal ACL plan requires at least one root")
|
||||
}
|
||||
|
||||
entries := make([]WindowsACLEntry, 0, len(input.WriteRoots)*2+len(input.ReadRoots)+len(input.DenyRead))
|
||||
|
||||
// Deny first. A deny ACE inside a write root (protected metadata, git
|
||||
// internals) has to win over the grant that follows it.
|
||||
// Materialized, matching the capability plan. applyWindowsACLPlan skips a
|
||||
// target that does not exist, so without this a deny-read path created after
|
||||
// setup ran never got an ACE at all and the principal could read it. The
|
||||
// deny has to be in place before the object is.
|
||||
for _, path := range normalizeProfilePaths(input.DenyRead) {
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyRead,
|
||||
Path: path,
|
||||
Capability: input.PrincipalSID,
|
||||
Materialize: true,
|
||||
})
|
||||
}
|
||||
for _, path := range normalizeProfilePaths(input.DenyWrite) {
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: path,
|
||||
Capability: input.PrincipalSID,
|
||||
Materialize: true,
|
||||
})
|
||||
}
|
||||
for _, root := range input.WriteRoots {
|
||||
// Normalized the same way as read and deny paths: a write root may arrive
|
||||
// with "~" or as a relative path, and an ACE has to name the same absolute,
|
||||
// symlink-resolved object the deny entries do or the two disagree.
|
||||
cleaned := normalizeProfilePath(root.Root)
|
||||
if cleaned == "" {
|
||||
return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: unusable write root %q", root.Root)
|
||||
}
|
||||
// Materialized, like the metadata and policy denies below and above.
|
||||
//
|
||||
// These are the git control-plane carveouts (.git/config, .git/hooks). On a
|
||||
// workspace where git has not run yet they do not exist at setup time, and
|
||||
// applyWindowsACLPlan skips a target that is absent, so the ACEs were never
|
||||
// written. Once git created those paths the principal still held inherited
|
||||
// write access to the workspace and could install a hook or rewrite
|
||||
// credential.helper. The capability plan gets away without this because its
|
||||
// child runs as the caller; a separate principal account does not.
|
||||
// Which carveouts are files rather than directories comes from the same
|
||||
// spec list the profile built ReadOnlySubpaths from, so a new carveout
|
||||
// cannot be added without its shape coming along.
|
||||
for _, subpath := range normalizeProfilePaths(root.ReadOnlySubpaths) {
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: subpath,
|
||||
Capability: input.PrincipalSID,
|
||||
Materialize: true,
|
||||
MaterializeFile: gitMetadataCarveoutIsFile(subpath),
|
||||
})
|
||||
}
|
||||
for _, name := range root.ProtectedMetadataNames {
|
||||
// These are documented as NAMES, and the join below is what makes that
|
||||
// documentation load-bearing rather than descriptive: ".." or a
|
||||
// separator would place this deny ACE, and the directory it
|
||||
// materializes, outside the write root entirely. Today every caller
|
||||
// passes a package constant, so this is unreachable; it is here so that
|
||||
// stays true when a future caller sources these from config.
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: protected metadata name: %w", err)
|
||||
}
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: filepath.Join(cleaned, name),
|
||||
Capability: input.PrincipalSID,
|
||||
Materialize: true,
|
||||
})
|
||||
}
|
||||
// .git gets DELETE denied on the directory itself, because the carveouts
|
||||
// that protect it are attached to .git/config and .git/hooks as OBJECTS.
|
||||
// Rename .git aside and recreate it and those objects are gone, so the
|
||||
// fresh config and hooks inherit the workspace allow with no deny of their
|
||||
// own, handing back credential.helper and core.hooksPath.
|
||||
//
|
||||
// Not DenyWrite (git writes index, objects and refs), not materialized
|
||||
// (git creates .git, and an empty one breaks git init), and not inherited,
|
||||
// so everything underneath stays writable.
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyDelete,
|
||||
Path: windowsRenameProtectedObject(cleaned),
|
||||
Capability: input.PrincipalSID,
|
||||
})
|
||||
}
|
||||
|
||||
// Then the grants the principal cannot work without.
|
||||
for _, root := range input.WriteRoots {
|
||||
cleaned := normalizeProfilePath(root.Root)
|
||||
if cleaned == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLAllowWrite,
|
||||
Path: cleaned,
|
||||
Capability: input.PrincipalSID,
|
||||
})
|
||||
}
|
||||
for _, path := range normalizeProfilePaths(input.ReadRoots) {
|
||||
// NEVER grant at a volume root.
|
||||
//
|
||||
// permissionProfileReadRoots seeds its list with profileRootPath(), which
|
||||
// is the separator alone, because the workspace-write posture is
|
||||
// read-all/write-jail. That is harmless for the capability backend, whose
|
||||
// child runs as the CALLER and therefore reads what the caller could read
|
||||
// anyway. It is not harmless here: a principal is a separate local
|
||||
// account, so this loop turns that synthetic entry into a real,
|
||||
// persistent, inheritable allow-read ACE for that account at the root of
|
||||
// the current drive, reaching every directory that does not block
|
||||
// inheritance.
|
||||
//
|
||||
// Dropping it does not take away the reads the principal needs to run
|
||||
// commands. NetUserAdd with USER_PRIV_USER puts the account in the
|
||||
// built-in Users group (see usrPrivUser in windows_identity_windows.go),
|
||||
// and the machine's own ACLs already grant Users read on the system and
|
||||
// program directories. What the grant added on top was read access to the
|
||||
// places Users are deliberately kept out of, which is the opposite of what
|
||||
// a sandbox is for.
|
||||
//
|
||||
// Note this inherits rather than asserts: nothing here checks that those
|
||||
// default ACLs are actually in place, so a hardened image that strips
|
||||
// Users read would need an explicit bounded read set instead.
|
||||
if isWindowsVolumeRoot(path) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLAllowRead,
|
||||
Path: path,
|
||||
Capability: input.PrincipalSID,
|
||||
})
|
||||
}
|
||||
|
||||
return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil
|
||||
}
|
||||
|
||||
// windowsPrincipalRevokePlan returns the entries whose ACEs should be removed
|
||||
// when a principal is retired. Revocation is by TRUSTEE rather than by path:
|
||||
// every ACE naming this principal is dropped, so cleanup does not depend on
|
||||
// remembering which paths were granted, and a grant added by an older version
|
||||
// is still removed.
|
||||
//
|
||||
// This is the removal path the capability-SID model never had, where a synthetic
|
||||
// SID left ACEs behind on the user's tree with nothing to match them against.
|
||||
func windowsPrincipalRevokePlan(principalSID string, paths []string) (WindowsACLPlan, error) {
|
||||
if principalSID == "" {
|
||||
return WindowsACLPlan{}, errors.New("windows principal revoke plan requires a principal SID")
|
||||
}
|
||||
cleaned := normalizeProfilePaths(paths)
|
||||
entries := make([]WindowsACLEntry, 0, len(cleaned))
|
||||
for _, path := range cleaned {
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: windowsACLRevoke,
|
||||
Path: path,
|
||||
Capability: principalSID,
|
||||
})
|
||||
}
|
||||
return WindowsACLPlan{Entries: entries}, nil
|
||||
}
|
||||
|
||||
// windowsACLRevoke removes every ACE naming the trustee on a path, whatever
|
||||
// access it granted or denied.
|
||||
const windowsACLRevoke WindowsACLAction = "revoke"
|
||||
|
||||
// windowsACLPlanPaths lives in windows_identity_runtime_windows.go, beside its
|
||||
// only callers. It was here, in the portable file, which made it dead code on
|
||||
// every non-Windows build and failed the static analysis gate.
|
||||
@@ -0,0 +1,275 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testPrincipalSID = "S-1-5-21-1111111111-2222222222-3333333333-1005"
|
||||
|
||||
func testPrincipalInput() windowsPrincipalACLInput {
|
||||
return windowsPrincipalACLInput{
|
||||
PrincipalSID: testPrincipalSID,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: filepath.FromSlash("/ws/project"),
|
||||
ReadOnlySubpaths: []string{filepath.FromSlash("/ws/project/.git/config")},
|
||||
ProtectedMetadataNames: []string{".zero", ".agents"},
|
||||
}},
|
||||
ReadRoots: []string{filepath.FromSlash("/usr/lib")},
|
||||
DenyRead: []string{filepath.FromSlash("/shared/secrets")},
|
||||
}
|
||||
}
|
||||
|
||||
// Windows evaluates an explicit DACL deny-before-allow, so a carve-out inside a
|
||||
// granted root only survives if its deny ACE is written first. If the grant on
|
||||
// the workspace landed before the deny on .zero, the protected metadata would be
|
||||
// writable from inside the sandbox.
|
||||
func TestPrincipalACLPlanEmitsDeniesBeforeAllows(t *testing.T) {
|
||||
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
lastDeny, firstAllow := -1, -1
|
||||
for index, entry := range plan.Entries {
|
||||
switch entry.Action {
|
||||
case WindowsACLDenyRead, WindowsACLDenyWrite:
|
||||
lastDeny = index
|
||||
case WindowsACLAllowWrite, WindowsACLAllowRead:
|
||||
if firstAllow == -1 {
|
||||
firstAllow = index
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstAllow == -1 || lastDeny == -1 {
|
||||
t.Fatalf("plan is missing a deny or an allow: %+v", plan.Entries)
|
||||
}
|
||||
if lastDeny > firstAllow {
|
||||
t.Fatalf("deny at %d comes after allow at %d; carve-outs would be overridden", lastDeny, firstAllow)
|
||||
}
|
||||
}
|
||||
|
||||
// Every ACE must name the sandbox principal. An entry with any other trustee
|
||||
// would change access for a real user.
|
||||
func TestPrincipalACLPlanNamesOnlyThePrincipal(t *testing.T) {
|
||||
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Capability != testPrincipalSID {
|
||||
t.Fatalf("entry %+v names %q, want the principal SID", entry, entry.Capability)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A principal is a separate account with no inherent access, so a write root
|
||||
// must be granted read+write and a read root granted read. Without the grant the
|
||||
// sandbox cannot open its own workspace.
|
||||
func TestPrincipalACLPlanGrantsRoots(t *testing.T) {
|
||||
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
var grantedWrite, grantedRead bool
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLAllowWrite && entry.Path == normalizeProfilePath(filepath.FromSlash("/ws/project")) {
|
||||
grantedWrite = true
|
||||
}
|
||||
if entry.Action == WindowsACLAllowRead && entry.Path == normalizeProfilePath(filepath.FromSlash("/usr/lib")) {
|
||||
grantedRead = true
|
||||
}
|
||||
}
|
||||
if !grantedWrite {
|
||||
t.Fatal("write root was not granted; the sandbox could not write its workspace")
|
||||
}
|
||||
if !grantedRead {
|
||||
t.Fatal("read root was not granted; the sandbox could not read it")
|
||||
}
|
||||
}
|
||||
|
||||
// Protected metadata is denied write and marked Materialize so the ACE is
|
||||
// created even when the directory does not exist yet, closing the window where
|
||||
// a sandboxed command creates .zero before the deny lands.
|
||||
func TestPrincipalACLPlanProtectsMetadata(t *testing.T) {
|
||||
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
found := map[string]WindowsACLEntry{}
|
||||
for _, entry := range plan.Entries {
|
||||
found[entry.Path] = entry
|
||||
}
|
||||
for _, name := range []string{".zero", ".agents"} {
|
||||
path := filepath.Join(normalizeProfilePath(filepath.FromSlash("/ws/project")), name)
|
||||
entry, ok := found[path]
|
||||
if !ok {
|
||||
t.Fatalf("no entry protecting %s", path)
|
||||
}
|
||||
if entry.Action != WindowsACLDenyWrite {
|
||||
t.Fatalf("%s has action %q, want deny-write", path, entry.Action)
|
||||
}
|
||||
if !entry.Materialize {
|
||||
t.Fatalf("%s must be materialized so the deny exists before the directory does", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A missing principal SID must be a hard error: an empty trustee would either
|
||||
// fail at apply time or, worse, be interpreted as some other account.
|
||||
func TestPrincipalACLPlanRequiresSID(t *testing.T) {
|
||||
input := testPrincipalInput()
|
||||
input.PrincipalSID = ""
|
||||
if _, err := buildWindowsPrincipalACLPlan(input); err == nil {
|
||||
t.Fatal("an empty principal SID must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// A plan with no roots at all is a caller mistake rather than a valid empty
|
||||
// grant, since the resulting sandbox could not run anything.
|
||||
func TestPrincipalACLPlanRequiresRoots(t *testing.T) {
|
||||
input := windowsPrincipalACLInput{PrincipalSID: testPrincipalSID}
|
||||
if _, err := buildWindowsPrincipalACLPlan(input); err == nil {
|
||||
t.Fatal("a plan with no roots must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation is keyed to the trustee, so retiring a principal removes every ACE
|
||||
// naming it without having to remember what was granted. This is the cleanup
|
||||
// path the capability-SID model lacks.
|
||||
func TestPrincipalRevokePlanTargetsTrustee(t *testing.T) {
|
||||
paths := []string{filepath.FromSlash("/ws/project"), filepath.FromSlash("/usr/lib")}
|
||||
plan, err := windowsPrincipalRevokePlan(testPrincipalSID, paths)
|
||||
if err != nil {
|
||||
t.Fatalf("revoke plan: %v", err)
|
||||
}
|
||||
if len(plan.Entries) != len(paths) {
|
||||
t.Fatalf("got %d entries, want %d", len(plan.Entries), len(paths))
|
||||
}
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action != windowsACLRevoke {
|
||||
t.Fatalf("entry %+v is not a revoke", entry)
|
||||
}
|
||||
if entry.Capability != testPrincipalSID {
|
||||
t.Fatalf("revoke names %q, want the principal", entry.Capability)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalRevokePlanRequiresSID(t *testing.T) {
|
||||
if _, err := windowsPrincipalRevokePlan("", []string{"/ws"}); err == nil {
|
||||
t.Fatal("revoking without a principal SID must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// The action strings end up in a serialized plan consumed by the elevated
|
||||
// helper, so they must stay stable and distinct from the existing actions.
|
||||
func TestPrincipalACLActionsAreDistinct(t *testing.T) {
|
||||
actions := []WindowsACLAction{
|
||||
WindowsACLAllowWrite, WindowsACLAllowRead,
|
||||
WindowsACLDenyRead, WindowsACLDenyWrite, windowsACLRevoke,
|
||||
}
|
||||
seen := map[WindowsACLAction]bool{}
|
||||
for _, action := range actions {
|
||||
if strings.TrimSpace(string(action)) == "" {
|
||||
t.Fatal("an action string is empty")
|
||||
}
|
||||
if seen[action] {
|
||||
t.Fatalf("duplicate action %q", action)
|
||||
}
|
||||
seen[action] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ProtectedMetadataNames is joined onto the write root to place a deny ACE and to
|
||||
// materialize the directory it names. A value that is not a single component
|
||||
// therefore puts both OUTSIDE the workspace: ".." walks up out of it, and a
|
||||
// separator reaches through whatever sits in between. Every caller passes a
|
||||
// package constant today, so this is the guard that keeps it true if one ever
|
||||
// sources these from config.
|
||||
func TestPrincipalACLPlanRefusesProtectedNamesThatEscapeTheWriteRoot(t *testing.T) {
|
||||
root := filepath.FromSlash("/ws/project")
|
||||
for _, name := range []string{"..", ".", "", `..\..\Windows\System32`, "nested/child", `nested\child`, "C:", "stream:name"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
input := testPrincipalInput()
|
||||
input.WriteRoots = []WritableRoot{{
|
||||
Root: root,
|
||||
ProtectedMetadataNames: []string{name},
|
||||
}}
|
||||
plan, err := buildWindowsPrincipalACLPlan(input)
|
||||
if err == nil {
|
||||
t.Fatalf("accepted protected metadata name %q, which would place a deny ACE outside %s:\n%#v", name, root, plan.Entries)
|
||||
}
|
||||
if len(plan.Entries) != 0 {
|
||||
t.Errorf("returned %d entries alongside the error, so a caller ignoring err would still apply them", len(plan.Entries))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A PRINCIPAL MUST NEVER BE GRANTED READ AT A VOLUME ROOT.
|
||||
//
|
||||
// permissionProfileReadRoots seeds its list with the bare separator, because
|
||||
// the workspace-write posture is read-all with a write jail. That costs nothing
|
||||
// for the capability backend, whose child runs as the caller and could read
|
||||
// those paths anyway. For a principal it is a real, persistent, inheritable
|
||||
// allow-read ACE for a separate local account at the root of the drive.
|
||||
//
|
||||
// Built from the production profile rather than a synthetic fixture, because
|
||||
// the whole point is that the shipped configuration produced it.
|
||||
func TestPrincipalACLPlanNeverGrantsReadAtAVolumeRoot(t *testing.T) {
|
||||
workspace := filepath.FromSlash("/ws/project")
|
||||
profile := DefaultPermissionProfile(workspace)
|
||||
|
||||
// If the profile ever stops carrying a volume root, this test proves nothing
|
||||
// and should be retired rather than left passing vacuously.
|
||||
seeded := false
|
||||
for _, root := range profile.FileSystem.ReadRoots {
|
||||
if isWindowsVolumeRoot(normalizeProfilePath(root)) {
|
||||
seeded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seeded {
|
||||
t.Skipf("the production profile no longer contains a volume read root: %v", profile.FileSystem.ReadRoots)
|
||||
}
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: testPrincipalSID,
|
||||
ReadRoots: profile.FileSystem.ReadRoots,
|
||||
WriteRoots: profile.FileSystem.WriteRoots,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLAllowRead && isWindowsVolumeRoot(entry.Path) {
|
||||
t.Errorf("plan grants the principal read at the volume root %q, which inherits into every directory on the drive", entry.Path)
|
||||
}
|
||||
}
|
||||
// And the workspace itself must still be reachable, or this traded a real
|
||||
// grant for a broken sandbox.
|
||||
wantWorkspace := normalizeProfilePath(workspace)
|
||||
reachable := false
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Path == wantWorkspace && (entry.Action == WindowsACLAllowRead || entry.Action == WindowsACLAllowWrite) {
|
||||
reachable = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !reachable {
|
||||
t.Errorf("no grant for the workspace root %q, so the principal could not read its own workspace", wantWorkspace)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary names must keep working, or the guard above is just a break.
|
||||
func TestPrincipalACLPlanStillAcceptsTheRealProtectedNames(t *testing.T) {
|
||||
input := testPrincipalInput()
|
||||
input.WriteRoots = []WritableRoot{{
|
||||
Root: filepath.FromSlash("/ws/project"),
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
}}
|
||||
if _, err := buildWindowsPrincipalACLPlan(input); err != nil {
|
||||
t.Fatalf("the shipped protected names were rejected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// DPAPI wrapping for the stored sandbox principal password.
|
||||
//
|
||||
// The file ACL is the primary control and remains the thing that keeps the
|
||||
// sandbox principal itself from reading its own credential. This adds the layer
|
||||
// the ACL cannot: an ACL is only meaningful while the filesystem is being asked
|
||||
// to enforce it, so a backup, a mounted disk image, or a copy taken by anyone
|
||||
// who can bypass the DACL yields the password in the clear. CryptProtectData
|
||||
// binds the ciphertext to the invoking user's logon secret, so an offline copy
|
||||
// is inert without that user's credentials.
|
||||
//
|
||||
// The principal name is passed as optional entropy, which makes a blob usable
|
||||
// only for the account it was minted for; moving one secret file over another
|
||||
// then fails to decrypt instead of silently authenticating the wrong principal.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// protectWindowsSecret encrypts a password to the current user.
|
||||
//
|
||||
// CRYPTPROTECT_UI_FORBIDDEN matters here: this runs inside a CLI and, on the
|
||||
// setup path, potentially without an interactive desktop, so DPAPI must fail
|
||||
// rather than try to prompt.
|
||||
func protectWindowsSecret(plaintext string, entropy string) ([]byte, error) {
|
||||
if plaintext == "" {
|
||||
return nil, errors.New("windows sandbox secret: refusing to protect an empty password")
|
||||
}
|
||||
in := windows.DataBlob{
|
||||
Size: uint32(len(plaintext)),
|
||||
Data: &[]byte(plaintext)[0],
|
||||
}
|
||||
entropyBytes := []byte(entropy)
|
||||
var entropyBlob *windows.DataBlob
|
||||
if len(entropyBytes) > 0 {
|
||||
entropyBlob = &windows.DataBlob{Size: uint32(len(entropyBytes)), Data: &entropyBytes[0]}
|
||||
}
|
||||
var out windows.DataBlob
|
||||
if err := windows.CryptProtectData(&in, nil, entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil {
|
||||
return nil, fmt.Errorf("protect sandbox secret: %w", err)
|
||||
}
|
||||
// DPAPI allocates the output with LocalAlloc; copy it out and hand it back.
|
||||
defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data)))
|
||||
return append([]byte(nil), unsafe.Slice(out.Data, out.Size)...), nil
|
||||
}
|
||||
|
||||
// unprotectWindowsSecret reverses protectWindowsSecret. It fails for any user
|
||||
// other than the one that wrote the blob, and for a blob minted with a different
|
||||
// principal name as entropy.
|
||||
func unprotectWindowsSecret(ciphertext []byte, entropy string) (string, error) {
|
||||
if len(ciphertext) == 0 {
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
in := windows.DataBlob{
|
||||
Size: uint32(len(ciphertext)),
|
||||
Data: &ciphertext[0],
|
||||
}
|
||||
entropyBytes := []byte(entropy)
|
||||
var entropyBlob *windows.DataBlob
|
||||
if len(entropyBytes) > 0 {
|
||||
entropyBlob = &windows.DataBlob{Size: uint32(len(entropyBytes)), Data: &entropyBytes[0]}
|
||||
}
|
||||
var out windows.DataBlob
|
||||
if err := windows.CryptUnprotectData(&in, nil, entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil {
|
||||
return "", fmt.Errorf("unprotect sandbox secret: %w", err)
|
||||
}
|
||||
defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data)))
|
||||
return string(unsafe.Slice(out.Data, out.Size)), nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// Minting a token for a sandbox principal.
|
||||
//
|
||||
// A provisioned account is inert until something can log on as it. Windows
|
||||
// gates that behind account rights held in the local security policy, so setup
|
||||
// grants the principal exactly one: the right to be logged on as a batch job,
|
||||
// which is what a non-interactive service-style logon needs. It is deliberately
|
||||
// NOT granted interactive, network or remote-interactive logon, and those three
|
||||
// are explicitly DENIED, so the account cannot be used to sign in at the
|
||||
// console, over SMB, or through RDP even if its password leaked. The password
|
||||
// exists only so LogonUser can mint a token; nobody is meant to type it.
|
||||
//
|
||||
// Rights are granted at setup (elevated) because LsaAddAccountRights requires
|
||||
// administrator privileges. The per-command path only calls LogonUser, which
|
||||
// needs no special privilege once the batch right is in place.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
// Logon type/provider for a non-interactive token. Batch is the closest
|
||||
// match for "run this command as a service-like principal": it produces a
|
||||
// full token without a desktop or network-credential footprint.
|
||||
logon32LogonBatch = 4
|
||||
logon32ProviderDefault = 0
|
||||
|
||||
// LSA policy access rights needed to add account rights.
|
||||
policyCreateAccount = 0x00000010
|
||||
policyLookupNames = 0x00000800
|
||||
|
||||
// Account rights. The sandbox principal gets the batch right and is denied
|
||||
// every interactive path.
|
||||
seBatchLogonRight = "SeBatchLogonRight"
|
||||
seDenyInteractiveLogonRight = "SeDenyInteractiveLogonRight"
|
||||
seDenyNetworkLogonRight = "SeDenyNetworkLogonRight"
|
||||
seDenyRemoteInteractiveRight = "SeDenyRemoteInteractiveLogonRight"
|
||||
seDenyServiceLogonRightName = "SeDenyServiceLogonRight"
|
||||
windowsIdentityLogonRightsNote = "granted by `zero sandbox setup`"
|
||||
)
|
||||
|
||||
var (
|
||||
procLogonUserW = windows.NewLazySystemDLL("advapi32.dll").NewProc("LogonUserW")
|
||||
procLsaOpenPolicy = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaOpenPolicy")
|
||||
procLsaClose = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaClose")
|
||||
procLsaAddAccountRights = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaAddAccountRights")
|
||||
// Retiring a principal has to drop its rights as well as its account, or the
|
||||
// LSA policy database keeps an entry keyed to a SID that no longer resolves.
|
||||
procLsaRemoveAccountRights = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaRemoveAccountRights")
|
||||
procLsaNtStatusToWinErr = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaNtStatusToWinError")
|
||||
)
|
||||
|
||||
// lsaUnicodeString mirrors LSA_UNICODE_STRING. Length and MaximumLength are
|
||||
// BYTE counts, not rune counts, which is the usual source of bugs here.
|
||||
type lsaUnicodeString struct {
|
||||
Length uint16
|
||||
MaximumLength uint16
|
||||
Buffer *uint16
|
||||
}
|
||||
|
||||
// lsaObjectAttributes mirrors LSA_OBJECT_ATTRIBUTES. Every field except Length
|
||||
// is unused for LsaOpenPolicy, but the struct must still be the right size.
|
||||
type lsaObjectAttributes struct {
|
||||
Length uint32
|
||||
RootDirectory windows.Handle
|
||||
ObjectName *lsaUnicodeString
|
||||
Attributes uint32
|
||||
SecurityDescriptor unsafe.Pointer
|
||||
SecurityQualityOfService unsafe.Pointer
|
||||
}
|
||||
|
||||
// newLSAString builds an LSA_UNICODE_STRING over a UTF-16 buffer the caller
|
||||
// keeps alive. The returned value borrows that buffer, so the buffer must
|
||||
// outlive every use of the string.
|
||||
func newLSAString(buffer []uint16) lsaUnicodeString {
|
||||
if len(buffer) == 0 {
|
||||
return lsaUnicodeString{}
|
||||
}
|
||||
// The buffer is NUL-terminated; the LSA length counts bytes WITHOUT the
|
||||
// terminator, while MaximumLength counts bytes WITH it.
|
||||
runes := len(buffer) - 1
|
||||
return lsaUnicodeString{
|
||||
Length: uint16(runes * 2),
|
||||
MaximumLength: uint16(len(buffer) * 2),
|
||||
Buffer: &buffer[0],
|
||||
}
|
||||
}
|
||||
|
||||
// lsaStatusError converts an NTSTATUS from an Lsa* call into a Go error, going
|
||||
// through LsaNtStatusToWinError so the message is the familiar Win32 one rather
|
||||
// than a raw NTSTATUS.
|
||||
func lsaStatusError(call string, status uintptr) error {
|
||||
if status == 0 {
|
||||
return nil
|
||||
}
|
||||
winErr, _, _ := procLsaNtStatusToWinErr.Call(status)
|
||||
return fmt.Errorf("%s: %w", call, windows.Errno(winErr))
|
||||
}
|
||||
|
||||
// grantWindowsSandboxLogonRights gives the principal the batch logon right and
|
||||
// denies every interactive logon path. Idempotent: LsaAddAccountRights silently
|
||||
// succeeds when the account already holds a right, so setup can re-run.
|
||||
//
|
||||
// Requires an elevated caller.
|
||||
func grantWindowsSandboxLogonRights(sid *windows.SID) error {
|
||||
if sid == nil {
|
||||
return fmt.Errorf("grant sandbox logon rights: nil SID")
|
||||
}
|
||||
var attributes lsaObjectAttributes
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
var policy windows.Handle
|
||||
status, _, _ := procLsaOpenPolicy.Call(
|
||||
0, // local system
|
||||
uintptr(unsafe.Pointer(&attributes)),
|
||||
uintptr(policyCreateAccount|policyLookupNames),
|
||||
uintptr(unsafe.Pointer(&policy)),
|
||||
)
|
||||
// LsaOpenPolicy borrows the attributes struct by address, so it has to stay
|
||||
// reachable until the call has returned.
|
||||
runtime.KeepAlive(attributes)
|
||||
if err := lsaStatusError("LsaOpenPolicy", status); err != nil {
|
||||
return err
|
||||
}
|
||||
defer procLsaClose.Call(uintptr(policy))
|
||||
|
||||
rights := []string{
|
||||
seBatchLogonRight,
|
||||
seDenyInteractiveLogonRight,
|
||||
seDenyNetworkLogonRight,
|
||||
seDenyRemoteInteractiveRight,
|
||||
seDenyServiceLogonRightName,
|
||||
}
|
||||
// Each right is added on its own call so one unsupported name on an odd SKU
|
||||
// cannot silently drop the others.
|
||||
for _, right := range rights {
|
||||
buffer, err := windows.UTF16FromString(right)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := newLSAString(buffer)
|
||||
status, _, _ := procLsaAddAccountRights.Call(
|
||||
uintptr(policy),
|
||||
uintptr(unsafe.Pointer(sid)),
|
||||
uintptr(unsafe.Pointer(&entry)),
|
||||
1,
|
||||
)
|
||||
// Both the descriptor and the buffer it points at are borrowed by the
|
||||
// call. Kept alive before the error check, not after, so the failure path
|
||||
// does not return with them already collectable.
|
||||
runtime.KeepAlive(entry)
|
||||
runtimeKeepAliveUint16(buffer)
|
||||
if err := lsaStatusError("LsaAddAccountRights("+right+")", status); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revokeWindowsSandboxLogonRights drops every account right held by a principal
|
||||
// and removes its entry from the LSA policy database.
|
||||
//
|
||||
// This is the logon-rights counterpart to revoking ACEs by trustee, and it has
|
||||
// the same reason to exist: deleting the account on its own leaves the rights
|
||||
// behind, keyed to a SID that no longer resolves, which is the orphaned residue
|
||||
// this model is supposed to avoid. It must therefore run BEFORE the account is
|
||||
// deleted, while the SID is still resolvable.
|
||||
//
|
||||
// Removing all rights rather than naming them is deliberate. The principal is
|
||||
// being retired, so anything keyed to it should go, including rights a previous
|
||||
// version of setup granted and this one no longer knows about.
|
||||
//
|
||||
// Requires an elevated caller. A principal that holds no rights is not an error:
|
||||
// LsaRemoveAccountRights reports ERROR_FILE_NOT_FOUND for an account with no LSA
|
||||
// entry, which is the state teardown is trying to reach anyway.
|
||||
func revokeWindowsSandboxLogonRights(sid *windows.SID) error {
|
||||
if sid == nil {
|
||||
return fmt.Errorf("revoke sandbox logon rights: nil SID")
|
||||
}
|
||||
var attributes lsaObjectAttributes
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
var policy windows.Handle
|
||||
status, _, _ := procLsaOpenPolicy.Call(
|
||||
0, // local system
|
||||
uintptr(unsafe.Pointer(&attributes)),
|
||||
uintptr(policyCreateAccount|policyLookupNames),
|
||||
uintptr(unsafe.Pointer(&policy)),
|
||||
)
|
||||
runtime.KeepAlive(attributes)
|
||||
if err := lsaStatusError("LsaOpenPolicy", status); err != nil {
|
||||
return err
|
||||
}
|
||||
defer procLsaClose.Call(uintptr(policy))
|
||||
|
||||
status, _, _ = procLsaRemoveAccountRights.Call(
|
||||
uintptr(policy),
|
||||
uintptr(unsafe.Pointer(sid)),
|
||||
1, // AllRights: drop everything and delete the LSA account object
|
||||
0, // UserRights ignored when AllRights is set
|
||||
0, // CountOfRights likewise
|
||||
)
|
||||
runtime.KeepAlive(sid)
|
||||
if err := lsaStatusError("LsaRemoveAccountRights", status); err != nil {
|
||||
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logonWindowsSandboxPrincipal mints a primary token for the sandbox account.
|
||||
// The caller owns the returned token and must Close it.
|
||||
//
|
||||
// This needs no elevation: the batch logon right granted at setup is what makes
|
||||
// it work, which is why the per-command path can run unelevated once setup has
|
||||
// been done once.
|
||||
func logonWindowsSandboxPrincipal(username string, password string) (windows.Token, error) {
|
||||
user, err := windows.UTF16PtrFromString(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// "." is the local machine, so the lookup never leaves this host even if the
|
||||
// machine is domain-joined and a same-named domain account exists.
|
||||
domain, err := windows.UTF16PtrFromString(".")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
secret, err := windows.UTF16PtrFromString(password)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var token windows.Token
|
||||
result, _, callErr := procLogonUserW.Call(
|
||||
uintptr(unsafe.Pointer(user)),
|
||||
uintptr(unsafe.Pointer(domain)),
|
||||
uintptr(unsafe.Pointer(secret)),
|
||||
logon32LogonBatch,
|
||||
logon32ProviderDefault,
|
||||
uintptr(unsafe.Pointer(&token)),
|
||||
)
|
||||
// The three strings are borrowed for the duration of the call.
|
||||
runtime.KeepAlive(user)
|
||||
runtime.KeepAlive(domain)
|
||||
runtime.KeepAlive(secret)
|
||||
if result == 0 {
|
||||
if callErr != nil && callErr != windows.ERROR_SUCCESS {
|
||||
return 0, fmt.Errorf("LogonUser(%s): %w", username, callErr)
|
||||
}
|
||||
return 0, fmt.Errorf("LogonUser(%s) failed", username)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// runtimeKeepAliveUint16 keeps a UTF-16 buffer reachable across a syscall that
|
||||
// borrows it. Declared rather than inlined so the intent is explicit at each
|
||||
// call site; the compiler must not free the slice while LSA holds the pointer.
|
||||
func runtimeKeepAliveUint16(buffer []uint16) {
|
||||
if len(buffer) == 0 {
|
||||
return
|
||||
}
|
||||
_ = buffer[0]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// revokeWindowsSandboxLogonRights treats "this account holds no rights" as
|
||||
// success, because that is the state teardown is trying to reach anyway. That
|
||||
// relies on the NTSTATUS for it surviving the trip through LsaNtStatusToWinError
|
||||
// as an error errors.Is can still match, which is exactly the kind of Windows
|
||||
// errno assumption that quietly turns out to be false. Assert it rather than
|
||||
// trust it.
|
||||
//
|
||||
// Needs no privilege: LsaNtStatusToWinError is a pure status translation, so
|
||||
// this runs everywhere rather than joining the gated set.
|
||||
func TestLsaStatusErrorMapsObjectNameNotFound(t *testing.T) {
|
||||
// STATUS_OBJECT_NAME_NOT_FOUND, what LsaRemoveAccountRights reports for an
|
||||
// account that has no LSA entry.
|
||||
const statusObjectNameNotFound = 0xC0000034
|
||||
|
||||
err := lsaStatusError("LsaRemoveAccountRights", statusObjectNameNotFound)
|
||||
if err == nil {
|
||||
t.Fatal("a nonzero NTSTATUS produced no error")
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
|
||||
t.Fatalf("error = %v, want one errors.Is matches against ERROR_FILE_NOT_FOUND; "+
|
||||
"without that, revoking a principal that simply holds no rights fails teardown", err)
|
||||
}
|
||||
|
||||
// The tolerance must be specific. If any failure matched it, revoke would
|
||||
// swallow a real one and teardown would report success having done nothing.
|
||||
const statusAccessDenied = 0xC0000022
|
||||
if other := lsaStatusError("LsaRemoveAccountRights", statusAccessDenied); errors.Is(other, windows.ERROR_FILE_NOT_FOUND) {
|
||||
t.Fatalf("access denied matched the not-found tolerance: %v", other)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Policy deny-write has to reach the principal plan.
|
||||
//
|
||||
// The capability plan has always emitted these. The principal plan denied write
|
||||
// only on protected metadata and read-only subpaths inside write roots, so once
|
||||
// the runner used a principal token a policy deny sitting anywhere else was not
|
||||
// enforced at the OS layer at all, and a shell child could write where the
|
||||
// restricted-token backend would have stopped it.
|
||||
func TestPrincipalACLPlanCarriesPolicyDenyWrite(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "workspace")
|
||||
denied := filepath.Join(root, "protected", "keep-out")
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-0-0-0-1001",
|
||||
WriteRoots: []WritableRoot{{Root: root}},
|
||||
DenyWrite: []string{denied},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
|
||||
entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyWrite, denied)
|
||||
if !ok {
|
||||
t.Fatalf("no deny-write ACE for the policy path; plan = %+v", plan.Entries)
|
||||
}
|
||||
// Materialized for the same reason the capability plan does it: the applier
|
||||
// skips targets that do not exist, so a deny on a path created after setup
|
||||
// would never be written.
|
||||
if !entry.Materialize {
|
||||
t.Error("policy deny-write ACE is not materialized, so it is skipped when the path does not exist yet")
|
||||
}
|
||||
}
|
||||
|
||||
// Deny-read has to be materialized too, which it was not.
|
||||
func TestPrincipalACLPlanMaterializesDenyRead(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "workspace")
|
||||
secret := filepath.Join(t.TempDir(), "elsewhere", "creds")
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-0-0-0-1001",
|
||||
WriteRoots: []WritableRoot{{Root: root}},
|
||||
DenyRead: []string{secret},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyRead, secret)
|
||||
if !ok {
|
||||
t.Fatalf("no deny-read ACE emitted; plan = %+v", plan.Entries)
|
||||
}
|
||||
if !entry.Materialize {
|
||||
t.Fatal("deny-read ACE is not materialized, so a path created after setup never gets one")
|
||||
}
|
||||
}
|
||||
|
||||
// Deny entries must still precede the grants they carve out of, which is what
|
||||
// makes them win under Windows DACL evaluation. Adding deny-write to the plan is
|
||||
// only safe if it did not disturb that ordering.
|
||||
func TestPrincipalACLPlanKeepsDeniesBeforeGrants(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "workspace")
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-0-0-0-1001",
|
||||
WriteRoots: []WritableRoot{{Root: root}},
|
||||
DenyWrite: []string{filepath.Join(root, "nope")},
|
||||
DenyRead: []string{filepath.Join(root, "secret")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
firstGrant := -1
|
||||
for i, entry := range plan.Entries {
|
||||
switch entry.Action {
|
||||
case WindowsACLAllowWrite, WindowsACLAllowRead:
|
||||
if firstGrant == -1 {
|
||||
firstGrant = i
|
||||
}
|
||||
case WindowsACLDenyRead, WindowsACLDenyWrite:
|
||||
if firstGrant != -1 {
|
||||
t.Fatalf("deny entry at %d follows a grant at %d; the grant would win", i, firstGrant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findPrincipalACLEntry(plan WindowsACLPlan, action WindowsACLAction, path string) (WindowsACLEntry, bool) {
|
||||
want := normalizeProfilePath(path)
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == action && entry.Path == want {
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
return WindowsACLEntry{}, false
|
||||
}
|
||||
|
||||
// An adopted account must not have its password rotated during provisioning.
|
||||
//
|
||||
// Rotating there left every later step running against an account whose password
|
||||
// had already been replaced with nothing on disk holding it. Any failure in
|
||||
// between stranded a working principal: the rollback correctly declined to
|
||||
// delete an account it had not created, so what remained was a live account
|
||||
// authenticated by a password no longer stored anywhere, and the command path
|
||||
// read the absent secret as "not provisioned" and quietly fell back to the
|
||||
// weaker backend.
|
||||
func TestProvisionWindowsSandboxIdentityDefersPasswordRotation(t *testing.T) {
|
||||
stubWindowsProvisioning(t, true, nil, nil, nil)
|
||||
|
||||
rotated := false
|
||||
previous := resetWindowsSandboxUserPasswordFn
|
||||
t.Cleanup(func() { resetWindowsSandboxUserPasswordFn = previous })
|
||||
resetWindowsSandboxUserPasswordFn = func(string, string) error {
|
||||
rotated = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil {
|
||||
t.Fatalf("provisioning an adopted account: %v", err)
|
||||
}
|
||||
if rotated {
|
||||
t.Fatal("provisioning rotated the password; the window this closes lasts until the secret is committed")
|
||||
}
|
||||
}
|
||||
|
||||
// The ownership comment carries the full workspace key, so two workspaces whose
|
||||
// digests collide in the 11 characters the account name can hold are refused
|
||||
// rather than silently sharing one account, one secret and one ACL identity.
|
||||
func TestWindowsSandboxUserCommentDistinguishesWorkspaces(t *testing.T) {
|
||||
first := windowsSandboxUserCommentFor("aaaaaaaaaaaabbbbbbbb")
|
||||
second := windowsSandboxUserCommentFor("aaaaaaaaaaaacccccccc")
|
||||
if first == second {
|
||||
t.Fatal("two workspaces produced the same ownership comment, so a name collision would be adopted")
|
||||
}
|
||||
if !strings.HasPrefix(first, windowsSandboxUserComment) {
|
||||
t.Fatalf("comment %q lost the marker prefix that identifies it as ours", first)
|
||||
}
|
||||
// The names DO collide, which is the whole reason the comment has to carry
|
||||
// the key. If this stops being true the test is no longer covering anything.
|
||||
if windowsSandboxUserName("aaaaaaaaaaaabbbbbbbb", windowsSandboxRoleOffline) != windowsSandboxUserName("aaaaaaaaaaaacccccccc", windowsSandboxRoleOffline) {
|
||||
t.Skip("account names no longer collide for these keys; revisit what this test is for")
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback must not strip an adopted principal's logon rights.
|
||||
//
|
||||
// revokeWindowsSandboxLogonRights passes AllRights, which drops every right the
|
||||
// account holds and deletes its LSA object. On an account this run created that
|
||||
// is a rollback; on one it adopted it destroys the SeBatchLogonRight and
|
||||
// deny-logon rights an earlier setup established, which is the working
|
||||
// principal this path exists to preserve.
|
||||
func TestSetupRollbackRevokesRightsOnlyForCreatedPrincipals(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
existed bool
|
||||
wantRevoked bool
|
||||
}{
|
||||
"adopted principal": {existed: true, wantRevoked: false},
|
||||
"created principal": {existed: false, wantRevoked: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
stubWindowsProvisioning(t, testCase.existed, nil, nil, nil)
|
||||
|
||||
revoked := false
|
||||
prevGrant, prevRevoke := grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn
|
||||
t.Cleanup(func() {
|
||||
grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn = prevGrant, prevRevoke
|
||||
})
|
||||
// Fail the grant so the undo path runs with rights already attempted,
|
||||
// which is the state that used to revoke unconditionally.
|
||||
grantWindowsSandboxLogonRightsFn = func(*windows.SID) error {
|
||||
return errors.New("LSA grant refused by policy")
|
||||
}
|
||||
revokeWindowsSandboxLogonRightsFn = func(*windows.SID) error {
|
||||
revoked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
WorkspaceRoots: []string{`C:\ws`},
|
||||
}
|
||||
if _, _, err := provisionWindowsSandboxPrincipalForSetup(config, windowsSandboxRoleOffline); err == nil {
|
||||
t.Fatal("provisioning reported success despite an injected grant failure")
|
||||
}
|
||||
if revoked != testCase.wantRevoked {
|
||||
if testCase.wantRevoked {
|
||||
t.Fatal("rights were not revoked for an account this run created, leaving LSA entries keyed to a SID about to be deleted")
|
||||
}
|
||||
t.Fatal("rights were revoked for an adopted account; AllRights drops its pre-existing rights and deletes the LSA object")
|
||||
}
|
||||
// And the account itself: requested through the fake for the created
|
||||
// case, never for an adopted one. This fixture fakes creation, so the
|
||||
// only acceptable deletion is one the fake sees.
|
||||
removals := len(stubbedWindowsIdentityRemovals)
|
||||
if testCase.existed && removals != 0 {
|
||||
t.Fatalf("rollback deleted an adopted account: %v", stubbedWindowsIdentityRemovals)
|
||||
}
|
||||
if !testCase.existed && removals != 1 {
|
||||
t.Fatalf("rollback of a created account requested %d removals through the fake, want exactly one: %v", removals, stubbedWindowsIdentityRemovals)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A secret the current user cannot read is unavailability, not breakage.
|
||||
//
|
||||
// The secret's DACL names whoever ran setup. An operator who elevated with a
|
||||
// separate administrative account, via runas or an over-the-shoulder UAC
|
||||
// prompt, leaves a secret their ordinary account cannot open. Treating that as a
|
||||
// hard error made every sandboxed command fail on a machine that was merely set
|
||||
// up by a different admin; it belongs in the same fail-soft path as a missing
|
||||
// secret, so the warning fires and the restricted token takes over.
|
||||
func TestReadWindowsSandboxSecretTreatsPermissionDeniedAsUnavailable(t *testing.T) {
|
||||
previous := readWindowsSandboxSecretFile
|
||||
t.Cleanup(func() { readWindowsSandboxSecretFile = previous })
|
||||
readWindowsSandboxSecretFile = func(string) ([]byte, error) {
|
||||
return nil, &os.PathError{Op: "open", Path: "secret", Err: windows.ERROR_ACCESS_DENIED}
|
||||
}
|
||||
|
||||
if _, err := readWindowsSandboxSecret(`C:\anything.secret`); err != errWindowsSandboxIdentityUnavailable {
|
||||
t.Fatalf("permission-denied read returned %v, want errWindowsSandboxIdentityUnavailable so the command falls back", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace must not bind to another workspace's principal.
|
||||
//
|
||||
// The account name carries only 11 characters of the workspace digest, so two
|
||||
// workspaces can derive the same name. Provisioning refuses that case by
|
||||
// checking the full key in the account comment, but the command path resolved
|
||||
// the name straight to a SID. The workspace that lost the race would have failed
|
||||
// setup and then quietly run as the other one's principal, using its secret and
|
||||
// its ACL identity.
|
||||
func TestLookupWindowsSandboxIdentityRejectsForeignWorkspace(t *testing.T) {
|
||||
previous := windowsSandboxUserIsManagedFn
|
||||
t.Cleanup(func() { windowsSandboxUserIsManagedFn = previous })
|
||||
|
||||
prevSID := resolveWindowsSandboxSIDFn
|
||||
t.Cleanup(func() { resolveWindowsSandboxSIDFn = prevSID })
|
||||
// The account resolves; whether it BELONGS to this workspace is the question.
|
||||
resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) {
|
||||
return windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
}
|
||||
|
||||
var askedKey string
|
||||
windowsSandboxUserIsManagedFn = func(_ string, workspaceKey string) (bool, error) {
|
||||
askedKey = workspaceKey
|
||||
return false, nil
|
||||
}
|
||||
_, err := lookupWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
|
||||
if err == nil {
|
||||
t.Fatal("lookup accepted an account belonging to another workspace")
|
||||
}
|
||||
if errors.Is(err, errWindowsSandboxIdentityUnavailable) {
|
||||
t.Fatal("a foreign account must not read as unprovisioned; that would silently fall back instead of reporting the conflict")
|
||||
}
|
||||
if askedKey != "" && askedKey != "workspacekey" {
|
||||
t.Fatalf("ownership was checked against %q, want the caller's workspace key", askedKey)
|
||||
}
|
||||
}
|
||||
|
||||
// An account that does not exist must stay the unavailable sentinel rather than
|
||||
// becoming a collision error, since that is the ordinary not-set-up state.
|
||||
func TestLookupWindowsSandboxIdentityAbsentAccountIsUnavailable(t *testing.T) {
|
||||
previous := windowsSandboxUserIsManagedFn
|
||||
t.Cleanup(func() { windowsSandboxUserIsManagedFn = previous })
|
||||
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) {
|
||||
t.Fatal("ownership must not be consulted for an account that does not resolve")
|
||||
return false, nil
|
||||
}
|
||||
if _, err := lookupWindowsSandboxIdentity("nosuchworkspacekey", windowsSandboxRoleOffline); !errors.Is(err, errWindowsSandboxIdentityUnavailable) {
|
||||
t.Fatalf("absent account returned %v, want errWindowsSandboxIdentityUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The git control-plane carveouts must be materialized.
|
||||
//
|
||||
// gitMetadataWriteCarveouts supplies .git/config and .git/hooks as
|
||||
// ReadOnlySubpaths of the workspace. On a workspace where git has not run yet
|
||||
// they do not exist when setup applies the plan, and applyWindowsACLPlan skips
|
||||
// an absent target, so the deny ACEs were never written. Once git created those
|
||||
// paths the principal still held inherited write access and could install a
|
||||
// hook or rewrite credential.helper.
|
||||
func TestPrincipalACLPlanMaterializesReadOnlySubpaths(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "workspace")
|
||||
carveout := filepath.Join(root, ".git", "config")
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-0-0-0-1001",
|
||||
WriteRoots: []WritableRoot{{Root: root, ReadOnlySubpaths: []string{carveout}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyWrite, carveout)
|
||||
if !ok {
|
||||
t.Fatalf("no deny-write ACE for the git carveout; plan = %+v", plan.Entries)
|
||||
}
|
||||
if !entry.Materialize {
|
||||
t.Fatal("git carveout deny-write is not materialized, so it is skipped on a workspace where .git does not exist yet")
|
||||
}
|
||||
}
|
||||
|
||||
// Setup must grant the principal the same runtime root that commands write to.
|
||||
//
|
||||
// permissionProfileWithRuntime appends this root to WriteRoots on every command
|
||||
// and redirects HOME, GOCACHE and npm_config_cache into it, but it lives under
|
||||
// the user cache rather than the workspace, so the profile setup sees never
|
||||
// contains it. A principal is a separate account with no rights there, so
|
||||
// without a grant every npm install or go build fails on a cache write.
|
||||
//
|
||||
// The assertion that matters is that the two derivations agree. If they drift,
|
||||
// setup writes the ACE on one directory while commands use another, and the
|
||||
// symptom is a bare ACCESS_DENIED with nothing pointing at the sandbox.
|
||||
func TestSetupGrantsTheRuntimeRootCommandsActuallyUse(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{
|
||||
WorkspaceRoots: []string{workspace},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("setupWindowsSandboxRuntimeRoot: %v", err)
|
||||
}
|
||||
if len(granted) == 0 {
|
||||
t.Fatal("no runtime root resolved for a configured workspace")
|
||||
}
|
||||
// EVERY granted candidate has to exist, not just the one this process would
|
||||
// pick: applyWindowsACLPlan fails the whole run on a target that is absent.
|
||||
for _, root := range granted {
|
||||
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
||||
t.Fatalf("runtime root %q was not created; applyWindowsACLPlan skips absent targets so the grant would no-op (stat err %v)", root, err)
|
||||
}
|
||||
}
|
||||
|
||||
// What a command would actually use.
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
if release != nil {
|
||||
defer release()
|
||||
}
|
||||
// The command's choice has to be one setup provisioned. Setup grants the whole
|
||||
// candidate set precisely because the choice is made per process, so the
|
||||
// contract is membership rather than equality.
|
||||
if !grantedRuntimeRootsCover(granted, runtimeState.Root) {
|
||||
t.Fatalf("setup granted %q but commands write to %q", granted, runtimeState.Root)
|
||||
}
|
||||
}
|
||||
|
||||
// grantedRuntimeRootsCover reports whether the root a command selected is one of
|
||||
// the roots setup granted an ACE on.
|
||||
func grantedRuntimeRootsCover(granted []string, selected string) bool {
|
||||
for _, root := range granted {
|
||||
if filepath.Clean(root) == filepath.Clean(selected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// No workspace root means nothing to grant, which is not an error.
|
||||
func TestSetupRuntimeRootWithoutWorkspaceIsNotAnError(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("no workspace root should not error: %v", err)
|
||||
}
|
||||
if len(granted) != 0 {
|
||||
t.Fatalf("granted %q with no workspace configured", granted)
|
||||
}
|
||||
}
|
||||
|
||||
// An account in a privileged group must not be adopted, even when name and
|
||||
// ownership comment say it is ours.
|
||||
//
|
||||
// Adoption resets the password and hands the account to the sandbox. If that
|
||||
// account is also in Administrators, the sandbox gains the rights the sandbox
|
||||
// exists to withhold: rewriting the ACLs confining it, reading the secret locked
|
||||
// to the invoking user, and stopping Zero.
|
||||
func TestProvisionWindowsSandboxIdentityRefusesPrivilegedAccount(t *testing.T) {
|
||||
stubWindowsProvisioning(t, true, nil, nil, nil)
|
||||
|
||||
previous := windowsSandboxUserIsPrivilegedFn
|
||||
t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous })
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return true, nil }
|
||||
|
||||
_, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
|
||||
if !errors.Is(err, errWindowsSandboxPrivilegedAccount) {
|
||||
t.Fatalf("provisioning adopted a privileged account, err = %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Fatal("created must stay false for an account this run refused to adopt")
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary adopted account is unaffected.
|
||||
func TestProvisionWindowsSandboxIdentityAdoptsUnprivilegedAccount(t *testing.T) {
|
||||
stubWindowsProvisioning(t, true, nil, nil, nil)
|
||||
|
||||
previous := windowsSandboxUserIsPrivilegedFn
|
||||
t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous })
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil }
|
||||
|
||||
if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil {
|
||||
t.Fatalf("an unprivileged managed account must still be adopted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Group membership is not frozen at setup. An account provisioned clean can be
|
||||
// added to Administrators afterwards — by an operator, or by an attacker who
|
||||
// already has that access and wants the sandbox to hand it back. Provisioning's
|
||||
// refusal cannot see that; only the path that mints the token can.
|
||||
func TestLookupPrincipalForCommandRefusesAnAccountThatBecamePrivileged(t *testing.T) {
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWellKnownSid: %v", err)
|
||||
}
|
||||
restoreLookupSeams(t, sid)
|
||||
|
||||
privilegedCalls := 0
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) {
|
||||
privilegedCalls++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
_, err = lookupWindowsSandboxPrincipalForCommand("workspace-key", windowsSandboxRoleOnline)
|
||||
if !errors.Is(err, errWindowsSandboxPrivilegedAccount) {
|
||||
t.Fatalf("err = %v, want errWindowsSandboxPrivilegedAccount", err)
|
||||
}
|
||||
if privilegedCalls != 1 {
|
||||
t.Errorf("privileged check ran %d times, want exactly 1", privilegedCalls)
|
||||
}
|
||||
// It must be a hard refusal, not the unavailable sentinel — that one is the
|
||||
// quiet "not provisioned" fallback and would silently drop the sandbox back
|
||||
// to the restricted token instead of telling the operator.
|
||||
if errors.Is(err, errWindowsSandboxIdentityUnavailable) {
|
||||
t.Error("privileged refusal must not read as the not-provisioned fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupPrincipalForCommandAcceptsAnUnprivilegedAccount(t *testing.T) {
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWellKnownSid: %v", err)
|
||||
}
|
||||
restoreLookupSeams(t, sid)
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil }
|
||||
|
||||
identity, err := lookupWindowsSandboxPrincipalForCommand("workspace-key", windowsSandboxRoleOnline)
|
||||
if err != nil {
|
||||
t.Fatalf("lookupWindowsSandboxPrincipalForCommand: %v", err)
|
||||
}
|
||||
if identity.Username == "" {
|
||||
t.Error("expected the resolved principal")
|
||||
}
|
||||
}
|
||||
|
||||
// Teardown must stay able to clean up an account that has become privileged.
|
||||
// If the refusal lived inside lookupWindowsSandboxIdentity, the account this
|
||||
// guard exists to catch would become undeletable by Zero.
|
||||
func TestLookupIdentityItselfDoesNotConsultPrivilege(t *testing.T) {
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWellKnownSid: %v", err)
|
||||
}
|
||||
restoreLookupSeams(t, sid)
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) {
|
||||
t.Error("teardown's lookup must not be gated on privilege")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if _, err := lookupWindowsSandboxIdentity("workspace-key", windowsSandboxRoleOnline); err != nil {
|
||||
t.Fatalf("lookupWindowsSandboxIdentity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreLookupSeams(t *testing.T, sid *windows.SID) {
|
||||
t.Helper()
|
||||
prevResolve := resolveWindowsSandboxSIDFn
|
||||
prevManaged := windowsSandboxUserIsManagedFn
|
||||
prevPrivileged := windowsSandboxUserIsPrivilegedFn
|
||||
t.Cleanup(func() {
|
||||
resolveWindowsSandboxSIDFn = prevResolve
|
||||
windowsSandboxUserIsManagedFn = prevManaged
|
||||
windowsSandboxUserIsPrivilegedFn = prevPrivileged
|
||||
})
|
||||
resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) { return sid, nil }
|
||||
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil }
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// stubWindowsProvisioning replaces the four provisioning syscalls so the
|
||||
// function can run on an ordinary machine. Every one of them needs an elevated
|
||||
// caller and a real local account, so without this the test would stop at the
|
||||
// first call and never reach the behaviour it is named for.
|
||||
func stubWindowsProvisioning(t *testing.T, existed bool, groupErr error, offlineErr error, sidErr error) {
|
||||
t.Helper()
|
||||
prevGroup, prevUser := ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn
|
||||
prevOfflineGroup := ensureWindowsSandboxOfflineGroupFn
|
||||
prevAdd, prevSID := addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn
|
||||
prevManaged := windowsSandboxUserIsManagedFn
|
||||
prevOffline := addWindowsSandboxUserToOfflineGroupFn
|
||||
prevReset := resetWindowsSandboxUserPasswordFn
|
||||
prevRemove, prevUpgrade := removeWindowsSandboxIdentityFn, upgradeWindowsSandboxUserCommentFn
|
||||
stubbedWindowsIdentityRemovals = nil
|
||||
t.Cleanup(func() {
|
||||
ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn = prevGroup, prevUser
|
||||
ensureWindowsSandboxOfflineGroupFn = prevOfflineGroup
|
||||
addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn = prevAdd, prevSID
|
||||
windowsSandboxUserIsManagedFn = prevManaged
|
||||
addWindowsSandboxUserToOfflineGroupFn = prevOffline
|
||||
resetWindowsSandboxUserPasswordFn = prevReset
|
||||
removeWindowsSandboxIdentityFn, upgradeWindowsSandboxUserCommentFn = prevRemove, prevUpgrade
|
||||
})
|
||||
|
||||
// THE INVERSE IS FAKED TOO. Creation is faked and ownership is faked, so a
|
||||
// rollback branch that trusts both would otherwise call the real NetUserDel
|
||||
// on the current user's deterministic principal for C:ws, an account this
|
||||
// fixture never created. Removals are recorded for the tests to assert on.
|
||||
removeWindowsSandboxIdentityFn = func(username, workspaceKey string) error {
|
||||
stubbedWindowsIdentityRemovals = append(stubbedWindowsIdentityRemovals, username+"|"+workspaceKey)
|
||||
return nil
|
||||
}
|
||||
upgradeWindowsSandboxUserCommentFn = func(string, string) error { return nil }
|
||||
ensureWindowsSandboxGroupFn = func() error { return nil }
|
||||
ensureWindowsSandboxOfflineGroupFn = func() error { return nil }
|
||||
// Adopted accounts are ours in these tests; the ownership check is a real
|
||||
// syscall and would otherwise refuse before the code under test runs.
|
||||
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil }
|
||||
// Stubbed even though provisioning no longer rotates: rotation moved to the
|
||||
// caller, so nothing under test reaches this today. Left in so that if it
|
||||
// ever moves back, these tests fail rather than resetting the password of a
|
||||
// real managed account that happens to exist on the machine running them.
|
||||
resetWindowsSandboxUserPasswordFn = func(string, string) error {
|
||||
t.Fatal("provisioning must not rotate an account password; rotation belongs to the caller, immediately before the secret is written")
|
||||
return nil
|
||||
}
|
||||
ensureWindowsSandboxUserFn = func(string, string, string) (bool, error) { return existed, nil }
|
||||
addWindowsSandboxUserToGroupFn = func(string) error { return groupErr }
|
||||
addWindowsSandboxUserToOfflineGroupFn = func(string) error { return offlineErr }
|
||||
resolveWindowsSandboxSIDFn = func(username string) (*windows.SID, error) {
|
||||
if sidErr != nil {
|
||||
return nil, sidErr
|
||||
}
|
||||
return windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
}
|
||||
}
|
||||
|
||||
// A failure after the account has been created must still hand back the name.
|
||||
//
|
||||
// The caller's rollback deletes by identity.Username, so returning a zero
|
||||
// identity alongside created=true asked it to delete "" and quietly left the
|
||||
// account this run had just made. Group attachment is the case that matters
|
||||
// most: it is the enforcement boundary and it can fail under local policy.
|
||||
func TestProvisionWindowsSandboxIdentityReturnsNameForRollback(t *testing.T) {
|
||||
groupFailure := errors.New("group attachment refused by policy")
|
||||
offlineFailure := errors.New("offline group attachment refused by policy")
|
||||
sidFailure := errors.New("sid lookup failed")
|
||||
|
||||
for name, testCase := range map[string]struct {
|
||||
groupErr error
|
||||
offlineErr error
|
||||
sidErr error
|
||||
}{
|
||||
"group attachment fails": {groupErr: groupFailure},
|
||||
"offline group attachment fails": {offlineErr: offlineFailure},
|
||||
"sid resolution fails": {sidErr: sidFailure},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
stubWindowsProvisioning(t, false, testCase.groupErr, testCase.offlineErr, testCase.sidErr)
|
||||
|
||||
identity, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
|
||||
if err == nil {
|
||||
t.Fatal("provisioning reported success despite an injected failure")
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("created = false, so the rollback would skip an account this run made")
|
||||
}
|
||||
// The whole point: without a name there is nothing to delete.
|
||||
if identity.Username == "" {
|
||||
t.Fatal("identity carries no username, so the rollback deletes \"\" and strands the account")
|
||||
}
|
||||
if want := windowsSandboxUserName("workspacekey", windowsSandboxRoleOffline); identity.Username != want {
|
||||
t.Fatalf("username = %q, want %q", identity.Username, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An account that already existed must not be deleted because a later step
|
||||
// failed. created=false is what stops the rollback turning a partial failure
|
||||
// into the loss of a working principal from an earlier setup.
|
||||
func TestProvisionWindowsSandboxIdentityDoesNotClaimPreexistingAccount(t *testing.T) {
|
||||
stubWindowsProvisioning(t, true, errors.New("group attachment refused"), nil, nil)
|
||||
|
||||
_, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
|
||||
if err == nil {
|
||||
t.Fatal("provisioning reported success despite an injected failure")
|
||||
}
|
||||
// created is the whole assertion. It is what stops the rollback deleting an
|
||||
// account it did not make, and it must stay false however provisioning
|
||||
// fails. The identity is deliberately not checked: an adopted account exits
|
||||
// early at the ownership check, which is a real syscall and not stubbed
|
||||
// here, so asserting on the name would be testing the stub rather than the
|
||||
// contract.
|
||||
if created {
|
||||
t.Fatal("created = true for an account this run did not create; rollback would delete a working principal")
|
||||
}
|
||||
}
|
||||
|
||||
// The write grant has to include delete.
|
||||
//
|
||||
// FILE_GENERIC_WRITE covers creating and modifying but not removing or
|
||||
// renaming, and a rename needs delete on the source. The old same-user token hid
|
||||
// this because the caller already held inherited rights on its own tree; a
|
||||
// principal is a separate account with none, so without these it can write files
|
||||
// it can never remove, which fails ordinary editing and most git operations
|
||||
// rather than an edge case.
|
||||
func TestWindowsACLAllowWriteGrantsDelete(t *testing.T) {
|
||||
mode, mask, err := windowsACLAccess(WindowsACLAllowWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsACLAccess: %v", err)
|
||||
}
|
||||
if mode != windows.GRANT_ACCESS {
|
||||
t.Fatalf("mode = %v, want GRANT_ACCESS", mode)
|
||||
}
|
||||
// Atomic bits only. FILE_GENERIC_READ and FILE_GENERIC_WRITE both carry
|
||||
// READ_CONTROL and SYNCHRONIZE, so testing a composite constant with & is
|
||||
// satisfied by any grant at all and proves nothing.
|
||||
for label, bit := range map[string]windows.ACCESS_MASK{
|
||||
"DELETE": windows.DELETE,
|
||||
"FILE_WRITE_DATA": windows.FILE_WRITE_DATA,
|
||||
} {
|
||||
if mask&bit == 0 {
|
||||
t.Errorf("write grant is missing %s", label)
|
||||
}
|
||||
}
|
||||
// Granting these would let the principal rewrite the very restrictions
|
||||
// placed on it. They are in the deny mask for that reason and must not
|
||||
// appear here.
|
||||
//
|
||||
// FILE_DELETE_CHILD belongs in this set and was originally in the one
|
||||
// above, on the reasoning that the grant should mirror the deny mask. On a
|
||||
// parent it authorises deleting a child whatever the child's own DACL says,
|
||||
// so on a write root it hands back the write-denied carve-outs underneath:
|
||||
// delete .git/config, recreate it, and the replacement inherits the grant
|
||||
// with no deny of its own. Mirroring the deny mask is the wrong instinct —
|
||||
// denying a capability is not a reason to grant it.
|
||||
for label, bit := range map[string]windows.ACCESS_MASK{
|
||||
"WRITE_DAC": windows.WRITE_DAC,
|
||||
"WRITE_OWNER": windows.WRITE_OWNER,
|
||||
"FILE_DELETE_CHILD": windowsFileDeleteChild,
|
||||
} {
|
||||
if mask&bit != 0 {
|
||||
t.Errorf("write grant unexpectedly includes %s", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The read grant must stay read-only. Widening the write mask above is only
|
||||
// safe if this one did not move with it.
|
||||
func TestWindowsACLAllowReadGrantsNoDelete(t *testing.T) {
|
||||
_, mask, err := windowsACLAccess(WindowsACLAllowRead)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsACLAccess: %v", err)
|
||||
}
|
||||
// Atomic bits, for the same reason as above: the read and write composites
|
||||
// overlap on the standard rights, so a composite check here would report a
|
||||
// failure that is not real.
|
||||
for label, bit := range map[string]windows.ACCESS_MASK{
|
||||
"DELETE": windows.DELETE,
|
||||
"FILE_DELETE_CHILD": windowsFileDeleteChild,
|
||||
"FILE_WRITE_DATA": windows.FILE_WRITE_DATA,
|
||||
} {
|
||||
if mask&bit != 0 {
|
||||
t.Errorf("read grant unexpectedly includes %s", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stubbedWindowsIdentityRemovals records every account deletion the mocked
|
||||
// provisioning fixture was asked for, newest last, as "username|workspaceKey".
|
||||
var stubbedWindowsIdentityRemovals []string
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,292 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An opted-in command that ends up on the restricted token anyway must say so.
|
||||
// Both cases below are correct fallbacks, not errors — but silence leaves the
|
||||
// operator believing an account boundary is isolating them when it is not, which
|
||||
// is the same failure the setup-protocol opt-in check exists to prevent, reached
|
||||
// from the other side. The deny case matters most: deny is the DEFAULT network
|
||||
// mode, so a fully provisioned, fully agreeing setup still never uses the
|
||||
// principal for an ordinary command.
|
||||
func TestWindowsSandboxPrincipalFallbackIsAnnounced(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
mode NetworkMode
|
||||
reason string
|
||||
}{
|
||||
// The network-deny case is deliberately absent. It used to warn here, but
|
||||
// this runner is re-exec'd per command, so the sync.Once guarding the notice
|
||||
// is once per COMMAND — and deny is the default mode, so the warning landed
|
||||
// on nearly every tool call. That fact belongs to `zero doctor` now, which is
|
||||
// read once. The deny-mode BEHAVIOUR is still pinned, by
|
||||
// TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied below.
|
||||
{name: "no principal provisioned on this machine", mode: NetworkAllow, reason: "no sandbox principal is provisioned"},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var warned []string
|
||||
originalWarn := warnWindowsSandboxPrincipalNotUsed
|
||||
warnWindowsSandboxPrincipalNotUsed = func(reason string) { warned = append(warned, reason) }
|
||||
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
|
||||
t.Cleanup(func() {
|
||||
warnWindowsSandboxPrincipalNotUsed = originalWarn
|
||||
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
|
||||
})
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}},
|
||||
Network: NetworkPolicy{Mode: testCase.mode},
|
||||
},
|
||||
Env: map[string]string{windowsSandboxIdentityEnv: "1"},
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
Command: []string{"cmd.exe", "/c", "echo"},
|
||||
}
|
||||
// Assert the precondition rather than assume it: this must be the quiet
|
||||
// fallback path, not a token this host actually minted and not an error.
|
||||
token, ok, err := windowsSandboxPrincipalToken(config)
|
||||
if ok {
|
||||
token.Close()
|
||||
t.Fatalf("host unexpectedly provisioned a principal; this test cannot measure the fallback")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("windowsSandboxPrincipalToken error = %v, want the quiet fallback", err)
|
||||
}
|
||||
if len(warned) != 1 {
|
||||
t.Fatalf("opted-in fallback warnings = %v, want exactly one naming %q", warned, testCase.reason)
|
||||
}
|
||||
if !strings.Contains(warned[0], testCase.reason) {
|
||||
t.Fatalf("warning = %q, want it to name %q", warned[0], testCase.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The opt-out must stay silent, or the warning becomes noise every user learns
|
||||
// to ignore.
|
||||
func TestWindowsSandboxPrincipalFallbackIsSilentWhenOptedOut(t *testing.T) {
|
||||
var warned []string
|
||||
originalWarn := warnWindowsSandboxPrincipalNotUsed
|
||||
warnWindowsSandboxPrincipalNotUsed = func(reason string) { warned = append(warned, reason) }
|
||||
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
|
||||
t.Cleanup(func() {
|
||||
warnWindowsSandboxPrincipalNotUsed = originalWarn
|
||||
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
|
||||
})
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
Env: map[string]string{windowsSandboxIdentityEnv: "0"},
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
Command: []string{"cmd.exe", "/c", "echo"},
|
||||
}
|
||||
if _, ok, err := windowsSandboxPrincipalToken(config); ok || err != nil {
|
||||
t.Fatalf("windowsSandboxPrincipalToken ok=%v err=%v, want the quiet opted-out fallback", ok, err)
|
||||
}
|
||||
if len(warned) != 0 {
|
||||
t.Fatalf("opted-out command warned %v, want silence", warned)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup must stay inert unless the principal backend is explicitly opted into.
|
||||
// This is the property that makes the branch safe to merge while the privileged
|
||||
// paths are still being validated: without the opt-in, `zero sandbox setup`
|
||||
// creates no local account and the capability-SID backend is the whole of setup.
|
||||
func TestWindowsSandboxIdentityGating(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
env map[string]string
|
||||
want bool
|
||||
}{
|
||||
// An explicit map entry is authoritative; these cases never reach the
|
||||
// process environment.
|
||||
"empty": {env: map[string]string{windowsSandboxIdentityEnv: ""}, want: false},
|
||||
"zero": {env: map[string]string{windowsSandboxIdentityEnv: "0"}, want: false},
|
||||
"true not one": {env: map[string]string{windowsSandboxIdentityEnv: "true"}, want: false},
|
||||
"one": {env: map[string]string{windowsSandboxIdentityEnv: "1"}, want: true},
|
||||
"one with space": {env: map[string]string{windowsSandboxIdentityEnv: " 1 "}, want: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Pin the process variable too. Every case here supplies an explicit
|
||||
// map entry so none of them should consult it, and pinning proves
|
||||
// that rather than assuming it: without this a developer who exports
|
||||
// the opt-in would see different results from CI.
|
||||
t.Setenv(windowsSandboxIdentityEnv, "1")
|
||||
if got := windowsSandboxIdentityEnabled(testCase.env); got != testCase.want {
|
||||
t.Fatalf("enabled = %v, want %v for %q", got, testCase.want, testCase.env[windowsSandboxIdentityEnv])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// With no map entry the process environment decides. That fallback is what the
|
||||
// elevated setup path actually runs on — it passes no Env — so it needs its own
|
||||
// coverage rather than riding on a case that also has a map entry.
|
||||
func TestWindowsSandboxIdentityGatingFallsBackToTheProcessEnvironment(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
value string
|
||||
set bool
|
||||
want bool
|
||||
}{
|
||||
"unset": {set: false, want: false},
|
||||
"empty": {value: "", set: true, want: false},
|
||||
"zero": {value: "0", set: true, want: false},
|
||||
"one": {value: "1", set: true, want: true},
|
||||
"one with space": {value: " 1 ", set: true, want: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// t.Setenv registers the restore even when the variable is then
|
||||
// cleared, which is the only way to test a genuinely absent variable
|
||||
// without leaking that state into the rest of the package.
|
||||
t.Setenv(windowsSandboxIdentityEnv, testCase.value)
|
||||
if !testCase.set {
|
||||
if err := os.Unsetenv(windowsSandboxIdentityEnv); err != nil {
|
||||
t.Fatalf("unset %s: %v", windowsSandboxIdentityEnv, err)
|
||||
}
|
||||
}
|
||||
if got := windowsSandboxIdentityEnabled(nil); got != testCase.want {
|
||||
t.Fatalf("enabled = %v, want %v (set=%v value=%q)", got, testCase.want, testCase.set, testCase.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The command environment wins over the process environment, so a run can opt in
|
||||
// or out without depending on how the parent shell was launched.
|
||||
func TestWindowsSandboxIdentityEnvOverridesProcess(t *testing.T) {
|
||||
t.Setenv(windowsSandboxIdentityEnv, "1")
|
||||
if windowsSandboxIdentityEnabled(map[string]string{windowsSandboxIdentityEnv: "0"}) {
|
||||
t.Fatal("command env set to 0 must override a process env of 1")
|
||||
}
|
||||
if !windowsSandboxIdentityEnabled(map[string]string{}) {
|
||||
t.Fatal("with no command-env entry the process env should apply")
|
||||
}
|
||||
}
|
||||
|
||||
// One workspace maps to one principal, and different workspaces must not share
|
||||
// an account, or two projects would run under the same identity and could reach
|
||||
// each other's granted roots.
|
||||
func TestWindowsSandboxWorkspaceKeyIsStableAndDistinct(t *testing.T) {
|
||||
first := windowsSandboxWorkspaceKey([]string{`C:\ws\alpha`})
|
||||
again := windowsSandboxWorkspaceKey([]string{`C:\ws\alpha`})
|
||||
other := windowsSandboxWorkspaceKey([]string{`C:\ws\beta`})
|
||||
if first != again {
|
||||
t.Fatalf("key is not stable: %q vs %q", first, again)
|
||||
}
|
||||
if first == other {
|
||||
t.Fatal("two different workspaces produced the same principal key")
|
||||
}
|
||||
if first == "" {
|
||||
t.Fatal("empty key")
|
||||
}
|
||||
// An empty root list still has to yield a usable key rather than a blank one.
|
||||
if windowsSandboxWorkspaceKey(nil) == "" {
|
||||
t.Fatal("no workspace roots produced an empty key")
|
||||
}
|
||||
}
|
||||
|
||||
// Network denial is enforced by WFP filters keyed to the offline-marker SID,
|
||||
// which only the restricted token carries. A principal token would leave those
|
||||
// filters matching nothing, so the principal backend must stand down whenever
|
||||
// the network is denied rather than silently trading network enforcement for
|
||||
// read confinement.
|
||||
// The backend now runs under both network modes, so the opt-in is the only thing
|
||||
// eligibility turns on. The mode selects which principal is used, not whether one
|
||||
// is used at all.
|
||||
func TestPrincipalBackendEligibilityTurnsOnlyOnTheOptIn(t *testing.T) {
|
||||
eligible := func(mode NetworkMode, optIn string) bool {
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
WorkspaceRoots: []string{t.TempDir()},
|
||||
Env: map[string]string{windowsSandboxIdentityEnv: optIn},
|
||||
}
|
||||
config.PermissionProfile.Network.Mode = mode
|
||||
return windowsSandboxPrincipalEligible(config)
|
||||
}
|
||||
|
||||
for _, mode := range []NetworkMode{NetworkDeny, NetworkAllow} {
|
||||
if !eligible(mode, "1") {
|
||||
t.Fatalf("principal backend refused with network %q; the mode should choose a principal, not disable the backend", mode)
|
||||
}
|
||||
if eligible(mode, "0") {
|
||||
t.Fatalf("principal backend eligible without the opt-in for network %q", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The network mode is enforced by WHICH principal runs, because the offline one
|
||||
// is a member of the group the block filters match. Picking the wrong role is
|
||||
// therefore a silent loss of network enforcement, so the mapping is asserted
|
||||
// directly rather than inferred from a token that only exists on a provisioned
|
||||
// machine.
|
||||
func TestPrincipalRoleFollowsNetworkMode(t *testing.T) {
|
||||
if got := windowsSandboxRoleForNetwork(NetworkDeny); got != windowsSandboxRoleOffline {
|
||||
t.Fatalf("deny selected %q, want the offline principal; the online one is not in the blocked group and would have the network", got)
|
||||
}
|
||||
if got := windowsSandboxRoleForNetwork(NetworkAllow); got != windowsSandboxRoleOnline {
|
||||
t.Fatalf("allow selected %q, want the online principal", got)
|
||||
}
|
||||
// Fail closed. An unrecognised mode must lose the network rather than keep it,
|
||||
// so anything that is not an explicit allow maps to the offline principal.
|
||||
for _, mode := range []NetworkMode{NetworkMode(""), NetworkMode("bogus"), NetworkMode("ALLOW")} {
|
||||
if got := windowsSandboxRoleForNetwork(mode); got != windowsSandboxRoleOffline {
|
||||
t.Fatalf("unrecognised mode %q selected %q, want the offline principal so an unknown mode fails closed", mode, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The account key has to separate USERS, not only workspaces.
|
||||
//
|
||||
// The name is machine-wide while the DPAPI secret and the ACL ledger both live
|
||||
// under the invoking user's sandbox home. Keyed on the workspace alone, two
|
||||
// Windows users sharing one path derive the same account with private
|
||||
// bookkeeping: the second finds no ledger of its own and either retires the
|
||||
// first user's working principal or adopts it and rotates the password while
|
||||
// storing only its own secret. The first user's marker still validates and its
|
||||
// next command cannot log on.
|
||||
//
|
||||
// Asserting the principal key DIFFERS from the workspace key is what pins that.
|
||||
// It is the cheapest observable difference; a test that only checked the key was
|
||||
// stable would pass against the broken version.
|
||||
func TestPrincipalKeyIsScopedToTheUserNotOnlyTheWorkspace(t *testing.T) {
|
||||
roots := []string{`C:\ws\shared`}
|
||||
workspace := windowsSandboxWorkspaceKey(roots)
|
||||
config := WindowsSandboxCommandConfig{WorkspaceRoots: roots}
|
||||
principal := windowsSandboxPrincipalKey(config)
|
||||
|
||||
if principal == workspace {
|
||||
t.Fatal("the principal key equals the workspace key, so two users sharing this workspace would derive one account with separate secrets and ledgers")
|
||||
}
|
||||
if principal != windowsSandboxPrincipalKey(config) {
|
||||
t.Error("the principal key is not stable for one user, so a second setup would not find its own account")
|
||||
}
|
||||
if other := windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: []string{`C:\ws\other`}}); other == principal {
|
||||
t.Error("two workspaces derived the same principal key, so they would share one account")
|
||||
}
|
||||
}
|
||||
|
||||
// The setup lock must stay keyed to the WORKSPACE alone. Two users setting up
|
||||
// one shared workspace now provision separate accounts, but they still write
|
||||
// DACLs on the same paths, so they have to keep serializing against each other.
|
||||
func TestSetupLockStaysWorkspaceScoped(t *testing.T) {
|
||||
roots := []string{`C:\ws\shared`}
|
||||
if windowsSandboxWorkspaceKey(roots) == windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: roots}) {
|
||||
t.Fatal("the lock key and the principal key are the same, so making the account per-user also made the lock per-user")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// AND THE ANCESTOR IS PART OF THE PATH TOO.
|
||||
//
|
||||
// The sibling test puts the junction at the directory the secret is addressed
|
||||
// into, which the no-follow open of that directory refuses. This one puts it
|
||||
// ABOVE a component that does not exist yet, which is the case the refusal never
|
||||
// saw: os.MkdirAll walked the pathname, followed the junction, and created the
|
||||
// missing component on the other side of it as Administrator. The no-follow open
|
||||
// that came next then found a perfectly ordinary directory at the requested
|
||||
// pathname, because the caller-controlled redirection had already happened one
|
||||
// level up, and the elevated write landed outside the sandbox home.
|
||||
//
|
||||
// A junction rather than a symlink for the same reason as the sibling test: it
|
||||
// needs no privilege, so it is reachable by exactly the unprivileged user this
|
||||
// guards against.
|
||||
func TestWriteWindowsSandboxSecretRefusesAJunctionedAncestor(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := filepath.Join(base, "outside")
|
||||
if err := os.MkdirAll(outside, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// home is the junction. The secret is addressed two levels below it, and the
|
||||
// level in between does not exist, so it has to be created.
|
||||
home := filepath.Join(base, "home")
|
||||
makeJunction(t, home, outside)
|
||||
|
||||
path := filepath.Join(home, "identity", "zero-sbx-test.secret")
|
||||
err := writeWindowsSandboxSecret(path, "Zs1!EXAMPLEPASSWORDVALUE")
|
||||
if err == nil {
|
||||
t.Error("wrote the principal secret below a junctioned ancestor, so an elevated setup placed it in a tree the caller controls")
|
||||
}
|
||||
|
||||
// The refusal is only half of it: nothing may have been created on the other
|
||||
// side of the junction, because an elevated create there is a write primitive
|
||||
// at a path the caller chose even when no secret follows it.
|
||||
entries, readErr := os.ReadDir(outside)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := []string{}
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Errorf("the junction target gained %v, so an elevated create followed the ancestor", names)
|
||||
}
|
||||
}
|
||||
|
||||
// The same shape for the runtime root, which is the other elevated pathname
|
||||
// create in this setup path. Its candidates sit under the invoking user's own
|
||||
// cache directory, which is to say under a tree the party this sandbox contains
|
||||
// can rearrange before elevated setup runs.
|
||||
func TestSetupWindowsSandboxRuntimeRootRefusesAJunctionedAncestor(t *testing.T) {
|
||||
isolateSandboxRuntimeRoots(t)
|
||||
cache := t.TempDir()
|
||||
t.Setenv("LOCALAPPDATA", cache)
|
||||
t.Setenv("XDG_CACHE_HOME", cache)
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := WindowsSandboxCommandConfig{WorkspaceRoots: []string{workspace}}
|
||||
|
||||
candidates := windowsSandboxRuntimeCandidates(config.WorkspaceRoots)
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("SETUP INVALID: no runtime root candidate for a configured workspace")
|
||||
}
|
||||
// PICKED BY EXCLUDING THE OTHER ONE, NOT BY PATH PREFIX. The second candidate
|
||||
// is the shared fallback under the system temp directory, which this test
|
||||
// must not junction. Selecting the cache-derived one by prefix looks
|
||||
// equivalent and is not: a CI runner spells one directory two ways
|
||||
// (RUNNER~1 against runneradmin), so os.UserCacheDir and t.TempDir disagree
|
||||
// on a path they both mean and the prefix matches nothing.
|
||||
fallback, _ := fallbackSandboxRuntimeRoot(workspace)
|
||||
cacheCandidate := ""
|
||||
for _, candidate := range candidates {
|
||||
if candidate != fallback {
|
||||
cacheCandidate = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if cacheCandidate == "" {
|
||||
t.Fatalf("SETUP INVALID: no cache-derived candidate among %v (fallback %q)", candidates, fallback)
|
||||
}
|
||||
|
||||
// The swap: the candidate's parent is a junction out of the cache tree, and
|
||||
// the candidate itself does not exist, so it has to be created.
|
||||
outside := filepath.Join(t.TempDir(), "outside")
|
||||
if err := os.MkdirAll(outside, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent := filepath.Dir(cacheCandidate)
|
||||
if err := os.MkdirAll(filepath.Dir(parent), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
makeJunction(t, parent, outside)
|
||||
|
||||
if _, err := setupWindowsSandboxRuntimeRoot(config); err == nil {
|
||||
t.Error("created the runtime root below a junctioned ancestor, as an elevated process, in a tree the caller controls")
|
||||
}
|
||||
|
||||
entries, readErr := os.ReadDir(outside)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := []string{}
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Errorf("the junction target gained %v, so an elevated create followed the ancestor", names)
|
||||
}
|
||||
}
|
||||
|
||||
// And an ordinary nested directory is still created, or the refusals above
|
||||
// prove only that these functions fail.
|
||||
func TestWriteWindowsSandboxSecretCreatesAMissingChain(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "home", "identity", "nested", "zero-sbx-test.secret")
|
||||
const password = "Zs1!EXAMPLEPASSWORDVALUE"
|
||||
if err := writeWindowsSandboxSecret(path, password); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, err := readWindowsSandboxSecret(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if got != password {
|
||||
t.Fatalf("read %q, want the stored password", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// CLEANUP HAS TO UNDO WHAT CREATE DID, ON THE SAME OBJECT.
|
||||
//
|
||||
// Everything between creating the secret file and writing it is handle-relative
|
||||
// and no-follow. The failure path was not: it called os.Remove on the pathname,
|
||||
// which is the one resolution the rest of the function exists to avoid, and it
|
||||
// did not even work. The leaf is created with FILE_SHARE_READ alone, so a delete
|
||||
// by name while that handle is open is a sharing violation; the error was
|
||||
// discarded, the directory unwind did not cover the file, and a failed
|
||||
// provisioning left an empty secret behind that a later run would read as a
|
||||
// stale password.
|
||||
func TestWriteWindowsSandboxSecretRemovesTheLeafWhenLockingFails(t *testing.T) {
|
||||
restore := failWindowsSecretLock(t, errors.New("SetSecurityInfo refused"))
|
||||
defer restore()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "cfg", "zero-sbx-test.secret")
|
||||
err := writeWindowsSandboxSecret(path, "Zs1!EXAMPLEPASSWORDVALUE")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("SETUP INVALID: the injected lock failure was not reported")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SetSecurityInfo refused") {
|
||||
t.Fatalf("err = %v, want the injected cause", err)
|
||||
}
|
||||
if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("the secret file survived a failed lock (stat: %v); a later run would read it as a stale password", statErr)
|
||||
}
|
||||
// The directory this attempt created goes too, which is the half that
|
||||
// already worked and must keep working.
|
||||
if _, statErr := os.Stat(filepath.Dir(path)); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Errorf("the directory chain this attempt created was left behind (stat: %v)", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// AND A CLEANUP THAT CANNOT FINISH SAYS SO. Leftover state after a failed
|
||||
// provisioning is the caller's business, and an empty secret file is exactly
|
||||
// what a later run would mistake for a stored password.
|
||||
//
|
||||
// The delete is injected rather than blocked for real. Making a Windows delete
|
||||
// fail means arranging a sharing conflict, and my first attempt at that held a
|
||||
// second handle on the leaf and did not block anything: the cleanup succeeded
|
||||
// and the test asserted its way to a false negative. A seam says what the code
|
||||
// does with a failure, which is the thing under test here.
|
||||
func TestWriteWindowsSandboxSecretReportsAFailedCleanup(t *testing.T) {
|
||||
restoreLock := failWindowsSecretLock(t, errors.New("SetSecurityInfo refused"))
|
||||
defer restoreLock()
|
||||
|
||||
previousDelete := deleteWindowsSecretLeafFn
|
||||
deleteWindowsSecretLeafFn = func(windows.Handle, string) error {
|
||||
return errors.New("STATUS_CANNOT_DELETE")
|
||||
}
|
||||
defer func() { deleteWindowsSecretLeafFn = previousDelete }()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "cfg", "zero-sbx-test.secret")
|
||||
err := writeWindowsSandboxSecret(path, "Zs1!EXAMPLEPASSWORDVALUE")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("SETUP INVALID: the injected lock failure was not reported")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SetSecurityInfo refused") {
|
||||
t.Errorf("err = %v, want the original cause preserved", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "remove partially written secret") {
|
||||
t.Errorf("err = %v, want the failed cleanup reported rather than discarded", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "STATUS_CANNOT_DELETE") {
|
||||
t.Errorf("err = %v, want the delete failure carried too", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary path is unchanged, or the two refusals above would prove only
|
||||
// that this function fails.
|
||||
func TestWriteWindowsSandboxSecretStillStoresAndReadsBack(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cfg", "zero-sbx-test.secret")
|
||||
const password = "Zs1!EXAMPLEPASSWORDVALUE"
|
||||
if err := writeWindowsSandboxSecret(path, password); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, err := readWindowsSandboxSecret(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if got != password {
|
||||
t.Fatalf("read %q, want the stored password", got)
|
||||
}
|
||||
}
|
||||
|
||||
// failWindowsSecretLock makes the DACL step fail for one test.
|
||||
func failWindowsSecretLock(t *testing.T, cause error) func() {
|
||||
t.Helper()
|
||||
previous := lockWindowsSecretHandleToOwnerFn
|
||||
lockWindowsSecretHandleToOwnerFn = func(windows.Handle, *windows.SID) error { return cause }
|
||||
return func() { lockWindowsSecretHandleToOwnerFn = previous }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// createWindowsSecretFileNoFollow creates (or replaces) the secret file as a
|
||||
// CHILD of a pinned parent handle and returns a handle to it.
|
||||
//
|
||||
// The pathname version this replaces resolved the path four separate times in an
|
||||
// elevated process: MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then
|
||||
// WriteFile by name. The sandbox home belongs to the invoking user, who is the
|
||||
// party this sandbox exists to contain, so each of those resolutions was a place
|
||||
// to swap a component. Worst case an attacker-chosen file gets truncated and then
|
||||
// has its DACL rewritten by an Administrator; the milder junction-only case still
|
||||
// plants the deterministic secret somewhere the caller then owns.
|
||||
//
|
||||
// One handle, one resolution. The parent chain is built no-follow by the caller
|
||||
// and its deepest handle is passed in here, the leaf is created relative to that
|
||||
// handle so its name can never be resolved again, and both the DACL and the
|
||||
// bytes are applied to the handle rather than to a path.
|
||||
//
|
||||
// FILE_OVERWRITE_IF, not FILE_CREATE: an existing secret must be replaced, since
|
||||
// a stale password makes LogonUser fail in a way that reads as a sandbox bug.
|
||||
// FILE_OPEN_REPARSE_POINT means a leaf that IS a reparse point comes back as
|
||||
// itself rather than being followed, and the attribute check below then refuses
|
||||
// it instead of writing through it.
|
||||
func createWindowsSecretFileAt(parent windows.Handle, name string) (windows.Handle, error) {
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return 0, fmt.Errorf("secret file name: %w", err)
|
||||
}
|
||||
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode secret file name %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
var handle windows.Handle
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
// WRITE_DAC to lock it down and FILE_WRITE_DATA to fill it, both through
|
||||
// this one handle. READ_CONTROL because SetSecurityInfo reads the existing
|
||||
// descriptor before replacing it, and FILE_READ_ATTRIBUTES because the
|
||||
// reparse-point check below asks the handle what it landed on.
|
||||
windows.FILE_WRITE_DATA|windows.FILE_READ_ATTRIBUTES|windows.WRITE_DAC|windows.READ_CONTROL|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
windows.FILE_SHARE_READ,
|
||||
windows.FILE_OVERWRITE_IF,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf("create secret file %s: %w", name, err)
|
||||
}
|
||||
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// lockWindowsSecretHandleToOwner applies the owner-and-SYSTEM-only DACL to an
|
||||
// open handle instead of to a pathname, so the object being locked is provably
|
||||
// the object that was just created rather than whatever the name resolves to now.
|
||||
func lockWindowsSecretHandleToOwner(handle windows.Handle, owner *windows.SID) error {
|
||||
acl, err := windowsSecretOwnerACL(owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := windows.SetSecurityInfo(
|
||||
handle,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
acl,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fmt.Errorf("lock secret to owner: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeWindowsSecretHandle writes the whole payload through the handle.
|
||||
func writeWindowsSecretHandle(handle windows.Handle, payload []byte) error {
|
||||
for written := 0; written < len(payload); {
|
||||
var n uint32
|
||||
if err := windows.WriteFile(handle, payload[written:], &n, nil); err != nil {
|
||||
return fmt.Errorf("write secret: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("write secret: wrote no bytes with %d remaining", len(payload)-written)
|
||||
}
|
||||
written += int(n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE SECRET MUST NOT BE WRITTEN THROUGH A REPARSE POINT.
|
||||
//
|
||||
// The sandbox home belongs to the invoking user, who is the party this sandbox
|
||||
// exists to contain, so they can put a junction where the secret directory is
|
||||
// expected before elevated setup runs. The pathname version resolved the path
|
||||
// four times over (MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then
|
||||
// WriteFile by name) and every one of them followed it, so an Administrator
|
||||
// process created the principal's password in a directory of the caller's
|
||||
// choosing and then granted them control of it.
|
||||
//
|
||||
// A junction rather than a symlink on purpose: it needs no privilege, so it is
|
||||
// reachable by exactly the unprivileged user this guards against. os.Symlink
|
||||
// would need SeCreateSymbolicLinkPrivilege and would skip on an ordinary
|
||||
// developer or CI account, which is where this most needs to run.
|
||||
func TestWriteWindowsSandboxSecretRefusesAJunctionedDirectory(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := filepath.Join(base, "outside")
|
||||
if err := os.MkdirAll(outside, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
// The swap: the directory the secret is addressed into is a junction that
|
||||
// leads out of the sandbox home. The pathname never changes.
|
||||
cfg := filepath.Join(base, "cfg")
|
||||
makeJunction(t, cfg, outside)
|
||||
|
||||
path := filepath.Join(cfg, "zero-sbx-test.secret")
|
||||
err := writeWindowsSandboxSecret(path, "Zs1!EXAMPLEPASSWORDVALUE")
|
||||
if err == nil {
|
||||
t.Fatal("wrote the principal secret through a junction, so an elevated setup would have placed it in a directory the caller controls")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse") {
|
||||
t.Fatalf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
// Refusing is only half of it. Nothing may survive on the other side, or the
|
||||
// caller still ends up holding a file the sandbox created for them.
|
||||
entries, readErr := os.ReadDir(outside)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadDir: %v", readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("junction target holds %d entries after the refusal, want none", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary path must still work, or the refusal above proves only that the
|
||||
// function fails.
|
||||
func TestWriteWindowsSandboxSecretStillWritesAnOrdinaryDirectory(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cfg", "zero-sbx-test.secret")
|
||||
const password = "Zs1!EXAMPLEPASSWORDVALUE"
|
||||
if err := writeWindowsSandboxSecret(path, password); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, err := readWindowsSandboxSecret(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if got != password {
|
||||
t.Fatalf("read %q, want the stored password", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// Storing a sandbox principal's password.
|
||||
//
|
||||
// The elevated setup path provisions the account, but the per-command path runs
|
||||
// UNELEVATED and needs the password to call LogonUser. So the secret has to
|
||||
// cross that boundary on disk, and the only thing standing between it and the
|
||||
// sandboxed child is the file's ACL.
|
||||
//
|
||||
// The file is locked to the invoking user: an explicit, INHERITANCE-PROTECTED
|
||||
// DACL granting that user and SYSTEM, and nobody else. The sandbox principal is
|
||||
// deliberately absent from it, which is the property that matters, because a
|
||||
// principal that could read this file could mint its own token and the whole
|
||||
// identity boundary would be decorative. Administrators are not added either;
|
||||
// an admin can already take ownership, so naming them buys nothing and widens
|
||||
// the visible grant.
|
||||
//
|
||||
// Ordering is load-bearing: the ACL is applied to an EMPTY file before the
|
||||
// password is written, so the bytes never exist under the directory's inherited
|
||||
// permissions even briefly.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// windowsSandboxSecretDirName holds per-principal secrets under the Zero config
|
||||
// directory. Kept in its own directory so the whole set can be removed when the
|
||||
// sandbox is torn down.
|
||||
const windowsSandboxSecretDirName = "windows-sandbox"
|
||||
|
||||
// windowsSandboxSecretPath returns where a principal's password lives. The
|
||||
// account name is already sanitised to [a-z0-9-] by windowsSandboxUserName, so
|
||||
// it cannot escape the directory.
|
||||
func windowsSandboxSecretPath(configDir string, username string) (string, error) {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", errors.New("windows sandbox secret: empty config directory")
|
||||
}
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return "", errors.New("windows sandbox secret: empty principal name")
|
||||
}
|
||||
// Defence in depth against a caller passing something windowsSandboxUserName
|
||||
// did not produce: refuse anything with a separator or a parent reference.
|
||||
if strings.ContainsAny(username, `\/:`) || strings.Contains(username, "..") {
|
||||
return "", fmt.Errorf("windows sandbox secret: unsafe principal name %q", username)
|
||||
}
|
||||
return filepath.Join(configDir, windowsSandboxSecretDirName, username+".secret"), nil
|
||||
}
|
||||
|
||||
// currentTokenUserSID returns the SID of the user this process runs as. Under
|
||||
// UAC the elevated token keeps the same user SID as the desktop session, so
|
||||
// setup and the later unelevated command path agree on the owner, which is what
|
||||
// makes an owner-scoped ACL usable across the elevation boundary.
|
||||
func currentTokenUserSID() (*windows.SID, error) {
|
||||
var token windows.Token
|
||||
if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {
|
||||
return nil, fmt.Errorf("open process token: %w", err)
|
||||
}
|
||||
defer token.Close()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get token user: %w", err)
|
||||
}
|
||||
// The SID points into a buffer owned by the Tokenuser, so copy it out before
|
||||
// that buffer goes away.
|
||||
copied, err := user.User.Sid.Copy()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy token user SID: %w", err)
|
||||
}
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
// windowsSecretOwnerACL builds the explicit, inheritance-protected DACL granting
|
||||
// only owner and SYSTEM. PROTECTED is what drops any ACE inherited from the
|
||||
// config directory; without it a permissive parent would still grant access to
|
||||
// whoever it names.
|
||||
//
|
||||
// Returns the ACL rather than applying it, because the caller applies it to an
|
||||
// open HANDLE. Applying by pathname meant the object being locked was whatever
|
||||
// the name resolved to at that instant, which is not necessarily the object that
|
||||
// was just created.
|
||||
func windowsSecretOwnerACL(owner *windows.SID) (*windows.ACL, error) {
|
||||
if owner == nil {
|
||||
return nil, errors.New("windows sandbox secret: nil owner SID")
|
||||
}
|
||||
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve SYSTEM SID: %w", err)
|
||||
}
|
||||
entries := []windows.EXPLICIT_ACCESS{
|
||||
{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_USER,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(owner),
|
||||
},
|
||||
},
|
||||
{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_USER,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(system),
|
||||
},
|
||||
},
|
||||
}
|
||||
acl, err := windows.ACLFromEntries(entries, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build secret ACL: %w", err)
|
||||
}
|
||||
return acl, nil
|
||||
}
|
||||
|
||||
// writeWindowsSandboxSecret stores a principal's password readable only by the
|
||||
// invoking user.
|
||||
//
|
||||
// The file is created empty, locked down, and only then written, so the secret
|
||||
// is never on disk under the directory's inherited ACL. An existing file is
|
||||
// replaced rather than appended, since a stale password would make LogonUser
|
||||
// fail in a way that looks like a sandbox bug.
|
||||
func writeWindowsSandboxSecret(path string, password string) error {
|
||||
owner, err := currentTokenUserSID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// THE PARENT CHAIN IS PART OF THE RESOLUTION COUNT. os.MkdirAll walks a
|
||||
// pathname, so a junction planted on the way down pointed this elevated
|
||||
// create at a directory of the caller's choosing, and the no-follow open
|
||||
// below then refused a tree this process had already made outside the
|
||||
// sandbox home. The chain is built the same way the ACL targets are: from
|
||||
// the deepest existing ancestor, one component at a time relative to the
|
||||
// handle above it, with a record of what was created so a later failure
|
||||
// unwinds exactly that and nothing else.
|
||||
created, parent, err := makeWindowsACLDirChainNoFollow(filepath.Dir(path))
|
||||
if err != nil {
|
||||
if _, unwindErr := rollbackWindowsACLMaterialization(created); unwindErr != nil {
|
||||
err = errors.Join(err, unwindErr)
|
||||
}
|
||||
return fmt.Errorf("create secret directory: %w", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
unwindChain := func(cause error) error {
|
||||
if _, unwindErr := rollbackWindowsACLMaterialization(created); unwindErr != nil {
|
||||
return errors.Join(cause, unwindErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
// Sealed BEFORE the file exists, so a failure here leaves nothing on disk at
|
||||
// all rather than an empty file for someone else to win a race on.
|
||||
//
|
||||
// Encrypted to the invoking user on top of the ACL, so a copy taken outside
|
||||
// the filesystem's enforcement (backup, disk image) is inert. The principal
|
||||
// name is the entropy, which keeps one principal's blob from authenticating
|
||||
// another.
|
||||
sealed, err := protectWindowsSecret(password, windowsSandboxSecretEntropy(path))
|
||||
if err != nil {
|
||||
return unwindChain(err)
|
||||
}
|
||||
// ONE resolution of the path, not four. The parent chain above was built
|
||||
// relative to handles, the leaf is created relative to the deepest of them,
|
||||
// and the DACL and the bytes are both applied to the leaf handle. Resolving
|
||||
// the name again between any two of those steps is what let a junction or
|
||||
// symlink swap point an elevated write at a file of the caller's choosing.
|
||||
leaf := filepath.Base(path)
|
||||
handle, err := createWindowsSecretFileAt(parent, leaf)
|
||||
if err != nil {
|
||||
return unwindChain(err)
|
||||
}
|
||||
leafClosed := false
|
||||
closeLeaf := func() {
|
||||
if leafClosed {
|
||||
return
|
||||
}
|
||||
leafClosed = true
|
||||
_ = windows.CloseHandle(handle)
|
||||
}
|
||||
defer closeLeaf()
|
||||
// CLEANUP ACTS ON THE OBJECT THE CREATE MADE, NOT ON THE NAME AGAIN.
|
||||
//
|
||||
// This used to call os.Remove(path), which is the one pathname resolution
|
||||
// the rest of this function was written to remove, and it did not even
|
||||
// work: the leaf is created with FILE_SHARE_READ alone, so a delete by
|
||||
// name while this handle is open comes back as a sharing violation. The
|
||||
// error was discarded, unwindChain rolled back only the directories, and a
|
||||
// failed provisioning left an empty secret file it believed it had removed.
|
||||
//
|
||||
// The handle is closed first and the leaf is then deleted relative to the
|
||||
// pinned parent, the same way rollbackWindowsACLMaterialization removes
|
||||
// what it created. Closing first rather than widening the share mask keeps
|
||||
// the secret file unshareable for delete while it exists, which is the
|
||||
// narrower of the two ways to make this work.
|
||||
removeLeaf := func(cause error) error {
|
||||
closeLeaf()
|
||||
if deleteErr := deleteWindowsSecretLeafFn(parent, leaf); deleteErr != nil {
|
||||
// Reported, not discarded: a secret file left behind after a failed
|
||||
// provisioning is state the caller has to know about, and the empty
|
||||
// file is the thing a later run would read as a stale password.
|
||||
cause = errors.Join(cause, fmt.Errorf("remove partially written secret %s: %w", path, deleteErr))
|
||||
}
|
||||
return unwindChain(cause)
|
||||
}
|
||||
if err := lockWindowsSecretHandleToOwnerFn(handle, owner); err != nil {
|
||||
return removeLeaf(err)
|
||||
}
|
||||
if err := writeWindowsSecretHandle(handle, sealed); err != nil {
|
||||
return removeLeaf(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lockWindowsSecretHandleToOwnerFn is a seam for the one step between creating
|
||||
// the secret file and writing it, so the cleanup that runs when it fails is
|
||||
// reachable without an elevated machine or a way to make SetSecurityInfo fail.
|
||||
var lockWindowsSecretHandleToOwnerFn = lockWindowsSecretHandleToOwner
|
||||
|
||||
// deleteWindowsSecretLeafFn is the matching seam for the cleanup itself, so the
|
||||
// case where cleanup cannot finish is reachable without arranging a Windows
|
||||
// sharing conflict, which is not something a test can stage reliably.
|
||||
var deleteWindowsSecretLeafFn = deleteWindowsACLChildFile
|
||||
|
||||
// windowsSandboxSecretEntropy derives the DPAPI entropy from the secret's own
|
||||
// filename, which is the principal name. Deriving it rather than threading the
|
||||
// name through keeps read and write agreeing by construction.
|
||||
func windowsSandboxSecretEntropy(path string) string {
|
||||
return strings.TrimSuffix(filepath.Base(path), ".secret")
|
||||
}
|
||||
|
||||
// Seamed so the permission-denied mapping in readWindowsSandboxSecret is
|
||||
// testable. Producing a real ERROR_ACCESS_DENIED needs DACL surgery on Windows,
|
||||
// since a 0000 file is still readable and reading a directory reports
|
||||
// "Incorrect function", so a test built that way would exercise the platform
|
||||
// rather than the mapping.
|
||||
var readWindowsSandboxSecretFile = os.ReadFile
|
||||
|
||||
// readWindowsSandboxSecret loads a principal's password. A missing file means
|
||||
// setup has not run for this workspace, which the caller turns into a fallback
|
||||
// rather than a hard failure.
|
||||
func readWindowsSandboxSecret(path string) (string, error) {
|
||||
data, err := readWindowsSandboxSecretFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
// Permission denied is unavailability, not breakage. The secret's DACL
|
||||
// names whoever ran setup, so an operator who elevated with a separate
|
||||
// administrative account, through runas or an over-the-shoulder UAC
|
||||
// prompt, ends up with a secret their ordinary account cannot open. That
|
||||
// is the documented fail-soft case: fall back to the restricted token and
|
||||
// let the warning say so. Treating it as a hard error instead made every
|
||||
// sandboxed command fail on a machine that was merely set up by a
|
||||
// different admin, which is a common way to run an elevated setup.
|
||||
if os.IsPermission(err) {
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
return "", fmt.Errorf("read sandbox secret: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
secret, err := unprotectWindowsSecret(data, windowsSandboxSecretEntropy(path))
|
||||
if err != nil {
|
||||
// A blob written by another user, for another principal, or by an older
|
||||
// build that stored the password in the clear. Report it as unavailable so
|
||||
// the caller falls back to the restricted token; the next elevated setup
|
||||
// rewrites the secret in the current format.
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// removeWindowsSandboxSecret deletes a stored password. Called before the
|
||||
// account itself is removed so a secret never outlives the principal it
|
||||
// authenticates.
|
||||
// errWindowsSandboxSecretNotOurs reports a secret this account cannot delete
|
||||
// because another administrator's setup owns its DACL.
|
||||
//
|
||||
// Distinguishable so teardown can carry on. The alternative is what it used to
|
||||
// do: abort before removing the account, its logon rights, its ACEs and its
|
||||
// ledger, leaving a fully provisioned principal on the machine because one file
|
||||
// could not be unlinked.
|
||||
var errWindowsSandboxSecretNotOurs = errors.New("the sandbox secret belongs to a different administrator's setup")
|
||||
|
||||
func removeWindowsSandboxSecret(path string) error {
|
||||
err := os.Remove(path)
|
||||
if err == nil || os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
// The read path already treats permission-denied as unavailability rather
|
||||
// than breakage, for the reason documented on readWindowsSandboxSecret: an
|
||||
// operator who elevated with a separate administrative account gets a secret
|
||||
// their ordinary account cannot open. Removal has to agree. It did not, so on
|
||||
// exactly those machines teardown stopped at the first file and stranded
|
||||
// everything after it.
|
||||
if os.IsPermission(err) {
|
||||
return fmt.Errorf("%w: %s", errWindowsSandboxSecretNotOurs, path)
|
||||
}
|
||||
return fmt.Errorf("remove sandbox secret: %w", err)
|
||||
}
|
||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user