mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
* fix(sandbox): keep Windows restricted-token SIDs narrow and fail closed on DenyRead * fix(sandbox): address CodeRabbit review feedback on PR 640 * refactor(sandbox): Address CodeRabbit nitpicks on PR 640. Co-Authored-By: cairn-code <[email protected]> * fix(sandbox): skip inherited ACEs and reject DenyRead upfront during setup Skip inherited ACEs when preserving read deny entries, correct syntax in the command runner error message, and evaluate unsupported DenyRead profiles before the elevation check in Windows sandbox setup. Refs #640 * Reject unsupported DenyRead profiles upfront during sandbox setup Refs #640 * Migrate legacy Windows DenyWrite ACEs, randomize smoke probes, and clean dead descendant machinery. Refs #640 * Use /d /c for denied-write probe command and verify ownership on cleanup. Refs #640 * Honor NoInherit in legacy ACL migration and verify probe ownership. Refs #639 * Preserve DenyRead bits when migrating combined legacy deny ACEs. Refs #639 --------- Co-authored-by: euxaristia <[email protected]> Co-authored-by: cairn-code <[email protected]>
313 lines
11 KiB
Go
313 lines
11 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe"
|
|
|
|
const windowsSandboxSetupMarkerSchemaVersion = 5
|
|
|
|
type WindowsSandboxSetupArgsOptions struct {
|
|
SandboxHome string
|
|
CommandCWD string
|
|
WorkspaceRoots []string
|
|
PermissionProfile PermissionProfile
|
|
}
|
|
|
|
type WindowsSandboxSetupConfig struct {
|
|
SandboxHome string
|
|
CommandCWD string
|
|
WorkspaceRoots []string
|
|
PermissionProfile PermissionProfile
|
|
}
|
|
|
|
type WindowsSandboxSetupMarker struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
ACLPlanHash string `json:"aclPlanHash"`
|
|
ACLPlanEntries int `json:"aclPlanEntries"`
|
|
// NetworkInfraHash fingerprints the mode-INDEPENDENT network infrastructure
|
|
// setup provisioned (block filters scoped to the offline-marker SID), so one
|
|
// marker validly serves both an allow command and a deny command. It replaces
|
|
// the old per-command NetworkPolicyHash/NetworkPlanHash, which locked the
|
|
// marker to a single mode and bricked approved network commands.
|
|
NetworkInfraHash string `json:"networkInfraHash"`
|
|
OfflineFilterSID string `json:"offlineFilterSid"`
|
|
NetworkFilters int `json:"networkFilters"`
|
|
}
|
|
|
|
func WindowsSandboxSetupMarkerPath(sandboxHome string) string {
|
|
return filepath.Join(sandboxHome, "windows-setup.json")
|
|
}
|
|
|
|
func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]string, error) {
|
|
commandCWD := strings.TrimSpace(options.CommandCWD)
|
|
if commandCWD == "" {
|
|
return nil, errors.New("windows sandbox setup requires command cwd")
|
|
}
|
|
sandboxHome := strings.TrimSpace(options.SandboxHome)
|
|
if sandboxHome == "" {
|
|
var err error
|
|
sandboxHome, err = ResolveWindowsSandboxHome(nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
workspaceRoots := trimNonEmptyStrings(options.WorkspaceRoots)
|
|
if len(workspaceRoots) == 0 {
|
|
workspaceRoots = []string{commandCWD}
|
|
}
|
|
profileJSON, err := json.Marshal(options.PermissionProfile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal windows sandbox setup permission profile: %w", err)
|
|
}
|
|
args := []string{
|
|
"--sandbox-home", sandboxHome,
|
|
"--command-cwd", commandCWD,
|
|
"--permission-profile", string(profileJSON),
|
|
}
|
|
for _, root := range workspaceRoots {
|
|
args = append(args, "--workspace-root", root)
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
func ParseWindowsSandboxSetupArgs(args []string) (WindowsSandboxSetupConfig, error) {
|
|
var config WindowsSandboxSetupConfig
|
|
var profileJSON string
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch arg {
|
|
case "--command-cwd":
|
|
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
|
if err != nil {
|
|
return WindowsSandboxSetupConfig{}, err
|
|
}
|
|
config.CommandCWD = strings.TrimSpace(value)
|
|
index = next
|
|
case "--sandbox-home":
|
|
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
|
if err != nil {
|
|
return WindowsSandboxSetupConfig{}, err
|
|
}
|
|
config.SandboxHome = strings.TrimSpace(value)
|
|
index = next
|
|
case "--workspace-root":
|
|
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
|
if err != nil {
|
|
return WindowsSandboxSetupConfig{}, err
|
|
}
|
|
if root := strings.TrimSpace(value); root != "" {
|
|
config.WorkspaceRoots = append(config.WorkspaceRoots, root)
|
|
}
|
|
index = next
|
|
case "--permission-profile":
|
|
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
|
if err != nil {
|
|
return WindowsSandboxSetupConfig{}, err
|
|
}
|
|
profileJSON = strings.TrimSpace(value)
|
|
index = next
|
|
default:
|
|
return WindowsSandboxSetupConfig{}, fmt.Errorf("unknown windows sandbox setup flag %q", arg)
|
|
}
|
|
}
|
|
if config.CommandCWD == "" {
|
|
return WindowsSandboxSetupConfig{}, errors.New("missing --command-cwd")
|
|
}
|
|
if config.SandboxHome == "" {
|
|
return WindowsSandboxSetupConfig{}, errors.New("missing --sandbox-home")
|
|
}
|
|
if len(config.WorkspaceRoots) == 0 {
|
|
config.WorkspaceRoots = []string{config.CommandCWD}
|
|
}
|
|
if profileJSON == "" {
|
|
return WindowsSandboxSetupConfig{}, errors.New("missing --permission-profile")
|
|
}
|
|
if err := json.Unmarshal([]byte(profileJSON), &config.PermissionProfile); err != nil {
|
|
return WindowsSandboxSetupConfig{}, fmt.Errorf("invalid --permission-profile: %w", err)
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func RunWindowsSandboxSetup(args []string, stderr io.Writer) int {
|
|
config, err := ParseWindowsSandboxSetupArgs(args)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
|
return 2
|
|
}
|
|
if err := windowsDenyReadRestrictedTokenUnsupportedProfile(config.PermissionProfile); err != nil {
|
|
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
|
return 1
|
|
}
|
|
return runWindowsSandboxSetup(config, stderr)
|
|
}
|
|
|
|
func (config WindowsSandboxSetupConfig) commandConfig() WindowsSandboxCommandConfig {
|
|
return WindowsSandboxCommandConfig{
|
|
SandboxHome: config.SandboxHome,
|
|
CommandCWD: config.CommandCWD,
|
|
WorkspaceRoots: cloneStrings(config.WorkspaceRoots),
|
|
PermissionProfile: config.PermissionProfile,
|
|
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
|
}
|
|
}
|
|
|
|
func WindowsSandboxSetupConfigFromCommand(config WindowsSandboxCommandConfig) WindowsSandboxSetupConfig {
|
|
return WindowsSandboxSetupConfig{
|
|
SandboxHome: config.SandboxHome,
|
|
CommandCWD: config.CommandCWD,
|
|
WorkspaceRoots: cloneStrings(config.WorkspaceRoots),
|
|
PermissionProfile: config.PermissionProfile,
|
|
}
|
|
}
|
|
|
|
func BuildWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) {
|
|
plan, err := BuildWindowsACLPlan(config.commandConfig())
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, err
|
|
}
|
|
hash, err := WindowsACLPlanHash(plan)
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, err
|
|
}
|
|
// Fingerprint the mode-INDEPENDENT network infrastructure (block filters
|
|
// scoped to the offline-marker SID), NOT the per-command network mode, so the
|
|
// marker validates for both allow and deny commands against this one setup.
|
|
infraPlan, err := BuildWindowsNetworkInfraPlan(config.commandConfig())
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, err
|
|
}
|
|
infraHash, err := WindowsNetworkInfraHash(infraPlan)
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, err
|
|
}
|
|
offlineSID := ""
|
|
if len(infraPlan.IdentitySIDs) > 0 {
|
|
offlineSID = infraPlan.IdentitySIDs[0]
|
|
}
|
|
return WindowsSandboxSetupMarker{
|
|
SchemaVersion: windowsSandboxSetupMarkerSchemaVersion,
|
|
ACLPlanHash: hash,
|
|
ACLPlanEntries: len(plan.Entries),
|
|
NetworkInfraHash: infraHash,
|
|
OfflineFilterSID: offlineSID,
|
|
NetworkFilters: len(infraPlan.Filters),
|
|
}, nil
|
|
}
|
|
|
|
func WriteWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) {
|
|
marker, err := BuildWindowsSandboxSetupMarker(config)
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, err
|
|
}
|
|
path := WindowsSandboxSetupMarkerPath(config.SandboxHome)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("create windows sandbox setup marker dir: %w", err)
|
|
}
|
|
bytes, err := json.MarshalIndent(marker, "", " ")
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("marshal windows sandbox setup marker: %w", err)
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-setup-*.tmp")
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("create windows sandbox setup marker temp file: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
if _, err := tmp.Write(bytes); err != nil {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpPath)
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("write windows sandbox setup marker temp file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("close windows sandbox setup marker temp file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("replace windows sandbox setup marker: %w", err)
|
|
}
|
|
return marker, nil
|
|
}
|
|
|
|
func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error {
|
|
expected, err := BuildWindowsSandboxSetupMarker(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path := WindowsSandboxSetupMarkerPath(config.SandboxHome)
|
|
bytes, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("windows sandbox is not initialized for this workspace — run `zero sandbox setup` from an elevated (Administrator) terminal (missing %s)", filepath.Base(path))
|
|
}
|
|
return fmt.Errorf("read windows sandbox setup marker: %w", err)
|
|
}
|
|
var actual WindowsSandboxSetupMarker
|
|
if err := json.Unmarshal(bytes, &actual); err != nil {
|
|
return fmt.Errorf("parse windows sandbox setup marker: %w", err)
|
|
}
|
|
if actual.SchemaVersion != expected.SchemaVersion {
|
|
return fmt.Errorf("windows sandbox setup is out of date: schema %d, want %d", actual.SchemaVersion, expected.SchemaVersion)
|
|
}
|
|
if actual.ACLPlanHash != expected.ACLPlanHash || actual.ACLPlanEntries != expected.ACLPlanEntries {
|
|
return errors.New("windows sandbox setup is out of date: permission roots or deny lists changed")
|
|
}
|
|
// Mode-agnostic: validate the provisioned infrastructure, never the
|
|
// per-command network mode — so an approved (allow) network command and an
|
|
// ordinary (deny) command both validate against this one setup.
|
|
if actual.NetworkInfraHash != expected.NetworkInfraHash {
|
|
return errors.New("windows sandbox setup is out of date: network infrastructure changed")
|
|
}
|
|
if actual.OfflineFilterSID != expected.OfflineFilterSID {
|
|
return errors.New("windows sandbox setup is out of date: offline network identity changed")
|
|
}
|
|
if actual.NetworkFilters != expected.NetworkFilters {
|
|
return errors.New("windows sandbox setup is out of date: network enforcement plan changed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func WindowsACLPlanHash(plan WindowsACLPlan) (string, error) {
|
|
entries := canonicalWindowsACLEntries(plan.Entries)
|
|
bytes, err := json.Marshal(entries)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal windows ACL plan hash input: %w", err)
|
|
}
|
|
sum := sha256.Sum256(bytes)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func canonicalWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry {
|
|
out := make([]WindowsACLEntry, 0, len(entries))
|
|
for _, entry := range dedupeWindowsACLEntries(entries) {
|
|
entry.Path = windowsCapabilityPathKey(entry.Path)
|
|
entry.Capability = strings.ToLower(strings.TrimSpace(entry.Capability))
|
|
out = append(out, entry)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
left := out[i]
|
|
right := out[j]
|
|
if left.Action != right.Action {
|
|
return left.Action < right.Action
|
|
}
|
|
if left.Path != right.Path {
|
|
return left.Path < right.Path
|
|
}
|
|
if left.Capability != right.Capability {
|
|
return left.Capability < right.Capability
|
|
}
|
|
return !left.Materialize && right.Materialize
|
|
})
|
|
return out
|
|
}
|