Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f7b225d
feat: add PyPI package configuration device policy
raysubham Aug 26, 2026
797490d
fix: reopen Windows security handles safely
raysubham Aug 26, 2026
d0ebe6d
refactor(devicepolicy): remove offline Python policy mode
raysubham Aug 26, 2026
18cb395
test(devicepolicy): remove standalone Python index server
raysubham Aug 26, 2026
65422ad
refactor(devicepolicy): simplify package policy dispatch
raysubham Aug 26, 2026
b2685f7
refactor(devicepolicy): isolate secure user files from npm
raysubham Aug 26, 2026
57047c4
fix(devicepolicy): restore removed relative-symlink targets
raysubham Aug 26, 2026
d1111ca
test(devicepolicy): consolidate Python policy coverage
raysubham Aug 26, 2026
3f89e91
docs: remove unrelated formatting changes
raysubham Aug 26, 2026
7319dc4
test(devicepolicy): keep platform helpers local
raysubham Aug 26, 2026
f68f515
fix(devicepolicy): preserve secure file trust boundaries
raysubham Aug 26, 2026
c3d2281
fix(devicepolicy): isolate Python client failures
raysubham Aug 26, 2026
0570937
test(devicepolicy): normalize Windows test ownership
raysubham Aug 26, 2026
a6e3ff8
fix(devicepolicy): set Windows secure file owners
raysubham Aug 26, 2026
3549114
fix(devicepolicy): normalize PyPI ownership markers
raysubham Aug 26, 2026
1c8a81d
fix(devicepolicy): report npm evaluated policy hash
raysubham Aug 27, 2026
bc98519
fix(devicepolicy): make netrc ownership uv-compatible
raysubham Aug 27, 2026
ca9c54b
chore: bump version to 1.17.0
raysubham Aug 27, 2026
f7003a7
Revert "chore: bump version to 1.17.0"
raysubham Aug 27, 2026
38f9828
fix(devicepolicy): reject nested netrc ownership records
raysubham Aug 27, 2026
ce08dd5
test(devicepolicy): cover misplaced netrc ownership records
raysubham Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ jobs:
# tests the macOS test job can only cross-compile, not run:
# - devicepolicy: the registry managed-policy probe, %APPDATA%
# settings-path resolution
# - secureuserfile: target-user ownership and restricted Windows ACLs
# - detector/credentials: reading the security descriptor behind
# broad_read_allow_ace_present, and the per-account environment read from
# the registry hive
Expand All @@ -124,7 +125,7 @@ jobs:
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
- run: go test -race -count=1 ./internal/devicepolicy/ ./internal/detector/credentials/ ./internal/safepath/
- run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/

smoke:
name: Smoke Tests
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ Compromised packages most often reach a machine because that machine resolves di

Configuration is read from `.npmrc` (npm), pnpm config, `bunfig.toml` (bun), `.yarnrc` / `.yarnrc.yml` (yarn classic and berry), and `pip.conf` (pip). In enterprise mode this rolls up into the **Package Configs** view in the dashboard, where you can spot machines that are unprotected or pointed at the wrong registry.

Enterprise Device Policy can also set StepSecurity Secure Registry as the sole user-level Python index for pip and uv. It manages only the resolved developer's user configuration and shared StepSecurity `.netrc` entry, keeps pip and uv results independent, and restores owned settings on an explicit policy clear. Project files, virtual environments, system configuration, environment variables, direct URLs, and Poetry are not modified.

### Suspicious file detection

Some supply chain attacks plant files that trigger code execution outside the package lifecycle scripts most tools watch — for example a malicious `binding.gyp` that runs during `npm install`, or an editor configuration file that runs when a project is opened. Dev Machine Guard ships a rules-engine scanner that flags these files as IOCs and wires the results into scan telemetry. The detector streams one file at a time, so scan memory stays bounded regardless of repository size.
Expand Down
92 changes: 43 additions & 49 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -809,16 +809,8 @@ func runIDEExtensionEnforce(exec executor.Executor, log *progress.Logger) {
}
}

