Skip to content

Commit 567a4a3

Browse files
authored
Merge pull request #86 from shubham-stepsecurity/sm/update
chore(info): Adding invocation_method and in-flight status_info
2 parents cb9abe5 + 7915016 commit 567a4a3

15 files changed

Lines changed: 1110 additions & 44 deletions

internal/detector/nodescan.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,23 @@ type NodeScanner struct {
3434
exec executor.Executor
3535
log *progress.Logger
3636
loggedInUser string // when non-empty and running as root, commands run as this user
37+
// ProgressHook, when non-nil, is invoked from inside ScanProjects /
38+
// ScanGlobalPackages with a short human-readable detail string ("project
39+
// 12 of 47", "scanning yarn", ...). Telemetry plumbs this into
40+
// PhaseTracker.UpdateDetail so heartbeats surface mid-phase progress.
41+
ProgressHook func(detail string)
3742
}
3843

3944
func NewNodeScanner(exec executor.Executor, log *progress.Logger, loggedInUser string) *NodeScanner {
4045
return &NodeScanner{exec: exec, log: log, loggedInUser: loggedInUser}
4146
}
4247

48+
func (s *NodeScanner) emitProgress(detail string) {
49+
if s.ProgressHook != nil {
50+
s.ProgressHook(detail)
51+
}
52+
}
53+
4354
// shouldRunAsUser returns true when commands should be delegated to the logged-in user.
4455
// Only applies on Unix — RunAsUser uses sudo which is not available on Windows.
4556
func (s *NodeScanner) shouldRunAsUser() bool {
@@ -107,16 +118,19 @@ func (s *NodeScanner) checkPath(ctx context.Context, name string) error {
107118
func (s *NodeScanner) ScanGlobalPackages(ctx context.Context) []model.NodeScanResult {
108119
var results []model.NodeScanResult
109120

121+
s.emitProgress("global: npm")
110122
s.log.Progress(" Checking npm global packages...")
111123
if r, ok := s.scanNPMGlobal(ctx); ok {
112124
results = append(results, r)
113125
}
114126

127+
s.emitProgress("global: yarn")
115128
s.log.Progress(" Checking yarn global packages...")
116129
if r, ok := s.scanYarnGlobal(ctx); ok {
117130
results = append(results, r)
118131
}
119132

133+
s.emitProgress("global: pnpm")
120134
s.log.Progress(" Checking pnpm global packages...")
121135
if r, ok := s.scanPnpmGlobal(ctx); ok {
122136
results = append(results, r)
@@ -354,6 +368,10 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m
354368
var results []model.NodeScanResult
355369
totalSize := int64(0)
356370

371+
totalProjects := len(projects)
372+
if totalProjects > maxNodeProjects {
373+
totalProjects = maxNodeProjects
374+
}
357375
for i, p := range projects {
358376
if i >= maxNodeProjects {
359377
s.log.Progress(" Reached maximum of %d projects, stopping search", maxNodeProjects)
@@ -366,6 +384,11 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m
366384
break
367385
}
368386

387+
// Per-project sub-progress for the heartbeat goroutine. Surfaces
388+
// to console as "current_phase_detail: project 12 of 47" so a
389+
// stuck scan is visibly so, not just opaque "node_scan in progress".
390+
s.emitProgress(fmt.Sprintf("project %d of %d", i+1, totalProjects))
391+
369392
s.log.Progress(" Found project: %s", p.dir)
370393
pm := DetectProjectPM(s.exec, p.dir)
371394
s.log.Progress(" Package manager: %s", pm)

internal/detector/pythonscan.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,23 @@ import (
1515
type PythonScanner struct {
1616
exec executor.Executor
1717
log *progress.Logger
18+
// ProgressHook, when non-nil, is invoked from inside ScanGlobalPackages
19+
// with a short human-readable detail string ("scanning pip3", ...).
20+
// Telemetry plumbs this into PhaseTracker.UpdateDetail so heartbeats
21+
// surface mid-phase progress.
22+
ProgressHook func(detail string)
1823
}
1924

2025
func NewPythonScanner(exec executor.Executor, log *progress.Logger) *PythonScanner {
2126
return &PythonScanner{exec: exec, log: log}
2227
}
2328

29+
func (s *PythonScanner) emitProgress(detail string) {
30+
if s.ProgressHook != nil {
31+
s.ProgressHook(detail)
32+
}
33+
}
34+
2435
type pythonScanSpec struct {
2536
binary string
2637
name string
@@ -49,6 +60,7 @@ func (s *PythonScanner) ScanGlobalPackages(ctx context.Context) []model.PythonSc
4960
continue
5061
}
5162

63+
s.emitProgress("scanning " + spec.name)
5264
s.log.Progress(" Checking %s global packages...", spec.name)
5365
version := s.getVersion(ctx, spec.binary, spec.versionCmd)
5466

internal/launchd/launchd.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,22 @@ const (
2020
systemLogDir = "/var/log/stepsecurity"
2121
)
2222

23+
// DaemonPlistPath is the system-wide launchd plist installed when the agent
24+
// runs as root. Exported so other packages (notably telemetry's invocation
25+
// detector) can check for an installed footprint without re-deriving the path.
26+
const DaemonPlistPath = daemonPlistPath
27+
28+
// UserPlistPath returns the per-user launchd plist path installed when the
29+
// agent runs without root. Empty when the home directory cannot be resolved.
30+
func UserPlistPath() string {
31+
return agentPlistPath()
32+
}
33+
2334
func agentPlistPath() string {
2435
homeDir, _ := os.UserHomeDir()
36+
if homeDir == "" {
37+
return ""
38+
}
2539
return homeDir + "/Library/LaunchAgents/com.stepsecurity.agent.plist"
2640
}
2741

internal/schtasks/schtasks.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
osexec "os/exec"
78
"strconv"
89

910
"github.com/step-security/dev-machine-guard/internal/config"
@@ -14,6 +15,17 @@ import (
1415

1516
const taskName = "StepSecurity Dev Machine Guard"
1617

18+
// IsTaskRegistered reports whether the Windows scheduled task created by
19+
// `dev-machine-guard install` is currently registered. Used by the
20+
// telemetry package's invocation detector to distinguish a manual CLI run
21+
// from a scheduler-triggered one. Any error or non-zero schtasks exit is
22+
// treated as "not registered" so a transient Schedule-service hiccup
23+
// degrades to "one_time" rather than erroring the run.
24+
func IsTaskRegistered() bool {
25+
cmd := osexec.Command("schtasks", "/query", "/tn", taskName)
26+
return cmd.Run() == nil
27+
}
28+
1729
// Install configures Windows Task Scheduler for periodic scanning.
1830
// If already installed, upgrades by removing and re-creating the task.
1931
func Install(exec executor.Executor, log *progress.Logger) error {

internal/systemd/systemd.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ import (
1717

1818
const unitName = "stepsecurity-dev-machine-guard"
1919

20+
// TimerUnitPath returns the per-user systemd timer unit path installed for
21+
// periodic scanning. Exported so the telemetry package's invocation detector
22+
// can stat for an installed footprint without re-deriving the path. Returns
23+
// empty when the home directory cannot be resolved.
24+
func TimerUnitPath() string {
25+
homeDir, _ := os.UserHomeDir()
26+
if homeDir == "" {
27+
return ""
28+
}
29+
return filepath.Join(homeDir, ".config", "systemd", "user", unitName+".timer")
30+
}
31+
2032
// Install configures a systemd user timer for periodic scanning.
2133
// If already installed, upgrades by removing and re-creating the units.
2234
func Install(exec executor.Executor, log *progress.Logger) error {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package telemetry
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
// TestHeartbeatShutdown_NoDeadlock mirrors the cancel-then-wait pattern
10+
// Run() uses to shut down its heartbeat goroutine. Two `defer` statements
11+
// (cancel + wait) would deadlock under LIFO ordering — wait runs first,
12+
// blocks on the goroutine, and cancel never fires. Combining them into a
13+
// single deferred function is the fix; this test pins it down.
14+
func TestHeartbeatShutdown_NoDeadlock(t *testing.T) {
15+
ctx, cancel := context.WithCancel(context.Background())
16+
done := make(chan struct{})
17+
18+
go func() {
19+
defer close(done)
20+
ticker := time.NewTicker(50 * time.Millisecond)
21+
defer ticker.Stop()
22+
for {
23+
select {
24+
case <-ctx.Done():
25+
return
26+
case <-ticker.C:
27+
// no-op, mimics postPhase
28+
}
29+
}
30+
}()
31+
32+
// This is the load-bearing pattern from Run(): cancel first, THEN wait.
33+
// If a future refactor splits these into separate `defer` statements at
34+
// the top level of Run(), the LIFO ordering will deadlock.
35+
shutdownStart := time.Now()
36+
func() {
37+
defer func() {
38+
cancel()
39+
<-done
40+
}()
41+
}()
42+
elapsed := time.Since(shutdownStart)
43+
44+
// 50ms ticker + small scheduler overhead; 1s is generous and signals a
45+
// hang clearly if the pattern ever regresses.
46+
if elapsed > time.Second {
47+
t.Fatalf("heartbeat shutdown took %s — likely deadlocked on defer ordering", elapsed)
48+
}
49+
}

internal/telemetry/invocation.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package telemetry
2+
3+
import (
4+
"os"
5+
"runtime"
6+
7+
"github.com/step-security/dev-machine-guard/internal/launchd"
8+
"github.com/step-security/dev-machine-guard/internal/schtasks"
9+
"github.com/step-security/dev-machine-guard/internal/systemd"
10+
)
11+
12+
// Wire-format values for the invocation_method field. Kept stable —
13+
// console and backend match on these literal strings.
14+
const (
15+
InvocationInstall = "install"
16+
InvocationOneTime = "one_time"
17+
)
18+
19+
// DetectInvocationMethod returns "install" when the dev-machine-guard
20+
// scheduler footprint is present on this machine, else "one_time".
21+
//
22+
// The check is best-effort and never returns an error: a stat failure or a
23+
// flaky schtasks call degrades to "one_time" so an unknown environment is
24+
// never misreported as an installed agent. Detection is filesystem-based on
25+
// darwin/linux and a single schtasks query on windows, so an agent rolled
26+
// out before this code shipped starts reporting "install" on its next
27+
// scheduled fire without any installer changes.
28+
func DetectInvocationMethod() string {
29+
if isSchedulerInstalled() {
30+
return InvocationInstall
31+
}
32+
return InvocationOneTime
33+
}
34+
35+
func isSchedulerInstalled() bool {
36+
switch runtime.GOOS {
37+
case "darwin":
38+
return fileExists(launchd.DaemonPlistPath) || fileExists(launchd.UserPlistPath())
39+
case "linux":
40+
return fileExists(systemd.TimerUnitPath())
41+
case "windows":
42+
return schtasks.IsTaskRegistered()
43+
default:
44+
return false
45+
}
46+
}
47+
48+
func fileExists(path string) bool {
49+
if path == "" {
50+
return false
51+
}
52+
info, err := os.Stat(path)
53+
return err == nil && !info.IsDir()
54+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package telemetry
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"strings"
8+
"testing"
9+
10+
"github.com/step-security/dev-machine-guard/internal/launchd"
11+
"github.com/step-security/dev-machine-guard/internal/systemd"
12+
)
13+
14+
func TestFileExists(t *testing.T) {
15+
dir := t.TempDir()
16+
present := filepath.Join(dir, "marker")
17+
if err := os.WriteFile(present, []byte("x"), 0o600); err != nil {
18+
t.Fatal(err)
19+
}
20+
21+
cases := []struct {
22+
name string
23+
path string
24+
want bool
25+
}{
26+
{"existing file", present, true},
27+
{"missing file", filepath.Join(dir, "nope"), false},
28+
{"empty path", "", false},
29+
{"directory", dir, false}, // dirs intentionally don't count as installs
30+
}
31+
32+
for _, tc := range cases {
33+
t.Run(tc.name, func(t *testing.T) {
34+
if got := fileExists(tc.path); got != tc.want {
35+
t.Fatalf("fileExists(%q) = %v, want %v", tc.path, got, tc.want)
36+
}
37+
})
38+
}
39+
}
40+
41+
// TestDetectInvocationMethod_HostMachine exercises the detector against the
42+
// real machine. The result is whatever the current dev box reports; we can
43+
// only assert the value is one of the two valid wire-format strings.
44+
func TestDetectInvocationMethod_HostMachine(t *testing.T) {
45+
got := DetectInvocationMethod()
46+
if got != InvocationInstall && got != InvocationOneTime {
47+
t.Fatalf("DetectInvocationMethod returned %q, want %q or %q",
48+
got, InvocationInstall, InvocationOneTime)
49+
}
50+
}
51+
52+
// TestDetectInvocationMethod_RespondsToFilesystem covers the darwin/linux
53+
// path that stats a scheduler artifact. On Windows the check shells out to
54+
// schtasks, which we can't safely stub without an executor seam — skip there.
55+
//
56+
// Sandboxes HOME (Unix) and USERPROFILE (Windows-safe no-op on Unix) under
57+
// t.TempDir() so launchd.UserPlistPath / systemd.TimerUnitPath compute paths
58+
// that live entirely inside the temp tree. Without this the test would write
59+
// markers (and MkdirAll-created parent dirs) into the developer's real
60+
// ~/Library/LaunchAgents or ~/.config/systemd/user — leaving stray files
61+
// behind on CI and risking a tiny TOCTOU window against a real install.
62+
func TestDetectInvocationMethod_RespondsToFilesystem(t *testing.T) {
63+
if runtime.GOOS == "windows" {
64+
t.Skip("windows uses schtasks /query, not filesystem")
65+
}
66+
67+
tempHome := t.TempDir()
68+
t.Setenv("HOME", tempHome)
69+
t.Setenv("USERPROFILE", tempHome) // no-op on Unix but cheap and keeps the seam consistent
70+
71+
// Resolve the platform's expected artifact path AFTER the env override
72+
// so os.UserHomeDir() returns tempHome.
73+
var path string
74+
switch runtime.GOOS {
75+
case "darwin":
76+
path = launchd.UserPlistPath()
77+
case "linux":
78+
path = systemd.TimerUnitPath()
79+
default:
80+
t.Skipf("no scheduler artifact path on %s", runtime.GOOS)
81+
}
82+
if path == "" {
83+
t.Skip("could not resolve scheduler artifact path on this host")
84+
}
85+
if !strings.HasPrefix(path, tempHome) {
86+
t.Fatalf("resolved path %q escaped tempHome %q — env sandbox is not effective", path, tempHome)
87+
}
88+
89+
// Fresh temp home — detector starts at one_time, flips to install when
90+
// the marker appears, flips back when it's removed.
91+
if got := DetectInvocationMethod(); got != InvocationOneTime {
92+
t.Fatalf("on clean temp home, detector returned %q, want %q",
93+
got, InvocationOneTime)
94+
}
95+
96+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
97+
t.Fatalf("prepare scheduler artifact dir: %v", err)
98+
}
99+
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
100+
t.Fatalf("write fake scheduler artifact: %v", err)
101+
}
102+
// No explicit cleanup: everything lives under t.TempDir() and is
103+
// removed by the testing framework when the test ends.
104+
105+
if got := DetectInvocationMethod(); got != InvocationInstall {
106+
t.Fatalf("after creating %q, detector returned %q, want %q",
107+
path, got, InvocationInstall)
108+
}
109+
110+
// Remove the marker mid-test and re-check — confirms detection is not
111+
// cached and reflects current filesystem state.
112+
if err := os.Remove(path); err != nil {
113+
t.Fatalf("remove fake artifact: %v", err)
114+
}
115+
116+
if got := DetectInvocationMethod(); got != InvocationOneTime {
117+
t.Fatalf("after removing %q, detector returned %q, want %q",
118+
path, got, InvocationOneTime)
119+
}
120+
}

0 commit comments

Comments
 (0)