diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c707cfb..912d9a25 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -104,6 +104,7 @@ jobs: # tests the macOS test job can only cross-compile, not run: # - devicepolicy: the registry managed-policy probe, %APPDATA% # settings-path resolution + # - secureuserfile: target-user ownership and restricted Windows ACLs # - detector/credentials: reading the security descriptor behind # broad_read_allow_ace_present, and the per-account environment read from # the registry hive @@ -124,7 +125,7 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/detector/credentials/ ./internal/safepath/ + - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ smoke: name: Smoke Tests diff --git a/README.md b/README.md index 1325587e..4d6a5a08 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,8 @@ Compromised packages most often reach a machine because that machine resolves di Configuration is read from `.npmrc` (npm), pnpm config, `bunfig.toml` (bun), `.yarnrc` / `.yarnrc.yml` (yarn classic and berry), and `pip.conf` (pip). In enterprise mode this rolls up into the **Package Configs** view in the dashboard, where you can spot machines that are unprotected or pointed at the wrong registry. +Enterprise Device Policy can also set StepSecurity Secure Registry as the sole user-level Python index for pip and uv. It manages only the resolved developer's user configuration and shared StepSecurity `.netrc` entry, keeps pip and uv results independent, and restores owned settings on an explicit policy clear. Project files, virtual environments, system configuration, environment variables, direct URLs, and Poetry are not modified. + ### Suspicious file detection Some supply chain attacks plant files that trigger code execution outside the package lifecycle scripts most tools watch — for example a malicious `binding.gyp` that runs during `npm install`, or an editor configuration file that runs when a project is opened. Dev Machine Guard ships a rules-engine scanner that flags these files as IOCs and wires the results into scan telemetry. The detector streams one file at a time, so scan memory stays bounded regardless of repository size. diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index acbcf18c..87c6a9dd 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -809,16 +809,8 @@ func runIDEExtensionEnforce(exec executor.Executor, log *progress.Logger) { } } -// runPackageConfigEnforce fetches the device's effective package-config policy -// (the npm secure-registry directive) and converges the managed block in the -// console user's ~/.npmrc to match, then reports compliance — on the same -// scheduled cycle and agent auth channel as the IDE-extension enforcement above. -// It runs on every telemetry cycle, INCLUDING cycles where telemetry itself -// failed, so an emergency unassignment/offboarding directive is never blocked by -// a telemetry outage. A device whose npm config is already governed by the MDM -// remediation script is detected by the writer's content-aware probe and reported -// mdm_managed instead. A silent no-op when enterprise config is missing. Failures -// are logged but never crash main. +// runPackageConfigEnforce runs npm and PyPI independently after resolving their +// shared enterprise and device identity once. Failures never crash main. func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) { cfg, ok := ingest.Snapshot() if !ok { @@ -837,74 +829,76 @@ func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) { } ctx, cancel := context.WithTimeout(context.Background(), devicePolicyEnforceTimeout) - defer cancel() - dev := device.Gather(ctx, exec) + cancel() if dev.SerialNumber == "" || dev.SerialNumber == "unknown" { log.Warn("package-config enforce: device serial unresolved; skipping") return } - serial := dev.SerialNumber + runPackageConfigLanes(exec, log, fetcher, reporter, cfg.CustomerID, dev.SerialNumber, dev.Platform) +} + +func runPackageConfigLanes(exec executor.Executor, log *progress.Logger, fetcher devicepolicy.Fetcher, reporter devicepolicy.Reporter, customerID, serial, platform string) { + npmCtx, npmCancel := context.WithTimeout(context.Background(), devicePolicyEnforceTimeout) + npmErr := runNPMPackageConfigLane(npmCtx, exec, log, fetcher, reporter, customerID, serial, platform) + npmCancel() + if npmErr != nil { + wrapped := fmt.Errorf("npm package-config enforce: %w", npmErr) + log.Warn("%v", wrapped) + aiagentscli.AppendError("devicepolicy", "enforce_failed", wrapped.Error(), "") + } + pypiCtx, pypiCancel := context.WithTimeout(context.Background(), devicePolicyEnforceTimeout) + pypiErr := runPyPIPackageConfigLane(pypiCtx, exec, log, fetcher, reporter, customerID, serial, platform) + pypiCancel() + if pypiErr != nil { + wrapped := fmt.Errorf("PyPI package-config enforce: %w", pypiErr) + log.Warn("%v", wrapped) + aiagentscli.AppendError("devicepolicy", "enforce_failed", wrapped.Error(), "") + } +} + +func runNPMPackageConfigLane(ctx context.Context, exec executor.Executor, log *progress.Logger, fetcher devicepolicy.Fetcher, reporter devicepolicy.Reporter, customerID, serial, platform string) error { r := &devicepolicy.Reconciler{ Fetcher: fetcher, Reporter: reporter, - CustomerID: cfg.CustomerID, + CustomerID: customerID, DeviceID: serial, - Platform: dev.Platform, + Platform: platform, Category: devicepolicy.CategoryPackageConfig, Target: devicepolicy.TargetNPM, - // Render derives the two managed ~/.npmrc content lines from the policy and - // this device's serial. It fully validates the policy and is pure, so it is - // wired even when the writer below could not be constructed. Render: func(policy json.RawMessage) (string, error) { return devicepolicy.RenderNPMRCBlock(policy, serial) }, OwnsByMarker: true, - // The managed block is one atomic unit, so the lane owns exactly one - // WrittenSettings entry under this key. OwnershipKey: devicepolicy.NPMOwnedKey, Logf: func(format string, args ...any) { log.Debug(format, args...) }, } - // The writer resolves the console user and opens a directory fd over their - // home. When it cannot (no enforceable target user, or an infrastructure - // failure) leave the writer seams nil and hand the reconciler the init error: - // it classifies AFTER the fetch (absent → silent, clear → retain all state, - // enforce → policy_not_applied for no-target else write_failed). Binding - // w.Converged / w.ProbeExpected before this nil check would capture method - // values on a nil receiver, and the deferred Close would panic. - w, werr := devicepolicy.NewNPMRCWriter(exec) - if werr != nil { - r.WriterInitErr = werr + w, err := devicepolicy.NewNPMRCWriter(exec) + if err != nil { + r.WriterInitErr = err } else { defer w.Close() w.SetLogf(func(format string, args ...any) { log.Debug(format, args...) }) - - // Concurrent convergence of this ~/.npmrc is not serialized across - // processes. Every write is an atomic temp+rename, so an overlapping - // cycle never sees a torn file. While the policy is stable both cycles - // render identical bytes; only a policy transition (a key rotation, or an - // enforce racing a clear) that interleaves with a concurrent cycle can - // briefly leave the superseded value, reconverged next cycle — eventual - // consistency, the same model the VS Code settings.json lane relies on. - // The telemetry singleton lock already serializes the preceding scan phase. - // Ownership state is the exception: it shares one file with every other - // category, so its read-modify-write does take a cross-process lock. r.Writer = w r.Converged = w.Converged r.ProbeExpected = w.ProbeExpected r.RestoreSnapshot = w.RestoreSnapshot - // Verify-only channel (enforcement=mdm): read the effective ~/.npmrc and - // report the observed bag instead of writing. Bound here because it needs the - // writer's identity-checked read path; with no writer the reconciler's - // category-aware fallback reports verification_failed rather than probing VS - // Code policy for an npm category. r.ProbeContent = w.ProbeContentNPM } + return r.Reconcile(ctx) +} - if err := r.Reconcile(ctx); err != nil { - log.Warn("package-config enforce: %v", err) - aiagentscli.AppendError("devicepolicy", "enforce_failed", err.Error(), "") +func runPyPIPackageConfigLane(ctx context.Context, exec executor.Executor, log *progress.Logger, fetcher devicepolicy.Fetcher, reporter devicepolicy.Reporter, customerID, serial, platform string) error { + coordinator := &devicepolicy.PyPICoordinator{ + Fetcher: fetcher, + Reporter: reporter, + Exec: exec, + CustomerID: customerID, + DeviceID: serial, + Platform: platform, + Logf: func(format string, args ...any) { log.Debug(format, args...) }, } + return coordinator.Reconcile(ctx) } diff --git a/cmd/stepsecurity-dev-machine-guard/main_devicepolicy_test.go b/cmd/stepsecurity-dev-machine-guard/main_devicepolicy_test.go new file mode 100644 index 00000000..d1647e88 --- /dev/null +++ b/cmd/stepsecurity-dev-machine-guard/main_devicepolicy_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/devicepolicy" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +type packageConfigFetcher struct { + calls []string + contexts map[string]context.Context + failures map[string]error +} + +func (f *packageConfigFetcher) Fetch(ctx context.Context, _, _, _, target string) (devicepolicy.EffectivePolicy, error) { + f.calls = append(f.calls, target) + if f.contexts != nil { + f.contexts[target] = ctx + } + return devicepolicy.EffectivePolicy{}, f.failures[target] +} + +type packageConfigReporter struct{} + +func (packageConfigReporter) Report(context.Context, string, string, devicepolicy.ComplianceReport) error { + return nil +} + +func TestPackageConfigLanes_FailureDoesNotSuppressSibling(t *testing.T) { + t.Setenv("STEPSECURITY_HOME", t.TempDir()) + tests := []struct { + name string + failTarget string + }{ + {"npm failure still runs PyPI", devicepolicy.TargetNPM}, + {"PyPI failure keeps npm success", devicepolicy.TargetPyPI}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fetcher := &packageConfigFetcher{failures: map[string]error{tc.failTarget: errors.New("lane failed")}} + mock := executor.NewMock() + mock.SetLoggedInUserError(errors.New("no user needed for absent policy")) + + runPackageConfigLanes(mock, progress.NewNoop(), fetcher, packageConfigReporter{}, "customer", "serial", "linux") + + if got, want := strings.Join(fetcher.calls, ","), devicepolicy.TargetNPM+","+devicepolicy.TargetPyPI; got != want { + t.Errorf("lane calls = %q, want %q", got, want) + } + }) + } +} + +func TestPackageConfigLanes_UseSeparateTimeoutContexts(t *testing.T) { + fetcher := &packageConfigFetcher{contexts: map[string]context.Context{}, failures: map[string]error{}} + mock := executor.NewMock() + mock.SetLoggedInUserError(errors.New("no user needed for absent policy")) + + runPackageConfigLanes(mock, progress.NewNoop(), fetcher, packageConfigReporter{}, "customer", "serial", "linux") + + npmCtx := fetcher.contexts[devicepolicy.TargetNPM] + pypiCtx := fetcher.contexts[devicepolicy.TargetPyPI] + if npmCtx == nil || pypiCtx == nil { + t.Fatalf("lane contexts = %#v, want both", fetcher.contexts) + } + if npmCtx == pypiCtx { + t.Error("npm and PyPI shared one context") + } + if _, ok := npmCtx.Deadline(); !ok { + t.Error("npm context has no deadline") + } + if _, ok := pypiCtx.Deadline(); !ok { + t.Error("PyPI context has no deadline") + } +} diff --git a/internal/detector/configaudit/pipconfig.go b/internal/detector/configaudit/pipconfig.go index 9c258672..2cd81673 100644 --- a/internal/detector/configaudit/pipconfig.go +++ b/internal/detector/configaudit/pipconfig.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io/fs" "os" @@ -50,19 +51,32 @@ var pipEnvVarsToWatch = []string{ "HTTPS_PROXY", } -// pipInvocationsToTry orders the candidate ways to find pip on the host. -// The detector probes each in turn and uses the first that resolves. -var pipInvocationsToTry = []struct { +type pipInvocationCandidate struct { binary string args []string // args prepended to the binary call (e.g., "-m" "pip") display string -}{ +} + +// pipInvocationsToTry orders the candidate ways to find pip on the host. +// The detector probes each in turn and uses the first that resolves. +var pipInvocationsToTry = []pipInvocationCandidate{ {"pip", nil, "pip"}, {"pip3", nil, "pip3"}, {"python3", []string{"-m", "pip"}, "python3 -m pip"}, {"python", []string{"-m", "pip"}, "python -m pip"}, } +func pipEnforcementInvocationCandidates(goos string) []pipInvocationCandidate { + out := append([]pipInvocationCandidate(nil), pipInvocationsToTry...) + if goos == model.PlatformWindows { + out = append(out, + pipInvocationCandidate{"py", []string{"-m", "pip"}, "py -m pip"}, + pipInvocationCandidate{"py", []string{"-3", "-m", "pip"}, "py -3 -m pip"}, + ) + } + return out +} + // pipConfigDebugSectionRE matches the layer headers in `pip config debug` // output: `env_var:`, `env:`, `global:`, `site:`, `user:`. var pipConfigDebugSectionRE = regexp.MustCompile(`^(env_var|env|global|site|user):$`) @@ -152,7 +166,7 @@ func (d *PipConfigDetector) Detect(ctx context.Context, loggedInUser *user.User) // ok — true when a working pip was found func (d *PipConfigDetector) detectPip(ctx context.Context) (string, []string, string, string, bool) { for _, cand := range pipInvocationsToTry { - path, err := d.exec.LookPath(cand.binary) + path, err := executor.LookPathWithContext(ctx, d.exec, cand.binary) if err != nil { continue } @@ -184,7 +198,7 @@ func (d *PipConfigDetector) detectPip(ctx context.Context) (string, []string, st // was available at all. func (d *PipConfigDetector) runPip(ctx context.Context, timeout time.Duration, pipArgs ...string) (string, int, bool) { for _, cand := range pipInvocationsToTry { - path, err := d.exec.LookPath(cand.binary) + path, err := executor.LookPathWithContext(ctx, d.exec, cand.binary) if err != nil { continue } @@ -209,6 +223,201 @@ func (d *PipConfigDetector) runPip(ctx context.Context, timeout time.Duration, p // --- discovery -------------------------------------------------------------- +// PipUserConfigDiscovery is the trusted user-tier path and invocation set used +// by package policy enforcement. Paths are resolved beneath the interactive +// user's home; pip output can only confirm entries already in that allowlist. +type PipUserConfigDiscovery struct { + AllowedUserPaths []string + ExistingUserPaths []string + Invocations [][]string +} + +// DiscoverPipUserConfig discovers every supported pip invocation and only +// documented user-tier configuration paths for the resolved interactive user. +func DiscoverPipUserConfig(ctx context.Context, exec executor.Executor) (PipUserConfigDiscovery, error) { + u, err := exec.LoggedInUser() + if err != nil { + return PipUserConfigDiscovery{}, fmt.Errorf("pip discovery: resolving interactive user: %w", err) + } + if u == nil || u.HomeDir == "" { + return PipUserConfigDiscovery{}, errors.New("pip discovery: resolved interactive user has no home directory") + } + home := filepath.Clean(u.HomeDir) + userExec := executor.NewUserAwareExecutor(exec, u.Username) + allowed := pipAllowedUserPaths(userExec, home) + if err := executor.UserEnvironmentError(userExec); err != nil { + return PipUserConfigDiscovery{}, fmt.Errorf("pip discovery: resolving user environment: %w", err) + } + + invocations := make([][]string, 0, len(pipInvocationsToTry)+2) + seenInvocations := map[string]bool{} + confirmed := map[string]bool{} + for _, candidate := range pipEnforcementInvocationCandidates(exec.GOOS()) { + path, err := executor.LookPathWithContext(ctx, userExec, candidate.binary) + if ctxErr := ctx.Err(); ctxErr != nil { + return PipUserConfigDiscovery{}, ctxErr + } + if err != nil || userExec.IsAppleCLTStub(ctx, path) { + if ctxErr := ctx.Err(); ctxErr != nil { + return PipUserConfigDiscovery{}, ctxErr + } + continue + } + key := filepath.Clean(path) + "\x00" + strings.Join(candidate.args, "\x00") + if seenInvocations[key] { + continue + } + versionArgs := append(append([]string(nil), candidate.args...), "--version") + stdout, _, exit, err := userExec.RunWithTimeout(ctx, 5*time.Second, candidate.binary, versionArgs...) + if ctxErr := ctx.Err(); ctxErr != nil { + return PipUserConfigDiscovery{}, ctxErr + } + if err != nil || exit != 0 || !strings.HasPrefix(strings.TrimSpace(stdout), "pip ") { + continue + } + seenInvocations[key] = true + invocation := append([]string{candidate.binary}, candidate.args...) + invocations = append(invocations, invocation) + + debugArgs := append(append([]string(nil), candidate.args...), "config", "debug") + debug, _, exit, err := userExec.RunWithTimeout(ctx, 10*time.Second, candidate.binary, debugArgs...) + if ctxErr := ctx.Err(); ctxErr != nil { + return PipUserConfigDiscovery{}, ctxErr + } + if err != nil || exit != 0 { + continue + } + for _, discovered := range parsePipConfigDebug(debug) { + if discovered.layer != "user" || !discovered.exists { + continue + } + if trusted, ok := matchingPipAllowedPath(discovered.path, allowed, exec.GOOS()); ok { + confirmed[trusted] = true + } + } + } + + existing := make([]string, 0, len(allowed)) + for _, path := range allowed { + if exec.FileExists(path) || confirmed[path] { + existing = append(existing, path) + } + } + return PipUserConfigDiscovery{AllowedUserPaths: allowed, ExistingUserPaths: existing, Invocations: invocations}, nil +} + +func pipAllowedUserPaths(exec executor.Executor, home string) []string { + paths := pipUserConfigPaths(exec, home, true) + out := make([]string, len(paths)) + for i := range paths { + out[i] = paths[i].path + } + return out +} + +type pipUserConfigPath struct { + path string + layer string +} + +// pipUserConfigPaths is the shared documented user-path source for inventory +// and enforcement. Enforcement requests current-first, home-confined paths; +// inventory retains its historical platform order and visibility. +func pipUserConfigPaths(exec executor.Executor, home string, trusted bool) []pipUserConfigPath { + if home == "" { + return nil + } + var paths []pipUserConfigPath + switch exec.GOOS() { + case model.PlatformWindows: + appData := strings.TrimSpace(exec.Getenv("APPDATA")) + if trusted && !pipPathWithinHome(appData, home, true) { + appData = filepath.Join(home, "AppData", "Roaming") + } + if appData != "" { + paths = append(paths, pipUserConfigPath{filepath.Join(appData, "pip", "pip.ini"), "user"}) + } + paths = append(paths, pipUserConfigPath{filepath.Join(home, "pip", "pip.ini"), "user-legacy"}) + case model.PlatformDarwin: + applicationSupportDir := filepath.Join(home, "Library", "Application Support", "pip") + applicationSupport := filepath.Join(applicationSupportDir, "pip.conf") + xdg := filepath.Join(home, ".config", "pip", "pip.conf") + if trusted && !exec.DirExists(applicationSupportDir) { + paths = append(paths, pipUserConfigPath{xdg, "user"}, pipUserConfigPath{applicationSupport, "user"}) + } else { + paths = append(paths, pipUserConfigPath{applicationSupport, "user"}, pipUserConfigPath{xdg, "user"}) + } + paths = append(paths, pipUserConfigPath{filepath.Join(home, ".pip", "pip.conf"), "user-legacy"}) + default: + xdgHome := strings.TrimSpace(exec.Getenv("XDG_CONFIG_HOME")) + if xdgHome == "" || trusted && !pipPathWithinHome(xdgHome, home, false) { + xdgHome = filepath.Join(home, ".config") + } + paths = append(paths, + pipUserConfigPath{filepath.Join(xdgHome, "pip", "pip.conf"), "user"}, + pipUserConfigPath{filepath.Join(home, ".pip", "pip.conf"), "user-legacy"}, + ) + } + out := make([]pipUserConfigPath, 0, len(paths)) + seen := map[string]bool{} + for _, candidate := range paths { + candidate.path = filepath.Clean(candidate.path) + if candidate.path == "." || seen[candidate.path] { + continue + } + seen[candidate.path] = true + out = append(out, candidate) + } + return out +} + +func pipPathWithinHome(path, home string, windows bool) bool { + if path == "" || !filepath.IsAbs(path) { + return false + } + path, home = filepath.Clean(path), filepath.Clean(home) + if windows { + path, home = strings.ToLower(path), strings.ToLower(home) + } + rel, err := filepath.Rel(home, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +type pipDebugPath struct { + path string + layer string + exists bool +} + +func parsePipConfigDebug(stdout string) []pipDebugPath { + var out []pipDebugPath + currentLayer := "" + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimRight(line, "\r") + if match := pipConfigDebugSectionRE.FindStringSubmatch(line); match != nil { + currentLayer = match[1] + continue + } + if currentLayer != "global" && currentLayer != "user" && currentLayer != "site" { + continue + } + if match := pipConfigDebugFileRE.FindStringSubmatch(line); match != nil { + out = append(out, pipDebugPath{path: strings.TrimSpace(match[1]), layer: currentLayer, exists: match[2] == "True"}) + } + } + return out +} + +func matchingPipAllowedPath(reported string, allowed []string, goos string) (string, bool) { + reported = filepath.Clean(reported) + for _, trusted := range allowed { + if reported == trusted || goos == model.PlatformWindows && strings.EqualFold(reported, trusted) { + return trusted, true + } + } + return "", false +} + // discoverFiles returns the union of (`pip config debug`-derived paths) and // (PIP_CONFIG_FILE / VIRTUAL_ENV-derived paths). Deduplicates by absolute // path; the first layer assignment wins. @@ -320,31 +529,13 @@ func (d *PipConfigDetector) discoverViaPathEnumeration(loggedInUser *user.User) var out []struct{ path, layer string } switch goos { - case "windows": + case model.PlatformWindows: // Global. Vista is unsupported; skip. if pd := d.exec.Getenv("ProgramData"); pd != "" { out = append(out, struct{ path, layer string }{filepath.Join(pd, "pip", "pip.ini"), "global"}) } - // User. - if appdata := d.exec.Getenv("APPDATA"); appdata != "" { - out = append(out, struct{ path, layer string }{filepath.Join(appdata, "pip", "pip.ini"), "user"}) - } - if homeDir != "" { - out = append(out, struct{ path, layer string }{filepath.Join(homeDir, "pip", "pip.ini"), "user-legacy"}) - } - case "darwin": - // Global. + case model.PlatformDarwin: out = append(out, struct{ path, layer string }{"/Library/Application Support/pip/pip.conf", "global"}) - if homeDir != "" { - // pip itself prefers ~/Library/Application Support/pip when that - // directory exists, and otherwise reads ~/.config/pip. We surface - // both candidates: the audit is inventory-only, and having a - // stray config at the unused path is itself worth showing. - out = append(out, struct{ path, layer string }{filepath.Join(homeDir, "Library", "Application Support", "pip", "pip.conf"), "user"}) - out = append(out, struct{ path, layer string }{filepath.Join(homeDir, ".config", "pip", "pip.conf"), "user"}) - // Legacy. - out = append(out, struct{ path, layer string }{filepath.Join(homeDir, ".pip", "pip.conf"), "user-legacy"}) - } default: // linux + everything else // XDG_CONFIG_DIRS is colon-separated; check each. xdgDirs := d.exec.Getenv("XDG_CONFIG_DIRS") @@ -359,15 +550,9 @@ func (d *PipConfigDetector) discoverViaPathEnumeration(loggedInUser *user.User) out = append(out, struct{ path, layer string }{filepath.Join(dir, "pip", "pip.conf"), "global"}) } out = append(out, struct{ path, layer string }{"/etc/pip.conf", "global"}) - - if homeDir != "" { - xdgHome := d.exec.Getenv("XDG_CONFIG_HOME") - if xdgHome == "" { - xdgHome = filepath.Join(homeDir, ".config") - } - out = append(out, struct{ path, layer string }{filepath.Join(xdgHome, "pip", "pip.conf"), "user"}) - out = append(out, struct{ path, layer string }{filepath.Join(homeDir, ".pip", "pip.conf"), "user-legacy"}) - } + } + for _, candidate := range pipUserConfigPaths(d.exec, homeDir, false) { + out = append(out, struct{ path, layer string }{candidate.path, candidate.layer}) } return out } diff --git a/internal/detector/configaudit/pipconfig_test.go b/internal/detector/configaudit/pipconfig_test.go index 91bd5f2b..c279554b 100644 --- a/internal/detector/configaudit/pipconfig_test.go +++ b/internal/detector/configaudit/pipconfig_test.go @@ -2,6 +2,7 @@ package configaudit import ( "context" + "errors" "os" "os/user" "path/filepath" @@ -388,3 +389,260 @@ func TestPipConfigDetector_DetectsUsrBinWhenCLTInstalled(t *testing.T) { t.Errorf("expected Version=24.0, got %q", audit.Version) } } + +func TestDiscoverPipUserConfig_PlatformPaths(t *testing.T) { + tests := []struct { + name string + goos string + configure func(*executor.Mock, string) + wantAllowed []string + wantCurrent string + }{ + { + name: "linux XDG and legacy", + goos: "linux", + configure: func(mock *executor.Mock, home string) { + mock.SetEnv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) + mock.SetFile(filepath.Join(home, "xdg", "pip", "pip.conf"), nil) + mock.SetFile(filepath.Join(home, ".pip", "pip.conf"), nil) + }, + wantAllowed: []string{filepath.Join("xdg", "pip", "pip.conf"), filepath.Join(".pip", "pip.conf")}, + wantCurrent: filepath.Join("xdg", "pip", "pip.conf"), + }, + { + name: "macOS current Application Support directory", + goos: "darwin", + configure: func(mock *executor.Mock, home string) { + mock.SetDir(filepath.Join(home, "Library", "Application Support", "pip")) + }, + wantAllowed: []string{ + filepath.Join("Library", "Application Support", "pip", "pip.conf"), + filepath.Join(".config", "pip", "pip.conf"), + filepath.Join(".pip", "pip.conf"), + }, + wantCurrent: filepath.Join("Library", "Application Support", "pip", "pip.conf"), + }, + { + name: "windows APPDATA and legacy", + goos: "windows", + configure: func(mock *executor.Mock, home string) { + mock.SetEnv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + mock.SetFile(filepath.Join(home, "pip", "pip.ini"), nil) + }, + wantAllowed: []string{ + filepath.Join("AppData", "Roaming", "pip", "pip.ini"), + filepath.Join("pip", "pip.ini"), + }, + wantCurrent: filepath.Join("AppData", "Roaming", "pip", "pip.ini"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + mock := executor.NewMock() + mock.SetGOOS(tc.goos) + mock.SetHomeDir(home) + mock.SetUsername("") + tc.configure(mock, home) + + got, err := DiscoverPipUserConfig(context.Background(), mock) + if err != nil { + t.Fatalf("DiscoverPipUserConfig: %v", err) + } + if len(got.AllowedUserPaths) != len(tc.wantAllowed) { + t.Fatalf("AllowedUserPaths = %v, want %v", got.AllowedUserPaths, tc.wantAllowed) + } + for i, relative := range tc.wantAllowed { + if want := filepath.Join(home, relative); got.AllowedUserPaths[i] != want { + t.Errorf("AllowedUserPaths[%d] = %q, want %q", i, got.AllowedUserPaths[i], want) + } + } + if got.AllowedUserPaths[0] != filepath.Join(home, tc.wantCurrent) { + t.Errorf("current path = %q, want %q", got.AllowedUserPaths[0], filepath.Join(home, tc.wantCurrent)) + } + }) + } +} + +func TestDiscoverPipUserConfig_InvocationsDeduplicateAndRejectOutsidePath(t *testing.T) { + home := t.TempDir() + current := filepath.Join(home, ".config", "pip", "pip.conf") + legacy := filepath.Join(home, ".pip", "pip.conf") + outside := filepath.Join(t.TempDir(), "pip.conf") + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetHomeDir(home) + mock.SetUsername("") + mock.SetPath("pip", "/opt/bin/pip") + mock.SetPath("pip3", "/opt/bin/pip") + mock.SetPath("python3", "/opt/bin/python3") + mock.SetCommand("pip 25.2 from /opt/site-packages/pip\n", "", 0, "pip", "--version") + mock.SetCommand("pip 25.2 from /opt/site-packages/pip\n", "", 0, "python3", "-m", "pip", "--version") + debug := "user:\n " + current + ", exists: True\n " + legacy + ", exists: True\n " + outside + ", exists: True\n" + mock.SetCommand(debug, "", 0, "pip", "config", "debug") + mock.SetCommand(debug, "", 0, "python3", "-m", "pip", "config", "debug") + mock.SetFile(current, nil) + mock.SetFile(legacy, nil) + mock.SetFile(outside, nil) + + got, err := DiscoverPipUserConfig(context.Background(), mock) + if err != nil { + t.Fatalf("DiscoverPipUserConfig: %v", err) + } + wantInvocations := [][]string{{"pip"}, {"python3", "-m", "pip"}} + if strings.TrimSpace(invocationStrings(got.Invocations)) != strings.TrimSpace(invocationStrings(wantInvocations)) { + t.Fatalf("Invocations = %v, want %v", got.Invocations, wantInvocations) + } + if len(got.ExistingUserPaths) != 2 || got.ExistingUserPaths[0] != current || got.ExistingUserPaths[1] != legacy { + t.Fatalf("ExistingUserPaths = %v, want [%q %q] without outside path", got.ExistingUserPaths, current, legacy) + } + for _, path := range got.AllowedUserPaths { + if path == outside { + t.Fatalf("outside debug path entered allowlist: %q", path) + } + } +} + +func TestPipConfigDetector_WindowsPreservesHistoricalLauncherSelection(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetPath("py", `C:\\Windows\\py.exe`) + mock.SetCommand("pip 25.2\n", "", 0, "py", "-m", "pip", "--version") + detector := NewPipConfigDetector(mock) + if _, _, _, _, ok := detector.detectPip(context.Background()); ok { + t.Fatal("config audit selected enforcement-only py launcher") + } +} + +func TestDiscoverPipUserConfig_WindowsLauncherForms(t *testing.T) { + home := t.TempDir() + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetHomeDir(home) + mock.SetEnv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + mock.SetPath("py", `C:\\Windows\\py.exe`) + mock.SetCommand("pip 25.2\n", "", 0, "py", "-m", "pip", "--version") + mock.SetCommand("pip 25.2\n", "", 0, "py", "-3", "-m", "pip", "--version") + mock.SetCommand("user:\n", "", 0, "py", "-m", "pip", "config", "debug") + mock.SetCommand("user:\n", "", 0, "py", "-3", "-m", "pip", "config", "debug") + + got, err := DiscoverPipUserConfig(context.Background(), mock) + if err != nil { + t.Fatalf("DiscoverPipUserConfig: %v", err) + } + want := [][]string{{"py", "-m", "pip"}, {"py", "-3", "-m", "pip"}} + if invocationStrings(got.Invocations) != invocationStrings(want) { + t.Fatalf("Invocations = %v, want %v", got.Invocations, want) + } +} + +type resolvedPipEnvironmentExecutor struct { + *executor.Mock + environment string + runAsUser func(context.Context, string) (string, error) +} + +func (e *resolvedPipEnvironmentExecutor) RunAsUser(ctx context.Context, username, command string) (string, error) { + if e.runAsUser != nil { + return e.runAsUser(ctx, command) + } + if strings.Contains(command, "XDG_CONFIG_HOME") && strings.Contains(command, "PIP_CONFIG_FILE") { + return e.environment, nil + } + return e.Mock.RunAsUser(ctx, username, command) +} + +func TestDiscoverPipUserConfig_UsesResolvedUserEnvironment(t *testing.T) { + home := t.TempDir() + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetUsername("alice") + mock.SetHomeDir(home) + mock.SetEnv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "service-xdg")) + exec := &resolvedPipEnvironmentExecutor{ + Mock: mock, + environment: "XDG_CONFIG_HOME=" + filepath.Join(home, "user-xdg") + "\x00", + } + got, err := DiscoverPipUserConfig(context.Background(), exec) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, "user-xdg", "pip", "pip.conf") + if len(got.AllowedUserPaths) == 0 || got.AllowedUserPaths[0] != want { + t.Fatalf("AllowedUserPaths = %v, want resolved-user path first %q", got.AllowedUserPaths, want) + } +} + +func TestDiscoverPipUserConfig_UsesResolvedUserLoginShellPATH(t *testing.T) { + home := t.TempDir() + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetIsRoot(true) + mock.SetUsername("alice") + mock.SetHomeDir(home) + mock.SetAppleCLTInstalled(true) + mock.SetCommand("/opt/homebrew/bin/pip\n", "", 0, "bash", "-c", "which 'pip'") + mock.SetCommand("pip 25.2 from /opt/homebrew/lib/python/site-packages/pip\n", "", 0, "bash", "-c", "'pip' '--version'") + mock.SetCommand("user:\n", "", 0, "bash", "-c", "'pip' 'config' 'debug'") + + exec := &resolvedPipEnvironmentExecutor{Mock: mock} + got, err := DiscoverPipUserConfig(context.Background(), exec) + if err != nil { + t.Fatalf("DiscoverPipUserConfig: %v", err) + } + if len(got.Invocations) != 1 || invocationStrings(got.Invocations) != "pip" { + t.Fatalf("Invocations = %v, want login-shell pip", got.Invocations) + } +} + +func TestDiscoverPipUserConfig_UserEnvironmentFailureIsError(t *testing.T) { + home := t.TempDir() + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetUsername("alice") + mock.SetHomeDir(home) + exec := &resolvedPipEnvironmentExecutor{Mock: mock} + exec.runAsUser = func(context.Context, string) (string, error) { + return "", context.DeadlineExceeded + } + if _, err := DiscoverPipUserConfig(context.Background(), exec); err == nil { + t.Fatal("DiscoverPipUserConfig() error = nil, want environment inspection failure") + } +} + +func TestDiscoverPipUserConfig_CanceledContextReturnsError(t *testing.T) { + home := t.TempDir() + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetUsername("alice") + mock.SetHomeDir(home) + calls := 0 + exec := &resolvedPipEnvironmentExecutor{Mock: mock} + exec.runAsUser = func(ctx context.Context, command string) (string, error) { + calls++ + if strings.Contains(command, "XDG_CONFIG_HOME") { + return "XDG_CONFIG_HOME=\x00", nil + } + if ctx.Err() != context.Canceled { + t.Fatalf("path lookup context error = %v, want canceled", ctx.Err()) + } + return "", ctx.Err() + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := DiscoverPipUserConfig(ctx, exec); !errors.Is(err, context.Canceled) { + t.Fatalf("DiscoverPipUserConfig() error = %v, want context.Canceled", err) + } + if calls != 1 { + t.Fatalf("RunAsUser calls = %d, want only environment snapshot", calls) + } +} + +func invocationStrings(invocations [][]string) string { + parts := make([]string, len(invocations)) + for i, invocation := range invocations { + parts[i] = strings.Join(invocation, " ") + } + return strings.Join(parts, "|") +} diff --git a/internal/devicepolicy/api.go b/internal/devicepolicy/api.go index 71a046b7..ab15d6ac 100644 --- a/internal/devicepolicy/api.go +++ b/internal/devicepolicy/api.go @@ -306,6 +306,7 @@ type ComplianceReport struct { Target string `json:"target"` State string `json:"state"` AppliedHash string `json:"applied_hash"` + EvaluatedHash string `json:"evaluated_hash,omitempty"` AgentVersion string `json:"agent_version"` Platform string `json:"platform"` Observed json.RawMessage `json:"observed,omitempty"` diff --git a/internal/devicepolicy/api_identity_test.go b/internal/devicepolicy/api_identity_test.go index 7ed7eaf2..7a8e8995 100644 --- a/internal/devicepolicy/api_identity_test.go +++ b/internal/devicepolicy/api_identity_test.go @@ -2,6 +2,7 @@ package devicepolicy import ( "context" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -93,6 +94,50 @@ func TestFetchPackageConfigTargetRoundTrips(t *testing.T) { } } +func TestPackageConfigPyPIIdentityRoundTrip(t *testing.T) { + body := `{"policy":{"category":"package_config","target":"pypi","clear":false,` + + `"policy":{"ecosystem":"pypi"},"hash":"sha256:pypi","generated_at":"x"}}` + f := newPolicyFetchServer(t, CategoryPackageConfig, TargetPyPI, body) + ep, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryPackageConfig, TargetPyPI) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if ep.Category != CategoryPackageConfig || ep.Target != TargetPyPI { + t.Fatalf("round-trip identity = %q/%q, want %q/%q", + ep.Category, ep.Target, CategoryPackageConfig, TargetPyPI) + } +} + +func TestComplianceReportEvaluatedHashJSON(t *testing.T) { + tests := []struct { + name string + hash string + want bool + }{ + {"empty omitted", "", false}, + {"value included", "sha256:pypi", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + raw, err := json.Marshal(ComplianceReport{Category: CategoryPackageConfig, Target: TargetPyPI, EvaluatedHash: tc.hash}) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + got, present := fields["evaluated_hash"] + if present != tc.want { + t.Fatalf("evaluated_hash presence = %v, want %v: %s", present, tc.want, raw) + } + if present && string(got) != `"sha256:pypi"` { + t.Fatalf("evaluated_hash = %s, want %q", got, "sha256:pypi") + } + }) + } +} + // --------------------------------------------------------------------------- // Category-gated policy validation // --------------------------------------------------------------------------- diff --git a/internal/devicepolicy/cache_test.go b/internal/devicepolicy/cache_test.go index f8bbb906..40025877 100644 --- a/internal/devicepolicy/cache_test.go +++ b/internal/devicepolicy/cache_test.go @@ -450,6 +450,26 @@ func TestAppliedTargetSingleValueRecordsOneEntry(t *testing.T) { } } +func TestPackageConfigPyPIComponentOwnershipRoundTrip(t *testing.T) { + restore := SetCachePathForTest(filepath.Join(t.TempDir(), CacheFilename)) + defer restore() + + want := AppliedTargetState{ + AppliedHash: "sha256:pypi", + WrittenSettings: map[string]string{"component": PyPICredentialOwnershipValue}, + } + if err := WriteAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget, want); err != nil { + t.Fatal(err) + } + got, ok := ReadAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget) + if !ok || got.WrittenSettings["component"] != PyPICredentialOwnershipValue { + t.Fatalf("credential component = %+v ok=%v, want %q", got, ok, PyPICredentialOwnershipValue) + } + if _, ok := ReadAppliedState(CategoryPackageConfig, TargetPyPI); ok { + t.Fatal("component ownership must not create a public pypi target record") + } +} + // TestAppliedTargetLegacyWrittenValueReadsAsUnowned pins the no-migrator // decision: a state file written before the collapse carries only the retired // written_value key, which decodes into no WrittenSettings entry — so the target diff --git a/internal/devicepolicy/netrc_writer.go b/internal/devicepolicy/netrc_writer.go new file mode 100644 index 00000000..d1d81e10 --- /dev/null +++ b/internal/devicepolicy/netrc_writer.go @@ -0,0 +1,904 @@ +package devicepolicy + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "path/filepath" + "runtime" + "strings" + "unicode/utf8" + + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/secureuserfile" +) + +const ( + dmgNetrcBegin = "#stepsecurity-pypi-credential-dmg-begin" + dmgNetrcEnd = "#stepsecurity-pypi-credential-end" + + mdmNetrcBegin = "#stepsecurity-pypi-credential-mdm-begin" + mdmNetrcEnd = "#stepsecurity-pypi-credential-end" + + dmgNetrcDisabledPrefix = "#stepsecurity-pypi-credential-dmg-disabled:" + mdmNetrcDisabledPrefix = "#stepsecurity-pypi-credential-mdm-disabled:" + mdmNetrcCreated = "#stepsecurity-pypi-credential-mdm-created" + netrcBackupPrefix = ".dmg-" +) + +// NetrcWriter owns only the exact registry host entry inside one user's netrc. +type NetrcWriter struct { + file *secureuserfile.File + alternate *secureuserfile.File + host string + token string + expected string + lookupEnv func(string) string +} + +func NewNetrcWriter(home *secureuserfile.Home, policy PyPIPolicy) (*NetrcWriter, error) { + if home == nil { + return nil, errors.New("netrc: nil secure user home") + } + registry, registryErr := parsePyPIRegistryURL(policy.RegistryURL) + host := policy.RegistryHost() + token := policy.DeviceToken() + if policy.Ecosystem != "pypi" || !canonicalPyPIClients(policy.Clients) || policy.Auth.Scheme != pypiAuthScheme || + registryErr != nil || registry.EscapedPath() != "/python/simple" || policy.Auth.APIKey == "" || + len(policy.Auth.APIKey) > npmrcMaxKeyBytes || policy.deviceID == "" || len(policy.deviceID) > npmrcMaxSerialBytes || + strings.Contains(policy.Auth.APIKey, "::") || !isNPMSafe(policy.Auth.APIKey) || !isNPMSafe(policy.deviceID) || + !isValidHost(host) || !isNetrcCredential(token) { + return nil, errors.New("netrc: policy cannot render a safe credential entry") + } + expected := renderNetrcEntry(host, token) + + primary, err := home.Open(".netrc", netrcBackupPrefix, secureuserfile.MaxBytes) + if err != nil { + return nil, err + } + w := &NetrcWriter{file: primary, host: host, token: token, expected: expected, lookupEnv: home.Getenv} + if runtime.GOOS != model.PlatformWindows { + return w, nil + } + + alternate, err := home.Open("_netrc", netrcBackupPrefix, secureuserfile.MaxBytes) + if err != nil { + return nil, err + } + _, primaryExists, _, err := primary.Read() + if err != nil { + return nil, err + } + _, alternateExists, _, err := alternate.Read() + if err != nil { + return nil, err + } + if !primaryExists && alternateExists { + w.file, w.alternate = alternate, primary + } else { + w.alternate = alternate + } + return w, nil +} + +func renderNetrcEntry(host, token string) string { + return "machine " + host + "\nlogin step-security\npassword " + token +} + +func isNetrcCredential(value string) bool { + if value == "" || !utf8.ValidString(value) { + return false + } + for i := 0; i < len(value); i++ { + if value[i] < 0x21 || value[i] > 0x7e { + return false + } + } + return true +} + +func (w *NetrcWriter) Location() string { + if w == nil || w.file == nil { + return "" + } + return w.file.Location() +} + +func (w *NetrcWriter) validateExpected(expected string) error { + if w == nil || w.file == nil || expected != w.expected { + return errors.New("netrc: expected entry does not match the validated policy") + } + return nil +} + +// Read returns the DMG-managed entry without its markers. +func (w *NetrcWriter) Read() (string, bool, error) { + analysis, err := w.readSelected() + if err != nil || !analysis.existed || analysis.markers.dmg == nil { + return "", false, err + } + return analysis.markers.dmg.body, true, nil +} + +// Write migrates at most one ordinary exact-host entry and installs the managed entry. +func (w *NetrcWriter) Write(expected string) (string, error) { + if err := w.validateExpected(expected); err != nil { + return "", err + } + if err := w.checkAlternateConflict(); err != nil { + return "", err + } + analysis, err := w.readSelected() + if err != nil { + return "", err + } + if analysis.markers.mdm { + return "", fmt.Errorf("netrc: MDM marker conflicts with DMG ownership: %w", ErrTargetUnusable) + } + + next, err := rewriteNetrc(analysis.data, w.host, expected) + if err != nil { + return "", err + } + if err := w.file.Commit(next, secureuserfile.FileMode); err != nil { + return "", err + } + readback, present, err := w.Read() + if err != nil || !present || readback != expected { + if err == nil { + err = errors.New("netrc: managed credential did not match readback") + } + if restoreErr := w.file.RestoreSnapshot(); restoreErr != nil { + return "", fmt.Errorf("netrc: readback failed and rollback failed: %w", ErrWriteUnverified) + } + return "", err + } + return readback, nil +} + +// Clear removes this lane's block and restores only entries carrying its prefix. +func (w *NetrcWriter) Clear() (bool, error) { + type candidate struct { + file *secureuserfile.File + analysis netrcAnalysis + } + files := []*secureuserfile.File{w.file} + if w.alternate != nil { + files = append(files, w.alternate) + } + candidates := make([]candidate, 0, len(files)) + owned := -1 + conflict := false + for _, file := range files { + data, existed, _, err := file.Read() + if err != nil { + return false, err + } + analysis := netrcAnalysis{existed: existed} + if existed { + analysis, err = analyzeNetrc(data, w.host) + if err != nil { + return false, err + } + analysis.existed = true + } + candidates = append(candidates, candidate{file: file, analysis: analysis}) + if analysis.markers.dmg != nil { + if owned >= 0 { + return false, fmt.Errorf("netrc: multiple managed credential files: %w", ErrTargetUnusable) + } + owned = len(candidates) - 1 + } else if analysis.markers.mdm || len(exactHostEntries(analysis.entries, w.host)) != 0 { + conflict = true + } + } + if owned >= 0 { + for i, candidate := range candidates { + if i != owned && (candidate.analysis.markers.dmg != nil || candidate.analysis.markers.mdm || len(exactHostEntries(candidate.analysis.entries, w.host)) != 0) { + return false, fmt.Errorf("netrc: alternate credential file conflicts with managed file: %w", ErrTargetUnusable) + } + } + } else if conflict { + return false, fmt.Errorf("netrc: exact-host credential exists without an owned block: %w", ErrTargetUnusable) + } + purge := func() error { + var errs []error + for _, candidate := range candidates { + if err := candidate.file.PurgeBackups(); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) + } + if owned < 0 { + return false, purge() + } + target := candidates[owned] + next, changed, err := clearNetrc(target.analysis.data, w.host) + if err != nil || !changed { + return false, err + } + rest, _ := stripBOM(next) + if len(bytes.TrimSpace(rest)) == 0 { + err = target.file.Remove() + } else { + err = target.file.Commit(next, secureuserfile.FileMode) + } + if err != nil { + return false, err + } + if err := purge(); err != nil { + return false, errors.Join(fmt.Errorf("netrc: purge backups: %w", err), target.file.RestoreSnapshot()) + } + return true, nil +} + +func (w *NetrcWriter) RestoreSnapshot() error { return w.file.RestoreSnapshot() } + +func (w *NetrcWriter) Converged(expected string) (bool, error) { + if err := w.validateExpected(expected); err != nil { + return false, err + } + if err := w.checkAlternateConflict(); err != nil { + return false, err + } + analysis, err := w.readSelected() + if err != nil || !analysis.existed { + return false, err + } + if analysis.markers.mdm || analysis.markers.dmg == nil || analysis.markers.dmg.body != expected { + return false, nil + } + entries := exactHostEntries(analysis.entries, w.host) + if len(entries) > 1 { + return false, fmt.Errorf("netrc: duplicate exact-host entries: %w", ErrTargetUnusable) + } + if len(entries) != 1 || !entryMatches(entries[0], w.host, "step-security", w.token) { + return false, nil + } + if w.netrcOverrideActive() { + return false, nil + } + return w.file.MetadataSecure(secureuserfile.FileMode) +} + +// Observation returns only a secret-free credential verdict. +func (w *NetrcWriter) Observation(expected string) (string, error) { + if err := w.validateExpected(expected); err != nil { + return authTokenUnreadable, err + } + if err := w.checkAlternateConflict(); err != nil { + return authTokenUnreadable, err + } + analysis, err := w.readSelected() + if err != nil { + return authTokenUnreadable, err + } + if !analysis.existed { + return authTokenAbsent, nil + } + entries := exactHostEntries(analysis.entries, w.host) + if len(entries) == 0 { + return authTokenAbsent, nil + } + if len(entries) > 1 { + return authTokenUnreadable, fmt.Errorf("netrc: duplicate exact-host entries: %w", ErrTargetUnusable) + } + if !entryMatches(entries[0], w.host, "step-security", w.token) || w.netrcOverrideActive() { + return authTokenMismatch, nil + } + secure, err := w.file.MetadataSecure(secureuserfile.FileMode) + if err != nil { + return authTokenUnreadable, err + } + if !secure { + return authTokenMismatch, nil + } + return authTokenMatch, nil +} + +func (w *NetrcWriter) MDMOwned() (bool, error) { + analysis, err := w.readSelected() + if err != nil || !analysis.existed || analysis.markers.mdmBlock == nil { + return false, err + } + entries, err := parseNetrc([]byte(analysis.markers.mdmBlock.body)) + if err != nil { + return false, err + } + return len(exactHostEntries(entries, w.host)) == 1, nil +} + +func (w *NetrcWriter) HasMDMMarker() (bool, error) { + for _, file := range []*secureuserfile.File{w.file, w.alternate} { + if file == nil { + continue + } + data, existed, _, err := file.Read() + if err != nil { + return false, err + } + if !existed { + continue + } + analysis, err := analyzeNetrc(data, w.host) + if err != nil { + return false, err + } + if analysis.markers.mdm { + return true, nil + } + } + return false, nil +} + +func (w *NetrcWriter) netrcOverrideActive() bool { + if w.lookupEnv == nil { + return false + } + override := strings.TrimSpace(w.lookupEnv("NETRC")) + if override == "" { + return false + } + if !filepath.IsAbs(override) { + absolute, err := filepath.Abs(override) + if err != nil { + return true + } + override = absolute + } + return filepath.Clean(override) != filepath.Clean(w.Location()) +} + +func (w *NetrcWriter) checkAlternateConflict() error { + if w.alternate == nil { + return nil + } + data, existed, _, err := w.alternate.Read() + if err != nil || !existed { + return err + } + analysis, err := analyzeNetrc(data, w.host) + if err != nil { + return err + } + if analysis.markers.dmg != nil || analysis.markers.mdm || len(exactHostEntries(analysis.entries, w.host)) != 0 { + return fmt.Errorf("netrc: alternate credential file conflicts with selected file: %w", ErrTargetUnusable) + } + return nil +} + +func (w *NetrcWriter) readSelected() (netrcAnalysis, error) { + if w == nil || w.file == nil { + return netrcAnalysis{}, errors.New("netrc: nil writer") + } + data, existed, _, err := w.file.Read() + if err != nil || !existed { + return netrcAnalysis{existed: existed}, err + } + analysis, err := analyzeNetrc(data, w.host) + analysis.existed = true + return analysis, err +} + +const authTokenUnreadable = "unreadable" + +type netrcAnalysis struct { + data []byte + existed bool + entries []netrcEntry + markers netrcMarkers +} + +type netrcMarkers struct { + dmg *netrcManagedBlock + mdmBlock *netrcManagedBlock + dmgDisabled *netrcDisabledEntry + mdmDisabled *netrcDisabledEntry + mdm bool +} + +type netrcDisabledEntry struct { + line netrcLine + decoded []byte +} + +type netrcManagedBlock struct { + start int + end int + body string +} + +type netrcEntry struct { + host string + login, account, pass string + startToken, startLine, end int + isDefault bool +} + +func analyzeNetrc(data []byte, host string) (netrcAnalysis, error) { + markers, err := scanNetrcMarkers(data) + if err != nil { + return netrcAnalysis{}, err + } + for _, disabled := range []*netrcDisabledEntry{markers.dmgDisabled, markers.mdmDisabled} { + if disabled != nil { + if err := validateNetrcDisabledEntry(disabled.decoded, host); err != nil { + return netrcAnalysis{}, err + } + } + } + rest, _ := stripBOM(data) + entries, err := parseNetrc(rest) + if err != nil { + return netrcAnalysis{}, err + } + for _, block := range []*netrcManagedBlock{markers.dmg, markers.mdmBlock} { + if block == nil { + continue + } + bodyEntries, err := parseNetrc([]byte(block.body)) + if err != nil || len(bodyEntries) != 1 || bodyEntries[0].isDefault { + return netrcAnalysis{}, fmt.Errorf("netrc: malformed managed credential block: %w", ErrTargetUnusable) + } + } + return netrcAnalysis{data: data, existed: true, entries: entries, markers: markers}, nil +} + +func exactHostEntries(entries []netrcEntry, host string) []netrcEntry { + out := make([]netrcEntry, 0, 1) + for _, entry := range entries { + if !entry.isDefault && entry.host == host { + out = append(out, entry) + } + } + return out +} + +func entryMatches(entry netrcEntry, host, login, password string) bool { + return !entry.isDefault && entry.host == host && entry.login == login && entry.pass == password +} + +func rewriteNetrc(data []byte, host, expected string) ([]byte, error) { + analysis, err := analyzeNetrc(data, host) + if err != nil { + return nil, err + } + if len(exactHostEntries(analysis.entries, host)) > 1 { + return nil, fmt.Errorf("netrc: duplicate exact-host entries: %w", ErrTargetUnusable) + } + rest, bom := stripBOM(data) + if analysis.markers.dmg != nil { + rest = append(append([]byte(nil), rest[:analysis.markers.dmg.start]...), rest[analysis.markers.dmg.end:]...) + } + entries, err := parseNetrc(rest) + if err != nil { + return nil, err + } + exact := exactHostEntries(entries, host) + if len(exact) > 1 { + return nil, fmt.Errorf("netrc: duplicate exact-host entries: %w", ErrTargetUnusable) + } + if len(exact) == 1 { + entry := exact[0] + if entry.end <= entry.startLine || len(bytes.TrimSpace(rest[entry.startLine:entry.startToken])) != 0 { + return nil, fmt.Errorf("netrc: exact-host entry shares a line with another entry: %w", ErrTargetUnusable) + } + rest = encodeNetrcEntry(rest, entry.startLine, entry.end) + } + + newline := netrcNewline(data) + var out bytes.Buffer + out.Write(bom) + out.Write(rest) + if len(rest) != 0 { + // This separator belongs to the managed block, so clear can remove it exactly. + out.WriteString(newline) + } + out.WriteString(dmgNetrcBegin) + out.WriteString(newline) + out.WriteString(strings.ReplaceAll(expected, "\n", newline)) + out.WriteString(newline) + out.WriteString(dmgNetrcEnd) + out.WriteString(newline) + return out.Bytes(), nil +} + +func clearNetrc(data []byte, host string) ([]byte, bool, error) { + analysis, err := analyzeNetrc(data, host) + if err != nil { + return nil, false, err + } + rest, bom := stripBOM(data) + changed := false + if analysis.markers.dmg != nil { + rest = append(append([]byte(nil), rest[:analysis.markers.dmg.start]...), rest[analysis.markers.dmg.end:]...) + changed = true + } + restored, decoded, err := decodeNetrcEntries(rest, dmgNetrcDisabledPrefix) + if err != nil { + return nil, false, err + } + changed = changed || decoded + if !changed { + return data, false, nil + } + if _, err := parseNetrc(restored); err != nil { + return nil, false, err + } + return append(append([]byte(nil), bom...), restored...), true, nil +} + +func encodeNetrcEntry(data []byte, start, end int) []byte { + entry := data[start:end] + var terminator []byte + switch { + case bytes.HasSuffix(entry, []byte("\r\n")): + terminator = []byte("\r\n") + case bytes.HasSuffix(entry, []byte("\n")): + terminator = []byte("\n") + } + encoded := base64.RawURLEncoding.EncodeToString(entry) + out := make([]byte, 0, len(data)-len(entry)+len(dmgNetrcDisabledPrefix)+len(encoded)+len(terminator)) + out = append(out, data[:start]...) + out = append(out, dmgNetrcDisabledPrefix...) + out = append(out, encoded...) + out = append(out, terminator...) + out = append(out, data[end:]...) + return out +} + +func decodeNetrcEntries(data []byte, prefix string) ([]byte, bool, error) { + lines := splitNetrcLines(data) + var out bytes.Buffer + changed := false + for _, line := range lines { + content := data[line.start:line.contentEnd] + if !bytes.HasPrefix(content, []byte(prefix)) { + out.Write(data[line.start:line.end]) + continue + } + decoded, err := decodeNetrcDisabledEntry(string(content[len(prefix):])) + if err != nil { + return nil, false, err + } + out.Write(decoded) + changed = true + } + return out.Bytes(), changed, nil +} + +func decodeNetrcDisabledEntry(encoded string) ([]byte, error) { + if encoded == "" || base64.RawURLEncoding.DecodedLen(len(encoded)) > secureuserfile.MaxBytes { + return nil, fmt.Errorf("netrc: invalid disabled credential entry size: %w", ErrTargetUnusable) + } + decoded, err := base64.RawURLEncoding.Strict().DecodeString(encoded) + if err != nil || base64.RawURLEncoding.EncodeToString(decoded) != encoded { + return nil, fmt.Errorf("netrc: malformed disabled credential entry: %w", ErrTargetUnusable) + } + return decoded, nil +} + +func validateNetrcDisabledEntry(data []byte, host string) error { + if len(data) > secureuserfile.MaxBytes || hasNetrcOwnershipLine(data) { + return fmt.Errorf("netrc: invalid disabled credential entry: %w", ErrTargetUnusable) + } + entries, err := parseNetrc(data) + if err != nil || len(entries) != 1 || entries[0].isDefault || entries[0].host != host || + entries[0].startLine != 0 || entries[0].end != len(data) { + return fmt.Errorf("netrc: disabled credential entry is not one exact-host entry: %w", ErrTargetUnusable) + } + return nil +} + +func hasNetrcOwnershipLine(data []byte) bool { + for _, line := range splitNetrcLines(data) { + text := strings.TrimSpace(string(data[line.start:line.contentEnd])) + if text == dmgNetrcBegin || text == mdmNetrcBegin || text == dmgNetrcEnd || text == mdmNetrcCreated || + strings.HasPrefix(text, dmgNetrcDisabledPrefix) || strings.HasPrefix(text, mdmNetrcDisabledPrefix) { + return true + } + } + return false +} + +type netrcLine struct { + start, contentEnd, end int +} + +func splitNetrcLines(data []byte) []netrcLine { + lines := make([]netrcLine, 0, bytes.Count(data, []byte("\n"))+1) + for start := 0; start < len(data); { + i := bytes.IndexByte(data[start:], '\n') + end := len(data) + contentEnd := end + if i >= 0 { + end = start + i + 1 + contentEnd = end - 1 + if contentEnd > start && data[contentEnd-1] == '\r' { + contentEnd-- + } + } + lines = append(lines, netrcLine{start: start, contentEnd: contentEnd, end: end}) + start = end + } + return lines +} + +func scanNetrcMarkers(data []byte) (netrcMarkers, error) { + rest, _ := stripBOM(data) + if !utf8.Valid(rest) || bytes.IndexByte(rest, 0) >= 0 || hasLoneCR(string(rest)) { + return netrcMarkers{}, fmt.Errorf("netrc: invalid text encoding or line endings: %w", ErrTargetUnusable) + } + lines := splitNetrcLines(rest) + var begins, ends, mdmBegins, mdmCreated []netrcLine + var dmgDisabled, mdmDisabled []netrcDisabledEntry + for _, line := range lines { + raw := string(rest[line.start:line.contentEnd]) + text := strings.TrimSpace(raw) + switch { + case text == dmgNetrcBegin: + if raw != text { + return netrcMarkers{}, fmt.Errorf("netrc: managed marker contains whitespace: %w", ErrTargetUnusable) + } + begins = append(begins, line) + case text == dmgNetrcEnd: + if raw != text { + return netrcMarkers{}, fmt.Errorf("netrc: managed marker contains whitespace: %w", ErrTargetUnusable) + } + ends = append(ends, line) + case text == mdmNetrcBegin: + if raw != text { + return netrcMarkers{}, fmt.Errorf("netrc: managed marker contains whitespace: %w", ErrTargetUnusable) + } + mdmBegins = append(mdmBegins, line) + case text == mdmNetrcCreated: + if raw != text { + return netrcMarkers{}, fmt.Errorf("netrc: created marker contains whitespace: %w", ErrTargetUnusable) + } + mdmCreated = append(mdmCreated, line) + case strings.HasPrefix(text, dmgNetrcDisabledPrefix): + if raw != text { + return netrcMarkers{}, fmt.Errorf("netrc: disabled credential entry contains whitespace: %w", ErrTargetUnusable) + } + decoded, err := decodeNetrcDisabledEntry(strings.TrimPrefix(text, dmgNetrcDisabledPrefix)) + if err != nil { + return netrcMarkers{}, err + } + dmgDisabled = append(dmgDisabled, netrcDisabledEntry{line: line, decoded: decoded}) + case strings.HasPrefix(text, mdmNetrcDisabledPrefix): + if raw != text { + return netrcMarkers{}, fmt.Errorf("netrc: disabled credential entry contains whitespace: %w", ErrTargetUnusable) + } + decoded, err := decodeNetrcDisabledEntry(strings.TrimPrefix(text, mdmNetrcDisabledPrefix)) + if err != nil { + return netrcMarkers{}, err + } + mdmDisabled = append(mdmDisabled, netrcDisabledEntry{line: line, decoded: decoded}) + } + } + if len(begins) > 1 || len(mdmBegins) > 1 || len(ends) > 1 || len(mdmCreated) > 1 || len(dmgDisabled) > 1 || len(mdmDisabled) > 1 || + (len(begins) != 0 && len(mdmBegins) != 0) || (len(dmgDisabled) != 0 && len(mdmDisabled) != 0) { + return netrcMarkers{}, fmt.Errorf("netrc: duplicate or conflicting managed ownership: %w", ErrTargetUnusable) + } + if len(begins) == 0 && len(mdmBegins) == 0 { + if len(ends) != 0 || len(mdmCreated) != 0 || len(dmgDisabled) != 0 || len(mdmDisabled) != 0 { + return netrcMarkers{}, fmt.Errorf("netrc: orphaned managed ownership: %w", ErrTargetUnusable) + } + return netrcMarkers{}, nil + } + if len(begins) == 1 { + if len(ends) != 1 || begins[0].start >= ends[0].start || len(mdmCreated) != 0 || len(mdmDisabled) != 0 || + (len(dmgDisabled) == 1 && dmgDisabled[0].line.start >= begins[0].start) { + return netrcMarkers{}, fmt.Errorf("netrc: malformed or crossed-lane managed ownership: %w", ErrTargetUnusable) + } + block, err := netrcMarkerBlock(rest, begins[0], ends[0]) + markers := netrcMarkers{dmg: block} + if len(dmgDisabled) == 1 { + markers.dmgDisabled = &dmgDisabled[0] + } + return markers, err + } + if len(ends) != 1 || mdmBegins[0].start >= ends[0].start || len(dmgDisabled) != 0 || + (len(mdmDisabled) == 1 && mdmDisabled[0].line.start >= mdmBegins[0].start) || + (len(mdmCreated) == 1 && (mdmCreated[0].start <= mdmBegins[0].start || mdmCreated[0].start >= ends[0].start)) { + return netrcMarkers{}, fmt.Errorf("netrc: malformed or crossed-lane managed ownership: %w", ErrTargetUnusable) + } + block, err := netrcMarkerBlock(rest, mdmBegins[0], ends[0]) + markers := netrcMarkers{mdm: true, mdmBlock: block} + if len(mdmDisabled) == 1 { + markers.mdmDisabled = &mdmDisabled[0] + } + return markers, err +} + +func netrcMarkerBlock(data []byte, begin, end netrcLine) (*netrcManagedBlock, error) { + bodyBytes := trimOneNetrcNewline(data[begin.end:end.start]) + body := strings.ReplaceAll(string(bodyBytes), "\r\n", "\n") + blockStart := begin.start + if blockStart > 0 { + switch { + case blockStart >= 2 && bytes.Equal(data[blockStart-2:blockStart], []byte("\r\n")): + blockStart -= 2 + case data[blockStart-1] == '\n': + blockStart-- + default: + return nil, fmt.Errorf("netrc: managed marker is not line-delimited: %w", ErrTargetUnusable) + } + } + return &netrcManagedBlock{start: blockStart, end: end.end, body: body}, nil +} + +func trimOneNetrcNewline(data []byte) []byte { + if bytes.HasSuffix(data, []byte("\r\n")) { + return data[:len(data)-2] + } + if bytes.HasSuffix(data, []byte("\n")) { + return data[:len(data)-1] + } + return data +} + +func netrcNewline(data []byte) string { + if bytes.Contains(data, []byte("\r\n")) { + return "\r\n" + } + return "\n" +} + +type netrcToken struct { + value string + start int + lineStart int +} + +func lexNetrc(data []byte) ([]netrcToken, error) { + if !utf8.Valid(data) || bytes.IndexByte(data, 0) >= 0 || hasLoneCR(string(data)) { + return nil, fmt.Errorf("netrc: invalid text encoding or line endings: %w", ErrTargetUnusable) + } + var tokens []netrcToken + for i := 0; i < len(data); { + for i < len(data) { + if data[i] == ' ' || data[i] == '\t' || data[i] == '\n' || data[i] == '\r' { + i++ + continue + } + break + } + if i >= len(data) { + break + } + start := i + lineStart := bytes.LastIndexByte(data[:start], '\n') + 1 + var value strings.Builder + quote := byte(0) + for i < len(data) { + c := data[i] + if quote != 0 { + switch c { + case quote: + quote = 0 + i++ + case '\\': + if i+1 >= len(data) || data[i+1] == '\n' || data[i+1] == '\r' { + return nil, fmt.Errorf("netrc: malformed quoted token: %w", ErrTargetUnusable) + } + value.WriteByte(data[i+1]) + i += 2 + default: + value.WriteByte(c) + i++ + } + continue + } + switch c { + case '\'', '"': + quote = c + i++ + case '\\': + if i+1 >= len(data) || data[i+1] == '\n' || data[i+1] == '\r' { + return nil, fmt.Errorf("netrc: malformed escaped token: %w", ErrTargetUnusable) + } + value.WriteByte(data[i+1]) + i += 2 + case ' ', '\t', '\n', '\r': + goto tokenDone + default: + value.WriteByte(c) + i++ + } + } + tokenDone: + if quote != 0 || value.Len() == 0 { + return nil, fmt.Errorf("netrc: malformed tokenization: %w", ErrTargetUnusable) + } + tokens = append(tokens, netrcToken{value: value.String(), start: start, lineStart: lineStart}) + } + return tokens, nil +} + +func skipNetrcLine(tokens []netrcToken, i int) int { + lineStart := tokens[i].lineStart + for i < len(tokens) && tokens[i].lineStart == lineStart { + i++ + } + return i +} + +func parseNetrc(data []byte) ([]netrcEntry, error) { + tokens, err := lexNetrc(data) + if err != nil { + return nil, err + } + entries := make([]netrcEntry, 0) + for i := 0; i < len(tokens); { + token := tokens[i] + if strings.HasPrefix(token.value, "#") { + i = skipNetrcLine(tokens, i) + continue + } + entry := netrcEntry{startToken: token.start, startLine: token.lineStart} + switch token.value { + case "machine": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("netrc: machine has no name: %w", ErrTargetUnusable) + } + entry.host = tokens[i].value + i++ + case "default": + entry.isDefault = true + i++ + case "macdef": + return nil, fmt.Errorf("netrc: macdef is unsupported: %w", ErrTargetUnusable) + default: + return nil, fmt.Errorf("netrc: directive outside an entry: %w", ErrTargetUnusable) + } + + seen := map[string]bool{} + for i < len(tokens) && tokens[i].value != "machine" && tokens[i].value != "default" && tokens[i].value != "macdef" { + if strings.HasPrefix(tokens[i].value, "#") { + i = skipNetrcLine(tokens, i) + continue + } + directive := tokens[i].value + if directive != "login" && directive != "account" && directive != "password" { + return nil, fmt.Errorf("netrc: unsupported entry directive: %w", ErrTargetUnusable) + } + if seen[directive] { + return nil, fmt.Errorf("netrc: duplicate entry directive: %w", ErrTargetUnusable) + } + seen[directive] = true + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("netrc: entry directive has no value: %w", ErrTargetUnusable) + } + value := tokens[i].value + switch directive { + case "login": + entry.login = value + case "account": + entry.account = value + case "password": + entry.pass = value + } + i++ + } + entry.end = len(data) + if i < len(tokens) { + entry.end = tokens[i].lineStart + } + entries = append(entries, entry) + } + defaultSeen := false + for _, entry := range entries { + if entry.isDefault { + if defaultSeen { + return nil, fmt.Errorf("netrc: duplicate default entries: %w", ErrTargetUnusable) + } + defaultSeen = true + } + } + return entries, nil +} diff --git a/internal/devicepolicy/netrc_writer_test.go b/internal/devicepolicy/netrc_writer_test.go new file mode 100644 index 00000000..14b7317f --- /dev/null +++ b/internal/devicepolicy/netrc_writer_test.go @@ -0,0 +1,734 @@ +package devicepolicy + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/secureuserfile" +) + +const netrcExpected = "machine registry.stepsecurity.io\nlogin step-security\npassword step_acme-1_uuid::dev:DEVICE-123" + +func TestNetrcMarkers_Canonical(t *testing.T) { + tests := []struct { + name string + got string + want string + }{ + {"DMG begin", dmgNetrcBegin, "#stepsecurity-pypi-credential-dmg-begin"}, + {"DMG end", dmgNetrcEnd, "#stepsecurity-pypi-credential-end"}, + {"MDM begin", mdmNetrcBegin, "#stepsecurity-pypi-credential-mdm-begin"}, + {"MDM end", mdmNetrcEnd, "#stepsecurity-pypi-credential-end"}, + {"DMG disabled prefix", dmgNetrcDisabledPrefix, "#stepsecurity-pypi-credential-dmg-disabled:"}, + {"MDM disabled prefix", mdmNetrcDisabledPrefix, "#stepsecurity-pypi-credential-mdm-disabled:"}, + {"MDM created", mdmNetrcCreated, "#stepsecurity-pypi-credential-mdm-created"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("marker = %q, want %q", tc.got, tc.want) + } + }) + } +} + +func TestNetrcWriter_CredentialOwnershipLinesAreSingleTokens(t *testing.T) { + tests := []struct { + name string + initial string + wantLines int + }{ + { + name: "ordinary entry before managed block", + initial: "machine other.example login user password secret\n", + wantLines: 2, + }, + { + name: "displaced exact-host entry", + initial: "machine registry.stepsecurity.io login old password old-secret\nmachine other.example login user password secret\n", + wantLines: 3, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, path := newNetrcTestWriter(t, []byte(tc.initial)) + if _, err := w.Write(netrcExpected); err != nil { + t.Fatalf("Write: %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var ownershipLines []string + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSuffix(line, "\r") + if strings.HasPrefix(line, "#") && strings.Contains(strings.ToLower(line), "credential") { + ownershipLines = append(ownershipLines, line) + } + } + if len(ownershipLines) != tc.wantLines { + t.Fatalf("credential ownership lines = %q, want %d", ownershipLines, tc.wantLines) + } + for _, line := range ownershipLines { + if !strings.HasPrefix(line, "#stepsecurity-pypi-credential") || strings.ContainsAny(line, " \t\r") { + t.Errorf("credential ownership line %q is not one whitespace-free token", line) + } + } + }) + } +} + +func newNetrcTestWriter(t *testing.T, initial []byte) (*NetrcWriter, string) { + t.Helper() + t.Setenv("NETRC", "") + home := t.TempDir() + if initial != nil { + if err := os.WriteFile(filepath.Join(home, ".netrc"), initial, 0o600); err != nil { + t.Fatalf("seed .netrc: %v", err) + } + } + h := newSecureTestHome(t, home) + w, err := NewNetrcWriter(h, netrcTestPolicy(t)) + if err != nil { + t.Fatalf("NewNetrcWriter: %v", err) + } + w.lookupEnv = os.Getenv + return w, filepath.Join(home, ".netrc") +} + +func netrcTestPolicy(t *testing.T) PyPIPolicy { + t.Helper() + policy, err := ParsePyPIPolicy(json.RawMessage(`{"ecosystem":"pypi","clients":["pip","uv"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`), "DEVICE-123") + if err != nil { + t.Fatalf("ParsePyPIPolicy: %v", err) + } + return policy +} + +func TestNetrcWriter_PreservesOrdinaryGrammarAndClearRestores(t *testing.T) { + initial := []byte("\ufeff# keep this comment\r\nmachine files.example login \"user name\" account deploy password \"secret with space\"\r\ndefault login fallback password fallback-secret\r\n") + w, path := newNetrcTestWriter(t, initial) + + got, err := w.Write(netrcExpected) + if err != nil { + t.Fatalf("Write: %v", err) + } + if got != netrcExpected { + t.Fatalf("Write readback = %q, want exact managed entry", got) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(content), string(initial)) { + t.Fatalf("unrelated netrc bytes changed:\n%s", content) + } + if !strings.Contains(string(content), "\r\n"+dmgNetrcBegin+"\r\n") || strings.Contains(strings.ReplaceAll(string(content), "\r\n", ""), "\n") { + t.Fatalf("managed block did not preserve CRLF style:\n%q", content) + } + if converged, err := w.Converged(netrcExpected); err != nil || !converged { + t.Fatalf("Converged = %v, %v, want true", converged, err) + } + + changed, err := w.Clear() + if err != nil { + t.Fatalf("Clear: %v", err) + } + if !changed { + t.Fatal("Clear changed = false, want true") + } + restored, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(restored) != string(initial) { + t.Fatalf("clear restored %q, want exact original %q", restored, initial) + } +} + +func TestNetrcWriter_MigratesOneExactHostReversibly(t *testing.T) { + initial := []byte("# before\nmachine registry.stepsecurity.io\n login old-user\n account old-account\n password old-secret\nmachine other.example login other password other-secret") + w, path := newNetrcTestWriter(t, initial) + + if _, err := w.Write(netrcExpected); err != nil { + t.Fatalf("Write: %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + displaced := "machine registry.stepsecurity.io\n login old-user\n account old-account\n password old-secret\n" + var encoded string + for _, line := range strings.Split(string(content), "\n") { + if strings.HasPrefix(line, dmgNetrcDisabledPrefix) { + encoded = strings.TrimPrefix(line, dmgNetrcDisabledPrefix) + break + } + } + decoded, err := base64.RawURLEncoding.Strict().DecodeString(encoded) + if err != nil { + t.Fatalf("disabled ownership record is not canonical base64url: %v", err) + } + if string(decoded) != displaced { + t.Fatalf("disabled ownership record decodes to %q, want exact %q", decoded, displaced) + } + if !strings.Contains(string(content), "machine other.example login other password other-secret") { + t.Fatalf("unrelated host was not preserved:\n%s", content) + } + if _, err := w.Write(netrcExpected); err != nil { + t.Fatalf("idempotent Write: %v", err) + } + content, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(content), dmgNetrcDisabledPrefix); got != 1 { + t.Fatalf("disabled ownership records after idempotent write = %d, want 1", got) + } + if changed, err := w.Clear(); err != nil || !changed { + t.Fatalf("Clear = %v, %v, want changed", changed, err) + } + restored, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(restored) != string(initial) { + t.Fatalf("clear restored %q, want exact original %q", restored, initial) + } +} + +func TestNetrcWriter_EncodedEntryRoundTripsExactBytes(t *testing.T) { + tests := []struct { + name string + initial []byte + }{ + {"LF one-line final newline", []byte("machine registry.stepsecurity.io login old password secret\n")}, + {"CRLF multiline", []byte("machine registry.stepsecurity.io\r\n\tlogin old\r\n\tpassword secret\r\n")}, + {"UTF-8 BOM and indentation", append([]byte{0xef, 0xbb, 0xbf}, []byte(" machine registry.stepsecurity.io login old password secret\n")...)}, + {"no final newline", []byte("machine registry.stepsecurity.io login old password secret")}, + {"surrounding entries", []byte("# before\nmachine first.example login one password one\nmachine registry.stepsecurity.io\n login old\n password secret\nmachine last.example login last password last\n# after\n")}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, path := newNetrcTestWriter(t, tc.initial) + if _, err := w.Write(netrcExpected); err != nil { + t.Fatalf("Write: %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(content), dmgNetrcDisabledPrefix); got != 1 { + t.Fatalf("disabled ownership records = %d, want 1", got) + } + if changed, err := w.Clear(); err != nil || !changed { + t.Fatalf("Clear = %v, %v, want changed", changed, err) + } + restored, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(restored, tc.initial) { + t.Fatalf("clear restored %q, want exact original %q", restored, tc.initial) + } + }) + } +} + +func TestNetrcWriter_KeyRotationRetainsOneEncodedEntry(t *testing.T) { + initial := []byte("machine registry.stepsecurity.io login old password secret\n") + w, path := newNetrcTestWriter(t, initial) + if _, err := w.Write(netrcExpected); err != nil { + t.Fatalf("initial Write: %v", err) + } + rotatedPolicy, err := ParsePyPIPolicy(json.RawMessage(`{"ecosystem":"pypi","clients":["pip","uv"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_rotated"}}`), "DEVICE-123") + if err != nil { + t.Fatal(err) + } + rotated, err := NewNetrcWriter(newSecureTestHome(t, filepath.Dir(path)), rotatedPolicy) + if err != nil { + t.Fatal(err) + } + rotated.lookupEnv = os.Getenv + rotatedExpected := renderNetrcEntry(rotatedPolicy.RegistryHost(), rotatedPolicy.DeviceToken()) + if _, err := rotated.Write(rotatedExpected); err != nil { + t.Fatalf("rotated Write: %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(content), dmgNetrcDisabledPrefix); got != 1 { + t.Fatalf("disabled ownership records after rotation = %d, want 1", got) + } + if changed, err := rotated.Clear(); err != nil || !changed { + t.Fatalf("Clear = %v, %v, want changed", changed, err) + } + restored, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(restored, initial) { + t.Fatalf("clear restored %q, want exact original %q", restored, initial) + } +} + +func TestNetrcWriter_CreatesRotatesAndRemovesCredential(t *testing.T) { + w, path := newNetrcTestWriter(t, nil) + if w.Location() != path { + t.Fatalf("Location = %q, want %q", w.Location(), path) + } + if _, err := w.Write(netrcExpected); err != nil { + t.Fatalf("Write: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if enforcePOSIXMetadata && info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %#o, want 0600", info.Mode().Perm()) + } + + rotated := strings.Replace(netrcExpected, "step_acme-1_uuid", "step_acme-1_rotated", 1) + _, writeErr := w.Write(rotated) + if writeErr == nil { + t.Fatal("Write accepted an entry different from the constructor policy") + } + if strings.Contains(writeErr.Error(), "step_acme") { + t.Fatalf("Write error leaked credential material: %v", writeErr) + } + + if changed, err := w.Clear(); err != nil || !changed { + t.Fatalf("Clear = %v, %v, want changed", changed, err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("credential-only file remains after clear: %v", err) + } + if backups, err := filepath.Glob(path + ".dmg-*.bak"); err != nil || len(backups) != 0 { + t.Fatalf("backups after clear = %v, %v, want none", backups, err) + } + staleBackup := path + ".dmg-stale.bak" + if err := os.WriteFile(staleBackup, []byte("stale protected credential backup"), 0o600); err != nil { + t.Fatal(err) + } + if changed, err := w.Clear(); err != nil || changed { + t.Fatalf("absent-file Clear = %v, %v, want unchanged", changed, err) + } + if _, err := os.Stat(staleBackup); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale credential backup remains after clear: %v", err) + } +} + +func TestParseNetrc_PreservesHashInPasswords(t *testing.T) { + tests := []struct { + name string + password string + }{ + {"embedded hash", "pa#ss"}, + {"leading hash", "#secret"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + data := []byte("# comment\nmachine other.example login user password " + tc.password + "\n") + entries, err := parseNetrc(data) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].pass != tc.password { + t.Fatalf("entries = %+v, want password %q", entries, tc.password) + } + }) + } +} + +func TestNetrcWriter_RejectsAmbiguousOrMalformedInput(t *testing.T) { + tests := []struct { + name string + body string + }{ + {"duplicate exact host", "machine registry.stepsecurity.io login one password old\nmachine registry.stepsecurity.io login two password old"}, + {"exact host shares a line", "machine other.example login u password p machine registry.stepsecurity.io login stale password old"}, + {"macdef", "macdef init\necho unsafe\n\nmachine other.example login u password p\n"}, + {"unterminated quote", "machine other.example login \"unterminated"}, + {"missing directive value", "machine other.example login"}, + {"unknown directive", "machine other.example protocol https"}, + {"duplicate default", "default login one\ndefault login two"}, + {"duplicate begin marker", dmgNetrcBegin + "\n" + dmgNetrcBegin + "\n" + netrcExpected + "\n" + dmgNetrcEnd + "\n"}, + {"duplicate end marker", dmgNetrcBegin + "\n" + netrcExpected + "\n" + dmgNetrcEnd + "\n" + dmgNetrcEnd + "\n"}, + {"incomplete MDM marker", mdmNetrcBegin + "\n" + netrcExpected + "\n"}, + {"end before begin", dmgNetrcEnd + "\n" + dmgNetrcBegin + "\n" + netrcExpected + "\n"}, + {"lone carriage return", "machine other.example\rlogin user"}, + {"invalid utf8", string([]byte{0xff, 0xfe})}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, path := newNetrcTestWriter(t, []byte(tc.body)) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(netrcExpected); err == nil { + t.Fatal("Write error = nil, want fail-closed refusal") + } else if strings.Contains(err.Error(), "step_acme") || strings.Contains(err.Error(), "old-secret") { + t.Fatalf("Write error leaked credential material: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("refused write changed file: before=%q after=%q", before, after) + } + }) + } +} + +func TestScanNetrcMarkers_RejectsDisabledRecordInsideOrAfterManagedBlock(t *testing.T) { + encoded := base64.RawURLEncoding.EncodeToString([]byte("machine registry.stepsecurity.io login old password old-secret\n")) + dmgRecord := dmgNetrcDisabledPrefix + encoded + mdmRecord := mdmNetrcDisabledPrefix + encoded + tests := []struct { + name string + data string + }{ + {"DMG record inside block", dmgNetrcBegin + "\n" + dmgRecord + "\n" + netrcExpected + "\n" + dmgNetrcEnd + "\n"}, + {"DMG record after block", dmgNetrcBegin + "\n" + netrcExpected + "\n" + dmgNetrcEnd + "\n" + dmgRecord + "\n"}, + {"MDM record inside block", mdmNetrcBegin + "\n" + mdmRecord + "\n" + netrcExpected + "\n" + mdmNetrcEnd + "\n"}, + {"MDM record after block", mdmNetrcBegin + "\n" + netrcExpected + "\n" + mdmNetrcEnd + "\n" + mdmRecord + "\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := scanNetrcMarkers([]byte(tc.data)); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("scanNetrcMarkers error = %v, want ErrTargetUnusable", err) + } + }) + } +} + +func TestNetrcWriter_RejectsInvalidEncodedOwnershipWithoutMutation(t *testing.T) { + encode := func(data string) string { + return base64.RawURLEncoding.EncodeToString([]byte(data)) + } + exact := "machine registry.stepsecurity.io login old password old-secret\n" + valid := encode(exact) + block := func(record string) string { + return record + "\n" + dmgNetrcBegin + "\n" + netrcExpected + "\n" + dmgNetrcEnd + "\n" + } + tests := []struct { + name string + initial string + }{ + {"malformed base64url", block(dmgNetrcDisabledPrefix + "%%%")}, + {"padded base64url", block(dmgNetrcDisabledPrefix + valid + "=")}, + {"duplicate records", block(dmgNetrcDisabledPrefix + valid + "\n" + dmgNetrcDisabledPrefix + valid)}, + {"wrong-lane record", block(mdmNetrcDisabledPrefix + valid)}, + {"wrong-lane created marker", block(mdmNetrcCreated)}, + {"orphan record", dmgNetrcDisabledPrefix + valid + "\nmachine other.example login user password secret\n"}, + {"leading record whitespace", block(" " + dmgNetrcDisabledPrefix + valid)}, + {"trailing record whitespace", block(dmgNetrcDisabledPrefix + valid + " ")}, + {"leading marker whitespace", dmgNetrcDisabledPrefix + valid + "\n " + dmgNetrcBegin + "\n" + netrcExpected + "\n" + dmgNetrcEnd + "\n"}, + {"decoded invalid UTF-8", block(dmgNetrcDisabledPrefix + base64.RawURLEncoding.EncodeToString([]byte{0xff}))}, + {"decoded NUL", block(dmgNetrcDisabledPrefix + base64.RawURLEncoding.EncodeToString([]byte("machine registry.stepsecurity.io login u password p\x00")))}, + {"decoded lone carriage return", block(dmgNetrcDisabledPrefix + encode("machine registry.stepsecurity.io\rlogin u password p"))}, + {"decoded wrong host", block(dmgNetrcDisabledPrefix + encode("machine other.example login u password p\n"))}, + {"decoded default entry", block(dmgNetrcDisabledPrefix + encode("default login u password p\n"))}, + {"decoded multiple entries", block(dmgNetrcDisabledPrefix + encode("machine registry.stepsecurity.io login u password p\nmachine other.example login u password p\n"))}, + {"decoded hidden MDM ownership", block(dmgNetrcDisabledPrefix + encode(exact+mdmNetrcCreated+"\n"))}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, path := newNetrcTestWriter(t, []byte(tc.initial)) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := w.Clear(); err == nil { + t.Fatal("Clear error = nil, want fail-closed refusal") + } else if strings.Contains(err.Error(), "old-secret") { + t.Fatalf("Clear error leaked displaced credential material: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatalf("refused clear changed file: before=%q after=%q", before, after) + } + }) + } +} + +func TestDecodeNetrcEntries_RejectsOversizedDecodedContent(t *testing.T) { + payload := base64.RawURLEncoding.EncodeToString(make([]byte, secureuserfile.MaxBytes+1)) + if _, _, err := decodeNetrcEntries([]byte(dmgNetrcDisabledPrefix+payload), dmgNetrcDisabledPrefix); err == nil { + t.Fatal("decodeNetrcEntries error = nil, want oversized refusal") + } +} + +func TestNetrcWriter_ObservationUsesExactTokenAndNETRCOverride(t *testing.T) { + w, _ := newNetrcTestWriter(t, nil) + if status, err := w.Observation(netrcExpected); err != nil || status != authTokenAbsent { + t.Fatalf("absent Observation = %q, %v", status, err) + } + if _, err := w.Write(netrcExpected); err != nil { + t.Fatal(err) + } + if status, err := w.Observation(netrcExpected); err != nil || status != authTokenMatch { + t.Fatalf("matching Observation = %q, %v", status, err) + } + + prefixOnly := strings.TrimSuffix(netrcExpected, "DEVICE-123") + content, err := os.ReadFile(w.Location()) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(w.Location(), bytes.Replace(content, []byte(netrcExpected), []byte(prefixOnly), 1), 0o600); err != nil { + t.Fatal(err) + } + if status, err := w.Observation(netrcExpected); err != nil || status != authTokenMismatch { + t.Fatalf("prefix-only on-disk Observation = %q, %v, want mismatch", status, err) + } + if _, err := w.Write(netrcExpected); err != nil { + t.Fatalf("repair after prefix-only token: %v", err) + } + + t.Setenv("NETRC", filepath.Join(t.TempDir(), "alternate.netrc")) + if status, err := w.Observation(netrcExpected); err != nil || status != authTokenMismatch { + t.Fatalf("NETRC override Observation = %q, %v, want mismatch", status, err) + } + t.Setenv("NETRC", w.Location()) + if status, err := w.Observation(netrcExpected); err != nil || status != authTokenMatch { + t.Fatalf("exact NETRC Observation = %q, %v, want match", status, err) + } +} + +func TestNetrcWriter_SecurityRefusalsAndPermissionRepair(t *testing.T) { + t.Run("non-regular", func(t *testing.T) { + w, path := newNetrcTestWriter(t, nil) + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if _, err := w.Write(netrcExpected); !errors.Is(err, secureuserfile.ErrTargetUnusable) { + t.Fatalf("Write error = %v, want secureuserfile.ErrTargetUnusable", err) + } + }) + + t.Run("symlink escape", func(t *testing.T) { + w, path := newNetrcTestWriter(t, nil) + outside := filepath.Join(t.TempDir(), "outside.netrc") + if err := os.WriteFile(outside, []byte("machine other.example login u password p\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, path); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if _, err := w.Write(netrcExpected); !errors.Is(err, secureuserfile.ErrTargetUnusable) { + t.Fatalf("Write error = %v, want secureuserfile.ErrTargetUnusable", err) + } + }) + + t.Run("oversized", func(t *testing.T) { + w, path := newNetrcTestWriter(t, nil) + if err := os.WriteFile(path, []byte(strings.Repeat("x", secureuserfile.MaxBytes+1)), 0o600); err != nil { + t.Fatal(err) + } + if _, err := w.Write(netrcExpected); !errors.Is(err, secureuserfile.ErrTargetUnusable) { + t.Fatalf("Write error = %v, want secureuserfile.ErrTargetUnusable", err) + } + }) + + if runtime.GOOS != "windows" { + t.Run("loose mode repaired", func(t *testing.T) { + w, path := newNetrcTestWriter(t, []byte("machine other.example login u password p\n")) + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if converged, err := w.Converged(netrcExpected); err != nil || converged { + t.Fatalf("Converged before repair = %v, %v, want false", converged, err) + } + if _, err := w.Write(netrcExpected); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode after write = %#o, want 0600", info.Mode().Perm()) + } + }) + } +} + +func TestNetrcWriter_OwnershipStateNeverPersistsCredential(t *testing.T) { + t.Setenv("NETRC", "") + homeDir := t.TempDir() + home := newSecureTestHome(t, homeDir) + withTempCache(t) + if err := WriteAppliedState(CategoryPackageConfig, TargetPyPI, AppliedTargetState{ + AppliedHash: "sibling", + WrittenSettings: map[string]string{"keep": "non-secret"}, + }); err != nil { + t.Fatal(err) + } + + run := func(raw, hash string) *NetrcWriter { + t.Helper() + policy, err := ParsePyPIPolicy(json.RawMessage(raw), "DEVICE-123") + if err != nil { + t.Fatal(err) + } + writer, err := NewNetrcWriter(home, policy) + if err != nil { + t.Fatal(err) + } + r := &Reconciler{ + Fetcher: &fakeFetcher{ep: EffectivePolicy{ + Category: CategoryPackageConfig, + Target: TargetPyPI, + Policy: json.RawMessage(raw), + Hash: hash, + }}, + Writer: writer, + Category: CategoryPackageConfig, + Target: TargetPyPI, + OwnershipTarget: PyPICredentialOwnershipTarget, + OwnershipStateValue: PyPICredentialOwnershipValue, + OwnershipKey: "credential", + OwnsByMarker: true, + Render: func(raw json.RawMessage) (string, error) { + parsed, err := ParsePyPIPolicy(raw, "DEVICE-123") + if err != nil { + return "", err + } + return renderNetrcEntry(parsed.RegistryHost(), parsed.DeviceToken()), nil + }, + Converged: writer.Converged, + RestoreSnapshot: writer.RestoreSnapshot, + Probe: func() (bool, string) { return false, "" }, + Now: func() time.Time { return time.Date(2026, 8, 26, 0, 0, 0, 0, time.UTC) }, + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + assertNetrcStateSecretFree(t, pypiKey, policy.DeviceToken()) + return writer + } + + oldRaw := `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}` + writer := run(oldRaw, "sha256:OLD") + run(oldRaw, "sha256:OLD") // idempotent convergence + + content, err := os.ReadFile(writer.Location()) + if err != nil { + t.Fatal(err) + } + content = bytes.Replace(content, []byte("step_acme-1_uuid::dev:DEVICE-123"), []byte("tampered"), 1) + if err := os.WriteFile(writer.Location(), content, 0o600); err != nil { + t.Fatal(err) + } + run(oldRaw, "sha256:OLD") // same-hash drift repair + + newRaw := `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_rotated"}}` + writer = run(newRaw, "sha256:NEW") + assertNetrcStateSecretFree(t, "step_rotated", "step_rotated::dev:DEVICE-123") + + r := &Reconciler{ + Fetcher: &fakeFetcher{ep: EffectivePolicy{Category: CategoryPackageConfig, Target: TargetPyPI, Clear: true}}, + Writer: writer, + Category: CategoryPackageConfig, + Target: TargetPyPI, + OwnershipTarget: PyPICredentialOwnershipTarget, + OwnershipStateValue: PyPICredentialOwnershipValue, + OwnershipKey: "credential", + OwnsByMarker: true, + RestoreSnapshot: writer.RestoreSnapshot, + Probe: func() (bool, string) { return false, "" }, + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("clear Reconcile: %v", err) + } + assertNetrcStateSecretFree(t, pypiKey, "step_rotated", "::dev:") + if _, ok := ReadAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget); ok { + t.Fatal("credential ownership state remains after clear") + } +} + +func assertNetrcStateSecretFree(t *testing.T, forbidden ...string) { + t.Helper() + state, err := os.ReadFile(CachePath()) + if err != nil { + t.Fatalf("read complete ownership state: %v", err) + } + for _, value := range forbidden { + if value != "" && bytes.Contains(state, []byte(value)) { + t.Fatalf("ownership state contains credential material %q: %s", value, state) + } + } +} + +func TestNetrcWriter_MDMOwnershipRequiresExactHostInsideBlock(t *testing.T) { + tests := []struct { + name string + initial string + }{ + {"unmarked", netrcExpected + "\n"}, + {"marker around other host", mdmNetrcBegin + "\nmachine other.example login step-security password other\n" + mdmNetrcEnd + "\n" + netrcExpected + "\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, _ := newNetrcTestWriter(t, []byte(tc.initial)) + owned, err := w.MDMOwned() + if err != nil { + t.Fatal(err) + } + if owned { + t.Fatal("MDMOwned = true, want false") + } + }) + } +} + +func TestNetrcWriter_AcceptsValidMDMEncodedOwnership(t *testing.T) { + displaced := []byte("machine registry.stepsecurity.io login old password old-secret\n") + encoded := base64.RawURLEncoding.EncodeToString(displaced) + initial := []byte(mdmNetrcDisabledPrefix + encoded + "\n" + mdmNetrcBegin + "\n" + mdmNetrcCreated + "\n" + netrcExpected + "\n" + mdmNetrcEnd + "\n") + w, _ := newNetrcTestWriter(t, initial) + hardenSecureTestFile(t, w.file) + if present, err := w.HasMDMMarker(); err != nil || !present { + t.Fatalf("HasMDMMarker = %v, %v, want true", present, err) + } + if owned, err := w.MDMOwned(); err != nil || !owned { + t.Fatalf("MDMOwned = %v, %v, want true", owned, err) + } + if status, err := w.Observation(netrcExpected); err != nil || status != authTokenMatch { + t.Fatalf("Observation = %q, %v, want match", status, err) + } +} + +func TestNetrcWriter_ReadAndMDMMarker(t *testing.T) { + w, _ := newNetrcTestWriter(t, []byte(mdmNetrcBegin+"\n"+netrcExpected+"\n"+mdmNetrcEnd+"\n")) + hardenSecureTestFile(t, w.file) + if present, err := w.HasMDMMarker(); err != nil || !present { + t.Fatalf("HasMDMMarker = %v, %v, want true", present, err) + } + if owned, err := w.MDMOwned(); err != nil || !owned { + t.Fatalf("MDMOwned = %v, %v, want true", owned, err) + } + if _, present, err := w.Read(); err != nil || present { + t.Fatalf("Read DMG block = present %v, %v, want absent", present, err) + } + if status, err := w.Observation(netrcExpected); err != nil || status != authTokenMatch { + t.Fatalf("MDM credential Observation = %q, %v, want match", status, err) + } +} diff --git a/internal/devicepolicy/netrc_writer_windows_test.go b/internal/devicepolicy/netrc_writer_windows_test.go new file mode 100644 index 00000000..36655867 --- /dev/null +++ b/internal/devicepolicy/netrc_writer_windows_test.go @@ -0,0 +1,238 @@ +//go:build windows + +package devicepolicy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +func TestNetrcWriter_WindowsPathSelectionAndAlternateConflict(t *testing.T) { + t.Run("existing underscore file is selected", func(t *testing.T) { + home := t.TempDir() + underscore := filepath.Join(home, "_netrc") + if err := os.WriteFile(underscore, []byte("machine other.example login u password p\r\n"), 0o600); err != nil { + t.Fatal(err) + } + w, err := NewNetrcWriter(newSecureTestHome(t, home), netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if w.Location() != underscore { + t.Fatalf("Location = %q, want existing %q", w.Location(), underscore) + } + if _, err := w.Write(netrcExpected); err != nil { + t.Fatalf("Write: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".netrc")); !os.IsNotExist(err) { + t.Fatalf("Write created a second netrc file: %v", err) + } + }) + + t.Run("dot file wins when both exist", func(t *testing.T) { + home := t.TempDir() + for _, name := range []string{".netrc", "_netrc"} { + if err := os.WriteFile(filepath.Join(home, name), []byte("machine other.example login u password p\r\n"), 0o600); err != nil { + t.Fatal(err) + } + } + w, err := NewNetrcWriter(newSecureTestHome(t, home), netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if filepath.Base(w.Location()) != ".netrc" { + t.Fatalf("Location = %q, want preferred .netrc", w.Location()) + } + }) + + t.Run("unused alternate exact host fails closed", func(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, ".netrc"), []byte("machine other.example login u password p\r\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, "_netrc"), []byte("machine registry.stepsecurity.io login stale password old-secret\r\n"), 0o600); err != nil { + t.Fatal(err) + } + w, err := NewNetrcWriter(newSecureTestHome(t, home), netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(netrcExpected); err == nil { + t.Fatal("Write error = nil, want alternate-file conflict") + } else if strings.Contains(err.Error(), "old-secret") { + t.Fatalf("alternate conflict leaked credential: %v", err) + } + }) +} + +func TestNetrcWriter_WindowsClearFindsOwnedFile(t *testing.T) { + t.Run("managed underscore survives selection change", func(t *testing.T) { + home := t.TempDir() + underscore := filepath.Join(home, "_netrc") + initial := []byte("machine other.example login u password p\r\n") + if err := os.WriteFile(underscore, initial, 0o600); err != nil { + t.Fatal(err) + } + writer, err := NewNetrcWriter(newSecureTestHome(t, home), netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if _, err := writer.Write(netrcExpected); err != nil { + t.Fatal(err) + } + dot := filepath.Join(home, ".netrc") + dotContent := []byte("machine dot.example login u password p\r\n") + if err := os.WriteFile(dot, dotContent, 0o600); err != nil { + t.Fatal(err) + } + writer, err = NewNetrcWriter(newSecureTestHome(t, home), netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + changed, err := writer.Clear() + if err != nil || !changed { + t.Fatalf("Clear = %v, %v, want managed alternate cleared", changed, err) + } + got, err := os.ReadFile(underscore) + if err != nil { + t.Fatal(err) + } + if string(got) != string(initial) { + t.Fatalf("underscore = %q, want restored %q", got, initial) + } + got, err = os.ReadFile(dot) + if err != nil || string(got) != string(dotContent) { + t.Fatalf("dot file changed: %q, %v", got, err) + } + }) + + t.Run("alternate exact host blocks clear", func(t *testing.T) { + home := t.TempDir() + underscore := filepath.Join(home, "_netrc") + if err := os.WriteFile(underscore, []byte("machine other.example login u password p\r\n"), 0o600); err != nil { + t.Fatal(err) + } + writer, err := NewNetrcWriter(newSecureTestHome(t, home), netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if _, err := writer.Write(netrcExpected); err != nil { + t.Fatal(err) + } + dot := filepath.Join(home, ".netrc") + conflict := []byte("machine registry.stepsecurity.io login other password keep\r\n") + if err := os.WriteFile(dot, conflict, 0o600); err != nil { + t.Fatal(err) + } + writer, err = NewNetrcWriter(newSecureTestHome(t, home), netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if _, err := writer.Clear(); err == nil { + t.Fatal("Clear error = nil, want alternate exact-host conflict") + } + got, err := os.ReadFile(dot) + if err != nil || string(got) != string(conflict) { + t.Fatalf("conflicting file changed: %q, %v", got, err) + } + }) + + t.Run("two managed files block clear", func(t *testing.T) { + home := t.TempDir() + underscore := filepath.Join(home, "_netrc") + if err := os.WriteFile(underscore, []byte("machine other.example login u password p\r\n"), 0o600); err != nil { + t.Fatal(err) + } + writer, err := NewNetrcWriter(newSecureTestHome(t, home), netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if _, err := writer.Write(netrcExpected); err != nil { + t.Fatal(err) + } + managed, err := os.ReadFile(underscore) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".netrc"), managed, 0o600); err != nil { + t.Fatal(err) + } + writer, err = NewNetrcWriter(newSecureTestHome(t, home), netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if _, err := writer.Clear(); err == nil { + t.Fatal("Clear error = nil, want conflicting managed files") + } + }) +} + +func TestNetrcWriter_WindowsACLRejectsUnexpectedReader(t *testing.T) { + w, path := newNetrcTestWriter(t, nil) + if _, err := w.Write(netrcExpected); err != nil { + t.Fatal(err) + } + if converged, err := w.Converged(netrcExpected); err != nil || !converged { + t.Fatalf("Converged after secure write = %v, %v, want true", converged, err) + } + + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + t.Fatal(err) + } + targetSID, _, err := descriptor.Owner() + if err != nil || targetSID == nil { + t.Fatalf("target owner: %v", err) + } + systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatal(err) + } + everyoneSID, err := windows.CreateWellKnownSid(windows.WinWorldSid) + if err != nil { + t.Fatal(err) + } + acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{ + netrcTestExplicitAccess(targetSID, windows.GENERIC_ALL, windows.TRUSTEE_IS_USER), + netrcTestExplicitAccess(systemSID, windows.GENERIC_ALL, windows.TRUSTEE_IS_WELL_KNOWN_GROUP), + netrcTestExplicitAccess(everyoneSID, windows.GENERIC_READ, windows.TRUSTEE_IS_WELL_KNOWN_GROUP), + }, nil) + if err != nil { + t.Fatal(err) + } + if err := windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + acl, + nil, + ); err != nil { + t.Fatal(err) + } + + if converged, err := w.Converged(netrcExpected); err != nil || converged { + t.Fatalf("Converged with unexpected reader = %v, %v, want false", converged, err) + } + if status, err := w.Observation(netrcExpected); err != nil || status != authTokenMismatch { + t.Fatalf("Observation with unexpected reader = %q, %v, want mismatch", status, err) + } +} + +func netrcTestExplicitAccess(sid *windows.SID, permissions windows.ACCESS_MASK, trusteeType windows.TRUSTEE_TYPE) windows.EXPLICIT_ACCESS { + return windows.EXPLICIT_ACCESS{ + AccessPermissions: permissions, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: trusteeType, + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + } +} diff --git a/internal/devicepolicy/pip_writer.go b/internal/devicepolicy/pip_writer.go new file mode 100644 index 00000000..e6bdc57b --- /dev/null +++ b/internal/devicepolicy/pip_writer.go @@ -0,0 +1,1073 @@ +package devicepolicy + +import ( + "bytes" + "context" + "errors" + "fmt" + "path/filepath" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/secureuserfile" +) + +const ( + dmgPipBegin = "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by dmg" + dmgPipEnd = "# END StepSecurity PyPI Secure Registry pip" + mdmPipBegin = "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by mdm" + mdmPipEnd = "# END StepSecurity PyPI Secure Registry pip" + + dmgPipDisabledPrefix = "# [stepsecurity-pypi-pip-dmg] " + pipBackupPrefix = ".dmg-" + pipAppendMetadata = "# [stepsecurity-pypi-pip-dmg] appended-global" + pipGlobalMetadata = "# [stepsecurity-pypi-pip-dmg] existing-global final-newline=false" + unsafePipRegistryObservation = "\x00" +) + +var pipConflictOptions = map[string]bool{ + "index-url": true, + "extra-index-url": true, + "find-links": true, + "no-index": true, +} + +type PipObservation struct { + RegistryURL string + ConfigStatus string + EffectiveStatus string + OverrideSource string +} + +type pipManagedFile struct { + file *secureuserfile.File + current bool +} + +// PipWriter manages the complete trusted user-tier pip configuration set. +type PipWriter struct { + exec executor.Executor + home *secureuserfile.Home + files []pipManagedFile + invocations [][]string + expected string + registryURL string + lastWritten []*secureuserfile.File +} + +func NewPipWriter(ctx context.Context, exec executor.Executor, home *secureuserfile.Home, policy PyPIPolicy) (*PipWriter, error) { + if home == nil { + return nil, errors.New("pip: nil secure user home") + } + expected, err := renderPipSettings(policy) + if err != nil { + return nil, err + } + discovery, err := configaudit.DiscoverPipUserConfig(ctx, exec) + if err != nil { + return nil, err + } + files := make([]pipManagedFile, 0, len(discovery.AllowedUserPaths)) + for i, path := range discovery.AllowedUserPaths { + relative, err := filepath.Rel(home.Path(), filepath.Clean(path)) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + return nil, fmt.Errorf("pip: discovered user path is outside resolved home: %w", ErrTargetUnusable) + } + file, err := home.Open(relative, pipBackupPrefix, secureuserfile.MaxBytes) + if err != nil { + return nil, err + } + files = append(files, pipManagedFile{file: file, current: i == 0}) + } + if len(files) == 0 { + return nil, errors.New("pip: no trusted user configuration path") + } + return &PipWriter{ + exec: executor.NewUserAwareExecutor(exec, home.Username()), + home: home, + files: files, + invocations: discovery.Invocations, + expected: expected, + registryURL: policy.RegistryURL, + }, nil +} + +func renderPipSettings(policy PyPIPolicy) (string, error) { + registry, err := parsePyPIRegistryURL(policy.RegistryURL) + if policy.Ecosystem != "pypi" || !canonicalPyPIClients(policy.Clients) || policy.Auth.Scheme != pypiAuthScheme || + err != nil || registry.EscapedPath() != "/python/simple" || policy.Auth.APIKey == "" || + len(policy.Auth.APIKey) > npmrcMaxKeyBytes || policy.deviceID == "" || len(policy.deviceID) > npmrcMaxSerialBytes || + strings.Contains(policy.Auth.APIKey, "::") || !isNPMSafe(policy.Auth.APIKey) || !isNPMSafe(policy.deviceID) || + !isValidHost(policy.RegistryHost()) { + return "", errors.New("pip: policy cannot render safe user settings") + } + return "index-url = " + policy.RegistryURL + "\nno-index = false", nil +} + +func (w *PipWriter) validateExpected(expected string) error { + if w == nil || expected != w.expected { + return errors.New("pip: expected settings do not match the validated policy") + } + return nil +} + +func (w *PipWriter) Location() string { + if w == nil { + return "" + } + locations := make([]string, len(w.files)) + for i := range w.files { + locations[i] = w.files[i].file.Location() + } + return strings.Join(locations, ", ") +} + +// Read returns the shared managed body only when every applicable user file has it. +func (w *PipWriter) Read() (string, bool, error) { + selected := 0 + for _, managed := range w.files { + analysis, err := readPipFile(managed.file) + if err != nil { + return "", false, err + } + if !pipFileApplicable(managed.current, analysis) { + continue + } + selected++ + if analysis.markers.dmg == nil || analysis.markers.dmg.body != w.expected { + return "", false, nil + } + } + if selected == 0 { + return "", false, nil + } + return w.expected, true, nil +} + +// Write atomically updates every applicable user file and rolls all of them back on partial failure. +func (w *PipWriter) Write(expected string) (string, error) { + if err := w.validateExpected(expected); err != nil { + return "", err + } + w.lastWritten = nil + for _, managed := range w.files { + analysis, err := readPipFile(managed.file) + if err != nil { + return "", w.rollbackWritten(err) + } + if analysis.markers.mdm { + return "", w.rollbackWritten(fmt.Errorf("pip: MDM marker conflicts with DMG ownership: %w", ErrTargetUnusable)) + } + if !pipFileApplicable(managed.current, analysis) { + continue + } + if !analysis.existed { + if err := w.home.EnsureParent(managed.file.RelativePath()); err != nil { + return "", w.rollbackWritten(err) + } + } + next, err := rewritePipConfig(analysis, expected) + if err != nil { + return "", w.rollbackWritten(err) + } + secure, metadataErr := managed.file.MetadataSecure(secureuserfile.FileMode) + if metadataErr != nil && analysis.existed { + return "", w.rollbackWritten(metadataErr) + } + if analysis.existed && bytes.Equal(next, analysis.data) && secure { + continue + } + if err := managed.file.Commit(next, secureuserfile.FileMode); err != nil { + return "", w.rollbackWritten(err) + } + w.lastWritten = append(w.lastWritten, managed.file) + } + if converged, err := w.StaticConverged(expected); err != nil || !converged { + if err == nil { + err = errors.New("pip: managed settings did not match readback") + } + return "", w.rollbackWritten(err) + } + return expected, nil +} + +func (w *PipWriter) rollbackWritten(cause error) error { + rollbackFailed := false + for i := len(w.lastWritten) - 1; i >= 0; i-- { + if err := w.lastWritten[i].RestoreSnapshot(); err != nil { + rollbackFailed = true + } + } + w.lastWritten = nil + if rollbackFailed { + return fmt.Errorf("pip: multi-file write failed and rollback was incomplete: %w", ErrWriteUnverified) + } + return cause +} + +func (w *PipWriter) RestoreSnapshot() error { + if w == nil || len(w.lastWritten) == 0 { + return errors.New("pip: no snapshots to restore") + } + var firstErr error + for i := len(w.lastWritten) - 1; i >= 0; i-- { + if err := w.lastWritten[i].RestoreSnapshot(); err != nil && firstErr == nil { + firstErr = err + } + } + w.lastWritten = nil + return firstErr +} + +func (w *PipWriter) Clear() (bool, error) { + changed := false + var firstErr error + for _, managed := range w.files { + analysis, err := readPipFile(managed.file) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if !analysis.existed { + if analysis.parentPresent { + if err := managed.file.PurgeBackups(); err != nil && firstErr == nil { + firstErr = err + } + } + continue + } + next, fileChanged, created, err := clearPipConfig(analysis.data) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if fileChanged { + if created && len(bytes.TrimSpace(next)) == 0 { + err = managed.file.Remove() + } else { + err = managed.file.Commit(next, secureuserfile.FileMode) + } + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + changed = true + } + if err := managed.file.PurgeBackups(); err != nil && firstErr == nil { + firstErr = err + } + } + return changed, firstErr +} + +func (w *PipWriter) Converged(expected string) (bool, error) { + return w.StaticConverged(expected) +} + +func (w *PipWriter) StaticConverged(expected string) (bool, error) { + if err := w.validateExpected(expected); err != nil { + return false, err + } + selected := 0 + for _, managed := range w.files { + analysis, err := readPipFile(managed.file) + if err != nil { + return false, err + } + if analysis.markers.mdm { + return false, nil + } + if !pipFileApplicable(managed.current, analysis) { + continue + } + selected++ + if !analysis.existed || analysis.markers.dmg == nil || analysis.markers.dmg.body != expected || analysis.activeConflict { + return false, nil + } + secure, err := managed.file.MetadataSecure(secureuserfile.FileMode) + if err != nil || !secure { + return false, err + } + } + return selected != 0, nil +} + +func (w *PipWriter) MDMOwned() (bool, error) { + selected := 0 + for _, managed := range w.files { + analysis, err := readPipFile(managed.file) + if err != nil { + return false, err + } + if !pipFileApplicable(managed.current, analysis) { + continue + } + selected++ + if !analysis.existed || analysis.markers.mdmBlock == nil { + return false, nil + } + } + return selected != 0, nil +} + +func (w *PipWriter) HasMDMMarker() (bool, error) { + for _, managed := range w.files { + analysis, err := readPipFile(managed.file) + if err != nil { + return false, err + } + if analysis.markers.mdm { + return true, nil + } + } + return false, nil +} + +func pipFileApplicable(current bool, analysis pipAnalysis) bool { + return current || analysis.markers.dmg != nil || analysis.markers.mdm || analysis.existed && analysis.activeConflict +} + +type pipAnalysis struct { + data []byte + existed bool + parentPresent bool + markers pipMarkers + parsed pipINI + activeConflict bool +} + +func readPipFile(file *secureuserfile.File) (pipAnalysis, error) { + parentPresent, err := pipParentPresent(file) + if err != nil || !parentPresent { + return pipAnalysis{parentPresent: parentPresent}, err + } + data, existed, _, err := file.Read() + if err != nil || !existed { + return pipAnalysis{existed: existed, parentPresent: true}, err + } + analysis, err := analyzePipConfig(data) + analysis.parentPresent = true + return analysis, err +} + +func pipParentPresent(file *secureuserfile.File) (bool, error) { + return file.ParentPresent() +} + +func analyzePipConfig(data []byte) (pipAnalysis, error) { + markers, err := scanPipMarkers(data) + if err != nil { + return pipAnalysis{}, err + } + managedBlock := markers.dmg + if managedBlock == nil { + managedBlock = markers.mdmBlock + } + withoutManaged := removePipManagedBlock(data, managedBlock) + parsed, err := parsePipINI(withoutManaged) + if err != nil { + return pipAnalysis{}, err + } + activeConflict := false + for _, option := range parsed.options { + if pipConflictOptions[option.key] { + activeConflict = true + break + } + } + return pipAnalysis{data: data, existed: true, markers: markers, parsed: parsed, activeConflict: activeConflict}, nil +} + +type pipMarkers struct { + dmg *pipManagedBlock + mdmBlock *pipManagedBlock + mdm bool +} + +type pipManagedBlock struct { + start, end int + body string + appendedGlobal bool + createdFile bool + originalFinalNewline bool + existingGlobalNoNewline bool +} + +func scanPipMarkers(data []byte) (pipMarkers, error) { + rest, _ := stripBOM(data) + if !utf8.Valid(rest) || bytes.IndexByte(rest, 0) >= 0 || hasLoneCR(string(rest)) { + return pipMarkers{}, fmt.Errorf("pip: invalid text encoding or line endings: %w", ErrTargetUnusable) + } + lines := splitPipLines(rest) + begin, mdmBegin, end := -1, -1, -1 + for i, line := range lines { + text := strings.TrimSpace(string(rest[line.start:line.contentEnd])) + switch text { + case dmgPipBegin: + if begin >= 0 { + return pipMarkers{}, fmt.Errorf("pip: duplicate DMG begin marker: %w", ErrTargetUnusable) + } + begin = i + case dmgPipEnd: + if end >= 0 { + return pipMarkers{}, fmt.Errorf("pip: duplicate managed end marker: %w", ErrTargetUnusable) + } + end = i + case mdmPipBegin: + if mdmBegin >= 0 { + return pipMarkers{}, fmt.Errorf("pip: duplicate MDM begin marker: %w", ErrTargetUnusable) + } + mdmBegin = i + } + } + if begin >= 0 && mdmBegin >= 0 { + return pipMarkers{}, fmt.Errorf("pip: mixed DMG and MDM markers: %w", ErrTargetUnusable) + } + activeBegin := begin + if mdmBegin >= 0 { + activeBegin = mdmBegin + } + if (activeBegin < 0) != (end < 0) || activeBegin >= end && activeBegin >= 0 { + return pipMarkers{}, fmt.Errorf("pip: malformed managed markers: %w", ErrTargetUnusable) + } + markers := pipMarkers{mdm: mdmBegin >= 0} + if mdmBegin >= 0 { + markers.mdmBlock = pipMarkerBody(rest, lines, mdmBegin, end) + return markers, nil + } + if begin >= 0 { + markers.dmg = pipMarkerBody(rest, lines, begin, end) + } + return markers, nil +} + +func pipMarkerBody(data []byte, lines []pipLine, begin, end int) *pipManagedBlock { + bodyLines := make([]string, 0, end-begin-1) + block := &pipManagedBlock{start: lines[begin].start, end: lines[end].end} + for _, line := range lines[begin+1 : end] { + text := string(data[line.start:line.contentEnd]) + if strings.HasPrefix(text, pipAppendMetadata) { + block.appendedGlobal = true + block.createdFile = strings.Contains(text, "created=true") + block.originalFinalNewline = strings.Contains(text, "final-newline=true") + continue + } + if text == pipGlobalMetadata { + block.existingGlobalNoNewline = true + continue + } + if strings.EqualFold(strings.TrimSpace(text), "[global]") { + continue + } + bodyLines = append(bodyLines, strings.TrimRight(text, "\r")) + } + block.body = strings.Join(bodyLines, "\n") + return block +} + +type pipLine struct{ start, contentEnd, end int } + +func splitPipLines(data []byte) []pipLine { + if len(data) == 0 { + return nil + } + lines := make([]pipLine, 0, bytes.Count(data, []byte("\n"))+1) + for start := 0; start < len(data); { + i := bytes.IndexByte(data[start:], '\n') + end, contentEnd := len(data), len(data) + if i >= 0 { + end = start + i + 1 + contentEnd = end - 1 + if contentEnd > start && data[contentEnd-1] == '\r' { + contentEnd-- + } + } + lines = append(lines, pipLine{start: start, contentEnd: contentEnd, end: end}) + start = end + } + return lines +} + +func removePipManagedBlock(data []byte, block *pipManagedBlock) []byte { + if block == nil { + return data + } + rest, bom := stripBOM(data) + prefix := append([]byte(nil), rest[:block.start]...) + if block.end == len(rest) && (block.appendedGlobal && !block.originalFinalNewline || block.existingGlobalNoNewline) { + prefix = trimPipFinalNewline(prefix) + } + out := append(prefix, rest[block.end:]...) + return append(append([]byte(nil), bom...), out...) +} + +type pipINI struct { + sections []pipSection + options []pipOption +} + +type pipSection struct { + name string + headerLine int +} + +type pipOption struct { + section string + key, value string + startLine int + endLine int + indent int +} + +func parsePipINI(data []byte) (pipINI, error) { + rest, _ := stripBOM(data) + if !utf8.Valid(rest) || bytes.IndexByte(rest, 0) >= 0 || hasLoneCR(string(rest)) { + return pipINI{}, fmt.Errorf("pip: invalid INI text: %w", ErrTargetUnusable) + } + lines := splitPipLines(rest) + result := pipINI{} + sections := map[string]bool{} + options := map[string]map[string]bool{} + section := "" + lastOption := -1 + for i, line := range lines { + raw := string(rest[line.start:line.contentEnd]) + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") { + lastOption = -1 + continue + } + indent := pipLineIndent(raw) + if lastOption >= 0 && indent > result.options[lastOption].indent { + result.options[lastOption].endLine = i + 1 + continue + } + if strings.HasPrefix(trimmed, "[") { + close := strings.IndexByte(trimmed, ']') + suffix := "" + if close >= 0 { + suffix = strings.TrimSpace(trimmed[close+1:]) + } + if close <= 1 || suffix != "" && !strings.HasPrefix(suffix, "#") && !strings.HasPrefix(suffix, ";") { + return pipINI{}, fmt.Errorf("pip: malformed INI section: %w", ErrTargetUnusable) + } + name := strings.ToLower(strings.TrimSpace(trimmed[1:close])) + if name == "" || sections[name] { + return pipINI{}, fmt.Errorf("pip: duplicate or empty INI section: %w", ErrTargetUnusable) + } + sections[name] = true + options[name] = map[string]bool{} + section = name + result.sections = append(result.sections, pipSection{name: name, headerLine: i}) + lastOption = -1 + continue + } + delimiter := strings.IndexAny(raw, "=:") + if delimiter >= 0 { + if section == "" { + return pipINI{}, fmt.Errorf("pip: option appears before a section: %w", ErrTargetUnusable) + } + key := normalizePipOption(raw[:delimiter]) + if key == "" || options[section][key] { + return pipINI{}, fmt.Errorf("pip: duplicate or empty INI option: %w", ErrTargetUnusable) + } + options[section][key] = true + result.options = append(result.options, pipOption{section: section, key: key, value: strings.TrimSpace(raw[delimiter+1:]), startLine: i, endLine: i + 1, indent: indent}) + lastOption = len(result.options) - 1 + continue + } + return pipINI{}, fmt.Errorf("pip: malformed INI line: %w", ErrTargetUnusable) + } + return result, nil +} + +func normalizePipOption(key string) string { + return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "_", "-") +} + +func pipLineIndent(line string) int { + indent := 0 + for indent < len(line) && (line[indent] == ' ' || line[indent] == '\t') { + indent++ + } + return indent +} + +func rewritePipConfig(analysis pipAnalysis, expected string) ([]byte, error) { + base := removePipManagedBlock(analysis.data, analysis.markers.dmg) + parsed, err := parsePipINI(base) + if err != nil { + return nil, err + } + rest, bom := stripBOM(base) + lines := splitPipLines(rest) + conflictLines := map[int]bool{} + for _, option := range parsed.options { + if !pipConflictOptions[option.key] { + continue + } + for i := option.startLine; i < option.endLine; i++ { + conflictLines[i] = true + } + } + var transformed bytes.Buffer + for i, line := range lines { + if conflictLines[i] { + transformed.WriteString(dmgPipDisabledPrefix) + } + transformed.Write(rest[line.start:line.end]) + } + base = append(append([]byte(nil), bom...), transformed.Bytes()...) + + newline := pipNewline(analysis.data) + body := strings.ReplaceAll(expected, "\n", newline) + parsed, err = parsePipINI(base) + if err != nil { + return nil, err + } + rest, bom = stripBOM(base) + lines = splitPipLines(rest) + globalOffset := -1 + if global := findPipSection(parsed.sections, "global"); global >= 0 { + globalOffset = lines[parsed.sections[global].headerLine].end + } + if globalOffset >= 0 { + global := findPipSection(parsed.sections, "global") + header := lines[parsed.sections[global].headerLine] + headerHasNewline := header.end > header.contentEnd + var out bytes.Buffer + out.Write(bom) + out.Write(rest[:globalOffset]) + if !headerHasNewline { + out.WriteString(newline) + } + out.WriteString(dmgPipBegin + newline) + if !headerHasNewline { + out.WriteString(pipGlobalMetadata + newline) + } + out.WriteString(body + newline + dmgPipEnd + newline) + out.Write(rest[globalOffset:]) + return out.Bytes(), nil + } + + created := !analysis.existed + if analysis.markers.dmg != nil && analysis.markers.dmg.appendedGlobal && len(bytes.TrimSpace(rest)) == 0 { + created = analysis.markers.dmg.createdFile + } + finalNewline := len(rest) != 0 && (bytes.HasSuffix(rest, []byte("\n")) || bytes.HasSuffix(rest, []byte("\r"))) + var out bytes.Buffer + out.Write(bom) + out.Write(rest) + if len(rest) != 0 && !finalNewline { + out.WriteString(newline) + } + out.WriteString(dmgPipBegin + newline) + out.WriteString(pipAppendMetadata + " created=" + strconv.FormatBool(created) + " final-newline=" + strconv.FormatBool(finalNewline) + newline) + out.WriteString("[global]" + newline + body + newline + dmgPipEnd + newline) + return out.Bytes(), nil +} + +func findPipSection(sections []pipSection, name string) int { + for i := range sections { + if sections[i].name == name { + return i + } + } + return -1 +} + +func clearPipConfig(data []byte) ([]byte, bool, bool, error) { + markers, err := scanPipMarkers(data) + if err != nil { + return nil, false, false, err + } + rest, bom := stripBOM(data) + created := false + changed := false + if markers.dmg != nil { + block := markers.dmg + created = block.createdFile + prefix := append([]byte(nil), rest[:block.start]...) + if block.end == len(rest) && (block.appendedGlobal && !block.originalFinalNewline || block.existingGlobalNoNewline) { + prefix = trimPipFinalNewline(prefix) + } + rest = append(prefix, rest[block.end:]...) + changed = true + } + lines := splitPipLines(rest) + var out bytes.Buffer + for _, line := range lines { + content := rest[line.start:line.contentEnd] + if bytes.HasPrefix(content, []byte(dmgPipDisabledPrefix)) { + out.Write(content[len(dmgPipDisabledPrefix):]) + out.Write(rest[line.contentEnd:line.end]) + changed = true + } else { + out.Write(rest[line.start:line.end]) + } + } + result := append(append([]byte(nil), bom...), out.Bytes()...) + if changed { + if _, err := parsePipINI(result); err != nil && len(bytes.TrimSpace(out.Bytes())) != 0 { + return nil, false, false, err + } + } + return result, changed, created, nil +} + +func trimPipFinalNewline(data []byte) []byte { + if bytes.HasSuffix(data, []byte("\r\n")) { + return data[:len(data)-2] + } + if bytes.HasSuffix(data, []byte("\n")) { + return data[:len(data)-1] + } + return data +} + +func pipNewline(data []byte) string { + if bytes.Contains(data, []byte("\r\n")) { + return "\r\n" + } + return "\n" +} + +func (w *PipWriter) Observation(ctx context.Context, expected string) (PipObservation, error) { + observation := PipObservation{OverrideSource: "none"} + if err := w.validateExpected(expected); err != nil { + observation.ConfigStatus = "unreadable" + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, err + } + static, err := w.observedStaticConverged(expected) + if err != nil { + observation.ConfigStatus = "unreadable" + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, err + } + if static { + observation.ConfigStatus = "match" + observation.RegistryURL = w.registryURL + } else if w.anyPipConfigExists() { + observation.ConfigStatus = "mismatch" + registry, err := w.staticRegistryObservation() + if err != nil { + observation.ConfigStatus = "unreadable" + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, err + } + if registry != unsafePipRegistryObservation { + observation.RegistryURL = registry + } + } else { + observation.ConfigStatus = "absent" + } + + if err := executor.UserEnvironmentError(w.exec); err != nil { + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, err + } + if source := w.environmentOverride(); source != "" { + observation.EffectiveStatus = "mismatch" + observation.OverrideSource = source + return observation, nil + } + if len(w.invocations) == 0 { + observation.EffectiveStatus = "not_installed" + return observation, nil + } + + overall := "match" + for _, invocation := range w.invocations { + status, source, registry := w.probeInvocation(ctx, invocation) + switch registry { + case unsafePipRegistryObservation: + observation.RegistryURL = "" + case "": + default: + observation.RegistryURL = registry + } + if status == "mismatch" { + overall = "mismatch" + if observation.OverrideSource == "none" || observation.OverrideSource == "unknown" { + observation.OverrideSource = source + } + } else if status == "unknown" && overall == "match" { + overall = "unknown" + if source != "none" { + observation.OverrideSource = "unknown" + } + } + } + observation.EffectiveStatus = overall + return observation, nil +} + +func (w *PipWriter) observedStaticConverged(expected string) (bool, error) { + selected := 0 + for _, managed := range w.files { + analysis, err := readPipFile(managed.file) + if err != nil { + return false, err + } + if !pipFileApplicable(managed.current, analysis) { + continue + } + selected++ + block := analysis.markers.dmg + if block == nil { + block = analysis.markers.mdmBlock + } + if !analysis.existed || block == nil || block.body != expected || analysis.activeConflict { + return false, nil + } + secure, err := managed.file.MetadataSecure(secureuserfile.FileMode) + if err != nil || !secure { + return false, err + } + } + return selected != 0, nil +} + +func (w *PipWriter) anyPipConfigExists() bool { + for _, managed := range w.files { + analysis, err := readPipFile(managed.file) + if err == nil && analysis.existed { + return true + } + } + return false +} + +func (w *PipWriter) staticRegistryObservation() (string, error) { + registry := "" + for _, managed := range w.files { + analysis, err := readPipFile(managed.file) + if err != nil { + return "", err + } + if !pipFileApplicable(managed.current, analysis) { + continue + } + block := analysis.markers.dmg + if block == nil { + block = analysis.markers.mdmBlock + } + if block != nil && block.body == w.expected { + registry = w.registryURL + } + for _, option := range analysis.parsed.options { + if option.key == "index-url" { + registry = safePipRegistryURL(option.value) + } + } + } + return registry, nil +} + +func (w *PipWriter) environmentOverride() string { + if index := strings.TrimSpace(w.exec.Getenv("PIP_INDEX_URL")); index != "" && index != w.registryURL { + return "environment" + } + for _, name := range []string{"PIP_EXTRA_INDEX_URL", "PIP_FIND_LINKS"} { + if strings.TrimSpace(w.exec.Getenv(name)) != "" { + return "environment" + } + } + if noIndex := strings.TrimSpace(w.exec.Getenv("PIP_NO_INDEX")); noIndex != "" && !isPipFalse(noIndex) { + return "environment" + } + if strings.TrimSpace(w.exec.Getenv("PIP_CONFIG_FILE")) != "" { + return "explicit_config" + } + if strings.TrimSpace(w.exec.Getenv("VIRTUAL_ENV")) != "" { + return "virtualenv" + } + if netrc := strings.TrimSpace(w.exec.Getenv("NETRC")); netrc != "" { + if !filepath.IsAbs(netrc) { + return "environment" + } + if filepath.Clean(netrc) != filepath.Join(w.home.Path(), ".netrc") { + return "environment" + } + } + return "" +} + +func (w *PipWriter) probeInvocation(ctx context.Context, invocation []string) (status, source, registry string) { + if len(invocation) == 0 { + return "unknown", "unknown", "" + } + name, baseArgs := invocation[0], invocation[1:] + versionArgs := append(append([]string(nil), baseArgs...), "--version") + stdout, _, exit, err := w.exec.RunWithTimeout(ctx, 5*time.Second, name, versionArgs...) + if err != nil || exit != 0 { + return "unknown", "unknown", "" + } + major, minor, ok := parsePipVersion(stdout) + if !ok { + return "unknown", "unknown", "" + } + if major < 20 || major == 20 && minor < 2 { + return "unknown", "none", "" + } + debugArgs := append(append([]string(nil), baseArgs...), "config", "debug") + debug, _, exit, err := w.exec.RunWithTimeout(ctx, 10*time.Second, name, debugArgs...) + if err != nil || exit != 0 || !validPipDebugOutput(debug) { + return "unknown", "unknown", "" + } + listArgs := append(append([]string(nil), baseArgs...), "config", "list", "-v") + listed, _, exit, err := w.exec.RunWithTimeout(ctx, 10*time.Second, name, listArgs...) + if err != nil || exit != 0 { + return "unknown", "unknown", "" + } + entries, ok := parsePipEffectiveOutput(listed) + if !ok { + return "unknown", "unknown", "" + } + indexMatch, noIndexFalse := false, false + for _, entry := range entries { + if !pipConflictOptions[entry.key] { + continue + } + entrySource := classifyPipEntrySource(entry, w.files) + if entry.section != "global" { + if entry.section == ":env:" { + entrySource = "environment" + } else { + entrySource = "command_section" + } + } + switch entry.key { + case "index-url": + if entry.section == "global" && entry.value == w.registryURL { + indexMatch = true + continue + } + return "mismatch", entrySource, safePipRegistryURL(entry.value) + case "no-index": + if entry.section == "global" && isPipFalse(entry.value) { + noIndexFalse = true + continue + } + return "mismatch", entrySource, "" + default: + return "mismatch", entrySource, safePipRegistryURL(entry.value) + } + } + if !indexMatch || !noIndexFalse { + return "mismatch", "unknown", "" + } + return "match", "none", w.registryURL +} + +func parsePipVersion(stdout string) (int, int, bool) { + fields := strings.Fields(strings.TrimSpace(stdout)) + if len(fields) < 2 || fields[0] != "pip" { + return 0, 0, false + } + parts := strings.Split(fields[1], ".") + if len(parts) < 2 { + return 0, 0, false + } + major, errMajor := strconv.Atoi(parts[0]) + minor, errMinor := strconv.Atoi(parts[1]) + return major, minor, errMajor == nil && errMinor == nil +} + +func validPipDebugOutput(stdout string) bool { + for _, line := range strings.Split(strings.ReplaceAll(stdout, "\r\n", "\n"), "\n") { + switch strings.TrimSpace(line) { + case "env_var:", "env:", "global:", "site:", "user:": + return true + } + } + return false +} + +type pipEffectiveEntry struct { + section, key, value, source string +} + +func parsePipEffectiveOutput(stdout string) ([]pipEffectiveEntry, bool) { + entries := make([]pipEffectiveEntry, 0) + for _, raw := range strings.Split(strings.ReplaceAll(stdout, "\r\n", "\n"), "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "For variant ") { + continue + } + eq := strings.IndexByte(line, '=') + if eq <= 0 || eq+1 >= len(line) || line[eq+1] != '\'' { + return nil, false + } + valueAndSource := line[eq+2:] + quote := strings.LastIndex(valueAndSource, "'") + if quote < 0 { + return nil, false + } + value := valueAndSource[:quote] + tail := strings.TrimSpace(valueAndSource[quote+1:]) + source := "" + if tail != "" { + if !strings.HasPrefix(tail, "from ") { + return nil, false + } + source = strings.TrimSpace(strings.TrimPrefix(tail, "from ")) + } + name := line[:eq] + dot := strings.LastIndexByte(name, '.') + if dot <= 0 || dot == len(name)-1 { + return nil, false + } + entries = append(entries, pipEffectiveEntry{section: strings.ToLower(name[:dot]), key: normalizePipOption(name[dot+1:]), value: value, source: source}) + } + return entries, true +} + +func classifyPipEntrySource(entry pipEffectiveEntry, files []pipManagedFile) string { + if entry.source == "" { + return "unknown" + } + if strings.HasPrefix(entry.source, "PIP_") { + return "environment" + } + for _, managed := range files { + if filepath.Clean(entry.source) == filepath.Clean(managed.file.Location()) { + return "none" + } + } + return "system_config" +} + +func safePipRegistryURL(raw string) string { + if raw == "" { + return "" + } + if safe := safeObservedRegistryURL(raw); safe != "" { + return safe + } + return unsafePipRegistryObservation +} + +func isPipFalse(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "0", "false", "no", "off": + return true + default: + return false + } +} diff --git a/internal/devicepolicy/pip_writer_test.go b/internal/devicepolicy/pip_writer_test.go new file mode 100644 index 00000000..d672e154 --- /dev/null +++ b/internal/devicepolicy/pip_writer_test.go @@ -0,0 +1,517 @@ +package devicepolicy + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +const pipExpected = "index-url = https://registry.stepsecurity.io/python/simple\nno-index = false" + +func TestPipMarkers_Canonical(t *testing.T) { + tests := []struct { + name string + got string + want string + }{ + {"DMG begin", dmgPipBegin, "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by dmg"}, + {"DMG end", dmgPipEnd, "# END StepSecurity PyPI Secure Registry pip"}, + {"MDM begin", mdmPipBegin, "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by mdm"}, + {"MDM end", mdmPipEnd, "# END StepSecurity PyPI Secure Registry pip"}, + {"disabled prefix", dmgPipDisabledPrefix, "# [stepsecurity-pypi-pip-dmg] "}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("marker = %q, want %q", tc.got, tc.want) + } + }) + } +} + +func newPipTestWriter(t *testing.T, initial []byte) (*PipWriter, *executor.Mock, string) { + t.Helper() + homeDir := t.TempDir() + path := filepath.Join(homeDir, ".config", "pip", "pip.conf") + if initial != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, initial, 0o600); err != nil { + t.Fatal(err) + } + } + home := newSecureTestHome(t, homeDir) + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetUsername("") + mock.SetHomeDir(homeDir) + writer, err := NewPipWriter(context.Background(), mock, home, netrcTestPolicy(t)) + if err != nil { + t.Fatalf("NewPipWriter: %v", err) + } + writer.exec = mock + return writer, mock, path +} + +func TestPipWriter_TransformsAndRestoresConflicts(t *testing.T) { + initial := []byte("# keep\n[install]\nfind_links: ./wheelhouse\nno_index = true\ntrusted-host = old.example\n[global]\ntimeout = 30\nINDEX_URL = https://old.example/simple\nextra-index-url =\n https://one.example/simple\n https://two.example/simple\n") + w, _, path := newPipTestWriter(t, initial) + + got, err := w.Write(pipExpected) + if err != nil { + t.Fatalf("Write: %v", err) + } + if got != pipExpected { + t.Fatalf("Write = %q, want %q", got, pipExpected) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, marker := range []string{dmgPipBegin, dmgPipEnd} { + if !bytes.Contains(content, []byte(marker)) { + t.Errorf("managed output missing marker %q:\n%s", marker, content) + } + } + for _, line := range []string{ + "INDEX_URL = https://old.example/simple", + "extra-index-url =", + " https://one.example/simple", + " https://two.example/simple", + "find_links: ./wheelhouse", + "no_index = true", + } { + if !bytes.Contains(content, []byte(dmgPipDisabledPrefix+line)) { + t.Errorf("conflict block line %q was not reversibly disabled:\n%s", line, content) + } + } + if !bytes.Contains(content, []byte("[global]\n"+dmgPipBegin+"\n"+pipExpected+"\n"+dmgPipEnd)) { + t.Errorf("managed block was not placed inside existing [global]:\n%s", content) + } + if converged, err := w.Converged(pipExpected); err != nil || !converged { + t.Fatalf("Converged = %v, %v, want true", converged, err) + } + + changed, err := w.Clear() + if err != nil || !changed { + t.Fatalf("Clear = %v, %v, want changed", changed, err) + } + restored, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(restored, initial) { + t.Fatalf("Clear restored:\n%q\nwant:\n%q", restored, initial) + } +} + +func TestPipWriter_AppendsGlobalAndPreservesBOMCRLF(t *testing.T) { + initial := []byte("\ufeff# comment\r\n[download]\r\ntimeout = 15\r\n") + w, _, path := newPipTestWriter(t, initial) + if _, err := w.Write(pipExpected); err != nil { + t.Fatalf("Write: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(got, []byte("\ufeff")) { + t.Fatal("UTF-8 BOM was not preserved") + } + withoutCRLF := bytes.ReplaceAll(got, []byte("\r\n"), nil) + if bytes.Contains(withoutCRLF, []byte{'\n'}) { + t.Fatalf("managed output mixed newline styles: %q", got) + } + if !bytes.Contains(got, []byte("\r\n"+dmgPipBegin+"\r\n"+pipAppendMetadata)) || !bytes.Contains(got, []byte("\r\n[global]\r\n"+strings.ReplaceAll(pipExpected, "\n", "\r\n"))) { + t.Fatalf("missing appended [global] managed block: %q", got) + } + before := append([]byte(nil), got...) + if _, err := w.Write(pipExpected); err != nil { + t.Fatalf("idempotent Write: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatalf("idempotent write changed bytes:\nbefore=%q\nafter=%q", before, after) + } +} + +func TestPipWriter_CommentedGlobalHeaderRoundTrips(t *testing.T) { + initial := []byte("[global] # user comment\ncache-dir = /tmp/cache\n") + w, _, path := newPipTestWriter(t, initial) + if _, err := w.Write(pipExpected); err != nil { + t.Fatalf("Write: %v", err) + } + changed, err := w.Clear() + if err != nil || !changed { + t.Fatalf("Clear = %v, %v", changed, err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, initial) { + t.Fatalf("round trip = %q, want %q", got, initial) + } +} + +func TestPipWriter_GlobalHeaderWithoutFinalNewlineRestoresExactly(t *testing.T) { + initial := []byte("[global]") + w, _, path := newPipTestWriter(t, initial) + if _, err := w.Write(pipExpected); err != nil { + t.Fatalf("Write: %v", err) + } + if _, err := w.Write(pipExpected); err != nil { + t.Fatalf("idempotent Write: %v", err) + } + if changed, err := w.Clear(); err != nil || !changed { + t.Fatalf("Clear = %v, %v, want changed", changed, err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, initial) { + t.Fatalf("Clear restored %q, want %q", got, initial) + } +} + +func TestPipWriter_RefusesMalformedAndDuplicateINI(t *testing.T) { + tests := []struct { + name string + body []byte + }{ + {"option before section", []byte("index-url = https://old.example/simple\n")}, + {"duplicate section", []byte("[global]\ntimeout=1\n[GLOBAL]\ntimeout=2\n")}, + {"normalized duplicate option", []byte("[global]\nindex_url=https://one.example\nINDEX-URL=https://two.example\n")}, + {"orphan continuation", []byte("[global]\n continuation\n")}, + {"malformed section", []byte("[global\ntimeout=1\n")}, + {"lone carriage return", []byte("[global]\rindex-url=x\n")}, + {"invalid UTF-8", []byte{0xff, 0xfe}}, + {"duplicate begin marker", []byte("[global]\n" + dmgPipBegin + "\n" + dmgPipBegin + "\n" + pipExpected + "\n" + dmgPipEnd + "\n")}, + {"MDM marker conflict", []byte("[global]\n" + mdmPipBegin + "\n" + pipExpected + "\n" + mdmPipEnd + "\n")}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, _, path := newPipTestWriter(t, tc.body) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(pipExpected); err == nil { + t.Fatal("Write error = nil, want fail-closed refusal") + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatalf("refused write changed file: before=%q after=%q", before, after) + } + }) + } +} + +func TestPipWriter_DriftRepairAndMultipleUserFiles(t *testing.T) { + homeDir := t.TempDir() + current := filepath.Join(homeDir, ".config", "pip", "pip.conf") + legacy := filepath.Join(homeDir, ".pip", "pip.conf") + for path, body := range map[string]string{ + current: "[global]\ntimeout=30\n", + legacy: "[install]\nextra-index-url=https://legacy.example/simple\n", + } { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + home := newSecureTestHome(t, homeDir) + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetUsername("") + mock.SetHomeDir(homeDir) + mock.SetFile(current, nil) + mock.SetFile(legacy, nil) + w, err := NewPipWriter(context.Background(), mock, home, netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(pipExpected); err != nil { + t.Fatal(err) + } + for _, path := range []string{current, legacy} { + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(content, []byte(dmgPipBegin)) { + t.Errorf("%s was not managed:\n%s", path, content) + } + } + content, err := os.ReadFile(current) + if err != nil { + t.Fatal(err) + } + content = append(content, []byte("extra-index-url=https://drift.example/simple\n")...) + if err := os.WriteFile(current, content, 0o600); err != nil { + t.Fatal(err) + } + if converged, err := w.Converged(pipExpected); err != nil || converged { + t.Fatalf("Converged after drift = %v, %v, want false", converged, err) + } + if _, err := w.Write(pipExpected); err != nil { + t.Fatalf("drift repair: %v", err) + } + if converged, err := w.Converged(pipExpected); err != nil || !converged { + t.Fatalf("Converged after repair = %v, %v, want true", converged, err) + } +} + +func TestPipWriter_MultiFileFailureRollsBackEarlierFiles(t *testing.T) { + homeDir := t.TempDir() + current := filepath.Join(homeDir, ".config", "pip", "pip.conf") + legacy := filepath.Join(homeDir, ".pip", "pip.conf") + initial := map[string][]byte{ + current: []byte("[global]\ntimeout=30\n"), + legacy: []byte("[global]\nextra-index-url=https://legacy.example/simple\n"), + } + for path, body := range initial { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + } + home := newSecureTestHome(t, homeDir) + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetUsername("") + mock.SetHomeDir(homeDir) + mock.SetFile(current, nil) + mock.SetFile(legacy, nil) + w, err := NewPipWriter(context.Background(), mock, home, netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(legacy); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(legacy, 0o700); err != nil { + t.Fatal(err) + } + if _, err := w.Write(pipExpected); err == nil { + t.Fatal("Write error = nil, want second-file refusal") + } + got, err := os.ReadFile(current) + if err != nil { + t.Fatal(err) + } + if want := initial[current]; !bytes.Equal(got, want) { + t.Fatalf("current file after rollback = %q, want %q", got, want) + } +} + +func TestPipWriter_SecurityAndMDMMarker(t *testing.T) { + w, _, path := newPipTestWriter(t, []byte("[global]\ntimeout=30\n")) + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if _, err := w.Write(pipExpected); err != nil { + t.Fatal(err) + } + if enforcePOSIXMetadata { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %#o, want 0600", info.Mode().Perm()) + } + } + if has, err := w.HasMDMMarker(); err != nil || has { + t.Fatalf("HasMDMMarker = %v, %v, want false", has, err) + } + if err := os.WriteFile(path, []byte("[global]\n"+mdmPipBegin+"\n"+pipExpected+"\n"+mdmPipEnd+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if has, err := w.HasMDMMarker(); err != nil || !has { + t.Fatalf("HasMDMMarker = %v, %v, want true", has, err) + } +} + +func TestPipObservation_UserEnvironmentFailureIsUnknown(t *testing.T) { + w, mock, _ := newPipTestWriter(t, nil) + if _, err := w.Write(pipExpected); err != nil { + t.Fatalf("Write: %v", err) + } + w.exec = executor.NewUserAwareExecutor(&failedUserEnvironmentExecutor{Executor: mock}, "alice") + got, err := w.Observation(context.Background(), pipExpected) + if err == nil { + t.Fatal("Observation error = nil, want environment inspection failure") + } + if got.EffectiveStatus != "unknown" || got.OverrideSource != "unknown" { + t.Fatalf("Observation = %+v, want unknown environment", got) + } +} + +func TestPipObservation_VersionBoundaryAndAbsent(t *testing.T) { + tests := []struct { + name string + version string + wantEffective string + }{ + {"pip absent", "", "not_installed"}, + {"pip below 20.2", "19.3.1", "unknown"}, + {"pip 20.2", "20.2", "match"}, + {"current pip", "25.2", "match"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, mock, _ := newPipTestWriter(t, nil) + if _, err := w.Write(pipExpected); err != nil { + t.Fatal(err) + } + if tc.version != "" { + mock.SetPath("pip", "/opt/bin/pip") + mock.SetCommand("pip "+tc.version+" from /opt/pip\n", "", 0, "pip", "--version") + mock.SetCommand("user:\n", "", 0, "pip", "config", "debug") + mock.SetCommand("global.index-url='https://registry.stepsecurity.io/python/simple'\nglobal.no-index='false'\n", "", 0, "pip", "config", "list", "-v") + w.invocations = [][]string{{"pip"}} + } + got, err := w.Observation(context.Background(), pipExpected) + if err != nil { + t.Fatalf("Observation: %v", err) + } + if got.ConfigStatus != "match" || got.EffectiveStatus != tc.wantEffective || got.OverrideSource != "none" { + t.Fatalf("Observation = %+v, want config match, effective %s, no override", got, tc.wantEffective) + } + }) + } +} + +func TestPipObservation_OverridesAndUnknownOutput(t *testing.T) { + tests := []struct { + name string + configure func(*executor.Mock) + listOutput string + wantEffective string + wantOverride string + }{ + {"environment", func(m *executor.Mock) { m.SetEnv("PIP_INDEX_URL", "https://user:SECRET@evil.example/simple") }, "", "mismatch", "environment"}, + {"explicit config", func(m *executor.Mock) { m.SetEnv("PIP_CONFIG_FILE", "/tmp/secret-path") }, "", "mismatch", "explicit_config"}, + {"virtualenv", func(m *executor.Mock) { m.SetEnv("VIRTUAL_ENV", "/tmp/venv") }, "", "mismatch", "virtualenv"}, + {"system config", func(*executor.Mock) {}, "global.index-url='https://evil.example/simple' from /etc/pip.conf\nglobal.no-index='false' from /etc/pip.conf\n", "mismatch", "system_config"}, + {"command section", func(*executor.Mock) {}, "install.index-url='https://evil.example/simple'\nglobal.index-url='https://registry.stepsecurity.io/python/simple'\nglobal.no-index='false'\n", "mismatch", "command_section"}, + {"unknown output", func(*executor.Mock) {}, "changed format without equals\n", "unknown", "unknown"}, + {"userinfo mismatch", func(*executor.Mock) {}, "global.index-url='https://user:SECRET@evil.example/simple'\nglobal.no-index='false'\n", "mismatch", "unknown"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, mock, _ := newPipTestWriter(t, nil) + if _, err := w.Write(pipExpected); err != nil { + t.Fatal(err) + } + mock.SetPath("pip", "/opt/bin/pip") + mock.SetCommand("pip 25.2 from /opt/pip\n", "", 0, "pip", "--version") + mock.SetCommand("user:\n", "", 0, "pip", "config", "debug") + mock.SetCommand(tc.listOutput, "", 0, "pip", "config", "list", "-v") + tc.configure(mock) + w.invocations = [][]string{{"pip"}} + + got, err := w.Observation(context.Background(), pipExpected) + if err != nil { + t.Fatalf("Observation: %v", err) + } + if got.EffectiveStatus != tc.wantEffective || got.OverrideSource != tc.wantOverride { + t.Fatalf("Observation = %+v, want effective=%s override=%s", got, tc.wantEffective, tc.wantOverride) + } + if strings.Contains(got.RegistryURL, "SECRET") || (err != nil && strings.Contains(err.Error(), "SECRET")) { + t.Fatalf("Observation leaked URL userinfo: %+v, %v", got, err) + } + if tc.name == "userinfo mismatch" && got.RegistryURL != "" { + t.Fatalf("userinfo registry URL = %q, want empty", got.RegistryURL) + } + }) + } +} + +func TestPipWriter_MDMOwnershipRejectsOtherLanes(t *testing.T) { + tests := []struct { + name string + initial string + }{ + {"unmarked", "[global]\n" + pipExpected + "\n"}, + {"DMG marker", "[global]\n" + dmgPipBegin + "\n" + pipExpected + "\n" + dmgPipEnd + "\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, _, _ := newPipTestWriter(t, []byte(tc.initial)) + owned, err := w.MDMOwned() + if err != nil { + t.Fatal(err) + } + if owned { + t.Fatal("MDMOwned = true, want false") + } + }) + } +} + +func TestPipObservation_MDMManagedStaticConfiguration(t *testing.T) { + initial := []byte("[global]\n" + mdmPipBegin + "\n" + pipExpected + "\n" + mdmPipEnd + "\n") + w, _, _ := newPipTestWriter(t, initial) + for _, managed := range w.files { + if managed.current { + hardenSecureTestFile(t, managed.file) + } + } + if owned, err := w.MDMOwned(); err != nil || !owned { + t.Fatalf("MDMOwned = %v, %v, want true", owned, err) + } + got, err := w.Observation(context.Background(), pipExpected) + if err != nil { + t.Fatalf("Observation: %v", err) + } + if got.ConfigStatus != "match" || got.EffectiveStatus != "not_installed" || got.RegistryURL != "https://registry.stepsecurity.io/python/simple" { + t.Fatalf("Observation = %+v, want matching MDM static config", got) + } +} + +func TestPipWriter_ExpectedValidationAndSnapshotRestore(t *testing.T) { + w, _, path := newPipTestWriter(t, []byte("[global]\ntimeout=30\n")) + if _, err := w.Write("index-url = https://evil.example/simple\nno-index = false"); err == nil { + t.Fatal("Write accepted settings not rendered from policy") + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(pipExpected); err != nil { + t.Fatal(err) + } + if err := w.RestoreSnapshot(); err != nil { + t.Fatalf("RestoreSnapshot: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatalf("RestoreSnapshot = %q, want %q", after, before) + } + if _, err := w.Write(pipExpected); err != nil && !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("Write after restore: %v", err) + } +} diff --git a/internal/devicepolicy/pypi_coordinator.go b/internal/devicepolicy/pypi_coordinator.go new file mode 100644 index 00000000..528522b4 --- /dev/null +++ b/internal/devicepolicy/pypi_coordinator.go @@ -0,0 +1,715 @@ +package devicepolicy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/secureuserfile" +) + +const ( + pypiCredentialOwnershipKey = "credential" + pypiPipOwnershipKey = "pip" + pypiUVOwnershipKey = "uv" +) + +// PyPICoordinator fetches one PyPI policy and coordinates its three local resources. +type PyPICoordinator struct { + Fetcher Fetcher + Reporter Reporter + Exec executor.Executor + CustomerID string + DeviceID string + Platform string + Logf func(format string, args ...any) + + buildComponents func(context.Context, executor.Executor, PyPIPolicy) (*pypiComponents, error) + writeState func(category, target string, state AppliedTargetState) error + clearState func(category, target string) error +} + +type pypiComponents struct { + credential *pypiComponent + pip *pypiComponent + uv *pypiComponent + close func() error +} + +type pypiComponent struct { + name string + ownershipTarget string + ownershipKey string + ownershipStateValue string + writer Writer + initErr error + expected string + converged func(string) (bool, error) + restoreSnapshot func() error + hasMDMMarker func() (bool, error) + mdmOwned func() (bool, error) + staticConverged func(string) (bool, error) + observe func(context.Context) (componentObservation, error) +} + +type componentObservation struct { + credential string + client *PyPIClientObservation +} + +type componentResult struct { + name string + state string + err error + observation componentObservation + observationErr error + staticConverged bool + staticConvergeErr error +} + +type fixedFetcher struct{ policy EffectivePolicy } + +func (f fixedFetcher) Fetch(_ context.Context, _, _, category, target string) (EffectivePolicy, error) { + if category != CategoryPackageConfig || target != TargetPyPI { + return EffectivePolicy{}, errors.New("devicepolicy: fixed PyPI fetch identity mismatch") + } + return f.policy, nil +} + +type collectingReporter struct{ reports []ComplianceReport } + +func (r *collectingReporter) Report(_ context.Context, _, _ string, report ComplianceReport) error { + r.reports = append(r.reports, report) + return nil +} + +// Reconcile runs one fetch-once PyPI policy cycle. +func (c *PyPICoordinator) Reconcile(ctx context.Context) error { + if c.Fetcher == nil { + return errors.New("devicepolicy: nil PyPI fetcher") + } + effective, err := c.Fetcher.Fetch(ctx, c.CustomerID, c.DeviceID, CategoryPackageConfig, TargetPyPI) + if err != nil { + return fmt.Errorf("devicepolicy: fetch PyPI policy: %w", err) + } + if !effective.present() { + c.logf("devicepolicy: run-config carried no package_config/pypi policy; leaving state untouched") + return nil + } + + enforcement := canonicalEnforcement(effective.Enforcement) + if effective.Clear { + return c.clear(ctx, effective, clearPyPIPolicy()) + } + + policy, err := ParsePyPIPolicy(effective.Policy, c.DeviceID) + if err != nil { + reportErr := c.report(ctx, StatePolicyNotApplied, "", effective.Hash, enforcement, nil) + return errors.Join(err, reportErr) + } + components, err := c.components(ctx, policy) + if err != nil { + state := StateWriteFailed + if errors.Is(err, ErrNoTargetUser) || errors.Is(err, secureuserfile.ErrNoTargetUser) { + state = StatePolicyNotApplied + } + reportErr := c.report(ctx, state, "", effective.Hash, enforcement, nil) + return errors.Join(err, reportErr) + } + if components.close != nil { + defer func() { _ = components.close() }() + } + + effective.Enforcement = enforcement + if enforcement == enforcementMDM { + return c.reconcileMDM(ctx, effective, policy, components) + } + return c.reconcileDMG(ctx, effective, policy, components) +} + +func (c *PyPICoordinator) clear(ctx context.Context, effective EffectivePolicy, policy PyPIPolicy) error { + components, err := c.components(ctx, policy) + if err != nil { + if errors.Is(err, ErrNoTargetUser) || errors.Is(err, secureuserfile.ErrNoTargetUser) { + return fmt.Errorf("devicepolicy: PyPI clear requires an enforceable target user: %w", err) + } + return err + } + if components.close != nil { + defer func() { _ = components.close() }() + } + effective.Enforcement = enforcementDMG + var errs []error + for _, component := range []*pypiComponent{components.pip, components.uv, components.credential} { + result := c.runClear(ctx, effective, component) + if result.err != nil { + errs = append(errs, result.err) + } + } + return errors.Join(errs...) +} + +func (c *PyPICoordinator) reconcileMDM(ctx context.Context, effective EffectivePolicy, policy PyPIPolicy, components *pypiComponents) error { + results := c.observeMDMSelected(ctx, policy, components) + credential, pip, uv, observedErr := observations(policy, results) + observed, marshalErr := buildPyPIObserved(policy, credential, pip, uv) + state := aggregateMDMState(results, observedErr != nil || marshalErr != nil) + reportErr := c.report(ctx, state, "", effective.Hash, enforcementMDM, observed) + return errors.Join(componentErrors(results), observedErr, marshalErr, reportErr) +} + +func (c *PyPICoordinator) observeMDMSelected(ctx context.Context, policy PyPIPolicy, components *pypiComponents) []componentResult { + selected := []*pypiComponent{components.credential} + if policy.Selects(PyPIClientPip) { + selected = append(selected, components.pip) + } + if policy.Selects(PyPIClientUV) { + selected = append(selected, components.uv) + } + results := make([]componentResult, 0, len(selected)) + for _, component := range selected { + if component == nil { + err := errors.New("devicepolicy: nil PyPI component") + results = append(results, componentResult{state: StateVerificationFailed, observationErr: err}) + continue + } + result := c.observeOnly(ctx, component) + if result.observationErr != nil { + results = append(results, result) + continue + } + if component.mdmOwned == nil { + result.state = StateVerificationFailed + result.observationErr = fmt.Errorf("devicepolicy: %s has no MDM ownership probe", component.name) + results = append(results, result) + continue + } + owned, err := component.mdmOwned() + if err != nil { + result.state, result.observationErr = StateVerificationFailed, err + } else if owned { + result.state = StateMDMManaged + } else { + result.state = StatePolicyNotApplied + if result.observation.credential == authTokenMatch { + result.observation.credential = authTokenMismatch + } + if result.observation.client != nil && result.observation.client.ConfigStatus == "match" { + observed := *result.observation.client + observed.ConfigStatus = "mismatch" + result.observation.client = &observed + } + } + results = append(results, result) + } + return results +} + +func aggregateMDMState(results []componentResult, failed bool) string { + if failed || anyComponentError(results) { + return StateVerificationFailed + } + for _, result := range results { + if result.state != StateMDMManaged { + return StatePolicyNotApplied + } + } + return StateMDMManaged +} + +func (c *PyPICoordinator) reconcileDMG(ctx context.Context, effective EffectivePolicy, policy PyPIPolicy, components *pypiComponents) error { + all := []*pypiComponent{components.credential, components.pip, components.uv} + managed := false + var markerErrs []error + for _, component := range all { + if component == nil || component.initErr != nil { + if component != nil && component.initErr != nil { + markerErrs = append(markerErrs, component.initErr) + } + continue + } + present, err := component.hasMDMMarker() + if err != nil { + err = fmt.Errorf("devicepolicy: inspect %s MDM marker: %w", component.name, err) + component.initErr = errors.Join(component.initErr, err) + markerErrs = append(markerErrs, err) + continue + } + managed = managed || present + } + if managed { + results := c.observeMDMSelected(ctx, policy, components) + credential, pip, uv, observedErr := observations(policy, results) + observed, marshalErr := buildPyPIObserved(policy, credential, pip, uv) + state := aggregateMDMState(results, len(markerErrs) != 0 || observedErr != nil || marshalErr != nil) + reportErr := c.report(ctx, state, "", effective.Hash, enforcementDMG, observed) + return errors.Join(errors.Join(markerErrs...), observedErr, marshalErr, reportErr) + } + + credentialPriorState, credentialHadPriorState := ReadAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget) + if err := c.preflightOwnership(all); err != nil { + reportErr := c.report(ctx, StateWriteFailed, "", effective.Hash, enforcementDMG, nil) + return errors.Join(err, reportErr) + } + + credentialWasConverged := false + if components.credential.initErr == nil { + var convergeErr error + credentialWasConverged, convergeErr = components.credential.converged(components.credential.expected) + if convergeErr != nil { + c.logf("devicepolicy: credential pre-cycle convergence check failed: %v", convergeErr) + } + } + credentialResult := c.runComponent(ctx, effective, components.credential) + results := []componentResult{credentialResult} + if !componentSucceeded(credentialResult.state) { + skipped := c.observeSelected(ctx, policy, components) + for i := range skipped { + if skipped[i].name != "credential" { + skipped[i].state = StatePolicyNotApplied + results = append(results, skipped[i]) + } + } + return c.finishDMG(ctx, effective, policy, results) + } + + for _, component := range []*pypiComponent{components.pip, components.uv} { + selected := component.name == "pip" && policy.Selects(PyPIClientPip) || component.name == "uv" && policy.Selects(PyPIClientUV) + if !selected { + results = append(results, c.runClear(ctx, EffectivePolicy{Category: CategoryPackageConfig, Target: TargetPyPI, Clear: true, Enforcement: enforcementDMG}, component)) + } + } + for _, component := range []*pypiComponent{components.pip, components.uv} { + selected := component.name == "pip" && policy.Selects(PyPIClientPip) || component.name == "uv" && policy.Selects(PyPIClientUV) + if selected { + results = append(results, c.runComponent(ctx, effective, component)) + } + } + + anyStatic := false + for _, result := range results { + if (result.name == "pip" || result.name == "uv") && result.staticConverged { + anyStatic = true + } + } + credentialChanged := !credentialWasConverged && componentSucceeded(credentialResult.state) + if credentialChanged && !anyStatic { + rollbackState := StatePolicyNotApplied + rollbackErr := components.credential.restoreSnapshot() + if rollbackErr != nil { + rollbackState = StateVerificationFailed + } else { + var stateErr error + if credentialHadPriorState { + stateErr = c.writeOwnershipState(CategoryPackageConfig, PyPICredentialOwnershipTarget, credentialPriorState) + } else { + stateErr = c.clearOwnershipState(CategoryPackageConfig, PyPICredentialOwnershipTarget) + } + if stateErr != nil { + rollbackErr = stateErr + rollbackState = StateWriteFailed + } + } + results = append(results, componentResult{name: "credential_rollback", state: rollbackState, err: rollbackErr}) + refreshed := c.observeOnly(ctx, components.credential) + for i := range results { + if results[i].name == "credential" { + results[i].observation = refreshed.observation + results[i].observationErr = refreshed.observationErr + break + } + } + } + + return c.finishDMG(ctx, effective, policy, results) +} + +func (c *PyPICoordinator) finishDMG(ctx context.Context, effective EffectivePolicy, policy PyPIPolicy, results []componentResult) error { + credential, pip, uv, observedErr := observations(policy, results) + observed, marshalErr := buildPyPIObserved(policy, credential, pip, uv) + state := aggregatePyPIState(results) + appliedHash := "" + if state == StateCompliant || state == StateDriftDetected { + appliedHash = effective.Hash + } + reportErr := c.report(ctx, state, appliedHash, effective.Hash, enforcementDMG, observed) + return errors.Join(componentErrors(results), observedErr, marshalErr, reportErr) +} + +func (c *PyPICoordinator) runComponent(ctx context.Context, effective EffectivePolicy, component *pypiComponent) componentResult { + if component == nil { + err := errors.New("devicepolicy: nil PyPI component") + return componentResult{state: StateVerificationFailed, err: err, observationErr: err} + } + result := componentResult{name: component.name} + if component.initErr != nil { + result.state, result.err, result.observationErr = StateVerificationFailed, component.initErr, component.initErr + return result + } + collector := &collectingReporter{} + reconciler := c.childReconciler(effective, component, collector) + result.err = reconciler.Reconcile(ctx) + if len(collector.reports) == 1 { + result.state = collector.reports[0].State + if errors.Is(result.err, errUVUnsupportedVersion) { + result.state = StatePolicyNotApplied + result.err = nil + } + } else { + result.state = StateVerificationFailed + result.err = errors.Join(result.err, fmt.Errorf("devicepolicy: %s child produced %d reports", component.name, len(collector.reports))) + } + result.observation, result.observationErr = component.observe(ctx) + if effective.Enforcement != enforcementMDM { + result.state = aggregatePyPIState([]componentResult{{state: result.state}, {state: observationState(result.observation, result.observationErr)}}) + if component.staticConverged != nil { + result.staticConverged, result.staticConvergeErr = component.staticConverged(component.expected) + if result.staticConvergeErr != nil { + result.state = StateVerificationFailed + } + } + } + return result +} + +func (c *PyPICoordinator) runClear(ctx context.Context, effective EffectivePolicy, component *pypiComponent) componentResult { + if component == nil { + return componentResult{state: StateWriteFailed, err: errors.New("devicepolicy: nil PyPI component")} + } + result := componentResult{name: component.name, state: StateCompliant} + if component.initErr != nil { + result.state = StateWriteFailed + result.err = component.initErr + return result + } + collector := &collectingReporter{} + result.err = c.childReconciler(effective, component, collector).Reconcile(ctx) + if result.err != nil { + result.state = classifyWriteError(result.err) + } + return result +} + +func (c *PyPICoordinator) childReconciler(effective EffectivePolicy, component *pypiComponent, reporter Reporter) *Reconciler { + reconciler := &Reconciler{ + Fetcher: fixedFetcher{policy: effective}, + Reporter: reporter, + Writer: component.writer, + WriterInitErr: component.initErr, + CustomerID: c.CustomerID, + DeviceID: c.DeviceID, + Platform: c.Platform, + Category: CategoryPackageConfig, + Target: TargetPyPI, + OwnershipTarget: component.ownershipTarget, + OwnershipStateValue: component.ownershipStateValue, + OwnershipKey: component.ownershipKey, + OwnsByMarker: true, + Converged: component.converged, + RestoreSnapshot: component.restoreSnapshot, + ProbeExpected: func(string) (bool, string) { return false, "" }, + ProbeContent: func(string) (bool, map[string]json.RawMessage, error) { return true, nil, nil }, + Render: func(json.RawMessage) (string, error) { return component.expected, nil }, + Logf: c.Logf, + writeState: c.writeOwnershipState, + clearState: c.clearOwnershipState, + } + return reconciler +} + +func (c *PyPICoordinator) components(ctx context.Context, policy PyPIPolicy) (*pypiComponents, error) { + if c.buildComponents != nil { + return c.buildComponents(ctx, c.Exec, policy) + } + if c.Exec == nil { + return nil, errors.New("devicepolicy: nil PyPI executor") + } + return buildPyPIComponents(ctx, c.Exec, policy) +} + +func buildPyPIComponents(ctx context.Context, exec executor.Executor, policy PyPIPolicy) (*pypiComponents, error) { + home, err := secureuserfile.OpenUserHome(exec) + if err != nil { + return nil, err + } + components := &pypiComponents{close: home.Close} + userExec := executor.NewUserAwareExecutor(exec, home.Username()) + + credentialExpected := renderNetrcEntry(policy.RegistryHost(), policy.DeviceToken()) + credential, credentialErr := NewNetrcWriter(home, policy) + components.credential = &pypiComponent{ + name: "credential", ownershipTarget: PyPICredentialOwnershipTarget, ownershipKey: pypiCredentialOwnershipKey, + ownershipStateValue: PyPICredentialOwnershipValue, writer: credential, initErr: credentialErr, expected: credentialExpected, + } + if credential != nil { + components.credential.converged = credential.Converged + components.credential.restoreSnapshot = credential.RestoreSnapshot + components.credential.hasMDMMarker = credential.HasMDMMarker + components.credential.mdmOwned = credential.MDMOwned + components.credential.observe = func(context.Context) (componentObservation, error) { + status, err := credential.Observation(credentialExpected) + return componentObservation{credential: status}, err + } + } + + pipExpected, pipRenderErr := renderPipSettings(policy) + pip, pipErr := NewPipWriter(ctx, userExec, home, policy) + components.pip = &pypiComponent{name: "pip", ownershipTarget: PyPIPipOwnershipTarget, ownershipKey: pypiPipOwnershipKey, writer: pip, initErr: errors.Join(pipRenderErr, pipErr), expected: pipExpected} + if pip != nil { + components.pip.converged = pip.Converged + components.pip.restoreSnapshot = pip.RestoreSnapshot + components.pip.hasMDMMarker = pip.HasMDMMarker + components.pip.mdmOwned = pip.MDMOwned + components.pip.staticConverged = pip.StaticConverged + components.pip.observe = func(ctx context.Context) (componentObservation, error) { + observation, err := pip.Observation(ctx, pipExpected) + client := PyPIClientObservation(observation) + return componentObservation{client: &client}, err + } + } + + uvExpected, uvRenderErr := renderUVSettings(policy) + uv, uvErr := NewUVWriter(ctx, userExec, home, policy) + components.uv = &pypiComponent{name: "uv", ownershipTarget: PyPIUVOwnershipTarget, ownershipKey: pypiUVOwnershipKey, writer: uv, initErr: errors.Join(uvRenderErr, uvErr), expected: uvExpected} + if uv != nil { + components.uv.converged = uv.Converged + components.uv.restoreSnapshot = uv.RestoreSnapshot + components.uv.hasMDMMarker = uv.HasMDMMarker + components.uv.mdmOwned = uv.MDMOwned + components.uv.staticConverged = uv.StaticConverged + components.uv.observe = func(ctx context.Context) (componentObservation, error) { + observation, err := uv.Observation(ctx, uvExpected) + client := PyPIClientObservation(observation) + return componentObservation{client: &client}, err + } + } + return components, nil +} + +func clearPyPIPolicy() PyPIPolicy { + policy := PyPIPolicy{Ecosystem: "pypi", Clients: []PyPIClient{PyPIClientPip, PyPIClientUV}, RegistryURL: "https://registry.stepsecurity.io/python/simple", deviceID: "clear"} + policy.Auth.Scheme = pypiAuthScheme + policy.Auth.APIKey = "clear" + return policy +} + +func (c *PyPICoordinator) preflightOwnership(components []*pypiComponent) error { + for _, component := range components { + state, ok := ReadAppliedState(CategoryPackageConfig, component.ownershipTarget) + if !ok { + state = AppliedTargetState{FetchedAt: time.Now().UTC()} + } + if err := c.writeOwnershipState(CategoryPackageConfig, component.ownershipTarget, state); err != nil { + return fmt.Errorf("devicepolicy: preflight %s ownership state: %w", component.name, err) + } + } + return nil +} + +func (c *PyPICoordinator) writeOwnershipState(category, target string, state AppliedTargetState) error { + if c.writeState != nil { + return c.writeState(category, target, state) + } + return WriteAppliedState(category, target, state) +} + +func (c *PyPICoordinator) clearOwnershipState(category, target string) error { + if c.clearState != nil { + return c.clearState(category, target) + } + return ClearAppliedState(category, target) +} + +func (c *PyPICoordinator) observeOnly(ctx context.Context, component *pypiComponent) componentResult { + result := componentResult{name: component.name, state: StateCompliant} + if component.initErr != nil { + result.state, result.observationErr = StateWriteFailed, component.initErr + return result + } + result.observation, result.observationErr = component.observe(ctx) + if result.observationErr != nil { + result.state = StateVerificationFailed + } + return result +} + +func (c *PyPICoordinator) observeSelected(ctx context.Context, policy PyPIPolicy, components *pypiComponents) []componentResult { + results := []componentResult{c.observeOnly(ctx, components.credential)} + if policy.Selects(PyPIClientPip) { + results = append(results, c.observeOnly(ctx, components.pip)) + } + if policy.Selects(PyPIClientUV) { + results = append(results, c.observeOnly(ctx, components.uv)) + } + return results +} + +func observations(policy PyPIPolicy, results []componentResult) (string, *PipObservation, *UVObservation, error) { + credential := authTokenUnreadable + var pip *PipObservation + var uv *UVObservation + var errs []error + for _, result := range results { + if result.observationErr != nil { + errs = append(errs, result.observationErr) + } + switch result.name { + case "credential": + if result.observation.credential != "" { + credential = result.observation.credential + } + case "pip": + if result.observation.client != nil { + observation := PipObservation(*result.observation.client) + pip = &observation + } + case "uv": + if result.observation.client != nil { + observation := UVObservation(*result.observation.client) + uv = &observation + } + } + } + if policy.Selects(PyPIClientPip) && pip == nil { + pip = &PipObservation{ConfigStatus: "unreadable", EffectiveStatus: "unknown", OverrideSource: "unknown"} + } + if policy.Selects(PyPIClientUV) && uv == nil { + uv = &UVObservation{ConfigStatus: "unreadable", EffectiveStatus: "unknown", OverrideSource: "unknown"} + } + return credential, pip, uv, errors.Join(errs...) +} + +func observationState(observation componentObservation, err error) string { + if err != nil { + return StateVerificationFailed + } + if observation.credential != "" { + switch observation.credential { + case authTokenMatch: + return StateCompliant + case authTokenUnreadable: + return StateVerificationFailed + default: + return StatePolicyNotApplied + } + } + if observation.client == nil { + return StateVerificationFailed + } + if observation.client.ConfigStatus == "unreadable" { + return StateVerificationFailed + } + if observation.client.ConfigStatus != "match" { + return StatePolicyNotApplied + } + switch observation.client.EffectiveStatus { + case "match", "not_installed": + return StateCompliant + default: + return StatePolicyNotApplied + } +} + +func componentSucceeded(state string) bool { + return state == StateCompliant || state == StateDriftDetected +} + +func anyComponentError(results []componentResult) bool { + for _, result := range results { + if result.err != nil || result.observationErr != nil || result.staticConvergeErr != nil { + return true + } + } + return false +} + +func componentErrors(results []componentResult) error { + var errs []error + for _, result := range results { + errs = append(errs, result.err, result.observationErr, result.staticConvergeErr) + } + return errors.Join(errs...) +} + +// aggregatePyPIState applies the coordinator's deterministic failure precedence. +func aggregatePyPIState(results []componentResult) string { + precedence := map[string]int{ + StateCompliant: 0, + StateDriftDetected: 1, + StatePolicyNotApplied: 2, + StateWriteFailed: 3, + StateVerificationFailed: 4, + } + state, rank := StateCompliant, 0 + for _, result := range results { + candidate, ok := precedence[result.state] + if !ok { + return StateVerificationFailed + } + if candidate > rank { + state, rank = result.state, candidate + } + } + return state +} + +// buildPyPIObserved returns only selected-client, credential-free evidence. +func buildPyPIObserved(policy PyPIPolicy, credential string, pip *PipObservation, uv *UVObservation) (json.RawMessage, error) { + observed := PyPIObserved{Ecosystem: "pypi", AuthTokenStatus: credential, Clients: map[string]PyPIClientObservation{}} + if policy.Selects(PyPIClientPip) { + if pip == nil { + return nil, errors.New("devicepolicy: missing selected pip observation") + } + client := PyPIClientObservation(*pip) + client.RegistryURL = safeObservedRegistryURL(client.RegistryURL) + observed.Clients[string(PyPIClientPip)] = client + } + if policy.Selects(PyPIClientUV) { + if uv == nil { + return nil, errors.New("devicepolicy: missing selected uv observation") + } + client := PyPIClientObservation(*uv) + client.RegistryURL = safeObservedRegistryURL(client.RegistryURL) + observed.Clients[string(PyPIClientUV)] = client + } + return json.Marshal(observed) +} + +func canonicalEnforcement(value string) string { + if strings.EqualFold(strings.TrimSpace(value), enforcementMDM) { + return enforcementMDM + } + return enforcementDMG +} + +func (c *PyPICoordinator) report(ctx context.Context, state, appliedHash, evaluatedHash, enforcement string, observed json.RawMessage) error { + report := ComplianceReport{ + Category: CategoryPackageConfig, + Target: TargetPyPI, + State: state, + AppliedHash: appliedHash, + EvaluatedHash: evaluatedHash, + AgentVersion: AgentVersion(), + Platform: c.Platform, + Observed: observed, + EvaluatedEnforcement: enforcement, + } + c.logf("devicepolicy: reporting aggregate state=%s category=%s target=%s", state, CategoryPackageConfig, TargetPyPI) + if c.Reporter == nil { + return nil + } + if err := c.Reporter.Report(ctx, c.CustomerID, c.DeviceID, report); err != nil { + return fmt.Errorf("devicepolicy: report PyPI state %s: %w", state, err) + } + return nil +} + +func (c *PyPICoordinator) logf(format string, args ...any) { + if c.Logf != nil { + c.Logf(format, args...) + } +} diff --git a/internal/devicepolicy/pypi_coordinator_test.go b/internal/devicepolicy/pypi_coordinator_test.go new file mode 100644 index 00000000..96200469 --- /dev/null +++ b/internal/devicepolicy/pypi_coordinator_test.go @@ -0,0 +1,687 @@ +package devicepolicy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/user" + "runtime" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +type coordinatorFetcher struct { + policy EffectivePolicy + calls int +} + +type coordinatorUserExecutor struct { + *executor.Mock + user *user.User +} + +func (e *coordinatorUserExecutor) CurrentUser() (*user.User, error) { return e.user, nil } +func (e *coordinatorUserExecutor) LoggedInUser() (*user.User, error) { return e.user, nil } +func (e *coordinatorUserExecutor) RunAsUser(ctx context.Context, username, command string) (string, error) { + if strings.Contains(command, "XDG_CONFIG_HOME") && strings.Contains(command, "PIP_CONFIG_FILE") { + return "", nil + } + return e.Mock.RunAsUser(ctx, username, command) +} + +func (f *coordinatorFetcher) Fetch(_ context.Context, _, _, category, target string) (EffectivePolicy, error) { + f.calls++ + if category != CategoryPackageConfig || target != TargetPyPI { + return EffectivePolicy{}, errors.New("wrong policy identity") + } + return f.policy, nil +} + +type coordinatorReporter struct { + reports []ComplianceReport +} + +func (r *coordinatorReporter) Report(_ context.Context, _, _ string, report ComplianceReport) error { + r.reports = append(r.reports, report) + return nil +} + +type coordinatorWriter struct { + name string + events *[]string + value string + present bool + static bool + mdm bool + mdmUnowned bool + writeErr error + clearErr error + observeErr error + restores int + override string + snapshotValue string + snapshotPresent bool + snapshotStatic bool +} + +func (w *coordinatorWriter) Read() (string, bool, error) { return w.value, w.present, nil } + +func (w *coordinatorWriter) Write(value string) (string, error) { + *w.events = append(*w.events, w.name+":write") + if w.writeErr != nil { + return "", w.writeErr + } + w.snapshotValue, w.snapshotPresent, w.snapshotStatic = w.value, w.present, w.static + w.value, w.present, w.static = value, true, true + return value, nil +} + +func (w *coordinatorWriter) Clear() (bool, error) { + *w.events = append(*w.events, w.name+":clear") + if w.clearErr != nil { + return false, w.clearErr + } + changed := w.present + w.value, w.present, w.static = "", false, false + return changed, nil +} + +func (w *coordinatorWriter) Location() string { return w.name } + +func (w *coordinatorWriter) converged(expected string) (bool, error) { + return w.present && w.value == expected, nil +} + +func (w *coordinatorWriter) staticConverged(string) (bool, error) { + return w.static, nil +} + +func (w *coordinatorWriter) restore() error { + w.restores++ + *w.events = append(*w.events, w.name+":restore") + w.value, w.present, w.static = w.snapshotValue, w.snapshotPresent, w.snapshotStatic + return nil +} + +func (w *coordinatorWriter) observation(client PyPIClient, registryURL, expected string) (componentObservation, error) { + if w.observeErr != nil { + return componentObservation{}, w.observeErr + } + if client == "" { + status := authTokenAbsent + if w.present { + status = authTokenMismatch + } + if w.static && w.value == expected { + status = authTokenMatch + } + return componentObservation{credential: status}, nil + } + status := "absent" + if w.static { + status = "match" + } + effective, source := "not_installed", "none" + if w.override != "" { + effective, source = "mismatch", w.override + } + return componentObservation{client: &PyPIClientObservation{ + RegistryURL: registryURL, + ConfigStatus: status, + EffectiveStatus: effective, + OverrideSource: source, + }}, nil +} + +type coordinatorFixture struct { + events []string + credential *coordinatorWriter + pip *coordinatorWriter + uv *coordinatorWriter +} + +func newCoordinatorFixture() *coordinatorFixture { + f := &coordinatorFixture{} + f.credential = &coordinatorWriter{name: "credential", events: &f.events} + f.pip = &coordinatorWriter{name: "pip", events: &f.events} + f.uv = &coordinatorWriter{name: "uv", events: &f.events} + return f +} + +func (f *coordinatorFixture) components(policy PyPIPolicy) *pypiComponents { + return &pypiComponents{ + credential: fakeCoordinatorComponent("credential", PyPICredentialOwnershipTarget, PyPICredentialOwnershipValue, policy.DeviceToken(), f.credential, "", policy.RegistryURL), + pip: fakeCoordinatorComponent("pip", PyPIPipOwnershipTarget, "", "pip-settings:"+policy.RegistryURL, f.pip, PyPIClientPip, policy.RegistryURL), + uv: fakeCoordinatorComponent("uv", PyPIUVOwnershipTarget, "", "uv-settings:"+policy.RegistryURL, f.uv, PyPIClientUV, policy.RegistryURL), + } +} + +func fakeCoordinatorComponent(name, ownershipTarget, ownershipValue, expected string, writer *coordinatorWriter, client PyPIClient, registryURL string) *pypiComponent { + return &pypiComponent{ + name: name, + ownershipTarget: ownershipTarget, + ownershipKey: name, + ownershipStateValue: ownershipValue, + writer: writer, + expected: expected, + converged: writer.converged, + restoreSnapshot: writer.restore, + hasMDMMarker: func() (bool, error) { return writer.mdm, nil }, + mdmOwned: func() (bool, error) { return writer.mdm && !writer.mdmUnowned, nil }, + staticConverged: writer.staticConverged, + observe: func(context.Context) (componentObservation, error) { + return writer.observation(client, registryURL, expected) + }, + } +} + +func coordinatorPolicy(clients string, hash string, enforcement string) EffectivePolicy { + return EffectivePolicy{ + Category: CategoryPackageConfig, + Target: TargetPyPI, + Policy: json.RawMessage(`{"ecosystem":"pypi","clients":` + clients + `,"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"tenant-secret"}}`), + Hash: hash, + Enforcement: enforcement, + } +} + +func newTestCoordinator(t *testing.T, policy EffectivePolicy, fixture *coordinatorFixture) (*PyPICoordinator, *coordinatorFetcher, *coordinatorReporter) { + t.Helper() + withTempCache(t) + fetcher := &coordinatorFetcher{policy: policy} + reporter := &coordinatorReporter{} + coordinator := &PyPICoordinator{ + Fetcher: fetcher, + Reporter: reporter, + Exec: executor.NewMock(), + CustomerID: "cust", + DeviceID: "DEVICE-123", + Platform: "linux", + buildComponents: func(_ context.Context, _ executor.Executor, parsed PyPIPolicy) (*pypiComponents, error) { + return fixture.components(parsed), nil + }, + } + return coordinator, fetcher, reporter +} + +func TestPyPICoordinator_FetchesOnceAndMissingPolicyIsNoOp(t *testing.T) { + fixture := newCoordinatorFixture() + coordinator, fetcher, reporter := newTestCoordinator(t, EffectivePolicy{}, fixture) + coordinator.buildComponents = func(context.Context, executor.Executor, PyPIPolicy) (*pypiComponents, error) { + t.Fatal("components constructed for absent policy") + return nil, nil + } + + if err := coordinator.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if fetcher.calls != 1 || len(reporter.reports) != 0 || len(fixture.events) != 0 { + t.Fatalf("calls=%d reports=%d events=%v, want one fetch and no side effects", fetcher.calls, len(reporter.reports), fixture.events) + } +} + +func TestPyPICoordinator_ComponentOrdering(t *testing.T) { + clear := EffectivePolicy{Category: CategoryPackageConfig, Target: TargetPyPI, Clear: true} + tests := []struct { + name string + policy EffectivePolicy + configure func(*coordinatorFixture) + wantEvents string + wantErr bool + wantReports int + wantState string + wantApplied string + }{ + { + name: "credential then pip then uv", + policy: coordinatorPolicy(`["pip","uv"]`, "sha256:H", enforcementDMG), + wantEvents: "credential:write,pip:write,uv:write", wantReports: 1, + wantState: StateCompliant, wantApplied: "sha256:H", + }, + { + name: "credential failure skips clients", + policy: coordinatorPolicy(`["pip","uv"]`, "sha256:H", enforcementDMG), + configure: func(f *coordinatorFixture) { f.credential.writeErr = errors.New("credential write failed") }, + wantEvents: "credential:write", wantErr: true, wantReports: 1, wantState: StateWriteFailed, + }, + { + name: "unselected uv clears before selected pip", + policy: coordinatorPolicy(`["pip"]`, "sha256:H", enforcementDMG), + configure: func(f *coordinatorFixture) { f.uv.present, f.uv.static = true, true }, + wantEvents: "credential:write,uv:clear,pip:write", wantReports: 1, + wantState: StateCompliant, wantApplied: "sha256:H", + }, + { + name: "explicit clear continues after error", + policy: clear, + configure: func(f *coordinatorFixture) { f.pip.clearErr = errors.New("pip clear failed") }, + wantEvents: "pip:clear,uv:clear,credential:clear", wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fixture := newCoordinatorFixture() + if tc.configure != nil { + tc.configure(fixture) + } + coordinator, fetcher, reporter := newTestCoordinator(t, tc.policy, fixture) + err := coordinator.Reconcile(context.Background()) + if (err != nil) != tc.wantErr { + t.Fatalf("Reconcile error = %v, wantErr %v", err, tc.wantErr) + } + if got := strings.Join(fixture.events, ","); got != tc.wantEvents { + t.Fatalf("events = %q, want %q", got, tc.wantEvents) + } + if fetcher.calls != 1 || len(reporter.reports) != tc.wantReports { + t.Fatalf("fetches=%d reports=%d, want 1 and %d", fetcher.calls, len(reporter.reports), tc.wantReports) + } + if tc.wantReports == 1 { + report := reporter.reports[0] + if report.Target != TargetPyPI || report.State != tc.wantState || report.AppliedHash != tc.wantApplied || report.EvaluatedHash != "sha256:H" { + t.Fatalf("report = %+v", report) + } + } + }) + } +} + +func TestPyPICoordinator_ExplicitClearWithoutTargetUserFailsWithoutReport(t *testing.T) { + fixture := newCoordinatorFixture() + policy := EffectivePolicy{Category: CategoryPackageConfig, Target: TargetPyPI, Clear: true} + coordinator, _, reporter := newTestCoordinator(t, policy, fixture) + coordinator.buildComponents = func(context.Context, executor.Executor, PyPIPolicy) (*pypiComponents, error) { + return nil, ErrNoTargetUser + } + + if err := coordinator.Reconcile(context.Background()); !errors.Is(err, ErrNoTargetUser) { + t.Fatalf("Reconcile error = %v, want ErrNoTargetUser", err) + } + if len(reporter.reports) != 0 || len(fixture.events) != 0 { + t.Fatalf("reports=%d events=%v, want failed clear without side effects", len(reporter.reports), fixture.events) + } +} + +func TestPyPICoordinator_MDMRequiresEverySelectedMarker(t *testing.T) { + tests := []struct { + name string + credentialMDM bool + pipMDM bool + unowned bool + wantState string + wantAuth string + wantPip string + }{ + {"all MDM-owned", true, true, false, StateMDMManaged, authTokenMatch, "match"}, + {"unrelated credential marker", true, true, true, StatePolicyNotApplied, authTokenMismatch, "match"}, + {"credential only", true, false, false, StatePolicyNotApplied, authTokenMatch, "mismatch"}, + {"pip only", false, true, false, StatePolicyNotApplied, authTokenMismatch, "match"}, + {"equivalent unowned configuration", false, false, false, StatePolicyNotApplied, authTokenMismatch, "mismatch"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + effective := coordinatorPolicy(`["pip"]`, "sha256:MDM", enforcementMDM) + policy, err := ParsePyPIPolicy(effective.Policy, "DEVICE-123") + if err != nil { + t.Fatal(err) + } + fixture := newCoordinatorFixture() + fixture.credential.present, fixture.credential.static = true, true + fixture.credential.value, fixture.credential.mdm = policy.DeviceToken(), tc.credentialMDM + fixture.credential.mdmUnowned = tc.unowned + fixture.pip.static, fixture.pip.mdm = true, tc.pipMDM + coordinator, _, reporter := newTestCoordinator(t, effective, fixture) + if err := coordinator.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(reporter.reports) != 1 || reporter.reports[0].State != tc.wantState { + t.Fatalf("reports = %+v, want state %s", reporter.reports, tc.wantState) + } + var observed PyPIObserved + if err := json.Unmarshal(reporter.reports[0].Observed, &observed); err != nil { + t.Fatal(err) + } + if observed.AuthTokenStatus != tc.wantAuth || observed.Clients[string(PyPIClientPip)].ConfigStatus != tc.wantPip { + t.Fatalf("observed = %+v, want auth=%s pip=%s", observed, tc.wantAuth, tc.wantPip) + } + }) + } +} + +func TestPyPICoordinator_MDMRouting(t *testing.T) { + tests := []struct { + name string + clients string + hash string + enforcement string + marker string + static bool + initFailure bool + wantErr bool + wantState string + }{ + {name: "credential marker", clients: `["pip","uv"]`, hash: "sha256:H", enforcement: enforcementDMG, marker: "credential", wantState: StatePolicyNotApplied}, + {name: "pip marker", clients: `["pip","uv"]`, hash: "sha256:H", enforcement: enforcementDMG, marker: "pip", wantState: StatePolicyNotApplied}, + {name: "uv marker", clients: `["pip","uv"]`, hash: "sha256:H", enforcement: enforcementDMG, marker: "uv", wantState: StatePolicyNotApplied}, + {name: "verify only", clients: `["pip","uv"]`, hash: "sha256:MDM", enforcement: enforcementMDM, marker: "all", static: true, wantState: StateMDMManaged}, + {name: "case insensitive", clients: `["pip"]`, hash: "sha256:MDM", enforcement: " MDM ", marker: "all", static: true, wantState: StateMDMManaged}, + {name: "component init failure", clients: `["pip"]`, hash: "sha256:MDM", enforcement: enforcementMDM, initFailure: true, wantErr: true, wantState: StateVerificationFailed}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fixture := newCoordinatorFixture() + if tc.static { + fixture.credential.static, fixture.pip.static, fixture.uv.static = true, true, true + } + switch tc.marker { + case "credential": + fixture.credential.mdm = true + case "pip": + fixture.pip.mdm = true + case "uv": + fixture.uv.mdm = true + case "all": + fixture.credential.mdm, fixture.pip.mdm, fixture.uv.mdm = true, true, true + } + coordinator, _, reporter := newTestCoordinator(t, coordinatorPolicy(tc.clients, tc.hash, tc.enforcement), fixture) + if tc.initFailure { + coordinator.buildComponents = func(_ context.Context, _ executor.Executor, policy PyPIPolicy) (*pypiComponents, error) { + components := fixture.components(policy) + components.credential = &pypiComponent{name: "credential", ownershipTarget: PyPICredentialOwnershipTarget, ownershipKey: pypiCredentialOwnershipKey, initErr: ErrNoTargetUser, expected: policy.DeviceToken()} + return components, nil + } + } + + err := coordinator.Reconcile(context.Background()) + if (err != nil) != tc.wantErr { + t.Fatalf("Reconcile error = %v, wantErr %v", err, tc.wantErr) + } + if len(fixture.events) != 0 { + t.Fatalf("MDM wrote: %v", fixture.events) + } + if len(reporter.reports) != 1 { + t.Fatalf("reports = %+v", reporter.reports) + } + report := reporter.reports[0] + if report.State != tc.wantState || report.AppliedHash != "" || report.EvaluatedHash != tc.hash || report.EvaluatedEnforcement != canonicalEnforcement(tc.enforcement) { + t.Fatalf("report = %+v", report) + } + }) + } +} + +func TestAggregatePyPIState_Precedence(t *testing.T) { + tests := []struct { + name string + states []string + want string + }{ + {"all compliant", []string{StateCompliant, StateCompliant}, StateCompliant}, + {"drift", []string{StateCompliant, StateDriftDetected}, StateDriftDetected}, + {"policy not applied over drift", []string{StateDriftDetected, StatePolicyNotApplied}, StatePolicyNotApplied}, + {"write over policy", []string{StatePolicyNotApplied, StateWriteFailed}, StateWriteFailed}, + {"verification over write", []string{StateWriteFailed, StateVerificationFailed}, StateVerificationFailed}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + results := make([]componentResult, len(tc.states)) + for i, state := range tc.states { + results[i].state = state + } + if got := aggregatePyPIState(results); got != tc.want { + t.Fatalf("aggregatePyPIState(%v) = %q, want %q", tc.states, got, tc.want) + } + }) + } +} + +func TestPyPICoordinator_UnsupportedUVReportsPolicyNotApplied(t *testing.T) { + fixture := newCoordinatorFixture() + fixture.uv.writeErr = errUVUnsupportedVersion + coordinator, _, reporter := newTestCoordinator(t, coordinatorPolicy(`["uv"]`, "sha256:H", enforcementDMG), fixture) + + if err := coordinator.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile error = %v, want nil for expected unsupported uv", err) + } + if len(reporter.reports) != 1 || reporter.reports[0].State != StatePolicyNotApplied || reporter.reports[0].AppliedHash != "" { + t.Fatalf("reports = %+v", reporter.reports) + } +} + +func TestPyPICoordinator_ComponentInspectionFailureDoesNotBlockSibling(t *testing.T) { + tests := []struct { + name string + configure func(*pypiComponents) + }{ + { + name: "initialization failure", + configure: func(components *pypiComponents) { + components.uv.initErr = errors.New("uv initialization failed") + }, + }, + { + name: "marker inspection failure", + configure: func(components *pypiComponents) { + components.uv.hasMDMMarker = func() (bool, error) { + return false, errors.New("uv marker inspection failed") + } + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fixture := newCoordinatorFixture() + coordinator, _, reporter := newTestCoordinator(t, coordinatorPolicy(`["pip","uv"]`, "sha256:H", enforcementDMG), fixture) + coordinator.buildComponents = func(_ context.Context, _ executor.Executor, policy PyPIPolicy) (*pypiComponents, error) { + components := fixture.components(policy) + tc.configure(components) + return components, nil + } + + if err := coordinator.Reconcile(context.Background()); err == nil { + t.Fatal("Reconcile error = nil, want component failure") + } + if got := strings.Join(fixture.events, ","); got != "credential:write,pip:write" { + t.Fatalf("events = %q, want successful sibling enforcement", got) + } + if !fixture.pip.static || !fixture.pip.present { + t.Fatal("successful pip enforcement was suppressed") + } + if len(reporter.reports) != 1 || reporter.reports[0].State != StateVerificationFailed || reporter.reports[0].AppliedHash != "" { + t.Fatalf("reports = %+v", reporter.reports) + } + }) + } +} + +func TestPyPICoordinator_PartialSuccessRetainsSiblingAndOmitsAppliedHash(t *testing.T) { + fixture := newCoordinatorFixture() + fixture.uv.writeErr = errors.New("uv failed") + coordinator, _, reporter := newTestCoordinator(t, coordinatorPolicy(`["pip","uv"]`, "sha256:H", enforcementDMG), fixture) + + if err := coordinator.Reconcile(context.Background()); err == nil { + t.Fatal("Reconcile error = nil, want uv error") + } + if !fixture.pip.static || !fixture.pip.present { + t.Fatal("successful pip enforcement was rolled back") + } + if fixture.credential.restores != 0 { + t.Fatal("credential rolled back despite a statically converged client") + } + if len(reporter.reports) != 1 || reporter.reports[0].State != StateWriteFailed || reporter.reports[0].AppliedHash != "" { + t.Fatalf("reports = %+v", reporter.reports) + } +} + +func TestPyPICoordinator_CredentialRollbackRules(t *testing.T) { + t.Run("new credential rolls back when no client converges", func(t *testing.T) { + fixture := newCoordinatorFixture() + fixture.pip.writeErr = errors.New("pip failed") + fixture.uv.writeErr = errors.New("uv failed") + coordinator, _, _ := newTestCoordinator(t, coordinatorPolicy(`["pip","uv"]`, "sha256:H", enforcementDMG), fixture) + if err := coordinator.Reconcile(context.Background()); err == nil { + t.Fatal("Reconcile error = nil") + } + if fixture.credential.restores != 1 || fixture.credential.present { + t.Fatalf("credential restores=%d present=%v, want one restore and absent", fixture.credential.restores, fixture.credential.present) + } + if _, ok := ReadAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget); ok { + t.Fatal("rolled-back credential ownership remains") + } + }) + + t.Run("rotation rollback restores prior credential ownership", func(t *testing.T) { + fixture := newCoordinatorFixture() + fixture.credential.value = "old-secret::dev:DEVICE-123" + fixture.credential.present, fixture.credential.static = true, true + fixture.pip.writeErr = errors.New("pip failed") + coordinator, _, _ := newTestCoordinator(t, coordinatorPolicy(`["pip"]`, "sha256:NEW", enforcementDMG), fixture) + prior := AppliedTargetState{ + AppliedHash: "sha256:OLD", + WrittenSettings: map[string]string{pypiCredentialOwnershipKey: PyPICredentialOwnershipValue}, + } + if err := WriteAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget, prior); err != nil { + t.Fatal(err) + } + + if err := coordinator.Reconcile(context.Background()); err == nil { + t.Fatal("Reconcile error = nil") + } + if fixture.credential.value != "old-secret::dev:DEVICE-123" || fixture.credential.restores != 1 { + t.Fatalf("credential value=%q restores=%d, want old credential restored", fixture.credential.value, fixture.credential.restores) + } + state, ok := ReadAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget) + if !ok || state.AppliedHash != prior.AppliedHash || state.WrittenSettings[pypiCredentialOwnershipKey] != PyPICredentialOwnershipValue { + t.Fatalf("credential ownership = %+v ok=%v, want prior state", state, ok) + } + }) + + t.Run("static client with environment override retains credential", func(t *testing.T) { + fixture := newCoordinatorFixture() + fixture.pip.override = "environment" + coordinator, _, reporter := newTestCoordinator(t, coordinatorPolicy(`["pip"]`, "sha256:H", enforcementDMG), fixture) + if err := coordinator.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if fixture.credential.restores != 0 || !fixture.credential.present { + t.Fatal("credential not retained for matching static configuration") + } + if reporter.reports[0].State != StatePolicyNotApplied || reporter.reports[0].AppliedHash != "" { + t.Fatalf("report = %+v", reporter.reports[0]) + } + }) + + t.Run("already serving credential survives later client failure", func(t *testing.T) { + fixture := newCoordinatorFixture() + fixture.credential.value = "tenant-secret::dev:DEVICE-123" + fixture.credential.present, fixture.credential.static = true, true + fixture.pip.writeErr = errors.New("pip failed") + coordinator, _, _ := newTestCoordinator(t, coordinatorPolicy(`["pip"]`, "sha256:H", enforcementDMG), fixture) + if err := coordinator.Reconcile(context.Background()); err == nil { + t.Fatal("Reconcile error = nil") + } + if fixture.credential.restores != 0 || !fixture.credential.present { + t.Fatal("already-serving credential was rolled back") + } + }) +} + +func TestPyPICoordinator_StateAndReportRemainSecretFreeAcrossLifecycle(t *testing.T) { + fixture := newCoordinatorFixture() + policy := coordinatorPolicy(`["pip","uv"]`, "sha256:OLD", enforcementDMG) + coordinator, fetcher, reporter := newTestCoordinator(t, policy, fixture) + + assertSecretFree := func(forbidden ...string) { + t.Helper() + state, err := os.ReadFile(CachePath()) + if err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + reports, err := json.Marshal(reporter.reports) + if err != nil { + t.Fatal(err) + } + combined := append(append([]byte(nil), state...), reports...) + for _, secret := range forbidden { + if bytes.Contains(combined, []byte(secret)) { + t.Fatalf("state/report leaked %q: %s", secret, combined) + } + } + if bytes.Contains(reports, []byte(PyPICredentialOwnershipTarget)) || bytes.Contains(reports, []byte(PyPIPipOwnershipTarget)) || bytes.Contains(reports, []byte(PyPIUVOwnershipTarget)) { + t.Fatalf("component ownership target entered report: %s", reports) + } + } + + if err := coordinator.Reconcile(context.Background()); err != nil { + t.Fatal(err) + } + assertSecretFree("tenant-secret", "::dev:") + + fixture.credential.value = "tampered" + fixture.credential.static = false + if err := coordinator.Reconcile(context.Background()); err != nil { + t.Fatal(err) + } + assertSecretFree("tenant-secret", "::dev:") + + fetcher.policy = coordinatorPolicy(`["pip","uv"]`, "sha256:NEW", enforcementDMG) + fetcher.policy.Policy = bytes.Replace(fetcher.policy.Policy, []byte("tenant-secret"), []byte("rotated-secret"), 1) + if err := coordinator.Reconcile(context.Background()); err != nil { + t.Fatal(err) + } + assertSecretFree("tenant-secret", "rotated-secret", "::dev:") + + fetcher.policy = EffectivePolicy{Category: CategoryPackageConfig, Target: TargetPyPI, Clear: true} + if err := coordinator.Reconcile(context.Background()); err != nil { + t.Fatal(err) + } + assertSecretFree("tenant-secret", "rotated-secret", "::dev:") +} + +func TestBuildPyPIComponents_SharesResolvedUserExecutor(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("resolved-user shell wrapping is Unix-only") + } + current, err := user.Current() + if err != nil { + t.Fatal(err) + } + homeDir := t.TempDir() + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetIsRoot(true) + mock.SetHomeDir(homeDir) + mock.SetUsername(current.Username) + mock.SetAppleCLTInstalled(true) + mock.SetCommand("/opt/homebrew/bin/pip\n", "", 0, "bash", "-c", "which 'pip'") + mock.SetCommand("pip 25.2 from /opt/homebrew/lib/python/site-packages/pip\n", "", 0, "bash", "-c", "'pip' '--version'") + mock.SetCommand("user:\n", "", 0, "bash", "-c", "'pip' 'config' 'debug'") + mock.SetCommand("/opt/homebrew/bin/uv\n", "", 0, "bash", "-c", "which 'uv'") + mock.SetCommand("uv 0.10.0\n", "", 0, "bash", "-c", "'uv' '--version'") + exec := &coordinatorUserExecutor{Mock: mock, user: &user.User{Username: current.Username, Uid: current.Uid, Gid: current.Gid, HomeDir: homeDir}} + + components, err := buildPyPIComponents(context.Background(), exec, netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = components.close() }() + pip := components.pip.writer.(*PipWriter) + uv := components.uv.writer.(*UVWriter) + if components.credential.mdmOwned == nil || components.pip.mdmOwned == nil || components.uv.mdmOwned == nil { + t.Fatal("components are missing strict MDM ownership probes") + } + if pip.exec != uv.exec { + t.Fatalf("pip executor %p and uv executor %p differ", pip.exec, uv.exec) + } + if _, ok := pip.exec.(*executor.UserAwareExecutor); !ok { + t.Fatalf("shared executor = %T, want *executor.UserAwareExecutor", pip.exec) + } + if len(pip.invocations) == 0 || !uv.installed { + t.Fatalf("resolved-user discovery missed clients: pip=%d uv=%v", len(pip.invocations), uv.installed) + } +} diff --git a/internal/devicepolicy/pypi_policy.go b/internal/devicepolicy/pypi_policy.go new file mode 100644 index 00000000..ee335c91 --- /dev/null +++ b/internal/devicepolicy/pypi_policy.go @@ -0,0 +1,237 @@ +package devicepolicy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "strings" +) + +const pypiAuthScheme = "stepsecurity_device_token" + +type PyPIClient string + +const ( + PyPIClientPip PyPIClient = "pip" + PyPIClientUV PyPIClient = "uv" +) + +type PyPIPolicy struct { + Ecosystem string `json:"ecosystem"` + Clients []PyPIClient `json:"clients"` + RegistryURL string `json:"registry_url"` + Auth struct { + Scheme string `json:"scheme"` + APIKey string `json:"api_key"` + } `json:"auth"` + + deviceID string +} + +type PyPIClientObservation struct { + RegistryURL string `json:"registry_url"` + ConfigStatus string `json:"config_status"` + EffectiveStatus string `json:"effective_status"` + OverrideSource string `json:"override_source"` +} + +type PyPIObserved struct { + Ecosystem string `json:"ecosystem"` + AuthTokenStatus string `json:"auth_token_status"` + Clients map[string]PyPIClientObservation `json:"clients"` +} + +// ParsePyPIPolicy strictly validates one compiled package_config/pypi policy. +func ParsePyPIPolicy(raw json.RawMessage, deviceID string) (PyPIPolicy, error) { + var policy PyPIPolicy + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&policy); err != nil { + return PyPIPolicy{}, errors.New("pypi: policy is not a well-formed policy object") + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return PyPIPolicy{}, errors.New("pypi: policy has trailing data") + } + if err := rejectDuplicateJSONKeys(raw); err != nil { + return PyPIPolicy{}, errors.New("pypi: policy contains duplicate JSON keys") + } + + if policy.Ecosystem != "pypi" { + return PyPIPolicy{}, errors.New("pypi: policy ecosystem is not pypi") + } + if !canonicalPyPIClients(policy.Clients) { + return PyPIPolicy{}, errors.New("pypi: policy clients are not canonical") + } + if policy.Auth.Scheme != pypiAuthScheme { + return PyPIPolicy{}, errors.New("pypi: unsupported auth scheme") + } + if err := validatePyPICredentialPart("api_key", policy.Auth.APIKey, npmrcMaxKeyBytes); err != nil { + return PyPIPolicy{}, err + } + if strings.Contains(policy.Auth.APIKey, "::") { + return PyPIPolicy{}, errors.New("pypi: policy api_key already contains a source suffix") + } + if err := validatePyPICredentialPart("device_id", deviceID, npmrcMaxSerialBytes); err != nil { + return PyPIPolicy{}, err + } + + u, err := parsePyPIRegistryURL(policy.RegistryURL) + if err != nil { + return PyPIPolicy{}, fmt.Errorf("pypi: policy %w", err) + } + switch u.EscapedPath() { + case "/python/simple": + case "/python/simple/": + policy.RegistryURL = strings.TrimSuffix(policy.RegistryURL, "/") + default: + return PyPIPolicy{}, errors.New("pypi: policy registry_url path must be /python/simple") + } + + policy.deviceID = deviceID + return policy, nil +} + +func parsePyPIRegistryURL(raw string) (*url.URL, error) { + if raw == "" { + return nil, errors.New("registry_url is empty") + } + if hasControlBytes(raw) { + return nil, errors.New("registry_url contains control characters") + } + // url.Parse does not expose a ForceFragment bit for a trailing bare '#'. + if strings.ContainsAny(raw, "#?") { + return nil, errors.New("registry_url must not contain '#' or '?'") + } + u, err := url.Parse(raw) + if err != nil { + return nil, errors.New("registry_url is not a valid URL") + } + if u.Scheme != "https" { + return nil, errors.New("registry_url must be https") + } + if u.User != nil { + return nil, errors.New("registry_url must not contain userinfo") + } + if u.RawQuery != "" || u.ForceQuery { + return nil, errors.New("registry_url must not contain a query") + } + if u.Fragment != "" { + return nil, errors.New("registry_url must not contain a fragment") + } + if u.Port() != "" { + return nil, errors.New("registry_url must not contain a port") + } + if !isValidHost(u.Hostname()) { + return nil, errors.New("registry_url host is not a valid hostname") + } + return u, nil +} + +func canonicalPyPIClients(clients []PyPIClient) bool { + switch len(clients) { + case 1: + return clients[0] == PyPIClientPip || clients[0] == PyPIClientUV + case 2: + return clients[0] == PyPIClientPip && clients[1] == PyPIClientUV + default: + return false + } +} + +func validatePyPICredentialPart(name, value string, maxBytes int) error { + if value == "" || strings.TrimSpace(value) == "" { + return fmt.Errorf("pypi: policy %s is empty", name) + } + if len(value) > maxBytes { + return fmt.Errorf("pypi: policy %s too long", name) + } + if !isNPMSafe(value) { + return fmt.Errorf("pypi: policy %s contains unsupported characters", name) + } + return nil +} + +func (p PyPIPolicy) RegistryHost() string { + u, err := url.Parse(p.RegistryURL) + if err != nil { + return "" + } + return u.Hostname() +} + +func (p PyPIPolicy) DeviceToken() string { return p.Auth.APIKey + "::dev:" + p.deviceID } + +func (p PyPIPolicy) Selects(client PyPIClient) bool { + for _, selected := range p.Clients { + if selected == client { + return true + } + } + return false +} + +// safeObservedRegistryURL returns only credential-free absolute HTTP(S) URLs. +func safeObservedRegistryURL(raw string) string { + if err := transmittableRegistryURL(raw); err != nil { + return "" + } + return raw +} + +func rejectDuplicateJSONKeys(body []byte) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + + var scanValue func() error + scanValue = func() error { + token, err := decoder.Token() + if err != nil { + return err + } + delim, ok := token.(json.Delim) + if !ok { + return nil + } + + switch delim { + case '{': + seen := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("invalid object key") + } + if _, duplicate := seen[key]; duplicate { + return errors.New("duplicate object key") + } + seen[key] = struct{}{} + if err := scanValue(); err != nil { + return err + } + } + case '[': + for decoder.More() { + if err := scanValue(); err != nil { + return err + } + } + default: + return errors.New("unexpected closing delimiter") + } + _, err = decoder.Token() + return err + } + + if err := scanValue(); err != nil { + return fmt.Errorf("devicepolicy: invalid local policy JSON: %w", err) + } + return nil +} diff --git a/internal/devicepolicy/pypi_policy_test.go b/internal/devicepolicy/pypi_policy_test.go new file mode 100644 index 00000000..cd703950 --- /dev/null +++ b/internal/devicepolicy/pypi_policy_test.go @@ -0,0 +1,172 @@ +package devicepolicy + +import ( + "encoding/json" + "slices" + "strings" + "testing" +) + +const ( + pypiDeviceID = "DEVICE-123" + pypiKey = "step_acme-1_uuid" + pypiURL = "https://registry.stepsecurity.io/python/simple" +) + +func TestParsePyPIPolicy_ValidClientSubsets(t *testing.T) { + tests := []struct { + name string + raw string + wantClients []PyPIClient + wantURL string + }{ + { + name: "pip", + raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`, + wantClients: []PyPIClient{PyPIClientPip}, + wantURL: pypiURL, + }, + { + name: "uv with trailing slash normalization", + raw: `{"ecosystem":"pypi","clients":["uv"],"registry_url":"https://registry.stepsecurity.io/python/simple/","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`, + wantClients: []PyPIClient{PyPIClientUV}, + wantURL: pypiURL, + }, + { + name: "pip and uv", + raw: `{"ecosystem":"pypi","clients":["pip","uv"],"registry_url":"https://tenant.registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`, + wantClients: []PyPIClient{PyPIClientPip, PyPIClientUV}, + wantURL: "https://tenant.registry.stepsecurity.io/python/simple", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParsePyPIPolicy(json.RawMessage(tc.raw), pypiDeviceID) + if err != nil { + t.Fatalf("ParsePyPIPolicy() error = %v", err) + } + if got.Ecosystem != "pypi" { + t.Errorf("Ecosystem = %q, want pypi", got.Ecosystem) + } + if got.RegistryURL != tc.wantURL { + t.Errorf("RegistryURL = %q, want %q", got.RegistryURL, tc.wantURL) + } + if !slices.Equal(got.Clients, tc.wantClients) { + t.Errorf("Clients = %v, want %v", got.Clients, tc.wantClients) + } + if got.RegistryHost() != strings.TrimPrefix(strings.Split(tc.wantURL, "/python/simple")[0], "https://") { + t.Errorf("RegistryHost() = %q", got.RegistryHost()) + } + if got.DeviceToken() != pypiKey+"::dev:"+pypiDeviceID { + t.Errorf("DeviceToken() did not append the device suffix exactly once") + } + for _, client := range []PyPIClient{PyPIClientPip, PyPIClientUV} { + if got.Selects(client) != slices.Contains(tc.wantClients, client) { + t.Errorf("Selects(%q) = %v", client, got.Selects(client)) + } + } + }) + } +} + +func TestParsePyPIPolicy_Rejections(t *testing.T) { + valid := `{"ecosystem":"pypi","clients":["pip","uv"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}` + tests := []struct { + name string + raw string + deviceID string + }{ + {name: "empty JSON", raw: ``}, + {name: "malformed JSON", raw: `{`}, + {name: "non-object JSON", raw: `[]`}, + {name: "wrong ecosystem", raw: `{"ecosystem":"npm","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "missing ecosystem", raw: `{"clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "missing clients", raw: `{"ecosystem":"pypi","registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "null clients", raw: `{"ecosystem":"pypi","clients":null,"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "empty clients", raw: `{"ecosystem":"pypi","clients":[],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "duplicate client", raw: `{"ecosystem":"pypi","clients":["pip","pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "unsorted clients", raw: `{"ecosystem":"pypi","clients":["uv","pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "unknown client", raw: `{"ecosystem":"pypi","clients":["poetry"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "missing auth", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple"}`}, + {name: "wrong auth scheme", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"basic","api_key":"step_acme-1_uuid"}}`}, + {name: "empty key", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":""}}`}, + {name: "excessive key length", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"` + strings.Repeat("a", 257) + `"}}`}, + {name: "key whitespace", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step key"}}`}, + {name: "key control byte", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step\u0000key"}}`}, + {name: "key non-ASCII", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"stép"}}`}, + {name: "key unsafe character", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step#key"}}`}, + {name: "key already has device suffix", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_key::dev:other"}}`}, + {name: "key already has another source suffix", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_key::gha:run"}}`}, + {name: "empty registry URL", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "non-HTTPS registry URL", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"http://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "URL userinfo", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://user:secret@registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "URL query", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple?x=1","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "URL bare query", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple?","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "URL fragment", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple#x","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "URL bare fragment", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple#","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "URL port", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io:443/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "uppercase hostname", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://Registry.StepSecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "invalid hostname", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://-registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "wrong path", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "double trailing slash", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple//","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "escaped path", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python%2fsimple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "unknown top-level field", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"},"extra":true}`}, + {name: "unknown auth field", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid","token":"secret"}}`}, + {name: "duplicate JSON key", raw: `{"ecosystem":"pypi","ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid"}}`}, + {name: "duplicate nested JSON key", raw: `{"ecosystem":"pypi","clients":["pip"],"registry_url":"https://registry.stepsecurity.io/python/simple","auth":{"scheme":"stepsecurity_device_token","api_key":"step_acme-1_uuid","api_key":"step_acme-1_uuid"}}`}, + {name: "trailing JSON", raw: valid + ` {}`}, + {name: "trailing data", raw: valid + ` x`}, + {name: "empty device ID", raw: valid, deviceID: " "}, + {name: "excessive device ID", raw: valid, deviceID: strings.Repeat("d", 129)}, + {name: "unsafe device ID", raw: valid, deviceID: "device id"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deviceID := tc.deviceID + if deviceID == "" { + deviceID = pypiDeviceID + } + _, err := ParsePyPIPolicy(json.RawMessage(tc.raw), deviceID) + if err == nil { + t.Fatal("ParsePyPIPolicy() error = nil, want error") + } + for _, secret := range []string{pypiKey, "user:secret", "step#key"} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("ParsePyPIPolicy() error leaked credential material: %v", err) + } + } + }) + } +} + +func TestSafeObservedRegistryURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "expected HTTPS", raw: pypiURL, want: pypiURL}, + {name: "safe HTTP drift", raw: "http://mirror.example/simple", want: "http://mirror.example/simple"}, + {name: "safe port drift", raw: "https://mirror.example:8443/simple", want: "https://mirror.example:8443/simple"}, + {name: "empty", raw: ""}, + {name: "relative", raw: "/python/simple"}, + {name: "non-HTTP scheme", raw: "file://mirror.example/python/simple"}, + {name: "userinfo credential", raw: "https://user:secret@mirror.example/python/simple"}, + {name: "query", raw: "https://mirror.example/python/simple?token=secret"}, + {name: "bare query", raw: "https://mirror.example/python/simple?"}, + {name: "fragment", raw: "https://mirror.example/python/simple#secret"}, + {name: "bare fragment", raw: "https://mirror.example/python/simple#"}, + {name: "control byte", raw: "https://mirror.example/python/\x00simple"}, + {name: "oversized", raw: "https://mirror.example/" + strings.Repeat("a", npmrcMaxRegistryURLBytes)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := safeObservedRegistryURL(tc.raw); got != tc.want { + t.Errorf("safeObservedRegistryURL(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} diff --git a/internal/devicepolicy/reconcile.go b/internal/devicepolicy/reconcile.go index 327dd873..46c2a274 100644 --- a/internal/devicepolicy/reconcile.go +++ b/internal/devicepolicy/reconcile.go @@ -8,6 +8,8 @@ import ( "sort" "strings" "time" + + "github.com/step-security/dev-machine-guard/internal/secureuserfile" ) // enforcementDMG and enforcementMDM are the enforcement channels carried in @@ -37,6 +39,13 @@ type Reconciler struct { Category string // defaults to ide_extension Target string // defaults to vscode + // OwnershipTarget changes only the local state key. Reports still use Target. + OwnershipTarget string + + // OwnershipStateValue replaces the rendered single-value ownership payload. + // Empty preserves existing IDE and npm behavior. + OwnershipStateValue string + // Probe reports whether a real MDM/admin-managed AllowedExtensions policy // exists at this OS's policy location (registry / policy.json / managed // preferences). Such a policy outranks user settings inside VS Code, so the @@ -127,6 +136,8 @@ type Reconciler struct { // "dmg"). Stamped onto every report as EvaluatedEnforcement so it matches the // backend's exact-match gate. Per-cycle scratch. enforcement string + // evaluatedHash is the active npm policy hash fetched for this cycle. + evaluatedHash string } // readState / persistState / dropState are every category's access to the one @@ -135,18 +146,20 @@ type Reconciler struct { // category and target, and takes a cross-process lock so two agent processes // reconciling different categories cannot drop each other's record. The // writeState/clearState test seams inject persist failures. -func (r *Reconciler) readState(cat, tgt string) (AppliedTargetState, bool) { - return ReadAppliedState(cat, tgt) +func (r *Reconciler) readState(cat string) (AppliedTargetState, bool) { + return ReadAppliedState(cat, r.stateTarget()) } -func (r *Reconciler) persistState(cat, tgt string, s AppliedTargetState) error { +func (r *Reconciler) persistState(cat string, s AppliedTargetState) error { + tgt := r.stateTarget() if r.writeState != nil { return r.writeState(cat, tgt, s) } return WriteAppliedState(cat, tgt, s) } -func (r *Reconciler) dropState(cat, tgt string) error { +func (r *Reconciler) dropState(cat string) error { + tgt := r.stateTarget() if r.clearState != nil { return r.clearState(cat, tgt) } @@ -209,7 +222,7 @@ func (r *Reconciler) rollback(prevOnDisk string, prevPresent bool) (state string // transient I/O) stays verification_failed. The IDE writer never wraps the // sentinel, so this always returns verification_failed for it. func classifyReadError(err error) string { - if errors.Is(err, ErrTargetUnusable) { + if errors.Is(err, ErrTargetUnusable) || errors.Is(err, secureuserfile.ErrTargetUnusable) { return StateWriteFailed } return StateVerificationFailed @@ -222,7 +235,7 @@ func classifyReadError(err error) string { // which is verification_failed, not a clean write failure. The IDE writer never // returns that sentinel, so this is always write_failed for it. func classifyWriteError(err error) string { - if errors.Is(err, ErrWriteUnverified) { + if errors.Is(err, ErrWriteUnverified) || errors.Is(err, secureuserfile.ErrWriteUnverified) { return StateVerificationFailed } return StateWriteFailed @@ -255,6 +268,20 @@ func (r *Reconciler) target() string { return TargetVSCode } +func (r *Reconciler) stateTarget() string { + if r.OwnershipTarget != "" { + return r.OwnershipTarget + } + return r.target() +} + +func (r *Reconciler) stateValue(rendered string) string { + if r.OwnershipStateValue != "" { + return r.OwnershipStateValue + } + return rendered +} + func (r *Reconciler) probe() (bool, string) { if r.Probe != nil { return r.Probe() @@ -314,6 +341,7 @@ func (r *Reconciler) ownershipKey() string { // - policy result → probe → ownership/drift-checked write + readback + // verify + report (handleEnforce). func (r *Reconciler) Reconcile(ctx context.Context) error { + r.evaluatedHash = "" if r.Fetcher == nil { return errors.New("devicepolicy: nil fetcher") } @@ -325,6 +353,9 @@ func (r *Reconciler) Reconcile(ctx context.Context) error { // Malformed/transient: do nothing. The on-disk policy (if any) stands. return fmt.Errorf("devicepolicy: fetch: %w", err) } + if cat == CategoryPackageConfig && tgt == TargetNPM && ep.present() && !ep.Clear { + r.evaluatedHash = ep.Hash + } // Resolve the requested channel to the canonical one this cycle actually // runs, and stamp THAT on every report as EvaluatedEnforcement: the backend // gates on an exact "mdm"/"dmg", so the report must name the channel that ran @@ -466,7 +497,7 @@ func (r *Reconciler) handleClear(cat, tgt string) error { return r.handleClearByMarker(cat, tgt) } - prev, hadPrev := r.readState(cat, tgt) + prev, hadPrev := r.readState(cat) if mw, ok := r.Writer.(managedSettingsWriter); ok { return r.clearManaged(cat, tgt, prev, hadPrev, mw) } @@ -540,7 +571,7 @@ func (r *Reconciler) clearManaged(cat, tgt string, prev AppliedTargetState, hadP // failed. An absent entry → no-op (idempotent). func (r *Reconciler) dropClearedState(cat, tgt string, hadPrev bool) error { if hadPrev { - if err := r.dropState(cat, tgt); err != nil { + if err := r.dropState(cat); err != nil { return fmt.Errorf("devicepolicy: clear: update state: %w", err) } } @@ -566,7 +597,7 @@ func (r *Reconciler) handleClearByMarker(cat, tgt string) error { } else { r.logf("devicepolicy: clear requested but %s holds no managed block; nothing to remove", r.Writer.Location()) } - if err := r.dropState(cat, tgt); err != nil { + if err := r.dropState(cat); err != nil { return fmt.Errorf("devicepolicy: clear: update state: %w", err) } return nil @@ -662,7 +693,8 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe ownKey := r.ownershipKey() // 2. Read the current value. - prev, hadPrev := r.readState(cat, tgt) + prev, hadPrev := r.readState(cat) + prevWritten := prev.WrittenSettings[ownKey] onDisk, present, err := r.Writer.Read() if err != nil { // Couldn't read to decide idempotency/drift. A structural refusal (the @@ -686,8 +718,8 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe _ = r.report(ctx, cat, tgt, state, "") return fmt.Errorf("devicepolicy: enforce: convergence check %s: %w", r.Writer.Location(), cerr) } - if converged && prev.AppliedHash == ep.Hash { - r.logf("devicepolicy: policy already applied (hash unchanged) — no write") + if converged && prev.AppliedHash == ep.Hash && (r.OwnershipStateValue == "" || prevWritten == r.stateValue(newValue)) { + r.logf("devicepolicy: policy already applied (hash unchanged) - no write") return r.report(ctx, cat, tgt, StateCompliant, ep.Hash) } @@ -702,9 +734,9 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe // cycle. Gated on the Converged seam so the settings.json path (body equality) // is byte-identical to before. if converged && r.Converged != nil { - if perr := r.persistState(cat, tgt, AppliedTargetState{ + if perr := r.persistState(cat, AppliedTargetState{ AppliedHash: ep.Hash, - WrittenSettings: map[string]string{ownKey: newValue}, + WrittenSettings: map[string]string{ownKey: r.stateValue(newValue)}, FetchedAt: r.now(), }); perr != nil { r.logf("devicepolicy: could not adopt already-converged state at %s: %v", r.Writer.Location(), perr) @@ -717,8 +749,13 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe // it (edited or removed — typically the user hand-editing settings.json). // Enforcement means converging it back; the distinct state lets the // backend surface that it happened. - prevWritten := prev.WrittenSettings[ownKey] drifted := hadPrev && prevWritten != "" && (!present || onDisk != prevWritten) + if r.OwnershipStateValue != "" { + // A fixed marker proves ownership, not content equality. Under the same + // desired hash, failed target-specific convergence is drift; a new hash is + // a desired-policy transition. + drifted = hadPrev && prevWritten != "" && prev.AppliedHash == ep.Hash && !converged + } if drifted { r.logf("devicepolicy: %s diverged from the recorded written value → re-applying (drift)", r.Writer.Location()) } @@ -731,7 +768,7 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe if !hadPrev { probe = AppliedTargetState{FetchedAt: r.now()} } - if perr := r.persistState(cat, tgt, probe); perr != nil { + if perr := r.persistState(cat, probe); perr != nil { _ = r.report(ctx, cat, tgt, StateWriteFailed, "") return fmt.Errorf("devicepolicy: enforce: ownership state not writable, refusing to write policy: %w", perr) } @@ -746,14 +783,14 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe } readbackMatch := rb == newValue - // Ownership is recorded on EVERY successful write — it means "what the agent - // wrote", not "what it verified". On a readback mismatch the write may still - // have landed; without a record the next cycle would classify the agent's - // own value as drift forever. Value-based ownership self-corrects: the - // record only takes effect when the on-disk value actually equals it. - if err := r.persistState(cat, tgt, AppliedTargetState{ + // Ownership is recorded on EVERY successful write. By default it records the + // rendered value; a fixed-state marker component records its non-secret + // identity instead and delegates exact content checks to Converged. On a + // readback mismatch the write may still have landed, so the record is retained + // for next-cycle recovery. + if err := r.persistState(cat, AppliedTargetState{ AppliedHash: ep.Hash, - WrittenSettings: map[string]string{ownKey: newValue}, + WrittenSettings: map[string]string{ownKey: r.stateValue(newValue)}, FetchedAt: r.now(), }); err != nil { // The write happened but ownership couldn't be recorded — undo it so no @@ -792,7 +829,7 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe // (a foreign or absent value is never deleted). No setting id is special-cased, // so a new managed key rides through with no change here. func (r *Reconciler) enforceManaged(ctx context.Context, cat, tgt string, ep EffectivePolicy, desired map[string]string, mw managedSettingsWriter) error { - prev, hadPrev := r.readState(cat, tgt) + prev, hadPrev := r.readState(cat) owned := ownedKeys(prev, hadPrev) // 1. Read every key this cycle may touch: the union of the settings map's keys @@ -865,7 +902,7 @@ func (r *Reconciler) enforceManaged(ctx context.Context, cat, tgt string, ep Eff if !hadPrev { probe = AppliedTargetState{FetchedAt: r.now()} } - if perr := r.persistState(cat, tgt, probe); perr != nil { + if perr := r.persistState(cat, probe); perr != nil { _ = r.report(ctx, cat, tgt, StateWriteFailed, "") return fmt.Errorf("devicepolicy: enforce: ownership state not writable, refusing to write policy: %w", perr) } @@ -901,7 +938,7 @@ func (r *Reconciler) enforceManaged(ctx context.Context, cat, tgt string, ep Eff for key, v := range desired { ownedAfter[key] = v } - if err := r.persistState(cat, tgt, AppliedTargetState{ + if err := r.persistState(cat, AppliedTargetState{ AppliedHash: ep.Hash, WrittenSettings: ownedAfter, FetchedAt: r.now(), @@ -1054,9 +1091,13 @@ func (r *Reconciler) report(ctx context.Context, cat, tgt, state, appliedHash st } // sendReport stamps the shared fields (agent version, platform, -// EvaluatedEnforcement) and submits. Callers fill Category/Target/State and the -// lane-specific field: AppliedHash for the write path, Observed for MDM. +// EvaluatedEnforcement, and npm's fetched EvaluatedHash) and submits. Callers +// fill Category/Target/State and the lane-specific field: AppliedHash for the +// write path, Observed for MDM. func (r *Reconciler) sendReport(ctx context.Context, rep ComplianceReport) error { + if rep.EvaluatedHash == "" { + rep.EvaluatedHash = r.evaluatedHash + } rep.AgentVersion = AgentVersion() rep.Platform = r.Platform rep.EvaluatedEnforcement = r.enforcement diff --git a/internal/devicepolicy/reconcile_npm_test.go b/internal/devicepolicy/reconcile_npm_test.go index 038e947e..431ed693 100644 --- a/internal/devicepolicy/reconcile_npm_test.go +++ b/internal/devicepolicy/reconcile_npm_test.go @@ -154,6 +154,9 @@ func TestNPMEnforceRendersBlockAndWrites(t *testing.T) { if got.AppliedHash != "sha256:N" { t.Fatalf("applied_hash = %q, want sha256:N", got.AppliedHash) } + if got.EvaluatedHash != "sha256:N" { + t.Fatalf("evaluated_hash = %q, want sha256:N", got.EvaluatedHash) + } // Ownership recorded in the one shared state file, under this category/target. if st.writes == 0 { t.Fatal("ownership must be recorded in the state file") @@ -373,7 +376,7 @@ func TestNPMClearByMarkerAlwaysClearsAndDrops(t *testing.T) { st.seed(t, CategoryPackageConfig, TargetNPM, *tc.seed) } w := &fakeWriter{value: "a-managed-block", present: true} - ep := EffectivePolicy{Category: CategoryPackageConfig, Target: TargetNPM, Clear: true} + ep := EffectivePolicy{Category: CategoryPackageConfig, Target: TargetNPM, Clear: true, Hash: "sha256:CLEAR"} r, rep := newNPMRec(t, ep, w, st) if err := r.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) @@ -464,9 +467,13 @@ func TestNPMWriteErrorClassification(t *testing.T) { if len(w.writes) != 1 { t.Fatalf("Write should have been attempted once, got %v", w.writes) } - if got := lastReport(t, rep); got.State != tc.state { + got := lastReport(t, rep) + if got.State != tc.state { t.Fatalf("state = %q, want %q", got.State, tc.state) } + if got.EvaluatedHash != "sha256:N" { + t.Fatalf("evaluated_hash = %q, want sha256:N", got.EvaluatedHash) + } }) } } @@ -520,11 +527,35 @@ func TestNPMWriterInitErrClassification(t *testing.T) { t.Fatalf("report[%d] identity = %q/%q, want package_config/npm", i, rep.reports[i].Category, rep.reports[i].Target) } + if rep.reports[i].EvaluatedHash != tc.ep.Hash { + t.Fatalf("report[%d] evaluated_hash = %q, want %q", i, rep.reports[i].EvaluatedHash, tc.ep.Hash) + } } }) } } +func TestNPMAbsentPolicyDoesNotReport(t *testing.T) { + r, rep := newNPMRec(t, EffectivePolicy{}, &fakeWriter{}, newNPMStore(t)) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(rep.reports) != 0 { + t.Fatalf("absent policy must not report, got %+v", rep.reports) + } +} + +func TestNPMFetchFailureDoesNotReport(t *testing.T) { + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), &fakeWriter{}, newNPMStore(t)) + r.Fetcher = &fakeFetcher{err: errors.New("fetch failed")} + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("fetch failure must surface") + } + if len(rep.reports) != 0 { + t.Fatalf("fetch failure must not report, got %+v", rep.reports) + } +} + func TestNPMStateLivesInTheOneSharedFile(t *testing.T) { // npm ownership goes in device-policy-state.json under // categories.package_config.targets.npm — the same file, and the same @@ -694,6 +725,9 @@ func TestNPMMDMChannelVerifiesAndNeverWrites(t *testing.T) { if rec.AppliedHash != "" { t.Fatalf("applied_hash = %q, want empty in mdm mode", rec.AppliedHash) } + if rec.EvaluatedHash != "sha256:N" { + t.Fatalf("evaluated_hash = %q, want sha256:N", rec.EvaluatedHash) + } var observed map[string]json.RawMessage if err := json.Unmarshal(rec.Observed, &observed); err != nil { t.Fatalf("observed is not a JSON object: %v (%s)", err, rec.Observed) @@ -889,8 +923,12 @@ func TestIDEMDMChannelKeepsItsDefaultProbe(t *testing.T) { if err := r.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } - if got := lastReport(t, rep).State; got != StatePolicyNotApplied && got != StateMDMManaged { - t.Fatalf("state = %q, want the OS probe's verdict (policy_not_applied or mdm_managed), not an error", got) + got := lastReport(t, rep) + if got.State != StatePolicyNotApplied && got.State != StateMDMManaged { + t.Fatalf("state = %q, want the OS probe's verdict (policy_not_applied or mdm_managed), not an error", got.State) + } + if got.EvaluatedHash != "" { + t.Fatalf("evaluated_hash = %q, want empty for ide_extension", got.EvaluatedHash) } } diff --git a/internal/devicepolicy/reconcile_test.go b/internal/devicepolicy/reconcile_test.go index 806bf159..717f8466 100644 --- a/internal/devicepolicy/reconcile_test.go +++ b/internal/devicepolicy/reconcile_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" ) @@ -1470,3 +1471,187 @@ func TestReconcileDMGEchoesEvaluatedEnforcement(t *testing.T) { t.Fatalf("evaluated_enforcement = %q, want dmg", got.EvaluatedEnforcement) } } + +const testPyPICredentialOwnershipKey = "credential" + +func newPyPICredentialRec(t *testing.T, hash, rendered string, w *fakeWriter) (*Reconciler, *fakeReporter) { + t.Helper() + withTempCache(t) + rep := &fakeReporter{} + r := &Reconciler{ + Fetcher: &fakeFetcher{ep: EffectivePolicy{ + Category: CategoryPackageConfig, + Target: TargetPyPI, + Policy: json.RawMessage(`{"ecosystem":"pypi"}`), + Hash: hash, + }}, + Reporter: rep, + Writer: w, + CustomerID: "cust", + DeviceID: "dev-1", + Platform: "linux", + Category: CategoryPackageConfig, + Target: TargetPyPI, + OwnershipTarget: PyPICredentialOwnershipTarget, + OwnershipStateValue: PyPICredentialOwnershipValue, + OwnershipKey: testPyPICredentialOwnershipKey, + OwnsByMarker: true, + Render: func(json.RawMessage) (string, error) { return rendered, nil }, + Converged: func(expected string) (bool, error) { return w.present && w.value == expected, nil }, + Probe: func() (bool, string) { return false, "" }, + Now: func() time.Time { return time.Date(2026, 8, 26, 0, 0, 0, 0, time.UTC) }, + } + return r, rep +} + +func TestReconcilerComponentOwnershipUsesLocalTargetAndExternalReportIdentity(t *testing.T) { + const rendered = "tenant-key::dev:dev-1" + w := &fakeWriter{} + r, rep := newPyPICredentialRec(t, "sha256:H", rendered, w) + + publicState := AppliedTargetState{ + AppliedHash: "sha256:H", + WrittenSettings: map[string]string{ + testPyPICredentialOwnershipKey: "public-target-sentinel", + }, + } + if err := WriteAppliedState(CategoryPackageConfig, TargetPyPI, publicState); err != nil { + t.Fatal(err) + } + + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + gotReport := lastReport(t, rep) + if gotReport.Target != TargetPyPI || gotReport.State != StateCompliant { + t.Fatalf("report = %+v, want external target pypi and compliant", gotReport) + } + credentialState, ok := ReadAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget) + if !ok { + t.Fatal("credential ownership target was not written") + } + if got := credentialState.WrittenSettings[testPyPICredentialOwnershipKey]; len(credentialState.WrittenSettings) != 1 || got != PyPICredentialOwnershipValue { + t.Fatalf("credential ownership = %+v, want only %q", credentialState.WrittenSettings, PyPICredentialOwnershipValue) + } + rawState, err := os.ReadFile(CachePath()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rawState), rendered) { + t.Fatalf("rendered credential leaked into ownership state: %s", rawState) + } + if got, ok := ReadAppliedState(CategoryPackageConfig, TargetPyPI); !ok || got.WrittenSettings[testPyPICredentialOwnershipKey] != "public-target-sentinel" { + t.Fatalf("public target state changed: %+v ok=%v", got, ok) + } + + r.Fetcher.(*fakeFetcher).ep = EffectivePolicy{Category: CategoryPackageConfig, Target: TargetPyPI, Clear: true} + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("clear Reconcile: %v", err) + } + if _, ok := ReadAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget); ok { + t.Fatal("credential ownership target must be removed by clear") + } + if got, ok := ReadAppliedState(CategoryPackageConfig, TargetPyPI); !ok || got.WrittenSettings[testPyPICredentialOwnershipKey] != "public-target-sentinel" { + t.Fatalf("clear changed public target state: %+v ok=%v", got, ok) + } +} + +func TestReconcilerFixedOwnershipAdoptsStaleOrMismatchedState(t *testing.T) { + const rendered = "tenant-key::dev:dev-1" + tests := []struct { + name string + prior *AppliedTargetState + }{ + {"missing state", nil}, + {"stale hash", &AppliedTargetState{AppliedHash: "sha256:OLD", WrittenSettings: map[string]string{testPyPICredentialOwnershipKey: PyPICredentialOwnershipValue}}}, + {"missing ownership key", &AppliedTargetState{AppliedHash: "sha256:H", WrittenSettings: map[string]string{}}}, + {"mismatched ownership value", &AppliedTargetState{AppliedHash: "sha256:H", WrittenSettings: map[string]string{testPyPICredentialOwnershipKey: "wrong"}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{value: rendered, present: true} + r, rep := newPyPICredentialRec(t, "sha256:H", rendered, w) + if tc.prior != nil { + if err := WriteAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget, *tc.prior); err != nil { + t.Fatal(err) + } + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatal(err) + } + if len(w.writes) != 0 { + t.Fatalf("writes = %v, want converged marker adoption", w.writes) + } + state, ok := ReadAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget) + if !ok || state.AppliedHash != "sha256:H" || state.WrittenSettings[testPyPICredentialOwnershipKey] != PyPICredentialOwnershipValue { + t.Fatalf("ownership state = %+v ok=%v, want repaired current state", state, ok) + } + if got := lastReport(t, rep); got.State != StateCompliant || got.AppliedHash != "sha256:H" { + t.Fatalf("report = %+v, want compliant current hash", got) + } + }) + } +} + +func TestReconcilerFixedOwnershipPolicyRotationIsNotDrift(t *testing.T) { + const ( + oldRendered = "old-key::dev:dev-1" + newRendered = "new-key::dev:dev-1" + ) + w := &fakeWriter{value: oldRendered, present: true} + r, rep := newPyPICredentialRec(t, "sha256:NEW", newRendered, w) + if err := WriteAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget, AppliedTargetState{ + AppliedHash: "sha256:OLD", + WrittenSettings: map[string]string{ + testPyPICredentialOwnershipKey: PyPICredentialOwnershipValue, + }, + }); err != nil { + t.Fatal(err) + } + + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(w.writes) != 1 || w.writes[0] != newRendered { + t.Fatalf("writes = %v, want rotated credential", w.writes) + } + if got := lastReport(t, rep); got.State != StateCompliant { + t.Fatalf("state = %q, want compliant for desired-hash rotation", got.State) + } +} + +func TestReconcilerFixedOwnershipSameHashMismatchIsDrift(t *testing.T) { + const rendered = "tenant-key::dev:dev-1" + w := &fakeWriter{value: "manually-edited", present: true} + r, rep := newPyPICredentialRec(t, "sha256:H", rendered, w) + if err := WriteAppliedState(CategoryPackageConfig, PyPICredentialOwnershipTarget, AppliedTargetState{ + AppliedHash: "sha256:H", + WrittenSettings: map[string]string{ + testPyPICredentialOwnershipKey: PyPICredentialOwnershipValue, + }, + }); err != nil { + t.Fatal(err) + } + + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if got := lastReport(t, rep); got.State != StateDriftDetected { + t.Fatalf("state = %q, want drift_detected for same-hash convergence failure", got.State) + } +} + +func TestReconcilerDefaultOwnershipBehaviorUnchanged(t *testing.T) { + w := &fakeWriter{} + r, rep := newRec(t, policyEP("sha256:H"), nil, w) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + st, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode) + if !ok || st.WrittenSettings[allowedExtensionsSettingKey] != samplePolicy { + t.Fatalf("default ownership = %+v ok=%v, want rendered policy under vscode", st, ok) + } + if got := lastReport(t, rep); got.Target != TargetVSCode || got.State != StateCompliant { + t.Fatalf("default report = %+v, want vscode compliant", got) + } +} diff --git a/internal/devicepolicy/secure_user_file_test.go b/internal/devicepolicy/secure_user_file_test.go new file mode 100644 index 00000000..4551e5ab --- /dev/null +++ b/internal/devicepolicy/secure_user_file_test.go @@ -0,0 +1,71 @@ +package devicepolicy + +import ( + "os" + "os/user" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/secureuserfile" +) + +type secureTestExecutor struct { + executor.Executor + user *user.User +} + +func (e secureTestExecutor) LoggedInUser() (*user.User, error) { return e.user, nil } +func (e secureTestExecutor) IsRoot() bool { return false } +func (e secureTestExecutor) GOOS() string { return "test" } + +func newSecureTestHome(t *testing.T, home string) *secureuserfile.Home { + t.Helper() + u, err := user.Current() + if err != nil { + t.Fatalf("current user: %v", err) + } + return newSecureTestHomeAs(t, home, u.Username) +} + +func newSecureTestHomeAs(t *testing.T, home, username string) *secureuserfile.Home { + t.Helper() + u, err := user.Current() + if err != nil { + t.Fatalf("current user: %v", err) + } + u.HomeDir = home + u.Username = username + normalizeSecureTestUser(t, u) + h, err := secureuserfile.OpenUserHome(secureTestExecutor{Executor: executor.NewReal(), user: u}) + if err != nil { + t.Fatalf("OpenUserHome: %v", err) + } + t.Cleanup(func() { _ = h.Close() }) + return h +} + +func hardenSecureTestFile(t *testing.T, f *secureuserfile.File) { + t.Helper() + data, err := os.ReadFile(f.Location()) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(f.Location()); err != nil { + t.Fatal(err) + } + if err := f.Commit(data, secureuserfile.FileMode); err != nil { + t.Fatal(err) + } +} + +func TestPythonWriterBackupPrefixes(t *testing.T) { + for name, got := range map[string]string{ + "netrc": netrcBackupPrefix, + "pip": pipBackupPrefix, + "uv": uvBackupPrefix, + } { + if got != ".dmg-" { + t.Errorf("%s backup prefix = %q, want .dmg-", name, got) + } + } +} diff --git a/internal/devicepolicy/secure_user_file_unix_test.go b/internal/devicepolicy/secure_user_file_unix_test.go new file mode 100644 index 00000000..6cddac14 --- /dev/null +++ b/internal/devicepolicy/secure_user_file_unix_test.go @@ -0,0 +1,12 @@ +//go:build unix + +package devicepolicy + +import ( + "os/user" + "testing" +) + +func normalizeSecureTestUser(t *testing.T, _ *user.User) { + t.Helper() +} diff --git a/internal/devicepolicy/secure_user_file_windows_test.go b/internal/devicepolicy/secure_user_file_windows_test.go new file mode 100644 index 00000000..7970e74c --- /dev/null +++ b/internal/devicepolicy/secure_user_file_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package devicepolicy + +import ( + "os/user" + "testing" + + "golang.org/x/sys/windows" +) + +func normalizeSecureTestUser(t *testing.T, u *user.User) { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(u.HomeDir, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + t.Fatal(err) + } + owner, _, err := descriptor.Owner() + if err != nil || owner == nil { + t.Fatalf("temporary home owner: %v", err) + } + u.Uid = owner.String() +} diff --git a/internal/devicepolicy/testdata/uv-show-settings-0.12.6.txt b/internal/devicepolicy/testdata/uv-show-settings-0.12.6.txt new file mode 100644 index 00000000..87237f7b --- /dev/null +++ b/internal/devicepolicy/testdata/uv-show-settings-0.12.6.txt @@ -0,0 +1,211 @@ +GlobalSettings { + required_version: None, + quiet: 0, + verbose: 0, + color: Auto, + network_settings: NetworkSettings { + connectivity: Online, + offline: Disabled, + system_certs: false, + custom_certificates: None, + http_proxy: None, + https_proxy: None, + no_proxy: None, + allow_insecure_host: [], + read_timeout: 30s, + connect_timeout: 10s, + retries: 3, + }, + concurrency: Concurrency { + downloads: 50, + builds: 10, + installs: 10, + cache_reads: 4, + }, + show_settings: true, + preview: Preview { + flags: [], + }, + python_preference: Managed, + python_downloads: Automatic, + no_progress: false, + installer_metadata: true, +} +CacheSettings { + no_cache: false, + cache_dir: None, +} +PipInstallSettings { + package: [ + "example-package", + ], + requirements: [], + editables: [], + editable: None, + constraints: [], + overrides: [], + excludes: [], + build_constraints: [], + dry_run: Disabled, + constraints_from_workspace: [], + overrides_from_workspace: [], + excludes_from_workspace: [], + build_constraints_from_workspace: [], + modifications: Sufficient, + refresh: None( + Timestamp( + SystemTime { + tv_sec: 1787723910, + tv_nsec: 593710000, + }, + ), + ), + settings: PipSettings { + index_locations: IndexLocations { + indexes: [ + Index { + name: Some( + IndexName( + "step-security", + ), + ), + url: Url( + VerbatimUrl { + url: DisplaySafeUrl { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "registry.stepsecurity.io", + ), + ), + port: None, + path: "/python/simple", + query: None, + fragment: None, + }, + given: Some( + "https://registry.stepsecurity.io/python/simple", + ), + expanded: false, + }, + ), + explicit: false, + default: true, + origin: None, + format: Simple, + publish_url: None, + authenticate: Auto, + ignore_error_codes: None, + cache_control: None, + hash_algorithm: None, + exclude_newer: None, + }, + ], + flat_index: [], + no_index: false, + }, + python: None, + install_mirrors: PythonInstallMirrors { + python_install_mirror: None, + pypy_install_mirror: None, + python_downloads_json_url: None, + }, + system: false, + extras: ExtrasSpecification( + ExtrasSpecificationInner { + include: Some( + [], + ), + exclude: [], + only_extras: false, + history: ExtrasSpecificationHistory { + extra: [], + only_extra: [], + no_extra: [], + all_extras: false, + no_default_extras: false, + defaults: List( + [], + ), + }, + }, + ), + groups: [], + break_system_packages: false, + target: None, + prefix: None, + index_strategy: FirstIndex, + keyring_provider: Disabled, + torch_backend: None, + cuda_driver_version: None, + amd_gpu_architecture: None, + build_isolation: Isolate, + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), + extra_build_variables: ExtraBuildVariables( + {}, + ), + build_options: BuildOptions { + no_binary: None, + no_build: None, + }, + allow_empty_requirements: false, + strict: false, + dependency_mode: Transitive, + resolution: Highest, + prerelease: Prerelease { + global: IfNecessary, + package: PrereleasePackage( + {}, + ), + }, + fork_strategy: RequiresPython, + dependency_metadata: DependencyMetadata( + {}, + ), + output_file: None, + no_strip_extras: false, + no_strip_markers: false, + no_annotate: false, + no_header: false, + custom_compile_command: None, + generate_hashes: false, + config_setting: ConfigSettings( + {}, + ), + config_settings_package: PackageConfigSettings( + {}, + ), + python_version: None, + python_platform: None, + universal: false, + exclude_newer: ExcludeNewer { + global: None, + package: ExcludeNewerPackage( + {}, + ), + }, + no_emit_package: [], + emit_index_url: false, + emit_find_links: false, + emit_build_options: false, + emit_marker_expression: false, + emit_index_annotation: false, + annotation_style: Split, + link_mode: Clone, + compile_bytecode: false, + sources: None, + hash_checking: Some( + Verify, + ), + upgrade: Upgrade { + strategy: None, + constraints: {}, + }, + reinstall: None, + }, +} diff --git a/internal/devicepolicy/uv_writer.go b/internal/devicepolicy/uv_writer.go new file mode 100644 index 00000000..2d7fa55e --- /dev/null +++ b/internal/devicepolicy/uv_writer.go @@ -0,0 +1,1037 @@ +package devicepolicy + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/pelletier/go-toml/v2" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/secureuserfile" +) + +const ( + dmgUVBegin = "# BEGIN StepSecurity PyPI Secure Registry uv -- managed by dmg" + dmgUVEnd = "# END StepSecurity PyPI Secure Registry uv" + mdmUVBegin = "# BEGIN StepSecurity PyPI Secure Registry uv -- managed by mdm" + mdmUVEnd = "# END StepSecurity PyPI Secure Registry uv" + dmgUVDisabledPrefix = "# [stepsecurity-pypi-uv-dmg] " + dmgUVCreatedFile = "# [stepsecurity-pypi-uv-dmg] created=true" + uvBackupPrefix = ".dmg-" + uvProbePackage = "stepsecurity-policy-probe" +) + +var errUVUnsupportedVersion = errors.New("uv: installed version is not supported") + +// UVObservation is the secret-free user and effective uv policy state. +type UVObservation struct { + RegistryURL string + ConfigStatus string + EffectiveStatus string + OverrideSource string +} + +// UVWriter manages the resolved user's uv.toml. +type UVWriter struct { + exec executor.Executor + home *secureuserfile.Home + file *secureuserfile.File + expected string + registryURL string + installed bool + versionKnown bool + versionSupported bool + + restoreSnapshot func() error + purgeBackups func() error +} + +func NewUVWriter(ctx context.Context, exec executor.Executor, home *secureuserfile.Home, policy PyPIPolicy) (*UVWriter, error) { + if home == nil { + return nil, errors.New("uv: nil secure user home") + } + expected, err := renderUVSettings(policy) + if err != nil { + return nil, err + } + userExec := executor.NewUserAwareExecutor(exec, home.Username()) + path, err := uvUserConfigPath(userExec, home.Path()) + if err != nil { + return nil, err + } + if err := executor.UserEnvironmentError(userExec); err != nil { + return nil, fmt.Errorf("uv: resolving user environment: %w", err) + } + relative, err := filepath.Rel(home.Path(), path) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + return nil, fmt.Errorf("uv: user path is outside resolved home: %w", ErrTargetUnusable) + } + file, err := home.Open(relative, uvBackupPrefix, secureuserfile.MaxBytes) + if err != nil { + return nil, err + } + w := &UVWriter{exec: userExec, home: home, file: file, expected: expected, registryURL: policy.RegistryURL} + w.restoreSnapshot = file.RestoreSnapshot + w.purgeBackups = file.PurgeBackups + if _, err := executor.LookPathWithContext(ctx, userExec, "uv"); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + return w, nil + } + w.installed = true + stdout, _, exit, err := userExec.RunWithTimeout(ctx, 5*time.Second, "uv", "--version") + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + if err != nil || exit != 0 { + return w, nil + } + major, minor, patch, ok := parseUVVersion(stdout) + if !ok { + w.versionKnown = isUVPrerelease(stdout) + return w, nil + } + w.versionKnown = true + w.versionSupported = uvVersionAtLeast(major, minor, patch, 0, 10, 0) + return w, nil +} + +func renderUVSettings(policy PyPIPolicy) (string, error) { + if policy.RegistryURL == "" { + return "", errors.New("uv: empty registry URL") + } + return "index-strategy = \"first-index\"\n\n[[index]]\nname = \"stepsecurity\"\nurl = " + strconv.Quote(policy.RegistryURL) + "\ndefault = true\nauthenticate = \"always\"", nil +} + +func uvUserConfigPath(exec executor.Executor, home string) (string, error) { + root := "" + if exec.GOOS() == "windows" { + root = strings.TrimSpace(exec.Getenv("APPDATA")) + if root == "" { + root = filepath.Join(home, "AppData", "Roaming") + } + } else { + root = strings.TrimSpace(exec.Getenv("XDG_CONFIG_HOME")) + if root == "" { + root = filepath.Join(home, ".config") + } + } + if !filepath.IsAbs(root) { + return "", fmt.Errorf("uv: configuration root must be absolute: %w", ErrTargetUnusable) + } + return filepath.Join(filepath.Clean(root), "uv", "uv.toml"), nil +} + +func (w *UVWriter) validateExpected(expected string) error { + if expected == "" || expected != w.expected { + return errors.New("uv: expected settings do not match validated policy") + } + return nil +} + +func (w *UVWriter) Location() string { return w.file.Location() } + +func (w *UVWriter) readCurrent() ([]byte, bool, os.FileMode, error) { + present, err := w.file.ParentPresent() + if err != nil || !present { + return nil, false, 0, err + } + return w.file.Read() +} + +func (w *UVWriter) Read() (string, bool, error) { + ok, err := w.StaticConverged(w.expected) + if err != nil || !ok { + return "", false, err + } + return w.expected, true, nil +} + +func (w *UVWriter) Write(expected string) (string, error) { + if err := w.validateExpected(expected); err != nil { + return "", err + } + if w.installed && (!w.versionKnown || !w.versionSupported) { + return "", errUVUnsupportedVersion + } + if err := w.home.EnsureParent(w.file.RelativePath()); err != nil { + return "", err + } + current, existed, _, err := w.file.Read() + if err != nil { + return "", err + } + markers, err := scanUVMarkers(current) + if err != nil { + return "", err + } + if markers.mdmComplete() { + return "", fmt.Errorf("uv: MDM marker present: %w", ErrTargetUnusable) + } + created := !existed + if markers.complete() { + created = markers.created + } + updated, err := rewriteUVConfig(current, expected, created) + if err != nil { + return "", err + } + if err := w.file.Commit(updated, 0o600); err != nil { + return "", err + } + ok, err := w.StaticConverged(expected) + if err != nil { + return "", errors.Join(err, w.restoreSnapshot()) + } + if !ok { + err = errors.New("uv: committed settings did not verify") + return "", errors.Join(err, w.restoreSnapshot()) + } + return expected, nil +} + +func (w *UVWriter) Clear() (bool, error) { + current, existed, _, err := w.readCurrent() + if err != nil { + return false, err + } + if !existed { + return false, nil + } + markers, err := scanUVMarkers(current) + if err != nil { + return false, err + } + updated, changed, err := clearUVConfig(current) + if err != nil || !changed { + return false, err + } + if len(bytes.TrimSpace(stripUTF8BOM(updated))) == 0 && markers.created { + if err := w.file.Remove(); err != nil { + return false, err + } + } else if err := w.file.Commit(updated, 0o600); err != nil { + return false, err + } + if err := w.purgeBackups(); err != nil { + return false, errors.Join(err, w.restoreSnapshot()) + } + return true, nil +} + +func (w *UVWriter) Converged(expected string) (bool, error) { + return w.StaticConverged(expected) +} + +func (w *UVWriter) StaticConverged(expected string) (bool, error) { + if err := w.validateExpected(expected); err != nil { + return false, err + } + data, existed, _, err := w.readCurrent() + if err != nil || !existed { + return false, err + } + markers, err := scanUVMarkers(data) + if err != nil { + return false, err + } + if markers.mdmComplete() || !markers.complete() { + return false, nil + } + var doc map[string]any + if err := toml.Unmarshal(stripUTF8BOM(data), &doc); err != nil { + return false, fmt.Errorf("uv: parsing managed TOML: %w", ErrTargetUnusable) + } + if !uvSemanticMatch(doc, w.registryURL) { + return false, nil + } + return w.file.MetadataSecure(0o600) +} + +func (w *UVWriter) RestoreSnapshot() error { return w.file.RestoreSnapshot() } + +func (w *UVWriter) MDMOwned() (bool, error) { + return w.HasMDMMarker() +} + +func (w *UVWriter) HasMDMMarker() (bool, error) { + data, existed, _, err := w.readCurrent() + if err != nil || !existed { + return false, err + } + markers, err := scanUVMarkers(data) + if err != nil { + return false, err + } + return markers.mdmComplete(), nil +} + +type uvMarkers struct { + owner string + begin, end int + created bool +} + +func (m uvMarkers) complete() bool { + return m.owner == "dmg" && m.begin == 1 && m.end == 1 +} + +func (m uvMarkers) mdmComplete() bool { + return m.owner == "mdm" && m.begin == 1 && m.end == 1 +} + +func uvMultilineStringLines(lines []string) []bool { + const ( + stringNone = iota + stringBasic + stringLiteral + stringMultilineBasic + stringMultilineLiteral + ) + state := stringNone + inside := make([]bool, len(lines)) + for i, line := range lines { + inside[i] = state == stringMultilineBasic || state == stringMultilineLiteral + for j := 0; j < len(line); { + switch state { + case stringNone: + switch { + case line[j] == '#': + j = len(line) + case strings.HasPrefix(line[j:], `"""`): + state, j = stringMultilineBasic, j+3 + case strings.HasPrefix(line[j:], `'''`): + state, j = stringMultilineLiteral, j+3 + case line[j] == '"': + state, j = stringBasic, j+1 + case line[j] == '\'': + state, j = stringLiteral, j+1 + default: + j++ + } + case stringBasic: + switch line[j] { + case '\\': + j += 2 + case '"': + state, j = stringNone, j+1 + default: + j++ + } + case stringLiteral: + if line[j] == '\'' { + state = stringNone + } + j++ + case stringMultilineBasic: + if line[j] == '\\' { + j += 2 + } else if strings.HasPrefix(line[j:], `"""`) { + state, j = stringNone, j+3 + } else { + j++ + } + case stringMultilineLiteral: + if strings.HasPrefix(line[j:], `'''`) { + state, j = stringNone, j+3 + } else { + j++ + } + } + } + if state == stringBasic || state == stringLiteral { + state = stringNone + } + } + return inside +} + +func scanUVMarkers(data []byte) (uvMarkers, error) { + var m uvMarkers + active := false + lines := strings.Split(strings.ReplaceAll(string(stripUTF8BOM(data)), "\r\n", "\n"), "\n") + stringLines := uvMultilineStringLines(lines) + for i, line := range lines { + if stringLines[i] { + continue + } + owner, begin := "", false + switch strings.TrimSpace(line) { + case dmgUVBegin: + owner, begin = "dmg", true + case mdmUVBegin: + owner, begin = "mdm", true + case dmgUVEnd: + case dmgUVCreatedFile: + if !active || m.owner != "dmg" || m.created { + return m, fmt.Errorf("uv: misplaced or duplicated file marker: %w", ErrTargetUnusable) + } + m.created = true + continue + default: + continue + } + if begin { + if m.owner == "" { + m.owner = owner + } + if owner != m.owner { + return m, fmt.Errorf("uv: crossed managed owners: %w", ErrTargetUnusable) + } + if active || m.begin != 0 || m.end != 0 { + return m, fmt.Errorf("uv: nested or duplicated managed marker: %w", ErrTargetUnusable) + } + active = true + m.begin++ + continue + } + if !active { + return m, fmt.Errorf("uv: reversed managed marker: %w", ErrTargetUnusable) + } + active = false + m.end++ + } + if active || m.owner != "" && !m.complete() && !m.mdmComplete() { + return m, fmt.Errorf("uv: incomplete managed markers: %w", ErrTargetUnusable) + } + return m, nil +} + +func rewriteUVConfig(current []byte, expected string, created bool) ([]byte, error) { + if !utf8.Valid(current) || bytes.IndexByte(current, 0) >= 0 || hasLoneCR(string(current)) { + return nil, fmt.Errorf("uv: invalid text encoding: %w", ErrTargetUnusable) + } + base, _, err := clearUVConfig(current) + if err != nil { + return nil, err + } + body := stripUTF8BOM(base) + var parsed map[string]any + if len(bytes.TrimSpace(body)) > 0 { + if err := toml.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("uv: parsing TOML: %w", ErrTargetUnusable) + } + } + newline := uvNewline(base) + hadFinalNewline := bytes.HasSuffix(body, []byte(newline)) + lines := strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + disabled, firstTable, err := disableUVConflicts(lines) + if err != nil { + return nil, err + } + managed := []string{dmgUVBegin} + if created { + managed = append(managed, dmgUVCreatedFile) + } + managed = append(managed, + "index-strategy = \"first-index\"", + "", + "[[index]]", + "name = \"stepsecurity\"", + "url = "+strconv.Quote(uvURLFromExpected(expected)), + "default = true", + "authenticate = \"always\"", + dmgUVEnd, + ) + if firstTable < 0 { + firstTable = len(disabled) + } + out := make([]string, 0, len(disabled)+len(managed)) + out = append(out, disabled[:firstTable]...) + out = append(out, managed...) + out = append(out, disabled[firstTable:]...) + encodedText := strings.Join(out, newline) + if hadFinalNewline || len(body) == 0 { + encodedText += newline + } + encoded := []byte(encodedText) + if bytes.HasPrefix(base, []byte{0xef, 0xbb, 0xbf}) { + encoded = append([]byte{0xef, 0xbb, 0xbf}, encoded...) + } + var verify map[string]any + if err := toml.Unmarshal(stripUTF8BOM(encoded), &verify); err != nil || !uvSemanticMatch(verify, uvURLFromExpected(expected)) { + return nil, fmt.Errorf("uv: transformed TOML did not verify: %w", ErrTargetUnusable) + } + return encoded, nil +} + +func disableUVConflicts(lines []string) ([]string, int, error) { + out := append([]string(nil), lines...) + firstTable := -1 + section := "root" + for i := 0; i < len(out); { + trimmed := strings.TrimSpace(out[i]) + if strings.HasPrefix(trimmed, "[") { + if firstTable < 0 { + firstTable = i + } + header := normalizedUVHeader(trimmed) + if header == "[[index]]" { + end := i + 1 + for end < len(out) && !strings.HasPrefix(strings.TrimSpace(out[end]), "[") { + end++ + } + if uvSpanHasMultiline(out[i:end]) { + return nil, -1, fmt.Errorf("uv: multiline value overlaps index table: %w", ErrTargetUnusable) + } + for j := i; j < end; j++ { + if out[j] != "" { + out[j] = dmgUVDisabledPrefix + out[j] + } + } + i = end + section = "root" + continue + } + section = strings.Trim(header, "[]") + i++ + continue + } + key, value, ok := uvAssignment(trimmed) + if ok && uvConflictKey(section, key) { + if strings.Contains(value, "\"\"\"") || strings.Contains(value, "'''") || strings.HasPrefix(strings.TrimSpace(value), "[") && !strings.Contains(value, "]") { + return nil, -1, fmt.Errorf("uv: multiline managed value is ambiguous: %w", ErrTargetUnusable) + } + out[i] = dmgUVDisabledPrefix + out[i] + } + i++ + } + return out, firstTable, nil +} + +func uvSpanHasMultiline(lines []string) bool { + for _, line := range lines { + if strings.Contains(line, "\"\"\"") || strings.Contains(line, "'''") { + return true + } + } + return false +} + +func normalizedUVHeader(line string) string { + quoted := byte(0) + escaped := false + for i := 0; i < len(line); i++ { + if quoted != 0 { + if quoted == '"' && escaped { + escaped = false + continue + } + if quoted == '"' && line[i] == '\\' { + escaped = true + continue + } + if line[i] == quoted { + quoted = 0 + } + continue + } + switch line[i] { + case '\'', '"': + quoted = line[i] + case '#': + line = line[:i] + i = len(line) + } + } + return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(line)), " ", "") +} + +func uvAssignment(line string) (key, value string, ok bool) { + if line == "" || strings.HasPrefix(line, "#") { + return "", "", false + } + idx := strings.IndexByte(line, '=') + if idx <= 0 { + return "", "", false + } + key = strings.ToLower(strings.Trim(strings.TrimSpace(line[:idx]), "\"'")) + return key, strings.TrimSpace(line[idx+1:]), true +} + +func uvConflictKey(section, key string) bool { + if section != "root" && section != "pip" { + return false + } + switch strings.ReplaceAll(key, "_", "-") { + case "index-strategy", "index", "default-index", "index-url", "extra-index-url", "find-links": + return true + default: + return false + } +} + +func clearUVConfig(data []byte) ([]byte, bool, error) { + if !utf8.Valid(data) || bytes.IndexByte(data, 0) >= 0 || hasLoneCR(string(data)) { + return nil, false, fmt.Errorf("uv: invalid text encoding: %w", ErrTargetUnusable) + } + markers, err := scanUVMarkers(data) + if err != nil { + return nil, false, err + } + if markers.mdmComplete() { + return data, false, nil + } + bom := bytes.HasPrefix(data, []byte{0xef, 0xbb, 0xbf}) + newline := uvNewline(data) + lines := strings.Split(strings.ReplaceAll(string(stripUTF8BOM(data)), "\r\n", "\n"), "\n") + stringLines := uvMultilineStringLines(lines) + out := make([]string, 0, len(lines)) + inside := false + changed := false + for i, line := range lines { + if stringLines[i] { + out = append(out, line) + continue + } + trimmed := strings.TrimSpace(line) + switch trimmed { + case dmgUVBegin: + inside = true + changed = true + continue + case dmgUVEnd: + inside = false + continue + } + if inside { + continue + } + if strings.HasPrefix(line, dmgUVDisabledPrefix) { + line = strings.TrimPrefix(line, dmgUVDisabledPrefix) + changed = true + } + out = append(out, line) + } + if inside { + return nil, false, fmt.Errorf("uv: incomplete managed block: %w", ErrTargetUnusable) + } + text := strings.Join(out, newline) + if bom { + text = string([]byte{0xef, 0xbb, 0xbf}) + text + } + updated := []byte(text) + if changed && len(bytes.TrimSpace(stripUTF8BOM(updated))) > 0 { + var parsed map[string]any + if err := toml.Unmarshal(stripUTF8BOM(updated), &parsed); err != nil { + return nil, false, fmt.Errorf("uv: restored TOML did not verify: %w", ErrTargetUnusable) + } + } + return updated, changed, nil +} + +func uvSemanticMatch(doc map[string]any, registryURL string) bool { + strategy, ok := doc["index-strategy"].(string) + if !ok || strategy != "first-index" { + return false + } + for _, key := range []string{"index-url", "extra-index-url", "find-links", "default-index"} { + if _, exists := doc[key]; exists { + return false + } + } + if pip, ok := doc["pip"].(map[string]any); ok { + for _, key := range []string{"index", "index-strategy", "index-url", "extra-index-url", "find-links", "default-index"} { + if _, exists := pip[key]; exists { + return false + } + } + } + indexes, ok := doc["index"].([]map[string]any) + if !ok || len(indexes) != 1 { + if generic, ok := doc["index"].([]any); ok && len(generic) == 1 { + index, ok := generic[0].(map[string]any) + if !ok { + return false + } + indexes = []map[string]any{index} + } else { + return false + } + } + index := indexes[0] + name, _ := index["name"].(string) + url, _ := index["url"].(string) + def, _ := index["default"].(bool) + auth, _ := index["authenticate"].(string) + return name == "stepsecurity" && url == registryURL && def && auth == "always" +} + +func uvURLFromExpected(expected string) string { + for _, line := range strings.Split(expected, "\n") { + key, value, ok := uvAssignment(strings.TrimSpace(line)) + if ok && key == "url" { + unquoted, err := strconv.Unquote(value) + if err == nil { + return unquoted + } + } + } + return "" +} + +func stripUTF8BOM(data []byte) []byte { + return bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf}) +} + +func uvNewline(data []byte) string { + if bytes.Contains(data, []byte("\r\n")) { + return "\r\n" + } + return "\n" +} + +func (w *UVWriter) Observation(ctx context.Context, expected string) (UVObservation, error) { + observation := UVObservation{OverrideSource: "none"} + if err := w.validateExpected(expected); err != nil { + observation.ConfigStatus = "unreadable" + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, err + } + static, err := w.StaticConverged(expected) + if err != nil { + observation.ConfigStatus = "unreadable" + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, err + } + if static { + observation.ConfigStatus = "match" + observation.RegistryURL = w.registryURL + } else if data, existed, _, readErr := w.readCurrent(); readErr != nil { + observation.ConfigStatus = "unreadable" + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, readErr + } else if existed { + markers, markerErr := scanUVMarkers(data) + if markerErr != nil { + observation.ConfigStatus = "unreadable" + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, markerErr + } + if markers.mdmComplete() { + var doc map[string]any + if unmarshalErr := toml.Unmarshal(stripUTF8BOM(data), &doc); unmarshalErr != nil { + observation.ConfigStatus = "unreadable" + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, fmt.Errorf("uv: parsing MDM TOML: %w", ErrTargetUnusable) + } + if uvSemanticMatch(doc, w.registryURL) { + observation.ConfigStatus = "match" + observation.RegistryURL = w.registryURL + } else { + observation.ConfigStatus = "mismatch" + observation.RegistryURL = observedUVRegistryURL(data) + } + } else { + observation.ConfigStatus = "mismatch" + observation.RegistryURL = observedUVRegistryURL(data) + } + } else { + observation.ConfigStatus = "absent" + } + if err := executor.UserEnvironmentError(w.exec); err != nil { + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, err + } + if source := w.environmentOverride(); source != "" { + observation.EffectiveStatus = "mismatch" + observation.OverrideSource = source + return observation, nil + } + if !w.installed { + observation.EffectiveStatus = "not_installed" + return observation, nil + } + if !w.versionKnown { + observation.EffectiveStatus = "unknown" + observation.OverrideSource = "unknown" + return observation, nil + } + if !w.versionSupported { + observation.EffectiveStatus = "unsupported_version" + return observation, nil + } + status, source, registry := w.probeSettings(ctx) + observation.EffectiveStatus = status + observation.OverrideSource = source + if registry == unsafeUVRegistryObservation { + observation.RegistryURL = "" + } else if registry != "" { + observation.RegistryURL = registry + } + return observation, nil +} + +const unsafeUVRegistryObservation = "\x00unsafe" + +func (w *UVWriter) environmentOverride() string { + if strings.TrimSpace(w.exec.Getenv("UV_CONFIG_FILE")) != "" || strings.TrimSpace(w.exec.Getenv("UV_NO_CONFIG")) != "" { + return "explicit_config" + } + for _, name := range []string{"UV_INDEX", "UV_DEFAULT_INDEX", "UV_INDEX_URL", "UV_EXTRA_INDEX_URL", "UV_INDEX_STRATEGY", "UV_FIND_LINKS", "UV_NO_INDEX"} { + if strings.TrimSpace(w.exec.Getenv(name)) != "" { + return "environment" + } + } + if netrc := strings.TrimSpace(w.exec.Getenv("NETRC")); netrc != "" { + if !filepath.IsAbs(netrc) || filepath.Clean(netrc) != filepath.Join(w.home.Path(), ".netrc") { + return "environment" + } + } + return "" +} + +func (w *UVWriter) probeSettings(ctx context.Context) (status, source, registry string) { + dir, err := w.probeDirectory(ctx) + if err != nil { + return "unknown", "unknown", "" + } + defer os.Remove(dir) + stdout, _, exit, err := w.exec.RunInDir(ctx, dir, 10*time.Second, "uv", "pip", "install", "--show-settings", uvProbePackage) + if err != nil || exit != 0 { + return "unknown", "unknown", "" + } + status, registry = parseUVShowSettings(stdout, w.registryURL) + if status == "match" { + return status, "none", registry + } + return status, "unknown", registry +} + +func (w *UVWriter) probeDirectory(ctx context.Context) (string, error) { + base := strings.TrimSpace(w.exec.Getenv("TMPDIR")) + if base == "" { + base = os.TempDir() + } + base = filepath.Clean(base) + if !filepath.IsAbs(base) { + return "", errors.New("uv: target-user temporary directory is not absolute") + } + var dir string + var err error + if w.exec.GOOS() == model.PlatformWindows { + dir, err = os.MkdirTemp(base, "dmg-uv-probe-") + } else { + var exit int + dir, _, exit, err = w.exec.RunWithTimeout(ctx, 5*time.Second, "mktemp", "-d", filepath.Join(base, "dmg-uv-probe-XXXXXXXX")) + if err == nil && exit != 0 { + err = fmt.Errorf("mktemp exited with code %d", exit) + } + } + if err != nil { + return "", fmt.Errorf("uv: creating target-user probe directory: %w", err) + } + dir = filepath.Clean(strings.TrimSpace(dir)) + relative, relErr := filepath.Rel(base, dir) + if relErr != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) || !strings.HasPrefix(filepath.Base(dir), "dmg-uv-probe-") { + _ = os.Remove(dir) + return "", errors.New("uv: target-user probe directory escaped temporary root") + } + f, err := os.Open(dir) + if err != nil { + _ = os.Remove(dir) + return "", fmt.Errorf("uv: opening target-user probe directory: %w", err) + } + ownerErr := w.home.VerifyOwner(f, dir) + info, statErr := f.Stat() + closeErr := f.Close() + if ownerErr != nil || statErr != nil || closeErr != nil || !info.IsDir() || w.exec.GOOS() != model.PlatformWindows && info.Mode().Perm() != 0o700 { + _ = os.Remove(dir) + return "", errors.Join(ownerErr, statErr, closeErr, errors.New("uv: insecure target-user probe directory")) + } + return dir, nil +} + +var uvDebugGivenPattern = regexp.MustCompile(`(?s)given:\s*Some\(\s*("(\\.|[^"\\])*")`) + +func parseUVShowSettings(stdout, registryURL string) (status, registry string) { + if strings.Count(stdout, "index_locations: IndexLocations {") != 1 { + return "unknown", "" + } + locations, ok := uvDebugBlock(stdout, "index_locations: IndexLocations {", '{', '}') + if !ok { + return "unknown", "" + } + indexes, ok := uvDebugBlock(locations, "indexes: [", '[', ']') + if !ok { + return "unknown", "" + } + flat, ok := uvDebugBlock(locations, "flat_index: [", '[', ']') + if !ok { + return "unknown", "" + } + noIndex, ok := uvDebugScalar(locations, "no_index") + if !ok || noIndex != "false" { + return "mismatch", "" + } + strategy, ok := uvDebugScalar(stdout, "index_strategy") + if !ok || strategy != "FirstIndex" { + return "mismatch", "" + } + urls, ok := uvDebugURLs(indexes) + if !ok { + return "unknown", "" + } + if strings.TrimSpace(flat) != "" || len(urls) != 1 || urls[0] != registryURL { + for _, raw := range urls { + if safe := safeObservedRegistryURL(raw); safe != "" { + return "mismatch", safe + } + } + if len(urls) != 0 { + return "mismatch", unsafeUVRegistryObservation + } + return "mismatch", "" + } + return "match", registryURL +} + +func uvDebugBlock(text, marker string, open, close byte) (string, bool) { + start := strings.Index(text, marker) + if start < 0 { + return "", false + } + start += len(marker) - 1 + depth := 0 + quoted := false + escaped := false + for i := start; i < len(text); i++ { + if quoted { + if escaped { + escaped = false + } else if text[i] == '\\' { + escaped = true + } else if text[i] == '"' { + quoted = false + } + continue + } + if text[i] == '"' { + quoted = true + continue + } + switch text[i] { + case open: + depth++ + case close: + depth-- + if depth == 0 { + return text[start+1 : i], true + } + } + } + return "", false +} + +func uvDebugScalar(text, key string) (string, bool) { + var value string + found := false + prefix := key + ":" + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, prefix) { + continue + } + if found { + return "", false + } + value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, prefix), ",")) + found = true + } + return value, found +} + +func uvDebugURLs(indexes string) ([]string, bool) { + matches := uvDebugGivenPattern.FindAllStringSubmatch(indexes, -1) + urls := make([]string, 0, len(matches)) + for _, match := range matches { + value, err := strconv.Unquote(match[1]) + if err != nil { + return nil, false + } + urls = append(urls, value) + } + return urls, true +} + +func observedUVRegistryURL(data []byte) string { + var doc map[string]any + if err := toml.Unmarshal(stripUTF8BOM(data), &doc); err != nil { + return "" + } + indexes, ok := doc["index"].([]map[string]any) + if ok { + for _, index := range indexes { + if raw, ok := index["url"].(string); ok { + return safeObservedRegistryURL(raw) + } + } + } + return "" +} + +func isUVPrerelease(stdout string) bool { + fields := strings.Fields(strings.TrimSpace(stdout)) + if len(fields) < 2 || fields[0] != "uv" { + return false + } + stable, _, found := strings.Cut(fields[1], "-") + if !found { + return false + } + _, _, _, ok := parseUVVersion("uv " + stable) + return ok +} + +func parseUVVersion(stdout string) (major, minor, patch int, ok bool) { + fields := strings.Fields(strings.TrimSpace(stdout)) + if len(fields) < 2 || fields[0] != "uv" { + return 0, 0, 0, false + } + parts := strings.Split(fields[1], ".") + if len(parts) != 3 { + return 0, 0, 0, false + } + values := [3]int{} + for i, part := range parts { + if part == "" { + return 0, 0, 0, false + } + value, err := strconv.Atoi(part) + if err != nil || value < 0 { + return 0, 0, 0, false + } + values[i] = value + } + return values[0], values[1], values[2], true +} + +func uvVersionAtLeast(major, minor, patch, wantMajor, wantMinor, wantPatch int) bool { + if major != wantMajor { + return major > wantMajor + } + if minor != wantMinor { + return minor > wantMinor + } + return patch >= wantPatch +} diff --git a/internal/devicepolicy/uv_writer_test.go b/internal/devicepolicy/uv_writer_test.go new file mode 100644 index 00000000..dc18f9d7 --- /dev/null +++ b/internal/devicepolicy/uv_writer_test.go @@ -0,0 +1,684 @@ +package devicepolicy + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +const uvExpected = "index-strategy = \"first-index\"\n\n[[index]]\nname = \"stepsecurity\"\nurl = \"https://registry.stepsecurity.io/python/simple\"\ndefault = true\nauthenticate = \"always\"" + +func TestUVMarkers_Canonical(t *testing.T) { + tests := []struct { + name string + got string + want string + }{ + {"DMG begin", dmgUVBegin, "# BEGIN StepSecurity PyPI Secure Registry uv -- managed by dmg"}, + {"DMG end", dmgUVEnd, "# END StepSecurity PyPI Secure Registry uv"}, + {"MDM begin", mdmUVBegin, "# BEGIN StepSecurity PyPI Secure Registry uv -- managed by mdm"}, + {"MDM end", mdmUVEnd, "# END StepSecurity PyPI Secure Registry uv"}, + {"disabled prefix", dmgUVDisabledPrefix, "# [stepsecurity-pypi-uv-dmg] "}, + {"created file", dmgUVCreatedFile, "# [stepsecurity-pypi-uv-dmg] created=true"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("marker = %q, want %q", tc.got, tc.want) + } + }) + } +} + +func newUVTestWriter(t *testing.T, initial []byte, version string) (*UVWriter, *executor.Mock, string) { + t.Helper() + homeDir := t.TempDir() + path := filepath.Join(homeDir, ".config", "uv", "uv.toml") + if initial != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, initial, 0o600); err != nil { + t.Fatal(err) + } + } + home := newSecureTestHomeAs(t, homeDir, "") + mock := executor.NewMock() + mock.SetGOOS("linux") + if runtime.GOOS == "windows" { + mock.SetGOOS("windows") + mock.SetEnv("APPDATA", filepath.Join(homeDir, ".config")) + } + mock.SetUsername("") + mock.SetHomeDir(homeDir) + probeBase := t.TempDir() + probeDir := filepath.Join(probeBase, "dmg-uv-probe-test") + if err := os.Mkdir(probeDir, 0o700); err != nil { + t.Fatal(err) + } + mock.SetEnv("TMPDIR", probeBase) + mock.SetCommand(probeDir, "", 0, "mktemp", "-d", filepath.Join(probeBase, "dmg-uv-probe-XXXXXXXX")) + if version != "" { + mock.SetPath("uv", "/opt/bin/uv") + mock.SetCommand("uv "+version+"\n", "", 0, "uv", "--version") + } + writer, err := NewUVWriter(context.Background(), mock, home, netrcTestPolicy(t)) + if err != nil { + t.Fatalf("NewUVWriter: %v", err) + } + writer.exec = mock + return writer, mock, path +} + +func TestUVWriter_TransformsAndRestoresConflicts(t *testing.T) { + initial := []byte("# keep\nindex-strategy = \"unsafe-best-match\"\ncache-dir = \"/tmp/cache\"\n\n[[index]]\nname = \"private\"\nurl = \"https://private.example/simple\"\ndefault = true\n\n[pip]\nextra-index-url = \"https://extra.example/simple\"\nresolution = \"highest\"\n") + w, _, path := newUVTestWriter(t, initial, "0.10.0") + + got, err := w.Write(uvExpected) + if err != nil { + t.Fatalf("Write: %v", err) + } + if got != uvExpected { + t.Fatalf("Write = %q, want %q", got, uvExpected) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, marker := range []string{dmgUVBegin, dmgUVEnd} { + if !bytes.Contains(content, []byte(marker)) { + t.Errorf("managed output missing marker %q:\n%s", marker, content) + } + } + if bytes.Index(content, []byte(dmgUVBegin)) > bytes.Index(content, []byte("[[index]]")) { + t.Fatalf("root managed settings appear after first table:\n%s", content) + } + for _, line := range []string{ + "index-strategy = \"unsafe-best-match\"", + "[[index]]", + "name = \"private\"", + "url = \"https://private.example/simple\"", + "default = true", + "extra-index-url = \"https://extra.example/simple\"", + } { + if !bytes.Contains(content, []byte(dmgUVDisabledPrefix+line)) { + t.Errorf("conflicting line %q was not reversibly disabled:\n%s", line, content) + } + } + if !bytes.Contains(content, []byte("cache-dir = \"/tmp/cache\"")) || !bytes.Contains(content, []byte("resolution = \"highest\"")) { + t.Fatalf("unrelated TOML was not preserved:\n%s", content) + } + if converged, err := w.Converged(uvExpected); err != nil || !converged { + t.Fatalf("Converged = %v, %v, want true", converged, err) + } + + changed, err := w.Clear() + if err != nil || !changed { + t.Fatalf("Clear = %v, %v, want changed", changed, err) + } + restored, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(restored, initial) { + t.Fatalf("Clear restored:\n%q\nwant:\n%q", restored, initial) + } +} + +func TestUVWriter_PreservesMarkerTextInsideMultilineStrings(t *testing.T) { + tests := []struct { + name string + opening string + }{ + {"basic", `"""`}, + {"literal", `'''`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + initial := []byte("message = " + tc.opening + "\nkeep\n" + dmgUVBegin + "\ninside\n" + dmgUVEnd + "\nkeep\n" + tc.opening + "\n") + w, _, path := newUVTestWriter(t, initial, "0.10.0") + if _, err := w.Write(uvExpected); err != nil { + t.Fatalf("Write: %v", err) + } + if changed, err := w.Clear(); err != nil || !changed { + t.Fatalf("Clear = %v, %v, want changed", changed, err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, initial) { + t.Fatalf("Clear restored:\n%q\nwant:\n%q", got, initial) + } + }) + } +} + +func TestUVWriter_ClearRejectsInvalidRestoredTOML(t *testing.T) { + w, _, path := newUVTestWriter(t, []byte("index-strategy = \"unsafe-best-match\"\n"), "0.10.0") + if _, err := w.Write(uvExpected); err != nil { + t.Fatalf("Write: %v", err) + } + managed, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + managed = bytes.Replace(managed, + []byte(dmgUVDisabledPrefix+"index-strategy = \"unsafe-best-match\""), + []byte(dmgUVDisabledPrefix+"index-strategy = ["), 1) + if err := os.WriteFile(path, managed, 0o600); err != nil { + t.Fatal(err) + } + if changed, err := w.Clear(); err == nil || changed { + t.Fatalf("Clear = %v, %v, want unchanged error", changed, err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, managed) { + t.Fatal("failed clear changed uv.toml") + } +} + +func TestUVWriter_ClearRestoresOriginalFilePresence(t *testing.T) { + tests := []struct { + name string + initial []byte + exists bool + }{ + {"created file removed", nil, false}, + {"empty file retained", []byte{}, true}, + {"whitespace file retained", []byte(" \n"), true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, _, path := newUVTestWriter(t, tc.initial, "0.10.0") + if _, err := w.Write(uvExpected); err != nil { + t.Fatalf("Write: %v", err) + } + if changed, err := w.Clear(); err != nil || !changed { + t.Fatalf("Clear = %v, %v, want changed", changed, err) + } + got, err := os.ReadFile(path) + if !tc.exists { + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("ReadFile error = %v, want not exist", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, tc.initial) { + t.Fatalf("Clear restored %q, want %q", got, tc.initial) + } + }) + } +} + +func TestUVWriter_CommentedTableHeaderRoundTrips(t *testing.T) { + initial := []byte("[[index]] # user index\nname = \"private\"\nurl = \"https://private.example/simple\"\ndefault = true\n") + w, _, path := newUVTestWriter(t, initial, "0.10.0") + if _, err := w.Write(uvExpected); err != nil { + t.Fatalf("Write: %v", err) + } + changed, err := w.Clear() + if err != nil || !changed { + t.Fatalf("Clear = %v, %v", changed, err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, initial) { + t.Fatalf("round trip = %q, want %q", got, initial) + } +} + +func TestUVWriter_PreservesBOMCRLFAndIsIdempotent(t *testing.T) { + initial := append([]byte{0xef, 0xbb, 0xbf}, []byte("cache-dir = \"cache\"\r\n")...) + w, _, path := newUVTestWriter(t, initial, "0.10.0") + if _, err := w.Write(uvExpected); err != nil { + t.Fatal(err) + } + first, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(first, []byte{0xef, 0xbb, 0xbf}) || bytes.Contains(bytes.ReplaceAll(first, []byte("\r\n"), nil), []byte("\n")) { + t.Fatalf("BOM or CRLF style not preserved: %q", first) + } + if _, err := w.Write(uvExpected); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Fatal("idempotent write changed bytes") + } +} + +func TestUVWriter_RefusesMalformedAmbiguousAndMDMContent(t *testing.T) { + tests := []struct { + name string + content string + }{ + {"malformed TOML", "cache-dir = [\n"}, + {"duplicate key", "cache-dir = \"a\"\ncache-dir = \"b\"\n"}, + {"duplicate marker", dmgUVBegin + "\n" + dmgUVEnd + "\n" + dmgUVBegin + "\n" + dmgUVEnd + "\n"}, + {"reversed marker", dmgUVEnd + "\n" + dmgUVBegin + "\n"}, + {"nested marker", dmgUVBegin + "\n" + dmgUVBegin + "\n" + dmgUVEnd + "\n" + dmgUVEnd + "\n"}, + {"mixed owner markers", dmgUVBegin + "\n" + mdmUVBegin + "\n" + dmgUVEnd + "\n"}, + {"incomplete marker", dmgUVBegin + "\nindex-strategy = \"first-index\"\n"}, + {"overlapping multiline conflict", "index-strategy = \"\"\"first-index\ncontinued\"\"\"\n"}, + {"MDM marker", mdmUVBegin + "\nindex-strategy = \"first-index\"\n" + mdmUVEnd + "\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, _, path := newUVTestWriter(t, []byte(tc.content), "0.10.0") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(uvExpected); err == nil { + t.Fatal("Write succeeded, want refusal") + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("refused write changed file") + } + }) + } +} + +type failedUserEnvironmentExecutor struct { + executor.Executor +} + +func (e *failedUserEnvironmentExecutor) RunAsUser(context.Context, string, string) (string, error) { + return "", context.DeadlineExceeded +} + +type uvUserContextExecutor struct { + *executor.Mock + userEnv string + showSettings string + tempBase string + createdDir string + mktempCalled bool +} + +func (e *uvUserContextExecutor) RunAsUser(_ context.Context, _, command string) (string, error) { + switch { + case strings.Contains(command, "XDG_CONFIG_HOME") && strings.Contains(command, "UV_INDEX_URL"): + return e.userEnv, nil + case command == "which 'uv'": + return "/opt/bin/uv", nil + case command == "'uv' '--version'": + return "uv 0.10.0", nil + case strings.HasPrefix(command, "'mktemp' '-d' "): + e.mktempCalled = true + dir, err := os.MkdirTemp(e.tempBase, "dmg-uv-probe-") + e.createdDir = dir + return dir, err + case strings.HasPrefix(command, "cd "): + info, err := os.Stat(e.createdDir) + if err != nil { + return "", err + } + if info.Mode().Perm() != 0o700 { + return "", fmt.Errorf("probe directory mode = %o", info.Mode().Perm()) + } + return e.showSettings, nil + default: + return "", fmt.Errorf("unexpected user command %q", command) + } +} + +func newResolvedUserUVWriter(t *testing.T, userEnv, showSettings string) (*UVWriter, *uvUserContextExecutor, string) { + t.Helper() + homeDir := t.TempDir() + home := newSecureTestHomeAs(t, homeDir, "alice") + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetUsername("alice") + mock.SetHomeDir(homeDir) + mock.SetEnv("XDG_CONFIG_HOME", filepath.Join(homeDir, "service-xdg")) + tempBase := t.TempDir() + userEnv = strings.ReplaceAll(userEnv, "{HOME}", homeDir) + userEnv = strings.ReplaceAll(userEnv, "{TMP}", tempBase) + inner := &uvUserContextExecutor{Mock: mock, userEnv: userEnv, showSettings: showSettings, tempBase: tempBase} + writer, err := NewUVWriter(context.Background(), inner, home, netrcTestPolicy(t)) + if err != nil { + t.Fatalf("NewUVWriter: %v", err) + } + return writer, inner, homeDir +} + +func TestUVWriter_UsesResolvedUserEnvironment(t *testing.T) { + writer, _, resolvedHome := newResolvedUserUVWriter(t, + "XDG_CONFIG_HOME={HOME}/user-xdg\x00UV_INDEX_URL=https://override.example/simple\x00", + "") + want := filepath.Join(resolvedHome, "user-xdg", "uv", "uv.toml") + if got := writer.Location(); got != want { + t.Fatalf("Location = %q, want resolved-user path %q", got, want) + } + if _, err := writer.Write(uvExpected); err != nil { + t.Fatal(err) + } + observation, err := writer.Observation(context.Background(), uvExpected) + if err != nil { + t.Fatal(err) + } + if observation.OverrideSource != "environment" || observation.EffectiveStatus != "mismatch" { + t.Fatalf("Observation = %+v, want resolved-user environment override", observation) + } +} + +func TestUVObservation_ParsesRealSettingsInTargetUserDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("target-user shell probing is Unix-only") + } + fixture, err := os.ReadFile(filepath.Join("testdata", "uv-show-settings-0.12.6.txt")) + if err != nil { + t.Fatal(err) + } + writer, inner, _ := newResolvedUserUVWriter(t, "TMPDIR={TMP}\x00", string(fixture)) + if _, err := writer.Write(uvExpected); err != nil { + t.Fatal(err) + } + observation, err := writer.Observation(context.Background(), uvExpected) + if err != nil { + t.Fatal(err) + } + if observation.EffectiveStatus != "match" { + t.Fatalf("Observation = %+v, want effective match", observation) + } + if !inner.mktempCalled { + t.Fatal("uv probe directory was not created through the target-user executor") + } + if _, err := os.Stat(inner.createdDir); !os.IsNotExist(err) { + t.Fatalf("probe directory remains after observation: %v", err) + } +} + +func TestParseUVShowSettings_RealFixture(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join("testdata", "uv-show-settings-0.12.6.txt")) + if err != nil { + t.Fatal(err) + } + status, registry := parseUVShowSettings(string(fixture), "https://registry.stepsecurity.io/python/simple") + if status != "match" || registry != "https://registry.stepsecurity.io/python/simple" { + t.Fatalf("parseUVShowSettings = %q, %q", status, registry) + } +} + +func TestParseUVVersion_StrictStableSemver(t *testing.T) { + tests := []struct { + output string + valid bool + supported bool + }{ + {"uv 0.9.9", true, false}, + {"uv 0.10.0", true, true}, + {"uv 0.10.0-rc.1", false, false}, + {"uv 0.12.6 (7938ca5d5 2026-08-25 aarch64-apple-darwin)", true, true}, + {"uv 0.10", false, false}, + {"uv 0.10.0.1", false, false}, + {"changed format", false, false}, + } + for _, tc := range tests { + t.Run(tc.output, func(t *testing.T) { + major, minor, patch, valid := parseUVVersion(tc.output) + if valid != tc.valid { + t.Fatalf("parseUVVersion(%q) valid = %v, want %v", tc.output, valid, tc.valid) + } + if supported := valid && uvVersionAtLeast(major, minor, patch, 0, 10, 0); supported != tc.supported { + t.Fatalf("parseUVVersion(%q) supported = %v, want %v", tc.output, supported, tc.supported) + } + }) + } +} + +func TestUVWriter_JoinsVerificationAndRollbackFailures(t *testing.T) { + w, _, _ := newUVTestWriter(t, nil, "0.10.0") + w.registryURL = "https://different.example/simple" + rollbackErr := errors.New("rollback failed") + w.restoreSnapshot = func() error { return rollbackErr } + if _, err := w.Write(uvExpected); !errors.Is(err, rollbackErr) || !strings.Contains(err.Error(), "did not verify") { + t.Fatalf("Write error = %v, want verification and rollback failures", err) + } +} + +func TestUVWriter_ClearRollsBackCleanupFailure(t *testing.T) { + initial := []byte("cache-dir = \"keep\"\n") + w, _, path := newUVTestWriter(t, initial, "0.10.0") + if _, err := w.Write(uvExpected); err != nil { + t.Fatal(err) + } + cleanupErr := errors.New("cleanup failed") + w.purgeBackups = func() error { return cleanupErr } + changed, err := w.Clear() + if changed || !errors.Is(err, cleanupErr) { + t.Fatalf("Clear = %v, %v, want rolled-back cleanup failure", changed, err) + } + got, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Contains(got, []byte(dmgUVBegin)) || !bytes.Contains(got, []byte(dmgUVEnd)) { + t.Fatalf("clear cleanup failure did not restore managed file:\n%s", got) + } +} + +func TestUVWriter_MDMOwnershipRejectsOtherLanes(t *testing.T) { + tests := []struct { + name string + initial string + }{ + {"unmarked", uvExpected + "\n"}, + {"DMG marker", dmgUVBegin + "\n" + uvExpected + "\n" + dmgUVEnd + "\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, _, _ := newUVTestWriter(t, []byte(tc.initial), "0.10.0") + owned, err := w.MDMOwned() + if err != nil { + t.Fatal(err) + } + if owned { + t.Fatal("MDMOwned = true, want false") + } + }) + } +} + +func TestUVObservation_AcceptsValidMDMMarkers(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join("testdata", "uv-show-settings-0.12.6.txt")) + if err != nil { + t.Fatal(err) + } + mdm := []byte(mdmUVBegin + "\nindex-strategy = \"first-index\"\n\n[[index]]\nname = \"stepsecurity\"\nurl = \"https://registry.stepsecurity.io/python/simple\"\ndefault = true\nauthenticate = \"always\"\n" + mdmUVEnd + "\n") + w, mock, _ := newUVTestWriter(t, mdm, "0.10.0") + if owned, err := w.MDMOwned(); err != nil || !owned { + t.Fatalf("MDMOwned = %v, %v, want true", owned, err) + } + mock.SetCommand(string(fixture), "", 0, "uv", "pip", "install", "--show-settings", uvProbePackage) + observation, err := w.Observation(context.Background(), uvExpected) + if err != nil { + t.Fatal(err) + } + if observation.ConfigStatus != "match" || observation.EffectiveStatus != "match" { + t.Fatalf("Observation = %+v, want valid MDM match", observation) + } +} + +func TestNewUVWriter_UserEnvironmentFailureIsError(t *testing.T) { + homeDir := t.TempDir() + home := newSecureTestHomeAs(t, homeDir, "alice") + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetHomeDir(homeDir) + inner := &failedUserEnvironmentExecutor{Executor: mock} + if _, err := NewUVWriter(context.Background(), inner, home, netrcTestPolicy(t)); err == nil { + t.Fatal("NewUVWriter() error = nil, want environment inspection failure") + } +} + +func TestNewUVWriter_CanceledContextIsError(t *testing.T) { + homeDir := t.TempDir() + home := newSecureTestHomeAs(t, homeDir, "alice") + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetHomeDir(homeDir) + inner := &uvUserContextExecutor{Mock: mock, tempBase: t.TempDir()} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := NewUVWriter(ctx, inner, home, netrcTestPolicy(t)); !errors.Is(err, context.Canceled) { + t.Fatalf("NewUVWriter() error = %v, want context.Canceled", err) + } +} + +func TestUVWriter_VersionBoundaryAndPaths(t *testing.T) { + t.Run("unsupported installed uv remains untouched", func(t *testing.T) { + initial := []byte("cache-dir = \"keep\"\n") + w, _, path := newUVTestWriter(t, initial, "0.9.11") + if _, err := w.Write(uvExpected); err == nil { + t.Fatal("Write succeeded for unsupported uv") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, initial) { + t.Fatal("unsupported uv configuration changed") + } + }) + + t.Run("XDG path", func(t *testing.T) { + w, _, homeDir := newResolvedUserUVWriter(t, "XDG_CONFIG_HOME={HOME}/xdg\x00", "") + if got, want := w.Location(), filepath.Join(homeDir, "xdg", "uv", "uv.toml"); got != want { + t.Fatalf("Location = %q, want %q", got, want) + } + }) + + t.Run("Windows APPDATA path", func(t *testing.T) { + homeDir := t.TempDir() + appData := filepath.Join(homeDir, "AppData", "Roaming") + home := newSecureTestHome(t, homeDir) + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetHomeDir(homeDir) + mock.SetEnv("APPDATA", appData) + w, err := NewUVWriter(context.Background(), mock, home, netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if got, want := w.Location(), filepath.Join(appData, "uv", "uv.toml"); got != want { + t.Fatalf("Location = %q, want %q", got, want) + } + }) + + t.Run("resolved user executor", func(t *testing.T) { + homeDir := t.TempDir() + home := newSecureTestHomeAs(t, homeDir, "alice") + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir(homeDir) + inner := &uvUserContextExecutor{Mock: mock, tempBase: t.TempDir()} + w, err := NewUVWriter(context.Background(), inner, home, netrcTestPolicy(t)) + if err != nil { + t.Fatal(err) + } + if _, ok := w.exec.(*executor.UserAwareExecutor); !ok { + t.Fatalf("executor = %T, want *executor.UserAwareExecutor", w.exec) + } + }) +} + +func TestUVObservation_UserEnvironmentFailureIsUnknown(t *testing.T) { + w, mock, _ := newUVTestWriter(t, nil, "0.10.0") + if _, err := w.Write(uvExpected); err != nil { + t.Fatalf("Write: %v", err) + } + mock.SetGOOS("linux") + w.exec = executor.NewUserAwareExecutor(&failedUserEnvironmentExecutor{Executor: mock}, "alice") + got, err := w.Observation(context.Background(), uvExpected) + if err == nil { + t.Fatal("Observation error = nil, want environment inspection failure") + } + if got.EffectiveStatus != "unknown" || got.OverrideSource != "unknown" { + t.Fatalf("Observation = %+v, want unknown environment", got) + } +} + +func TestUVObservation_VersionsAndOverrides(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join("testdata", "uv-show-settings-0.12.6.txt")) + if err != nil { + t.Fatal(err) + } + showSettings := string(fixture) + userinfoSettings := strings.Replace(showSettings, + "https://registry.stepsecurity.io/python/simple", + "https://user:SECRET@evil.example/simple", 1) + tests := []struct { + name string + version string + configure func(*executor.Mock) + showSettings string + wantConfig string + wantEffective string + wantOverride string + }{ + {"uv absent", "", func(*executor.Mock) {}, "", "match", "not_installed", "none"}, + {"uv below minimum", "0.9.11", func(*executor.Mock) {}, "", "absent", "unsupported_version", "none"}, + {"uv prerelease", "0.10.0-rc.1", func(*executor.Mock) {}, "", "absent", "unsupported_version", "none"}, + {"uv minimum", "0.10.0", func(*executor.Mock) {}, showSettings, "match", "match", "none"}, + {"environment", "0.10.0", func(m *executor.Mock) { m.SetEnv("UV_INDEX_URL", "https://user:SECRET@evil.example/simple") }, "", "match", "mismatch", "environment"}, + {"explicit config", "0.10.0", func(m *executor.Mock) { m.SetEnv("UV_CONFIG_FILE", "/tmp/secret") }, "", "match", "mismatch", "explicit_config"}, + {"netrc override", "0.10.0", func(m *executor.Mock) { m.SetEnv("NETRC", "/tmp/secret") }, "", "match", "mismatch", "environment"}, + {"unknown output", "0.10.0", func(*executor.Mock) {}, "changed format\n", "match", "unknown", "unknown"}, + {"userinfo output", "0.10.0", func(*executor.Mock) {}, userinfoSettings, "match", "mismatch", "unknown"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, mock, _ := newUVTestWriter(t, nil, tc.version) + if tc.wantEffective != "unsupported_version" { + if _, err := w.Write(uvExpected); err != nil { + t.Fatalf("Write: %v", err) + } + } + tc.configure(mock) + if tc.version >= "0.10.0" && tc.showSettings != "" { + mock.SetCommand(tc.showSettings, "", 0, "uv", "pip", "install", "--show-settings", "stepsecurity-policy-probe") + } + got, err := w.Observation(context.Background(), uvExpected) + if err != nil { + t.Fatalf("Observation: %v", err) + } + if got.ConfigStatus != tc.wantConfig || got.EffectiveStatus != tc.wantEffective || got.OverrideSource != tc.wantOverride { + t.Fatalf("Observation = %+v, want config=%s effective=%s override=%s", got, tc.wantConfig, tc.wantEffective, tc.wantOverride) + } + if strings.Contains(got.RegistryURL, "SECRET") { + t.Fatalf("Observation leaked URL userinfo: %+v", got) + } + if tc.name == "userinfo output" && got.RegistryURL != "" { + t.Fatalf("userinfo registry URL = %q, want empty", got.RegistryURL) + } + }) + } +} diff --git a/internal/devicepolicy/verify.go b/internal/devicepolicy/verify.go index d15414d2..a766d69b 100644 --- a/internal/devicepolicy/verify.go +++ b/internal/devicepolicy/verify.go @@ -38,6 +38,16 @@ const TargetVSCode = "vscode" const ( CategoryPackageConfig = "package_config" TargetNPM = "npm" + TargetPyPI = "pypi" +) + +// PyPI component targets are local ownership identities beneath the public +// package_config/pypi policy. They are never used for fetches or reports. +const ( + PyPICredentialOwnershipTarget = "pypi-credential" //#nosec G101 -- public ownership target identifier, not a credential. + PyPIPipOwnershipTarget = "pypi-pip" + PyPIUVOwnershipTarget = "pypi-uv" + PyPICredentialOwnershipValue = "stepsecurity-pypi-credential" //#nosec G101 -- public ownership marker, not a credential. ) // VerifyInput is the result set the verifier reasons over. It is intentionally diff --git a/internal/executor/user_aware.go b/internal/executor/user_aware.go index 280ee1a7..73e9b509 100644 --- a/internal/executor/user_aware.go +++ b/internal/executor/user_aware.go @@ -6,6 +6,7 @@ import ( "os" "os/user" "strings" + "sync" "time" ) @@ -21,6 +22,32 @@ import ( type UserAwareExecutor struct { inner Executor username string // logged-in user to delegate to; empty = no delegation + + envOnce sync.Once + env map[string]string + envErr error +} + +var userEnvironmentKeys = []string{ + "APPDATA", + "NETRC", + "PIP_CONFIG_FILE", + "PIP_EXTRA_INDEX_URL", + "PIP_FIND_LINKS", + "PIP_INDEX_URL", + "PIP_NO_INDEX", + "TMPDIR", + "UV_CONFIG_FILE", + "UV_DEFAULT_INDEX", + "UV_EXTRA_INDEX_URL", + "UV_FIND_LINKS", + "UV_INDEX", + "UV_INDEX_STRATEGY", + "UV_INDEX_URL", + "UV_NO_CONFIG", + "UV_NO_INDEX", + "VIRTUAL_ENV", + "XDG_CONFIG_HOME", } // NewUserAwareExecutor returns a wrapped executor that runs commands through the @@ -36,6 +63,9 @@ func NewUserAwareExecutor(inner Executor, username string) Executor { if username == "" || inner.GOOS() == "windows" { return inner // no wrapping needed } + if current, ok := inner.(*UserAwareExecutor); ok && current.username == username { + return inner + } return &UserAwareExecutor{inner: inner, username: username} } @@ -100,14 +130,83 @@ func (e *UserAwareExecutor) RunAsUser(ctx context.Context, username, command str } func (e *UserAwareExecutor) LookPath(name string) (string, error) { - stdout, err := e.inner.RunAsUser(context.Background(), e.username, "which "+posixShellQuote(name)) + return e.lookPath(context.Background(), name) +} + +func (e *UserAwareExecutor) lookPath(ctx context.Context, name string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + stdout, err := e.inner.RunAsUser(ctx, e.username, "which "+posixShellQuote(name)) path := strings.TrimSpace(stdout) - if err != nil || path == "" || !strings.HasPrefix(path, "/") { + if err != nil { + return "", fmt.Errorf("%s not found in user PATH: %w", name, err) + } + if path == "" || !strings.HasPrefix(path, "/") { return "", fmt.Errorf("%s not found in user PATH", name) } return path, nil } +// LookPathWithContext resolves name without outliving the caller's deadline. +func LookPathWithContext(ctx context.Context, exec Executor, name string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + if userExec, ok := exec.(*UserAwareExecutor); ok { + return userExec.lookPath(ctx, name) + } + return exec.LookPath(name) +} + +func (e *UserAwareExecutor) loadUserEnvironment() { + e.env = make(map[string]string, len(userEnvironmentKeys)) + var format strings.Builder + var command strings.Builder + for _, key := range userEnvironmentKeys { + format.WriteString(key) + format.WriteString("=%s\\000") + } + command.WriteString("printf ") + command.WriteString(posixShellQuote(format.String())) + for _, key := range userEnvironmentKeys { + command.WriteString(` "${`) + command.WriteString(key) + command.WriteString(`-}"`) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + stdout, err := e.inner.RunAsUser(ctx, e.username, command.String()) + if err != nil { + e.envErr = fmt.Errorf("inspect user environment: %w", err) + return + } + for _, entry := range strings.Split(stdout, "\x00") { + key, value, ok := strings.Cut(entry, "=") + if ok { + e.env[key] = value + } + } +} + +// UserEnvironmentError reports a failed target-user environment snapshot. +func UserEnvironmentError(exec Executor) error { + userExec, ok := exec.(*UserAwareExecutor) + if !ok { + return nil + } + userExec.envOnce.Do(userExec.loadUserEnvironment) + return userExec.envErr +} + +func isUserEnvironmentKey(key string) bool { + for _, candidate := range userEnvironmentKeys { + if key == candidate { + return true + } + } + return false +} + // --- Pass-through methods --- func (e *UserAwareExecutor) FileExists(path string) bool { return e.inner.FileExists(path) } @@ -118,9 +217,15 @@ func (e *UserAwareExecutor) ReadDir(path string) ([]os.DirEntry, error) { } func (e *UserAwareExecutor) Stat(path string) (os.FileInfo, error) { return e.inner.Stat(path) } func (e *UserAwareExecutor) Hostname() (string, error) { return e.inner.Hostname() } -func (e *UserAwareExecutor) Getenv(key string) string { return e.inner.Getenv(key) } -func (e *UserAwareExecutor) IsRoot() bool { return e.inner.IsRoot() } -func (e *UserAwareExecutor) CurrentUser() (*user.User, error) { return e.inner.CurrentUser() } +func (e *UserAwareExecutor) Getenv(key string) string { + if !isUserEnvironmentKey(key) { + return e.inner.Getenv(key) + } + e.envOnce.Do(e.loadUserEnvironment) + return e.env[key] +} +func (e *UserAwareExecutor) IsRoot() bool { return e.inner.IsRoot() } +func (e *UserAwareExecutor) CurrentUser() (*user.User, error) { return e.inner.CurrentUser() } func (e *UserAwareExecutor) HomeDir(username string) (string, error) { return e.inner.HomeDir(username) } diff --git a/internal/executor/user_aware_test.go b/internal/executor/user_aware_test.go index 2fad0cd5..88517d63 100644 --- a/internal/executor/user_aware_test.go +++ b/internal/executor/user_aware_test.go @@ -120,3 +120,101 @@ func TestUserAwareExecutor_RunInDirQuotesDirAndArgs(t *testing.T) { t.Errorf("stdout = %q, want {\"ok\":true}", stdout) } } + +type userContextExecutor struct { + Executor + runAsUser func(context.Context, string, string) (string, error) +} + +func (e *userContextExecutor) RunAsUser(ctx context.Context, username, command string) (string, error) { + return e.runAsUser(ctx, username, command) +} + +func TestUserAwareExecutor_GetenvUsesAllowlistedUserSnapshot(t *testing.T) { + service := NewMock() + service.SetGOOS("linux") + service.SetEnv("XDG_CONFIG_HOME", "/service/xdg") + inner := &userContextExecutor{ + Executor: service, + runAsUser: func(_ context.Context, _, command string) (string, error) { + if !strings.Contains(command, "XDG_CONFIG_HOME") || !strings.Contains(command, "UV_INDEX_URL") { + t.Fatalf("environment snapshot command = %q", command) + } + return "XDG_CONFIG_HOME=/home/alice/.xdg\x00PIP_EXTRA_INDEX_URL=https://pip-extra.example/simple\x00UV_INDEX_URL=https://user.example/simple\x00UV_NO_INDEX=true\x00", nil + }, + } + exec := NewUserAwareExecutor(inner, "alice") + if got := exec.Getenv("XDG_CONFIG_HOME"); got != "/home/alice/.xdg" { + t.Fatalf("XDG_CONFIG_HOME = %q, want resolved user value", got) + } + if got := exec.Getenv("PIP_EXTRA_INDEX_URL"); got != "https://pip-extra.example/simple" { + t.Fatalf("PIP_EXTRA_INDEX_URL = %q, want resolved user value", got) + } + if got := exec.Getenv("UV_INDEX_URL"); got != "https://user.example/simple" { + t.Fatalf("UV_INDEX_URL = %q, want resolved user value", got) + } + if got := exec.Getenv("UV_NO_INDEX"); got != "true" { + t.Fatalf("UV_NO_INDEX = %q, want resolved user value", got) + } +} + +func TestUserAwareExecutor_ReportsEnvironmentInspectionFailure(t *testing.T) { + service := NewMock() + service.SetGOOS("linux") + inner := &userContextExecutor{ + Executor: service, + runAsUser: func(context.Context, string, string) (string, error) { + return "", context.DeadlineExceeded + }, + } + exec := NewUserAwareExecutor(inner, "alice") + if got := exec.Getenv("PIP_CONFIG_FILE"); got != "" { + t.Fatalf("PIP_CONFIG_FILE = %q, want empty after failed inspection", got) + } + if err := UserEnvironmentError(exec); err == nil { + t.Fatal("UserEnvironmentError() = nil, want inspection failure") + } +} + +func TestUserAwareExecutor_LookPathUsesCallerContext(t *testing.T) { + service := NewMock() + service.SetGOOS("linux") + calls := 0 + inner := &userContextExecutor{ + Executor: service, + runAsUser: func(ctx context.Context, _, _ string) (string, error) { + calls++ + if ctx.Err() != context.Canceled { + t.Fatalf("RunAsUser context error = %v, want canceled", ctx.Err()) + } + return "", ctx.Err() + }, + } + exec := NewUserAwareExecutor(inner, "alice") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := LookPathWithContext(ctx, exec, "pip"); err == nil { + t.Fatal("LookPathWithContext() error = nil, want cancellation") + } + if calls != 0 { + t.Fatalf("RunAsUser calls = %d, want 0 after caller cancellation", calls) + } +} + +func TestUserAwareExecutor_LookPathHasDeadline(t *testing.T) { + service := NewMock() + service.SetGOOS("linux") + inner := &userContextExecutor{ + Executor: service, + runAsUser: func(ctx context.Context, _, _ string) (string, error) { + if _, ok := ctx.Deadline(); !ok { + t.Fatal("LookPath RunAsUser context has no deadline") + } + return "/usr/bin/uv", nil + }, + } + exec := NewUserAwareExecutor(inner, "alice") + if _, err := exec.LookPath("uv"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/secureuserfile/file.go b/internal/secureuserfile/file.go new file mode 100644 index 00000000..3a433bfa --- /dev/null +++ b/internal/secureuserfile/file.go @@ -0,0 +1,974 @@ +package secureuserfile + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/user" + "path/filepath" + "sort" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +const ( + MaxBytes = 1 << 20 + FileMode = os.FileMode(0o600) + ParentMode = os.FileMode(0o700) + maxSymlinkDepth = 8 + maxBackups = 3 +) + +var ( + ErrTargetUnusable = errors.New("secure user file: target unusable") + ErrNoTargetUser = errors.New("secure user file: no enforceable target user") + ErrAbsoluteSymlink = fmt.Errorf("secure user file: absolute symlink: %w", ErrTargetUnusable) + ErrSymlinkLoop = fmt.Errorf("secure user file: symlink chain too deep: %w", ErrTargetUnusable) + ErrDanglingSymlink = fmt.Errorf("secure user file: symlink target does not exist: %w", ErrTargetUnusable) + ErrWriteUnverified = errors.New("secure user file: write could not be verified or rolled back") +) + +// Home pins all managed file operations beneath one resolved user's home. +type Home struct { + targetUser *user.User + home string + uid, gid int + root *os.Root + owners ownerReader + metadata metadataReader + + randomSuffix func() (string, error) + afterParentCreate func(relativePath string) + applyMetadata func(*Home, *os.File, os.FileMode, bool) error + getenv func(string) string + logf func(format string, args ...any) +} + +// File owns safe byte and metadata operations for one relative path. +type File struct { + home *Home + relativePath string + backupPrefix string + maxBytes int64 + pending *secureFileSnapshot +} + +type secureFileSnapshot struct { + data []byte + existed bool + mode os.FileMode + leaf string + chain []secureSymlinkHop + committed os.FileInfo + removed bool +} + +type secureSymlinkHop struct { + path string + target string + info os.FileInfo +} + +type ownerReader interface { + ownerUIDGID(f *os.File) (uid, gid uint32, enforced bool, err error) +} + +type metadataReader interface { + secure(f *os.File, home *Home, want os.FileMode) (bool, error) +} + +func OpenUserHome(exec executor.Executor) (*Home, error) { + return openUserHome(exec, interactiveSessionOK) +} + +func openUserHome(exec executor.Executor, sessionOK func(executor.Executor) bool) (*Home, error) { + if !sessionOK(exec) { + return nil, ErrNoTargetUser + } + u, err := exec.LoggedInUser() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrNoTargetUser, err) + } + home, err := openHome(u) + if err == nil { + home.getenv = executor.NewUserAwareExecutor(exec, u.Username).Getenv + } + return home, err +} + +func openHome(u *user.User) (*Home, error) { + if u == nil || u.HomeDir == "" { + return nil, errors.New("secure user file: resolved user has no home directory") + } + uid, gid, err := secureUserIDs(u) + if err != nil { + return nil, err + } + root, err := os.OpenRoot(u.HomeDir) + if err != nil { + return nil, fmt.Errorf("secure user file: open home root %q: %w", u.HomeDir, err) + } + owners := newOwnerReader() + metadata, ok := owners.(metadataReader) + if !ok { + _ = root.Close() + return nil, errors.New("secure user file: platform metadata reader unavailable") + } + return &Home{ + targetUser: u, + home: u.HomeDir, + uid: uid, + gid: gid, + root: root, + owners: owners, + metadata: metadata, + randomSuffix: randomSuffix, + applyMetadata: applySecureMetadata, + getenv: os.Getenv, + }, nil +} + +func (h *Home) Username() string { + if h == nil || h.targetUser == nil { + return "" + } + return h.targetUser.Username +} + +func (h *Home) Close() error { + if h == nil || h.root == nil { + return nil + } + err := h.root.Close() + h.root = nil + return err +} + +// Open pins one home-relative file and applies strict platform metadata. +func (h *Home) Open(relativePath, backupPrefix string, maxBytes int64) (*File, error) { + clean, err := cleanSecureRelativePath(relativePath) + if err != nil { + return nil, err + } + if backupPrefix == "" || strings.ContainsAny(backupPrefix, `/\\`) { + return nil, fmt.Errorf("secure user file: invalid backup prefix: %w", ErrTargetUnusable) + } + if maxBytes <= 0 || maxBytes > MaxBytes { + return nil, fmt.Errorf("secure user file: invalid read limit: %w", ErrTargetUnusable) + } + return &File{home: h, relativePath: clean, backupPrefix: backupPrefix, maxBytes: maxBytes}, nil +} + +func cleanSecureRelativePath(path string) (string, error) { + if path == "" || filepath.IsAbs(path) { + return "", fmt.Errorf("secure user file: path must be relative: %w", ErrTargetUnusable) + } + clean := filepath.Clean(path) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("secure user file: path escapes home: %w", ErrTargetUnusable) + } + return clean, nil +} + +func removeCreatedParent(parent *os.Root, component string, created os.FileInfo) error { + info, err := parent.Lstat(component) + if err != nil { + return fmt.Errorf("secure user file: inspect failed parent cleanup %q: %w", component, err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() || !os.SameFile(info, created) { + return fmt.Errorf("secure user file: created parent %q changed before cleanup: %w", component, ErrTargetUnusable) + } + if err := parent.Remove(component); err != nil { + return fmt.Errorf("secure user file: remove failed parent %q: %w", component, err) + } + return nil +} + +// EnsureParent creates missing parent components without traversing symlinks. +func (h *Home) EnsureParent(relativePath string) error { + clean, err := cleanSecureRelativePath(relativePath) + if err != nil { + return err + } + mode := ParentMode + parent := filepath.Dir(clean) + if parent == "." { + return nil + } + + current, err := h.root.OpenRoot(".") + if err != nil { + return fmt.Errorf("secure user file: pin home: %w", err) + } + defer func() { _ = current.Close() }() + currentRel := "" + for _, component := range strings.Split(parent, string(filepath.Separator)) { + created := false + var createdInfo os.FileInfo + info, lerr := current.Lstat(component) + if errors.Is(lerr, os.ErrNotExist) { + if err := current.Mkdir(component, mode); err != nil { + return fmt.Errorf("secure user file: create parent %q: %w", component, err) + } + created = true + createdInfo, lerr = current.Lstat(component) + if lerr != nil { + return fmt.Errorf("secure user file: inspect created parent %q: %w", component, lerr) + } + createdRel := filepath.Join(currentRel, component) + if h.afterParentCreate != nil { + h.afterParentCreate(createdRel) + } + info, lerr = current.Lstat(component) + } + if lerr != nil { + return fmt.Errorf("secure user file: inspect parent %q: %w", component, lerr) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("secure user file: parent %q is not a real directory: %w", component, ErrTargetUnusable) + } + if created && !os.SameFile(createdInfo, info) { + return fmt.Errorf("secure user file: parent %q changed after creation: %w", component, ErrTargetUnusable) + } + + next, handle, err := h.pinDirectory(current, component) + if err != nil { + if created { + return errors.Join(err, removeCreatedParent(current, component, createdInfo)) + } + return err + } + if created { + if err := h.applyMetadata(h, handle, mode, true); err != nil { + _ = handle.Close() + _ = next.Close() + return errors.Join(err, removeCreatedParent(current, component, createdInfo)) + } + } + if err := h.VerifyOwner(handle, component); err != nil { + _ = handle.Close() + _ = next.Close() + if created { + return errors.Join(err, removeCreatedParent(current, component, createdInfo)) + } + return err + } + _ = handle.Close() + _ = current.Close() + current = next + currentRel = filepath.Join(currentRel, component) + } + return nil +} + +func (h *Home) pinDirectory(parent *os.Root, component string) (*os.Root, *os.File, error) { + child, err := parent.OpenRoot(component) + if err != nil { + if errors.Is(err, os.ErrPermission) { + return nil, nil, fmt.Errorf("secure user file: pin parent %q: %w", component, err) + } + return nil, nil, fmt.Errorf("secure user file: pin parent %q: %w", component, ErrTargetUnusable) + } + handle, err := child.Open(".") + if err != nil { + _ = child.Close() + return nil, nil, fmt.Errorf("secure user file: open pinned parent %q: %w", component, err) + } + hi, err := handle.Stat() + if err != nil { + _ = handle.Close() + _ = child.Close() + return nil, nil, fmt.Errorf("secure user file: stat pinned parent %q: %w", component, err) + } + li, err := parent.Lstat(component) + if err != nil || li.Mode()&fs.ModeSymlink != 0 || !li.IsDir() || !os.SameFile(li, hi) { + _ = handle.Close() + _ = child.Close() + return nil, nil, fmt.Errorf("secure user file: parent %q changed while pinning: %w", component, ErrTargetUnusable) + } + return child, handle, nil +} + +// VerifyOwner checks that an opened target belongs to the resolved user. +func (h *Home) VerifyOwner(f *os.File, name string) error { + uid, _, enforced, err := h.owners.ownerUIDGID(f) + if err != nil { + return fmt.Errorf("secure user file: read owner: %w", err) + } + if enforced && uid != uint32(h.uid) { // #nosec G115 -- uid is parsed from os/user on POSIX + return fmt.Errorf("secure user file: %q owned by uid %d, not target user: %w", name, uid, ErrTargetUnusable) + } + return checkSecurePlatformOwner(h, f) +} + +func (f *File) applyMetadata(file *os.File, mode os.FileMode, directory bool) error { + return f.home.applyMetadata(f.home, file, mode, directory) +} + +// Path returns the pinned user's home directory. +func (h *Home) Path() string { + if h == nil { + return "" + } + return h.home +} + +// Getenv reads an environment variable in the resolved user's context. +func (h *Home) Getenv(name string) string { + if h == nil || h.getenv == nil { + return "" + } + return h.getenv(name) +} + +func (f *File) Location() string { + if f == nil || f.home == nil { + return "" + } + return filepath.Join(f.home.home, f.relativePath) +} + +// RelativePath returns the cleaned path beneath the pinned home. +func (f *File) RelativePath() string { + if f == nil { + return "" + } + return f.relativePath +} + +// ParentPresent reports whether every parent is an existing real directory. +func (f *File) ParentPresent() (bool, error) { + parent := filepath.Dir(f.relativePath) + if parent == "." { + return true, nil + } + current := "" + for _, component := range strings.Split(parent, string(filepath.Separator)) { + current = filepath.Join(current, component) + info, err := f.home.root.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("secure user file: inspect parent: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return false, fmt.Errorf("secure user file: parent is not a real directory: %w", ErrTargetUnusable) + } + } + return true, nil +} + +func (f *File) log(format string, args ...any) { + if f.home.logf != nil { + f.home.logf(format, args...) + } +} + +type secureResolvedTarget struct { + child *os.Root + base string + rel string +} + +func (rt *secureResolvedTarget) close() { + if rt != nil && rt.child != nil { + _ = rt.child.Close() + } +} + +func (f *File) resolveLeaf() (*secureResolvedTarget, error) { + rel, err := f.resolveLeafPath(false) + if err != nil { + return nil, err + } + return f.pin(rel) +} + +func (f *File) resolveLeafPath(allowMissingSymlinkTarget bool) (string, error) { + rel, _, err := f.resolveLeafPathWithChain(allowMissingSymlinkTarget) + return rel, err +} + +func (f *File) resolveLeafPathWithChain(allowMissingSymlinkTarget bool) (string, []secureSymlinkHop, error) { + cur := f.relativePath + var chain []secureSymlinkHop + for depth := 0; ; depth++ { + if depth > maxSymlinkDepth { + return "", nil, ErrSymlinkLoop + } + info, err := f.home.root.Lstat(cur) + if errors.Is(err, os.ErrNotExist) { + if len(chain) != 0 && !allowMissingSymlinkTarget { + return "", nil, ErrDanglingSymlink + } + return cur, chain, nil + } + if err != nil { + return "", nil, fmt.Errorf("secure user file: lstat %q: %w", cur, err) + } + if info.Mode()&fs.ModeSymlink == 0 { + return cur, chain, nil + } + target, err := f.home.root.Readlink(cur) + if err != nil { + return "", nil, fmt.Errorf("secure user file: readlink %q: %w", cur, err) + } + if isAbsSymlinkTarget(target) { + return "", nil, ErrAbsoluteSymlink + } + if endsInSeparatorOrDot(target) { + return "", nil, fmt.Errorf("secure user file: directory-shaped symlink: %w", ErrTargetUnusable) + } + chain = append(chain, secureSymlinkHop{path: cur, target: target, info: info}) + next := filepath.Clean(filepath.Join(filepath.Dir(cur), target)) + if next == ".." || strings.HasPrefix(next, ".."+string(filepath.Separator)) { + return "", nil, fmt.Errorf("secure user file: symlink escapes home: %w", ErrTargetUnusable) + } + cur = next + } +} + +func sameSymlinkChain(a, b []secureSymlinkHop) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].path != b[i].path || a[i].target != b[i].target || !os.SameFile(a[i].info, b[i].info) { + return false + } + } + return true +} + +func (f *File) pin(rel string) (*secureResolvedTarget, error) { + parent := filepath.Dir(rel) + base := filepath.Base(rel) + if base == "." || base == ".." || strings.ContainsRune(base, filepath.Separator) { + return nil, fmt.Errorf("secure user file: invalid leaf %q: %w", rel, ErrTargetUnusable) + } + child, err := f.pinParent(parent) + if err != nil { + return nil, err + } + return &secureResolvedTarget{child: child, base: base, rel: rel}, nil +} + +func (f *File) pinParent(parent string) (*os.Root, error) { + current, err := f.home.root.OpenRoot(".") + if err != nil { + return nil, fmt.Errorf("secure user file: pin home: %w", err) + } + if parent == "." { + return current, nil + } + for _, component := range strings.Split(parent, string(filepath.Separator)) { + info, err := current.Lstat(component) + if err != nil || info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + _ = current.Close() + if errors.Is(err, os.ErrPermission) { + return nil, fmt.Errorf("secure user file: pin parent %q: %w", parent, err) + } + return nil, fmt.Errorf("secure user file: invalid parent %q: %w", parent, ErrTargetUnusable) + } + next, handle, err := f.home.pinDirectory(current, component) + if err != nil { + _ = current.Close() + return nil, err + } + if err := f.home.VerifyOwner(handle, component); err != nil { + _ = handle.Close() + _ = next.Close() + _ = current.Close() + return nil, err + } + _ = handle.Close() + _ = current.Close() + current = next + } + return current, nil +} + +func isAbsSymlinkTarget(target string) bool { + if target == "" { + return false + } + return target[0] == '/' || target[0] == filepath.Separator || filepath.IsAbs(target) +} + +func endsInSeparatorOrDot(target string) bool { + if target == "" { + return false + } + last := target[len(target)-1] + if last == '/' || last == filepath.Separator { + return true + } + return target == "." || strings.HasSuffix(target, "/.") || + (filepath.Separator != '/' && strings.HasSuffix(target, string(filepath.Separator)+".")) +} + +func (f *File) Read() ([]byte, bool, os.FileMode, error) { + rt, err := f.resolveLeaf() + if err != nil { + return nil, false, 0, err + } + defer rt.close() + return f.readCurrent(rt) +} + +// MetadataSecure verifies the current leaf's platform permission boundary. +func (f *File) MetadataSecure(want os.FileMode) (bool, error) { + file, err := f.openMetadata() + if err != nil || file == nil { + return false, err + } + defer file.Close() + return f.home.metadata.secure(file, f.home, want) +} + +func (f *File) openMetadata() (*os.File, error) { + rt, err := f.resolveLeaf() + if err != nil { + return nil, err + } + defer rt.close() + li, err := rt.child.Lstat(rt.base) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil || li.Mode()&fs.ModeSymlink != 0 || !li.Mode().IsRegular() { + return nil, fmt.Errorf("secure user file: invalid leaf metadata: %w", ErrTargetUnusable) + } + file, err := rt.child.OpenFile(rt.base, os.O_RDONLY|nonblockOpenFlag(), 0) + if err != nil { + return nil, fmt.Errorf("secure user file: open leaf metadata: %w", err) + } + hi, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, fmt.Errorf("secure user file: stat leaf metadata: %w", err) + } + li2, err := rt.child.Lstat(rt.base) + if err != nil || li2.Mode()&fs.ModeSymlink != 0 || !os.SameFile(li2, hi) { + _ = file.Close() + return nil, fmt.Errorf("secure user file: leaf changed during metadata check: %w", ErrTargetUnusable) + } + if err := f.home.VerifyOwner(file, rt.base); err != nil { + _ = file.Close() + return nil, err + } + return file, nil +} + +func (f *File) readCurrent(rt *secureResolvedTarget) ([]byte, bool, os.FileMode, error) { + li, err := rt.child.Lstat(rt.base) + if errors.Is(err, os.ErrNotExist) { + return nil, false, 0, nil + } + if err != nil { + return nil, false, 0, fmt.Errorf("secure user file: lstat leaf %q: %w", rt.base, err) + } + if li.Mode()&fs.ModeSymlink != 0 || !li.Mode().IsRegular() { + return nil, false, 0, fmt.Errorf("secure user file: leaf %q is not a regular file: %w", rt.base, ErrTargetUnusable) + } + file, err := rt.child.OpenFile(rt.base, os.O_RDONLY|nonblockOpenFlag(), 0) + if err != nil { + return nil, false, 0, fmt.Errorf("secure user file: open leaf %q: %w", rt.base, err) + } + defer file.Close() + hi, err := file.Stat() + if err != nil { + return nil, false, 0, fmt.Errorf("secure user file: stat leaf handle: %w", err) + } + li2, err := rt.child.Lstat(rt.base) + if err != nil || li2.Mode()&fs.ModeSymlink != 0 || !hi.Mode().IsRegular() || !li2.Mode().IsRegular() || !os.SameFile(li2, hi) { + return nil, false, 0, fmt.Errorf("secure user file: leaf %q changed during open: %w", rt.base, ErrTargetUnusable) + } + if err := f.home.VerifyOwner(file, rt.base); err != nil { + return nil, false, 0, err + } + data, err := io.ReadAll(io.LimitReader(file, f.maxBytes+1)) + if err != nil { + return nil, false, 0, fmt.Errorf("secure user file: read leaf %q: %w", rt.base, err) + } + if int64(len(data)) > f.maxBytes { + return nil, false, 0, fmt.Errorf("secure user file: leaf %q exceeds %d bytes: %w", rt.base, f.maxBytes, ErrTargetUnusable) + } + return data, true, hi.Mode().Perm(), nil +} + +func (f *File) Commit(data []byte, mode os.FileMode) error { + if mode.Perm()&^os.FileMode(0o600) != 0 { + return fmt.Errorf("secure user file: file mode must be no broader than 0600: %w", ErrTargetUnusable) + } + if int64(len(data)) > f.maxBytes { + return fmt.Errorf("secure user file: new content exceeds %d bytes: %w", f.maxBytes, ErrTargetUnusable) + } + rt, err := f.resolveLeaf() + if err != nil { + return err + } + defer rt.close() + current, existed, oldMode, err := f.readCurrent(rt) + if err != nil { + return err + } + if !existed { + oldMode = mode + } + snap := &secureFileSnapshot{data: current, existed: existed, mode: oldMode, leaf: rt.rel} + if existed { + if err := f.backup(rt, current); err != nil { + f.log("secure user file: backup of %q failed: %v", rt.base, err) + } + } + out, err := f.commit(rt, data, mode) + if err != nil { + if out.renamed { + return f.afterFailedRollback(rt, snap, err) + } + return err + } + snap.committed = out.committed + readback, exists, _, err := f.readCurrent(rt) + if err != nil || !exists || !bytes.Equal(readback, data) { + if err == nil { + err = errors.New("secure user file: committed bytes did not match readback") + } + return f.afterFailedRollback(rt, snap, err) + } + f.pending = snap + return nil +} + +func (f *File) Remove() error { + rel, chain, err := f.resolveLeafPathWithChain(false) + if err != nil { + return err + } + rt, err := f.pin(rel) + if err != nil { + return err + } + defer rt.close() + current, existed, mode, err := f.readCurrent(rt) + if err != nil { + return err + } + if !existed { + f.pending = &secureFileSnapshot{leaf: rt.rel, chain: chain} + return nil + } + snap := &secureFileSnapshot{data: current, existed: true, mode: mode, leaf: rt.rel, chain: chain} + if err := f.backup(rt, current); err != nil { + f.log("secure user file: backup of %q failed: %v", rt.base, err) + } + info, err := rt.child.Lstat(rt.base) + if err != nil { + return fmt.Errorf("secure user file: lstat before remove: %w", err) + } + if err := rt.child.Remove(rt.base); err != nil { + return fmt.Errorf("secure user file: remove %q: %w", rt.base, err) + } + snap.committed = info + snap.removed = true + f.pending = snap + f.syncDir(rt) + return nil +} + +func (f *File) RestoreSnapshot() error { + if f.pending == nil { + return errors.New("secure user file: no snapshot to restore") + } + snap := f.pending + f.pending = nil + var rt *secureResolvedTarget + var err error + if snap.removed { + var rel string + var chain []secureSymlinkHop + rel, chain, err = f.resolveLeafPathWithChain(true) + if err == nil && (rel != snap.leaf || !sameSymlinkChain(chain, snap.chain)) { + err = fmt.Errorf("secure user file: chain changed after removal: %w", ErrTargetUnusable) + } + if err == nil { + rt, err = f.pin(snap.leaf) + } + if err == nil { + if _, lerr := rt.child.Lstat(rt.base); !errors.Is(lerr, os.ErrNotExist) { + if lerr != nil { + err = fmt.Errorf("secure user file: inspect removed leaf before restore: %w", lerr) + } else { + err = fmt.Errorf("secure user file: removed leaf was recreated: %w", ErrTargetUnusable) + } + } + } + } else { + rt, err = f.resolveLeaf() + if err == nil && rt.rel != snap.leaf { + err = fmt.Errorf("secure user file: chain moved from %q to %q: %w", snap.leaf, rt.rel, ErrTargetUnusable) + } + } + if err != nil { + if rt != nil { + rt.close() + } + return err + } + defer rt.close() + if snap.committed != nil { + li, err := rt.child.Lstat(rt.base) + if err == nil && (li.Mode()&fs.ModeSymlink != 0 || !li.Mode().IsRegular() || !os.SameFile(li, snap.committed)) { + return fmt.Errorf("secure user file: leaf changed since commit: %w", ErrTargetUnusable) + } + if err != nil && !(errors.Is(err, os.ErrNotExist) && (!snap.existed || snap.removed)) { + return fmt.Errorf("secure user file: inspect before restore: %w", err) + } + } + return f.restoreFrom(rt, snap) +} + +func (f *File) restoreFrom(rt *secureResolvedTarget, snap *secureFileSnapshot) error { + if !snap.existed { + if err := rt.child.Remove(rt.base); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("secure user file: restore remove: %w", err) + } + return nil + } + _, err := f.commit(rt, snap.data, snap.mode) + return err +} + +func (f *File) afterFailedRollback(rt *secureResolvedTarget, snap *secureFileSnapshot, cause error) error { + if err := f.restoreFrom(rt, snap); err != nil { + f.log("secure user file: rollback failed: %v", err) + return fmt.Errorf("secure user file: %v: %w", cause, ErrWriteUnverified) + } + return cause +} + +type secureCommitOutcome struct { + committed os.FileInfo + renamed bool +} + +func (f *File) commit(rt *secureResolvedTarget, data []byte, mode os.FileMode) (secureCommitOutcome, error) { + tmp, tmpName, err := f.createExclusive(rt, rt.base+".dmg-tmp-", "") + if err != nil { + return secureCommitOutcome{}, err + } + cleanup := true + defer func() { + if cleanup { + _ = rt.child.Remove(tmpName) + } + }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return secureCommitOutcome{}, fmt.Errorf("secure user file: write temp: %w", err) + } + if err := f.applyMetadata(tmp, mode, false); err != nil { + _ = tmp.Close() + return secureCommitOutcome{}, err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return secureCommitOutcome{}, fmt.Errorf("secure user file: sync temp: %w", err) + } + tmpInfo, err := tmp.Stat() + if err != nil { + _ = tmp.Close() + return secureCommitOutcome{}, fmt.Errorf("secure user file: stat temp: %w", err) + } + if err := tmp.Close(); err != nil { + return secureCommitOutcome{}, fmt.Errorf("secure user file: close temp: %w", err) + } + if err := rt.child.Rename(tmpName, rt.base); err != nil { + return secureCommitOutcome{}, fmt.Errorf("secure user file: rename into place: %w", err) + } + cleanup = false + li, err := rt.child.Lstat(rt.base) + if err != nil { + return secureCommitOutcome{renamed: true}, fmt.Errorf("secure user file: lstat after rename: %w", err) + } + if li.Mode()&fs.ModeSymlink != 0 || !li.Mode().IsRegular() || !os.SameFile(li, tmpInfo) { + return secureCommitOutcome{renamed: true}, fmt.Errorf("secure user file: identity changed across rename: %w", ErrTargetUnusable) + } + f.syncDir(rt) + return secureCommitOutcome{committed: li, renamed: true}, nil +} + +func (f *File) createExclusive(rt *secureResolvedTarget, prefix, suffix string) (*os.File, string, error) { + for range 8 { + middle, err := f.home.randomSuffix() + if err != nil { + return nil, "", fmt.Errorf("secure user file: random suffix: %w", err) + } + name := prefix + middle + suffix + file, err := rt.child.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, FileMode) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return nil, "", fmt.Errorf("secure user file: create %q: %w", name, err) + } + return file, name, nil + } + return nil, "", errors.New("secure user file: could not create a unique temporary file") +} + +func (f *File) syncDir(rt *secureResolvedTarget) { + dir, err := rt.child.Open(".") + if err != nil { + return + } + _ = dir.Sync() + _ = dir.Close() +} + +func (f *File) backup(rt *secureResolvedTarget, data []byte) error { + file, name, err := f.createExclusive(rt, rt.base+f.backupPrefix, ".bak") + if err != nil { + return err + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + _ = rt.child.Remove(name) + return fmt.Errorf("secure user file: write backup: %w", err) + } + if err := f.applyMetadata(file, FileMode, false); err != nil { + _ = file.Close() + _ = rt.child.Remove(name) + return err + } + if err := file.Close(); err != nil { + return fmt.Errorf("secure user file: close backup: %w", err) + } + f.rotateBackups(rt) + return nil +} + +func (f *File) rotateBackups(rt *secureResolvedTarget) { + dir, err := rt.child.Open(".") + if err != nil { + f.log("secure user file: backup rotation open failed: %v", err) + return + } + defer dir.Close() + prefix := rt.base + f.backupPrefix + tmpPrefix := rt.base + ".dmg-tmp-" + type backupFile struct { + name string + mtime int64 + } + kept := make([]backupFile, 0, maxBackups) + insert := func(item backupFile) { + i := sort.Search(len(kept), func(i int) bool { return kept[i].mtime > item.mtime }) + kept = append(kept, backupFile{}) + copy(kept[i+1:], kept[i:]) + kept[i] = item + } + remove := func(name string) { + if err := rt.child.Remove(name); err != nil { + f.log("secure user file: prune backup %q failed: %v", name, err) + } + } + for { + entries, readErr := dir.ReadDir(256) + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".bak") || strings.HasPrefix(name, tmpPrefix) { + continue + } + info, err := rt.child.Lstat(name) + if err != nil || !info.Mode().IsRegular() { + continue + } + item := backupFile{name: name, mtime: info.ModTime().UnixNano()} + if len(kept) < maxBackups { + insert(item) + } else if item.mtime <= kept[0].mtime { + remove(item.name) + } else { + remove(kept[0].name) + copy(kept, kept[1:]) + kept = kept[:len(kept)-1] + insert(item) + } + } + if errors.Is(readErr, io.EOF) { + return + } + if readErr != nil { + f.log("secure user file: backup rotation read failed: %v", readErr) + return + } + } +} + +func (f *File) PurgeBackups() error { + var rt *secureResolvedTarget + var err error + if f.pending != nil && f.pending.leaf != "" { + rt, err = f.pin(f.pending.leaf) + } else { + rt, err = f.resolveLeaf() + } + if err != nil { + return err + } + defer rt.close() + return f.purgeBackups(rt) +} + +func (f *File) purgeBackups(rt *secureResolvedTarget) error { + dir, err := rt.child.Open(".") + if err != nil { + return fmt.Errorf("secure user file: open parent for backup purge: %w", err) + } + defer dir.Close() + prefix := rt.base + f.backupPrefix + tmpPrefix := rt.base + ".dmg-tmp-" + var firstErr error + for { + entries, readErr := dir.ReadDir(256) + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".bak") || strings.HasPrefix(name, tmpPrefix) { + continue + } + info, err := rt.child.Lstat(name) + if err != nil || !info.Mode().IsRegular() { + continue + } + if err := rt.child.Remove(name); err != nil && firstErr == nil { + firstErr = err + } + } + if errors.Is(readErr, io.EOF) { + return firstErr + } + if readErr != nil { + if firstErr != nil { + return firstErr + } + return readErr + } + } +} + +func randomSuffix() (string, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} diff --git a/internal/secureuserfile/file_test.go b/internal/secureuserfile/file_test.go new file mode 100644 index 00000000..7b6b28eb --- /dev/null +++ b/internal/secureuserfile/file_test.go @@ -0,0 +1,566 @@ +package secureuserfile + +import ( + "bytes" + "errors" + "os" + "os/user" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func newSecureTestHome(t *testing.T, home string) *Home { + t.Helper() + u, err := user.Current() + if err != nil { + t.Fatalf("current user: %v", err) + } + u.HomeDir = home + normalizeSecureTestUser(t, u) + h, err := openHome(u) + if err != nil { + t.Fatalf("openHome: %v", err) + } + t.Cleanup(func() { _ = h.Close() }) + return h +} + +func openSecureTestFile(t *testing.T, h *Home, relativePath string) *File { + t.Helper() + f, err := h.Open(relativePath, ".dmg-", MaxBytes) + if err != nil { + t.Fatalf("open(%q): %v", relativePath, err) + } + return f +} + +func TestOpenUserHome_RejectsNonInteractiveNonRoot(t *testing.T) { + mock := executor.NewMock() + mock.SetIsRoot(false) + home, err := openUserHome(mock, func(executor.Executor) bool { return false }) + if home != nil { + _ = home.Close() + } + if !errors.Is(err, ErrNoTargetUser) { + t.Fatalf("openUserHome error = %v, want ErrNoTargetUser", err) + } +} + +func TestSecureUserFile_CreatesPinnedParentsAndCommits(t *testing.T) { + tests := []struct { + name string + path string + }{ + {"nested parents", filepath.Join(".config", "tool", "config")}, + {"alternate parents", filepath.Join(".local", "tool", "settings")}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + h := newSecureTestHome(t, home) + if err := h.EnsureParent(tc.path); err != nil { + t.Fatalf("ensureParent: %v", err) + } + f := openSecureTestFile(t, h, tc.path) + if err := f.Commit([]byte("managed\n"), 0o600); err != nil { + t.Fatalf("Commit: %v", err) + } + got, existed, mode, err := f.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if !existed || string(got) != "managed\n" { + t.Fatalf("Read = (%q, %v), want (%q, true)", got, existed, "managed\\n") + } + if enforcePOSIXMetadata && mode.Perm() != 0o600 { + t.Fatalf("file mode = %v, want 0600", mode.Perm()) + } + for dir := filepath.Dir(tc.path); dir != "."; dir = filepath.Dir(dir) { + info, err := os.Stat(filepath.Join(home, dir)) + if err != nil { + t.Fatalf("stat parent %q: %v", dir, err) + } + if enforcePOSIXMetadata && info.Mode().Perm() != 0o700 { + t.Fatalf("parent %q mode = %v, want 0700", dir, info.Mode().Perm()) + } + } + }) + } +} + +func TestSecureUserFile_ParentRefusals(t *testing.T) { + tests := []struct { + name string + seed func(t *testing.T, home string) + path string + }{ + { + name: "symlinked component", + seed: func(t *testing.T, home string) { + t.Helper() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(home, ".config")); err != nil { + t.Fatalf("symlink: %v", err) + } + }, + path: filepath.Join(".config", "tool", "config"), + }, + { + name: "non-directory component", + seed: func(t *testing.T, home string) { + t.Helper() + if err := os.WriteFile(filepath.Join(home, ".config"), []byte("x"), 0o600); err != nil { + t.Fatalf("seed file: %v", err) + } + }, + path: filepath.Join(".config", "tool", "config"), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + tc.seed(t, home) + h := newSecureTestHome(t, home) + if err := h.EnsureParent(tc.path); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("ensureParent error = %v, want ErrTargetUnusable", err) + } + }) + } + + home := t.TempDir() + h := newSecureTestHome(t, home) + if err := h.EnsureParent(filepath.Join("..", "outside", "config")); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("escaping path error = %v, want ErrTargetUnusable", err) + } +} + +func TestSecureUserFile_ParentHardeningFailureRemovesNewDirectory(t *testing.T) { + home := t.TempDir() + h := newSecureTestHome(t, home) + h.applyMetadata = func(*Home, *os.File, os.FileMode, bool) error { + return errors.New("hardening failed") + } + path := filepath.Join(".config", "tool", "config") + if err := h.EnsureParent(path); err == nil { + t.Fatal("ensureParent error = nil, want hardening failure") + } + if _, err := os.Stat(filepath.Join(home, ".config")); !os.IsNotExist(err) { + t.Fatalf("unsafe created parent remains: %v", err) + } + if runtime.GOOS == "windows" { + return // This retry requires an interactive Windows user. + } + h.applyMetadata = func(*Home, *os.File, os.FileMode, bool) error { return nil } + if err := h.EnsureParent(path); err != nil { + t.Fatalf("safe retry failed: %v", err) + } +} + +func TestSecureUserFile_ParentSwapDuringCreationRejected(t *testing.T) { + home := t.TempDir() + outside := t.TempDir() + h := newSecureTestHome(t, home) + h.afterParentCreate = func(relativePath string) { + if relativePath != ".config" { + return + } + if err := os.Rename(filepath.Join(home, relativePath), filepath.Join(home, ".config-original")); err != nil { + t.Fatalf("rename created parent: %v", err) + } + if err := os.Symlink(outside, filepath.Join(home, relativePath)); err != nil { + t.Fatalf("swap created parent: %v", err) + } + } + if err := h.EnsureParent(filepath.Join(".config", "tool", "config")); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("ensureParent error = %v, want ErrTargetUnusable", err) + } + if _, err := os.Stat(filepath.Join(outside, "tool")); !os.IsNotExist(err) { + t.Fatalf("escaped parent was modified, stat error = %v", err) + } +} + +func TestSecureUserFile_RemoveAndRestoreRelativeSymlinkTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("relative symlink setup requires elevated Windows privileges") + } + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, "credentials"), 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(home, "credentials", "auth") + original := []byte("original credential\n") + if err := os.WriteFile(target, original, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("credentials", "auth"), filepath.Join(home, "credential")); err != nil { + t.Fatal(err) + } + + file := openSecureTestFile(t, newSecureTestHome(t, home), "credential") + if err := file.Remove(); err != nil { + t.Fatal(err) + } + if err := file.RestoreSnapshot(); err != nil { + t.Fatalf("RestoreSnapshot: %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Fatalf("restored bytes = %q, want %q", got, original) + } +} + +func TestSecureUserFile_RestoreRemovedSymlinkRejectsChangedChain(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("relative symlink setup requires elevated Windows privileges") + } + tests := []struct { + name string + mutate func(t *testing.T, home, link, target string) + }{ + { + name: "retargeted", + mutate: func(t *testing.T, home, link, _ string) { + other := filepath.Join(home, "credentials", "other") + if err := os.WriteFile(other, []byte("other"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("credentials", "other"), link); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "leaf recreated", + mutate: func(t *testing.T, _, _, target string) { + if err := os.WriteFile(target, []byte("replacement"), 0o600); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "escape", + mutate: func(t *testing.T, _, link, _ string) { + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", "outside"), link); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "link removed", + mutate: func(t *testing.T, _, link, _ string) { + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "same target through different chain", + mutate: func(t *testing.T, home, link, _ string) { + if err := os.Symlink(filepath.Join("credentials", "auth"), filepath.Join(home, "alternate")); err != nil { + t.Fatal(err) + } + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink("alternate", link); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "link recreated", + mutate: func(t *testing.T, _, link, _ string) { + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("credentials", "auth"), link); err != nil { + t.Fatal(err) + } + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, "credentials"), 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(home, "credentials", "auth") + if err := os.WriteFile(target, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(home, "credential") + if err := os.Symlink(filepath.Join("credentials", "auth"), link); err != nil { + t.Fatal(err) + } + file := openSecureTestFile(t, newSecureTestHome(t, home), "credential") + if err := file.Remove(); err != nil { + t.Fatal(err) + } + tc.mutate(t, home, link, target) + if err := file.RestoreSnapshot(); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("RestoreSnapshot error = %v, want ErrTargetUnusable", err) + } + }) + } +} + +func TestSecureUserFile_SymlinkPolicy(t *testing.T) { + t.Run("relative in-home leaf", func(t *testing.T) { + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, "dotfiles"), 0o700); err != nil { + t.Fatal(err) + } + leaf := filepath.Join(home, "dotfiles", "config") + if err := os.WriteFile(leaf, []byte("before"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("dotfiles", "config"), filepath.Join(home, "config")); err != nil { + t.Fatal(err) + } + f := openSecureTestFile(t, newSecureTestHome(t, home), "config") + if err := f.Commit([]byte("after"), 0o600); err != nil { + t.Fatalf("Commit: %v", err) + } + if got, err := os.ReadFile(leaf); err != nil || string(got) != "after" { + t.Fatalf("resolved leaf = %q, %v, want after", got, err) + } + if info, err := os.Lstat(filepath.Join(home, "config")); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("original symlink not preserved: %v, %v", info, err) + } + }) + + t.Run("relative in-home clear purges resolved backups", func(t *testing.T) { + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, "dotfiles"), 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(home, "dotfiles", "credentials") + if err := os.WriteFile(target, []byte("before"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("dotfiles", "credentials"), filepath.Join(home, "credential")); err != nil { + t.Fatal(err) + } + f := openSecureTestFile(t, newSecureTestHome(t, home), "credential") + if err := f.Commit([]byte("managed"), 0o600); err != nil { + t.Fatal(err) + } + if err := f.Remove(); err != nil { + t.Fatal(err) + } + if err := f.PurgeBackups(); err != nil { + t.Fatalf("PurgeBackups after symlink target removal: %v", err) + } + backups, err := filepath.Glob(target + ".dmg-*.bak") + if err != nil || len(backups) != 0 { + t.Fatalf("credential backups remain: %v, %v", backups, err) + } + }) + + tests := []struct { + name string + target string + }{ + {"absolute", string(filepath.Separator) + filepath.Join("etc", "hosts")}, + {"escaping", filepath.Join("..", "outside")}, + {"dangling", "missing"}, + {"directory-shaped", "leaf" + string(filepath.Separator)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + if tc.name == "directory-shaped" { + if err := os.WriteFile(filepath.Join(home, "leaf"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(tc.target, filepath.Join(home, "config")); err != nil { + t.Fatal(err) + } + f := openSecureTestFile(t, newSecureTestHome(t, home), "config") + if _, _, _, err := f.Read(); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("Read error = %v, want ErrTargetUnusable", err) + } + }) + } + + t.Run("deep chain", func(t *testing.T) { + home := t.TempDir() + for i := 0; i <= maxSymlinkDepth; i++ { + from := filepath.Join(home, "link"+string(rune('a'+i))) + to := "link" + string(rune('a'+i+1)) + if err := os.Symlink(to, from); err != nil { + t.Fatal(err) + } + } + f := openSecureTestFile(t, newSecureTestHome(t, home), "linka") + if _, _, _, err := f.Read(); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("Read error = %v, want ErrTargetUnusable", err) + } + }) +} + +func TestSecureUserFile_RejectsDirectoryAndOversize(t *testing.T) { + t.Run("directory leaf", func(t *testing.T) { + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, "config"), 0o700); err != nil { + t.Fatal(err) + } + f := openSecureTestFile(t, newSecureTestHome(t, home), "config") + if _, _, _, err := f.Read(); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("Read error = %v, want ErrTargetUnusable", err) + } + }) + + t.Run("oversize leaf", func(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "config"), []byte(strings.Repeat("x", 17)), 0o600); err != nil { + t.Fatal(err) + } + f, err := newSecureTestHome(t, home).Open("config", ".dmg-", 16) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := f.Read(); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("Read error = %v, want ErrTargetUnusable", err) + } + }) +} + +func TestSecureUserFile_ExclusiveTempCollisionAndAtomicReplace(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, "config") + if err := os.WriteFile(path, []byte("before"), 0o600); err != nil { + t.Fatal(err) + } + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + // Windows resolves os.Stat file identity lazily from the path. + if !os.SameFile(before, before) { + t.Fatal("could not capture original file identity") + } + collision := filepath.Join(home, "config.dmg-tmp-collision") + if err := os.WriteFile(collision, []byte("planted"), 0o600); err != nil { + t.Fatal(err) + } + h := newSecureTestHome(t, home) + // The existing leaf is backed up first; the next name exercises the temp + // collision and the final name proves the exclusive-create retry succeeds. + names := []string{"backup", "collision", "unique"} + h.randomSuffix = func() (string, error) { + name := names[0] + names = names[1:] + return name, nil + } + f := openSecureTestFile(t, h, "config") + if err := f.Commit([]byte("after"), 0o600); err != nil { + t.Fatalf("Commit: %v", err) + } + if got, err := os.ReadFile(collision); err != nil || string(got) != "planted" { + t.Fatalf("collision file = %q, %v, want planted", got, err) + } + after, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if os.SameFile(before, after) { + t.Fatal("Commit must atomically replace the leaf inode") + } + if _, err := os.Stat(filepath.Join(home, "config.dmg-tmp-unique")); !os.IsNotExist(err) { + t.Fatalf("temporary file remains after commit: %v", err) + } +} + +func TestSecureUserFile_SnapshotBackupRotationAndCleanup(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, "config") + if err := os.WriteFile(path, []byte("original"), 0o640); err != nil { + t.Fatal(err) + } + f := openSecureTestFile(t, newSecureTestHome(t, home), "config") + if err := f.Commit([]byte("managed"), 0o600); err != nil { + t.Fatalf("Commit: %v", err) + } + if err := f.RestoreSnapshot(); err != nil { + t.Fatalf("RestoreSnapshot: %v", err) + } + got, err := os.ReadFile(path) + if err != nil || string(got) != "original" { + t.Fatalf("restored file = %q, %v, want original", got, err) + } + if enforcePOSIXMetadata { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o640 { + t.Fatalf("restored mode = %v, want 0640", info.Mode().Perm()) + } + } + if err := f.RestoreSnapshot(); err == nil { + t.Fatal("second RestoreSnapshot must fail") + } + + for i := 0; i < maxBackups+3; i++ { + if err := f.Commit([]byte{byte('a' + i)}, 0o600); err != nil { + t.Fatalf("Commit %d: %v", i, err) + } + } + backups, err := filepath.Glob(path + ".dmg-*.bak") + if err != nil { + t.Fatal(err) + } + if len(backups) == 0 || len(backups) > maxBackups { + t.Fatalf("backup count = %d, want 1..%d", len(backups), maxBackups) + } + for _, backup := range backups { + if info, err := os.Stat(backup); err != nil { + t.Fatal(err) + } else if enforcePOSIXMetadata && info.Mode().Perm() != 0o600 { + t.Fatalf("backup %q mode = %v, want 0600", backup, info.Mode().Perm()) + } + } + if err := f.PurgeBackups(); err != nil { + t.Fatalf("PurgeBackups: %v", err) + } + if backups, err = filepath.Glob(path + ".dmg-*.bak"); err != nil || len(backups) != 0 { + t.Fatalf("backups after purge = %v, %v, want none", backups, err) + } +} + +func TestSecureUserFile_RemoveAndRestoreSnapshot(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, "config") + if err := os.WriteFile(path, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + f := openSecureTestFile(t, newSecureTestHome(t, home), "config") + if err := f.Remove(); err != nil { + t.Fatalf("Remove: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("removed file still exists: %v", err) + } + if err := f.RestoreSnapshot(); err != nil { + t.Fatalf("RestoreSnapshot: %v", err) + } + if got, err := os.ReadFile(path); err != nil || string(got) != "original" { + t.Fatalf("restored file = %q, %v, want original", got, err) + } +} diff --git a/internal/secureuserfile/file_unix.go b/internal/secureuserfile/file_unix.go new file mode 100644 index 00000000..0c42bc0c --- /dev/null +++ b/internal/secureuserfile/file_unix.go @@ -0,0 +1,65 @@ +//go:build unix + +package secureuserfile + +import ( + "errors" + "fmt" + "os" + "os/user" + "strconv" + "syscall" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +const enforcePOSIXMetadata = true + +func nonblockOpenFlag() int { return syscall.O_NONBLOCK } + +func interactiveSessionOK(executor.Executor) bool { return true } + +func secureUserIDs(u *user.User) (int, int, error) { + uid, uidErr := strconv.Atoi(u.Uid) + gid, gidErr := strconv.Atoi(u.Gid) + if uidErr != nil || gidErr != nil { + return 0, 0, fmt.Errorf("secure user file: target user %q has non-numeric uid/gid", u.Username) + } + return uid, gid, nil +} + +func applySecureMetadata(h *Home, f *os.File, mode os.FileMode, _ bool) error { + if err := f.Chmod(mode); err != nil { + return fmt.Errorf("secure user file: fchmod: %w", err) + } + if err := f.Chown(h.uid, h.gid); err != nil { + return fmt.Errorf("secure user file: fchown: %w", err) + } + return nil +} + +func checkSecurePlatformOwner(_ *Home, _ *os.File) error { return nil } + +func newOwnerReader() ownerReader { return unixOwnerReader{} } + +type unixOwnerReader struct{} + +func (unixOwnerReader) secure(f *os.File, _ *Home, want os.FileMode) (bool, error) { + info, err := f.Stat() + if err != nil { + return false, fmt.Errorf("secure user file: stat metadata: %w", err) + } + return info.Mode().Perm() == want.Perm(), nil +} + +func (unixOwnerReader) ownerUIDGID(f *os.File) (uid, gid uint32, enforced bool, err error) { + info, err := f.Stat() + if err != nil { + return 0, 0, true, err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, 0, true, errors.New("secure user file: handle has no unix owner metadata") + } + return stat.Uid, stat.Gid, true, nil +} diff --git a/internal/secureuserfile/file_unix_test.go b/internal/secureuserfile/file_unix_test.go new file mode 100644 index 00000000..e3d4edfc --- /dev/null +++ b/internal/secureuserfile/file_unix_test.go @@ -0,0 +1,93 @@ +//go:build unix + +package secureuserfile + +import ( + "errors" + "os" + "os/user" + "path/filepath" + "syscall" + "testing" +) + +func normalizeSecureTestUser(t *testing.T, _ *user.User) { + t.Helper() +} + +type fakeOwner struct { + uid, gid uint32 + enforced bool + err error +} + +func (f fakeOwner) ownerUIDGID(_ *os.File) (uint32, uint32, bool, error) { + return f.uid, f.gid, f.enforced, f.err +} + +func TestSecureUserFile_RejectsFIFOAndWrongOwner(t *testing.T) { + t.Run("FIFO", func(t *testing.T) { + home := t.TempDir() + if err := syscall.Mkfifo(filepath.Join(home, "config"), 0o600); err != nil { + t.Skipf("mkfifo unsupported: %v", err) + } + f := openSecureTestFile(t, newSecureTestHome(t, home), "config") + if _, _, _, err := f.Read(); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("Read error = %v, want ErrTargetUnusable", err) + } + }) + + t.Run("wrong-owner leaf", func(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "config"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + h := newSecureTestHome(t, home) + h.owners = fakeOwner{uid: uint32(h.uid + 1), enforced: true} + f := openSecureTestFile(t, h, "config") + if _, _, _, err := f.Read(); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("Read error = %v, want ErrTargetUnusable", err) + } + }) + + t.Run("wrong-owner parent", func(t *testing.T) { + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, ".config"), 0o700); err != nil { + t.Fatal(err) + } + h := newSecureTestHome(t, home) + h.owners = fakeOwner{uid: uint32(h.uid + 1), enforced: true} + if err := h.EnsureParent(filepath.Join(".config", "tool", "config")); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("ensureParent error = %v, want ErrTargetUnusable", err) + } + }) +} + +func TestSecureUserFile_AppliesTargetOwnership(t *testing.T) { + home := t.TempDir() + h := newSecureTestHome(t, home) + if err := h.EnsureParent(filepath.Join(".config", "tool", "config")); err != nil { + t.Fatalf("ensureParent: %v", err) + } + f := openSecureTestFile(t, h, filepath.Join(".config", "tool", "config")) + if err := f.Commit([]byte("managed"), 0o600); err != nil { + t.Fatalf("Commit: %v", err) + } + for _, path := range []string{ + filepath.Join(home, ".config"), + filepath.Join(home, ".config", "tool"), + filepath.Join(home, ".config", "tool", "config"), + } { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("%q has no unix stat", path) + } + if int(st.Uid) != h.uid || int(st.Gid) != h.gid { + t.Fatalf("%q owner = %d:%d, want %d:%d", path, st.Uid, st.Gid, h.uid, h.gid) + } + } +} diff --git a/internal/secureuserfile/file_windows.go b/internal/secureuserfile/file_windows.go new file mode 100644 index 00000000..8289bd94 --- /dev/null +++ b/internal/secureuserfile/file_windows.go @@ -0,0 +1,297 @@ +//go:build windows + +package secureuserfile + +import ( + "errors" + "fmt" + "os" + "os/user" + "unsafe" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "golang.org/x/sys/windows" +) + +const enforcePOSIXMetadata = false + +func nonblockOpenFlag() int { return 0 } + +func secureUserIDs(u *user.User) (int, int, error) { + if u.Uid == "" { + return 0, 0, fmt.Errorf("secure user file: target user %q has no SID", u.Username) + } + if _, err := windows.StringToSid(u.Uid); err != nil { + return 0, 0, fmt.Errorf("secure user file: target user %q has invalid SID: %w", u.Username, err) + } + return 0, 0, nil +} + +func newOwnerReader() ownerReader { return windowsOwnerReader{} } + +type windowsOwnerReader struct{} + +func (windowsOwnerReader) ownerUIDGID(*os.File) (uint32, uint32, bool, error) { + return 0, 0, false, nil +} + +func applySecureMetadata(h *Home, f *os.File, _ os.FileMode, directory bool) error { + targetSID, err := windows.StringToSid(h.targetUser.Uid) + if err != nil { + return fmt.Errorf("secure user file: target SID: %w", err) + } + systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return fmt.Errorf("secure user file: SYSTEM SID: %w", err) + } + inheritance := uint32(windows.NO_INHERITANCE) + if directory { + inheritance = windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT + } + entries := []windows.EXPLICIT_ACCESS{ + secureExplicitAccess(targetSID, windows.GENERIC_ALL, inheritance, windows.TRUSTEE_IS_USER), + secureExplicitAccess(systemSID, windows.GENERIC_ALL, inheritance, windows.TRUSTEE_IS_WELL_KNOWN_GROUP), + } + acl, err := windows.ACLFromEntries(entries, nil) + if err != nil { + return fmt.Errorf("secure user file: build ACL: %w", err) + } + handle, err := reopenSecurityHandle(f, windows.READ_CONTROL|windows.WRITE_DAC|windows.WRITE_OWNER) + if err != nil { + return fmt.Errorf("secure user file: reopen for metadata: %w", err) + } + defer windows.CloseHandle(handle) + if err := windows.SetSecurityInfo( + handle, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + targetSID, + nil, + acl, + nil, + ); err != nil { + return fmt.Errorf("secure user file: set metadata: %w", err) + } + return nil +} + +func secureExplicitAccess(sid *windows.SID, permissions windows.ACCESS_MASK, inheritance uint32, trusteeType windows.TRUSTEE_TYPE) windows.EXPLICIT_ACCESS { + return windows.EXPLICIT_ACCESS{ + AccessPermissions: permissions, + AccessMode: windows.GRANT_ACCESS, + Inheritance: inheritance, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: trusteeType, + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + } +} + +func (windowsOwnerReader) secure(f *os.File, h *Home, _ os.FileMode) (bool, error) { + handle, err := reopenSecurityHandle(f, windows.READ_CONTROL) + if err != nil { + return false, fmt.Errorf("secure user file: reopen for ACL check: %w", err) + } + defer windows.CloseHandle(handle) + descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return false, fmt.Errorf("secure user file: read ACL: %w", err) + } + control, _, err := descriptor.Control() + if err != nil { + return false, fmt.Errorf("secure user file: read ACL control: %w", err) + } + if control&windows.SE_DACL_PROTECTED == 0 { + return false, nil + } + acl, _, err := descriptor.DACL() + if err != nil { + return false, fmt.Errorf("secure user file: parse ACL: %w", err) + } + if acl == nil || acl.AceCount != 2 { + return false, nil + } + targetSID, err := windows.StringToSid(h.targetUser.Uid) + if err != nil { + return false, fmt.Errorf("secure user file: target SID: %w", err) + } + systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return false, fmt.Errorf("secure user file: SYSTEM SID: %w", err) + } + seenTarget, seenSystem := false, false + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + return false, fmt.Errorf("secure user file: read ACL entry: %w", err) + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || ace.Header.AceFlags&windows.INHERITED_ACE != 0 { + return false, nil + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + switch { + case sid.Equals(targetSID): + seenTarget = true + case sid.Equals(systemSID): + seenSystem = true + default: + return false, nil + } + } + return seenTarget && seenSystem, nil +} + +func checkSecurePlatformOwner(h *Home, f *os.File) error { + handle, err := reopenSecurityHandle(f, windows.READ_CONTROL) + if err != nil { + return fmt.Errorf("secure user file: reopen for owner check: %w", err) + } + defer windows.CloseHandle(handle) + descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("secure user file: read owner: %w", err) + } + owner, _, err := descriptor.Owner() + if err != nil { + return fmt.Errorf("secure user file: parse owner: %w", err) + } + target, err := windows.StringToSid(h.targetUser.Uid) + if err != nil { + return fmt.Errorf("secure user file: target SID: %w", err) + } + if owner == nil || !owner.Equals(target) { + return fmt.Errorf("secure user file: wrong owner: %w", ErrTargetUnusable) + } + return nil +} + +var ( + wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll") + procWTSQuerySessionInformationW = wtsapi32.NewProc("WTSQuerySessionInformationW") +) + +func reopenSecurityHandle(f *os.File, access uint32) (windows.Handle, error) { + path, err := windows.UTF16PtrFromString(f.Name()) + if err != nil { + return windows.InvalidHandle, err + } + handle, err := windows.CreateFile( + path, + access|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 { + return windows.InvalidHandle, err + } + if err := requireSameFileIdentity(windows.Handle(f.Fd()), handle); err != nil { + _ = windows.CloseHandle(handle) + return windows.InvalidHandle, err + } + return handle, nil +} + +func requireSameFileIdentity(original, reopened windows.Handle) error { + var originalInfo, reopenedInfo windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(original, &originalInfo); err != nil { + return fmt.Errorf("inspect original file identity: %w", err) + } + if err := windows.GetFileInformationByHandle(reopened, &reopenedInfo); err != nil { + return fmt.Errorf("inspect reopened file identity: %w", err) + } + if originalInfo.VolumeSerialNumber != reopenedInfo.VolumeSerialNumber || + originalInfo.FileIndexHigh != reopenedInfo.FileIndexHigh || + originalInfo.FileIndexLow != reopenedInfo.FileIndexLow { + return fmt.Errorf("reopened file identity changed: %w", ErrTargetUnusable) + } + return nil +} + +const ( + wtsCurrentServerHandle = 0 + wtsInfoUserName = 5 + wtsInfoDomainName = 7 + sidLocalSystem = "S-1-5-18" +) + +func interactiveSessionOK(exec executor.Executor) bool { + if exec.GOOS() != model.PlatformWindows { + return true + } + tokenSID, err := currentTokenUserSID() + if err != nil || tokenSID.String() == sidLocalSystem { + return false + } + var sessionID uint32 + if err := windows.ProcessIdToSessionId(windows.GetCurrentProcessId(), &sessionID); err != nil || sessionID == 0 { + return false + } + state, ok := sessionConnectState(sessionID) + if !ok || state != uint32(windows.WTSActive) { + return false + } + sessionSID, err := sessionUserSID(sessionID) + return err == nil && tokenSID.String() == sessionSID.String() +} + +func currentTokenUserSID() (*windows.SID, error) { + tokenUser, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, err + } + return tokenUser.User.Sid, nil +} + +func sessionConnectState(sessionID uint32) (uint32, bool) { + var info *windows.WTS_SESSION_INFO + var count uint32 + if err := windows.WTSEnumerateSessions(0, 0, 1, &info, &count); err != nil { + return 0, false + } + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info))) + for _, session := range unsafe.Slice(info, count) { + if session.SessionID == sessionID { + return session.State, true + } + } + return 0, false +} + +func sessionUserSID(sessionID uint32) (*windows.SID, error) { + name, err := wtsQueryString(sessionID, wtsInfoUserName) + if err != nil { + return nil, err + } + if name == "" { + return nil, errors.New("secure user file: session has no logged-on user") + } + domain, _ := wtsQueryString(sessionID, wtsInfoDomainName) + account := name + if domain != "" { + account = domain + `\` + name + } + sid, _, _, err := windows.LookupSID("", account) + return sid, err +} + +func wtsQueryString(sessionID uint32, infoClass uint32) (string, error) { + var buffer *uint16 + var bytesReturned uint32 + result, _, callErr := procWTSQuerySessionInformationW.Call( + uintptr(wtsCurrentServerHandle), + uintptr(sessionID), + uintptr(infoClass), + uintptr(unsafe.Pointer(&buffer)), + uintptr(unsafe.Pointer(&bytesReturned)), + ) + if result == 0 { + return "", callErr + } + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(buffer))) + return windows.UTF16PtrToString(buffer), nil +} diff --git a/internal/secureuserfile/file_windows_test.go b/internal/secureuserfile/file_windows_test.go new file mode 100644 index 00000000..d62d7e1f --- /dev/null +++ b/internal/secureuserfile/file_windows_test.go @@ -0,0 +1,175 @@ +//go:build windows + +package secureuserfile + +import ( + "errors" + "os" + "os/user" + "path/filepath" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +func normalizeSecureTestUser(t *testing.T, u *user.User) { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(u.HomeDir, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + t.Fatal(err) + } + owner, _, err := descriptor.Owner() + if err != nil || owner == nil { + t.Fatalf("temporary home owner: %v", err) + } + u.Uid = owner.String() +} + +func assertWindowsOwner(t *testing.T, path string, want *windows.SID) { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("GetNamedSecurityInfo(%q): %v", path, err) + } + owner, _, err := descriptor.Owner() + if err != nil || owner == nil { + t.Fatalf("owner(%q): %v", path, err) + } + if !owner.Equals(want) { + t.Fatalf("owner(%q) = %s, want %s", path, owner.String(), want.String()) + } +} + +func TestReopenSecurityHandle_FromRootFile(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer root.Close() + file, err := root.OpenFile("config", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + handle, err := reopenSecurityHandle(file, windows.READ_CONTROL) + if err != nil { + t.Fatalf("reopenSecurityHandle: %v", err) + } + windows.CloseHandle(handle) +} + +func TestSecureUserFile_CreatedParentsHaveRestrictedACL(t *testing.T) { + home := t.TempDir() + current, err := user.Current() + if err != nil { + t.Fatal(err) + } + current.HomeDir = home + h, err := openHome(current) + if err != nil { + t.Fatal(err) + } + defer h.Close() + if err := h.EnsureParent(filepath.Join(".config", "tool", "config")); err != nil { + t.Fatalf("ensureParent: %v", err) + } + + targetSID, err := windows.StringToSid(current.Uid) + if err != nil { + t.Fatal(err) + } + systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{filepath.Join(home, ".config"), filepath.Join(home, ".config", "tool")} { + assertWindowsOwner(t, path, targetSID) + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("GetNamedSecurityInfo(%q): %v", path, err) + } + control, _, err := descriptor.Control() + if err != nil { + t.Fatal(err) + } + if control&windows.SE_DACL_PROTECTED == 0 { + t.Fatalf("%q inherits a broad parent ACL", path) + } + acl, _, err := descriptor.DACL() + if err != nil { + t.Fatal(err) + } + if acl == nil || acl.AceCount < 2 { + t.Fatalf("%q ACL = %v, want target user and SYSTEM entries", path, acl) + } + seenTarget, seenSystem := false, false + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + t.Fatal(err) + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || ace.Header.AceFlags&windows.INHERITED_ACE != 0 { + t.Fatalf("%q contains a non-explicit allow ACE", path) + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + switch { + case sid.Equals(targetSID): + seenTarget = true + case sid.Equals(systemSID): + seenSystem = true + default: + t.Fatalf("%q ACL grants access to unexpected SID %s", path, sid.String()) + } + } + if !seenTarget || !seenSystem { + t.Fatalf("%q ACL does not contain only target user and SYSTEM", path) + } + } + + file := openSecureTestFile(t, h, filepath.Join(".config", "tool", "config")) + if err := file.Commit([]byte("managed\n"), FileMode); err != nil { + t.Fatal(err) + } + assertWindowsOwner(t, file.Location(), targetSID) +} + +func TestSecureUserFile_PreexistingWrongOwnerRejected(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, "config") + if err := os.WriteFile(path, []byte("existing\n"), FileMode); err != nil { + t.Fatal(err) + } + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + t.Fatal(err) + } + originalOwner, _, err := descriptor.Owner() + if err != nil || originalOwner == nil { + t.Fatalf("original owner: %v", err) + } + targetSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatal(err) + } + if originalOwner.Equals(targetSID) { + t.Skip("test object is already owned by SYSTEM") + } + current, err := user.Current() + if err != nil { + t.Fatal(err) + } + current.HomeDir = home + current.Uid = targetSID.String() + h, err := openHome(current) + if err != nil { + t.Fatal(err) + } + defer h.Close() + file := openSecureTestFile(t, h, "config") + if _, _, _, err := file.Read(); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("Read error = %v, want ErrTargetUnusable", err) + } + assertWindowsOwner(t, path, originalOwner) +}