Skip to content
3 changes: 3 additions & 0 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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))
Expand Down
50 changes: 50 additions & 0 deletions internal/detector/runtimeinv/arch.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
154 changes: 154 additions & 0 deletions internal/detector/runtimeinv/attribution.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
99 changes: 99 additions & 0 deletions internal/detector/runtimeinv/attribution_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading