Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
117 changes: 113 additions & 4 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,13 @@ func main() {
os.Exit(1)
}
armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log)
if err := telemetry.Run(exec, log, cfg); err != nil {
log.Error("%v", err)
telemetryErr := telemetry.Run(exec, log, cfg)
// Package-config enforcement runs on every cycle, even one where telemetry
// failed, so an emergency unassignment/offboarding directive is never
// blocked by a telemetry outage — hence before the error-exit below.
runPackageConfigEnforce(exec, log)
if telemetryErr != nil {
log.Error("%v", telemetryErr)
os.Exit(1)
}
runHookStateReconcile(exec, log)
Expand Down Expand Up @@ -351,6 +356,16 @@ func main() {
}
}

// Package-config enforcement runs even if the initial telemetry failed
// (before the error-exit below). Skipped on Windows at install time: an
// elevated installer resolves the ADMINISTRATOR's home, not the developer's,
// so let the first scheduled /ru INTERACTIVE firing do the first enforcement
// (macOS root installs resolve the console user; Linux installs are user
// mode, and the Windows-SYSTEM / macOS paths already returned above).
if runtime.GOOS != model.PlatformWindows {
runPackageConfigEnforce(exec, log)
}

if telemetryErr != nil {
if cfg.IgnoreTelemetryError {
// Opt-in tolerance for MSI/SCCM/Intune deployments: the
Expand Down Expand Up @@ -475,8 +490,12 @@ func main() {
case config.IsEnterpriseMode():
log.Debug("dispatch: enterprise telemetry (auto-detected)")
armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log)
if err := telemetry.Run(exec, log, cfg); err != nil {
log.Error("%v", err)
telemetryErr := telemetry.Run(exec, log, cfg)
// Package-config enforcement runs on every enterprise cycle — including a
// manually invoked one, and even when telemetry failed.
runPackageConfigEnforce(exec, log)
if telemetryErr != nil {
log.Error("%v", telemetryErr)
os.Exit(1)
}
default:
Expand Down Expand Up @@ -744,3 +763,93 @@ func runIDEExtensionEnforce(exec executor.Executor, log *progress.Logger) {
aiagentscli.AppendError("devicepolicy", "enforce_failed", err.Error(), "")
}
}

// runPackageConfigEnforce fetches the device's effective package-config policy
// (the npm secure-registry directive) and converges the managed block in the
// console user's ~/.npmrc to match, then reports compliance — on the same
// scheduled cycle and agent auth channel as the IDE-extension enforcement above.
// It runs on every telemetry cycle, INCLUDING cycles where telemetry itself
// failed, so an emergency unassignment/offboarding directive is never blocked by
// a telemetry outage. A device whose npm config is already governed by the MDM
// remediation script is detected by the writer's content-aware probe and reported
// mdm_managed instead. A silent no-op when enterprise config is missing. Failures
// are logged but never crash main.
func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) {
cfg, ok := ingest.Snapshot()
if !ok {
log.Debug("package-config enforce: skipped (no enterprise config)")
return
}
fetcher, ok := devicepolicy.NewHTTPFetcher(cfg, nil)
if !ok {
log.Debug("package-config enforce: skipped (fetcher init refused config)")
return
}
reporter, ok := devicepolicy.NewHTTPReporter(cfg, nil)
if !ok {
log.Debug("package-config enforce: skipped (reporter init refused config)")
return
}

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

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

r := &devicepolicy.Reconciler{
Fetcher: fetcher,
Reporter: reporter,
CustomerID: cfg.CustomerID,
DeviceID: serial,
Platform: dev.Platform,
Category: devicepolicy.CategoryPackageConfig,
Target: devicepolicy.TargetNPM,
// Render derives the two managed ~/.npmrc content lines from the policy and
// this device's serial. It fully validates the policy and is pure, so it is
// wired even when the writer below could not be constructed.
Render: func(policy json.RawMessage) (string, error) {
return devicepolicy.RenderNPMRCBlock(policy, serial)
},
OwnsByMarker: true,
Logf: func(format string, args ...any) { log.Debug(format, args...) },
}

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

// Concurrent convergence of this ~/.npmrc is not serialized across
// processes. Every write is an atomic temp+rename, so an overlapping
// cycle never sees a torn file. While the policy is stable both cycles
// render identical bytes; only a policy transition (a key rotation, or an
// enforce racing a clear) that interleaves with a concurrent cycle can
// briefly leave the superseded value, reconverged next cycle — eventual
// consistency, the same model the VS Code settings.json lane relies on.
// The telemetry singleton lock already serializes the preceding scan phase.
r.Writer = w
r.Converged = w.Converged
r.ProbeExpected = w.ProbeExpected
r.RestoreSnapshot = w.RestoreSnapshot
r.State = devicepolicy.NewStateStoreFor(w.TargetUser())
}

if err := r.Reconcile(ctx); err != nil {
log.Warn("package-config enforce: %v", err)
aiagentscli.AppendError("devicepolicy", "enforce_failed", err.Error(), "")
}
}
10 changes: 10 additions & 0 deletions internal/devicepolicy/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,16 @@ func (c *HTTPFetcher) Fetch(ctx context.Context, customerID, deviceID, category,
Hash: strings.TrimSpace(p.Hash),
GeneratedAt: p.GeneratedAt,
}
// Reject a response scoped to a different category/target than requested
// (backend bug, proxy/cache mixup). Acting on it could enforce — or worse,
// clear — the wrong pair. An empty field is not a mismatch; it defaults to
// the requested value just below.
if ep.Category != "" && ep.Category != category {
return EffectivePolicy{}, fmt.Errorf("devicepolicy: response category %q does not match requested %q", ep.Category, category)
}
if ep.Target != "" && ep.Target != target {
return EffectivePolicy{}, fmt.Errorf("devicepolicy: response target %q does not match requested %q", ep.Target, target)
}
if ep.Category == "" {
ep.Category = category
}
Expand Down
94 changes: 94 additions & 0 deletions internal/devicepolicy/api_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package devicepolicy

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/step-security/dev-machine-guard/internal/aiagents/ingest"
)

// newPolicyFetchServer is a fetch server that asserts the request carries the
// EXPECTED category/target query and returns a fixed body. Unlike newFetchServer
// (pinned to ide_extension/vscode) it lets a test drive any requested pair —
// needed by the identity checks below, which turn on the (category, target) the
// RESPONSE claims versus the one the agent asked for.
func newPolicyFetchServer(t *testing.T, wantCategory, wantTarget, body string) *HTTPFetcher {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("category"); got != wantCategory {
t.Errorf("request category = %q, want %q", got, wantCategory)
}
if got := r.URL.Query().Get("target"); got != wantTarget {
t.Errorf("request target = %q, want %q", got, wantTarget)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)

f, ok := NewHTTPFetcher(ingest.Config{APIEndpoint: srv.URL, APIKey: "test-key"}, srv.Client())
if !ok {
t.Fatal("NewHTTPFetcher returned ok=false on valid config")
}
return f
}

func TestFetchRejectsMismatchedResponseCategory(t *testing.T) {
// The agent asked for ide_extension/vscode; the response claims a DIFFERENT
// category (backend bug, proxy/cache mixup). Enforcing it would apply the
// wrong pair — Fetch must reject it before the reconciler ever sees it.
body := `{"policy":{"category":"package_config","target":"vscode","clear":false,` +
`"policy":{"x":true},"hash":"sha256:h","generated_at":"x"}}`
f := newPolicyFetchServer(t, CategoryIDEExtension, TargetVSCode, body)
_, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryIDEExtension, TargetVSCode)
if err == nil || !strings.Contains(err.Error(), "category") {
t.Fatalf("mismatched response category must error, got %v", err)
}
}

func TestFetchRejectsMismatchedResponseTarget(t *testing.T) {
// Category matches but the response targets a different IDE family — still the
// wrong pair to act on.
body := `{"policy":{"category":"ide_extension","target":"jetbrains","clear":false,` +
`"policy":{"x":true},"hash":"sha256:h","generated_at":"x"}}`
f := newPolicyFetchServer(t, CategoryIDEExtension, TargetVSCode, body)
_, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryIDEExtension, TargetVSCode)
if err == nil || !strings.Contains(err.Error(), "target") {
t.Fatalf("mismatched response target must error, got %v", err)
}
}

func TestFetchRejectsMismatchedClearDirective(t *testing.T) {
// The most dangerous mixup: a clear:true scoped to the WRONG pair. If it
// slipped through, the reconciler would remove an unrelated category's value.
// The identity check fires before clear is ever surfaced as a directive.
body := `{"policy":{"category":"package_config","target":"npm","clear":true,"generated_at":"x"}}`
f := newPolicyFetchServer(t, CategoryIDEExtension, TargetVSCode, body)
_, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryIDEExtension, TargetVSCode)
if err == nil {
t.Fatal("a clear scoped to a different category/target must be rejected, not surfaced as a clear")
}
}

func TestFetchPackageConfigTargetRoundTrips(t *testing.T) {
// The generic fetcher carries the package_config/npm pair end to end: the
// request query is scoped to it and a matching response parses back cleanly.
body := `{"policy":{"category":"package_config","target":"npm","clear":false,` +
`"policy":{"registry":"https://npm.pkg.example/"},"hash":"sha256:npm","generated_at":"x"}}`
f := newPolicyFetchServer(t, CategoryPackageConfig, TargetNPM, body)
ep, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryPackageConfig, TargetNPM)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if ep.Category != CategoryPackageConfig || ep.Target != TargetNPM {
t.Fatalf("round-trip identity = %q/%q, want %q/%q",
ep.Category, ep.Target, CategoryPackageConfig, TargetNPM)
}
if ep.Hash != "sha256:npm" || !ep.present() {
t.Fatalf("ep = %+v, want present with hash sha256:npm", ep)
}
}
Loading
Loading