// runPackageConfigEnforce fetches the device's effective package-config policy
// (the npm secure-registry directive) and converges the managed block in the
// console user's ~/.npmrc to match, then reports compliance — on the same
// scheduled cycle and agent auth channel as the IDE-extension enforcement above.
// It runs on every telemetry cycle, INCLUDING cycles where telemetry itself
// failed, so an emergency unassignment/offboarding directive is never blocked by
// a telemetry outage. A device whose npm config is already governed by the MDM
// remediation script is detected by the writer's content-aware probe and reported
// mdm_managed instead. A silent no-op when enterprise config is missing. Failures
// are logged but never crash main.
// runPackageConfigEnforce runs npm and PyPI independently after resolving their
// shared enterprise and device identity once. Failures never crash main.
func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) {
cfg, ok := ingest.Snapshot()
if !ok {
Expand All @@ -837,74 +829,76 @@ func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) {
}

ctx, cancel := context.WithTimeout(context.Background(), devicePolicyEnforceTimeout)
defer cancel()

dev := device.Gather(ctx, exec)
cancel()
if dev.SerialNumber == "" || dev.SerialNumber == "unknown" {
log.Warn("package-config enforce: device serial unresolved; skipping")
return
}
serial := dev.SerialNumber
runPackageConfigLanes(exec, log, fetcher, reporter, cfg.CustomerID, dev.SerialNumber, dev.Platform)
}

func runPackageConfigLanes(exec executor.Executor, log *progress.Logger, fetcher devicepolicy.Fetcher, reporter devicepolicy.Reporter, customerID, serial, platform string) {
npmCtx, npmCancel := context.WithTimeout(context.Background(), devicePolicyEnforceTimeout)
npmErr := runNPMPackageConfigLane(npmCtx, exec, log, fetcher, reporter, customerID, serial, platform)
npmCancel()
if npmErr != nil {
wrapped := fmt.Errorf("npm package-config enforce: %w", npmErr)
log.Warn("%v", wrapped)
aiagentscli.AppendError("devicepolicy", "enforce_failed", wrapped.Error(), "")
}

pypiCtx, pypiCancel := context.WithTimeout(context.Background(), devicePolicyEnforceTimeout)
pypiErr := runPyPIPackageConfigLane(pypiCtx, exec, log, fetcher, reporter, customerID, serial, platform)
pypiCancel()
if pypiErr != nil {
wrapped := fmt.Errorf("PyPI package-config enforce: %w", pypiErr)
log.Warn("%v", wrapped)
aiagentscli.AppendError("devicepolicy", "enforce_failed", wrapped.Error(), "")
}
}

