diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 944e3a7..6b92c7d 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -893,6 +893,7 @@ func runPackageConfigLanes(exec executor.Executor, log *progress.Logger, fetcher } func runNPMPackageConfigLane(ctx context.Context, exec executor.Executor, log *progress.Logger, fetcher devicepolicy.Fetcher, reporter devicepolicy.Reporter, customerID, serial, platform string) error { + var w *devicepolicy.NPMRCWriter r := &devicepolicy.Reconciler{ Fetcher: fetcher, Reporter: reporter, @@ -909,12 +910,12 @@ func runNPMPackageConfigLane(ctx context.Context, exec executor.Executor, log *p OwnershipStateValue: devicepolicy.NPMOwnershipValue, Logf: func(format string, args ...any) { log.Debug(format, args...) }, } - - w, err := devicepolicy.NewNPMRCWriter(exec) - if err != nil { - r.WriterInitErr = err - } else { - defer w.Close() + r.InitWriter = func() error { + var err error + w, err = devicepolicy.NewNPMRCWriter(exec) + if err != nil { + return err + } w.SetLogf(func(format string, args ...any) { log.Debug(format, args...) }) r.Writer = w r.Converged = w.Converged @@ -923,8 +924,13 @@ func runNPMPackageConfigLane(ctx context.Context, exec executor.Executor, log *p r.ProbeExpected = w.ProbeExpected r.RestoreSnapshot = w.RestoreSnapshot r.ProbeContent = w.ProbeContentNPM + return nil + } + err := r.Reconcile(ctx) + if w != nil { + _ = w.Close() } - return r.Reconcile(ctx) + return err } func runPyPIPackageConfigLane(ctx context.Context, exec executor.Executor, log *progress.Logger, fetcher devicepolicy.Fetcher, reporter devicepolicy.Reporter, customerID, serial, platform string) error { diff --git a/cmd/stepsecurity-dev-machine-guard/main_devicepolicy_test.go b/cmd/stepsecurity-dev-machine-guard/main_devicepolicy_test.go index 7f345ee..2298f9b 100644 --- a/cmd/stepsecurity-dev-machine-guard/main_devicepolicy_test.go +++ b/cmd/stepsecurity-dev-machine-guard/main_devicepolicy_test.go @@ -53,6 +53,17 @@ func (npmLaneFetcher) Fetch(context.Context, string, string, string, string) (de }, nil } +type invalidNPMSettingsFetcher struct{} + +func (invalidNPMSettingsFetcher) Fetch(context.Context, string, string, string, string) (devicepolicy.EffectivePolicy, error) { + return devicepolicy.EffectivePolicy{ + Category: devicepolicy.CategoryPackageConfig, + Target: devicepolicy.TargetNPM, + Policy: []byte(`{"ecosystem":"npm","registry_url":"https://registry-int.stepsecurity.io/javascript","auth":{"scheme":"stepsecurity_device_token","api_key":"device-secret"},"settings":null}`), + Hash: "sha256:npm", + }, nil +} + type countingTargetExecutor struct { *executor.Mock user *user.User @@ -120,6 +131,17 @@ func TestNPMPackageConfigLanePersistsSecretFreeOwnership(t *testing.T) { } } +func TestNPMPackageConfigLane_ValidatesSettingsBeforeResolvingTargetUser(t *testing.T) { + exec := &countingTargetExecutor{Mock: executor.NewMock()} + err := runNPMPackageConfigLane(context.Background(), exec, progress.NewNoop(), invalidNPMSettingsFetcher{}, packageConfigReporter{}, "customer", "serial", "linux") + if err == nil { + t.Fatal("invalid settings must fail") + } + if got, want := exec.calls, 0; got != want { + t.Fatalf("LoggedInUser calls = %d, want %d", got, want) + } +} + func TestPackageConfigLanes_FailureDoesNotSuppressSibling(t *testing.T) { t.Setenv("STEPSECURITY_HOME", t.TempDir()) tests := []struct { diff --git a/internal/devicepolicy/go_env_writer.go b/internal/devicepolicy/go_env_writer.go index 3deebb7..1a52420 100644 --- a/internal/devicepolicy/go_env_writer.go +++ b/internal/devicepolicy/go_env_writer.go @@ -15,9 +15,9 @@ import ( ) const ( - dmgGoEnvBegin = "# BEGIN StepSecurity Go Secure Registry GOPROXY -- managed by dmg" - mdmGoEnvBegin = "# BEGIN StepSecurity Go Secure Registry GOPROXY -- managed by mdm" - goEnvEnd = "# END StepSecurity Go Secure Registry GOPROXY" + dmgGoEnvBegin = "# BEGIN StepSecurity Package Configuration go -- managed by dmg" + mdmGoEnvBegin = "# BEGIN StepSecurity Package Configuration go -- managed by mdm" + goEnvEnd = "# END StepSecurity Package Configuration go" dmgGoEnvDisabledPrefix = "# [stepsecurity-go-env-dmg] " mdmGoEnvDisabledPrefix = "# [stepsecurity-go-env-mdm] " dmgGoEnvCreatedFile = "# [stepsecurity-go-env-dmg] created=true" diff --git a/internal/devicepolicy/netrc_writer.go b/internal/devicepolicy/netrc_writer.go index 8d7e029..6f63136 100644 --- a/internal/devicepolicy/netrc_writer.go +++ b/internal/devicepolicy/netrc_writer.go @@ -14,15 +14,15 @@ import ( ) const ( - dmgNetrcBegin = "#stepsecurity-secure-registry-credential-dmg-begin" - dmgNetrcEnd = "#stepsecurity-secure-registry-credential-end" + dmgNetrcBegin = "#stepsecurity-package-config-credential-dmg-begin" + dmgNetrcEnd = "#stepsecurity-package-config-credential-end" - mdmNetrcBegin = "#stepsecurity-secure-registry-credential-mdm-begin" - mdmNetrcEnd = "#stepsecurity-secure-registry-credential-end" + mdmNetrcBegin = "#stepsecurity-package-config-credential-mdm-begin" + mdmNetrcEnd = "#stepsecurity-package-config-credential-end" - dmgNetrcDisabledPrefix = "#stepsecurity-secure-registry-credential-dmg-disabled:" - mdmNetrcDisabledPrefix = "#stepsecurity-secure-registry-credential-mdm-disabled:" - mdmNetrcCreated = "#stepsecurity-secure-registry-credential-mdm-created" + dmgNetrcDisabledPrefix = "#stepsecurity-package-config-credential-dmg-disabled:" + mdmNetrcDisabledPrefix = "#stepsecurity-package-config-credential-mdm-disabled:" + mdmNetrcCreated = "#stepsecurity-package-config-credential-mdm-created" netrcBackupPrefix = ".dmg-" ) diff --git a/internal/devicepolicy/netrc_writer_test.go b/internal/devicepolicy/netrc_writer_test.go index f9a91d1..247f3fa 100644 --- a/internal/devicepolicy/netrc_writer_test.go +++ b/internal/devicepolicy/netrc_writer_test.go @@ -27,13 +27,13 @@ func TestNetrcMarkers_Canonical(t *testing.T) { got string want string }{ - {"DMG begin", dmgNetrcBegin, "#stepsecurity-secure-registry-credential-dmg-begin"}, - {"DMG end", dmgNetrcEnd, "#stepsecurity-secure-registry-credential-end"}, - {"MDM begin", mdmNetrcBegin, "#stepsecurity-secure-registry-credential-mdm-begin"}, - {"MDM end", mdmNetrcEnd, "#stepsecurity-secure-registry-credential-end"}, - {"DMG disabled prefix", dmgNetrcDisabledPrefix, "#stepsecurity-secure-registry-credential-dmg-disabled:"}, - {"MDM disabled prefix", mdmNetrcDisabledPrefix, "#stepsecurity-secure-registry-credential-mdm-disabled:"}, - {"MDM created", mdmNetrcCreated, "#stepsecurity-secure-registry-credential-mdm-created"}, + {"DMG begin", dmgNetrcBegin, "#stepsecurity-package-config-credential-dmg-begin"}, + {"DMG end", dmgNetrcEnd, "#stepsecurity-package-config-credential-end"}, + {"MDM begin", mdmNetrcBegin, "#stepsecurity-package-config-credential-mdm-begin"}, + {"MDM end", mdmNetrcEnd, "#stepsecurity-package-config-credential-end"}, + {"DMG disabled prefix", dmgNetrcDisabledPrefix, "#stepsecurity-package-config-credential-dmg-disabled:"}, + {"MDM disabled prefix", mdmNetrcDisabledPrefix, "#stepsecurity-package-config-credential-mdm-disabled:"}, + {"MDM created", mdmNetrcCreated, "#stepsecurity-package-config-credential-mdm-created"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -82,7 +82,7 @@ func TestNetrcWriter_CredentialOwnershipLinesAreSingleTokens(t *testing.T) { t.Fatalf("credential ownership lines = %q, want %d", ownershipLines, tc.wantLines) } for _, line := range ownershipLines { - if !strings.HasPrefix(line, "#stepsecurity-secure-registry-credential") || strings.ContainsAny(line, " \t\r") { + if !strings.HasPrefix(line, "#stepsecurity-package-config-credential") || strings.ContainsAny(line, " \t\r") { t.Errorf("credential ownership line %q is not one whitespace-free token", line) } } diff --git a/internal/devicepolicy/npmrc.go b/internal/devicepolicy/npmrc.go index 14fd618..e0e1a9f 100644 --- a/internal/devicepolicy/npmrc.go +++ b/internal/devicepolicy/npmrc.go @@ -16,6 +16,8 @@ import ( "sort" "strconv" "strings" + "unicode" + "unicode/utf8" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/secureuserfile" @@ -24,7 +26,9 @@ import ( // 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 +// tenant's StepSecurity secure registry and/or a policy-managed set of scalar +// npm settings (a third-party default or scoped registry with an +// environment-referenced token, plus ordinary options). 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 @@ -42,6 +46,10 @@ const ( // 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" + // npmrcMDMBeginMarker and npmrcMDMEndMarker bound the variable-size block + // emitted by MDM when npm settings are present. + npmrcMDMBeginMarker = "# BEGIN StepSecurity Secure Registry -- managed by mdm" + npmrcMDMEndMarker = "# END StepSecurity Secure Registry -- managed by mdm" ) // NPMOwnedKey is the WrittenSettings key the npm lane records ownership under. @@ -51,21 +59,25 @@ const NPMOwnedKey = "npmrc" // configuration. Exact content and effectiveness are verified from disk. const NPMOwnershipValue = "dmg_marker_v1" -// The observed-bag keys and auth verdicts of the MDM verify-only report. They are -// WIRE-PERMANENT: the backend validates exactly these three keys and rejects any -// other (a secret-ingest guard), and maps auth_token_status to a redacted auth -// change. auth_token_status is the ONLY axis decided on-device, because deciding -// it backend-side would mean transmitting a token. +// The observed-bag keys and verdicts of the MDM verify-only report. They are +// WIRE-PERMANENT: the backend accepts the base three-key StepSecurity shape, the +// four-key combined shape, and the two-key settings-only shape (ecosystem plus +// settings_status). Both secret-bearing domains are reduced to status enums +// on-device. const ( observedKeyEcosystem = "ecosystem" observedKeyRegistryURL = "registry_url" // #nosec G101 -- a JSON field NAME, not a credential; the value it carries is // one of the three verdicts below and never token material. observedKeyAuthTokenStatus = "auth_token_status" + observedKeySettingsStatus = "settings_status" authTokenMatch = "match" authTokenMismatch = "mismatch" authTokenAbsent = "absent" + settingsMatch = "match" + settingsMismatch = "mismatch" + settingsAbsent = "absent" ) // npmrcMaxRegistryURLBytes caps the observed registry_url before transmission. @@ -89,9 +101,12 @@ const ( // 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 + // npmrcMaxRenderedBytes caps the complete rendered body. Anything past // this is a malformed policy, not a block to write. - npmrcMaxRenderedBytes = 4 << 10 + npmrcMaxRenderedBytes = 4 << 10 + npmrcMaxSettings = 50 + npmrcMaxSettingKeyBytes = 512 + npmrcMaxSettingValueBytes = 4096 // npmrcMaxKeyBytes / npmrcMaxSerialBytes bound the two variable-length // fields the renderer accepts. npmrcMaxKeyBytes = 256 @@ -1149,15 +1164,68 @@ func randomSuffix() (string, error) { return hex.EncodeToString(b[:]), nil } +// parseNPMDesired parses a rendered body into the one form shared by rewrite, +// convergence, MDM verification, and observation. It accepts exactly what +// RenderNPMRCBlock produces: an optional leading StepSecurity pair followed by +// the byte-sorted settings, or the sorted settings alone. The pair is +// recognized by the two-line shape RenderNPMRCBlock emits, a bare `registry=` +// line immediately followed by a `//…:_authToken=` line, not by the bare +// `registry` key alone: a settings-only body may carry `registry` as an +// ordinary setting, but never in that position, because byte order sorts every +// `//`-scoped key before it. +func parseNPMDesired(body string) (npmDesired, bool) { + if strings.HasSuffix(body, "\n") { + return npmDesired{}, false + } + lines := strings.Split(body, "\n") + values := make(map[string]string, len(lines)) + entries := make([]npmSetting, 0, len(lines)) + for _, line := range lines { + // Compare the desired body using the same semantics npm applies when it + // reads the rendered bytes, notably doubled backslashes. + key, value, ok := activeKV(line) + if !ok || key == "" { + return npmDesired{}, false + } + if _, duplicate := values[key]; duplicate { + return npmDesired{}, false + } + values[key] = value + entries = append(entries, npmSetting{key: key, value: value}) + } + desired := npmDesired{body: body, settings: entries, values: values} + if len(entries) >= 2 && isStepSecurityPair(entries[0], entries[1]) { + desired.registry = entries[0].value + desired.tokenKey = entries[1].key + desired.tokenValue = entries[1].value + desired.settings = entries[2:] + } + return desired, true +} + +// isStepSecurityPair reports whether two leading rendered lines have the shape +// of the StepSecurity registry and device-token pair. Only the shape is judged, +// not the values: an observed MDM body with a drifted registry or a shared +// tenant token must still parse so its settings can be compared. +func isStepSecurityPair(registry, token npmSetting) bool { + return registry.key == "registry" && registry.value != "" && + strings.HasPrefix(token.key, "//") && strings.HasSuffix(token.key, ":_authToken") && token.value != "" +} + // --------------------------------------------------------------------------- // 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. +// section header, comment out active bare `registry=` lines when the +// StepSecurity registry is desired, 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) { + desired, ok := parseNPMDesired(body) + if !ok { + return nil, fmt.Errorf("npmrc: expected value is not a rendered npm policy: %w", ErrTargetUnusable) + } 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) @@ -1168,6 +1236,9 @@ func (w *NPMRCWriter) rewriteContent(current []byte, body string) ([]byte, error if strippedToEOF { w.log("npmrc: managed block had no END marker; stripped to EOF and rewriting") } + if countMarker(lines, npmrcMDMMarker) != 0 || countMarker(lines, npmrcMDMBeginMarker) != 0 || countMarker(lines, npmrcMDMEndMarker) != 0 { + return nil, fmt.Errorf("npmrc: file contains an mdm-managed block; cannot safely append: %w", ErrTargetUnusable) + } 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 @@ -1178,14 +1249,21 @@ func (w *NPMRCWriter) rewriteContent(current []byte, body string) ([]byte, error 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) } - if _, tokKey, _, _ := parseExpected(body); hasArrayAppendOverride(lines, tokKey) { + if hasArrayAppendOverride(lines, desired) { // npm folds `registry[]=` and our block's `registry=` into one array, so the // block would be present and last-wins yet npm would not resolve to the // tenant registry alone. Commenting the array line out is not enough (npm // arrays are order-independent), so refuse the transform. return nil, fmt.Errorf("npmrc: file uses npm array-append syntax on a managed key; cannot safely transform: %w", ErrTargetUnusable) } - lines = commentBareRegistry(lines) + if desired.stepSecurity() { + lines = commentBareRegistry(lines) + } else { + // A settings-only policy treats `registry` as an ordinary last-wins + // setting, so a bare registry line the StepSecurity shape had previously + // commented out is handed back to the user before the new block lands. + lines = unprefixDMG(lines) + } base := strings.Join(lines, "\n") var buf bytes.Buffer @@ -1353,19 +1431,15 @@ func hasCoercibleQuotedKey(lines []string) bool { // registry= + registry[]= → "," // registry[]= + registry= → "," // -// Only the keys we manage are judged, so an unrelated array config (`omit[]=dev`) -// is left alone. tokenKey is the single `//host/path/:_authToken` this writer -// manages: npm consults exactly that key for the tenant registry's credential, so -// an array-append on any OTHER registry's token cannot perturb what we render or -// read, and refusing the file over it would be a false unenforceable. When the -// desired pair does not parse, which key is ours is unknown, so every token key is -// judged rather than none. +// Only keys in the parsed desired body are judged, so an unrelated array config +// (`omit[]=dev`) remains untouched while arrays for the registry, either token, +// or any optional setting fail closed. // // The `[]` suffix is tested AFTER npmUnsafe, matching npm's own order (it unquotes // before checking for `[]`), which is what catches the quoted `"registry[]"=…` form; // `registry [] = …` is NOT flagged because npm stores that under the distinct key // "registry " and it overrides nothing. -func hasArrayAppendOverride(lines []string, tokenKey string) bool { +func hasArrayAppendOverride(lines []string, desired npmDesired) bool { for _, l := range lines { key, _, ok := activeKV(l) if !ok { @@ -1375,14 +1449,7 @@ func hasArrayAppendOverride(lines []string, tokenKey string) bool { if !isAppend { continue } - if base == "registry" { - return true - } - if tokenKey != "" { - if base == tokenKey { - return true - } - } else if strings.HasSuffix(base, ":_authToken") { + if _, managed := desired.values[base]; managed { return true } } @@ -1487,9 +1554,8 @@ func activeKV(line string) (key, value string, ok bool) { // `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. +// compliant. Rendered policy settings may contain backslashes, so +// parseNPMDesired runs them through this same normalization before comparison. func npmUnsafe(s string) string { s = strings.TrimSpace(s) if inner, ok := unquoteININToken(s); ok { @@ -1605,12 +1671,16 @@ func extractManagedBody(content string) (string, bool) { // 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 +// managed scalar values, 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) { + desired, ok := parseNPMDesired(expected) + if !ok { + return false, fmt.Errorf("npmrc: expected value is not a rendered npm policy: %w", ErrTargetUnusable) + } rt, err := w.resolveLeaf() if err != nil { return false, err @@ -1648,7 +1718,7 @@ func (w *NPMRCWriter) Converged(expected string) (bool, error) { // 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) } - if _, tokKey, _, _ := parseExpected(expected); hasArrayAppendOverride(lines, tokKey) { + if hasArrayAppendOverride(lines, desired) { // npm folds an array-append line into the same key as the block's scalar // assignment, so last-wins would report converged while npm resolves to a // list containing someone else's registry. Fail closed, as the rewrite path @@ -1682,36 +1752,14 @@ func (w *NPMRCWriter) Converged(expected string) (bool, error) { 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. +// blockIsLastEffective reports whether the block's registry, token, and optional +// settings are the last-wins scalar values for the file. func blockIsLastEffective(lines []string, expected string) bool { - expReg, expTokKey, expTokVal, ok := parseExpected(expected) + desired, ok := parseNPMDesired(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 + return desiredValuesEffective(lines, desired) } func countMarker(lines []string, marker string) int { @@ -1733,9 +1781,9 @@ func countMarker(lines []string, marker string) int { // ~/.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). +// block, the MDM block body matching the expected marker format, every managed +// scalar effective (last-wins), and sane metadata (0600, target-user-owned on +// POSIX). func (w *NPMRCWriter) ProbeExpected(expected string) (bool, string) { rt, err := w.resolveLeaf() if err != nil { @@ -1771,7 +1819,7 @@ func (w *NPMRCWriter) ProbeExpected(expected string) (bool, string) { // 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) + desired, ok := parseNPMDesired(expected) if !ok { return false, "" } @@ -1796,77 +1844,107 @@ func probeNPMRCContent(content, expected string) (bool, string) { // marker plus matching lines is then not proof; fail closed (not managed). return false, "" } - if hasArrayAppendOverride(lines, expTokKey) { + if hasArrayAppendOverride(lines, desired) { // npm folds an array-append line into the MDM block's own key, so a marker // plus matching lines is not proof the MDM lane governs npm. Fail closed. 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 + markers, inDMG, err := scanNPMMDMMarkers(lines) + if err != nil { + return false, "" + } + if len(desired.settings) == 0 { + if len(markers.fixed) != 1 || len(markers.begins) != 0 || len(markers.ends) != 0 || + !fixedMDMBlockMatches(lines, inDMG, markers.fixed[0], desired) { + return false, "" } - if isMarkerLine(l, npmrcMDMMarker) { - mdmIdx = i - break + } else { + body, valid := boundedMDMBody(lines, inDMG, markers) + if len(markers.fixed) != 0 || !valid || body != desired.body { + return false, "" } } - if mdmIdx < 0 { + if !desiredValuesEffective(lines, desired) { return false, "" } + return true, "mdm-managed npmrc block present and effective" +} - // 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 { +type npmMDMMarkers struct { + fixed []int + begins []int + ends []int +} + +func scanNPMMDMMarkers(lines []string) (npmMDMMarkers, []bool, error) { + inDMG, err := dmgBlockLines(lines) + if err != nil { + return npmMDMMarkers{}, nil, err + } + var markers npmMDMMarkers + for i, line := range lines { + if inDMG[i] { continue } - if key == "registry" && val == expReg { - mdmReg = true + switch { + case isMarkerLine(line, npmrcMDMMarker): + markers.fixed = append(markers.fixed, i) + case isMarkerLine(line, npmrcMDMBeginMarker): + markers.begins = append(markers.begins, i) + case isMarkerLine(line, npmrcMDMEndMarker): + markers.ends = append(markers.ends, i) } - if key == expTokKey && val == expTokVal { - mdmTok = true + } + return markers, inDMG, nil +} + +func fixedMDMBlockMatches(lines []string, inDMG []bool, marker int, desired npmDesired) bool { + found := make(map[string]bool, 2) + for i := marker + 1; i < len(lines); i++ { + if inDMG[i] || strings.TrimSpace(lines[i]) == "" || isSectionLine(lines[i]) { + break + } + key, value, ok := activeKV(lines[i]) + if ok && desired.values[key] == value { + found[key] = true } } - if !mdmReg || !mdmTok { - return false, "" + return found["registry"] && found[desired.tokenKey] +} + +func boundedMDMBody(lines []string, inDMG []bool, markers npmMDMMarkers) (string, bool) { + if len(markers.begins) != 1 || len(markers.ends) != 1 || markers.ends[0] <= markers.begins[0] { + return "", false } + begin, end := markers.begins[0], markers.ends[0] + body := make([]string, 0, end-begin-1) + for i := begin + 1; i < end; i++ { + if inDMG[i] { + return "", false + } + body = append(body, strings.TrimRight(lines[i], "\r")) + } + return strings.Join(body, "\n"), true +} - // 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) +func desiredValuesEffective(lines []string, desired npmDesired) bool { + last := make(map[string]string, len(desired.values)) + for _, line := range lines { + key, value, ok := activeKV(line) if !ok { continue } - if key == "registry" { - lastReg, lastRegOK = val, true - } - if key == expTokKey { - lastTok, lastTokOK = val, true + if _, managed := desired.values[key]; managed { + last[key] = value } } - if !lastRegOK || lastReg != expReg || !lastTokOK || lastTok != expTokVal { - return false, "" + for key, value := range desired.values { + if last[key] != value { + return false + } } - return true, "mdm-managed npmrc block present and effective" + return true } // managedBlockBounds returns the [begin, end] line indices of our block, or @@ -1890,26 +1968,12 @@ func managedBlockBounds(lines []string) (int, int) { 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 -} - // ProbeContentNPM is the MDM verify-only reader. It reports whether a // StepSecurity MDM-managed block is present in ~/.npmrc and, if so, the effective -// (last-wins) configuration as the observed bag {ecosystem, registry_url, -// auth_token_status}. It NEVER writes, patches, or clears — in MDM mode the agent -// owns nothing on this file — and it never touches the ownership state store. +// (last-wins) configuration as the base three-key observed bag plus aggregate +// settings_status when settings are desired. It NEVER writes, patches, or +// clears — in MDM mode the agent owns nothing on this file — and it never +// touches the ownership state store. // // expected is the rendered desired block. Only its tenant key (the api_key before // `::dev:`) is used, to decide auth_token_status here on the device; no @@ -1926,12 +1990,10 @@ func parseExpected(expected string) (registry, tokenKey, tokenVal string, ok boo // policy_not_applied. // - MDM marker present and the file parses → (true, bag, nil) → mdm_managed. // -// Unlike the DMG-mode ProbeExpected this does NOT require 0600: perms are outside -// the locked observed contract, so a correctly-deployed-but-lax file must still -// report its real registry and auth status rather than be hidden behind a -// synthetic failure. Ownership IS still enforced — readCurrent refuses a leaf the -// target user does not own, because another user's file is not this user's -// effective npm config. +// Base-only MDM blocks retain the existing behavior of reporting their observed +// values even when metadata is loose. Settings-aware blocks require secure mode +// or ACL metadata because they may carry additional environment-backed registry +// credentials. Ownership is always enforced by readCurrent. func (w *NPMRCWriter) ProbeContentNPM(expected string) (bool, map[string]json.RawMessage, error) { rt, err := w.resolveLeaf() if err != nil { @@ -1946,27 +2008,45 @@ func (w *NPMRCWriter) ProbeContentNPM(expected string) (bool, map[string]json.Ra if !existed { return false, nil, nil } + present, observed, err := probeNPMRCObserved(string(data), expected) + if err != nil || !present { + return present, observed, err + } + desired, ok := parseNPMDesired(expected) + if !ok { + return false, nil, errors.New("npmrc: expected value is not a rendered npm policy") + } + if len(desired.settings) == 0 { + if enforcePOSIXMetadata && mode.Perm() != npmrcFileMode { + w.log("npmrc: mdm-managed file mode is %#o, not %#o (token may be readable by other local users)", mode.Perm(), npmrcFileMode) + } + return present, observed, nil + } if enforcePOSIXMetadata && mode.Perm() != npmrcFileMode { - // Not a verification failure (see the doc comment), and not reportable — the - // observed bag has no perms field. Log it so support can spot a token file - // other local users can read; the mode only, never the content. - w.log("npmrc: mdm-managed file mode is %#o, not %#o (token may be readable by other local users)", mode.Perm(), npmrcFileMode) + return false, nil, fmt.Errorf("npmrc: settings-aware mdm file has insecure mode: %w", ErrTargetUnusable) + } + if w.secureHome != nil { + secure, err := w.metadataSecure(rt) + if err != nil { + return false, nil, fmt.Errorf("npmrc: verify settings-aware mdm metadata: %w", err) + } + if !secure { + return false, nil, fmt.Errorf("npmrc: settings-aware mdm file has insecure metadata: %w", ErrTargetUnusable) + } } - return probeNPMRCObserved(string(data), expected) + return present, observed, nil } // probeNPMRCObserved is the pure content logic behind ProbeContentNPM. It shares // the parse guards and the last-wins precedence scan with probeNPMRCContent, but -// returns the observed VALUES instead of a yield/no-yield verdict: the backend -// structurally compares registry_url and ecosystem against desired, so the agent -// reports them raw and judges only the secret axis. +// returns the observed registry plus secret-free auth and settings verdicts. func probeNPMRCObserved(content, expected string) (bool, map[string]json.RawMessage, error) { // The desired registry is deliberately unused: the backend compares // registry_url structurally. Only the token key (which _authToken line belongs // to the tenant registry) and its value (the tenant key) are needed here. - _, expTokKey, expTokVal, ok := parseExpected(expected) + desired, ok := parseNPMDesired(expected) if !ok { - return false, nil, errors.New("npmrc: expected value is not a rendered registry/token pair") + return false, nil, errors.New("npmrc: expected value is not a rendered npm policy") } rest, _ := stripBOM([]byte(content)) @@ -1984,7 +2064,7 @@ func probeNPMRCObserved(content, expected string) (bool, map[string]json.RawMess if hasCoercibleQuotedKey(lines) { return false, nil, fmt.Errorf("npmrc: file contains a coercible quoted key: %w", ErrTargetUnusable) } - if hasArrayAppendOverride(lines, expTokKey) { + if hasArrayAppendOverride(lines, desired) { // The registry we would report is not the one npm resolves: it folds the // array-append line into the same key. Reporting the scalar last-wins value // would be a confident wrong observation. @@ -1993,24 +2073,28 @@ func probeNPMRCObserved(content, expected string) (bool, map[string]json.RawMess // Presence = an MDM marker OUTSIDE every DMG-owned block, so a marker planted // inside one of our own blocks cannot pass as MDM management. - inDMGBlock, err := dmgBlockLines(lines) + markers, inDMGBlock, err := scanNPMMDMMarkers(lines) if err != nil { return false, nil, err } - present := false - for i, l := range lines { - if inDMGBlock[i] { - continue - } - if isMarkerLine(l, npmrcMDMMarker) { - present = true - break - } + present := len(markers.fixed) > 0 + if len(desired.settings) > 0 { + present = present || len(markers.begins) > 0 } if !present { return false, nil, nil } + settingsStatus := "" + if len(desired.settings) > 0 { + settingsStatus = observedSettingsStatus(lines, inDMGBlock, markers, desired) + } + if !desired.stepSecurity() { + // A settings-only policy has no StepSecurity registry or credential axis to + // report; the aggregate settings verdict is the whole observation. + return npmObservedBag("", "", settingsStatus) + } + // Effective precedence over the WHOLE file: npm takes the LAST active // assignment, so a line below the MDM block wins. Report what npm would // actually use — an override surfaces as drift at the backend, which is the @@ -2025,7 +2109,7 @@ func probeNPMRCObserved(content, expected string) (bool, map[string]json.RawMess switch key { case "registry": lastReg, lastRegOK = val, true - case expTokKey: + case desired.tokenKey: // The tenant registry's _authToken key. A block pointing at a DIFFERENT // registry carries a different token key, so its token does not count as // this policy's credential — it reports absent, alongside the registry drift. @@ -2048,11 +2132,61 @@ func probeNPMRCObserved(content, expected string) (bool, map[string]json.RawMess // match. The serial is device-specific and deliberately not part of the // verdict. status = authTokenMismatch - if tenantKeyPrefix(lastTok) == tenantKeyPrefix(expTokVal) { + if tenantKeyPrefix(lastTok) == tenantKeyPrefix(desired.tokenValue) { status = authTokenMatch } } - return npmObservedBag(lastReg, status) + + return npmObservedBag(lastReg, status, settingsStatus) +} + +// observedSettingsStatus reduces the desired settings to one secret-free +// verdict: absent when no desired settings key has an active assignment, match +// when exactly one bounded MDM block carries precisely the desired settings and +// every one of them is the last-effective value, mismatch for any other readable +// state (partial, wrong, overridden, duplicated, or a fixed-marker block). +func observedSettingsStatus(lines []string, inDMG []bool, markers npmMDMMarkers, desired npmDesired) string { + last := make(map[string]string, len(desired.settings)) + for _, line := range lines { + key, value, ok := activeKV(line) + if !ok || desired.stepSecurityKey(key) { + continue + } + if _, managed := desired.values[key]; managed { + last[key] = value + } + } + if len(last) == 0 { + return settingsAbsent + } + body, valid := boundedMDMBody(lines, inDMG, markers) + if len(markers.fixed) != 0 || !valid || !boundedSettingsMatch(body, desired) { + return settingsMismatch + } + for _, setting := range desired.settings { + if last[setting.key] != setting.value { + return settingsMismatch + } + } + return settingsMatch +} + +// boundedSettingsMatch compares only the settings entries of a bounded MDM body, +// so registry or token drift in a combined block is reported on its own axes +// rather than as a settings mismatch. The block must still be the desired SHAPE: +// a stale combined block left behind after a policy moved to settings-only keeps +// StepSecurity as the effective default registry, and must not read as match. +func boundedSettingsMatch(body string, desired npmDesired) bool { + observed, ok := parseNPMDesired(body) + if !ok || observed.stepSecurity() != desired.stepSecurity() || len(observed.settings) != len(desired.settings) { + return false + } + for i, setting := range desired.settings { + if observed.settings[i] != setting { + return false + } + } + return true } // dmgBlockLines marks every line that falls inside a DMG-owned block, so the MDM @@ -2128,97 +2262,464 @@ func tenantKeyPrefix(token string) string { return strings.SplitN(token, "::dev:", 2)[0] } -// npmObservedBag builds the observed bag. Exactly three keys, JSON strings — the -// backend rejects any unknown key, and nothing derived from the token beyond the +// npmObservedBag builds the observed bag for the desired shape: ecosystem always, +// registry_url and auth_token_status when the StepSecurity registry is desired +// (authStatus non-empty), and settings_status when settings are desired +// (non-empty). Nothing derived from a token or setting beyond its aggregate // verdict is included. -func npmObservedBag(registryURL, authStatus string) (bool, map[string]json.RawMessage, error) { - reg, err := json.Marshal(registryURL) - if err != nil { - return false, nil, fmt.Errorf("npmrc: encode observed registry_url: %w", err) +func npmObservedBag(registryURL, authStatus, settingsStatus string) (bool, map[string]json.RawMessage, error) { + observed := make(map[string]json.RawMessage, 4) + put := func(key, value string) error { + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("npmrc: encode observed %s: %w", key, err) + } + observed[key] = raw + return nil } - eco, err := json.Marshal("npm") - if err != nil { - return false, nil, fmt.Errorf("npmrc: encode observed ecosystem: %w", err) + if err := put(observedKeyEcosystem, "npm"); err != nil { + return false, nil, err } - status, err := json.Marshal(authStatus) - if err != nil { - return false, nil, fmt.Errorf("npmrc: encode observed auth_token_status: %w", err) + if authStatus != "" { + if err := put(observedKeyRegistryURL, registryURL); err != nil { + return false, nil, err + } + if err := put(observedKeyAuthTokenStatus, authStatus); err != nil { + return false, nil, err + } + } + if settingsStatus != "" { + if err := put(observedKeySettingsStatus, settingsStatus); err != nil { + return false, nil, err + } + } + return true, observed, nil +} + +// npmCompliantObserved is the observed bag a converged DMG-enforced file reports +// for a policy with settings: every desired axis reads match. A StepSecurity-only +// policy reports no bag, preserving its existing wire shape. +func npmCompliantObserved(rendered string) (map[string]json.RawMessage, error) { + desired, ok := parseNPMDesired(rendered) + if !ok || len(desired.settings) == 0 { + return nil, nil } - return true, map[string]json.RawMessage{ - observedKeyEcosystem: eco, - observedKeyRegistryURL: reg, - observedKeyAuthTokenStatus: status, - }, nil + authStatus := "" + if desired.stepSecurity() { + authStatus = authTokenMatch + } + _, observed, err := npmObservedBag(desired.registry, authStatus, settingsMatch) + return observed, err } // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- -// npmPolicy is the run-config policy payload for the npm ecosystem. +// npmPolicy is the run-config policy payload for the npm ecosystem. The +// StepSecurity fields stay raw so an explicit null is distinguishable from an +// absent member: registry_url and auth are a pair that is either both present +// (StepSecurity-backed) or both absent (settings-only). 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. + Ecosystem string `json:"ecosystem"` + RegistryURL json.RawMessage `json:"registry_url"` + Auth json.RawMessage `json:"auth"` + Settings json.RawMessage `json:"settings"` +} + +type npmAuth struct { + Scheme string `json:"scheme"` + APIKey string `json:"api_key"` +} + +type npmSetting struct { + key string + value string +} + +// npmDesired is the one parsed form shared by rewrite, convergence, MDM +// verification, and observation. registry, tokenKey, and tokenValue describe +// the leading StepSecurity pair and are empty for a settings-only policy; +// settings holds the policy settings in byte-sorted key order (a settings-only +// `registry` is one of them); values maps every managed scalar key, StepSecurity +// pair included, to its desired value. +type npmDesired struct { + body string + registry string + tokenKey string + tokenValue string + settings []npmSetting + values map[string]string +} + +// stepSecurity reports whether the desired body carries the StepSecurity +// registry and device-token pair. +func (d npmDesired) stepSecurity() bool { return d.tokenKey != "" } + +// stepSecurityKey reports whether key is one of the two product-owned +// StepSecurity lines rather than a policy setting. +func (d npmDesired) stepSecurityKey(key string) bool { + return d.stepSecurity() && (key == "registry" || key == d.tokenKey) +} + +// RenderNPMRCBlock validates a policy and returns the content lines the writer +// wraps in its markers, with no markers or trailing newline: the StepSecurity +// registry/token pair when the policy carries one, followed by any settings in +// byte-sorted key order. 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, the composed device token is `::dev:`, and a +// settings-only policy must declare a default or scoped registry. 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) { + if !utf8.Valid(policy) { + return "", errors.New("npmrc: policy is not valid UTF-8") + } var p npmPolicy - if err := json.Unmarshal(policy, &p); err != nil { + decoder := json.NewDecoder(bytes.NewReader(policy)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&p); err != nil { return "", errors.New("npmrc: policy is not a well-formed npm policy object") } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return "", errors.New("npmrc: policy has trailing data") + } + if err := rejectDuplicateJSONKeys(policy); err != nil { + return "", errors.New("npmrc: policy contains duplicate JSON keys") + } 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") + + var lines []string + productTokenKey := "" + switch { + case len(p.RegistryURL) > 0 || len(p.Auth) > 0: + var err error + if lines, productTokenKey, err = renderStepSecurityPair(p, serial); err != nil { + return "", err + } + case len(p.Settings) == 0: + return "", errors.New("npmrc: policy has neither a StepSecurity registry nor settings") + default: + if err := validateDeviceSerial(serial); err != nil { + return "", err + } + } + + settings, err := validateNPMSettings(p.Settings, productTokenKey) + if err != nil { + return "", err + } + for _, setting := range settings { + lines = append(lines, setting.key+"="+setting.value) + } + body := strings.Join(lines, "\n") + if len(body) > npmrcMaxRenderedBytes { + return "", errors.New("npmrc: rendered block exceeds size limit") + } + return body, nil +} + +// renderStepSecurityPair validates the StepSecurity registry_url/auth pair and +// returns its two rendered lines plus the derived token key the settings must +// not collide with. +func renderStepSecurityPair(p npmPolicy, serial string) (lines []string, tokenKey string, err error) { + if len(p.RegistryURL) == 0 || len(p.Auth) == 0 { + return nil, "", errors.New("npmrc: policy registry_url and auth must be present together") + } + var registryURL string + if err := json.Unmarshal(p.RegistryURL, ®istryURL); err != nil { + return nil, "", errors.New("npmrc: policy registry_url is not a string") + } + var auth npmAuth + authDecoder := json.NewDecoder(bytes.NewReader(p.Auth)) + authDecoder.DisallowUnknownFields() + if err := authDecoder.Decode(&auth); err != nil { + return nil, "", errors.New("npmrc: policy auth is not a well-formed object") + } + if auth.Scheme != "stepsecurity_device_token" { + return nil, "", errors.New("npmrc: unsupported auth scheme") } - key := p.Auth.APIKey + key := auth.APIKey if key == "" { - return "", errors.New("npmrc: policy api_key is empty") + return nil, "", errors.New("npmrc: policy api_key is empty") } if len(key) > npmrcMaxKeyBytes { - return "", errors.New("npmrc: policy api_key too long") + return nil, "", errors.New("npmrc: policy api_key too long") } if !isNPMSafe(key) { - return "", errors.New("npmrc: policy api_key contains unsupported characters") + return nil, "", errors.New("npmrc: policy api_key contains unsupported characters") + } + if err := validateDeviceSerial(serial); err != nil { + return nil, "", err + } + + host, path, err := validateRegistryURL(registryURL) + if err != nil { + return nil, "", err } + + token := key + "::dev:" + serial + // npm's _authToken key is `//host/path/:_authToken` with a trailing slash + // before the colon. + tokenKey = "//" + host + path + "/:_authToken" + return []string{"registry=" + registryURL, tokenKey + "=" + token}, tokenKey, nil +} + +func validateDeviceSerial(serial string) error { if serial == "" { - return "", errors.New("npmrc: device serial is empty") + return errors.New("npmrc: device serial is empty") } if len(serial) > npmrcMaxSerialBytes { - return "", errors.New("npmrc: device serial too long") + return errors.New("npmrc: device serial too long") } if !isNPMSafe(serial) { - return "", errors.New("npmrc: device serial contains unsupported characters") + return errors.New("npmrc: device serial contains unsupported characters") } + return nil +} - host, path, err := validateRegistryURL(p.RegistryURL) - if err != nil { - return "", err +// validateNPMSettings decodes and validates the optional settings map and +// returns it sorted by key. productTokenKey is the derived StepSecurity token +// key for a StepSecurity-backed policy and empty for a settings-only policy, +// which must instead declare a default `registry` or `@scope:registry`. +func validateNPMSettings(raw json.RawMessage, productTokenKey string) ([]npmSetting, error) { + if len(raw) == 0 { + return nil, nil + } + var members map[string]json.RawMessage + if err := json.Unmarshal(raw, &members); err != nil || members == nil { + return nil, errors.New("npmrc: policy settings must be a non-null object of strings") + } + settings := make(map[string]string, len(members)) + for key, rawValue := range members { + var value string + if bytes.Equal(bytes.TrimSpace(rawValue), []byte("null")) || json.Unmarshal(rawValue, &value) != nil { + return nil, errors.New("npmrc: policy settings must be a non-null object of strings") + } + settings[key] = value + } + if len(settings) == 0 || len(settings) > npmrcMaxSettings { + return nil, fmt.Errorf("npmrc: policy settings must contain 1 through %d entries", npmrcMaxSettings) } - 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") + stepSecurity := productTokenKey != "" + keys := make([]string, 0, len(settings)) + registryAuthKeys := make(map[string]struct{}) + for key, value := range settings { + if err := validateNPMSettingKey(key); err != nil { + return nil, err + } + if err := validateNPMSettingValue(value); err != nil { + return nil, err + } + if key == productTokenKey { + return nil, errors.New("npmrc: policy setting collides with the StepSecurity token key") + } + scope, scoped := npmScopedRegistryKey(key) + if scoped || key == "registry" && !stepSecurity { + authKey, canonical, err := canonicalNPMRegistry(value) + if err != nil || canonical != value || scoped && scope == "" { + return nil, errors.New("npmrc: policy contains a non-canonical registry setting") + } + if authKey == productTokenKey { + // StepSecurity authentication is expressed only through the compiled + // registry_url/auth pair, never as a managed setting. + return nil, errors.New("npmrc: policy registry setting targets the StepSecurity registry") + } + registryAuthKeys[authKey] = struct{}{} + } + keys = append(keys, key) + } + if !stepSecurity && len(registryAuthKeys) == 0 { + return nil, errors.New("npmrc: settings-only policy must declare a default or scoped registry") + } + + for _, key := range keys { + value := settings[key] + if isReservedNPMSetting(key, !stepSecurity) { + return nil, errors.New("npmrc: policy contains a reserved setting key") + } + credential, scoped := npmURLScopedCredential(key) + if !scoped { + continue + } + if !strings.EqualFold(credential, "_authToken") { + return nil, errors.New("npmrc: policy contains an unsupported credential setting") + } + if _, ok := registryAuthKeys[key]; !ok || !isExactEnvReference(value) { + return nil, errors.New("npmrc: policy scoped auth token is not canonical") + } + } + + sort.Strings(keys) + result := make([]npmSetting, 0, len(keys)) + for _, key := range keys { + result = append(result, npmSetting{key: key, value: settings[key]}) + } + return result, nil +} + +func validateNPMSettingKey(key string) error { + if key == "" || strings.Trim(key, " \t") != key { + return errors.New("npmrc: policy contains a non-canonical setting key") + } + if len(key) > npmrcMaxSettingKeyBytes || !utf8.ValidString(key) { + return errors.New("npmrc: policy contains an invalid setting key") + } + for _, r := range key { + if unicode.IsSpace(r) || unicode.IsControl(r) || strings.ContainsRune("=#;'\"[]", r) { + return errors.New("npmrc: policy contains an unsafe setting key") + } + } + return nil +} + +func validateNPMSettingValue(value string) error { + if strings.Trim(value, " \t") != value { + return errors.New("npmrc: policy contains a non-canonical setting value") + } + if len(value) > npmrcMaxSettingValueBytes || !utf8.ValidString(value) { + return errors.New("npmrc: policy contains an invalid setting value") + } + for _, r := range value { + if unicode.IsControl(r) || strings.ContainsRune("#;'\"", r) { + return errors.New("npmrc: policy contains an unsafe setting value") + } + } + if !validEnvReferences(value) { + return errors.New("npmrc: policy contains a malformed environment reference") + } + if u, err := url.Parse(value); err == nil && u.IsAbs() && u.User != nil { + return errors.New("npmrc: policy setting URL contains userinfo") + } + return nil +} + +func validEnvReferences(value string) bool { + for start := 0; ; { + i := strings.Index(value[start:], "${") + if i < 0 { + return true + } + i += start + end := strings.IndexByte(value[i+2:], '}') + if end < 0 { + return false + } + end += i + 2 + if !validEnvName(value[i+2 : end]) { + return false + } + start = end + 1 + } +} + +func validEnvName(name string) bool { + if name == "" || !isASCIIAlpha(name[0]) && name[0] != '_' { + return false + } + for i := 1; i < len(name); i++ { + if !isASCIIAlpha(name[i]) && (name[i] < '0' || name[i] > '9') && name[i] != '_' { + return false + } + } + return true +} + +func isASCIIAlpha(b byte) bool { + return b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' +} + +func isExactEnvReference(value string) bool { + return len(value) >= 4 && strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") && validEnvName(value[2:len(value)-1]) +} + +// isReservedNPMSetting reports whether key is a product-owned or +// credential-bearing npm config name a policy may not manage. The exact +// lowercase `registry` is the third-party default registry of a settings-only +// policy and is released when allowDefaultRegistry is set; its case variants +// stay reserved so they cannot evade the registry rules. +func isReservedNPMSetting(key string, allowDefaultRegistry bool) bool { + if strings.HasPrefix(key, "//") { + return false + } + switch strings.ToLower(key) { + case "registry": + return !allowDefaultRegistry || key != "registry" + case "_authtoken", "_auth", "_password", "username", "tokenhelper", "cert", "key": + return true + default: + return false + } +} + +func npmScopedRegistryKey(key string) (string, bool) { + const suffix = ":registry" + if !strings.HasPrefix(key, "@") || len(key) < len(suffix) || !strings.EqualFold(key[len(key)-len(suffix):], suffix) { + return "", false + } + if !strings.HasSuffix(key, suffix) { + return "", true + } + scope := strings.TrimSuffix(strings.TrimPrefix(key, "@"), suffix) + if scope == "" { + return "", true + } + for i := 0; i < len(scope); i++ { + c := scope[i] + if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || strings.ContainsRune("._~-", rune(c)) { + continue + } + return "", true + } + return scope, true +} + +func canonicalNPMRegistry(raw string) (authKey, canonical string, err error) { + if hasControlBytes(raw) || strings.ContainsAny(raw, "#?") { + return "", "", errors.New("unsafe registry URL") + } + u, err := url.Parse(raw) + if err != nil || !strings.EqualFold(u.Scheme, "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.Opaque != "" { + return "", "", errors.New("invalid registry URL") + } + if strings.HasSuffix(u.Host, ":") { + return "", "", errors.New("invalid registry port") + } + if port := u.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return "", "", errors.New("invalid registry port") + } + } + host := strings.ToLower(u.Host) + path := strings.TrimRight(u.EscapedPath(), "/") + "/" + if path == "" { + path = "/" + } + canonical = "https://" + host + path + return "//" + host + path + ":_authToken", canonical, nil +} + +func npmURLScopedCredential(key string) (string, bool) { + if !strings.HasPrefix(key, "//") { + return "", false + } + i := strings.LastIndexByte(key, ':') + if i < 2 || i == len(key)-1 { + return "", false + } + credential := key[i+1:] + switch strings.ToLower(credential) { + case "_authtoken", "_auth", "_password", "username", "tokenhelper", "cert", "key": + return credential, true + default: + return "", false } - return body, nil } // validateRegistryURL requires an HTTPS URL with no userinfo, query, fragment, diff --git a/internal/devicepolicy/npmrc_observed_test.go b/internal/devicepolicy/npmrc_observed_test.go index f1109b9..8666b30 100644 --- a/internal/devicepolicy/npmrc_observed_test.go +++ b/internal/devicepolicy/npmrc_observed_test.go @@ -127,6 +127,85 @@ func TestProbeContentNPM_ObservedBag(t *testing.T) { } } +func TestProbeContentNPM_SettingsStatus(t *testing.T) { + cases := []struct { + name string + content string + want string + }{ + {name: "match", content: boundedMDMBlock(stdSettingsBody), want: settingsMatch}, + {name: "absent from fixed block", content: mdmBlock(), want: settingsAbsent}, + {name: "partial", content: boundedMDMBlock(strings.Replace(stdSettingsBody, "engine-strict=true\n", "", 1)), want: settingsMismatch}, + {name: "wrong", content: boundedMDMBlock(strings.Replace(stdSettingsBody, "save-exact=true", "save-exact=false", 1)), want: settingsMismatch}, + {name: "later override", content: boundedMDMBlock(stdSettingsBody) + "save-exact=false\n", want: settingsMismatch}, + {name: "duplicate block", content: boundedMDMBlock(stdSettingsBody) + boundedMDMBlock(stdSettingsBody), want: settingsMismatch}, + {name: "missing end", content: npmrcMDMBeginMarker + "\n" + stdSettingsBody + "\n", want: settingsMismatch}, + {name: "bounded block without settings", content: boundedMDMBlock(stdBody), want: settingsAbsent}, + {name: "stale extra setting", content: boundedMDMBlock(stdSettingsBody + "\nstale-option=true"), want: settingsMismatch}, + {name: "registry drift keeps settings match", content: boundedMDMBlock(strings.Replace(stdSettingsBody, stdRegistry, "https://other.example/javascript", 1)), want: settingsMatch}, + {name: "credential drift keeps settings match", content: boundedMDMBlock(strings.Replace(stdSettingsBody, stdTokenVal, "other::dev:SERIAL123", 1)), want: settingsMatch}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + present, observed, err := probeNPMRCObserved(tc.content, stdSettingsBody) + if err != nil { + t.Fatalf("probeNPMRCObserved: %v", err) + } + if !present { + t.Fatal("MDM ownership was not recognized") + } + got := observedStrings(t, observed) + if len(got) != 4 { + t.Fatalf("observed key count = %d, want 4", len(got)) + } + if got[observedKeySettingsStatus] != tc.want { + t.Fatalf("settings_status = %q, want %q", got[observedKeySettingsStatus], tc.want) + } + raw, err := json.Marshal(observed) + if err != nil { + t.Fatal(err) + } + for _, sensitive := range []string{"save-exact", "EXAMPLE_NPM_TOKEN", "${EXAMPLE_NPM_TOKEN}", "engine-strict"} { + if strings.Contains(string(raw), sensitive) { + t.Fatalf("observed evidence contains %q: %s", sensitive, raw) + } + } + }) + } +} + +func TestProbeContentNPM_SettingsUnsafeShapesFailClosed(t *testing.T) { + cases := []string{ + boundedMDMBlock(stdSettingsBody) + "save-exact[]=false\n", + "[team]\n" + boundedMDMBlock(stdSettingsBody), + boundedMDMBlock(stdSettingsBody) + "save-exact=false\r", + } + for _, content := range cases { + present, observed, err := probeNPMRCObserved(content, stdSettingsBody) + if err == nil { + t.Fatalf("unsafe content did not fail: present=%v observed=%v", present, observed) + } + if present { + t.Fatal("unsafe content reported MDM ownership") + } + if observed != nil { + t.Fatalf("unsafe content produced evidence: %v", observed) + } + } + + planted := block(npmrcMDMBeginMarker + "\n" + stdSettingsBody + "\n" + npmrcMDMEndMarker) + present, observed, err := probeNPMRCObserved(planted, stdSettingsBody) + if err != nil { + t.Fatal(err) + } + if present { + t.Fatal("bounded MDM marker inside a DMG block claimed ownership") + } + if observed != nil { + t.Fatalf("bounded MDM marker inside a DMG block produced evidence: %v", observed) + } +} + func TestProbeContentNPM_FailsClosedNotUnapplied(t *testing.T) { // Constructs we cannot reason about must return an ERROR (→ verification_failed), // never the clean present=false (→ policy_not_applied). Reporting "nothing is @@ -164,7 +243,7 @@ func TestProbeContentNPM_FailsClosedNotUnapplied(t *testing.T) { func TestProbeContentNPM_RejectsUnrenderableExpected(t *testing.T) { // Without a parseable desired block there is no tenant key to compare against, // so the auth verdict cannot be computed. Fail rather than guess. - if _, _, err := probeNPMRCObserved(mdmBlock(), "registry=only-one-line"); err == nil { + if _, _, err := probeNPMRCObserved(mdmBlock(), "# not a rendered block"); err == nil { t.Fatal("a non-rendered expected value must error") } } @@ -298,6 +377,10 @@ func TestHasArrayAppendOverride(t *testing.T) { // comma-joined list while a scalar last-wins scan still picks our line. Verified // against npm 10.9.7. Only keys we manage are judged: an unrelated array config // must not make the file unusable. + desired, ok := parseNPMDesired(stdBody) + if !ok { + t.Fatal("standard body did not parse") + } flagged := []string{ "registry[]=https://evil.example/", `"registry[]"=https://evil.example/`, @@ -305,7 +388,7 @@ func TestHasArrayAppendOverride(t *testing.T) { "registry[]=", } for _, l := range flagged { - if !hasArrayAppendOverride([]string{l}, stdTokenKey) { + if !hasArrayAppendOverride([]string{l}, desired) { t.Errorf("hasArrayAppendOverride(%q) = false, want true", l) } } @@ -325,21 +408,10 @@ func TestHasArrayAppendOverride(t *testing.T) { "", } for _, l := range clean { - if hasArrayAppendOverride([]string{l}, stdTokenKey) { + if hasArrayAppendOverride([]string{l}, desired) { t.Errorf("hasArrayAppendOverride(%q) = true, want false", l) } } - - // With no parseable desired pair we cannot tell our token key from anyone - // else's, so every token key is judged rather than none. - for _, l := range []string{stdTokenKey + "[]=ssevil", "//other.example/:_authToken[]=x"} { - if !hasArrayAppendOverride([]string{l}, "") { - t.Errorf("hasArrayAppendOverride(%q, \"\") = false, want true", l) - } - } - if hasArrayAppendOverride([]string{"omit[]=dev"}, "") { - t.Error("an unrelated array config must stay clean even with no token key") - } } func TestDMGBlockLines(t *testing.T) { @@ -379,3 +451,92 @@ func TestDMGBlockLines(t *testing.T) { t.Fatalf("two dmg blocks must fail closed with ErrTargetUnusable, got %v", err) } } + +// A combined block left on disk after the policy moved to settings-only keeps +// StepSecurity as the effective default registry even when every desired setting +// is present, so it must not report settings_status=match. +func TestProbeContentNPM_SettingsOnlyRejectsStaleCombinedBlock(t *testing.T) { + settingsOnly := strings.TrimPrefix(stdSettingsBody, stdBody+"\n") + present, observed, err := probeNPMRCObserved(boundedMDMBlock(stdSettingsBody), settingsOnly) + if err != nil || !present { + t.Fatalf("probeNPMRCObserved: present=%v err=%v", present, err) + } + if got := observedStrings(t, observed); got[observedKeySettingsStatus] != settingsMismatch { + t.Fatalf("settings_status = %q, want %q", got[observedKeySettingsStatus], settingsMismatch) + } +} + +func TestProbeContentNPM_SettingsOnlyObserved(t *testing.T) { + cases := []struct { + name string + content string + want string + }{ + {name: "match", content: boundedMDMBlock(stdSettingsOnlyBody), want: settingsMatch}, + {name: "match with unrelated user config", content: "engine-strict=true\n" + boundedMDMBlock(stdSettingsOnlyBody), want: settingsMatch}, + {name: "absent under a bare fixed marker", content: npmrcMDMMarker + "\n", want: settingsAbsent}, + {name: "fixed StepSecurity block registry mismatches", content: mdmBlock(), want: settingsMismatch}, + {name: "partial", content: boundedMDMBlock(strings.Replace(stdSettingsOnlyBody, "\nsave-exact=true", "", 1)), want: settingsMismatch}, + {name: "wrong registry", content: boundedMDMBlock(strings.Replace(stdSettingsOnlyBody, "packages.example.com/npm/\n", "other.example/\n", 1)), want: settingsMismatch}, + {name: "later registry override", content: boundedMDMBlock(stdSettingsOnlyBody) + "registry=https://later.example/\n", want: settingsMismatch}, + {name: "duplicate block", content: boundedMDMBlock(stdSettingsOnlyBody) + boundedMDMBlock(stdSettingsOnlyBody), want: settingsMismatch}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + present, observed, err := probeNPMRCObserved(tc.content, stdSettingsOnlyBody) + if err != nil { + t.Fatalf("probeNPMRCObserved: %v", err) + } + if !present { + t.Fatal("MDM ownership was not recognized") + } + got := observedStrings(t, observed) + if len(got) != 2 || got[observedKeyEcosystem] != "npm" { + t.Fatalf("settings-only observed = %v, want ecosystem and settings_status only", got) + } + if got[observedKeySettingsStatus] != tc.want { + t.Fatalf("settings_status = %q, want %q", got[observedKeySettingsStatus], tc.want) + } + raw, err := json.Marshal(observed) + if err != nil { + t.Fatal(err) + } + for _, sensitive := range []string{"packages.example.com", "EXAMPLE_NPM_TOKEN", "save-exact", "registry_url", "auth_token_status"} { + if strings.Contains(string(raw), sensitive) { + t.Fatalf("observed evidence contains %q: %s", sensitive, raw) + } + } + }) + } + + for _, content := range []string{"", "registry=https://packages.example.com/npm/\n", block(stdSettingsOnlyBody)} { + present, observed, err := probeNPMRCObserved(content, stdSettingsOnlyBody) + if err != nil || present || observed != nil { + t.Fatalf("content %q: present=%v observed=%v err=%v, want not applied", content, present, observed, err) + } + } + if _, _, err := probeNPMRCObserved(boundedMDMBlock(stdSettingsOnlyBody)+"registry[]=https://evil.example/\n", stdSettingsOnlyBody); err == nil { + t.Fatal("managed registry array must fail closed") + } +} + +func TestNPMCompliantObserved(t *testing.T) { + observed, err := npmCompliantObserved(stdBody) + if err != nil || observed != nil { + t.Fatalf("StepSecurity-only compliant report must carry no bag, got %v err=%v", observed, err) + } + observed, err = npmCompliantObserved(stdSettingsBody) + if err != nil { + t.Fatal(err) + } + if got := observedStrings(t, observed); len(got) != 4 || got[observedKeyRegistryURL] != stdRegistry || got[observedKeyAuthTokenStatus] != authTokenMatch || got[observedKeySettingsStatus] != settingsMatch { + t.Fatalf("combined compliant bag = %v", got) + } + observed, err = npmCompliantObserved(stdSettingsOnlyBody) + if err != nil { + t.Fatal(err) + } + if got := observedStrings(t, observed); len(got) != 2 || got[observedKeyEcosystem] != "npm" || got[observedKeySettingsStatus] != settingsMatch { + t.Fatalf("settings-only compliant bag = %v", got) + } +} diff --git a/internal/devicepolicy/npmrc_test.go b/internal/devicepolicy/npmrc_test.go index b8deb2d..b97b9fb 100644 --- a/internal/devicepolicy/npmrc_test.go +++ b/internal/devicepolicy/npmrc_test.go @@ -3,20 +3,39 @@ package devicepolicy import ( "encoding/json" "errors" + "fmt" "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" + 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" + stdSettingsBody = stdBody + "\n//registry.npmjs.org/:_authToken=${EXAMPLE_NPM_TOKEN}\n@example:registry=https://registry.npmjs.org/\nengine-strict=true\nsave-exact=true" ) +func npmSettingsPolicy(t *testing.T, settings any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "ecosystem": "npm", + "registry_url": stdRegistry, + "auth": map[string]any{ + "scheme": "stepsecurity_device_token", + "api_key": "ssabc123", + }, + "settings": settings, + }) + if err != nil { + t.Fatal(err) + } + return raw +} + // 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" @@ -47,6 +66,164 @@ func TestRenderNPMRCBlock_Valid(t *testing.T) { } } +func TestRenderNPMRCBlock_Settings(t *testing.T) { + settings := map[string]string{ + "save-exact": "true", + "@example:registry": "https://registry.npmjs.org/", + "engine-strict": "true", + "//registry.npmjs.org/:_authToken": "${EXAMPLE_NPM_TOKEN}", + } + got, err := RenderNPMRCBlock(npmSettingsPolicy(t, settings), stdSerial) + if err != nil { + t.Fatalf("RenderNPMRCBlock: %v", err) + } + if got != stdSettingsBody { + t.Fatalf("rendered body = %q, want %q", got, stdSettingsBody) + } + if !strings.Contains(got, "${EXAMPLE_NPM_TOKEN}") { + t.Fatal("environment reference was not preserved literally") + } + if strings.HasSuffix(got, "\n") { + t.Fatal("rendered body must not end in a newline") + } + + first := json.RawMessage(`{"ecosystem":"npm","registry_url":"https://registry-int.stepsecurity.io/javascript","auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"},"settings":{"z":"last","a":"first"}}`) + second := json.RawMessage(`{"settings":{"a":"first","z":"last"},"auth":{"api_key":"ssabc123","scheme":"stepsecurity_device_token"},"registry_url":"https://registry-int.stepsecurity.io/javascript","ecosystem":"npm"}`) + one, err := RenderNPMRCBlock(first, stdSerial) + if err != nil { + t.Fatal(err) + } + two, err := RenderNPMRCBlock(second, stdSerial) + if err != nil { + t.Fatal(err) + } + if one != two { + t.Fatalf("map order changed rendering: %q != %q", one, two) + } + + advanced, err := RenderNPMRCBlock(npmSettingsPolicy(t, map[string]string{ + "@private:registry": "https://packages.example:8443/npm/", + "//packages.example:8443/npm/:_authToken": "${PRIVATE_TOKEN}", + "empty-option": "", + "value-with-equals": "left=right", + "literal-dollar": "$HOME", + }), stdSerial) + if err != nil { + t.Fatalf("valid port/path settings: %v", err) + } + for _, line := range []string{ + "//packages.example:8443/npm/:_authToken=${PRIVATE_TOKEN}", + "@private:registry=https://packages.example:8443/npm/", + "empty-option=", + "literal-dollar=$HOME", + "value-with-equals=left=right", + } { + if !strings.Contains(advanced, "\n"+line) { + t.Fatalf("rendered body missing %q: %q", line, advanced) + } + } +} + +func TestRenderNPMRCBlock_SettingsRejections(t *testing.T) { + tooMany := make(map[string]string, npmrcMaxSettings+1) + for i := 0; i <= npmrcMaxSettings; i++ { + tooMany[fmt.Sprintf("setting-%02d", i)] = "x" + } + productTokenKey := stdTokenKey + cases := []struct { + name string + raw json.RawMessage + }{ + {name: "null", raw: npmSettingsPolicy(t, nil)}, + {name: "empty", raw: npmSettingsPolicy(t, map[string]string{})}, + {name: "wrong type", raw: npmSettingsPolicy(t, []string{"x"})}, + {name: "non-string value", raw: npmSettingsPolicy(t, map[string]any{"save-exact": true})}, + {name: "null member value", raw: npmSettingsPolicy(t, map[string]any{"save-exact": nil})}, + {name: "too many", raw: npmSettingsPolicy(t, tooMany)}, + {name: "unknown top-level field", raw: json.RawMessage(`{"ecosystem":"npm","registry_url":"https://registry-int.stepsecurity.io/javascript","auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"},"extra":true}`)}, + {name: "unknown auth field", raw: json.RawMessage(`{"ecosystem":"npm","registry_url":"https://registry-int.stepsecurity.io/javascript","auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123","extra":true}}`)}, + {name: "trailing JSON", raw: json.RawMessage(stdPolicyJSON + ` {}`)}, + {name: "duplicate top-level member", raw: json.RawMessage(`{"ecosystem":"npm","ecosystem":"npm","registry_url":"https://registry-int.stepsecurity.io/javascript","auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"}}`)}, + {name: "duplicate setting member", raw: json.RawMessage(`{"ecosystem":"npm","registry_url":"https://registry-int.stepsecurity.io/javascript","auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"},"settings":{"save-exact":"true","save-exact":"false"}}`)}, + {name: "key whitespace", raw: npmSettingsPolicy(t, map[string]string{" save-exact": "true"})}, + {name: "value whitespace", raw: npmSettingsPolicy(t, map[string]string{"save-exact": " true"})}, + {name: "empty key", raw: npmSettingsPolicy(t, map[string]string{"": "true"})}, + {name: "oversize key", raw: npmSettingsPolicy(t, map[string]string{strings.Repeat("k", npmrcMaxSettingKeyBytes+1): "x"})}, + {name: "oversize value", raw: npmSettingsPolicy(t, map[string]string{"x": strings.Repeat("v", npmrcMaxSettingValueBytes+1)})}, + {name: "array key", raw: npmSettingsPolicy(t, map[string]string{"omit[]": "dev"})}, + {name: "section key", raw: npmSettingsPolicy(t, map[string]string{"[team]": "x"})}, + {name: "comment in value", raw: npmSettingsPolicy(t, map[string]string{"x": "value#comment"})}, + {name: "reserved registry", raw: npmSettingsPolicy(t, map[string]string{"ReGiStRy": "https://registry.npmjs.org/"})}, + {name: "reserved auth", raw: npmSettingsPolicy(t, map[string]string{"_AUTHTOKEN": "${TOKEN}"})}, + {name: "product token collision", raw: npmSettingsPolicy(t, map[string]string{productTokenKey: "${TOKEN}"})}, + {name: "literal scoped token", raw: npmSettingsPolicy(t, map[string]string{"@example:registry": "https://registry.npmjs.org/", "//registry.npmjs.org/:_authToken": "secret-token"})}, + {name: "unpaired scoped token", raw: npmSettingsPolicy(t, map[string]string{"//registry.npmjs.org/:_authToken": "${TOKEN}"})}, + {name: "unsupported scoped credential", raw: npmSettingsPolicy(t, map[string]string{"//registry.npmjs.org/:username": "user"})}, + {name: "malformed environment reference", raw: npmSettingsPolicy(t, map[string]string{"x": "${BAD-NAME}"})}, + {name: "invalid scope", raw: npmSettingsPolicy(t, map[string]string{"@Bad:registry": "https://registry.npmjs.org/"})}, + {name: "non-canonical registry suffix", raw: npmSettingsPolicy(t, map[string]string{"@example:REGISTRY": "https://registry.npmjs.org/"})}, + {name: "http scoped registry", raw: npmSettingsPolicy(t, map[string]string{"@example:registry": "http://registry.npmjs.org/"})}, + {name: "scoped registry userinfo", raw: npmSettingsPolicy(t, map[string]string{"@example:registry": "https://user:pass@registry.npmjs.org/"})}, + {name: "scoped registry query", raw: npmSettingsPolicy(t, map[string]string{"@example:registry": "https://registry.npmjs.org/?x=1"})}, + {name: "scoped registry bad port", raw: npmSettingsPolicy(t, map[string]string{"@example:registry": "https://registry.npmjs.org:70000/"})}, + {name: "scoped registry empty port", raw: npmSettingsPolicy(t, map[string]string{"@example:registry": "https://registry.npmjs.org:/"})}, + {name: "scoped registry missing host", raw: npmSettingsPolicy(t, map[string]string{"@example:registry": "https:///packages/"})}, + {name: "non-canonical host", raw: npmSettingsPolicy(t, map[string]string{"@example:registry": "https://Registry.NPMJS.org/"})}, + {name: "non-canonical trailing slash", raw: npmSettingsPolicy(t, map[string]string{"@example:registry": "https://registry.npmjs.org/path"})}, + {name: "ordinary URL userinfo", raw: npmSettingsPolicy(t, map[string]string{"proxy": "https://user:pass@proxy.example/"})}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := RenderNPMRCBlock(tc.raw, stdSerial); err == nil { + t.Fatal("expected rejection") + } + }) + } + + invalidUTF8 := append([]byte(`{"ecosystem":"npm","registry_url":"https://registry-int.stepsecurity.io/javascript","auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"},"settings":{"x":"`), 0xff) + invalidUTF8 = append(invalidUTF8, []byte(`"}}`)...) + if _, err := RenderNPMRCBlock(invalidUTF8, stdSerial); err == nil { + t.Fatal("invalid UTF-8 was accepted") + } +} + +func TestRenderNPMRCBlock_SettingsErrorsDoNotLeakValues(t *testing.T) { + const literalSecret = "literal-private-token" + const envName = "CUSTOMER_PRIVATE_TOKEN" + raw := npmSettingsPolicy(t, map[string]string{ + "@example:registry": "https://registry.npmjs.org/", + "//registry.npmjs.org/:_authToken": literalSecret + "-${" + envName + "}", + }) + _, err := RenderNPMRCBlock(raw, stdSerial) + if err == nil { + t.Fatal("unsafe credential value was accepted") + } + for _, sensitive := range []string{literalSecret, envName, "ssabc123", stdTokenVal} { + if strings.Contains(err.Error(), sensitive) { + t.Fatalf("error contains %q: %v", sensitive, err) + } + } +} + +func TestRenderNPMRCBlock_SettingsSizeBoundary(t *testing.T) { + prefix, err := RenderNPMRCBlock(json.RawMessage(stdPolicyJSON), stdSerial) + if err != nil { + t.Fatal(err) + } + key := "x" + atLimit := strings.Repeat("v", npmrcMaxRenderedBytes-len(prefix)-len(key)-2) + body, err := RenderNPMRCBlock(npmSettingsPolicy(t, map[string]string{key: atLimit}), stdSerial) + if err != nil { + t.Fatalf("body at limit: %v", err) + } + if len(body) != npmrcMaxRenderedBytes { + t.Fatalf("rendered length = %d, want %d", len(body), npmrcMaxRenderedBytes) + } + if _, err := RenderNPMRCBlock(npmSettingsPolicy(t, map[string]string{key: atLimit + "v"}), stdSerial); err == nil { + t.Fatal("body above limit was accepted") + } +} + func TestRenderNPMRCBlock_Rejections(t *testing.T) { base := func(mut func(m map[string]any)) json.RawMessage { m := map[string]any{ @@ -273,6 +450,129 @@ func TestRewrite_Idempotent(t *testing.T) { // edge 15 (content) } } +func TestRewrite_SettingsLifecycleAndPrecedence(t *testing.T) { + initial := "\ufeffsave-exact=false\r\n@example:registry=https://old.example/\r\n" + w := &NPMRCWriter{} + applied, err := w.rewriteContent([]byte(initial), stdSettingsBody) + if err != nil { + t.Fatalf("initial rewrite: %v", err) + } + if !strings.HasPrefix(string(applied), initial) { + t.Fatalf("existing scalar assignments changed: %q", applied) + } + if !strings.HasSuffix(string(applied), block(stdSettingsBody)) { + t.Fatalf("managed settings block was not appended last: %q", applied) + } + cleared, err := w.clearContent(applied) + if err != nil { + t.Fatalf("clear: %v", err) + } + if string(cleared) != initial { + t.Fatalf("clear = %q, want exact original %q", cleared, initial) + } + + drifted := append(append([]byte(nil), applied...), []byte("save-exact=false\n")...) + lines := strings.Split(string(drifted), "\n") + if blockIsLastEffective(lines, stdSettingsBody) { + t.Fatal("later setting override must defeat convergence") + } + repaired, err := w.rewriteContent(drifted, stdSettingsBody) + if err != nil { + t.Fatalf("repair: %v", err) + } + if !strings.Contains(string(repaired), "save-exact=false\n") { + t.Fatal("repair removed the user override") + } + if !strings.HasSuffix(string(repaired), block(stdSettingsBody)) { + t.Fatal("repair did not move the managed block last") + } + + changed := strings.Replace(stdSettingsBody, "save-exact=true", "save-exact=false", 1) + changed = strings.Replace(changed, "engine-strict=true\n", "", 1) + updated, err := w.rewriteContent(repaired, changed) + if err != nil { + t.Fatalf("policy update: %v", err) + } + if strings.Contains(extractBodyForTest(t, updated), "engine-strict=") { + t.Fatal("removed setting remained in the managed block") + } + baseOnly, err := w.rewriteContent(updated, stdBody) + if err != nil { + t.Fatalf("return to base-only: %v", err) + } + if got := extractBodyForTest(t, baseOnly); got != stdBody { + t.Fatalf("base-only body = %q, want %q", got, stdBody) + } +} + +func TestRewrite_BackslashSettingUsesNPMSemantics(t *testing.T) { + body, err := RenderNPMRCBlock(npmSettingsPolicy(t, map[string]string{"cache": `\\server\share`}), stdSerial) + if err != nil { + t.Fatal(err) + } + w := &NPMRCWriter{} + first, err := w.rewriteContent(nil, body) + if err != nil { + t.Fatal(err) + } + second, err := w.rewriteContent(first, body) + if err != nil { + t.Fatal(err) + } + if string(second) != string(first) { + t.Fatalf("repeated rewrite changed bytes: %q != %q", second, first) + } + lines := strings.Split(string(first), "\n") + if !blockIsLastEffective(lines, body) { + t.Fatal("unchanged backslash setting did not converge") + } + managed, _ := probeNPMRCContent(boundedMDMBlock(body), body) + if !managed { + t.Fatal("matching bounded MDM block with a backslash setting was not managed") + } + present, observed, err := probeNPMRCObserved(boundedMDMBlock(body), body) + if err != nil { + t.Fatal(err) + } + if !present { + t.Fatal("matching bounded MDM block was not observed") + } + got := observedStrings(t, observed) + if got[observedKeySettingsStatus] != settingsMatch { + t.Fatalf("settings_status = %q, want %q", got[observedKeySettingsStatus], settingsMatch) + } +} + +func extractBodyForTest(t *testing.T, content []byte) string { + t.Helper() + body, present := extractManagedBody(string(content)) + if !present { + t.Fatal("managed block missing") + } + return body +} + +func TestRewrite_SettingsArrayConflict(t *testing.T) { + desired, ok := parseNPMDesired(stdSettingsBody) + if !ok { + t.Fatal("standard settings body did not parse") + } + if !hasArrayAppendOverride([]string{"save-exact[]=false"}, desired) { + t.Fatal("managed setting array was not detected") + } + w := &NPMRCWriter{} + if _, err := w.rewriteContent([]byte("save-exact[]=false\n"), stdSettingsBody); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("managed setting array error = %v, want target unusable", err) + } + out, err := w.rewriteContent([]byte("omit[]=dev\n"), stdSettingsBody) + if err != nil { + t.Fatalf("unrelated array rewrite: %v", err) + } + if !strings.HasPrefix(string(out), "omit[]=dev\n") { + t.Fatalf("unrelated array changed: %q", out) + } +} + // --------------------------------------------------------------------------- // clearContent — the clear transform (pure bytes in, bytes out) // --------------------------------------------------------------------------- @@ -594,6 +894,10 @@ func mdmBlock() string { return npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" } +func boundedMDMBlock(body string) string { + return npmrcMDMBeginMarker + "\n" + body + "\n" + npmrcMDMEndMarker + "\n" +} + func TestProbeContent(t *testing.T) { cases := []struct { name string @@ -621,6 +925,48 @@ func TestProbeContent(t *testing.T) { } } +func TestProbeContent_SettingsBoundedMDMBlock(t *testing.T) { + cases := []struct { + name string + content string + managed bool + }{ + {name: "exact bounded block", content: boundedMDMBlock(stdSettingsBody), managed: true}, + {name: "exact bounded CRLF block", content: strings.ReplaceAll(boundedMDMBlock(stdSettingsBody), "\n", "\r\n"), managed: true}, + {name: "fixed base-only block", content: mdmBlock()}, + {name: "wrong setting", content: boundedMDMBlock(strings.Replace(stdSettingsBody, "save-exact=true", "save-exact=false", 1))}, + {name: "partial block", content: boundedMDMBlock(strings.Replace(stdSettingsBody, "engine-strict=true\n", "", 1))}, + {name: "later setting override", content: boundedMDMBlock(stdSettingsBody) + "save-exact=false\n"}, + {name: "duplicate bounded block", content: boundedMDMBlock(stdSettingsBody) + boundedMDMBlock(stdSettingsBody)}, + {name: "missing end", content: npmrcMDMBeginMarker + "\n" + stdSettingsBody + "\n"}, + {name: "managed array", content: boundedMDMBlock(stdSettingsBody) + "save-exact[]=false\n"}, + {name: "section", content: "[team]\n" + boundedMDMBlock(stdSettingsBody)}, + {name: "marker planted in dmg block", content: block(npmrcMDMBeginMarker + "\n" + stdSettingsBody + "\n" + npmrcMDMEndMarker)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + managed, _ := probeNPMRCContent(tc.content, stdSettingsBody) + if managed != tc.managed { + t.Fatalf("managed = %v, want %v", managed, tc.managed) + } + }) + } + + if managed, _ := probeNPMRCContent(boundedMDMBlock(stdSettingsBody), stdBody); managed { + t.Fatal("bounded settings block must not satisfy a base-only policy") + } + present, observed, err := probeNPMRCObserved(boundedMDMBlock(stdSettingsBody), stdBody) + if err != nil { + t.Fatal(err) + } + if present { + t.Fatal("bounded settings block must not claim base-only MDM ownership") + } + if observed != nil { + t.Fatalf("bounded settings block produced base-only evidence: %v", observed) + } +} + 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. @@ -630,16 +976,6 @@ func TestProbeContent_MarkerInsideOurBlockIgnored(t *testing.T) { } } -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) // --------------------------------------------------------------------------- @@ -674,3 +1010,238 @@ func TestSymlinkTargetPredicates(t *testing.T) { func isTargetUnusable(err error) bool { return errors.Is(err, ErrTargetUnusable) } + +// --------------------------------------------------------------------------- +// settings-only policy (no StepSecurity registry_url/auth pair) +// --------------------------------------------------------------------------- + +const stdSettingsOnlyBody = "//packages.example.com/npm/:_authToken=${EXAMPLE_NPM_TOKEN}\nregistry=https://packages.example.com/npm/\nsave-exact=true" + +func npmSettingsOnlyPolicy(t *testing.T, settings any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(map[string]any{"ecosystem": "npm", "settings": settings}) + if err != nil { + t.Fatal(err) + } + return raw +} + +func stdSettingsOnlySettings() map[string]string { + return map[string]string{ + "save-exact": "true", + "registry": "https://packages.example.com/npm/", + "//packages.example.com/npm/:_authToken": "${EXAMPLE_NPM_TOKEN}", + } +} + +func TestRenderNPMRCBlock_SettingsOnly(t *testing.T) { + got, err := RenderNPMRCBlock(npmSettingsOnlyPolicy(t, stdSettingsOnlySettings()), stdSerial) + if err != nil { + t.Fatalf("RenderNPMRCBlock: %v", err) + } + if got != stdSettingsOnlyBody { + t.Fatalf("rendered body = %q, want %q", got, stdSettingsOnlyBody) + } + if strings.Contains(got, "stepsecurity") || strings.Contains(got, "::dev:") { + t.Fatalf("settings-only body carries StepSecurity lines: %q", got) + } + + reordered, err := RenderNPMRCBlock(json.RawMessage(`{"settings":{"save-exact":"true","//packages.example.com/npm/:_authToken":"${EXAMPLE_NPM_TOKEN}","registry":"https://packages.example.com/npm/"},"ecosystem":"npm"}`), stdSerial) + if err != nil { + t.Fatal(err) + } + if reordered != got { + t.Fatalf("map order changed rendering: %q != %q", reordered, got) + } + + scopedOnly, err := RenderNPMRCBlock(npmSettingsOnlyPolicy(t, map[string]string{ + "@example:registry": "https://registry.npmjs.org/", + "//registry.npmjs.org/:_authToken": "${EXAMPLE_NPM_TOKEN}", + }), stdSerial) + if err != nil { + t.Fatalf("scoped-registry-only policy: %v", err) + } + if scopedOnly != "//registry.npmjs.org/:_authToken=${EXAMPLE_NPM_TOKEN}\n@example:registry=https://registry.npmjs.org/" { + t.Fatalf("scoped-only body = %q", scopedOnly) + } + + desired, ok := parseNPMDesired(got) + if !ok { + t.Fatal("settings-only body did not parse") + } + if desired.stepSecurity() || desired.registry != "" || desired.tokenKey != "" { + t.Fatalf("settings-only body parsed as StepSecurity-backed: %+v", desired) + } + if len(desired.settings) != 3 || desired.values["registry"] != "https://packages.example.com/npm/" { + t.Fatalf("settings-only registry is not an ordinary setting: %+v", desired) + } + withPair, ok := parseNPMDesired(stdSettingsBody) + if !ok || !withPair.stepSecurity() || withPair.registry != stdRegistry || withPair.tokenKey != stdTokenKey || len(withPair.settings) != 4 { + t.Fatalf("StepSecurity pair not recognized: %+v", withPair) + } +} + +func TestRenderNPMRCBlock_SettingsOnlyRejections(t *testing.T) { + cases := []struct { + name string + raw json.RawMessage + }{ + {name: "neither registry nor settings", raw: json.RawMessage(`{"ecosystem":"npm"}`)}, + {name: "registry_url without auth", raw: json.RawMessage(`{"ecosystem":"npm","registry_url":"` + stdRegistry + `","settings":{"save-exact":"true"}}`)}, + {name: "auth without registry_url", raw: json.RawMessage(`{"ecosystem":"npm","auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"},"settings":{"save-exact":"true"}}`)}, + {name: "null registry_url and auth", raw: json.RawMessage(`{"ecosystem":"npm","registry_url":null,"auth":null,"settings":{"registry":"https://packages.example.com/npm/"}}`)}, + {name: "non-string registry_url", raw: json.RawMessage(`{"ecosystem":"npm","registry_url":1,"auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"}}`)}, + {name: "ordinary settings without a registry", raw: npmSettingsOnlyPolicy(t, map[string]string{"save-exact": "true"})}, + {name: "case-variant registry is not a registry", raw: npmSettingsOnlyPolicy(t, map[string]string{"Registry": "https://packages.example.com/npm/"})}, + {name: "non-canonical default registry", raw: npmSettingsOnlyPolicy(t, map[string]string{"registry": "https://packages.example.com/npm"})}, + {name: "http default registry", raw: npmSettingsOnlyPolicy(t, map[string]string{"registry": "http://packages.example.com/npm/"})}, + {name: "token unpaired with the default registry", raw: npmSettingsOnlyPolicy(t, map[string]string{"registry": "https://packages.example.com/npm/", "//other.example/:_authToken": "${TOKEN}"})}, + {name: "literal default registry token", raw: npmSettingsOnlyPolicy(t, map[string]string{"registry": "https://packages.example.com/npm/", "//packages.example.com/npm/:_authToken": "literal"})}, + {name: "empty settings", raw: npmSettingsOnlyPolicy(t, map[string]string{})}, + {name: "null settings", raw: npmSettingsOnlyPolicy(t, nil)}, + {name: "settings.registry with StepSecurity registry", raw: npmSettingsPolicy(t, map[string]string{"registry": "https://packages.example.com/npm/"})}, + {name: "scoped registry targeting the StepSecurity registry", raw: npmSettingsPolicy(t, map[string]string{"@team:registry": stdRegistry + "/"})}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := RenderNPMRCBlock(tc.raw, stdSerial); err == nil { + t.Fatal("expected rejection") + } + }) + } + if _, err := RenderNPMRCBlock(npmSettingsOnlyPolicy(t, stdSettingsOnlySettings()), ""); err == nil { + t.Fatal("settings-only policy accepted an empty device serial") + } +} + +func TestRenderNPMRCBlock_SettingsOnlySizeBoundary(t *testing.T) { + const registry = "registry=https://packages.example.com/npm/\n" + key := "x" + atLimit := strings.Repeat("v", npmrcMaxRenderedBytes-len(registry)-len(key)-1) + settings := map[string]string{"registry": "https://packages.example.com/npm/", key: atLimit} + body, err := RenderNPMRCBlock(npmSettingsOnlyPolicy(t, settings), stdSerial) + if err != nil { + t.Fatalf("body at limit: %v", err) + } + if len(body) != npmrcMaxRenderedBytes { + t.Fatalf("rendered length = %d, want %d", len(body), npmrcMaxRenderedBytes) + } + settings[key] = atLimit + "v" + if _, err := RenderNPMRCBlock(npmSettingsOnlyPolicy(t, settings), stdSerial); err == nil { + t.Fatal("body above limit was accepted") + } +} + +func TestRewrite_SettingsOnlyRegistryIsAnOrdinarySetting(t *testing.T) { + initial := "registry=https://old.example/\nsave-exact=false\n" + w := &NPMRCWriter{} + applied, err := w.rewriteContent([]byte(initial), stdSettingsOnlyBody) + if err != nil { + t.Fatalf("rewrite: %v", err) + } + if string(applied) != initial+block(stdSettingsOnlyBody) { + t.Fatalf("settings-only rewrite = %q, want user lines untouched and block appended", applied) + } + if !blockIsLastEffective(strings.Split(string(applied), "\n"), stdSettingsOnlyBody) { + t.Fatal("appended settings-only block was not last-effective") + } + again, err := w.rewriteContent(applied, stdSettingsOnlyBody) + if err != nil { + t.Fatal(err) + } + if string(again) != string(applied) { + t.Fatalf("repeated rewrite changed bytes: %q", again) + } + + overridden := append(append([]byte(nil), applied...), []byte("registry=https://later.example/\n")...) + if blockIsLastEffective(strings.Split(string(overridden), "\n"), stdSettingsOnlyBody) { + t.Fatal("later registry override must defeat convergence") + } + repaired, err := w.rewriteContent(overridden, stdSettingsOnlyBody) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(repaired), "\nregistry=https://later.example/\n") || strings.Contains(string(repaired), npmrcDMGPrefix) { + t.Fatalf("settings-only repair must keep the user registry line unprefixed: %q", repaired) + } + if !strings.HasSuffix(string(repaired), block(stdSettingsOnlyBody)) { + t.Fatal("repair did not move the managed block last") + } + + if _, err := w.rewriteContent([]byte("registry[]=https://evil.example/\n"), stdSettingsOnlyBody); !errors.Is(err, ErrTargetUnusable) { + t.Fatalf("settings-only registry array error = %v, want target unusable", err) + } + + cleared, err := w.clearContent(applied) + if err != nil { + t.Fatal(err) + } + if string(cleared) != initial { + t.Fatalf("clear = %q, want %q", cleared, initial) + } +} + +func TestRewrite_StepSecurityToSettingsOnlyTransition(t *testing.T) { + initial := "registry=https://old.example/\n" + w := &NPMRCWriter{} + stepSecurity, err := w.rewriteContent([]byte(initial), stdSettingsBody) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(stepSecurity), npmrcDMGPrefix+"registry=https://old.example/\n") { + t.Fatalf("StepSecurity shape did not prefix the bare registry: %q", stepSecurity) + } + + settingsOnly, err := w.rewriteContent(stepSecurity, stdSettingsOnlyBody) + if err != nil { + t.Fatal(err) + } + if string(settingsOnly) != initial+block(stdSettingsOnlyBody) { + t.Fatalf("transition to settings-only = %q, want restored registry line then block", settingsOnly) + } + + back, err := w.rewriteContent(settingsOnly, stdBody) + if err != nil { + t.Fatal(err) + } + if string(back) != npmrcDMGPrefix+initial+block(stdBody) { + t.Fatalf("transition back to StepSecurity = %q, want re-prefixed registry then block", back) + } + restored, err := w.clearContent(back) + if err != nil { + t.Fatal(err) + } + if string(restored) != initial { + t.Fatalf("clear after round trip = %q, want %q", restored, initial) + } +} + +func TestProbeContent_SettingsOnlyBoundedMDMBlock(t *testing.T) { + cases := []struct { + name string + content string + managed bool + }{ + {name: "exact bounded block", content: boundedMDMBlock(stdSettingsOnlyBody), managed: true}, + {name: "exact block after unrelated user config", content: "save-exact=false\n" + boundedMDMBlock(stdSettingsOnlyBody), managed: true}, + {name: "fixed StepSecurity block", content: mdmBlock()}, + {name: "wrong registry", content: boundedMDMBlock(strings.Replace(stdSettingsOnlyBody, "packages.example.com/npm/\n", "other.example/\n", 1))}, + {name: "partial block", content: boundedMDMBlock(strings.Replace(stdSettingsOnlyBody, "\nsave-exact=true", "", 1))}, + {name: "later registry override", content: boundedMDMBlock(stdSettingsOnlyBody) + "registry=https://later.example/\n"}, + {name: "registry array", content: boundedMDMBlock(stdSettingsOnlyBody) + "registry[]=https://later.example/\n"}, + {name: "duplicate bounded block", content: boundedMDMBlock(stdSettingsOnlyBody) + boundedMDMBlock(stdSettingsOnlyBody)}, + {name: "section", content: "[team]\n" + boundedMDMBlock(stdSettingsOnlyBody)}, + {name: "marker planted in dmg block", content: block(npmrcMDMBeginMarker + "\n" + stdSettingsOnlyBody + "\n" + npmrcMDMEndMarker)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + managed, _ := probeNPMRCContent(tc.content, stdSettingsOnlyBody) + if managed != tc.managed { + t.Fatalf("managed = %v, want %v", managed, tc.managed) + } + }) + } + if managed, _ := probeNPMRCContent(boundedMDMBlock(stdSettingsOnlyBody), stdBody); managed { + t.Fatal("settings-only bounded block must not satisfy a StepSecurity-only policy") + } +} diff --git a/internal/devicepolicy/npmrc_unix_test.go b/internal/devicepolicy/npmrc_unix_test.go index d3b9368..bfb41e5 100644 --- a/internal/devicepolicy/npmrc_unix_test.go +++ b/internal/devicepolicy/npmrc_unix_test.go @@ -82,12 +82,12 @@ func TestWrite_CreatesFile(t *testing.T) { func TestWrite_ThenConvergedTrue(t *testing.T) { // edge 15 on disk home := t.TempDir() w := newDiskWriter(t, home) - if _, err := w.Write(stdBody); err != nil { + if _, err := w.Write(stdSettingsBody); err != nil { t.Fatalf("Write: %v", err) } first := readFile(t, npmrcPath(home)) - conv, err := w.Converged(stdBody) + conv, err := w.Converged(stdSettingsBody) if err != nil { t.Fatalf("Converged: %v", err) } @@ -95,7 +95,7 @@ func TestWrite_ThenConvergedTrue(t *testing.T) { // edge 15 on disk t.Fatal("expected Converged=true after a fresh write") } // A second write is byte-identical. - if _, err := w.Write(stdBody); err != nil { + if _, err := w.Write(stdSettingsBody); err != nil { t.Fatalf("second Write: %v", err) } if second := readFile(t, npmrcPath(home)); second != first { @@ -103,6 +103,92 @@ func TestWrite_ThenConvergedTrue(t *testing.T) { // edge 15 on disk } } +func TestWrite_SettingsConvergesAndClearsExactly(t *testing.T) { + home := t.TempDir() + path := npmrcPath(home) + original := []byte("save-exact=false\r\n@example:registry=https://old.example/\r\n") + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdSettingsBody); err != nil { + t.Fatalf("Write: %v", err) + } + converged, err := w.Converged(stdSettingsBody) + if err != nil { + t.Fatalf("Converged: %v", err) + } + if !converged { + t.Fatal("settings block did not converge") + } + if _, err := w.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(original) { + t.Fatalf("cleared bytes = %q, want %q", got, original) + } +} + +func TestWrite_RejectsStaleMDMBlocksWithoutMutation(t *testing.T) { + tests := []struct { + name string + content string + body string + }{ + {name: "fixed block", content: mdmBlock(), body: stdBody}, + {name: "bounded block", content: boundedMDMBlock(stdSettingsBody), body: stdSettingsBody}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + path := npmrcPath(home) + before := []byte(tc.content) + if err := os.WriteFile(path, before, 0o600); err != nil { + t.Fatal(err) + } + beforeInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(tc.body); !isTargetUnusable(err) { + t.Fatalf("Write error = %v, want target unusable", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("content changed: %q != %q", after, before) + } + afterInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(beforeInfo, afterInfo) { + t.Fatal("file identity changed") + } + if afterInfo.Mode() != beforeInfo.Mode() { + t.Fatalf("mode = %v, want %v", afterInfo.Mode(), beforeInfo.Mode()) + } + if !afterInfo.ModTime().Equal(beforeInfo.ModTime()) { + t.Fatalf("mtime = %v, want %v", afterInfo.ModTime(), beforeInfo.ModTime()) + } + backups, err := filepath.Glob(path + ".dmg-*.bak") + if err != nil { + t.Fatal(err) + } + if len(backups) != 0 { + t.Fatalf("backup residue = %v, want none", backups) + } + }) + } +} + func TestConverged_FalseOnLooseMode(t *testing.T) { // edge 18 home := t.TempDir() w := newDiskWriter(t, home) @@ -324,7 +410,7 @@ func TestRestoreSnapshot(t *testing.T) { 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 { + if _, err := w.Write(stdSettingsBody); err != nil { t.Fatalf("Write: %v", err) } if err := w.RestoreSnapshot(); err != nil { @@ -684,22 +770,61 @@ func TestProbeContentNPM_OnDisk(t *testing.T) { } else if len(entries) != 1 { t.Fatalf("ProbeContentNPM left extra files behind: %v", entries) } + + settingsBefore := []byte(boundedMDMBlock(stdSettingsBody)) + if err := os.WriteFile(npmrcPath(home), settingsBefore, 0o600); err != nil { + t.Fatal(err) + } + present, observed, err = w.ProbeContentNPM(stdSettingsBody) + if err != nil { + t.Fatalf("settings MDM block: %v", err) + } + if !present { + t.Fatal("settings MDM block was not recognized") + } + if len(observed) != 4 { + t.Fatalf("settings observed = %v, want exactly 4 keys", observed) + } + settingsAfter, err := os.ReadFile(npmrcPath(home)) + if err != nil { + t.Fatal(err) + } + if string(settingsAfter) != string(settingsBefore) { + t.Fatalf("settings probe mutated the file: %q != %q", settingsAfter, settingsBefore) + } } -func TestProbeContentNPM_LooseModeStillObserved(t *testing.T) { - // Deliberate divergence from ProbeExpected, which rejects loose metadata so the - // DMG lane enforces instead. In verify-only mode there is no write to fall back - // to, and perms are not part of the observed contract — so a - // correctly-deployed-but-0644 file must still report its real registry and auth - // status rather than be hidden behind a synthetic failure. +func TestProbeContentNPM_LooseModeBaseOnlyObservedSettingsRejected(t *testing.T) { + // Preserve the base-only observation contract, but fail settings-aware MDM + // verification because that shape requires secure metadata. home := t.TempDir() if err := os.WriteFile(npmrcPath(home), []byte(mdmBlock()), 0o644); err != nil { t.Fatalf("seed: %v", err) } w := newDiskWriter(t, home) present, observed, err := w.ProbeContentNPM(stdBody) - if err != nil || !present || len(observed) != 3 { - t.Fatalf("a 0644 MDM block = (%v, %v, %v), want it observed", present, observed, err) + if err != nil { + t.Fatalf("base-only probe: %v", err) + } + if !present { + t.Fatal("base-only MDM block was not observed") + } + if len(observed) != 3 { + t.Fatalf("base-only observed keys = %d, want 3", len(observed)) + } + + if err := os.WriteFile(npmrcPath(home), []byte(boundedMDMBlock(stdSettingsBody)), 0o644); err != nil { + t.Fatal(err) + } + present, observed, err = w.ProbeContentNPM(stdSettingsBody) + if !isTargetUnusable(err) { + t.Fatalf("settings-aware probe error = %v, want target unusable", err) + } + if present { + t.Fatal("insecure settings-aware MDM block reported present") + } + if observed != nil { + t.Fatalf("insecure settings-aware MDM block produced evidence: %v", observed) } } @@ -848,3 +973,64 @@ func dmgBackups(t *testing.T, home string) []string { } return out } + +func TestWrite_SettingsOnlyTransitionsAndClearsExactly(t *testing.T) { + home := t.TempDir() + path := npmrcPath(home) + original := []byte("registry=https://old.example/\nsave-exact=false\n") + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdSettingsBody); err != nil { + t.Fatalf("StepSecurity write: %v", err) + } + if _, err := w.Write(stdSettingsOnlyBody); err != nil { + t.Fatalf("settings-only write: %v", err) + } + if got := readFile(t, path); got != string(original)+block(stdSettingsOnlyBody) { + t.Fatalf("settings-only file = %q, want restored registry line then block", got) + } + converged, err := w.Converged(stdSettingsOnlyBody) + if err != nil { + t.Fatalf("Converged: %v", err) + } + if !converged { + t.Fatal("settings-only block did not converge") + } + if converged, _ := w.Converged(stdBody); converged { + t.Fatal("settings-only block must not satisfy a StepSecurity-only body") + } + if _, err := w.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + if got := readFile(t, path); got != string(original) { + t.Fatalf("cleared bytes = %q, want %q", got, original) + } +} + +func TestProbeContentNPM_SettingsOnlyOnDiskIsReadOnly(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + content := "engine-strict=true\n" + boundedMDMBlock(stdSettingsOnlyBody) + if err := os.WriteFile(npmrcPath(home), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + present, observed, err := w.ProbeContentNPM(stdSettingsOnlyBody) + if err != nil || !present { + t.Fatalf("settings-only MDM block = (%v, %v), want present with no error", present, err) + } + got := observedStrings(t, observed) + if len(got) != 2 || got[observedKeySettingsStatus] != settingsMatch { + t.Fatalf("observed = %v, want ecosystem and matching settings_status only", got) + } + if managed, _ := w.ProbeExpected(stdSettingsOnlyBody); !managed { + t.Fatal("ProbeExpected did not recognize the settings-only MDM block") + } + if after := readFile(t, npmrcPath(home)); after != content { + t.Fatalf("MDM probe mutated the file: %q", after) + } + if entries, _ := os.ReadDir(home); len(entries) != 1 { + t.Fatalf("MDM probe left extra files: %v", entries) + } +} diff --git a/internal/devicepolicy/npmrc_windows_test.go b/internal/devicepolicy/npmrc_windows_test.go index d5c0065..c8f6431 100644 --- a/internal/devicepolicy/npmrc_windows_test.go +++ b/internal/devicepolicy/npmrc_windows_test.go @@ -28,10 +28,10 @@ func TestNPMRCWriterWindowsAppliesTargetUserSecurity(t *testing.T) { t.Fatal(err) } defer w.Close() - if _, err := w.Write(stdBody); err != nil { + if _, err := w.Write(stdSettingsBody); err != nil { t.Fatal(err) } - if _, err := w.Write(stdBody); err != nil { + if _, err := w.Write(stdSettingsBody); err != nil { t.Fatal(err) } paths, err := filepath.Glob(filepath.Join(homeDir, ".npmrc*")) @@ -107,7 +107,7 @@ func TestNPMRCWriterWindowsBackupAndRollback(t *testing.T) { t.Fatal(err) } defer w.Close() - if _, err := w.Write(stdBody); err != nil { + if _, err := w.Write(stdSettingsBody); err != nil { t.Fatal(err) } backups, err := filepath.Glob(path + ".dmg-*.bak") @@ -127,12 +127,14 @@ func TestNPMRCWriterWindowsClearRestoresGeneratedLifecycle(t *testing.T) { tests := []struct { name string initial []byte + body string wantAbsent bool wantContent []byte }{ {name: "agent-created file returns to absent", wantAbsent: true}, {name: "pre-existing empty file remains empty", initial: []byte{}, wantContent: []byte{}}, {name: "pre-existing CRLF config restores exactly", initial: []byte("registry=https://registry.npmjs.org/\r\n"), wantContent: []byte("registry=https://registry.npmjs.org/\r\n")}, + {name: "pre-existing settings conflict restores exactly", initial: []byte("save-exact=false\r\n"), body: stdSettingsBody, wantContent: []byte("save-exact=false\r\n")}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -154,7 +156,11 @@ func TestNPMRCWriterWindowsClearRestoresGeneratedLifecycle(t *testing.T) { t.Fatal(err) } defer w.Close() - if _, err := w.Write(stdBody); err != nil { + body := tc.body + if body == "" { + body = stdBody + } + if _, err := w.Write(body); err != nil { t.Fatal(err) } state := AppliedTargetState{} @@ -231,6 +237,53 @@ func TestNPMRCWriterWindowsRepairsWeakExpectedPrincipalACL(t *testing.T) { } } +func TestNPMRCWriterWindowsRejectsWeakSettingsMDMACL(t *testing.T) { + homeDir := t.TempDir() + path := filepath.Join(homeDir, ".npmrc") + if err := os.WriteFile(path, []byte(boundedMDMBlock(stdSettingsBody)), 0o600); err != nil { + t.Fatal(err) + } + u, err := user.Current() + if err != nil { + t.Fatal(err) + } + u.HomeDir = homeDir + normalizeSecureTestUser(t, u) + targetSID, err := windows.StringToSid(u.Uid) + if err != nil { + t.Fatal(err) + } + systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatal(err) + } + acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{ + npmTestExplicitAccess(targetSID, windows.GENERIC_READ, windows.TRUSTEE_IS_USER), + npmTestExplicitAccess(systemSID, windows.GENERIC_READ, windows.TRUSTEE_IS_WELL_KNOWN_GROUP), + }, nil) + if err != nil { + t.Fatal(err) + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, acl, nil); err != nil { + t.Fatal(err) + } + w, err := NewNPMRCWriter(secureTestExecutor{Executor: executor.NewReal(), user: u}) + if err != nil { + t.Fatal(err) + } + defer w.Close() + present, observed, err := w.ProbeContentNPM(stdSettingsBody) + if !isTargetUnusable(err) { + t.Fatalf("ProbeContentNPM error = %v, want target unusable", err) + } + if present { + t.Fatal("weak-ACL settings MDM block reported present") + } + if observed != nil { + t.Fatalf("weak-ACL settings MDM block produced evidence: %v", observed) + } +} + func npmTestExplicitAccess(sid *windows.SID, permissions windows.ACCESS_MASK, trusteeType windows.TRUSTEE_TYPE) windows.EXPLICIT_ACCESS { return windows.EXPLICIT_ACCESS{ AccessPermissions: permissions, diff --git a/internal/devicepolicy/pip_writer.go b/internal/devicepolicy/pip_writer.go index 054bdb8..ce7aefd 100644 --- a/internal/devicepolicy/pip_writer.go +++ b/internal/devicepolicy/pip_writer.go @@ -19,10 +19,10 @@ import ( ) const ( - dmgPipBegin = "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by dmg" - dmgPipEnd = "# END StepSecurity PyPI Secure Registry pip" - mdmPipBegin = "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by mdm" - mdmPipEnd = "# END StepSecurity PyPI Secure Registry pip" + dmgPipBegin = "# BEGIN StepSecurity Package Configuration pip -- managed by dmg" + dmgPipEnd = "# END StepSecurity Package Configuration pip" + mdmPipBegin = "# BEGIN StepSecurity Package Configuration pip -- managed by mdm" + mdmPipEnd = "# END StepSecurity Package Configuration pip" dmgPipDisabledPrefix = "# [stepsecurity-pypi-pip-dmg] " pipBackupPrefix = ".dmg-" diff --git a/internal/devicepolicy/pip_writer_test.go b/internal/devicepolicy/pip_writer_test.go index d0cd920..a26d531 100644 --- a/internal/devicepolicy/pip_writer_test.go +++ b/internal/devicepolicy/pip_writer_test.go @@ -22,10 +22,10 @@ func TestPipMarkers_Canonical(t *testing.T) { got string want string }{ - {"DMG begin", dmgPipBegin, "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by dmg"}, - {"DMG end", dmgPipEnd, "# END StepSecurity PyPI Secure Registry pip"}, - {"MDM begin", mdmPipBegin, "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by mdm"}, - {"MDM end", mdmPipEnd, "# END StepSecurity PyPI Secure Registry pip"}, + {"DMG begin", dmgPipBegin, "# BEGIN StepSecurity Package Configuration pip -- managed by dmg"}, + {"DMG end", dmgPipEnd, "# END StepSecurity Package Configuration pip"}, + {"MDM begin", mdmPipBegin, "# BEGIN StepSecurity Package Configuration pip -- managed by mdm"}, + {"MDM end", mdmPipEnd, "# END StepSecurity Package Configuration pip"}, {"disabled prefix", dmgPipDisabledPrefix, "# [stepsecurity-pypi-pip-dmg] "}, } for _, tc := range tests { @@ -733,17 +733,17 @@ func TestPipObservedStaticConvergedAcceptsOnlyCanonicalMDMCreatedBlocks(t *testi } func TestPipObservation_AcceptsExactGeneratedMDMArtifacts(t *testing.T) { - created := "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by mdm\n" + + created := "# BEGIN StepSecurity Package Configuration pip -- managed by mdm\n" + "# [stepsecurity-pypi-pip-mdm] created=true\n" + "[global]\n" + pipExpected + "\n" + - "# END StepSecurity PyPI Secure Registry pip\n" + "# END StepSecurity Package Configuration pip\n" existingGlobal := "[global]\n" + - "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by mdm\n" + pipExpected + "\n" + - "# END StepSecurity PyPI Secure Registry pip\n" + "# BEGIN StepSecurity Package Configuration pip -- managed by mdm\n" + pipExpected + "\n" + + "# END StepSecurity Package Configuration pip\n" existingWithoutGlobal := "[install]\nuser = true\n" + - "# BEGIN StepSecurity PyPI Secure Registry pip -- managed by mdm\n" + + "# BEGIN StepSecurity Package Configuration pip -- managed by mdm\n" + "[global]\n" + pipExpected + "\n" + - "# END StepSecurity PyPI Secure Registry pip\n" + "# END StepSecurity Package Configuration pip\n" tests := []struct { name string body string diff --git a/internal/devicepolicy/reconcile.go b/internal/devicepolicy/reconcile.go index 8809b99..1aed0ca 100644 --- a/internal/devicepolicy/reconcile.go +++ b/internal/devicepolicy/reconcile.go @@ -90,8 +90,8 @@ type Reconciler struct { FullStateDrift bool // 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 + // e.g. rendering the ~/.npmrc managed body 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) @@ -131,6 +131,12 @@ type Reconciler struct { // Writer is the ordinary unsupported-platform silent no-op. WriterInitErr error + // InitWriter initializes the npm writer after an active policy has rendered, + // or before an explicit DMG clear. It is nil for every other target. This + // preserves the npm trust-boundary ordering: malformed compiled settings are + // rejected before resolving the target user or opening their home. + InitWriter func() error + // Now and Logf are optional seams. Now defaults to time.Now().UTC; Logf to a // no-op. Now func() time.Time @@ -149,6 +155,9 @@ type Reconciler struct { enforcement string // evaluatedHash is the active npm policy hash fetched for this cycle. evaluatedHash string + // renderedValue caches the trust-boundary render for the current cycle. + renderedValue string + rendered bool } // readState / persistState / dropState are every category's access to the one @@ -195,12 +204,25 @@ func (r *Reconciler) probeOwnershipState(cat string, previous AppliedTargetState // 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.rendered { + return r.renderedValue, nil + } if r.Render != nil { return r.Render(policy) } return compactJSON(policy) } +func (r *Reconciler) prepareRenderedValue(policy json.RawMessage) error { + value, err := r.renderValue(policy) + if err != nil { + return err + } + r.renderedValue = value + r.rendered = true + return nil +} + // 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. @@ -382,6 +404,8 @@ func (r *Reconciler) ownershipKey() string { // verify + report (handleEnforce). func (r *Reconciler) Reconcile(ctx context.Context) error { r.evaluatedHash = "" + r.renderedValue = "" + r.rendered = false if r.Fetcher == nil { return errors.New("devicepolicy: nil fetcher") } @@ -405,7 +429,6 @@ func (r *Reconciler) Reconcile(ctx context.Context) error { switch strings.ToLower(strings.TrimSpace(ep.Enforcement)) { case enforcementMDM: r.enforcement = enforcementMDM - return r.verifyMDM(ctx, cat, tgt, ep) case enforcementDMG, "": r.enforcement = enforcementDMG default: @@ -413,6 +436,25 @@ func (r *Reconciler) Reconcile(ctx context.Context) error { r.enforcement = enforcementDMG } + needsWriter := ep.present() && (r.enforcement == enforcementDMG || !ep.Clear) + if r.InitWriter != nil && needsWriter { + if ep.present() && !ep.Clear { + if err := r.prepareRenderedValue(ep.Policy); err != nil { + if r.enforcement == enforcementMDM { + r.logf("devicepolicy: mdm render desired value failed: %v → verification_failed", err) + return r.sendReport(ctx, ComplianceReport{Category: cat, Target: tgt, State: StateVerificationFailed}) + } + _ = r.report(ctx, cat, tgt, StatePolicyNotApplied, "") + return fmt.Errorf("devicepolicy: enforce: render policy: %w", err) + } + } + r.WriterInitErr = r.InitWriter() + } + + if r.enforcement == enforcementMDM { + return r.verifyMDM(ctx, cat, tgt, ep) + } + if r.Writer == nil { // No usable writer. Two shapes: an unsupported platform (no init error → // the long-standing silent skip) or a construction failure (init error → @@ -682,6 +724,8 @@ func (r *Reconciler) handleEnforce(ctx context.Context, cat, tgt string, ep Effe } return fmt.Errorf("devicepolicy: enforce: compact policy: %w", err) } + r.renderedValue = newValue + r.rendered = true // 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 @@ -1126,12 +1170,25 @@ func (r *Reconciler) rollbackWrite(prevOnDisk string, prevPresent bool) { // report submits a write-path compliance report. func (r *Reconciler) report(ctx context.Context, cat, tgt, state, appliedHash string) error { - return r.sendReport(ctx, ComplianceReport{ + rep := ComplianceReport{ Category: cat, Target: tgt, State: state, AppliedHash: appliedHash, - }) + } + if cat == CategoryPackageConfig && tgt == TargetNPM && (state == StateCompliant || state == StateDriftDetected) { + observed, err := npmCompliantObserved(r.renderedValue) + if err != nil { + return err + } + if observed != nil { + rep.Observed, err = json.Marshal(observed) + if err != nil { + return err + } + } + } + return r.sendReport(ctx, rep) } // sendReport stamps the shared fields (agent version, platform, diff --git a/internal/devicepolicy/reconcile_npm_test.go b/internal/devicepolicy/reconcile_npm_test.go index 1dc2df2..25a6c86 100644 --- a/internal/devicepolicy/reconcile_npm_test.go +++ b/internal/devicepolicy/reconcile_npm_test.go @@ -68,8 +68,8 @@ func (s *npmStore) exists() bool { // 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. +// value the reconciler writes and compares, standing in for the managed body +// RenderNPMRCBlock produces. const npmPolicyWire = `{"registry":"https://npm.pkg.example/","always_auth":true}` const npmRendered = "registry=https://npm.pkg.example/\nalways-auth=true" @@ -167,22 +167,66 @@ func TestNPMEnforceRendersBlockAndWrites(t *testing.T) { } } -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. +func TestNPMSettingsDMGReportIncludesAggregateMatchWithPrebuiltWriter(t *testing.T) { w := &fakeWriter{} + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, newNPMStore(t)) + r.Render = func(json.RawMessage) (string, error) { return stdSettingsBody, nil } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + r.Converged = func(expected string) (bool, error) { return w.present && w.value == expected, nil } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("second Reconcile: %v", err) + } + w.value = "registry=https://drift.example/" + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("drift Reconcile: %v", err) + } + if len(w.writes) != 2 { + t.Fatalf("writes = %d, want apply, no-op, and drift repair", len(w.writes)) + } + + for i, got := range rep.reports { + wantState := StateCompliant + if i == 2 { + wantState = StateDriftDetected + } + if got.State != wantState { + t.Fatalf("report[%d] state = %q, want %q", i, got.State, wantState) + } + var observed map[string]json.RawMessage + if err := json.Unmarshal(got.Observed, &observed); err != nil { + t.Fatalf("report[%d] observed is not a JSON object: %v (%s)", i, err, got.Observed) + } + if len(observed) != 4 || string(observed[observedKeySettingsStatus]) != `"match"` { + t.Fatalf("report[%d] observed = %s, want four-key bag with matching settings", i, got.Observed) + } + for _, sensitive := range []string{"save-exact", "EXAMPLE_NPM_TOKEN", "engine-strict"} { + if strings.Contains(string(got.Observed), sensitive) { + t.Fatalf("report[%d] contains %q: %s", i, sensitive, got.Observed) + } + } + } +} + +func TestNPMNullSettingRejectedBeforeWriterInitialization(t *testing.T) { + // A null settings member must fail at the policy boundary before target-user + // resolution or any filesystem access. st := newNPMStore(t) - 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") } + ep := npmPolicyEP("sha256:N") + ep.Policy = json.RawMessage(`{"ecosystem":"npm","registry_url":"https://registry-int.stepsecurity.io/javascript","auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"},"settings":{"save-exact":null}}`) + r, rep := newNPMRec(t, ep, nil, st) + r.Render = func(policy json.RawMessage) (string, error) { return RenderNPMRCBlock(policy, stdSerial) } + initialized := false + r.InitWriter = func() error { + initialized = true + return nil + } if err := r.Reconcile(context.Background()); err == nil { - t.Fatal("a render failure must surface an error") + t.Fatal("a null settings member 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 initialized { + t.Fatal("writer initialized before settings validation") } if got := lastReport(t, rep); got.State != StatePolicyNotApplied { t.Fatalf("state = %q, want policy_not_applied", got.State) @@ -709,6 +753,12 @@ func npmObservedFake() map[string]json.RawMessage { } } +func npmSettingsObservedFake(status string) map[string]json.RawMessage { + observed := npmObservedFake() + observed[observedKeySettingsStatus] = json.RawMessage(`"` + status + `"`) + return observed +} + // npmMDMEP is an npm policy directive on the verify-only channel. func npmMDMEP(hash string) EffectivePolicy { ep := npmPolicyEP(hash) @@ -792,6 +842,49 @@ func TestNPMMDMChannelVerifiesAndNeverWrites(t *testing.T) { } } +func TestNPMMDMChannelReportsAggregateSettingsStatus(t *testing.T) { + w := &fakeWriter{} + r, rep := newNPMRec(t, npmMDMEP("sha256:N"), w, newNPMStore(t)) + r.Render = func(json.RawMessage) (string, error) { return stdSettingsBody, nil } + r.ProbeContent = func(expected string) (bool, map[string]json.RawMessage, error) { + if expected != stdSettingsBody { + t.Fatalf("expected = %q, want settings body", expected) + } + return true, npmSettingsObservedFake(settingsMismatch), nil + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + report := lastReport(t, rep) + if report.State != StateMDMManaged { + t.Fatalf("state = %q, want %q", report.State, StateMDMManaged) + } + var observed map[string]json.RawMessage + if err := json.Unmarshal(report.Observed, &observed); err != nil { + t.Fatal(err) + } + if len(observed) != 4 { + t.Fatalf("observed key count = %d, want 4", len(observed)) + } + if string(observed[observedKeySettingsStatus]) != `"mismatch"` { + t.Fatalf("settings_status = %s, want mismatch", observed[observedKeySettingsStatus]) + } + for _, sensitive := range []string{"save-exact", "EXAMPLE_NPM_TOKEN", "engine-strict"} { + if strings.Contains(string(report.Observed), sensitive) { + t.Fatalf("report contains %q: %s", sensitive, report.Observed) + } + } + if len(w.writes) != 0 { + t.Fatalf("MDM observation wrote through the writer: %v", w.writes) + } + if w.clears != 0 { + t.Fatalf("MDM observation clear count = %d, want 0", w.clears) + } + if w.reads != 0 { + t.Fatalf("MDM observation read count = %d, want 0", w.reads) + } +} + func TestNPMMDMChannelStatesFromProbe(t *testing.T) { cases := []struct { name string @@ -1133,3 +1226,28 @@ func TestNPMNeverLogsOrReportsTheToken(t *testing.T) { t.Fatal("the test proved nothing — no log lines were captured") } } + +func TestNPMSettingsOnlyDMGReportCarriesSettingsStatusOnly(t *testing.T) { + w := &fakeWriter{} + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, newNPMStore(t)) + r.Render = func(json.RawMessage) (string, error) { return stdSettingsOnlyBody, nil } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + got := lastReport(t, rep) + if got.State != StateCompliant { + t.Fatalf("state = %q, want %q", got.State, StateCompliant) + } + var observed map[string]json.RawMessage + if err := json.Unmarshal(got.Observed, &observed); err != nil { + t.Fatalf("observed is not a JSON object: %v (%s)", err, got.Observed) + } + if len(observed) != 2 || string(observed[observedKeyEcosystem]) != `"npm"` || string(observed[observedKeySettingsStatus]) != `"match"` { + t.Fatalf("observed = %s, want ecosystem and settings_status only", got.Observed) + } + for _, sensitive := range []string{"packages.example.com", "EXAMPLE_NPM_TOKEN", "save-exact"} { + if strings.Contains(string(got.Observed), sensitive) { + t.Fatalf("report contains %q: %s", sensitive, got.Observed) + } + } +} diff --git a/internal/devicepolicy/uv_writer.go b/internal/devicepolicy/uv_writer.go index 201a1d0..c677970 100644 --- a/internal/devicepolicy/uv_writer.go +++ b/internal/devicepolicy/uv_writer.go @@ -21,10 +21,10 @@ import ( ) const ( - dmgUVBegin = "# BEGIN StepSecurity PyPI Secure Registry uv -- managed by dmg" - dmgUVEnd = "# END StepSecurity PyPI Secure Registry uv" - mdmUVBegin = "# BEGIN StepSecurity PyPI Secure Registry uv -- managed by mdm" - mdmUVEnd = "# END StepSecurity PyPI Secure Registry uv" + dmgUVBegin = "# BEGIN StepSecurity Package Configuration uv -- managed by dmg" + dmgUVEnd = "# END StepSecurity Package Configuration uv" + mdmUVBegin = "# BEGIN StepSecurity Package Configuration uv -- managed by mdm" + mdmUVEnd = "# END StepSecurity Package Configuration uv" dmgUVDisabledPrefix = "# [stepsecurity-pypi-uv-dmg] " dmgUVCreatedFile = "# [stepsecurity-pypi-uv-dmg] created=true" uvBackupPrefix = ".dmg-" diff --git a/internal/devicepolicy/uv_writer_test.go b/internal/devicepolicy/uv_writer_test.go index 9b0127c..0dd71ed 100644 --- a/internal/devicepolicy/uv_writer_test.go +++ b/internal/devicepolicy/uv_writer_test.go @@ -22,10 +22,10 @@ func TestUVMarkers_Canonical(t *testing.T) { got string want string }{ - {"DMG begin", dmgUVBegin, "# BEGIN StepSecurity PyPI Secure Registry uv -- managed by dmg"}, - {"DMG end", dmgUVEnd, "# END StepSecurity PyPI Secure Registry uv"}, - {"MDM begin", mdmUVBegin, "# BEGIN StepSecurity PyPI Secure Registry uv -- managed by mdm"}, - {"MDM end", mdmUVEnd, "# END StepSecurity PyPI Secure Registry uv"}, + {"DMG begin", dmgUVBegin, "# BEGIN StepSecurity Package Configuration uv -- managed by dmg"}, + {"DMG end", dmgUVEnd, "# END StepSecurity Package Configuration uv"}, + {"MDM begin", mdmUVBegin, "# BEGIN StepSecurity Package Configuration uv -- managed by mdm"}, + {"MDM end", mdmUVEnd, "# END StepSecurity Package Configuration uv"}, {"disabled prefix", dmgUVDisabledPrefix, "# [stepsecurity-pypi-uv-dmg] "}, {"created file", dmgUVCreatedFile, "# [stepsecurity-pypi-uv-dmg] created=true"}, }