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..185d9259 --- /dev/null +++ b/internal/detector/runtimeinv/attribution.go @@ -0,0 +1,154 @@ +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"}, + {"/.volta/tools/image/", "volta"}, // ~/.volta on Linux/macOS + {"/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"}, + {"/sdk/go1.", "go-dl"}, // golang.org/dl downloads (~/sdk/go1.x) + {"/.dotnet/", "dotnet-install"}, + {"/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[/\\]`) + 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. +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 := electronRe.FindStringSubmatch(realPath); m != nil { + return m[1] + } + if eclipseJreRe.MatchString(realPath) { + return "Eclipse" + } + return "" +} diff --git a/internal/detector/runtimeinv/attribution_test.go b/internal/detector/runtimeinv/attribution_test.go new file mode 100644 index 00000000..9b0a5f8e --- /dev/null +++ b/internal/detector/runtimeinv/attribution_test.go @@ -0,0 +1,99 @@ +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 + {"node-22", "linux", "node"}, + {"ruby-mri", "linux", "ruby"}, + {"ruby3.3", "linux", "ruby"}, + {"node-gyp", "linux", ""}, + {"rubocop", "linux", ""}, + } + 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/e2e_findings_test.go b/internal/detector/runtimeinv/e2e_findings_test.go new file mode 100644 index 00000000..381374ee --- /dev/null +++ b/internal/detector/runtimeinv/e2e_findings_test.go @@ -0,0 +1,169 @@ +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) + } +} + +// 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/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/misc.go b/internal/detector/runtimeinv/misc.go new file mode 100644 index 00000000..67e4695f --- /dev/null +++ b/internal/detector/runtimeinv/misc.go @@ -0,0 +1,39 @@ +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 { + // #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 + } + 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/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..8e5be1dd --- /dev/null +++ b/internal/detector/runtimeinv/roots.go @@ -0,0 +1,171 @@ +package runtimeinv + +import ( + "path/filepath" + "strings" +) + +// 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. +// 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", "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}, + {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", "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, appsBudget}, + ) + } + 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", "nodejs"), 3, nil, 0}, + {join(localAppData, "Programs"), 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 + + // Machine locations. + {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}, + // 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..5eef5a81 --- /dev/null +++ b/internal/detector/runtimeinv/scanner.go @@ -0,0 +1,378 @@ +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 + 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 +// 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. +// 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{} + s.out = nil + + roots := s.rootsOverride + if roots == nil { + roots = s.roots() + } + for _, r := range roots { + if ctx.Err() != nil || s.entries >= maxEntries { + s.whole = true + break + } + s.rootEntries, s.rootBudget, s.curRoot = 0, r.budget, r.path + 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 + }) + 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 +// 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.entries >= maxEntries { + s.whole = true + return + } + s.entries++ + s.rootEntries++ + if s.rootEntries >= s.rootBudget { + if !contains(s.truncRoots, s.curRoot) { + s.truncRoots = append(s.truncRoots, s.curRoot) + } + 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) || s.isCondaPkgCache(it.dir, lower) || isVenvTemplates(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) || inLauncherDir(real) || s.isLauncher(sig, 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.BundledBy = snapBundler(sig.runtime, 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) +} + +// 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 { + 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 +} + +// 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 { + 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). +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, truncatedRoots := NewScanner(exec, "").Scan(ctx) + if runtimes == nil { + runtimes = []model.RuntimeInstall{} + } + return &model.RuntimeScanResult{ + 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 new file mode 100644 index 00000000..901f65aa --- /dev/null +++ b/internal/detector/runtimeinv/scanner_test.go @@ -0,0 +1,322 @@ +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}}} + _, 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) + } +} + +// 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, 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/detector/runtimeinv/signatures.go b/internal/detector/runtimeinv/signatures.go new file mode 100644 index 00000000..3a9b5346 --- /dev/null +++ b/internal/detector/runtimeinv/signatures.go @@ -0,0 +1,77 @@ +// 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+)?)?$`) + // 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 { + 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: 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, 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")}, +} + +// 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..ebb39c1c --- /dev/null +++ b/internal/detector/runtimeinv/version.go @@ -0,0 +1,272 @@ +package runtimeinv + +import ( + "context" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// Version sources reported in RuntimeInstall.VersionSource. +const ( + versionFromFile = "file" + versionFromPE = "pe" + versionFromPath = "path" + versionFromPkg = "pkg" // OS package database (rpm/dpkg) + 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*"([^"]+)"`) + 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+`) +) + +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 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 { + 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..fb5ef0c3 --- /dev/null +++ b/internal/detector/winpkg.go @@ -0,0 +1,358 @@ +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 uint32 // REG_DWORD + 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"` // the software's vendor + Owners string `xml:"owners"` // the Chocolatey package maintainers + 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, + Vendor: n.Metadata.Authors, + Maintainer: n.Metadata.Owners, + URL: n.Metadata.ProjectURL, + License: n.Metadata.License, + InstallPath: dir, + Source: "chocolatey", + Scope: "machine", + } + // 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) + } + 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 := readFirst(d.exec, current, "scoop-manifest.json", "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 := readFirst(d.exec, current, "scoop-install.json", "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 +} + +// 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 { + 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..a42d2790 --- /dev/null +++ b/internal/detector/winpkg_arp_other.go @@ -0,0 +1,99 @@ +//go:build !windows + +package detector + +import ( + "context" + "math" + "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 v := dword(); typ == "REG_DWORD" && v <= math.MaxUint32 { + cur.EstimatedSizeKB = uint32(v) + } + } + } + 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..471bf230 --- /dev/null +++ b/internal/detector/winpkg_arp_windows.go @@ -0,0 +1,76 @@ +//go:build windows + +package detector + +import ( + "context" + "math" + + "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 && v <= math.MaxUint32 { + e.EstimatedSizeKB = uint32(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..a47e91e9 --- /dev/null +++ b/internal/detector/winpkg_test.go @@ -0,0 +1,193 @@ +//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 + chocolatey-community + 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") + // 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), + }) + + 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].Vendor != "Node.js Foundation" || choco.Packages[0].Maintainer != "chocolatey-community" { + 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" || p.Source != "main" { + 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..624d080e 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,32 @@ 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, 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, … + 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) + 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"` +} + // BrewPackage represents a single installed Homebrew formula or cask. type BrewPackage struct { Name string `json:"name"` @@ -354,7 +389,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 {