func runNPMPackageConfigLane(ctx context.Context, exec executor.Executor, log *progress.Logger, fetcher devicepolicy.Fetcher, reporter devicepolicy.Reporter, customerID, serial, platform string) error {
r := &devicepolicy.Reconciler{
Fetcher: fetcher,
Reporter: reporter,
CustomerID: cfg.CustomerID,
CustomerID: customerID,
DeviceID: serial,
Platform: dev.Platform,
Platform: platform,
Category: devicepolicy.CategoryPackageConfig,
Target: devicepolicy.TargetNPM,
// Render derives the two managed ~/.npmrc content lines from the policy and
// this device's serial. It fully validates the policy and is pure, so it is
// wired even when the writer below could not be constructed.
Render: func(policy json.RawMessage) (string, error) {
return devicepolicy.RenderNPMRCBlock(policy, serial)
},
OwnsByMarker: true,
// The managed block is one atomic unit, so the lane owns exactly one
// WrittenSettings entry under this key.
OwnershipKey: devicepolicy.NPMOwnedKey,
Logf: func(format string, args ...any) { log.Debug(format, args...) },
}

// The writer resolves the console user and opens a directory fd over their
// home. When it cannot (no enforceable target user, or an infrastructure
// failure) leave the writer seams nil and hand the reconciler the init error:
// it classifies AFTER the fetch (absent → silent, clear → retain all state,
// enforce → policy_not_applied for no-target else write_failed). Binding
// w.Converged / w.ProbeExpected before this nil check would capture method
// values on a nil receiver, and the deferred Close would panic.
w, werr := devicepolicy.NewNPMRCWriter(exec)
if werr != nil {
r.WriterInitErr = werr
w, err := devicepolicy.NewNPMRCWriter(exec)
if err != nil {
r.WriterInitErr = err
} else {
defer w.Close()
w.SetLogf(func(format string, args ...any) { log.Debug(format, args...) })

// Concurrent convergence of this ~/.npmrc is not serialized across
// processes. Every write is an atomic temp+rename, so an overlapping
// cycle never sees a torn file. While the policy is stable both cycles
// render identical bytes; only a policy transition (a key rotation, or an
// enforce racing a clear) that interleaves with a concurrent cycle can
// briefly leave the superseded value, reconverged next cycle — eventual
// consistency, the same model the VS Code settings.json lane relies on.
// The telemetry singleton lock already serializes the preceding scan phase.
// Ownership state is the exception: it shares one file with every other
// category, so its read-modify-write does take a cross-process lock.
r.Writer = w
r.Converged = w.Converged
r.ProbeExpected = w.ProbeExpected
r.RestoreSnapshot = w.RestoreSnapshot
// Verify-only channel (enforcement=mdm): read the effective ~/.npmrc and
// report the observed bag instead of writing. Bound here because it needs the
// writer's identity-checked read path; with no writer the reconciler's
// category-aware fallback reports verification_failed rather than probing VS
// Code policy for an npm category.
r.ProbeContent = w.ProbeContentNPM
}
return r.Reconcile(ctx)
}

if err := r.Reconcile(ctx); err != nil {
log.Warn("package-config enforce: %v", err)
aiagentscli.AppendError("devicepolicy", "enforce_failed", err.Error(), "")
func runPyPIPackageConfigLane(ctx context.Context, exec executor.Executor, log *progress.Logger, fetcher devicepolicy.Fetcher, reporter devicepolicy.Reporter, customerID, serial, platform string) error {
coordinator := &devicepolicy.PyPICoordinator{
Fetcher: fetcher,
Reporter: reporter,
Exec: exec,
CustomerID: customerID,
DeviceID: serial,
Platform: platform,
Logf: func(format string, args ...any) { log.Debug(format, args...) },
}
return coordinator.Reconcile(ctx)
}
79 changes: 79 additions & 0 deletions cmd/stepsecurity-dev-machine-guard/main_devicepolicy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package main

import (
"context"
"errors"
"strings"
"testing"

"github.com/step-security/dev-machine-guard/internal/devicepolicy"
"github.com/step-security/dev-machine-guard/internal/executor"
"github.com/step-security/dev-machine-guard/internal/progress"
)

type packageConfigFetcher struct {
calls []string
contexts map[string]context.Context
failures map[string]error
}

func (f *packageConfigFetcher) Fetch(ctx context.Context, _, _, _, target string) (devicepolicy.EffectivePolicy, error) {
f.calls = append(f.calls, target)
if f.contexts != nil {
f.contexts[target] = ctx
}
return devicepolicy.EffectivePolicy{}, f.failures[target]
}

type packageConfigReporter struct{}

func (packageConfigReporter) Report(context.Context, string, string, devicepolicy.ComplianceReport) error {
return nil
}

func TestPackageConfigLanes_FailureDoesNotSuppressSibling(t *testing.T) {
t.Setenv("STEPSECURITY_HOME", t.TempDir())
tests := []struct {
name string
failTarget string
}{
{"npm failure still runs PyPI", devicepolicy.TargetNPM},
{"PyPI failure keeps npm success", devicepolicy.TargetPyPI},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fetcher := &packageConfigFetcher{failures: map[string]error{tc.failTarget: errors.New("lane failed")}}
mock := executor.NewMock()
mock.SetLoggedInUserError(errors.New("no user needed for absent policy"))

runPackageConfigLanes(mock, progress.NewNoop(), fetcher, packageConfigReporter{}, "customer", "serial", "linux")

if got, want := strings.Join(fetcher.calls, ","), devicepolicy.TargetNPM+","+devicepolicy.TargetPyPI; got != want {
t.Errorf("lane calls = %q, want %q", got, want)
}
})
}
}

func TestPackageConfigLanes_UseSeparateTimeoutContexts(t *testing.T) {
fetcher := &packageConfigFetcher{contexts: map[string]context.Context{}, failures: map[string]error{}}
mock := executor.NewMock()
mock.SetLoggedInUserError(errors.New("no user needed for absent policy"))

runPackageConfigLanes(mock, progress.NewNoop(), fetcher, packageConfigReporter{}, "customer", "serial", "linux")

npmCtx := fetcher.contexts[devicepolicy.TargetNPM]
pypiCtx := fetcher.contexts[devicepolicy.TargetPyPI]
if npmCtx == nil || pypiCtx == nil {
t.Fatalf("lane contexts = %#v, want both", fetcher.contexts)
}
if npmCtx == pypiCtx {
t.Error("npm and PyPI shared one context")
}
if _, ok := npmCtx.Deadline(); !ok {
t.Error("npm context has no deadline")
}
if _, ok := pypiCtx.Deadline(); !ok {
t.Error("PyPI context has no deadline")
}
}
Loading
Loading