From 61fb6caf298ab8d4e0551135862b3954f2ba17e2 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Sat, 24 Oct 2026 16:15:45 +0530 Subject: [PATCH 1/7] feat: inventory language runtimes on all platforms and Windows system packages Add a runtime inventory (node, python, php, java, go, ruby, deno, bun, dotnet) that finds installs on disk regardless of installer, and collect Windows system packages from the Uninstall registry keys, Chocolatey, Scoop and winget portable packages. --- cmd/stepsecurity-dev-machine-guard/main.go | 3 + internal/cli/cli.go | 9 + internal/config/config.go | 6 + internal/detector/runtimeinv/arch.go | 50 +++ internal/detector/runtimeinv/attribution.go | 128 +++++++ .../detector/runtimeinv/attribution_test.go | 94 +++++ internal/detector/runtimeinv/pe_windows.go | 47 +++ internal/detector/runtimeinv/roots.go | 167 +++++++++ internal/detector/runtimeinv/scanner.go | 331 +++++++++++++++++ internal/detector/runtimeinv/scanner_test.go | 300 +++++++++++++++ internal/detector/runtimeinv/signatures.go | 73 ++++ internal/detector/runtimeinv/version.go | 212 +++++++++++ internal/detector/winpkg.go | 341 ++++++++++++++++++ internal/detector/winpkg_arp_other.go | 98 +++++ internal/detector/winpkg_arp_windows.go | 75 ++++ internal/detector/winpkg_test.go | 190 ++++++++++ internal/model/model.go | 48 ++- internal/output/html.go | 36 ++ internal/output/pretty.go | 37 ++ internal/scan/scanner.go | 37 +- internal/telemetry/phase_deadline.go | 1 + internal/telemetry/telemetry.go | 46 ++- 22 files changed, 2318 insertions(+), 11 deletions(-) create mode 100644 internal/detector/runtimeinv/arch.go create mode 100644 internal/detector/runtimeinv/attribution.go create mode 100644 internal/detector/runtimeinv/attribution_test.go create mode 100644 internal/detector/runtimeinv/pe_windows.go create mode 100644 internal/detector/runtimeinv/roots.go create mode 100644 internal/detector/runtimeinv/scanner.go create mode 100644 internal/detector/runtimeinv/scanner_test.go create mode 100644 internal/detector/runtimeinv/signatures.go create mode 100644 internal/detector/runtimeinv/version.go create mode 100644 internal/detector/winpkg.go create mode 100644 internal/detector/winpkg_arp_other.go create mode 100644 internal/detector/winpkg_arp_windows.go create mode 100644 internal/detector/winpkg_test.go diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 0bab30b0..e97e3569 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -111,6 +111,9 @@ func main() { if cfg.EnablePythonScan == nil && config.EnablePythonScan != nil { cfg.EnablePythonScan = config.EnablePythonScan } + if cfg.EnableRuntimeScan == nil && config.EnableRuntimeScan != nil { + cfg.EnableRuntimeScan = config.EnableRuntimeScan + } // --legacy-python-scan / --disk-python-scan override the config-file value // (which config.Load already applied to config.UseLegacyPythonScan). if cfg.UseLegacyPythonScan != nil { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 224bc960..d53838f4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -30,6 +30,7 @@ type Config struct { UseLegacyNodeScan *bool // nil=auto (disk scan); true=command path, false=disk path EnableBrewScan *bool // nil=auto, true/false=explicit EnablePythonScan *bool // nil=auto, true/false=explicit + EnableRuntimeScan *bool // nil=auto (on), true/false=explicit UseLegacyPythonScan *bool // nil=auto (disk scan); true=pip path, false=disk path IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output // IncludeTCCProtected is tristate: nil or false = skip the macOS @@ -247,6 +248,12 @@ func Parse(args []string) (*Config, error) { case arg == "--disable-python-scan": v := false cfg.EnablePythonScan = &v + case arg == "--enable-runtime-scan": + v := true + cfg.EnableRuntimeScan = &v + case arg == "--disable-runtime-scan": + v := false + cfg.EnableRuntimeScan = &v case arg == "--legacy-python-scan": v := true cfg.UseLegacyPythonScan = &v @@ -582,6 +589,8 @@ Options: --disable-brew-scan Disable Homebrew package scanning --enable-python-scan Enable Python package scanning --disable-python-scan Disable Python package scanning + --enable-runtime-scan Enable language runtime inventory (default) + --disable-runtime-scan Disable language runtime inventory --include-bundled-plugins Include bundled/platform plugins in output (Windows) --include-tcc-protected Scan macOS TCC-protected dirs (Documents, Downloads, ~/Library/Mail, etc.). Default: skipped to avoid diff --git a/internal/config/config.go b/internal/config/config.go index 893fda9e..3483a17a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ var ( EnableNPMScan *bool // nil=auto EnableBrewScan *bool // nil=auto EnablePythonScan *bool // nil=auto + EnableRuntimeScan *bool // nil=auto (on) IncludeTCCProtected *bool // nil or false = skip macOS TCC-protected dirs (default); true = walk them. The scan as a whole runs in both cases; only reads inside the TCC-protected subtrees themselves (Documents, Mail, etc.) need the agent to have Full Disk Access (PPPC or manual grant) — without that, those walks return EACCES per entry while the rest of the scan completes normally. See docs/macos-tcc-permissions.md. // IncludeNetworkVolumes defaults the OTHER way from IncludeTCCProtected: // nil or true = walk macOS network volumes (default); false = skip them. @@ -84,6 +85,7 @@ type ConfigFile struct { EnableNPMScan *bool `json:"enable_npm_scan,omitempty"` EnableBrewScan *bool `json:"enable_brew_scan,omitempty"` EnablePythonScan *bool `json:"enable_python_scan,omitempty"` + EnableRuntimeScan *bool `json:"enable_runtime_scan,omitempty"` IncludeTCCProtected *bool `json:"include_tcc_protected,omitempty"` IncludeNetworkVolumes *bool `json:"include_network_volumes,omitempty"` ColorMode string `json:"color_mode,omitempty"` @@ -241,6 +243,9 @@ func Load() { if cfg.EnablePythonScan != nil && EnablePythonScan == nil { EnablePythonScan = cfg.EnablePythonScan } + if cfg.EnableRuntimeScan != nil && EnableRuntimeScan == nil { + EnableRuntimeScan = cfg.EnableRuntimeScan + } if cfg.IncludeTCCProtected != nil && IncludeTCCProtected == nil { IncludeTCCProtected = cfg.IncludeTCCProtected } @@ -648,6 +653,7 @@ func ShowConfigure() { fmt.Printf(" %-24s %s\n", "Legacy Node Scan:", displayBoolScan(cfg.UseLegacyNodeScan)) fmt.Printf(" %-24s %s\n", "Enable Brew Scan:", displayBoolScan(cfg.EnableBrewScan)) fmt.Printf(" %-24s %s\n", "Enable Python Scan:", displayBoolScan(cfg.EnablePythonScan)) + fmt.Printf(" %-24s %s\n", "Enable Runtime Scan:", displayBoolScan(cfg.EnableRuntimeScan)) fmt.Printf(" %-24s %s\n", "Legacy Python Scan:", displayBoolScan(cfg.UseLegacyPythonScan)) fmt.Printf(" %-24s %s\n", "Scan TCC-Protected Dirs:", displayTCC(cfg.IncludeTCCProtected)) fmt.Printf(" %-24s %s\n", "Scan Network Volumes:", displayNetworkVolumes(cfg.IncludeNetworkVolumes)) diff --git a/internal/detector/runtimeinv/arch.go b/internal/detector/runtimeinv/arch.go new file mode 100644 index 00000000..b1bccd75 --- /dev/null +++ b/internal/detector/runtimeinv/arch.go @@ -0,0 +1,50 @@ +package runtimeinv + +import ( + "debug/elf" + "debug/macho" + "debug/pe" +) + +// binaryArch reads the CPU architecture from the executable header (headers +// only; the binary is never run). Returns "" when the format is unknown. +func binaryArch(path string) string { + if f, err := pe.Open(path); err == nil { + defer func() { _ = f.Close() }() + switch f.Machine { + case pe.IMAGE_FILE_MACHINE_AMD64: + return "amd64" + case pe.IMAGE_FILE_MACHINE_ARM64: + return "arm64" + case pe.IMAGE_FILE_MACHINE_I386: + return "386" + } + return "" + } + if f, err := elf.Open(path); err == nil { + defer func() { _ = f.Close() }() + switch f.Machine { + case elf.EM_X86_64: + return "amd64" + case elf.EM_AARCH64: + return "arm64" + case elf.EM_386: + return "386" + } + return "" + } + if f, err := macho.OpenFat(path); err == nil { + _ = f.Close() + return "universal" + } + if f, err := macho.Open(path); err == nil { + defer func() { _ = f.Close() }() + switch f.Cpu { + case macho.CpuAmd64: + return "amd64" + case macho.CpuArm64: + return "arm64" + } + } + return "" +} diff --git a/internal/detector/runtimeinv/attribution.go b/internal/detector/runtimeinv/attribution.go new file mode 100644 index 00000000..441f7c2e --- /dev/null +++ b/internal/detector/runtimeinv/attribution.go @@ -0,0 +1,128 @@ +package runtimeinv + +import ( + "regexp" + "strings" +) + +// normPath lowercases and forward-slashes a path and wraps it in slashes so +// rules can match whole components on every OS ("/cellar/"). +func normPath(p string) string { + return "/" + strings.Trim(strings.ToLower(strings.ReplaceAll(p, `\`, "/")), "/") + "/" +} + +// sourceRules map a path fragment to the tool that installed the runtime. +// First match wins, so more specific fragments come first. +var sourceRules = []struct{ fragment, source string }{ + {"/.nvm/versions/", "nvm"}, + {"/appdata/roaming/nvm/", "nvm"}, // nvm-windows default root + {"/fnm/node-versions/", "fnm"}, + {"/volta/tools/image/", "volta"}, + {"/n/versions/", "n"}, + {"/nodist/", "nodist"}, + {"/pnpm/nodejs/", "pnpm"}, + {"/.asdf/installs/", "asdf"}, + {"/mise/installs/", "mise"}, + {"/.pyenv/versions/", "pyenv"}, + {"/pyenv-win/versions/", "pyenv"}, + {"/uv/python/", "uv"}, + {"/.rye/", "rye"}, + {"/miniconda", "conda"}, + {"/anaconda", "conda"}, + {"/miniforge", "conda"}, + {"/mambaforge", "conda"}, + {"/.sdkman/candidates/", "sdkman"}, + {"/.rbenv/versions/", "rbenv"}, + {"/.rvm/rubies/", "rvm"}, + {"/.goenv/versions/", "goenv"}, + {"/golang.org/toolchain@", "go-toolchain"}, + {"/cellar/", "brew"}, + {"/caskroom/", "brew"}, + {"/opt/homebrew/", "brew"}, + {"/home/linuxbrew/", "brew"}, + {"/scoop/apps/", "scoop"}, + {"/chocolatey/", "choco"}, + {"/winget/packages/", "winget"}, + {"/xampp/", "xampp"}, + {"/wamp64/", "wamp"}, + {"/wamp/", "wamp"}, + {"/laragon/", "laragon"}, + {"/snap/", "snap"}, + {"/library/developer/commandlinetools/", "xcode-clt"}, +} + +// systemPrefixes are distro / OS owned locations (package-managed on Linux). +var systemPrefixes = []string{"/usr/bin/", "/usr/lib/", "/usr/lib64/", "/usr/libexec/", "/usr/share/", "/bin/", "/system/"} + +// installerFragments are locations official installers write to. +var installerFragments = []string{ + "/library/java/javavirtualmachines/", + "/library/frameworks/python.framework/", + "/program files/", + "/program files (x86)/", + "/appdata/local/programs/", + "/.jdks/", +} + +// attributeSource names the installer of a runtime from its resolved path. +// nvmHome is NVM_HOME (nvm-windows custom root), may be empty. +func attributeSource(realPath, nvmHome string) string { + p := normPath(realPath) + if nvmHome != "" && strings.HasPrefix(p, normPath(nvmHome)) { + return "nvm" + } + for _, r := range sourceRules { + if strings.Contains(p, r.fragment) { + return r.source + } + } + for _, pre := range systemPrefixes { + if strings.HasPrefix(p, pre) { + return "system" + } + } + for _, f := range installerFragments { + if strings.Contains(p, f) { + return "installer" + } + } + return "manual" +} + +var ( + appBundleRe = regexp.MustCompile(`(?i)[/\\]([^/\\]+)\.app[/\\]contents[/\\]`) + editorExtRe = regexp.MustCompile(`(?i)[/\\]\.(vscode|vscode-insiders|cursor|windsurf|kiro)[^/\\]*[/\\]extensions[/\\]([^/\\]+)[/\\]`) + remoteSrvRe = regexp.MustCompile(`(?i)[/\\]\.(vscode-server|vscode-server-insiders|cursor-server|windsurf-server)[/\\]`) + jbrRe = regexp.MustCompile(`(?i)[/\\]([^/\\]+)[/\\]jbr[/\\]`) + vsNodeRe = regexp.MustCompile(`(?i)[/\\]microsoft visual studio[/\\]`) + adobeRe = regexp.MustCompile(`(?i)[/\\]adobe[/\\]`) + asarUnpackRe = regexp.MustCompile(`(?i)[/\\]([^/\\]+)[/\\]resources[/\\]app\.asar\.unpacked[/\\]`) +) + +// bundledBy names the application that privately ships this runtime, or "" +// for a standalone install. Bundled runtimes are inventoried but are not +// installs a developer manages. +func bundledBy(realPath string) string { + if m := appBundleRe.FindStringSubmatch(realPath); m != nil { + return m[1] + } + if m := remoteSrvRe.FindStringSubmatch(realPath); m != nil { + return m[1] + } + if m := editorExtRe.FindStringSubmatch(realPath); m != nil { + return m[1] + " extension " + m[2] + } + if m := jbrRe.FindStringSubmatch(realPath); m != nil { + return m[1] + } + if vsNodeRe.MatchString(realPath) { + return "Visual Studio" + } + if adobeRe.MatchString(realPath) { + return "Adobe" + } + if m := asarUnpackRe.FindStringSubmatch(realPath); m != nil { + return m[1] + } + return "" +} diff --git a/internal/detector/runtimeinv/attribution_test.go b/internal/detector/runtimeinv/attribution_test.go new file mode 100644 index 00000000..38c4b2dd --- /dev/null +++ b/internal/detector/runtimeinv/attribution_test.go @@ -0,0 +1,94 @@ +package runtimeinv + +import "testing" + +func TestAttributeSource(t *testing.T) { + cases := []struct{ path, nvmHome, want string }{ + {"/Users/a/.nvm/versions/node/v18.0.0/bin/node", "", "nvm"}, + {`C:\Users\a\AppData\Roaming\nvm\v18.0.0\node.exe`, "", "nvm"}, + {`D:\tools\nvm\v18.0.0\node.exe`, `D:\tools\nvm`, "nvm"}, + {`C:\Users\a\AppData\Local\Volta\tools\image\node\20.1.0\node.exe`, "", "volta"}, + {"/Users/a/Library/Application Support/fnm/node-versions/v22.0.0/installation/bin/node", "", "fnm"}, + {"/opt/homebrew/Cellar/php/8.3.1/bin/php", "", "brew"}, + {`C:\Users\a\scoop\apps\nodejs\22.0.0\node.exe`, "", "scoop"}, + {`C:\ProgramData\chocolatey\lib\php\tools\php.exe`, "", "choco"}, + {`C:\xampp\php\php.exe`, "", "xampp"}, + {"/usr/bin/python3.12", "", "system"}, + {"/usr/lib/jvm/java-21-openjdk/bin/java", "", "system"}, + {`C:\Program Files\nodejs\node.exe`, "", "installer"}, + {"/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java", "", "installer"}, + {"/home/a/.sdkman/candidates/java/21.0.2-tem/bin/java", "", "sdkman"}, + {"/Users/a/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.0.darwin-arm64/bin/go", "", "go-toolchain"}, + {"/usr/local/bin/node", "", "manual"}, + {"/home/a/Downloads/node-v18.0.0-linux-x64/bin/node", "", "manual"}, + } + for _, c := range cases { + if got := attributeSource(c.path, c.nvmHome); got != c.want { + t.Errorf("attributeSource(%q) = %q, want %q", c.path, got, c.want) + } + } +} + +func TestBundledBy(t *testing.T) { + cases := []struct{ path, want string }{ + {"/Applications/Docker.app/Contents/Resources/bin/node", "Docker"}, + {"/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home/bin/java", "IntelliJ IDEA"}, + {"/opt/android-studio/jbr/bin/java", "android-studio"}, + {"/home/a/.vscode-server/bin/abc123/node", "vscode-server"}, + {"/home/a/.vscode/extensions/ms-python.python-2024.1/python/bin/python3", "vscode extension ms-python.python-2024.1"}, + {`C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Microsoft\VisualStudio\NodeJs\node.exe`, "Visual Studio"}, + {`C:\Program Files\Adobe\Adobe Creative Cloud Experience\libs\node.exe`, "Adobe"}, + {"/home/a/.nvm/versions/node/v18.0.0/bin/node", ""}, + {`C:\Program Files\nodejs\node.exe`, ""}, + } + for _, c := range cases { + if got := bundledBy(c.path); got != c.want { + t.Errorf("bundledBy(%q) = %q, want %q", c.path, got, c.want) + } + } +} + +func TestCleanVersion(t *testing.T) { + cases := map[string]string{ + "18.20.4.0": "18.20.4", + "3.12.1": "3.12.1", + "8.2.12": "8.2.12", + "21.0.2+13": "21.0.2", + "1.22.1 ": "1.22.1", + "3.12.1150.1013": "3.12.1150.1013", + "": "", + "abc": "", + } + for in, want := range cases { + if got := cleanVersion(in); got != want { + t.Errorf("cleanVersion(%q) = %q, want %q", in, got, want) + } + } +} + +func TestMatchSignature(t *testing.T) { + cases := []struct{ name, goos, want string }{ + {"node", "linux", "node"}, + {"nodejs", "linux", "node"}, + {"python3.12", "linux", "python"}, + {"python3.12-config", "linux", ""}, + {"php8.2", "linux", "php"}, + {"php-fpm", "linux", ""}, + {"node.exe", "windows", "node"}, + {"Node.EXE", "windows", "node"}, + {"node", "windows", ""}, + {"php-cgi.exe", "windows", ""}, + {"java", "darwin", "java"}, + {"javac", "darwin", ""}, + {"Python", "darwin", ""}, // framework library, not the interpreter + } + for _, c := range cases { + got := "" + if s := matchSignature(c.name, c.goos); s != nil { + got = s.runtime + } + if got != c.want { + t.Errorf("matchSignature(%q, %s) = %q, want %q", c.name, c.goos, got, c.want) + } + } +} diff --git a/internal/detector/runtimeinv/pe_windows.go b/internal/detector/runtimeinv/pe_windows.go new file mode 100644 index 00000000..4e842f15 --- /dev/null +++ b/internal/detector/runtimeinv/pe_windows.go @@ -0,0 +1,47 @@ +//go:build windows + +package runtimeinv + +import ( + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +func init() { peVersion = readPEVersion } + +// readPEVersion returns the ProductVersion string from the version resource, +// falling back to the numeric VS_FIXEDFILEINFO product version. +func readPEVersion(path string) string { + size, err := windows.GetFileVersionInfoSize(path, nil) + if err != nil || size == 0 { + return "" + } + buf := make([]byte, size) + if err := windows.GetFileVersionInfo(path, 0, size, unsafe.Pointer(&buf[0])); err != nil { + return "" + } + + // First translation entry -> \StringFileInfo\\ProductVersion. + var trans *[2]uint16 + var n uint32 + if windows.VerQueryValue(unsafe.Pointer(&buf[0]), `\VarFileInfo\Translation`, unsafe.Pointer(&trans), &n) == nil && n >= 4 { + sub := fmt.Sprintf(`\StringFileInfo\%04x%04x\ProductVersion`, trans[0], trans[1]) + var str *uint16 + var l uint32 + if windows.VerQueryValue(unsafe.Pointer(&buf[0]), sub, unsafe.Pointer(&str), &l) == nil && l > 0 { + if v := windows.UTF16PtrToString(str); v != "" { + return v + } + } + } + + var fixed *windows.VS_FIXEDFILEINFO + if windows.VerQueryValue(unsafe.Pointer(&buf[0]), `\`, unsafe.Pointer(&fixed), &n) != nil || fixed == nil { + return "" + } + return fmt.Sprintf("%d.%d.%d.%d", + fixed.ProductVersionMS>>16, fixed.ProductVersionMS&0xffff, + fixed.ProductVersionLS>>16, fixed.ProductVersionLS&0xffff) +} diff --git a/internal/detector/runtimeinv/roots.go b/internal/detector/runtimeinv/roots.go new file mode 100644 index 00000000..aa9c7b72 --- /dev/null +++ b/internal/detector/runtimeinv/roots.go @@ -0,0 +1,167 @@ +package runtimeinv + +import ( + "path/filepath" + "strings" +) + +// homeBudget is the entry budget of the generic home walk. +const homeBudget = 1_000_000 + +// roots lists what to walk on this OS: a depth-capped walk of the user's +// home and the machine install locations, plus deeper walks of known +// version-manager / installer roots whose runtimes sit below the generic cap. +// Env-var overrides (NVM_DIR, VOLTA_HOME, …) add roots; they never replace +// the defaults, since the agent's env may not be the user's shell env. +func (s *Scanner) roots() []walkRoot { + switch s.goos { + case "windows": + return s.windowsRoots() + case "darwin": + return s.unixRoots(true) + default: + return s.unixRoots(false) + } +} + +func (s *Scanner) env(key string) string { return strings.TrimSpace(s.exec.Getenv(key)) } + +func (s *Scanner) homeJoin(parts ...string) string { + if s.home == "" { + return "" + } + return filepath.Join(append([]string{s.home}, parts...)...) +} + +func (s *Scanner) unixRoots(darwin bool) []walkRoot { + h := s.homeJoin + roots := []walkRoot{ + // Known user roots, deeper than the generic home walk reaches. + {h(".nvm", "versions"), 4, nil, 0}, + {h(".volta", "tools", "image"), 5, nil, 0}, + {h(".local", "share", "fnm"), 6, nil, 0}, + {h(".fnm"), 6, nil, 0}, + {h(".local", "share", "mise", "installs"), 5, nil, 0}, + {h(".local", "share", "pnpm"), 6, nil, 0}, + {h(".local", "share", "uv", "python"), 4, nil, 0}, + {h(".asdf", "installs"), 5, nil, 0}, + {h(".pyenv", "versions"), 3, nil, 0}, + {h(".sdkman", "candidates"), 4, nil, 0}, + {h(".rbenv", "versions"), 3, nil, 0}, + {h(".rvm", "rubies"), 3, nil, 0}, + {h(".goenv", "versions"), 3, nil, 0}, + {h(".dotnet"), 1, nil, 0}, + {h("sdk"), 3, nil, 0}, // golang.org/dl downloads + {h("go", "pkg", "mod", "golang.org"), 3, nil, 0}, // GOTOOLCHAIN downloads + {h(".jdks"), 4, nil, 0}, + {h(".local", "share", "JetBrains"), 6, nil, 0}, + {h(".vscode-server"), 4, nil, 0}, + {h(".cursor-server"), 4, nil, 0}, + + // Machine locations. + {"/usr/bin", 0, nil, 0}, + {"/usr/local", 5, nil, 0}, + {"/opt", 6, nil, 0}, + {"/usr/lib/jvm", 4, nil, 0}, + {"/usr/lib/dotnet", 1, nil, 0}, + {"/usr/share/dotnet", 1, nil, 0}, + {"/usr/lib/go", 2, nil, 0}, + {"/snap", 4, nil, 0}, + } + if darwin { + roots = append(roots, + walkRoot{h("Library", "Application Support", "fnm"), 6, nil, 0}, + walkRoot{h("Library", "pnpm"), 6, nil, 0}, + walkRoot{h("Library", "Java", "JavaVirtualMachines"), 5, nil, 0}, + walkRoot{h("Library", "Application Support", "JetBrains", "Toolbox", "apps"), 8, nil, 0}, + walkRoot{"/Library/Java/JavaVirtualMachines", 5, nil, 0}, + walkRoot{"/Library/Frameworks/Python.framework/Versions", 3, nil, 0}, + walkRoot{"/Library/Developer/CommandLineTools/usr/bin", 0, nil, 0}, + walkRoot{"/Applications", 7, nil, 0}, + ) + } + for _, kv := range []struct { + env string + depth int + }{ + {"NVM_DIR", 5}, {"FNM_DIR", 6}, {"VOLTA_HOME", 6}, {"N_PREFIX", 5}, {"ASDF_DATA_DIR", 6}, + {"PYENV_ROOT", 4}, {"SDKMAN_DIR", 5}, {"JAVA_HOME", 2}, {"GOROOT", 2}, {"DOTNET_ROOT", 1}, + } { + if v := s.env(kv.env); v != "" { + roots = append(roots, walkRoot{v, kv.depth, nil, 0}) + } + } + // Generic walk of home last, with the largest budget: it is the most + // likely to be huge and must not starve the known roots. ~/Library + // (macOS) runtime locations are listed explicitly above. + return append(roots, walkRoot{s.home, 5, []string{"library"}, homeBudget}) +} + +func (s *Scanner) windowsRoots() []walkRoot { + h := s.homeJoin + appData := s.env("APPDATA") + if appData == "" { + appData = h("AppData", "Roaming") + } + localAppData := s.env("LOCALAPPDATA") + if localAppData == "" { + localAppData = h("AppData", "Local") + } + sysDrive := s.env("SystemDrive") + if sysDrive == "" { + sysDrive = "C:" + } + sysRoot := sysDrive + `\` + programFiles := firstNonEmpty(s.env("ProgramFiles"), sysRoot+"Program Files") + programFilesX86 := firstNonEmpty(s.env("ProgramFiles(x86)"), sysRoot+"Program Files (x86)") + programData := firstNonEmpty(s.env("ProgramData"), sysRoot+"ProgramData") + + join := filepath.Join + roots := []walkRoot{ + // Known user roots. + {join(appData, "nvm"), 2, nil, 0}, + {join(appData, "fnm"), 5, nil, 0}, + {join(localAppData, "fnm"), 5, nil, 0}, + {join(localAppData, "Volta"), 7, nil, 0}, + {join(localAppData, "pnpm"), 5, nil, 0}, + {join(localAppData, "Programs"), 5, nil, 0}, + {join(localAppData, "Microsoft", "WinGet", "Packages"), 5, nil, 0}, + {join(localAppData, "JetBrains", "Toolbox", "apps"), 6, nil, 0}, + {h("go", "pkg", "mod", "golang.org"), 3, nil, 0}, // GOTOOLCHAIN downloads + + // Machine locations. + {programFiles, 5, []string{"windowsapps"}, 0}, + {programFilesX86, 5, nil, 0}, + {join(programFiles, "Microsoft Visual Studio"), 9, nil, 0}, + {join(programFilesX86, "Microsoft Visual Studio"), 9, nil, 0}, + {programData, 5, nil, 0}, + // Top-level dirs on the system drive (C:\xampp, C:\wamp64, + // C:\laragon, C:\Python312, C:\Ruby33-x64, C:\tools, …). + {sysRoot, 4, []string{ + "windows", "program files", "program files (x86)", "programdata", "users", + "recovery", "perflogs", "$winreagent", "config.msi", "msocache", + }, 0}, + } + for _, kv := range []struct { + env string + depth int + }{ + {"NVM_HOME", 2}, {"VOLTA_HOME", 6}, {"FNM_DIR", 5}, {"SCOOP", 5}, {"SCOOP_GLOBAL", 5}, + {"ChocolateyInstall", 5}, {"JAVA_HOME", 2}, {"GOROOT", 2}, {"DOTNET_ROOT", 1}, {"PYENV", 4}, + } { + if v := s.env(kv.env); v != "" { + roots = append(roots, walkRoot{v, kv.depth, nil, 0}) + } + } + // Generic profile walk last; AppData is covered by the known roots above. + return append(roots, walkRoot{s.home, 5, []string{"appdata"}, homeBudget}) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/detector/runtimeinv/scanner.go b/internal/detector/runtimeinv/scanner.go new file mode 100644 index 00000000..2bdb892a --- /dev/null +++ b/internal/detector/runtimeinv/scanner.go @@ -0,0 +1,331 @@ +package runtimeinv + +import ( + "context" + "io/fs" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +const ( + // maxEntries bounds the total directory entries visited per scan so a + // pathological tree cannot stall the phase; the result is marked truncated. + maxEntries = 2_500_000 + // defaultRootBudget bounds the entries one root may consume, so a huge + // home directory cannot starve the roots after it. + defaultRootBudget = 150_000 + // maxDirEntries bounds a single directory listing. + maxDirEntries = 20_000 +) + +// walkRoot is a directory to walk to a depth. skip lists top-level child +// names (lowercased) the walk does not descend into from this root — used to +// keep the generic home walk out of ~/Library and AppData, whose runtime +// locations are walked as their own known roots instead. +type walkRoot struct { + path string + depth int + skip []string + budget int // entries this root may visit; 0 = defaultRootBudget +} + +// skipDirs are never descended at any depth: dependency stores (runtimes +// vendored there are package-scan territory), VCS internals, trash and OS +// internals. +var skipDirs = map[string]bool{ + "node_modules": true, "site-packages": true, "dist-packages": true, "__pycache__": true, + ".git": true, ".hg": true, ".svn": true, + ".npm": true, ".pnpm-store": true, ".m2": true, ".gradle": true, ".cargo": true, ".rustup": true, + ".trash": true, "$recycle.bin": true, "system volume information": true, + "windowsapps": true, "winsxs": true, +} + +// Scanner discovers runtime installs for the logged-in user and the machine. +type Scanner struct { + exec executor.Executor + goos string + home string + + rootsOverride []walkRoot // tests + + entries int + rootEntries int + rootBudget int + truncated bool + walked map[string]int // dir -> remaining depth it was walked with + seenExe map[string]bool + seenRoot map[string]bool + out []model.RuntimeInstall +} + +// NewScanner returns a scanner for the given user home ("" = resolve from the +// logged-in user). +func NewScanner(exec executor.Executor, home string) *Scanner { + if home == "" { + if u, err := exec.LoggedInUser(); err == nil { + home = u.HomeDir + } + } + return &Scanner{exec: exec, goos: exec.GOOS(), home: home} +} + +// Scan walks all roots and returns the inventory, sorted by runtime then path. +// It stops early (truncated=true) on ctx cancellation or the entry cap. +func (s *Scanner) Scan(ctx context.Context) (runtimes []model.RuntimeInstall, truncated bool) { + s.entries, s.truncated = 0, false + s.walked = map[string]int{} + s.seenExe = map[string]bool{} + s.seenRoot = map[string]bool{} + s.out = nil + + roots := s.rootsOverride + if roots == nil { + roots = s.roots() + } + for _, r := range roots { + if ctx.Err() != nil || s.entries >= maxEntries { + s.truncated = true + break + } + s.rootEntries, s.rootBudget = 0, r.budget + if s.rootBudget <= 0 { + s.rootBudget = defaultRootBudget + } + s.walk(ctx, r.path, r.depth, r.skip) + } + sort.Slice(s.out, func(i, j int) bool { + if s.out[i].Runtime != s.out[j].Runtime { + return s.out[i].Runtime < s.out[j].Runtime + } + if s.out[i].ExecutablePath != s.out[j].ExecutablePath { + return s.out[i].ExecutablePath < s.out[j].ExecutablePath + } + return s.out[i].Version < s.out[j].Version + }) + return s.out, s.truncated +} + +// walk visits a root breadth-first, so when the entry budget runs out it is +// the deepest levels that go unvisited rather than whichever subtrees sort +// last. +func (s *Scanner) walk(ctx context.Context, root string, depth int, skipTop []string) { + type item struct { + dir string + depth int + top bool + } + queue := []item{{root, depth, true}} + for len(queue) > 0 { + it := queue[0] + queue = queue[1:] + if it.dir == "" || it.depth < 0 { + continue + } + key := s.pathKey(it.dir) + if prev, ok := s.walked[key]; ok && prev >= it.depth { + continue + } + s.walked[key] = it.depth + + ents, _, err := s.exec.ReadDirLimit(it.dir, maxDirEntries) + if err != nil { + continue + } + // A directory holding pyvenv.cfg is a virtualenv; its interpreter is + // a copy/link of a base install the python package scan covers. + if hasEntry(ents, "pyvenv.cfg") { + continue + } + for _, e := range ents { + if ctx.Err() != nil { + s.truncated = true + return + } + s.entries++ + s.rootEntries++ + if s.entries >= maxEntries || s.rootEntries >= s.rootBudget { + s.truncated = true + return + } + name := e.Name() + path := filepath.Join(it.dir, name) + // IsDir is false for symlinks and Windows junctions, so links are + // never followed: nvm/scoop "current" links and NVM_SYMLINK + // resolve to directories the walk reaches directly. + if e.IsDir() { + lower := strings.ToLower(name) + if skipDirs[lower] || (it.top && contains(skipTop, lower)) || isGoModCache(it.dir, lower) { + continue + } + if it.depth > 0 { + queue = append(queue, item{path, it.depth - 1, false}) + } + continue + } + if e.Type()&^fs.ModeSymlink != 0 { // devices, sockets, pipes, junctions + continue + } + if sig := matchSignature(name, s.goos); sig != nil { + s.consider(sig, path) + } + } + } +} + +func hasEntry(ents []fs.DirEntry, name string) bool { + for _, e := range ents { + if e.Name() == name { + return true + } + } + return false +} + +// consider resolves a candidate executable and records it. +func (s *Scanner) consider(sig *signature, path string) { + real, err := s.exec.EvalSymlinks(path) + if err != nil || real == "" { + return + } + info, err := s.exec.Stat(real) + if err != nil || info.IsDir() { + return + } + if s.goos != "windows" && info.Mode().Perm()&0o111 == 0 { + return + } + if s.isShim(real) { + return + } + exeKey := sig.runtime + "|" + s.pathKey(real) + if s.seenExe[exeKey] { + return + } + s.seenExe[exeKey] = true + + root := installRoot(real) + // JDK 8 ships jre/bin/java next to bin/java; report the JDK once. + if sig.runtime == "java" && strings.EqualFold(filepath.Base(root), "jre") { + if s.exec.FileExists(filepath.Join(filepath.Dir(root), "bin", filepath.Base(real))) { + return + } + } + + base := model.RuntimeInstall{ + Runtime: sig.runtime, + ExecutablePath: real, + InstallRoot: root, + Source: attributeSource(real, s.exec.Getenv("NVM_HOME")), + Scope: s.scope(real), + BundledBy: bundledBy(real), + Arch: binaryArch(real), + } + if base.BundledBy != "" { + base.Source = "bundled" + } + if !info.ModTime().IsZero() { + base.InstallTimeUnix = info.ModTime().Unix() + } + + if sig.runtime == "dotnet" { + for _, v := range dotnetRuntimeVersions(s.exec, filepath.Dir(real)) { + r := base + r.InstallRoot = filepath.Dir(real) + r.Version, r.VersionSource = v, versionFromPath + s.add(r) + } + return + } + + vr := detectVersion(s.exec, sig, real, root) + base.Version, base.VersionSource = vr.version, vr.source + if vr.vendor != "" { + base.Vendor = vr.vendor + } + s.add(base) +} + +// add records a runtime unless the same (runtime, root, version) is already +// present — e.g. python, python3 and python3.12 copies in one bin/. +func (s *Scanner) add(r model.RuntimeInstall) { + k := r.Runtime + "|" + s.pathKey(r.InstallRoot) + "|" + r.Version + if s.seenRoot[k] { + return + } + s.seenRoot[k] = true + s.out = append(s.out, r) +} + +// isShim reports binaries that are launchers rather than runtimes: the macOS +// /usr/bin stubs that prompt for the Command Line Tools / a JDK. +func (s *Scanner) isShim(real string) bool { + if s.goos != "darwin" { + return false + } + switch real { + case "/usr/bin/python3", "/usr/bin/java": + return true + } + return false +} + +func (s *Scanner) scope(real string) string { + if s.home != "" && strings.HasPrefix(normPath(real), normPath(s.home)) { + return "user" + } + return "machine" +} + +// pathKey folds case on Windows, where paths are case-insensitive. +func (s *Scanner) pathKey(p string) string { + if s.goos == "windows" { + return strings.ToLower(p) + } + return p +} + +// installRoot is the runtime home: the parent of bin/, else the exe's dir +// (Windows layouts put node.exe / python.exe / php.exe at the root). +func installRoot(exe string) string { + d := filepath.Dir(exe) + if strings.EqualFold(filepath.Base(d), "bin") { + return filepath.Dir(d) + } + return d +} + +// isGoModCache reports $GOPATH/pkg/mod: a dependency store of hundreds of +// thousands of files. Go toolchains downloaded into it are walked as their +// own known root (pkg/mod/golang.org). +func isGoModCache(parent, lowerName string) bool { + return lowerName == "mod" && strings.EqualFold(filepath.Base(parent), "pkg") +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +// ScanResult runs a scan for the logged-in user and packages it for telemetry. +func ScanResult(ctx context.Context, exec executor.Executor) *model.RuntimeScanResult { + start := time.Now() + runtimes, truncated := NewScanner(exec, "").Scan(ctx) + if runtimes == nil { + runtimes = []model.RuntimeInstall{} + } + return &model.RuntimeScanResult{ + Runtimes: runtimes, + RuntimesCount: len(runtimes), + Truncated: truncated, + ScanDurationMs: time.Since(start).Milliseconds(), + } +} diff --git a/internal/detector/runtimeinv/scanner_test.go b/internal/detector/runtimeinv/scanner_test.go new file mode 100644 index 00000000..d3de879c --- /dev/null +++ b/internal/detector/runtimeinv/scanner_test.go @@ -0,0 +1,300 @@ +package runtimeinv + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// fixture builds files under a temp root; exe=true makes them executable. +type fixture struct { + t *testing.T + root string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) // macOS /var -> /private/var + if err != nil { + t.Fatal(err) + } + return &fixture{t: t, root: root} +} + +func (f *fixture) path(rel string) string { return filepath.Join(f.root, filepath.FromSlash(rel)) } + +func (f *fixture) file(rel, content string, exe bool) string { + f.t.Helper() + p := f.path(rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + f.t.Fatal(err) + } + mode := os.FileMode(0o644) + if exe { + mode = 0o755 + } + if err := os.WriteFile(p, []byte(content), mode); err != nil { + f.t.Fatal(err) + } + return p +} + +func (f *fixture) symlink(rel, target string) { + f.t.Helper() + p := f.path(rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.Symlink(target, p); err != nil { + f.t.Fatal(err) + } +} + +func (f *fixture) scan(goos, home string, roots ...walkRoot) []model.RuntimeInstall { + f.t.Helper() + s := &Scanner{exec: executor.NewReal(), goos: goos, home: home, rootsOverride: roots} + out, truncated := s.Scan(context.Background()) + if truncated { + f.t.Fatal("unexpected truncation") + } + return out +} + +func byRuntime(rs []model.RuntimeInstall, runtime string) []model.RuntimeInstall { + var out []model.RuntimeInstall + for _, r := range rs { + if r.Runtime == runtime { + out = append(out, r) + } + } + return out +} + +const nodeVersionH = `#ifndef SRC_NODE_VERSION_H_ +#define SRC_NODE_VERSION_H_ + +#define NODE_MAJOR_VERSION 18 +#define NODE_MINOR_VERSION 20 +#define NODE_PATCH_VERSION 4 +` + +func TestScanUnixLayouts(t *testing.T) { + f := newFixture(t) + home := f.path("home/dev") + + // nvm with headers. + f.file("home/dev/.nvm/versions/node/v18.20.4/bin/node", "", true) + f.file("home/dev/.nvm/versions/node/v18.20.4/include/node/node_version.h", nodeVersionH, false) + // nvm without headers -> path version. + f.file("home/dev/.nvm/versions/node/v20.11.1/bin/node", "", true) + // brew node reached via a /usr/local/bin symlink and the Cellar: one record. + cellarNode := f.file("usr/local/Cellar/node/22.1.0/bin/node", "", true) + f.symlink("usr/local/bin/node", cellarNode) + // pyenv python: python3.12 real, python3 copy, python symlink -> one record. + f.file("home/dev/.pyenv/versions/3.12.1/bin/python3.12", "", true) + f.file("home/dev/.pyenv/versions/3.12.1/bin/python3", "", true) + f.symlink("home/dev/.pyenv/versions/3.12.1/bin/python", "python3.12") + f.file("home/dev/.pyenv/versions/3.12.1/include/python3.12/patchlevel.h", `#define PY_VERSION "3.12.1"`, false) + // python3.12-config is not an interpreter. + f.file("home/dev/.pyenv/versions/3.12.1/bin/python3.12-config", "", true) + // A virtualenv is skipped. + f.file("home/dev/proj/.venv/pyvenv.cfg", "home = /usr/bin", false) + f.file("home/dev/proj/.venv/bin/python3", "", true) + // node_modules is skipped. + f.file("home/dev/proj/node_modules/node/bin/node", "", true) + // Non-executable file is ignored. + f.file("home/dev/notes/node", "", false) + // JDK with release file; JDK 8 style jre/bin/java is not double counted. + f.file("opt/jdk-21.0.2/bin/java", "", true) + f.file("opt/jdk-21.0.2/release", "JAVA_VERSION=\"21.0.2\"\nIMPLEMENTOR=\"Eclipse Adoptium\"\n", false) + f.file("opt/jdk-21.0.2/jre/bin/java", "", true) + // Go with VERSION. + f.file("usr/local/go/bin/go", "", true) + f.file("usr/local/go/VERSION", "go1.22.1\ntime 2024-02-29T18:18:00Z\n", false) + // Ruby via rbenv with rbconfig. + f.file("home/dev/.rbenv/versions/3.3.0/bin/ruby", "", true) + f.file("home/dev/.rbenv/versions/3.3.0/lib/ruby/3.3.0/arm64-darwin23/rbconfig.rb", ` CONFIG["RUBY_PROGRAM_VERSION"] = "3.3.0"`, false) + // PHP with headers. + f.file("opt/php/bin/php", "", true) + f.file("opt/php/include/php/main/php_version.h", `#define PHP_VERSION "8.3.1"`, false) + // dotnet with two side-by-side runtimes. + f.file("home/dev/.dotnet/dotnet", "", true) + f.file("home/dev/.dotnet/shared/Microsoft.NETCore.App/8.0.4/x", "", false) + f.file("home/dev/.dotnet/shared/Microsoft.NETCore.App/6.0.29/x", "", false) + // Bundled node inside an app bundle. + f.file("Applications/Foo.app/Contents/Resources/node", "", true) + // bun with no version source. + f.file("home/dev/.bun/bin/bun", "", true) + + out := f.scan("linux", home, + walkRoot{home, 6, nil, 0}, + walkRoot{f.path("usr/local"), 5, nil, 0}, + walkRoot{f.path("opt"), 4, nil, 0}, + walkRoot{f.path("Applications"), 5, nil, 0}, + ) + + nodes := byRuntime(out, "node") + want := map[string]struct{ version, vsrc, source, scope, bundled string }{ + f.path("home/dev/.nvm/versions/node/v18.20.4/bin/node"): {"18.20.4", "file", "nvm", "user", ""}, + f.path("home/dev/.nvm/versions/node/v20.11.1/bin/node"): {"20.11.1", "path", "nvm", "user", ""}, + cellarNode: {"22.1.0", "path", "brew", "machine", ""}, + f.path("Applications/Foo.app/Contents/Resources/node"): {"unknown", "unknown", "bundled", "machine", "Foo"}, + } + if len(nodes) != len(want) { + t.Fatalf("node records = %d, want %d: %+v", len(nodes), len(want), nodes) + } + for _, n := range nodes { + w, ok := want[n.ExecutablePath] + if !ok { + t.Errorf("unexpected node %s", n.ExecutablePath) + continue + } + if n.Version != w.version || n.VersionSource != w.vsrc || n.Source != w.source || n.Scope != w.scope || n.BundledBy != w.bundled { + t.Errorf("%s = %+v, want %+v", n.ExecutablePath, n, w) + } + } + + py := byRuntime(out, "python") + if len(py) != 1 || py[0].Version != "3.12.1" || py[0].Source != "pyenv" { + t.Errorf("python = %+v", py) + } + java := byRuntime(out, "java") + if len(java) != 1 || java[0].Version != "21.0.2" || java[0].Vendor != "Eclipse Adoptium" { + t.Errorf("java = %+v", java) + } + if g := byRuntime(out, "go"); len(g) != 1 || g[0].Version != "1.22.1" { + t.Errorf("go = %+v", g) + } + if r := byRuntime(out, "ruby"); len(r) != 1 || r[0].Version != "3.3.0" || r[0].Source != "rbenv" { + t.Errorf("ruby = %+v", r) + } + if p := byRuntime(out, "php"); len(p) != 1 || p[0].Version != "8.3.1" { + t.Errorf("php = %+v", p) + } + if d := byRuntime(out, "dotnet"); len(d) != 2 || d[0].Version != "6.0.29" || d[1].Version != "8.0.4" { + t.Errorf("dotnet = %+v", d) + } + if b := byRuntime(out, "bun"); len(b) != 1 || b[0].Version != "unknown" || b[0].Source != "manual" { + t.Errorf("bun = %+v", b) + } +} + +func TestScanWindowsLayouts(t *testing.T) { + f := newFixture(t) + home := f.path("Users/dev") + orig := peVersion + t.Cleanup(func() { peVersion = orig }) + peVersion = func(p string) string { + if filepath.Base(filepath.Dir(p)) == "xampp-php" { + return "" + } + switch filepath.Base(p) { + case "node.exe": + return "18.20.4.0" + case "php.exe": + return "8.2.12" + } + return "" + } + + f.file("Users/dev/AppData/Roaming/nvm/v18.20.4/node.exe", "", false) + f.file("Users/dev/AppData/Roaming/nvm/v16.0.0/notes.txt", "", false) + f.file("Users/dev/scoop/apps/php/8.2.12/php.exe", "", false) + f.file("Users/dev/scoop/apps/php/8.2.12/php-cgi.exe", "", false) + f.file("Program Files/Microsoft Visual Studio/2022/Community/MSBuild/Microsoft/VisualStudio/NodeJs/node.exe", "", false) + // php.exe without a PE version under a versioned dir -> path version. + f.file("wamp64/bin/php/php7.4.33/xampp-php/php.exe", "", false) + // A non-.exe named node is ignored on Windows. + f.file("Users/dev/tools/node", "", true) + + out := f.scan("windows", home, + walkRoot{f.path("Users/dev/AppData/Roaming/nvm"), 2, nil, 0}, + walkRoot{home, 5, []string{"appdata"}, 0}, + walkRoot{f.path("Program Files"), 9, nil, 0}, + walkRoot{f.path("wamp64"), 5, nil, 0}, + ) + + nodes := byRuntime(out, "node") + if len(nodes) != 2 { + t.Fatalf("nodes = %+v", nodes) + } + for _, n := range nodes { + switch { + case n.BundledBy == "Visual Studio": + if n.Source != "bundled" || n.Scope != "machine" { + t.Errorf("vs node = %+v", n) + } + case n.Source == "nvm": + if n.Version != "18.20.4" || n.VersionSource != "pe" || n.Scope != "user" { + t.Errorf("nvm node = %+v", n) + } + default: + t.Errorf("unexpected node %+v", n) + } + } + php := byRuntime(out, "php") + if len(php) != 2 { + t.Fatalf("php = %+v", php) + } + for _, p := range php { + switch p.Source { + case "scoop": + if p.Version != "8.2.12" || p.VersionSource != "pe" { + t.Errorf("scoop php = %+v", p) + } + case "wamp": + if p.Version != "7.4.33" || p.VersionSource != "path" { + t.Errorf("wamp php = %+v", p) + } + default: + t.Errorf("unexpected php %+v", p) + } + } +} + +func TestScanDepthCapAndRevisit(t *testing.T) { + f := newFixture(t) + f.file("a/b/c/d/bin/node", "", true) + if out := f.scan("linux", "", walkRoot{f.path("a"), 3, nil, 0}); len(out) != 0 { + t.Fatalf("depth 3 should not reach a/b/c/d/bin: %+v", out) + } + // A shallow root first must not prevent a deeper root from reaching it. + out := f.scan("linux", "", walkRoot{f.path("a"), 1, nil, 0}, walkRoot{f.path("a"), 4, nil, 0}) + if len(out) != 1 { + t.Fatalf("deeper revisit missed node: %+v", out) + } +} + +func TestScanCancelledIsTruncated(t *testing.T) { + f := newFixture(t) + f.file("x/bin/node", "", true) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := &Scanner{exec: executor.NewReal(), goos: "linux", rootsOverride: []walkRoot{{f.root, 3, nil, 0}}} + if _, truncated := s.Scan(ctx); !truncated { + t.Fatal("cancelled scan should report truncated") + } +} + +// A large deep subtree that exhausts the budget must not hide a shallow +// install in a later-sorting sibling (the walk is breadth-first). +func TestScanBudgetPrefersShallow(t *testing.T) { + f := newFixture(t) + for i := 0; i < 50; i++ { + f.file(fmt.Sprintf("a/b/c/f%02d", i), "", false) + } + f.file("z/node", "", true) + s := &Scanner{exec: executor.NewReal(), goos: "linux", rootsOverride: []walkRoot{{f.root, 5, nil, 20}}} + out, truncated := s.Scan(context.Background()) + if !truncated { + t.Fatal("expected budget truncation") + } + if len(out) != 1 || out[0].ExecutablePath != f.path("z/node") { + t.Fatalf("shallow node missed: %+v", out) + } +} diff --git a/internal/detector/runtimeinv/signatures.go b/internal/detector/runtimeinv/signatures.go new file mode 100644 index 00000000..6bad253e --- /dev/null +++ b/internal/detector/runtimeinv/signatures.go @@ -0,0 +1,73 @@ +// Package runtimeinv inventories language runtime installs (node, python, +// php, java, go, ruby, deno, bun, dotnet) on disk, independent of how they +// were installed: version managers, package managers, installers, zips and +// runtimes bundled inside other apps. Discovered binaries are never executed; +// versions come from files the install ships, the PE version resource, or the +// install path. +package runtimeinv + +import ( + "regexp" + "strings" +) + +// signature describes one runtime: which executable names identify it and +// which version strategies apply, in order of preference. +type signature struct { + runtime string + matches func(name string) bool // lowercased base name, ".exe" stripped + versions []versionStrategy +} + +var ( + pythonExeRe = regexp.MustCompile(`^python([23](\.\d+)?)?$`) + phpExeRe = regexp.MustCompile(`^php(\d+(\.\d+)?)?$`) +) + +func exact(names ...string) func(string) bool { + return func(n string) bool { + for _, want := range names { + if n == want { + return true + } + } + return false + } +} + +// signatures is the runtime table. Adding a runtime means adding a row here +// (plus a version reader if it ships a version file). +var signatures = []signature{ + {runtime: "node", matches: exact("node", "nodejs"), versions: []versionStrategy{nodeHeaderVersion, peVersionStrategy, pathVersionStrategy}}, + {runtime: "python", matches: pythonExeRe.MatchString, versions: []versionStrategy{pythonPatchlevelVersion, peVersionStrategy, pathVersionStrategy}}, + {runtime: "php", matches: phpExeRe.MatchString, versions: []versionStrategy{phpHeaderVersion, peVersionStrategy, pathVersionStrategy}}, + {runtime: "java", matches: exact("java"), versions: []versionStrategy{javaReleaseVersion, peVersionStrategy, pathVersionStrategy}}, + {runtime: "go", matches: exact("go"), versions: []versionStrategy{goVersionFile, pathVersionStrategy}}, + {runtime: "ruby", matches: exact("ruby"), versions: []versionStrategy{rubyConfigVersion, peVersionStrategy, pathVersionStrategy}}, + {runtime: "deno", matches: exact("deno"), versions: []versionStrategy{peVersionStrategy, pathVersionStrategy}}, + {runtime: "bun", matches: exact("bun"), versions: []versionStrategy{peVersionStrategy, pathVersionStrategy}}, + // dotnet is one host binary serving several side-by-side runtimes; the + // scanner expands it to one record per shared/Microsoft.NETCore.App/. + {runtime: "dotnet", matches: exact("dotnet")}, +} + +// matchSignature returns the signature for a directory entry name, or nil. +// On Windows only .exe files are considered. +func matchSignature(name, goos string) *signature { + // Names are case-sensitive except on Windows: on macOS the framework + // library "Python" must not match the "python" interpreter. + n := name + if goos == "windows" { + n = strings.ToLower(n) + if !strings.HasSuffix(n, ".exe") { + return nil + } + n = strings.TrimSuffix(n, ".exe") + } + for i := range signatures { + if signatures[i].matches(n) { + return &signatures[i] + } + } + return nil +} diff --git a/internal/detector/runtimeinv/version.go b/internal/detector/runtimeinv/version.go new file mode 100644 index 00000000..d68f9ec7 --- /dev/null +++ b/internal/detector/runtimeinv/version.go @@ -0,0 +1,212 @@ +package runtimeinv + +import ( + "path/filepath" + "regexp" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// Version sources reported in RuntimeInstall.VersionSource. +const ( + versionFromFile = "file" + versionFromPE = "pe" + versionFromPath = "path" + versionFromUnknown = "unknown" +) + +// versionResult is what a strategy found; vendor is optional. +type versionResult struct { + version string + vendor string + source string +} + +// versionStrategy inspects an install (resolved exe path and its root) and +// returns ok=false when it cannot tell. +type versionStrategy func(exec executor.Executor, exe, root string) (versionResult, bool) + +// peVersion reads the version resource of a Windows executable. Replaced on +// Windows (pe_windows.go); tests may override it. +var peVersion = func(string) string { return "" } + +func peVersionStrategy(_ executor.Executor, exe, _ string) (versionResult, bool) { + v := cleanVersion(peVersion(exe)) + if v == "" { + return versionResult{}, false + } + return versionResult{version: v, source: versionFromPE}, true +} + +var ( + cDefineRe = regexp.MustCompile(`(?m)^#define[ \t]+(\w+)[ \t]+"?([^"\s]+)"?`) + keyValueRe = regexp.MustCompile(`(?m)^(\w+)="?([^"\r\n]*)"?`) + rbConfigRe = regexp.MustCompile(`CONFIG\["RUBY_PROGRAM_VERSION"\]\s*=\s*"([^"]+)"`) + numericRe = regexp.MustCompile(`^\d+(\.\d+)*`) + pathVerRe = regexp.MustCompile(`(?i)^(?:[a-z]+[-_]?)?v?(\d+\.\d+(?:\.\d+){0,2})`) + dotnetVerRe = regexp.MustCompile(`^\d+\.\d+\.\d+`) +) + +func readDefines(exec executor.Executor, path string) map[string]string { + data, err := exec.ReadFile(path) + if err != nil { + return nil + } + out := map[string]string{} + for _, m := range cDefineRe.FindAllStringSubmatch(string(data), -1) { + out[m[1]] = m[2] + } + return out +} + +// nodeHeaderVersion reads include/node/node_version.h, which official +// tarballs, nvm, fnm, volta and brew installs all ship. +func nodeHeaderVersion(exec executor.Executor, _, root string) (versionResult, bool) { + d := readDefines(exec, filepath.Join(root, "include", "node", "node_version.h")) + if d["NODE_MAJOR_VERSION"] == "" || d["NODE_MINOR_VERSION"] == "" || d["NODE_PATCH_VERSION"] == "" { + return versionResult{}, false + } + v := d["NODE_MAJOR_VERSION"] + "." + d["NODE_MINOR_VERSION"] + "." + d["NODE_PATCH_VERSION"] + return versionResult{version: v, source: versionFromFile}, true +} + +// pythonPatchlevelVersion reads PY_VERSION from patchlevel.h: +// include/pythonX.Y/patchlevel.h (POSIX) or include/patchlevel.h (Windows). +func pythonPatchlevelVersion(exec executor.Executor, _, root string) (versionResult, bool) { + candidates := []string{filepath.Join(root, "include", "patchlevel.h")} + if ents, err := exec.ReadDir(filepath.Join(root, "include")); err == nil { + for _, e := range ents { + if strings.HasPrefix(e.Name(), "python") { + candidates = append(candidates, filepath.Join(root, "include", e.Name(), "patchlevel.h")) + } + } + } + for _, c := range candidates { + if v := cleanVersion(readDefines(exec, c)["PY_VERSION"]); v != "" { + return versionResult{version: v, source: versionFromFile}, true + } + } + return versionResult{}, false +} + +// phpHeaderVersion reads include/php/main/php_version.h (present with dev headers). +func phpHeaderVersion(exec executor.Executor, _, root string) (versionResult, bool) { + v := cleanVersion(readDefines(exec, filepath.Join(root, "include", "php", "main", "php_version.h"))["PHP_VERSION"]) + if v == "" { + return versionResult{}, false + } + return versionResult{version: v, source: versionFromFile}, true +} + +// javaReleaseVersion reads the JDK/JRE `release` file (JAVA_VERSION, IMPLEMENTOR). +func javaReleaseVersion(exec executor.Executor, _, root string) (versionResult, bool) { + data, err := exec.ReadFile(filepath.Join(root, "release")) + if err != nil { + return versionResult{}, false + } + kv := map[string]string{} + for _, m := range keyValueRe.FindAllStringSubmatch(string(data), -1) { + kv[m[1]] = strings.TrimSpace(m[2]) + } + if kv["JAVA_VERSION"] == "" { + return versionResult{}, false + } + return versionResult{version: kv["JAVA_VERSION"], vendor: kv["IMPLEMENTOR"], source: versionFromFile}, true +} + +// goVersionFile reads $GOROOT/VERSION ("go1.22.1" on the first line). +func goVersionFile(exec executor.Executor, _, root string) (versionResult, bool) { + data, err := exec.ReadFile(filepath.Join(root, "VERSION")) + if err != nil { + return versionResult{}, false + } + line, _, _ := strings.Cut(string(data), "\n") + v := cleanVersion(strings.TrimPrefix(strings.TrimSpace(line), "go")) + if v == "" { + return versionResult{}, false + } + return versionResult{version: v, source: versionFromFile}, true +} + +// rubyConfigVersion reads RUBY_PROGRAM_VERSION from lib/ruby///rbconfig.rb. +func rubyConfigVersion(exec executor.Executor, _, root string) (versionResult, bool) { + libRuby := filepath.Join(root, "lib", "ruby") + abis, err := exec.ReadDir(libRuby) + if err != nil { + return versionResult{}, false + } + for _, abi := range abis { + if !abi.IsDir() || !numericRe.MatchString(abi.Name()) { + continue + } + arches, err := exec.ReadDir(filepath.Join(libRuby, abi.Name())) + if err != nil { + continue + } + for _, arch := range arches { + if !arch.IsDir() { + continue + } + data, err := exec.ReadFile(filepath.Join(libRuby, abi.Name(), arch.Name(), "rbconfig.rb")) + if err != nil { + continue + } + if m := rbConfigRe.FindSubmatch(data); m != nil { + return versionResult{version: string(m[1]), source: versionFromFile}, true + } + } + } + return versionResult{}, false +} + +// pathVersionStrategy takes the nearest version-looking path component between +// the executable and two levels above the install root: nvm's v18.20.0, +// Cellar/node/22.1.0, php-8.3.1-Win32-vs16-x64, jdk-21.0.2+13. +func pathVersionStrategy(_ executor.Executor, exe, root string) (versionResult, bool) { + stop := filepath.Dir(filepath.Dir(root)) + for d := filepath.Dir(exe); ; d = filepath.Dir(d) { + if m := pathVerRe.FindStringSubmatch(filepath.Base(d)); m != nil { + return versionResult{version: m[1], source: versionFromPath}, true + } + if d == stop || filepath.Dir(d) == d { + break + } + } + return versionResult{}, false +} + +// cleanVersion keeps the leading dotted-numeric part of a version string and +// drops trailing ".0" padding PE resources add (18.20.0.0 -> 18.20.0). +func cleanVersion(v string) string { + v = numericRe.FindString(strings.TrimSpace(v)) + for strings.Count(v, ".") > 2 && strings.HasSuffix(v, ".0") { + v = strings.TrimSuffix(v, ".0") + } + return v +} + +// detectVersion runs a signature's strategies in order. +func detectVersion(exec executor.Executor, sig *signature, exe, root string) versionResult { + for _, s := range sig.versions { + if r, ok := s(exec, exe, root); ok { + return r + } + } + return versionResult{version: "unknown", source: versionFromUnknown} +} + +// dotnetRuntimeVersions lists side-by-side .NET runtimes under a dotnet root. +func dotnetRuntimeVersions(exec executor.Executor, root string) []string { + ents, err := exec.ReadDir(filepath.Join(root, "shared", "Microsoft.NETCore.App")) + if err != nil { + return nil + } + var out []string + for _, e := range ents { + if e.IsDir() && dotnetVerRe.MatchString(e.Name()) { + out = append(out, e.Name()) + } + } + return out +} diff --git a/internal/detector/winpkg.go b/internal/detector/winpkg.go new file mode 100644 index 00000000..19bfb5a8 --- /dev/null +++ b/internal/detector/winpkg.go @@ -0,0 +1,341 @@ +package detector + +import ( + "context" + "encoding/json" + "encoding/xml" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// arpEntry is one subkey of an Uninstall registry key ("Programs and Features"). +type arpEntry struct { + KeyName string + Hive string // HKLM or HKCU + View string // 64 or 32 (HKLM only) + DisplayName string + DisplayVersion string + Publisher string + InstallLocation string + InstallDate string // YYYYMMDD + URLInfoAbout string + EstimatedSizeKB uint64 + SystemComponent bool + WindowsInstaller bool + ParentKeyName string + ReleaseType string +} + +// WindowsPkgDetector inventories Windows software from the Uninstall +// registry keys (ARP), Chocolatey, Scoop and winget portable packages. It +// only reads the registry and files — winget/choco/scoop are never run +// (winget is a per-user MSIX that is unreliable outside an interactive shell; +// Win32_Product triggers MSI repairs). +type WindowsPkgDetector struct { + exec executor.Executor +} + +func NewWindowsPkgDetector(exec executor.Executor) *WindowsPkgDetector { + return &WindowsPkgDetector{exec: exec} +} + +// Scan returns one result per source that is present. ARP always reports. +func (d *WindowsPkgDetector) Scan(ctx context.Context) []model.SystemPackageScanResult { + if d.exec.GOOS() != model.PlatformWindows { + return nil + } + var results []model.SystemPackageScanResult + + start := time.Now() + entries := readARPEntries(ctx, d.exec) + arp := arpPackages(entries) + results = append(results, scanResult("arp", &model.PkgManager{Name: "arp", Path: `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`}, arp, start)) + + home := "" + if u, err := d.exec.LoggedInUser(); err == nil { + home = u.HomeDir + } + programData := firstNonEmptyStr(d.exec.Getenv("ProgramData"), `C:\ProgramData`) + + chocoRoot := firstNonEmptyStr(d.exec.Getenv("ChocolateyInstall"), filepath.Join(programData, "chocolatey")) + if d.exec.DirExists(filepath.Join(chocoRoot, "lib")) { + start = time.Now() + pkgs := d.chocoPackages(chocoRoot) + results = append(results, scanResult("choco", &model.PkgManager{Name: "choco", Path: chocoRoot}, pkgs, start)) + } + + var scoopPkgs []model.SystemPackage + var scoopPaths []string + start = time.Now() + for _, r := range []struct{ root, scope string }{ + {firstNonEmptyStr(d.exec.Getenv("SCOOP"), joinIf(home, "scoop")), "user"}, + {firstNonEmptyStr(d.exec.Getenv("SCOOP_GLOBAL"), filepath.Join(programData, "scoop")), "machine"}, + } { + if r.root == "" || !d.exec.DirExists(filepath.Join(r.root, "apps")) { + continue + } + scoopPaths = append(scoopPaths, r.root) + scoopPkgs = append(scoopPkgs, d.scoopPackages(r.root, r.scope)...) + } + if len(scoopPaths) > 0 { + results = append(results, scanResult("scoop", &model.PkgManager{Name: "scoop", Path: strings.Join(scoopPaths, ";")}, scoopPkgs, start)) + } + + var wingetPkgs []model.SystemPackage + var wingetPaths []string + start = time.Now() + arpByKey := map[string]arpEntry{} + for _, e := range entries { + arpByKey[strings.ToLower(e.KeyName)] = e + } + for _, r := range []struct{ root, scope string }{ + {joinIf(d.exec.Getenv("LOCALAPPDATA"), "Microsoft", "WinGet", "Packages"), "user"}, + {joinIf(d.exec.Getenv("ProgramFiles"), "WinGet", "Packages"), "machine"}, + } { + if r.root == "" || !d.exec.DirExists(r.root) { + continue + } + wingetPaths = append(wingetPaths, r.root) + wingetPkgs = append(wingetPkgs, d.wingetPortablePackages(r.root, r.scope, arpByKey)...) + } + if len(wingetPaths) > 0 { + results = append(results, scanResult("winget", &model.PkgManager{Name: "winget", Path: strings.Join(wingetPaths, ";")}, wingetPkgs, start)) + } + return results +} + +func scanResult(scanType string, pm *model.PkgManager, pkgs []model.SystemPackage, start time.Time) model.SystemPackageScanResult { + if pkgs == nil { + pkgs = []model.SystemPackage{} + } + sort.Slice(pkgs, func(i, j int) bool { + if pkgs[i].Name != pkgs[j].Name { + return pkgs[i].Name < pkgs[j].Name + } + return pkgs[i].Version < pkgs[j].Version + }) + return model.SystemPackageScanResult{ + ScanType: scanType, + PackageManager: pm, + Packages: pkgs, + PackagesCount: len(pkgs), + ScanDurationMs: time.Since(start).Milliseconds(), + } +} + +// arpPackages filters ARP entries the way Programs and Features does — +// no DisplayName, SystemComponent=1, and updates/hotfixes (ParentKeyName or +// an update ReleaseType) are hidden — and dedupes entries visible from more +// than one registry view. +func arpPackages(entries []arpEntry) []model.SystemPackage { + seen := map[string]bool{} + var out []model.SystemPackage + for _, e := range entries { + if strings.TrimSpace(e.DisplayName) == "" || e.SystemComponent || e.ParentKeyName != "" { + continue + } + switch strings.ToLower(e.ReleaseType) { + case "update", "hotfix", "security update", "update rollup", "service pack": + continue + } + k := strings.ToLower(e.DisplayName + "|" + e.DisplayVersion + "|" + e.Publisher + "|" + e.Hive) + if seen[k] { + continue + } + seen[k] = true + + p := model.SystemPackage{ + Name: strings.TrimSpace(e.DisplayName), + Version: strings.TrimSpace(e.DisplayVersion), + Vendor: strings.TrimSpace(e.Publisher), + InstallPath: strings.TrimSpace(e.InstallLocation), + URL: strings.TrimSpace(e.URLInfoAbout), + InstalledSize: int64(e.EstimatedSizeKB) * 1024, + Source: "exe", + Scope: "machine", + } + if e.WindowsInstaller { + p.Source = "msi" + } + if e.Hive == "HKCU" { + p.Scope = "user" + } else if e.View == "32" { + p.Arch = "x86" + } + if t, err := time.Parse("20060102", strings.TrimSpace(e.InstallDate)); err == nil { + p.InstallTimeUnix = t.Unix() + } + out = append(out, p) + } + return out +} + +// nuspec is the subset of a Chocolatey .nuspec we read. Field tags carry no +// namespace so they match regardless of the nuspec schema version. +type nuspec struct { + Metadata struct { + ID string `xml:"id"` + Version string `xml:"version"` + Authors string `xml:"authors"` + ProjectURL string `xml:"projectUrl"` + License string `xml:"licenseUrl"` + } `xml:"metadata"` +} + +// chocoPackages reads \lib\\.nuspec, as osquery's +// chocolatey_packages table does. +func (d *WindowsPkgDetector) chocoPackages(root string) []model.SystemPackage { + lib := filepath.Join(root, "lib") + ents, err := d.exec.ReadDir(lib) + if err != nil { + return nil + } + var out []model.SystemPackage + for _, e := range ents { + if !e.IsDir() { + continue + } + dir := filepath.Join(lib, e.Name()) + path := filepath.Join(dir, e.Name()+".nuspec") + data, err := d.exec.ReadFile(path) + if err != nil { + continue + } + var n nuspec + if xml.Unmarshal(data, &n) != nil || n.Metadata.ID == "" { + continue + } + p := model.SystemPackage{ + Name: n.Metadata.ID, + Version: n.Metadata.Version, + Maintainer: n.Metadata.Authors, + URL: n.Metadata.ProjectURL, + License: n.Metadata.License, + InstallPath: dir, + Source: "chocolatey", + Scope: "machine", + } + if info, err := d.exec.Stat(path); err == nil && !info.ModTime().IsZero() { + p.InstallTimeUnix = info.ModTime().Unix() + } + out = append(out, p) + } + return out +} + +type scoopManifest struct { + Version string `json:"version"` + Homepage string `json:"homepage"` + License json.RawMessage `json:"license"` // string or {"identifier": ...} +} + +type scoopInstall struct { + Bucket string `json:"bucket"` + Architecture string `json:"architecture"` +} + +// scoopPackages reads \apps\\current\{manifest,install}.json. +// "current" is a junction to the active version directory. +func (d *WindowsPkgDetector) scoopPackages(root, scope string) []model.SystemPackage { + apps := filepath.Join(root, "apps") + ents, err := d.exec.ReadDir(apps) + if err != nil { + return nil + } + var out []model.SystemPackage + for _, e := range ents { + current := filepath.Join(apps, e.Name(), "current") + data, err := d.exec.ReadFile(filepath.Join(current, "manifest.json")) + if err != nil { + continue + } + var m scoopManifest + if json.Unmarshal(data, &m) != nil { + continue + } + p := model.SystemPackage{ + Name: e.Name(), + Version: m.Version, + URL: m.Homepage, + License: scoopLicense(m.License), + InstallPath: current, + Scope: scope, + } + if real, err := d.exec.EvalSymlinks(current); err == nil { + p.InstallPath = real + } + if data, err := d.exec.ReadFile(filepath.Join(current, "install.json")); err == nil { + var inst scoopInstall + if json.Unmarshal(data, &inst) == nil { + p.Source = inst.Bucket + p.Arch = inst.Architecture + } + } + out = append(out, p) + } + return out +} + +func scoopLicense(raw json.RawMessage) string { + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + var obj struct { + Identifier string `json:"identifier"` + } + if json.Unmarshal(raw, &obj) == nil { + return obj.Identifier + } + return "" +} + +// wingetPortablePackages lists winget portable installs: one directory per +// package named _. winget registers each +// with an ARP entry under the same key name, which carries the version. +func (d *WindowsPkgDetector) wingetPortablePackages(root, scope string, arpByKey map[string]arpEntry) []model.SystemPackage { + ents, err := d.exec.ReadDir(root) + if err != nil { + return nil + } + var out []model.SystemPackage + for _, e := range ents { + if !e.IsDir() { + continue + } + id, source, _ := strings.Cut(e.Name(), "_") + p := model.SystemPackage{ + Name: id, + InstallPath: filepath.Join(root, e.Name()), + Source: source, + Scope: scope, + } + if a, ok := arpByKey[strings.ToLower(e.Name())]; ok { + p.Version = a.DisplayVersion + p.Vendor = a.Publisher + } + out = append(out, p) + } + return out +} + +func firstNonEmptyStr(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func joinIf(base string, rel ...string) string { + if base == "" { + return "" + } + return filepath.Join(append([]string{base}, rel...)...) +} diff --git a/internal/detector/winpkg_arp_other.go b/internal/detector/winpkg_arp_other.go new file mode 100644 index 00000000..c787ebfb --- /dev/null +++ b/internal/detector/winpkg_arp_other.go @@ -0,0 +1,98 @@ +//go:build !windows + +package detector + +import ( + "context" + "regexp" + "strconv" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// regValueRe matches a `reg query` value line: name, type, data separated by +// four spaces (names may contain single spaces). +var regValueRe = regexp.MustCompile(`^\s+(.+?)\s{4}(REG_\w+)(?:\s{4}(.*))?$`) + +// readARPEntries is the non-Windows twin of the native reader: it parses +// `reg query /s`. Only mock-based tests running with +// SetGOOS("windows") reach it. +func readARPEntries(ctx context.Context, exec executor.Executor) []arpEntry { + var out []arpEntry + for _, src := range []struct{ root, hive, view string }{ + {`HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "HKLM", "64"}, + {`HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, "HKLM", "32"}, + {`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "HKCU", ""}, + } { + stdout, _, _, err := exec.Run(ctx, "reg", "query", src.root, "/s") + if err != nil { + continue + } + out = append(out, parseRegQueryARP(stdout, src.hive, src.view)...) + } + return out +} + +func parseRegQueryARP(stdout, hive, view string) []arpEntry { + var out []arpEntry + var cur *arpEntry + flush := func() { + if cur != nil { + out = append(out, *cur) + cur = nil + } + } + for _, line := range strings.Split(strings.ReplaceAll(stdout, "\r\n", "\n"), "\n") { + if strings.HasPrefix(line, "HKEY_") { + flush() + name := line[strings.LastIndex(line, `\`)+1:] + cur = &arpEntry{KeyName: strings.TrimSpace(name), Hive: hive, View: view} + continue + } + m := regValueRe.FindStringSubmatch(line) + if m == nil || cur == nil { + continue + } + name, typ, data := m[1], m[2], strings.TrimSpace(m[3]) + dword := func() uint64 { + v, _ := strconv.ParseUint(strings.TrimPrefix(data, "0x"), 16, 64) + return v + } + switch name { + case "DisplayName": + cur.DisplayName = data + case "DisplayVersion": + cur.DisplayVersion = data + case "Publisher": + cur.Publisher = data + case "InstallLocation": + cur.InstallLocation = data + case "InstallDate": + cur.InstallDate = data + case "URLInfoAbout": + cur.URLInfoAbout = data + case "ParentKeyName": + cur.ParentKeyName = data + case "ReleaseType": + cur.ReleaseType = data + case "SystemComponent": + cur.SystemComponent = typ == "REG_DWORD" && dword() == 1 + case "WindowsInstaller": + cur.WindowsInstaller = typ == "REG_DWORD" && dword() == 1 + case "EstimatedSize": + if typ == "REG_DWORD" { + cur.EstimatedSizeKB = dword() + } + } + } + flush() + // The root key itself carries no values; drop it. + filtered := out[:0] + for _, e := range out { + if !strings.EqualFold(e.KeyName, "Uninstall") { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/internal/detector/winpkg_arp_windows.go b/internal/detector/winpkg_arp_windows.go new file mode 100644 index 00000000..9caf4042 --- /dev/null +++ b/internal/detector/winpkg_arp_windows.go @@ -0,0 +1,75 @@ +//go:build windows + +package detector + +import ( + "context" + + "github.com/step-security/dev-machine-guard/internal/executor" + "golang.org/x/sys/windows/registry" +) + +const uninstallKeyPath = `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` + +// readARPEntries reads every Uninstall subkey from HKLM (64- and 32-bit +// views) and HKCU. The scan runs as the interactive user, so HKCU is that +// user's hive. +func readARPEntries(ctx context.Context, _ executor.Executor) []arpEntry { + var out []arpEntry + for _, src := range []struct { + root registry.Key + hive string + view string + access uint32 + }{ + {registry.LOCAL_MACHINE, "HKLM", "64", registry.WOW64_64KEY}, + {registry.LOCAL_MACHINE, "HKLM", "32", registry.WOW64_32KEY}, + {registry.CURRENT_USER, "HKCU", "", 0}, + } { + if ctx.Err() != nil { + break + } + out = append(out, readUninstallKey(src.root, src.hive, src.view, src.access)...) + } + return out +} + +func readUninstallKey(root registry.Key, hive, view string, access uint32) []arpEntry { + k, err := registry.OpenKey(root, uninstallKeyPath, registry.ENUMERATE_SUB_KEYS|access) + if err != nil { + return nil + } + names, err := k.ReadSubKeyNames(-1) + _ = k.Close() + if err != nil { + return nil + } + out := make([]arpEntry, 0, len(names)) + for _, name := range names { + sk, err := registry.OpenKey(root, uninstallKeyPath+`\`+name, registry.QUERY_VALUE|access) + if err != nil { + continue + } + e := arpEntry{KeyName: name, Hive: hive, View: view} + e.DisplayName, _, _ = sk.GetStringValue("DisplayName") + e.DisplayVersion, _, _ = sk.GetStringValue("DisplayVersion") + e.Publisher, _, _ = sk.GetStringValue("Publisher") + e.InstallLocation, _, _ = sk.GetStringValue("InstallLocation") + e.InstallDate, _, _ = sk.GetStringValue("InstallDate") + e.URLInfoAbout, _, _ = sk.GetStringValue("URLInfoAbout") + e.ParentKeyName, _, _ = sk.GetStringValue("ParentKeyName") + e.ReleaseType, _, _ = sk.GetStringValue("ReleaseType") + if v, _, err := sk.GetIntegerValue("SystemComponent"); err == nil { + e.SystemComponent = v == 1 + } + if v, _, err := sk.GetIntegerValue("WindowsInstaller"); err == nil { + e.WindowsInstaller = v == 1 + } + if v, _, err := sk.GetIntegerValue("EstimatedSize"); err == nil { + e.EstimatedSizeKB = v + } + _ = sk.Close() + out = append(out, e) + } + return out +} diff --git a/internal/detector/winpkg_test.go b/internal/detector/winpkg_test.go new file mode 100644 index 00000000..adaf0811 --- /dev/null +++ b/internal/detector/winpkg_test.go @@ -0,0 +1,190 @@ +//go:build !windows + +package detector + +import ( + "context" + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +const regQueryHKLM64 = ` +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall + +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{4F1E1A6B-0000-0000-0000-NODEJS000001} + DisplayName REG_SZ Node.js + DisplayVersion REG_SZ 18.20.4 + Publisher REG_SZ Node.js Foundation + InstallLocation REG_SZ C:\Program Files\nodejs\ + InstallDate REG_SZ 20240315 + WindowsInstaller REG_DWORD 0x1 + EstimatedSize REG_DWORD 0x13c68 + +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{HIDDEN-RUNTIME} + DisplayName REG_SZ Microsoft Visual C++ 2022 X64 Minimum Runtime + DisplayVersion REG_SZ 14.38.33130 + SystemComponent REG_DWORD 0x1 + +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\KB5034441 + DisplayName REG_SZ Security Update for Windows (KB5034441) + ParentKeyName REG_SZ OperatingSystem + +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\NoName + UninstallString REG_SZ C:\x\uninstall.exe + +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\XAMPP + DisplayName REG_SZ XAMPP + DisplayVersion REG_SZ 8.2.12-0 + Publisher REG_SZ Bitnami +` + +const regQueryHKLM32 = ` +HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\7-Zip + DisplayName REG_SZ 7-Zip 23.01 + DisplayVersion REG_SZ 23.01 + Publisher REG_SZ Igor Pavlov +` + +const regQueryHKCU = ` +HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft VS Code + DisplayName REG_SZ Microsoft Visual Studio Code (User) + DisplayVersion REG_SZ 1.95.3 + Publisher REG_SZ Microsoft Corporation + +HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Schniz.fnm_Microsoft.Winget.Source_8wekyb3d8bbwe + DisplayName REG_SZ fnm + DisplayVersion REG_SZ 1.37.1 + Publisher REG_SZ Schniz +` + +func newWinPkgMock() *executor.Mock { + m := executor.NewMock() + m.SetGOOS(model.PlatformWindows) + m.SetHomeDir("/C/Users/dev") + m.SetEnv("ProgramData", "/C/ProgramData") + m.SetEnv("LOCALAPPDATA", "/C/Users/dev/AppData/Local") + m.SetCommand(regQueryHKLM64, "", 0, "reg", "query", `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "/s") + m.SetCommand(regQueryHKLM32, "", 0, "reg", "query", `HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, "/s") + m.SetCommand(regQueryHKCU, "", 0, "reg", "query", `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "/s") + return m +} + +func scanByType(results []model.SystemPackageScanResult) map[string]model.SystemPackageScanResult { + out := map[string]model.SystemPackageScanResult{} + for _, r := range results { + out[r.ScanType] = r + } + return out +} + +func TestWindowsPkgARP(t *testing.T) { + m := newWinPkgMock() + res := scanByType(NewWindowsPkgDetector(m).Scan(context.Background())) + arp, ok := res["arp"] + if !ok { + t.Fatal("no arp result") + } + byName := map[string]model.SystemPackage{} + for _, p := range arp.Packages { + byName[p.Name] = p + } + if len(arp.Packages) != 5 || arp.PackagesCount != 5 { + t.Fatalf("arp packages = %d, want 5 (hidden/update/no-name filtered): %+v", len(arp.Packages), arp.Packages) + } + node := byName["Node.js"] + if node.Version != "18.20.4" || node.Vendor != "Node.js Foundation" || node.Source != "msi" || + node.Scope != "machine" || node.InstallPath != `C:\Program Files\nodejs\` || + node.InstalledSize != 0x13c68*1024 || node.InstallTimeUnix != 1710460800 { + t.Errorf("node = %+v", node) + } + if p := byName["7-Zip 23.01"]; p.Arch != "x86" || p.Source != "exe" { + t.Errorf("7-zip = %+v", p) + } + if p := byName["Microsoft Visual Studio Code (User)"]; p.Scope != "user" { + t.Errorf("vscode = %+v", p) + } + for _, hidden := range []string{"Microsoft Visual C++ 2022 X64 Minimum Runtime", "Security Update for Windows (KB5034441)"} { + if _, ok := byName[hidden]; ok { + t.Errorf("%q should be filtered", hidden) + } + } + if _, ok := res["choco"]; ok { + t.Error("choco reported without a choco install") + } +} + +func TestWindowsPkgChocoScoopWinget(t *testing.T) { + m := newWinPkgMock() + // Chocolatey. + m.SetDirEntries("/C/ProgramData/chocolatey/lib", []os.DirEntry{ + executor.MockDirEntry("nodejs.install", true), + executor.MockDirEntry("broken", true), + }) + m.SetFile("/C/ProgramData/chocolatey/lib/nodejs.install/nodejs.install.nuspec", []byte(` + + + nodejs.install + 20.11.1 + Node.js Foundation + https://nodejs.org/ + +`)) + m.SetFile("/C/ProgramData/chocolatey/lib/broken/broken.nuspec", []byte(`not xml`)) + // Scoop (user). + m.SetDirEntries("/C/Users/dev/scoop/apps", []os.DirEntry{ + executor.MockDirEntry("php", true), + executor.MockDirEntry("nodejs-lts", true), + }) + m.SetFile("/C/Users/dev/scoop/apps/php/current/manifest.json", []byte(`{"version":"8.3.1","homepage":"https://php.net","license":"PHP-3.01"}`)) + m.SetFile("/C/Users/dev/scoop/apps/php/current/install.json", []byte(`{"bucket":"main","architecture":"64bit"}`)) + m.SetSymlink("/C/Users/dev/scoop/apps/php/current", "/C/Users/dev/scoop/apps/php/8.3.1") + m.SetFile("/C/Users/dev/scoop/apps/nodejs-lts/current/manifest.json", []byte(`{"version":"20.11.1","license":{"identifier":"MIT"}}`)) + // winget portable. + m.SetDirEntries("/C/Users/dev/AppData/Local/Microsoft/WinGet/Packages", []os.DirEntry{ + executor.MockDirEntry("Schniz.fnm_Microsoft.Winget.Source_8wekyb3d8bbwe", true), + }) + + res := scanByType(NewWindowsPkgDetector(m).Scan(context.Background())) + + choco := res["choco"] + if len(choco.Packages) != 1 || choco.Packages[0].Name != "nodejs.install" || choco.Packages[0].Version != "20.11.1" || + choco.Packages[0].Maintainer != "Node.js Foundation" { + t.Errorf("choco = %+v", choco.Packages) + } + scoop := res["scoop"] + if len(scoop.Packages) != 2 { + t.Fatalf("scoop = %+v", scoop.Packages) + } + for _, p := range scoop.Packages { + switch p.Name { + case "php": + if p.Version != "8.3.1" || p.Source != "main" || p.Arch != "64bit" || p.License != "PHP-3.01" || + p.InstallPath != "/C/Users/dev/scoop/apps/php/8.3.1" || p.Scope != "user" { + t.Errorf("scoop php = %+v", p) + } + case "nodejs-lts": + if p.Version != "20.11.1" || p.License != "MIT" { + t.Errorf("scoop node = %+v", p) + } + } + } + winget := res["winget"] + if len(winget.Packages) != 1 { + t.Fatalf("winget = %+v", winget.Packages) + } + if p := winget.Packages[0]; p.Name != "Schniz.fnm" || p.Version != "1.37.1" || p.Vendor != "Schniz" || + p.Source != "Microsoft.Winget.Source_8wekyb3d8bbwe" || p.Scope != "user" { + t.Errorf("winget = %+v", p) + } +} + +func TestWindowsPkgNonWindowsIsNil(t *testing.T) { + m := executor.NewMock() + m.SetGOOS(model.PlatformLinux) + if got := NewWindowsPkgDetector(m).Scan(context.Background()); got != nil { + t.Fatalf("got %+v on linux", got) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 80ea43e9..ed71f51c 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -26,11 +26,18 @@ type ScanResult struct { SnapPackages []SystemPackage `json:"snap_packages"` FlatpakPkgManager *PkgManager `json:"flatpak_package_manager,omitempty"` FlatpakPackages []SystemPackage `json:"flatpak_packages"` - NPMRCAudit *NPMRCAudit `json:"npmrc_audit,omitempty"` - PipAudit *PipAudit `json:"pip_audit,omitempty"` - PnpmAudit *PnpmAudit `json:"pnpm_audit,omitempty"` - BunAudit *BunAudit `json:"bun_audit,omitempty"` - YarnAudit *YarnAudit `json:"yarn_audit,omitempty"` + + // WindowsPackageScans holds the Windows system package sources (arp, + // choco, scoop, winget); Windows only. + WindowsPackageScans []SystemPackageScanResult `json:"windows_package_scans,omitempty"` + // RuntimeScan is the language runtime inventory; nil when disabled. + RuntimeScan *RuntimeScanResult `json:"runtime_scan,omitempty"` + + NPMRCAudit *NPMRCAudit `json:"npmrc_audit,omitempty"` + PipAudit *PipAudit `json:"pip_audit,omitempty"` + PnpmAudit *PnpmAudit `json:"pnpm_audit,omitempty"` + BunAudit *BunAudit `json:"bun_audit,omitempty"` + YarnAudit *YarnAudit `json:"yarn_audit,omitempty"` // AgentSkills is the flat list of discovered AI agent skills. AgentSkillScan // is the phase summary; its non-nil presence is the "scan ran" sentinel (a @@ -208,6 +215,7 @@ type Summary struct { SystemPackagesCount int `json:"system_packages_count"` SnapPackagesCount int `json:"snap_packages_count"` FlatpakPackagesCount int `json:"flatpak_packages_count"` + RuntimesCount int `json:"runtimes_count"` AgentSkillsCount int `json:"agent_skills_count"` AgentPluginsCount int `json:"agent_plugins_count"` } @@ -296,7 +304,7 @@ type ProjectInfo struct { } // SystemPackage represents a package installed via the system package manager -// (rpm, dpkg, pacman, apk, snap, flatpak). +// (rpm, dpkg, pacman, apk, snap, flatpak; on Windows: ARP, choco, scoop, winget). type SystemPackage struct { Name string `json:"name"` Version string `json:"version"` @@ -304,6 +312,7 @@ type SystemPackage struct { Source string `json:"source,omitempty"` // Origin: source RPM, dpkg source, snap publisher, flatpak remote InstallPath string `json:"install_path,omitempty"` // On-disk install root. Populated for snap (/snap//current) and flatpak (~/.local/share/flatpak/app/ or /var/lib/flatpak/app/). Not applicable for rpm/dpkg/pacman/apk (file collections). InstallTimeUnix int64 `json:"install_time_unix,omitempty"` // Unix epoch seconds when installed (rpm, dpkg, pacman) + Scope string `json:"scope,omitempty"` // Windows: "machine" (HKLM, ProgramData) or "user" (HKCU, user profile) // Provenance & trust signals Vendor string `json:"vendor,omitempty"` // Distributor: rpm VENDOR, dpkg Origin @@ -326,6 +335,31 @@ type SystemPackage struct { CommitHash string `json:"commit_hash,omitempty"` // apk commit, flatpak active commit } +// RuntimeInstall is one language runtime install found on disk (node, python, +// java, …), independent of how it was installed. +type RuntimeInstall struct { + Runtime string `json:"runtime"` // node, python, php, java, go, ruby, deno, bun, dotnet + Version string `json:"version"` // "unknown" when no strategy yielded one + VersionSource string `json:"version_source"` // file, pe, path, unknown + ExecutablePath string `json:"executable_path"` // symlink-resolved path of the binary + InstallRoot string `json:"install_root,omitempty"` // runtime home (parent of bin/, or the exe dir) + Source string `json:"source"` // nvm, fnm, volta, brew, scoop, pyenv, sdkman, system, installer, manual, … + Scope string `json:"scope"` // user (under the user's home) or machine + Vendor string `json:"vendor,omitempty"` // distribution vendor where known (java IMPLEMENTOR) + BundledBy string `json:"bundled_by,omitempty"` // app that ships this runtime privately; empty for standalone installs + Arch string `json:"arch,omitempty"` // from the binary header: amd64, arm64, 386, universal + InstallTimeUnix int64 `json:"install_time_unix,omitempty"` // executable mtime +} + +// RuntimeScanResult holds the runtime inventory for enterprise telemetry. +type RuntimeScanResult struct { + Runtimes []RuntimeInstall `json:"runtimes"` + RuntimesCount int `json:"runtimes_count"` + Truncated bool `json:"truncated,omitempty"` // walk stopped early (entry cap or deadline) + Error string `json:"error,omitempty"` + ScanDurationMs int64 `json:"scan_duration_ms"` +} + // BrewPackage represents a single installed Homebrew formula or cask. type BrewPackage struct { Name string `json:"name"` @@ -354,7 +388,7 @@ type PythonPackage struct { // Unlike BrewScanResult (which sends raw base64), this sends pre-parsed packages // since syspkg.go already handles the format-specific parsing edge cases. type SystemPackageScanResult struct { - ScanType string `json:"scan_type"` // "rpm", "dpkg", "pacman", "apk", "snap", "flatpak" + ScanType string `json:"scan_type"` // "rpm", "dpkg", "pacman", "apk", "snap", "flatpak"; Windows: "arp", "choco", "scoop", "winget" PackageManager *PkgManager `json:"package_manager,omitempty"` Packages []SystemPackage `json:"packages"` PackagesCount int `json:"packages_count"` diff --git a/internal/output/html.go b/internal/output/html.go index 8594af4c..797efdd1 100644 --- a/internal/output/html.go +++ b/internal/output/html.go @@ -32,6 +32,8 @@ type htmlData struct { // Nil means the phase did not run, which the template shows differently from a // scan that ran and found nothing. BrowserExtensionScan *model.BrowserExtensionScanInfo + WindowsPackageScans []model.SystemPackageScanResult + RuntimeScan *model.RuntimeScanResult Summary model.Summary } @@ -80,6 +82,8 @@ func HTML(outputFile string, result *model.ScanResult) error { AgentPlugins: result.AgentPlugins, BrowserExtensionScan: result.BrowserExtensionScan, + WindowsPackageScans: result.WindowsPackageScans, + RuntimeScan: result.RuntimeScan, Summary: result.Summary, } @@ -393,6 +397,38 @@ const htmlTemplate = ` {{end}} +{{if .RuntimeScan}} +
+
+

Language Runtimes {{.Summary.RuntimesCount}} installs

+ ▼ +
+
+ + + {{range .RuntimeScan.Runtimes}} + {{end}} +
RuntimeVersionSourcePath
{{.Runtime}}{{.Version}}{{if .BundledBy}}bundled by {{.BundledBy}}{{else}}{{.Source}}{{end}}{{.ExecutablePath}}
+
+
+{{end}} + +{{range .WindowsPackageScans}} +
+
+

System Packages ({{.ScanType}}) {{.PackagesCount}} packages

+ +
+ +
+{{end}} + {{if .PythonPkgManagers}}
diff --git a/internal/output/pretty.go b/internal/output/pretty.go index 9f14709c..fd169aa3 100644 --- a/internal/output/pretty.go +++ b/internal/output/pretty.go @@ -83,6 +83,12 @@ func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error { if result.FlatpakPkgManager != nil { fmt.Fprintf(w, " %-24s %s%d%s\n", "Flatpak Apps", c.green, result.Summary.FlatpakPackagesCount, c.reset) } + if len(result.WindowsPackageScans) > 0 { + fmt.Fprintf(w, " %-24s %s%d%s\n", "System Packages", c.green, result.Summary.SystemPackagesCount, c.reset) + } + if result.RuntimeScan != nil { + fmt.Fprintf(w, " %-24s %s%d%s\n", "Language Runtimes", c.green, result.Summary.RuntimesCount, c.reset) + } fmt.Fprintln(w) // AI AGENTS AND TOOLS @@ -308,6 +314,37 @@ func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error { fmt.Fprintln(w) } + // SYSTEM PACKAGES (Windows: one block per source) + for _, scan := range result.WindowsPackageScans { + fmt.Fprintf(w, " %s%sSYSTEM PACKAGES (%s)%s\n", c.purple, c.bold, strings.ToUpper(scan.ScanType), c.reset) + fmt.Fprintln(w) + if len(scan.Packages) > 0 { + printSectionHeader(w, c, "Installed Packages", len(scan.Packages)) + for _, pkg := range scan.Packages { + fmt.Fprintf(w, " %-36s %s%s%s\n", pkg.Name, c.dim, pkg.Version, c.reset) + } + } else { + fmt.Fprintf(w, " %sNo packages found%s\n", c.dim, c.reset) + } + fmt.Fprintln(w) + } + + // LANGUAGE RUNTIMES + if result.RuntimeScan != nil { + printSectionHeader(w, c, "LANGUAGE RUNTIMES", result.RuntimeScan.RuntimesCount) + for _, r := range result.RuntimeScan.Runtimes { + origin := r.Source + if r.BundledBy != "" { + origin = "bundled by " + r.BundledBy + } + fmt.Fprintf(w, " %-8s %-14s %s%-22s %s%s\n", r.Runtime, r.Version, c.dim, origin, r.ExecutablePath, c.reset) + } + if result.RuntimeScan.Truncated { + fmt.Fprintf(w, " %sWalk stopped early; list may be partial%s\n", c.dim, c.reset) + } + fmt.Fprintln(w) + } + // NPM CONFIG AUDIT (compact summary; deep view via --npmrc) if result.NPMRCAudit != nil { printNPMRCAuditSummary(w, c, result.NPMRCAudit) diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 99f0110a..214c8bc0 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -12,6 +12,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/detector/browserext" "github.com/step-security/dev-machine-guard/internal/detector/configaudit" "github.com/step-security/dev-machine-guard/internal/detector/credentials" + "github.com/step-security/dev-machine-guard/internal/detector/runtimeinv" "github.com/step-security/dev-machine-guard/internal/device" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/featuregate" @@ -184,7 +185,8 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { log.StepSkip("disabled (use --enable-brew-scan to enable)") } - // System package managers (Linux only — rpm/dpkg/pacman/apk + snap + flatpak) + // System package managers: Linux (rpm/dpkg/pacman/apk + snap + flatpak) + // and Windows (ARP registry, choco, scoop, winget portable). var systemPkgManager *model.PkgManager var systemPackages []model.SystemPackage var snapPkgManager, flatpakPkgManager *model.PkgManager @@ -213,6 +215,33 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { } log.StepDone(time.Since(start)) } + var windowsPackageScans []model.SystemPackageScanResult + windowsPackagesCount := 0 + if exec.GOOS() == model.PlatformWindows { + log.StepStart("Detecting system packages") + start = time.Now() + windowsPackageScans = detector.NewWindowsPkgDetector(exec).Scan(ctx) + for _, s := range windowsPackageScans { + windowsPackagesCount += s.PackagesCount + } + log.StepDone(time.Since(start)) + } + + // Language runtime inventory (on unless disabled) + var runtimeScan *model.RuntimeScanResult + if cfg.EnableRuntimeScan == nil || *cfg.EnableRuntimeScan { + log.StepStart("Inventorying language runtimes") + start = time.Now() + runtimeScan = runtimeinv.ScanResult(ctx, exec) + log.StepDone(time.Since(start)) + } else { + log.StepStart("Language runtime inventory") + log.StepSkip("disabled (use --enable-runtime-scan to enable)") + } + runtimesCount := 0 + if runtimeScan != nil { + runtimesCount = runtimeScan.RuntimesCount + } // Python scanning (community mode defaults to off, explicit flag overrides) pythonEnabled := false @@ -429,6 +458,9 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { AgentPlugins: skillsResult.Plugins, CredentialScan: credentialScan, + WindowsPackageScans: windowsPackageScans, + RuntimeScan: runtimeScan, + BrowserExtensionScan: browserExtensionScan, Summary: model.Summary{ AIAgentsAndToolsCount: len(aiTools), @@ -439,9 +471,10 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { BrewFormulaeCount: len(brewFormulae), BrewCasksCount: len(brewCasks), PythonProjectsCount: len(pythonProjects), - SystemPackagesCount: len(systemPackages), + SystemPackagesCount: len(systemPackages) + windowsPackagesCount, SnapPackagesCount: len(snapPackages), FlatpakPackagesCount: len(flatpakPackages), + RuntimesCount: runtimesCount, AgentSkillsCount: len(agentSkills), AgentPluginsCount: skillsResult.Plugins.PluginCount(), }, diff --git a/internal/telemetry/phase_deadline.go b/internal/telemetry/phase_deadline.go index 5054a919..d0122f99 100644 --- a/internal/telemetry/phase_deadline.go +++ b/internal/telemetry/phase_deadline.go @@ -41,6 +41,7 @@ var phaseBudgets = map[string]time.Duration{ "brew_scan": 5 * time.Minute, "python_scan": 10 * time.Minute, "syspkg_scan": 5 * time.Minute, + "runtime_scan": 3 * time.Minute, // walk is entry-budgeted; deadline marks it truncated "node_scan": 15 * time.Minute, // Spawns only — it never waits for a distro scan to finish — so this // bounds a wedged wsl.exe, not the work. Also bounds the download of diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 32d130b2..4f13e840 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -26,6 +26,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/detector/configaudit" "github.com/step-security/dev-machine-guard/internal/detector/credentials" "github.com/step-security/dev-machine-guard/internal/detector/rules" + "github.com/step-security/dev-machine-guard/internal/detector/runtimeinv" "github.com/step-security/dev-machine-guard/internal/device" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/featuregate" @@ -111,6 +112,7 @@ type Payload struct { PythonProjectsRemoved []model.RemovedProjectRef `json:"python_projects_removed,omitempty"` PythonGlobalsUnchanged []model.UnchangedGlobalRef `json:"python_globals_unchanged,omitempty"` SystemPackageScans []model.SystemPackageScanResult `json:"system_package_scans"` + RuntimeScan *model.RuntimeScanResult `json:"runtime_scan,omitempty"` // nil = runtime inventory disabled AIAgents []model.AITool `json:"ai_agents"` MCPConfigs []model.MCPConfigEnterprise `json:"mcp_configs"` NPMRCAudit *model.NPMRCAudit `json:"npmrc_audit,omitempty"` @@ -159,6 +161,7 @@ type PerformanceMetrics struct { PythonGlobalPkgsCount int `json:"python_global_packages_count"` PythonProjectsCount int `json:"python_projects_count"` SystemPackagesCount int `json:"system_packages_count"` + RuntimesCount int `json:"runtimes_count"` AgentSkillsCount int `json:"agent_skills_count"` AgentPluginsCount int `json:"agent_plugins_count"` } @@ -897,7 +900,8 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err fmt.Fprintln(os.Stderr) } - // System package scanning (Linux only — rpm, dpkg, pacman, apk, snap, flatpak) + // System package scanning: Linux (rpm, dpkg, pacman, apk, snap, flatpak) + // and Windows (ARP registry, choco, scoop, winget portable). var systemPackageScans []model.SystemPackageScanResult if exec.GOOS() == model.PlatformLinux { @@ -956,8 +960,37 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err fmt.Fprintln(os.Stderr) endPhase(phaseCtx, phaseCancel, tracker, log, "syspkg_scan") postPhase() + } else if exec.GOOS() == model.PlatformWindows { + phaseCtx, phaseCancel = startPhase(ctx, tracker, "syspkg_scan") + log.Progress("Detecting system packages...") + systemPackageScans = detector.NewWindowsPkgDetector(exec).Scan(phaseCtx) + for _, s := range systemPackageScans { + log.Progress(" %s: %d packages in %dms", s.ScanType, s.PackagesCount, s.ScanDurationMs) + } + fmt.Fprintln(os.Stderr) + endPhase(phaseCtx, phaseCancel, tracker, log, "syspkg_scan") + postPhase() + } else { + log.Progress("System package scanning: skipped (macOS uses Homebrew)") + fmt.Fprintln(os.Stderr) + } + + // Language runtime inventory (all platforms): every node/python/php/java/ + // go/ruby/deno/bun/dotnet install on disk, however it was installed. + var runtimeScan *model.RuntimeScanResult + if cfg.EnableRuntimeScan == nil || *cfg.EnableRuntimeScan { + phaseCtx, phaseCancel = startPhase(ctx, tracker, "runtime_scan") + log.Progress("Inventorying language runtimes...") + runtimeScan = runtimeinv.ScanResult(phaseCtx, exec) + log.Progress(" Found %d runtime installs in %dms", runtimeScan.RuntimesCount, runtimeScan.ScanDurationMs) + if runtimeScan.Truncated { + log.Progress(" Runtime walk stopped early (entry budget or deadline); results are partial") + } + fmt.Fprintln(os.Stderr) + endPhase(phaseCtx, phaseCancel, tracker, log, "runtime_scan") + postPhase() } else { - log.Progress("System package scanning: skipped (non-Linux)") + log.Progress("Runtime inventory is DISABLED") fmt.Fprintln(os.Stderr) } @@ -1304,6 +1337,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err PythonProjectsRemoved: pyRemovedRefs, PythonGlobalsUnchanged: pyGlobalsUnchangedRefs, SystemPackageScans: systemPackageScans, + RuntimeScan: runtimeScan, AIAgents: allAI, MCPConfigs: mcpConfigs, NPMRCAudit: &npmrcAudit, @@ -1344,6 +1378,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err PythonGlobalPkgsCount: len(pythonGlobalPkgs), PythonProjectsCount: len(pythonProjects), SystemPackagesCount: totalSystemPackagesCount(systemPackageScans), + RuntimesCount: runtimeScanCount(runtimeScan), AgentSkillsCount: len(agentSkills), AgentPluginsCount: skillsResult.Plugins.PluginCount(), }, @@ -1487,6 +1522,13 @@ func brewCasksCount(scans []model.BrewScanResult) int { return 0 } +func runtimeScanCount(r *model.RuntimeScanResult) int { + if r == nil { + return 0 + } + return r.RuntimesCount +} + func totalSystemPackagesCount(scans []model.SystemPackageScanResult) int { total := 0 for _, s := range scans { From 089dfd359e50638bf9f290b0d0660e918b994a75 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Sat, 24 Oct 2026 16:20:52 +0530 Subject: [PATCH 2/7] fix(winpkg): bound ARP EstimatedSize to a DWORD before widening --- internal/detector/winpkg.go | 2 +- internal/detector/winpkg_arp_other.go | 5 +++-- internal/detector/winpkg_arp_windows.go | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/detector/winpkg.go b/internal/detector/winpkg.go index 19bfb5a8..21291c0d 100644 --- a/internal/detector/winpkg.go +++ b/internal/detector/winpkg.go @@ -24,7 +24,7 @@ type arpEntry struct { InstallLocation string InstallDate string // YYYYMMDD URLInfoAbout string - EstimatedSizeKB uint64 + EstimatedSizeKB uint32 // REG_DWORD SystemComponent bool WindowsInstaller bool ParentKeyName string diff --git a/internal/detector/winpkg_arp_other.go b/internal/detector/winpkg_arp_other.go index c787ebfb..a42d2790 100644 --- a/internal/detector/winpkg_arp_other.go +++ b/internal/detector/winpkg_arp_other.go @@ -4,6 +4,7 @@ package detector import ( "context" + "math" "regexp" "strconv" "strings" @@ -81,8 +82,8 @@ func parseRegQueryARP(stdout, hive, view string) []arpEntry { case "WindowsInstaller": cur.WindowsInstaller = typ == "REG_DWORD" && dword() == 1 case "EstimatedSize": - if typ == "REG_DWORD" { - cur.EstimatedSizeKB = dword() + if v := dword(); typ == "REG_DWORD" && v <= math.MaxUint32 { + cur.EstimatedSizeKB = uint32(v) } } } diff --git a/internal/detector/winpkg_arp_windows.go b/internal/detector/winpkg_arp_windows.go index 9caf4042..471bf230 100644 --- a/internal/detector/winpkg_arp_windows.go +++ b/internal/detector/winpkg_arp_windows.go @@ -4,6 +4,7 @@ package detector import ( "context" + "math" "github.com/step-security/dev-machine-guard/internal/executor" "golang.org/x/sys/windows/registry" @@ -65,8 +66,8 @@ func readUninstallKey(root registry.Key, hive, view string, access uint32) []arp if v, _, err := sk.GetIntegerValue("WindowsInstaller"); err == nil { e.WindowsInstaller = v == 1 } - if v, _, err := sk.GetIntegerValue("EstimatedSize"); err == nil { - e.EstimatedSizeKB = v + if v, _, err := sk.GetIntegerValue("EstimatedSize"); err == nil && v <= math.MaxUint32 { + e.EstimatedSizeKB = uint32(v) } _ = sk.Close() out = append(out, e) From 4f0448ce96e1197c9bec5781f55c27cc3f88aba5 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Sat, 24 Oct 2026 18:04:38 +0530 Subject: [PATCH 3/7] feat(runtimeinv): report per-root truncation and right-size walk budgets Report truncated_roots when only per-root budgets ran out, so the backend can replace installs outside those roots instead of merging everything. Give /Applications and Program Files a larger budget and walk only pnpm's nodejs dir, not its package store. --- internal/detector/runtimeinv/roots.go | 16 ++++--- internal/detector/runtimeinv/scanner.go | 45 +++++++++++++------- internal/detector/runtimeinv/scanner_test.go | 34 ++++++++++++--- internal/model/model.go | 3 +- 4 files changed, 69 insertions(+), 29 deletions(-) diff --git a/internal/detector/runtimeinv/roots.go b/internal/detector/runtimeinv/roots.go index aa9c7b72..8d56e34d 100644 --- a/internal/detector/runtimeinv/roots.go +++ b/internal/detector/runtimeinv/roots.go @@ -8,6 +8,10 @@ import ( // homeBudget is the entry budget of the generic home walk. const homeBudget = 1_000_000 +// appsBudget covers app install trees (/Applications, Program Files), which +// on developer machines routinely exceed the default per-root budget. +const appsBudget = 400_000 + // roots lists what to walk on this OS: a depth-capped walk of the user's // home and the machine install locations, plus deeper walks of known // version-manager / installer roots whose runtimes sit below the generic cap. @@ -42,7 +46,7 @@ func (s *Scanner) unixRoots(darwin bool) []walkRoot { {h(".local", "share", "fnm"), 6, nil, 0}, {h(".fnm"), 6, nil, 0}, {h(".local", "share", "mise", "installs"), 5, nil, 0}, - {h(".local", "share", "pnpm"), 6, nil, 0}, + {h(".local", "share", "pnpm", "nodejs"), 3, nil, 0}, // `pnpm env` installs; the store beside it is a dependency store {h(".local", "share", "uv", "python"), 4, nil, 0}, {h(".asdf", "installs"), 5, nil, 0}, {h(".pyenv", "versions"), 3, nil, 0}, @@ -71,13 +75,13 @@ func (s *Scanner) unixRoots(darwin bool) []walkRoot { if darwin { roots = append(roots, walkRoot{h("Library", "Application Support", "fnm"), 6, nil, 0}, - walkRoot{h("Library", "pnpm"), 6, nil, 0}, + walkRoot{h("Library", "pnpm", "nodejs"), 3, nil, 0}, walkRoot{h("Library", "Java", "JavaVirtualMachines"), 5, nil, 0}, walkRoot{h("Library", "Application Support", "JetBrains", "Toolbox", "apps"), 8, nil, 0}, walkRoot{"/Library/Java/JavaVirtualMachines", 5, nil, 0}, walkRoot{"/Library/Frameworks/Python.framework/Versions", 3, nil, 0}, walkRoot{"/Library/Developer/CommandLineTools/usr/bin", 0, nil, 0}, - walkRoot{"/Applications", 7, nil, 0}, + walkRoot{"/Applications", 7, nil, appsBudget}, ) } for _, kv := range []struct { @@ -123,15 +127,15 @@ func (s *Scanner) windowsRoots() []walkRoot { {join(appData, "fnm"), 5, nil, 0}, {join(localAppData, "fnm"), 5, nil, 0}, {join(localAppData, "Volta"), 7, nil, 0}, - {join(localAppData, "pnpm"), 5, nil, 0}, + {join(localAppData, "pnpm", "nodejs"), 3, nil, 0}, {join(localAppData, "Programs"), 5, nil, 0}, {join(localAppData, "Microsoft", "WinGet", "Packages"), 5, nil, 0}, {join(localAppData, "JetBrains", "Toolbox", "apps"), 6, nil, 0}, {h("go", "pkg", "mod", "golang.org"), 3, nil, 0}, // GOTOOLCHAIN downloads // Machine locations. - {programFiles, 5, []string{"windowsapps"}, 0}, - {programFilesX86, 5, nil, 0}, + {programFiles, 5, []string{"windowsapps"}, appsBudget}, + {programFilesX86, 5, nil, appsBudget}, {join(programFiles, "Microsoft Visual Studio"), 9, nil, 0}, {join(programFilesX86, "Microsoft Visual Studio"), 9, nil, 0}, {programData, 5, nil, 0}, diff --git a/internal/detector/runtimeinv/scanner.go b/internal/detector/runtimeinv/scanner.go index 2bdb892a..3a8a8041 100644 --- a/internal/detector/runtimeinv/scanner.go +++ b/internal/detector/runtimeinv/scanner.go @@ -56,11 +56,16 @@ type Scanner struct { entries int rootEntries int rootBudget int - truncated bool - walked map[string]int // dir -> remaining depth it was walked with - seenExe map[string]bool - seenRoot map[string]bool - out []model.RuntimeInstall + curRoot string + // whole is set when the scan stopped for a reason that affects every + // root (deadline, global cap); truncRoots lists roots that only ran out + // of their own budget. + whole bool + truncRoots []string + walked map[string]int // dir -> remaining depth it was walked with + seenExe map[string]bool + seenRoot map[string]bool + out []model.RuntimeInstall } // NewScanner returns a scanner for the given user home ("" = resolve from the @@ -75,9 +80,11 @@ func NewScanner(exec executor.Executor, home string) *Scanner { } // Scan walks all roots and returns the inventory, sorted by runtime then path. -// It stops early (truncated=true) on ctx cancellation or the entry cap. -func (s *Scanner) Scan(ctx context.Context) (runtimes []model.RuntimeInstall, truncated bool) { - s.entries, s.truncated = 0, false +// truncated reports an incomplete walk. truncatedRoots is non-empty only when +// every truncation was a per-root budget hit, so installs outside those roots +// are known to be complete; on a deadline or the global cap it is nil. +func (s *Scanner) Scan(ctx context.Context) (runtimes []model.RuntimeInstall, truncated bool, truncatedRoots []string) { + s.entries, s.whole, s.truncRoots = 0, false, nil s.walked = map[string]int{} s.seenExe = map[string]bool{} s.seenRoot = map[string]bool{} @@ -89,10 +96,10 @@ func (s *Scanner) Scan(ctx context.Context) (runtimes []model.RuntimeInstall, tr } for _, r := range roots { if ctx.Err() != nil || s.entries >= maxEntries { - s.truncated = true + s.whole = true break } - s.rootEntries, s.rootBudget = 0, r.budget + s.rootEntries, s.rootBudget, s.curRoot = 0, r.budget, r.path if s.rootBudget <= 0 { s.rootBudget = defaultRootBudget } @@ -107,7 +114,10 @@ func (s *Scanner) Scan(ctx context.Context) (runtimes []model.RuntimeInstall, tr } return s.out[i].Version < s.out[j].Version }) - return s.out, s.truncated + if s.whole { + return s.out, true, nil + } + return s.out, len(s.truncRoots) > 0, s.truncRoots } // walk visits a root breadth-first, so when the entry budget runs out it is @@ -142,14 +152,16 @@ func (s *Scanner) walk(ctx context.Context, root string, depth int, skipTop []st continue } for _, e := range ents { - if ctx.Err() != nil { - s.truncated = true + if ctx.Err() != nil || s.entries >= maxEntries { + s.whole = true return } s.entries++ s.rootEntries++ - if s.entries >= maxEntries || s.rootEntries >= s.rootBudget { - s.truncated = true + if s.rootEntries >= s.rootBudget { + if !contains(s.truncRoots, s.curRoot) { + s.truncRoots = append(s.truncRoots, s.curRoot) + } return } name := e.Name() @@ -318,7 +330,7 @@ func contains(list []string, s string) bool { // ScanResult runs a scan for the logged-in user and packages it for telemetry. func ScanResult(ctx context.Context, exec executor.Executor) *model.RuntimeScanResult { start := time.Now() - runtimes, truncated := NewScanner(exec, "").Scan(ctx) + runtimes, truncated, truncatedRoots := NewScanner(exec, "").Scan(ctx) if runtimes == nil { runtimes = []model.RuntimeInstall{} } @@ -326,6 +338,7 @@ func ScanResult(ctx context.Context, exec executor.Executor) *model.RuntimeScanR Runtimes: runtimes, RuntimesCount: len(runtimes), Truncated: truncated, + TruncatedRoots: truncatedRoots, ScanDurationMs: time.Since(start).Milliseconds(), } } diff --git a/internal/detector/runtimeinv/scanner_test.go b/internal/detector/runtimeinv/scanner_test.go index d3de879c..901f65aa 100644 --- a/internal/detector/runtimeinv/scanner_test.go +++ b/internal/detector/runtimeinv/scanner_test.go @@ -58,7 +58,7 @@ func (f *fixture) symlink(rel, target string) { func (f *fixture) scan(goos, home string, roots ...walkRoot) []model.RuntimeInstall { f.t.Helper() s := &Scanner{exec: executor.NewReal(), goos: goos, home: home, rootsOverride: roots} - out, truncated := s.Scan(context.Background()) + out, truncated, _ := s.Scan(context.Background()) if truncated { f.t.Fatal("unexpected truncation") } @@ -276,8 +276,9 @@ func TestScanCancelledIsTruncated(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() s := &Scanner{exec: executor.NewReal(), goos: "linux", rootsOverride: []walkRoot{{f.root, 3, nil, 0}}} - if _, truncated := s.Scan(ctx); !truncated { - t.Fatal("cancelled scan should report truncated") + _, truncated, roots := s.Scan(ctx) + if !truncated || roots != nil { + t.Fatalf("cancelled scan: truncated=%v roots=%v, want true and nil (whole scan partial)", truncated, roots) } } @@ -290,11 +291,32 @@ func TestScanBudgetPrefersShallow(t *testing.T) { } f.file("z/node", "", true) s := &Scanner{exec: executor.NewReal(), goos: "linux", rootsOverride: []walkRoot{{f.root, 5, nil, 20}}} - out, truncated := s.Scan(context.Background()) - if !truncated { - t.Fatal("expected budget truncation") + out, truncated, roots := s.Scan(context.Background()) + if !truncated || len(roots) != 1 || roots[0] != f.root { + t.Fatalf("budget truncation: truncated=%v roots=%v, want [%s]", truncated, roots, f.root) } if len(out) != 1 || out[0].ExecutablePath != f.path("z/node") { t.Fatalf("shallow node missed: %+v", out) } } + +// Only the root that exhausted its own budget is reported; a later root that +// completes is not, so the backend can replace installs outside it. +func TestScanTruncatedRootsListsOnlyExhaustedRoot(t *testing.T) { + f := newFixture(t) + for i := 0; i < 30; i++ { + f.file(fmt.Sprintf("big/d/f%02d", i), "", false) + } + f.file("small/bin/node", "", true) + s := &Scanner{exec: executor.NewReal(), goos: "linux", rootsOverride: []walkRoot{ + {f.path("big"), 3, nil, 10}, + {f.path("small"), 3, nil, 0}, + }} + out, truncated, roots := s.Scan(context.Background()) + if !truncated || len(roots) != 1 || roots[0] != f.path("big") { + t.Fatalf("truncated=%v roots=%v, want only %s", truncated, roots, f.path("big")) + } + if len(out) != 1 { + t.Fatalf("node under the complete root missed: %+v", out) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index ed71f51c..28c7297b 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -355,7 +355,8 @@ type RuntimeInstall struct { type RuntimeScanResult struct { Runtimes []RuntimeInstall `json:"runtimes"` RuntimesCount int `json:"runtimes_count"` - Truncated bool `json:"truncated,omitempty"` // walk stopped early (entry cap or deadline) + Truncated bool `json:"truncated,omitempty"` // walk stopped early (entry cap or deadline) + TruncatedRoots []string `json:"truncated_roots,omitempty"` // set only when every truncation was a per-root budget hit: installs outside these roots are complete Error string `json:"error,omitempty"` ScanDurationMs int64 `json:"scan_duration_ms"` } From d07a3d1fb50cb1fbb6c0406e900daaa7e7758746 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Sat, 24 Oct 2026 18:42:46 +0530 Subject: [PATCH 4/7] fix(runtimeinv): skip launchers and caches, read distro runtime versions From a Fedora end-to-end run: - skip version-manager launchers (links to a non-runtime binary, scripts, scoop .shim) and conda's pkgs cache - accept versioned distro binary names (node-22, ruby-mri) - read distro runtime versions from rpm/dpkg (version_source "pkg") and versioned executable names - read ruby's version from MAJOR/MINOR/TEENY when RUBY_PROGRAM_VERSION is a template - attribute ~/.volta, ~/sdk/go*, ~/.dotnet; mark runtimes inside another snap as bundled by it --- internal/detector/runtimeinv/attribution.go | 3 + .../detector/runtimeinv/attribution_test.go | 5 + .../detector/runtimeinv/e2e_findings_test.go | 129 ++++++++++++++++++ internal/detector/runtimeinv/misc.go | 38 ++++++ internal/detector/runtimeinv/scanner.go | 30 +++- internal/detector/runtimeinv/signatures.go | 16 ++- internal/detector/runtimeinv/version.go | 64 ++++++++- 7 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 internal/detector/runtimeinv/e2e_findings_test.go create mode 100644 internal/detector/runtimeinv/misc.go diff --git a/internal/detector/runtimeinv/attribution.go b/internal/detector/runtimeinv/attribution.go index 441f7c2e..dce128c5 100644 --- a/internal/detector/runtimeinv/attribution.go +++ b/internal/detector/runtimeinv/attribution.go @@ -18,6 +18,7 @@ var sourceRules = []struct{ fragment, source string }{ {"/appdata/roaming/nvm/", "nvm"}, // nvm-windows default root {"/fnm/node-versions/", "fnm"}, {"/volta/tools/image/", "volta"}, + {"/.volta/tools/image/", "volta"}, // ~/.volta on Linux/macOS {"/n/versions/", "n"}, {"/nodist/", "nodist"}, {"/pnpm/nodejs/", "pnpm"}, @@ -36,6 +37,8 @@ var sourceRules = []struct{ fragment, source string }{ {"/.rvm/rubies/", "rvm"}, {"/.goenv/versions/", "goenv"}, {"/golang.org/toolchain@", "go-toolchain"}, + {"/sdk/go1.", "go-dl"}, // golang.org/dl downloads (~/sdk/go1.x) + {"/.dotnet/", "dotnet-install"}, {"/cellar/", "brew"}, {"/caskroom/", "brew"}, {"/opt/homebrew/", "brew"}, diff --git a/internal/detector/runtimeinv/attribution_test.go b/internal/detector/runtimeinv/attribution_test.go index 38c4b2dd..9b0a5f8e 100644 --- a/internal/detector/runtimeinv/attribution_test.go +++ b/internal/detector/runtimeinv/attribution_test.go @@ -81,6 +81,11 @@ func TestMatchSignature(t *testing.T) { {"java", "darwin", "java"}, {"javac", "darwin", ""}, {"Python", "darwin", ""}, // framework library, not the interpreter + {"node-22", "linux", "node"}, + {"ruby-mri", "linux", "ruby"}, + {"ruby3.3", "linux", "ruby"}, + {"node-gyp", "linux", ""}, + {"rubocop", "linux", ""}, } for _, c := range cases { got := "" diff --git a/internal/detector/runtimeinv/e2e_findings_test.go b/internal/detector/runtimeinv/e2e_findings_test.go new file mode 100644 index 00000000..f314ac63 --- /dev/null +++ b/internal/detector/runtimeinv/e2e_findings_test.go @@ -0,0 +1,129 @@ +package runtimeinv + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// Regressions from the Fedora E2E run (ledger-e2e-linux.md F1–F6). + +func TestE2ELauncherAndCacheSkips(t *testing.T) { + f := newFixture(t) + home := f.path("home/dev") + // F4: volta's node is a link to volta-shim; pyenv/rbenv shims are scripts. + f.file("home/dev/.volta/bin/volta-shim", "\x7fELF", true) + f.symlink("home/dev/.volta/bin/node", "volta-shim") + f.file("home/dev/.volta/tools/image/node/20.18.0/bin/node", "\x7fELF", true) + f.file("home/dev/.pyenv/shims/python3", "#!/usr/bin/env bash\nexec pyenv exec python3 \"$@\"\n", true) + f.file("home/dev/.rbenv/shims/ruby", "#!/usr/bin/env bash\n", true) + // F5: conda's pkgs/ cache is skipped; its environments are not. + f.file("home/dev/miniconda3/conda-meta/history", "", false) + f.file("home/dev/miniconda3/bin/python3.12", "\x7fELF", true) + f.file("home/dev/miniconda3/pkgs/python-3.12.4-h1/bin/python3.12", "\x7fELF", true) + + out := f.scan("linux", home, walkRoot{home, 7, nil, 0}) + var paths []string + for _, r := range out { + paths = append(paths, r.ExecutablePath+" ("+r.Source+")") + } + if len(out) != 2 { + t.Fatalf("want volta image node + conda python only, got %v", paths) + } + for _, r := range out { + switch r.Runtime { + case "node": + if r.Source != "volta" || r.Version != "20.18.0" { // F1: ~/.volta attribution + t.Errorf("volta node = %+v", r) + } + case "python": + if r.Source != "conda" || r.Version != "3.12" { // versioned exe name + t.Errorf("conda python = %+v", r) + } + default: + t.Errorf("unexpected %+v", r) + } + } +} + +func TestRubyVersionFromConfigParts(t *testing.T) { + data := []byte(` CONFIG["MAJOR"] = "3" + CONFIG["MINOR"] = "3" + CONFIG["TEENY"] = "9" + CONFIG["RUBY_PROGRAM_VERSION"] = "$(MAJOR).$(MINOR).$(TEENY)" +`) + if got := rubyVersionFromConfig(data); got != "3.3.9" { // F3 + t.Fatalf("got %q", got) + } + if got := rubyVersionFromConfig([]byte(`CONFIG["RUBY_PROGRAM_VERSION"] = "2.6.10"`)); got != "2.6.10" { + t.Fatalf("literal: got %q", got) + } +} + +func TestPkgOwnerVersion(t *testing.T) { // F2 + rpm := executor.NewMock() + rpm.SetGOOS("linux") + rpm.SetCommand("22.11.0", "", 0, "rpm", "-qf", "--queryformat", "%{VERSION}", "/usr/bin/node") + if r, ok := pkgOwnerVersion(rpm, "/usr/bin/node", "/usr"); !ok || r.version != "22.11.0" || r.source != "pkg" { + t.Errorf("rpm: %+v %v", r, ok) + } + + deb := executor.NewMock() + deb.SetGOOS("linux") + deb.SetCommandError(context.DeadlineExceeded, "rpm", "-qf", "--queryformat", "%{VERSION}", "/usr/bin/python3.12") + deb.SetCommand("python3.12-minimal: /usr/bin/python3.12\n", "", 0, "dpkg-query", "-S", "/usr/bin/python3.12") + deb.SetCommand("1:3.12.3-1ubuntu0.1", "", 0, "dpkg-query", "-W", "-f=${Version}", "python3.12-minimal") + if r, ok := pkgOwnerVersion(deb, "/usr/bin/python3.12", "/usr"); !ok || r.version != "3.12.3" { + t.Errorf("dpkg: %+v %v", r, ok) + } + + // Not consulted outside system locations or off Linux. + if _, ok := pkgOwnerVersion(rpm, "/home/a/.nvm/versions/node/v18.0.0/bin/node", ""); ok { + t.Error("non-system path consulted the package db") + } + mac := executor.NewMock() + mac.SetGOOS("darwin") + if _, ok := pkgOwnerVersion(mac, "/usr/bin/ruby", "/usr"); ok { + t.Error("darwin consulted the package db") + } +} + +func TestSnapBundlerAndNewSources(t *testing.T) { // F5, F6 + if got := snapBundler("python", "/snap/core18/2846/usr/bin/python3.6"); got != "core18" { + t.Errorf("core18 python bundled_by = %q", got) + } + if got := snapBundler("python", "/var/lib/snapd/snap/core/17292/usr/bin/python3.5"); got != "core" { + t.Errorf("fedora snap mount bundled_by = %q", got) + } + if got := snapBundler("node", "/snap/node/9000/bin/node"); got != "" { + t.Errorf("node snap is standalone, got %q", got) + } + for path, want := range map[string]string{ + "/home/a/sdk/go1.22.12/bin/go": "go-dl", + "/home/a/.dotnet/dotnet": "dotnet-install", + "/home/a/.volta/tools/image/node/20.18.0/bin/node": "volta", + } { + if got := attributeSource(path, ""); got != want { + t.Errorf("attributeSource(%s) = %q, want %q", path, got, want) + } + } +} + +// Fedora: node is a link to the versioned node-22 binary; ruby is a wrapper +// script around ruby-mri. Both installs are reported once, via the real binary. +func TestE2EFedoraVersionedSystemBinaries(t *testing.T) { + f := newFixture(t) + f.file("usr/bin/node-22", "\x7fELF", true) + f.symlink("usr/bin/node", "node-22") + f.file("usr/bin/ruby", "#!/usr/bin/bash\nexec /usr/bin/ruby-mri \"$@\"\n", true) + f.file("usr/bin/ruby-mri", "\x7fELF", true) + out := f.scan("linux", "", walkRoot{f.path("usr/bin"), 0, nil, 0}) + got := map[string]string{} + for _, r := range out { + got[r.Runtime] = r.ExecutablePath + } + if len(out) != 2 || got["node"] != f.path("usr/bin/node-22") || got["ruby"] != f.path("usr/bin/ruby-mri") { + t.Fatalf("got %+v", out) + } +} diff --git a/internal/detector/runtimeinv/misc.go b/internal/detector/runtimeinv/misc.go new file mode 100644 index 00000000..1460abda --- /dev/null +++ b/internal/detector/runtimeinv/misc.go @@ -0,0 +1,38 @@ +package runtimeinv + +import ( + "os" + "regexp" + "strings" +) + +// snapRe matches a snap mount: /snap, or /var/lib/snapd/snap where /snap is +// a symlink to it (Fedora, Arch). +var snapRe = regexp.MustCompile(`^(?:/var/lib/snapd)?/snap/([^/]+)/`) + +// isScript reports a file starting with a shebang. +func isScript(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer func() { _ = f.Close() }() + b := make([]byte, 2) + n, _ := f.Read(b) + return n == 2 && string(b) == "#!" +} + +// snapBundler names the snap that privately ships a runtime: one under +// /snap// where is not that runtime's own snap (core18's +// python3 vs the node snap's node). +func snapBundler(runtime, real string) string { + m := snapRe.FindStringSubmatch(real) + if m == nil { + return "" + } + name := m[1] + if strings.HasPrefix(name, runtime) { + return "" + } + return name +} diff --git a/internal/detector/runtimeinv/scanner.go b/internal/detector/runtimeinv/scanner.go index 3a8a8041..8555ee36 100644 --- a/internal/detector/runtimeinv/scanner.go +++ b/internal/detector/runtimeinv/scanner.go @@ -171,7 +171,7 @@ func (s *Scanner) walk(ctx context.Context, root string, depth int, skipTop []st // resolve to directories the walk reaches directly. if e.IsDir() { lower := strings.ToLower(name) - if skipDirs[lower] || (it.top && contains(skipTop, lower)) || isGoModCache(it.dir, lower) { + if skipDirs[lower] || (it.top && contains(skipTop, lower)) || isGoModCache(it.dir, lower) || s.isCondaPkgCache(it.dir, lower) { continue } if it.depth > 0 { @@ -211,7 +211,7 @@ func (s *Scanner) consider(sig *signature, path string) { if s.goos != "windows" && info.Mode().Perm()&0o111 == 0 { return } - if s.isShim(real) { + if s.isShim(real) || s.isLauncher(sig, real) { return } exeKey := sig.runtime + "|" + s.pathKey(real) @@ -237,6 +237,9 @@ func (s *Scanner) consider(sig *signature, path string) { BundledBy: bundledBy(real), Arch: binaryArch(real), } + if base.BundledBy == "" { + base.BundledBy = snapBundler(sig.runtime, real) + } if base.BundledBy != "" { base.Source = "bundled" } @@ -273,6 +276,23 @@ func (s *Scanner) add(r model.RuntimeInstall) { s.out = append(s.out, r) } +// isLauncher reports version-manager shims and wrappers, which select a +// runtime rather than being one: +// - a link whose target is not itself a runtime binary (volta's +// node -> volta-shim); +// - a script (pyenv/rbenv/asdf shims are shell scripts; runtimes are +// compiled binaries); +// - a Scoop shim, marked by a sibling .shim file. +func (s *Scanner) isLauncher(sig *signature, real string) bool { + if t := matchSignature(filepath.Base(real), s.goos); t == nil || t.runtime != sig.runtime { + return true + } + if s.goos == "windows" { + return s.exec.FileExists(strings.TrimSuffix(real, filepath.Ext(real)) + ".shim") + } + return isScript(real) +} + // isShim reports binaries that are launchers rather than runtimes: the macOS // /usr/bin stubs that prompt for the Command Line Tools / a JDK. func (s *Scanner) isShim(real string) bool { @@ -311,6 +331,12 @@ func installRoot(exe string) string { return d } +// isCondaPkgCache reports a conda install's pkgs/ dir: extracted package +// tarballs (a dependency store), not environments. +func (s *Scanner) isCondaPkgCache(parent, lowerName string) bool { + return lowerName == "pkgs" && s.exec.DirExists(filepath.Join(parent, "conda-meta")) +} + // isGoModCache reports $GOPATH/pkg/mod: a dependency store of hundreds of // thousands of files. Go toolchains downloaded into it are walked as their // own known root (pkg/mod/golang.org). diff --git a/internal/detector/runtimeinv/signatures.go b/internal/detector/runtimeinv/signatures.go index 6bad253e..577062a4 100644 --- a/internal/detector/runtimeinv/signatures.go +++ b/internal/detector/runtimeinv/signatures.go @@ -22,6 +22,10 @@ type signature struct { var ( pythonExeRe = regexp.MustCompile(`^python([23](\.\d+)?)?$`) phpExeRe = regexp.MustCompile(`^php(\d+(\.\d+)?)?$`) + // Distros install versioned binaries behind the plain name: Fedora's + // node -> node-22, ruby (a wrapper script) -> ruby-mri. + nodeExeRe = regexp.MustCompile(`^node(js)?(-?\d+(\.\d+)*)?$`) + rubyExeRe = regexp.MustCompile(`^ruby(-mri|-?\d+(\.\d+)*)?$`) ) func exact(names ...string) func(string) bool { @@ -38,12 +42,12 @@ func exact(names ...string) func(string) bool { // signatures is the runtime table. Adding a runtime means adding a row here // (plus a version reader if it ships a version file). var signatures = []signature{ - {runtime: "node", matches: exact("node", "nodejs"), versions: []versionStrategy{nodeHeaderVersion, peVersionStrategy, pathVersionStrategy}}, - {runtime: "python", matches: pythonExeRe.MatchString, versions: []versionStrategy{pythonPatchlevelVersion, peVersionStrategy, pathVersionStrategy}}, - {runtime: "php", matches: phpExeRe.MatchString, versions: []versionStrategy{phpHeaderVersion, peVersionStrategy, pathVersionStrategy}}, - {runtime: "java", matches: exact("java"), versions: []versionStrategy{javaReleaseVersion, peVersionStrategy, pathVersionStrategy}}, - {runtime: "go", matches: exact("go"), versions: []versionStrategy{goVersionFile, pathVersionStrategy}}, - {runtime: "ruby", matches: exact("ruby"), versions: []versionStrategy{rubyConfigVersion, peVersionStrategy, pathVersionStrategy}}, + {runtime: "node", matches: nodeExeRe.MatchString, versions: []versionStrategy{nodeHeaderVersion, peVersionStrategy, pkgOwnerVersion, pathVersionStrategy}}, + {runtime: "python", matches: pythonExeRe.MatchString, versions: []versionStrategy{pythonPatchlevelVersion, peVersionStrategy, pkgOwnerVersion, pathVersionStrategy}}, + {runtime: "php", matches: phpExeRe.MatchString, versions: []versionStrategy{phpHeaderVersion, peVersionStrategy, pkgOwnerVersion, pathVersionStrategy}}, + {runtime: "java", matches: exact("java"), versions: []versionStrategy{javaReleaseVersion, peVersionStrategy, pkgOwnerVersion, pathVersionStrategy}}, + {runtime: "go", matches: exact("go"), versions: []versionStrategy{goVersionFile, pkgOwnerVersion, pathVersionStrategy}}, + {runtime: "ruby", matches: rubyExeRe.MatchString, versions: []versionStrategy{rubyConfigVersion, peVersionStrategy, pkgOwnerVersion, pathVersionStrategy}}, {runtime: "deno", matches: exact("deno"), versions: []versionStrategy{peVersionStrategy, pathVersionStrategy}}, {runtime: "bun", matches: exact("bun"), versions: []versionStrategy{peVersionStrategy, pathVersionStrategy}}, // dotnet is one host binary serving several side-by-side runtimes; the diff --git a/internal/detector/runtimeinv/version.go b/internal/detector/runtimeinv/version.go index d68f9ec7..ebb39c1c 100644 --- a/internal/detector/runtimeinv/version.go +++ b/internal/detector/runtimeinv/version.go @@ -1,9 +1,11 @@ package runtimeinv import ( + "context" "path/filepath" "regexp" "strings" + "time" "github.com/step-security/dev-machine-guard/internal/executor" ) @@ -13,6 +15,7 @@ const ( versionFromFile = "file" versionFromPE = "pe" versionFromPath = "path" + versionFromPkg = "pkg" // OS package database (rpm/dpkg) versionFromUnknown = "unknown" ) @@ -43,6 +46,7 @@ var ( cDefineRe = regexp.MustCompile(`(?m)^#define[ \t]+(\w+)[ \t]+"?([^"\s]+)"?`) keyValueRe = regexp.MustCompile(`(?m)^(\w+)="?([^"\r\n]*)"?`) rbConfigRe = regexp.MustCompile(`CONFIG\["RUBY_PROGRAM_VERSION"\]\s*=\s*"([^"]+)"`) + rbPartRe = regexp.MustCompile(`CONFIG\["(MAJOR|MINOR|TEENY)"\]\s*=\s*"(\d+)"`) numericRe = regexp.MustCompile(`^\d+(\.\d+)*`) pathVerRe = regexp.MustCompile(`(?i)^(?:[a-z]+[-_]?)?v?(\d+\.\d+(?:\.\d+){0,2})`) dotnetVerRe = regexp.MustCompile(`^\d+\.\d+\.\d+`) @@ -152,18 +156,74 @@ func rubyConfigVersion(exec executor.Executor, _, root string) (versionResult, b if err != nil { continue } - if m := rbConfigRe.FindSubmatch(data); m != nil { - return versionResult{version: string(m[1]), source: versionFromFile}, true + if v := rubyVersionFromConfig(data); v != "" { + return versionResult{version: v, source: versionFromFile}, true } } } return versionResult{}, false } +// rubyVersionFromConfig reads RUBY_PROGRAM_VERSION, which some builds (rbenv, +// ruby-build) leave as "$(MAJOR).$(MINOR).$(TEENY)"; then the parts are used. +func rubyVersionFromConfig(data []byte) string { + if m := rbConfigRe.FindSubmatch(data); m != nil && !strings.Contains(string(m[1]), "$(") { + return string(m[1]) + } + parts := map[string]string{} + for _, m := range rbPartRe.FindAllSubmatch(data, -1) { + parts[string(m[1])] = string(m[2]) + } + if parts["MAJOR"] == "" || parts["MINOR"] == "" || parts["TEENY"] == "" { + return "" + } + return parts["MAJOR"] + "." + parts["MINOR"] + "." + parts["TEENY"] +} + +// pkgOwnerVersion asks the Linux package database which package owns a +// distro-installed runtime: distro builds ship no version headers unless the +// -devel package is installed. It runs rpm / dpkg-query, never the runtime. +func pkgOwnerVersion(exec executor.Executor, exe, _ string) (versionResult, bool) { + if exec.GOOS() != "linux" || attributeSource(exe, "") != "system" { + return versionResult{}, false + } + ctx := context.Background() + const timeout = 5 * time.Second + if out, _, code, err := exec.RunWithTimeout(ctx, timeout, "rpm", "-qf", "--queryformat", "%{VERSION}", exe); err == nil && code == 0 { + if v := cleanVersion(out); v != "" { + return versionResult{version: v, source: versionFromPkg}, true + } + } + out, _, code, err := exec.RunWithTimeout(ctx, timeout, "dpkg-query", "-S", exe) + if err != nil || code != 0 { + return versionResult{}, false + } + pkg, _, ok := strings.Cut(strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]), ": ") + if !ok || pkg == "" || strings.Contains(pkg, ",") { // "diversion by" / multiple owners + return versionResult{}, false + } + out, _, code, err = exec.RunWithTimeout(ctx, timeout, "dpkg-query", "-W", "-f=${Version}", pkg) + if err != nil || code != 0 { + return versionResult{}, false + } + v := strings.TrimSpace(out) + if _, after, found := strings.Cut(v, ":"); found { // drop the epoch + v = after + } + if v = cleanVersion(v); v == "" { + return versionResult{}, false + } + return versionResult{version: v, source: versionFromPkg}, true +} + // pathVersionStrategy takes the nearest version-looking path component between // the executable and two levels above the install root: nvm's v18.20.0, // Cellar/node/22.1.0, php-8.3.1-Win32-vs16-x64, jdk-21.0.2+13. func pathVersionStrategy(_ executor.Executor, exe, root string) (versionResult, bool) { + // A versioned executable name (python3.13, php8.2) is the most specific. + if m := pathVerRe.FindStringSubmatch(strings.TrimSuffix(filepath.Base(exe), ".exe")); m != nil { + return versionResult{version: m[1], source: versionFromPath}, true + } stop := filepath.Dir(filepath.Dir(root)) for d := filepath.Dir(exe); ; d = filepath.Dir(d) { if m := pathVerRe.FindStringSubmatch(filepath.Base(d)); m != nil { From d49cdb92f6597920835e09146a9cbf50a8ce71fe Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Sat, 24 Oct 2026 18:48:57 +0530 Subject: [PATCH 5/7] chore(runtimeinv): annotate shebang probe for gosec --- internal/detector/runtimeinv/misc.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/detector/runtimeinv/misc.go b/internal/detector/runtimeinv/misc.go index 1460abda..67e4695f 100644 --- a/internal/detector/runtimeinv/misc.go +++ b/internal/detector/runtimeinv/misc.go @@ -12,6 +12,7 @@ var snapRe = regexp.MustCompile(`^(?:/var/lib/snapd)?/snap/([^/]+)/`) // isScript reports a file starting with a shebang. func isScript(path string) bool { + // #nosec G304 -- path is a binary found by the runtime walk; only its first two bytes are read. f, err := os.Open(path) if err != nil { return false From be101918a0ac90af1d4ba1be09baf1a991c2bad0 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Sat, 24 Oct 2026 19:32:52 +0530 Subject: [PATCH 6/7] fix(winpkg,runtimeinv): Windows E2E findings - scoop: read scoop-manifest.json / scoop-install.json (Scoop 0.6) with the old names as fallback - choco: vendor from nuspec authors, maintainer from owners; install time from the package dir, not the packaged nuspec - runtimes: skip version-manager shim directories (volta, scoop shims, chocolatey bin, pyenv/rbenv/asdf/mise shims) and the stdlib venv launcher templates; bundled detection covers Electron resources/app and Eclipse's justj JRE; walk winget portable packages deeper --- internal/detector/runtimeinv/attribution.go | 27 ++++++++++++- .../detector/runtimeinv/e2e_findings_test.go | 40 +++++++++++++++++++ internal/detector/runtimeinv/roots.go | 2 +- internal/detector/runtimeinv/scanner.go | 12 +++++- internal/detector/winpkg.go | 27 ++++++++++--- internal/detector/winpkg_test.go | 9 +++-- 6 files changed, 104 insertions(+), 13 deletions(-) diff --git a/internal/detector/runtimeinv/attribution.go b/internal/detector/runtimeinv/attribution.go index dce128c5..185d9259 100644 --- a/internal/detector/runtimeinv/attribution.go +++ b/internal/detector/runtimeinv/attribution.go @@ -99,9 +99,29 @@ var ( jbrRe = regexp.MustCompile(`(?i)[/\\]([^/\\]+)[/\\]jbr[/\\]`) vsNodeRe = regexp.MustCompile(`(?i)[/\\]microsoft visual studio[/\\]`) adobeRe = regexp.MustCompile(`(?i)[/\\]adobe[/\\]`) - asarUnpackRe = regexp.MustCompile(`(?i)[/\\]([^/\\]+)[/\\]resources[/\\]app\.asar\.unpacked[/\\]`) + electronRe = regexp.MustCompile(`(?i)[/\\]([^/\\]+)[/\\]resources[/\\]app(?:\.asar\.unpacked)?[/\\]`) + eclipseJreRe = regexp.MustCompile(`(?i)[/\\]plugins[/\\]org\.eclipse\.justj\.`) ) +// launcherDirs are version-manager shim directories: everything in them +// selects a runtime rather than being one. On Windows shims are copies of a +// launcher exe, so they cannot be told apart from runtimes by content. +var launcherDirs = []string{ + "/scoop/shims/", "/chocolatey/bin/", "/program files/volta/", "/volta/bin/", "/.volta/bin/", + "/.pyenv/shims/", "/pyenv-win/shims/", "/.rbenv/shims/", "/.asdf/shims/", "/mise/shims/", "/nodist/bin/", +} + +// inLauncherDir reports a path directly inside a launcher directory. +func inLauncherDir(realPath string) bool { + dir := normPath(realPath[:max(0, strings.LastIndexAny(realPath, `/\`))]) + for _, d := range launcherDirs { + if strings.HasSuffix(dir, d) { + return true + } + } + return false +} + // bundledBy names the application that privately ships this runtime, or "" // for a standalone install. Bundled runtimes are inventoried but are not // installs a developer manages. @@ -124,8 +144,11 @@ func bundledBy(realPath string) string { if adobeRe.MatchString(realPath) { return "Adobe" } - if m := asarUnpackRe.FindStringSubmatch(realPath); m != nil { + if m := electronRe.FindStringSubmatch(realPath); m != nil { return m[1] } + if eclipseJreRe.MatchString(realPath) { + return "Eclipse" + } return "" } diff --git a/internal/detector/runtimeinv/e2e_findings_test.go b/internal/detector/runtimeinv/e2e_findings_test.go index f314ac63..381374ee 100644 --- a/internal/detector/runtimeinv/e2e_findings_test.go +++ b/internal/detector/runtimeinv/e2e_findings_test.go @@ -127,3 +127,43 @@ func TestE2EFedoraVersionedSystemBinaries(t *testing.T) { t.Fatalf("got %+v", out) } } + +// Regressions from the Windows E2E run (ledger-e2e-windows.md F2–F4, F6). +func TestE2EWindowsLaunchersTemplatesBundled(t *testing.T) { + f := newFixture(t) + orig := peVersion + t.Cleanup(func() { peVersion = orig }) + peVersion = func(string) string { return "" } + + f.file("Program Files/Volta/node.exe", "MZ", false) // volta shim copy + f.file("Program Files/Volta/volta-shim.exe", "MZ", false) + f.file("Users/dev/scoop/shims/php.exe", "MZ", false) // scoop shim + f.file("Users/dev/scoop/shims/php.shim", "path = x", false) + f.file("Python312/python.exe", "MZ", false) // real install + f.file("Python312/Lib/venv/scripts/nt/python.exe", "MZ", false) // stdlib template + f.file("Users/dev/AppData/Local/Programs/cursor/resources/app/resources/helpers/node.exe", "MZ", false) + f.file("Users/dev/AppData/Local/Microsoft/WinGet/Packages/Eclipse/eclipse/plugins/org.eclipse.justj.openjdk.hotspot.jre.full.win32.x86_64_21.0.5/jre/bin/java.exe", "MZ", false) + + out := f.scan("windows", f.path("Users/dev"), + walkRoot{f.path("Program Files"), 5, nil, 0}, + walkRoot{f.path("Users/dev"), 12, nil, 0}, + walkRoot{f.path("Python312"), 6, nil, 0}, + ) + got := map[string]string{} + for _, r := range out { + got[r.ExecutablePath] = r.BundledBy + } + want := map[string]string{ + f.path("Python312/python.exe"): "", + f.path("Users/dev/AppData/Local/Programs/cursor/resources/app/resources/helpers/node.exe"): "cursor", + f.path("Users/dev/AppData/Local/Microsoft/WinGet/Packages/Eclipse/eclipse/plugins/org.eclipse.justj.openjdk.hotspot.jre.full.win32.x86_64_21.0.5/jre/bin/java.exe"): "Eclipse", + } + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for p, b := range want { + if gb, ok := got[p]; !ok || gb != b { + t.Errorf("%s: bundled_by=%q present=%v, want %q", p, gb, ok, b) + } + } +} diff --git a/internal/detector/runtimeinv/roots.go b/internal/detector/runtimeinv/roots.go index 8d56e34d..8e5be1dd 100644 --- a/internal/detector/runtimeinv/roots.go +++ b/internal/detector/runtimeinv/roots.go @@ -129,7 +129,7 @@ func (s *Scanner) windowsRoots() []walkRoot { {join(localAppData, "Volta"), 7, nil, 0}, {join(localAppData, "pnpm", "nodejs"), 3, nil, 0}, {join(localAppData, "Programs"), 5, nil, 0}, - {join(localAppData, "Microsoft", "WinGet", "Packages"), 5, nil, 0}, + {join(localAppData, "Microsoft", "WinGet", "Packages"), 9, nil, 0}, // portable apps nest bundled runtimes deep (Eclipse justj JRE) {join(localAppData, "JetBrains", "Toolbox", "apps"), 6, nil, 0}, {h("go", "pkg", "mod", "golang.org"), 3, nil, 0}, // GOTOOLCHAIN downloads diff --git a/internal/detector/runtimeinv/scanner.go b/internal/detector/runtimeinv/scanner.go index 8555ee36..5eef5a81 100644 --- a/internal/detector/runtimeinv/scanner.go +++ b/internal/detector/runtimeinv/scanner.go @@ -171,7 +171,7 @@ func (s *Scanner) walk(ctx context.Context, root string, depth int, skipTop []st // resolve to directories the walk reaches directly. if e.IsDir() { lower := strings.ToLower(name) - if skipDirs[lower] || (it.top && contains(skipTop, lower)) || isGoModCache(it.dir, lower) || s.isCondaPkgCache(it.dir, lower) { + if skipDirs[lower] || (it.top && contains(skipTop, lower)) || isGoModCache(it.dir, lower) || s.isCondaPkgCache(it.dir, lower) || isVenvTemplates(it.dir, lower) { continue } if it.depth > 0 { @@ -211,7 +211,7 @@ func (s *Scanner) consider(sig *signature, path string) { if s.goos != "windows" && info.Mode().Perm()&0o111 == 0 { return } - if s.isShim(real) || s.isLauncher(sig, real) { + if s.isShim(real) || inLauncherDir(real) || s.isLauncher(sig, real) { return } exeKey := sig.runtime + "|" + s.pathKey(real) @@ -331,6 +331,14 @@ func installRoot(exe string) string { return d } +// isVenvTemplates reports the stdlib venv package's scripts dir +// (Lib\venv\scripts\nt\python.exe on Windows): launcher templates copied +// into new virtualenvs, not an install. +func isVenvTemplates(parent, lowerName string) bool { + return lowerName == "scripts" && strings.EqualFold(filepath.Base(parent), "venv") && + strings.EqualFold(filepath.Base(filepath.Dir(parent)), "lib") +} + // isCondaPkgCache reports a conda install's pkgs/ dir: extracted package // tarballs (a dependency store), not environments. func (s *Scanner) isCondaPkgCache(parent, lowerName string) bool { diff --git a/internal/detector/winpkg.go b/internal/detector/winpkg.go index 21291c0d..fb5ef0c3 100644 --- a/internal/detector/winpkg.go +++ b/internal/detector/winpkg.go @@ -181,7 +181,8 @@ type nuspec struct { Metadata struct { ID string `xml:"id"` Version string `xml:"version"` - Authors string `xml:"authors"` + Authors string `xml:"authors"` // the software's vendor + Owners string `xml:"owners"` // the Chocolatey package maintainers ProjectURL string `xml:"projectUrl"` License string `xml:"licenseUrl"` } `xml:"metadata"` @@ -213,14 +214,17 @@ func (d *WindowsPkgDetector) chocoPackages(root string) []model.SystemPackage { p := model.SystemPackage{ Name: n.Metadata.ID, Version: n.Metadata.Version, - Maintainer: n.Metadata.Authors, + Vendor: n.Metadata.Authors, + Maintainer: n.Metadata.Owners, URL: n.Metadata.ProjectURL, License: n.Metadata.License, InstallPath: dir, Source: "chocolatey", Scope: "machine", } - if info, err := d.exec.Stat(path); err == nil && !info.ModTime().IsZero() { + // The package dir is created at install; the nuspec keeps the + // timestamp it had inside the .nupkg. + if info, err := d.exec.Stat(dir); err == nil && !info.ModTime().IsZero() { p.InstallTimeUnix = info.ModTime().Unix() } out = append(out, p) @@ -250,7 +254,7 @@ func (d *WindowsPkgDetector) scoopPackages(root, scope string) []model.SystemPac var out []model.SystemPackage for _, e := range ents { current := filepath.Join(apps, e.Name(), "current") - data, err := d.exec.ReadFile(filepath.Join(current, "manifest.json")) + data, err := readFirst(d.exec, current, "scoop-manifest.json", "manifest.json") if err != nil { continue } @@ -269,7 +273,7 @@ func (d *WindowsPkgDetector) scoopPackages(root, scope string) []model.SystemPac if real, err := d.exec.EvalSymlinks(current); err == nil { p.InstallPath = real } - if data, err := d.exec.ReadFile(filepath.Join(current, "install.json")); err == nil { + if data, err := readFirst(d.exec, current, "scoop-install.json", "install.json"); err == nil { var inst scoopInstall if json.Unmarshal(data, &inst) == nil { p.Source = inst.Bucket @@ -281,6 +285,19 @@ func (d *WindowsPkgDetector) scoopPackages(root, scope string) []model.SystemPac return out } +// readFirst reads the first of names present in dir. Scoop 0.6 renamed +// manifest.json / install.json to scoop-manifest.json / scoop-install.json. +func readFirst(exec executor.Executor, dir string, names ...string) ([]byte, error) { + var err error + for _, n := range names { + var data []byte + if data, err = exec.ReadFile(filepath.Join(dir, n)); err == nil { + return data, nil + } + } + return nil, err +} + func scoopLicense(raw json.RawMessage) string { var s string if json.Unmarshal(raw, &s) == nil { diff --git a/internal/detector/winpkg_test.go b/internal/detector/winpkg_test.go index adaf0811..a47e91e9 100644 --- a/internal/detector/winpkg_test.go +++ b/internal/detector/winpkg_test.go @@ -129,6 +129,7 @@ func TestWindowsPkgChocoScoopWinget(t *testing.T) { nodejs.install 20.11.1 Node.js Foundation + chocolatey-community https://nodejs.org/ `)) @@ -141,7 +142,9 @@ func TestWindowsPkgChocoScoopWinget(t *testing.T) { m.SetFile("/C/Users/dev/scoop/apps/php/current/manifest.json", []byte(`{"version":"8.3.1","homepage":"https://php.net","license":"PHP-3.01"}`)) m.SetFile("/C/Users/dev/scoop/apps/php/current/install.json", []byte(`{"bucket":"main","architecture":"64bit"}`)) m.SetSymlink("/C/Users/dev/scoop/apps/php/current", "/C/Users/dev/scoop/apps/php/8.3.1") - m.SetFile("/C/Users/dev/scoop/apps/nodejs-lts/current/manifest.json", []byte(`{"version":"20.11.1","license":{"identifier":"MIT"}}`)) + // Scoop 0.6 names its files scoop-manifest.json / scoop-install.json. + m.SetFile("/C/Users/dev/scoop/apps/nodejs-lts/current/scoop-manifest.json", []byte(`{"version":"20.11.1","license":{"identifier":"MIT"}}`)) + m.SetFile("/C/Users/dev/scoop/apps/nodejs-lts/current/scoop-install.json", []byte(`{"bucket":"main"}`)) // winget portable. m.SetDirEntries("/C/Users/dev/AppData/Local/Microsoft/WinGet/Packages", []os.DirEntry{ executor.MockDirEntry("Schniz.fnm_Microsoft.Winget.Source_8wekyb3d8bbwe", true), @@ -151,7 +154,7 @@ func TestWindowsPkgChocoScoopWinget(t *testing.T) { choco := res["choco"] if len(choco.Packages) != 1 || choco.Packages[0].Name != "nodejs.install" || choco.Packages[0].Version != "20.11.1" || - choco.Packages[0].Maintainer != "Node.js Foundation" { + choco.Packages[0].Vendor != "Node.js Foundation" || choco.Packages[0].Maintainer != "chocolatey-community" { t.Errorf("choco = %+v", choco.Packages) } scoop := res["scoop"] @@ -166,7 +169,7 @@ func TestWindowsPkgChocoScoopWinget(t *testing.T) { t.Errorf("scoop php = %+v", p) } case "nodejs-lts": - if p.Version != "20.11.1" || p.License != "MIT" { + if p.Version != "20.11.1" || p.License != "MIT" || p.Source != "main" { t.Errorf("scoop node = %+v", p) } } From 1ba324640cfe1a4d5ebf208a650928d5ea67fe0b Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Sun, 25 Oct 2026 02:59:39 +0530 Subject: [PATCH 7/7] feat(runtimeinv): read node/deno/bun versions embedded in the binary When no version file exists (single-binary installs, VS Code Server's node), stream the executable for the runtime's own version string (nodejs.org release URL, Deno/x.y.z, Bun/x.y.z) and report it with version_source "binary". The binary is read, never executed; the scan is capped at 256 MiB and the highest match wins. --- internal/detector/runtimeinv/embedded.go | 105 ++++++++++++++++++ internal/detector/runtimeinv/embedded_test.go | 48 ++++++++ internal/detector/runtimeinv/signatures.go | 6 +- internal/model/model.go | 2 +- 4 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 internal/detector/runtimeinv/embedded.go create mode 100644 internal/detector/runtimeinv/embedded_test.go diff --git a/internal/detector/runtimeinv/embedded.go b/internal/detector/runtimeinv/embedded.go new file mode 100644 index 00000000..a2804a20 --- /dev/null +++ b/internal/detector/runtimeinv/embedded.go @@ -0,0 +1,105 @@ +package runtimeinv + +import ( + "bufio" + "io" + "os" + "regexp" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +const ( + versionFromBinary = "binary" // version string embedded in the executable + maxEmbeddedScan = 256 << 20 + embeddedChunk = 1 << 20 + embeddedOverlap = 128 // longer than any match, so a match split across chunks is still seen +) + +// Per-runtime version strings compiled into the executable. Bun and Deno +// also embed a Node compatibility version, so each runtime has its own +// pattern rather than a shared one. +var ( + nodeEmbeddedRe = regexp.MustCompile(`nodejs\.org/download/release/v(\d+\.\d+\.\d+)/`) + denoEmbeddedRe = regexp.MustCompile(`Deno/(\d+\.\d+\.\d+)`) + bunEmbeddedRe = regexp.MustCompile(`Bun/(\d+\.\d+\.\d+)`) +) + +// embeddedVersion returns a strategy that streams the executable (never runs +// it) for re. The highest match wins: Deno also carries an old Deno/0.x +// string from a dependency. +func embeddedVersion(re *regexp.Regexp) versionStrategy { + return func(_ executor.Executor, exe, _ string) (versionResult, bool) { + if v := scanEmbedded(exe, re); v != "" { + return versionResult{version: v, source: versionFromBinary}, true + } + return versionResult{}, false + } +} + +func scanEmbedded(path string, re *regexp.Regexp) string { + // #nosec G304 -- path is a runtime binary found by the walk; it is read, never executed. + f, err := os.Open(path) + if err != nil { + return "" + } + defer func() { _ = f.Close() }() + r := bufio.NewReaderSize(io.LimitReader(f, maxEmbeddedScan), embeddedChunk) + buf := make([]byte, 0, embeddedChunk+embeddedOverlap) + best := "" + chunk := make([]byte, embeddedChunk) + for { + n, err := io.ReadFull(r, chunk) + buf = append(buf, chunk[:n]...) + for _, m := range re.FindAllSubmatch(buf, -1) { + if v := string(m[1]); best == "" || versionLess(best, v) { + best = v + } + } + if err != nil { + return best + } + keep := buf[max(0, len(buf)-embeddedOverlap):] + buf = append(buf[:0], keep...) + } +} + +// versionLess compares dotted numeric versions. +func versionLess(a, b string) bool { + pa, pb := numericParts(a), numericParts(b) + for i := 0; i < max(len(pa), len(pb)); i++ { + var x, y int + if i < len(pa) { + x = pa[i] + } + if i < len(pb) { + y = pb[i] + } + if x != y { + return x < y + } + } + return false +} + +func numericParts(v string) []int { + var out []int + n, in := 0, false + for _, c := range v { + if c >= '0' && c <= '9' { + n, in = n*10+int(c-'0'), true + continue + } + if in { + out = append(out, n) + } + n, in = 0, false + if c != '.' { + break + } + } + if in { + out = append(out, n) + } + return out +} diff --git a/internal/detector/runtimeinv/embedded_test.go b/internal/detector/runtimeinv/embedded_test.go new file mode 100644 index 00000000..abfe927a --- /dev/null +++ b/internal/detector/runtimeinv/embedded_test.go @@ -0,0 +1,48 @@ +package runtimeinv + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func writeBin(t *testing.T, parts ...[]byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), "bin") + if err := os.WriteFile(p, bytes.Join(parts, nil), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestScanEmbedded(t *testing.T) { + pad := bytes.Repeat([]byte{0}, embeddedChunk-10) // puts the match across the first chunk boundary + node := writeBin(t, pad, []byte("https://nodejs.org/download/release/v22.21.1/node-v22.21.1.tar.gz")) + if got := scanEmbedded(node, nodeEmbeddedRe); got != "22.21.1" { + t.Errorf("node across chunk boundary = %q", got) + } + deno := writeBin(t, []byte("x Deno/0.82.0 y"), pad, []byte("ua Deno/2.9.8 z nodejs.org/download/release/v21.2.0/")) + if got := scanEmbedded(deno, denoEmbeddedRe); got != "2.9.8" { + t.Errorf("deno highest = %q", got) + } + bun := writeBin(t, []byte("Bun/1.3.6 nodejs.org/download/release/v24.3.0/")) + if got := scanEmbedded(bun, bunEmbeddedRe); got != "1.3.6" { + t.Errorf("bun = %q", got) + } + none := writeBin(t, []byte("no versions here")) + if got := scanEmbedded(none, nodeEmbeddedRe); got != "" { + t.Errorf("none = %q", got) + } +} + +func TestVersionLess(t *testing.T) { + for _, c := range []struct { + a, b string + want bool + }{{"0.82.0", "2.9.8", true}, {"8.2.9", "8.2.12", true}, {"2.9.8", "2.9.8", false}, {"1.10.0", "1.9.9", false}} { + if got := versionLess(c.a, c.b); got != c.want { + t.Errorf("versionLess(%s,%s)=%v", c.a, c.b, got) + } + } +} diff --git a/internal/detector/runtimeinv/signatures.go b/internal/detector/runtimeinv/signatures.go index 577062a4..3a9b5346 100644 --- a/internal/detector/runtimeinv/signatures.go +++ b/internal/detector/runtimeinv/signatures.go @@ -42,14 +42,14 @@ func exact(names ...string) func(string) bool { // signatures is the runtime table. Adding a runtime means adding a row here // (plus a version reader if it ships a version file). var signatures = []signature{ - {runtime: "node", matches: nodeExeRe.MatchString, versions: []versionStrategy{nodeHeaderVersion, peVersionStrategy, pkgOwnerVersion, pathVersionStrategy}}, + {runtime: "node", matches: nodeExeRe.MatchString, versions: []versionStrategy{nodeHeaderVersion, peVersionStrategy, pkgOwnerVersion, embeddedVersion(nodeEmbeddedRe), pathVersionStrategy}}, {runtime: "python", matches: pythonExeRe.MatchString, versions: []versionStrategy{pythonPatchlevelVersion, peVersionStrategy, pkgOwnerVersion, pathVersionStrategy}}, {runtime: "php", matches: phpExeRe.MatchString, versions: []versionStrategy{phpHeaderVersion, peVersionStrategy, pkgOwnerVersion, pathVersionStrategy}}, {runtime: "java", matches: exact("java"), versions: []versionStrategy{javaReleaseVersion, peVersionStrategy, pkgOwnerVersion, pathVersionStrategy}}, {runtime: "go", matches: exact("go"), versions: []versionStrategy{goVersionFile, pkgOwnerVersion, pathVersionStrategy}}, {runtime: "ruby", matches: rubyExeRe.MatchString, versions: []versionStrategy{rubyConfigVersion, peVersionStrategy, pkgOwnerVersion, pathVersionStrategy}}, - {runtime: "deno", matches: exact("deno"), versions: []versionStrategy{peVersionStrategy, pathVersionStrategy}}, - {runtime: "bun", matches: exact("bun"), versions: []versionStrategy{peVersionStrategy, pathVersionStrategy}}, + {runtime: "deno", matches: exact("deno"), versions: []versionStrategy{peVersionStrategy, embeddedVersion(denoEmbeddedRe), pathVersionStrategy}}, + {runtime: "bun", matches: exact("bun"), versions: []versionStrategy{peVersionStrategy, embeddedVersion(bunEmbeddedRe), pathVersionStrategy}}, // dotnet is one host binary serving several side-by-side runtimes; the // scanner expands it to one record per shared/Microsoft.NETCore.App/. {runtime: "dotnet", matches: exact("dotnet")}, diff --git a/internal/model/model.go b/internal/model/model.go index 28c7297b..624d080e 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -340,7 +340,7 @@ type SystemPackage struct { type RuntimeInstall struct { Runtime string `json:"runtime"` // node, python, php, java, go, ruby, deno, bun, dotnet Version string `json:"version"` // "unknown" when no strategy yielded one - VersionSource string `json:"version_source"` // file, pe, path, unknown + VersionSource string `json:"version_source"` // file, pe, pkg, binary, path, unknown ExecutablePath string `json:"executable_path"` // symlink-resolved path of the binary InstallRoot string `json:"install_root,omitempty"` // runtime home (parent of bin/, or the exe dir) Source string `json:"source"` // nvm, fnm, volta, brew, scoop, pyenv, sdkman, system, installer, manual, …