diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index c621334..6f11fec 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -262,8 +262,13 @@ func main() { os.Exit(1) } armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log) - if err := telemetry.Run(exec, log, cfg); err != nil { - log.Error("%v", err) + telemetryErr := telemetry.Run(exec, log, cfg) + // Package-config enforcement runs on every cycle, even one where telemetry + // failed, so an emergency unassignment/offboarding directive is never + // blocked by a telemetry outage — hence before the error-exit below. + runPackageConfigEnforce(exec, log) + if telemetryErr != nil { + log.Error("%v", telemetryErr) os.Exit(1) } runHookStateReconcile(exec, log) @@ -351,6 +356,16 @@ func main() { } } + // Package-config enforcement runs even if the initial telemetry failed + // (before the error-exit below). Skipped on Windows at install time: an + // elevated installer resolves the ADMINISTRATOR's home, not the developer's, + // so let the first scheduled /ru INTERACTIVE firing do the first enforcement + // (macOS root installs resolve the console user; Linux installs are user + // mode, and the Windows-SYSTEM / macOS paths already returned above). + if runtime.GOOS != model.PlatformWindows { + runPackageConfigEnforce(exec, log) + } + if telemetryErr != nil { if cfg.IgnoreTelemetryError { // Opt-in tolerance for MSI/SCCM/Intune deployments: the @@ -475,8 +490,12 @@ func main() { case config.IsEnterpriseMode(): log.Debug("dispatch: enterprise telemetry (auto-detected)") armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log) - if err := telemetry.Run(exec, log, cfg); err != nil { - log.Error("%v", err) + telemetryErr := telemetry.Run(exec, log, cfg) + // Package-config enforcement runs on every enterprise cycle — including a + // manually invoked one, and even when telemetry failed. + runPackageConfigEnforce(exec, log) + if telemetryErr != nil { + log.Error("%v", telemetryErr) os.Exit(1) } default: @@ -744,3 +763,93 @@ func runIDEExtensionEnforce(exec executor.Executor, log *progress.Logger) { aiagentscli.AppendError("devicepolicy", "enforce_failed", err.Error(), "") } } + +// 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. +func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) { + cfg, ok := ingest.Snapshot() + if !ok { + log.Debug("package-config enforce: skipped (no enterprise config)") + return + } + fetcher, ok := devicepolicy.NewHTTPFetcher(cfg, nil) + if !ok { + log.Debug("package-config enforce: skipped (fetcher init refused config)") + return + } + reporter, ok := devicepolicy.NewHTTPReporter(cfg, nil) + if !ok { + log.Debug("package-config enforce: skipped (reporter init refused config)") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), devicePolicyEnforceTimeout) + defer cancel() + + dev := device.Gather(ctx, exec) + if dev.SerialNumber == "" || dev.SerialNumber == "unknown" { + log.Warn("package-config enforce: device serial unresolved; skipping") + return + } + serial := dev.SerialNumber + + r := &devicepolicy.Reconciler{ + Fetcher: fetcher, + Reporter: reporter, + CustomerID: cfg.CustomerID, + DeviceID: serial, + Platform: dev.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, + 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 + } 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. + r.Writer = w + r.Converged = w.Converged + r.ProbeExpected = w.ProbeExpected + r.RestoreSnapshot = w.RestoreSnapshot + r.State = devicepolicy.NewStateStoreFor(w.TargetUser()) + } + + if err := r.Reconcile(ctx); err != nil { + log.Warn("package-config enforce: %v", err) + aiagentscli.AppendError("devicepolicy", "enforce_failed", err.Error(), "") + } +} diff --git a/internal/devicepolicy/api.go b/internal/devicepolicy/api.go index 1d4c9e7..f03c214 100644 --- a/internal/devicepolicy/api.go +++ b/internal/devicepolicy/api.go @@ -199,6 +199,16 @@ func (c *HTTPFetcher) Fetch(ctx context.Context, customerID, deviceID, category, Hash: strings.TrimSpace(p.Hash), GeneratedAt: p.GeneratedAt, } + // Reject a response scoped to a different category/target than requested + // (backend bug, proxy/cache mixup). Acting on it could enforce — or worse, + // clear — the wrong pair. An empty field is not a mismatch; it defaults to + // the requested value just below. + if ep.Category != "" && ep.Category != category { + return EffectivePolicy{}, fmt.Errorf("devicepolicy: response category %q does not match requested %q", ep.Category, category) + } + if ep.Target != "" && ep.Target != target { + return EffectivePolicy{}, fmt.Errorf("devicepolicy: response target %q does not match requested %q", ep.Target, target) + } if ep.Category == "" { ep.Category = category } diff --git a/internal/devicepolicy/api_identity_test.go b/internal/devicepolicy/api_identity_test.go new file mode 100644 index 0000000..a829c1f --- /dev/null +++ b/internal/devicepolicy/api_identity_test.go @@ -0,0 +1,94 @@ +package devicepolicy + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/aiagents/ingest" +) + +// newPolicyFetchServer is a fetch server that asserts the request carries the +// EXPECTED category/target query and returns a fixed body. Unlike newFetchServer +// (pinned to ide_extension/vscode) it lets a test drive any requested pair — +// needed by the identity checks below, which turn on the (category, target) the +// RESPONSE claims versus the one the agent asked for. +func newPolicyFetchServer(t *testing.T, wantCategory, wantTarget, body string) *HTTPFetcher { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("category"); got != wantCategory { + t.Errorf("request category = %q, want %q", got, wantCategory) + } + if got := r.URL.Query().Get("target"); got != wantTarget { + t.Errorf("request target = %q, want %q", got, wantTarget) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + f, ok := NewHTTPFetcher(ingest.Config{APIEndpoint: srv.URL, APIKey: "test-key"}, srv.Client()) + if !ok { + t.Fatal("NewHTTPFetcher returned ok=false on valid config") + } + return f +} + +func TestFetchRejectsMismatchedResponseCategory(t *testing.T) { + // The agent asked for ide_extension/vscode; the response claims a DIFFERENT + // category (backend bug, proxy/cache mixup). Enforcing it would apply the + // wrong pair — Fetch must reject it before the reconciler ever sees it. + body := `{"policy":{"category":"package_config","target":"vscode","clear":false,` + + `"policy":{"x":true},"hash":"sha256:h","generated_at":"x"}}` + f := newPolicyFetchServer(t, CategoryIDEExtension, TargetVSCode, body) + _, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryIDEExtension, TargetVSCode) + if err == nil || !strings.Contains(err.Error(), "category") { + t.Fatalf("mismatched response category must error, got %v", err) + } +} + +func TestFetchRejectsMismatchedResponseTarget(t *testing.T) { + // Category matches but the response targets a different IDE family — still the + // wrong pair to act on. + body := `{"policy":{"category":"ide_extension","target":"jetbrains","clear":false,` + + `"policy":{"x":true},"hash":"sha256:h","generated_at":"x"}}` + f := newPolicyFetchServer(t, CategoryIDEExtension, TargetVSCode, body) + _, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryIDEExtension, TargetVSCode) + if err == nil || !strings.Contains(err.Error(), "target") { + t.Fatalf("mismatched response target must error, got %v", err) + } +} + +func TestFetchRejectsMismatchedClearDirective(t *testing.T) { + // The most dangerous mixup: a clear:true scoped to the WRONG pair. If it + // slipped through, the reconciler would remove an unrelated category's value. + // The identity check fires before clear is ever surfaced as a directive. + body := `{"policy":{"category":"package_config","target":"npm","clear":true,"generated_at":"x"}}` + f := newPolicyFetchServer(t, CategoryIDEExtension, TargetVSCode, body) + _, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryIDEExtension, TargetVSCode) + if err == nil { + t.Fatal("a clear scoped to a different category/target must be rejected, not surfaced as a clear") + } +} + +func TestFetchPackageConfigTargetRoundTrips(t *testing.T) { + // The generic fetcher carries the package_config/npm pair end to end: the + // request query is scoped to it and a matching response parses back cleanly. + body := `{"policy":{"category":"package_config","target":"npm","clear":false,` + + `"policy":{"registry":"https://npm.pkg.example/"},"hash":"sha256:npm","generated_at":"x"}}` + f := newPolicyFetchServer(t, CategoryPackageConfig, TargetNPM, body) + ep, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryPackageConfig, TargetNPM) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if ep.Category != CategoryPackageConfig || ep.Target != TargetNPM { + t.Fatalf("round-trip identity = %q/%q, want %q/%q", + ep.Category, ep.Target, CategoryPackageConfig, TargetNPM) + } + if ep.Hash != "sha256:npm" || !ep.present() { + t.Fatalf("ep = %+v, want present with hash sha256:npm", ep) + } +} diff --git a/internal/devicepolicy/npmrc.go b/internal/devicepolicy/npmrc.go new file mode 100644 index 0000000..eceef80 --- /dev/null +++ b/internal/devicepolicy/npmrc.go @@ -0,0 +1,1772 @@ +package devicepolicy + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/url" + "os" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// This file backs the package_config#npm policy category: it converges a +// managed block inside the console user's ~/.npmrc so npm (and the pnpm / yarn +// v1 / bun tools that read the same file) resolves packages through the +// tenant's StepSecurity secure registry. It parallels the VS Code +// settings.json writer (settings_writer.go) but the target is a file the agent +// may run as root against a user-owned tree, so every file operation goes +// through os.Root rather than atomicfile — see the security notes on +// NPMRCWriter below. + +// Ownership markers for the managed block. The BEGIN/END pair delimits exactly +// the bytes the agent owns; nothing outside it is ever rewritten. BEGIN carries +// the "-- managed by dmg" suffix so it is distinguishable from the MDM script's +// own header (which ends "-- managed by mdm") — the two lanes must never claim +// each other's block. +const ( + npmrcBeginMarker = "# BEGIN StepSecurity Secure Registry -- managed by dmg" + npmrcEndMarker = "# END StepSecurity Secure Registry" + // npmrcMDMMarker is the header the published MDM remediation script writes. + // The probe treats its presence (outside our block) as the first signal + // that the MDM lane is managing this file. + npmrcMDMMarker = "# StepSecurity Secure Registry -- managed by mdm" +) + +// npmrcDMGPrefix is prepended to a user's active bare `registry=` line when the +// managed block is applied, so the original survives (commented) and can be +// restored on clear. It is deliberately distinct from the MDM script's +// `# [stepsecurity] ` prefix: each lane only ever un-comments its own prefix, +// so they cannot resurrect each other's work. +const ( + npmrcDMGPrefix = "# [stepsecurity-dmg] " + npmrcMDMPrefix = "# [stepsecurity] " +) + +const ( + // npmrcMaxBytes caps the file the writer will read, snapshot, back up, or + // transform. A pathological multi-megabyte .npmrc must not balloon memory + // or the backup set; exceeding it is a structural refusal, not a transform. + npmrcMaxBytes = 1 << 20 + // npmrcMaxRenderedBytes caps the two rendered content lines. Anything past + // this is a malformed policy, not a block to write. + npmrcMaxRenderedBytes = 4 << 10 + // npmrcMaxKeyBytes / npmrcMaxSerialBytes bound the two variable-length + // fields the renderer accepts. + npmrcMaxKeyBytes = 256 + npmrcMaxSerialBytes = 128 + // npmrcMaxHostBytes is the RFC 1123 hostname length ceiling. + npmrcMaxHostBytes = 253 + // npmrcMaxSymlinkDepth bounds the .npmrc symlink chain the resolver will + // follow before declaring a loop. + npmrcMaxSymlinkDepth = 8 + // npmrcMaxBackups is the retained backup count beside the resolved leaf. + npmrcMaxBackups = 3 + // npmrcFileMode is the mode every file the writer creates or rewrites lands + // with. A token-bearing file is never group/other-readable. + npmrcFileMode os.FileMode = 0o600 +) + +// Structural errors. Every "this target cannot be enforced" condition wraps +// ErrTargetUnusable so the reconciler can classify the whole class as +// write_failed regardless of whether a read or a write surfaced it, while a +// plain permission-denied / transient I/O error (which does not wrap it) stays +// verification_failed. ErrNoTargetUser is separate: it means there is no +// enforceable user on this machine state (LocalSystem, a non-interactive +// Windows session, or root with no resolvable GUI user), which the reconciler +// reports as policy_not_applied, not write_failed. +var ( + ErrTargetUnusable = errors.New("npmrc: target unusable") + ErrNoTargetUser = errors.New("npmrc: no enforceable target user") + ErrAbsoluteSymlink = fmt.Errorf("npmrc: .npmrc is an absolute symlink: %w", ErrTargetUnusable) + ErrSymlinkLoop = fmt.Errorf("npmrc: .npmrc symlink chain too deep: %w", ErrTargetUnusable) + ErrDanglingSymlink = fmt.Errorf("npmrc: .npmrc symlink target does not exist: %w", ErrTargetUnusable) +) + +// ErrWriteUnverified means a mutating op landed new bytes it could then neither +// verify NOR roll back — the post-rename identity re-check failed and the restore +// to the pre-state also failed. On-disk state is therefore indeterminate (not the +// clean "write failed, disk untouched" case), which the reconciler classifies as +// verification_failed rather than write_failed. It deliberately does NOT wrap +// ErrTargetUnusable, so the write-path classifier routes it to verification_failed. +var ErrWriteUnverified = errors.New("npmrc: write could not be verified or rolled back") + +// NPMRCWriter converges the managed block in one user's ~/.npmrc. It satisfies +// the Writer interface (Read/Write/Clear/Location) and adds the concrete-type +// methods the reconciler seams need — Converged, ProbeExpected, RestoreSnapshot +// — none of which fit the settings.json-shaped interface. +// +// Threat model: the agent can run as root (macOS LaunchDaemon) against a home +// directory the target user controls. A user who can plant symlinks or swap +// directory entries mid-operation must not be able to steer a root-owned write, +// a root read, or a token-bearing backup outside their own regular .npmrc. The +// writer therefore anchors every operation to a directory file descriptor via +// os.Root, resolves the .npmrc symlink chain explicitly, pins the resolved +// parent with a second os.Root, re-verifies file identity after every open, and +// performs all metadata changes on open handles (never by path). It never uses +// atomicfile, whose predictable, symlink-following backup names are exactly the +// attack this design closes. +type NPMRCWriter struct { + exec executor.Executor + targetUser *user.User + home string + uid, gid int // parsed from targetUser; only meaningful where enforcePOSIXMetadata + + root *os.Root // directory fd over the target home, held for the writer's lifetime + owners ownerReader + logf func(format string, args ...any) + + // pending is the memory-only snapshot captured at the start of the last + // mutating op (Write/Clear) and retained on success so a later + // RestoreSnapshot can undo it. It is never persisted. + pending *pendingSnapshot +} + +// pendingSnapshot is the pre-mutation state one Write or Clear can roll back to. +type pendingSnapshot struct { + existed bool + data []byte + mode os.FileMode + // leaf is the resolved leaf path relative to the home root at capture time. + // RestoreSnapshot re-resolves and refuses to write if the chain now points + // somewhere else. + leaf string + // committed is the identity (post-rename FileInfo) of the file this writer + // last left at the leaf. RestoreSnapshot requires the on-disk leaf to still be + // SameFile as this before reverting: a relative path can be unchanged while the + // parent directory or the leaf inode was swapped underneath it, and reverting + // into that would write stale bytes into someone else's file. + committed os.FileInfo +} + +// ownerReader reads the uid/gid owning an open file. enforcePOSIXMetadata +// platforms return the real owner; elsewhere (Windows, ACL model) enforced is +// false and the caller skips every ownership decision. Tests inject a fake to +// exercise wrong-owner branches without root. +type ownerReader interface { + ownerUIDGID(f *os.File) (uid, gid uint32, enforced bool, err error) +} + +// NewNPMRCWriter resolves the console user and opens a directory fd over their +// home. It returns ErrNoTargetUser when this machine state has no enforceable +// user (Windows LocalSystem or a non-interactive session, root with no GUI +// user); any other error is an infrastructure failure (home unresolvable or +// unopenable). The caller defers Close to release the directory fd. +func NewNPMRCWriter(exec executor.Executor) (*NPMRCWriter, error) { + // On Windows, a write from any identity that is not the interactive user of + // an active session would silently land in the wrong profile + // (service/RMM account, session 0, runas alternate credentials). Fail + // closed to no-target rather than enforce against the wrong .npmrc. + if !interactiveSessionOK() { + return nil, ErrNoTargetUser + } + + u, err := exec.LoggedInUser() + if err != nil { + // The only error LoggedInUser returns is the darwin-root "no GUI console + // user" case; treat the absence of a resolvable user as no-target. + return nil, fmt.Errorf("%w: %v", ErrNoTargetUser, err) + } + if u == nil || u.HomeDir == "" { + return nil, fmt.Errorf("npmrc: resolved user has no home directory") + } + + root, err := os.OpenRoot(u.HomeDir) + if err != nil { + return nil, fmt.Errorf("npmrc: open home root %q: %w", u.HomeDir, err) + } + + w := &NPMRCWriter{ + exec: exec, + targetUser: u, + home: u.HomeDir, + root: root, + owners: newOwnerReader(), + } + if enforcePOSIXMetadata { + // Uid/Gid are numeric on POSIX. A parse failure means we cannot chown to + // the target user, which defeats the whole point of resolving them. + uid, uerr := strconv.Atoi(u.Uid) + gid, gerr := strconv.Atoi(u.Gid) + if uerr != nil || gerr != nil { + _ = root.Close() + return nil, fmt.Errorf("npmrc: target user %q has non-numeric uid/gid", u.Username) + } + w.uid, w.gid = uid, gid + } + return w, nil +} + +// Close releases the home directory fd. Safe to call more than once. +func (w *NPMRCWriter) Close() error { + if w == nil || w.root == nil { + return nil + } + err := w.root.Close() + w.root = nil + w.pending = nil + return err +} + +// TargetUser is the console user resolved once at construction and immutable +// for the writer's lifetime. The per-user state store must be built from this +// identity, never a second independent resolution — two resolutions can +// straddle a console-user switch and bind one user's file to another's record. +func (w *NPMRCWriter) TargetUser() *user.User { return w.targetUser } + +// Location is a human-readable target description for logs. It never includes +// file contents or key material. +func (w *NPMRCWriter) Location() string { + return filepath.Join(w.home, ".npmrc") + " [npm secure registry]" +} + +// SetLogf installs an optional diagnostic sink for non-fatal events (a missing +// END marker stripped to EOF, a backup-rotation prune failure, a snapshot +// restore that aborted). It is never handed file contents or key material. +func (w *NPMRCWriter) SetLogf(logf func(format string, args ...any)) { w.logf = logf } + +func (w *NPMRCWriter) log(format string, args ...any) { + if w.logf != nil { + w.logf(format, args...) + } +} + +// --------------------------------------------------------------------------- +// Symlink-chain resolution +// --------------------------------------------------------------------------- + +// resolvedTarget is the outcome of walking the ~/.npmrc symlink chain: a child +// os.Root pinned at the resolved leaf's real parent directory plus the leaf's +// basename within it. Every subsequent operation uses (child, base) so a swap +// of an ancestor directory after resolution cannot redirect the open or rename +// — a directory fd references the original directory even if it is later moved. +type resolvedTarget struct { + child *os.Root // caller closes + base string // leaf basename within child + rel string // leaf path relative to the home root (parentDir/base) + viaSymlink bool // the leaf was reached by following at least one link + existed bool // the leaf exists (Lstat succeeded) +} + +func (rt *resolvedTarget) close() { + if rt != nil && rt.child != nil { + _ = rt.child.Close() + } +} + +// resolveLeaf walks the .npmrc chain relative to the home root and pins the +// resolved parent. It rejects, before any file open: +// - an absolute symlink target (ErrAbsoluteSymlink) — even one pointing back +// inside the home; enforcing through an absolute link is out of scope; +// - a raw target ending in a path separator or "/." (ErrTargetUnusable), +// checked BEFORE cleaning: `.npmrc -> "file/"` fails kernel resolution with +// ENOTDIR when `file` is not a directory, so npm never reads it; cleaning +// would erase that evidence and let a bogus write report success; +// - a chain deeper than npmrcMaxSymlinkDepth (ErrSymlinkLoop); +// - a dangling target (ErrDanglingSymlink); +// - a chain escaping the home (os.Root refuses; surfaces as ErrTargetUnusable). +func (w *NPMRCWriter) resolveLeaf() (*resolvedTarget, error) { + cur := ".npmrc" + viaSymlink := false + + for depth := 0; ; depth++ { + if depth > npmrcMaxSymlinkDepth { + return nil, ErrSymlinkLoop + } + fi, err := w.root.Lstat(cur) + if errors.Is(err, os.ErrNotExist) { + if viaSymlink { + // A link resolved to a name that does not exist. + return nil, ErrDanglingSymlink + } + // The plain .npmrc simply does not exist yet. + return w.pin(cur, false, false) + } + if err != nil { + return nil, fmt.Errorf("npmrc: lstat %q: %w", cur, err) + } + if fi.Mode()&fs.ModeSymlink == 0 { + // Regular (or other) leaf reached. + return w.pin(cur, viaSymlink, true) + } + + target, err := w.root.Readlink(cur) + if err != nil { + return nil, fmt.Errorf("npmrc: readlink %q: %w", cur, err) + } + if isAbsSymlinkTarget(target) { + return nil, ErrAbsoluteSymlink + } + if endsInSeparatorOrDot(target) { + return nil, fmt.Errorf("npmrc: symlink target %q is directory-shaped: %w", target, ErrTargetUnusable) + } + next := filepath.Clean(filepath.Join(filepath.Dir(cur), target)) + if next == ".." || strings.HasPrefix(next, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("npmrc: symlink escapes home: %w", ErrTargetUnusable) + } + cur = next + viaSymlink = true + } +} + +// pin opens a child os.Root at the resolved leaf's parent directory so every +// later op is anchored to that directory fd. +func (w *NPMRCWriter) pin(rel string, viaSymlink, existed bool) (*resolvedTarget, error) { + parent := filepath.Dir(rel) + base := filepath.Base(rel) + if base == "." || base == ".." || strings.ContainsRune(base, filepath.Separator) { + return nil, fmt.Errorf("npmrc: resolved leaf %q is not a basename: %w", rel, ErrTargetUnusable) + } + child, err := w.root.OpenRoot(parent) + if err != nil { + if errors.Is(err, os.ErrPermission) { + // The parent exists but is unreadable (permissions / transient). That is + // not a structural refusal: surface it as a plain error so the reconciler + // classifies it verification_failed and retries, rather than the + // write_failed reserved for a target that can never be enforced. + return nil, fmt.Errorf("npmrc: pin parent %q: %w", parent, err) + } + // A parent that is itself a symlink escaping the home, or a non-directory + // component, lands here: structurally unenforceable. + return nil, fmt.Errorf("npmrc: pin parent %q: %w", parent, ErrTargetUnusable) + } + return &resolvedTarget{child: child, base: base, rel: rel, viaSymlink: viaSymlink, existed: existed}, nil +} + +// isAbsSymlinkTarget reports whether a raw link target is absolute. filepath.IsAbs +// covers POSIX "/..." and Windows drive/UNC forms; a leading separator is caught +// explicitly so a POSIX target evaluated on any host is still rejected. +func isAbsSymlinkTarget(target string) bool { + if target == "" { + return false + } + if target[0] == '/' || target[0] == filepath.Separator { + return true + } + return filepath.IsAbs(target) +} + +// endsInSeparatorOrDot reports whether a raw (uncleaned) symlink target is +// directory-shaped — ending in a separator or in "/." — the GO-2026-4970 +// trigger the resolver refuses before filepath.Clean can erase the evidence. +func endsInSeparatorOrDot(target string) bool { + if target == "" { + return false + } + last := target[len(target)-1] + if last == '/' || last == filepath.Separator { + return true + } + if target == "." || strings.HasSuffix(target, "/.") { + return true + } + if filepath.Separator != '/' && strings.HasSuffix(target, string(filepath.Separator)+".") { + return true + } + return false +} + +// --------------------------------------------------------------------------- +// Bounded, identity-checked reads +// --------------------------------------------------------------------------- + +// readCurrent opens the resolved leaf and returns its bytes, existence, and +// mode. It enforces the full open-identity discipline: a Lstat pre-screen that +// fast-fails an obvious FIFO/device, an O_NONBLOCK open so a FIFO cannot block +// the daemon, a post-open regular-file check, a re-Lstat + SameFile identity +// check to close the resolve→open swap race, an ownership rule (the resolved +// leaf must be owned by the target user — root-owned included is refused, since +// this writer always chowns its own output to that user and so never leaves a +// root-owned leaf behind), and a size cap. An absent file returns +// (nil, false, 0, nil). +func (w *NPMRCWriter) readCurrent(rt *resolvedTarget) ([]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("npmrc: lstat leaf %q: %w", rt.base, err) + } + if li.Mode()&fs.ModeSymlink != 0 { + // The chain was resolved to a regular leaf; a symlink here means the + // entry was swapped after resolution. + return nil, false, 0, fmt.Errorf("npmrc: leaf %q became a symlink: %w", rt.base, ErrTargetUnusable) + } + if !li.Mode().IsRegular() { + return nil, false, 0, fmt.Errorf("npmrc: leaf %q is not a regular file: %w", rt.base, ErrTargetUnusable) + } + + f, err := rt.child.OpenFile(rt.base, os.O_RDONLY|nonblockOpenFlag(), 0) + if err != nil { + return nil, false, 0, fmt.Errorf("npmrc: open leaf %q: %w", rt.base, err) + } + defer f.Close() + + hi, err := f.Stat() + if err != nil { + return nil, false, 0, fmt.Errorf("npmrc: stat leaf handle: %w", err) + } + if !hi.Mode().IsRegular() { + return nil, false, 0, fmt.Errorf("npmrc: opened leaf %q is not a regular file: %w", rt.base, ErrTargetUnusable) + } + + // Re-Lstat through the pinned child and require the same inode: an in-root + // symlink swapped in between the pre-screen and the open would have been + // followed by os.Root to another file, which this rejects. + li2, err := rt.child.Lstat(rt.base) + if err != nil { + return nil, false, 0, fmt.Errorf("npmrc: re-lstat leaf %q: %w", rt.base, err) + } + if li2.Mode()&fs.ModeSymlink != 0 || !li2.Mode().IsRegular() || !os.SameFile(li2, hi) { + return nil, false, 0, fmt.Errorf("npmrc: leaf %q changed during open: %w", rt.base, ErrTargetUnusable) + } + + if err := w.checkOwner(f, rt); err != nil { + return nil, false, 0, err + } + + data, err := io.ReadAll(io.LimitReader(f, npmrcMaxBytes+1)) + if err != nil { + return nil, false, 0, fmt.Errorf("npmrc: read leaf %q: %w", rt.base, err) + } + if len(data) > npmrcMaxBytes { + return nil, false, 0, fmt.Errorf("npmrc: leaf %q exceeds %d bytes: %w", rt.base, npmrcMaxBytes, ErrTargetUnusable) + } + return data, true, hi.Mode().Perm(), nil +} + +// checkOwner enforces the ownership rule for an existing target: on POSIX the +// resolved leaf must be owned by the target user, full stop. A leaf owned by +// anyone else — root included — is refused. A user could otherwise point .npmrc +// at a root-owned file in their home and have the daemon read it, copy its bytes +// into a user-readable backup, and rewrite it user-owned, disclosing and mutating +// a file they could not otherwise touch. And since this writer always chowns its +// own output to the target user (applyMetadata), a root-owned leaf is never one +// it left behind, so there is nothing legitimate to tolerate. On Windows +// (enforced=false) ownership is governed by ACLs and this check is skipped. +func (w *NPMRCWriter) checkOwner(f *os.File, rt *resolvedTarget) error { + uid, _, enforced, err := w.owners.ownerUIDGID(f) + if err != nil { + return fmt.Errorf("npmrc: read owner: %w", err) + } + if !enforced { + return nil + } + if uid == uint32(w.uid) { // #nosec G115 -- w.uid is strconv.Atoi of a POSIX uid (os/user), always non-negative and within uint32 + return nil + } + return fmt.Errorf("npmrc: leaf %q owned by uid %d, not target user: %w", rt.base, uid, ErrTargetUnusable) +} + +// Read returns the managed block body (canonicalized) and whether it is +// present. It satisfies the Writer interface; the reconciler uses Converged +// (not this) for the real idempotency decision. +func (w *NPMRCWriter) Read() (string, bool, error) { + rt, err := w.resolveLeaf() + if err != nil { + return "", false, err + } + defer rt.close() + + data, existed, _, err := w.readCurrent(rt) + if err != nil { + return "", false, err + } + if !existed { + return "", false, nil + } + body, present := extractManagedBody(string(data)) + return body, present, nil +} + +// --------------------------------------------------------------------------- +// Write / Clear (the §3 rewrite and clear algorithms) +// --------------------------------------------------------------------------- + +// Write applies the rewrite transform for the given rendered block body and +// returns the block body read back from disk. The op is transactional: it +// snapshots the pre-state first, and any failure after the rename self-restores +// before returning. On success the snapshot is retained for a later +// RestoreSnapshot. +func (w *NPMRCWriter) Write(value string) (string, error) { + rt, err := w.resolveLeaf() + if err != nil { + return "", err + } + defer rt.close() + + cur, existed, mode, err := w.readCurrent(rt) + if err != nil { + return "", err + } + if !existed { + mode = npmrcFileMode + } + + next, err := w.rewriteContent(cur, value) + if err != nil { + return "", err + } + + snap := &pendingSnapshot{existed: existed, data: cur, mode: mode, leaf: rt.rel} + if existed { + // Preserve the pre-rewrite file before overwriting it. Best-effort: + // backup failure must not block enforcement. + if err := w.backup(rt, cur); err != nil { + w.log("npmrc: backup of %q failed: %v", rt.base, err) + } + } + out, err := w.commit(rt, next, npmrcFileMode) + if err != nil { + if out.renamed { + // New bytes landed but their identity could not be confirmed. Revert to + // the pre-state so an unverified write is never left behind; if that + // revert also fails, disk is indeterminate (ErrWriteUnverified). + return "", w.afterFailedRollback(rt, snap, err, "commit verification") + } + return "", err + } + snap.committed = out.committed + + body, err := w.readbackBody(rt) + if err != nil { + // The write landed but readback failed — undo it so disk is not left in an + // unverified state (and flag an indeterminate disk if the undo fails too). + return "", w.afterFailedRollback(rt, snap, err, "readback") + } + w.pending = snap + return body, nil +} + +// Clear removes the managed block and restores the user's commented-out +// `registry=` lines. It carries the same transactional and metadata guarantees +// as Write and never deletes the file. +func (w *NPMRCWriter) Clear() error { + rt, err := w.resolveLeaf() + if err != nil { + return err + } + defer rt.close() + + cur, existed, mode, err := w.readCurrent(rt) + if err != nil { + return err + } + if !existed { + // Nothing to clear; leave the (absent) file alone. + return nil + } + + next := w.clearContent(cur) + if bytes.Equal(next, cur) { + // No managed block and no prefixed lines — a no-op that performs no + // write at all. + return nil + } + + snap := &pendingSnapshot{existed: true, data: cur, mode: mode, leaf: rt.rel} + if err := w.backup(rt, cur); err != nil { + w.log("npmrc: backup of %q failed: %v", rt.base, err) + } + out, err := w.commit(rt, next, npmrcFileMode) + if err != nil { + if out.renamed { + // The cleared bytes landed but unverified — revert to the pre-clear state + // rather than leave an unverified file; a failed revert leaves disk + // indeterminate (ErrWriteUnverified). + return w.afterFailedRollback(rt, snap, err, "clear commit verification") + } + return err + } + snap.committed = out.committed + w.pending = snap + return nil +} + +// RestoreSnapshot reverts the last successful Write/Clear. It re-resolves the +// chain and refuses to write if the leaf now differs from the snapshot's — either +// by relative path (the user re-pointed .npmrc) or by identity (the parent or +// leaf inode was swapped under an unchanged path) — surfacing that as an error the +// reconciler maps to verification_failed. The snapshot is CONSUMED: a restore is +// attempted at most once, so a second call cannot re-run against a stale leaf. +// Calling it with no pending snapshot is an error. +func (w *NPMRCWriter) RestoreSnapshot() error { + if w.pending == nil { + return errors.New("npmrc: no snapshot to restore") + } + snap := w.pending + w.pending = nil + + rt, err := w.resolveLeaf() + if err != nil { + return err + } + defer rt.close() + if rt.rel != snap.leaf { + return fmt.Errorf("npmrc: chain moved from %q to %q; refusing to restore: %w", snap.leaf, rt.rel, ErrTargetUnusable) + } + if err := w.verifyCommitted(rt, snap); err != nil { + return err + } + return w.restoreFrom(rt, snap) +} + +// verifyCommitted confirms the leaf on disk is still the exact file this writer +// committed (SameFile against the snapshot's recorded identity) before a restore +// touches it. A relative path can be unchanged while the parent directory or the +// leaf has been swapped underneath it; reverting into that would write stale bytes +// into someone else's file. An identity mismatch wraps ErrTargetUnusable. +func (w *NPMRCWriter) verifyCommitted(rt *resolvedTarget, snap *pendingSnapshot) error { + if snap.committed == nil { + return nil + } + li, err := rt.child.Lstat(rt.base) + if errors.Is(err, os.ErrNotExist) { + if !snap.existed { + // We had created the file and it is already gone — the pre-state is + // "absent", so there is nothing to revert. + return nil + } + return fmt.Errorf("npmrc: committed leaf %q vanished before restore: %w", rt.base, ErrTargetUnusable) + } + if err != nil { + return fmt.Errorf("npmrc: lstat leaf before restore: %w", err) + } + if li.Mode()&fs.ModeSymlink != 0 || !li.Mode().IsRegular() || !os.SameFile(li, snap.committed) { + return fmt.Errorf("npmrc: leaf %q changed since it was written; refusing to restore: %w", rt.base, ErrTargetUnusable) + } + return nil +} + +// restoreFrom writes a snapshot's bytes/existence/mode at an already-resolved +// target. Ownership is not snapshotted: on restore, as on write, the file is +// chowned to the target user (the file should be target-user-owned, and an +// arbitrary prior owner cannot be expressed portably anyway). +func (w *NPMRCWriter) restoreFrom(rt *resolvedTarget, snap *pendingSnapshot) error { + if !snap.existed { + // The pre-state was "no file": remove what we created. + if err := rt.child.Remove(rt.base); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("npmrc: restore-remove %q: %w", rt.base, err) + } + return nil + } + _, err := w.commit(rt, snap.data, snap.mode) + return err +} + +// afterFailedRollback runs the pre-state restore after a post-rename failure and +// returns the error to surface. If the restore itself fails, on-disk state is +// indeterminate — new bytes landed and could not be reverted — so the returned +// error wraps ErrWriteUnverified (the reconciler reports verification_failed). If +// the restore succeeds, disk is back to the pre-state and the original cause is +// surfaced unchanged (a clean write_failed). +func (w *NPMRCWriter) afterFailedRollback(rt *resolvedTarget, snap *pendingSnapshot, cause error, stage string) error { + if rerr := w.restoreFrom(rt, snap); rerr != nil { + w.log("npmrc: restore after %s aborted: %v", stage, rerr) + return fmt.Errorf("npmrc: %s failed and rollback could not be verified (%v): %w", stage, cause, ErrWriteUnverified) + } + return cause +} + +// commitOutcome reports what a commit did to disk so a caller can react to a +// partial failure. renamed is true once the temp has been renamed into place — +// even if the post-rename identity re-check then failed, meaning new bytes are on +// disk but unverified. committed is the verified leaf identity on full success +// (nil on any error). +type commitOutcome struct { + committed os.FileInfo + renamed bool +} + +// commit writes data to a fresh O_CREATE|O_EXCL temp beside the leaf, sets mode +// and owner on the handle before the rename, renames it into place, then +// re-verifies identity through the pinned child. On Windows the rename is +// best-effort replace semantics rather than a POSIX atomic swap. The returned +// commitOutcome lets Write/Clear tell "failed before the rename, disk untouched" +// apart from "renamed then failed to verify, disk changed" so they can restore. +func (w *NPMRCWriter) commit(rt *resolvedTarget, data []byte, mode os.FileMode) (commitOutcome, error) { + tmp, tmpName, err := w.createExclusive(rt, rt.base+".dmg-tmp-", "") + if err != nil { + return commitOutcome{}, err + } + cleanupTmp := true + defer func() { + if cleanupTmp { + _ = rt.child.Remove(tmpName) + } + }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return commitOutcome{}, fmt.Errorf("npmrc: write temp: %w", err) + } + if err := w.applyMetadata(tmp, mode); err != nil { + _ = tmp.Close() + return commitOutcome{}, err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return commitOutcome{}, fmt.Errorf("npmrc: fsync temp: %w", err) + } + tmpInfo, err := tmp.Stat() + if err != nil { + _ = tmp.Close() + return commitOutcome{}, fmt.Errorf("npmrc: stat temp: %w", err) + } + if err := tmp.Close(); err != nil { + return commitOutcome{}, fmt.Errorf("npmrc: close temp: %w", err) + } + + if err := rt.child.Rename(tmpName, rt.base); err != nil { + return commitOutcome{}, fmt.Errorf("npmrc: rename into place: %w", err) + } + cleanupTmp = false // the temp name no longer exists after a successful rename + + // Re-verify the just-written leaf is the file we renamed. From here the rename + // has landed, so a failure reports renamed=true: the caller must restore, not + // assume disk is untouched. + li, err := rt.child.Lstat(rt.base) + if err != nil { + return commitOutcome{renamed: true}, fmt.Errorf("npmrc: re-lstat after rename: %w", err) + } + if li.Mode()&fs.ModeSymlink != 0 || !li.Mode().IsRegular() || !os.SameFile(li, tmpInfo) { + return commitOutcome{renamed: true}, fmt.Errorf("npmrc: leaf identity changed across rename: %w", ErrTargetUnusable) + } + w.syncDir(rt) + return commitOutcome{committed: li, renamed: true}, nil +} + +// createExclusive opens a uniquely named file beside the leaf with +// O_CREATE|O_EXCL — the one open mode os.Root never resolves through a symlink, +// which is what makes a pre-planted file at the name harmless. The random middle +// keeps the name unpredictable; prefix and suffix let callers distinguish temp +// files (`.dmg-tmp-`) from committed backups (`.dmg-.bak`). +func (w *NPMRCWriter) createExclusive(rt *resolvedTarget, prefix, suffix string) (*os.File, string, error) { + for attempt := 0; attempt < 8; attempt++ { + mid, err := randomSuffix() + if err != nil { + return nil, "", fmt.Errorf("npmrc: random suffix: %w", err) + } + name := prefix + mid + suffix + f, err := rt.child.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, npmrcFileMode) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return nil, "", fmt.Errorf("npmrc: create %q: %w", name, err) + } + return f, name, nil + } + return nil, "", errors.New("npmrc: could not create a unique temp file") +} + +// applyMetadata sets mode and owner on an open handle. Both are POSIX-only: +// Windows inherits ACLs and asserts no POSIX mode, so this is a no-op there. +func (w *NPMRCWriter) applyMetadata(f *os.File, mode os.FileMode) error { + if !enforcePOSIXMetadata { + return nil + } + if err := f.Chmod(mode); err != nil { + return fmt.Errorf("npmrc: fchmod: %w", err) + } + if err := chownHandle(f, w.uid, w.gid); err != nil { + return fmt.Errorf("npmrc: fchown: %w", err) + } + return nil +} + +// syncDir best-effort fsyncs the resolved parent directory so the rename is +// durable. A failure here never fails the write. +func (w *NPMRCWriter) syncDir(rt *resolvedTarget) { + d, err := rt.child.Open(".") + if err != nil { + return + } + _ = d.Sync() + _ = d.Close() +} + +// readbackBody re-reads the leaf and extracts the managed block body. +func (w *NPMRCWriter) readbackBody(rt *resolvedTarget) (string, error) { + data, existed, _, err := w.readCurrent(rt) + if err != nil { + return "", err + } + if !existed { + return "", nil + } + body, _ := extractManagedBody(string(data)) + return body, nil +} + +// --------------------------------------------------------------------------- +// Backups +// --------------------------------------------------------------------------- + +// backup copies the pre-rewrite bytes into a uniquely named, 0600, +// target-user-owned sibling and prunes the set to the newest npmrcMaxBackups. +// The first backup is the pre-policy file (no token); every later one is +// token-bearing, so it carries the same 0600/ownership as the live file. The +// prune is best-effort — a transient extra backup is the same exposure class as +// the file itself and is not worth failing enforcement over. +func (w *NPMRCWriter) backup(rt *resolvedTarget, data []byte) error { + f, name, err := w.createExclusive(rt, rt.base+".dmg-", ".bak") + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + _ = rt.child.Remove(name) + return fmt.Errorf("npmrc: write backup: %w", err) + } + if err := w.applyMetadata(f, npmrcFileMode); err != nil { + _ = f.Close() + _ = rt.child.Remove(name) + return err + } + if err := f.Close(); err != nil { + return fmt.Errorf("npmrc: close backup: %w", err) + } + w.rotateBackups(rt) + return nil +} + +// rotateBackups prunes backups beside the leaf down to the newest npmrcMaxBackups, +// matching basenames only. Committed backups are ".dmg-.bak"; in-flight +// temp files (".dmg-tmp-") carry no ".bak" suffix and are excluded. +// +// The directory is read in bounded batches and pruning happens as candidates are +// seen, so a home stuffed with millions of pattern-matching entries cannot force +// the whole listing into memory: at most one batch plus npmrcMaxBackups names are +// ever held. Only already-returned entries are removed mid-iteration (the current +// candidate or one kept from an earlier batch), which is safe for directory +// enumeration. +func (w *NPMRCWriter) rotateBackups(rt *resolvedTarget) { + d, err := rt.child.Open(".") + if err != nil { + w.log("npmrc: backup rotation open dir failed: %v", err) + return + } + defer d.Close() + + prefix := rt.base + ".dmg-" + tmpPrefix := rt.base + ".dmg-tmp-" + + type backupFile struct { + name string + mtime int64 + } + kept := make([]backupFile, 0, npmrcMaxBackups) // ascending mtime; kept[0] is oldest + remove := func(name string) { + if err := rt.child.Remove(name); err != nil { + w.log("npmrc: prune backup %q failed: %v", name, err) + } + } + insert := func(bf backupFile) { + i := sort.Search(len(kept), func(i int) bool { return kept[i].mtime > bf.mtime }) + kept = append(kept, backupFile{}) + copy(kept[i+1:], kept[i:]) + kept[i] = bf + } + + for { + entries, rerr := d.ReadDir(256) + for _, e := range entries { + name := e.Name() + if name == "" || strings.ContainsRune(name, filepath.Separator) { + continue + } + if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".bak") { + continue + } + if strings.HasPrefix(name, tmpPrefix) { + continue + } + li, lerr := rt.child.Lstat(name) + if lerr != nil || !li.Mode().IsRegular() { + continue + } + bf := backupFile{name: name, mtime: li.ModTime().UnixNano()} + if len(kept) < npmrcMaxBackups { + insert(bf) + continue + } + if bf.mtime <= kept[0].mtime { + remove(bf.name) // older than everything kept + continue + } + remove(kept[0].name) // evict the oldest kept, then keep this newer one + copy(kept, kept[1:]) + kept = kept[:len(kept)-1] + insert(bf) + } + if rerr == io.EOF { + break + } + if rerr != nil { + w.log("npmrc: backup rotation readdir failed: %v", rerr) + break + } + } +} + +func randomSuffix() (string, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +// --------------------------------------------------------------------------- +// Content transforms (rewrite / clear) and the INI classifier +// --------------------------------------------------------------------------- + +// rewriteContent produces the new file bytes from the current bytes and the +// rendered block body: strip any existing managed block, fail closed on an INI +// section header, comment out active bare `registry=` lines, and append a fresh +// block at the very bottom on its own line. Preserves all other bytes exactly. +func (w *NPMRCWriter) rewriteContent(current []byte, body string) ([]byte, error) { + rest, bom := stripBOM(current) + if hasLoneCR(string(rest)) { + return nil, fmt.Errorf("npmrc: file contains a bare CR npm would treat as a line break; cannot safely transform: %w", ErrTargetUnusable) + } + lines := strings.Split(string(rest), "\n") + + lines, strippedToEOF := stripManagedBlock(lines) + if strippedToEOF { + w.log("npmrc: managed block had no END marker; stripped to EOF and rewriting") + } + if containsSection(lines) { + // An INI section header scopes every following key to section.key, which + // npm ignores — our appended block would be inert while a line-based + // check reported it applied. There is no way to close a section, so the + // only safe outcome is to refuse. + return nil, fmt.Errorf("npmrc: file contains an INI [section] header; cannot safely append: %w", ErrTargetUnusable) + } + if hasCoercibleQuotedKey(lines) { + return nil, fmt.Errorf("npmrc: file has a quoted key npm would coerce from non-string JSON; cannot safely transform: %w", ErrTargetUnusable) + } + lines = commentBareRegistry(lines) + + base := strings.Join(lines, "\n") + var buf bytes.Buffer + buf.Write(bom) + buf.WriteString(base) + if len(base) > 0 && !strings.HasSuffix(base, "\n") { + // Start the block on a fresh line, but never squash a pre-existing + // trailing newline (blank lines are content and are preserved). + buf.WriteByte('\n') + } + buf.WriteString(npmrcBeginMarker) + buf.WriteByte('\n') + buf.WriteString(body) + buf.WriteByte('\n') + buf.WriteString(npmrcEndMarker) + buf.WriteByte('\n') + return buf.Bytes(), nil +} + +// clearContent removes the managed block and un-comments only our own +// `# [stepsecurity-dmg] ` lines. It never touches the MDM script's +// `# [stepsecurity] ` lines and never deletes the file. The one permitted byte +// deviation from "restore the world" is that a missing original final newline +// is not restored — the remainder keeps the newline enforce added before the +// block. +func (w *NPMRCWriter) clearContent(current []byte) []byte { + rest, bom := stripBOM(current) + lines := strings.Split(string(rest), "\n") + lines, _ = stripManagedBlock(lines) + lines = unprefixDMG(lines) + var buf bytes.Buffer + buf.Write(bom) + buf.WriteString(strings.Join(lines, "\n")) + return buf.Bytes() +} + +// stripBOM splits a leading UTF-8 BOM off the content. The BOM is removed for +// parsing (so a first-line key is matched correctly) and re-prepended on +// rewrite so the byte is preserved. +func stripBOM(b []byte) (rest, bom []byte) { + const bomSeq = "\ufeff" + if bytes.HasPrefix(b, []byte(bomSeq)) { + return b[len(bomSeq):], []byte(bomSeq) + } + return b, nil +} + +// stripManagedBlock removes EVERY managed block (each BEGIN marker through its +// matching END, inclusive), not just the first. A BEGIN with no matching END +// anywhere after it is a truncated block and is stripped to EOF, reported via the +// returned flag. Removing all blocks is what makes offboarding revoke every +// token: a duplicated block — a user copy, or a partial prior write — must never +// survive a clear still carrying a live token, and must never make a rewrite +// oscillate forever between one block and two. +func stripManagedBlock(lines []string) ([]string, bool) { + out := make([]string, 0, len(lines)) + strippedToEOF := false + for i := 0; i < len(lines); { + if !isMarkerLine(lines[i], npmrcBeginMarker) { + out = append(out, lines[i]) + i++ + continue + } + end := -1 + for j := i + 1; j < len(lines); j++ { + if isMarkerLine(lines[j], npmrcEndMarker) { + end = j + break + } + } + if end < 0 { + // Truncated block: no END exists past this BEGIN. Drop it to EOF so no + // partial token lingers; bytes past a genuine truncation are not + // recoverable structure. + strippedToEOF = true + break + } + i = end + 1 + } + return out, strippedToEOF +} + +// isMarkerLine matches a marker tolerantly of surrounding whitespace and a +// trailing CR, so a marker survives being read back from a CRLF file. +func isMarkerLine(line, marker string) bool { + return strings.TrimSpace(line) == marker +} + +// containsSection reports whether any line is an INI section header. +func containsSection(lines []string) bool { + for _, l := range lines { + if isSectionLine(l) { + return true + } + } + return false +} + +// hasLoneCR reports whether s contains a bare carriage return — a '\r' not +// immediately followed by '\n'. npm's INI parser splits logical lines on '\r\n', +// '\n', AND a lone '\r', so a bare CR begins a new line for npm that our +// '\n'-only split does not see. That split mismatch is exploitable: `[global]\r` +// is a section header to npm (scoping, and thus nullifying, our appended block) +// but one opaque line to us, and `k=v\rregistry=evil` hides an overriding +// registry from us while npm honors it. We cannot safely transform such a file, +// so the enforce/convergence/probe paths treat a bare CR the way they treat an +// INI section: fail closed. A CRLF ('\r\n') file is NOT a lone CR and still +// round-trips through the '\n' split with its trailing '\r' preserved. +func hasLoneCR(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == '\r' && (i+1 >= len(s) || s[i+1] != '\n') { + return true + } + } + return false +} + +// hasCoercibleQuotedKey reports whether any active line's key is a single-quoted +// token whose inner text npm's unsafe() JSON-parses to a NON-string value. npm +// strips the single quotes, JSON-parses the bare inner, and then coerces the +// result to a string when it is used as a config key — a single-element array +// like `'["registry"]'` becomes the JS array ["registry"], which coerces to the +// key `registry`, forging an override. Replicating JS's String() coercion for +// every shape (arrays join, objects → "[object Object]", numbers, bools) is +// fragile, and our own keys are never quoted, so we fail closed on any such line +// the same way we do on an INI section or a bare CR. (A double-quoted token is +// itself a JSON string literal, so it always decodes to a string — jsonDecodeString +// already handles it — and is not coercible-non-string.) +func hasCoercibleQuotedKey(lines []string) bool { + for _, l := range lines { + if isCommentLine(l) || isSectionLine(l) { + continue + } + i := strings.IndexByte(l, '=') + if i < 0 { + continue + } + if quotedNonStringInner(strings.TrimSpace(l[:i])) { + return true + } + } + return false +} + +// quotedNonStringInner reports whether s is a single-quoted token whose bare inner +// is valid JSON that is NOT a string (array, object, number, bool, null) — the +// shape npm coerces to a string key. A parse failure (e.g. the bare word in +// `'registry'`) or a JSON string is not coercible-non-string and is left to the +// normal npmUnsafe path. +func quotedNonStringInner(s string) bool { + if len(s) < 2 || s[0] != '\'' || s[len(s)-1] != '\'' { + return false + } + var v any + if err := json.Unmarshal([]byte(s[1:len(s)-1]), &v); err != nil { + return false + } + _, isStr := v.(string) + return !isStr +} + +// commentBareRegistry prefixes every active bare `registry=` line with the DMG +// prefix, preserving the original (including any trailing CR) after the prefix. +// Scoped `@scope:registry=` lines, token lines, cooldown keys, env-ref lines, +// and every comment are left untouched. +func commentBareRegistry(lines []string) []string { + out := make([]string, len(lines)) + for i, l := range lines { + if key, _, ok := activeKV(l); ok && key == "registry" { + out[i] = npmrcDMGPrefix + l + continue + } + out[i] = l + } + return out +} + +// unprefixDMG restores lines the writer previously commented out, removing only +// an exact leading DMG prefix. +func unprefixDMG(lines []string) []string { + out := make([]string, len(lines)) + for i, l := range lines { + if strings.HasPrefix(l, npmrcDMGPrefix) { + out[i] = l[len(npmrcDMGPrefix):] + continue + } + out[i] = l + } + return out +} + +// isCommentLine reports whether a line is an npm INI comment (first non-space +// rune is '#' or ';') or blank. +func isCommentLine(line string) bool { + t := strings.TrimLeft(line, " \t") + if t == "" { + return true + } + return t[0] == '#' || t[0] == ';' +} + +// isSectionLine reports whether a line is an INI section header `[...]`. +func isSectionLine(line string) bool { + t := strings.TrimSpace(line) + return len(t) >= 2 && t[0] == '[' && t[len(t)-1] == ']' +} + +// activeKV parses an active (uncommented, non-section) key=value line the way +// npm's INI parser does: split on the FIRST '=', then run BOTH sides through +// npmUnsafe — npm's own key/value normalization (trim, unquote a fully quoted +// token, or strip an unescaped inline ';'/'#' comment). ok is false for comments, +// sections, and lines with no '=' or an empty key. This one classifier backs +// every key-matching path (comment-out, clear, probe precedence, convergence); +// parsing keys exactly as npm does is what keeps a disguised override like +// `registry#x=evil` or `"registry"=evil` — both of which npm reads as the key +// `registry` — from slipping past the precedence checks as an unrecognized key, +// and a spaced form like `registry = https://evil/` from being mistaken for inert. +func activeKV(line string) (key, value string, ok bool) { + if isCommentLine(line) || isSectionLine(line) { + return "", "", false + } + i := strings.IndexByte(line, '=') + if i < 0 { + return "", "", false + } + key = npmUnsafe(line[:i]) + if key == "" { + return "", "", false + } + value = npmUnsafe(line[i+1:]) + return key, value, true +} + +// npmUnsafe mirrors the npm `ini` package's unsafe(): the normalization npm +// applies to BOTH the key and the value of every line before storing it. A fully +// quoted token is unquoted (one layer); otherwise everything from the first +// UNESCAPED ';' or '#' is dropped as an inline comment and '\;', '\#', '\\' +// escapes are resolved (any other '\x' is kept verbatim). Our classifier must +// match npm here or it fails to recognize a disguised override: npm reads +// `registry#x=evil` as key `registry` and `"registry"=evil` as key `registry`, so +// a naive first-'=' split keeping `registry#x` / `"registry"` would let a later +// poisoned line defeat last-wins while Converged/ProbeExpected still reported +// compliant. Every key and value this writer itself renders is drawn from a +// comment-, quote-, and backslash-free alphabet, so this is the identity function +// on our own content. +func npmUnsafe(s string) string { + s = strings.TrimSpace(s) + if inner, ok := unquoteININToken(s); ok { + return inner + } + var b strings.Builder + b.Grow(len(s)) + esc := false + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case esc: + if c == '\\' || c == ';' || c == '#' { + b.WriteByte(c) + } else { + b.WriteByte('\\') + b.WriteByte(c) + } + esc = false + case c == ';' || c == '#': + return strings.TrimSpace(b.String()) + case c == '\\': + esc = true + default: + b.WriteByte(c) + } + } + if esc { + b.WriteByte('\\') + } + return strings.TrimSpace(b.String()) +} + +// unquoteININToken mirrors npm ini unsafe()'s quoted branch, which JSON-parses a +// quoted token rather than merely stripping the quotes. A double-quoted token is +// JSON-decoded whole, so string escapes resolve — `"registry"` decodes to +// `registry`, an active override we must recognize — and falls back to the +// ORIGINAL quoted string when it is not a valid JSON string (npm keeps the quoted +// form on a JSON.parse failure). A single-quoted token has its quotes stripped +// and the inside JSON-decoded only if that inside is itself a valid JSON string, +// else kept verbatim. ok is false when s is not fully quoted, so the caller falls +// through to inline-comment handling. Merely trimming the quotes (the previous +// behavior) would read `"registry"` as the key `registry` and miss the +// override npm honors as `registry`. +func unquoteININToken(s string) (string, bool) { + if len(s) < 2 { + return "", false + } + if s[0] == '"' && s[len(s)-1] == '"' { + if v, ok := jsonDecodeString(s); ok { + return v, true + } + return s, true // npm keeps the quoted form when JSON.parse fails + } + if s[0] == '\'' && s[len(s)-1] == '\'' { + inner := s[1 : len(s)-1] + if v, ok := jsonDecodeString(inner); ok { + return v, true + } + return inner, true + } + return "", false +} + +// jsonDecodeString reports whether s is a valid JSON string literal and, if so, +// its decoded value — the string half of npm's JSON.parse(). A non-string JSON +// value (number, object, bool) or a parse error yields ok=false. +func jsonDecodeString(s string) (string, bool) { + var v string + if err := json.Unmarshal([]byte(s), &v); err != nil { + return "", false + } + return v, true +} + +// extractManagedBody returns the canonical body between our markers (the two +// content lines, '\n'-joined, no markers) and whether a well-formed block is +// present. A BEGIN with no END yields present=false. +func extractManagedBody(content string) (string, bool) { + rest, _ := stripBOM([]byte(content)) + lines := strings.Split(string(rest), "\n") + begin := -1 + for i, l := range lines { + if isMarkerLine(l, npmrcBeginMarker) { + begin = i + break + } + } + if begin < 0 { + return "", false + } + end := -1 + for i := begin + 1; i < len(lines); i++ { + if isMarkerLine(lines[i], npmrcEndMarker) { + end = i + break + } + } + if end < 0 { + return "", false + } + body := make([]string, 0, end-begin-1) + for _, l := range lines[begin+1 : end] { + body = append(body, strings.TrimRight(l, "\r")) + } + return strings.Join(body, "\n"), true +} + +// --------------------------------------------------------------------------- +// Convergence +// --------------------------------------------------------------------------- + +// Converged reports whether the file already reflects the desired block with no +// further work needed. It is stronger than block-body equality: the block must +// be present with body == expected, effective (nothing active overrides its +// registry/token after it, END marker intact, no displaced duplicate), and +// carry sane metadata (0600, target-user-owned on POSIX). A `registry=` line +// appended below an unchanged block (e.g. `aws codeartifact login`) leaves the +// body equal but defeats precedence — so body equality alone would report +// converged forever without ever re-running the transform. +func (w *NPMRCWriter) Converged(expected string) (bool, error) { + rt, err := w.resolveLeaf() + if err != nil { + return false, err + } + defer rt.close() + + data, existed, mode, err := w.readCurrent(rt) + if err != nil { + return false, err + } + if !existed { + return false, nil + } + + rest, _ := stripBOM(data) + if hasLoneCR(string(rest)) { + // A bare CR is a line break to npm but not to our '\n' split, so a section or + // overriding line could hide behind it — the block would look present and + // effective to us yet be scoped-out or overridden for npm. Fail closed, the + // same refusal the rewrite path makes. + return false, fmt.Errorf("npmrc: file contains a bare CR npm treats as a line break; managed block cannot be verified: %w", ErrTargetUnusable) + } + lines := strings.Split(string(rest), "\n") + if containsSection(lines) { + // An INI [section] header scopes our registry/token keys to section.key, + // which npm ignores for the global registry: the block would be present and + // body-equal yet inert, so reporting it converged would loop on a false + // 'compliant'. Fail closed — the same refusal the rewrite path makes — so + // enforce classifies it write_failed rather than silently accepting it. + return false, fmt.Errorf("npmrc: file contains an INI [section] header; managed block cannot be effective: %w", ErrTargetUnusable) + } + if hasCoercibleQuotedKey(lines) { + // A single-quoted key npm coerces from non-string JSON (e.g. '["registry"]') + // could override our registry/token invisibly to a line-based check. Fail + // closed, the same refusal the rewrite path makes. + return false, fmt.Errorf("npmrc: file has a quoted key npm would coerce from non-string JSON; managed block cannot be verified: %w", ErrTargetUnusable) + } + + body, present := extractManagedBody(string(data)) + if !present || body != expected { + return false, nil + } + + if countMarker(lines, npmrcBeginMarker) != 1 || countMarker(lines, npmrcEndMarker) != 1 { + // A duplicate or displaced block: converge by rewriting. + return false, nil + } + if !blockIsLastEffective(lines, expected) { + return false, nil + } + + if enforcePOSIXMetadata && mode.Perm() != npmrcFileMode { + return false, nil + } + // Ownership is not re-checked here: readCurrent already required the resolved + // leaf to be owned by the target user, from the same identity-verified handle + // it read the content through. A second open to re-read the owner would race + // the very content check it is meant to corroborate. + return true, nil +} + +// blockIsLastEffective reports whether, after our block, no active line +// overrides the block's registry or token — i.e. the block's own keys are the +// last-wins values for the file. +func blockIsLastEffective(lines []string, expected string) bool { + expReg, expTokKey, expTokVal, ok := parseExpected(expected) + if !ok { + return false + } + endIdx := -1 + for i, l := range lines { + if isMarkerLine(l, npmrcEndMarker) { + endIdx = i + } + } + if endIdx < 0 { + return false + } + for _, l := range lines[endIdx+1:] { + key, val, ok := activeKV(l) + if !ok { + continue + } + if key == "registry" && val != expReg { + return false + } + if key == expTokKey && val != expTokVal { + return false + } + } + return true +} + +func countMarker(lines []string, marker string) int { + n := 0 + for _, l := range lines { + if isMarkerLine(l, marker) { + n++ + } + } + return n +} + +// --------------------------------------------------------------------------- +// MDM probe +// --------------------------------------------------------------------------- + +// ProbeExpected reports whether the MDM lane has actually achieved the current +// desired state for this device — not merely that an MDM marker exists. Because +// ~/.npmrc is user-writable (unlike the privileged VS Code policy locations), +// trusting a marker alone would let a user pin permanent mdm_managed while +// pointing npm anywhere. Managed requires all of: the MDM marker outside our +// block, the MDM block's own registry/token lines equal to the expected +// rendered content, those keys effective (last-wins) with nothing overriding +// them, and sane metadata (0600, target-user-owned on POSIX). +func (w *NPMRCWriter) ProbeExpected(expected string) (bool, string) { + rt, err := w.resolveLeaf() + if err != nil { + return false, "" + } + defer rt.close() + + data, existed, mode, err := w.readCurrent(rt) + if err != nil || !existed { + return false, "" + } + + managed, detail := probeNPMRCContent(string(data), expected) + if !managed { + return false, "" + } + if enforcePOSIXMetadata && mode.Perm() != npmrcFileMode { + return false, "" + } + // Ownership is already enforced by readCurrent's checkOwner on the same + // identity-verified handle; re-opening to re-read the owner would race the + // content probe above. + return true, detail +} + +// probeNPMRCContent is the pure content logic behind ProbeExpected. It takes the +// whole file and the expected rendered body and reports whether the MDM lane +// owns an effective, current block. +func probeNPMRCContent(content, expected string) (bool, string) { + expReg, expTokKey, expTokVal, ok := parseExpected(expected) + if !ok { + return false, "" + } + rest, _ := stripBOM([]byte(content)) + if hasLoneCR(string(rest)) { + // A bare CR hides a section/override from our '\n' split; a marker plus + // matching lines is then not proof the MDM lane governs npm. Fail closed. + return false, "" + } + lines := strings.Split(string(rest), "\n") + + if containsSection(lines) { + // A section scopes every following key to section.key; npm then ignores the + // MDM block's registry/token for the global registry, so a marker plus + // matching lines under a section is NOT proof the MDM lane governs npm. Fail + // closed (not managed) — enforce then refuses the sectioned file too. + return false, "" + } + if hasCoercibleQuotedKey(lines) { + // A single-quoted key npm coerces from non-string JSON could override the + // registry/token below the MDM block invisibly to the precedence loop. A + // marker plus matching lines is then not proof; fail closed (not managed). + return false, "" + } + + // Our own block boundaries, so the MDM marker search can exclude it (a user + // planting the marker inside our block must not count). + ourBegin, ourEnd := managedBlockBounds(lines) + + mdmIdx := -1 + for i, l := range lines { + if i >= ourBegin && i <= ourEnd { + continue + } + if isMarkerLine(l, npmrcMDMMarker) { + mdmIdx = i + break + } + } + if mdmIdx < 0 { + return false, "" + } + + // The MDM block's own lines (contiguous config after its header, stopping at + // a blank line, our block, or a section) must carry the expected content. + mdmReg, mdmTok := false, false + for i := mdmIdx + 1; i < len(lines); i++ { + if i >= ourBegin && i <= ourEnd { + break + } + l := lines[i] + if strings.TrimSpace(l) == "" || isSectionLine(l) { + break + } + key, val, ok := activeKV(l) + if !ok { + continue + } + if key == "registry" && val == expReg { + mdmReg = true + } + if key == expTokKey && val == expTokVal { + mdmTok = true + } + } + if !mdmReg || !mdmTok { + return false, "" + } + + // Effective precedence: the last active registry and token in the whole + // file must be the expected ones. A later override (poisoned token, bare + // registry) defeats this and we enforce instead. + lastReg, lastRegOK := "", false + lastTok, lastTokOK := "", false + for _, l := range lines { + key, val, ok := activeKV(l) + if !ok { + continue + } + if key == "registry" { + lastReg, lastRegOK = val, true + } + if key == expTokKey { + lastTok, lastTokOK = val, true + } + } + if !lastRegOK || lastReg != expReg || !lastTokOK || lastTok != expTokVal { + return false, "" + } + return true, "mdm-managed npmrc block present and effective" +} + +// managedBlockBounds returns the [begin, end] line indices of our block, or +// (len, -1) when absent (so the "i >= begin && i <= end" exclusion is empty). +func managedBlockBounds(lines []string) (int, int) { + begin := -1 + for i, l := range lines { + if isMarkerLine(l, npmrcBeginMarker) { + begin = i + break + } + } + if begin < 0 { + return len(lines), -1 + } + for i := begin + 1; i < len(lines); i++ { + if isMarkerLine(lines[i], npmrcEndMarker) { + return begin, i + } + } + return begin, len(lines) - 1 +} + +// parseExpected splits the rendered body (two content lines) into the registry +// value, the token key, and the token value used by the precedence checks. +func parseExpected(expected string) (registry, tokenKey, tokenVal string, ok bool) { + lines := strings.Split(expected, "\n") + if len(lines) != 2 { + return "", "", "", false + } + rk, rv, rok := activeKV(lines[0]) + tk, tv, tok := activeKV(lines[1]) + if !rok || rk != "registry" || !tok { + return "", "", "", false + } + return rv, tk, tv, true +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +// npmPolicy is the run-config policy payload for the npm ecosystem. +type npmPolicy struct { + Ecosystem string `json:"ecosystem"` + RegistryURL string `json:"registry_url"` + Auth struct { + Scheme string `json:"scheme"` + APIKey string `json:"api_key"` + } `json:"auth"` +} + +// RenderNPMRCBlock validates a policy and returns the two content lines the +// writer wraps in its markers: the `registry=` line and the `//host/path/:_authToken=` +// line, '\n'-joined with no markers and no trailing newline. It fully validates +// the policy (the HTTP layer only checks "is a JSON object"): the token line's +// host and path derive from registry_url, and the composed device token is +// `::dev:`. Any validation failure returns an error the +// reconciler reports as policy_not_applied; error messages never echo the key +// or the policy. +func RenderNPMRCBlock(policy json.RawMessage, serial string) (string, error) { + var p npmPolicy + if err := json.Unmarshal(policy, &p); err != nil { + return "", errors.New("npmrc: policy is not a well-formed npm policy object") + } + if p.Ecosystem != "npm" { + return "", errors.New("npmrc: policy ecosystem is not npm") + } + if p.Auth.Scheme != "stepsecurity_device_token" { + return "", errors.New("npmrc: unsupported auth scheme") + } + + key := p.Auth.APIKey + if key == "" { + return "", errors.New("npmrc: policy api_key is empty") + } + if len(key) > npmrcMaxKeyBytes { + return "", errors.New("npmrc: policy api_key too long") + } + if !isNPMSafe(key) { + return "", errors.New("npmrc: policy api_key contains unsupported characters") + } + if serial == "" { + return "", errors.New("npmrc: device serial is empty") + } + if len(serial) > npmrcMaxSerialBytes { + return "", errors.New("npmrc: device serial too long") + } + if !isNPMSafe(serial) { + return "", errors.New("npmrc: device serial contains unsupported characters") + } + + host, path, err := validateRegistryURL(p.RegistryURL) + if err != nil { + return "", err + } + + token := key + "::dev:" + serial + // npm's _authToken key is `//host/path/:_authToken` with a trailing slash + // before the colon. + tokenKey := "//" + host + path + "/:_authToken" + body := "registry=" + p.RegistryURL + "\n" + tokenKey + "=" + token + if len(body) > npmrcMaxRenderedBytes { + return "", errors.New("npmrc: rendered block exceeds size limit") + } + return body, nil +} + +// validateRegistryURL requires an HTTPS URL with no userinfo, query, fragment, +// or port; a valid lowercase RFC 1123 host; and an exact `/javascript` path. It +// returns the host and path used to compose the token key. +func validateRegistryURL(raw string) (host, path string, err error) { + if raw == "" { + return "", "", errors.New("npmrc: policy registry_url is empty") + } + if hasControlBytes(raw) { + return "", "", errors.New("npmrc: policy registry_url contains control characters") + } + // Reject '#' and '?' in the raw string. url.Parse turns a trailing bare '#' + // into an empty Fragment (there is no ForceFragment to catch it the way + // ForceQuery catches a bare '?'), so `.../javascript#` would otherwise slip + // through the Fragment check below and land verbatim in the rendered + // `registry=` line — where an npm INI parser could read '#' as a mid-value + // comment and silently fall back to the default registry. + if strings.ContainsAny(raw, "#?") { + return "", "", errors.New("npmrc: policy registry_url must not contain '#' or '?'") + } + u, perr := url.Parse(raw) + if perr != nil { + return "", "", errors.New("npmrc: policy registry_url is not a valid URL") + } + if u.Scheme != "https" { + return "", "", errors.New("npmrc: policy registry_url must be https") + } + if u.User != nil { + return "", "", errors.New("npmrc: policy registry_url must not contain userinfo") + } + if u.RawQuery != "" || u.ForceQuery { + return "", "", errors.New("npmrc: policy registry_url must not contain a query") + } + if u.Fragment != "" { + return "", "", errors.New("npmrc: policy registry_url must not contain a fragment") + } + if u.Port() != "" { + return "", "", errors.New("npmrc: policy registry_url must not contain a port") + } + host = u.Hostname() + if !isValidHost(host) { + return "", "", errors.New("npmrc: policy registry_url host is not a valid hostname") + } + if u.EscapedPath() != "/javascript" { + return "", "", errors.New("npmrc: policy registry_url path must be /javascript") + } + return host, "/javascript", nil +} + +// isNPMSafe reports whether every byte is in the unquoted npm-INI-safe alphabet +// [A-Za-z0-9._:@/-]. Anything outside it — spaces, quotes, '#', ';', '=', +// '$', control bytes — is rejected rather than escaped; v1 defines no escaping. +func isNPMSafe(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'A' && c <= 'Z': + case c >= 'a' && c <= 'z': + case c >= '0' && c <= '9': + case c == '.' || c == '_' || c == ':' || c == '@' || c == '/' || c == '-': + default: + return false + } + } + return true +} + +func hasControlBytes(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return true + } + } + return false +} + +// isValidHost validates a lowercase RFC 1123 hostname. The grammar is checked, +// not an allowlist — dedicated instances use custom domains, so no base domain +// can be pinned agent-side. +func isValidHost(host string) bool { + if host == "" || len(host) > npmrcMaxHostBytes { + return false + } + labels := strings.Split(host, ".") + for _, label := range labels { + if label == "" || len(label) > 63 { + return false + } + for i := 0; i < len(label); i++ { + c := label[i] + switch { + case c >= 'a' && c <= 'z': + case c >= '0' && c <= '9': + case c == '-': + if i == 0 || i == len(label)-1 { + return false + } + default: + return false + } + } + } + return true +} diff --git a/internal/devicepolicy/npmrc_test.go b/internal/devicepolicy/npmrc_test.go new file mode 100644 index 0000000..e1531bb --- /dev/null +++ b/internal/devicepolicy/npmrc_test.go @@ -0,0 +1,599 @@ +package devicepolicy + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// Standard fixture policy + serial shared across the pure-layer tests. +const ( + stdSerial = "SERIAL123" + stdPolicyJSON = `{"ecosystem":"npm","registry_url":"https://registry-int.stepsecurity.io/javascript","auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"}}` + stdBody = "registry=https://registry-int.stepsecurity.io/javascript\n//registry-int.stepsecurity.io/javascript/:_authToken=ssabc123::dev:SERIAL123" + stdRegistry = "https://registry-int.stepsecurity.io/javascript" + stdTokenKey = "//registry-int.stepsecurity.io/javascript/:_authToken" + stdTokenVal = "ssabc123::dev:SERIAL123" +) + +// block wraps a rendered body in the managed markers exactly as the writer does. +func block(body string) string { + return npmrcBeginMarker + "\n" + body + "\n" + npmrcEndMarker + "\n" +} + +// --------------------------------------------------------------------------- +// RenderNPMRCBlock — validation table +// --------------------------------------------------------------------------- + +func TestRenderNPMRCBlock_Valid(t *testing.T) { + got, err := RenderNPMRCBlock(json.RawMessage(stdPolicyJSON), stdSerial) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != stdBody { + t.Fatalf("rendered body mismatch:\n got: %q\nwant: %q", got, stdBody) + } + // The rendered body is exactly two content lines, no markers, no trailing + // newline. + if strings.Contains(got, npmrcBeginMarker) || strings.Contains(got, npmrcEndMarker) { + t.Fatalf("rendered body must not contain markers: %q", got) + } + if strings.HasSuffix(got, "\n") { + t.Fatalf("rendered body must not end in a newline: %q", got) + } + if lines := strings.Split(got, "\n"); len(lines) != 2 { + t.Fatalf("rendered body must be two lines, got %d", len(lines)) + } +} + +func TestRenderNPMRCBlock_Rejections(t *testing.T) { + base := func(mut func(m map[string]any)) json.RawMessage { + m := map[string]any{ + "ecosystem": "npm", + "registry_url": stdRegistry, + "auth": map[string]any{ + "scheme": "stepsecurity_device_token", + "api_key": "ssabc123", + }, + } + if mut != nil { + mut(m) + } + b, _ := json.Marshal(m) + return b + } + setAuth := func(m map[string]any, k string, v any) { + m["auth"].(map[string]any)[k] = v + } + + cases := []struct { + name string + policy json.RawMessage + serial string + }{ + {"not-an-object", json.RawMessage(`["nope"]`), stdSerial}, + {"wrong-ecosystem", base(func(m map[string]any) { m["ecosystem"] = "pip" }), stdSerial}, + {"wrong-scheme", base(func(m map[string]any) { setAuth(m, "scheme", "basic") }), stdSerial}, + {"empty-key", base(func(m map[string]any) { setAuth(m, "api_key", "") }), stdSerial}, + {"oversize-key", base(func(m map[string]any) { setAuth(m, "api_key", strings.Repeat("a", 257)) }), stdSerial}, + {"unsafe-key-space", base(func(m map[string]any) { setAuth(m, "api_key", "ab cd") }), stdSerial}, + {"unsafe-key-hash", base(func(m map[string]any) { setAuth(m, "api_key", "ab#cd") }), stdSerial}, + {"unsafe-key-dollar", base(func(m map[string]any) { setAuth(m, "api_key", "${X}") }), stdSerial}, + {"unsafe-key-newline", base(func(m map[string]any) { setAuth(m, "api_key", "ab\ncd") }), stdSerial}, + {"empty-serial", base(nil), ""}, + {"oversize-serial", base(nil), strings.Repeat("s", 129)}, + {"unsafe-serial", base(nil), "ser ial"}, + {"empty-url", base(func(m map[string]any) { m["registry_url"] = "" }), stdSerial}, + {"http-url", base(func(m map[string]any) { m["registry_url"] = "http://registry-int.stepsecurity.io/javascript" }), stdSerial}, + {"url-with-userinfo", base(func(m map[string]any) { m["registry_url"] = "https://user:pw@registry-int.stepsecurity.io/javascript" }), stdSerial}, + {"url-with-query", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/javascript?x=1" }), stdSerial}, + {"url-with-fragment", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/javascript#f" }), stdSerial}, + {"url-bare-fragment", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/javascript#" }), stdSerial}, + {"url-bare-query", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/javascript?" }), stdSerial}, + {"url-control-byte", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/java\x00script" }), stdSerial}, + {"url-with-port", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io:8443/javascript" }), stdSerial}, + {"url-wrong-path", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/py" }), stdSerial}, + {"url-trailing-slash-path", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/javascript/" }), stdSerial}, + {"url-uppercase-host", base(func(m map[string]any) { m["registry_url"] = "https://Registry-Int.StepSecurity.io/javascript" }), stdSerial}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := RenderNPMRCBlock(tc.policy, tc.serial) + if err == nil { + t.Fatalf("expected rejection, got nil error") + } + // Error messages never echo the key material. + if strings.Contains(err.Error(), "ssabc123") || strings.Contains(err.Error(), "${X}") { + t.Fatalf("error message leaked key material: %v", err) + } + }) + } +} + +// --------------------------------------------------------------------------- +// rewriteContent — the §3 rewrite algorithm (pure []byte -> []byte) +// --------------------------------------------------------------------------- + +func rewrite(t *testing.T, current string) string { + t.Helper() + w := &NPMRCWriter{} + out, err := w.rewriteContent([]byte(current), stdBody) + if err != nil { + t.Fatalf("rewriteContent(%q): %v", current, err) + } + return string(out) +} + +func TestRewrite_Table(t *testing.T) { + cases := []struct { + name string + current string + want string + }{ + { + name: "empty file creates block only", // edge 1 (content) + current: "", + want: block(stdBody), + }, + { + name: "no registry lines appends block", // edge 2 + current: "cache=/tmp/x\n", + want: "cache=/tmp/x\n" + block(stdBody), + }, + { + name: "bare registry commented out", // edge 3 + current: "registry=https://registry.npmjs.org/\n", + want: "# [stepsecurity-dmg] registry=https://registry.npmjs.org/\n" + block(stdBody), + }, + { + name: "scoped registry / token / cooldown preserved", // edge 4 + current: "@acme:registry=https://acme.jfrog.io/\n//acme.jfrog.io/:_authToken=xyz\nmin-release-age=7\n", + want: "@acme:registry=https://acme.jfrog.io/\n//acme.jfrog.io/:_authToken=xyz\nmin-release-age=7\n" + block(stdBody), + }, + { + name: "already prefixed line not double prefixed", // edge 5 + current: "# [stepsecurity-dmg] registry=https://registry.npmjs.org/\n", + want: "# [stepsecurity-dmg] registry=https://registry.npmjs.org/\n" + block(stdBody), + }, + { + name: "registry appended below block is re-commented, block stays last", // edge 6 + current: block(stdBody) + "registry=https://evil/\n", + want: "# [stepsecurity-dmg] registry=https://evil/\n" + block(stdBody), + }, + { + name: "missing END stripped to EOF", // edge 10 + current: "foo\n" + npmrcBeginMarker + "\nregistry=stale\n", + want: "foo\n" + block(stdBody), + }, + { + name: "env-ref token line preserved", // edge 13 + current: "//host/:_authToken=${NPM_TOKEN}\n", + want: "//host/:_authToken=${NPM_TOKEN}\n" + block(stdBody), + }, + { + name: "no trailing newline gets one before block", // edge 34 + current: "foo", + want: "foo\n" + block(stdBody), + }, + { + name: "pre-existing blank lines preserved", // edge 34 + current: "foo\n\n\n", + want: "foo\n\n\n" + block(stdBody), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := rewrite(t, tc.current); got != tc.want { + t.Fatalf("rewrite mismatch:\n current: %q\n got: %q\n want: %q", tc.current, got, tc.want) + } + }) + } +} + +func TestRewrite_CRLFPreserved(t *testing.T) { // edge 11 + current := "cache=x\r\nregistry=y\r\n" + got := rewrite(t, current) + want := "cache=x\r\n# [stepsecurity-dmg] registry=y\r\n" + block(stdBody) + if got != want { + t.Fatalf("CRLF rewrite mismatch:\n got: %q\nwant: %q", got, want) + } +} + +func TestRewrite_BOMPreserved(t *testing.T) { // edge 38 + current := "\ufeff" + "registry=x\n" + got := rewrite(t, current) + if !strings.HasPrefix(got, "\ufeff") { + t.Fatalf("BOM not preserved at start: %q", got) + } + if !strings.Contains(got, "# [stepsecurity-dmg] registry=x\n") { + t.Fatalf("BOM file registry not commented (BOM must not glue to key): %q", got) + } + if strings.Count(got, "\ufeff") != 1 { + t.Fatalf("BOM should appear exactly once: %q", got) + } +} + +func TestRewrite_SectionFailsClosed(t *testing.T) { // edge 37 + w := &NPMRCWriter{} + _, err := w.rewriteContent([]byte("[global]\nregistry=x\n"), stdBody) + if err == nil { + t.Fatal("expected a section header to fail closed") + } + if !isTargetUnusable(err) { + t.Fatalf("section rewrite error should be ErrTargetUnusable, got %v", err) + } +} + +func TestStripManagedBlock_RemovesAllDuplicates(t *testing.T) { + // Two complete managed blocks (a user copy, or a partial prior write) must both + // be stripped — the pure guarantee behind offboarding revoking EVERY token and + // a rewrite never oscillating between one block and two. + two := block(stdBody) + block(stdBody) + lines := strings.Split(strings.TrimRight(two, "\n"), "\n") + out, toEOF := stripManagedBlock(lines) + if toEOF { + t.Fatal("two well-formed blocks must not report a truncated (EOF) strip") + } + for _, l := range out { + if isMarkerLine(l, npmrcBeginMarker) || isMarkerLine(l, npmrcEndMarker) { + t.Fatalf("a managed marker survived stripping all blocks: %q", out) + } + } +} + +func TestRewrite_CollapsesDuplicateBlocks(t *testing.T) { + // A file carrying two managed blocks rewrites to exactly one clean block — + // otherwise Converged's single-block requirement would loop forever. + got := rewrite(t, block(stdBody)+block(stdBody)) + if n := strings.Count(got, npmrcBeginMarker); n != 1 { + t.Fatalf("expected exactly one block after rewrite, got %d:\n%s", n, got) + } + if got != block(stdBody) { + t.Fatalf("rewrite of duplicate blocks = %q, want a single clean block %q", got, block(stdBody)) + } +} + +func TestRewrite_Idempotent(t *testing.T) { // edge 15 (content) + fixtures := []string{ + "", + "registry=https://registry.npmjs.org/\n", + "@acme:registry=https://acme.jfrog.io/\nmin-release-age=7\n", + "foo", + "foo\n\n\n", + "\ufeffregistry=x\n", + "cache=x\r\nregistry=y\r\n", + } + for _, f := range fixtures { + first := rewrite(t, f) + second := rewrite(t, first) + if first != second { + t.Fatalf("not idempotent for %q:\nfirst: %q\nsecond: %q", f, first, second) + } + } +} + +// --------------------------------------------------------------------------- +// clearContent — the §3 clear algorithm (pure []byte -> []byte) +// --------------------------------------------------------------------------- + +func clearOf(current string) string { + w := &NPMRCWriter{} + return string(w.clearContent([]byte(current))) +} + +func TestClear_RestoresAndPreserves(t *testing.T) { // edge 9 + // A file the writer previously converged: original registry commented, MDM + // line present, our block at the bottom. + current := "# [stepsecurity-dmg] registry=https://registry.npmjs.org/\n" + + "# [stepsecurity] registry=https://mdm/\n" + + block(stdBody) + got := clearOf(current) + want := "registry=https://registry.npmjs.org/\n# [stepsecurity] registry=https://mdm/\n" + if got != want { + t.Fatalf("clear mismatch:\n got: %q\nwant: %q", got, want) + } +} + +func TestClear_ShellOnlyBlockRemoved(t *testing.T) { // edge 24 + current := npmrcBeginMarker + "\n" + npmrcEndMarker + "\n" + if got := clearOf(current); got != "" { + t.Fatalf("shell-only block should clear to empty, got %q", got) + } +} + +func TestClear_NeverUnprefixesMDM(t *testing.T) { + current := "# [stepsecurity] registry=https://mdm/\n" + block(stdBody) + got := clearOf(current) + if !strings.Contains(got, "# [stepsecurity] registry=https://mdm/\n") { + t.Fatalf("clear must not un-comment the MDM lane's prefix: %q", got) + } +} + +func TestClear_MissingFinalNewlineNotRestored(t *testing.T) { // edge 34 (clear) + // Enforce turned "foo" (no trailing newline) into "foo\n". Clearing + // keeps the "\n" enforce added — the one permitted byte deviation. + enforced := rewrite(t, "foo") + got := clearOf(enforced) + if got != "foo\n" { + t.Fatalf("clear should leave %q, got %q", "foo\n", got) + } +} + +// --------------------------------------------------------------------------- +// INI classifier + shared-consumer behavior +// --------------------------------------------------------------------------- + +func TestActiveKV(t *testing.T) { + cases := []struct { + line string + key string + val string + ok bool + }{ + {"registry=https://x/", "registry", "https://x/", true}, + {"registry = https://x/", "registry", "https://x/", true}, // spaced + {"registry\t=\tx", "registry", "x", true}, // tabbed + {"@acme:registry=https://y/", "@acme:registry", "https://y/", true}, + {`always-auth="true"`, "always-auth", "true", true}, // quoted value + // npm parity: npm's unsafe() strips an unescaped inline ';'/'#' comment and + // unquotes a fully quoted token on BOTH key and value, so each of these is an + // active `registry` assignment to npm — and must be to us, or a disguised + // override slips past last-wins. + {"registry#ignored=https://evil/", "registry", "https://evil/", true}, + {`"registry"=https://evil/`, "registry", "https://evil/", true}, + {`'registry'=https://evil/`, "registry", "https://evil/", true}, + {`"a\qb"=v`, `"a\qb"`, "v", true}, // invalid JSON escape → npm keeps the quoted form + {"registry ; note=https://evil/", "registry", "https://evil/", true}, // comment in key portion + {"registry=https://evil/ # trailing", "registry", "https://evil/", true}, + {`registry\#x=v`, "registry#x", "v", true}, // escaped '#' is literal, not a comment + {"# registry=commented", "", "", false}, + {"; registry=commented", "", "", false}, + {" # indented comment", "", "", false}, + {"[section]", "", "", false}, + {"", "", "", false}, + {"noequalsline", "", "", false}, + {"=noKey", "", "", false}, + } + for _, tc := range cases { + key, val, ok := activeKV(tc.line) + if ok != tc.ok || key != tc.key || val != tc.val { + t.Fatalf("activeKV(%q) = (%q,%q,%v), want (%q,%q,%v)", tc.line, key, val, ok, tc.key, tc.val, tc.ok) + } + } +} + +func TestActiveKV_DoubleQuotedJSONEscapeDecodes(t *testing.T) { + // npm's ini unsafe() runs JSON.parse on a double-quoted token, so a \uXXXX + // escape resolves: the on-disk key `"registry"` (i = 'i') is the key + // `registry` to npm. Our classifier must JSON-decode it too, or the override is + // missed and Converged/probe report a false compliant/managed. The escape is + // assembled from a literal backslash so the on-disk bytes are unambiguous. + bs := "\\" // one backslash + line := `"reg` + bs + `u0069stry"=https://evil/` + if !strings.Contains(line, "u0069") { // guard: prove it is the ESCAPED form, not plain "registry" + t.Fatalf("test did not build the escaped form: %q", line) + } + key, val, ok := activeKV(line) + if !ok || key != "registry" || val != "https://evil/" { + t.Fatalf("activeKV(%q) = (%q,%q,%v), want (registry, https://evil/, true)", line, key, val, ok) + } + // The effectiveness + precedence consumers must treat it as a real override. + blk := strings.Split(strings.TrimRight(block(stdBody)+line+"\n", "\n"), "\n") + if blockIsLastEffective(blk, stdBody) { + t.Fatal("a \\u-escaped registry override must defeat blockIsLastEffective") + } + if managed, _ := probeNPMRCContent(mdmBlock()+line+"\n", stdBody); managed { + t.Fatal("a \\u-escaped registry override must prevent a managed probe") + } +} + +// TestSharedClassifier_SpacedForms proves one INI classifier backs the +// consumers that must see npm's whitespace-tolerant key matching: comment-out +// (rewrite), precedence (probe), and round-trip restore (clear). +func TestSharedClassifier_SpacedForms(t *testing.T) { + for _, spaced := range []string{"registry = https://evil/", "registry\t=\thttps://evil/"} { + // rewrite comments out the spaced active registry line. + out := rewrite(t, spaced+"\n") + if !strings.Contains(out, npmrcDMGPrefix+spaced+"\n") { + t.Fatalf("spaced registry %q was not commented out:\n%s", spaced, out) + } + // clear restores it exactly (literal prefix strip, spacing intact). + if got := clearOf(out); got != spaced+"\n" { + t.Fatalf("clear did not restore spaced form: got %q want %q", got, spaced+"\n") + } + // probe precedence: the same spaced line below an MDM block defeats + // effectiveness (a later bare registry overrides). + content := npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" + spaced + "\n" + if managed, _ := probeNPMRCContent(content, stdBody); managed { + t.Fatalf("probe must not report managed when a spaced registry override follows: %q", spaced) + } + // Converged's effectiveness check (the 4th consumer) sees the spaced form + // too: a spaced registry override after our block defeats last-wins. + blk := strings.Split(strings.TrimRight(block(stdBody)+spaced+"\n", "\n"), "\n") + if blockIsLastEffective(blk, stdBody) { + t.Fatalf("blockIsLastEffective must be false when a spaced registry override follows: %q", spaced) + } + } +} + +// TestClassifier_NpmDisguisedOverrideCaught proves the npm-faithful key parsing +// catches an override npm honors but a naive first-'=' split would miss: an +// inline comment or quotes on the key both resolve to `registry` for npm. Both +// the convergence effectiveness check and the MDM precedence probe must treat +// each as a real override and refuse to report the block effective/managed. +func TestClassifier_NpmDisguisedOverrideCaught(t *testing.T) { + for _, override := range []string{ + "registry#ignored=https://evil/", + `"registry"=https://evil/`, + `'registry'=https://evil/`, + "registry = https://evil/ # trailing", + } { + blk := strings.Split(strings.TrimRight(block(stdBody)+override+"\n", "\n"), "\n") + if blockIsLastEffective(blk, stdBody) { + t.Fatalf("blockIsLastEffective must be false when an npm-parsed override follows: %q", override) + } + if managed, _ := probeNPMRCContent(mdmBlock()+override+"\n", stdBody); managed { + t.Fatalf("probe must not report managed when an npm-parsed override follows: %q", override) + } + } +} + +func TestRewrite_LoneCRFailsClosed(t *testing.T) { + // A bare CR (old-Mac line break, or an injected one) is a line separator to npm + // but not to our '\n' split; a section or override hidden behind it must fail + // closed, never be silently mis-parsed. + w := &NPMRCWriter{} + for _, in := range []string{ + "[global]\rregistry=x\n", // section hidden behind a bare CR + "cache=x\rregistry=https://evil/\n", // override hidden behind a bare CR + "foo\r", // trailing bare CR + } { + if _, err := w.rewriteContent([]byte(in), stdBody); !isTargetUnusable(err) { + t.Fatalf("rewriteContent(%q) must fail closed with ErrTargetUnusable, got %v", in, err) + } + } + // CRLF is NOT a lone CR and must still round-trip. + if _, err := w.rewriteContent([]byte("cache=x\r\nregistry=y\r\n"), stdBody); err != nil { + t.Fatalf("CRLF must not be rejected as a bare CR: %v", err) + } +} + +func TestRewrite_CoercibleQuotedKeyFailsClosed(t *testing.T) { + // npm strips single quotes and JSON-parses the inner, coercing a non-string + // (an array) to a string key: `'["registry"]'` becomes the key `registry`. We + // can't cheaply mirror that coercion, so any single-quoted non-string JSON key + // fails closed rather than being silently mis-parsed and missed. + w := &NPMRCWriter{} + for _, in := range []string{ + `'["registry"]'=https://evil/` + "\n", + `'["//registry-int.stepsecurity.io/javascript/:_authToken"]'=evil::dev:X` + "\n", + `'[["registry"]]'=https://evil/` + "\n", + } { + if _, err := w.rewriteContent([]byte(in), stdBody); !isTargetUnusable(err) { + t.Fatalf("rewriteContent(%q) must fail closed with ErrTargetUnusable, got %v", in, err) + } + } + // A single-quoted STRING key is NOT coercible-non-string: npm reads it as the + // plain key, and so do we — it is recognized and commented out, not refused. + for _, in := range []string{`'registry'=https://evil/` + "\n", `'"registry"'=https://evil/` + "\n"} { + out, err := w.rewriteContent([]byte(in), stdBody) + if err != nil { + t.Fatalf("rewriteContent(%q) must not fail closed, got %v", in, err) + } + if !strings.Contains(string(out), npmrcDMGPrefix) { + t.Fatalf("a single-quoted registry key should be commented out, got %q", string(out)) + } + } +} + +func TestProbeContent_LoneCRNotManaged(t *testing.T) { + // A bare CR that hides a section from our split must not let a probe report + // managed off a marker plus matching lines npm would actually scope out. + if managed, _ := probeNPMRCContent("[team]\r"+mdmBlock(), stdBody); managed { + t.Fatal("probe must fail closed on a bare CR") + } +} + +func TestExtractManagedBody(t *testing.T) { + body, present := extractManagedBody(block(stdBody)) + if !present || body != stdBody { + t.Fatalf("extractManagedBody = (%q,%v), want (%q,true)", body, present, stdBody) + } + // A BEGIN with no END is not a well-formed block. + if _, present := extractManagedBody(npmrcBeginMarker + "\nregistry=x\n"); present { + t.Fatal("a block with no END marker must report not present") + } + if _, present := extractManagedBody("registry=x\n"); present { + t.Fatal("no markers means not present") + } +} + +// --------------------------------------------------------------------------- +// probeNPMRCContent — MDM ownership logic (pure) +// --------------------------------------------------------------------------- + +func mdmBlock() string { + return npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" +} + +func TestProbeContent(t *testing.T) { + cases := []struct { + name string + content string + managed bool + }{ + {"managed and effective", mdmBlock(), true}, // edge 8 + {"mdm absorbed our lines, our empty shell present", mdmBlock() + block(""), true}, // edge 16 + {"our shell only, mdm removed", block(""), false}, // edge 17 + {"no mdm marker", "registry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n", false}, + {"planted marker without valid content", npmrcMDMMarker + "\nregistry=https://wrong/\n", false}, // edge 20 + {"stale token under marker", npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=stale::dev:X\n", false}, // edge 21 + {"later bare registry override defeats precedence", mdmBlock() + "registry=https://evil/\n", false}, + {"later token override defeats precedence", mdmBlock() + stdTokenKey + "=evil::dev:X\n", false}, + {"section scopes keys → not managed", "[team]\n" + mdmBlock(), false}, + {"single-quoted array key coerces to a registry override", mdmBlock() + `'["registry"]'=https://evil/` + "\n", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + managed, _ := probeNPMRCContent(tc.content, stdBody) + if managed != tc.managed { + t.Fatalf("probeNPMRCContent managed=%v, want %v\ncontent:\n%s", managed, tc.managed, tc.content) + } + }) + } +} + +func TestProbeContent_MarkerInsideOurBlockIgnored(t *testing.T) { + // A user cannot force mdm_managed by planting the MDM marker inside our own + // block — condition 1 searches only outside it. + content := npmrcBeginMarker + "\n" + npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" + npmrcEndMarker + "\n" + if managed, _ := probeNPMRCContent(content, stdBody); managed { + t.Fatal("MDM marker inside our block must not count as MDM-managed") + } +} + +func TestParseExpected(t *testing.T) { + reg, tokKey, tokVal, ok := parseExpected(stdBody) + if !ok || reg != stdRegistry || tokKey != stdTokenKey || tokVal != stdTokenVal { + t.Fatalf("parseExpected = (%q,%q,%q,%v)", reg, tokKey, tokVal, ok) + } + if _, _, _, ok := parseExpected("registry=only-one-line"); ok { + t.Fatal("a single-line body must not parse") + } +} + +// --------------------------------------------------------------------------- +// resolver predicates (pure) +// --------------------------------------------------------------------------- + +func TestSymlinkTargetPredicates(t *testing.T) { + absCases := []string{"/etc/passwd", "/home/u/.npmrc"} + for _, c := range absCases { + if !isAbsSymlinkTarget(c) { + t.Fatalf("isAbsSymlinkTarget(%q) = false, want true", c) + } + } + for _, c := range []string{"dotfiles/npmrc", "../up", "npmrc"} { + if isAbsSymlinkTarget(c) { + t.Fatalf("isAbsSymlinkTarget(%q) = true, want false", c) + } + } + // The GO-2026-4970 trigger: a directory-shaped raw target. + for _, c := range []string{"file/", "dir/.", "."} { + if !endsInSeparatorOrDot(c) { + t.Fatalf("endsInSeparatorOrDot(%q) = false, want true", c) + } + } + for _, c := range []string{"file", "dotfiles/npmrc"} { + if endsInSeparatorOrDot(c) { + t.Fatalf("endsInSeparatorOrDot(%q) = true, want false", c) + } + } +} + +// isTargetUnusable mirrors the reconciler's future structural-error +// classification. +func isTargetUnusable(err error) bool { + return errors.Is(err, ErrTargetUnusable) +} diff --git a/internal/devicepolicy/npmrc_unix.go b/internal/devicepolicy/npmrc_unix.go new file mode 100644 index 0000000..8f1b9f9 --- /dev/null +++ b/internal/devicepolicy/npmrc_unix.go @@ -0,0 +1,46 @@ +//go:build unix + +package devicepolicy + +import ( + "errors" + "os" + "syscall" +) + +// enforcePOSIXMetadata gates every mode/owner decision in the writer. On Unix +// the writer asserts mode 0600 and chowns the file to the target user; the +// Windows counterpart leaves both to the platform's ACL model. +const enforcePOSIXMetadata = true + +// nonblockOpenFlag adds O_NONBLOCK to the leaf open so that if the entry was +// swapped for a FIFO between the pre-screen Lstat and the open, the open returns +// immediately instead of blocking the daemon before the regular-file check can +// run. It is harmless on a regular file. +func nonblockOpenFlag() int { return syscall.O_NONBLOCK } + +// chownHandle sets ownership on an already-open handle (fchown), never by path, +// so the operation cannot be redirected through a swapped symlink. +func chownHandle(f *os.File, uid, gid int) error { return f.Chown(uid, gid) } + +// interactiveSessionOK is the Windows-only session guard. On Unix the console +// user was already resolved by the executor, so there is nothing further to +// gate here. +func interactiveSessionOK() bool { return true } + +func newOwnerReader() ownerReader { return unixOwnerReader{} } + +// unixOwnerReader reads the owning uid/gid from an open handle's stat. +type unixOwnerReader struct{} + +func (unixOwnerReader) ownerUIDGID(f *os.File) (uid, gid uint32, enforced bool, err error) { + fi, serr := f.Stat() + if serr != nil { + return 0, 0, true, serr + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return 0, 0, true, errors.New("npmrc: file handle has no unix owner metadata") + } + return st.Uid, st.Gid, true, nil +} diff --git a/internal/devicepolicy/npmrc_unix_test.go b/internal/devicepolicy/npmrc_unix_test.go new file mode 100644 index 0000000..c9abd1a --- /dev/null +++ b/internal/devicepolicy/npmrc_unix_test.go @@ -0,0 +1,537 @@ +//go:build unix + +package devicepolicy + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +// fakeOwner is an injectable ownerReader for exercising ownership branches +// without root: it reports whatever uid/gid the test sets, while the file's +// real mode is still read from disk. +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 +} + +// newDiskWriter builds a writer anchored at a real tempdir home. Files created +// there are owned by the test process, so the real ownership reader is used by +// default; tests needing a foreign owner swap w.owners. +func newDiskWriter(t *testing.T, home string) *NPMRCWriter { + t.Helper() + root, err := os.OpenRoot(home) + if err != nil { + t.Fatalf("OpenRoot(%q): %v", home, err) + } + t.Cleanup(func() { _ = root.Close() }) + return &NPMRCWriter{ + home: home, + root: root, + owners: newOwnerReader(), + uid: os.Getuid(), + gid: os.Getgid(), + } +} + +func npmrcPath(home string) string { return filepath.Join(home, ".npmrc") } + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %q: %v", path, err) + } + return string(b) +} + +// TestWrite_CreatesFile covers edge 1 and doubles as the os.Root.OpenRoot(".") +// canary for the common direct-.npmrc case. +func TestWrite_CreatesFile(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + + body, err := w.Write(stdBody) + if err != nil { + t.Fatalf("Write: %v", err) + } + if body != stdBody { + t.Fatalf("readback body = %q, want %q", body, stdBody) + } + got := readFile(t, npmrcPath(home)) + if got != block(stdBody) { + t.Fatalf("file content = %q, want %q", got, block(stdBody)) + } + fi, err := os.Stat(npmrcPath(home)) + if err != nil { + t.Fatalf("stat: %v", err) + } + if fi.Mode().Perm() != 0o600 { + t.Fatalf("mode = %v, want 0600", fi.Mode().Perm()) + } +} + +func TestWrite_ThenConvergedTrue(t *testing.T) { // edge 15 on disk + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + first := readFile(t, npmrcPath(home)) + + conv, err := w.Converged(stdBody) + if err != nil { + t.Fatalf("Converged: %v", err) + } + if !conv { + t.Fatal("expected Converged=true after a fresh write") + } + // A second write is byte-identical. + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("second Write: %v", err) + } + if second := readFile(t, npmrcPath(home)); second != first { + t.Fatalf("second write not idempotent:\n%q\n%q", first, second) + } +} + +func TestConverged_FalseOnLooseMode(t *testing.T) { // edge 18 + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + if err := os.Chmod(npmrcPath(home), 0o644); err != nil { + t.Fatalf("chmod: %v", err) + } + conv, err := w.Converged(stdBody) + if err != nil { + t.Fatalf("Converged: %v", err) + } + if conv { + t.Fatal("expected Converged=false when mode is 0644") + } +} + +func TestConverged_RootOwnedRejected(t *testing.T) { // edge 19 (root-owned refused) + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + // A root-owned direct .npmrc is never one this writer left behind — it always + // chowns its output to the target user. Reading it would disclose potentially + // root-only content into a user-owned backup, so the read fails closed + // (ErrTargetUnusable → write_failed) rather than quietly reporting "not + // converged". + w.owners = fakeOwner{uid: 0, enforced: true} + if _, err := w.Converged(stdBody); !isTargetUnusable(err) { + t.Fatalf("root-owned leaf: want ErrTargetUnusable, got %v", err) + } +} + +func TestConverged_FalseWhenActiveRegistryBelowBlock(t *testing.T) { // edge 27 + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + // `aws codeartifact login`-style append below the block leaves the body + // equal but defeats precedence. + f, err := os.OpenFile(npmrcPath(home), os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatalf("open append: %v", err) + } + if _, err := f.WriteString("registry=https://evil/\n"); err != nil { + t.Fatalf("append: %v", err) + } + f.Close() + + conv, err := w.Converged(stdBody) + if err != nil { + t.Fatalf("Converged: %v", err) + } + if conv { + t.Fatal("expected Converged=false when an active registry follows the block") + } +} + +func TestForeignOwner_ReadRejected(t *testing.T) { // edge 36 + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte("registry=x\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + w.owners = fakeOwner{uid: 99999, enforced: true} + + if _, _, err := w.Read(); !isTargetUnusable(err) { + t.Fatalf("Read of foreign-owned file: want ErrTargetUnusable, got %v", err) + } +} + +func TestClear_RemovesBlockKeepsFile(t *testing.T) { // edge 9 / 24 on disk + home := t.TempDir() + w := newDiskWriter(t, home) + if err := os.WriteFile(npmrcPath(home), []byte("registry=https://registry.npmjs.org/\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + if err := w.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + got := readFile(t, npmrcPath(home)) + if strings.Contains(got, npmrcBeginMarker) { + t.Fatalf("block not removed by Clear: %q", got) + } + if !strings.Contains(got, "registry=https://registry.npmjs.org/\n") { + t.Fatalf("Clear did not restore the commented registry line: %q", got) + } +} + +func TestClear_AbsentFileIsNoOp(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + if err := w.Clear(); err != nil { + t.Fatalf("Clear on absent file: %v", err) + } + if _, err := os.Stat(npmrcPath(home)); !os.IsNotExist(err) { + t.Fatal("Clear must not create the file") + } +} + +func TestRestoreSnapshot(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + if err := os.WriteFile(npmrcPath(home), []byte("registry=original\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + if err := w.RestoreSnapshot(); err != nil { + t.Fatalf("RestoreSnapshot: %v", err) + } + if got := readFile(t, npmrcPath(home)); got != "registry=original\n" { + t.Fatalf("restore did not revert file: %q", got) + } +} + +func TestRestoreSnapshot_RemovesCreatedFile(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { // file did not exist before + t.Fatalf("Write: %v", err) + } + if err := w.RestoreSnapshot(); err != nil { + t.Fatalf("RestoreSnapshot: %v", err) + } + if _, err := os.Stat(npmrcPath(home)); !os.IsNotExist(err) { + t.Fatal("restore of a created file should remove it") + } +} + +func TestRestoreSnapshot_NoPending(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + if err := w.RestoreSnapshot(); err == nil { + t.Fatal("RestoreSnapshot with no pending snapshot must error") + } +} + +func TestSymlink_RelativeInHomeResolved(t *testing.T) { // edge 22 + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, "dotfiles"), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(home, "dotfiles", "npmrc"), []byte("registry=orig\n"), 0o600); err != nil { + t.Fatalf("seed leaf: %v", err) + } + if err := os.Symlink(filepath.Join("dotfiles", "npmrc"), npmrcPath(home)); err != nil { + t.Fatalf("symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write via symlink: %v", err) + } + // The chain is preserved: .npmrc is still a symlink. + li, err := os.Lstat(npmrcPath(home)) + if err != nil { + t.Fatalf("lstat: %v", err) + } + if li.Mode()&os.ModeSymlink == 0 { + t.Fatal("symlink was replaced by a regular file") + } + // The block landed at the resolved leaf, and a backup sits beside it. + leaf := readFile(t, filepath.Join(home, "dotfiles", "npmrc")) + if !strings.Contains(leaf, npmrcBeginMarker) { + t.Fatalf("resolved leaf missing block: %q", leaf) + } + entries, _ := os.ReadDir(filepath.Join(home, "dotfiles")) + backups := 0 + for _, e := range entries { + if strings.HasPrefix(e.Name(), "npmrc.dmg-") && strings.HasSuffix(e.Name(), ".bak") { + backups++ + } + } + if backups == 0 { + t.Fatal("expected a backup beside the resolved leaf") + } +} + +func TestSymlink_AbsoluteRejected(t *testing.T) { // edge 30 + home := t.TempDir() + if err := os.Symlink("/etc/hosts", npmrcPath(home)); err != nil { + t.Fatalf("symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("absolute symlink: want ErrTargetUnusable, got %v", err) + } +} + +func TestSymlink_EscapesHomeRejected(t *testing.T) { // edge 25 + home := t.TempDir() + if err := os.Symlink(filepath.Join("..", "outside"), npmrcPath(home)); err != nil { + t.Fatalf("symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("escaping symlink: want ErrTargetUnusable, got %v", err) + } +} + +func TestSymlink_SlashTerminatedRejected(t *testing.T) { // GO-2026-4970 regression + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "file"), []byte("x"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if err := os.Symlink("file/", npmrcPath(home)); err != nil { + t.Fatalf("symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("slash-terminated symlink: want ErrTargetUnusable, got %v", err) + } +} + +func TestSymlink_DanglingRejected(t *testing.T) { + home := t.TempDir() + if err := os.Symlink("nonexistent-target", npmrcPath(home)); err != nil { + t.Fatalf("symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("dangling symlink: want ErrTargetUnusable, got %v", err) + } +} + +func TestNonRegular_FIFORejected(t *testing.T) { // edge 31 (FIFO) + home := t.TempDir() + if err := syscall.Mkfifo(npmrcPath(home), 0o600); err != nil { + t.Skipf("mkfifo unsupported: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("FIFO leaf: want ErrTargetUnusable, got %v", err) + } +} + +func TestOversizeRejected(t *testing.T) { // edge 31 (size) + home := t.TempDir() + big := make([]byte, npmrcMaxBytes+10) + for i := range big { + big[i] = 'a' + } + if err := os.WriteFile(npmrcPath(home), big, 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("oversize file: want ErrTargetUnusable, got %v", err) + } +} + +func TestProbeExpected_OnDisk(t *testing.T) { // edge 8 + metadata gate + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte(mdmBlock()), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + if managed, _ := w.ProbeExpected(stdBody); !managed { + t.Fatal("expected ProbeExpected=true for an effective 0600 MDM block") + } + // Loose metadata must not freeze the file as managed. + if err := os.Chmod(npmrcPath(home), 0o644); err != nil { + t.Fatalf("chmod: %v", err) + } + if managed, _ := w.ProbeExpected(stdBody); managed { + t.Fatal("ProbeExpected must reject an MDM block with loose (0644) metadata") + } +} + +func TestBackup_ModeAndRotation(t *testing.T) { // edge 28 + rotation cap + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte("registry=seed\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + for i := 0; i < 5; i++ { + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write %d: %v", i, err) + } + } + entries, _ := os.ReadDir(home) + var backups []os.DirEntry + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".npmrc.dmg-") && strings.HasSuffix(e.Name(), ".bak") { + backups = append(backups, e) + } + } + if len(backups) == 0 || len(backups) > npmrcMaxBackups { + t.Fatalf("expected 1..%d backups, got %d", npmrcMaxBackups, len(backups)) + } + for _, b := range backups { + fi, err := b.Info() + if err != nil { + t.Fatalf("info: %v", err) + } + if fi.Mode().Perm() != 0o600 { + t.Fatalf("backup %q mode = %v, want 0600", b.Name(), fi.Mode().Perm()) + } + } +} + +func TestConverged_SectionFailsClosed(t *testing.T) { + // A [section] header above the block scopes npm's registry key so the block is + // inert. Converged must fail closed (ErrTargetUnusable → write_failed) instead + // of reporting a false 'compliant' on a body-equal but ineffective block. + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + orig := readFile(t, npmrcPath(home)) + if err := os.WriteFile(npmrcPath(home), []byte("[global]\n"+orig), 0o600); err != nil { + t.Fatalf("prepend section: %v", err) + } + if _, err := w.Converged(stdBody); !isTargetUnusable(err) { + t.Fatalf("sectioned file: want ErrTargetUnusable from Converged, got %v", err) + } +} + +func TestConverged_LoneCRFailsClosed(t *testing.T) { + // A bare CR is a line break to npm but not to our '\n' split, so a section or + // override could hide behind it. Converged must fail closed (ErrTargetUnusable + // → write_failed), never report a false 'compliant'. + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + orig := readFile(t, npmrcPath(home)) + // Prepend a bare-CR line npm reads as `[global]` + `x=1` (a section) but our + // '\n' split sees as one opaque line. + if err := os.WriteFile(npmrcPath(home), []byte("[global]\rx=1\n"+orig), 0o600); err != nil { + t.Fatalf("prepend lone CR: %v", err) + } + if _, err := w.Converged(stdBody); !isTargetUnusable(err) { + t.Fatalf("lone-CR file: want ErrTargetUnusable from Converged, got %v", err) + } +} + +func TestConverged_CoercibleQuotedKeyFailsClosed(t *testing.T) { + // A single-quoted non-string JSON key npm coerces to `registry` (e.g. + // '["registry"]') appended below the block could override it invisibly to a + // line-based check. Converged must fail closed (ErrTargetUnusable), never report + // a false 'compliant'. + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + f, err := os.OpenFile(npmrcPath(home), os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatalf("open append: %v", err) + } + if _, err := f.WriteString(`'["registry"]'=https://evil/` + "\n"); err != nil { + t.Fatalf("append: %v", err) + } + f.Close() + if _, err := w.Converged(stdBody); !isTargetUnusable(err) { + t.Fatalf("coercible quoted key: want ErrTargetUnusable from Converged, got %v", err) + } +} + +func TestClear_RemovesDuplicateBlocks(t *testing.T) { + // Offboarding must revoke EVERY token: a file carrying two managed blocks must + // clear to zero blocks and zero token bytes, not leave the second one live. + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte(block(stdBody)+block(stdBody)), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + if err := w.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + got := readFile(t, npmrcPath(home)) + if strings.Contains(got, npmrcBeginMarker) || strings.Contains(got, stdTokenVal) { + t.Fatalf("clear left a duplicate block or live token behind: %q", got) + } +} + +func TestReadCurrent_LeafSwappedToSymlinkRejected(t *testing.T) { // edge 35 + // The leaf resolves as a regular file, then is swapped for a symlink before the + // bounded read. readCurrent's Lstat pre-screen must reject it as + // ErrTargetUnusable rather than follow the swap to another file. + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte("registry=x\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if err := os.WriteFile(filepath.Join(home, "elsewhere"), []byte("registry=evil\n"), 0o600); err != nil { + t.Fatalf("seed elsewhere: %v", err) + } + w := newDiskWriter(t, home) + rt, err := w.resolveLeaf() + if err != nil { + t.Fatalf("resolveLeaf: %v", err) + } + defer rt.close() + if err := os.Remove(npmrcPath(home)); err != nil { + t.Fatalf("remove leaf: %v", err) + } + if err := os.Symlink("elsewhere", npmrcPath(home)); err != nil { + t.Fatalf("symlink swap: %v", err) + } + if _, _, _, err := w.readCurrent(rt); !isTargetUnusable(err) { + t.Fatalf("swapped-to-symlink leaf: want ErrTargetUnusable, got %v", err) + } +} + +func TestRestoreSnapshot_ConsumedAfterUse(t *testing.T) { + // A snapshot is restored at most once: after a successful restore it is + // consumed, so a second call has nothing to revert and errors. + home := t.TempDir() + w := newDiskWriter(t, home) + if err := os.WriteFile(npmrcPath(home), []byte("registry=original\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + if err := w.RestoreSnapshot(); err != nil { + t.Fatalf("first RestoreSnapshot: %v", err) + } + if err := w.RestoreSnapshot(); err == nil { + t.Fatal("second RestoreSnapshot must error — the snapshot is consumed after one use") + } +} diff --git a/internal/devicepolicy/npmrc_windows.go b/internal/devicepolicy/npmrc_windows.go new file mode 100644 index 0000000..e87b22c --- /dev/null +++ b/internal/devicepolicy/npmrc_windows.go @@ -0,0 +1,152 @@ +//go:build windows + +package devicepolicy + +import ( + "errors" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +// enforcePOSIXMetadata is false on Windows: the file is governed by inherited +// NTFS ACLs, so the writer neither asserts a POSIX mode nor chowns. +const enforcePOSIXMetadata = false + +// nonblockOpenFlag is a no-op on Windows — there is no O_NONBLOCK and no +// npm-relevant FIFO to guard against. +func nonblockOpenFlag() int { return 0 } + +// chownHandle is a no-op on Windows (ACL ownership model). +func chownHandle(f *os.File, uid, gid int) error { return nil } + +func newOwnerReader() ownerReader { return windowsOwnerReader{} } + +// windowsOwnerReader reports enforced=false so the writer skips every ownership +// decision on this platform. +type windowsOwnerReader struct{} + +func (windowsOwnerReader) ownerUIDGID(f *os.File) (uid, gid uint32, enforced bool, err error) { + return 0, 0, false, nil +} + +const ( + wtsCurrentServerHandle = 0 + wtsInfoUserName = 5 // WTSUserName + wtsInfoDomainName = 7 // WTSDomainName + sidLocalSystem = "S-1-5-18" +) + +// WTSQuerySessionInformationW is not exposed by golang.org/x/sys/windows, so it +// is bound directly. wtsapi32.dll is always present on Windows. +var ( + wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll") + procWTSQuerySessionInformationW = wtsapi32.NewProc("WTSQuerySessionInformationW") +) + +// interactiveSessionOK reports whether this process is the interactive user of +// an active session — the only state in which writing ~/.npmrc lands in the +// developer's own profile. It rejects LocalSystem, session 0 (services), any +// non-active (disconnected) session, and alternate-credential (runas) processes +// whose token user differs from the session's logged-on user. A standard +// elevated admin inside their own active session passes both checks. A true +// cross-session resolver (WTSQueryUserToken impersonation) needs SeTcbPrivilege +// and is deliberately out of scope here. +func interactiveSessionOK() bool { + tokenSID, err := currentTokenUserSID() + if err != nil { + return false + } + if tokenSID.String() == sidLocalSystem { + return false + } + + var sessionID uint32 + if err := windows.ProcessIdToSessionId(windows.GetCurrentProcessId(), &sessionID); err != nil { + return false + } + if sessionID == 0 { + // Session 0 is the non-interactive services session; never a developer. + return false + } + + state, ok := sessionConnectState(sessionID) + if !ok || state != uint32(windows.WTSActive) { + return false + } + + sessionSID, err := sessionUserSID(sessionID) + if err != nil { + return false + } + // Session membership alone cannot catch runas: the alternate-credential + // process lives in the caller's session but carries a different user SID. + return tokenSID.String() == sessionSID.String() +} + +func currentTokenUserSID() (*windows.SID, error) { + tu, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, err + } + return tu.User.Sid, nil +} + +// sessionConnectState returns the WTS connect state for a session id. +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 _, s := range unsafe.Slice(info, count) { + if s.SessionID == sessionID { + return s.State, true + } + } + return 0, false +} + +// sessionUserSID resolves the SID of the user logged on to a session via its +// WTS user/domain name. Unlike WTSQueryUserToken this needs no SeTcbPrivilege, +// so it works from a normal elevated admin process. +func sessionUserSID(sessionID uint32) (*windows.SID, error) { + name, err := wtsQueryString(sessionID, wtsInfoUserName) + if err != nil { + return nil, err + } + if name == "" { + return nil, errors.New("npmrc: session has no logged-on user") + } + domain, _ := wtsQueryString(sessionID, wtsInfoDomainName) + account := name + if domain != "" { + account = domain + `\` + name + } + sid, _, _, err := windows.LookupSID("", account) + if err != nil { + return nil, err + } + return sid, nil +} + +// wtsQueryString calls WTSQuerySessionInformationW for a string info class and +// returns the decoded value, freeing the buffer the API allocates. +func wtsQueryString(sessionID uint32, infoClass uint32) (string, error) { + var buf *uint16 + var bytesReturned uint32 + r1, _, callErr := procWTSQuerySessionInformationW.Call( + uintptr(wtsCurrentServerHandle), + uintptr(sessionID), + uintptr(infoClass), + uintptr(unsafe.Pointer(&buf)), + uintptr(unsafe.Pointer(&bytesReturned)), + ) + if r1 == 0 { + return "", callErr + } + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(buf))) + return windows.UTF16PtrToString(buf), nil +} diff --git a/internal/devicepolicy/reconcile.go b/internal/devicepolicy/reconcile.go index 04a179f..214cdb3 100644 --- a/internal/devicepolicy/reconcile.go +++ b/internal/devicepolicy/reconcile.go @@ -2,6 +2,7 @@ package devicepolicy import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -33,6 +34,62 @@ type Reconciler struct { // inject a stub so results never depend on the host machine. Probe func() (managed bool, detail string) + // The seams below adapt the ladder to a target whose ownership and + // convergence model differs from the single-JSON-key settings.json writer + // (concretely the ~/.npmrc block writer, npmrc.go). EVERY seam nil/false + // reproduces the settings.json behavior byte-for-byte — the IDE wiring sets + // none of them, so its path is unchanged. + + // Converged, when set, REPLACES the generic body-equality idempotency test + // (present && on-disk == desired) with a target-specific full-state check — + // e.g. the ~/.npmrc writer also verifies its block is effective (nothing + // overrides it below) and carries sane metadata (0600, owned by the target + // user). Body equality alone is a hole there: a `registry=` line appended + // below an unchanged block leaves the body equal but defeats precedence. + Converged func(expected string) (bool, error) + + // Render, when set, derives the value to write/compare from the raw policy — + // e.g. rendering the two ~/.npmrc content lines from the npm policy object + // and the device serial. nil → the value is the compacted policy JSON + // (settings.json). A render failure is a malformed backend payload and is + // reported as policy_not_applied. + Render func(policy json.RawMessage) (string, error) + + // ProbeExpected, when set, REPLACES Probe for this cycle and receives the + // rendered value so a content-aware probe can decide whether the MDM lane has + // achieved the SAME desired state (the ~/.npmrc file is user-writable, so a + // bare marker is not proof). nil → Probe. + ProbeExpected func(expected string) (managed bool, detail string) + + // RestoreSnapshot, when set, is the rollback used after a post-write ownership + // persist fails: the writer reverts its whole-file transformation from a + // snapshot and its RESULT is classified — restore succeeded → write_failed + // (the enforce write was undone), restore failed/aborted → verification_failed + // (on-disk state now unknown). nil → the generic best-effort re-write of the + // previous value (always write_failed), which suits a single settings key. + RestoreSnapshot func() error + + // OwnsByMarker switches handleClear from value-based ownership (clear only + // when on-disk still equals the recorded written value) to marker-based: + // always call Writer.Clear() and drop the state record unconditionally. It + // suits a writer whose Clear is intrinsically scoped to its own markers, so a + // lost/drifted/empty record must not strand a token-bearing block on disk. + OwnsByMarker bool + + // WriterInitErr carries a writer-construction failure (Writer is then nil). + // The reconciler classifies it AFTER the fetch: absent policy → silent no-op, + // clear → retain all state (no target to act against), enforce → + // policy_not_applied for ErrNoTargetUser else write_failed. nil with a nil + // Writer is the ordinary unsupported-platform silent no-op. + WriterInitErr error + + // State, when set, is the ownership store this cycle reads and writes through, + // replacing the package-level shared-file functions. The npm category uses + // its own per-mode/per-user store so its record can never share the IDE + // file's unlocked read-modify-write. nil → the shared package-level store + // (settings.json), byte-identical to before. + State StateStore + // Now and Logf are optional seams. Now defaults to time.Now().UTC; Logf to a // no-op. Now func() time.Time @@ -40,11 +97,26 @@ type Reconciler struct { // writeState and clearState are test seams over the ownership store // (WriteAppliedState / ClearAppliedState). nil → the real implementation. + // They apply only to the shared store (State == nil). writeState func(category, target string, s AppliedTargetState) error clearState func(category, target string) error } +// readState / persistState / dropState route through the injected StateStore +// when one is set, and otherwise fall back to the shared package-level store +// (with the writeState/clearState test seams) exactly as before — so the IDE +// wiring, which sets no State, is unchanged. +func (r *Reconciler) readState(cat, tgt string) (AppliedTargetState, bool) { + if r.State != nil { + return r.State.Read(cat, tgt) + } + return ReadAppliedState(cat, tgt) +} + func (r *Reconciler) persistState(cat, tgt string, s AppliedTargetState) error { + if r.State != nil { + return r.State.Write(cat, tgt, s) + } if r.writeState != nil { return r.writeState(cat, tgt, s) } @@ -52,12 +124,90 @@ func (r *Reconciler) persistState(cat, tgt string, s AppliedTargetState) error { } func (r *Reconciler) dropState(cat, tgt string) error { + if r.State != nil { + return r.State.Drop(cat, tgt) + } if r.clearState != nil { return r.clearState(cat, tgt) } return ClearAppliedState(cat, tgt) } +// renderValue produces the value to write/compare: the rendered block via the +// Render seam, or the compacted policy JSON for settings.json. +func (r *Reconciler) renderValue(policy json.RawMessage) (string, error) { + if r.Render != nil { + return r.Render(policy) + } + return compactJSON(policy) +} + +// converged answers "is the desired value already fully in place?". With the +// Converged seam it delegates to the writer's full-state check; otherwise it is +// the generic body-equality test over the already-read on-disk value. +func (r *Reconciler) converged(expected, onDisk string, present bool) (bool, error) { + if r.Converged != nil { + return r.Converged(expected) + } + return present && onDisk == expected, nil +} + +// probeExpected reports whether a managed policy already governs this target. +// The content-aware ProbeExpected seam (needs the rendered value) wins; else the +// legacy content-blind Probe. +func (r *Reconciler) probeExpected(expected string) (bool, string) { + if r.ProbeExpected != nil { + return r.ProbeExpected(expected) + } + return r.probe() +} + +// rollback undoes the just-committed write after the post-write ownership +// persist failed, and returns the compliance state the outcome warrants. +// +// With the RestoreSnapshot seam, the writer performs a whole-file transactional +// restore whose success is meaningful: restore succeeded → write_failed (the +// enforce write was cleanly undone); restore failed/aborted (e.g. the resolved +// path moved between write and rollback) → verification_failed (on-disk state is +// now unknown). Without the seam it falls back to the generic best-effort +// re-write of the previous value, which always yields write_failed — correct for +// a single settings key and byte-identical for the IDE path. +func (r *Reconciler) rollback(prevOnDisk string, prevPresent bool) (state string, err error) { + if r.RestoreSnapshot != nil { + if rerr := r.RestoreSnapshot(); rerr != nil { + return StateVerificationFailed, rerr + } + return StateWriteFailed, nil + } + r.rollbackWrite(prevOnDisk, prevPresent) + return StateWriteFailed, nil +} + +// classifyReadError maps a Writer read/convergence error to a compliance state. +// A structural refusal (the target cannot be enforced at all — wraps +// ErrTargetUnusable) is a write-class fact; everything else (permission denied, +// 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) { + return StateWriteFailed + } + return StateVerificationFailed +} + +// classifyWriteError maps a Writer.Write / Writer.Clear failure to a compliance +// state. A write that errored is write_failed by default — the value did not take +// effect. The one exception is a writer that landed new bytes it could neither +// verify NOR roll back (ErrWriteUnverified): on-disk state is then indeterminate, +// 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) { + return StateVerificationFailed + } + return StateWriteFailed +} + func (r *Reconciler) now() time.Time { if r.Now != nil { return r.Now() @@ -97,13 +247,18 @@ func (r *Reconciler) probe() (bool, string) { // // - fetch error (transport / non-200 / malformed) → NO-OP, error returned. // Enforcement on disk is never wiped on a transient or malformed response. -// - platform not enforceable (nil Writer) → silent no-op. +// - platform not enforceable (nil Writer, nil WriterInitErr) → silent no-op. +// - writer could not be constructed (nil Writer, WriterInitErr set) → +// classified AFTER the fetch by what run-config asked for: absent → silent; +// clear → no-op retaining ALL state (no resolved target to act against); +// enforce → policy_not_applied (ErrNoTargetUser) or write_failed (other). // - absent policy (run-config carried no `policy` directive for this // category/target) → silent no-op; the on-disk value and ownership record // stand. This is NOT a clear — removal happens only on an explicit clear. -// - clear result → remove ONLY the agent-owned settings key; a value the -// agent has no record of writing is left untouched. No compliance report -// (an unassigned device is backend-derived). +// - clear result → remove ONLY the agent-owned value; a value the agent has no +// record of writing is left untouched (value-based ownership) or the block +// is removed and the record dropped (marker-based ownership, OwnsByMarker). +// No compliance report (an unassigned device is backend-derived). // - policy result → probe → ownership/drift-checked write + readback + // verify + report (handleEnforce). func (r *Reconciler) Reconcile(ctx context.Context) error { @@ -120,8 +275,15 @@ func (r *Reconciler) Reconcile(ctx context.Context) error { } if r.Writer == nil { - r.logf("devicepolicy: no settings path on this platform; skipping (category=%s target=%s)", cat, tgt) - return nil + // No usable writer. Two shapes: an unsupported platform (no init error → + // the long-standing silent skip) or a construction failure (init error → + // classified against the fetched directive, since reporting before the + // fetch would fire even when run-config would have said "no policy"). + if r.WriterInitErr == nil { + r.logf("devicepolicy: no settings path on this platform; skipping (category=%s target=%s)", cat, tgt) + return nil + } + return r.handleNoWriter(ctx, cat, tgt, ep) } if !ep.present() { @@ -138,13 +300,53 @@ func (r *Reconciler) Reconcile(ctx context.Context) error { return r.handleEnforce(ctx, cat, tgt, ep) } -// handleClear removes the agent-owned settings key on unassignment. It clears -// the on-disk value ONLY when it still equals what the agent last wrote -// (ownership); a value the agent has no record of writing — the user's own -// extensions.allowed predates enforcement, or the record was lost — is left -// intact. +// handleNoWriter classifies a cycle whose writer could not be constructed +// (WriterInitErr set, Writer nil). It never touches disk or state — there is no +// resolved target user to act against — and decides purely from the fetched +// directive: +// +// - absent policy → silent no-op (nothing to enforce, nothing to clear); +// - clear → no-op that RETAINS every state record. With ErrNoTargetUser there +// is no uid to even select a per-user record, and dropping records blindly +// would erase the bookkeeping that other users still carry a token-bearing +// block pending cleanup. The backend re-sends clear each cycle, so cleanup +// happens when a writer is next constructible (a real user is present); +// - enforce → report why nothing was applied: policy_not_applied when this +// machine state simply has no enforceable target user (ErrNoTargetUser), +// write_failed for any other construction failure (home unresolvable/ +// unopenable — an infrastructure problem worth surfacing louder). +func (r *Reconciler) handleNoWriter(ctx context.Context, cat, tgt string, ep EffectivePolicy) error { + if !ep.present() { + r.logf("devicepolicy: no enforceable target user and run-config carried no policy for %s/%s; nothing to do", cat, tgt) + return nil + } + if ep.Clear { + r.logf("devicepolicy: clear requested for %s/%s but no enforceable target user; retaining all state (cleared when a user is present)", cat, tgt) + return nil + } + state := StateWriteFailed + if errors.Is(r.WriterInitErr, ErrNoTargetUser) { + state = StatePolicyNotApplied + } + _ = r.report(ctx, cat, tgt, state, "") + return fmt.Errorf("devicepolicy: enforce %s/%s: no usable writer: %w", cat, tgt, r.WriterInitErr) +} + +// handleClear removes the agent-owned value on unassignment. Two ownership +// models, selected by OwnsByMarker: +// +// - value-based (default, settings.json): clear the on-disk value ONLY when it +// still equals what the agent last wrote; a value the agent has no record of +// writing — the user's own extensions.allowed predates enforcement, or the +// record was lost — is left intact, and the state record is dropped only +// when one existed. +// - marker-based (OwnsByMarker, ~/.npmrc): handleClearByMarker. func (r *Reconciler) handleClear(cat, tgt string) error { - prev, hadPrev := ReadAppliedState(cat, tgt) + if r.OwnsByMarker { + return r.handleClearByMarker(cat, tgt) + } + + prev, hadPrev := r.readState(cat, tgt) onDisk, present, err := r.Writer.Read() if err != nil { return fmt.Errorf("devicepolicy: clear: read %s: %w", r.Writer.Location(), err) @@ -174,6 +376,26 @@ func (r *Reconciler) handleClear(cat, tgt string) error { return nil } +// handleClearByMarker removes the managed block regardless of recorded state. +// Ownership is intrinsic to the writer's own markers — its Clear only ever +// removes content between OUR markers and un-prefixes OUR commented lines, never +// anything else — so a value-equality gate is both unnecessary and unsafe here: +// lost or corrupt state, a drifted block, or an empty marker shell would +// otherwise strand a token-bearing block on disk forever after unassignment. +// Clear is called unconditionally (a no-op when there is no block) and the state +// record is dropped UNCONDITIONALLY afterward — a store read that failed or lied +// (no record found) must not leave an orphan behind; Drop is idempotent. +func (r *Reconciler) handleClearByMarker(cat, tgt string) error { + if err := r.Writer.Clear(); err != nil { + return fmt.Errorf("devicepolicy: clear %s: %w", r.Writer.Location(), err) + } + r.logf("devicepolicy: cleared managed block at %s", r.Writer.Location()) + if err := r.dropState(cat, tgt); err != nil { + return fmt.Errorf("devicepolicy: clear: update state: %w", err) + } + return nil +} + // handleEnforce converges settings.json to the compiled policy and reports. // The ladder, in order: // @@ -186,42 +408,82 @@ func (r *Reconciler) handleClear(cat, tgt string) error { // → persist ownership on every successful write (rollback if that fails) // → Verify → report (drift upgrades a would-be compliant to drift_detected) func (r *Reconciler) handleEnforce(ctx context.Context, cat, tgt string, ep EffectivePolicy) error { - // The compiled policy compacted: the canonical comparison form for - // readback, idempotency, and ownership. (The backend's hash still travels - // verbatim; only the value bytes are normalized for comparison.) - newValue, err := compactJSON(ep.Policy) + // The value to enforce: the rendered block (Render seam) or the compacted + // policy JSON. Computed FIRST because the content-aware probe below needs it. + // (The backend's hash still travels verbatim; only the value bytes are + // normalized for comparison.) + newValue, err := r.renderValue(ep.Policy) if err != nil { - // Defensive: the fetcher already validated object shape, so this is a - // malformed-payload class failure → no-op, never write. + if r.Render != nil { + // A malformed backend payload the renderer rejected: nothing was + // applied and nothing will be. Make it visible rather than a silent + // no-op. (The default compactJSON path only fails on bytes the fetcher + // already rejected as a non-object, so it keeps its silent return.) + _ = r.report(ctx, cat, tgt, StatePolicyNotApplied, "") + return fmt.Errorf("devicepolicy: enforce: render policy: %w", err) + } return fmt.Errorf("devicepolicy: enforce: compact policy: %w", err) } - // 1. Managed-policy probe. A policy at the OS policy location outranks - // user settings inside VS Code — writing would be ineffective at best and - // fight the MDM at worst. Yield and report. - if managed, detail := r.probe(); managed { + // 1. Managed-policy probe. A real managed policy outranks the value the agent + // would write — writing would be ineffective at best and fight the MDM at + // worst. Yield and report. + if managed, detail := r.probeExpected(newValue); managed { r.logf("devicepolicy: managed policy present at %s → mdm_managed (yielding)", detail) return r.report(ctx, cat, tgt, StateMDMManaged, "") } - // 2. Read the current settings value. - prev, hadPrev := ReadAppliedState(cat, tgt) + // 2. Read the current value. + prev, hadPrev := r.readState(cat, tgt) onDisk, present, err := r.Writer.Read() if err != nil { - // Couldn't read to decide idempotency/drift → verification_failed. - // This includes an unsalvageable settings.json (not valid JSONC), which - // the writer refuses to touch. - _ = r.report(ctx, cat, tgt, StateVerificationFailed, "") + // Couldn't read to decide idempotency/drift. A structural refusal (the + // target cannot be enforced) is write_failed; a plain unreadable/unparseable + // file is verification_failed. classifyReadError always returns the latter + // for the IDE writer, which never wraps ErrTargetUnusable. + state := classifyReadError(err) + _ = r.report(ctx, cat, tgt, state, "") return fmt.Errorf("devicepolicy: enforce: read %s: %w", r.Writer.Location(), err) } - // 3. Idempotency: the desired policy is already in place and unchanged. - // No write — but still report so the backend sees a fresh evaluation. - if present && onDisk == newValue && prev.AppliedHash == ep.Hash { + // 3. Idempotency: the desired value is already fully in place and the hash is + // unchanged. No write — but still report so the backend sees a fresh + // evaluation. The convergence test is the writer's when the Converged seam is + // set (it also checks effectiveness and metadata), else plain body equality. + converged, cerr := r.converged(newValue, onDisk, present) + if cerr != nil { + // Converged runs its own secure read; a structural refusal there is the + // same write-class fact as an initial read refusal. + state := classifyReadError(cerr) + _ = 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") return r.report(ctx, cat, tgt, StateCompliant, ep.Hash) } + // The full-state convergence seam (npm) proves the exact desired block is on + // disk, effective, and correctly owned — a strictly stronger fact than body + // equality — yet THIS cycle's store does not carry this hash. That happens when + // the other privilege mode applied it and recorded in its own per-mode store, + // or our record is stale. Adopt the on-disk state into this store rather than + // churn a redundant rewrite or misreport it as drift, and report compliant. + // Best-effort: the block is already applied, so a store hiccup only defers the + // record one cycle. Gated on the Converged seam so the settings.json path + // (body equality, shared store) is byte-identical to before. + if converged && r.Converged != nil { + if perr := r.persistState(cat, tgt, AppliedTargetState{ + AppliedHash: ep.Hash, + WrittenValue: newValue, + FetchedAt: r.now(), + }); perr != nil { + r.logf("devicepolicy: could not adopt already-converged state at %s: %v", r.Writer.Location(), perr) + } + r.logf("devicepolicy: %s already holds the desired block (adopted) — no write", r.Writer.Location()) + return r.report(ctx, cat, tgt, StateCompliant, ep.Hash) + } + // 4. Drift: the agent wrote a value before, and what is on disk now is not // it (edited or removed — typically the user hand-editing settings.json). // Enforcement means converging it back; the distinct state lets the @@ -247,7 +509,9 @@ func (r *Reconciler) handleEnforce(ctx context.Context, cat, tgt string, ep Effe // 6. Merge-write + readback. rb, werr := r.Writer.Write(newValue) if werr != nil { - _ = r.report(ctx, cat, tgt, StateWriteFailed, "") + // write_failed by default; verification_failed only when the writer landed + // bytes it could neither verify nor roll back (on-disk state indeterminate). + _ = r.report(ctx, cat, tgt, classifyWriteError(werr), "") return fmt.Errorf("devicepolicy: enforce: write %s: %w", r.Writer.Location(), werr) } readbackMatch := rb == newValue @@ -263,10 +527,14 @@ func (r *Reconciler) handleEnforce(ctx context.Context, cat, tgt string, ep Effe WrittenValue: newValue, FetchedAt: r.now(), }); err != nil { - // The write happened but ownership couldn't be recorded — undo it so - // no unrecorded value is left behind, and report a failed write. - r.rollbackWrite(onDisk, present) - _ = r.report(ctx, cat, tgt, StateWriteFailed, "") + // The write happened but ownership couldn't be recorded — undo it so no + // unrecorded value is left behind. The rollback outcome decides the state: + // cleanly undone → write_failed; restore failed/aborted → verification_failed. + state, rbErr := r.rollback(onDisk, present) + if rbErr != nil { + r.logf("devicepolicy: rollback at %s failed: %v", r.Writer.Location(), rbErr) + } + _ = r.report(ctx, cat, tgt, state, "") return fmt.Errorf("devicepolicy: enforce: update state: %w", err) } r.logf("devicepolicy: wrote policy to %s (readback_match=%v)", r.Writer.Location(), readbackMatch) diff --git a/internal/devicepolicy/reconcile_npm_test.go b/internal/devicepolicy/reconcile_npm_test.go new file mode 100644 index 0000000..3858ff7 --- /dev/null +++ b/internal/devicepolicy/reconcile_npm_test.go @@ -0,0 +1,576 @@ +package devicepolicy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +// The tests here drive the reconciler with the seams the ~/.npmrc path sets +// (Render, Converged, ProbeExpected, RestoreSnapshot, OwnsByMarker, State). The +// existing reconcile_test.go covers the settings.json path with every seam at +// its zero value; together they show the ladder serves both targets from one +// body — the seams change behavior ONLY when set. + +// --- in-memory StateStore fake --------------------------------------------- + +// memStateStore is an in-memory StateStore for the npm reconcile tests. The npm +// category always drives ownership through the State seam (never the shared +// package-level cache), so these tests inject this instead of using +// withTempCache. It counts calls so a test can assert the ladder routed through +// the store, and failWriteFrom forces the post-write persist to fail. +type memStateStore struct { + m map[string]AppliedTargetState + writeErr error + dropErr error + failWriteFrom int // when >0, Write errors on the Nth call and after + writes int + drops int + reads int +} + +func msKey(cat, tgt string) string { return cat + "\x00" + tgt } + +func (s *memStateStore) Read(cat, tgt string) (AppliedTargetState, bool) { + s.reads++ + st, ok := s.m[msKey(cat, tgt)] + return st, ok +} + +func (s *memStateStore) Write(cat, tgt string, st AppliedTargetState) error { + s.writes++ + if s.failWriteFrom > 0 && s.writes >= s.failWriteFrom { + return errors.New("state store write failed") + } + if s.writeErr != nil { + return s.writeErr + } + if s.m == nil { + s.m = map[string]AppliedTargetState{} + } + s.m[msKey(cat, tgt)] = st + return nil +} + +func (s *memStateStore) Drop(cat, tgt string) error { + s.drops++ + if s.dropErr != nil { + return s.dropErr + } + delete(s.m, msKey(cat, tgt)) + return nil +} + +func (s *memStateStore) get(cat, tgt string) (AppliedTargetState, bool) { + st, ok := s.m[msKey(cat, tgt)] + return st, ok +} + +// --- npm fixtures ----------------------------------------------------------- + +// npmPolicyWire stands in for the fetched npm policy payload (passed verbatim to +// the Render seam). npmRendered is what the fake renderer turns it into — the +// value the reconciler writes and compares, standing in for the two managed +// content lines RenderNPMRCBlock produces. +const npmPolicyWire = `{"registry":"https://npm.pkg.example/","always_auth":true}` +const npmRendered = "registry=https://npm.pkg.example/\nalways-auth=true" + +func npmRenderOK(json.RawMessage) (string, error) { return npmRendered, nil } + +func npmPolicyEP(hash string) EffectivePolicy { + return EffectivePolicy{ + Category: CategoryPackageConfig, + Target: TargetNPM, + Clear: false, + Policy: json.RawMessage(npmPolicyWire), + Hash: hash, + } +} + +// newNPMRec builds a marker-owned, State-backed reconciler wired like the +// ~/.npmrc path: OwnsByMarker, a Render seam that produces the managed block, a +// content-aware ProbeExpected, and a Converged seam. Defaults: Render → the +// fixed block, probe → not managed, Converged → false (proceed to write). Tests +// override a single seam to exercise one rung. No withTempCache — State routes +// every ownership access away from the shared file. +func newNPMRec(t *testing.T, ep EffectivePolicy, w *fakeWriter, st *memStateStore) (*Reconciler, *fakeReporter) { + t.Helper() + rep := &fakeReporter{} + r := &Reconciler{ + Fetcher: &fakeFetcher{ep: ep}, + Reporter: rep, + Writer: w, + CustomerID: "cust", + DeviceID: "SERIAL-1", + Platform: "darwin", + Category: CategoryPackageConfig, + Target: TargetNPM, + OwnsByMarker: true, + State: st, + Render: npmRenderOK, + ProbeExpected: func(string) (bool, string) { return false, "" }, + Converged: func(string) (bool, error) { return false, nil }, + Now: func() time.Time { return time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC) }, + } + return r, rep +} + +// --- tests ------------------------------------------------------------------ + +func TestNPMEnforceRendersBlockAndWrites(t *testing.T) { + w := &fakeWriter{} + st := &memStateStore{} + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + // The rendered block is written verbatim — the Render seam, not compactJSON. + if len(w.writes) != 1 || w.writes[0] != npmRendered { + t.Fatalf("expected the rendered block written once, got %v", w.writes) + } + got := lastReport(t, rep) + if got.State != StateCompliant || got.Category != CategoryPackageConfig || got.Target != TargetNPM { + t.Fatalf("report = %+v, want compliant package_config/npm", got) + } + if got.AppliedHash != "sha256:N" { + t.Fatalf("applied_hash = %q, want sha256:N", got.AppliedHash) + } + // Ownership recorded through the State seam (never the shared file). + if st.writes == 0 { + t.Fatal("ownership must be recorded through the State seam") + } + rec, ok := st.get(CategoryPackageConfig, TargetNPM) + if !ok || rec.WrittenValue != npmRendered || rec.AppliedHash != "sha256:N" { + t.Fatalf("state record = %+v ok=%v, want the rendered block + hash", rec, ok) + } +} + +func TestNPMRenderFailureReportsPolicyNotApplied(t *testing.T) { + // A malformed npm policy the renderer rejects: nothing is applied and the + // cycle reports policy_not_applied (not a silent no-op). Render runs FIRST, so + // the writer is never read or written and the probe never runs. + w := &fakeWriter{} + st := &memStateStore{} + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + probed := false + r.ProbeExpected = func(string) (bool, string) { probed = true; return false, "" } + r.Render = func(json.RawMessage) (string, error) { return "", errors.New("policy missing registry") } + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a render failure must surface an error") + } + if w.reads != 0 || len(w.writes) != 0 || w.clears != 0 || probed { + t.Fatalf("render failure must touch nothing: reads=%d writes=%v clears=%d probed=%v", + w.reads, w.writes, w.clears, probed) + } + if got := lastReport(t, rep); got.State != StatePolicyNotApplied { + t.Fatalf("state = %q, want policy_not_applied", got.State) + } +} + +func TestNPMProbeExpectedReceivesRenderedBlockAndYields(t *testing.T) { + // The content-aware probe receives the RENDERED block (not the raw policy) — + // the ~/.npmrc file is user-writable, so a bare marker is not proof; the probe + // compares the desired state. When it reports the MDM lane already governs the + // same state, the reconciler yields mdm_managed without touching the file. + w := &fakeWriter{value: "whatever", present: true} + st := &memStateStore{} + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + var gotArg string + r.ProbeExpected = func(expected string) (bool, string) { + gotArg = expected + return true, "managed npm config present" + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if gotArg != npmRendered { + t.Fatalf("ProbeExpected received %q, want the rendered block %q", gotArg, npmRendered) + } + if w.reads != 0 || len(w.writes) != 0 { + t.Fatalf("managed probe must short-circuit before any file I/O: reads=%d writes=%v", w.reads, w.writes) + } + if got := lastReport(t, rep); got.State != StateMDMManaged || got.AppliedHash != "" { + t.Fatalf("report = %+v, want mdm_managed with no applied_hash", got) + } +} + +func TestNPMConvergedSeamOverridesBodyEquality(t *testing.T) { + // Body equality alone is not convergence for ~/.npmrc: a `registry=` line + // appended BELOW the block leaves the block bytes identical yet overrides it. + // The Converged seam owns that decision. Here on-disk == the rendered block + // (body-equal) and the recorded hash matches, but Converged=false → the + // reconciler still rewrites, where plain body-equality would have skipped. + w := &fakeWriter{value: npmRendered, present: true} + st := &memStateStore{m: map[string]AppliedTargetState{ + msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:N", WrittenValue: npmRendered}, + }} + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + r.Converged = func(string) (bool, error) { return false, nil } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(w.writes) != 1 { + t.Fatalf("Converged=false must force a rewrite even when body-equal, writes=%v", w.writes) + } + if got := lastReport(t, rep); got.State != StateCompliant { + t.Fatalf("state = %q, want compliant", got.State) + } +} + +func TestNPMConvergedTrueIsIdempotent(t *testing.T) { + // Converged=true AND the recorded hash matches → the block is fully in place + // and effective. No write; still reports compliant so the backend sees a fresh + // evaluation. + w := &fakeWriter{value: npmRendered, present: true} + st := &memStateStore{m: map[string]AppliedTargetState{ + msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:N", WrittenValue: npmRendered}, + }} + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + r.Converged = func(string) (bool, error) { return true, nil } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(w.writes) != 0 { + t.Fatalf("converged + hash unchanged must not write, got %v", w.writes) + } + if got := lastReport(t, rep); got.State != StateCompliant || got.AppliedHash != "sha256:N" { + t.Fatalf("report = %+v, want compliant + echoed hash", got) + } +} + +func TestNPMAdoptsAlreadyConvergedState(t *testing.T) { + // Cross-mode store split: the exact block is fully applied on disk + // (Converged=true) but THIS store carries no matching hash — the other + // privilege mode applied and recorded it in its own per-mode store, or our + // record is stale. The reconciler must adopt the on-disk state (no rewrite, no + // false drift) and report compliant, recording the current hash for next cycle. + cases := []struct { + name string + st *memStateStore + }{ + {"empty store (other mode applied)", &memStateStore{}}, + {"stale hash in store", &memStateStore{m: map[string]AppliedTargetState{ + msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:OLD", WrittenValue: npmRendered}, + }}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{value: npmRendered, present: true} + r, rep := newNPMRec(t, npmPolicyEP("sha256:NEW"), w, tc.st) + r.Converged = func(string) (bool, error) { return true, nil } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(w.writes) != 0 { + t.Fatalf("already-converged state must not rewrite, writes=%v", w.writes) + } + if got := lastReport(t, rep); got.State != StateCompliant || got.AppliedHash != "sha256:NEW" { + t.Fatalf("report = %+v, want compliant + adopted hash", got) + } + rec, ok := tc.st.get(CategoryPackageConfig, TargetNPM) + if !ok || rec.AppliedHash != "sha256:NEW" || rec.WrittenValue != npmRendered { + t.Fatalf("state not adopted: rec=%+v ok=%v", rec, ok) + } + }) + } +} + +func TestNPMReadErrorClassification(t *testing.T) { + // A structural refusal on the initial read (the target cannot be enforced at + // all — wraps ErrTargetUnusable) is a write-class fact → write_failed; a plain + // unreadable file stays verification_failed. + cases := []struct { + name string + err error + state string + }{ + {"structural refusal", fmt.Errorf("npmrc: %w", ErrTargetUnusable), StateWriteFailed}, + {"plain unreadable", errors.New("permission denied"), StateVerificationFailed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{readErr: tc.err} + st := &memStateStore{} + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a read error must surface") + } + if len(w.writes) != 0 { + t.Fatalf("nothing must be written on a read error, writes=%v", w.writes) + } + if got := lastReport(t, rep); got.State != tc.state { + t.Fatalf("state = %q, want %q", got.State, tc.state) + } + }) + } +} + +func TestNPMConvergedErrorClassification(t *testing.T) { + // The Converged seam runs its OWN secure read; a structural refusal there is + // the same write-class fact as a refusal on the initial read. + cases := []struct { + name string + err error + state string + }{ + {"structural refusal", fmt.Errorf("npmrc: %w", ErrTargetUnusable), StateWriteFailed}, + {"plain error", errors.New("stat failed"), StateVerificationFailed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{value: "x", present: true} + st := &memStateStore{} + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + r.Converged = func(string) (bool, error) { return false, tc.err } + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a convergence-check error must surface") + } + if len(w.writes) != 0 { + t.Fatalf("nothing must be written on a convergence error, writes=%v", w.writes) + } + if got := lastReport(t, rep); got.State != tc.state { + t.Fatalf("state = %q, want %q", got.State, tc.state) + } + }) + } +} + +func TestNPMClearByMarkerAlwaysClearsAndDrops(t *testing.T) { + // Marker-based ownership: on unassignment the block is removed UNCONDITIONALLY + // (Clear is scoped to our own markers) and the record dropped UNCONDITIONALLY — + // even with no record, and without reading the file — so a lost/empty/drifted + // record can never strand a token-bearing block. + cases := []struct { + name string + st *memStateStore + }{ + {"no record", &memStateStore{}}, + {"stale record", &memStateStore{m: map[string]AppliedTargetState{ + msKey(CategoryPackageConfig, TargetNPM): {WrittenValue: "old-block"}, + }}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{value: "a-managed-block", present: true} + ep := EffectivePolicy{Category: CategoryPackageConfig, Target: TargetNPM, Clear: true} + r, rep := newNPMRec(t, ep, w, tc.st) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if w.clears != 1 { + t.Fatalf("marker clear must call Clear exactly once, clears=%d", w.clears) + } + if w.reads != 0 { + t.Fatalf("marker clear must not read the file, reads=%d", w.reads) + } + if tc.st.drops != 1 { + t.Fatalf("marker clear must Drop the record unconditionally, drops=%d", tc.st.drops) + } + if _, ok := tc.st.get(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("state record must be gone after a marker clear") + } + if len(rep.reports) != 0 { + t.Fatalf("clear reports no compliance state, got %+v", rep.reports) + } + }) + } +} + +func TestNPMRestoreSnapshotRollbackClassification(t *testing.T) { + // After the block is written, the post-write ownership persist fails. The npm + // writer reverts its whole-file change from a snapshot (RestoreSnapshot seam), + // and the OUTCOME is classified: a clean restore → write_failed (the write was + // cleanly undone); a failed/aborted restore → verification_failed (on-disk + // state now unknown). The generic re-write path is NOT used — Writer.Write ran + // once (the enforce) and Clear never ran. + cases := []struct { + name string + restoreErr error + wantState string + }{ + {"restore succeeds", nil, StateWriteFailed}, + {"restore fails", errors.New("path moved under us"), StateVerificationFailed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{} + st := &memStateStore{failWriteFrom: 2} // preflight ok, post-write persist fails + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + restored := 0 + r.RestoreSnapshot = func() error { restored++; return tc.restoreErr } + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a post-write persist failure must surface an error") + } + if restored != 1 { + t.Fatalf("RestoreSnapshot must run exactly once, ran %d", restored) + } + if len(w.writes) != 1 { + t.Fatalf("the generic re-write path must NOT run; Writer.Write should have run once, got %v", w.writes) + } + if w.clears != 0 { + t.Fatalf("RestoreSnapshot replaces the generic clear-based rollback, clears=%d", w.clears) + } + if got := lastReport(t, rep); got.State != tc.wantState { + t.Fatalf("state = %q, want %q", got.State, tc.wantState) + } + }) + } +} + +func TestNPMWriteErrorClassification(t *testing.T) { + // A Writer.Write failure is write_failed by default; the one exception is a + // writer that landed bytes it could neither verify nor roll back + // (ErrWriteUnverified) → verification_failed, since on-disk state is then + // indeterminate. The IDE writer never returns the sentinel, so its Write + // failures stay write_failed (proven in TestSeamFallbacksMatchIDEBehavior). + cases := []struct { + name string + err error + state string + }{ + {"plain write failure", errors.New("disk full"), StateWriteFailed}, + {"unverified rollback", fmt.Errorf("npmrc: commit: %w", ErrWriteUnverified), StateVerificationFailed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{writeErr: tc.err} + st := &memStateStore{} + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a write error must surface") + } + 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 { + t.Fatalf("state = %q, want %q", got.State, tc.state) + } + }) + } +} + +func TestNPMWriterInitErrClassification(t *testing.T) { + // Writer construction failed (Writer nil, WriterInitErr set). The reconciler + // classifies AFTER the fetch by what run-config asked for — it never touches + // disk or state, since there is no resolved target user to act against. + npmEnforce := npmPolicyEP("sha256:N") + npmClear := EffectivePolicy{Category: CategoryPackageConfig, Target: TargetNPM, Clear: true} + cases := []struct { + name string + ep EffectivePolicy + initErr error + wantErr bool + wantReports []string + }{ + {"no target user + enforce → policy_not_applied", npmEnforce, ErrNoTargetUser, true, []string{StatePolicyNotApplied}}, + {"other failure + enforce → write_failed", npmEnforce, errors.New("home unopenable"), true, []string{StateWriteFailed}}, + {"clear + no writer → retain, no report", npmClear, ErrNoTargetUser, false, nil}, + {"absent + no writer → silent", EffectivePolicy{}, ErrNoTargetUser, false, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rep := &fakeReporter{} + r := &Reconciler{ + Fetcher: &fakeFetcher{ep: tc.ep}, + Reporter: rep, + Writer: nil, + WriterInitErr: tc.initErr, + CustomerID: "cust", + DeviceID: "SERIAL-1", + Platform: "darwin", + Category: CategoryPackageConfig, + Target: TargetNPM, + OwnsByMarker: true, + } + err := r.Reconcile(context.Background()) + if tc.wantErr != (err != nil) { + t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr) + } + if len(rep.reports) != len(tc.wantReports) { + t.Fatalf("reports = %+v, want %v", rep.reports, tc.wantReports) + } + for i, want := range tc.wantReports { + if rep.reports[i].State != want { + t.Fatalf("report[%d] state = %q, want %q", i, rep.reports[i].State, want) + } + if rep.reports[i].Category != CategoryPackageConfig || rep.reports[i].Target != TargetNPM { + t.Fatalf("report[%d] identity = %q/%q, want package_config/npm", + i, rep.reports[i].Category, rep.reports[i].Target) + } + } + }) + } +} + +func TestNPMStateRoutingBypassesSharedFile(t *testing.T) { + // With the State seam set, every ownership access routes to the injected store; + // the shared device-policy-state.json is never created or read. This keeps the + // npm record out of the shared file's unlocked read-modify-write. + path := filepath.Join(t.TempDir(), CacheFilename) + restore := SetCachePathForTest(path) + defer restore() + + w := &fakeWriter{} + st := &memStateStore{} + r, _ := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if st.writes == 0 { + t.Fatal("ownership must have routed through the State seam") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("shared state file must never be created when State is set; stat err = %v", err) + } + if _, ok := ReadAppliedState(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("shared store must hold no npm record") + } +} + +func TestSeamFallbacksMatchIDEBehavior(t *testing.T) { + // Every seam at its zero value must reproduce the settings.json behavior — the + // fallbacks the IDE wiring relies on (it sets none of the seams). This pins the + // nil-seam contract directly, next to the reconcile_test.go path that exercises + // it end to end. + r := &Reconciler{} + + // renderValue → compacted policy JSON, not a rendered block. + got, err := r.renderValue(json.RawMessage(samplePolicyWire)) + if err != nil || got != samplePolicy { + t.Fatalf("renderValue fallback = %q err=%v, want %q", got, err, samplePolicy) + } + + // converged → plain body equality over the already-read value. + if ok, _ := r.converged("v", "v", true); !ok { + t.Fatal("converged fallback must be true when present and body-equal") + } + if ok, _ := r.converged("v", "v", false); ok { + t.Fatal("converged fallback must be false when not present") + } + if ok, _ := r.converged("v", "other", true); ok { + t.Fatal("converged fallback must be false when the body differs") + } + + // classifyReadError → verification_failed for a plain error (the IDE writer + // never wraps ErrTargetUnusable); write_failed only for the structural sentinel. + if s := classifyReadError(errors.New("plain")); s != StateVerificationFailed { + t.Fatalf("classifyReadError(plain) = %q, want verification_failed", s) + } + if s := classifyReadError(fmt.Errorf("x: %w", ErrTargetUnusable)); s != StateWriteFailed { + t.Fatalf("classifyReadError(unusable) = %q, want write_failed", s) + } + + // classifyWriteError → write_failed by default (the IDE writer never returns the + // unverified-rollback sentinel); verification_failed only for ErrWriteUnverified. + if s := classifyWriteError(errors.New("plain")); s != StateWriteFailed { + t.Fatalf("classifyWriteError(plain) = %q, want write_failed", s) + } + if s := classifyWriteError(fmt.Errorf("x: %w", ErrWriteUnverified)); s != StateVerificationFailed { + t.Fatalf("classifyWriteError(unverified) = %q, want verification_failed", s) + } +} diff --git a/internal/devicepolicy/statestore.go b/internal/devicepolicy/statestore.go new file mode 100644 index 0000000..6340a46 --- /dev/null +++ b/internal/devicepolicy/statestore.go @@ -0,0 +1,274 @@ +package devicepolicy + +import ( + "encoding/json" + "errors" + "os" + "os/user" + "path/filepath" + "runtime" + "strconv" + "sync" +) + +// StateStore is the ownership store the reconciler reads and writes through. It +// abstracts WHERE a category's applied-state records live so a category can keep +// its records in its own file instead of the shared device-policy-state.json. +// The three methods mirror the package-level ReadAppliedState / +// WriteAppliedState / ClearAppliedState the shared IDE path still calls directly. +// +// The npm category always uses one of these; the IDE category leaves it nil and +// keeps the shared store, byte-for-byte unchanged. +type StateStore interface { + Read(category, target string) (AppliedTargetState, bool) + Write(category, target string, s AppliedTargetState) error + Drop(category, target string) error +} + +// packageConfigStateBasename is the state-file basename for the package_config +// category. It is DELIBERATELY separate from CacheFilename (the shared IDE +// state): neither store takes a cross-process lock, so the only thing that keeps +// a package reconcile in one process from silently dropping a concurrent IDE +// reconcile's record in another (or vice versa) is that they never share a file. +// Separate files make that lost update structurally impossible. +const packageConfigStateBasename = "package-config-state" + +// NewStateStoreFor builds the package_config ownership store for a resolved +// target user. It is bound to the identity handed in — the writer's +// TargetUser(), resolved exactly once — never a second independent lookup, so a +// console-user switch between two resolutions cannot file one user's state under +// another's record. +// +// Placement: +// - user mode (the process IS the target user; the common case, and every +// Windows case) → /.stepsecurity/package-config-state.json (0600). +// - root mode (a POSIX daemon running as uid 0) → a machine-owned directory +// (/Library/Application Support/StepSecurity on macOS, /var/lib/stepsecurity +// on Linux; 0700 root) with ONE record file per target uid +// (package-config-state-.json). The shared store's ~/.stepsecurity is +// unsafe under root here: the LaunchDaemon bakes the install-time console +// user's HOME into its plist, so os.UserHomeDir() resolves into a +// user-controlled tree and stays pinned to that user even after someone else +// logs in. Per-uid files also keep one console user's ~/.npmrc state from +// conflating with another's across cycles (false drift / wrong ownership). +func NewStateStoreFor(u *user.User) StateStore { + if dir, uid, ok := rootMachineStateDir(u); ok { + return &fileStateStore{ + dir: dir, + path: filepath.Join(dir, packageConfigStateBasename+"-"+uid+".json"), + } + } + home := "" + if u != nil { + home = u.HomeDir + } + dir := filepath.Join(home, ".stepsecurity") + return &fileStateStore{ + dir: dir, + path: filepath.Join(dir, packageConfigStateBasename+".json"), + } +} + +// rootMachineStateDir returns the machine-owned state directory and the target +// user's uid string when the process is a POSIX root daemon with a usable +// per-user identity. ok=false — meaning user-mode placement — when the process +// is not root (Geteuid is -1 on Windows, so it never is there), the OS has no +// defined machine path, or the uid is unresolvable. +func rootMachineStateDir(u *user.User) (dir, uid string, ok bool) { + if os.Geteuid() != 0 || u == nil { + return "", "", false + } + if _, err := strconv.Atoi(u.Uid); err != nil { + return "", "", false + } + switch runtime.GOOS { + case "darwin": + return "/Library/Application Support/StepSecurity", u.Uid, true + case "linux": + return "/var/lib/stepsecurity", u.Uid, true + default: + return "", "", false + } +} + +// fileStateStore is a StateStore backed by one JSON file at a fixed path. It +// carries the same schema-versioned category→target→record shape and the same +// atomic-replace (temp + fsync + rename) + future-schema-refusal discipline as +// the shared cache.go store — only the path differs, and the file belongs to +// this category alone. A process-local mutex serializes its read-modify-write +// within a process; across processes there is no lock — the atomic temp+rename +// keeps the file from tearing, and a concurrent overlap is eventually consistent +// (identical records while the policy is stable; a transient stale record only +// during a policy transition, reconverged on the next cycle). +type fileStateStore struct { + dir string + path string + mu sync.Mutex +} + +func (s *fileStateStore) Read(category, target string) (AppliedTargetState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + f, status := s.load() + if status != stateReadable { + return AppliedTargetState{}, false + } + cat, ok := f.Categories[category] + if !ok { + return AppliedTargetState{}, false + } + st, ok := cat.Targets[target] + return st, ok +} + +func (s *fileStateStore) Write(category, target string, st AppliedTargetState) error { + s.mu.Lock() + defer s.mu.Unlock() + + f, status := s.load() + switch status { + case stateFuture: + return errFutureSchema + case stateAbsentOrCorrupt: + f = AppliedStateFile{Categories: map[string]AppliedCategoryState{}} + } + if f.Categories == nil { + f.Categories = map[string]AppliedCategoryState{} + } + cat := f.Categories[category] + if cat.Targets == nil { + cat.Targets = map[string]AppliedTargetState{} + } + cat.Targets[target] = st + f.Categories[category] = cat + return s.persist(f) +} + +func (s *fileStateStore) Drop(category, target string) error { + s.mu.Lock() + defer s.mu.Unlock() + + f, status := s.load() + switch status { + case stateFuture: + return errFutureSchema + case stateAbsentOrCorrupt: + // Absent → nothing to drop. Corrupt (present but unparseable) → the bytes on + // disk can still carry a token-bearing WrittenValue; a Drop that left the file + // in place would strand that credential after offboarding. Remove it so no + // stale record — readable or not — survives the clear. + return s.removeIfPresent() + } + cat, ok := f.Categories[category] + if !ok { + return nil + } + if _, ok := cat.Targets[target]; !ok { + return nil + } + delete(cat.Targets, target) + if len(cat.Targets) == 0 { + delete(f.Categories, category) + } else { + f.Categories[category] = cat + } + return s.persist(f) +} + +// removeIfPresent deletes this store's file, treating an already-absent file as +// success. It underpins Drop's corrupt-file cleanup: a corrupt state file is +// removed rather than left to strand a token-bearing record on disk. A symlink at +// the path is unlinked (not its target), so it cannot redirect the delete. +func (s *fileStateStore) removeIfPresent() error { + if s.path == "" { + return nil + } + if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +// load reads and classifies this store's file. UNLOCKED: callers hold s.mu. It +// is the path-parameterized twin of cache.go's readStateFile and reuses the same +// schema-version classification (corrupt → recreate, newer → refuse). +func (s *fileStateStore) load() (AppliedStateFile, readStatus) { + if s.path == "" { + return AppliedStateFile{}, stateAbsentOrCorrupt + } + // #nosec G304 -- s.path is built from the resolved target user's home or a + // fixed machine directory plus package constants, never external input. + b, err := os.ReadFile(s.path) + if err != nil { + return AppliedStateFile{}, stateAbsentOrCorrupt + } + ver, ok := peekSchemaVersion(b) + if !ok { + return AppliedStateFile{}, stateAbsentOrCorrupt + } + if ver > CacheSchemaVersion { + return AppliedStateFile{}, stateFuture + } + var f AppliedStateFile + if err := json.Unmarshal(b, &f); err != nil { + return AppliedStateFile{}, stateAbsentOrCorrupt + } + if f.SchemaVersion == 0 { + f.SchemaVersion = CacheSchemaVersion + } + if f.Categories == nil { + f.Categories = map[string]AppliedCategoryState{} + } + return f, stateReadable +} + +// persist stamps the schema version and atomically replaces the file, creating +// the parent dir 0700 and the file 0600. UNLOCKED: callers hold s.mu. The +// path-parameterized twin of cache.go's persistStateFile. On POSIX root the dir +// and file land root-owned (machine state); in user mode they are the target +// user's own — either way not group/other-accessible. +func (s *fileStateStore) persist(f AppliedStateFile) error { + f.SchemaVersion = CacheSchemaVersion + if f.Categories == nil { + f.Categories = map[string]AppliedCategoryState{} + } + if s.path == "" || s.dir == "" { + return errNoHomeDir + } + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + if err := os.MkdirAll(s.dir, cacheParentDirMode); err != nil { + return err + } + tmp, err := os.CreateTemp(s.dir, "."+packageConfigStateBasename+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + if _, statErr := os.Stat(tmpPath); statErr == nil { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpPath, cacheFileMode); err != nil { + return err + } + return os.Rename(tmpPath, s.path) +} diff --git a/internal/devicepolicy/statestore_test.go b/internal/devicepolicy/statestore_test.go new file mode 100644 index 0000000..a5a12b8 --- /dev/null +++ b/internal/devicepolicy/statestore_test.go @@ -0,0 +1,215 @@ +package devicepolicy + +import ( + "os" + "os/user" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func newFileStore(t *testing.T) *fileStateStore { + t.Helper() + dir := t.TempDir() + return &fileStateStore{ + dir: dir, + path: filepath.Join(dir, packageConfigStateBasename+".json"), + } +} + +func TestFileStateStoreRoundTrip(t *testing.T) { + s := newFileStore(t) + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("an empty store must read absent") + } + want := AppliedTargetState{ + AppliedHash: "sha256:N", + WrittenValue: "managed-block", + FetchedAt: time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC), + } + if err := s.Write(CategoryPackageConfig, TargetNPM, want); err != nil { + t.Fatalf("Write: %v", err) + } + got, ok := s.Read(CategoryPackageConfig, TargetNPM) + if !ok || got.AppliedHash != want.AppliedHash || got.WrittenValue != want.WrittenValue || !got.FetchedAt.Equal(want.FetchedAt) { + t.Fatalf("Read = %+v ok=%v, want %+v", got, ok, want) + } + // The file is created 0600 (not group/other-accessible). POSIX-only: Windows + // has no permission bits, so Chmod can't set 0600 and Stat reports 0666 for any + // writable file. + if runtime.GOOS != "windows" { + info, err := os.Stat(s.path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != cacheFileMode { + t.Fatalf("file mode = %o, want %o", info.Mode().Perm(), cacheFileMode) + } + } + if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { + t.Fatalf("Drop: %v", err) + } + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("a dropped record must read absent") + } +} + +func TestFileStateStorePreservesSiblings(t *testing.T) { + // The read-modify-write must preserve every other category/target — the same + // guarantee the shared cache makes, applied to this category's own file. + s := newFileStore(t) + npm := AppliedTargetState{AppliedHash: "sha256:N", WrittenValue: "npm-block"} + ide := AppliedTargetState{AppliedHash: "sha256:V", WrittenValue: "vscode-value"} + if err := s.Write(CategoryPackageConfig, TargetNPM, npm); err != nil { + t.Fatal(err) + } + if err := s.Write(CategoryIDEExtension, TargetVSCode, ide); err != nil { + t.Fatal(err) + } + if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { + t.Fatal(err) + } + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("the dropped npm record must be gone") + } + got, ok := s.Read(CategoryIDEExtension, TargetVSCode) + if !ok || got.WrittenValue != "vscode-value" { + t.Fatalf("the sibling record must survive, got %+v ok=%v", got, ok) + } +} + +func TestFileStateStoreRefusesFutureSchema(t *testing.T) { + // An older agent meeting a NEWER agent's state file must not overwrite it: + // Read yields "owns nothing", and Write/Drop refuse rather than clobber + // metadata they can't interpret. The file stays byte-identical. + dir := t.TempDir() + path := filepath.Join(dir, packageConfigStateBasename+".json") + future := `{"schema_version":999,"categories":{"package_config":{"targets":` + + `{"npm":{"applied_hash":"sha256:z","written_value":"blk","fetched_at":"2026-07-01T00:00:00Z"}}}}}` + "\n" + if err := os.WriteFile(path, []byte(future), 0o600); err != nil { + t.Fatal(err) + } + s := &fileStateStore{dir: dir, path: path} + + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("a future-schema file must read as absent") + } + if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenValue: "x"}); err != errFutureSchema { + t.Fatalf("Write err = %v, want errFutureSchema", err) + } + if err := s.Drop(CategoryPackageConfig, TargetNPM); err != errFutureSchema { + t.Fatalf("Drop err = %v, want errFutureSchema", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != future { + t.Fatalf("a future-schema file must be left byte-identical, got %q", string(after)) + } +} + +func TestFileStateStoreRecreatesCorruptFile(t *testing.T) { + // A corrupt (non-JSON-object) file reads as absent and is recreated cleanly on + // the next write — never surfaced as an error. + dir := t.TempDir() + path := filepath.Join(dir, packageConfigStateBasename+".json") + if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + s := &fileStateStore{dir: dir, path: path} + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("a corrupt file must read as absent") + } + if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenValue: "blk"}); err != nil { + t.Fatalf("Write over a corrupt file must recreate it, got %v", err) + } + got, ok := s.Read(CategoryPackageConfig, TargetNPM) + if !ok || got.WrittenValue != "blk" { + t.Fatalf("record after recreate = %+v ok=%v", got, ok) + } +} + +func TestFileStateStoreDropAbsentIsNoOp(t *testing.T) { + s := newFileStore(t) + // No file yet. + if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { + t.Fatalf("dropping from an absent store must be a no-op, got %v", err) + } + // A file that exists but holds no such record. + if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenValue: "x"}); err != nil { + t.Fatal(err) + } + if err := s.Drop(CategoryIDEExtension, TargetVSCode); err != nil { + t.Fatalf("dropping an absent record must be a no-op, got %v", err) + } + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); !ok { + t.Fatal("the existing record must be untouched") + } +} + +func TestFileStateStoreDropRemovesCorruptFile(t *testing.T) { + // A corrupt state file can still carry token-bearing WrittenValue bytes. Drop + // (offboarding) must not report success while leaving those bytes on disk — it + // removes the file so no stale credential survives the clear. (Contrast an + // absent file, which stays a no-op — nothing to remove.) + dir := t.TempDir() + path := filepath.Join(dir, packageConfigStateBasename+".json") + corrupt := `{ broken "written_value": "ssabc123::dev:S1` // invalid JSON, token-shaped bytes + if err := os.WriteFile(path, []byte(corrupt), 0o600); err != nil { + t.Fatal(err) + } + s := &fileStateStore{dir: dir, path: path} + if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { + t.Fatalf("Drop over a corrupt file must succeed, got %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("Drop must remove a corrupt state file, stat err = %v", err) + } +} + +func TestNewStateStoreForPlacement(t *testing.T) { + // Placement depends on whether the process is a POSIX root daemon. Assert the + // branch that matches this test process's euid so the test is host-portable. + u := &user.User{Uid: "501", HomeDir: filepath.Join(t.TempDir(), "home")} + s, ok := NewStateStoreFor(u).(*fileStateStore) + if !ok { + t.Fatal("NewStateStoreFor must return a *fileStateStore") + } + base := filepath.Base(s.path) + if os.Geteuid() == 0 { + // Root daemon → machine-owned dir + ONE record file per target uid. + if !strings.HasPrefix(s.path, "/Library/Application Support/StepSecurity") && + !strings.HasPrefix(s.path, "/var/lib/stepsecurity") { + t.Fatalf("root-mode path must be machine-owned, got %q", s.path) + } + if base != packageConfigStateBasename+"-501.json" { + t.Fatalf("root-mode file must be per-uid, got %q", base) + } + } else { + // User mode → under the target user's ~/.stepsecurity. + wantDir := filepath.Join(u.HomeDir, ".stepsecurity") + if s.dir != wantDir { + t.Fatalf("user-mode dir = %q, want %q", s.dir, wantDir) + } + if base != packageConfigStateBasename+".json" { + t.Fatalf("user-mode file = %q, want %q", base, packageConfigStateBasename+".json") + } + } +} + +func TestNewStateStoreForNonNumericUIDIsUserMode(t *testing.T) { + // A non-numeric uid (a Windows SID, or an unresolved identity) cannot select a + // per-uid machine file, so placement falls back to user mode — proven by the + // user-mode basename regardless of euid. + u := &user.User{Uid: "S-1-5-21-abc", HomeDir: filepath.Join(t.TempDir(), "home")} + s, ok := NewStateStoreFor(u).(*fileStateStore) + if !ok { + t.Fatal("NewStateStoreFor must return a *fileStateStore") + } + if base := filepath.Base(s.path); base != packageConfigStateBasename+".json" { + t.Fatalf("a non-numeric uid must yield the user-mode file, got %q", base) + } +} diff --git a/internal/devicepolicy/verify.go b/internal/devicepolicy/verify.go index 46378a5..d15414d 100644 --- a/internal/devicepolicy/verify.go +++ b/internal/devicepolicy/verify.go @@ -29,6 +29,17 @@ const CategoryIDEExtension = "ide_extension" // report. const TargetVSCode = "vscode" +// CategoryPackageConfig / TargetNPM identify the second policy category: the +// managed StepSecurity secure-registry block inside the console user's ~/.npmrc +// (see npmrc.go). They mirror agent-api's package_config / npm identifiers and, +// like the IDE pair, are passed as ?category=/?target= on the run-config fetch +// and echoed in the compliance report. The Verify() states are shared verbatim; +// this category adds no new state. +const ( + CategoryPackageConfig = "package_config" + TargetNPM = "npm" +) + // VerifyInput is the result set the verifier reasons over. It is intentionally // pure data: the writer performs the I/O (write + readback), so Verify itself // touches nothing.