From 0020fc06f5bac16ba15d5836d2ed8b67cbe72e0a Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Fri, 23 Oct 2026 00:48:33 +0530 Subject: [PATCH 1/6] feat(detector): add Ruby package inventory and RubyGems/Bundler config audit Adds passive Ruby inventory and RubyGems/Bundler configuration audit to enterprise telemetry as optional ruby_inventory and ruby_config_audit sections, collected in a new ruby_scan phase. Declared, locked, installed and cached evidence stay separate. Sources that are shared, unreadable, cancelled or budget-limited are partial so prior evidence is never retired on unproven absence. The collector never executes Ruby, Bundler, gemspecs or Gemfiles, never extracts archives, and reports credentials as configured only. --- README.md | 2 + SCAN_COVERAGE.md | 18 + internal/detector/composerscan_test.go | 13 + internal/detector/configaudit/rubyconfig.go | 1244 +++++++++++++++ .../detector/configaudit/rubyconfig_test.go | 339 ++++ internal/detector/fuzz_test.go | 58 + internal/detector/ruby_golden_test.go | 81 + internal/detector/rubymetadata.go | 1199 ++++++++++++++ internal/detector/rubymetadata_test.go | 375 +++++ internal/detector/rubyscan.go | 1373 ++++++++++++++++ internal/detector/rubyscan_test.go | 732 +++++++++ internal/executor/env_prefix_test.go | 5 + internal/executor/executor.go | 15 + internal/executor/mock.go | 13 + internal/executor/user_aware.go | 3 + internal/model/model.go | 114 ++ internal/model/ruby_golden_test.go | 72 + .../testdata/ruby_inventory_v1_golden.json | 1394 +++++++++++++++++ internal/telemetry/telemetry.go | 12 + internal/telemetry/telemetry_out_test.go | 29 + 20 files changed, 7091 insertions(+) create mode 100644 internal/detector/configaudit/rubyconfig.go create mode 100644 internal/detector/configaudit/rubyconfig_test.go create mode 100644 internal/detector/ruby_golden_test.go create mode 100644 internal/detector/rubymetadata.go create mode 100644 internal/detector/rubymetadata_test.go create mode 100644 internal/detector/rubyscan.go create mode 100644 internal/detector/rubyscan_test.go create mode 100644 internal/model/ruby_golden_test.go create mode 100644 internal/model/testdata/ruby_inventory_v1_golden.json diff --git a/README.md b/README.md index c15994ef..ab2326ee 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,8 @@ Composer auditing reads selected global `config.json`, root manifests and verifi NuGet auditing reads project, ancestor, user and machine `NuGet.Config` files plus verified process overrides. It reports package sources, source mapping, fallback folders, proxy, TLS and audit settings with credentials redacted, and records which configuration applied to each project and restore. It never executes `dotnet` or `nuget` or reads credential-provider output. See [.NET Packages](SCAN_COVERAGE.md#net-packages) for scope and limits. +Ruby auditing reads RubyGems `gemrc` files and Bundler global, project and process settings for the resolved developer. It reports package sources, mirrors, install and cache paths, proxy, TLS and checksum settings with credentials redacted, and records which configuration applied to each project. It never executes `ruby`, `gem` or `bundle`, and reads `~/.gem/credentials` for existence only. See [Ruby Packages](SCAN_COVERAGE.md#ruby-packages) for scope and limits. + Enterprise Device Policy can also set StepSecurity Secure Registry as the sole user-level Python index for pip and uv. It manages only the resolved developer's user configuration and shared StepSecurity `.netrc` entry, keeps pip and uv results independent, and restores owned settings on an explicit policy clear. Project files, virtual environments, system configuration, environment variables, direct URLs, and Poetry are not modified. ### Suspicious file detection diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 9564aff9..92ce40ad 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -344,6 +344,24 @@ Enterprise telemetry emits `nuget_inventory` and `nuget_config_audit` for the re **Limits and privacy.** Each metadata file is capped at 2 MiB and each `.nupkg` read at 32 MiB; discovery at 100,000 entries per root and depth 128; inventory at 50,000 records; audit at 256 KiB; combined sections at 16 MiB. Failures and limits carry partial coverage. URL credentials, queries and fragments are removed before telemetry; `ClearTextPassword`, `Password`, API keys and credential-provider arguments are never collected, and an environment-variable reference is not reported as a stored password. `project.json`, `.ridpackage.json`, `#i` script sources and `global.json` SDK resolution are outside v1 coverage. +## Ruby Packages + +Enterprise telemetry emits `ruby_inventory` and `ruby_config_audit` for the resolved developer. Collection is static: `ruby`, `gem`, `bundle`, Git, plugins, binstubs, Gemfiles and gemspecs are never executed, and no source is contacted. + +| Evidence | Source and meaning | +|---|---| +| Declared requirements | `gem` statements in `gems.rb` or `Gemfile` (with `eval_gemfile` and `gemspec` references) and dependency calls in project gemspecs, with literal requirements, groups, platforms and `source`/`git`/`path` origins. Only literal Ruby is read: a file with conditionals, interpolation, heredocs or other dynamic code is `unsupported_format`, and its lock is still read. | +| Locked packages | `Gemfile.lock`/`gems.locked` GEM, GIT and PATH specs with platform, Git revision, direct/transitive relation from `DEPENDENCIES` and recorded `CHECKSUMS` SHA-256 values. A lock no project selects is still reported, and its directory's project selection is `partial`. | +| Installed packages | `# stub:` headers of `specifications/*.gemspec` in each gem home, with package-directory presence; default gems (`specifications/default`) and Bundler Git checkouts. | +| Cached packages | Identity from `metadata.gz` (or `metadata`) inside `.gem` archives in gem-home caches, project `vendor/cache` and the Bundler user cache, without extracting files or decompressing `data.tar.gz`; serialized checkout gemspecs in `vendor/cache`. | +| Configuration | `GEMRC` files, `~/.gemrc` (or its XDG fallback), `/etc/gemrc` and interpreter `etc/gemrc` for RubyGems; process, global, project-local and manifest settings for Bundler, with allowlisted source, mirror, path, cache, lock, proxy, TLS and checksum settings per ambient and project context, and findings `ruby-001`…`ruby-004`. `~/.gem/credentials` is checked for existence only. Credentials are recorded as configured only. | + +**Discovery.** Gem homes come from rbenv, RVM, asdf, mise and chruby rubies, macOS system Ruby, Homebrew (macOS and Linux) and distribution prefixes, Windows RubyInstaller paths, `~/.gem`, the XDG data directory, verified `GEM_HOME`/`GEM_PATH` and gemrc `gemhome`/`gempath`. Projects come from the selected search roots with protected-path and network-volume guards, plus a verified `BUNDLE_GEMFILE`, which replaces the default manifest in its directory. A project's Bundler `path` follows Bundler's local, environment, global precedence. Gem homes, caches, `.bundle` and VCS directories are pruned from the walk; directory symlinks are not followed. An interpreter prefix that cannot be fully listed leaves the configuration audit partial, so an `etc/gemrc` it may hide keeps its history. + +**Meaning.** Declared, locked, installed and cached evidence stay independent when they disagree. Recorded checksums are never verified. Installed and cached records have `unknown` origin; a lock origin is a single GEM remote, a Git remote or a resolved path, otherwise `unknown`. A missing package directory changes presence only. An evaluated Gemfile or gemspec shared by several projects stays `partial` while any possible consumer is unread, unsupported or outside the walk. + +**Limits and privacy.** Each metadata file is capped at 2 MiB and each `.gem` read at 32 MiB; discovery at 100,000 entries per root and depth 128; inventory at 50,000 records; audit at 256 KiB; combined sections at 16 MiB. Failures and limits carry partial coverage. YAML tags and aliases are never instantiated, and a relevant Bundler config line that Bundler's line reader interprets differently from YAML (a comment after a quoted value, escapes, extra spacing) makes the file `unsupported`. URL credentials, queries and fragments are removed before telemetry; API keys, S3 secrets, host credential values and command option strings are never collected, and arbitrary `BUNDLE_*` variables are not read. Plugin sources, Homebrew formula inventory and other users' homes are outside v1 coverage. + ## System Package Scanning (Linux) System package scanning is **automatic on Linux** — no opt-in flag required. Multiple package managers can coexist. diff --git a/internal/detector/composerscan_test.go b/internal/detector/composerscan_test.go index aa642200..43ddfad9 100644 --- a/internal/detector/composerscan_test.go +++ b/internal/detector/composerscan_test.go @@ -49,6 +49,19 @@ func (x *composerTrap) HasEnvPrefix(prefix string) bool { } return false } +func (x *composerTrap) EnvNames(prefix string) []string { + if x.current.Uid != "1000" { + x.t.Error("read process environment names for a different developer") + } + names := []string{} + for k := range x.env { + if strings.HasPrefix(k, prefix) { + names = append(names, k) + } + } + slices.Sort(names) + return names +} func (x *composerTrap) Stat(path string) (os.FileInfo, error) { if err := x.denied[filepath.Clean(path)]; err != nil { return nil, err diff --git a/internal/detector/configaudit/rubyconfig.go b/internal/detector/configaudit/rubyconfig.go new file mode 100644 index 00000000..15012833 --- /dev/null +++ b/internal/detector/configaudit/rubyconfig.go @@ -0,0 +1,1244 @@ +package configaudit + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "maps" + "net/url" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "gopkg.in/yaml.v3" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +var maxRubyAuditBytes = 256 << 10 + +const rubyConfigBytes int64 = 2 << 20 +const rubySettingLimit = 256 + +var ( + rubyGemNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + rubyTokenPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + rubyHostPattern = regexp.MustCompile(`^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z][a-z0-9-]*(?::[0-9]+)?$`) + rubyIntPattern = regexp.MustCompile(`^[0-9]{1,9}$`) + rubyDecimalPattern = regexp.MustCompile(`^[0-9]{1,9}(?:\.[0-9]{1,9})?$`) +) + +// RubyGemName is the bounded package-name grammar, with at least one letter. +func RubyGemName(s string) bool { + return len(s) <= 256 && rubyGemNamePattern.MatchString(s) && strings.ContainsFunc(s, unicode.IsLetter) +} + +// RubyToken bounds artifact platforms and group/platform labels. +func RubyToken(s string) bool { return len(s) <= 256 && rubyTokenPattern.MatchString(s) } + +// RubyConfigScope comes from the resolved developer, never a login shell. +type RubyConfigScope struct { + Username, Home string + Protected func(string) string + Volume func(string) bool + ProcessVerified bool +} + +// RubyManifestSource is one literal Gemfile source. URL is raw and stays in +// memory; only its sanitized display is serialized. +type RubyManifestSource struct{ Type, URL, Cooldown string } + +// RubyManifest is a manifest already read by inventory, so this package +// never parses Ruby. Status uses the file-status vocabulary. +type RubyManifest struct { + Path, Status string + Reasons []string + Sources []RubyManifestSource + Lockfile string + NoLockfile bool +} + +// RubyProject is one Bundler project: its root manifest first, then +// manifests it evaluates. +type RubyProject struct { + Dir string + Manifests []RubyManifest +} + +// RubyProjectLeads are discovery locations, not a claimed effective config. +// An empty Path means Bundler uses shared gem homes found independently. +type RubyProjectLeads struct { + Path, CachePath, AppConfig, Lockfile string + Partial bool +} + +// RubyConfigSnapshot accumulates RubyGems and Bundler observations as +// inventory discovers interpreter prefixes and projects. +type RubyConfigSnapshot struct { + GemHomes []string + UserCache, UserPlugin string + Gemfile string + exec executor.Executor + ctx context.Context + scope RubyConfigScope + audit model.RubyConfigAudit + files map[string]*model.RubyConfigFile + parsed map[string]bool + env map[string]string + leads map[string]map[string]string + literal map[string]bool + homes map[string]bool + credentials map[string]bool + gemProcess, bundle *model.RubyConfigFile + userGemrc, systemrc []*model.RubyConfigFile + global *model.RubyConfigFile + ignoreConfig bool + partial bool + systemPartial bool // interpreter prefixes not fully discovered + bytes int + limited bool +} + +type RubyConfigDetector struct{ exec executor.Executor } + +func NewRubyConfigDetector(exec executor.Executor) *RubyConfigDetector { + return &RubyConfigDetector{exec: exec} +} + +func emptyRubyAudit() model.RubyConfigAudit { + return model.RubyConfigAudit{SchemaVersion: model.RubyInventorySchemaVersion, Status: "complete", Reasons: []string{}, Files: []model.RubyConfigFile{}, Contexts: []model.RubyConfigContext{}, CredentialFiles: []model.RubyCredentialFile{}, Findings: []model.CargoConfigFinding{}} +} + +func (d *RubyConfigDetector) Detect(ctx context.Context, scope RubyConfigScope) *RubyConfigSnapshot { + a := &RubyConfigSnapshot{exec: d.exec, ctx: ctx, scope: scope, audit: emptyRubyAudit(), files: map[string]*model.RubyConfigFile{}, parsed: map[string]bool{}, env: map[string]string{}, + leads: map[string]map[string]string{}, literal: map[string]bool{}, homes: map[string]bool{}, credentials: map[string]bool{}} + if scope.Username == "" || scope.Home == "" { + a.degrade("user_unresolved") + return a + } + if ctx.Err() != nil { + a.degrade("deadline_exceeded") + return a + } + home := scope.Home + a.gemProcess, a.bundle = a.record("rubygems", "process", ""), a.record("bundler", "process", "") + gemrc := []string{} + if scope.ProcessVerified { + gemrc = a.processEnv() + } else { + a.fileReason(a.gemProcess, "unsupported", "user_unresolved") + a.fileReason(a.bundle, "unsupported", "user_unresolved") + a.partial = true + } + // GEMRC files merge in list order, so the last one has highest precedence. + for _, p := range slices.Backward(gemrc) { + a.userGemrc = append(a.userGemrc, a.readFile("rubygems", "user", p, a.parseGemrc)) + } + user := a.readFile("rubygems", "user", filepath.Join(home, ".gemrc"), a.parseGemrc) + a.userGemrc = append(a.userGemrc, user) + // RubyGems falls back to XDG only when ~/.gemrc does not exist; an + // unreadable ~/.gemrc leaves both candidates possible. + if user == nil || user.Status == "absent" || user.Status == "unreadable" || user.Status == "skipped_protected" { + if user == nil || user.Status != "absent" { + a.partial = true + } + configHome := cmpOr(a.env["XDG_CONFIG_HOME"], filepath.Join(home, ".config")) + a.userGemrc = append(a.userGemrc, a.readFile("rubygems", "user", filepath.Join(configHome, "gem", "gemrc"), a.parseGemrc)) + } + sysconf := "/etc" + if d.exec.GOOS() == model.PlatformWindows { + sysconf = cmpOr(a.env["PROGRAMDATA"], `C:\ProgramData`) + } + a.systemrc = append(a.systemrc, a.readFile("rubygems", "system", filepath.Join(sysconf, "gemrc"), a.parseGemrc)) + userHome := cmpOr(a.env["BUNDLE_USER_HOME"], filepath.Join(home, ".bundle")) + global := filepath.Join(userHome, "config") + for _, key := range []string{"BUNDLE_USER_CONFIG", "BUNDLE_CONFIG"} { + if v := a.env[key]; v != "" { + global = v + } + } + a.global = a.readFile("bundler", "user", global, a.parseBundler) + a.UserCache = cmpOr(a.env["BUNDLE_USER_CACHE"], filepath.Join(userHome, "cache")) + a.UserPlugin = cmpOr(a.env["BUNDLE_USER_PLUGIN"], filepath.Join(userHome, "plugin")) + a.Gemfile = a.env["BUNDLE_GEMFILE"] + // Existence only; credential contents are never read. + if a.credential(filepath.Join(home, ".gem", "credentials")) == "absent" { + a.credential(filepath.Join(cmpOr(a.env["XDG_DATA_HOME"], filepath.Join(home, ".local", "share")), "gem", "credentials")) + } + return a +} + +func cmpOr(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +// Env returns a verified developer-process root selector as a safe absolute +// path, or "" when unset, unverified or unsafe. +func (a *RubyConfigSnapshot) Env(key string) string { return a.env[key] } + +func (a *RubyConfigSnapshot) key(p string) string { return GoPathKey(a.exec.GOOS(), p) } +func (a *RubyConfigSnapshot) degrade(reason string) { + a.audit.Status = "partial" + if !slices.Contains(a.audit.Reasons, reason) { + a.audit.Reasons = append(a.audit.Reasons, reason) + } +} +func (a *RubyConfigSnapshot) fileReason(f *model.RubyConfigFile, status, reason string) { + if f != nil { + f.Status = status + if !slices.Contains(f.Reasons, reason) { + f.Reasons = append(f.Reasons, reason) + } + } + a.degrade(reason) +} +func (a *RubyConfigSnapshot) unresolved() { + a.partial = true + a.degrade("path_unresolved") +} + +func rubySplitList(goos, v string) []string { + sep := ":" + if goos == model.PlatformWindows { + sep = ";" + } + return strings.Split(v, sep) +} + +// processEnv reads only allowlisted names. Root selectors are discovery +// leads; credential variables are reduced to presence. +func (a *RubyConfigSnapshot) processEnv() []string { + e := a.exec + for _, key := range []string{"GEM_VENDOR", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "RBENV_ROOT", "ASDF_DATA_DIR", "MISE_DATA_DIR", "BUNDLE_GEMFILE", "BUNDLE_CONFIG", "BUNDLE_USER_HOME", "BUNDLE_USER_CONFIG", "BUNDLE_USER_CACHE", "BUNDLE_USER_PLUGIN", "PROGRAMDATA"} { + if v := e.Getenv(key); v != "" { + if !filepath.IsAbs(v) || !composerSafeText(v, 4096) { + a.unresolved() + continue + } + a.env[key] = filepath.Clean(v) + } + } + // A relative app config resolves from each project, so keep it raw. + if v := e.Getenv("BUNDLE_APP_CONFIG"); v != "" { + if composerSafeText(v, 4096) { + a.env["BUNDLE_APP_CONFIG"] = v + } else { + a.unresolved() + } + } + if v := a.env["GEM_VENDOR"]; v != "" { + a.home(v) + } + f := a.gemProcess + if v := e.Getenv("GEM_HOME"); v != "" { + a.gemHome(f, v) + } + if v := e.Getenv("GEM_PATH"); v != "" { + a.gemPath(f, rubySplitList(e.GOOS(), v)) + } + for _, key := range []string{"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"} { + v := e.Getenv(key) + if v == "" { + continue + } + display, redacted := sanitizeComposerProxy(v) + if strings.EqualFold(key, "NO_PROXY") { + display, redacted = v, false + if !composerNoProxy(v) { + display, redacted = goRedacted, true + } + } + a.setting(f, strings.ToLower(key), display, redacted) + } + if e.Getenv("GEM_HOST_API_KEY") != "" { + a.setting(f, "auth_configured", "true", true) + } + gemrc := []string{} + if v := e.Getenv("GEMRC"); v != "" { + // GEMRC both selects and merges user files; which ones applied to a + // past invocation is not knowable from static files. + a.partial = true + for _, p := range rubySplitList(e.GOOS(), v) { + if p == "" { + continue + } + if !filepath.IsAbs(p) || !composerSafeText(p, 4096) { + a.unresolved() + continue + } + gemrc = append(gemrc, filepath.Clean(p)) + } + } + entries := map[string]string{} + for _, name := range e.EnvNames("BUNDLE_") { + // Windows names are case-insensitive; Bundler's keys are uppercase. + key := name + if e.GOOS() == model.PlatformWindows { + key = strings.ToUpper(name) + } + // Unknown names are never read: arbitrary keys can hold secrets. + if rubyBundleKnown(strings.TrimPrefix(key, "BUNDLE_")) { + entries[key] = e.Getenv(name) + } + } + a.bundleSettings(a.bundle, entries, true) + a.ignoreConfig = a.leads[a.bundle.SourceID]["ignore_config"] != "" + for _, p := range []*model.RubyConfigFile{a.gemProcess, a.bundle} { + if len(p.Settings) > 0 && p.Status == "absent" { + p.Status = "present" + } + } + return gemrc +} + +// home records a supported absolute gem-home lead once. +func (a *RubyConfigSnapshot) home(p string) { + p = filepath.Clean(p) + if !a.homes[a.key(p)] { + a.homes[a.key(p)] = true + a.GemHomes = append(a.GemHomes, p) + } +} + +func (a *RubyConfigSnapshot) gemHome(f *model.RubyConfigFile, v string) { + if !composerSafeText(v, 4096) { + a.fileReason(f, "invalid", "parse_error") + return + } + if strings.HasPrefix(v, "~/") || strings.HasPrefix(v, `~\`) { + v = filepath.Join(a.scope.Home, v[2:]) + } + if filepath.IsAbs(v) { + v = filepath.Clean(v) + a.home(v) + } else { + // A relative or dynamic value stays observed but unresolved. + a.unresolved() + } + display, redacted := SanitizeComposerURL(v) + a.setting(f, "gem_home", display, redacted) +} + +func (a *RubyConfigSnapshot) gemPath(f *model.RubyConfigFile, values []string) { + if len(values) > rubySettingLimit { + a.fileReason(f, "unsupported", "record_limit") + return + } + paths := []string{} + for _, v := range values { + if v == "" { + continue + } + if strings.HasPrefix(v, "~/") || strings.HasPrefix(v, `~\`) { + v = filepath.Join(a.scope.Home, v[2:]) + } + if !filepath.IsAbs(v) || !composerSafeText(v, 4096) { + a.unresolved() + continue + } + v = filepath.Clean(v) + if display, redacted := SanitizeComposerURL(v); !redacted { + paths = append(paths, display) + } + a.home(v) + } + encoded, _ := json.Marshal(paths) + a.setting(f, "gem_path", string(encoded), false) +} + +func (a *RubyConfigSnapshot) record(manager, scope, path string) *model.RubyConfigFile { + id := GoSourceID(a.scope.Username, "ruby_config_"+manager+"_"+scope, path) + if f := a.files[id]; f != nil { + return f + } + if a.limited || a.bytes+len(path)+256 > maxRubyAuditBytes { + a.limited = true + a.degrade("output_size_limit") + return nil + } + f := &model.RubyConfigFile{SourceID: id, Manager: manager, Scope: scope, Path: path, Status: "absent", Reasons: []string{}, Settings: []model.CargoConfigSetting{}} + a.files[id] = f + a.bytes += len(path) + 256 + return f +} + +// setting deduplicates identical observations, such as the same proxy in +// both spellings of a case-insensitive Windows environment. +func (a *RubyConfigSnapshot) setting(f *model.RubyConfigFile, key, value string, redacted bool) { + if f == nil { + return + } + if !composerSafeText(value, 4096) { + a.fileReason(f, "invalid", "parse_error") + return + } + if slices.ContainsFunc(f.Settings, func(s model.CargoConfigSetting) bool { return s.Key == key && s.Display == value }) { + return + } + if len(f.Settings) >= rubySettingLimit { + a.fileReason(f, "unsupported", "record_limit") + return + } + if a.bytes+len(key)+len(value)+128 > maxRubyAuditBytes { + a.limited = true + a.fileReason(f, "unsupported", "output_size_limit") + return + } + a.bytes += len(key) + len(value) + 128 + f.Settings = append(f.Settings, model.CargoConfigSetting{Key: key, Display: value, SourceID: f.SourceID, Redacted: redacted}) +} + +func (a *RubyConfigSnapshot) readFile(manager, scope, p string, parse func(*model.RubyConfigFile, []byte)) *model.RubyConfigFile { + if f := a.files[GoSourceID(a.scope.Username, "ruby_config_"+manager+"_"+scope, p)]; f != nil { + return f + } + f := a.record(manager, scope, p) + if f == nil { + return nil + } + if a.ctx.Err() != nil { + a.fileReason(f, "unreadable", "deadline_exceeded") + return f + } + files := a.exec.GuardedFiles([]string{p}, a.scope.Protected, rubyConfigBytes) + info, err := files.Stat(p) + if errors.Is(err, fs.ErrNotExist) { + return f + } + if err != nil { + a.readFailure(f, err, p) + return f + } + if !info.Mode().IsRegular() { + a.fileReason(f, "unsupported", "unsupported_entry") + return f + } + if info.Size() > rubyConfigBytes { + a.fileReason(f, "unreadable", "size_limit") + return f + } + data, err := files.ReadFile(p) + if errors.Is(err, fs.ErrNotExist) { + a.fileReason(f, "unreadable", "changed_during_scan") + return f + } + if err != nil { + a.readFailure(f, err, p) + return f + } + parse(f, data) + return f +} +func (a *RubyConfigSnapshot) readFailure(f *model.RubyConfigFile, err error, p string) { + reason := CargoReadReason(err, p, a.scope.Volume) + status := "unreadable" + if reason == "skipped_protected" || reason == "refused_network_volume" { + status = "skipped_protected" + } + a.fileReason(f, status, reason) +} + +// Prefixes observes each discovered interpreter's compiled sysconfdir gemrc. +// These are bounded candidates, not a claim that every runtime uses each one. +// An incomplete prefix search may have omitted a file, so the audit and the +// RubyGems context are partial; files that were read keep their settings. +func (a *RubyConfigSnapshot) Prefixes(prefixes, incomplete []string) { + if a.scope.Username == "" { + return + } + for _, reason := range incomplete { + a.systemPartial = true + a.degrade(reason) + } + for _, p := range prefixes { + f := a.readFile("rubygems", "system", filepath.Join(p, "etc", "gemrc"), a.parseGemrc) + if f != nil && !slices.Contains(a.systemrc, f) { + a.systemrc = append(a.systemrc, f) + } + } +} + +// rubyYAMLRoot returns root mapping pairs. A merge or aliased key could +// inject relevant settings, so either rejects the file. +func (a *RubyConfigSnapshot) yamlRoot(f *model.RubyConfigFile, data []byte) ([][2]*yaml.Node, bool) { + var doc yaml.Node + if !utf8.Valid(data) || yaml.Unmarshal(data, &doc) != nil { + a.fileReason(f, "invalid", "parse_error") + return nil, false + } + if len(doc.Content) == 0 { + return nil, true + } + root := doc.Content[0] + if root.Kind != yaml.MappingNode || root.Anchor != "" || root.Style&yaml.TaggedStyle != 0 { + a.fileReason(f, "invalid", "parse_error") + return nil, false + } + var out [][2]*yaml.Node + for i := 0; i+1 < len(root.Content); i += 2 { + k := root.Content[i] + if k.Kind != yaml.ScalarNode || k.Value == "<<" || k.Anchor != "" || k.Style&yaml.TaggedStyle != 0 { + a.fileReason(f, "unsupported", "unsupported_format") + return nil, false + } + out = append(out, [2]*yaml.Node{k, root.Content[i+1]}) + } + return out, true +} + +// rubyYAMLData accepts plain data only. The official line serializers never +// write anchors, aliases, explicit tags or block scalars, so those are not +// interpreted with full-YAML semantics. +func rubyYAMLData(n *yaml.Node, depth int) bool { + if n == nil || depth > 4 || n.Anchor != "" || n.Kind == yaml.AliasNode || n.Style&(yaml.TaggedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 { + return false + } + switch n.Kind { + case yaml.ScalarNode: + switch n.ShortTag() { + case "!!str", "!!int", "!!bool", "!!float", "!!null": + return true + } + return false + case yaml.SequenceNode, yaml.MappingNode: + for i, c := range n.Content { + if n.Kind == yaml.MappingNode && i%2 == 0 && (c.Kind != yaml.ScalarNode || c.Value == "<<") { + return false + } + if !rubyYAMLData(c, depth+1) { + return false + } + } + return true + } + return false +} + +func rubyScalar(n *yaml.Node) (string, bool) { + if n.Kind != yaml.ScalarNode { + return "", false + } + if n.ShortTag() == "!!null" { + return "", true + } + return n.Value, true +} + +var rubyGemrcKeys = []string{"sources", "gemhome", "gempath", "ssl_verify_mode", "ssl_ca_cert", "ssl_client_cert", "http_proxy", "rubygems_api_key", "s3_source"} + +// parseGemrc reads allowlisted keys in string or symbol spelling. Command +// option strings (gem/install/update) can hold secrets and are never read. +func (a *RubyConfigSnapshot) parseGemrc(f *model.RubyConfigFile, data []byte) { + pairs, ok := a.yamlRoot(f, data) + if !ok { + return + } + values := map[string]*yaml.Node{} + for _, kv := range pairs { + key := strings.TrimPrefix(kv[0].Value, ":") + if !slices.Contains(rubyGemrcKeys, key) { + continue + } + if values[key] != nil || !rubyYAMLData(kv[1], 0) { + a.fileReason(f, "unsupported", "unsupported_format") + return + } + values[key] = kv[1] + } + f.Status = "present" + invalid := func() { a.fileReason(f, "invalid", "parse_error") } + literal := false + if n := values["sources"]; n != nil { + if n.Kind != yaml.SequenceNode { + invalid() + } else if len(n.Content) > rubySettingLimit { + a.fileReason(f, "unsupported", "record_limit") + } else { + for i, item := range n.Content { + v, ok := rubyScalar(item) + if !ok || v == "" { + invalid() + continue + } + prefix := "sources." + strconv.Itoa(i) + "." + display, redacted := SanitizeRubyURL(v) + literal = literal || rubyURLCredential(v) + a.setting(f, prefix+"type", "registry", false) + a.setting(f, prefix+"url", display, redacted) + } + } + } + if n := values["gemhome"]; n != nil { + if v, ok := rubyScalar(n); ok && v != "" { + a.gemHome(f, v) + } else { + invalid() + } + } + if n := values["gempath"]; n != nil { + var list []string + if v, ok := rubyScalar(n); ok { + list = rubySplitList(a.exec.GOOS(), v) + } else if n.Kind == yaml.SequenceNode { + for _, item := range n.Content { + v, ok := rubyScalar(item) + if !ok { + invalid() + } + list = append(list, v) + } + } else { + invalid() + } + a.gemPath(f, list) + } + if n := values["ssl_verify_mode"]; n != nil { + if v, ok := rubyScalar(n); ok && rubyIntPattern.MatchString(v) { + i, _ := strconv.Atoi(v) + a.setting(f, "ssl_verify_mode", strconv.Itoa(i), false) + } else { + invalid() + } + } + for _, key := range []string{"ssl_ca_cert", "ssl_client_cert"} { + if n := values[key]; n != nil { + // Path only; certificate and key contents are never read. + if v, ok := rubyScalar(n); ok && v != "" && composerSafeText(v, 4096) { + display, redacted := SanitizeComposerURL(v) + a.setting(f, key, display, redacted) + } else { + invalid() + } + } + } + if n := values["http_proxy"]; n != nil { + v, ok := rubyScalar(n) + switch { + case !ok: + invalid() + case v == "" || strings.HasPrefix(v, ":"): + // `:no_proxy` disables the proxy; it is not an endpoint. + default: + display, redacted := sanitizeComposerProxy(v) + a.setting(f, "http_proxy", display, redacted) + if !strings.Contains(v, "://") { + v = "http://" + v + } + literal = literal || rubyURLCredential(v) + } + } + auth := false + if n := values["rubygems_api_key"]; n != nil { + if v, ok := rubyScalar(n); ok && v != "" { + auth, literal = true, true + } + } + if n := values["s3_source"]; n != nil { + if n.Kind != yaml.MappingNode { + invalid() + } else { + for i := 1; i < len(n.Content); i += 2 { + bucket := n.Content[i] + if bucket.Kind != yaml.MappingNode { + invalid() + continue + } + for j := 0; j+1 < len(bucket.Content); j += 2 { + v, ok := rubyScalar(bucket.Content[j+1]) + if !ok || v == "" { + continue + } + switch strings.TrimPrefix(bucket.Content[j].Value, ":") { + case "secret", "security_token": + auth, literal = true, true + case "provider": + // An external provider configures auth without an embedded secret. + if v == "env" || v == "instance_profile" { + auth = true + } + } + } + } + } + } + if auth || literal { + a.setting(f, "auth_configured", "true", true) + } + if literal { + a.literal[f.SourceID] = true + } +} + +func (a *RubyConfigSnapshot) parseBundler(f *model.RubyConfigFile, data []byte) { + pairs, ok := a.yamlRoot(f, data) + if !ok { + return + } + entries := map[string]string{} + at := map[int][2]string{} + for _, kv := range pairs { + key := kv[0].Value + if !strings.HasPrefix(key, "BUNDLE_") { + continue + } + v, scalar := rubyScalar(kv[1]) + if _, dup := entries[key]; dup || !scalar || !rubyYAMLData(kv[1], 0) || kv[1].Line != kv[0].Line { + a.fileReason(f, "unsupported", "unsupported_format") + return + } + entries[key] = v + at[kv[0].Line] = [2]string{key, v} + } + // Bundler reads this file line by line, not as YAML. A relevant line it + // reads differently (quoted comment, escape, extra space) is unsupported, + // so `"false" # comment`, which Bundler treats as true, never shows false. + for i, line := range strings.Split(string(data), "\n") { + key, value, ok := rubyBundlerLine(strings.TrimSuffix(line, "\r")) + got, parsed := at[i+1] + relevant := parsed && rubyBundleKnown(strings.TrimPrefix(got[0], "BUNDLE_")) || + ok && strings.HasPrefix(key, "BUNDLE_") && rubyBundleKnown(strings.TrimPrefix(key, "BUNDLE_")) + if relevant && (!ok || !parsed || got != [2]string{key, value}) { + a.fileReason(f, "unsupported", "unsupported_format") + return + } + } + f.Status = "present" + a.bundleSettings(f, entries, false) +} + +var ( + rubyBundleBool = map[string]string{"DEPLOYMENT": "deployment", "FROZEN": "frozen", "PATH__SYSTEM": "path.system", "CACHE_ALL": "cache_all", "CACHE_ALL_PLATFORMS": "cache_all_platforms", + "GLOBAL_GEM_CACHE": "global_gem_cache", "FORCE_RUBY_PLATFORM": "force_ruby_platform", "DISABLE_SHARED_GEMS": "disable_shared_gems", + "DISABLE_CHECKSUM_VALIDATION": "disable_checksum_validation", "LOCKFILE_CHECKSUMS": "lockfile_checksums"} + rubyBundlePath = map[string]string{"PATH": "path", "CACHE_PATH": "cache_path", "GEMFILE": "gemfile", "LOCKFILE": "lockfile", "SSL_CA_CERT": "ssl_ca_cert", "SSL_CLIENT_CERT": "ssl_client_cert"} + rubyBundleList = map[string]string{"WITH": "with", "WITHOUT": "without", "ONLY": "only"} + rubyBundleInt = map[string]string{"SSL_VERIFY_MODE": "ssl_verify_mode", "COOLDOWN": "cooldown"} +) + +// rubyBundlerLine mirrors one top-level line of Bundler's YAMLSerializer.load: +// HASH_REGEX with its greedy `#`-free key, one optional space, a quote pair +// only when the line ends with it, then strip_comment. An empty unquoted +// value loads as a nested hash, never a setting. +func rubyBundlerLine(line string) (key, value string, ok bool) { + rest := strings.TrimLeft(line, " ") + if (len(line)-len(rest))/2 != 0 { + return "", "", false + } + end := strings.IndexByte(rest, '#') + if end < 0 { + end = len(rest) + } + colon := -1 + for i := end - 1; i > 0 && colon < 0; i-- { + if rest[i] == ':' && (i+1 == len(rest) || strings.IndexByte(" \t\r\n\f\v", rest[i+1]) >= 0) { + colon = i + } + } + if colon < 0 { + return "", "", false + } + value = strings.TrimPrefix(rest[colon+1:], " ") + quoted := len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[len(value)-1] == value[0] + if quoted { + value = value[1 : len(value)-1] + } + if before, _, found := strings.Cut(value, "#"); found && !strings.HasPrefix(value, "#") { + value = strings.Trim(before, " \t\r\n\f\v\x00") + } + if !quoted && value == "" { + return "", "", false + } + return rest[:colon], value, true +} + +// rubyBundleDecode applies Bundler's key unescaping in upstream order. +func rubyBundleDecode(s string) string { + return strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(s, "___", "-"), "__", ".")) +} + +// rubyBundleCredential recognizes Bundler's per-host credential keys +// (GEMS__EXAMPLE__COM or a full source URI). Dotted families that are +// settings, not hosts, are excluded before the host test. +func rubyBundleCredential(name string) bool { + if strings.Contains(name, "://") { + return true + } + for _, family := range []string{"BUILD__", "GEM__", "MIRROR__", "LOCAL__", "PATH__"} { + if strings.HasPrefix(name, family) { + return false + } + } + return rubyHostPattern.MatchString(rubyBundleDecode(name)) +} + +func rubyBundleKnown(name string) bool { + return name == "IGNORE_CONFIG" || rubyBundleBool[name] != "" || rubyBundlePath[name] != "" || rubyBundleList[name] != "" || rubyBundleInt[name] != "" || + strings.HasPrefix(name, "MIRROR__") || strings.HasPrefix(name, "LOCAL__") || rubyBundleCredential(name) +} + +// rubyBundleBoolValue follows Bundler's boolean conversion for known keys. +func rubyBundleBoolValue(v string) string { + switch strings.ToLower(v) { + case "false", "f", "no", "n", "0", "": + return "false" + } + return "true" +} + +// bundleSettings classifies BUNDLE_* keys before decoding, so a host-shaped +// credential key or its value never becomes a display. +func (a *RubyConfigSnapshot) bundleSettings(f *model.RubyConfigFile, entries map[string]string, process bool) { + if f == nil { + return + } + leads := map[string]string{} + a.leads[f.SourceID] = leads + type mirror struct{ url, timeout string } + mirrors := map[string]*mirror{} + locals := map[string]string{} + invalid := func() { a.fileReason(f, "invalid", "parse_error") } + auth := false + for _, key := range slices.Sorted(maps.Keys(entries)) { + name, value := strings.TrimPrefix(key, "BUNDLE_"), entries[key] + switch { + case name == "IGNORE_CONFIG": + // Presence semantics: any value ignores config files. + leads["ignore_config"] = "true" + a.setting(f, "ignore_config", "true", false) + case rubyBundleBool[name] != "": + leads[rubyBundleBool[name]] = rubyBundleBoolValue(value) + a.setting(f, rubyBundleBool[name], rubyBundleBoolValue(value), false) + case rubyBundlePath[name] != "": + if value == "" || !composerSafeText(value, 4096) { + invalid() + continue + } + leads[rubyBundlePath[name]] = value + display, redacted := SanitizeComposerURL(value) + a.setting(f, rubyBundlePath[name], display, redacted) + case rubyBundleList[name] != "": + labels := []string{} + for _, l := range strings.FieldsFunc(value, func(r rune) bool { return r == ' ' || r == ':' }) { + if !RubyToken(l) { + invalid() + labels = nil + break + } + if !slices.Contains(labels, l) { + labels = append(labels, l) + } + } + if labels == nil { + continue + } + if len(labels) > rubySettingLimit { + a.fileReason(f, "unsupported", "record_limit") + continue + } + encoded, _ := json.Marshal(labels) + a.setting(f, rubyBundleList[name], string(encoded), false) + case rubyBundleInt[name] != "": + if !rubyIntPattern.MatchString(value) { + invalid() + continue + } + i, _ := strconv.Atoi(value) + a.setting(f, rubyBundleInt[name], strconv.Itoa(i), false) + case strings.HasPrefix(name, "MIRROR__"): + // mirror.[.fallback_timeout][/], per Bundler's MirrorConfig. + src := strings.TrimSuffix(rubyBundleDecode(strings.TrimPrefix(name, "MIRROR__")), "/") + src, timeout := strings.CutSuffix(src, ".fallback_timeout") + if strings.Contains(src, "://") && !strings.HasSuffix(src, "/") { + src += "/" + } + if rubyURLCredential(src) || rubyURLCredential(value) { + auth = true + } + m := mirrors[src] + if m == nil { + m = &mirror{} + mirrors[src] = m + } + if !timeout { + m.url = value + continue + } + switch v := strings.ToLower(value); { + case v == "true": + m.timeout = "0.1" + case v == "false": + m.timeout = "0" + case rubyDecimalPattern.MatchString(value): + m.timeout = value + default: + invalid() + } + case strings.HasPrefix(name, "LOCAL__"): + gem := rubyBundleDecode(strings.TrimPrefix(name, "LOCAL__")) + if !RubyGemName(gem) || value == "" || !composerSafeText(value, 4096) { + invalid() + continue + } + locals[gem] = value + case rubyBundleCredential(name): + // The value is a secret; only its presence is observed. + if value != "" { + auth = true + } + } + } + if len(mirrors) > rubySettingLimit || len(locals) > rubySettingLimit { + a.fileReason(f, "unsupported", "record_limit") + return + } + for i, src := range slices.Sorted(maps.Keys(mirrors)) { + m, prefix := mirrors[src], "mirrors."+strconv.Itoa(i)+"." + display, redacted := src, false + switch { + case src == "all": + case strings.Contains(src, "://"): + display, redacted = SanitizeRubyURL(src) + case !rubyHostPattern.MatchString(src): + display, redacted = goRedacted, true + } + a.setting(f, prefix+"source", display, redacted) + if m.url != "" { + display, redacted := SanitizeRubyURL(m.url) + a.setting(f, prefix+"url", display, redacted) + } + if m.timeout != "" { + a.setting(f, prefix+"fallback_timeout", m.timeout, false) + } + } + for i, gem := range slices.Sorted(maps.Keys(locals)) { + prefix := "local." + strconv.Itoa(i) + "." + a.setting(f, prefix+"name", gem, false) + display, redacted := SanitizeComposerURL(locals[gem]) + a.setting(f, prefix+"path", display, redacted) + } + if auth { + a.setting(f, "auth_configured", "true", true) + // Process credentials are expected injection, not embedded config. + if !process { + a.literal[f.SourceID] = true + } + } +} + +// SanitizeRubyURL is the shared whole-URL redactor, except that SCP-like Git +// remotes are never rewritten to an inequivalent ssh:// URI. +func SanitizeRubyURL(raw string) (string, bool) { + if !strings.Contains(raw, "://") && strings.Contains(raw, "@") { + return goRedacted, true + } + return SanitizeComposerURL(raw) +} + +// rubyURLCredential reports an embedded secret: any HTTP(S) userinfo +// (Bundler sends a lone user as a token) or a password on other schemes. A +// login user on ssh or SCP-like remotes is not a credential; malformed text +// is redacted but not asserted as a credential. +func rubyURLCredential(raw string) bool { + if !strings.Contains(raw, "://") { + return false + } + u, err := url.Parse(raw) + if err != nil || u.User == nil { + return false + } + _, password := u.User.Password() + switch strings.ToLower(u.Scheme) { + case "http", "https": + return u.User.Username() != "" || password + } + return password +} + +// Project observes one Bundler project's manifests and local config and +// selects installation leads with Bundler's Settings#path rule: the first +// level (local, process, global) holding path, path.system or +// disable_shared_gems decides; deployment applies only when none does. +func (a *RubyConfigSnapshot) Project(p RubyProject) RubyProjectLeads { + out := RubyProjectLeads{Partial: a.partial} + if a.scope.Username == "" { + out.Partial = true + return out + } + ids := []string{} + for _, m := range p.Manifests { + f := a.record("bundler", "project", m.Path) + if f == nil { + out.Partial = true + continue + } + if !a.parsed[f.SourceID] { + a.parsed[f.SourceID] = true + f.Status, f.Reasons = m.Status, append([]string{}, m.Reasons...) + if m.Status == "present" { + a.manifestSettings(f, m) + } + } + if f.Status != "present" { + out.Partial = true + } + ids = append(ids, f.SourceID) + } + appConfig := filepath.Join(p.Dir, ".bundle") + if v := a.env["BUNDLE_APP_CONFIG"]; v != "" { + appConfig = v + if !filepath.IsAbs(v) { + appConfig = filepath.Join(p.Dir, v) + } + } + out.AppConfig = filepath.Clean(appConfig) + local := a.readFile("bundler", "project", filepath.Join(out.AppConfig, "config"), a.parseBundler) + all := []*model.RubyConfigFile{local, a.bundle, a.global} + levels := all + if a.ignoreConfig { + // Ignored files stay observed but are not applicable. + levels = []*model.RubyConfigFile{a.bundle} + } + for _, f := range all { + if f == nil || f.Status != "present" && f.Status != "absent" { + out.Partial = true + } + } + value := func(key string) (string, bool) { + for _, f := range levels { + if f == nil { + continue + } + if v, ok := a.leads[f.SourceID][key]; ok { + return v, true + } + } + return "", false + } + resolve := func(v string) string { + if strings.HasPrefix(v, "~/") || strings.HasPrefix(v, `~\`) { + v = filepath.Join(a.scope.Home, v[2:]) + } + if !filepath.IsAbs(v) { + v = filepath.Join(p.Dir, v) + } + if !composerSafeText(v, 4096) { + out.Partial = true + a.degrade("path_unresolved") + return "" + } + return filepath.Clean(v) + } + decided := false + for _, f := range levels { + if f == nil { + continue + } + l := a.leads[f.SourceID] + path, hasPath := l["path"] + system, hasSystem := l["path.system"] + shared, hasShared := l["disable_shared_gems"] + if !hasPath && !hasSystem && !hasShared { + continue + } + decided = true + // System gems live in shared homes that root discovery already covers. + if system != "true" && shared != "false" && hasPath { + out.Path = resolve(path) + } + break + } + if v, ok := value("deployment"); !decided && ok && v == "true" { + out.Path = filepath.Join(p.Dir, "vendor", "bundle") + } + out.CachePath = filepath.Join(p.Dir, "vendor", "cache") + if v, ok := value("cache_path"); ok { + out.CachePath = resolve(v) + } + if v, ok := value("lockfile"); ok { + out.Lockfile = resolve(v) + } + chain := append([]string{}, ids...) + for _, f := range all { + if f != nil && (f == a.bundle || !a.ignoreConfig) { + chain = append(chain, f.SourceID) + } + } + if !a.limited { + selection := "observed" + if out.Partial { + selection = "partial" + } + a.audit.Contexts = append(a.audit.Contexts, model.RubyConfigContext{Manager: "bundler", ProjectPath: p.Dir, ConfigSourceIDs: chain, SelectionStatus: selection}) + a.bytes += len(p.Dir) + len(chain)*70 + 128 + } + return out +} + +func (a *RubyConfigSnapshot) manifestSettings(f *model.RubyConfigFile, m RubyManifest) { + if len(m.Sources) > rubySettingLimit { + a.fileReason(f, "unsupported", "record_limit") + return + } + literal := false + for i, s := range m.Sources { + prefix := "sources." + strconv.Itoa(i) + "." + display, redacted := SanitizeRubyURL(s.URL) + literal = literal || rubyURLCredential(s.URL) + a.setting(f, prefix+"type", s.Type, false) + a.setting(f, prefix+"url", display, redacted) + if s.Cooldown != "" { + a.setting(f, prefix+"cooldown", s.Cooldown, false) + } + } + if m.NoLockfile { + a.setting(f, "lockfile", "false", false) + } else if m.Lockfile != "" { + display, redacted := SanitizeComposerURL(m.Lockfile) + a.setting(f, "lockfile", display, redacted) + } + if literal { + a.setting(f, "auth_configured", "true", true) + a.literal[f.SourceID] = true + } +} + +func (a *RubyConfigSnapshot) credential(path string) string { + if a.limited || a.credentials[a.key(path)] { + return "" + } + a.credentials[a.key(path)] = true + c := model.RubyCredentialFile{Path: path, Status: "present", Presence: "present"} + if a.ctx.Err() != nil { + c.Status, c.Presence = "unreadable", "unknown" + a.degrade("deadline_exceeded") + } else { + info, err := a.exec.GuardedFiles([]string{path}, a.scope.Protected, 0).Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + c.Status, c.Presence = "absent", "absent" + case err != nil: + c.Status, c.Presence = "unreadable", "unknown" + reason := CargoReadReason(err, path, a.scope.Volume) + if reason == "skipped_protected" || reason == "refused_network_volume" { + c.Status = "skipped_protected" + } + a.degrade(reason) + case !info.Mode().IsRegular(): + c.Status, c.Presence = "unsupported", "unknown" + a.degrade("unsupported_entry") + } + } + a.audit.CredentialFiles = append(a.audit.CredentialFiles, c) + a.bytes += len(path) + 128 + return c.Status +} + +func (a *RubyConfigSnapshot) Finish() model.RubyConfigAudit { + if a.gemProcess != nil { + // One ambient RubyGems chain; several present system files mean the + // applicable interpreter is ambiguous. + ids, present, partial := []string{}, 0, a.partial || a.systemPartial + for _, f := range append(append([]*model.RubyConfigFile{a.gemProcess}, a.userGemrc...), a.systemrc...) { + if f == nil { + partial = true + continue + } + ids = append(ids, f.SourceID) + if f.Status != "present" && f.Status != "absent" { + partial = true + } + if f.Scope == "system" && f.Status == "present" { + present++ + } + } + a.context("rubygems", ids, partial || present > 1) + ids, partial = []string{a.bundle.SourceID}, a.partial || a.bundle.Status != "present" && a.bundle.Status != "absent" + if a.global != nil && !a.ignoreConfig { + ids = append(ids, a.global.SourceID) + partial = partial || a.global.Status != "present" && a.global.Status != "absent" + } + a.context("bundler", ids, partial) + } + for _, f := range a.files { + // An incomplete source has no current settings or findings. + if f.Status != "present" { + f.Settings = []model.CargoConfigSetting{} + } + slices.Sort(f.Reasons) + slices.SortFunc(f.Settings, func(x, y model.CargoConfigSetting) int { + return strings.Compare(x.Key+"\x00"+x.Display, y.Key+"\x00"+y.Display) + }) + a.audit.Files = append(a.audit.Files, *f) + a.audit.Findings = append(a.audit.Findings, rubyFindings(*f, a.literal[f.SourceID])...) + for _, r := range f.Reasons { + a.degrade(r) + } + } + slices.SortFunc(a.audit.Files, func(x, y model.RubyConfigFile) int { + return strings.Compare(x.Manager+"\x00"+x.Scope+"\x00"+x.Path, y.Manager+"\x00"+y.Scope+"\x00"+y.Path) + }) + slices.SortFunc(a.audit.Contexts, func(x, y model.RubyConfigContext) int { + return strings.Compare(x.Manager+"\x00"+x.ProjectPath, y.Manager+"\x00"+y.ProjectPath) + }) + slices.SortFunc(a.audit.CredentialFiles, func(x, y model.RubyCredentialFile) int { return strings.Compare(x.Path, y.Path) }) + slices.SortFunc(a.audit.Findings, func(x, y model.CargoConfigFinding) int { + return strings.Compare(x.Code+x.SourceID+x.Key, y.Code+y.SourceID+y.Key) + }) + slices.Sort(a.audit.Reasons) + data, _ := json.Marshal(a.audit) + if len(data) > maxRubyAuditBytes { + a.audit = emptyRubyAudit() + a.degrade("output_size_limit") + } + return a.audit +} + +func (a *RubyConfigSnapshot) context(manager string, ids []string, partial bool) { + if a.limited { + return + } + selection := "observed" + if partial { + selection = "partial" + } + a.audit.Contexts = append(a.audit.Contexts, model.RubyConfigContext{Manager: manager, ConfigSourceIDs: ids, SelectionStatus: selection}) + a.bytes += len(ids)*70 + 128 +} + +// rubyFindings derives 001/002/004 from kept settings. 003 comes from the +// pre-redaction literal-credential flag and never carries secret text. +func rubyFindings(f model.RubyConfigFile, literal bool) []model.CargoConfigFinding { + var out []model.CargoConfigFinding + seen := map[string]bool{} + add := func(code, key, detail string) { + if !seen[code+"\x00"+key] { + seen[code+"\x00"+key] = true + out = append(out, model.CargoConfigFinding{SourceID: f.SourceID, Code: code, Severity: "MEDIUM", Key: key, Detail: detail}) + } + } + for _, s := range f.Settings { + lower := strings.ToLower(s.Display) + source := strings.HasPrefix(s.Key, "sources.") && strings.HasSuffix(s.Key, ".url") + mirror := strings.HasPrefix(s.Key, "mirrors.") && (strings.HasSuffix(s.Key, ".source") || strings.HasSuffix(s.Key, ".url")) + switch { + case (source || mirror) && strings.HasPrefix(lower, "http://"), source && strings.HasPrefix(lower, "git://"): + add("ruby-001", s.Key, "Ruby package source uses an unencrypted connection.") + case s.Key == "ssl_verify_mode" && s.Display == "0": + add("ruby-002", s.Key, "TLS certificate verification is disabled by Ruby package configuration.") + case s.Key == "disable_checksum_validation" && s.Display == "true": + add("ruby-004", s.Key, "Bundler checksum validation is disabled.") + case s.Key == "auth_configured" && literal && f.Scope != "process": + add("ruby-003", s.Key, "Credentials are embedded in Ruby package configuration.") + } + } + return out +} diff --git a/internal/detector/configaudit/rubyconfig_test.go b/internal/detector/configaudit/rubyconfig_test.go new file mode 100644 index 00000000..9d59e50f --- /dev/null +++ b/internal/detector/configaudit/rubyconfig_test.go @@ -0,0 +1,339 @@ +package configaudit + +import ( + "context" + "encoding/json" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +func rubyConfigTest(t *testing.T) (*composerConfigMock, RubyConfigScope) { + t.Helper() + m, scope := composerConfigTest(t) + return m, RubyConfigScope{Username: scope.Username, Home: scope.Home, ProcessVerified: true} +} + +func rubyFile(t *testing.T, audit model.RubyConfigAudit, manager, scope, path string) model.RubyConfigFile { + t.Helper() + for _, f := range audit.Files { + if f.Manager == manager && f.Scope == scope && f.Path == path { + return f + } + } + t.Fatalf("no %s %s file %q in %+v", manager, scope, path, audit.Files) + return model.RubyConfigFile{} +} + +func rubySettings(f model.RubyConfigFile) map[string]string { + out := map[string]string{} + for _, s := range f.Settings { + out[s.Key] = s.Display + } + return out +} + +func TestRubyConfigAllowlistRedactionAndFindings(t *testing.T) { + m, scope := rubyConfigTest(t) + home := scope.Home + m.SetEnv("GEM_HOST_API_KEY", "CANARY_HOST_KEY") + m.SetEnv("HTTPS_PROXY", "https://alice:CANARY_PROXY@proxy.example.invalid:8443/?CANARY_QUERY") + m.SetEnv("BUNDLE_GEMS__EXAMPLE__COM", "CANARY_USER:CANARY_PASS") + m.SetEnv("BUNDLE_CANARY_UNKNOWN", "CANARY_UNKNOWN_VALUE") + m.SetEnv("BUNDLE_WITHOUT", "development:test") + m.SetFile(filepath.Join(home, ".gemrc"), []byte(`--- +:sources: +- http://CANARY_USER:CANARY_PASS@gems.example.invalid/ +- https://rubygems.org/ +:ssl_verify_mode: 0 +:rubygems_api_key: CANARY_API_KEY +gem: --source CANARY_OPTION +install: --CANARY_INSTALL +:s3_source: + bucket: + id: CANARY_ID + secret: CANARY_SECRET +`)) + m.SetFile(filepath.Join(home, ".bundle", "config"), []byte(`--- +BUNDLE_DISABLE_CHECKSUM_VALIDATION: "true" +BUNDLE_GEMS__EXAMPLE__COM: "CANARY_USER:CANARY_PASS" +BUNDLE_HTTPS://CANARY__EXAMPLE__COM/: "CANARY_URI_TOKEN" +BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/: "http://mirror.example.invalid/" +BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/__FALLBACK_TIMEOUT/: "true" +BUNDLE_LOCAL__RACK: "~/src/rack" +BUNDLE_JOBS: "4" +`)) + a := NewRubyConfigDetector(m).Detect(context.Background(), scope) + dir := filepath.Join(home, "app") + leads := a.Project(RubyProject{Dir: dir, Manifests: []RubyManifest{{Path: filepath.Join(dir, "Gemfile"), Status: "present", + Sources: []RubyManifestSource{{Type: "registry", URL: "http://CANARY_USER:CANARY_PASS@insecure.example.invalid/"}, {Type: "git", URL: "git@github.com:CANARY/repo.git"}}}}}) + audit := a.Finish() + data, _ := json.Marshal(audit) + if strings.Contains(string(data), "CANARY_") { + t.Fatalf("canary survived: %s", data) + } + if leads.Path != "" || leads.CachePath != filepath.Join(dir, "vendor", "cache") || leads.AppConfig != filepath.Join(dir, ".bundle") { + t.Fatalf("leads %+v", leads) + } + gemrc := rubySettings(rubyFile(t, audit, "rubygems", "user", filepath.Join(home, ".gemrc"))) + for key, want := range map[string]string{"sources.0.type": "registry", "sources.0.url": "http://gems.example.invalid/", "sources.1.url": "https://rubygems.org/", "ssl_verify_mode": "0", "auth_configured": "true"} { + if gemrc[key] != want { + t.Errorf("gemrc %s=%q want %q", key, gemrc[key], want) + } + } + bundle := rubySettings(rubyFile(t, audit, "bundler", "user", filepath.Join(home, ".bundle", "config"))) + for key, want := range map[string]string{"disable_checksum_validation": "true", "mirrors.0.source": "https://rubygems.org/", "mirrors.0.url": "http://mirror.example.invalid/", "mirrors.0.fallback_timeout": "0.1", "local.0.name": "rack", "local.0.path": "~/src/rack", "auth_configured": "true"} { + if bundle[key] != want { + t.Errorf("bundler %s=%q want %q", key, bundle[key], want) + } + } + if _, ok := bundle["jobs"]; ok { + t.Error("non-allowlisted Bundler key was serialized") + } + process := rubySettings(rubyFile(t, audit, "bundler", "process", "")) + if process["auth_configured"] != "true" || process["without"] != `["development","test"]` || len(process) != 2 { + t.Errorf("bundler process %+v", process) + } + if gem := rubySettings(rubyFile(t, audit, "rubygems", "process", "")); gem["auth_configured"] != "true" || gem["https_proxy"] != "https://proxy.example.invalid:8443/" { + t.Errorf("rubygems process %+v", gem) + } + manifest := rubySettings(rubyFile(t, audit, "bundler", "project", filepath.Join(dir, "Gemfile"))) + if manifest["sources.1.url"] != goRedacted || manifest["sources.1.type"] != "git" { + t.Errorf("SCP-like remote was not redacted: %+v", manifest) + } + got := map[string]bool{} + for _, f := range audit.Findings { + if f.Severity != "MEDIUM" || strings.Contains(f.Detail, "CANARY") { + t.Errorf("finding %+v", f) + } + for _, file := range audit.Files { + if file.SourceID == f.SourceID { + got[f.Code+" "+file.Manager+" "+file.Scope+" "+f.Key] = true + } + } + } + for _, want := range []string{ + "ruby-001 rubygems user sources.0.url", "ruby-002 rubygems user ssl_verify_mode", "ruby-003 rubygems user auth_configured", + "ruby-001 bundler user mirrors.0.url", "ruby-003 bundler user auth_configured", "ruby-004 bundler user disable_checksum_validation", + "ruby-001 bundler project sources.0.url", "ruby-003 bundler project auth_configured", + } { + if !got[want] { + t.Errorf("missing finding %s in %v", want, got) + } + } + if len(got) != 8 { + t.Errorf("findings %v; process credentials must not raise ruby-003", got) + } + if len(audit.CredentialFiles) != 2 || audit.CredentialFiles[0].Presence != "absent" { + t.Errorf("credential files %+v", audit.CredentialFiles) + } + for _, c := range audit.Contexts { + if c.SelectionStatus != "observed" { + t.Errorf("context %+v", c) + } + } +} + +// Bundler's Settings#path: the first level holding path, path.system or +// disable_shared_gems decides; deployment applies only when none does. +func TestRubyConfigPathSelection(t *testing.T) { + for _, tc := range []struct { + name string + local, global string + env map[string]string + path, cache, lock string + excludeLocalContext bool + }{ + {name: "shared default"}, + {name: "deployment fallback", global: "BUNDLE_DEPLOYMENT: \"true\"\n", path: "vendor/bundle"}, + {name: "local relative path", local: "BUNDLE_PATH: \"gems\"\n", path: "gems"}, + {name: "local system wins over global path", local: "BUNDLE_PATH__SYSTEM: \"true\"\n", global: "BUNDLE_PATH: \"/opt/global\"\n"}, + {name: "global path wins over local deployment", local: "BUNDLE_DEPLOYMENT: \"true\"\n", global: "BUNDLE_PATH: \"/opt/global\"\n", path: "/opt/global"}, + {name: "env path wins over global", global: "BUNDLE_PATH: \"/opt/global\"\n", env: map[string]string{"BUNDLE_PATH": "/opt/env"}, path: "/opt/env"}, + {name: "cache and lockfile", local: "BUNDLE_CACHE_PATH: \"pkg/cache\"\nBUNDLE_LOCKFILE: \"Other.lock\"\n", cache: "pkg/cache", lock: "Other.lock"}, + {name: "ignore config", local: "BUNDLE_PATH: \"gems\"\n", global: "BUNDLE_DEPLOYMENT: \"true\"\n", env: map[string]string{"BUNDLE_IGNORE_CONFIG": "1"}, excludeLocalContext: true}, + } { + t.Run(tc.name, func(t *testing.T) { + m, scope := rubyConfigTest(t) + dir := filepath.Join(scope.Home, "app") + for k, v := range tc.env { + m.SetEnv(k, v) + } + if tc.local != "" { + m.SetFile(filepath.Join(dir, ".bundle", "config"), []byte(tc.local)) + } + if tc.global != "" { + m.SetFile(filepath.Join(scope.Home, ".bundle", "config"), []byte(tc.global)) + } + a := NewRubyConfigDetector(m).Detect(context.Background(), scope) + leads := a.Project(RubyProject{Dir: dir, Manifests: []RubyManifest{{Path: filepath.Join(dir, "Gemfile"), Status: "present"}}}) + abs := func(p string) string { + if p == "" || filepath.IsAbs(p) { + return p + } + return filepath.Join(dir, p) + } + want := RubyProjectLeads{Path: abs(tc.path), CachePath: abs(cmpOr(tc.cache, "vendor/cache")), AppConfig: filepath.Join(dir, ".bundle"), Lockfile: abs(tc.lock)} + if leads != want { + t.Fatalf("leads %+v want %+v", leads, want) + } + audit := a.Finish() + local := rubyFile(t, audit, "bundler", "project", filepath.Join(dir, ".bundle", "config")).SourceID + for _, c := range audit.Contexts { + if c.Manager == "bundler" && c.ProjectPath == dir && slices.Contains(c.ConfigSourceIDs, local) == tc.excludeLocalContext { + t.Fatalf("ignored config context %+v", c) + } + } + }) + } +} + +func TestRubyConfigRejectsUnsafeYAMLAndUnverifiedProcess(t *testing.T) { + for _, tc := range []struct{ name, body, status string }{ + {"plain", ":sources:\n- https://rubygems.org/\n", "present"}, + {"anchor", ":sources: &s\n- https://rubygems.org/\n", "unsupported"}, + {"alias", "base: &b [https://rubygems.org/]\n:sources: *b\n", "unsupported"}, + {"merge key", "<<: {sources: [https://rubygems.org/]}\n", "unsupported"}, + {"ruby tag", ":sources: !ruby/object:Gem::Source\n uri: https://rubygems.org/\n", "unsupported"}, + {"block scalar", ":http_proxy: |\n http://proxy.example.invalid\n", "unsupported"}, + {"duplicate key", ":sources: []\nsources: []\n", "unsupported"}, + {"not yaml", ":sources: [\n", "invalid"}, + {"irrelevant tag ignored", "gem: !ruby/object:CANARY {}\n", "present"}, + } { + t.Run(tc.name, func(t *testing.T) { + m, scope := rubyConfigTest(t) + m.SetFile(filepath.Join(scope.Home, ".gemrc"), []byte(tc.body)) + audit := NewRubyConfigDetector(m).Detect(context.Background(), scope).Finish() + f := rubyFile(t, audit, "rubygems", "user", filepath.Join(scope.Home, ".gemrc")) + if f.Status != tc.status || tc.status != "present" && len(f.Settings) != 0 { + t.Fatalf("status %s settings %+v, want %s", f.Status, f.Settings, tc.status) + } + }) + } + + // Bundler's line reader, not YAML, decides what these files mean. + for _, tc := range []struct{ name, body, status, display string }{ + {"quoted value", "BUNDLE_DISABLE_CHECKSUM_VALIDATION: \"false\"\n", "present", "false"}, + {"plain comment agrees", "BUNDLE_DISABLE_CHECKSUM_VALIDATION: false # keep validation\n", "present", "false"}, + {"quoted comment reads as true", "BUNDLE_DISABLE_CHECKSUM_VALIDATION: \"false\" # keep validation\n", "unsupported", ""}, + {"trailing space after quote", "BUNDLE_DISABLE_CHECKSUM_VALIDATION: \"false\" \n", "unsupported", ""}, + {"escape", "BUNDLE_PATH: \"a\\\"b\"\n", "unsupported", ""}, + {"doubled single quote", "BUNDLE_PATH: 'it''s'\n", "unsupported", ""}, + {"hash inside quotes", "BUNDLE_PATH: \"a#b\"\n", "unsupported", ""}, + {"second space", "BUNDLE_PATH: \"gems\"\n", "unsupported", ""}, + {"value on next line", "BUNDLE_PATH:\n gems\n", "unsupported", ""}, + {"irrelevant key ignored", "BUNDLE_JOBS: \"4\" # comment\n", "present", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + m, scope := rubyConfigTest(t) + path := filepath.Join(scope.Home, ".bundle", "config") + m.SetFile(path, []byte("---\n"+tc.body)) + audit := NewRubyConfigDetector(m).Detect(context.Background(), scope).Finish() + f := rubyFile(t, audit, "bundler", "user", path) + if f.Status != tc.status || tc.status != "present" && len(f.Settings) != 0 { + t.Fatalf("status %s settings %+v, want %s", f.Status, f.Settings, tc.status) + } + if tc.display != "" && rubySettings(f)["disable_checksum_validation"] != tc.display { + t.Errorf("settings %+v, want %s", f.Settings, tc.display) + } + for _, c := range audit.Contexts { + if c.Manager == "bundler" && (c.SelectionStatus == "partial") != (tc.status != "present") { + t.Errorf("context %+v for %s file", c, tc.status) + } + } + }) + } + + m, scope := rubyConfigTest(t) + scope.ProcessVerified = false + m.SetEnv("GEM_HOST_API_KEY", "CANARY_HOST_KEY") + m.SetEnv("GEMRC", "/CANARY/gemrc") + audit := NewRubyConfigDetector(m).Detect(context.Background(), scope).Finish() + for _, manager := range []string{"rubygems", "bundler"} { + if f := rubyFile(t, audit, manager, "process", ""); f.Status != "unsupported" || !slices.Equal(f.Reasons, []string{"user_unresolved"}) || len(f.Settings) != 0 { + t.Errorf("unverified %s process %+v", manager, f) + } + } + for _, c := range audit.Contexts { + if c.SelectionStatus != "partial" { + t.Errorf("unverified process left context complete: %+v", c) + } + } + if data, _ := json.Marshal(audit); strings.Contains(string(data), "CANARY") { + t.Fatalf("unverified environment was read: %s", data) + } +} + +func TestRubyConfigGemrcSelection(t *testing.T) { + m, scope := rubyConfigTest(t) + home := scope.Home + first, second := filepath.Join(home, "one.gemrc"), filepath.Join(home, "two.gemrc") + m.SetEnv("GEMRC", first+":"+second) + m.SetFile(second, []byte(":ssl_verify_mode: 1\n")) + xdg := filepath.Join(home, ".config", "gem", "gemrc") + m.SetFile(xdg, []byte(":gemhome: ~/xdg-gems\n")) + a := NewRubyConfigDetector(m).Detect(context.Background(), scope) + prefix := filepath.Join(home, "rubies", "3.3.0") + m.SetFile(filepath.Join(prefix, "etc", "gemrc"), []byte(":sources:\n- https://rubygems.org/\n")) + a.Prefixes([]string{prefix}, nil) + audit := a.Finish() + // ~/.gemrc is absent, so the XDG file is a candidate. + if f := rubyFile(t, audit, "rubygems", "user", xdg); f.Status != "present" || rubySettings(f)["gem_home"] != filepath.Join(home, "xdg-gems") { + t.Fatalf("xdg gemrc %+v", f) + } + if !slices.Contains(a.GemHomes, filepath.Join(home, "xdg-gems")) { + t.Fatalf("gemhome lead %v", a.GemHomes) + } + if f := rubyFile(t, audit, "rubygems", "system", filepath.Join(prefix, "etc", "gemrc")); f.Status != "present" { + t.Fatalf("prefix gemrc %+v", f) + } + var ctx model.RubyConfigContext + for _, c := range audit.Contexts { + if c.Manager == "rubygems" { + ctx = c + } + } + // Process, both GEMRC files (last first), ~/.gemrc, XDG, then system files. + if len(ctx.ConfigSourceIDs) != 7 || ctx.SelectionStatus != "partial" { + t.Fatalf("rubygems context %+v", ctx) + } + if ctx.ConfigSourceIDs[1] != rubyFile(t, audit, "rubygems", "user", second).SourceID { + t.Fatal("GEMRC precedence order lost") + } + + // A present ~/.gemrc hides the XDG file. + m, scope = rubyConfigTest(t) + m.SetFile(filepath.Join(scope.Home, ".gemrc"), []byte("{}\n")) + audit = NewRubyConfigDetector(m).Detect(context.Background(), scope).Finish() + for _, f := range audit.Files { + if strings.HasSuffix(f.Path, filepath.Join("gem", "gemrc")) { + t.Fatalf("XDG gemrc observed beside ~/.gemrc: %+v", f) + } + } + if audit.Status != "complete" { + t.Fatalf("audit %s %v", audit.Status, audit.Reasons) + } +} + +func TestRubyURLSanitizer(t *testing.T) { + for _, tc := range []struct { + raw, display string + credential bool + }{ + {"https://rubygems.org/", "https://rubygems.org/", false}, + {"https://CANARY_TOKEN@gems.example.invalid/", "https://gems.example.invalid/", true}, + {"https://user:CANARY_PASS@gems.example.invalid/", "https://gems.example.invalid/", true}, + {"ssh://git@github.com/org/repo.git", "ssh://github.com/org/repo.git", false}, + {"ssh://git:CANARY_PASS@github.com/org/repo.git", "ssh://github.com/org/repo.git", true}, + {"git@github.com:org/repo.git", goRedacted, false}, + {"git://github.com/org/repo.git", "git://github.com/org/repo.git", false}, + } { + display, _ := SanitizeRubyURL(tc.raw) + if display != tc.display || rubyURLCredential(tc.raw) != tc.credential { + t.Errorf("%s: display %q credential %v, want %q %v", tc.raw, display, rubyURLCredential(tc.raw), tc.display, tc.credential) + } + } +} diff --git a/internal/detector/fuzz_test.go b/internal/detector/fuzz_test.go index f90a96bc..b62e021a 100644 --- a/internal/detector/fuzz_test.go +++ b/internal/detector/fuzz_test.go @@ -2,10 +2,12 @@ package detector import ( "bytes" + "reflect" "slices" "strings" "testing" + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" "github.com/step-security/dev-machine-guard/internal/executor" ) @@ -86,3 +88,59 @@ func FuzzPythonMetadata(f *testing.F) { } }) } + +// FuzzRubyMetadata feeds one input to every Ruby parser: each must be +// deterministic, leave its input untouched, and admit only valid identities. +func FuzzRubyMetadata(f *testing.F) { + for _, s := range []string{ + "source \"https://rubygems.org\"\ngem \"rack\", \"~> 3.0\", group: :test\n", + "Gem::Specification.new do |s|\n s.name = \"x\"\n s.version = \"1.0\"\n s.add_dependency \"y\"\nend\n", + "GEM\n remote: https://rubygems.org/\n specs:\n rack (3.0.8)\n\nDEPENDENCIES\n rack!\n\nCHECKSUMS\n rack (3.0.8) sha256=bad\n", + rubyStubHeader, "gem \"x\" if ENV[\"X\"]\n", "", "\xff\x00", + } { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 16*1024 { + t.Skip() + } + before := bytes.Clone(data) + for _, parse := range []func([]byte) (*rubyManifest, error){parseRubyGemfile, parseRubyGemspec} { + m, err := parse(data) + m2, err2 := parse(data) + if !reflect.DeepEqual(m, m2) || (err == nil) != (err2 == nil) { + t.Fatal("manifest parse is not deterministic") + } + if err == nil && m != nil { + for _, d := range m.declarations { + if !configaudit.RubyGemName(d.name) { + t.Fatalf("invalid declared name %q", d.name) + } + } + } + } + entries, reasons, err := parseRubyLock(data) + entries2, reasons2, err2 := parseRubyLock(data) + if !reflect.DeepEqual(entries, entries2) || !slices.Equal(reasons, reasons2) || (err == nil) != (err2 == nil) { + t.Fatal("lock parse is not deterministic") + } + for _, e := range entries { + if !configaudit.RubyGemName(e.pkg.PackageName) || e.pkg.Version == "" { + t.Fatalf("invalid locked identity %q %q", e.pkg.PackageName, e.pkg.Version) + } + } + if name, _, _, err := parseRubyStub(data); err == nil && !configaudit.RubyGemName(name) { + t.Fatalf("invalid stub name %q", name) + } + n, v, p, reason := parseRubyArchive(data) + if n2, v2, p2, reason2 := parseRubyArchive(data); n != n2 || v != v2 || p != p2 || reason != reason2 { + t.Fatal("archive parse is not deterministic") + } + if reason == "" && !configaudit.RubyGemName(n) { + t.Fatalf("invalid archive name %q", n) + } + if !bytes.Equal(data, before) { + t.Fatal("parser mutated input") + } + }) +} diff --git a/internal/detector/ruby_golden_test.go b/internal/detector/ruby_golden_test.go new file mode 100644 index 00000000..6f2c0034 --- /dev/null +++ b/internal/detector/ruby_golden_test.go @@ -0,0 +1,81 @@ +package detector + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// The shared contract is generated by the real guarded scanner over the +// fixture tree. Only the temporary home and the IDs derived from it are +// normalized. +func TestRubyGoldenProducer(t *testing.T) { + if runtime.GOOS == model.PlatformWindows { + t.Skip("canonical Unix fixture; wire round-trip and scanner tests also run on Windows") + } + home := goTestHome(t) + root := filepath.Join(home, "code") + rubyWriteFixture(t, home, root) + scanner, x := rubyTestScanner(t, home) + rubyFixtureEnv(x) + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + rubyAssertRefs(t, inv, audit) + type sections struct { + Inventory *model.RubyInventory `json:"ruby_inventory"` + Audit *model.RubyConfigAudit `json:"ruby_config_audit"` + } + pair := sections{inv, audit} + raw, err := json.Marshal(pair) + if err != nil { + t.Fatal(err) + } + raw = bytes.ReplaceAll(raw, []byte(home), []byte("/home/ruby-fixture")) + if err := json.Unmarshal(raw, &pair); err != nil { + t.Fatal(err) + } + replacements := []string{} + for _, s := range pair.Inventory.Sources { + replacements = append(replacements, s.SourceID, configaudit.GoSourceID(goTestTarget(home).Username, s.Kind, s.Path)) + } + for _, f := range pair.Audit.Files { + replacements = append(replacements, f.SourceID, configaudit.GoSourceID(goTestTarget(home).Username, "ruby_config_"+f.Manager+"_"+f.Scope, f.Path)) + } + raw = []byte(strings.NewReplacer(replacements...).Replace(string(raw))) + if err := json.Unmarshal(raw, &pair); err != nil { + t.Fatal(err) + } + for i := range pair.Inventory.Sources { + slices.Sort(pair.Inventory.Sources[i].DiscoveredSources) + } + slices.SortFunc(pair.Audit.Findings, func(a, b model.CargoConfigFinding) int { + return strings.Compare(a.Code+a.SourceID+a.Key, b.Code+b.SourceID+b.Key) + }) + rubyAssertRefs(t, pair.Inventory, pair.Audit) + raw, err = json.MarshalIndent(pair, "", " ") + if err != nil { + t.Fatal(err) + } + raw = append(raw, '\n') + path := "../model/testdata/ruby_inventory_v1_golden.json" + if os.Getenv("UPDATE_RUBY_GOLDEN") == "1" { + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(raw, want) { + t.Fatal("producer differs from shared Ruby golden; review the contract before regenerating") + } +} diff --git a/internal/detector/rubymetadata.go b/internal/detector/rubymetadata.go new file mode 100644 index 00000000..6a92ec57 --- /dev/null +++ b/internal/detector/rubymetadata.go @@ -0,0 +1,1199 @@ +package detector + +import ( + "archive/tar" + "bytes" + "cmp" + "compress/gzip" + "errors" + "io" + "maps" + "regexp" + "slices" + "strconv" + "strings" + "unicode/utf8" + + "gopkg.in/yaml.v3" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/model" +) + +var ( + maxRubyMetadataBytes int64 = 2 << 20 + maxRubyArchiveBytes int64 = 32 << 20 +) + +const ( + maxRubyRequirements = 32 + maxRubyLabels = 32 + maxRubyNesting = 32 +) + +var errRubyUnsupportedFormat = errors.New("unsupported metadata format") + +var ( + rubyVersionPattern = regexp.MustCompile(`^[0-9]+(?:\.[0-9A-Za-z]+)*(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) + rubyRevisionPattern = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) + rubySHA256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`) +) + +func rubyVersion(s string) bool { return len(s) <= 256 && rubyVersionPattern.MatchString(s) } + +// rubyDeclaration is one literal requirement before the scanner attaches its +// owner and project. Local paths and URLs stay raw until resolved/sanitized; +// an empty origin means the manifest tree's global sources decide. +type rubyDeclaration struct { + name, kind string + requirements []string + groups, platforms []string + origin, url, path string +} + +type rubyGemspecRef struct{ path, name string } + +// rubyManifest is a whole supported Gemfile or gemspec; any unsupported +// statement rejects the file rather than yielding a partial declaration set. +type rubyManifest struct { + name, version string + declarations []rubyDeclaration + sources []configaudit.RubyManifestSource + globals []string + evals []string + gemspecs []rubyGemspecRef + lockfile string + lockfileSet bool + noLockfile bool + partial bool + declared map[string]int + sourceSeen map[string]bool +} + +const ( + rubyTokIdent = iota + 1 + rubyTokString + rubyTokSymbol + rubyTokLabel + rubyTokInt + rubyTokPunct + rubyTokNewline +) + +type rubyToken struct { + kind int + text string +} + +func rubyIdentStart(c byte) bool { return c == '_' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' } +func rubyIdentByte(c byte) bool { return rubyIdentStart(c) || c >= '0' && c <= '9' } + +// rubyLex accepts only the literal subset. Heredocs, percent literals, +// backticks, interpolation, operators and globals all reject the file. +func rubyLex(data []byte) ([]rubyToken, error) { + if int64(len(data)) > maxRubyMetadataBytes || !utf8.Valid(data) { + return nil, errors.New("invalid ruby source") + } + data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")) + var toks []rubyToken + for i := 0; i < len(data); { + c := data[i] + switch { + case c == ' ' || c == '\t' || c == '\r': + i++ + case c == '\n' || c == ';': + toks = append(toks, rubyToken{kind: rubyTokNewline}) + i++ + case c == '#': + for i < len(data) && data[i] != '\n' { + i++ + } + case (i == 0 || data[i-1] == '\n') && c == '=' && bytes.HasPrefix(data[i:], []byte("=begin")): + return nil, errRubyUnsupportedFormat + case c == '"' || c == '\'': + s, n, err := rubyString(data[i:]) + if err != nil { + return nil, err + } + toks = append(toks, rubyToken{rubyTokString, s}) + i += n + case c == ':' && i+1 < len(data) && data[i+1] == ':': + toks = append(toks, rubyToken{rubyTokPunct, "::"}) + i += 2 + case c == ':' && i+1 < len(data) && (data[i+1] == '"' || data[i+1] == '\''): + s, n, err := rubyString(data[i+1:]) + if err != nil { + return nil, err + } + toks = append(toks, rubyToken{rubyTokSymbol, s}) + i += 1 + n + case c == ':' && i+1 < len(data) && rubyIdentStart(data[i+1]): + j := i + 1 + for j < len(data) && rubyIdentByte(data[j]) { + j++ + } + toks = append(toks, rubyToken{rubyTokSymbol, string(data[i+1 : j])}) + i = j + case rubyIdentStart(c): + j := i + for j < len(data) && rubyIdentByte(data[j]) { + j++ + } + word := string(data[i:j]) + if word == "__END__" || j < len(data) && (data[j] == '?' || data[j] == '!') { + return nil, errRubyUnsupportedFormat + } + // `key:` is a keyword label; `Const::Name` is scope resolution. + if j < len(data) && data[j] == ':' && (j+1 >= len(data) || data[j+1] != ':') { + toks = append(toks, rubyToken{rubyTokLabel, word}) + i = j + 1 + continue + } + toks = append(toks, rubyToken{rubyTokIdent, word}) + i = j + case c >= '0' && c <= '9': + j := i + for j < len(data) && data[j] >= '0' && data[j] <= '9' { + j++ + } + if j < len(data) && (rubyIdentByte(data[j]) || data[j] == '.') { + return nil, errRubyUnsupportedFormat + } + toks = append(toks, rubyToken{rubyTokInt, string(data[i:j])}) + i = j + case c == '=' && i+1 < len(data) && data[i+1] == '>': + toks = append(toks, rubyToken{rubyTokPunct, "=>"}) + i += 2 + case c == '=' && (i+1 >= len(data) || data[i+1] != '=' && data[i+1] != '~'): + toks = append(toks, rubyToken{rubyTokPunct, "="}) + i++ + case strings.IndexByte("()[]{},.|", c) >= 0: + toks = append(toks, rubyToken{rubyTokPunct, string(c)}) + i++ + default: + return nil, errRubyUnsupportedFormat + } + } + return toks, nil +} + +// rubyString decodes one single-line quoted literal with Ruby's quote rules. +// Double quotes accept only \\ and \" escapes and never interpolation. +func rubyString(data []byte) (string, int, error) { + quote := data[0] + var b strings.Builder + for j := 1; j < len(data) && b.Len() <= 1<<16; j++ { + c := data[j] + switch { + case c == '\n': + return "", 0, errRubyUnsupportedFormat + case c == quote: + return b.String(), j + 1, nil + case c == '\\': + if j+1 >= len(data) { + return "", 0, errRubyUnsupportedFormat + } + switch next := data[j+1]; { + case next == '\\' || next == quote: + b.WriteByte(next) + j++ + case quote == '\'': + // Single quotes keep every other backslash literally. + b.WriteByte(c) + default: + return "", 0, errRubyUnsupportedFormat + } + case quote == '"' && c == '#' && j+1 < len(data) && strings.IndexByte("{@$", data[j+1]) >= 0: + return "", 0, errRubyUnsupportedFormat + default: + b.WriteByte(c) + } + } + return "", 0, errRubyUnsupportedFormat +} + +// rubyValue is a literal; hash contents are validated but never retained. +type rubyValue struct { + kind byte // s string, y symbol, i integer, b boolean, n nil, a array, h hash + text string + items []rubyValue +} + +type rubyCall struct { + recv, name string + assign bool + args []rubyValue + opts map[string]rubyValue + block bool + param string + body []rubyCall +} + +type rubyParser struct { + toks []rubyToken + pos int + depth int +} + +func (p *rubyParser) peek() rubyToken { + if p.pos < len(p.toks) { + return p.toks[p.pos] + } + return rubyToken{} +} +func (p *rubyParser) take() rubyToken { + t := p.peek() + if p.pos < len(p.toks) { + p.pos++ + } + return t +} +func (p *rubyParser) is(kind int, text string) bool { + t := p.peek() + return t.kind == kind && t.text == text +} +func (p *rubyParser) accept(kind int, text string) bool { + if p.is(kind, text) { + p.pos++ + return true + } + return false +} +func (p *rubyParser) newlines() { + for p.peek().kind == rubyTokNewline { + p.pos++ + } +} + +func (p *rubyParser) value() (rubyValue, error) { + if p.depth >= maxRubyNesting { + return rubyValue{}, errRubyUnsupportedFormat + } + p.depth++ + defer func() { p.depth-- }() + t := p.take() + switch { + case t.kind == rubyTokString: + return rubyValue{kind: 's', text: t.text}, nil + case t.kind == rubyTokSymbol: + return rubyValue{kind: 'y', text: t.text}, nil + case t.kind == rubyTokInt: + return rubyValue{kind: 'i', text: t.text}, nil + case t.kind == rubyTokIdent && (t.text == "true" || t.text == "false"): + return rubyValue{kind: 'b', text: t.text}, nil + case t.kind == rubyTokIdent && t.text == "nil": + return rubyValue{kind: 'n'}, nil + case t.kind == rubyTokPunct && (t.text == "[" || t.text == "{"): + closer, v := "]", rubyValue{kind: 'a'} + if t.text == "{" { + closer, v.kind = "}", 'h' + } + for { + p.newlines() + if p.accept(rubyTokPunct, closer) { + return v, nil + } + if v.kind == 'a' { + item, err := p.value() + if err != nil { + return v, err + } + v.items = append(v.items, item) + } else if _, _, err := p.pair(); err != nil { + return v, err + } + p.newlines() + if !p.accept(rubyTokPunct, ",") { + if p.accept(rubyTokPunct, closer) { + return v, nil + } + return v, errRubyUnsupportedFormat + } + } + } + return rubyValue{}, errRubyUnsupportedFormat +} + +// pair reads `key: value` or a literal string/symbol `key => value`. +func (p *rubyParser) pair() (string, rubyValue, error) { + if t := p.peek(); t.kind == rubyTokLabel { + p.pos++ + p.newlines() + v, err := p.value() + return t.text, v, err + } + key, err := p.value() + if err != nil || key.kind != 's' && key.kind != 'y' || !p.accept(rubyTokPunct, "=>") { + return "", rubyValue{}, errRubyUnsupportedFormat + } + p.newlines() + v, err := p.value() + return key.text, v, err +} + +func (p *rubyParser) arguments(c *rubyCall) error { + paren := p.accept(rubyTokPunct, "(") + if t := p.peek(); !paren && (t.kind == 0 || t.kind == rubyTokNewline || t.kind == rubyTokIdent && (t.text == "do" || t.text == "end")) { + return nil + } + // A brace after a bare call is a block, never a hash argument. + if !paren && p.is(rubyTokPunct, "{") { + return errRubyUnsupportedFormat + } + for { + if paren { + p.newlines() + if p.accept(rubyTokPunct, ")") { + return nil + } + } + keyed := p.peek().kind == rubyTokLabel + var key string + var v rubyValue + var err error + if keyed { + key, v, err = p.pair() + } else if v, err = p.value(); err == nil && p.accept(rubyTokPunct, "=>") { + if v.kind != 's' && v.kind != 'y' { + return errRubyUnsupportedFormat + } + keyed, key = true, v.text + p.newlines() + v, err = p.value() + } + if err != nil { + return err + } + if keyed { + if _, dup := c.opts[key]; dup { + return errRubyUnsupportedFormat + } + if c.opts == nil { + c.opts = map[string]rubyValue{} + } + c.opts[key] = v + } else { + if len(c.opts) > 0 { + return errRubyUnsupportedFormat + } + c.args = append(c.args, v) + } + if p.accept(rubyTokPunct, ",") { + p.newlines() + continue + } + if paren { + p.newlines() + if !p.accept(rubyTokPunct, ")") { + return errRubyUnsupportedFormat + } + } + return nil + } +} + +func (p *rubyParser) statement() (rubyCall, error) { + t := p.take() + if t.kind != rubyTokIdent { + return rubyCall{}, errRubyUnsupportedFormat + } + c := rubyCall{name: t.text} + if p.accept(rubyTokPunct, ".") { + m := p.take() + if m.kind != rubyTokIdent { + return c, errRubyUnsupportedFormat + } + c.recv, c.name = t.text, m.text + if p.accept(rubyTokPunct, "=") { + p.newlines() + v, err := p.value() + c.assign, c.args = true, []rubyValue{v} + return c, err + } + } + if err := p.arguments(&c); err != nil { + return c, err + } + if p.accept(rubyTokIdent, "do") { + c.block = true + if p.accept(rubyTokPunct, "|") { + param := p.take() + if param.kind != rubyTokIdent || !p.accept(rubyTokPunct, "|") { + return c, errRubyUnsupportedFormat + } + c.param = param.text + } + if p.depth >= maxRubyNesting { + return c, errRubyUnsupportedFormat + } + p.depth++ + body, err := p.statements(true) + p.depth-- + if err != nil { + return c, err + } + c.body = body + } + return c, nil +} + +// statements reads one call per line up to `end` (block) or end of input. +func (p *rubyParser) statements(block bool) ([]rubyCall, error) { + var out []rubyCall + for { + p.newlines() + t := p.peek() + if t.kind == 0 { + if block { + return nil, errRubyUnsupportedFormat + } + return out, nil + } + if t.kind == rubyTokIdent && t.text == "end" { + if !block { + return nil, errRubyUnsupportedFormat + } + p.pos++ + return out, nil + } + c, err := p.statement() + if err != nil { + return nil, err + } + // Trailing tokens are modifiers or expressions outside the subset. + if next := p.peek(); next.kind != 0 && next.kind != rubyTokNewline && !(next.kind == rubyTokIdent && next.text == "end") { + return nil, errRubyUnsupportedFormat + } + out = append(out, c) + } +} + +// rubyLabels flattens literal symbol/string selectors, sorted and deduplicated. +func rubyLabels(values ...rubyValue) ([]string, bool) { + out := []string{} + for _, v := range values { + items := []rubyValue{v} + if v.kind == 'a' { + items = v.items + } + for _, item := range items { + if item.kind != 's' && item.kind != 'y' || !configaudit.RubyToken(item.text) { + return nil, false + } + out = append(out, item.text) + } + } + return rubyLabelSet(out) +} + +func rubyLabelSet(labels []string) ([]string, bool) { + out := slices.Clone(labels) + slices.Sort(out) + out = slices.Compact(out) + return out, len(out) <= maxRubyLabels +} + +// rubyRequirements keeps requirement text exactly; it is never solved. +func rubyRequirements(values []rubyValue) ([]string, bool) { + out := []string{} + for _, v := range values { + items := []rubyValue{v} + if v.kind == 'a' { + items = v.items + } + for _, item := range items { + if item.kind != 's' || strings.TrimSpace(item.text) == "" || !composerText(item.text, 1024) { + return nil, false + } + out = append(out, item.text) + } + } + return out, len(out) <= maxRubyRequirements +} + +// declare collapses exact repeats of one identity. Repeats that differ in +// requirements or labels could describe different contexts, so the whole +// file becomes unsupported instead of a guessed union. +func (m *rubyManifest) declare(d rubyDeclaration) error { + if !configaudit.RubyGemName(d.name) { + m.partial = true + return nil + } + if m.declared == nil { + m.declared = map[string]int{} + } + key := strings.Join([]string{d.name, d.kind, d.origin, d.url, d.path}, "\x00") + if i, ok := m.declared[key]; ok { + prev := m.declarations[i] + if slices.Equal(prev.requirements, d.requirements) && slices.Equal(prev.groups, d.groups) && slices.Equal(prev.platforms, d.platforms) { + return nil + } + return errRubyUnsupportedFormat + } + m.declared[key] = len(m.declarations) + m.declarations = append(m.declarations, d) + return nil +} + +func (m *rubyManifest) source(typ, url, cooldown string) { + key := typ + "\x00" + url + "\x00" + cooldown + if m.sourceSeen == nil { + m.sourceSeen = map[string]bool{} + } + if !m.sourceSeen[key] { + m.sourceSeen[key] = true + m.sources = append(m.sources, configaudit.RubyManifestSource{Type: typ, URL: url, Cooldown: cooldown}) + } +} + +type rubyScope struct { + groups, platforms []string + origin, url, path string +} + +func parseRubyGemfile(data []byte) (*rubyManifest, error) { + toks, err := rubyLex(data) + if err != nil { + return nil, err + } + p := &rubyParser{toks: toks} + body, err := p.statements(false) + if err != nil { + return nil, err + } + m := &rubyManifest{} + if err := m.gemfile(body, rubyScope{}, true); err != nil { + return nil, err + } + return m, nil +} + +func (m *rubyManifest) gemfile(calls []rubyCall, scope rubyScope, top bool) error { + for _, c := range calls { + if c.recv != "" || c.assign || c.param != "" { + return errRubyUnsupportedFormat + } + var err error + switch c.name { + case "source": + err = m.gemfileSource(c, scope, top) + case "gem": + err = m.gemfileGem(c, scope) + case "group", "platforms", "platform": + labels, ok := rubyLabels(c.args...) + if !ok || len(c.args) == 0 || !c.block { + return errRubyUnsupportedFormat + } + inner := scope + if c.name == "group" { + // `optional:` is an annotation; it never proves installation. + for k, v := range c.opts { + if k != "optional" || v.kind != 'b' { + return errRubyUnsupportedFormat + } + } + inner.groups = append(slices.Clone(scope.groups), labels...) + } else { + if len(c.opts) > 0 { + return errRubyUnsupportedFormat + } + inner.platforms = append(slices.Clone(scope.platforms), labels...) + } + err = m.gemfile(c.body, inner, false) + case "git", "path": + if len(c.args) != 1 || c.args[0].kind != 's' || c.args[0].text == "" || !c.block || scope.origin != "" { + return errRubyUnsupportedFormat + } + allowed := []string{"glob"} + if c.name == "git" { + allowed = []string{"branch", "tag", "ref", "glob"} + } + for k, v := range c.opts { + if !slices.Contains(allowed, k) || v.kind != 's' { + return errRubyUnsupportedFormat + } + } + inner := scope + if c.name == "git" { + inner.origin, inner.url = "git", c.args[0].text + m.source("git", c.args[0].text, "") + } else { + inner.origin, inner.path = "path", c.args[0].text + } + err = m.gemfile(c.body, inner, false) + case "gemspec": + if !top || c.block || len(c.args) > 0 { + return errRubyUnsupportedFormat + } + ref := rubyGemspecRef{path: "."} + for k, v := range c.opts { + switch { + case k == "path" && v.kind == 's' && v.text != "": + ref.path = v.text + case k == "name" && v.kind == 's': + ref.name = v.text + case k == "glob" && v.kind == 's', k == "development_group" && (v.kind == 's' || v.kind == 'y'): + default: + return errRubyUnsupportedFormat + } + } + m.gemspecs = append(m.gemspecs, ref) + case "eval_gemfile": + if !top || c.block || len(c.opts) > 0 || len(c.args) != 1 || c.args[0].kind != 's' || c.args[0].text == "" { + return errRubyUnsupportedFormat + } + m.evals = append(m.evals, c.args[0].text) + case "lockfile": + if !top || c.block || len(c.opts) > 0 || len(c.args) != 1 { + return errRubyUnsupportedFormat + } + switch v := c.args[0]; { + case v.kind == 's' && v.text != "": + m.lockfile = v.text + case v.kind == 'b' && v.text == "false": + m.noLockfile = true + default: + return errRubyUnsupportedFormat + } + m.lockfileSet = true + case "ruby": + // The interpreter requirement is context only, never a gem. + if !top || c.block { + return errRubyUnsupportedFormat + } + default: + return errRubyUnsupportedFormat + } + if err != nil { + return err + } + } + return nil +} + +func (m *rubyManifest) gemfileSource(c rubyCall, scope rubyScope, top bool) error { + if len(c.args) != 1 || c.args[0].kind != 's' || c.args[0].text == "" { + return errRubyUnsupportedFormat + } + cooldown := "" + for k, v := range c.opts { + // Plugin `type:` and other options are not silently registry origins. + if k != "cooldown" || v.kind != 'i' || len(v.text) > 9 { + return errRubyUnsupportedFormat + } + n, _ := strconv.Atoi(v.text) + cooldown = strconv.Itoa(n) + } + url := c.args[0].text + m.source("registry", url, cooldown) + if !c.block { + if !top { + return errRubyUnsupportedFormat + } + if !slices.Contains(m.globals, url) { + m.globals = append(m.globals, url) + } + return nil + } + if scope.origin != "" { + return errRubyUnsupportedFormat + } + inner := scope + inner.origin, inner.url = "registry", url + return m.gemfile(c.body, inner, false) +} + +var rubyGemOptions = []string{"group", "groups", "platform", "platforms", "require", "source", "git", "path", "branch", "tag", "ref", "glob", "force_ruby_platform"} + +func (m *rubyManifest) gemfileGem(c rubyCall, scope rubyScope) error { + if c.block || len(c.args) == 0 || c.args[0].kind != 's' { + return errRubyUnsupportedFormat + } + reqs, ok := rubyRequirements(c.args[1:]) + if !ok { + return errRubyUnsupportedFormat + } + d := rubyDeclaration{name: c.args[0].text, kind: "unknown", requirements: reqs, origin: scope.origin, url: scope.url, path: scope.path} + groups, platforms := slices.Clone(scope.groups), slices.Clone(scope.platforms) + explicit := 0 + for _, k := range slices.Sorted(maps.Keys(c.opts)) { + v := c.opts[k] + if !slices.Contains(rubyGemOptions, k) { + return errRubyUnsupportedFormat + } + switch k { + case "group", "groups", "platform", "platforms": + labels, ok := rubyLabels(v) + if !ok { + return errRubyUnsupportedFormat + } + if strings.HasPrefix(k, "group") { + groups = append(groups, labels...) + } else { + platforms = append(platforms, labels...) + } + case "force_ruby_platform": + if v.kind != 'b' { + return errRubyUnsupportedFormat + } + case "require": + // Load behavior is never executed or modeled. + case "source", "git", "path": + if v.kind != 's' || v.text == "" { + return errRubyUnsupportedFormat + } + explicit++ + switch k { + case "source": + d.origin, d.url, d.path = "registry", v.text, "" + case "git": + d.origin, d.url, d.path = "git", v.text, "" + default: + d.origin, d.url, d.path = "path", "", v.text + } + if k != "path" { + m.source(d.origin, v.text, "") + } + default: + // branch, tag, ref and glob select within a Git/path source. + if v.kind != 's' { + return errRubyUnsupportedFormat + } + } + } + if explicit > 1 { + return errRubyUnsupportedFormat + } + if d.groups, ok = rubyLabelSet(groups); !ok { + return errRubyUnsupportedFormat + } + if d.platforms, ok = rubyLabelSet(platforms); !ok { + return errRubyUnsupportedFormat + } + return m.declare(d) +} + +// Non-identity gemspec fields: literal values are skipped and never +// serialized, so personal text, file lists and key paths stay on device. +var rubyGemspecIgnored = []string{"summary", "description", "authors", "author", "email", "homepage", "license", "licenses", "files", "executables", "extensions", "bindir", "require_paths", "metadata", "required_ruby_version", "required_rubygems_version", "extra_rdoc_files", "rdoc_options", "post_install_message", "cert_chain", "signing_key"} + +// parseRubyGemspec accepts only the literal `Gem::Specification.new` block. +// Generated library gemspecs that compute versions or file lists stay +// unsupported until they are built or locked. +func parseRubyGemspec(data []byte) (*rubyManifest, error) { + toks, err := rubyLex(data) + if err != nil { + return nil, err + } + p := &rubyParser{toks: toks} + p.newlines() + for _, want := range []rubyToken{{rubyTokIdent, "Gem"}, {rubyTokPunct, "::"}, {rubyTokIdent, "Specification"}, {rubyTokPunct, "."}, {rubyTokIdent, "new"}, {rubyTokIdent, "do"}, {rubyTokPunct, "|"}} { + if !p.accept(want.kind, want.text) { + return nil, errRubyUnsupportedFormat + } + } + param := p.take() + if param.kind != rubyTokIdent || !p.accept(rubyTokPunct, "|") { + return nil, errRubyUnsupportedFormat + } + body, err := p.statements(true) + if err != nil { + return nil, err + } + if p.newlines(); p.peek().kind != 0 { + return nil, errRubyUnsupportedFormat + } + m := &rubyManifest{} + for _, c := range body { + if c.recv != param.text || c.block { + return nil, errRubyUnsupportedFormat + } + switch { + case c.assign && (c.name == "name" || c.name == "version" || c.name == "platform"): + v := c.args[0] + if v.kind != 's' { + return nil, errRubyUnsupportedFormat + } + if c.name == "name" && configaudit.RubyGemName(v.text) { + m.name = v.text + } + if c.name == "version" && rubyVersion(strings.TrimSpace(v.text)) { + m.version = strings.TrimSpace(v.text) + } + case c.assign && slices.Contains(rubyGemspecIgnored, c.name): + case !c.assign && (c.name == "add_dependency" || c.name == "add_runtime_dependency" || c.name == "add_development_dependency"): + if len(c.opts) > 0 || len(c.args) == 0 || c.args[0].kind != 's' { + return nil, errRubyUnsupportedFormat + } + reqs, ok := rubyRequirements(c.args[1:]) + if !ok { + return nil, errRubyUnsupportedFormat + } + kind := "runtime" + if c.name == "add_development_dependency" { + kind = "development" + } + if err := m.declare(rubyDeclaration{name: c.args[0].text, kind: kind, requirements: reqs}); err != nil { + return nil, err + } + default: + return nil, errRubyUnsupportedFormat + } + } + return m, nil +} + +// rubyLockEntry is one resolved lock spec. A local Git/PATH remote stays raw +// until the scanner resolves it against the project directory. +type rubyLockEntry struct { + pkg model.RubyPackage + local string +} + +type rubyLockSource struct { + section string + remotes []string + revision string + specs []model.RubyPackage +} + +var ( + rubyLockSpec = regexp.MustCompile(`^ ([^ ()!]+) \(([^-)]*)(?:-([^)]+))?\)$`) + rubyLockDep = regexp.MustCompile(`^ ([^ ()!]+)(?: \([^()]*\))?(!)?$`) + rubyLockChecksum = regexp.MustCompile(`^ ([^ ()!]+) \(([^-)]*)(?:-([^)]+))?\)(?: (\S+))?$`) + rubyLockAttr = regexp.MustCompile(`^ ([a-z_]+):(?: (.*))?$`) +) + +// parseRubyLock follows Bundler's lock text format. The version/platform +// split at the first hyphen is lock-only grammar (LockfileParser NAME_VERSION); +// it is never applied to filenames. Reasons are source-level coverage gaps. +func parseRubyLock(data []byte) ([]rubyLockEntry, []string, error) { + if int64(len(data)) > maxRubyMetadataBytes || !utf8.Valid(data) { + return nil, nil, errors.New("invalid lockfile") + } + reasons := []string{} + note := func(r string) { + if !slices.Contains(reasons, r) { + reasons = append(reasons, r) + } + } + type checksum struct { + values []string + invalid bool + } + var sources []*rubyLockSource + var cur *rubyLockSource + section, inSpecs, known := "", false, false + direct := map[string]bool{} + depsSeen, depsBad := false, false + sums := map[string]*checksum{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSuffix(line, "\r") + if strings.TrimSpace(line) == "" { + section, cur, inSpecs = "", nil, false + continue + } + if line[0] != ' ' { + section, cur, inSpecs = line, nil, false + switch line { + case "GEM", "GIT", "PATH", "PLUGIN SOURCE": + cur = &rubyLockSource{section: line} + sources = append(sources, cur) + if line == "PLUGIN SOURCE" { + // Plugin sources are never loaded; identities stay, origin is unknown. + note("unsupported_format") + } + case "DEPENDENCIES": + depsSeen = true + case "PLATFORMS", "RUBY VERSION", "BUNDLED WITH", "CHECKSUMS": + default: + // An unknown section never becomes complete empty inventory. + note("unsupported_format") + section = "\x00" + } + known = known || section != "\x00" + continue + } + switch { + case section == "\x00": + case cur != nil: + if m := rubyLockSpec.FindStringSubmatch(line); inSpecs && m != nil { + platform := cmp.Or(m[3], "ruby") + if !configaudit.RubyGemName(m[1]) || !rubyVersion(m[2]) || !configaudit.RubyToken(platform) { + note("parse_error") + continue + } + cur.specs = append(cur.specs, model.RubyPackage{PackageName: m[1], Version: m[2], Platform: platform}) + continue + } + // Six-space lines are a spec's own requirements, never packages. + if inSpecs && strings.HasPrefix(line, " ") && len(line) > 6 && line[6] != ' ' { + continue + } + if m := rubyLockAttr.FindStringSubmatch(line); m != nil { + inSpecs = m[1] == "specs" + switch m[1] { + case "remote": + cur.remotes = append(cur.remotes, m[2]) + case "revision": + cur.revision = m[2] + } + continue + } + note("parse_error") + case section == "DEPENDENCIES": + // The `!` suffix marks a pinned source, not part of the name. + if m := rubyLockDep.FindStringSubmatch(line); m != nil && configaudit.RubyGemName(m[1]) { + direct[m[1]] = true + continue + } + depsBad = true + note("parse_error") + case section == "CHECKSUMS": + m := rubyLockChecksum.FindStringSubmatch(line) + if m == nil || !configaudit.RubyGemName(m[1]) || !rubyVersion(m[2]) || !configaudit.RubyToken(cmp.Or(m[3], "ruby")) { + note("parse_error") + continue + } + c := &checksum{} + if m[4] != "" { + for _, item := range strings.Split(m[4], ",") { + algo, value, ok := strings.Cut(item, "=") + switch { + case !ok: + c.invalid = true + case algo != "sha256": + // Only SHA-256 is a supported recorded checksum. + case rubySHA256Pattern.MatchString(value): + c.values = append(c.values, value) + default: + c.invalid = true + } + } + } + sums[m[1]+"\x00"+m[2]+"\x00"+cmp.Or(m[3], "ruby")] = c + case section == "PLATFORMS" || section == "RUBY VERSION" || section == "BUNDLED WITH": + // Runtime sections are context, never package lists. + default: + note("parse_error") + } + } + if !known { + return nil, nil, errors.New("no lockfile sections") + } + if !depsSeen { + depsBad = true + note("parse_error") + } + var out []rubyLockEntry + for _, src := range sources { + origin, local, reason := rubyLockOrigin(src) + revision := "" + if src.section == "GIT" && src.revision != "" { + if rubyRevisionPattern.MatchString(src.revision) { + revision = src.revision + } else { + note("parse_error") + } + } + for _, p := range src.specs { + p.Reasons, p.VersionStatus, p.DependencyKind, p.Origin, p.Revision = []string{}, "known", "unknown", origin, revision + if reason != "" { + p.Reasons = append(p.Reasons, reason) + } + p.DependencyRelation = "transitive" + if direct[p.PackageName] { + p.DependencyRelation = "direct" + } + if depsBad { + p.DependencyRelation = "unknown" + } + p.ChecksumStatus, p.RecordedChecksums = "absent", []model.RubyRecordedChecksum{} + if c := sums[p.PackageName+"\x00"+p.Version+"\x00"+p.Platform]; c != nil { + for _, v := range c.values { + p.RecordedChecksums = append(p.RecordedChecksums, model.RubyRecordedChecksum{Algorithm: "sha256", Value: v, SourceKind: "lockfile", Verification: "not_verified"}) + } + switch { + case c.invalid: + p.ChecksumStatus = "partial" + note("checksum_invalid") + case len(c.values) > 0: + p.ChecksumStatus = "recorded" + } + } + out = append(out, rubyLockEntry{pkg: p, local: local}) + } + } + return out, reasons, nil +} + +// rubyLockOrigin maps one lock source block. Several GEM remotes are an older +// ambiguous format; local Git remotes are device-scoped paths, not endpoints. +func rubyLockOrigin(src *rubyLockSource) (model.RubyOrigin, string, string) { + unknown := model.RubyOrigin{Kind: "unknown"} + single := len(src.remotes) == 1 && src.remotes[0] != "" + switch src.section { + case "PLUGIN SOURCE": + return unknown, "", "" + case "PATH": + if !single { + return unknown, "", "path_unresolved" + } + return model.RubyOrigin{Kind: "path"}, src.remotes[0], "" + } + if !single { + return unknown, "", "origin_unresolved" + } + kind := "registry" + if src.section == "GIT" { + kind = "git" + if p, local := composerLocalURL(src.remotes[0], ""); local { + return model.RubyOrigin{Kind: "path"}, p, "" + } + } + url, _ := configaudit.SanitizeRubyURL(src.remotes[0]) + if url == "[redacted]" { + return model.RubyOrigin{Kind: kind, URL: url}, "", "origin_unresolved" + } + return model.RubyOrigin{Kind: kind, URL: url}, "", "" +} + +// parseRubyStub reads RubyGems' generated `# stub:` header, never the Ruby +// below it. Without the header RubyGems would evaluate the file, so it is +// unsupported; identity is never inferred from a filename. +func parseRubyStub(data []byte) (name, version, platform string, err error) { + lines := strings.SplitN(string(data), "\n", 3) + if len(lines) < 2 || !strings.HasPrefix(lines[0], "#") { + return "", "", "", errRubyUnsupportedFormat + } + line, ok := strings.CutPrefix(strings.TrimSuffix(lines[1], "\r"), "# stub: ") + if !ok { + return "", "", "", errRubyUnsupportedFormat + } + fields := strings.SplitN(line, " ", 4) + if !utf8.ValidString(line) || len(fields) < 3 || !configaudit.RubyGemName(fields[0]) || !rubyVersion(fields[1]) || !configaudit.RubyToken(fields[2]) { + return "", "", "", errors.New("invalid stub identity") + } + return fields[0], fields[1], fields[2], nil +} + +// parseRubyArchive reads identity from a .gem's single metadata member. It +// never extracts files or decompresses data.tar.gz; header iteration +// continues so a later duplicate identity member is still rejected. +func parseRubyArchive(data []byte) (name, version, platform, reason string) { + tr := tar.NewReader(bytes.NewReader(data)) + var metadata []byte + found := false + for i := 0; ; i++ { + h, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", "", "", "parse_error" + } + if i >= maxGoZipEntries { + return "", "", "", "entry_limit" + } + if h.Name != "metadata.gz" && h.Name != "metadata" { + continue + } + if found || h.Typeflag != tar.TypeReg { + return "", "", "", "parse_error" + } + found = true + if h.Size > maxRubyMetadataBytes { + return "", "", "", "size_limit" + } + raw, err := io.ReadAll(io.LimitReader(tr, maxRubyMetadataBytes+1)) + if err != nil { + return "", "", "", "parse_error" + } + if h.Name == "metadata.gz" { + zr, err := gzip.NewReader(bytes.NewReader(raw)) + if err != nil { + return "", "", "", "parse_error" + } + raw, err = io.ReadAll(io.LimitReader(zr, maxRubyMetadataBytes+1)) + if err != nil { + return "", "", "", "parse_error" + } + } + // The identity bound applies after decompression. + if int64(len(raw)) > maxRubyMetadataBytes { + return "", "", "", "size_limit" + } + metadata = raw + } + if !found { + return "", "", "", "unsupported_format" + } + name, version, platform, ok := rubyMetadataIdentity(metadata) + if !ok { + return "", "", "", "parse_error" + } + return name, version, platform, "" +} + +// rubyMetadataIdentity reads name, version and platform from YAML nodes. +// Ruby object tags are never instantiated and aliases never expanded; an +// alias or duplicate on an identity field rejects it, unrelated nodes do not. +func rubyMetadataIdentity(data []byte) (name, version, platform string, ok bool) { + var doc yaml.Node + if !utf8.Valid(data) || yaml.Unmarshal(data, &doc) != nil || doc.Kind != yaml.DocumentNode || len(doc.Content) != 1 || doc.Content[0].Kind != yaml.MappingNode { + return "", "", "", false + } + root := doc.Content[0] + seen := map[string]bool{} + for i := 0; i+1 < len(root.Content); i += 2 { + key, value := root.Content[i], root.Content[i+1] + if key.Kind != yaml.ScalarNode || key.Value != "name" && key.Value != "version" && key.Value != "platform" { + continue + } + if seen[key.Value] { + return "", "", "", false + } + seen[key.Value] = true + if key.Value == "version" && value.Kind == yaml.MappingNode { + value = rubyYAMLField(value, "version") + } + text, valid := rubyYAMLScalar(value) + if !valid { + return "", "", "", false + } + switch key.Value { + case "name": + name = text + case "version": + version = text + default: + platform = text + } + } + return name, version, platform, configaudit.RubyGemName(name) && rubyVersion(version) && configaudit.RubyToken(platform) +} + +// Aliases are their own node kind, so they never pass as identity scalars. +func rubyYAMLScalar(n *yaml.Node) (string, bool) { + if n == nil || n.Kind != yaml.ScalarNode { + return "", false + } + switch n.ShortTag() { + case "!!str", "!!int", "!!float": + return strings.TrimSpace(n.Value), true + } + return "", false +} + +func rubyYAMLField(m *yaml.Node, field string) *yaml.Node { + var out *yaml.Node + for i := 0; i+1 < len(m.Content); i += 2 { + if k := m.Content[i]; k.Kind == yaml.ScalarNode && k.Value == field { + if out != nil { + return nil + } + out = m.Content[i+1] + } + } + return out +} diff --git a/internal/detector/rubymetadata_test.go b/internal/detector/rubymetadata_test.go new file mode 100644 index 00000000..82ad0070 --- /dev/null +++ b/internal/detector/rubymetadata_test.go @@ -0,0 +1,375 @@ +package detector + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "errors" + "fmt" + "slices" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// rubyStubHeader is the generated header of a native RubyGems spec +// (macOS system Ruby 2.6 default gem forwardable-1.2.0.gemspec). +const rubyStubHeader = "# -*- encoding: utf-8 -*-\n# stub: forwardable 1.2.0 ruby lib\n\nGem::Specification.new do |s|\n s.name = \"forwardable\".freeze\nend\n" + +// rubyNativeMetadata is the head of a native .gem's metadata.gz +// (did_you_mean-1.3.0.gem), with Ruby object tags left in place. +const rubyNativeMetadata = `--- !ruby/object:Gem::Specification +name: did_you_mean +version: !ruby/object:Gem::Version + version: 1.3.0 +platform: ruby +authors: +- Yuki Nishijima +autorequire: +bindir: bin +cert_chain: [] +dependencies: +- !ruby/object:Gem::Dependency + name: bundler + requirement: !ruby/object:Gem::Requirement + requirements: + - - ">=" + - !ruby/object:Gem::Version + version: '0' + type: :development +` + +const rubyHashA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func rubyMetadataGz(t *testing.T, metadata string) string { + t.Helper() + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + _, _ = zw.Write([]byte(metadata)) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.String() +} + +// rubyGemArchive builds a .gem tar from name/body pairs in order. +func rubyGemArchive(t *testing.T, members ...string) string { + t.Helper() + var buf bytes.Buffer + w := tar.NewWriter(&buf) + for i := 0; i+1 < len(members); i += 2 { + if err := w.WriteHeader(&tar.Header{Name: members[i], Mode: 0o444, Size: int64(len(members[i+1])), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(members[i+1])) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.String() +} + +func rubyDeclarationText(m *rubyManifest) []string { + out := []string{} + for _, d := range m.declarations { + out = append(out, fmt.Sprintf("%s %s %q %v %v %s %s %s", d.name, d.kind, d.requirements, d.groups, d.platforms, d.origin, d.url, d.path)) + } + return out +} + +func TestRubyGemfileParser(t *testing.T) { + m, err := parseRubyGemfile([]byte(`# frozen_string_literal: true +# gem "commented" +source "https://rubygems.org" +ruby "3.3.0" +gemspec path: "lib", name: "mylib" +eval_gemfile "Gemfile.shared" +lockfile "Custom.lock" + +gem "rails", "~> 7.1", ">= 7.1.2" +gem 'rack', require: false +gem 'rack', require: false +gem("puma", + "~> 6.0") +group :development, :test, optional: true do + gem "rspec", groups: [:ci] +end +platforms :mri do + gem "byebug", platform: :windows +end +source "https://gems.example.invalid", cooldown: 3 do + gem "private-gem" +end +git "https://github.com/org/repo.git", branch: "main" do + gem "forked" +end +gem "local", path: "../local" +gem "tool", git: "git@github.com:org/tool.git", tag: "v1" +gem "pinned", :source => "https://other.example.invalid" +`)) + if err != nil { + t.Fatal(err) + } + want := []string{ + `rails unknown ["~> 7.1" ">= 7.1.2"] [] [] `, + `rack unknown [] [] [] `, + `puma unknown ["~> 6.0"] [] [] `, + `rspec unknown [] [ci development test] [] `, + `byebug unknown [] [] [mri windows] `, + `private-gem unknown [] [] [] registry https://gems.example.invalid `, + `forked unknown [] [] [] git https://github.com/org/repo.git `, + `local unknown [] [] [] path ../local`, + `tool unknown [] [] [] git git@github.com:org/tool.git `, + `pinned unknown [] [] [] registry https://other.example.invalid `, + } + if got := rubyDeclarationText(m); !slices.Equal(got, want) { + t.Fatalf("declarations\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + if !slices.Equal(m.globals, []string{"https://rubygems.org"}) || !slices.Equal(m.evals, []string{"Gemfile.shared"}) || m.lockfile != "Custom.lock" || !m.lockfileSet || m.noLockfile || + !slices.Equal(m.gemspecs, []rubyGemspecRef{{"lib", "mylib"}}) || m.partial { + t.Fatalf("manifest %+v", m) + } + wantSources := []configaudit.RubyManifestSource{{Type: "registry", URL: "https://rubygems.org"}, {Type: "registry", URL: "https://gems.example.invalid", Cooldown: "3"}, + {Type: "git", URL: "https://github.com/org/repo.git"}, {Type: "git", URL: "git@github.com:org/tool.git"}, {Type: "registry", URL: "https://other.example.invalid"}} + if !slices.Equal(m.sources, wantSources) { + t.Fatalf("sources %+v", m.sources) + } + if m, err := parseRubyGemfile([]byte("lockfile false\n")); err != nil || !m.noLockfile || !m.lockfileSet { + t.Fatalf("lockfile false: %+v %v", m, err) + } + + // Anything outside the literal subset rejects the whole file; no partial + // declaration set is ever returned. + for _, tc := range []struct{ name, src string }{ + {"heredoc", "gem \"x\", <<~EOS\n1\nEOS\n"}, + {"interpolation", "gem \"x#{1}\"\n"}, + {"modifier", "gem \"x\" if ENV[\"Y\"]\n"}, + {"conditional block", "if true\n gem \"x\"\nend\n"}, + {"backtick", "gem `cat name`\n"}, + {"percent literal", "gem %w[x].first\n"}, + {"receiver call", "ENV.fetch(\"X\")\n"}, + {"constant value", "gem \"x\", MyGem::VERSION\n"}, + {"global variable", "gem $name\n"}, + {"brace block", "gem(\"x\") { }\n"}, + {"embedded doc", "=begin\ngem \"x\"\n=end\n"}, + {"data section", "gem \"x\"\n__END__\n"}, + {"plugin source", "source \"https://x.example.invalid\", type: \"plugin\"\n"}, + {"conflicting duplicate", "gem \"x\", \"1\"\ngem \"x\", \"2\"\n"}, + {"two origins", "gem \"x\", git: \"a\", path: \"b\"\n"}, + {"nested gemspec", "group :test do\n gemspec\nend\n"}, + {"unknown option", "gem \"x\", install_if: true\n"}, + {"unterminated block", "group :test do\n gem \"x\"\n"}, + {"invalid utf8", "gem \"\xff\"\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + if m, err := parseRubyGemfile([]byte(tc.src)); err == nil { + t.Fatalf("accepted %q: %+v", tc.src, m) + } + }) + } +} + +func TestRubyGemspecParser(t *testing.T) { + m, err := parseRubyGemspec([]byte(`# frozen_string_literal: true +Gem::Specification.new do |spec| + spec.name = "mylib" + spec.version = "1.2.3" + spec.summary = "CANARY summary" + spec.authors = ["CANARY author"] + spec.files = ["lib/mylib.rb"] + spec.metadata = { "source_code_uri" => "https://example.invalid" } + spec.add_dependency "rack", ">= 2", "< 4" + spec.add_runtime_dependency "json" + spec.add_development_dependency "rspec", "~> 3.0" +end +`)) + if err != nil { + t.Fatal(err) + } + want := []string{`rack runtime [">= 2" "< 4"] [] [] `, `json runtime [] [] [] `, `rspec development ["~> 3.0"] [] [] `} + if got := rubyDeclarationText(m); m.name != "mylib" || m.version != "1.2.3" || !slices.Equal(got, want) { + t.Fatalf("gemspec %s %s %v", m.name, m.version, got) + } + // Generated library gemspecs stay unsupported until built or locked. + for _, src := range []string{ + "Gem::Specification.new do |s|\n s.version = MyLib::VERSION\nend\n", + "Gem::Specification.new do |s|\n s.files = `git ls-files`.split\nend\n", + "Gem::Specification.new do |s|\n s.files = Dir[\"lib/**\"]\nend\n", + "require_relative \"lib/version\"\nGem::Specification.new do |s|\nend\n", + "Gem::Specification.new do |s|\n t.name = \"other\"\nend\n", + "Gem::Specification.new do |s|\n s.unknown = \"x\"\nend\n", + "Gem::Specification.new do |s|\nend\nputs 1\n", + } { + if _, err := parseRubyGemspec([]byte(src)); !errors.Is(err, errRubyUnsupportedFormat) { + t.Errorf("accepted generated gemspec %q: %v", src, err) + } + } +} + +func TestRubyLockParser(t *testing.T) { + lock := `GIT + remote: https://github.com/org/forked.git + revision: 0123456789abcdef0123456789abcdef01234567 + branch: main + specs: + forked (0.1.0) + rack (>= 2) + +PATH + remote: ../local + specs: + local (1.0.0) + +GEM + remote: https://rubygems.org/ + specs: + nokogiri (1.16.0-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.0-x86_64-linux) + racc (~> 1.4) + racc (1.7.3) + rack (3.0.8) + +PLATFORMS + arm64-darwin + x86_64-linux + +DEPENDENCIES + forked! + local! + nokogiri + rack (>= 2) + +CHECKSUMS + nokogiri (1.16.0-arm64-darwin) sha256=` + rubyHashA + ` + nokogiri (1.16.0-x86_64-linux) sha256=bad + rack (3.0.8) + +BUNDLED WITH + 2.5.3 +` + for _, eol := range []string{"\n", "\r\n"} { + entries, reasons, err := parseRubyLock([]byte(strings.ReplaceAll(lock, "\n", eol))) + if err != nil || !slices.Equal(reasons, []string{"checksum_invalid"}) { + t.Fatalf("lock: %v %v", reasons, err) + } + got := []string{} + for _, e := range entries { + p := e.pkg + got = append(got, fmt.Sprintf("%s %s %s %s %s %s %s %s %d %s", p.PackageName, p.Version, p.Platform, p.DependencyRelation, p.Origin.Kind, p.Origin.URL, e.local, p.ChecksumStatus, len(p.RecordedChecksums), p.Revision)) + } + want := []string{ + "forked 0.1.0 ruby direct git https://github.com/org/forked.git absent 0 0123456789abcdef0123456789abcdef01234567", + "local 1.0.0 ruby direct path ../local absent 0 ", + "nokogiri 1.16.0 arm64-darwin direct registry https://rubygems.org/ recorded 1 ", + "nokogiri 1.16.0 x86_64-linux direct registry https://rubygems.org/ partial 0 ", + "racc 1.7.3 ruby transitive registry https://rubygems.org/ absent 0 ", + "rack 3.0.8 ruby direct registry https://rubygems.org/ absent 0 ", + } + if !slices.Equal(got, want) { + t.Fatalf("entries\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + } + + for _, tc := range []struct { + name, lock string + reasons []string + origin string + relation string + }{ + {"missing dependencies", "GEM\n remote: https://rubygems.org/\n specs:\n x (1.0)\n", []string{"parse_error"}, "registry", "unknown"}, + {"unknown section", "GEM\n remote: https://rubygems.org/\n specs:\n x (1.0)\n\nFUTURE\n y\n\nDEPENDENCIES\n x\n", []string{"unsupported_format"}, "registry", "direct"}, + {"plugin source", "PLUGIN SOURCE\n remote: https://plugin.example.invalid\n type: custom\n specs:\n x (1.0)\n\nDEPENDENCIES\n x!\n", []string{"unsupported_format"}, "unknown", "direct"}, + {"several remotes", "GEM\n remote: https://a.example.invalid/\n remote: https://b.example.invalid/\n specs:\n x (1.0)\n\nDEPENDENCIES\n x\n", []string{}, "unknown", "direct"}, + {"credential remote", "GEM\n remote: https://CANARY_TOKEN@gems.example.invalid/\n specs:\n x (1.0)\n\nDEPENDENCIES\n x\n", []string{}, "registry", "direct"}, + {"bad revision", "GIT\n remote: https://github.com/org/x.git\n revision: main\n specs:\n x (1.0)\n\nDEPENDENCIES\n x!\n", []string{"parse_error"}, "git", "direct"}, + {"invalid spec keeps neighbors", "GEM\n remote: https://rubygems.org/\n specs:\n -bad (1.0)\n x (1.0)\n\nDEPENDENCIES\n x\n", []string{"parse_error"}, "registry", "direct"}, + } { + t.Run(tc.name, func(t *testing.T) { + entries, reasons, err := parseRubyLock([]byte(tc.lock)) + if err != nil || !slices.Equal(reasons, tc.reasons) || len(entries) != 1 { + t.Fatalf("entries %+v reasons %v err %v", entries, reasons, err) + } + p := entries[0].pkg + if p.PackageName != "x" || p.Origin.Kind != tc.origin || p.DependencyRelation != tc.relation || p.Revision != "" || strings.Contains(p.Origin.URL, "CANARY") { + t.Fatalf("entry %+v", p) + } + }) + } + for _, bad := range []string{"", " indented only\n", "\xff"} { + if _, _, err := parseRubyLock([]byte(bad)); err == nil { + t.Errorf("accepted %q", bad) + } + } +} + +func TestRubyStubAndArchiveIdentity(t *testing.T) { + for _, tc := range []struct { + name, data string + gem, version, platform string + unsupported bool + }{ + {"native default", rubyStubHeader, "forwardable", "1.2.0", "ruby", false}, + {"native platform", "# -*- encoding: utf-8 -*-\r\n# stub: nokogiri 1.16.0 arm64-darwin lib\r\n", "nokogiri", "1.16.0", "arm64-darwin", false}, + {"no stub header", "Gem::Specification.new do |s|\nend\n", "", "", "", true}, + {"filename is never identity", "# -*- encoding: utf-8 -*-\nGem::Specification.new do |s|\nend\n", "", "", "", true}, + {"invalid identity", "#\n# stub: -bad 1.0 ruby lib\n", "", "", "", false}, + {"invalid version", "#\n# stub: x v1 ruby lib\n", "", "", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + name, version, platform, err := parseRubyStub([]byte(tc.data)) + if name != tc.gem || version != tc.version || platform != tc.platform || (err == nil) != (tc.gem != "") || errors.Is(err, errRubyUnsupportedFormat) != tc.unsupported { + t.Fatalf("stub = %q %q %q %v", name, version, platform, err) + } + }) + } + + native := rubyMetadataGz(t, rubyNativeMetadata) + for _, tc := range []struct { + name, archive, reason string + }{ + {"native gz", rubyGemArchive(t, "metadata.gz", native, "data.tar.gz", "never decompressed", "checksums.yaml.gz", ""), ""}, + {"plain metadata", rubyGemArchive(t, "metadata", rubyNativeMetadata), ""}, + {"irrelevant alias", rubyGemArchive(t, "metadata", "a: &x [1]\nb: *x\n"+rubyNativeMetadata[strings.Index(rubyNativeMetadata, "name:"):]), ""}, + {"both variants", rubyGemArchive(t, "metadata.gz", native, "metadata", rubyNativeMetadata), "parse_error"}, + {"duplicate member", rubyGemArchive(t, "metadata.gz", native, "data.tar.gz", "", "metadata.gz", native), "parse_error"}, + {"no metadata", rubyGemArchive(t, "data.tar.gz", ""), "unsupported_format"}, + {"aliased identity", rubyGemArchive(t, "metadata", "x: &n did_you_mean\nname: *n\nversion: 1.0\nplatform: ruby\n"), "parse_error"}, + {"duplicate identity key", rubyGemArchive(t, "metadata", "name: a\nname: b\nversion: 1.0\nplatform: ruby\n"), "parse_error"}, + {"tagged identity", rubyGemArchive(t, "metadata", "name: !ruby/sym x\nversion: 1.0\nplatform: ruby\n"), "parse_error"}, + {"missing platform", rubyGemArchive(t, "metadata", "name: x\nversion: 1.0\n"), "parse_error"}, + {"not tar", "not a tar archive", "parse_error"}, + } { + t.Run(tc.name, func(t *testing.T) { + name, version, platform, reason := parseRubyArchive([]byte(tc.archive)) + if reason != tc.reason || tc.reason == "" && (name != "did_you_mean" || version != "1.3.0" || platform != "ruby") || tc.reason != "" && name != "" { + t.Fatalf("archive = %q %q %q %q", name, version, platform, reason) + } + }) + } + + // The bound applies to the expanded member, so a small gzip cannot inflate. + old := maxRubyMetadataBytes + maxRubyMetadataBytes = 1024 + t.Cleanup(func() { maxRubyMetadataBytes = old }) + bomb := rubyMetadataGz(t, rubyNativeMetadata+"padding: "+strings.Repeat("x", 4096)+"\n") + if _, _, _, reason := parseRubyArchive([]byte(rubyGemArchive(t, "metadata.gz", bomb))); reason != "size_limit" { + t.Fatalf("expanded metadata bound = %q", reason) + } + if _, _, _, reason := parseRubyArchive([]byte(rubyGemArchive(t, "metadata", strings.Repeat("x", 2048)))); reason != "size_limit" { + t.Fatalf("plain metadata bound = %q", reason) + } +} + +func TestRubyLockOriginLocalGit(t *testing.T) { + origin, local, reason := rubyLockOrigin(&rubyLockSource{section: "GIT", remotes: []string{"/srv/repos/x"}}) + if origin != (model.RubyOrigin{Kind: "path"}) || local != "/srv/repos/x" || reason != "" { + t.Fatalf("local git = %+v %q %q", origin, local, reason) + } + origin, _, reason = rubyLockOrigin(&rubyLockSource{section: "GIT", remotes: []string{"git@github.com:org/x.git"}}) + if origin.URL != "[redacted]" || reason != "origin_unresolved" { + t.Fatalf("scp git = %+v %q", origin, reason) + } +} diff --git a/internal/detector/rubyscan.go b/internal/detector/rubyscan.go new file mode 100644 index 00000000..ffe26a59 --- /dev/null +++ b/internal/detector/rubyscan.go @@ -0,0 +1,1373 @@ +package detector + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "io/fs" + "maps" + "os/user" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +var ( + maxRubyWalkEntries = 100_000 + maxRubyWalkDepth = 128 + maxRubyRecords = 50_000 + maxRubyOutputBytes = 16 << 20 + maxRubyListEntries = 10_000 +) + +// maxRubyEvalDepth keeps eval_gemfile chains within the eight-ancestor +// source bound: search root, manifest, evaluated manifests, then a gemspec. +const maxRubyEvalDepth = 4 + +// RubyScanner reads metadata only. The raw executor is required because a +// UserAwareExecutor may source a login shell when reading environment values. +type RubyScanner struct { + exec executor.Executor + log *progress.Logger + protection func(string, *bool) (func(string) string, func(string) bool) +} + +func NewRubyScanner(exec executor.Executor, log *progress.Logger) *RubyScanner { + if log == nil { + log = progress.NewNoop() + } + return &RubyScanner{exec: exec, log: log, protection: configaudit.GoProtection} +} + +// rubyFile is a Gemfile, evaluated Gemfile or gemspec read once. Referenced +// files can be shared by several projects. +type rubyFile struct { + src *model.RubySource + manifest *rubyManifest + status string // configuration file status vocabulary + ref bool +} + +type rubyProjectState struct { + project model.RubyProject + gemspec, kept bool +} + +type rubyDiskLock struct{ path, parent string } + +// rubyInstall builds a receipt's installation from its validated identity. +type rubyInstall func(name, version, platform string, p *model.RubyPackage) *model.RubyInstallation + +const ( + rubyCacheArchives = iota // gem home cache: *.gem + rubyCacheSlugs // Bundler user cache: /*.gem + rubyCacheVendor // project cache: *.gem and serialized checkout stubs +) + +type rubyScan struct { + ctx context.Context + exec executor.Executor + home, identity, goos string + roots []string + guard func(string) string + volume func(string) bool + files executor.Executor + config *configaudit.RubyConfigSnapshot + inv model.RubyInventory + sources []*model.RubySource + byID map[string]*model.RubySource + projects map[string]*rubyProjectState + projectDirs map[string]string + reads map[string]*rubyFile + locks map[string]bool + diskLocks map[string]rubyDiskLock + homes map[string]bool + caches map[string]bool + pruned map[string]bool + seen map[string]bool + remaining int + bytes int + limited bool + unproven []string // coverage lost by a consumer of shared manifests +} + +func emptyRubyInventory() model.RubyInventory { + return model.RubyInventory{SchemaVersion: model.RubyInventorySchemaVersion, Status: "complete", Reasons: []string{}, Sources: []model.RubySource{}, Projects: []model.RubyProject{}, Packages: []model.RubyPackage{}} +} + +func (s *RubyScanner) Scan(ctx context.Context, target *user.User, searchDirs []string, includeNetworkVolumes *bool) (*model.RubyInventory, *model.RubyConfigAudit) { + start := time.Now() + inv := emptyRubyInventory() + configDetector := configaudit.NewRubyConfigDetector(s.exec) + if target == nil || target.Username == "" || target.HomeDir == "" || !filepath.IsAbs(target.HomeDir) || isGoServiceIdentity(s.exec.GOOS(), target) { + inv.Status, inv.Reasons = "partial", []string{"user_unresolved"} + audit := configDetector.Detect(ctx, configaudit.RubyConfigScope{}).Finish() + return &inv, &audit + } + guard, volume := s.protection(target.HomeDir, includeNetworkVolumes) + // Reserve the audit's separate 256 KiB allowance before collecting inventory. + c := &rubyScan{ctx: ctx, exec: s.exec, home: filepath.Clean(target.HomeDir), identity: target.Username, goos: s.exec.GOOS(), guard: guard, volume: volume, inv: inv, + byID: map[string]*model.RubySource{}, projects: map[string]*rubyProjectState{}, projectDirs: map[string]string{}, reads: map[string]*rubyFile{}, locks: map[string]bool{}, diskLocks: map[string]rubyDiskLock{}, + homes: map[string]bool{}, caches: map[string]bool{}, pruned: map[string]bool{}, seen: map[string]bool{}, remaining: maxRubyRecords, bytes: goJSONSize(&inv) + (256 << 10)} + c.roots = []string{c.home} + dirs := []string{} + for _, dir := range searchDirs { + if dir == "" { + continue + } + dir = canonicalNoStat(dir, c.home) + if !filepath.IsAbs(dir) || !composerText(dir, 4096) { + c.degrade(nil, "path_unresolved") + continue + } + dirs = append(dirs, dir) + c.roots = append(c.roots, dir) + } + c.files = s.exec.GuardedFiles(c.roots, guard, maxRubyMetadataBytes) + current, err := s.exec.CurrentUser() + verified := err == nil && current != nil && target.Uid != "" && current.Uid == target.Uid + c.config = configDetector.Detect(ctx, configaudit.RubyConfigScope{Username: target.Username, Home: c.home, Protected: guard, Volume: volume, ProcessVerified: verified}) + c.config.Prefixes(c.knownRoots()) + // Configured leads follow standard roots, so a shared root keeps its scope. + for _, home := range c.config.GemHomes { + c.gemHome(home, "user", "", "") + } + if c.config.UserPlugin != "" { + c.gemHome(c.config.UserPlugin, "user", "", "") + } + if c.config.UserCache != "" { + c.cacheRoot(filepath.Join(c.config.UserCache, "gems"), "user", "", "", rubyCacheSlugs) + } + if c.config.Gemfile != "" { + c.project(c.config.Gemfile, "", true) + } + c.walkRoots(dirs) + c.independentLocks() + if reason := c.stopReason(); reason != "" { + c.unprove(reason) + } + // An unread or undiscovered consumer may use any shared file, so no + // shared declaration's absence is proven. + for _, f := range c.reads { + if f.ref { + for _, reason := range c.unproven { + c.degrade(f.src, reason) + } + } + } + audit := c.config.Finish() + c.finish() + if goJSONSize(&c.inv)+goJSONSize(&audit) > maxRubyOutputBytes { + c.inv = emptyRubyInventory() + c.degrade(nil, "output_size_limit") + } + s.log.Debug("ruby scan: inventory=%s (%d sources, %d projects, %d packages) audit=%s (%d files, %d findings) in %dms", + c.inv.Status, len(c.inv.Sources), len(c.inv.Projects), len(c.inv.Packages), audit.Status, len(audit.Files), len(audit.Findings), time.Since(start).Milliseconds()) + return &c.inv, &audit +} +func (c *rubyScan) key(p string) string { return configaudit.GoPathKey(c.goos, p) } +func (c *rubyScan) reason(err error, p string) string { + return configaudit.CargoReadReason(err, p, c.volume) +} +func (c *rubyScan) degrade(src *model.RubySource, reason string) { + c.inv.Status = "partial" + if !slices.Contains(c.inv.Reasons, reason) { + c.inv.Reasons = append(c.inv.Reasons, reason) + } + for ; src != nil; src = c.byID[src.ParentSourceID] { + src.Status = "partial" + if !slices.Contains(src.Reasons, reason) { + src.Reasons = append(src.Reasons, reason) + } + } +} +func (c *rubyScan) charge(src *model.RubySource, record any) bool { + if c.remaining <= 0 { + c.degrade(src, "record_limit") + return false + } + // Reserve later coverage reasons, child IDs and separators per record. + size := goJSONSize(record) + 128 + if c.limited || c.bytes+size > maxRubyOutputBytes { + c.limited = true + c.degrade(src, "output_size_limit") + return false + } + c.remaining-- + c.bytes += size + return true +} + +// unprove records coverage a consumer of shared manifests lost. +func (c *rubyScan) unprove(reason string) { + if !slices.Contains(c.unproven, reason) { + c.unproven = append(c.unproven, reason) + } +} + +// unvisited degrades a walk that may have missed a consumer. +func (c *rubyScan) unvisited(src *model.RubySource, reason string) { + c.degrade(src, reason) + c.unprove(reason) +} +func (c *rubyScan) stopReason() string { + switch { + case c.ctx.Err() != nil: + return "deadline_exceeded" + case c.limited: + return "output_size_limit" + case c.remaining <= 0: + return "record_limit" + } + return "" +} +func (c *rubyScan) stopped(src *model.RubySource) bool { + if reason := c.stopReason(); reason != "" { + c.degrade(src, reason) + return true + } + return false +} +func (c *rubyScan) source(kind, path, parent string) *model.RubySource { + if !filepath.IsAbs(path) || !composerText(path, 4096) { + c.degrade(c.byID[parent], "path_unresolved") + return nil + } + id := configaudit.GoSourceID(c.identity, kind, path) + if src := c.byID[id]; src != nil { + return src + } + owner := c.byID[parent] + if owner == nil { + parent = "" + } + src := &model.RubySource{SourceID: id, Kind: kind, Path: path, ParentSourceID: parent, Status: "complete", Presence: "present", Reasons: []string{}, DiscoveredSources: []string{}} + if !c.charge(owner, src) { + return nil + } + c.byID[id] = src + c.sources = append(c.sources, src) + return src +} +func (c *rubyScan) fs(path string, limit int64) executor.Executor { + if configaudit.GoWithinRoots(c.goos, path, c.roots) { + return c.exec.GuardedFiles(c.roots, c.guard, limit) + } + // Exact metadata and shared roots permit only targeted access, never a new walk. + return c.exec.GuardedFiles([]string{path}, c.guard, limit) +} +func (c *rubyScan) read(src *model.RubySource, targeted bool, limit int64) ([]byte, bool) { + if c.stopped(src) { + src.Presence = "unknown" + return nil, false + } + files := c.fs(src.Path, limit) + info, err := files.Stat(src.Path) + switch { + case errors.Is(err, fs.ErrNotExist): + src.Presence = "absent" + if !targeted { + src.Presence = "unknown" + c.degrade(src, "changed_during_scan") + } + return nil, false + case err != nil: + src.Presence = "unknown" + c.degrade(src, c.reason(err, src.Path)) + return nil, false + case !info.Mode().IsRegular(): + c.degrade(src, "unsupported_entry") + return nil, false + case info.Size() > limit: + c.degrade(src, "size_limit") + return nil, false + } + data, err := files.ReadFile(src.Path) + if err != nil { + src.Presence = "unknown" + reason := c.reason(err, src.Path) + if errors.Is(err, fs.ErrNotExist) { + reason = "changed_during_scan" + } + c.degrade(src, reason) + return nil, false + } + return data, true +} + +// list enumerates one directory for its owner. A missing directory has no +// children; any other failure or truncation leaves the owner partial. +func (c *rubyScan) list(owner *model.RubySource, dir string) []fs.DirEntry { + if c.stopped(owner) { + return nil + } + entries, more, err := c.fs(dir, 0).ReadDirLimit(dir, maxRubyListEntries) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + c.degrade(owner, c.reason(err, dir)) + return nil + } + if more { + c.degrade(owner, "entry_limit") + } + return entries +} + +// expand matches `*` segments against guarded directory listings, never Glob. +// It also returns why any listing left the match set incomplete. +func (c *rubyScan) expand(base string, parts ...string) ([]string, []string) { + paths := []string{base} + var reasons []string + for _, part := range parts { + var next []string + for _, p := range paths { + if !strings.Contains(part, "*") { + next = append(next, filepath.Join(p, part)) + continue + } + if c.stopped(nil) { + return nil, append(reasons, c.stopReason()) + } + entries, _, err := c.fs(p, 0).ReadDirLimit(p, maxRubyListEntries) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + reason := c.reason(err, p) + c.degrade(nil, reason) + reasons = append(reasons, reason) + } + continue + } + pattern := part + if c.goos == model.PlatformWindows { + pattern = strings.ToLower(part) + } + for _, e := range entries { + name := e.Name() + if c.goos == model.PlatformWindows { + name = strings.ToLower(name) + } + if ok, _ := filepath.Match(pattern, name); ok && (e.IsDir() || e.Type()&fs.ModeSymlink != 0) { + next = append(next, filepath.Join(p, e.Name())) + } + } + } + paths = next + } + return paths, reasons +} + +func (c *rubyScan) scopeOf(path string) string { + if configaudit.GoWithinRoots(c.goos, path, []string{c.home}) { + return "user" + } + return "system" +} + +// knownRoots enumerates runtime-manager and shared gem homes and returns the +// interpreter prefixes that held one, for their compiled-sysconfdir gemrc, +// with the reasons prefix discovery was incomplete. +func (c *rubyScan) knownRoots() ([]string, []string) { + type root struct { + base string + parts []string + } + home, env := c.home, c.config.Env + prefixes := []root{ + {cmp.Or(env("RBENV_ROOT"), filepath.Join(home, ".rbenv")), []string{"versions", "*"}}, + {filepath.Join(home, ".rvm"), []string{"rubies", "*"}}, + {cmp.Or(env("ASDF_DATA_DIR"), filepath.Join(home, ".asdf")), []string{"installs", "ruby", "*"}}, + {cmp.Or(env("MISE_DATA_DIR"), filepath.Join(home, ".local", "share", "mise")), []string{"installs", "ruby", "*"}}, + {filepath.Join(home, ".rubies"), []string{"*"}}, + } + homes := []root{ + {filepath.Join(home, ".gem"), []string{"*", "*"}}, + {filepath.Join(cmp.Or(env("XDG_DATA_HOME"), filepath.Join(home, ".local", "share")), "gem"), []string{"*", "*"}}, + {filepath.Join(home, ".rvm", "gems"), []string{"*"}}, + } + // Homebrew: the prefix's shared gems, the opt link, and bounded Cellar kegs + // whose default-gem receipts can outlive a moved opt link. + brew := func(prefix string) { + prefixes = append(prefixes, root{prefix, nil}, root{prefix, []string{"opt", "ruby*"}}, root{prefix, []string{"Cellar", "ruby*", "*"}}) + } + switch c.goos { + case model.PlatformDarwin: + prefixes = append(prefixes, root{"/System/Library/Frameworks/Ruby.framework/Versions", []string{"*", "usr"}}, root{"/opt/rubies", []string{"*"}}) + homes = append(homes, root{"/Library/Ruby/Gems", []string{"*"}}) + brew("/opt/homebrew") + brew("/usr/local") + case model.PlatformLinux: + prefixes = append(prefixes, root{"/usr", nil}, root{"/usr/local", nil}, root{"/opt/rubies", []string{"*"}}) + homes = append(homes, root{"/var/lib/gems", []string{"*"}}, root{"/usr/share/rubygems-integration", []string{"*"}}) + brew("/home/linuxbrew/.linuxbrew") + case model.PlatformWindows: + prefixes = append(prefixes, root{filepath.Join(home, "AppData", "Local", "Programs"), []string{"Ruby*"}}, + root{`C:\Program Files`, []string{"Ruby*"}}, root{`C:\`, []string{"Ruby*"}}, root{`C:\tools`, []string{"ruby*"}}) + } + out := []string{} + seen := map[string]bool{} + var incomplete []string + for _, r := range prefixes { + candidates, reasons := c.expand(r.base, r.parts...) + incomplete = append(incomplete, reasons...) + for _, p := range candidates { + if c.stopped(nil) { + return out, append(incomplete, c.stopReason()) + } + phys := p + if resolved, err := c.fs(p, 0).EvalSymlinks(p); err == nil { + phys = resolved + } + if seen[c.key(phys)] { + continue + } + seen[c.key(phys)] = true + scope := c.scopeOf(p) + found := false + gemHomes, reasons := c.expand(p, "lib", "ruby", "gems", "*") + incomplete = append(incomplete, reasons...) + for _, h := range gemHomes { + found = c.gemHome(h, scope, "", "") || found + } + // TruffleRuby keeps its gem home directly under lib. + found = c.gemHome(filepath.Join(p, "lib", "gems"), scope, "", "") || found + if !found { + continue + } + out = append(out, p) + if scope == "user" { + // A manager-installed interpreter is never a project tree; + // shared system prefixes like /usr can still contain projects. + c.pruned[c.key(p)], c.pruned[c.key(phys)] = true, true + } + } + } + // A stopped gem-home probe can hide a prefix without a listing failure. + if reason := c.stopReason(); reason != "" { + incomplete = append(incomplete, reason) + } + for _, r := range homes { + paths, _ := c.expand(r.base, r.parts...) + for _, p := range paths { + c.gemHome(p, c.scopeOf(p), "", "") + } + } + return out, incomplete +} + +// gemHome enumerates one RubyGems directory: ordinary and default receipts, +// its archive cache and Bundler's Git checkouts. It reports whether path is +// a gem home, including one already enumerated under another spelling. +func (c *rubyScan) gemHome(path, scope, parent, project string) bool { + owner := c.byID[parent] + if !filepath.IsAbs(path) || !composerText(path, 4096) { + c.degrade(owner, "path_unresolved") + return false + } + path = filepath.Clean(path) + if c.homes[c.key(path)] { + return true + } + if c.stopped(owner) { + return false + } + files := c.fs(path, 0) + specs := filepath.Join(path, "specifications") + info, err := files.Stat(specs) + if errors.Is(err, fs.ErrNotExist) { + return false + } + phys := path + if resolved, err := files.EvalSymlinks(path); err == nil { + phys = resolved + } + // Opt/Cellar and other aliases keep the first safe spelling. + duplicate := c.homes[c.key(phys)] + c.homes[c.key(path)], c.homes[c.key(phys)] = true, true + c.pruned[c.key(path)], c.pruned[c.key(phys)] = true, true + if duplicate { + return true + } + src := c.source("gem_home", path, parent) + if src == nil { + return true + } + if err != nil { + src.Presence = "unknown" + c.degrade(src, c.reason(err, specs)) + return true + } + if !info.IsDir() { + c.degrade(src, "unsupported_entry") + return true + } + ordinary := func(name, version, platform string, p *model.RubyPackage) *model.RubyInstallation { + dir := name + "-" + version + if platform != "ruby" { + dir += "-" + platform + } + return c.presence(filepath.Join(path, "gems", dir), p) + } + builtin := func(string, string, string, *model.RubyPackage) *model.RubyInstallation { + // Default gems can live in Ruby's own library, with no package directory. + return &model.RubyInstallation{PathStatus: "not_applicable", Presence: "not_applicable", DefaultGem: true} + } + for _, e := range c.list(src, specs) { + p := filepath.Join(specs, e.Name()) + switch { + case e.IsDir() && e.Name() == "default": + for _, d := range c.list(src, p) { + if !d.IsDir() && strings.HasSuffix(d.Name(), ".gemspec") { + c.stub(filepath.Join(p, d.Name()), "installed_metadata", src, "installed_package", scope, project, builtin) + } + } + case !e.IsDir() && strings.HasSuffix(e.Name(), ".gemspec"): + c.stub(p, "installed_metadata", src, "installed_package", scope, project, ordinary) + } + } + c.cacheRoot(filepath.Join(path, "cache"), scope, src.SourceID, project, rubyCacheArchives) + checkouts := filepath.Join(path, "bundler", "gems") + for _, e := range c.list(src, checkouts) { + if !e.IsDir() || c.stopped(src) { + continue + } + co := c.source("bundler_checkout", filepath.Join(checkouts, e.Name()), src.SourceID) + if co == nil { + continue + } + for _, gs := range c.checkoutGemspecs(co, co.Path) { + // Each monorepo gemspec is installed in its own directory. + dir := filepath.Dir(gs) + c.stub(gs, "installed_metadata", co, "installed_package", scope, project, func(string, string, string, *model.RubyPackage) *model.RubyInstallation { + return &model.RubyInstallation{Path: dir, PathStatus: "resolved", Presence: "present"} + }) + } + } + return true +} + +// presence stats a receipt's package directory. A missing directory changes +// presence only, never the receipt source's completeness. +func (c *rubyScan) presence(path string, p *model.RubyPackage) *model.RubyInstallation { + out := &model.RubyInstallation{Path: path, PathStatus: "resolved", Presence: "present"} + if !composerText(path, 4096) { + out.Path, out.PathStatus, out.Presence = "", "unresolved", "unknown" + p.Reasons = append(p.Reasons, "path_unresolved") + return out + } + info, err := c.fs(path, 0).Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + out.Presence = "absent" + case err != nil: + out.Presence = "unknown" + p.Reasons = append(p.Reasons, c.reason(err, path)) + case !info.IsDir(): + out.Presence = "unknown" + p.Reasons = append(p.Reasons, "unsupported_entry") + } + return out +} + +// checkoutGemspecs follows Bundler's `{,*,*/*}.gemspec` default glob. +func (c *rubyScan) checkoutGemspecs(owner *model.RubySource, dir string) []string { + var out []string + for _, e := range c.list(owner, dir) { + p := filepath.Join(dir, e.Name()) + if !e.IsDir() { + if strings.HasSuffix(e.Name(), ".gemspec") { + out = append(out, p) + } + continue + } + if e.Name() == ".git" { + continue + } + for _, child := range c.list(owner, p) { + if !child.IsDir() && strings.HasSuffix(child.Name(), ".gemspec") { + out = append(out, filepath.Join(p, child.Name())) + } + } + } + return out +} + +func (c *rubyScan) cacheRoot(path, scope, parent, project string, mode int) { + owner := c.byID[parent] + if path == "" || c.caches[c.key(path)] || c.stopped(owner) { + return + } + info, err := c.fs(path, 0).Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return + } + c.caches[c.key(path)], c.pruned[c.key(path)] = true, true + src := c.source("cache_root", path, parent) + if src == nil { + return + } + if err != nil { + src.Presence = "unknown" + c.degrade(src, c.reason(err, path)) + return + } + if !info.IsDir() { + c.degrade(src, "unsupported_entry") + return + } + for _, e := range c.list(src, path) { + if c.stopped(src) { + return + } + p := filepath.Join(path, e.Name()) + switch { + case !e.IsDir() && strings.HasSuffix(e.Name(), ".gem") && mode != rubyCacheSlugs: + c.archive(p, src, scope, project) + case e.IsDir() && mode == rubyCacheSlugs: + // Only archive payloads; compact-index and bare Git caches are never packages. + for _, a := range c.list(src, p) { + if !a.IsDir() && strings.HasSuffix(a.Name(), ".gem") { + c.archive(filepath.Join(p, a.Name()), src, scope, project) + } + } + case e.IsDir() && mode == rubyCacheVendor: + // A vendored checkout is cached, never installed, even when complete. + for _, gs := range c.checkoutGemspecs(src, p) { + c.stub(gs, "cached_metadata", src, "cached_package", scope, project, nil) + } + } + } +} + +func rubyParseReason(err error) string { + if errors.Is(err, errRubyUnsupportedFormat) { + return "unsupported_format" + } + return "parse_error" +} + +// rubyFact is identity-only evidence: receipts and caches never assert origin +// or dependency relation. +func rubyFact(name, version, platform string) model.RubyPackage { + return model.RubyPackage{PackageName: name, Reasons: []string{}, VersionStatus: "known", Version: version, Platform: platform, DependencyRelation: "unknown", + DependencyKind: "unknown", Origin: model.RubyOrigin{Kind: "unknown"}, ChecksumStatus: "absent", RecordedChecksums: []model.RubyRecordedChecksum{}} +} + +func (c *rubyScan) stub(path, kind string, owner *model.RubySource, evidence, scope, project string, install rubyInstall) { + src := c.source(kind, path, owner.SourceID) + if src == nil { + return + } + data, ok := c.read(src, false, maxRubyMetadataBytes) + if !ok { + return + } + name, version, platform, err := parseRubyStub(data) + if err != nil { + c.degrade(src, rubyParseReason(err)) + return + } + p := rubyFact(name, version, platform) + if install != nil { + p.Installation = install(name, version, platform, &p) + } + c.add(src, p, evidence, project, scope) +} + +func (c *rubyScan) archive(path string, owner *model.RubySource, scope, project string) { + src := c.source("cache_archive", path, owner.SourceID) + if src == nil { + return + } + data, ok := c.read(src, false, maxRubyArchiveBytes) + if !ok { + return + } + name, version, platform, reason := parseRubyArchive(data) + if reason != "" { + c.degrade(src, reason) + return + } + c.add(src, rubyFact(name, version, platform), "cached_package", project, scope) +} + +func rubyPackageKey(p model.RubyPackage) string { b, _ := json.Marshal(p); return string(b) } +func (c *rubyScan) add(src *model.RubySource, p model.RubyPackage, evidence, project, scope string) { + p.SourceID, p.SourcePath, p.Evidence, p.ProjectPath, p.Scope = src.SourceID, src.Path, evidence, project, scope + for i := range p.RecordedChecksums { + h := &p.RecordedChecksums[i] + h.SourceID, h.SourcePath, h.SourceKind = src.SourceID, src.Path, src.Kind + } + slices.Sort(p.Reasons) + p.Reasons = slices.Compact(p.Reasons) + // The same file reached twice for one project is one observation. + key := rubyPackageKey(p) + if c.seen[key] { + return + } + if c.charge(src, p) { + c.seen[key] = true + c.inv.Packages = append(c.inv.Packages, p) + } +} + +// resolve applies Ruby's expand_path against a file's directory. +func (c *rubyScan) resolve(p, base string) string { + if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) { + p = filepath.Join(c.home, p[1:]) + } + if !filepath.IsAbs(p) { + p = filepath.Join(base, p) + } + if p = filepath.Clean(p); !composerText(p, 4096) { + return "" + } + return p +} + +// localOrigin is a device-scoped path resolved only against known project +// context, like Composer's local repositories. +func (c *rubyScan) localOrigin(raw, base string, p *model.RubyPackage) model.RubyOrigin { + path := raw + if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) { + path = c.home + string(filepath.Separator) + filepath.FromSlash(path[min(2, len(path)):]) + } + if !filepath.IsAbs(path) { + if base == "" { + p.Reasons = append(p.Reasons, "path_unresolved") + return model.RubyOrigin{Kind: "unknown"} + } + path = base + string(filepath.Separator) + filepath.FromSlash(path) + } + // A stopped scan keeps already-parsed declarations but resolves nothing. + if !composerText(path, 4096) || c.stopped(nil) { + p.Reasons = append(p.Reasons, "path_unresolved") + return model.RubyOrigin{Kind: "unknown"} + } + resolved, err := c.fs(path, 0).EvalSymlinks(path) + if err != nil { + p.Reasons = append(p.Reasons, "path_unresolved") + return model.RubyOrigin{Kind: "unknown"} + } + return model.RubyOrigin{Kind: "path", Path: resolved} +} + +func (c *rubyScan) manifestFile(src *model.RubySource, targeted, gemspec bool) *rubyFile { + if f := c.reads[c.key(src.Path)]; f != nil { + return f + } + f := &rubyFile{src: src, status: "present"} + c.reads[c.key(src.Path)] = f + data, ok := c.read(src, targeted, maxRubyMetadataBytes) + if !ok { + f.status = "unreadable" + switch { + case src.Presence == "absent": + f.status = "absent" + case slices.Contains(src.Reasons, "skipped_protected") || slices.Contains(src.Reasons, "refused_network_volume"): + f.status = "skipped_protected" + } + return f + } + parse := parseRubyGemfile + if gemspec { + parse = parseRubyGemspec + } + m, err := parse(data) + switch { + case errors.Is(err, errRubyUnsupportedFormat): + f.status = "unsupported" + c.degrade(src, "unsupported_format") + case err != nil: + f.status = "invalid" + c.degrade(src, "parse_error") + default: + f.manifest = m + if m.partial { + c.degrade(src, "parse_error") + } + } + return f +} + +// referenced reads an eval_gemfile or gemspec target once; later consumers +// reuse it and its first referencing file stays the owner. +func (c *rubyScan) referenced(path, kind string, parent *model.RubySource) *rubyFile { + if f := c.reads[c.key(path)]; f != nil { + f.ref = true + return f + } + src := c.source(kind, path, parent.SourceID) + if src == nil { + return nil + } + f := c.manifestFile(src, true, kind == "gemspec") + f.ref = true + return f +} + +func (c *rubyScan) evals(f *rubyFile, visited map[string]bool, depth int, tree *[]*rubyFile) bool { + complete := true + for _, rel := range f.manifest.evals { + path := c.resolve(rel, filepath.Dir(f.src.Path)) + switch { + case path == "": + c.degrade(f.src, "path_unresolved") + complete = false + continue + case visited[c.key(path)]: + continue + case depth >= maxRubyEvalDepth: + c.degrade(f.src, "depth_limit") + complete = false + continue + } + visited[c.key(path)] = true + ref := c.referenced(path, "manifest", f.src) + if ref == nil { + complete = false + continue + } + *tree = append(*tree, ref) + if ref.manifest == nil || !c.evals(ref, visited, depth+1, tree) { + complete = false + } + } + return complete +} + +// gemspecRefs resolves each `gemspec` directive like Bundler: one +// `{,*}.gemspec` in the directory, or the one whose name matches. +func (c *rubyScan) gemspecRefs(f *rubyFile) ([]*rubyFile, bool) { + var out []*rubyFile + complete := true + for _, ref := range f.manifest.gemspecs { + if c.stopped(f.src) { + return out, false + } + dir := c.resolve(ref.path, filepath.Dir(f.src.Path)) + if dir == "" { + c.degrade(f.src, "path_unresolved") + complete = false + continue + } + entries, more, err := c.fs(dir, 0).ReadDirLimit(dir, maxRubyListEntries) + if err != nil { + reason := c.reason(err, dir) + if errors.Is(err, fs.ErrNotExist) { + reason = "path_unresolved" + } + c.degrade(f.src, reason) + complete = false + continue + } + if more { + c.degrade(f.src, "entry_limit") + complete = false + } + var candidates []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".gemspec") { + candidates = append(candidates, filepath.Join(dir, e.Name())) + } + } + if ref.name == "" && len(candidates) > 1 { + // Bundler rejects an unnamed directive with several gemspecs. + c.degrade(f.src, "unsupported_format") + complete = false + continue + } + var matched []*rubyFile + for _, p := range candidates { + g := c.referenced(p, "gemspec", f.src) + switch { + case g == nil || g.manifest == nil: + complete = false + case ref.name == "" || g.manifest.name == ref.name: + matched = append(matched, g) + } + } + if len(matched) != 1 { + if len(matched) == 0 && len(candidates) == 0 { + c.degrade(f.src, "path_unresolved") + } else if len(matched) > 1 { + c.degrade(f.src, "unsupported_format") + } + complete = false + continue + } + out = append(out, matched[0]) + } + return out, complete +} + +func rubyLockPath(manifest string) string { + if filepath.Base(manifest) == "gems.rb" { + return strings.TrimSuffix(manifest, ".rb") + ".locked" + } + return manifest + ".lock" +} + +// project reads one Bundler project: its manifest tree, Bundler config leads, +// selected lock, project-local gem homes and caches, then declarations. +func (c *rubyScan) project(path, parent string, targeted bool) { + key := c.key(path) + if c.projects[key] != nil { + return + } + src := c.source("manifest", path, parent) + if src == nil { + return + } + dir := filepath.Dir(path) + st := &rubyProjectState{project: model.RubyProject{ManifestSourceID: src.SourceID, ManifestPath: path, ProjectPath: dir, SelectionStatus: "observed", LockfilePaths: []string{}}} + c.projects[key] = st + c.projectDirs[c.key(dir)] = dir + root := c.manifestFile(src, targeted, false) + tree := []*rubyFile{root} + complete := root.manifest != nil && c.evals(root, map[string]bool{key: true}, 0, &tree) + var specs []*rubyFile + for _, f := range tree { + if f.manifest == nil { + continue + } + matched, ok := c.gemspecRefs(f) + specs = append(specs, matched...) + complete = complete && ok + } + manifests := []configaudit.RubyManifest{} + globals := []string{} + var directive *rubyManifest + directives := 0 + for _, f := range tree { + // Inventory reasons propagated from referenced files are not this + // manifest's configuration coverage. + m := configaudit.RubyManifest{Path: f.src.Path, Status: f.status} + if f.status != "present" { + m.Reasons = f.src.Reasons + } + if f.manifest != nil { + m.Sources, m.Lockfile, m.NoLockfile = f.manifest.sources, f.manifest.lockfile, f.manifest.noLockfile + for _, g := range f.manifest.globals { + if !slices.Contains(globals, g) { + globals = append(globals, g) + } + } + if f.manifest.lockfileSet { + directive = f.manifest + directives++ + } + } + manifests = append(manifests, m) + } + if !complete { + for _, f := range tree { + for _, reason := range f.src.Reasons { + c.unprove(reason) + } + } + } + leads := c.config.Project(configaudit.RubyProject{Dir: dir, Manifests: manifests}) + // Several lockfile directives depend on evaluation order this reader + // does not model; the default lock is then only a candidate. + partial := !complete || leads.Partial || directives > 1 + lock := rubyLockPath(path) + switch { + case directives == 1 && directive.noLockfile: + lock = "" + case directives == 1: + if lock = c.resolve(directive.lockfile, dir); lock == "" { + c.degrade(src, "path_unresolved") + partial = true + } + case leads.Lockfile != "": + lock = leads.Lockfile + } + if lock != "" { + if l := c.lock(lock, src.SourceID, dir, true); l != nil && l.Presence != "absent" { + st.project.LockfilePaths = []string{lock} + } + } + if leads.Path != "" { + // A configured path appends engine/ABI; discover rather than guess them. + homes, _ := c.expand(leads.Path, "*", "*") + for _, h := range homes { + c.gemHome(h, "project", src.SourceID, dir) + } + } + c.cacheRoot(leads.CachePath, "project", src.SourceID, dir, rubyCacheVendor) + if leads.AppConfig != "" { + c.gemHome(filepath.Join(leads.AppConfig, "plugin"), "project", src.SourceID, dir) + c.pruned[c.key(leads.AppConfig)] = true + } + // Undecorated declarations use the tree's global sources only when they + // name exactly one endpoint and every manifest in the tree was read. + def, defReason := model.RubyOrigin{Kind: "unknown"}, "" + switch { + case !complete || len(globals) > 1: + defReason = "origin_unresolved" + case len(globals) == 1: + url, _ := configaudit.SanitizeRubyURL(globals[0]) + def = model.RubyOrigin{Kind: "registry", URL: url} + if url == "[redacted]" { + defReason = "origin_unresolved" + } + } + for _, f := range append(tree, specs...) { + if f.manifest == nil { + continue + } + for _, d := range f.manifest.declarations { + if c.stopped(f.src) { + break + } + c.declared(f.src, d, dir, def, defReason) + } + } + if len(specs) == 1 { + st.project.PackageName, st.project.PackageVersion = specs[0].manifest.name, specs[0].manifest.version + } + if partial { + st.project.SelectionStatus = "partial" + } + st.kept = c.charge(src, st.project) +} + +// standalone records a gemspec with no Bundler manifest beside it; its +// literal dependencies have no observed source. +func (c *rubyScan) standalone(path, parent string) { + key := c.key(path) + if c.projects[key] != nil || c.reads[key] != nil { + return + } + src := c.source("gemspec", path, parent) + if src == nil { + return + } + dir := filepath.Dir(path) + st := &rubyProjectState{gemspec: true, project: model.RubyProject{ManifestSourceID: src.SourceID, ManifestPath: path, ProjectPath: dir, SelectionStatus: "partial", LockfilePaths: []string{}}} + c.projects[key] = st + c.projectDirs[c.key(dir)] = dir + if f := c.manifestFile(src, false, true); f.manifest != nil { + st.project.SelectionStatus = "observed" + st.project.PackageName, st.project.PackageVersion = f.manifest.name, f.manifest.version + for _, d := range f.manifest.declarations { + if c.stopped(src) { + break + } + c.declared(src, d, dir, model.RubyOrigin{Kind: "unknown"}, "") + } + } + st.kept = c.charge(src, st.project) +} + +func (c *rubyScan) declared(src *model.RubySource, d rubyDeclaration, project string, def model.RubyOrigin, defReason string) { + requested := slices.Clone(d.requirements) + if requested == nil { + requested = []string{} + } + p := model.RubyPackage{PackageName: d.name, Reasons: []string{}, VersionStatus: "unknown", RequestedVersions: &requested, Groups: d.groups, DeclaredPlatforms: d.platforms, + DependencyRelation: "direct", DependencyKind: d.kind, Origin: def, ChecksumStatus: "absent", RecordedChecksums: []model.RubyRecordedChecksum{}} + base := filepath.Dir(src.Path) + switch d.origin { + case "": + if defReason != "" { + p.Reasons = append(p.Reasons, defReason) + } + case "path": + p.Origin = c.localOrigin(d.path, base, &p) + default: + if local, ok := composerLocalURL(d.url, ""); ok && d.origin == "git" { + p.Origin = c.localOrigin(local, base, &p) + break + } + url, _ := configaudit.SanitizeRubyURL(d.url) + p.Origin = model.RubyOrigin{Kind: d.origin, URL: url} + if url == "[redacted]" { + p.Reasons = append(p.Reasons, "origin_unresolved") + } + } + c.add(src, p, "declared_requirement", project, "project") +} + +func (c *rubyScan) lock(path, parent, project string, targeted bool) *model.RubySource { + c.locks[c.key(path)] = true + src := c.source("lockfile", path, parent) + if src == nil { + return nil + } + data, ok := c.read(src, targeted, maxRubyMetadataBytes) + if !ok { + return src + } + entries, reasons, err := parseRubyLock(data) + if err != nil { + c.degrade(src, "parse_error") + return src + } + for _, r := range reasons { + c.degrade(src, r) + } + for _, e := range entries { + if c.stopped(src) { + break + } + p := e.pkg + if e.local != "" { + p.Origin = c.localOrigin(e.local, project, &p) + } + c.add(src, p, "locked_package", project, "project") + } + return src +} + +// independentLocks emits on-disk locks no project selected. A lock beside a +// project that selected another (or none) still makes its selection partial. +func (c *rubyScan) independentLocks() { + for _, key := range slices.Sorted(maps.Keys(c.diskLocks)) { + l := c.diskLocks[key] + if c.locks[key] { + continue + } + dir := filepath.Dir(l.path) + for _, st := range c.projects { + if !st.gemspec && c.key(st.project.ProjectPath) == c.key(dir) { + st.project.SelectionStatus = "partial" + } + } + if c.stopped(c.byID[l.parent]) { + return + } + c.lock(l.path, l.parent, dir, false) + } +} + +func (c *rubyScan) skip(path string) bool { + path = c.key(path) + for root := range c.pruned { + if path == root || goPathWithin(path, root) { + return true + } + } + return false +} + +// enclosing returns the deepest known project directory containing path. +func (c *rubyScan) enclosing(path string) string { + best, key := "", c.key(path) + for k, dir := range c.projectDirs { + if (key == k || goPathWithin(key, k)) && len(dir) > len(best) { + best = dir + } + } + return best +} + +func (c *rubyScan) walkRoots(dirs []string) { + // Outermost logical/physical roots own discovery. Identical physical roots + // retain the first spelling, like Cargo, and never get walked twice. + type root struct { + path, phys string + src *model.RubySource + } + roots := []root{} + seen := map[string]bool{} + for _, dir := range dirs { + if seen[c.key(dir)] { + continue + } + seen[c.key(dir)] = true + src := c.source("project_search_root", dir, "") + if src == nil { + continue + } + if c.stopped(src) { + src.Presence = "unknown" + continue + } + phys, err := c.fs(dir, 0).EvalSymlinks(dir) + if errors.Is(err, fs.ErrNotExist) { + src.Presence = "absent" + continue + } + if err != nil { + src.Presence = "unknown" + c.unvisited(src, c.reason(err, dir)) + continue + } + roots = append(roots, root{dir, phys, src}) + } + for i, r := range roots { + duplicate := slices.ContainsFunc(roots[:i], func(other root) bool { return c.key(other.phys) == c.key(r.phys) }) + nested := slices.ContainsFunc(roots, func(other root) bool { return goPathWithin(c.key(r.phys), c.key(other.phys)) }) + if duplicate || nested { + delete(c.byID, r.src.SourceID) + continue + } + c.walk(r.path, r.src) + } +} + +// isGemHome recognizes a RubyGems directory without trusting a lone +// `specifications` folder. A denied probe is partial, never "not a gem home". +func (c *rubyScan) isGemHome(dir string, entries []fs.DirEntry, src *model.RubySource) bool { + has := func(name string) bool { + return slices.ContainsFunc(entries, func(e fs.DirEntry) bool { return e.Name() == name && e.IsDir() }) + } + if !has("specifications") { + return false + } + if has("gems") || has("cache") { + return true + } + specs := filepath.Join(dir, "specifications") + children, _, err := c.files.ReadDirLimit(specs, 256) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + c.degrade(src, c.reason(err, specs)) + } + return false + } + return slices.ContainsFunc(children, func(e fs.DirEntry) bool { + return e.IsDir() && e.Name() == "default" || !e.IsDir() && strings.HasSuffix(e.Name(), ".gemspec") + }) +} + +func (c *rubyScan) walk(root string, src *model.RubySource) { + type dir struct { + path string + depth int + } + queue := []dir{{root, 0}} + budget := maxRubyWalkEntries + for len(queue) > 0 { + if c.stopped(src) { + return + } + d := queue[0] + queue = queue[1:] + if c.skip(d.path) { + continue + } + entries, more, err := c.files.ReadDirLimit(d.path, budget) + if err != nil { + c.unvisited(src, c.reason(err, d.path)) + continue + } + budget -= len(entries) + file := func(name string) bool { + return slices.ContainsFunc(entries, func(e fs.DirEntry) bool { return e.Name() == name && !e.IsDir() }) + } + if c.isGemHome(d.path, entries, src) { + project, scope := c.enclosing(d.path), "unknown" + switch { + case project != "": + scope = "project" + case configaudit.GoWithinRoots(c.goos, d.path, []string{c.home}): + scope = "user" + } + c.gemHome(d.path, scope, src.SourceID, project) + continue + } + // gems.rb precedes Gemfile; the other is never read as a manifest. An + // existing BUNDLE_GEMFILE selection replaces both for its directory. + selected := false + if g := c.config.Gemfile; g != "" && c.key(filepath.Dir(g)) == c.key(d.path) { + src := c.byID[configaudit.GoSourceID(c.identity, "manifest", g)] + selected = src == nil || src.Presence != "absent" + } + switch { + case selected: + case file("gems.rb"): + c.project(filepath.Join(d.path, "gems.rb"), src.SourceID, false) + case file("Gemfile"): + c.project(filepath.Join(d.path, "Gemfile"), src.SourceID, false) + default: + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".gemspec") { + c.standalone(filepath.Join(d.path, e.Name()), src.SourceID) + } + } + } + for _, name := range []string{"Gemfile.lock", "gems.locked"} { + if p := filepath.Join(d.path, name); file(name) { + c.diskLocks[c.key(p)] = rubyDiskLock{p, src.SourceID} + } + } + for _, e := range entries { + if !e.IsDir() { + continue + } + p := filepath.Join(d.path, e.Name()) + switch e.Name() { + case ".git", ".hg", ".svn", "node_modules", "target", ".rustup", ".cache", "__pycache__", ".bundle": + continue + } + if c.skip(p) { + continue + } + if c.guard(p) != "" { + c.unvisited(src, c.reasonForGuard(p)) + continue + } + if d.depth+1 > maxRubyWalkDepth { + c.unvisited(src, "depth_limit") + continue + } + queue = append(queue, dir{p, d.depth + 1}) + } + if more { + c.unvisited(src, "entry_limit") + return + } + } +} +func (c *rubyScan) reasonForGuard(path string) string { + if c.volume != nil && c.volume(path) { + return "refused_network_volume" + } + return "skipped_protected" +} +func (c *rubyScan) finish() { + for _, src := range c.sources { + if c.byID[src.SourceID] == nil { + continue + } + if parent := c.byID[src.ParentSourceID]; parent != nil { + parent.DiscoveredSources = append(parent.DiscoveredSources, src.SourceID) + } else { + src.ParentSourceID = "" + } + } + for _, src := range c.sources { + if c.byID[src.SourceID] == nil { + continue + } + slices.Sort(src.Reasons) + slices.Sort(src.DiscoveredSources) + src.DiscoveredSources = slices.Compact(src.DiscoveredSources) + c.inv.Sources = append(c.inv.Sources, *src) + } + for _, st := range c.projects { + if st.kept { + c.inv.Projects = append(c.inv.Projects, st.project) + } + } + slices.SortFunc(c.inv.Sources, func(a, b model.RubySource) int { + return strings.Compare(a.Path+"\x00"+a.Kind, b.Path+"\x00"+b.Kind) + }) + slices.SortFunc(c.inv.Projects, func(a, b model.RubyProject) int { return strings.Compare(a.ManifestPath, b.ManifestPath) }) + slices.SortStableFunc(c.inv.Packages, func(a, b model.RubyPackage) int { + return cmp.Or(strings.Compare(a.SourcePath, b.SourcePath), strings.Compare(a.Evidence, b.Evidence), strings.Compare(a.ProjectPath, b.ProjectPath), + strings.Compare(a.PackageName, b.PackageName), strings.Compare(a.Version, b.Version), strings.Compare(a.Platform, b.Platform), strings.Compare(a.DependencyKind, b.DependencyKind)) + }) + slices.Sort(c.inv.Reasons) +} diff --git a/internal/detector/rubyscan_test.go b/internal/detector/rubyscan_test.go new file mode 100644 index 00000000..1292b2b1 --- /dev/null +++ b/internal/detector/rubyscan_test.go @@ -0,0 +1,732 @@ +package detector + +import ( + "bytes" + "context" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// rubyTrap confines the scan to the test's temporary tree, so shared roots +// on the host (/usr, /Library, Homebrew, C:\Ruby*) read as absent, counts +// filesystem calls, and fails on any read of a RubyGems credentials file. +type rubyTrap struct { + *composerTrap + sandbox string + calls *int +} + +func (x *rubyTrap) GuardedFiles(roots []string, guard func(string) string, max int64) executor.Executor { + return &rubyTrap{composerTrap: x.composerTrap.GuardedFiles(roots, guard, max).(*composerTrap), sandbox: x.sandbox, calls: x.calls} +} +func (x *rubyTrap) outside(method, path string) bool { + *x.calls++ + path = filepath.Clean(path) + if path == x.sandbox || strings.HasPrefix(path, x.sandbox+string(filepath.Separator)) { + return false + } + x.unguarded(method) + return true +} +func (x *rubyTrap) Stat(path string) (os.FileInfo, error) { + if x.outside("Stat", path) { + return nil, fs.ErrNotExist + } + return x.composerTrap.Stat(path) +} +func (x *rubyTrap) ReadDirLimit(path string, max int) ([]os.DirEntry, bool, error) { + if x.outside("ReadDirLimit", path) { + return nil, false, fs.ErrNotExist + } + return x.composerTrap.ReadDirLimit(path, max) +} +func (x *rubyTrap) EvalSymlinks(path string) (string, error) { + if x.outside("EvalSymlinks", path) { + return "", fs.ErrNotExist + } + return x.composerTrap.EvalSymlinks(path) +} +func (x *rubyTrap) ReadFile(path string) ([]byte, error) { + if filepath.Base(path) == "credentials" { + x.t.Fatalf("read credential file %s", path) + } + if x.outside("ReadFile", path) { + return nil, fs.ErrNotExist + } + return x.composerTrap.ReadFile(path) +} + +func rubyTestScanner(t *testing.T, home string) (*RubyScanner, *rubyTrap) { + t.Helper() + x := &rubyTrap{composerTrap: &composerTrap{goTrap: &goTrap{Executor: executor.NewReal(), t: t, current: goTestTarget(home)}, env: map[string]string{}, denied: map[string]error{}, reads: map[string]int{}, readErrors: map[string]error{}}, + sandbox: filepath.Dir(home), calls: new(int)} + s := NewRubyScanner(x, progress.NewNoop()) + s.protection = func(string, *bool) (func(string) string, func(string) bool) { + return func(string) string { return "" }, func(string) bool { return false } + } + return s, x +} + +func rubyStub(name, version, platform string) string { + return "# -*- encoding: utf-8 -*-\n# stub: " + name + " " + version + " " + platform + " lib\n\nGem::Specification.new do |s|\nend\n" +} + +func rubyGemFile(t *testing.T, name, version string) string { + t.Helper() + metadata := strings.Replace(strings.Replace(rubyNativeMetadata, "did_you_mean", name, 1), "1.3.0", version, 1) + return rubyGemArchive(t, "metadata.gz", rubyMetadataGz(t, metadata), "data.tar.gz", "") +} + +// rubyWriteFixture lays out every evidence kind: a gems.rb project with an +// evaluated Gemfile, project gemspec, lock, configured project gem home, +// Git checkouts and vendor cache; an unsupported manifest whose lock +// survives; a lock-only directory; an unselected lock; a standalone gemspec; +// a walk-found gem home; runtime-manager, user and Bundler cache roots; and +// RubyGems/Bundler config. Secrets are canaries that must never appear. +func rubyWriteFixture(t *testing.T, home, root string) { + t.Helper() + app := filepath.Join(root, "app") + goWrite(t, filepath.Join(app, "gems.rb"), `source "https://rubygems.org" +gemspec +eval_gemfile "../shared/Gemfile.shared" +gem "rails", "~> 7.1" +gem "local", path: "../local" +gem "forked", git: "https://github.com/org/forked.git" +group :test do + gem "rspec" +end +`, 0o600) + goWrite(t, filepath.Join(app, "Gemfile"), "raise \"gems.rb takes precedence\"\n", 0o600) + goWrite(t, filepath.Join(app, "app.gemspec"), `Gem::Specification.new do |spec| + spec.name = "app" + spec.version = "1.0.0" + spec.summary = "CANARY_SUMMARY" + spec.add_development_dependency "rubocop", "~> 1.60" +end +`, 0o600) + goWrite(t, filepath.Join(app, "gems.locked"), `GIT + remote: https://github.com/org/forked.git + revision: 0123456789abcdef0123456789abcdef01234567 + specs: + forked (0.1.0) + +PATH + remote: ../local + specs: + local (1.0.0) + +GEM + remote: https://rubygems.org/ + specs: + nokogiri (1.16.0-arm64-darwin) + nokogiri (1.16.0-x86_64-linux) + rack (3.0.8) + rails (7.1.3) + rack (>= 2) + +PLATFORMS + arm64-darwin + x86_64-linux + +DEPENDENCIES + forked! + local! + rails (~> 7.1) + rspec + +CHECKSUMS + nokogiri (1.16.0-arm64-darwin) sha256=`+rubyHashA+` + nokogiri (1.16.0-x86_64-linux) sha256=bad + rack (3.0.8) sha256=`+rubyHashA+` + +BUNDLED WITH + 2.5.3 +`, 0o600) + goWrite(t, filepath.Join(app, ".bundle", "config"), "---\nBUNDLE_PATH: \"vendor/bundle\"\n", 0o600) + gems := filepath.Join(app, "vendor", "bundle", "ruby", "3.3.0") + goWrite(t, filepath.Join(gems, "specifications", "rack-3.0.8.gemspec"), rubyStub("rack", "3.0.8", "ruby"), 0o600) + goMkdir(t, filepath.Join(gems, "gems", "rack-3.0.8")) + // A receipt whose package directory is gone changes presence only. + goWrite(t, filepath.Join(gems, "specifications", "nokogiri-1.16.0-arm64-darwin.gemspec"), rubyStub("nokogiri", "1.16.0", "arm64-darwin"), 0o600) + checkout := filepath.Join(gems, "bundler", "gems", "forked-0123456789ab") + goWrite(t, filepath.Join(checkout, "forked.gemspec"), rubyStub("forked", "0.1.0", "ruby"), 0o600) + goWrite(t, filepath.Join(checkout, "lib-a", "lib-a.gemspec"), rubyStub("lib-a", "0.1.0", "ruby"), 0o600) + goWrite(t, filepath.Join(app, "vendor", "cache", "rack-3.0.8.gem"), rubyGemFile(t, "rack", "3.0.8"), 0o600) + goWrite(t, filepath.Join(app, "vendor", "cache", "forked-0123456789ab", "forked.gemspec"), rubyStub("forked", "0.1.0", "ruby"), 0o600) + goWrite(t, filepath.Join(root, "shared", "Gemfile.shared"), "gem \"shared-dep\"\n", 0o600) + goWrite(t, filepath.Join(root, "local", "local.gemspec"), "Gem::Specification.new do |s|\n s.name = \"local\"\n s.version = \"1.0.0\"\n s.add_dependency \"thor\"\nend\n", 0o600) + // Executable Ruby: declarations are unproven, the lock still counts. + goWrite(t, filepath.Join(root, "legacy", "Gemfile"), "gem \"rack\" if ENV[\"CANARY_ENV\"]\n", 0o600) + goWrite(t, filepath.Join(root, "legacy", "Gemfile.lock"), "GEM\n remote: https://rubygems.org/\n specs:\n rack (2.2.8)\n\nDEPENDENCIES\n rack\n", 0o600) + goWrite(t, filepath.Join(root, "lockonly", "Gemfile.lock"), "GEM\n remote: https://rubygems.org/\n specs:\n json (2.7.1)\n\nDEPENDENCIES\n json\n", 0o600) + // Gemfile selects Gemfile.lock; the gems.locked beside it is independent. + goWrite(t, filepath.Join(root, "pair", "Gemfile"), "source \"https://rubygems.org\"\ngem \"rake\"\n", 0o600) + goWrite(t, filepath.Join(root, "pair", "gems.locked"), "GEM\n remote: https://rubygems.org/\n specs:\n rake (13.1.0)\n\nDEPENDENCIES\n rake\n", 0o600) + goWrite(t, filepath.Join(root, "tools", "vendored", "specifications", "thor-1.3.0.gemspec"), rubyStub("thor", "1.3.0", "ruby"), 0o600) + goMkdir(t, filepath.Join(root, "tools", "vendored", "gems", "thor-1.3.0")) + + prefix := filepath.Join(home, ".rbenv", "versions", "3.3.0") + shared := filepath.Join(prefix, "lib", "ruby", "gems", "3.3.0") + goWrite(t, filepath.Join(shared, "specifications", "default", "forwardable-1.2.0.gemspec"), rubyStubHeader, 0o600) + goWrite(t, filepath.Join(shared, "specifications", "json-2.7.1.gemspec"), rubyStub("json", "2.7.1", "ruby"), 0o600) + goMkdir(t, filepath.Join(shared, "gems", "json-2.7.1")) + goWrite(t, filepath.Join(shared, "cache", "json-2.7.1.gem"), rubyGemFile(t, "json", "2.7.1"), 0o600) + goWrite(t, filepath.Join(prefix, "etc", "gemrc"), ":ssl_verify_mode: 0\n", 0o600) + goWrite(t, filepath.Join(home, ".gem", "ruby", "3.3.0", "specifications", "bundler-2.5.3.gemspec"), rubyStub("bundler", "2.5.3", "ruby"), 0o600) + goWrite(t, filepath.Join(home, ".gem", "credentials"), "---\n:rubygems_api_key: CANARY_API_KEY\n", 0o600) + goWrite(t, filepath.Join(home, ".bundle", "cache", "gems", "rubygems.org.443.0123456789abcdef", "rack-3.0.8.gem"), rubyGemFile(t, "rack", "3.0.8"), 0o600) + goWrite(t, filepath.Join(home, ".bundle", "cache", "compact_index", "rubygems.org.443.0123456789abcdef", "versions"), "CANARY_INDEX\n", 0o600) + goWrite(t, filepath.Join(home, ".gemrc"), "---\n:sources:\n- http://gems.example.invalid/\n- https://rubygems.org/\n:http_proxy: http://CANARY_USER:CANARY_PASS@proxy.example.invalid:8080\ngem: --CANARY_OPTION\n", 0o600) + goWrite(t, filepath.Join(home, ".bundle", "config"), "---\nBUNDLE_DISABLE_CHECKSUM_VALIDATION: \"true\"\nBUNDLE_GEMS__EXAMPLE__COM: \"CANARY_USER:CANARY_PASS\"\nBUNDLE_JOBS: \"4\"\n", 0o600) +} + +func rubyFixtureEnv(x *rubyTrap) { + x.env["HTTPS_PROXY"] = "https://user:CANARY_PASSWORD@proxy.example.invalid:8443/?CANARY_QUERY" + x.env["GEM_HOST_API_KEY"] = "CANARY_HOST_KEY" + x.env["BUNDLE_GEMS__EXAMPLE__COM"] = "CANARY_USER:CANARY_PASS" + x.env["BUNDLE_CANARY_SETTING"] = "CANARY_VALUE" +} + +func rubyAssertRefs(t *testing.T, inv *model.RubyInventory, audit *model.RubyConfigAudit) { + t.Helper() + ids := map[string]bool{} + for _, s := range inv.Sources { + if ids[s.SourceID] { + t.Error("duplicate source") + } + ids[s.SourceID] = true + } + ref := func(id string) { + if !ids[id] { + t.Errorf("dangling inventory source %s", id) + } + } + for _, s := range inv.Sources { + if s.ParentSourceID != "" { + ref(s.ParentSourceID) + } + for _, id := range s.DiscoveredSources { + ref(id) + } + } + for _, p := range inv.Packages { + ref(p.SourceID) + for _, hash := range p.RecordedChecksums { + ref(hash.SourceID) + } + } + for _, p := range inv.Projects { + ref(p.ManifestSourceID) + } + files := map[string]bool{} + for _, f := range audit.Files { + files[f.SourceID] = true + } + for _, c := range audit.Contexts { + for _, id := range c.ConfigSourceIDs { + if !files[id] { + t.Error("dangling config context") + } + } + } + for _, f := range audit.Findings { + if !files[f.SourceID] { + t.Error("dangling finding") + } + } + for _, v := range []any{inv, audit} { + data, _ := json.Marshal(v) + if bytes.Contains(data, []byte(":null")) { + t.Fatal("null array") + } + if bytes.Contains(data, []byte("CANARY_")) { + t.Fatal("output contains a credential canary") + } + } +} + +func rubySource(t *testing.T, inv *model.RubyInventory, kind, path string) model.RubySource { + t.Helper() + for _, src := range inv.Sources { + if src.Kind == kind && src.Path == path { + return src + } + } + t.Fatalf("no %s source for %s", kind, path) + return model.RubySource{} +} + +func rubyPackages(inv *model.RubyInventory, evidence, name string) []model.RubyPackage { + var out []model.RubyPackage + for _, p := range inv.Packages { + if p.Evidence == evidence && (name == "" || p.PackageName == name) { + out = append(out, p) + } + } + return out +} + +func rubyProject(t *testing.T, inv *model.RubyInventory, manifest string) model.RubyProject { + t.Helper() + for _, p := range inv.Projects { + if p.ManifestPath == manifest { + return p + } + } + t.Fatalf("no project for %s", manifest) + return model.RubyProject{} +} + +func rubyOwned(inv *model.RubyInventory) map[string]int { + owned := map[string]int{} + for _, p := range inv.Packages { + owned[p.SourceID]++ + } + return owned +} + +// rubyAssertComplete checks the retirement contract: a source a bounded scan +// leaves complete must still hold everything it holds unbounded. +func rubyAssertComplete(t *testing.T, budget int, owned map[string]int, inv *model.RubyInventory) { + t.Helper() + got := rubyOwned(inv) + for _, src := range inv.Sources { + if src.Status == "complete" && got[src.SourceID] != owned[src.SourceID] { + t.Fatalf("budget %d: complete %s %s holds %d of %d packages", budget, src.Kind, src.Path, got[src.SourceID], owned[src.SourceID]) + } + } +} + +func TestRubyScanFixtureEvidence(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + app := filepath.Join(root, "app") + rubyWriteFixture(t, home, root) + scanner, x := rubyTestScanner(t, home) + rubyFixtureEnv(x) + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + rubyAssertRefs(t, inv, audit) + if inv.Status != "partial" || !slices.Equal(inv.Reasons, []string{"checksum_invalid", "unsupported_format"}) { + t.Fatalf("inventory %s %v", inv.Status, inv.Reasons) + } + if x.reads[filepath.Join(app, "Gemfile")] != 0 { + t.Error("read Gemfile beside gems.rb") + } + + if p := rubyProject(t, inv, filepath.Join(app, "gems.rb")); p.SelectionStatus != "observed" || !slices.Equal(p.LockfilePaths, []string{filepath.Join(app, "gems.locked")}) || p.PackageName != "app" || p.PackageVersion != "1.0.0" { + t.Errorf("app project %+v", p) + } + if p := rubyProject(t, inv, filepath.Join(root, "legacy", "Gemfile")); p.SelectionStatus != "partial" || len(p.LockfilePaths) != 1 { + t.Errorf("unsupported manifest project %+v", p) + } + if p := rubyProject(t, inv, filepath.Join(root, "pair", "Gemfile")); p.SelectionStatus != "partial" || len(p.LockfilePaths) != 0 { + t.Errorf("project beside an unselected lock %+v", p) + } + if p := rubyProject(t, inv, filepath.Join(root, "local", "local.gemspec")); p.SelectionStatus != "observed" || p.PackageName != "local" { + t.Errorf("standalone gemspec %+v", p) + } + if len(inv.Projects) != 4 { + t.Errorf("projects %+v", inv.Projects) + } + if src := rubySource(t, inv, "manifest", filepath.Join(root, "legacy", "Gemfile")); src.Status != "partial" || !slices.Equal(src.Reasons, []string{"unsupported_format"}) { + t.Errorf("unsupported manifest %+v", src) + } + + type want struct { + evidence, name, sourcePath, project, scope string + check func(model.RubyPackage) bool + } + gems := filepath.Join(app, "vendor", "bundle", "ruby", "3.3.0") + checkout := filepath.Join(gems, "bundler", "gems", "forked-0123456789ab") + shared := filepath.Join(home, ".rbenv", "versions", "3.3.0", "lib", "ruby", "gems", "3.3.0") + local := filepath.Join(root, "local") + installed := func(path, presence string) func(model.RubyPackage) bool { + return func(p model.RubyPackage) bool { + return p.Installation != nil && p.Installation.Path == path && p.Installation.Presence == presence && !p.Installation.DefaultGem && p.Origin.Kind == "unknown" + } + } + for _, w := range []want{ + {"declared_requirement", "rails", filepath.Join(app, "gems.rb"), app, "project", func(p model.RubyPackage) bool { + return p.Origin == model.RubyOrigin{Kind: "registry", URL: "https://rubygems.org"} && p.RequestedVersions != nil && slices.Equal(*p.RequestedVersions, []string{"~> 7.1"}) && + p.DependencyKind == "unknown" && p.VersionStatus == "unknown" + }}, + {"declared_requirement", "rspec", filepath.Join(app, "gems.rb"), app, "project", func(p model.RubyPackage) bool { return slices.Equal(p.Groups, []string{"test"}) }}, + {"declared_requirement", "local", filepath.Join(app, "gems.rb"), app, "project", func(p model.RubyPackage) bool { return p.Origin == model.RubyOrigin{Kind: "path", Path: local} }}, + {"declared_requirement", "forked", filepath.Join(app, "gems.rb"), app, "project", func(p model.RubyPackage) bool { return p.Origin.Kind == "git" }}, + {"declared_requirement", "shared-dep", filepath.Join(root, "shared", "Gemfile.shared"), app, "project", func(p model.RubyPackage) bool { return p.Origin.Kind == "registry" }}, + {"declared_requirement", "rubocop", filepath.Join(app, "app.gemspec"), app, "project", func(p model.RubyPackage) bool { return p.DependencyKind == "development" }}, + {"declared_requirement", "thor", filepath.Join(local, "local.gemspec"), local, "project", func(p model.RubyPackage) bool { return p.Origin.Kind == "unknown" && p.DependencyKind == "runtime" }}, + {"locked_package", "forked", filepath.Join(app, "gems.locked"), app, "project", func(p model.RubyPackage) bool { + return p.Revision == "0123456789abcdef0123456789abcdef01234567" && p.DependencyRelation == "direct" + }}, + {"locked_package", "local", filepath.Join(app, "gems.locked"), app, "project", func(p model.RubyPackage) bool { return p.Origin == model.RubyOrigin{Kind: "path", Path: local} }}, + {"locked_package", "rack", filepath.Join(app, "gems.locked"), app, "project", func(p model.RubyPackage) bool { + return p.ChecksumStatus == "recorded" && p.DependencyRelation == "transitive" && p.RecordedChecksums[0].SourceKind == "lockfile" + }}, + {"locked_package", "rack", filepath.Join(root, "legacy", "Gemfile.lock"), filepath.Join(root, "legacy"), "project", func(p model.RubyPackage) bool { return p.Version == "2.2.8" }}, + {"locked_package", "json", filepath.Join(root, "lockonly", "Gemfile.lock"), filepath.Join(root, "lockonly"), "project", func(p model.RubyPackage) bool { return true }}, + {"locked_package", "rake", filepath.Join(root, "pair", "gems.locked"), filepath.Join(root, "pair"), "project", func(p model.RubyPackage) bool { return true }}, + {"installed_package", "rack", filepath.Join(gems, "specifications", "rack-3.0.8.gemspec"), app, "project", installed(filepath.Join(gems, "gems", "rack-3.0.8"), "present")}, + {"installed_package", "nokogiri", filepath.Join(gems, "specifications", "nokogiri-1.16.0-arm64-darwin.gemspec"), app, "project", installed(filepath.Join(gems, "gems", "nokogiri-1.16.0-arm64-darwin"), "absent")}, + {"installed_package", "forked", filepath.Join(checkout, "forked.gemspec"), app, "project", installed(checkout, "present")}, + {"installed_package", "lib-a", filepath.Join(checkout, "lib-a", "lib-a.gemspec"), app, "project", installed(filepath.Join(checkout, "lib-a"), "present")}, + {"installed_package", "forwardable", filepath.Join(shared, "specifications", "default", "forwardable-1.2.0.gemspec"), "", "user", func(p model.RubyPackage) bool { + return *p.Installation == model.RubyInstallation{PathStatus: "not_applicable", Presence: "not_applicable", DefaultGem: true} + }}, + {"installed_package", "json", filepath.Join(shared, "specifications", "json-2.7.1.gemspec"), "", "user", installed(filepath.Join(shared, "gems", "json-2.7.1"), "present")}, + {"installed_package", "bundler", filepath.Join(home, ".gem", "ruby", "3.3.0", "specifications", "bundler-2.5.3.gemspec"), "", "user", installed(filepath.Join(home, ".gem", "ruby", "3.3.0", "gems", "bundler-2.5.3"), "absent")}, + {"installed_package", "thor", filepath.Join(root, "tools", "vendored", "specifications", "thor-1.3.0.gemspec"), "", "user", installed(filepath.Join(root, "tools", "vendored", "gems", "thor-1.3.0"), "present")}, + {"cached_package", "rack", filepath.Join(app, "vendor", "cache", "rack-3.0.8.gem"), app, "project", func(p model.RubyPackage) bool { return p.Installation == nil }}, + {"cached_package", "forked", filepath.Join(app, "vendor", "cache", "forked-0123456789ab", "forked.gemspec"), app, "project", func(p model.RubyPackage) bool { return p.Installation == nil }}, + {"cached_package", "json", filepath.Join(shared, "cache", "json-2.7.1.gem"), "", "user", func(p model.RubyPackage) bool { return p.Version == "2.7.1" }}, + {"cached_package", "rack", filepath.Join(home, ".bundle", "cache", "gems", "rubygems.org.443.0123456789abcdef", "rack-3.0.8.gem"), "", "user", func(p model.RubyPackage) bool { return p.Origin.Kind == "unknown" }}, + } { + found := false + for _, p := range rubyPackages(inv, w.evidence, w.name) { + if p.SourcePath == w.sourcePath { + found = true + if p.ProjectPath != w.project || p.Scope != w.scope || !w.check(p) { + t.Errorf("%s %s from %s = %+v", w.evidence, w.name, w.sourcePath, p) + } + } + } + if !found { + t.Errorf("missing %s %s from %s", w.evidence, w.name, w.sourcePath) + } + } + if n := len(rubyPackages(inv, "locked_package", "nokogiri")); n != 2 { + t.Errorf("multi-platform lock rows = %d", n) + } + if src := rubySource(t, inv, "lockfile", filepath.Join(app, "gems.locked")); src.Status != "partial" || !slices.Equal(src.Reasons, []string{"checksum_invalid"}) { + t.Errorf("lock with an invalid checksum %+v", src) + } + if src := rubySource(t, inv, "gem_home", filepath.Join(root, "tools", "vendored")); src.ParentSourceID != rubySource(t, inv, "project_search_root", root).SourceID { + t.Errorf("walk-found gem home %+v", src) + } + for _, src := range inv.Sources { + if strings.Contains(src.Path, "compact_index") || src.Path == filepath.Join(app, "Gemfile") { + t.Errorf("unexpected source %+v", src) + } + } + + if len(audit.CredentialFiles) != 1 || audit.CredentialFiles[0].Presence != "present" || audit.CredentialFiles[0].Path != filepath.Join(home, ".gem", "credentials") { + t.Errorf("credential files %+v", audit.CredentialFiles) + } + codes := map[string]bool{} + for _, f := range audit.Findings { + codes[f.Code] = true + } + if len(codes) != 4 { + t.Errorf("findings %+v", audit.Findings) + } + contexts := map[string]int{} + for _, c := range audit.Contexts { + contexts[c.Manager]++ + } + // One ambient RubyGems and Bundler context, plus one per Bundler project. + if contexts["rubygems"] != 1 || contexts["bundler"] != 4 { + t.Errorf("contexts %+v", audit.Contexts) + } +} + +func TestRubyScanUnknownProcessAndDeveloper(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + rubyWriteFixture(t, home, root) + scanner, x := rubyTestScanner(t, home) + x.current.Uid = "1001" + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + rubyAssertRefs(t, inv, audit) + for _, f := range audit.Files { + if f.Scope == "process" && (f.Status != "unsupported" || !slices.Contains(f.Reasons, "user_unresolved")) { + t.Errorf("process file = %+v", f) + } + } + for _, c := range audit.Contexts { + if c.SelectionStatus != "partial" { + t.Errorf("unverified process left %s context complete: %+v", c.Manager, c) + } + } + // Default roots do not depend on the process environment. + rubySource(t, inv, "gem_home", filepath.Join(home, ".rbenv", "versions", "3.3.0", "lib", "ruby", "gems", "3.3.0")) + + scanner, x = rubyTestScanner(t, home) + target := goTestTarget(home) + target.Uid = "0" + if x.GOOS() == model.PlatformWindows { + target.Uid = "S-1-5-18" + } + x.current = target + inv, audit = scanner.Scan(context.Background(), target, []string{root}, nil) + if inv.Status != "partial" || !slices.Equal(inv.Reasons, []string{"user_unresolved"}) || len(inv.Sources) != 0 || audit.Status != "partial" { + t.Fatalf("service identity scanned: %+v", inv) + } +} + +func TestRubyScanLimitsDeadlineAndGuards(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + app := filepath.Join(root, "app") + legacyLock := filepath.Join(root, "legacy", "Gemfile.lock") + rubyWriteFixture(t, home, root) + scanner, x := rubyTestScanner(t, home) + rubyFixtureEnv(x) + full, _ := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + total := len(full.Sources) + len(full.Projects) + len(full.Packages) + owned := rubyOwned(full) + old := maxRubyRecords + t.Cleanup(func() { maxRubyRecords = old }) + for budget := 1; budget < total; budget++ { + maxRubyRecords = budget + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + if inv.Status != "partial" || !slices.Contains(inv.Reasons, "record_limit") || len(inv.Sources)+len(inv.Projects)+len(inv.Packages) > budget { + t.Fatalf("budget %d: status %s reasons %v", budget, inv.Status, inv.Reasons) + } + rubyAssertComplete(t, budget, owned, inv) + rubyAssertRefs(t, inv, audit) + } + maxRubyRecords = old + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + *x.calls = 0 + inv, audit := scanner.Scan(ctx, goTestTarget(home), []string{root}, nil) + rubyAssertRefs(t, inv, audit) + if inv.Status != "partial" || !slices.Contains(inv.Reasons, "deadline_exceeded") || len(inv.Packages) != 0 || *x.calls != 0 { + t.Fatalf("cancelled scan = %s %v %d packages after %d filesystem calls", inv.Status, inv.Reasons, len(inv.Packages), *x.calls) + } + // Cancellation mid-project stops later reads and leaves the shared + // evaluated Gemfile unproven. + ctx, cancel = context.WithCancel(context.Background()) + x.reads, x.afterRead = map[string]int{}, func(path string) { + if path == filepath.Join(app, "gems.locked") { + cancel() + } + } + inv, audit = scanner.Scan(ctx, goTestTarget(home), []string{root}, nil) + x.afterRead = nil + rubyAssertRefs(t, inv, audit) + if !slices.Contains(inv.Reasons, "deadline_exceeded") || x.reads[legacyLock] != 0 { + t.Fatalf("cancelled scan kept reading: reasons %v", inv.Reasons) + } + if src := rubySource(t, inv, "manifest", filepath.Join(root, "shared", "Gemfile.shared")); src.Status != "partial" { + t.Errorf("shared manifest left complete after cancellation: %+v", src) + } + + // Cancelling right after a manifest naming `gemspec` is read stops + // discovery: no candidate listing or any other filesystem call follows. + ctx, cancel = context.WithCancel(context.Background()) + after := -1 + x.afterRead = func(path string) { + if path == filepath.Join(app, "gems.rb") { + cancel() + after = *x.calls + } + } + inv, audit = scanner.Scan(ctx, goTestTarget(home), []string{root}, nil) + x.afterRead = nil + rubyAssertRefs(t, inv, audit) + if after < 0 || *x.calls != after { + t.Fatalf("filesystem calls after cancellation: %d then %d", after, *x.calls) + } + + // Permission denied is never absence. + x.readErrors[filepath.Join(app, "gems.locked")] = os.ErrPermission + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + rubyAssertRefs(t, inv, audit) + if src := rubySource(t, inv, "lockfile", filepath.Join(app, "gems.locked")); src.Presence != "unknown" || !slices.Contains(src.Reasons, "permission_denied") { + t.Errorf("unreadable lock = %+v", src) + } + if p := rubyProject(t, inv, filepath.Join(app, "gems.rb")); len(p.LockfilePaths) != 1 { + t.Errorf("unreadable selected lock dropped from project: %+v", p) + } + delete(x.readErrors, filepath.Join(app, "gems.locked")) + + scanner.protection = func(string, *bool) (func(string) string, func(string) bool) { + return func(p string) string { + if strings.HasPrefix(p, filepath.Join(root, "legacy")) { + return "skipped_protected" + } + return "" + }, func(string) bool { return false } + } + x.reads = map[string]int{} + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + rubyAssertRefs(t, inv, audit) + if src := rubySource(t, inv, "project_search_root", root); !slices.Contains(src.Reasons, "skipped_protected") || x.reads[legacyLock] != 0 { + t.Errorf("protected directory not reported: %+v", src) + } + + // A blocked interpreter may hide its etc/gemrc: the audit is partial so + // earlier history is kept, and files that were read keep their settings. + prefix := filepath.Join(home, ".rbenv", "versions", "3.3.0") + scanner.protection = func(string, *bool) (func(string) string, func(string) bool) { + return func(p string) string { + if p == prefix || strings.HasPrefix(p, prefix+string(filepath.Separator)) { + return "skipped_protected" + } + return "" + }, func(string) bool { return false } + } + _, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + if audit.Status != "partial" || !slices.Contains(audit.Reasons, "skipped_protected") { + t.Errorf("blocked interpreter left audit %s %v", audit.Status, audit.Reasons) + } + for _, c := range audit.Contexts { + if c.Manager == "rubygems" && c.SelectionStatus != "partial" { + t.Errorf("blocked interpreter left RubyGems context %+v", c) + } + } + for _, f := range audit.Files { + if f.Path == filepath.Join(home, ".gemrc") && (f.Status != "present" || len(f.Settings) == 0) { + t.Errorf("readable gemrc lost its settings: %+v", f) + } + } + scanner, x = rubyTestScanner(t, home) + rubyFixtureEnv(x) + + // Every output budget keeps admitted records and leaves only fully + // collected sources complete. + oldOut := maxRubyOutputBytes + t.Cleanup(func() { maxRubyOutputBytes = oldOut }) + empty := emptyRubyInventory() + base, step := goJSONSize(&empty)+(256<<10), goJSONSize(full)/40 + for limit := base; ; limit += step { + if limit > base+4*goJSONSize(full) { + t.Fatal("output budget never admitted the full inventory") + } + maxRubyOutputBytes = limit + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + if !slices.Contains(inv.Reasons, "output_size_limit") { + break + } + if inv.Status != "partial" || goJSONSize(inv)+goJSONSize(audit) > limit { + t.Fatalf("limit %d: status %s size %d", limit, inv.Status, goJSONSize(inv)+goJSONSize(audit)) + } + rubyAssertComplete(t, limit, owned, inv) + rubyAssertRefs(t, inv, audit) + } +} + +func TestRubyScanRootsAndTargetedGemfile(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + rubyWriteFixture(t, home, root) + scanner, x := rubyTestScanner(t, home) + first, firstAudit := scanner.Scan(context.Background(), goTestTarget(home), []string{root, root, filepath.Join(root, "app")}, nil) + second, secondAudit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + a, _ := json.Marshal([]any{first, firstAudit}) + b, _ := json.Marshal([]any{second, secondAudit}) + if !bytes.Equal(a, b) { + t.Fatal("nested or repeated roots changed the inventory") + } + + // BUNDLE_GEMFILE selects a project outside every search root; a missing + // targeted manifest is absent, never a walk. + elsewhere := filepath.Join(home, "elsewhere") + goWrite(t, filepath.Join(elsewhere, "Gemfile"), "source \"https://rubygems.org\"\ngem \"sinatra\"\n", 0o600) + goWrite(t, filepath.Join(elsewhere, "Gemfile.lock"), "GEM\n remote: https://rubygems.org/\n specs:\n sinatra (4.0.0)\n\nDEPENDENCIES\n sinatra\n", 0o600) + x.env["BUNDLE_GEMFILE"] = filepath.Join(elsewhere, "Gemfile") + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + rubyAssertRefs(t, inv, audit) + if p := rubyProject(t, inv, filepath.Join(elsewhere, "Gemfile")); p.SelectionStatus != "observed" || len(p.LockfilePaths) != 1 { + t.Errorf("targeted project %+v", p) + } + if len(rubyPackages(inv, "locked_package", "sinatra")) != 1 || len(rubyPackages(inv, "declared_requirement", "sinatra")) != 1 { + t.Error("targeted project evidence missing") + } + x.env["BUNDLE_GEMFILE"] = filepath.Join(home, "missing", "Gemfile") + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + rubyAssertRefs(t, inv, audit) + if src := rubySource(t, inv, "manifest", filepath.Join(home, "missing", "Gemfile")); src.Presence != "absent" || src.Status != "complete" { + t.Errorf("missing targeted manifest %+v", src) + } + + // An existing alternate Gemfile replaces its directory's default manifest; + // the lock it does not select stays independent evidence. + pair := filepath.Join(root, "pair") + goWrite(t, filepath.Join(pair, "Gemfile.ci"), "source \"https://rubygems.org\"\ngem \"ci-only\"\n", 0o600) + x.env["BUNDLE_GEMFILE"] = filepath.Join(pair, "Gemfile.ci") + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + rubyAssertRefs(t, inv, audit) + if p := rubyProject(t, inv, filepath.Join(pair, "Gemfile.ci")); p.SelectionStatus != "partial" || len(p.LockfilePaths) != 0 { + t.Errorf("selected alternate Gemfile %+v", p) + } + for _, p := range inv.Projects { + if p.ManifestPath == filepath.Join(pair, "Gemfile") { + t.Error("default Gemfile scanned beside the selected Gemfile.ci") + } + } + if len(rubyPackages(inv, "declared_requirement", "ci-only")) != 1 || len(rubyPackages(inv, "declared_requirement", "rake")) != 0 || len(rubyPackages(inv, "locked_package", "rake")) != 1 { + t.Error("alternate Gemfile selection changed evidence") + } + + // Two spellings of one gem home emit its facts once. + link := filepath.Join(home, "gems-link") + if err := os.Symlink(filepath.Join(home, ".rbenv", "versions", "3.3.0", "lib", "ruby", "gems", "3.3.0"), link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + delete(x.env, "BUNDLE_GEMFILE") + x.env["GEM_PATH"] = link + inv, _ = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + if n := len(rubyPackages(inv, "installed_package", "json")); n != 1 { + t.Fatalf("aliased gem home duplicated facts: %d", n) + } +} + +// A shared evaluated Gemfile is complete only when every consumer that could +// use it was read; otherwise backend retirement of its occurrences is unsafe. +func TestRubyScanSharedManifestCoverage(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + shared := filepath.Join(root, "shared.rb") + goWrite(t, shared, "gem \"rack\"\n", 0o600) + for _, name := range []string{"a", "b"} { + goWrite(t, filepath.Join(root, name, "Gemfile"), "source \"https://rubygems.org\"\neval_gemfile \"../shared.rb\"\n", 0o600) + } + consumer := filepath.Join(root, "b") + for _, tc := range []struct { + name, reason string + deny, protect bool + uses int + }{ + {name: "every consumer read", uses: 2}, + {name: "unreadable consumer", reason: "permission_denied", deny: true, uses: 1}, + {name: "protected consumer", reason: "skipped_protected", protect: true, uses: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + scanner, x := rubyTestScanner(t, home) + if tc.deny { + x.readErrors[filepath.Join(consumer, "Gemfile")] = os.ErrPermission + } + if tc.protect { + scanner.protection = func(string, *bool) (func(string) string, func(string) bool) { + return func(p string) string { + if p == consumer || strings.HasPrefix(p, consumer+string(filepath.Separator)) { + return "skipped_protected" + } + return "" + }, func(string) bool { return false } + } + } + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + rubyAssertRefs(t, inv, audit) + if n := len(rubyPackages(inv, "declared_requirement", "rack")); n != tc.uses { + t.Errorf("shared declarations = %d, want %d", n, tc.uses) + } + src := rubySource(t, inv, "manifest", shared) + if tc.reason == "" && src.Status != "complete" || tc.reason != "" && (src.Status != "partial" || !slices.Contains(src.Reasons, tc.reason)) { + t.Errorf("shared manifest %+v", src) + } + }) + } +} diff --git a/internal/executor/env_prefix_test.go b/internal/executor/env_prefix_test.go index 652f251c..230dead6 100644 --- a/internal/executor/env_prefix_test.go +++ b/internal/executor/env_prefix_test.go @@ -18,4 +18,9 @@ func TestHasEnvPrefix(t *testing.T) { if !NewUserAwareExecutor(m, "developer").HasEnvPrefix(prefix) { t.Fatal("user-aware wrapper did not preserve process-only lookup") } + for _, x := range []Executor{NewReal(), m, NewUserAwareExecutor(m, "developer")} { + if got := x.EnvNames(prefix); len(got) != 1 || got[0] != prefix+"EMPTY" || len(x.EnvNames(prefix+"ABSENT_")) != 0 { + t.Fatalf("EnvNames(%q) = %v", prefix, got) + } + } } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index fd5a2686..626fa62e 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -64,6 +64,8 @@ type Executor interface { Getenv(key string) string // HasEnvPrefix checks process environment names without exposing their values. HasEnvPrefix(prefix string) bool + // EnvNames lists process environment names with prefix, never their values. + EnvNames(prefix string) []string // IsRoot returns true if the process is running as root. IsRoot() bool // CurrentUser returns the current OS user. @@ -475,3 +477,16 @@ func (r *Real) HasEnvPrefix(prefix string) bool { } return false } + +func (r *Real) EnvNames(prefix string) []string { + names := []string{} + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + // Windows names are case-insensitive, as Getenv already treats them. + if strings.HasPrefix(key, prefix) || runtime.GOOS == "windows" && len(key) >= len(prefix) && strings.EqualFold(key[:len(prefix)], prefix) { + names = append(names, key) + } + } + slices.Sort(names) + return slices.Compact(names) +} diff --git a/internal/executor/mock.go b/internal/executor/mock.go index 802805a2..4a568a89 100644 --- a/internal/executor/mock.go +++ b/internal/executor/mock.go @@ -6,6 +6,7 @@ import ( "os" "os/user" "path/filepath" + "slices" "strings" "sync" "time" @@ -567,3 +568,15 @@ func (m *Mock) HasEnvPrefix(prefix string) bool { } return false } +func (m *Mock) EnvNames(prefix string) []string { + m.mu.RLock() + defer m.mu.RUnlock() + names := []string{} + for key := range m.env { + if strings.HasPrefix(key, prefix) { + names = append(names, key) + } + } + slices.Sort(names) + return names +} diff --git a/internal/executor/user_aware.go b/internal/executor/user_aware.go index 86d30002..cbe215b8 100644 --- a/internal/executor/user_aware.go +++ b/internal/executor/user_aware.go @@ -347,3 +347,6 @@ func (e *UserAwareExecutor) DiskCapacityBytes(path string) uint64 { // HasEnvPrefix observes only this process; it never sources the user's shell. func (e *UserAwareExecutor) HasEnvPrefix(prefix string) bool { return e.inner.HasEnvPrefix(prefix) } + +// EnvNames observes only this process; it never sources the user's shell. +func (e *UserAwareExecutor) EnvNames(prefix string) []string { return e.inner.EnvNames(prefix) } diff --git a/internal/model/model.go b/internal/model/model.go index 80ea43e9..668e61fb 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1786,3 +1786,117 @@ type NuGetConfigContext struct { ConfigSourceIDs []string `json:"config_source_ids"` // highest precedence first RestoreMetadataPath string `json:"restore_metadata_path,omitempty"` } + +// RubyInventorySchemaVersion versions ruby_inventory and ruby_config_audit. +const RubyInventorySchemaVersion = 1 + +// RubySource reuses the Cargo source shape. Kinds: project_search_root | +// gem_home | manifest | gemspec | lockfile | installed_metadata | +// bundler_checkout | cache_root | cache_archive | cached_metadata. +type RubySource = CargoSource + +// RubyRecordedChecksum is a lock-recorded SHA-256, never a verification. +type RubyRecordedChecksum = ComposerRecordedChecksum + +// RubyCredentialFile reports existence only; contents are never read. +type RubyCredentialFile = CargoCredentialFile + +// RubyInventory covers RubyGems and Bundler from passive metadata reads; no +// Ruby, gem, bundle or Git command runs. Declarations, locks, installed +// receipts and caches stay separate evidence. +type RubyInventory struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` // complete | partial + Reasons []string `json:"reasons"` + Sources []RubySource `json:"sources"` + Projects []RubyProject `json:"projects"` + Packages []RubyPackage `json:"packages"` +} + +// RubyProject is a Gemfile, gems.rb or standalone gemspec project. Partial +// selection means its active lock or declarations are not established. +type RubyProject struct { + ManifestSourceID string `json:"manifest_source_id"` + ManifestPath string `json:"manifest_path"` + ProjectPath string `json:"project_path"` + SelectionStatus string `json:"selection_status"` // observed | partial + LockfilePaths []string `json:"lockfile_paths"` + PackageName string `json:"package_name,omitempty"` + PackageVersion string `json:"package_version,omitempty"` +} + +// RubyPackage is one observation. Names and versions keep their exact +// spelling; platform is the exact artifact platform, never a Gemfile selector. +type RubyPackage struct { + SourceID string `json:"source_id"` + SourcePath string `json:"source_path"` + PackageName string `json:"package_name"` + Evidence string `json:"evidence"` // declared_requirement | locked_package | installed_package | cached_package + ProjectPath string `json:"project_path,omitempty"` + Scope string `json:"scope"` // project | user | system | unknown + Reasons []string `json:"reasons"` + VersionStatus string `json:"version_status"` // known | unknown (declarations) + Version string `json:"version,omitempty"` + Platform string `json:"platform,omitempty"` + // RequestedVersions is present, possibly [], on declarations only. + RequestedVersions *[]string `json:"requested_versions,omitempty"` + Groups []string `json:"groups,omitempty"` + DeclaredPlatforms []string `json:"declared_platforms,omitempty"` + DependencyRelation string `json:"dependency_relation"` // direct | transitive | unknown + DependencyKind string `json:"dependency_kind"` // runtime | development | unknown + Origin RubyOrigin `json:"origin"` + Revision string `json:"revision,omitempty"` // recorded Git revision, lock evidence only + Installation *RubyInstallation `json:"installation,omitempty"` // installed evidence only + ChecksumStatus string `json:"checksum_status"` // absent | recorded | partial + RecordedChecksums []RubyRecordedChecksum `json:"recorded_checksums"` +} + +// RubyOrigin is observed provenance. Receipts and caches are always unknown; +// a configured default registry is never assumed. +type RubyOrigin struct { + Kind string `json:"kind"` // registry | git | path | unknown + URL string `json:"url,omitempty"` + Path string `json:"path,omitempty"` +} + +// RubyInstallation is directory presence, separate from receipt lifecycle. +// Default gems can live in Ruby's own library and have no package directory. +type RubyInstallation struct { + Path string `json:"path,omitempty"` + PathStatus string `json:"path_status"` // resolved | unresolved | not_applicable + Presence string `json:"presence"` // present | absent | unknown | not_applicable + DefaultGem bool `json:"default_gem"` +} + +// RubyConfigAudit follows Composer's observed-source model with a manager on +// each file and context. The backend derives endpoint summaries and counts. +type RubyConfigAudit struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` // complete | partial + Reasons []string `json:"reasons"` + Files []RubyConfigFile `json:"files"` + Contexts []RubyConfigContext `json:"contexts"` + CredentialFiles []RubyCredentialFile `json:"credential_files"` + Findings []CargoConfigFinding `json:"findings"` +} + +// RubyConfigFile is one configuration source. Process sources have no path; a +// source that is not present carries no current settings. +type RubyConfigFile struct { + SourceID string `json:"source_id"` + Manager string `json:"manager"` // rubygems | bundler + Scope string `json:"scope"` // system | user | project | process + Path string `json:"path,omitempty"` + Status string `json:"status"` // present | absent | skipped_protected | unreadable | invalid | unsupported + Reasons []string `json:"reasons"` + Settings []CargoConfigSetting `json:"settings"` +} + +// RubyConfigContext is one manager's observed precedence chain, not a claim +// of a single effective configuration. Ambient contexts have no project. +type RubyConfigContext struct { + Manager string `json:"manager"` + ProjectPath string `json:"project_path,omitempty"` + ConfigSourceIDs []string `json:"config_source_ids"` // highest precedence first + SelectionStatus string `json:"selection_status"` // observed | partial +} diff --git a/internal/model/ruby_golden_test.go b/internal/model/ruby_golden_test.go new file mode 100644 index 00000000..ea0cc9a5 --- /dev/null +++ b/internal/model/ruby_golden_test.go @@ -0,0 +1,72 @@ +package model + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "reflect" + "testing" +) + +func TestRubyGoldenContract(t *testing.T) { + raw, err := os.ReadFile("testdata/ruby_inventory_v1_golden.json") + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != "6fbfc578f80110235d3abcbbed43bea50c8f6dab1b8e62d72425abd284ce9424" { + t.Fatal("shared Ruby fixture digest changed; update both producer and consumer after review") + } + var pair struct { + Inventory *RubyInventory `json:"ruby_inventory"` + Audit *RubyConfigAudit `json:"ruby_config_audit"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&pair); err != nil { + t.Fatal(err) + } + if pair.Inventory == nil || pair.Audit == nil { + t.Fatal("missing section") + } + encoded, err := json.Marshal(pair) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(decodeGeneric(t, encoded), decodeGeneric(t, raw)) { + t.Fatal("wire round-trip dropped a field") + } + seen := map[string]bool{} + for _, s := range pair.Inventory.Sources { + seen["status:"+s.Status] = true + } + for _, p := range pair.Inventory.Packages { + seen["scope:"+p.Scope], seen["evidence:"+p.Evidence], seen["checksum:"+p.ChecksumStatus], seen["origin:"+p.Origin.Kind] = true, true, true, true + if p.Installation != nil { + seen["presence:"+p.Installation.Presence] = true + if p.Installation.DefaultGem { + seen["default_gem"] = true + } + } + } + for _, c := range pair.Audit.Contexts { + seen["context:"+c.Manager] = true + } + for _, f := range pair.Audit.Findings { + seen["finding:"+f.Code] = true + } + for _, key := range []string{ + "scope:project", "scope:user", "status:partial", + "evidence:declared_requirement", "evidence:locked_package", "evidence:installed_package", "evidence:cached_package", + "checksum:recorded", "checksum:partial", "checksum:absent", + "origin:registry", "origin:git", "origin:path", "origin:unknown", + "presence:present", "presence:absent", "presence:not_applicable", "default_gem", + "context:rubygems", "context:bundler", "finding:ruby-001", "finding:ruby-002", "finding:ruby-003", "finding:ruby-004", + } { + if !seen[key] { + t.Errorf("fixture does not exercise %s", key) + } + } +} diff --git a/internal/model/testdata/ruby_inventory_v1_golden.json b/internal/model/testdata/ruby_inventory_v1_golden.json new file mode 100644 index 00000000..1cef1999 --- /dev/null +++ b/internal/model/testdata/ruby_inventory_v1_golden.json @@ -0,0 +1,1394 @@ +{ + "ruby_inventory": { + "schema_version": 1, + "status": "partial", + "reasons": [ + "checksum_invalid", + "unsupported_format" + ], + "sources": [ + { + "source_id": "fdace5b2e2a8330191af4d8ba7f0777e0ba2513599e8d5ce94ebf54fe6a6721b", + "kind": "cache_root", + "path": "/home/ruby-fixture/.bundle/cache/gems", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [ + "281e4f83876f875780942af41a00d015796cacb888d691f4be083fb3d9fd9013" + ] + }, + { + "source_id": "281e4f83876f875780942af41a00d015796cacb888d691f4be083fb3d9fd9013", + "kind": "cache_archive", + "path": "/home/ruby-fixture/.bundle/cache/gems/rubygems.org.443.0123456789abcdef/rack-3.0.8.gem", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "fdace5b2e2a8330191af4d8ba7f0777e0ba2513599e8d5ce94ebf54fe6a6721b", + "discovered_sources": [] + }, + { + "source_id": "afc494d7b20e3d720192815ff7ea03a28c3b2872751794ec8099f9cc0f8a8ebc", + "kind": "gem_home", + "path": "/home/ruby-fixture/.gem/ruby/3.3.0", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [ + "e10054d2539eaad162809c6346a8e53c4e3686619987d3f781d13c9a0ac847f7" + ] + }, + { + "source_id": "e10054d2539eaad162809c6346a8e53c4e3686619987d3f781d13c9a0ac847f7", + "kind": "installed_metadata", + "path": "/home/ruby-fixture/.gem/ruby/3.3.0/specifications/bundler-2.5.3.gemspec", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "afc494d7b20e3d720192815ff7ea03a28c3b2872751794ec8099f9cc0f8a8ebc", + "discovered_sources": [] + }, + { + "source_id": "e24d284f41ebe75301324c9579a820a35e1ec9e2d4dc97754ae90285f3d50924", + "kind": "gem_home", + "path": "/home/ruby-fixture/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [ + "08e8056603aa48df66c580c30985b122e1a2ee53af43e6fa2efa04493db31eb8", + "2559efb51cb4b0a335585c1f78527a846d0b409d86f78b6cf21f10bdb5c1060a", + "33933041718781b9b82f4fc89be9041b6bd61994401bd95c5c8a18c0d75c5211" + ] + }, + { + "source_id": "33933041718781b9b82f4fc89be9041b6bd61994401bd95c5c8a18c0d75c5211", + "kind": "cache_root", + "path": "/home/ruby-fixture/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/cache", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "e24d284f41ebe75301324c9579a820a35e1ec9e2d4dc97754ae90285f3d50924", + "discovered_sources": [ + "549184df50cfb161faa9081a784ee5762eeef878a4b197eae3325fd66de1a027" + ] + }, + { + "source_id": "549184df50cfb161faa9081a784ee5762eeef878a4b197eae3325fd66de1a027", + "kind": "cache_archive", + "path": "/home/ruby-fixture/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/cache/json-2.7.1.gem", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "33933041718781b9b82f4fc89be9041b6bd61994401bd95c5c8a18c0d75c5211", + "discovered_sources": [] + }, + { + "source_id": "2559efb51cb4b0a335585c1f78527a846d0b409d86f78b6cf21f10bdb5c1060a", + "kind": "installed_metadata", + "path": "/home/ruby-fixture/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/specifications/default/forwardable-1.2.0.gemspec", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "e24d284f41ebe75301324c9579a820a35e1ec9e2d4dc97754ae90285f3d50924", + "discovered_sources": [] + }, + { + "source_id": "08e8056603aa48df66c580c30985b122e1a2ee53af43e6fa2efa04493db31eb8", + "kind": "installed_metadata", + "path": "/home/ruby-fixture/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/specifications/json-2.7.1.gemspec", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "e24d284f41ebe75301324c9579a820a35e1ec9e2d4dc97754ae90285f3d50924", + "discovered_sources": [] + }, + { + "source_id": "2f46d96614a8746d006af5ec8dc13f6f01028dd9f3c5f94279d8c008d88c849a", + "kind": "project_search_root", + "path": "/home/ruby-fixture/code", + "status": "partial", + "presence": "present", + "reasons": [ + "checksum_invalid", + "unsupported_format" + ], + "discovered_sources": [ + "1ffd3e534c038a09ffad098fe1240eb0cfeb973937600dda19de0debe6ae8542", + "3ecc9061ff2e5d7b328e8370e22e1e19caa0b148de2f9eb007f776b599351c03", + "67036fbba85d717bd8bbe6b3e610be9ed4a1be41755e6b2fe89dea71458f3d99", + "7b42d29cbf0da4b94864586c287c344dc31a4e8686b0336152a3dd6ca019ad3e", + "84e44db1a030aeb46d6aa5f583b212eff6641f30291901224ad11fe43432d295", + "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "d1531ce5ba91a3245c7b70c1e565d62eccd8319d8cdbad5b70eca3f174ad0fa6" + ] + }, + { + "source_id": "26ebb8807880d57c275e2965a8f3dafc949ac022ceef935a1049bf9ba7d00a72", + "kind": "gemspec", + "path": "/home/ruby-fixture/code/app/app.gemspec", + "status": "partial", + "presence": "present", + "reasons": [ + "unsupported_format" + ], + "parent_source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "discovered_sources": [] + }, + { + "source_id": "b29c835b4d0f58fe8084d4726274aca3ae20eecc772e4831c1cebb5af36da8c0", + "kind": "lockfile", + "path": "/home/ruby-fixture/code/app/gems.locked", + "status": "partial", + "presence": "present", + "reasons": [ + "checksum_invalid" + ], + "parent_source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "discovered_sources": [] + }, + { + "source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "kind": "manifest", + "path": "/home/ruby-fixture/code/app/gems.rb", + "status": "partial", + "presence": "present", + "reasons": [ + "checksum_invalid", + "unsupported_format" + ], + "parent_source_id": "2f46d96614a8746d006af5ec8dc13f6f01028dd9f3c5f94279d8c008d88c849a", + "discovered_sources": [ + "26ebb8807880d57c275e2965a8f3dafc949ac022ceef935a1049bf9ba7d00a72", + "5b3083edef05f45e06af7c9469a1b4a78baae9873e99c1ff0d5ef1d5cce05307", + "b29c835b4d0f58fe8084d4726274aca3ae20eecc772e4831c1cebb5af36da8c0", + "b3bc5a17f3e24edca8912c7c54497e13cc6bc5a456b8f1b225b8a61526ac285a", + "c717bb38dc547bebb40891c93238818c3ad88c90992549eb3a4c95da81ed6de5" + ] + }, + { + "source_id": "c717bb38dc547bebb40891c93238818c3ad88c90992549eb3a4c95da81ed6de5", + "kind": "gem_home", + "path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "discovered_sources": [ + "6a06548ed11de74601c3023192027fdf2c096a81728afc30606e656797221a89", + "7886d19981e796cd0bde407da27344752e2badfe3cb53da6f7b44980d47e9ce9", + "7f8915deab2242b6f92213934166b06d4d22211791bd6ad689017e2e8d570de6" + ] + }, + { + "source_id": "6a06548ed11de74601c3023192027fdf2c096a81728afc30606e656797221a89", + "kind": "bundler_checkout", + "path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/bundler/gems/forked-0123456789ab", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "c717bb38dc547bebb40891c93238818c3ad88c90992549eb3a4c95da81ed6de5", + "discovered_sources": [ + "6775490658c59a64edeb3ee423ed8da9919dc7ec378c86a714fe627fd53e6c32", + "9aebf61442dafc29f2f99f6d3042d90cefbed9f2cd8040853f496ce5fae80f78" + ] + }, + { + "source_id": "9aebf61442dafc29f2f99f6d3042d90cefbed9f2cd8040853f496ce5fae80f78", + "kind": "installed_metadata", + "path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/bundler/gems/forked-0123456789ab/forked.gemspec", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6a06548ed11de74601c3023192027fdf2c096a81728afc30606e656797221a89", + "discovered_sources": [] + }, + { + "source_id": "6775490658c59a64edeb3ee423ed8da9919dc7ec378c86a714fe627fd53e6c32", + "kind": "installed_metadata", + "path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/bundler/gems/forked-0123456789ab/lib-a/lib-a.gemspec", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6a06548ed11de74601c3023192027fdf2c096a81728afc30606e656797221a89", + "discovered_sources": [] + }, + { + "source_id": "7f8915deab2242b6f92213934166b06d4d22211791bd6ad689017e2e8d570de6", + "kind": "installed_metadata", + "path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/specifications/nokogiri-1.16.0-arm64-darwin.gemspec", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "c717bb38dc547bebb40891c93238818c3ad88c90992549eb3a4c95da81ed6de5", + "discovered_sources": [] + }, + { + "source_id": "7886d19981e796cd0bde407da27344752e2badfe3cb53da6f7b44980d47e9ce9", + "kind": "installed_metadata", + "path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/specifications/rack-3.0.8.gemspec", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "c717bb38dc547bebb40891c93238818c3ad88c90992549eb3a4c95da81ed6de5", + "discovered_sources": [] + }, + { + "source_id": "b3bc5a17f3e24edca8912c7c54497e13cc6bc5a456b8f1b225b8a61526ac285a", + "kind": "cache_root", + "path": "/home/ruby-fixture/code/app/vendor/cache", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "discovered_sources": [ + "5c1d783fd1a1a776ac4ebb5123d010a20a6e2523a8c3463adaa50318bd55ae0d", + "eda8373c72fe5da17acdaa28350c87b76f8574b055889434cd414d6e8f2bbf49" + ] + }, + { + "source_id": "5c1d783fd1a1a776ac4ebb5123d010a20a6e2523a8c3463adaa50318bd55ae0d", + "kind": "cached_metadata", + "path": "/home/ruby-fixture/code/app/vendor/cache/forked-0123456789ab/forked.gemspec", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "b3bc5a17f3e24edca8912c7c54497e13cc6bc5a456b8f1b225b8a61526ac285a", + "discovered_sources": [] + }, + { + "source_id": "eda8373c72fe5da17acdaa28350c87b76f8574b055889434cd414d6e8f2bbf49", + "kind": "cache_archive", + "path": "/home/ruby-fixture/code/app/vendor/cache/rack-3.0.8.gem", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "b3bc5a17f3e24edca8912c7c54497e13cc6bc5a456b8f1b225b8a61526ac285a", + "discovered_sources": [] + }, + { + "source_id": "d1531ce5ba91a3245c7b70c1e565d62eccd8319d8cdbad5b70eca3f174ad0fa6", + "kind": "manifest", + "path": "/home/ruby-fixture/code/legacy/Gemfile", + "status": "partial", + "presence": "present", + "reasons": [ + "unsupported_format" + ], + "parent_source_id": "2f46d96614a8746d006af5ec8dc13f6f01028dd9f3c5f94279d8c008d88c849a", + "discovered_sources": [ + "9eda848e18c6c55942a027414fbf4c00ace4ac11a855e87211a0d1d87529a0d0" + ] + }, + { + "source_id": "9eda848e18c6c55942a027414fbf4c00ace4ac11a855e87211a0d1d87529a0d0", + "kind": "lockfile", + "path": "/home/ruby-fixture/code/legacy/Gemfile.lock", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "d1531ce5ba91a3245c7b70c1e565d62eccd8319d8cdbad5b70eca3f174ad0fa6", + "discovered_sources": [] + }, + { + "source_id": "3ecc9061ff2e5d7b328e8370e22e1e19caa0b148de2f9eb007f776b599351c03", + "kind": "gemspec", + "path": "/home/ruby-fixture/code/local/local.gemspec", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "2f46d96614a8746d006af5ec8dc13f6f01028dd9f3c5f94279d8c008d88c849a", + "discovered_sources": [] + }, + { + "source_id": "67036fbba85d717bd8bbe6b3e610be9ed4a1be41755e6b2fe89dea71458f3d99", + "kind": "lockfile", + "path": "/home/ruby-fixture/code/lockonly/Gemfile.lock", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "2f46d96614a8746d006af5ec8dc13f6f01028dd9f3c5f94279d8c008d88c849a", + "discovered_sources": [] + }, + { + "source_id": "1ffd3e534c038a09ffad098fe1240eb0cfeb973937600dda19de0debe6ae8542", + "kind": "manifest", + "path": "/home/ruby-fixture/code/pair/Gemfile", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "2f46d96614a8746d006af5ec8dc13f6f01028dd9f3c5f94279d8c008d88c849a", + "discovered_sources": [ + "adabe7a1550a28fb77fa83bd03cff048e738f3bb71992593b8426f16a71d3ab4" + ] + }, + { + "source_id": "adabe7a1550a28fb77fa83bd03cff048e738f3bb71992593b8426f16a71d3ab4", + "kind": "lockfile", + "path": "/home/ruby-fixture/code/pair/Gemfile.lock", + "status": "complete", + "presence": "absent", + "reasons": [], + "parent_source_id": "1ffd3e534c038a09ffad098fe1240eb0cfeb973937600dda19de0debe6ae8542", + "discovered_sources": [] + }, + { + "source_id": "7b42d29cbf0da4b94864586c287c344dc31a4e8686b0336152a3dd6ca019ad3e", + "kind": "lockfile", + "path": "/home/ruby-fixture/code/pair/gems.locked", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "2f46d96614a8746d006af5ec8dc13f6f01028dd9f3c5f94279d8c008d88c849a", + "discovered_sources": [] + }, + { + "source_id": "5b3083edef05f45e06af7c9469a1b4a78baae9873e99c1ff0d5ef1d5cce05307", + "kind": "manifest", + "path": "/home/ruby-fixture/code/shared/Gemfile.shared", + "status": "partial", + "presence": "present", + "reasons": [ + "unsupported_format" + ], + "parent_source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "discovered_sources": [] + }, + { + "source_id": "84e44db1a030aeb46d6aa5f583b212eff6641f30291901224ad11fe43432d295", + "kind": "gem_home", + "path": "/home/ruby-fixture/code/tools/vendored", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "2f46d96614a8746d006af5ec8dc13f6f01028dd9f3c5f94279d8c008d88c849a", + "discovered_sources": [ + "ab84442d03c5cee5fc6dd5b131d8f7bfc7b8a6fa044c5ab6c85918e533c25d6c" + ] + }, + { + "source_id": "ab84442d03c5cee5fc6dd5b131d8f7bfc7b8a6fa044c5ab6c85918e533c25d6c", + "kind": "installed_metadata", + "path": "/home/ruby-fixture/code/tools/vendored/specifications/thor-1.3.0.gemspec", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "84e44db1a030aeb46d6aa5f583b212eff6641f30291901224ad11fe43432d295", + "discovered_sources": [] + } + ], + "projects": [ + { + "manifest_source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "manifest_path": "/home/ruby-fixture/code/app/gems.rb", + "project_path": "/home/ruby-fixture/code/app", + "selection_status": "observed", + "lockfile_paths": [ + "/home/ruby-fixture/code/app/gems.locked" + ], + "package_name": "app", + "package_version": "1.0.0" + }, + { + "manifest_source_id": "d1531ce5ba91a3245c7b70c1e565d62eccd8319d8cdbad5b70eca3f174ad0fa6", + "manifest_path": "/home/ruby-fixture/code/legacy/Gemfile", + "project_path": "/home/ruby-fixture/code/legacy", + "selection_status": "partial", + "lockfile_paths": [ + "/home/ruby-fixture/code/legacy/Gemfile.lock" + ] + }, + { + "manifest_source_id": "3ecc9061ff2e5d7b328e8370e22e1e19caa0b148de2f9eb007f776b599351c03", + "manifest_path": "/home/ruby-fixture/code/local/local.gemspec", + "project_path": "/home/ruby-fixture/code/local", + "selection_status": "observed", + "lockfile_paths": [], + "package_name": "local", + "package_version": "1.0.0" + }, + { + "manifest_source_id": "1ffd3e534c038a09ffad098fe1240eb0cfeb973937600dda19de0debe6ae8542", + "manifest_path": "/home/ruby-fixture/code/pair/Gemfile", + "project_path": "/home/ruby-fixture/code/pair", + "selection_status": "partial", + "lockfile_paths": [] + } + ], + "packages": [ + { + "source_id": "281e4f83876f875780942af41a00d015796cacb888d691f4be083fb3d9fd9013", + "source_path": "/home/ruby-fixture/.bundle/cache/gems/rubygems.org.443.0123456789abcdef/rack-3.0.8.gem", + "package_name": "rack", + "evidence": "cached_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "3.0.8", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "e10054d2539eaad162809c6346a8e53c4e3686619987d3f781d13c9a0ac847f7", + "source_path": "/home/ruby-fixture/.gem/ruby/3.3.0/specifications/bundler-2.5.3.gemspec", + "package_name": "bundler", + "evidence": "installed_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2.5.3", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/ruby-fixture/.gem/ruby/3.3.0/gems/bundler-2.5.3", + "path_status": "resolved", + "presence": "absent", + "default_gem": false + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "549184df50cfb161faa9081a784ee5762eeef878a4b197eae3325fd66de1a027", + "source_path": "/home/ruby-fixture/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/cache/json-2.7.1.gem", + "package_name": "json", + "evidence": "cached_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2.7.1", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "2559efb51cb4b0a335585c1f78527a846d0b409d86f78b6cf21f10bdb5c1060a", + "source_path": "/home/ruby-fixture/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/specifications/default/forwardable-1.2.0.gemspec", + "package_name": "forwardable", + "evidence": "installed_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.2.0", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path_status": "not_applicable", + "presence": "not_applicable", + "default_gem": true + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "08e8056603aa48df66c580c30985b122e1a2ee53af43e6fa2efa04493db31eb8", + "source_path": "/home/ruby-fixture/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/specifications/json-2.7.1.gemspec", + "package_name": "json", + "evidence": "installed_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2.7.1", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/ruby-fixture/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/gems/json-2.7.1", + "path_status": "resolved", + "presence": "present", + "default_gem": false + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "26ebb8807880d57c275e2965a8f3dafc949ac022ceef935a1049bf9ba7d00a72", + "source_path": "/home/ruby-fixture/code/app/app.gemspec", + "package_name": "rubocop", + "evidence": "declared_requirement", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_versions": [ + "~\u003e 1.60" + ], + "dependency_relation": "direct", + "dependency_kind": "development", + "origin": { + "kind": "registry", + "url": "https://rubygems.org" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "b29c835b4d0f58fe8084d4726274aca3ae20eecc772e4831c1cebb5af36da8c0", + "source_path": "/home/ruby-fixture/code/app/gems.locked", + "package_name": "forked", + "evidence": "locked_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "0.1.0", + "platform": "ruby", + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "git", + "url": "https://github.com/org/forked.git" + }, + "revision": "0123456789abcdef0123456789abcdef01234567", + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "b29c835b4d0f58fe8084d4726274aca3ae20eecc772e4831c1cebb5af36da8c0", + "source_path": "/home/ruby-fixture/code/app/gems.locked", + "package_name": "local", + "evidence": "locked_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "platform": "ruby", + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "path", + "path": "/home/ruby-fixture/code/local" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "b29c835b4d0f58fe8084d4726274aca3ae20eecc772e4831c1cebb5af36da8c0", + "source_path": "/home/ruby-fixture/code/app/gems.locked", + "package_name": "nokogiri", + "evidence": "locked_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.16.0", + "platform": "arm64-darwin", + "dependency_relation": "transitive", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org/" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_id": "b29c835b4d0f58fe8084d4726274aca3ae20eecc772e4831c1cebb5af36da8c0", + "source_kind": "lockfile", + "source_path": "/home/ruby-fixture/code/app/gems.locked", + "verification": "not_verified" + } + ] + }, + { + "source_id": "b29c835b4d0f58fe8084d4726274aca3ae20eecc772e4831c1cebb5af36da8c0", + "source_path": "/home/ruby-fixture/code/app/gems.locked", + "package_name": "nokogiri", + "evidence": "locked_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.16.0", + "platform": "x86_64-linux", + "dependency_relation": "transitive", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org/" + }, + "checksum_status": "partial", + "recorded_checksums": [] + }, + { + "source_id": "b29c835b4d0f58fe8084d4726274aca3ae20eecc772e4831c1cebb5af36da8c0", + "source_path": "/home/ruby-fixture/code/app/gems.locked", + "package_name": "rack", + "evidence": "locked_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "3.0.8", + "platform": "ruby", + "dependency_relation": "transitive", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org/" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_id": "b29c835b4d0f58fe8084d4726274aca3ae20eecc772e4831c1cebb5af36da8c0", + "source_kind": "lockfile", + "source_path": "/home/ruby-fixture/code/app/gems.locked", + "verification": "not_verified" + } + ] + }, + { + "source_id": "b29c835b4d0f58fe8084d4726274aca3ae20eecc772e4831c1cebb5af36da8c0", + "source_path": "/home/ruby-fixture/code/app/gems.locked", + "package_name": "rails", + "evidence": "locked_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "7.1.3", + "platform": "ruby", + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org/" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "source_path": "/home/ruby-fixture/code/app/gems.rb", + "package_name": "forked", + "evidence": "declared_requirement", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_versions": [], + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "git", + "url": "https://github.com/org/forked.git" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "source_path": "/home/ruby-fixture/code/app/gems.rb", + "package_name": "local", + "evidence": "declared_requirement", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_versions": [], + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "path", + "path": "/home/ruby-fixture/code/local" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "source_path": "/home/ruby-fixture/code/app/gems.rb", + "package_name": "rails", + "evidence": "declared_requirement", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_versions": [ + "~\u003e 7.1" + ], + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "c170c2671ff41c7a405d300739eade5847dda5adbaf407e9886ce236feab2ed6", + "source_path": "/home/ruby-fixture/code/app/gems.rb", + "package_name": "rspec", + "evidence": "declared_requirement", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_versions": [], + "groups": [ + "test" + ], + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "9aebf61442dafc29f2f99f6d3042d90cefbed9f2cd8040853f496ce5fae80f78", + "source_path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/bundler/gems/forked-0123456789ab/forked.gemspec", + "package_name": "forked", + "evidence": "installed_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "0.1.0", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/bundler/gems/forked-0123456789ab", + "path_status": "resolved", + "presence": "present", + "default_gem": false + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "6775490658c59a64edeb3ee423ed8da9919dc7ec378c86a714fe627fd53e6c32", + "source_path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/bundler/gems/forked-0123456789ab/lib-a/lib-a.gemspec", + "package_name": "lib-a", + "evidence": "installed_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "0.1.0", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/bundler/gems/forked-0123456789ab/lib-a", + "path_status": "resolved", + "presence": "present", + "default_gem": false + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "7f8915deab2242b6f92213934166b06d4d22211791bd6ad689017e2e8d570de6", + "source_path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/specifications/nokogiri-1.16.0-arm64-darwin.gemspec", + "package_name": "nokogiri", + "evidence": "installed_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.16.0", + "platform": "arm64-darwin", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/gems/nokogiri-1.16.0-arm64-darwin", + "path_status": "resolved", + "presence": "absent", + "default_gem": false + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "7886d19981e796cd0bde407da27344752e2badfe3cb53da6f7b44980d47e9ce9", + "source_path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/specifications/rack-3.0.8.gemspec", + "package_name": "rack", + "evidence": "installed_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "3.0.8", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/ruby-fixture/code/app/vendor/bundle/ruby/3.3.0/gems/rack-3.0.8", + "path_status": "resolved", + "presence": "present", + "default_gem": false + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "5c1d783fd1a1a776ac4ebb5123d010a20a6e2523a8c3463adaa50318bd55ae0d", + "source_path": "/home/ruby-fixture/code/app/vendor/cache/forked-0123456789ab/forked.gemspec", + "package_name": "forked", + "evidence": "cached_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "0.1.0", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "eda8373c72fe5da17acdaa28350c87b76f8574b055889434cd414d6e8f2bbf49", + "source_path": "/home/ruby-fixture/code/app/vendor/cache/rack-3.0.8.gem", + "package_name": "rack", + "evidence": "cached_package", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "3.0.8", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "9eda848e18c6c55942a027414fbf4c00ace4ac11a855e87211a0d1d87529a0d0", + "source_path": "/home/ruby-fixture/code/legacy/Gemfile.lock", + "package_name": "rack", + "evidence": "locked_package", + "project_path": "/home/ruby-fixture/code/legacy", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "2.2.8", + "platform": "ruby", + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org/" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "3ecc9061ff2e5d7b328e8370e22e1e19caa0b148de2f9eb007f776b599351c03", + "source_path": "/home/ruby-fixture/code/local/local.gemspec", + "package_name": "thor", + "evidence": "declared_requirement", + "project_path": "/home/ruby-fixture/code/local", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_versions": [], + "dependency_relation": "direct", + "dependency_kind": "runtime", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "67036fbba85d717bd8bbe6b3e610be9ed4a1be41755e6b2fe89dea71458f3d99", + "source_path": "/home/ruby-fixture/code/lockonly/Gemfile.lock", + "package_name": "json", + "evidence": "locked_package", + "project_path": "/home/ruby-fixture/code/lockonly", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "2.7.1", + "platform": "ruby", + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org/" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "1ffd3e534c038a09ffad098fe1240eb0cfeb973937600dda19de0debe6ae8542", + "source_path": "/home/ruby-fixture/code/pair/Gemfile", + "package_name": "rake", + "evidence": "declared_requirement", + "project_path": "/home/ruby-fixture/code/pair", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_versions": [], + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "7b42d29cbf0da4b94864586c287c344dc31a4e8686b0336152a3dd6ca019ad3e", + "source_path": "/home/ruby-fixture/code/pair/gems.locked", + "package_name": "rake", + "evidence": "locked_package", + "project_path": "/home/ruby-fixture/code/pair", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "13.1.0", + "platform": "ruby", + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org/" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "5b3083edef05f45e06af7c9469a1b4a78baae9873e99c1ff0d5ef1d5cce05307", + "source_path": "/home/ruby-fixture/code/shared/Gemfile.shared", + "package_name": "shared-dep", + "evidence": "declared_requirement", + "project_path": "/home/ruby-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_versions": [], + "dependency_relation": "direct", + "dependency_kind": "unknown", + "origin": { + "kind": "registry", + "url": "https://rubygems.org" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "ab84442d03c5cee5fc6dd5b131d8f7bfc7b8a6fa044c5ab6c85918e533c25d6c", + "source_path": "/home/ruby-fixture/code/tools/vendored/specifications/thor-1.3.0.gemspec", + "package_name": "thor", + "evidence": "installed_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.3.0", + "platform": "ruby", + "dependency_relation": "unknown", + "dependency_kind": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/ruby-fixture/code/tools/vendored/gems/thor-1.3.0", + "path_status": "resolved", + "presence": "present", + "default_gem": false + }, + "checksum_status": "absent", + "recorded_checksums": [] + } + ] + }, + "ruby_config_audit": { + "schema_version": 1, + "status": "partial", + "reasons": [ + "unsupported_format" + ], + "files": [ + { + "source_id": "4a753d3ec9d6c1d5439098d0386d6244422a71920606998211f8366f447618b9", + "manager": "bundler", + "scope": "process", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "4a753d3ec9d6c1d5439098d0386d6244422a71920606998211f8366f447618b9", + "redacted": true + } + ] + }, + { + "source_id": "efbf5762921325f5fdee1243e53b80dd06441b7badb7f5164b0a0f13b8ed2861", + "manager": "bundler", + "scope": "project", + "path": "/home/ruby-fixture/code/app/.bundle/config", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "path", + "display": "vendor/bundle", + "source_id": "efbf5762921325f5fdee1243e53b80dd06441b7badb7f5164b0a0f13b8ed2861", + "redacted": false + } + ] + }, + { + "source_id": "e5846614cecb732a2eac6e329e02b590bb39554b031d743db7846c7b7c814443", + "manager": "bundler", + "scope": "project", + "path": "/home/ruby-fixture/code/app/gems.rb", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "sources.0.type", + "display": "registry", + "source_id": "e5846614cecb732a2eac6e329e02b590bb39554b031d743db7846c7b7c814443", + "redacted": false + }, + { + "key": "sources.0.url", + "display": "https://rubygems.org", + "source_id": "e5846614cecb732a2eac6e329e02b590bb39554b031d743db7846c7b7c814443", + "redacted": false + }, + { + "key": "sources.1.type", + "display": "git", + "source_id": "e5846614cecb732a2eac6e329e02b590bb39554b031d743db7846c7b7c814443", + "redacted": false + }, + { + "key": "sources.1.url", + "display": "https://github.com/org/forked.git", + "source_id": "e5846614cecb732a2eac6e329e02b590bb39554b031d743db7846c7b7c814443", + "redacted": false + } + ] + }, + { + "source_id": "fe3c8ddecbf27a8452ead134027cdb4b4154360227432c4994ef2dac5be259b5", + "manager": "bundler", + "scope": "project", + "path": "/home/ruby-fixture/code/legacy/.bundle/config", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "8a5f8bb9302ed11c52da78ae51c2e95f412ed64039ff8a635e021e2bd6a0eb16", + "manager": "bundler", + "scope": "project", + "path": "/home/ruby-fixture/code/legacy/Gemfile", + "status": "unsupported", + "reasons": [ + "unsupported_format" + ], + "settings": [] + }, + { + "source_id": "3e2fe9fb5c5cec7d488e0d98efb7fd51b99615ced228d9839fac84d130531e0c", + "manager": "bundler", + "scope": "project", + "path": "/home/ruby-fixture/code/pair/.bundle/config", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "49a32fe503d3d2b558f3f8c9fc8c5ce3347f169377c496819fe61cf5ae2fdbc8", + "manager": "bundler", + "scope": "project", + "path": "/home/ruby-fixture/code/pair/Gemfile", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "sources.0.type", + "display": "registry", + "source_id": "49a32fe503d3d2b558f3f8c9fc8c5ce3347f169377c496819fe61cf5ae2fdbc8", + "redacted": false + }, + { + "key": "sources.0.url", + "display": "https://rubygems.org", + "source_id": "49a32fe503d3d2b558f3f8c9fc8c5ce3347f169377c496819fe61cf5ae2fdbc8", + "redacted": false + } + ] + }, + { + "source_id": "dc4e7ebcce5d7668f1ff0f360fbcac0a61b884292ac86898813f40ffc1c69697", + "manager": "bundler", + "scope": "project", + "path": "/home/ruby-fixture/code/shared/Gemfile.shared", + "status": "present", + "reasons": [], + "settings": [] + }, + { + "source_id": "ef12eb3cc57dac326c695eb8c320d48c4eb5cfaf39d6d8db80eb9a98f96d3259", + "manager": "bundler", + "scope": "user", + "path": "/home/ruby-fixture/.bundle/config", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "ef12eb3cc57dac326c695eb8c320d48c4eb5cfaf39d6d8db80eb9a98f96d3259", + "redacted": true + }, + { + "key": "disable_checksum_validation", + "display": "true", + "source_id": "ef12eb3cc57dac326c695eb8c320d48c4eb5cfaf39d6d8db80eb9a98f96d3259", + "redacted": false + } + ] + }, + { + "source_id": "fa4f3ce7db42bced2d95c56d41f775e0176bfd58e65d89177e3f4a1b9e75e180", + "manager": "rubygems", + "scope": "process", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "fa4f3ce7db42bced2d95c56d41f775e0176bfd58e65d89177e3f4a1b9e75e180", + "redacted": true + }, + { + "key": "https_proxy", + "display": "https://proxy.example.invalid:8443/", + "source_id": "fa4f3ce7db42bced2d95c56d41f775e0176bfd58e65d89177e3f4a1b9e75e180", + "redacted": true + } + ] + }, + { + "source_id": "52a4c9e8f9601e3d6d2dbfc021a40106b87199f58c3c7a8299551a722dbef1b1", + "manager": "rubygems", + "scope": "system", + "path": "/etc/gemrc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "b7f6e70a857bd062ef707b6abe86a1d814afc336583a89dd1577762c131f77a9", + "manager": "rubygems", + "scope": "system", + "path": "/home/ruby-fixture/.rbenv/versions/3.3.0/etc/gemrc", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "ssl_verify_mode", + "display": "0", + "source_id": "b7f6e70a857bd062ef707b6abe86a1d814afc336583a89dd1577762c131f77a9", + "redacted": false + } + ] + }, + { + "source_id": "57096c35a99d18fbe47c1be5e18a30b8e66c1d772ba83939648790664ec9d695", + "manager": "rubygems", + "scope": "user", + "path": "/home/ruby-fixture/.gemrc", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "57096c35a99d18fbe47c1be5e18a30b8e66c1d772ba83939648790664ec9d695", + "redacted": true + }, + { + "key": "http_proxy", + "display": "http://proxy.example.invalid:8080", + "source_id": "57096c35a99d18fbe47c1be5e18a30b8e66c1d772ba83939648790664ec9d695", + "redacted": true + }, + { + "key": "sources.0.type", + "display": "registry", + "source_id": "57096c35a99d18fbe47c1be5e18a30b8e66c1d772ba83939648790664ec9d695", + "redacted": false + }, + { + "key": "sources.0.url", + "display": "http://gems.example.invalid/", + "source_id": "57096c35a99d18fbe47c1be5e18a30b8e66c1d772ba83939648790664ec9d695", + "redacted": false + }, + { + "key": "sources.1.type", + "display": "registry", + "source_id": "57096c35a99d18fbe47c1be5e18a30b8e66c1d772ba83939648790664ec9d695", + "redacted": false + }, + { + "key": "sources.1.url", + "display": "https://rubygems.org/", + "source_id": "57096c35a99d18fbe47c1be5e18a30b8e66c1d772ba83939648790664ec9d695", + "redacted": false + } + ] + } + ], + "contexts": [ + { + "manager": "bundler", + "config_source_ids": [ + "4a753d3ec9d6c1d5439098d0386d6244422a71920606998211f8366f447618b9", + "ef12eb3cc57dac326c695eb8c320d48c4eb5cfaf39d6d8db80eb9a98f96d3259" + ], + "selection_status": "observed" + }, + { + "manager": "bundler", + "project_path": "/home/ruby-fixture/code/app", + "config_source_ids": [ + "e5846614cecb732a2eac6e329e02b590bb39554b031d743db7846c7b7c814443", + "dc4e7ebcce5d7668f1ff0f360fbcac0a61b884292ac86898813f40ffc1c69697", + "efbf5762921325f5fdee1243e53b80dd06441b7badb7f5164b0a0f13b8ed2861", + "4a753d3ec9d6c1d5439098d0386d6244422a71920606998211f8366f447618b9", + "ef12eb3cc57dac326c695eb8c320d48c4eb5cfaf39d6d8db80eb9a98f96d3259" + ], + "selection_status": "observed" + }, + { + "manager": "bundler", + "project_path": "/home/ruby-fixture/code/legacy", + "config_source_ids": [ + "8a5f8bb9302ed11c52da78ae51c2e95f412ed64039ff8a635e021e2bd6a0eb16", + "fe3c8ddecbf27a8452ead134027cdb4b4154360227432c4994ef2dac5be259b5", + "4a753d3ec9d6c1d5439098d0386d6244422a71920606998211f8366f447618b9", + "ef12eb3cc57dac326c695eb8c320d48c4eb5cfaf39d6d8db80eb9a98f96d3259" + ], + "selection_status": "partial" + }, + { + "manager": "bundler", + "project_path": "/home/ruby-fixture/code/pair", + "config_source_ids": [ + "49a32fe503d3d2b558f3f8c9fc8c5ce3347f169377c496819fe61cf5ae2fdbc8", + "3e2fe9fb5c5cec7d488e0d98efb7fd51b99615ced228d9839fac84d130531e0c", + "4a753d3ec9d6c1d5439098d0386d6244422a71920606998211f8366f447618b9", + "ef12eb3cc57dac326c695eb8c320d48c4eb5cfaf39d6d8db80eb9a98f96d3259" + ], + "selection_status": "observed" + }, + { + "manager": "rubygems", + "config_source_ids": [ + "fa4f3ce7db42bced2d95c56d41f775e0176bfd58e65d89177e3f4a1b9e75e180", + "57096c35a99d18fbe47c1be5e18a30b8e66c1d772ba83939648790664ec9d695", + "52a4c9e8f9601e3d6d2dbfc021a40106b87199f58c3c7a8299551a722dbef1b1", + "b7f6e70a857bd062ef707b6abe86a1d814afc336583a89dd1577762c131f77a9" + ], + "selection_status": "observed" + } + ], + "credential_files": [ + { + "path": "/home/ruby-fixture/.gem/credentials", + "status": "present", + "presence": "present" + } + ], + "findings": [ + { + "code": "ruby-001", + "severity": "MEDIUM", + "source_id": "57096c35a99d18fbe47c1be5e18a30b8e66c1d772ba83939648790664ec9d695", + "key": "sources.0.url", + "detail": "Ruby package source uses an unencrypted connection." + }, + { + "code": "ruby-002", + "severity": "MEDIUM", + "source_id": "b7f6e70a857bd062ef707b6abe86a1d814afc336583a89dd1577762c131f77a9", + "key": "ssl_verify_mode", + "detail": "TLS certificate verification is disabled by Ruby package configuration." + }, + { + "code": "ruby-003", + "severity": "MEDIUM", + "source_id": "57096c35a99d18fbe47c1be5e18a30b8e66c1d772ba83939648790664ec9d695", + "key": "auth_configured", + "detail": "Credentials are embedded in Ruby package configuration." + }, + { + "code": "ruby-003", + "severity": "MEDIUM", + "source_id": "ef12eb3cc57dac326c695eb8c320d48c4eb5cfaf39d6d8db80eb9a98f96d3259", + "key": "auth_configured", + "detail": "Credentials are embedded in Ruby package configuration." + }, + { + "code": "ruby-004", + "severity": "MEDIUM", + "source_id": "ef12eb3cc57dac326c695eb8c320d48c4eb5cfaf39d6d8db80eb9a98f96d3259", + "key": "disable_checksum_validation", + "detail": "Bundler checksum validation is disabled." + } + ] + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 32d130b2..7544ea2a 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -136,6 +136,8 @@ type Payload struct { ComposerConfigAudit *model.ComposerConfigAudit `json:"composer_config_audit,omitempty"` NuGetInventory *model.NuGetInventory `json:"nuget_inventory,omitempty"` NuGetConfigAudit *model.NuGetConfigAudit `json:"nuget_config_audit,omitempty"` + RubyInventory *model.RubyInventory `json:"ruby_inventory,omitempty"` + RubyConfigAudit *model.RubyConfigAudit `json:"ruby_config_audit,omitempty"` ExecutionLogs *ExecutionLogs `json:"execution_logs,omitempty"` PerformanceMetrics *PerformanceMetrics `json:"performance_metrics,omitempty"` @@ -1158,6 +1160,14 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err endPhase(phaseCtx, phaseCancel, tracker, log, "nuget_scan") postPhase() + // Ruby metadata uses the raw executor and the same developer as Go/Cargo. + phaseCtx, phaseCancel = startPhase(ctx, tracker, "ruby_scan") + log.Progress("Collecting Ruby packages and RubyGems/Bundler configuration...") + rubyInventory, rubyConfigAudit := detector.NewRubyScanner(exec, log).Scan(phaseCtx, browserTarget, searchDirs, cfg.IncludeNetworkVolumes) + log.Progress(" Ruby: %d projects, %d package facts (inventory %s, config %s)", len(rubyInventory.Projects), len(rubyInventory.Packages), rubyInventory.Status, rubyConfigAudit.Status) + endPhase(phaseCtx, phaseCancel, tracker, log, "ruby_scan") + postPhase() + // npm + pip configuration audits — surface-only inventory of every // .npmrc and pip.conf on the host, plus the merged effective views // each tool would resolve. We use the user-aware executor so npm and @@ -1325,6 +1335,8 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err ComposerConfigAudit: composerConfigAudit, NuGetInventory: nugetInventory, NuGetConfigAudit: nugetConfigAudit, + RubyInventory: rubyInventory, + RubyConfigAudit: rubyConfigAudit, ExecutionLogs: &ExecutionLogs{ OutputBase64: execLogsBase64, diff --git a/internal/telemetry/telemetry_out_test.go b/internal/telemetry/telemetry_out_test.go index 75f5548c..4c641d1e 100644 --- a/internal/telemetry/telemetry_out_test.go +++ b/internal/telemetry/telemetry_out_test.go @@ -227,3 +227,32 @@ func TestPayloadNuGetSections(t *testing.T) { } } } + +func TestPayloadRubySections(t *testing.T) { + raw, err := os.ReadFile("../model/testdata/ruby_inventory_v1_golden.json") + if err != nil { + t.Fatal(err) + } + var payload Payload + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&payload); err != nil { + t.Fatalf("Ruby golden does not fit Payload: %v", err) + } + if payload.RubyInventory == nil || payload.RubyConfigAudit == nil { + t.Fatal("Ruby sections not decoded into Payload") + } + data, err := json.Marshal(&Payload{}) + if err != nil { + t.Fatal(err) + } + var empty map[string]json.RawMessage + if err := json.Unmarshal(data, &empty); err != nil { + t.Fatal(err) + } + for _, key := range []string{"ruby_inventory", "ruby_config_audit"} { + if _, exists := empty[key]; exists { + t.Errorf("unrun section %s was emitted", key) + } + } +} From 8fe99cee17286936853283ef9e4b020df26c83bd Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Fri, 23 Oct 2026 02:20:00 +0530 Subject: [PATCH 2/6] fix(detector): handle Ruby runtime discovery paths --- SCAN_COVERAGE.md | 2 +- internal/detector/configaudit/rubyconfig.go | 3 ++- .../detector/configaudit/rubyconfig_test.go | 21 +++++++++++++++++++ internal/detector/rubyscan.go | 8 +++---- internal/detector/rubyscan_test.go | 20 ++++++++++++++++++ internal/safepath/open_windows.go | 2 +- internal/safepath/reader_test.go | 14 +++++++++++++ 7 files changed, 63 insertions(+), 7 deletions(-) diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 92ce40ad..0ee964ad 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -356,7 +356,7 @@ Enterprise telemetry emits `ruby_inventory` and `ruby_config_audit` for the reso | Cached packages | Identity from `metadata.gz` (or `metadata`) inside `.gem` archives in gem-home caches, project `vendor/cache` and the Bundler user cache, without extracting files or decompressing `data.tar.gz`; serialized checkout gemspecs in `vendor/cache`. | | Configuration | `GEMRC` files, `~/.gemrc` (or its XDG fallback), `/etc/gemrc` and interpreter `etc/gemrc` for RubyGems; process, global, project-local and manifest settings for Bundler, with allowlisted source, mirror, path, cache, lock, proxy, TLS and checksum settings per ambient and project context, and findings `ruby-001`…`ruby-004`. `~/.gem/credentials` is checked for existence only. Credentials are recorded as configured only. | -**Discovery.** Gem homes come from rbenv, RVM, asdf, mise and chruby rubies, macOS system Ruby, Homebrew (macOS and Linux) and distribution prefixes, Windows RubyInstaller paths, `~/.gem`, the XDG data directory, verified `GEM_HOME`/`GEM_PATH` and gemrc `gemhome`/`gempath`. Projects come from the selected search roots with protected-path and network-volume guards, plus a verified `BUNDLE_GEMFILE`, which replaces the default manifest in its directory. A project's Bundler `path` follows Bundler's local, environment, global precedence. Gem homes, caches, `.bundle` and VCS directories are pruned from the walk; directory symlinks are not followed. An interpreter prefix that cannot be fully listed leaves the configuration audit partial, so an `etc/gemrc` it may hide keeps its history. +**Discovery.** Gem homes come from rbenv, RVM, asdf, mise and chruby rubies, macOS system Ruby, Homebrew (macOS and Linux) and distribution prefixes (including multiarch RubyGems integration roots), Windows RubyInstaller paths, `~/.gem`, the XDG data directory, verified `GEM_HOME`/`GEM_PATH` and gemrc `gemhome`/`gempath`. Projects come from the selected search roots with protected-path and network-volume guards, plus a verified `BUNDLE_GEMFILE`, which replaces the default manifest in its directory. A project's Bundler `path` follows Bundler's local, environment, global precedence. Gem homes, caches, `.bundle` and VCS directories are pruned from the walk; directory symlinks are not followed. An interpreter prefix that cannot be fully listed leaves the configuration audit partial, so an `etc/gemrc` it may hide keeps its history. **Meaning.** Declared, locked, installed and cached evidence stay independent when they disagree. Recorded checksums are never verified. Installed and cached records have `unknown` origin; a lock origin is a single GEM remote, a Git remote or a resolved path, otherwise `unknown`. A missing package directory changes presence only. An evaluated Gemfile or gemspec shared by several projects stays `partial` while any possible consumer is unread, unsupported or outside the walk. diff --git a/internal/detector/configaudit/rubyconfig.go b/internal/detector/configaudit/rubyconfig.go index 15012833..0e58c91c 100644 --- a/internal/detector/configaudit/rubyconfig.go +++ b/internal/detector/configaudit/rubyconfig.go @@ -409,7 +409,8 @@ func (a *RubyConfigSnapshot) readFile(manager, scope, p string, parse func(*mode a.fileReason(f, "unreadable", "deadline_exceeded") return f } - files := a.exec.GuardedFiles([]string{p}, a.scope.Protected, rubyConfigBytes) + // A missing file cannot establish its own root through a parent symlink. + files := a.exec.GuardedFiles([]string{filepath.Dir(p), p}, a.scope.Protected, rubyConfigBytes) info, err := files.Stat(p) if errors.Is(err, fs.ErrNotExist) { return f diff --git a/internal/detector/configaudit/rubyconfig_test.go b/internal/detector/configaudit/rubyconfig_test.go index 9d59e50f..694210fc 100644 --- a/internal/detector/configaudit/rubyconfig_test.go +++ b/internal/detector/configaudit/rubyconfig_test.go @@ -3,11 +3,13 @@ package configaudit import ( "context" "encoding/json" + "os" "path/filepath" "slices" "strings" "testing" + "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" ) @@ -268,6 +270,25 @@ func TestRubyConfigRejectsUnsafeYAMLAndUnverifiedProcess(t *testing.T) { } func TestRubyConfigGemrcSelection(t *testing.T) { + t.Run("missing file under directory alias", func(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + physical, alias := filepath.Join(root, "physical"), filepath.Join(root, "alias") + if err := os.Mkdir(physical, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(physical, alias); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + a := NewRubyConfigDetector(executor.NewReal()).Detect(t.Context(), RubyConfigScope{Username: "dev", Home: root}) + p := filepath.Join(alias, "gemrc") + f := a.readFile("rubygems", "system", p, a.parseGemrc) + if f.Status != "absent" || len(f.Reasons) != 0 { + t.Fatalf("missing config = %+v", f) + } + }) m, scope := rubyConfigTest(t) home := scope.Home first, second := filepath.Join(home, "one.gemrc"), filepath.Join(home, "two.gemrc") diff --git a/internal/detector/rubyscan.go b/internal/detector/rubyscan.go index ffe26a59..04f8a626 100644 --- a/internal/detector/rubyscan.go +++ b/internal/detector/rubyscan.go @@ -406,7 +406,7 @@ func (c *rubyScan) knownRoots() ([]string, []string) { brew("/usr/local") case model.PlatformLinux: prefixes = append(prefixes, root{"/usr", nil}, root{"/usr/local", nil}, root{"/opt/rubies", []string{"*"}}) - homes = append(homes, root{"/var/lib/gems", []string{"*"}}, root{"/usr/share/rubygems-integration", []string{"*"}}) + homes = append(homes, root{"/var/lib/gems", []string{"*"}}, root{"/usr/share/rubygems-integration", []string{"*"}}, root{"/usr/lib", []string{"*-linux-gnu*", "rubygems-integration", "*"}}) brew("/home/linuxbrew/.linuxbrew") case model.PlatformWindows: prefixes = append(prefixes, root{filepath.Join(home, "AppData", "Local", "Programs"), []string{"Ruby*"}}, @@ -432,17 +432,17 @@ func (c *rubyScan) knownRoots() ([]string, []string) { seen[c.key(phys)] = true scope := c.scopeOf(p) found := false - gemHomes, reasons := c.expand(p, "lib", "ruby", "gems", "*") + gemHomes, reasons := c.expand(phys, "lib", "ruby", "gems", "*") incomplete = append(incomplete, reasons...) for _, h := range gemHomes { found = c.gemHome(h, scope, "", "") || found } // TruffleRuby keeps its gem home directly under lib. - found = c.gemHome(filepath.Join(p, "lib", "gems"), scope, "", "") || found + found = c.gemHome(filepath.Join(phys, "lib", "gems"), scope, "", "") || found if !found { continue } - out = append(out, p) + out = append(out, phys) if scope == "user" { // A manager-installed interpreter is never a project tree; // shared system prefixes like /usr can still contain projects. diff --git a/internal/detector/rubyscan_test.go b/internal/detector/rubyscan_test.go index 1292b2b1..9635c044 100644 --- a/internal/detector/rubyscan_test.go +++ b/internal/detector/rubyscan_test.go @@ -681,6 +681,26 @@ func TestRubyScanRootsAndTargetedGemfile(t *testing.T) { if n := len(rubyPackages(inv, "installed_package", "json")); n != 1 { t.Fatalf("aliased gem home duplicated facts: %d", n) } + + // A runtime alias must not turn missing optional directories into refusals. + prefix := filepath.Join(home, ".rbenv", "versions", "3.3.0") + physical := filepath.Join(home, "ruby-keg") + if err := os.Rename(prefix, physical); err != nil { + t.Fatal(err) + } + if err := os.Symlink(physical, prefix); err != nil { + t.Fatal(err) + } + inv, audit = scanner.Scan(t.Context(), goTestTarget(home), []string{root}, nil) + gemHome := filepath.Join(physical, "lib", "ruby", "gems", "3.3.0") + if src := rubySource(t, inv, "gem_home", gemHome); src.Status != "complete" { + t.Errorf("aliased runtime gem home = %+v", src) + } + for _, f := range audit.Files { + if slices.Contains(f.Reasons, "outside_approved_roots") { + t.Errorf("aliased runtime config = %+v", f) + } + } } // A shared evaluated Gemfile is complete only when every consumer that could diff --git a/internal/safepath/open_windows.go b/internal/safepath/open_windows.go index 31a3eb6c..fe4faa21 100644 --- a/internal/safepath/open_windows.go +++ b/internal/safepath/open_windows.go @@ -26,7 +26,7 @@ import ( // follows nothing has already refused a reparse point it saw during resolution, // and this reports the ones that appear after it. func openVerified(resolved string, wantDir, noFollow bool) (*os.File, os.FileInfo, error) { - if _, comps := split(resolved); len(comps) == 0 { + if _, comps := split(resolved); len(comps) == 0 && !wantDir { return nil, nil, refuse(ReasonUnresolved) } diff --git a/internal/safepath/reader_test.go b/internal/safepath/reader_test.go index 6c40f593..545d7eda 100644 --- a/internal/safepath/reader_test.go +++ b/internal/safepath/reader_test.go @@ -88,6 +88,20 @@ func TestReaderDotDotAfterSymlinkPreservesTarget(t *testing.T) { } func TestReaderReadDirLimit(t *testing.T) { + if runtime.GOOS == "windows" { + volume := filepath.VolumeName(t.TempDir()) + string(filepath.Separator) + r := NewReader([]string{volume}, nil) + if entries, _, err := r.ReadDirLimit(volume, 1); err != nil || len(entries) > 1 { + t.Fatalf("volume listing = %d entries, %v", len(entries), err) + } + if _, err := r.ReadFile(volume, 1); err == nil { + t.Fatal("volume root accepted as a file") + } + guarded := NewReader([]string{volume}, func(string) string { return "guarded" }) + if _, _, err := guarded.ReadDirLimit(volume, 1); ReasonOf(err) != "guarded" { + t.Fatalf("volume guard = %v", err) + } + } base := tempHome(t) root, outside := filepath.Join(base, "root"), filepath.Join(base, "outside") for _, name := range []string{"c", "a", "b"} { From 5261ef2a925384fb23cd14a9af6679c1aeb74607 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Fri, 23 Oct 2026 11:30:05 +0530 Subject: [PATCH 3/6] fix(detector): retain Ruby evidence when config discovery fails --- internal/detector/rubyscan.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/detector/rubyscan.go b/internal/detector/rubyscan.go index 04f8a626..d13a883b 100644 --- a/internal/detector/rubyscan.go +++ b/internal/detector/rubyscan.go @@ -976,6 +976,10 @@ func (c *rubyScan) project(path, parent string, targeted bool) { } } leads := c.config.Project(configaudit.RubyProject{Dir: dir, Manifests: manifests}) + if leads.Partial && src.Status == "complete" && src.Presence == "present" { + // Config may hide installed roots that this source previously owned. + c.degrade(src, "path_unresolved") + } // Several lockfile directives depend on evaluation order this reader // does not model; the default lock is then only a candidate. partial := !complete || leads.Partial || directives > 1 @@ -998,7 +1002,11 @@ func (c *rubyScan) project(path, parent string, targeted bool) { } if leads.Path != "" { // A configured path appends engine/ABI; discover rather than guess them. - homes, _ := c.expand(leads.Path, "*", "*") + homes, reasons := c.expand(leads.Path, "*", "*") + for _, reason := range reasons { + c.degrade(src, reason) + partial = true + } for _, h := range homes { c.gemHome(h, "project", src.SourceID, dir) } From 8ff72a029e06b2e03bf1fc93c607342320d7f691 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Fri, 23 Oct 2026 22:52:23 +0530 Subject: [PATCH 4/6] fix(detector): guard Ruby credentials and report truncated discovery --- internal/detector/configaudit/rubyconfig.go | 27 +++++++- internal/detector/rubyscan.go | 9 ++- internal/detector/rubyscan_test.go | 76 +++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/internal/detector/configaudit/rubyconfig.go b/internal/detector/configaudit/rubyconfig.go index 0e58c91c..8d21defd 100644 --- a/internal/detector/configaudit/rubyconfig.go +++ b/internal/detector/configaudit/rubyconfig.go @@ -95,6 +95,7 @@ type RubyConfigSnapshot struct { literal map[string]bool homes map[string]bool credentials map[string]bool + credentialPaths map[string]bool gemProcess, bundle *model.RubyConfigFile userGemrc, systemrc []*model.RubyConfigFile global *model.RubyConfigFile @@ -136,6 +137,17 @@ func (d *RubyConfigDetector) Detect(ctx context.Context, scope RubyConfigScope) a.fileReason(a.bundle, "unsupported", "user_unresolved") a.partial = true } + a.credentialPaths = map[string]bool{} + for _, p := range []string{ + filepath.Join(home, ".gem", "credentials"), + filepath.Join(home, ".local", "share", "gem", "credentials"), + filepath.Join(cmpOr(a.env["XDG_DATA_HOME"], filepath.Join(home, ".local", "share")), "gem", "credentials"), + } { + a.credentialPaths[a.key(p)] = true + if resolved, err := a.exec.GuardedFiles([]string{p}, scope.Protected, 0).EvalSymlinks(p); err == nil { + a.credentialPaths[a.key(resolved)] = true + } + } // GEMRC files merge in list order, so the last one has highest precedence. for _, p := range slices.Backward(gemrc) { a.userGemrc = append(a.userGemrc, a.readFile("rubygems", "user", p, a.parseGemrc)) @@ -185,6 +197,19 @@ func cmpOr(v, fallback string) string { // path, or "" when unset, unverified or unsafe. func (a *RubyConfigSnapshot) Env(key string) string { return a.env[key] } +// ContentGuard keeps credential files existence-only, including redirected reads. +func (a *RubyConfigSnapshot) ContentGuard(path string) string { + if a.scope.Protected != nil { + if reason := a.scope.Protected(path); reason != "" { + return reason + } + } + if a.credentialPaths[a.key(path)] { + return "skipped_protected" + } + return "" +} + func (a *RubyConfigSnapshot) key(p string) string { return GoPathKey(a.exec.GOOS(), p) } func (a *RubyConfigSnapshot) degrade(reason string) { a.audit.Status = "partial" @@ -410,7 +435,7 @@ func (a *RubyConfigSnapshot) readFile(manager, scope, p string, parse func(*mode return f } // A missing file cannot establish its own root through a parent symlink. - files := a.exec.GuardedFiles([]string{filepath.Dir(p), p}, a.scope.Protected, rubyConfigBytes) + files := a.exec.GuardedFiles([]string{filepath.Dir(p), p}, a.ContentGuard, rubyConfigBytes) info, err := files.Stat(p) if errors.Is(err, fs.ErrNotExist) { return f diff --git a/internal/detector/rubyscan.go b/internal/detector/rubyscan.go index d13a883b..c387075f 100644 --- a/internal/detector/rubyscan.go +++ b/internal/detector/rubyscan.go @@ -130,10 +130,11 @@ func (s *RubyScanner) Scan(ctx context.Context, target *user.User, searchDirs [] dirs = append(dirs, dir) c.roots = append(c.roots, dir) } - c.files = s.exec.GuardedFiles(c.roots, guard, maxRubyMetadataBytes) current, err := s.exec.CurrentUser() verified := err == nil && current != nil && target.Uid != "" && current.Uid == target.Uid c.config = configDetector.Detect(ctx, configaudit.RubyConfigScope{Username: target.Username, Home: c.home, Protected: guard, Volume: volume, ProcessVerified: verified}) + c.guard = c.config.ContentGuard + c.files = s.exec.GuardedFiles(c.roots, c.guard, maxRubyMetadataBytes) c.config.Prefixes(c.knownRoots()) // Configured leads follow standard roots, so a shared root keeps its scope. for _, home := range c.config.GemHomes { @@ -337,7 +338,7 @@ func (c *rubyScan) expand(base string, parts ...string) ([]string, []string) { if c.stopped(nil) { return nil, append(reasons, c.stopReason()) } - entries, _, err := c.fs(p, 0).ReadDirLimit(p, maxRubyListEntries) + entries, more, err := c.fs(p, 0).ReadDirLimit(p, maxRubyListEntries) if err != nil { if !errors.Is(err, fs.ErrNotExist) { reason := c.reason(err, p) @@ -346,6 +347,10 @@ func (c *rubyScan) expand(base string, parts ...string) ([]string, []string) { } continue } + if more { + c.degrade(nil, "entry_limit") + reasons = append(reasons, "entry_limit") + } pattern := part if c.goos == model.PlatformWindows { pattern = strings.ToLower(part) diff --git a/internal/detector/rubyscan_test.go b/internal/detector/rubyscan_test.go index 9635c044..89e121a8 100644 --- a/internal/detector/rubyscan_test.go +++ b/internal/detector/rubyscan_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "slices" + "strconv" "strings" "testing" @@ -76,6 +77,67 @@ func rubyTestScanner(t *testing.T, home string) (*RubyScanner, *rubyTrap) { return s, x } +func TestRubyScanCredentialReferences(t *testing.T) { + for _, kind := range []string{"direct", "alias", "credential-alias", "xdg", "default-xdg", "config"} { + t.Run(kind, func(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "projects") + scanner, x := rubyTestScanner(t, home) + credential := filepath.Join(home, ".gem", "credentials") + switch kind { + case "xdg": + x.env["XDG_DATA_HOME"] = filepath.Join(home, "data") + credential = filepath.Join(home, "data", "gem", "credentials") + case "default-xdg": + credential = filepath.Join(home, ".local", "share", "gem", "credentials") + } + goWrite(t, credential, "---\n:rubygems_api_key: CANARY_RUBY_CREDENTIAL\n", 0o600) + reference := credential + if kind == "alias" || kind == "credential-alias" { + alias := filepath.Join(home, "Gemfile.shared") + from, to := credential, alias + if kind == "credential-alias" { + if err := os.Rename(credential, alias); err != nil { + t.Fatal(err) + } + from, to = alias, credential + } + if err := os.Symlink(from, to); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + reference = alias + } + manifest := filepath.Join(root, "Gemfile") + if kind == "config" { + x.env["BUNDLE_USER_CONFIG"] = credential + goWrite(t, manifest, "source \"https://rubygems.org\"\n", 0o600) + } else { + goWrite(t, manifest, "eval_gemfile \""+filepath.ToSlash(reference)+"\"\n", 0o600) + } + inv, audit := scanner.Scan(t.Context(), goTestTarget(home), []string{root}, nil) + if x.reads[reference] != 0 || x.reads[credential] != 0 { + t.Fatal("credential contents were read") + } + if kind == "config" { + if !slices.Contains(audit.Reasons, "skipped_protected") { + t.Fatalf("audit reasons: %v", audit.Reasons) + } + } else if !slices.Contains(inv.Reasons, "skipped_protected") { + t.Fatalf("inventory reasons: %v", inv.Reasons) + } + found := false + for _, f := range audit.CredentialFiles { + if f.Path == credential && f.Presence == "present" { + found = true + } + } + if !found { + t.Fatal("credential existence was lost") + } + }) + } +} + func rubyStub(name, version, platform string) string { return "# -*- encoding: utf-8 -*-\n# stub: " + name + " " + version + " " + platform + " lib\n\nGem::Specification.new do |s|\nend\n" } @@ -594,6 +656,20 @@ func TestRubyScanLimitsDeadlineAndGuards(t *testing.T) { scanner, x = rubyTestScanner(t, home) rubyFixtureEnv(x) + t.Run("truncated interpreter listing", func(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "projects") + versions := filepath.Join(home, ".rbenv", "versions") + for i := range maxRubyListEntries + 1 { + goWrite(t, filepath.Join(versions, strconv.Itoa(i)), "", 0o600) + } + scanner, _ := rubyTestScanner(t, home) + inv, audit := scanner.Scan(t.Context(), goTestTarget(home), []string{root}, nil) + if !slices.Contains(inv.Reasons, "entry_limit") || !slices.Contains(audit.Reasons, "entry_limit") { + t.Fatalf("truncated listing: inventory %v, audit %v", inv.Reasons, audit.Reasons) + } + }) + // Every output budget keeps admitted records and leaves only fully // collected sources complete. oldOut := maxRubyOutputBytes From 0e59ec3ee642c7275fae0b57a853aeadd7755921 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Mon, 26 Oct 2026 00:58:46 +0530 Subject: [PATCH 5/6] feat(detector): add Conda package inventory and configuration audit Add optional conda_inventory and conda_config_audit telemetry sections collected in a new conda_scan phase. The scanner passively reads conda-meta receipts, extracted package caches, environment manifests, explicit, conda-lock and rattler locks, and the .condarc family plus saved environment state and process variables. It never executes conda, mamba, pixi or Python, never activates environments, never reads credential contents and never resolves dependencies. Discovery covers standard installation roots, verified CONDA_PREFIX, CONDA_ROOT, envs_dirs and pkgs_dirs leads, the environments.txt registry and project search roots. Sources carry discovery parents so a later scan of a surviving ancestor can prove removal; unreadable, cancelled, protected or budget-limited inputs leave their sources partial instead of retiring prior evidence. Channel, proxy and artifact URLs are emitted without credentials, query strings, fragments or Conda token path components. Findings conda-001 through conda-004 report unencrypted channels, disabled TLS verification, embedded credentials and disabled safety checks. Existing Python discovery and output are unchanged. --- README.md | 3 + SCAN_COVERAGE.md | 20 +- internal/detector/conda_golden_test.go | 84 + internal/detector/condametadata.go | 1038 ++++++ internal/detector/condametadata_test.go | 556 ++++ internal/detector/condascan.go | 1092 +++++++ internal/detector/condascan_test.go | 1013 ++++++ internal/detector/configaudit/condaconfig.go | 1264 ++++++++ .../detector/configaudit/condaconfig_test.go | 546 ++++ internal/detector/fuzz_test.go | 86 + internal/model/conda_golden_test.go | 96 + internal/model/model.go | 115 + .../testdata/conda_inventory_v1_golden.json | 2785 +++++++++++++++++ internal/telemetry/telemetry.go | 12 + internal/telemetry/telemetry_out_test.go | 29 + 15 files changed, 8738 insertions(+), 1 deletion(-) create mode 100644 internal/detector/conda_golden_test.go create mode 100644 internal/detector/condametadata.go create mode 100644 internal/detector/condametadata_test.go create mode 100644 internal/detector/condascan.go create mode 100644 internal/detector/condascan_test.go create mode 100644 internal/detector/configaudit/condaconfig.go create mode 100644 internal/detector/configaudit/condaconfig_test.go create mode 100644 internal/model/conda_golden_test.go create mode 100644 internal/model/testdata/conda_inventory_v1_golden.json diff --git a/README.md b/README.md index ab2326ee..e455c59f 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,7 @@ See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detec | Rust Packages | `Cargo.toml` declarations, `Cargo.lock`, registry cache, Git checkouts, vendored sources, `cargo install` receipts (enterprise) | | PHP Packages | `composer.json` requirements, `composer.lock` snapshots and Composer 1/2 installed receipts, with separate directory presence (enterprise) | | .NET Packages | MSBuild project and `packages.config` references, `packages.lock.json`, `project.assets.json` restores, global package cache, .NET tools, templates and `.deps.json` (enterprise) | +| Conda Packages | `environment.yml` declarations, `@EXPLICIT`, conda-lock and pixi locks, `conda-meta` receipts in every environment, extracted package caches and `.condarc` configuration (enterprise) | | Package Configs | npm (`.npmrc`), pnpm, bun (`bunfig.toml`), yarn classic & berry (`.yarnrc`/`.yarnrc.yml`), pip (`pip.conf`) — effective registry, cooldown policy, and auth surface across every scope | | Suspicious Files | Malicious-file IOCs from StepSecurity-maintained rules — e.g. `binding.gyp` that runs during `npm install`, and editor/AI-tool config files that auto-execute on project open | @@ -332,6 +333,8 @@ NuGet auditing reads project, ancestor, user and machine `NuGet.Config` files pl Ruby auditing reads RubyGems `gemrc` files and Bundler global, project and process settings for the resolved developer. It reports package sources, mirrors, install and cache paths, proxy, TLS and checksum settings with credentials redacted, and records which configuration applied to each project. It never executes `ruby`, `gem` or `bundle`, and reads `~/.gem/credentials` for existence only. See [Ruby Packages](SCAN_COVERAGE.md#ruby-packages) for scope and limits. +Conda auditing reads `.condarc` files from the system, installation root, user and environment search path, `condarc.d` fragments, saved `conda-meta/state` variables and verified `CONDA_*` process settings for the resolved developer. It reports channels, channel aliases, custom channels, environment and package directories, proxy, TLS and safety-check settings with credentials and Anaconda tokens redacted, and records which configuration applied to each environment and project. It never executes `conda`, `mamba`, `micromamba`, `pixi` or Python, and checks `~/.netrc` and the anaconda-client token store for existence only. See [Conda Packages](SCAN_COVERAGE.md#conda-packages) for scope and limits. + Enterprise Device Policy can also set StepSecurity Secure Registry as the sole user-level Python index for pip and uv. It manages only the resolved developer's user configuration and shared StepSecurity `.netrc` entry, keeps pip and uv results independent, and restores owned settings on an explicit policy clear. Project files, virtual environments, system configuration, environment variables, direct URLs, and Poetry are not modified. ### Suspicious file detection diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 0ee964ad..f4093d0d 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -240,7 +240,7 @@ Discovered by walking a bounded set of Python **install trees** and recognizing - **Frameworks (macOS):** Command Line Tools, Xcode, and python.org (`/Library/Frameworks/Python*.framework/…`). The `/usr/bin/python3` wrapper does not resolve into these, so they are found structurally. - **Homebrew:** `/opt/homebrew/lib/python*`, `/opt/homebrew/Cellar/python*`. - **System:** `/usr/local/lib/python*`; Linux `/usr/lib/python*`, `/usr/lib64/python*`. -- **Version managers:** pyenv, asdf, uv, rye, conda/mamba (base + named envs), pipx. +- **Version managers:** pyenv, asdf, uv, rye, conda/mamba (base + named envs), pipx. Conda environments are also inventoried as Conda packages, independently of their Python metadata; see [Conda Packages](#conda-packages). - **User site:** `~/.local/lib/python*`, and `~/Library/Python/*` on macOS. ### Project virtual environments @@ -362,6 +362,24 @@ Enterprise telemetry emits `ruby_inventory` and `ruby_config_audit` for the reso **Limits and privacy.** Each metadata file is capped at 2 MiB and each `.gem` read at 32 MiB; discovery at 100,000 entries per root and depth 128; inventory at 50,000 records; audit at 256 KiB; combined sections at 16 MiB. Failures and limits carry partial coverage. YAML tags and aliases are never instantiated, and a relevant Bundler config line that Bundler's line reader interprets differently from YAML (a comment after a quoted value, escapes, extra spacing) makes the file `unsupported`. URL credentials, queries and fragments are removed before telemetry; API keys, S3 secrets, host credential values and command option strings are never collected, and arbitrary `BUNDLE_*` variables are not read. Plugin sources, Homebrew formula inventory and other users' homes are outside v1 coverage. +## Conda Packages + +Enterprise telemetry emits `conda_inventory` and `conda_config_audit` for the resolved developer. Collection is static: `conda`, `mamba`, `micromamba`, `pixi`, Python and shells are never executed, no activation script or plugin runs, no channel is contacted and nothing is solved. Conda packages are language-independent, so every receipt is reported whatever it contains; Python metadata inside the same environment stays with the Python scan. + +| Evidence | Source and meaning | +|---|---| +| Declared requirements | `dependencies` entries of `environment.yml`, `environment.yaml`, `env.yml`, `env.yaml` and `environment.json`, with the literal spec kept and an origin only from a URL or local `channel::` qualifier. `pip:` blocks are Python, not Conda, and are ignored. Bracket, namespace and wildcard specs, `sel()` mappings and `# [selector]` comments are `unsupported_format` and leave the project `partial`. `prefix:` never claims an installation. | +| Locked packages | `@EXPLICIT` lists (`explicit.txt` and any `conda-*.lock` whose header proves it) with the `# platform:` comment and `#md5`/`#sha256` fragments; conda-lock v1 `conda-lock.yml`/`.yaml` `conda` rows per platform; pixi/rattler v6 and v7 `pixi.lock` `conda` rows per environment and platform. Wheel rows and `pypi` rows are reported as unsupported coverage, never as Conda packages. Every lock beside a manifest is listed on that project. | +| Installed packages | `conda-meta/--.json` receipts in installation roots, `envs/*`, `~/.conda/environments.txt` prefixes, project `.conda` and `.pixi/envs/*` prefixes, verified `CONDA_PREFIX`, `CONDA_ROOT`, `MAMBA_ROOT_PREFIX` and configured `envs_dirs`. The filename must agree with the identity inside (`identity_mismatch` otherwise), and `requested_specs` make the relation `direct`. A prefix without its `history` (or `pixi`) marker keeps its receipts and is `extraction_incomplete`. | +| Cached packages | `info/repodata_record.json` (download record) or `info/index.json` (archive index) of extracted packages in `pkgs` caches and configured `pkgs_dirs`, including Micromamba's nested `pkgs/https/…` layout. Archives are never opened: an archive with no extracted directory beside it is `unsupported_format`, and `urls.txt`, `cache/` and lock files are never read. | +| Configuration | `/etc/conda`, `/var/lib/conda`, `C:\ProgramData\conda`, `$CONDA_ROOT` and every installation root (system); `$XDG_CONFIG_HOME/conda`, `~/.config/conda`, `~/.conda` and `~/.condarc` (user); `$CONDA_PREFIX` and each discovered prefix, with `conda-meta/state` saved variables (environment); `$CONDARC` and `CONDA_*` / proxy process variables (process); manifest `channels` (project). `.condarc`, `condarc` and `condarc.d/*.yml|yaml` are read in loader order with allowlisted channel, alias, directory, proxy, TLS, offline, priority, safety-check and `exclude_newer` settings, and findings `conda-001`…`conda-004`. `~/.netrc` (`_netrc` on Windows) and the anaconda-client token store are checked for existence only. Credentials are recorded as configured only. | + +**Discovery.** Installation roots come from `~/miniconda3`, `~/anaconda3`, `~/miniforge3`, `~/mambaforge`, `~/micromamba`, `~/.conda`, `/opt/conda`, `/opt/anaconda3`, `/opt/miniconda3`, `/opt/miniforge3`, Homebrew casks, `%LOCALAPPDATA%\conda\conda` and `C:\ProgramData\{miniconda3,anaconda3,miniforge3}`, plus verified process prefixes, configured `envs_dirs`/`pkgs_dirs` and the registry `~/.conda/environments.txt`. Projects come from the selected search roots with protected-path and network-volume guards; a directory holding `conda-meta` is an environment wherever it lives, and one holding `envs` and `pkgs` is a root. Roots, prefixes, caches, `node_modules`, `.cache`, `__pycache__` and VCS directories are pruned from the walk; directory symlinks are not followed. A root that cannot be listed is reported with unknown presence, and its configuration contexts stay partial. An arbitrary project `.condarc` is not applied: only the loader's search path, `$CONDARC` and discovered prefixes are configuration sources. + +**Meaning.** Declared, locked, installed and cached evidence stay independent when they disagree. Recorded MD5 and SHA-256 values are never verified. Origin is a channel only from a channel URL or a download URL that carries one; a bare label such as `conda-forge` without a URL is `unknown`, `file://` channels are `local`. Installed and cached records are `unknown` dependency relation unless a request spec was recorded. A relative explicit path is `path_unresolved` because the format anchors it to the invoking directory, not the file. When more than one installation root holds configuration and `CONDA_ROOT` is unset, the ambient context is `partial`. + +**Limits and privacy.** Each metadata file is capped at 2 MiB (receipt `files` and `paths_data` are never decoded); discovery at 100,000 entries per root, depth 128 and cache depth 8; inventory at 50,000 records; `requested_specs` at 32 per package; audit at 256 KiB; combined sections at 16 MiB. Failures and limits carry partial coverage, and admitted records are kept when collection stops. YAML anchors, aliases, tags and merge keys are never instantiated. Anaconda `/t/` path components, URL credentials, queries and fragments are removed before telemetry, a percent-encoded token redacts the whole URL, manifest `variables` and state values are never collected, and arbitrary `CONDA_*` variables are not read. Archive-only caches, rattler's own cache, `.mambarc`/`MAMBARC`, Pixi configuration, `pixi.toml` manifests, wheel-only lock rows, unquoted date scalars in `.condarc` and other users' homes are outside v1 coverage. + ## System Package Scanning (Linux) System package scanning is **automatic on Linux** — no opt-in flag required. Multiple package managers can coexist. diff --git a/internal/detector/conda_golden_test.go b/internal/detector/conda_golden_test.go new file mode 100644 index 00000000..5af86d33 --- /dev/null +++ b/internal/detector/conda_golden_test.go @@ -0,0 +1,84 @@ +package detector + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// The shared contract is generated by the real guarded scanner over the +// fixture tree. Only the temporary home and the IDs derived from it are +// normalized. +func TestCondaGoldenProducer(t *testing.T) { + if runtime.GOOS == model.PlatformWindows { + t.Skip("canonical Unix fixture; wire round-trip and scanner tests also run on Windows") + } + home := goTestHome(t) + root := filepath.Join(home, "code") + condaWriteFixture(t, home, root) + scanner, x := condaTestScanner(t, home) + condaFixtureEnv(x, home) + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + type sections struct { + Inventory *model.CondaInventory `json:"conda_inventory"` + Audit *model.CondaConfigAudit `json:"conda_config_audit"` + } + pair := sections{inv, audit} + raw, err := json.Marshal(pair) + if err != nil { + t.Fatal(err) + } + raw = bytes.ReplaceAll(raw, []byte(home), []byte("/home/conda-fixture")) + if err := json.Unmarshal(raw, &pair); err != nil { + t.Fatal(err) + } + replacements := []string{} + for _, s := range pair.Inventory.Sources { + replacements = append(replacements, s.SourceID, configaudit.GoSourceID(goTestTarget(home).Username, s.Kind, s.Path)) + } + for _, f := range pair.Audit.Files { + replacements = append(replacements, f.SourceID, configaudit.GoSourceID(goTestTarget(home).Username, configaudit.CondaConfigKind(f.Scope), f.Path)) + } + raw = []byte(strings.NewReplacer(replacements...).Replace(string(raw))) + if err := json.Unmarshal(raw, &pair); err != nil { + t.Fatal(err) + } + for i := range pair.Inventory.Sources { + slices.Sort(pair.Inventory.Sources[i].DiscoveredSources) + } + slices.SortFunc(pair.Audit.Findings, func(a, b model.CargoConfigFinding) int { + return strings.Compare(a.Code+a.SourceID+a.Key, b.Code+b.SourceID+b.Key) + }) + slices.SortFunc(pair.Audit.Contexts, func(a, b model.CondaConfigContext) int { + return strings.Compare(a.EnvironmentPath+"\x00"+a.ProjectPath+"\x00"+strings.Join(a.ConfigSourceIDs, ","), b.EnvironmentPath+"\x00"+b.ProjectPath+"\x00"+strings.Join(b.ConfigSourceIDs, ",")) + }) + condaAssertRefs(t, pair.Inventory, pair.Audit) + raw, err = json.MarshalIndent(pair, "", " ") + if err != nil { + t.Fatal(err) + } + raw = append(raw, '\n') + path := "../model/testdata/conda_inventory_v1_golden.json" + if os.Getenv("UPDATE_CONDA_GOLDEN") == "1" { + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(raw, want) { + t.Fatal("producer differs from shared Conda golden; review the contract before regenerating") + } +} diff --git a/internal/detector/condametadata.go b/internal/detector/condametadata.go new file mode 100644 index 00000000..7d6072b2 --- /dev/null +++ b/internal/detector/condametadata.go @@ -0,0 +1,1038 @@ +package detector + +import ( + "bytes" + "encoding/json" + "errors" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "gopkg.in/yaml.v3" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// maxCondaMetadataBytes bounds receipts, cache records, manifests and locks. +// Receipts stay small because file lists are never decoded. +var maxCondaMetadataBytes int64 = 2 << 20 + +const maxCondaRequestedSpecs = 32 + +var errCondaUnsupportedFormat = errors.New("unsupported conda metadata format") + +const condaRedacted = "[redacted]" + +var ( + condaNamePattern = regexp.MustCompile(`^[a-z0-9_][a-z0-9._-]*$`) + condaLabelPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + condaSelectorPattern = regexp.MustCompile(`(?m)#\s*\[[^\]\n]+\]\s*$`) +) + +// condaName lowercases like Conda's MatchSpec and admits the native grammar +// without folding separators. Reserved `__` capability names never pass. +func condaName(s string) (string, bool) { + s = strings.ToLower(s) + return s, len(s) <= 256 && condaNamePattern.MatchString(s) && !strings.HasPrefix(s, "__") +} + +func condaVirtual(s string) bool { return strings.HasPrefix(s, "__") } + +// condaOpaque bounds a version or build string without interpreting it. +func condaOpaque(s string) bool { + return s != "" && composerText(s, 256) && !strings.ContainsFunc(s, unicode.IsSpace) +} + +// condaLabel bounds a subdir or target platform without a platform catalog. +func condaLabel(s string) bool { return len(s) <= 64 && condaLabelPattern.MatchString(s) } + +func condaChecksum(algorithm, value string) bool { + switch algorithm { + case "md5": + return cargoIsHex(value, 32) + case "sha256": + return cargoIsHex(value, 64) + } + return false +} + +// condaDist splits an artifact or receipt stem like Conda's dist rsplit: +// name-version-build, where only the last two dashes separate. +func condaDist(fn string) (name, version, build string, ok bool) { + stem := strings.TrimSuffix(strings.TrimSuffix(fn, ".conda"), ".tar.bz2") + i := strings.LastIndex(stem, "-") + if i <= 0 { + return "", "", "", false + } + build = stem[i+1:] + j := strings.LastIndex(stem[:i], "-") + if j <= 0 { + return "", "", "", false + } + name, version = stem[:j], stem[j+1:i] + if name, ok = condaName(name); !ok || !condaOpaque(version) || !condaOpaque(build) { + return "", "", "", false + } + return name, version, build, true +} + +// condaRecord is one prefix receipt or cache record. Channel and URL stay raw +// until condaOrigin sanitizes them; requested specs are already sanitized. +type condaRecord struct { + name, version, build, subdir string + buildNumber *int64 + channel, url string + md5, sha256 string + checksumInvalid bool + requested []string + reasons []string +} + +func condaJSONString(obj map[string]json.RawMessage, keys ...string) (string, bool) { + for _, key := range keys { + if raw := obj[key]; raw != nil { + s, ok := composerString(raw, 4096) + return s, ok + } + } + return "", true +} + +// parseCondaRecord reads only identity, origin, checksum and request fields. +// File lists, dependency graphs, licenses and link data are never decoded. +// Virtual package types are solver overlays Conda never writes to disk. +func parseCondaRecord(data []byte) (*condaRecord, error) { + if int64(len(data)) > maxCondaMetadataBytes { + return nil, errors.New("record too large") + } + obj, err := composerObject(data) + if err != nil { + return nil, err + } + if t, ok := condaJSONString(obj, "package_type"); !ok { + return nil, errors.New("invalid package type") + } else if strings.HasPrefix(t, "virtual_") { + return nil, errCondaUnsupportedFormat + } + r := &condaRecord{reasons: []string{}, requested: []string{}} + name, ok := condaJSONString(obj, "name") + if !ok { + return nil, errors.New("invalid record name") + } + if r.name, ok = condaName(name); !ok { + return nil, errors.New("invalid record name") + } + if r.version, ok = condaJSONString(obj, "version"); !ok || !condaOpaque(r.version) { + return nil, errors.New("invalid record version") + } + if r.build, ok = condaJSONString(obj, "build", "build_string"); !ok || !condaOpaque(r.build) { + return nil, errors.New("invalid record build") + } + if subdir, ok := condaJSONString(obj, "subdir"); !ok || subdir != "" && !condaLabel(subdir) { + r.reasons = append(r.reasons, "parse_error") + } else { + r.subdir = subdir + } + if raw := obj["build_number"]; raw != nil { + var n int64 + if err := json.Unmarshal(raw, &n); err != nil || n < 0 { + r.reasons = append(r.reasons, "parse_error") + } else { + r.buildNumber = &n + } + } + if r.channel, ok = condaJSONString(obj, "channel"); !ok { + r.channel = condaRedacted + } + if r.url, ok = condaJSONString(obj, "url"); !ok { + r.url = condaRedacted + } + for _, algorithm := range []string{"md5", "sha256"} { + v, ok := condaJSONString(obj, algorithm) + if v == "" && ok { + continue + } + v = strings.ToLower(v) + if !ok || !condaChecksum(algorithm, v) { + r.checksumInvalid = true + continue + } + if algorithm == "md5" { + r.md5 = v + } else { + r.sha256 = v + } + } + // Conda's own reader prefers a nonempty list, then the legacy scalar. + var specs []string + if raw := obj["requested_specs"]; raw != nil && !strings.EqualFold(strings.TrimSpace(string(raw)), "null") { + if json.Unmarshal(raw, &specs) != nil { + r.reasons = append(r.reasons, "parse_error") + specs = nil + } + } + if len(specs) == 0 { + // Early Conda wrote the Python None literal for packages nobody asked for. + if s, ok := condaJSONString(obj, "requested_spec"); !ok { + r.reasons = append(r.reasons, "parse_error") + } else if s != "" && s != "None" { + specs = []string{s} + } + } + for _, s := range specs { + if len(r.requested) >= maxCondaRequestedSpecs { + r.reasons = append(r.reasons, "entry_limit") + break + } + if display := condaSanitizeSpec(s); !slices.Contains(r.requested, display) { + r.requested = append(r.requested, display) + } + } + return r, nil +} + +// condaSanitizeSpec keeps a request readable while any URL or credential +// bearing text is cleaned or redacted whole. +func condaSanitizeSpec(spec string) string { + spec = strings.TrimSpace(spec) + if spec == "" || !composerText(spec, 4096) { + return condaRedacted + } + if channel, rest, ok := strings.Cut(spec, "::"); ok { + display, _ := configaudit.CondaChannelDisplay(channel) + if strings.ContainsAny(rest, "@:") || strings.Contains(rest, "/t/") { + return condaRedacted + } + return display + "::" + rest + } + switch { + case strings.Contains(spec, "://"): + if strings.ContainsAny(spec, " \t[]()") { + return condaRedacted + } + display, _ := configaudit.CondaSanitizeURL(spec) + return display + case strings.ContainsAny(spec, "@") || strings.Contains(spec, "/t/"): + return condaRedacted + } + return spec +} + +// condaArtifact is a recorded download location: a sanitized URL or local +// path, its checksum fragment and the identity its filename carries. +type condaArtifact struct { + display string + redacted, local, wheel, ok bool + name, version, build string + subdir, channel string + md5, sha256 string + checksumInvalid, unresolvable bool +} + +// condaLocalChannel recognizes a file URL or an absolute path in either OS +// syntax; a bare label like conda-forge or pkgs/main is never a path. +func condaLocalChannel(raw string) (string, bool) { + if strings.HasPrefix(strings.ToLower(raw), "file://") { + return composerLocalURL(raw, "") + } + return raw, isCondaAbs(raw) +} + +// isCondaAbs accepts either OS syntax: explicit files are written on one +// platform and read on another. +func isCondaAbs(p string) bool { + return strings.HasPrefix(p, "/") || len(p) > 2 && p[1] == ':' && (p[2] == '\\' || p[2] == '/') || strings.HasPrefix(p, `\\`) +} + +// parseCondaArtifactURL extracts a valid checksum fragment before the URL is +// sanitized, then reads identity only from the standard artifact filename +// and the channel only from an unambiguous `//` tail. +func parseCondaArtifactURL(raw string) condaArtifact { + var a condaArtifact + if !composerText(raw, 4096) { + a.display, a.redacted = condaRedacted, true + return a + } + base, fragment, _ := strings.Cut(raw, "#") + if fragment != "" { + fragment = strings.ToLower(fragment) + switch digest, ok := strings.CutPrefix(fragment, "sha256:"); { + case ok && cargoIsHex(digest, 64), cargoIsHex(fragment, 64): + a.sha256 = strings.TrimPrefix(fragment, "sha256:") + case cargoIsHex(fragment, 32): + a.md5 = fragment + default: + a.checksumInvalid = true + } + } + var dir, fn, prefix string + seps := "/" + if p, local := condaLocalChannel(base); local || !strings.Contains(base, "://") { + // A scheme-less row is a path relative to an unknown invocation directory. + a.local, a.unresolvable = true, !local + a.display, a.redacted = configaudit.SanitizeComposerURL(p) + if a.display == condaRedacted { + return a + } + seps = `/\` + i := strings.LastIndexAny(a.display, seps) + dir, fn = a.display[:max(i, 0)], a.display[i+1:] + } else { + a.display, a.redacted = configaudit.CondaSanitizeURL(base) + u, err := url.Parse(a.display) + if a.display == condaRedacted || err != nil { + a.display, a.redacted = condaRedacted, true + return a + } + i := strings.LastIndex(u.Path, "/") + dir, fn = u.Path[:max(i, 0)], u.Path[i+1:] + prefix = u.Scheme + "://" + u.Host + } + if strings.HasSuffix(fn, ".whl") { + a.wheel = true + return a + } + if a.name, a.version, a.build, a.ok = condaDist(fn); !a.ok { + return a + } + if j := strings.LastIndexAny(dir, seps); j >= 0 && condaLabel(dir[j+1:]) && (!a.local || j > 0) { + a.subdir, a.channel = dir[j+1:], prefix+dir[:j] + } + return a +} + +// condaOrigin establishes a channel only when credentials are gone and the +// identity is unambiguous. A bare label maps through a recorded standard +// artifact URL whose subdir matches; a wheel host never names a channel. +func condaOrigin(channel, rawURL, subdir string) (model.CondaOrigin, string) { + unknown := model.CondaOrigin{Kind: "unknown"} + strip := func(u string) string { + if subdir != "" { + u = strings.TrimSuffix(u, "/"+subdir) + } + return u + } + switch p, local := condaLocalChannel(channel); { + case local: + display, _ := configaudit.SanitizeComposerURL(p) + if display == condaRedacted { + return unknown, "origin_unresolved" + } + return model.CondaOrigin{Kind: "local", Path: strip(strings.TrimSuffix(display, "/"))}, "" + case strings.Contains(channel, "://"): + display, _ := configaudit.CondaSanitizeURL(channel) + if display == condaRedacted { + return unknown, "origin_unresolved" + } + return model.CondaOrigin{Kind: "channel", URL: strip(display)}, "" + case channel != "" && channel != "": + if _, bad := configaudit.CondaChannelDisplay(channel); bad { + return unknown, "origin_unresolved" + } + } + if rawURL == "" { + return unknown, "" + } + a := parseCondaArtifactURL(rawURL) + switch { + case a.display == condaRedacted: + return unknown, "origin_unresolved" + case !a.ok || a.wheel || a.channel == "" || a.unresolvable || subdir != "" && a.subdir != subdir: + return unknown, "" + case a.local: + return model.CondaOrigin{Kind: "local", Path: a.channel}, "" + } + return model.CondaOrigin{Kind: "channel", URL: a.channel}, "" +} + +// condaFact builds identity evidence from a record; installed and cached +// rows differ only in what the scanner attaches afterwards. +func condaFact(r *condaRecord) model.CondaPackage { + p := model.CondaPackage{PackageName: r.name, Reasons: slices.Clone(r.reasons), VersionStatus: "known", Version: r.version, Build: r.build, Subdir: r.subdir, BuildNumber: r.buildNumber, + RequestedSpecs: slices.Clone(r.requested), DependencyRelation: "unknown", Origin: model.CondaOrigin{Kind: "unknown"}, ChecksumStatus: "absent", RecordedChecksums: []model.CondaRecordedChecksum{}} + if len(p.RequestedSpecs) > 0 { + p.DependencyRelation = "direct" + } + var reason string + p.Origin, reason = condaOrigin(r.channel, r.url, r.subdir) + if reason != "" { + p.Reasons = append(p.Reasons, reason) + } + if r.url != "" { + a := parseCondaArtifactURL(r.url) + if a.display != condaRedacted && !a.unresolvable { + p.ArtifactURL = a.display + } + } + condaChecksums(&p, r.md5, r.sha256, r.checksumInvalid) + return p +} + +func condaChecksums(p *model.CondaPackage, md5, sha256 string, invalid bool) { + for _, h := range [][2]string{{"sha256", sha256}, {"md5", md5}} { + if h[1] != "" { + p.RecordedChecksums = append(p.RecordedChecksums, model.CondaRecordedChecksum{Algorithm: h[0], Value: h[1], Verification: "not_verified"}) + } + } + switch { + case invalid: + p.ChecksumStatus = "partial" + p.Reasons = append(p.Reasons, "checksum_invalid") + case len(p.RecordedChecksums) > 0: + p.ChecksumStatus = "recorded" + } +} + +// condaDeclaration is one literal manifest requirement. The channel display +// is already sanitized; the spec keeps its constraint text. +type condaDeclaration struct { + name, spec, channel string + channelRedacted bool +} + +// parseCondaMatchSpec reads concrete-name forms only: an optional channel +// qualifier, then a name followed by version/build text that is kept, not +// solved. Bracket, namespace and wildcard forms are unsupported. +func parseCondaMatchSpec(raw string) (condaDeclaration, string) { + var d condaDeclaration + raw = strings.TrimSpace(raw) + if raw == "" || !composerText(raw, 4096) { + return d, "invalid" + } + if strings.ContainsAny(raw, "[]()") { + return d, "unsupported" + } + rest := raw + if channel, after, ok := strings.Cut(raw, "::"); ok { + if !strings.Contains(channel, "://") && strings.Contains(channel, ":") || strings.Contains(after, "::") { + return d, "unsupported" + } + d.channel, _ = configaudit.CondaChannelDisplay(channel) + d.channelRedacted = d.channel == condaRedacted + rest = after + } else if strings.Contains(raw, ":") { + return d, "unsupported" + } + end := strings.IndexAny(rest, " =<>!~,|") + if end < 0 { + end = len(rest) + } + name := rest[:end] + switch { + case strings.Contains(name, "*"): + return d, "unsupported" + case condaVirtual(name): + return d, "virtual" + } + var ok bool + if d.name, ok = condaName(name); !ok { + return d, "invalid" + } + d.spec = condaSanitizeSpec(raw) + return d, "ok" +} + +// condaManifest is a whole environment file. Unsupported constructs leave +// the declaration set partial without rejecting the readable rows. +type condaManifest struct { + channels []string + declarations []condaDeclaration + unsupported, invalid bool +} + +func condaYAMLPlain(n *yaml.Node) bool { + return n != nil && n.Kind != yaml.AliasNode && n.Anchor == "" && n.Style&yaml.TaggedStyle == 0 +} + +// condaYAMLData rejects anchors, aliases and tags anywhere below n; lock +// writers never emit them and an alias could inject another row's fields. +func condaYAMLData(n *yaml.Node, depth int) bool { + if depth > 8 || !condaYAMLPlain(n) { + return false + } + for i, c := range n.Content { + if n.Kind == yaml.MappingNode && i%2 == 0 && (c.Kind != yaml.ScalarNode || c.Value == "<<") { + return false + } + if !condaYAMLData(c, depth+1) { + return false + } + } + return true +} + +// condaScalar reads a plain scalar; null reads as empty text. +func condaScalar(n *yaml.Node) (string, bool) { + if n == nil || n.Kind != yaml.ScalarNode { + return "", false + } + if n.ShortTag() == "!!null" { + return "", true + } + return n.Value, true +} + +// condaYAMLField returns a mapping value, or nil with found when the key +// repeats, since YAML loaders disagree on which duplicate wins. +func condaYAMLField(m *yaml.Node, field string) (*yaml.Node, bool) { + var out *yaml.Node + for i := 0; i+1 < len(m.Content); i += 2 { + if k := m.Content[i]; k.Kind == yaml.ScalarNode && k.Value == field { + if out != nil { + return nil, true + } + out = m.Content[i+1] + } + } + return out, out != nil +} + +// parseCondaEnvironment reads environment.yml and the exported JSON form +// through one YAML reader, since JSON is a YAML flow document. Selector +// comments are found on the raw bytes before the parser discards them. +func parseCondaEnvironment(data []byte) (*condaManifest, error) { + if int64(len(data)) > maxCondaMetadataBytes || !utf8.Valid(data) { + return nil, errors.New("invalid environment file") + } + var doc yaml.Node + if yaml.Unmarshal(data, &doc) != nil { + return nil, errors.New("invalid environment file") + } + m := &condaManifest{channels: []string{}} + if len(doc.Content) == 0 { + return m, nil + } + root := doc.Content[0] + if root.Kind != yaml.MappingNode || !condaYAMLPlain(root) { + return nil, errCondaUnsupportedFormat + } + selected := map[int]bool{} + for _, loc := range condaSelectorPattern.FindAllIndex(data, -1) { + m.unsupported = true + selected[bytes.Count(data[:loc[0]], []byte("\n"))+1] = true + } + if n, found := condaYAMLField(root, "channels"); found { + switch { + case n == nil || !condaYAMLData(n, 0): + m.unsupported = true + case n.Kind != yaml.SequenceNode: + m.invalid = true + default: + for _, item := range n.Content { + if v, ok := condaScalar(item); ok && v != "" && composerText(v, 4096) && !selected[item.Line] { + m.channels = append(m.channels, v) + } else { + m.invalid = true + } + } + } + } + n, found := condaYAMLField(root, "dependencies") + switch { + case !found: + case n == nil || !condaYAMLData(n, 0): + m.unsupported = true + case n.Kind != yaml.SequenceNode: + m.invalid = true + default: + for _, item := range n.Content { + if selected[item.Line] { + continue + } + if item.Kind == yaml.MappingNode { + // `pip:` lists Python requirements; anything else is a selector + // or plugin form this reader does not evaluate. + if len(item.Content) == 2 && item.Content[0].Value == "pip" { + continue + } + m.unsupported = true + continue + } + v, ok := condaScalar(item) + if !ok { + m.invalid = true + continue + } + d, status := parseCondaMatchSpec(v) + switch status { + case "ok": + m.declarations = append(m.declarations, d) + case "unsupported": + m.unsupported = true + case "invalid": + m.invalid = true + } + } + } + return m, nil +} + +// condaLockEntry is one locked row; reasons on the row are also reported on +// the lock source by the scanner. +type condaLockEntry struct{ pkg model.CondaPackage } + +func condaLocked(name, version string) model.CondaPackage { + return model.CondaPackage{PackageName: name, Reasons: []string{}, VersionStatus: "known", Version: version, RequestedSpecs: []string{}, DependencyRelation: "unknown", + Origin: model.CondaOrigin{Kind: "unknown"}, ChecksumStatus: "absent", RecordedChecksums: []model.CondaRecordedChecksum{}} +} + +func condaLines(data []byte) []string { + lines := strings.Split(string(data), "\n") + for i, line := range lines { + lines[i] = strings.TrimSpace(strings.TrimSuffix(line, "\r")) + } + return lines +} + +// condaIsExplicit reports the @EXPLICIT header as the first content line, +// so a probe of the first bytes decides whether a `conda-*.lock` is read. +func condaIsExplicit(data []byte) bool { + for _, line := range condaLines(data) { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + return line == "@EXPLICIT" + } + return false +} + +// parseCondaExplicit reads an @EXPLICIT artifact list. Rows are locked +// artifacts, not solver requirements; a relative path is anchored to an +// unknown invocation directory, never to the file. +func parseCondaExplicit(data []byte) ([]condaLockEntry, []string, error) { + if int64(len(data)) > maxCondaMetadataBytes || !utf8.Valid(data) { + return nil, nil, errors.New("invalid explicit file") + } + var entries []condaLockEntry + var reasons []string + note := func(r string) { + if !slices.Contains(reasons, r) { + reasons = append(reasons, r) + } + } + platform, started := "", false + for _, line := range condaLines(data) { + switch { + case line == "": + continue + case strings.HasPrefix(line, "#"): + if v, ok := strings.CutPrefix(line, "# platform:"); ok && !started { + if v = strings.TrimSpace(v); condaLabel(v) { + platform = v + } + } + continue + case !started: + if line != "@EXPLICIT" { + return nil, nil, errCondaUnsupportedFormat + } + started = true + continue + } + a := parseCondaArtifactURL(line) + switch { + case a.wheel: + note("unsupported_format") + continue + case !a.ok: + note("parse_error") + continue + } + p := condaLocked(a.name, a.version) + p.Build, p.Subdir, p.TargetPlatform = a.build, a.subdir, platform + switch { + case a.unresolvable: + p.Reasons = append(p.Reasons, "path_unresolved") + note("path_unresolved") + case a.local && a.channel != "": + p.Origin, p.ArtifactURL = model.CondaOrigin{Kind: "local", Path: a.channel}, a.display + case a.local: + p.ArtifactURL = a.display + case a.channel != "": + p.Origin, p.ArtifactURL = model.CondaOrigin{Kind: "channel", URL: a.channel}, a.display + default: + p.ArtifactURL = a.display + } + condaChecksums(&p, a.md5, a.sha256, a.checksumInvalid) + if a.checksumInvalid { + note("checksum_invalid") + } + entries = append(entries, condaLockEntry{p}) + } + if !started { + return nil, nil, errCondaUnsupportedFormat + } + return entries, reasons, nil +} + +func condaYAMLString(m *yaml.Node, field string) (string, bool) { + n, found := condaYAMLField(m, field) + if !found { + return "", true + } + if n == nil || !condaYAMLPlain(n) { + return "", false + } + v, ok := condaScalar(n) + return v, ok && composerText(v, 4096) +} + +// parseCondaLockYAML dispatches on the file's own version: conda-lock +// version 1 package rows, or rattler/pixi version 6 and 7 environments, +// which share one package table shape. Rows no environment references are +// not emitted. +func parseCondaLockYAML(data []byte) ([]condaLockEntry, []string, error) { + if int64(len(data)) > maxCondaMetadataBytes || !utf8.Valid(data) { + return nil, nil, errors.New("invalid lock file") + } + var doc yaml.Node + if yaml.Unmarshal(data, &doc) != nil || len(doc.Content) == 0 { + return nil, nil, errors.New("invalid lock file") + } + root := doc.Content[0] + if root.Kind != yaml.MappingNode || !condaYAMLPlain(root) { + return nil, nil, errors.New("invalid lock file") + } + version, ok := condaYAMLString(root, "version") + if !ok { + return nil, nil, errors.New("invalid lock version") + } + switch version { + case "1": + return parseCondaLockV1(root) + case "6", "7": + return parseCondaLockV6(root) + } + return nil, nil, errCondaUnsupportedFormat +} + +func condaSequence(m *yaml.Node, field string) ([]*yaml.Node, bool) { + n, found := condaYAMLField(m, field) + if !found { + return nil, true + } + if n == nil || !condaYAMLPlain(n) || n.Kind != yaml.SequenceNode { + return nil, false + } + return n.Content, true +} + +// condaLockArtifact applies a lock row's recorded URL: filename identity +// must agree with the declared one, and a wheel is never a Conda artifact. +func condaLockArtifact(p *model.CondaPackage, rawURL string, note func(string)) (condaArtifact, bool) { + a := parseCondaArtifactURL(rawURL) + switch { + case a.wheel: + if a.display != condaRedacted { + p.ArtifactURL = a.display + } + note("unsupported_format") + return a, false + case a.display == condaRedacted: + p.Reasons = append(p.Reasons, "origin_unresolved") + note("origin_unresolved") + return a, false + case !a.ok: + p.Reasons = append(p.Reasons, "parse_error") + note("parse_error") + return a, false + case a.name != p.PackageName || a.version != p.Version: + p.Reasons = append(p.Reasons, "identity_mismatch") + note("identity_mismatch") + return a, false + case a.unresolvable: + p.Reasons = append(p.Reasons, "path_unresolved") + note("path_unresolved") + return a, false + } + p.ArtifactURL = a.display + switch { + case a.local && a.channel != "": + p.Origin = model.CondaOrigin{Kind: "local", Path: a.channel} + case !a.local && a.channel != "": + p.Origin = model.CondaOrigin{Kind: "channel", URL: a.channel} + } + return a, true +} + +func parseCondaLockV1(root *yaml.Node) ([]condaLockEntry, []string, error) { + var entries []condaLockEntry + var reasons []string + note := func(r string) { + if !slices.Contains(reasons, r) { + reasons = append(reasons, r) + } + } + rows, ok := condaSequence(root, "package") + if !ok { + return nil, nil, errors.New("invalid lock packages") + } + for _, row := range rows { + if row.Kind != yaml.MappingNode || !condaYAMLData(row, 0) { + note("parse_error") + continue + } + manager, ok := condaYAMLString(row, "manager") + if !ok { + note("parse_error") + continue + } + if manager != "conda" { + continue + } + name, ok1 := condaYAMLString(row, "name") + version, ok2 := condaYAMLString(row, "version") + name, valid := condaName(name) + if !ok1 || !ok2 || !valid || !condaOpaque(version) { + note("parse_error") + continue + } + p := condaLocked(name, version) + if platform, ok := condaYAMLString(row, "platform"); !ok || platform != "" && !condaLabel(platform) { + note("parse_error") + continue + } else { + p.TargetPlatform = platform + } + rawURL, ok := condaYAMLString(row, "url") + if !ok { + note("parse_error") + continue + } + if rawURL != "" { + if a, ok := condaLockArtifact(&p, rawURL, note); ok { + p.Build, p.Subdir = a.build, a.subdir + } + } + md5, sha := "", "" + invalid := false + if h, found := condaYAMLField(row, "hash"); found { + if h == nil || h.Kind != yaml.MappingNode { + invalid = true + } else { + for _, algorithm := range []string{"md5", "sha256"} { + v, ok := condaYAMLString(h, algorithm) + v = strings.ToLower(v) + switch { + case v == "" && ok: + case !ok || !condaChecksum(algorithm, v): + invalid = true + case algorithm == "md5": + md5 = v + default: + sha = v + } + } + } + } + condaChecksums(&p, md5, sha, invalid) + if invalid { + note("checksum_invalid") + } + entries = append(entries, condaLockEntry{p}) + } + return entries, reasons, nil +} + +// parseCondaLockV6 tables the rows at each Conda location, then emits one +// row per environment and platform reference. A source location can hold +// several variants that a reference picks by name, build or subdir. A wheel +// under `conda:` is tabled as unsupported so its references are not +// mistaken for missing ones. +func parseCondaLockV6(root *yaml.Node) ([]condaLockEntry, []string, error) { + var entries []condaLockEntry + var reasons []string + note := func(r string) { + if !slices.Contains(reasons, r) { + reasons = append(reasons, r) + } + } + rows, ok := condaSequence(root, "packages") + if !ok { + return nil, nil, errors.New("invalid lock packages") + } + table := map[string][]*model.CondaPackage{} + for _, row := range rows { + if row.Kind != yaml.MappingNode || !condaYAMLData(row, 0) { + note("parse_error") + continue + } + location, ok := condaYAMLString(row, "conda") + if !ok { + note("parse_error") + continue + } + if location == "" { + continue // pypi rows are Python requirements + } + a := parseCondaArtifactURL(location) + if a.wheel { + note("unsupported_format") + table[location] = append(table[location], nil) + continue + } + name, ok1 := condaYAMLString(row, "name") + version, ok2 := condaYAMLString(row, "version") + build, ok3 := condaYAMLString(row, "build") + subdir, ok4 := condaYAMLString(row, "subdir") + if !ok1 || !ok2 || !ok3 || !ok4 || subdir != "" && !condaLabel(subdir) { + note("parse_error") + continue + } + // Explicit fields win; the filename supplies whatever they omit. + name, valid := condaName(cmpOr(name, a.name)) + version, build, subdir = cmpOr(version, a.version), cmpOr(build, a.build), cmpOr(subdir, a.subdir) + if !valid || !condaOpaque(version) || !condaOpaque(build) { + note("parse_error") + continue + } + p := condaLocked(name, version) + p.Build, p.Subdir = build, subdir + if a.display == condaRedacted { + p.Reasons = append(p.Reasons, "origin_unresolved") + note("origin_unresolved") + } else if a.ok && (a.name != name || a.version != version || a.build != build) { + p.Reasons = append(p.Reasons, "identity_mismatch") + note("identity_mismatch") + } else if a.unresolvable { + p.Reasons = append(p.Reasons, "path_unresolved") + note("path_unresolved") + } else { + p.ArtifactURL = a.display + switch { + case a.local && a.channel != "": + p.Origin = model.CondaOrigin{Kind: "local", Path: a.channel} + case !a.local && a.channel != "": + p.Origin = model.CondaOrigin{Kind: "channel", URL: a.channel} + } + } + if n, found := condaYAMLField(row, "build_number"); found { + v, ok := condaScalar(n) + if i, err := strconv.ParseInt(v, 10, 64); !ok || err != nil || i < 0 { + p.Reasons = append(p.Reasons, "parse_error") + note("parse_error") + } else { + p.BuildNumber = &i + } + } + var md5, sha string + invalid := false + for _, algorithm := range []string{"md5", "sha256"} { + v, ok := condaYAMLString(row, algorithm) + v = strings.ToLower(v) + switch { + case v == "" && ok: + case !ok || !condaChecksum(algorithm, v): + invalid = true + case algorithm == "md5": + md5 = v + default: + sha = v + } + } + condaChecksums(&p, md5, sha, invalid) + if invalid { + note("checksum_invalid") + } + table[location] = append(table[location], &p) + } + envs, found := condaYAMLField(root, "environments") + if !found { + return nil, nil, errors.New("invalid lock environments") + } + if envs == nil || !condaYAMLPlain(envs) || envs.Kind != yaml.MappingNode { + return nil, nil, errors.New("invalid lock environments") + } + for i := 0; i+1 < len(envs.Content); i += 2 { + envName, env := envs.Content[i].Value, envs.Content[i+1] + if envName == "" || !composerText(envName, 256) || env.Kind != yaml.MappingNode || !condaYAMLPlain(env) { + note("parse_error") + continue + } + platforms, found := condaYAMLField(env, "packages") + if !found || platforms == nil || !condaYAMLPlain(platforms) || platforms.Kind != yaml.MappingNode { + note("parse_error") + continue + } + for j := 0; j+1 < len(platforms.Content); j += 2 { + platform, refs := platforms.Content[j].Value, platforms.Content[j+1] + if !condaLabel(platform) || refs.Kind != yaml.SequenceNode || !condaYAMLPlain(refs) { + note("parse_error") + continue + } + for _, ref := range refs.Content { + if ref.Kind != yaml.MappingNode || !condaYAMLData(ref, 0) { + note("parse_error") + continue + } + location, ok := condaYAMLString(ref, "conda") + if !ok { + note("parse_error") + continue + } + if location == "" { + continue + } + base, ok := condaLockVariant(table[location], ref) + switch { + case !ok: + note("parse_error") + continue + case base == nil: + continue + } + p := *base + p.Reasons, p.RecordedChecksums = slices.Clone(base.Reasons), slices.Clone(base.RecordedChecksums) + p.LockEnvironment, p.TargetPlatform = envName, platform + entries = append(entries, condaLockEntry{p}) + } + } + } + return entries, reasons, nil +} + +// condaLockVariant selects the one tabled row a reference names; the +// reference's own name, build and subdir narrow a multi-variant location. +func condaLockVariant(rows []*model.CondaPackage, ref *yaml.Node) (*model.CondaPackage, bool) { + var match *model.CondaPackage + count := 0 + for _, row := range rows { + if row != nil { + ok := true + for _, field := range [][2]string{{"name", row.PackageName}, {"build", row.Build}, {"subdir", row.Subdir}} { + v, valid := condaYAMLString(ref, field[0]) + if !valid { + return nil, false + } + if field[0] == "name" { + v = strings.ToLower(v) + } + ok = ok && (v == "" || v == field[1]) + } + if !ok { + continue + } + } + match, count = row, count+1 + } + return match, count == 1 +} + +func cmpOr(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +func condaParseReason(err error) string { + if errors.Is(err, errCondaUnsupportedFormat) { + return "unsupported_format" + } + return "parse_error" +} diff --git a/internal/detector/condametadata_test.go b/internal/detector/condametadata_test.go new file mode 100644 index 00000000..9c752ee5 --- /dev/null +++ b/internal/detector/condametadata_test.go @@ -0,0 +1,556 @@ +package detector + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +const ( + condaMD5A = "f77c7d05dc47868e181135af65cb6e26" + condaSHA_A = "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91" +) + +func TestCondaNameAndDist(t *testing.T) { + for _, tc := range []struct { + name, in string + ok bool + want string + }{ + {"lowercased", "NumPy", true, "numpy"}, + {"dots and dashes", "ca-certificates", true, "ca-certificates"}, + {"underscore start", "_libgcc_mutex", true, "_libgcc_mutex"}, + {"virtual rejected", "__glibc", false, ""}, + {"empty", "", false, ""}, + {"space", "a b", false, ""}, + {"slash", "a/b", false, ""}, + } { + got, ok := condaName(tc.in) + if ok != tc.ok || ok && got != tc.want { + t.Errorf("%s: condaName(%q) = %q %v", tc.name, tc.in, got, ok) + } + } + for _, tc := range []struct { + fn string + name, version, build string + ok bool + }{ + {"zlib-1.2.11-hf3cbc9b_2.tar.bz2", "zlib", "1.2.11", "hf3cbc9b_2", true}, + {"python-3.13.0-hbbac1ca_101_cp313.conda", "python", "3.13.0", "hbbac1ca_101_cp313", true}, + {"_openmp_mutex-4.5-1_gnu", "_openmp_mutex", "4.5", "1_gnu", true}, + {"ansi2html-1.9.5-py3_none_any_0", "ansi2html", "1.9.5", "py3_none_any_0", true}, + {"zlib-1.2.11", "", "", "", false}, + {"-1.0-0", "", "", "", false}, + {"a--0", "", "", "", false}, + } { + name, version, build, ok := condaDist(tc.fn) + if ok != tc.ok || name != tc.name || version != tc.version || build != tc.build { + t.Errorf("condaDist(%q) = %q %q %q %v", tc.fn, name, version, build, ok) + } + } +} + +func TestCondaArtifactURL(t *testing.T) { + for _, tc := range []struct { + name, in string + display string + channel string + subdir string + local bool + wheel bool + ok bool + md5, sha256 string + invalid bool + unresolved bool + }{ + {name: "channel artifact with md5 fragment", in: "https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda#" + condaMD5A, + display: "https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda", channel: "https://conda.anaconda.org/conda-forge", subdir: "osx-arm64", ok: true, md5: condaMD5A}, + {name: "sha256 fragment with prefix", in: "https://prefix.dev/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda#sha256:" + condaSHA_A, + display: "https://prefix.dev/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", channel: "https://prefix.dev/conda-forge", subdir: "noarch", ok: true, sha256: condaSHA_A}, + {name: "bad fragment", in: "https://conda.anaconda.org/conda-forge/linux-64/zlib-1.2.11-h166bdaf_0.tar.bz2#CANARY", + display: "https://conda.anaconda.org/conda-forge/linux-64/zlib-1.2.11-h166bdaf_0.tar.bz2", channel: "https://conda.anaconda.org/conda-forge", subdir: "linux-64", ok: true, invalid: true}, + {name: "token path removed", in: "https://conda.anaconda.org/t/CANARY-TOKEN/private/linux-64/zlib-1.2.11-h166bdaf_0.conda", + display: "https://conda.anaconda.org/private/linux-64/zlib-1.2.11-h166bdaf_0.conda", channel: "https://conda.anaconda.org/private", subdir: "linux-64", ok: true}, + {name: "userinfo removed", in: "https://CANARY_USER:CANARY_PASS@repo.example.invalid/ch/linux-64/zlib-1.2.11-h166bdaf_0.conda", + display: "https://repo.example.invalid/ch/linux-64/zlib-1.2.11-h166bdaf_0.conda", channel: "https://repo.example.invalid/ch", subdir: "linux-64", ok: true}, + {name: "encoded token redacts whole url", in: "https://conda.anaconda.org%2Ft%2FCANARY/linux-64/zlib-1.2.11-h166bdaf_0.conda", display: condaRedacted}, + {name: "wheel", in: "https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl", display: "https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl", wheel: true}, + {name: "file url", in: "file:///opt/channel/linux-64/zlib-1.2.11-h166bdaf_0.conda", display: "/opt/channel/linux-64/zlib-1.2.11-h166bdaf_0.conda", channel: "/opt/channel", subdir: "linux-64", local: true, ok: true}, + {name: "absolute path", in: "/opt/channel/noarch/tzdata-2024b-hc8b5060_0.conda", display: "/opt/channel/noarch/tzdata-2024b-hc8b5060_0.conda", channel: "/opt/channel", subdir: "noarch", local: true, ok: true}, + {name: "windows path", in: `C:\channel\win-64\zlib-1.2.11-h8ffe710_4.conda`, display: `C:\channel\win-64\zlib-1.2.11-h8ffe710_4.conda`, channel: `C:\channel`, subdir: "win-64", local: true, ok: true}, + {name: "relative path unresolved", in: "pkgs/zlib-1.2.11-h166bdaf_0.conda", display: "pkgs/zlib-1.2.11-h166bdaf_0.conda", local: true, ok: true, unresolved: true}, + {name: "host directly above file has no channel", in: "https://host.example.invalid/zlib-1.2.11-h166bdaf_0.conda", display: "https://host.example.invalid/zlib-1.2.11-h166bdaf_0.conda", ok: true}, + {name: "not an artifact", in: "https://conda.anaconda.org/conda-forge/linux-64/zlib", display: "https://conda.anaconda.org/conda-forge/linux-64/zlib"}, + {name: "unsafe text", in: "https://x/\x00", display: condaRedacted}, + } { + a := parseCondaArtifactURL(tc.in) + if a.display != tc.display || a.channel != tc.channel || a.subdir != tc.subdir || a.local != tc.local || a.wheel != tc.wheel || a.ok != tc.ok || + a.md5 != tc.md5 || a.sha256 != tc.sha256 || a.checksumInvalid != tc.invalid || a.unresolvable != tc.unresolved { + t.Errorf("%s: %+v", tc.name, a) + } + if strings.Contains(a.display+a.channel, "CANARY") { + t.Errorf("%s: credential survived in %+v", tc.name, a) + } + } +} + +func TestCondaOrigin(t *testing.T) { + for _, tc := range []struct { + name, channel, url, subdir string + want model.CondaOrigin + reason string + }{ + {"channel url with subdir", "https://repo.anaconda.com/pkgs/main/osx-64", "", "osx-64", model.CondaOrigin{Kind: "channel", URL: "https://repo.anaconda.com/pkgs/main"}, ""}, + {"label resolved through artifact", "conda-forge", "https://conda.anaconda.org/conda-forge/linux-64/zlib-1.2.11-h166bdaf_0.conda", "linux-64", model.CondaOrigin{Kind: "channel", URL: "https://conda.anaconda.org/conda-forge"}, ""}, + {"label with mismatched artifact subdir", "conda-forge", "https://conda.anaconda.org/conda-forge/noarch/zlib-1.2.11-h166bdaf_0.conda", "linux-64", model.CondaOrigin{Kind: "unknown"}, ""}, + {"wheel host never a channel", "conda-pypi", "https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl", "noarch", model.CondaOrigin{Kind: "unknown"}, ""}, + {"local file channel", "file:///opt/channel/linux-64", "", "linux-64", model.CondaOrigin{Kind: "local", Path: "/opt/channel"}, ""}, + {"credential channel", "https://CANARY:CANARY@repo.example.invalid/t/CANARY/ch/noarch", "", "noarch", model.CondaOrigin{Kind: "channel", URL: "https://repo.example.invalid/ch"}, ""}, + {"unknown placeholder", "", "", "", model.CondaOrigin{Kind: "unknown"}, ""}, + {"redacted channel", condaRedacted, "", "", model.CondaOrigin{Kind: "unknown"}, "origin_unresolved"}, + {"empty", "", "", "", model.CondaOrigin{Kind: "unknown"}, ""}, + } { + got, reason := condaOrigin(tc.channel, tc.url, tc.subdir) + if got != tc.want || reason != tc.reason { + t.Errorf("%s: condaOrigin = %+v %q", tc.name, got, reason) + } + } +} + +func TestCondaMatchSpec(t *testing.T) { + for _, tc := range []struct { + in, status, name, spec, channel string + redacted bool + }{ + {"numpy>=1.26", "ok", "numpy", "numpy>=1.26", "", false}, + {"Python=3.12", "ok", "python", "Python=3.12", "", false}, + {"conda-forge::numpy>=1.26", "ok", "numpy", "conda-forge::numpy>=1.26", "conda-forge", false}, + {"https://CANARY:x@repo.example.invalid/ch::pkg 1.0", "ok", "pkg", "https://repo.example.invalid/ch::pkg 1.0", "https://repo.example.invalid/ch", false}, + {"bad channel!::pkg", "ok", "pkg", condaRedacted + "::pkg", condaRedacted, true}, + {"pkg 1.0 py38*", "ok", "pkg", "pkg 1.0 py38*", "", false}, + {"numpy[version='>=1.26']", "unsupported", "", "", "", false}, + {"numpy*", "unsupported", "", "", "", false}, + {"ns:conda-forge::pkg", "unsupported", "", "", "", false}, + {"pkg:1.0", "unsupported", "", "", "", false}, + {"__glibc>=2.17", "virtual", "", "", "", false}, + {"", "invalid", "", "", "", false}, + {"=1.0", "invalid", "", "", "", false}, + } { + d, status := parseCondaMatchSpec(tc.in) + if status != tc.status || d.name != tc.name || d.spec != tc.spec || d.channel != tc.channel || d.channelRedacted != tc.redacted { + t.Errorf("parseCondaMatchSpec(%q) = %+v %q", tc.in, d, status) + } + if strings.Contains(d.spec+d.channel, "CANARY") { + t.Errorf("credential survived in %+v", d) + } + } +} + +// condaZlibReceipt mirrors a historical upstream prefix record; the file +// lists Conda writes beside these fields are never read. +const condaZlibReceipt = `{ + "build": "hf3cbc9b_2", + "build_number": 2, + "channel": "https://repo.anaconda.com/pkgs/main/osx-64", + "constrains": [], + "depends": [], + "extracted_package_dir": "/Users/CANARY_HOME/pkgs/zlib-1.2.11-hf3cbc9b_2", + "features": "", + "files": ["lib/libz.1.2.11.dylib"], + "fn": "zlib-1.2.11-hf3cbc9b_2.tar.bz2", + "license": "zlib", + "link": {"source": "/Users/CANARY_HOME/pkgs/zlib-1.2.11-hf3cbc9b_2", "type": 1}, + "md5": "` + condaMD5A + `", + "name": "zlib", + "package_tarball_full_path": "/Users/CANARY_HOME/pkgs/zlib-1.2.11-hf3cbc9b_2.tar.bz2", + "paths_data": {"paths": [], "paths_version": 1}, + "requested_spec": "None", + "size": 103233, + "subdir": "osx-64", + "timestamp": 1510092154915, + "url": "https://repo.continuum.io/pkgs/main/osx-64/zlib-1.2.11-hf3cbc9b_2.tar.bz2", + "version": "1.2.11" +}` + +var condaManySpecs = func() string { + parts := []string{} + for i := range 40 { + parts = append(parts, `"spec`+string(rune('a'+i%26))+strings.Repeat("x", i/26)+`"`) + } + return strings.Join(parts, ",") +}() + +func TestCondaRecord(t *testing.T) { + r, err := parseCondaRecord([]byte(condaZlibReceipt)) + if err != nil { + t.Fatal(err) + } + if r.name != "zlib" || r.version != "1.2.11" || r.build != "hf3cbc9b_2" || r.subdir != "osx-64" || r.buildNumber == nil || *r.buildNumber != 2 || r.md5 != condaMD5A || len(r.requested) != 0 || len(r.reasons) != 0 { + t.Errorf("historical receipt %+v", r) + } + p := condaFact(r) + if p.DependencyRelation != "unknown" || p.Origin != (model.CondaOrigin{Kind: "channel", URL: "https://repo.anaconda.com/pkgs/main"}) || p.ArtifactURL != "https://repo.continuum.io/pkgs/main/osx-64/zlib-1.2.11-hf3cbc9b_2.tar.bz2" || + p.ChecksumStatus != "recorded" || len(p.RecordedChecksums) != 1 || p.RecordedChecksums[0].Algorithm != "md5" { + t.Errorf("historical fact %+v", p) + } + + modern := `{"name":"NumPy","version":"1.26.4","build":"py312h8813227_0","build_number":0,"subdir":"osx-arm64","channel":"conda-forge", + "url":"https://conda.anaconda.org/conda-forge/osx-arm64/numpy-1.26.4-py312h8813227_0.conda","md5":"` + condaMD5A + `","sha256":"` + strings.ToUpper(condaSHA_A) + `", + "requested_specs":["numpy>=1.26","https://CANARY:x@repo.example.invalid/t/CANARY/ch::numpy","numpy>=1.26"],"requested_spec":"ignored","package_type":"noarch_python"}` + r, err = parseCondaRecord([]byte(modern)) + if err != nil { + t.Fatal(err) + } + p = condaFact(r) + if p.PackageName != "numpy" || p.DependencyRelation != "direct" || !slices.Equal(p.RequestedSpecs, []string{"numpy>=1.26", "https://repo.example.invalid/ch::numpy"}) || p.BuildNumber == nil || *p.BuildNumber != 0 || + p.Origin != (model.CondaOrigin{Kind: "channel", URL: "https://conda.anaconda.org/conda-forge"}) || len(p.RecordedChecksums) != 2 || p.RecordedChecksums[0].Algorithm != "sha256" || p.RecordedChecksums[0].Value != condaSHA_A { + t.Errorf("modern fact %+v", p) + } + + // A wheel installed through conda-pypi: the channel is a label and the + // download host is not a channel. + wheel := `{"name":"ansi2html","version":"1.9.5","build":"py3_none_any_0","build_number":0,"subdir":"noarch","channel":"conda-pypi","noarch":"python", + "url":"https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl","sha256":"` + condaSHA_A + `","requested_spec":"conda-pypi::ansi2html"}` + r, err = parseCondaRecord([]byte(wheel)) + if err != nil { + t.Fatal(err) + } + p = condaFact(r) + if p.Origin.Kind != "unknown" || p.ArtifactURL != "https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl" || p.DependencyRelation != "direct" || p.ChecksumStatus != "recorded" || len(p.Reasons) != 0 { + t.Errorf("wheel fact %+v", p) + } + + for _, tc := range []struct { + name, in string + reasons []string + invalid bool + err error + }{ + {"virtual package type", `{"name":"__glibc","version":"2.17","build":"0","package_type":"virtual_system"}`, nil, false, errCondaUnsupportedFormat}, + {"virtual name", `{"name":"__glibc","version":"2.17","build":"0"}`, nil, false, errors.New("")}, + {"missing build", `{"name":"zlib","version":"1.2.11"}`, nil, false, errors.New("")}, + {"build_string alias", `{"name":"zlib","version":"1.2.11","build_string":"h0"}`, []string{}, false, nil}, + {"bad subdir and build number", `{"name":"zlib","version":"1.2.11","build":"h0","subdir":"Linux 64","build_number":-1}`, []string{"parse_error", "parse_error"}, false, nil}, + {"bad checksum", `{"name":"zlib","version":"1.2.11","build":"h0","md5":"xyz"}`, []string{}, true, nil}, + {"too many requests", `{"name":"zlib","version":"1.2.11","build":"h0","requested_specs":[` + condaManySpecs + `]}`, []string{"entry_limit"}, false, nil}, + {"not an object", `[]`, nil, false, errors.New("")}, + } { + r, err := parseCondaRecord([]byte(tc.in)) + switch { + case tc.err != nil && err == nil, tc.err == nil && err != nil: + t.Errorf("%s: err = %v", tc.name, err) + case tc.err == errCondaUnsupportedFormat && !errors.Is(err, errCondaUnsupportedFormat): + t.Errorf("%s: err = %v", tc.name, err) + case err == nil && (!slices.Equal(r.reasons, tc.reasons) || r.checksumInvalid != tc.invalid): + t.Errorf("%s: %+v", tc.name, r) + } + } + if r, _ := parseCondaRecord([]byte(`{"name":"zlib","version":"1.2.11","build":"h0","requested_specs":[` + strings.Repeat(`"a",`, 40) + `"b"]}`)); !slices.Equal(r.requested, []string{"a", "b"}) || len(r.reasons) != 0 { + t.Errorf("duplicate requests %+v", r) + } + if r, _ := parseCondaRecord([]byte(`{"name":"zlib","version":"1.2.11","build":"h0","requested_specs":[` + condaManySpecs + `]}`)); len(r.requested) != maxCondaRequestedSpecs { + t.Errorf("request cap kept %d", len(r.requested)) + } +} + +func TestCondaEnvironment(t *testing.T) { + m, err := parseCondaEnvironment([]byte(`name: app +channels: + - conda-forge + - http://mirror.example.invalid/ch + - https://user:CANARY@repo.example.invalid/t/CANARY/private + - nodefaults +dependencies: + - python=3.12 + - conda-forge::numpy>=1.26 + - pip + - __glibc>=2.17 + - numpy* + - pip: + - requests + - m2-base # [win] +variables: + CANARY_VAR: CANARY_VALUE +prefix: /home/CANARY/envs/app +`)) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(m.channels, []string{"conda-forge", "http://mirror.example.invalid/ch", "https://user:CANARY@repo.example.invalid/t/CANARY/private", "nodefaults"}) { + t.Errorf("channels %v", m.channels) + } + names := []string{} + for _, d := range m.declarations { + names = append(names, d.name) + } + if !slices.Equal(names, []string{"python", "numpy", "pip"}) || !m.unsupported || m.invalid { + t.Errorf("manifest %+v", m) + } + if m.declarations[1].channel != "conda-forge" || m.declarations[0].spec != "python=3.12" { + t.Errorf("declarations %+v", m.declarations) + } + + m, err = parseCondaEnvironment([]byte(`{"name": "app", "channels": ["conda-forge"], "dependencies": ["zlib=1.2.11", {"pip": ["x"]}], "prefix": "/opt/x"}`)) + if err != nil || len(m.declarations) != 1 || m.unsupported || m.invalid || !slices.Equal(m.channels, []string{"conda-forge"}) { + t.Errorf("json export %+v %v", m, err) + } + + for _, tc := range []struct { + name, in string + unsupported, invalid bool + err bool + }{ + {"sel form", "dependencies:\n - sel(win): m2-base\n", true, false, false}, + {"duplicate dependencies key", "dependencies:\n - a\ndependencies:\n - b\n", true, false, false}, + {"anchor", "channels: &c\n - conda-forge\ndependencies: *c\n", true, false, false}, + {"scalar dependencies", "dependencies: numpy\n", false, true, false}, + {"null channel entry", "channels:\n - \n", false, true, false}, + {"empty", "", false, false, false}, + {"sequence root", "- a\n", false, false, true}, + {"not yaml", "a: [\n", false, false, true}, + {"not utf8", "\xff", false, false, true}, + } { + m, err := parseCondaEnvironment([]byte(tc.in)) + if (err != nil) != tc.err || err == nil && (m.unsupported != tc.unsupported || m.invalid != tc.invalid) { + t.Errorf("%s: %+v %v", tc.name, m, err) + } + } +} + +func TestCondaExplicit(t *testing.T) { + data := []byte(`# This file may be used to create an environment using: +# $ conda create --name --file +# platform: osx-arm64 +@EXPLICIT +https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda#` + condaMD5A + ` +https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda#sha256:` + condaSHA_A + ` +https://conda.anaconda.org/t/CANARY-TOKEN/private/osx-arm64/zlib-1.2.13-h53f4e23_5.conda +https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2#CANARY +file:///opt/channel/osx-arm64/local-1.0-0.conda +pkgs/relative-1.0-0.conda +https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl +https://conda.anaconda.org/conda-forge/osx-arm64/README +`) + if !condaIsExplicit(data) || condaIsExplicit([]byte("# platform: x\nnumpy\n")) || condaIsExplicit(nil) { + t.Fatal("explicit header detection") + } + entries, reasons, err := parseCondaExplicit(data) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(reasons, []string{"checksum_invalid", "path_unresolved", "unsupported_format", "parse_error"}) { + t.Errorf("reasons %v", reasons) + } + if len(entries) != 6 { + t.Fatalf("rows %d", len(entries)) + } + rows := map[string]model.CondaPackage{} + for _, e := range entries { + rows[e.pkg.PackageName] = e.pkg + if e.pkg.TargetPlatform != "osx-arm64" || e.pkg.VersionStatus != "known" || e.pkg.DependencyRelation != "unknown" || strings.Contains(e.pkg.ArtifactURL, "CANARY") { + t.Errorf("row %+v", e.pkg) + } + } + if p := rows["bzip2"]; p.ChecksumStatus != "recorded" || p.RecordedChecksums[0].Value != condaMD5A || p.Origin != (model.CondaOrigin{Kind: "channel", URL: "https://conda.anaconda.org/conda-forge"}) || p.Build != "h93a5062_5" || p.Subdir != "osx-arm64" { + t.Errorf("bzip2 %+v", p) + } + if p := rows["tzdata"]; p.RecordedChecksums[0].Algorithm != "sha256" || p.Subdir != "noarch" { + t.Errorf("tzdata %+v", p) + } + if p := rows["zlib"]; p.Origin.URL != "https://conda.anaconda.org/private" || p.ArtifactURL != "https://conda.anaconda.org/private/osx-arm64/zlib-1.2.13-h53f4e23_5.conda" { + t.Errorf("zlib %+v", p) + } + if p := rows["libffi"]; p.ChecksumStatus != "partial" || !slices.Equal(p.Reasons, []string{"checksum_invalid"}) { + t.Errorf("libffi %+v", p) + } + if p := rows["local"]; p.Origin != (model.CondaOrigin{Kind: "local", Path: "/opt/channel"}) || p.ArtifactURL != "/opt/channel/osx-arm64/local-1.0-0.conda" { + t.Errorf("local %+v", p) + } + if p := rows["relative"]; p.Origin.Kind != "unknown" || p.ArtifactURL != "" || !slices.Equal(p.Reasons, []string{"path_unresolved"}) { + t.Errorf("relative %+v", p) + } + for _, in := range []string{"numpy\nzlib\n", "# platform: linux-64\n", "", "\xff@EXPLICIT\n"} { + if _, _, err := parseCondaExplicit([]byte(in)); err == nil { + t.Errorf("accepted %q", in) + } + } +} + +func TestCondaLockYAML(t *testing.T) { + v1 := `# This lock file was generated by conda-lock. DO NOT EDIT! +metadata: + channels: + - conda-forge + platforms: + - linux-64 + - osx-arm64 +package: +- category: main + dependencies: {} + hash: + md5: ` + condaMD5A + ` + manager: conda + name: _libgcc_mutex + optional: false + platform: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + version: '0.1' +- category: main + dependencies: {} + hash: + sha256: ` + condaSHA_A + ` + manager: conda + name: tzdata + optional: false + platform: linux-64 + url: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda + version: 2024b +- category: main + dependencies: {} + hash: + sha256: ` + condaSHA_A + ` + manager: conda + name: tzdata + optional: false + platform: osx-arm64 + url: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda + version: 2024b +- category: main + dependencies: {} + hash: + sha256: ` + condaSHA_A + ` + manager: pip + name: requests + optional: false + platform: linux-64 + url: https://files.pythonhosted.org/packages/ab/cd/requests-2.32.3-py3-none-any.whl + version: 2.32.3 +- category: main + dependencies: {} + hash: + md5: bad + manager: conda + name: renamed + optional: false + platform: linux-64 + url: https://user:CANARY@conda.anaconda.org/conda-forge/linux-64/other-1.0-0.conda + version: '1.0' +version: 1 +` + entries, reasons, err := parseCondaLockYAML([]byte(v1)) + if err != nil { + t.Fatal(err) + } + if len(entries) != 4 || !slices.Equal(reasons, []string{"identity_mismatch", "checksum_invalid"}) { + t.Fatalf("v1 rows %d reasons %v", len(entries), reasons) + } + if p := entries[0].pkg; p.PackageName != "_libgcc_mutex" || p.Version != "0.1" || p.Build != "conda_forge" || p.Subdir != "linux-64" || p.TargetPlatform != "linux-64" || + p.Origin != (model.CondaOrigin{Kind: "channel", URL: "https://conda.anaconda.org/conda-forge"}) || p.ChecksumStatus != "recorded" { + t.Errorf("v1 row %+v", p) + } + if a, b := entries[1].pkg, entries[2].pkg; a.TargetPlatform != "linux-64" || b.TargetPlatform != "osx-arm64" || a.ArtifactURL != b.ArtifactURL { + t.Errorf("two targets sharing a noarch artifact %+v %+v", a, b) + } + if p := entries[3].pkg; p.PackageName != "renamed" || p.ArtifactURL != "" || p.Origin.Kind != "unknown" || !slices.Equal(p.Reasons, []string{"identity_mismatch", "checksum_invalid"}) || p.ChecksumStatus != "partial" { + t.Errorf("v1 mismatched row %+v", p) + } + + v6 := `version: 6 +environments: + default: + channels: + - url: https://prefix.dev/conda-forge/ + packages: + osx-arm64: + - conda: https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-h99b78c6_7.conda + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda + - conda: https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/cd/numpy-2.2.1-cp313-cp313-macosx_11_0_arm64.whl + linux-64: + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda + - conda: ../minimal-project + build: second + - conda: https://prefix.dev/conda-forge/linux-64/missing-1.0-0.conda + second: + channels: + - url: https://prefix.dev/conda-forge/ + packages: + win-64: + - conda: ../minimal-project + build: first + - conda: ../minimal-project +packages: + - conda: https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-h99b78c6_7.conda + sha256: ` + condaSHA_A + ` + md5: ` + condaMD5A + ` + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + purls: [] + size: 122909 + timestamp: 1720974522888 + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda + sha256: ` + condaSHA_A + ` + md5: ` + condaMD5A + ` + license: LicenseRef-Public-Domain + size: 122354 + - conda: https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl + build_number: 0 + sha256: ` + condaSHA_A + ` + - pypi: https://files.pythonhosted.org/packages/ab/cd/numpy-2.2.1-cp313-cp313-macosx_11_0_arm64.whl + name: numpy + version: 2.2.1 + sha256: ` + condaSHA_A + ` + - conda: ../minimal-project + name: minimal-project + version: "0.1" + subdir: win-64 + build: first + - conda: ../minimal-project + name: minimal-project + version: "0.1" + build: second + subdir: win-64 +` + entries, reasons, err = parseCondaLockYAML([]byte(v6)) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(reasons, []string{"unsupported_format", "path_unresolved", "parse_error"}) { + t.Errorf("v6 reasons %v", reasons) + } + type key struct{ name, env, platform string } + got := map[key]model.CondaPackage{} + for _, e := range entries { + got[key{e.pkg.PackageName, e.pkg.LockEnvironment, e.pkg.TargetPlatform}] = e.pkg + } + if len(got) != 5 || len(entries) != 5 { + t.Fatalf("v6 rows %+v", entries) + } + if p := got[key{"bzip2", "default", "osx-arm64"}]; p.Version != "1.0.8" || p.Build != "h99b78c6_7" || p.Subdir != "osx-arm64" || p.Origin.URL != "https://prefix.dev/conda-forge" || len(p.RecordedChecksums) != 2 { + t.Errorf("v6 bzip2 %+v", p) + } + if _, ok := got[key{"tzdata", "default", "linux-64"}]; !ok { + t.Error("v6 noarch artifact referenced by two targets") + } + if p := got[key{"minimal-project", "default", "linux-64"}]; p.Build != "second" || p.Subdir != "win-64" || !slices.Equal(p.Reasons, []string{"path_unresolved"}) || p.ArtifactURL != "" { + t.Errorf("v6 source variant %+v", p) + } + if p := got[key{"minimal-project", "second", "win-64"}]; p.Build != "first" { + t.Errorf("v6 source variant by build %+v", p) + } + for _, in := range []string{"version: 2\npackage: []\n", "version: 6\n", "version: 6\npackages: []\nenvironments: []\n", "version: 1\npackage: {}\n", "", "\xff", "version: 6\npackages: &p []\nenvironments: *p\n"} { + if _, _, err := parseCondaLockYAML([]byte(in)); err == nil { + t.Errorf("accepted %q", in) + } + } + if _, _, err := parseCondaLockYAML([]byte("version: 2\npackage: []\n")); !errors.Is(err, errCondaUnsupportedFormat) { + t.Errorf("version 2 = %v", err) + } + if entries, _, err := parseCondaLockYAML([]byte(strings.Replace(v6, "version: 6", "version: 7\nplatforms:\n - name: osx-arm64\n", 1))); err != nil || len(entries) != 5 { + t.Errorf("version 7 = %d rows %v", len(entries), err) + } +} diff --git a/internal/detector/condascan.go b/internal/detector/condascan.go new file mode 100644 index 00000000..328be7a4 --- /dev/null +++ b/internal/detector/condascan.go @@ -0,0 +1,1092 @@ +package detector + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "io/fs" + "maps" + "os/user" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +var ( + maxCondaWalkEntries = 100_000 + maxCondaWalkDepth = 128 + maxCondaRecords = 50_000 + maxCondaOutputBytes = 16 << 20 + maxCondaListEntries = 10_000 +) + +// maxCondaCacheDepth reaches Micromamba's nested cache layout, +// pkgs/https////, with room to spare. +const maxCondaCacheDepth = 8 + +var ( + condaManifestNames = []string{"environment.yml", "environment.yaml", "env.yml", "env.yaml", "environment.json"} + condaLockNames = []string{"conda-lock.yml", "conda-lock.yaml", "pixi.lock", "explicit.txt"} +) + +// CondaScanner reads metadata only: no conda, mamba, micromamba, pixi, +// python or shell runs, no activation scripts and no dependency solving. +type CondaScanner struct { + exec executor.Executor + log *progress.Logger + protection func(string, *bool) (func(string) string, func(string) bool) +} + +func NewCondaScanner(exec executor.Executor, log *progress.Logger) *CondaScanner { + if log == nil { + log = progress.NewNoop() + } + return &CondaScanner{exec: exec, log: log, protection: configaudit.GoProtection} +} + +type condaProjectState struct { + project model.CondaProject + kept bool +} + +type condaDiskLock struct{ path, parent string } + +type condaScan struct { + ctx context.Context + exec executor.Executor + home, identity, goos string + roots []string + guard func(string) string + volume func(string) bool + files executor.Executor + config *configaudit.CondaConfigSnapshot + inv model.CondaInventory + sources []*model.CondaSource + byID map[string]*model.CondaSource + projects map[string]*condaProjectState + projectDirs map[string]string + locks map[string]bool + diskLocks map[string]condaDiskLock + envs, envRoots map[string]bool + envsDirs, caches map[string]bool + pruned map[string]bool + seen map[string]bool + remaining int + bytes int + limited bool +} + +func emptyCondaInventory() model.CondaInventory { + return model.CondaInventory{SchemaVersion: model.CondaInventorySchemaVersion, Status: "complete", Reasons: []string{}, Sources: []model.CondaSource{}, Projects: []model.CondaProject{}, Packages: []model.CondaPackage{}} +} + +func (s *CondaScanner) Scan(ctx context.Context, target *user.User, searchDirs []string, includeNetworkVolumes *bool) (*model.CondaInventory, *model.CondaConfigAudit) { + start := time.Now() + inv := emptyCondaInventory() + configDetector := configaudit.NewCondaConfigDetector(s.exec) + if target == nil || target.Username == "" || target.HomeDir == "" || !filepath.IsAbs(target.HomeDir) || isGoServiceIdentity(s.exec.GOOS(), target) { + inv.Status, inv.Reasons = "partial", []string{"user_unresolved"} + audit := configDetector.Detect(ctx, configaudit.CondaConfigScope{}).Finish() + return &inv, &audit + } + guard, volume := s.protection(target.HomeDir, includeNetworkVolumes) + // Reserve the audit's separate 256 KiB allowance before collecting inventory. + c := &condaScan{ctx: ctx, exec: s.exec, home: filepath.Clean(target.HomeDir), identity: target.Username, goos: s.exec.GOOS(), guard: guard, volume: volume, inv: inv, + byID: map[string]*model.CondaSource{}, projects: map[string]*condaProjectState{}, projectDirs: map[string]string{}, locks: map[string]bool{}, diskLocks: map[string]condaDiskLock{}, + envs: map[string]bool{}, envRoots: map[string]bool{}, envsDirs: map[string]bool{}, caches: map[string]bool{}, pruned: map[string]bool{}, seen: map[string]bool{}, remaining: maxCondaRecords, bytes: goJSONSize(&inv) + (256 << 10)} + c.roots = []string{c.home} + dirs := []string{} + for _, dir := range searchDirs { + if dir == "" { + continue + } + dir = canonicalNoStat(dir, c.home) + if !filepath.IsAbs(dir) || !composerText(dir, 4096) { + c.degrade(nil, "path_unresolved") + continue + } + dirs = append(dirs, dir) + c.roots = append(c.roots, dir) + } + current, err := s.exec.CurrentUser() + verified := err == nil && current != nil && target.Uid != "" && current.Uid == target.Uid + c.config = configDetector.Detect(ctx, configaudit.CondaConfigScope{Username: target.Username, Home: c.home, Protected: guard, Volume: volume, ProcessVerified: verified}) + c.guard = c.config.ContentGuard + c.files = s.exec.GuardedFiles(c.roots, c.guard, maxCondaMetadataBytes) + c.standardRoots() + c.leads() + c.registry() + c.walkRoots(dirs) + // Files read during the walk can add envs_dirs/pkgs_dirs leads. + c.leads() + c.independentLocks() + if reason := c.stopReason(); reason != "" { + c.degrade(nil, reason) + } + audit := c.config.Finish() + c.finish() + if goJSONSize(&c.inv)+goJSONSize(&audit) > maxCondaOutputBytes { + c.inv = emptyCondaInventory() + c.degrade(nil, "output_size_limit") + } + s.log.Debug("conda scan: inventory=%s (%d sources, %d projects, %d packages) audit=%s (%d files, %d findings) in %dms", + c.inv.Status, len(c.inv.Sources), len(c.inv.Projects), len(c.inv.Packages), audit.Status, len(audit.Files), len(audit.Findings), time.Since(start).Milliseconds()) + return &c.inv, &audit +} +func (c *condaScan) key(p string) string { return configaudit.GoPathKey(c.goos, p) } +func (c *condaScan) reason(err error, p string) string { + return configaudit.CargoReadReason(err, p, c.volume) +} +func (c *condaScan) degrade(src *model.CondaSource, reason string) { + c.inv.Status = "partial" + if !slices.Contains(c.inv.Reasons, reason) { + c.inv.Reasons = append(c.inv.Reasons, reason) + } + for ; src != nil; src = c.byID[src.ParentSourceID] { + src.Status = "partial" + if !slices.Contains(src.Reasons, reason) { + src.Reasons = append(src.Reasons, reason) + } + } +} +func (c *condaScan) charge(src *model.CondaSource, record any) bool { + if c.remaining <= 0 { + c.degrade(src, "record_limit") + return false + } + // Reserve later coverage reasons, child IDs and separators per record. + size := goJSONSize(record) + 128 + if c.limited || c.bytes+size > maxCondaOutputBytes { + c.limited = true + c.degrade(src, "output_size_limit") + return false + } + c.remaining-- + c.bytes += size + return true +} +func (c *condaScan) stopReason() string { + switch { + case c.ctx.Err() != nil: + return "deadline_exceeded" + case c.limited: + return "output_size_limit" + case c.remaining <= 0: + return "record_limit" + } + return "" +} +func (c *condaScan) stopped(src *model.CondaSource) bool { + if reason := c.stopReason(); reason != "" { + c.degrade(src, reason) + return true + } + return false +} +func (c *condaScan) source(kind, path, parent string) *model.CondaSource { + if !filepath.IsAbs(path) || !composerText(path, 4096) { + c.degrade(c.byID[parent], "path_unresolved") + return nil + } + id := configaudit.GoSourceID(c.identity, kind, path) + if src := c.byID[id]; src != nil { + return src + } + owner := c.byID[parent] + if owner == nil { + parent = "" + } + src := &model.CondaSource{SourceID: id, Kind: kind, Path: path, ParentSourceID: parent, Status: "complete", Presence: "present", Reasons: []string{}, DiscoveredSources: []string{}} + if !c.charge(owner, src) { + return nil + } + c.byID[id] = src + c.sources = append(c.sources, src) + return src +} +func (c *condaScan) fs(path string, limit int64) executor.Executor { + if configaudit.GoWithinRoots(c.goos, path, c.roots) { + return c.exec.GuardedFiles(c.roots, c.guard, limit) + } + // Exact metadata and shared roots permit only targeted access, never a new walk. + return c.exec.GuardedFiles([]string{path}, c.guard, limit) +} + +// read loads one file for its owner. A cached_metadata source owns an info +// directory, so the file is explicit rather than the source path. +func (c *condaScan) read(src *model.CondaSource, path string, targeted bool) ([]byte, bool) { + if c.stopped(src) { + src.Presence = "unknown" + return nil, false + } + files := c.fs(path, maxCondaMetadataBytes) + info, err := files.Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + src.Presence = "absent" + if !targeted { + src.Presence = "unknown" + c.degrade(src, "changed_during_scan") + } + return nil, false + case err != nil: + src.Presence = "unknown" + c.degrade(src, c.reason(err, path)) + return nil, false + case !info.Mode().IsRegular(): + c.degrade(src, "unsupported_entry") + return nil, false + case info.Size() > maxCondaMetadataBytes: + c.degrade(src, "size_limit") + return nil, false + } + data, err := files.ReadFile(path) + if err != nil { + src.Presence = "unknown" + reason := c.reason(err, path) + if errors.Is(err, fs.ErrNotExist) { + reason = "changed_during_scan" + } + c.degrade(src, reason) + return nil, false + } + return data, true +} + +// list enumerates one directory for its owner. A missing directory has no +// children; any other failure or truncation leaves the owner partial. +func (c *condaScan) list(owner *model.CondaSource, dir string) ([]fs.DirEntry, bool) { + if c.stopped(owner) { + return nil, false + } + entries, more, err := c.fs(dir, 0).ReadDirLimit(dir, maxCondaListEntries) + if errors.Is(err, fs.ErrNotExist) { + return nil, false + } + if err != nil { + c.degrade(owner, c.reason(err, dir)) + return nil, false + } + if more { + c.degrade(owner, "entry_limit") + } + return entries, true +} + +func (c *condaScan) scopeOf(path string) string { + if configaudit.GoWithinRoots(c.goos, path, []string{c.home}) { + return "user" + } + return "system" +} + +// standardRoots probes the fixed installer locations. Each is one stat; a +// missing one is silent. Rattler's own cache layout is not read. +func (c *condaScan) standardRoots() { + candidates := []string{} + for _, name := range []string{"miniconda3", "anaconda3", "miniforge3", "mambaforge", "micromamba", ".conda"} { + candidates = append(candidates, filepath.Join(c.home, name)) + } + switch c.goos { + case model.PlatformDarwin: + candidates = append(candidates, "/opt/anaconda3", "/opt/miniconda3", "/opt/homebrew/Caskroom/miniforge/base", "/opt/homebrew/Caskroom/miniconda/base", "/usr/local/Caskroom/miniforge/base", "/usr/local/Caskroom/miniconda/base") + case model.PlatformLinux: + candidates = append(candidates, "/opt/conda", "/opt/anaconda3", "/opt/miniconda3", "/opt/miniforge3") + case model.PlatformWindows: + local := cmpOr(c.config.Env("LOCALAPPDATA"), filepath.Join(c.home, "AppData", "Local")) + candidates = append(candidates, filepath.Join(local, "conda", "conda"), `C:\ProgramData\miniconda3`, `C:\ProgramData\anaconda3`, `C:\ProgramData\miniforge3`) + } + for _, p := range candidates { + if !c.environmentRoot(p, c.scopeOf(p), "", "") { + c.environment(p, c.scopeOf(p), "", "") + } + } +} + +// leads follows verified process variables and present configuration files. +// Each target dedups itself, so later calls only add what a walk uncovered. +func (c *condaScan) leads() { + for _, d := range c.config.EnvsDirs { + c.envsDir(d, c.scopeOf(d), nil) + } + for _, d := range c.config.PkgsDirs { + c.cacheRoot(d, c.scopeOf(d), "", "") + } + for _, p := range c.config.Prefixes { + if !c.environmentRoot(p, c.scopeOf(p), "", "") { + c.environment(p, c.scopeOf(p), "", "") + } + } +} + +// environmentRoot recognizes an installation root or an envs/pkgs +// container, then enumerates its base prefix, envs and package cache. It +// reports whether path is one, including a root enumerated under another +// spelling; an unreadable candidate is reported, never assumed absent. +func (c *condaScan) environmentRoot(path, scope, parent, project string) bool { + owner := c.byID[parent] + if !filepath.IsAbs(path) || !composerText(path, 4096) { + c.degrade(owner, "path_unresolved") + return false + } + path = filepath.Clean(path) + if c.envRoots[c.key(path)] { + return true + } + if c.stopped(owner) { + return false + } + files := c.fs(path, 0) + entries, more, err := files.ReadDirLimit(path, maxCondaListEntries) + if errors.Is(err, fs.ErrNotExist) { + return false + } + if err != nil { + c.envRoots[c.key(path)] = true + if src := c.source("environment_root", path, parent); src != nil { + src.Presence = "unknown" + c.degrade(src, c.reason(err, path)) + } + c.config.Root(path, true) + return true + } + has := func(name string) bool { + return slices.ContainsFunc(entries, func(e fs.DirEntry) bool { return e.Name() == name && (e.IsDir() || e.Type()&fs.ModeSymlink != 0) }) + } + // An activated or registered prefix with only conda-meta is an + // environment, not an installation whose .condarc the loader consults. + meta, envs, pkgs := has("conda-meta"), has("envs"), has("pkgs") + if !envs && !pkgs { + return false + } + phys := path + if resolved, err := files.EvalSymlinks(path); err == nil { + phys = resolved + } + duplicate := c.envRoots[c.key(phys)] + c.envRoots[c.key(path)], c.envRoots[c.key(phys)] = true, true + c.pruned[c.key(path)], c.pruned[c.key(phys)] = true, true + if duplicate { + return true + } + src := c.source("environment_root", path, parent) + if src == nil { + return true + } + if more { + c.degrade(src, "entry_limit") + } + c.config.Root(path, more) + if meta { + c.environment(path, scope, src.SourceID, project) + } + if envs { + c.envsDir(filepath.Join(path, "envs"), scope, src) + } + if pkgs { + c.cacheRoot(filepath.Join(path, "pkgs"), scope, src.SourceID, project) + } + return true +} + +// envsDir enumerates one environments directory; each child is a candidate +// prefix. It is a discovery step, not a source of its own. +func (c *condaScan) envsDir(dir, scope string, owner *model.CondaSource) { + if !filepath.IsAbs(dir) || c.envsDirs[c.key(dir)] { + return + } + c.envsDirs[c.key(dir)] = true + parent := "" + if owner != nil { + parent = owner.SourceID + } + entries, ok := c.list(owner, dir) + if !ok { + return + } + for _, e := range entries { + if e.IsDir() || e.Type()&fs.ModeSymlink != 0 { + c.environment(filepath.Join(dir, e.Name()), scope, parent, "") + } + } +} + +// environment enumerates one prefix's conda-meta receipts. It reports +// whether the prefix holds one, including a prefix already enumerated under +// another spelling. A missing history marker means creation never finished, +// so the receipts are kept but the source stays partial. +func (c *condaScan) environment(prefix, scope, parent, project string) bool { + owner := c.byID[parent] + if !filepath.IsAbs(prefix) || !composerText(prefix, 4096) { + c.degrade(owner, "path_unresolved") + return false + } + prefix = filepath.Clean(prefix) + if c.envs[c.key(prefix)] { + return true + } + if c.stopped(owner) { + return false + } + files := c.fs(prefix, 0) + meta := filepath.Join(prefix, "conda-meta") + info, err := files.Stat(meta) + if errors.Is(err, fs.ErrNotExist) { + return false + } + phys := prefix + if resolved, err := files.EvalSymlinks(prefix); err == nil { + phys = resolved + } + duplicate := c.envs[c.key(phys)] + c.envs[c.key(prefix)], c.envs[c.key(phys)] = true, true + c.pruned[c.key(prefix)], c.pruned[c.key(phys)] = true, true + if duplicate { + return true + } + src := c.source("environment", prefix, parent) + if src == nil { + return true + } + switch { + case err != nil: + src.Presence = "unknown" + c.degrade(src, c.reason(err, meta)) + case !info.IsDir(): + c.degrade(src, "unsupported_entry") + default: + entries, ok := c.list(src, meta) + finished := false + for _, e := range entries { + name := e.Name() + switch { + case name == "history" || name == "pixi": + finished = finished || !e.IsDir() + case e.IsDir() || !strings.HasSuffix(name, ".json") || strings.HasPrefix(name, "._"): + case c.stopped(src): + default: + c.receipt(filepath.Join(meta, name), src, scope, project, prefix) + } + } + if ok && !finished { + c.degrade(src, "extraction_incomplete") + } + } + c.config.Environment(prefix, src.Status != "complete") + return true +} + +// receipt reads one conda-meta record. Its filename must agree with the +// identity inside, as Conda itself requires; a mismatch yields no row and the +// filename never fills in identity. +func (c *condaScan) receipt(path string, owner *model.CondaSource, scope, project, prefix string) { + src := c.source("installed_metadata", path, owner.SourceID) + if src == nil { + return + } + data, ok := c.read(src, path, false) + if !ok { + return + } + r, err := parseCondaRecord(data) + if err != nil { + c.degrade(src, condaParseReason(err)) + return + } + name, version, build, ok := condaDist(strings.TrimSuffix(filepath.Base(path), ".json")) + if !ok || name != r.name || version != r.version || build != r.build { + c.degrade(src, "identity_mismatch") + return + } + p := condaFact(r) + p.EnvironmentPath = prefix + p.Installation = &model.CondaInstallation{Path: prefix, PathStatus: "resolved", Presence: "present"} + c.recordReasons(src, r) + c.add(src, p, "installed_package", project, scope) +} + +func (c *condaScan) recordReasons(src *model.CondaSource, r *condaRecord) { + if r.checksumInvalid { + c.degrade(src, "checksum_invalid") + } + for _, reason := range r.reasons { + c.degrade(src, reason) + } +} + +// registry reads ~/.conda/environments.txt, Conda's own list of prefixes it +// created anywhere on disk. A stale entry is simply not an environment. +func (c *condaScan) registry() { + path := filepath.Join(c.home, ".conda", "environments.txt") + if c.stopped(nil) { + return + } + files := c.fs(path, maxCondaMetadataBytes) + info, err := files.Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + return + case err != nil: + c.degrade(nil, c.reason(err, path)) + return + case !info.Mode().IsRegular(): + c.degrade(nil, "unsupported_entry") + return + case info.Size() > maxCondaMetadataBytes: + c.degrade(nil, "size_limit") + return + } + data, err := files.ReadFile(path) + if err != nil { + c.degrade(nil, c.reason(err, path)) + return + } + for _, line := range condaLines(data) { + if line == "" { + continue + } + if !composerText(line, 4096) || !filepath.IsAbs(line) { + c.degrade(nil, "path_unresolved") + continue + } + if c.stopped(nil) { + return + } + c.environment(line, c.scopeOf(line), "", c.enclosing(line)) + } +} + +// cacheRoot enumerates an extracted package cache. Archives are never +// opened: one that has no extracted directory beside it is reported as +// unsupported coverage, and no identity is guessed from its name. +func (c *condaScan) cacheRoot(path, scope, parent, project string) { + owner := c.byID[parent] + if path == "" || c.caches[c.key(path)] || c.stopped(owner) { + return + } + info, err := c.fs(path, 0).Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return + } + c.caches[c.key(path)], c.pruned[c.key(path)] = true, true + src := c.source("cache_root", path, parent) + if src == nil { + return + } + if err != nil { + src.Presence = "unknown" + c.degrade(src, c.reason(err, path)) + return + } + if !info.IsDir() { + c.degrade(src, "unsupported_entry") + return + } + type dir struct { + path string + depth int + } + queue := []dir{{path, 0}} + stems, extracted := map[string]bool{}, map[string]bool{} + for len(queue) > 0 { + if c.stopped(src) { + return + } + d := queue[0] + queue = queue[1:] + entries, ok := c.list(src, d.path) + if !ok { + continue + } + for _, e := range entries { + if c.stopped(src) { + return + } + name := e.Name() + p := filepath.Join(d.path, name) + switch { + case e.Type()&fs.ModeSymlink != 0: + case e.IsDir(): + // Index caches, lock files and trash hold no package metadata. + if name == "cache" || name == "locks" || strings.HasPrefix(name, ".") { + continue + } + if c.cached(p, src, scope, project) { + extracted[name] = true + continue + } + if d.depth+1 > maxCondaCacheDepth { + c.degrade(src, "depth_limit") + continue + } + queue = append(queue, dir{p, d.depth + 1}) + case strings.HasSuffix(name, ".conda") || strings.HasSuffix(name, ".tar.bz2"): + stems[strings.TrimSuffix(strings.TrimSuffix(name, ".conda"), ".tar.bz2")] = true + } + } + } + for stem := range stems { + if !extracted[stem] { + c.degrade(src, "unsupported_format") + break + } + } +} + +// cached reads one extracted package's info directory: the download record +// when present, else the archive's own index. It reports whether dir is a +// package, so the caller never descends into one. +func (c *condaScan) cached(dir string, owner *model.CondaSource, scope, project string) bool { + info := filepath.Join(dir, "info") + index := filepath.Join(info, "index.json") + st, err := c.fs(index, 0).Stat(index) + switch { + case errors.Is(err, fs.ErrNotExist): + return false + case err != nil: + if src := c.source("cached_metadata", info, owner.SourceID); src != nil { + src.Presence = "unknown" + c.degrade(src, c.reason(err, index)) + } + return true + case !st.Mode().IsRegular(): + return false + } + src := c.source("cached_metadata", info, owner.SourceID) + if src == nil { + return true + } + var r *condaRecord + if data, ok := c.read(src, filepath.Join(info, "repodata_record.json"), true); ok { + if rr, err := parseCondaRecord(data); err == nil { + r = rr + } else { + c.degrade(src, condaParseReason(err)) + } + } + if r == nil { + data, ok := c.read(src, index, true) + if !ok { + return true + } + rr, err := parseCondaRecord(data) + if err != nil { + c.degrade(src, condaParseReason(err)) + return true + } + r, src.Presence = rr, "present" + } + c.recordReasons(src, r) + c.add(src, condaFact(r), "cached_package", project, scope) + return true +} + +func condaPackageKey(p model.CondaPackage) string { b, _ := json.Marshal(p); return string(b) } +func (c *condaScan) add(src *model.CondaSource, p model.CondaPackage, evidence, project, scope string) { + p.SourceID, p.SourcePath, p.Evidence, p.ProjectPath, p.Scope = src.SourceID, src.Path, evidence, project, scope + for i := range p.RecordedChecksums { + h := &p.RecordedChecksums[i] + h.SourceID, h.SourcePath, h.SourceKind = src.SourceID, src.Path, src.Kind + } + slices.Sort(p.Reasons) + p.Reasons = slices.Compact(p.Reasons) + // The same file reached twice for one project is one observation. + key := condaPackageKey(p) + if c.seen[key] { + return + } + if c.charge(src, p) { + c.seen[key] = true + c.inv.Packages = append(c.inv.Packages, p) + } +} + +// project reads one environment manifest. Its declared channels become a +// project-scope configuration source; `prefix:` is ignored because a +// declaration never claims installation. +func (c *condaScan) project(path, parent string) { + key := c.key(path) + if c.projects[key] != nil { + return + } + src := c.source("manifest", path, parent) + if src == nil { + return + } + dir := filepath.Dir(path) + st := &condaProjectState{project: model.CondaProject{ManifestSourceID: src.SourceID, ManifestPath: path, ProjectPath: dir, SelectionStatus: "observed", LockfilePaths: []string{}}} + c.projects[key] = st + c.projectDirs[c.key(dir)] = dir + status := "present" + var m *condaManifest + if data, ok := c.read(src, path, false); !ok { + status = "unreadable" + switch { + case src.Presence == "absent": + status = "absent" + case slices.Contains(src.Reasons, "skipped_protected") || slices.Contains(src.Reasons, "refused_network_volume"): + status = "skipped_protected" + } + } else { + var err error + switch m, err = parseCondaEnvironment(data); { + case errors.Is(err, errCondaUnsupportedFormat): + status = "unsupported" + c.degrade(src, "unsupported_format") + case err != nil: + status = "invalid" + c.degrade(src, "parse_error") + default: + if m.unsupported { + c.degrade(src, "unsupported_format") + } + if m.invalid { + c.degrade(src, "parse_error") + } + } + } + channels := []string{} + if m != nil { + channels = m.channels + } + partial := c.config.Manifest(path, channels, status, src.Reasons) || status != "present" || m != nil && m.unsupported + if m != nil { + for _, d := range m.declarations { + if c.stopped(src) { + break + } + c.declared(src, d, dir) + } + } + if partial { + st.project.SelectionStatus = "partial" + } + st.kept = c.charge(src, st.project) +} + +// declared emits one requirement. Only a URL or local channel qualifier +// establishes origin; a bare label is not a global identity. +func (c *condaScan) declared(src *model.CondaSource, d condaDeclaration, project string) { + p := model.CondaPackage{PackageName: d.name, Reasons: []string{}, VersionStatus: "unknown", RequestedSpecs: []string{d.spec}, DependencyRelation: "direct", + Origin: model.CondaOrigin{Kind: "unknown"}, ChecksumStatus: "absent", RecordedChecksums: []model.CondaRecordedChecksum{}} + switch { + case d.channel == "": + case d.channelRedacted: + p.Reasons = append(p.Reasons, "origin_unresolved") + default: + if origin, _ := condaOrigin(d.channel, "", ""); origin.Kind != "unknown" { + p.Origin = origin + } + } + c.add(src, p, "declared_requirement", project, "project") +} + +// explicitHeader decides whether a conda-*.lock file is an @EXPLICIT list +// before any source is recorded for it. A file that cannot be read stays a +// candidate so the lock source reports the failure instead of vanishing. +func (c *condaScan) explicitHeader(path string, owner *model.CondaSource) bool { + if c.stopped(owner) { + return false + } + files := c.fs(path, maxCondaMetadataBytes) + info, err := files.Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + return false + case err != nil, info.Mode().IsRegular() && info.Size() > maxCondaMetadataBytes: + return true + case !info.Mode().IsRegular(): + return false + } + data, err := files.ReadFile(path) + return err != nil || condaIsExplicit(data) +} + +func (c *condaScan) lock(path, parent, project string) *model.CondaSource { + c.locks[c.key(path)] = true + src := c.source("lockfile", path, parent) + if src == nil { + return nil + } + data, ok := c.read(src, path, false) + if !ok { + return src + } + base := filepath.Base(path) + parse := parseCondaLockYAML + if base == "explicit.txt" || strings.HasPrefix(base, "conda-") && strings.HasSuffix(base, ".lock") && !strings.HasPrefix(base, "conda-lock.") { + parse = parseCondaExplicit + } + entries, reasons, err := parse(data) + if err != nil { + c.degrade(src, condaParseReason(err)) + return src + } + for _, r := range reasons { + c.degrade(src, r) + } + for _, e := range entries { + if c.stopped(src) { + break + } + c.add(src, e.pkg, "locked_package", project, "project") + } + return src +} + +// independentLocks reads every lock found on disk. Conda has no manifest-to- +// lock linkage, so each lock beside a manifest is listed on that project. +func (c *condaScan) independentLocks() { + for _, key := range slices.Sorted(maps.Keys(c.diskLocks)) { + l := c.diskLocks[key] + if c.locks[key] { + continue + } + dir := filepath.Dir(l.path) + if c.stopped(c.byID[l.parent]) { + return + } + if src := c.lock(l.path, l.parent, dir); src != nil && src.Presence != "absent" { + for _, st := range c.projects { + if c.key(st.project.ProjectPath) == c.key(dir) { + st.project.LockfilePaths = append(st.project.LockfilePaths, l.path) + } + } + } + } +} + +func (c *condaScan) skip(path string) bool { + path = c.key(path) + for root := range c.pruned { + if path == root || goPathWithin(path, root) { + return true + } + } + return false +} + +// enclosing returns the deepest known project directory containing path. +func (c *condaScan) enclosing(path string) string { + best, key := "", c.key(path) + for k, dir := range c.projectDirs { + if (key == k || goPathWithin(key, k)) && len(dir) > len(best) { + best = dir + } + } + return best +} + +func (c *condaScan) walkRoots(dirs []string) { + // Outermost logical/physical roots own discovery. Identical physical roots + // retain the first spelling and never get walked twice. + type root struct { + path, phys string + src *model.CondaSource + } + roots := []root{} + seen := map[string]bool{} + for _, dir := range dirs { + if seen[c.key(dir)] { + continue + } + seen[c.key(dir)] = true + src := c.source("project_search_root", dir, "") + if src == nil { + continue + } + if c.stopped(src) { + src.Presence = "unknown" + continue + } + phys, err := c.fs(dir, 0).EvalSymlinks(dir) + if errors.Is(err, fs.ErrNotExist) { + src.Presence = "absent" + continue + } + if err != nil { + src.Presence = "unknown" + c.degrade(src, c.reason(err, dir)) + continue + } + roots = append(roots, root{dir, phys, src}) + } + for i, r := range roots { + duplicate := slices.ContainsFunc(roots[:i], func(other root) bool { return c.key(other.phys) == c.key(r.phys) }) + nested := slices.ContainsFunc(roots, func(other root) bool { return goPathWithin(c.key(r.phys), c.key(other.phys)) }) + if duplicate || nested { + delete(c.byID, r.src.SourceID) + continue + } + c.walk(r.path, r.src) + } +} + +func (c *condaScan) walk(root string, src *model.CondaSource) { + type dir struct { + path string + depth int + } + queue := []dir{{root, 0}} + budget := maxCondaWalkEntries + for len(queue) > 0 { + if c.stopped(src) { + return + } + d := queue[0] + queue = queue[1:] + if c.skip(d.path) { + c.adopt(d.path, src) + continue + } + entries, more, err := c.files.ReadDirLimit(d.path, budget) + if err != nil { + c.degrade(src, c.reason(err, d.path)) + continue + } + budget -= len(entries) + has := func(name string, isDir bool) bool { + return slices.ContainsFunc(entries, func(e fs.DirEntry) bool { return e.Name() == name && e.IsDir() == isDir }) + } + project, scope := c.enclosing(d.path), "unknown" + switch { + case project != "": + scope = "project" + case configaudit.GoWithinRoots(c.goos, d.path, []string{c.home}): + scope = "user" + } + // A prefix with envs or pkgs beside its receipts is an installation + // root; a bare conda-meta is an environment, wherever it lives. + meta, envs, pkgs := has("conda-meta", true), has("envs", true), has("pkgs", true) + switch { + case envs && pkgs || meta && (envs || pkgs): + c.environmentRoot(d.path, scope, src.SourceID, project) + continue + case meta: + c.environment(d.path, scope, src.SourceID, project) + continue + } + for _, name := range condaManifestNames { + if has(name, false) { + c.project(filepath.Join(d.path, name), src.SourceID) + } + } + for _, e := range entries { + name := e.Name() + if e.IsDir() { + continue + } + p := filepath.Join(d.path, name) + named := slices.Contains(condaLockNames, name) + // Any other conda-*.lock is read only once its header proves it explicit. + if named || strings.HasPrefix(name, "conda-") && strings.HasSuffix(name, ".lock") && !strings.HasPrefix(name, "conda-lock.") && c.explicitHeader(p, src) { + c.diskLocks[c.key(p)] = condaDiskLock{p, src.SourceID} + c.projectDirs[c.key(d.path)] = d.path + } + } + for _, e := range entries { + if !e.IsDir() { + continue + } + p := filepath.Join(d.path, e.Name()) + switch e.Name() { + case ".git", ".hg", ".svn", "node_modules", "target", ".rustup", ".cache", "__pycache__", ".bundle": + continue + } + if c.skip(p) { + c.adopt(p, src) + continue + } + if c.guard(p) != "" { + c.degrade(src, c.reasonForGuard(p)) + continue + } + if d.depth+1 > maxCondaWalkDepth { + c.degrade(src, "depth_limit") + continue + } + queue = append(queue, dir{p, d.depth + 1}) + } + if more { + c.degrade(src, "entry_limit") + return + } + } +} + +// adopt gives a root, cache or prefix found earlier by the standard list, a +// lead or the registry the walk source that now covers it, so a later scan +// of that root can prove its removal. Existing parents are kept; adopted +// reasons propagate like any other child's. +func (c *condaScan) adopt(path string, owner *model.CondaSource) { + for _, kind := range []string{"environment_root", "cache_root", "environment"} { + child := c.byID[configaudit.GoSourceID(c.identity, kind, filepath.Clean(path))] + if child == nil || child.ParentSourceID != "" || child == owner { + continue + } + child.ParentSourceID = owner.SourceID + for _, r := range child.Reasons { + c.degrade(owner, r) + } + } +} +func (c *condaScan) reasonForGuard(path string) string { + if c.volume != nil && c.volume(path) { + return "refused_network_volume" + } + return "skipped_protected" +} +func (c *condaScan) finish() { + for _, src := range c.sources { + if c.byID[src.SourceID] == nil { + continue + } + if parent := c.byID[src.ParentSourceID]; parent != nil { + parent.DiscoveredSources = append(parent.DiscoveredSources, src.SourceID) + } else { + src.ParentSourceID = "" + } + } + for _, src := range c.sources { + if c.byID[src.SourceID] == nil { + continue + } + slices.Sort(src.Reasons) + slices.Sort(src.DiscoveredSources) + src.DiscoveredSources = slices.Compact(src.DiscoveredSources) + c.inv.Sources = append(c.inv.Sources, *src) + } + for _, st := range c.projects { + if st.kept { + slices.Sort(st.project.LockfilePaths) + c.inv.Projects = append(c.inv.Projects, st.project) + } + } + // A prefix inside a project discovered later still belongs to it; + // discovery order never changes scope. + for i := range c.inv.Packages { + p := &c.inv.Packages[i] + if p.EnvironmentPath != "" && p.ProjectPath == "" { + if dir := c.enclosing(p.EnvironmentPath); dir != "" { + p.ProjectPath, p.Scope = dir, "project" + } + } + } + slices.SortFunc(c.inv.Sources, func(a, b model.CondaSource) int { + return strings.Compare(a.Path+"\x00"+a.Kind, b.Path+"\x00"+b.Kind) + }) + slices.SortFunc(c.inv.Projects, func(a, b model.CondaProject) int { return strings.Compare(a.ManifestPath, b.ManifestPath) }) + slices.SortStableFunc(c.inv.Packages, func(a, b model.CondaPackage) int { + return cmp.Or(strings.Compare(a.SourcePath, b.SourcePath), strings.Compare(a.Evidence, b.Evidence), strings.Compare(a.ProjectPath, b.ProjectPath), + strings.Compare(a.EnvironmentPath, b.EnvironmentPath), strings.Compare(a.PackageName, b.PackageName), strings.Compare(a.Version, b.Version), + strings.Compare(a.Build, b.Build), strings.Compare(a.Subdir, b.Subdir), strings.Compare(a.LockEnvironment, b.LockEnvironment), strings.Compare(a.TargetPlatform, b.TargetPlatform)) + }) + slices.Sort(c.inv.Reasons) +} diff --git a/internal/detector/condascan_test.go b/internal/detector/condascan_test.go new file mode 100644 index 00000000..3c5ffa6e --- /dev/null +++ b/internal/detector/condascan_test.go @@ -0,0 +1,1013 @@ +package detector + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// condaTrap adds the Conda-specific never-read files: credential stores, +// download URL lists, index caches and Python requirement files. +type condaTrap struct{ *rubyTrap } + +func (x *condaTrap) GuardedFiles(roots []string, guard func(string) string, max int64) executor.Executor { + return &condaTrap{x.rubyTrap.GuardedFiles(roots, guard, max).(*rubyTrap)} +} +func (x *condaTrap) ReadFile(path string) ([]byte, error) { + name := filepath.Base(path) + if slices.Contains([]string{".netrc", "_netrc", "tokens", "urls.txt", "requirements.txt", "repodata.json"}, name) || + strings.Contains(path, "binstar") || strings.Contains(filepath.ToSlash(path), "/pkgs/cache/") { + x.t.Fatalf("read forbidden file %s", path) + } + return x.rubyTrap.ReadFile(path) +} + +func condaTestScanner(t *testing.T, home string) (*CondaScanner, *condaTrap) { + t.Helper() + _, inner := rubyTestScanner(t, home) + x := &condaTrap{inner} + s := NewCondaScanner(x, progress.NewNoop()) + s.protection = func(string, *bool) (func(string) string, func(string) bool) { + return func(string) string { return "" }, func(string) bool { return false } + } + return s, x +} + +// condaReceipt is a conda-meta record in the shape Conda writes: identity, +// channel, download URL and checksums, with the never-read link data trimmed. +func condaReceipt(name, version, build, subdir, channel, url, md5, sha256, requested string) string { + return `{"name":"` + name + `","version":"` + version + `","build":"` + build + `","build_number":0,"subdir":"` + subdir + `","channel":"` + channel + `","url":"` + url + `",` + + `"md5":"` + md5 + `","sha256":"` + sha256 + `","fn":"` + name + `-` + version + `-` + build + `.conda","depends":[],"constrains":[],"files":["lib/x"],"paths_data":{"paths":[],"paths_version":1},` + + `"link":{"source":"/Users/CANARY_HOME/pkgs/` + name + `","type":1},"extracted_package_dir":"/Users/CANARY_HOME/pkgs/` + name + `","requested_specs":[` + requested + `]}` + "\n" +} + +const condaTzdata = `{"name":"tzdata","version":"2024b","build":"hc8b5060_0","build_number":0,"subdir":"noarch","noarch":"generic","channel":"https://conda.anaconda.org/conda-forge/noarch",` + + `"url":"https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda","sha256":"` + condaSHA_A + `","md5":"` + condaMD5A + `","requested_spec":"None","license":"LicenseRef-Public-Domain"}` + "\n" + +func condaWriteFixture(t *testing.T, home, root string) { + t.Helper() + history := "==> 2024-09-01 10:00:00 <==\n# cmd: conda create -n app packaging\n+conda-forge::packaging-24.1-pyhd8ed1ab_0\n" + base := filepath.Join(home, "miniforge3") + meta := filepath.Join(base, "conda-meta") + goWrite(t, filepath.Join(meta, "history"), history, 0o600) + // A Windows launcher package has files but no path data. + goWrite(t, filepath.Join(meta, "conda-launchers-1.0.0-h57928b3_0.json"), `{"name":"conda-launchers","version":"1.0.0","build":"h57928b3_0","build_number":0,"subdir":"win-64",`+ + `"channel":"https://conda.anaconda.org/conda-forge/win-64","url":"https://conda.anaconda.org/conda-forge/win-64/conda-launchers-1.0.0-h57928b3_0.conda","md5":"`+condaMD5A+`",`+ + `"files":["Scripts/a.exe","Scripts/b.exe","Scripts/c.exe","Scripts/d.exe","Scripts/e.exe","Scripts/f.exe"],"paths_data":{"paths":[],"paths_version":1},"requested_spec":"None"}`+"\n", 0o600) + goWrite(t, filepath.Join(meta, "_openmp_mutex-4.5-2_gnu.json"), `{"name":"_openmp_mutex","version":"4.5","build":"2_gnu","build_number":2,"subdir":"linux-64","channel":"https://conda.anaconda.org/conda-forge/linux-64",`+ + `"url":"https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2","md5":"`+condaMD5A+`","sha256":"`+condaSHA_A+`","requested_specs":["_openmp_mutex=4.5=2_gnu"]}`+"\n", 0o600) + goWrite(t, filepath.Join(meta, "broken-1.0-0.json"), `{"name":"broken",`, 0o600) + goWrite(t, filepath.Join(meta, "renamed-1.0-0.json"), `{"name":"other","version":"1.0","build":"0"}`, 0o600) + goWrite(t, filepath.Join(meta, "._tmp-1.0-0.json"), `{"name":"tmp","version":"1.0","build":"0"}`, 0o600) + goWrite(t, filepath.Join(meta, "state"), `{"env_vars":{"CONDA_SSL_VERIFY":"false","CONDA_CHANNELS":"***unset***","HTTPS_PROXY":"https://u:CANARY_STATE@proxy.example.invalid:8443"}}`, 0o600) + goWrite(t, filepath.Join(base, ".condarc"), "channels:\n - https://user:CANARY_RC@private.example.invalid/ch\n - conda-forge\nssl_verify: false\nsafety_checks: disabled\n", 0o600) + + app := filepath.Join(base, "envs", "app") + appMeta := filepath.Join(app, "conda-meta") + goWrite(t, filepath.Join(appMeta, "history"), history, 0o600) + goWrite(t, filepath.Join(appMeta, "packaging-24.1-pyhd8ed1ab_0.json"), condaReceipt("packaging", "24.1", "pyhd8ed1ab_0", "noarch", "https://conda.anaconda.org/conda-forge/noarch", + "https://conda.anaconda.org/conda-forge/noarch/packaging-24.1-pyhd8ed1ab_0.conda", condaMD5A, condaSHA_A, `"packaging"`), 0o600) + // Mamba records a bare channel label beside the full download URL. + goWrite(t, filepath.Join(appMeta, "zlib-1.3.1-h4e2bd7c_1.json"), condaReceipt("zlib", "1.3.1", "h4e2bd7c_1", "osx-arm64", "conda-forge", + "https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.1-h4e2bd7c_1.conda", condaMD5A, condaSHA_A, ""), 0o600) + goWrite(t, filepath.Join(appMeta, "ansi2html-1.9.5-py3_none_any_0.json"), `{"name":"ansi2html","version":"1.9.5","build":"py3_none_any_0","build_number":0,"subdir":"noarch","noarch":"python","channel":"conda-pypi",`+ + `"url":"https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl","sha256":"`+condaSHA_A+`","requested_spec":"conda-pypi::ansi2html"}`+"\n", 0o600) + goWrite(t, filepath.Join(appMeta, "secret-1.0-0.json"), condaReceipt("secret", "1.0", "0", "osx-arm64", "https://user:CANARY_PASS@private.example.invalid/t/CANARY_TOKEN/ch/osx-arm64", + "https://user:CANARY_PASS@private.example.invalid/t/CANARY_TOKEN/ch/osx-arm64/secret-1.0-0.conda", condaMD5A, condaSHA_A, + `"https://user:CANARY_PASS@private.example.invalid/t/CANARY_TOKEN/ch::secret","CANARY_USER@private.example.invalid:secret"`), 0o600) + goWrite(t, filepath.Join(appMeta, "badsum-1.0-0.json"), `{"name":"badsum","version":"1.0","build":"0","channel":"conda-forge","md5":"xyz"}`, 0o600) + goWrite(t, filepath.Join(base, "envs", "empty", "conda-meta", "history"), history, 0o600) + goWrite(t, filepath.Join(base, "envs", "nohistory", "conda-meta", "tzdata-2024b-hc8b5060_0.json"), condaTzdata, 0o600) + + pkgs := filepath.Join(base, "pkgs") + goWrite(t, filepath.Join(pkgs, "packaging-24.1-pyhd8ed1ab_0", "info", "index.json"), `{"name":"packaging","version":"24.1","build":"pyhd8ed1ab_0","build_number":0,"subdir":"noarch","depends":["python >=3.8"]}`, 0o600) + goWrite(t, filepath.Join(pkgs, "packaging-24.1-pyhd8ed1ab_0", "info", "repodata_record.json"), condaReceipt("packaging", "24.1", "pyhd8ed1ab_0", "noarch", "https://conda.anaconda.org/conda-forge/noarch", + "https://conda.anaconda.org/conda-forge/noarch/packaging-24.1-pyhd8ed1ab_0.conda", condaMD5A, condaSHA_A, ""), 0o600) + goWrite(t, filepath.Join(pkgs, "packaging-24.1-pyhd8ed1ab_0.conda"), "CANARY_ARCHIVE", 0o600) + goWrite(t, filepath.Join(pkgs, "zlib-1.3.1-h4e2bd7c_1", "info", "index.json"), `{"name":"zlib","version":"1.3.1","build":"h4e2bd7c_1","build_number":1,"subdir":"osx-arm64"}`, 0o600) + goWrite(t, filepath.Join(pkgs, "broken-1.0-0", "info", "index.json"), `{"name":"broken","version":"1.0","build":"0","subdir":"noarch"}`, 0o600) + goWrite(t, filepath.Join(pkgs, "broken-1.0-0", "info", "repodata_record.json"), `{"name":"broken",`, 0o600) + goWrite(t, filepath.Join(pkgs, "orphan-1.0-0.tar.bz2"), "CANARY_ARCHIVE", 0o600) + goWrite(t, filepath.Join(pkgs, "cache", "repodata.json"), `{"CANARY_INDEX":1}`, 0o600) + goWrite(t, filepath.Join(pkgs, "urls.txt"), "https://user:CANARY_URLS@x.example.invalid/a.conda\n", 0o600) + goMkdir(t, filepath.Join(pkgs, "locks")) + + mm := filepath.Join(home, "micromamba") + goWrite(t, filepath.Join(mm, "pkgs", "https", "conda.anaconda.org", "conda-forge", "noarch", "tzdata-2024b-hc8b5060_0", "info", "index.json"), `{"name":"tzdata","version":"2024b","build":"hc8b5060_0","build_number":0,"subdir":"noarch","noarch":"generic"}`, 0o600) + goWrite(t, filepath.Join(mm, "envs", "mm", "conda-meta", "history"), history, 0o600) + goWrite(t, filepath.Join(mm, "envs", "mm", "conda-meta", "tzdata-2024b-hc8b5060_0.json"), condaTzdata, 0o600) + + goWrite(t, filepath.Join(home, ".conda", "envs", "old", "conda-meta", "history"), history, 0o600) + goWrite(t, filepath.Join(home, ".conda", "pkgs", "urls.txt"), "CANARY_URLS\n", 0o600) + elsewhere := filepath.Join(home, "elsewhere", "env") + goWrite(t, filepath.Join(elsewhere, "conda-meta", "history"), history, 0o600) + goWrite(t, filepath.Join(elsewhere, "conda-meta", "tzdata-2024b-hc8b5060_0.json"), condaTzdata, 0o600) + goWrite(t, filepath.Join(home, ".conda", "environments.txt"), base+"\n"+elsewhere+"\r\n"+filepath.Join(home, "stale-env")+"\nrelative/junk\n\n", 0o600) + + lead := filepath.Join(home, "extra-envs", "lead") + goWrite(t, filepath.Join(lead, "conda-meta", "history"), history, 0o600) + goWrite(t, filepath.Join(lead, "conda-meta", "tzdata-2024b-hc8b5060_0.json"), condaTzdata, 0o600) + goWrite(t, filepath.Join(home, "extra-pkgs", "bzip2-1.0.8-h99b78c6_7", "info", "index.json"), `{"name":"bzip2","version":"1.0.8","build":"h99b78c6_7","build_number":7,"subdir":"osx-arm64"}`, 0o600) + goWrite(t, filepath.Join(home, ".netrc"), "machine x login CANARY_NETRC password CANARY_NETRC\n", 0o600) + + proj := filepath.Join(root, "proj") + goWrite(t, filepath.Join(proj, "environment.yml"), `name: proj +channels: + - nodefaults + - http://mirror.example.invalid/conda + - https://conda.anaconda.org/t/CANARY_TOKEN/private + - conda-forge +dependencies: + - python=3.12 + - conda-forge::numpy>=1.26 + - numpy* + - __glibc>=2.17 + - pip + - requests # [win] + - pip: + - ansi2html==1.9.5 +variables: + SECRET: CANARY_VARIABLE +prefix: /Users/CANARY_HOME/miniforge3/envs/proj +`, 0o600) + goWrite(t, filepath.Join(proj, "environment.json"), `{"name": "proj", "channels": ["conda-forge"], "dependencies": ["python=3.12"], "prefix": "/Users/CANARY_HOME/miniforge3/envs/proj"}`, 0o600) + goWrite(t, filepath.Join(proj, ".conda", "conda-meta", "history"), history, 0o600) + goWrite(t, filepath.Join(proj, ".conda", "conda-meta", "tzdata-2024b-hc8b5060_0.json"), condaTzdata, 0o600) + goWrite(t, filepath.Join(proj, "explicit.txt"), `# This file may be used to create an environment using: +# $ conda create --name --file +# platform: osx-arm64 +@EXPLICIT +https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda#`+condaMD5A+` +https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda#sha256:`+condaSHA_A+` +https://conda.anaconda.org/t/CANARY_TOKEN/private/osx-arm64/zlib-1.2.13-h53f4e23_5.conda +https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2#CANARY_FRAGMENT +file:///opt/channel/osx-arm64/local-1.0-0.conda +pkgs/relative-1.0-0.conda +https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl +`, 0o600) + goWrite(t, filepath.Join(proj, "conda-lock.yml"), `# This lock file was generated by conda-lock. DO NOT EDIT! +metadata: + channels: + - conda-forge + platforms: + - linux-64 + - osx-arm64 +package: +- category: main + dependencies: {} + hash: + md5: `+condaMD5A+` + manager: conda + name: _libgcc_mutex + optional: false + platform: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + version: '0.1' +- category: main + dependencies: {} + hash: + sha256: `+condaSHA_A+` + manager: conda + name: tzdata + optional: false + platform: linux-64 + url: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda + version: 2024b +- category: main + dependencies: {} + hash: + sha256: `+condaSHA_A+` + manager: conda + name: tzdata + optional: false + platform: osx-arm64 + url: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda + version: 2024b +- category: main + dependencies: {} + hash: + sha256: `+condaSHA_A+` + manager: pip + name: requests + optional: false + platform: linux-64 + url: https://files.pythonhosted.org/packages/ab/cd/requests-2.32.3-py3-none-any.whl + version: 2.32.3 +- category: main + dependencies: {} + hash: + md5: `+condaMD5A+` + manager: conda + name: private + optional: false + platform: linux-64 + url: https://user:CANARY_LOCK@conda.anaconda.org/private/linux-64/private-1.0-0.conda + version: '1.0' +version: 1 +`, 0o600) + goWrite(t, filepath.Join(proj, "pixi.lock"), `version: 6 +environments: + default: + channels: + - url: https://prefix.dev/conda-forge/ + packages: + osx-arm64: + - conda: https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-h99b78c6_7.conda + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda + - conda: https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/cd/numpy-2.2.1-cp313-cp313-macosx_11_0_arm64.whl + linux-64: + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda + - conda: ../minimal-project + - conda: https://prefix.dev/conda-forge/linux-64/missing-1.0-0.conda +packages: + - conda: https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-h99b78c6_7.conda + sha256: `+condaSHA_A+` + md5: `+condaMD5A+` + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + purls: [] + size: 122909 + timestamp: 1720974522888 + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda + sha256: `+condaSHA_A+` + md5: `+condaMD5A+` + license: LicenseRef-Public-Domain + size: 122354 + - conda: https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl + build_number: 0 + sha256: `+condaSHA_A+` + - pypi: https://files.pythonhosted.org/packages/ab/cd/numpy-2.2.1-cp313-cp313-macosx_11_0_arm64.whl + name: numpy + version: 2.2.1 + sha256: `+condaSHA_A+` + - conda: ../minimal-project + name: minimal-project + version: "0.1" + subdir: linux-64 + build: hbf21a9e_0 +`, 0o600) + pixiMeta := filepath.Join(proj, ".pixi", "envs", "default", "conda-meta") + goWrite(t, filepath.Join(pixiMeta, "pixi"), `{"manifest_path":"`+filepath.ToSlash(filepath.Join(proj, "pixi.toml"))+`","environment_name":"default","pixi_version":"0.40.0"}`, 0o600) + goWrite(t, filepath.Join(pixiMeta, "bzip2-1.0.8-h99b78c6_7.json"), condaReceipt("bzip2", "1.0.8", "h99b78c6_7", "osx-arm64", "https://prefix.dev/conda-forge/osx-arm64", + "https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-h99b78c6_7.conda", condaMD5A, condaSHA_A, ""), 0o600) + goWrite(t, filepath.Join(proj, "conda-dev.lock"), "# platform: linux-64\n@EXPLICIT\nhttps://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2#"+condaMD5A+"\n", 0o600) + goWrite(t, filepath.Join(proj, "conda-notes.lock"), "notes about CANARY_NOTES\n", 0o600) + goWrite(t, filepath.Join(proj, "requirements.txt"), "https://user:CANARY_PIP@pypi.example.invalid/simple\n", 0o600) + goWrite(t, filepath.Join(root, "legacy", "environment.yaml"), "channels:\n - conda-forge\ndependencies:\n - python\n - sel(win): m2-base\n", 0o600) + goWrite(t, filepath.Join(root, "lockonly", "conda-lock.yaml"), "version: 2\npackage: []\n", 0o600) + goWrite(t, filepath.Join(root, "node_modules", "dep", "environment.yml"), "dependencies:\n - CANARY_SKIPPED\n", 0o600) +} + +func condaFixtureEnv(x *condaTrap, home string) { + x.env["HTTPS_PROXY"] = "https://user:CANARY_PASSWORD@proxy.example.invalid:8443/?CANARY_QUERY" + x.env["CONDA_ENVS_DIRS"] = filepath.Join(home, "extra-envs") + x.env["CONDA_PKGS_DIRS"] = filepath.Join(home, "extra-pkgs") + x.env["CONDA_PREFIX"] = filepath.Join(home, "miniforge3", "envs", "app") + x.env["CONDA_CHANNEL_ALIAS"] = "https://conda.example.invalid/t/CANARY_ALIAS" +} + +func condaAssertRefs(t *testing.T, inv *model.CondaInventory, audit *model.CondaConfigAudit) { + t.Helper() + ids := map[string]bool{} + for _, s := range inv.Sources { + if ids[s.SourceID] { + t.Error("duplicate source") + } + ids[s.SourceID] = true + if s.Status == "complete" && s.Presence == "unknown" { + t.Errorf("complete source with unknown presence: %+v", s) + } + } + ref := func(id string) { + if !ids[id] { + t.Errorf("dangling inventory source %s", id) + } + } + for _, s := range inv.Sources { + if s.ParentSourceID != "" { + ref(s.ParentSourceID) + } + for _, id := range s.DiscoveredSources { + ref(id) + } + } + for _, p := range inv.Packages { + ref(p.SourceID) + for _, hash := range p.RecordedChecksums { + ref(hash.SourceID) + } + if _, ok := condaName(p.PackageName); !ok || condaVirtual(p.PackageName) { + t.Errorf("invalid package name %q", p.PackageName) + } + } + for _, p := range inv.Projects { + ref(p.ManifestSourceID) + } + files := map[string]bool{} + for _, f := range audit.Files { + files[f.SourceID] = true + } + for _, c := range audit.Contexts { + for _, id := range c.ConfigSourceIDs { + if !files[id] { + t.Error("dangling config context") + } + } + } + for _, f := range audit.Findings { + if !files[f.SourceID] { + t.Error("dangling finding") + } + } + for _, v := range []any{inv, audit} { + data, _ := json.Marshal(v) + if bytes.Contains(data, []byte(":null")) { + t.Fatal("null array") + } + if bytes.Contains(data, []byte("CANARY")) { + t.Fatalf("output contains a canary: %s", data) + } + } +} + +func condaSource(t *testing.T, inv *model.CondaInventory, kind, path string) model.CondaSource { + t.Helper() + for _, src := range inv.Sources { + if src.Kind == kind && src.Path == path { + return src + } + } + t.Fatalf("no %s source for %s", kind, path) + return model.CondaSource{} +} + +func condaPackages(inv *model.CondaInventory, evidence, name string) []model.CondaPackage { + var out []model.CondaPackage + for _, p := range inv.Packages { + if p.Evidence == evidence && (name == "" || p.PackageName == name) { + out = append(out, p) + } + } + return out +} + +func condaProject(t *testing.T, inv *model.CondaInventory, manifest string) model.CondaProject { + t.Helper() + for _, p := range inv.Projects { + if p.ManifestPath == manifest { + return p + } + } + t.Fatalf("no project for %s", manifest) + return model.CondaProject{} +} + +func condaOwned(inv *model.CondaInventory) map[string]int { + owned := map[string]int{} + for _, p := range inv.Packages { + owned[p.SourceID]++ + } + return owned +} + +// condaAssertComplete checks the retirement contract: a source a bounded scan +// leaves complete must still hold everything it holds unbounded. +func condaAssertComplete(t *testing.T, budget int, owned map[string]int, inv *model.CondaInventory) { + t.Helper() + got := condaOwned(inv) + for _, src := range inv.Sources { + if src.Status == "complete" && got[src.SourceID] != owned[src.SourceID] { + t.Fatalf("budget %d: complete %s %s holds %d of %d packages", budget, src.Kind, src.Path, got[src.SourceID], owned[src.SourceID]) + } + } +} + +func TestCondaScanFixtureEvidence(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + condaWriteFixture(t, home, root) + scanner, x := condaTestScanner(t, home) + condaFixtureEnv(x, home) + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + base := filepath.Join(home, "miniforge3") + app := filepath.Join(base, "envs", "app") + proj := filepath.Join(root, "proj") + + type want struct{ evidence, name, source, project, scope string } + wants := []want{ + {"installed_package", "conda-launchers", filepath.Join(base, "conda-meta", "conda-launchers-1.0.0-h57928b3_0.json"), "", "user"}, + {"installed_package", "_openmp_mutex", filepath.Join(base, "conda-meta", "_openmp_mutex-4.5-2_gnu.json"), "", "user"}, + {"installed_package", "packaging", filepath.Join(app, "conda-meta", "packaging-24.1-pyhd8ed1ab_0.json"), "", "user"}, + {"installed_package", "zlib", filepath.Join(app, "conda-meta", "zlib-1.3.1-h4e2bd7c_1.json"), "", "user"}, + {"installed_package", "ansi2html", filepath.Join(app, "conda-meta", "ansi2html-1.9.5-py3_none_any_0.json"), "", "user"}, + {"installed_package", "secret", filepath.Join(app, "conda-meta", "secret-1.0-0.json"), "", "user"}, + {"installed_package", "badsum", filepath.Join(app, "conda-meta", "badsum-1.0-0.json"), "", "user"}, + {"installed_package", "tzdata", filepath.Join(base, "envs", "nohistory", "conda-meta", "tzdata-2024b-hc8b5060_0.json"), "", "user"}, + {"installed_package", "tzdata", filepath.Join(home, "micromamba", "envs", "mm", "conda-meta", "tzdata-2024b-hc8b5060_0.json"), "", "user"}, + {"installed_package", "tzdata", filepath.Join(home, "elsewhere", "env", "conda-meta", "tzdata-2024b-hc8b5060_0.json"), "", "user"}, + {"installed_package", "tzdata", filepath.Join(home, "extra-envs", "lead", "conda-meta", "tzdata-2024b-hc8b5060_0.json"), "", "user"}, + {"installed_package", "tzdata", filepath.Join(proj, ".conda", "conda-meta", "tzdata-2024b-hc8b5060_0.json"), proj, "project"}, + {"installed_package", "bzip2", filepath.Join(proj, ".pixi", "envs", "default", "conda-meta", "bzip2-1.0.8-h99b78c6_7.json"), proj, "project"}, + {"cached_package", "packaging", filepath.Join(base, "pkgs", "packaging-24.1-pyhd8ed1ab_0", "info"), "", "user"}, + {"cached_package", "zlib", filepath.Join(base, "pkgs", "zlib-1.3.1-h4e2bd7c_1", "info"), "", "user"}, + {"cached_package", "broken", filepath.Join(base, "pkgs", "broken-1.0-0", "info"), "", "user"}, + {"cached_package", "tzdata", filepath.Join(home, "micromamba", "pkgs", "https", "conda.anaconda.org", "conda-forge", "noarch", "tzdata-2024b-hc8b5060_0", "info"), "", "user"}, + {"cached_package", "bzip2", filepath.Join(home, "extra-pkgs", "bzip2-1.0.8-h99b78c6_7", "info"), "", "user"}, + {"declared_requirement", "python", filepath.Join(proj, "environment.yml"), proj, "project"}, + {"declared_requirement", "numpy", filepath.Join(proj, "environment.yml"), proj, "project"}, + {"declared_requirement", "pip", filepath.Join(proj, "environment.yml"), proj, "project"}, + {"declared_requirement", "python", filepath.Join(proj, "environment.json"), proj, "project"}, + {"declared_requirement", "python", filepath.Join(root, "legacy", "environment.yaml"), filepath.Join(root, "legacy"), "project"}, + {"locked_package", "bzip2", filepath.Join(proj, "explicit.txt"), proj, "project"}, + {"locked_package", "tzdata", filepath.Join(proj, "explicit.txt"), proj, "project"}, + {"locked_package", "zlib", filepath.Join(proj, "explicit.txt"), proj, "project"}, + {"locked_package", "libffi", filepath.Join(proj, "explicit.txt"), proj, "project"}, + {"locked_package", "local", filepath.Join(proj, "explicit.txt"), proj, "project"}, + {"locked_package", "relative", filepath.Join(proj, "explicit.txt"), proj, "project"}, + {"locked_package", "_libgcc_mutex", filepath.Join(proj, "conda-lock.yml"), proj, "project"}, + {"locked_package", "tzdata", filepath.Join(proj, "conda-lock.yml"), proj, "project"}, + {"locked_package", "tzdata", filepath.Join(proj, "conda-lock.yml"), proj, "project"}, + {"locked_package", "private", filepath.Join(proj, "conda-lock.yml"), proj, "project"}, + {"locked_package", "bzip2", filepath.Join(proj, "pixi.lock"), proj, "project"}, + {"locked_package", "tzdata", filepath.Join(proj, "pixi.lock"), proj, "project"}, + {"locked_package", "tzdata", filepath.Join(proj, "pixi.lock"), proj, "project"}, + {"locked_package", "minimal-project", filepath.Join(proj, "pixi.lock"), proj, "project"}, + {"locked_package", "_libgcc_mutex", filepath.Join(proj, "conda-dev.lock"), proj, "project"}, + } + got := []want{} + for _, p := range inv.Packages { + got = append(got, want{p.Evidence, p.PackageName, p.SourcePath, p.ProjectPath, p.Scope}) + } + key := func(w want) string { + return strings.Join([]string{w.evidence, w.name, w.source, w.project, w.scope}, "\x00") + } + slices.SortFunc(wants, func(a, b want) int { return strings.Compare(key(a), key(b)) }) + slices.SortFunc(got, func(a, b want) int { return strings.Compare(key(a), key(b)) }) + if !slices.Equal(got, wants) { + t.Errorf("package rows:\n got %+v\nwant %+v", got, wants) + } + + installed := func(name, source string) model.CondaPackage { + for _, p := range condaPackages(inv, "installed_package", name) { + if strings.HasPrefix(p.SourcePath, source) { + return p + } + } + t.Fatalf("no installed %s under %s", name, source) + return model.CondaPackage{} + } + if p := installed("packaging", app); p.Version != "24.1" || p.Build != "pyhd8ed1ab_0" || p.Subdir != "noarch" || p.BuildNumber == nil || *p.BuildNumber != 0 || p.VersionStatus != "known" || + p.DependencyRelation != "direct" || !slices.Equal(p.RequestedSpecs, []string{"packaging"}) || p.Origin != (model.CondaOrigin{Kind: "channel", URL: "https://conda.anaconda.org/conda-forge"}) || + p.ArtifactURL != "https://conda.anaconda.org/conda-forge/noarch/packaging-24.1-pyhd8ed1ab_0.conda" || p.ChecksumStatus != "recorded" || len(p.RecordedChecksums) != 2 || + p.EnvironmentPath != app || p.Installation == nil || p.Installation.Path != app || p.Installation.Presence != "present" || len(p.Reasons) != 0 { + t.Errorf("installed packaging %+v", p) + } + if p := installed("zlib", app); p.Origin != (model.CondaOrigin{Kind: "channel", URL: "https://conda.anaconda.org/conda-forge"}) || p.DependencyRelation != "unknown" || len(p.RequestedSpecs) != 0 { + t.Errorf("mamba bare label %+v", p) + } + if p := installed("ansi2html", app); p.Origin.Kind != "unknown" || p.ArtifactURL != "https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl" || !slices.Equal(p.RequestedSpecs, []string{"conda-pypi::ansi2html"}) { + t.Errorf("wheel receipt %+v", p) + } + if p := installed("secret", app); p.Origin != (model.CondaOrigin{Kind: "channel", URL: "https://private.example.invalid/ch"}) || p.ArtifactURL != "https://private.example.invalid/ch/osx-arm64/secret-1.0-0.conda" || + !slices.Equal(p.RequestedSpecs, []string{"https://private.example.invalid/ch::secret", condaRedacted}) { + t.Errorf("credential receipt %+v", p) + } + if p := installed("badsum", app); p.ChecksumStatus != "partial" || !slices.Equal(p.Reasons, []string{"checksum_invalid"}) || p.Origin.Kind != "unknown" { + t.Errorf("bad checksum %+v", p) + } + if p := installed("_openmp_mutex", base); p.BuildNumber == nil || *p.BuildNumber != 2 || p.DependencyRelation != "direct" { + t.Errorf("openmp %+v", p) + } + if p := installed("conda-launchers", base); p.ChecksumStatus != "recorded" || len(p.RecordedChecksums) != 1 || p.Subdir != "win-64" { + t.Errorf("launchers %+v", p) + } + for _, p := range condaPackages(inv, "cached_package", "") { + if p.EnvironmentPath != "" || p.Installation != nil || p.DependencyRelation != "unknown" || len(p.RequestedSpecs) != 0 { + t.Errorf("cached row %+v", p) + } + switch p.PackageName { + case "packaging": + if p.Origin.Kind != "channel" || p.ChecksumStatus != "recorded" { + t.Errorf("cached download record %+v", p) + } + default: + if p.Origin.Kind != "unknown" || p.ChecksumStatus != "absent" || p.ArtifactURL != "" { + t.Errorf("cached index fallback %+v", p) + } + } + } + for _, p := range condaPackages(inv, "declared_requirement", "") { + if p.VersionStatus != "unknown" || p.DependencyRelation != "direct" || len(p.RequestedSpecs) != 1 || p.ChecksumStatus != "absent" || p.EnvironmentPath != "" { + t.Errorf("declared row %+v", p) + } + } + if p := condaPackages(inv, "declared_requirement", "numpy"); p[0].RequestedSpecs[0] != "conda-forge::numpy>=1.26" || p[0].Origin.Kind != "unknown" { + t.Errorf("qualified declaration %+v", p[0]) + } + locked := func(name, lock, platform string) model.CondaPackage { + for _, p := range condaPackages(inv, "locked_package", name) { + if p.SourcePath == filepath.Join(proj, lock) && (platform == "" || p.TargetPlatform == platform) { + return p + } + } + t.Fatalf("no locked %s in %s", name, lock) + return model.CondaPackage{} + } + if p := locked("zlib", "explicit.txt", ""); p.ArtifactURL != "https://conda.anaconda.org/private/osx-arm64/zlib-1.2.13-h53f4e23_5.conda" || p.Origin.URL != "https://conda.anaconda.org/private" || p.TargetPlatform != "osx-arm64" { + t.Errorf("token lock row %+v", p) + } + if p := locked("libffi", "explicit.txt", ""); p.ChecksumStatus != "partial" || !slices.Equal(p.Reasons, []string{"checksum_invalid"}) { + t.Errorf("bad fragment row %+v", p) + } + if p := locked("local", "explicit.txt", ""); p.Origin != (model.CondaOrigin{Kind: "local", Path: "/opt/channel"}) { + t.Errorf("local channel row %+v", p) + } + if p := locked("relative", "explicit.txt", ""); !slices.Equal(p.Reasons, []string{"path_unresolved"}) || p.Origin.Kind != "unknown" { + t.Errorf("relative row %+v", p) + } + if p := locked("private", "conda-lock.yml", ""); p.ArtifactURL != "https://conda.anaconda.org/private/linux-64/private-1.0-0.conda" || p.Origin.URL != "https://conda.anaconda.org/private" { + t.Errorf("credential lock row %+v", p) + } + if p := locked("bzip2", "pixi.lock", "osx-arm64"); p.LockEnvironment != "default" || len(p.RecordedChecksums) != 2 || p.Build != "h99b78c6_7" { + t.Errorf("pixi row %+v", p) + } + if p := locked("minimal-project", "pixi.lock", "linux-64"); p.VersionStatus != "known" || p.Version != "0.1" || !slices.Equal(p.Reasons, []string{"path_unresolved"}) { + t.Errorf("pixi source row %+v", p) + } + + type srcWant struct { + kind, path, status, presence string + reasons []string + } + for _, w := range []srcWant{ + {"project_search_root", root, "partial", "present", []string{"checksum_invalid", "parse_error", "path_unresolved", "unsupported_format"}}, + {"environment_root", base, "partial", "present", []string{"checksum_invalid", "extraction_incomplete", "identity_mismatch", "parse_error", "unsupported_format"}}, + {"environment", base, "partial", "present", []string{"identity_mismatch", "parse_error"}}, + {"installed_metadata", filepath.Join(base, "conda-meta", "broken-1.0-0.json"), "partial", "present", []string{"parse_error"}}, + {"installed_metadata", filepath.Join(base, "conda-meta", "renamed-1.0-0.json"), "partial", "present", []string{"identity_mismatch"}}, + {"installed_metadata", filepath.Join(base, "conda-meta", "conda-launchers-1.0.0-h57928b3_0.json"), "complete", "present", []string{}}, + {"environment", app, "partial", "present", []string{"checksum_invalid"}}, + {"installed_metadata", filepath.Join(app, "conda-meta", "badsum-1.0-0.json"), "partial", "present", []string{"checksum_invalid"}}, + {"installed_metadata", filepath.Join(app, "conda-meta", "secret-1.0-0.json"), "complete", "present", []string{}}, + {"environment", filepath.Join(base, "envs", "empty"), "complete", "present", []string{}}, + {"environment", filepath.Join(base, "envs", "nohistory"), "partial", "present", []string{"extraction_incomplete"}}, + {"cache_root", filepath.Join(base, "pkgs"), "partial", "present", []string{"parse_error", "unsupported_format"}}, + {"cached_metadata", filepath.Join(base, "pkgs", "broken-1.0-0", "info"), "partial", "present", []string{"parse_error"}}, + {"cached_metadata", filepath.Join(base, "pkgs", "zlib-1.3.1-h4e2bd7c_1", "info"), "complete", "present", []string{}}, + {"environment_root", filepath.Join(home, "micromamba"), "complete", "present", []string{}}, + {"cache_root", filepath.Join(home, "micromamba", "pkgs"), "complete", "present", []string{}}, + {"environment_root", filepath.Join(home, ".conda"), "complete", "present", []string{}}, + {"environment", filepath.Join(home, ".conda", "envs", "old"), "complete", "present", []string{}}, + {"environment", filepath.Join(home, "elsewhere", "env"), "complete", "present", []string{}}, + {"environment", filepath.Join(home, "extra-envs", "lead"), "complete", "present", []string{}}, + {"cache_root", filepath.Join(home, "extra-pkgs"), "complete", "present", []string{}}, + {"environment", filepath.Join(proj, ".conda"), "complete", "present", []string{}}, + {"environment", filepath.Join(proj, ".pixi", "envs", "default"), "complete", "present", []string{}}, + {"manifest", filepath.Join(proj, "environment.yml"), "partial", "present", []string{"unsupported_format"}}, + {"manifest", filepath.Join(proj, "environment.json"), "complete", "present", []string{}}, + {"manifest", filepath.Join(root, "legacy", "environment.yaml"), "partial", "present", []string{"unsupported_format"}}, + {"lockfile", filepath.Join(proj, "explicit.txt"), "partial", "present", []string{"checksum_invalid", "path_unresolved", "unsupported_format"}}, + {"lockfile", filepath.Join(proj, "conda-lock.yml"), "complete", "present", []string{}}, + {"lockfile", filepath.Join(proj, "pixi.lock"), "partial", "present", []string{"parse_error", "path_unresolved", "unsupported_format"}}, + {"lockfile", filepath.Join(proj, "conda-dev.lock"), "complete", "present", []string{}}, + {"lockfile", filepath.Join(root, "lockonly", "conda-lock.yaml"), "partial", "present", []string{"unsupported_format"}}, + } { + src := condaSource(t, inv, w.kind, w.path) + if src.Status != w.status || src.Presence != w.presence || !slices.Equal(src.Reasons, w.reasons) { + t.Errorf("%s %s = %s/%s %v, want %s/%s %v", w.kind, w.path, src.Status, src.Presence, src.Reasons, w.status, w.presence, w.reasons) + } + } + for _, src := range inv.Sources { + switch { + case strings.Contains(src.Path, "conda-notes.lock"), strings.Contains(src.Path, "node_modules"), strings.Contains(src.Path, "stale-env"), strings.Contains(src.Path, "._tmp"): + t.Errorf("unexpected source %+v", src) + } + } + if inv.Status != "partial" || !slices.Equal(inv.Reasons, []string{"checksum_invalid", "extraction_incomplete", "identity_mismatch", "parse_error", "path_unresolved", "unsupported_format"}) { + t.Errorf("inventory %s %v", inv.Status, inv.Reasons) + } + if p := condaProject(t, inv, filepath.Join(proj, "environment.yml")); p.SelectionStatus != "partial" || p.ProjectPath != proj || + !slices.Equal(p.LockfilePaths, []string{filepath.Join(proj, "conda-dev.lock"), filepath.Join(proj, "conda-lock.yml"), filepath.Join(proj, "explicit.txt"), filepath.Join(proj, "pixi.lock")}) { + t.Errorf("project %+v", p) + } + if p := condaProject(t, inv, filepath.Join(proj, "environment.json")); p.SelectionStatus != "observed" || len(p.LockfilePaths) != 4 { + t.Errorf("json project %+v", p) + } + if len(inv.Projects) != 3 { + t.Errorf("projects %+v", inv.Projects) + } + // Caches, prefixes and environment roots are never walked for projects. + if x.reads[filepath.Join(base, "pkgs", "urls.txt")] != 0 || x.reads[filepath.Join(proj, "requirements.txt")] != 0 || x.reads[filepath.Join(proj, "conda-notes.lock")] != 1 { + t.Errorf("reads %v", x.reads) + } + + // Configuration: the root file, saved state and manifest channels are + // observed; findings carry no credential text. + findings := map[string]bool{} + for _, f := range audit.Findings { + for _, file := range audit.Files { + if file.SourceID == f.SourceID { + findings[f.Code+" "+file.Scope+" "+filepath.Base(file.Path)+" "+f.Key] = true + } + } + } + for _, want := range []string{ + "conda-002 system .condarc ssl_verify", "conda-003 system .condarc auth_configured", + "conda-002 environment state ssl_verify", "conda-003 environment state auth_configured", + "conda-001 project environment.yml channels.1.url", "conda-003 project environment.yml auth_configured", + } { + if !findings[want] { + t.Errorf("missing finding %s in %v", want, findings) + } + } + // Two manifests share one project path; a partial one marks the project. + contexts := map[string]string{} + for _, c := range audit.Contexts { + if key := c.EnvironmentPath + "|" + c.ProjectPath; contexts[key] != "partial" { + contexts[key] = c.SelectionStatus + } + } + for _, key := range []string{"|", app + "|", base + "|", filepath.Join(proj, ".conda") + "|", "|" + proj, "|" + filepath.Join(root, "legacy")} { + if contexts[key] == "" { + t.Errorf("missing context %q in %v", key, contexts) + } + } + if contexts[filepath.Join(base, "envs", "empty")+"|"] != "observed" || contexts["|"+proj] != "partial" || contexts["|"] != "observed" { + t.Errorf("context selection %v", contexts) + } + if audit.Status != "partial" || !slices.Contains(audit.Reasons, "unsupported_format") { + t.Errorf("audit %s %v", audit.Status, audit.Reasons) + } + present := 0 + for _, c := range audit.CredentialFiles { + if c.Presence == "present" { + present++ + } + } + if present != 1 { + t.Errorf("credential files %+v", audit.CredentialFiles) + } +} + +func TestCondaScanUnknownProcessAndDeveloper(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + condaWriteFixture(t, home, root) + scanner, x := condaTestScanner(t, home) + x.current.Uid = "1001" + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + for _, f := range audit.Files { + if f.Scope == "process" && (f.Status != "unsupported" || !slices.Contains(f.Reasons, "user_unresolved")) { + t.Errorf("process file = %+v", f) + } + } + for _, c := range audit.Contexts { + if c.SelectionStatus != "partial" { + t.Errorf("unverified process left context complete: %+v", c) + } + } + // Default roots and the registry do not depend on the process environment. + condaSource(t, inv, "environment_root", filepath.Join(home, "miniforge3")) + condaSource(t, inv, "environment", filepath.Join(home, "elsewhere", "env")) + for _, src := range inv.Sources { + if strings.HasPrefix(src.Path, filepath.Join(home, "extra-")) { + t.Errorf("process lead followed without a verified process: %+v", src) + } + } + + scanner, x = condaTestScanner(t, home) + target := goTestTarget(home) + target.Uid = "0" + if x.GOOS() == model.PlatformWindows { + target.Uid = "S-1-5-18" + } + x.current = target + inv, audit = scanner.Scan(context.Background(), target, []string{root}, nil) + if inv.Status != "partial" || !slices.Equal(inv.Reasons, []string{"user_unresolved"}) || len(inv.Sources) != 0 || audit.Status != "partial" { + t.Fatalf("service identity scanned: %+v", inv) + } +} + +func TestCondaScanLimitsDeadlineAndGuards(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + base := filepath.Join(home, "miniforge3") + app := filepath.Join(base, "envs", "app") + proj := filepath.Join(root, "proj") + condaWriteFixture(t, home, root) + scanner, x := condaTestScanner(t, home) + condaFixtureEnv(x, home) + full, _ := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + total := len(full.Sources) + len(full.Projects) + len(full.Packages) + owned := condaOwned(full) + old := maxCondaRecords + t.Cleanup(func() { maxCondaRecords = old }) + for budget := 1; budget < total; budget++ { + maxCondaRecords = budget + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + if inv.Status != "partial" || !slices.Contains(inv.Reasons, "record_limit") || len(inv.Sources)+len(inv.Projects)+len(inv.Packages) > budget { + t.Fatalf("budget %d: status %s reasons %v", budget, inv.Status, inv.Reasons) + } + condaAssertComplete(t, budget, owned, inv) + condaAssertRefs(t, inv, audit) + } + maxCondaRecords = old + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + *x.calls = 0 + inv, audit := scanner.Scan(ctx, goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + if inv.Status != "partial" || !slices.Contains(inv.Reasons, "deadline_exceeded") || len(inv.Packages) != 0 || *x.calls != 0 { + t.Fatalf("cancelled scan = %s %v %d packages after %d filesystem calls", inv.Status, inv.Reasons, len(inv.Packages), *x.calls) + } + // Cancellation mid-environment keeps admitted receipts, leaves the + // environment partial and stops every later read. + ctx, cancel = context.WithCancel(context.Background()) + after := -1 + x.reads, x.afterRead = map[string]int{}, func(path string) { + if path == filepath.Join(app, "conda-meta", "packaging-24.1-pyhd8ed1ab_0.json") { + cancel() + after = *x.calls + } + } + inv, audit = scanner.Scan(ctx, goTestTarget(home), []string{root}, nil) + x.afterRead = nil + condaAssertRefs(t, inv, audit) + if after < 0 || *x.calls != after || !slices.Contains(inv.Reasons, "deadline_exceeded") || x.reads[filepath.Join(proj, "environment.yml")] != 0 { + t.Fatalf("cancelled scan kept reading: reasons %v, %d then %d calls", inv.Reasons, after, *x.calls) + } + if src := condaSource(t, inv, "environment", app); src.Status != "partial" || !slices.Contains(src.Reasons, "deadline_exceeded") { + t.Errorf("environment left complete after cancellation: %+v", src) + } + if len(condaPackages(inv, "installed_package", "packaging")) != 1 { + t.Error("admitted receipt dropped after cancellation") + } + + // Cancellation during a project walk or a cache listing stops the lock + // header probe and the next cache entry too. + for _, trigger := range []string{filepath.Join(proj, "environment.yml"), filepath.Join(base, "pkgs", "packaging-24.1-pyhd8ed1ab_0", "info", "repodata_record.json")} { + ctx, cancel = context.WithCancel(context.Background()) + after = -1 + x.afterRead = func(path string) { + if path == trigger { + cancel() + after = *x.calls + } + } + inv, audit = scanner.Scan(ctx, goTestTarget(home), []string{root}, nil) + x.afterRead = nil + condaAssertRefs(t, inv, audit) + if after < 0 || *x.calls != after { + t.Errorf("cancellation after %s kept reading: %d then %d calls", trigger, after, *x.calls) + } + } + + // An unreadable platform lock is reported, never read as removed. + platformLock := filepath.Join(proj, "conda-linux-64.lock") + goWrite(t, platformLock, "@EXPLICIT\nhttps://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda\n", 0o600) + x.readErrors[platformLock] = os.ErrPermission + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + if src := condaSource(t, inv, "lockfile", platformLock); src.Presence != "unknown" || !slices.Contains(src.Reasons, "permission_denied") { + t.Errorf("unreadable platform lock = %+v", src) + } + if src := condaSource(t, inv, "project_search_root", root); !slices.Contains(src.Reasons, "permission_denied") { + t.Errorf("search root with unreadable lock = %+v", src) + } + delete(x.readErrors, platformLock) + if err := os.Remove(platformLock); err != nil { + t.Fatal(err) + } + + // Permission denied is never absence. + denied := filepath.Join(app, "conda-meta", "zlib-1.3.1-h4e2bd7c_1.json") + x.readErrors[denied] = os.ErrPermission + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + if src := condaSource(t, inv, "installed_metadata", denied); src.Presence != "unknown" || !slices.Contains(src.Reasons, "permission_denied") { + t.Errorf("unreadable receipt = %+v", src) + } + if src := condaSource(t, inv, "environment", app); src.Status != "partial" || !slices.Contains(src.Reasons, "permission_denied") { + t.Errorf("environment with unreadable receipt = %+v", src) + } + delete(x.readErrors, denied) + x.readErrors[filepath.Join(home, ".conda", "environments.txt")] = os.ErrPermission + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + if !slices.Contains(inv.Reasons, "permission_denied") { + t.Errorf("unreadable registry = %v", inv.Reasons) + } + condaSource(t, inv, "environment_root", base) + delete(x.readErrors, filepath.Join(home, ".conda", "environments.txt")) + + scanner.protection = func(string, *bool) (func(string) string, func(string) bool) { + return func(p string) string { + if strings.HasPrefix(p, filepath.Join(root, "legacy")) { + return "skipped_protected" + } + return "" + }, func(string) bool { return false } + } + x.reads = map[string]int{} + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + if src := condaSource(t, inv, "project_search_root", root); !slices.Contains(src.Reasons, "skipped_protected") || x.reads[filepath.Join(root, "legacy", "environment.yaml")] != 0 { + t.Errorf("protected directory not reported: %+v", src) + } + + // A blocked installation root is reported, never assumed absent, and its + // configuration contexts stay partial so earlier history is kept. + scanner.protection = func(string, *bool) (func(string) string, func(string) bool) { + return func(p string) string { + if p == base || strings.HasPrefix(p, base+string(filepath.Separator)) { + return "skipped_protected" + } + return "" + }, func(string) bool { return false } + } + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + if src := condaSource(t, inv, "environment_root", base); src.Presence != "unknown" || !slices.Contains(src.Reasons, "skipped_protected") { + t.Errorf("blocked root = %+v", src) + } + if len(condaPackages(inv, "installed_package", "packaging")) != 0 { + t.Error("blocked root still yielded receipts") + } + for _, c := range audit.Contexts { + if c.SelectionStatus != "partial" { + t.Errorf("blocked root left context %+v", c) + } + } + scanner, x = condaTestScanner(t, home) + condaFixtureEnv(x, home) + + t.Run("truncated environments listing", func(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "projects") + envs := filepath.Join(home, "miniconda3", "envs") + goWrite(t, filepath.Join(home, "miniconda3", "conda-meta", "history"), "", 0o600) + for i := range maxCondaListEntries + 1 { + goWrite(t, filepath.Join(envs, strconv.Itoa(i)), "", 0o600) + } + scanner, _ := condaTestScanner(t, home) + inv, audit := scanner.Scan(t.Context(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + if src := condaSource(t, inv, "environment_root", filepath.Join(home, "miniconda3")); !slices.Contains(src.Reasons, "entry_limit") || !slices.Contains(inv.Reasons, "entry_limit") { + t.Fatalf("truncated listing: %+v %v", src, inv.Reasons) + } + }) + + // Every output budget keeps admitted records and leaves only fully + // collected sources complete. + oldOut := maxCondaOutputBytes + t.Cleanup(func() { maxCondaOutputBytes = oldOut }) + empty := emptyCondaInventory() + basis, step := goJSONSize(&empty)+(256<<10), goJSONSize(full)/40 + for limit := basis; ; limit += step { + if limit > basis+4*goJSONSize(full) { + t.Fatal("output budget never admitted the full inventory") + } + maxCondaOutputBytes = limit + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + if !slices.Contains(inv.Reasons, "output_size_limit") { + break + } + if inv.Status != "partial" || goJSONSize(inv)+goJSONSize(audit) > limit { + t.Fatalf("limit %d: status %s size %d", limit, inv.Status, goJSONSize(inv)+goJSONSize(audit)) + } + condaAssertComplete(t, limit, owned, inv) + condaAssertRefs(t, inv, audit) + } +} + +func TestCondaScanRootsAndLeads(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + base := filepath.Join(home, "miniforge3") + condaWriteFixture(t, home, root) + scanner, x := condaTestScanner(t, home) + condaFixtureEnv(x, home) + // A registered prefix inside a search root is owned by that root, so a + // later scan of the root can prove its removal. + reg := filepath.Join(root, "regapp", ".conda") + goWrite(t, filepath.Join(reg, "conda-meta", "history"), "", 0o600) + goWrite(t, filepath.Join(reg, "conda-meta", "tzdata-2024b-hc8b5060_0.json"), condaTzdata, 0o600) + registry := filepath.Join(home, ".conda", "environments.txt") + registered, _ := os.ReadFile(registry) + goWrite(t, registry, string(registered)+reg+"\n", 0o600) + first, firstAudit := scanner.Scan(context.Background(), goTestTarget(home), []string{root, root, filepath.Join(root, "proj")}, nil) + second, secondAudit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + a, _ := json.Marshal([]any{first, firstAudit}) + b, _ := json.Marshal([]any{second, secondAudit}) + if !bytes.Equal(a, b) { + t.Fatal("nested or repeated roots changed the inventory") + } + if owner, src := condaSource(t, second, "project_search_root", root), condaSource(t, second, "environment", reg); src.ParentSourceID != owner.SourceID || !slices.Contains(owner.DiscoveredSources, src.SourceID) { + t.Errorf("registered prefix not owned by its search root: %+v", src) + } + // A cache reached as a lead and under its root is one source; a prefix + // reached as a lead, from the registry and under its root is one source. + x.env["CONDA_PKGS_DIRS"] = filepath.Join(base, "pkgs") + x.env["CONDA_PREFIX"] = base + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + if n := len(condaPackages(inv, "cached_package", "packaging")); n != 1 { + t.Errorf("cache reached twice emitted %d facts", n) + } + if n := len(condaPackages(inv, "installed_package", "_openmp_mutex")); n != 1 { + t.Errorf("root prefix reached three ways emitted %d facts", n) + } + roots := 0 + for _, src := range inv.Sources { + if src.Kind == "environment_root" && src.Path == base { + roots++ + } + } + if roots != 1 { + t.Errorf("environment root recorded %d times", roots) + } + // A search root inside an installation root is still the prefix, never a + // project walk through its receipts and cache. + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root, base}, nil) + condaAssertRefs(t, inv, audit) + if src, prefix := condaSource(t, inv, "project_search_root", base), condaSource(t, inv, "environment_root", base); !slices.Equal(src.DiscoveredSources, []string{prefix.SourceID}) || !slices.Equal(src.Reasons, prefix.Reasons) { + t.Errorf("search root at a prefix = %+v", src) + } + if x.reads[filepath.Join(base, "pkgs", "urls.txt")] != 0 { + t.Error("search root walked into the package cache") + } + // A standard root and a lead cache under a search root are owned by it, + // so removing them leaves a surviving ancestor that proves the removal. + condaFixtureEnv(x, home) + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{home}, nil) + condaAssertRefs(t, inv, audit) + owner := condaSource(t, inv, "project_search_root", home) + for _, src := range []model.CondaSource{condaSource(t, inv, "environment_root", base), condaSource(t, inv, "cache_root", filepath.Join(home, "extra-pkgs"))} { + if src.ParentSourceID != owner.SourceID || !slices.Contains(owner.DiscoveredSources, src.SourceID) { + t.Errorf("%s not owned by the search root: %+v", src.Kind, src) + } + } + if err := os.RemoveAll(base); err != nil { + t.Fatal(err) + } + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{home}, nil) + condaAssertRefs(t, inv, audit) + if src := condaSource(t, inv, "project_search_root", home); src.Presence != "present" { + t.Errorf("search root after removal = %+v", src) + } + for _, src := range inv.Sources { + if src.Path == base || goPathWithin(src.Path, base) { + t.Errorf("removed installation still observed: %+v", src) + } + } + if n := len(condaPackages(inv, "installed_package", "_openmp_mutex")); n != 0 { + t.Errorf("removed installation still has %d facts", n) + } + condaWriteFixture(t, home, root) + condaFixtureEnv(x, home) + + // Two spellings of one prefix emit its facts once. + link := filepath.Join(home, "app-link") + if err := os.Symlink(filepath.Join(base, "envs", "app"), link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + x.env["CONDA_PREFIX"] = link + delete(x.env, "CONDA_PKGS_DIRS") + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + if n := len(condaPackages(inv, "installed_package", "packaging")); n != 1 { + t.Fatalf("aliased prefix duplicated facts: %d", n) + } + // A relocated root reached through a symlink keeps its facts under the + // spelling it was found by. + physical := filepath.Join(home, "forge-keg") + if err := os.Rename(base, physical); err != nil { + t.Fatal(err) + } + if err := os.Symlink(physical, base); err != nil { + t.Fatal(err) + } + inv, audit = scanner.Scan(t.Context(), goTestTarget(home), []string{root}, nil) + condaAssertRefs(t, inv, audit) + if src := condaSource(t, inv, "environment_root", base); src.Status != "partial" || src.Presence != "present" { + t.Errorf("aliased root = %+v", src) + } + if n := len(condaPackages(inv, "installed_package", "packaging")); n != 1 { + t.Fatalf("aliased root duplicated facts: %d", n) + } +} diff --git a/internal/detector/configaudit/condaconfig.go b/internal/detector/configaudit/condaconfig.go new file mode 100644 index 00000000..68a97612 --- /dev/null +++ b/internal/detector/configaudit/condaconfig.go @@ -0,0 +1,1264 @@ +package configaudit + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "maps" + "net/url" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "unicode/utf8" + + "gopkg.in/yaml.v3" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +var maxCondaAuditBytes = 256 << 10 + +const condaConfigBytes int64 = 2 << 20 +const condaSettingLimit = 256 + +var ( + condaTokenPattern = regexp.MustCompile(`/t/[^/?#]*`) + condaLiteralTokenPattern = regexp.MustCompile(`/t/[^/?#]+`) + condaReferencePattern = regexp.MustCompile(`\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%`) + condaChannelPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*(?:/[A-Za-z0-9][A-Za-z0-9._-]*)*$`) + condaSchemePattern = regexp.MustCompile(`^[a-z][a-z0-9+.-]*$`) + condaExcludeNewerPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 .:+_/-]{0,63}$`) +) + +// CondaConfigKind is the source-ID kind for a Conda configuration scope. +func CondaConfigKind(scope string) string { return "conda_config_" + scope } + +// CondaSanitizeURL removes Anaconda token path components before the shared +// URL sanitizer strips userinfo, query and fragment. Every `/t/` +// component goes whole (the loader stops at non-token characters, which +// would leak a malformed token's tail), and a token that survives +// percent-decoding redacts the URL. +func CondaSanitizeURL(raw string) (string, bool) { + if !composerSafeText(raw, 4096) { + return goRedacted, true + } + clean := condaTokenPattern.ReplaceAllString(raw, "") + redacted := clean != raw + decoded, err := url.PathUnescape(clean) + if err != nil || condaTokenPattern.MatchString(decoded) { + return goRedacted, true + } + display, r := SanitizeRubyURL(clean) + return display, redacted || r +} + +// CondaChannelDisplay keeps a safe channel label, a sanitized URL or a local +// repository path; anything else is redacted. +func CondaChannelDisplay(raw string) (string, bool) { + switch { + case !composerSafeText(raw, 4096): + return goRedacted, true + case strings.Contains(raw, "://"): + return CondaSanitizeURL(raw) + case filepath.IsAbs(raw) || strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, `\\`) || len(raw) > 2 && raw[1] == ':': + return SanitizeComposerURL(raw) + case len(raw) <= 256 && condaChannelPattern.MatchString(raw): + return raw, false + } + return goRedacted, true +} + +// condaURLCredential reports an embedded secret: userinfo or a token path. +// A $VAR, ${VAR} or %VAR% reference holds no secret, so it is blanked first. +func condaURLCredential(raw string) bool { + if !strings.Contains(raw, "://") { + return false + } + raw = condaReferencePattern.ReplaceAllString(raw, "") + if condaLiteralTokenPattern.MatchString(raw) { + return true + } + if decoded, err := url.PathUnescape(raw); err == nil && condaLiteralTokenPattern.MatchString(decoded) { + return true + } + u, err := url.Parse(raw) + if err != nil || u.User == nil { + return false + } + password, _ := u.User.Password() + return u.User.Username() != "" || password != "" +} + +// condaProxySelector admits a plain scheme or scheme://host[:port]. +func condaProxySelector(raw string) (string, bool) { + if !composerSafeText(raw, 128) { + return goRedacted, true + } + if condaSchemePattern.MatchString(raw) { + return raw, false + } + u, err := url.Parse(raw) + if err != nil || !strings.Contains(raw, "://") || u.User != nil || u.Host == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" || !condaSchemePattern.MatchString(u.Scheme) { + return goRedacted, true + } + return raw, false +} + +// condaBool follows Conda's boolify sets; anything else is not a boolean. +func condaBool(v string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "yes", "on", "y": + return "true", true + case "false", "off", "n", "no", "non", "none", "": + return "false", true + } + return "", false +} + +// CondaConfigScope comes from the resolved developer, never a login shell. +type CondaConfigScope struct { + Username, Home string + Protected func(string) string + Volume func(string) bool + ProcessVerified bool +} + +// CondaConfigSnapshot accumulates .condarc-family observations as inventory +// discovers installation roots and prefixes. Leads are absolute paths from +// verified process variables and present files; they are not effective config. +type CondaConfigSnapshot struct { + EnvsDirs, PkgsDirs, Prefixes []string + exec executor.Executor + ctx context.Context + scope CondaConfigScope + audit model.CondaConfigAudit + files map[string]*model.CondaConfigFile + env map[string]string + literal map[string]bool + leads map[string]bool + credentialPaths map[string]bool + credentials map[string]bool + process, condarc *model.CondaConfigFile + environments []condaEnvironmentContext + system, user []*model.CondaConfigFile // lowest precedence first + roots []*model.CondaConfigFile + rootKeys map[string]bool + contexts map[string]bool + partial, rootsPartial bool + rootsPresent int + bytes int + limited bool +} + +// condaEnvironmentContext holds a prefix's own sources until every root is +// known, since roots found later still apply to it. +type condaEnvironmentContext struct { + prefix string + own []*model.CondaConfigFile + state *model.CondaConfigFile + partial bool +} + +type CondaConfigDetector struct{ exec executor.Executor } + +func NewCondaConfigDetector(exec executor.Executor) *CondaConfigDetector { + return &CondaConfigDetector{exec: exec} +} + +func emptyCondaAudit() model.CondaConfigAudit { + return model.CondaConfigAudit{SchemaVersion: model.CondaInventorySchemaVersion, Status: "complete", Reasons: []string{}, Files: []model.CondaConfigFile{}, Contexts: []model.CondaConfigContext{}, CredentialFiles: []model.CondaCredentialFile{}, Findings: []model.CargoConfigFinding{}} +} + +func (d *CondaConfigDetector) Detect(ctx context.Context, scope CondaConfigScope) *CondaConfigSnapshot { + a := &CondaConfigSnapshot{exec: d.exec, ctx: ctx, scope: scope, audit: emptyCondaAudit(), files: map[string]*model.CondaConfigFile{}, env: map[string]string{}, + literal: map[string]bool{}, leads: map[string]bool{}, credentialPaths: map[string]bool{}, credentials: map[string]bool{}, rootKeys: map[string]bool{}, contexts: map[string]bool{}} + if scope.Username == "" || scope.Home == "" { + a.degrade("user_unresolved") + return a + } + if ctx.Err() != nil { + a.degrade("deadline_exceeded") + return a + } + home, goos := scope.Home, d.exec.GOOS() + a.process = a.record("process", "") + if scope.ProcessVerified { + a.processEnv() + } else { + a.fileReason(a.process, "unsupported", "user_unresolved") + a.partial = true + } + // Existence only: requests' netrc and anaconda-client's token store. + binstar := filepath.Join(cmpOr(a.env["XDG_CONFIG_HOME"], filepath.Join(home, ".config")), "binstar") + switch goos { + case model.PlatformDarwin: + binstar = filepath.Join(home, "Library", "Application Support", "binstar") + case model.PlatformWindows: + binstar = filepath.Join(cmpOr(a.env["LOCALAPPDATA"], filepath.Join(home, "AppData", "Local")), "ContinuumIO", "binstar") + } + credentialFiles := []string{filepath.Join(home, ".netrc"), filepath.Join(home, "_netrc"), filepath.Join(binstar, "data")} + for _, p := range credentialFiles { + a.credentialPaths[a.key(p)] = true + if resolved, err := a.exec.GuardedFiles([]string{p}, scope.Protected, 0).EvalSymlinks(p); err == nil { + a.credentialPaths[a.key(resolved)] = true + } + } + systemDirs := []string{"/etc/conda", "/var/lib/conda"} + if goos == model.PlatformWindows { + // The loader hardcodes this directory; %PROGRAMDATA% is not consulted. + systemDirs = []string{filepath.Join(`C:\ProgramData`, "conda")} + } + for _, dir := range systemDirs { + a.system = append(a.system, a.entries("system", dir)...) + } + if root := a.env["CONDA_ROOT"]; root != "" { + a.rootKeys[a.key(root)] = true + a.system = append(a.system, a.entries("system", root)...) + } + if xdg := a.env["XDG_CONFIG_HOME"]; xdg != "" { + a.user = append(a.user, a.entries("user", filepath.Join(xdg, "conda"))...) + } + a.user = append(a.user, a.entries("user", filepath.Join(home, ".config", "conda"))...) + a.user = append(a.user, a.entries("user", filepath.Join(home, ".conda"))...) + a.user = append(a.user, a.readFile("user", filepath.Join(home, ".condarc"))) + if p := a.env["CONDARC"]; p != "" { + // The loader admits only its own filenames; a path it would ignore is + // not an applicable source and is never read. + if condaConfigName(p) { + a.condarc = a.readFile(a.scopeOf(p), p) + } else { + a.unresolved() + } + } + for _, p := range credentialFiles { + a.credential(p) + } + return a +} + +// condaConfigName mirrors the native loader's filename admission. +func condaConfigName(p string) bool { + base := filepath.Base(p) + return base == ".condarc" || base == "condarc" || strings.HasSuffix(base, ".yml") || strings.HasSuffix(base, ".yaml") +} + +func (a *CondaConfigSnapshot) scopeOf(p string) string { + if GoWithinRoots(a.exec.GOOS(), p, []string{a.scope.Home}) { + return "user" + } + return "system" +} + +// Env returns a verified developer-process selector as a safe absolute path. +func (a *CondaConfigSnapshot) Env(key string) string { return a.env[key] } + +// ContentGuard keeps credential files existence-only, including a token +// directory's contents and redirected reads. +func (a *CondaConfigSnapshot) ContentGuard(path string) string { + if a.scope.Protected != nil { + if reason := a.scope.Protected(path); reason != "" { + return reason + } + } + key := a.key(path) + for p := range a.credentialPaths { + if key == p || isLexicalAncestor(p, key) { + return "skipped_protected" + } + } + return "" +} + +func (a *CondaConfigSnapshot) key(p string) string { return GoPathKey(a.exec.GOOS(), p) } +func (a *CondaConfigSnapshot) degrade(reason string) { + a.audit.Status = "partial" + if !slices.Contains(a.audit.Reasons, reason) { + a.audit.Reasons = append(a.audit.Reasons, reason) + } +} +func (a *CondaConfigSnapshot) fileReason(f *model.CondaConfigFile, status, reason string) { + if f != nil { + f.Status = status + if !slices.Contains(f.Reasons, reason) { + f.Reasons = append(f.Reasons, reason) + } + } + a.degrade(reason) +} + +// limitation keeps a parseable file present with its supported settings +// while recording why its coverage is incomplete. +func (a *CondaConfigSnapshot) limitation(f *model.CondaConfigFile, reason string) { + if f != nil && !slices.Contains(f.Reasons, reason) { + f.Reasons = append(f.Reasons, reason) + } + a.partial = true + a.degrade(reason) +} +func (a *CondaConfigSnapshot) unresolved() { + a.partial = true + a.degrade("path_unresolved") +} + +func (a *CondaConfigSnapshot) record(scope, path string) *model.CondaConfigFile { + id := GoSourceID(a.scope.Username, CondaConfigKind(scope), path) + if f := a.files[id]; f != nil { + return f + } + if a.limited || a.bytes+len(path)+256 > maxCondaAuditBytes { + a.limited = true + a.degrade("output_size_limit") + return nil + } + f := &model.CondaConfigFile{SourceID: id, Manager: "conda", Scope: scope, Path: path, Status: "absent", Reasons: []string{}, Settings: []model.CargoConfigSetting{}} + a.files[id] = f + a.bytes += len(path) + 256 + return f +} + +func (a *CondaConfigSnapshot) setting(f *model.CondaConfigFile, key, value string, redacted bool) { + if f == nil { + return + } + if !composerSafeText(value, 4096) || len(key) > 512 { + a.fileReason(f, "invalid", "parse_error") + return + } + if slices.ContainsFunc(f.Settings, func(s model.CargoConfigSetting) bool { return s.Key == key && s.Display == value }) { + return + } + if len(f.Settings) >= condaSettingLimit { + a.limitation(f, "record_limit") + return + } + if a.bytes+len(key)+len(value)+128 > maxCondaAuditBytes { + a.limited = true + a.fileReason(f, "unsupported", "output_size_limit") + return + } + a.bytes += len(key) + len(value) + 128 + f.Settings = append(f.Settings, model.CargoConfigSetting{Key: key, Display: value, SourceID: f.SourceID, Redacted: redacted}) +} + +// entries lists one search-path directory in loader order: .condarc, +// condarc, then condarc.d fragments by name, later entries winning. +func (a *CondaConfigSnapshot) entries(scope, dir string) []*model.CondaConfigFile { + out := []*model.CondaConfigFile{a.readFile(scope, filepath.Join(dir, ".condarc")), a.readFile(scope, filepath.Join(dir, "condarc"))} + fragments := filepath.Join(dir, "condarc.d") + if a.ctx.Err() != nil { + a.degrade("deadline_exceeded") + a.partial = true + return out + } + names, more, err := a.exec.GuardedFiles([]string{fragments}, a.ContentGuard, 0).ReadDirLimit(fragments, condaSettingLimit) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + a.partial = true + a.degrade(CargoReadReason(err, fragments, a.scope.Volume)) + } + return out + } + if more { + a.partial = true + a.degrade("entry_limit") + } + var sorted []string + for _, e := range names { + if !e.IsDir() && (strings.HasSuffix(e.Name(), ".yml") || strings.HasSuffix(e.Name(), ".yaml")) { + sorted = append(sorted, e.Name()) + } + } + slices.Sort(sorted) + for _, name := range sorted { + out = append(out, a.readFile(scope, filepath.Join(fragments, name))) + } + return out +} + +func (a *CondaConfigSnapshot) readFile(scope, p string) *model.CondaConfigFile { + if f := a.files[GoSourceID(a.scope.Username, CondaConfigKind(scope), p)]; f != nil { + return f + } + f := a.record(scope, p) + if f == nil { + return nil + } + if a.ctx.Err() != nil { + a.fileReason(f, "unreadable", "deadline_exceeded") + return f + } + // A missing file cannot establish its own root through a parent symlink. + files := a.exec.GuardedFiles([]string{filepath.Dir(p), p}, a.ContentGuard, condaConfigBytes) + info, err := files.Stat(p) + if errors.Is(err, fs.ErrNotExist) { + return f + } + if err != nil { + a.readFailure(f, err, p) + return f + } + if !info.Mode().IsRegular() { + a.fileReason(f, "unsupported", "unsupported_entry") + return f + } + if info.Size() > condaConfigBytes { + a.fileReason(f, "unreadable", "size_limit") + return f + } + data, err := files.ReadFile(p) + if errors.Is(err, fs.ErrNotExist) { + a.fileReason(f, "unreadable", "changed_during_scan") + return f + } + if err != nil { + a.readFailure(f, err, p) + return f + } + a.parseCondarc(f, data) + return f +} +func (a *CondaConfigSnapshot) readFailure(f *model.CondaConfigFile, err error, p string) { + reason := CargoReadReason(err, p, a.scope.Volume) + status := "unreadable" + if reason == "skipped_protected" || reason == "refused_network_volume" { + status = "skipped_protected" + } + a.fileReason(f, status, reason) +} + +// condaPathSelectors are process variables that only steer discovery. +var condaPathSelectors = []string{"CONDA_PREFIX", "CONDA_ROOT", "MAMBA_ROOT_PREFIX", "CONDARC", "XDG_CONFIG_HOME", "LOCALAPPDATA"} + +// condaEnvSettings maps process variables to canonical keys; a pair shares +// one key and conflicts when both are set. +var condaEnvSettings = [][2]string{ + {"CONDA_ENVS_DIRS", "CONDA_ENVS_PATH"}, {"CONDA_PKGS_DIRS", ""}, {"CONDA_CHANNELS", "CONDA_CHANNEL"}, {"CONDA_DEFAULT_CHANNELS", ""}, + {"CONDA_ALLOWLIST_CHANNELS", "CONDA_WHITELIST_CHANNELS"}, {"CONDA_DENYLIST_CHANNELS", ""}, {"CONDA_CHANNEL_ALIAS", ""}, {"CONDA_SSL_VERIFY", "CONDA_VERIFY_SSL"}, + {"CONDA_OFFLINE", ""}, {"CONDA_CHANNEL_PRIORITY", ""}, {"CONDA_SAFETY_CHECKS", ""}, {"CONDA_EXTRA_SAFETY_CHECKS", ""}, {"CONDA_EXCLUDE_NEWER", ""}, +} + +var condaProxyEnv = [][2]string{{"HTTP_PROXY", "http"}, {"HTTPS_PROXY", "https"}, {"ALL_PROXY", "all"}} + +// processEnv reads only allowlisted names from the verified process. +func (a *CondaConfigSnapshot) processEnv() { + if a.process == nil { + return + } + e := a.exec + for _, key := range condaPathSelectors { + v := e.Getenv(key) + if v == "" { + continue + } + if !filepath.IsAbs(v) || !composerSafeText(v, 4096) { + a.unresolved() + continue + } + a.env[key] = filepath.Clean(v) + } + for _, key := range []string{"CONDA_PREFIX", "CONDA_ROOT", "MAMBA_ROOT_PREFIX"} { + if v := a.env[key]; v != "" && !slices.Contains(a.Prefixes, v) { + a.Prefixes = append(a.Prefixes, v) + } + } + values := map[string]string{} + for _, pair := range condaEnvSettings { + v, alias := e.Getenv(pair[0]), "" + if pair[1] != "" { + alias = e.Getenv(pair[1]) + } + switch { + case v != "" && alias != "": + a.limitation(a.process, "unsupported_format") + case v != "": + values[pair[0]] = v + case alias != "": + values[pair[0]] = alias + } + } + a.settings(a.process, values, true) + proxies := map[string]string{} + for _, p := range condaProxyEnv { + names := []string{p[0], strings.ToLower(p[0])} + if e.GOOS() == model.PlatformWindows { + // Windows names are case-insensitive, so both spellings are one capture. + names = names[:1] + } + for _, name := range names { + if v := e.Getenv(name); v != "" { + proxies[p[1]+"\x00"+name] = v + } + } + } + a.proxies(a.process, proxies) + if a.process.Status == "absent" && len(a.process.Settings) > 0 { + a.process.Status = "present" + } +} + +func (a *CondaConfigSnapshot) proxies(f *model.CondaConfigFile, proxies map[string]string) { + keys := slices.Sorted(maps.Keys(proxies)) + if len(keys) > condaSettingLimit { + a.limitation(f, "record_limit") + keys = keys[:condaSettingLimit] + } + for i, k := range keys { + scheme, _, _ := strings.Cut(k, "\x00") + selector, bad := condaProxySelector(scheme) + display, redacted := sanitizeComposerProxy(proxies[k]) + if display != goRedacted { + // A proxy is still a URL that may carry a token path. + var r bool + display, r = CondaSanitizeURL(display) + redacted = redacted || r + } + prefix := "proxy_servers." + strconv.Itoa(i) + "." + a.setting(f, prefix+"scheme", selector, bad) + a.setting(f, prefix+"url", display, redacted) + } +} + +func condaSplit(v, sep string) []string { + var out []string + for item := range strings.SplitSeq(v, sep) { + if item = strings.TrimSpace(item); item != "" { + out = append(out, item) + } + } + return out +} + +// settings applies canonical scalar and list values from process variables +// or a saved state file, where every value is text. +func (a *CondaConfigSnapshot) settings(f *model.CondaConfigFile, values map[string]string, leads bool) { + sep := ":" + if a.exec.GOOS() == model.PlatformWindows { + sep = ";" + } + literal := false + for _, key := range slices.Sorted(maps.Keys(values)) { + v := values[key] + switch key { + case "CONDA_ENVS_DIRS", "CONDA_PKGS_DIRS": + name, s := "envs_dirs", sep + if key == "CONDA_PKGS_DIRS" { + name, s = "pkgs_dirs", "," + } + a.dirs(f, name, condaSplit(v, s), leads) + case "CONDA_CHANNELS", "CONDA_DEFAULT_CHANNELS": + name := strings.ToLower(strings.TrimPrefix(key, "CONDA_")) + for i, c := range condaSplit(v, ",") { + if i >= condaSettingLimit { + a.limitation(f, "record_limit") + break + } + display, redacted := CondaChannelDisplay(c) + literal = literal || condaURLCredential(c) + a.setting(f, name+"."+strconv.Itoa(i)+".url", display, redacted) + } + case "CONDA_ALLOWLIST_CHANNELS", "CONDA_DENYLIST_CHANNELS": + a.channelList(f, strings.ToLower(strings.TrimPrefix(key, "CONDA_")), condaSplit(v, ",")) + case "CONDA_CHANNEL_ALIAS": + literal = literal || condaURLCredential(v) + a.channelAlias(f, v) + case "CONDA_SSL_VERIFY": + a.sslVerify(f, v) + case "CONDA_OFFLINE", "CONDA_EXTRA_SAFETY_CHECKS": + a.boolean(f, strings.ToLower(strings.TrimPrefix(key, "CONDA_")), v) + case "CONDA_CHANNEL_PRIORITY": + a.channelPriority(f, v) + case "CONDA_SAFETY_CHECKS": + a.enum(f, "safety_checks", v, "enabled", "warn", "disabled") + case "CONDA_EXCLUDE_NEWER": + a.excludeNewer(f, v) + } + } + if literal { + a.setting(f, "auth_configured", "true", true) + if f.Scope != "process" { + a.literal[f.SourceID] = true + } + } +} + +// dirs records envs_dirs/pkgs_dirs text and resolves discovery leads only +// through the verified home; a relative or variable path stays unresolved. +func (a *CondaConfigSnapshot) dirs(f *model.CondaConfigFile, name string, values []string, leads bool) { + display := []string{} + for _, v := range values { + if !composerSafeText(v, 4096) { + a.fileReason(f, "invalid", "parse_error") + return + } + d, redacted := SanitizeComposerURL(v) + if redacted { + display = append(display, goRedacted) + continue + } + display = append(display, d) + p := v + if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) { + p = filepath.Join(a.scope.Home, p[1:]) + } + if !filepath.IsAbs(p) || strings.ContainsAny(p, "$%") { + a.unresolved() + continue + } + if !leads { + continue + } + p = filepath.Clean(p) + if a.leads[name+"\x00"+a.key(p)] { + continue + } + a.leads[name+"\x00"+a.key(p)] = true + if name == "envs_dirs" { + a.EnvsDirs = append(a.EnvsDirs, p) + } else { + a.PkgsDirs = append(a.PkgsDirs, p) + } + } + if len(display) > condaSettingLimit { + a.limitation(f, "record_limit") + display = display[:condaSettingLimit] + } + encoded, _ := json.Marshal(display) + a.setting(f, name, string(encoded), slices.Contains(display, goRedacted)) +} + +func (a *CondaConfigSnapshot) channelList(f *model.CondaConfigFile, name string, values []string) { + display, redacted := []string{}, false + for _, v := range values { + d, r := CondaChannelDisplay(v) + display, redacted = append(display, d), redacted || r + } + if len(display) > condaSettingLimit { + a.limitation(f, "record_limit") + display = display[:condaSettingLimit] + } + encoded, _ := json.Marshal(display) + a.setting(f, name, string(encoded), redacted) +} + +func (a *CondaConfigSnapshot) channelAlias(f *model.CondaConfigFile, v string) { + if !strings.Contains(v, "://") { + a.fileReason(f, "invalid", "parse_error") + return + } + display, redacted := CondaSanitizeURL(v) + a.setting(f, "channel_alias", display, redacted) +} + +func (a *CondaConfigSnapshot) sslVerify(f *model.CondaConfigFile, v string) { + switch b, ok := condaBool(v); { + case strings.TrimSpace(v) == "": + a.limitation(f, "unsupported_format") + case ok: + a.setting(f, "ssl_verify", b, false) + case strings.EqualFold(strings.TrimSpace(v), "truststore"): + a.setting(f, "ssl_verify", "truststore", false) + default: + // A CA bundle path; its contents are never read. + display, redacted := SanitizeComposerURL(v) + a.setting(f, "ssl_verify", display, redacted) + } +} + +func (a *CondaConfigSnapshot) boolean(f *model.CondaConfigFile, key, v string) { + if b, ok := condaBool(v); ok { + a.setting(f, key, b, false) + return + } + a.fileReason(f, "invalid", "parse_error") +} + +func (a *CondaConfigSnapshot) channelPriority(f *model.CondaConfigFile, v string) { + // Conda reads the legacy boolean as flexible (true) or disabled (false). + if b, ok := condaBool(v); ok { + if b == "true" { + a.setting(f, "channel_priority", "flexible", false) + } else { + a.setting(f, "channel_priority", "disabled", false) + } + return + } + a.enum(f, "channel_priority", v, "strict", "flexible", "disabled") +} + +func (a *CondaConfigSnapshot) enum(f *model.CondaConfigFile, key, v string, allowed ...string) { + v = strings.ToLower(strings.TrimSpace(v)) + if slices.Contains(allowed, v) { + a.setting(f, key, v, false) + return + } + a.fileReason(f, "invalid", "parse_error") +} + +func (a *CondaConfigSnapshot) excludeNewer(f *model.CondaConfigFile, v string) { + v = strings.TrimSpace(v) + if condaExcludeNewerPattern.MatchString(v) { + a.setting(f, "exclude_newer", v, false) + return + } + a.limitation(f, "unsupported_format") +} + +// condaKeyAliases are older spellings the loader still accepts. +var condaKeyAliases = map[string]string{"channel": "channels", "envs_path": "envs_dirs", "verify_ssl": "ssl_verify", "whitelist_channels": "allowlist_channels", "cooldown": "exclude_newer"} + +var condaKeys = []string{"channels", "default_channels", "channel_alias", "custom_channels", "custom_multichannels", "envs_dirs", "pkgs_dirs", "ssl_verify", "proxy_servers", + "offline", "extra_safety_checks", "channel_priority", "safety_checks", "allowlist_channels", "denylist_channels", "exclude_newer"} + +// yamlRoot returns root mapping pairs. A merge or aliased key could inject +// relevant settings, so either rejects the file. +func (a *CondaConfigSnapshot) yamlRoot(f *model.CondaConfigFile, data []byte) ([][2]*yaml.Node, bool) { + var doc yaml.Node + if !utf8.Valid(data) || yaml.Unmarshal(data, &doc) != nil { + a.fileReason(f, "invalid", "parse_error") + return nil, false + } + if len(doc.Content) == 0 { + return nil, true + } + root := doc.Content[0] + if root.Kind != yaml.MappingNode || root.Anchor != "" || root.Style&yaml.TaggedStyle != 0 { + a.fileReason(f, "invalid", "parse_error") + return nil, false + } + var out [][2]*yaml.Node + for i := 0; i+1 < len(root.Content); i += 2 { + k := root.Content[i] + if k.Kind != yaml.ScalarNode || k.Value == "<<" || k.Anchor != "" || k.Style&yaml.TaggedStyle != 0 { + a.fileReason(f, "unsupported", "unsupported_format") + return nil, false + } + out = append(out, [2]*yaml.Node{k, root.Content[i+1]}) + } + return out, true +} + +func condaScalars(n *yaml.Node) ([]string, bool) { + if v, ok := rubyScalar(n); ok { + if v == "" { + return []string{}, true + } + return []string{v}, true + } + if n.Kind != yaml.SequenceNode { + return nil, false + } + out := []string{} + for _, item := range n.Content { + v, ok := rubyScalar(item) + if !ok { + return nil, false + } + out = append(out, v) + } + return out, true +} + +// parseCondarc reads allowlisted keys only. Unknown keys and the `#!final` +// family of comment flags are ignored; an alias beside its canonical key is +// not resolved into a guessed effective value. +func (a *CondaConfigSnapshot) parseCondarc(f *model.CondaConfigFile, data []byte) { + pairs, ok := a.yamlRoot(f, data) + if !ok { + return + } + f.Status = "present" + values := map[string]*yaml.Node{} + conflict := map[string]bool{} + for _, kv := range pairs { + key := kv[0].Value + if alias := condaKeyAliases[key]; alias != "" { + key = alias + } + if !slices.Contains(condaKeys, key) { + continue + } + if !rubyYAMLData(kv[1], 0) { + a.limitation(f, "unsupported_format") + continue + } + if values[key] != nil { + conflict[key] = true + continue + } + values[key] = kv[1] + } + for key := range conflict { + delete(values, key) + a.limitation(f, "unsupported_format") + } + invalid := func() { a.fileReason(f, "invalid", "parse_error") } + literal := false + for _, key := range []string{"channels", "default_channels"} { + n := values[key] + if n == nil { + continue + } + list, ok := condaScalars(n) + if !ok { + invalid() + continue + } + for i, c := range list { + if i >= condaSettingLimit { + a.limitation(f, "record_limit") + break + } + display, redacted := CondaChannelDisplay(c) + literal = literal || condaURLCredential(c) + a.setting(f, key+"."+strconv.Itoa(i)+".url", display, redacted) + } + } + if n := values["channel_alias"]; n != nil { + if v, ok := rubyScalar(n); ok && v != "" { + literal = literal || condaURLCredential(v) + a.channelAlias(f, v) + } else { + invalid() + } + } + for _, key := range []string{"custom_channels", "custom_multichannels"} { + n := values[key] + if n == nil { + continue + } + if n.Kind != yaml.MappingNode { + invalid() + continue + } + type entry struct { + name string + urls []string + } + var entries []entry + for i := 0; i+1 < len(n.Content); i += 2 { + urls, ok := condaScalars(n.Content[i+1]) + if !ok || key == "custom_channels" && len(urls) != 1 { + invalid() + continue + } + entries = append(entries, entry{n.Content[i].Value, urls}) + } + slices.SortFunc(entries, func(x, y entry) int { return strings.Compare(x.name, y.name) }) + if len(entries) > condaSettingLimit { + a.limitation(f, "record_limit") + entries = entries[:condaSettingLimit] + } + for i, e := range entries { + prefix := key + "." + strconv.Itoa(i) + "." + name, bad := nugetAlias(e.name) + a.setting(f, prefix+"name", name, bad) + display, redacted := []string{}, false + for _, u := range e.urls { + d, r := CondaChannelDisplay(u) + literal = literal || condaURLCredential(u) + display, redacted = append(display, d), redacted || r + } + if key == "custom_channels" { + a.setting(f, prefix+"url", display[0], redacted) + continue + } + encoded, _ := json.Marshal(display) + a.setting(f, prefix+"urls", string(encoded), redacted) + } + } + for _, key := range []string{"envs_dirs", "pkgs_dirs"} { + if n := values[key]; n != nil { + if list, ok := condaScalars(n); ok { + a.dirs(f, key, list, true) + } else { + invalid() + } + } + } + if n := values["ssl_verify"]; n != nil { + if v, ok := rubyScalar(n); ok { + a.sslVerify(f, v) + } else { + invalid() + } + } + if n := values["proxy_servers"]; n != nil { + if n.Kind != yaml.MappingNode { + invalid() + } else { + proxies := map[string]string{} + for i := 0; i+1 < len(n.Content); i += 2 { + v, ok := rubyScalar(n.Content[i+1]) + if !ok { + invalid() + continue + } + proxies[n.Content[i].Value+"\x00"] = v + if !strings.Contains(v, "://") { + v = "http://" + v + } + literal = literal || condaURLCredential(v) + } + a.proxies(f, proxies) + } + } + for _, key := range []string{"offline", "extra_safety_checks"} { + if n := values[key]; n != nil { + if v, ok := rubyScalar(n); ok { + a.boolean(f, key, v) + } else { + invalid() + } + } + } + if n := values["channel_priority"]; n != nil { + if v, ok := rubyScalar(n); ok { + a.channelPriority(f, v) + } else { + invalid() + } + } + if n := values["safety_checks"]; n != nil { + if v, ok := rubyScalar(n); ok { + a.enum(f, "safety_checks", v, "enabled", "warn", "disabled") + } else { + invalid() + } + } + for _, key := range []string{"allowlist_channels", "denylist_channels"} { + if n := values[key]; n != nil { + if list, ok := condaScalars(n); ok { + a.channelList(f, key, list) + } else { + invalid() + } + } + } + if n := values["exclude_newer"]; n != nil { + if v, ok := rubyScalar(n); ok { + a.excludeNewer(f, v) + } else { + invalid() + } + } + if literal { + a.setting(f, "auth_configured", "true", true) + a.literal[f.SourceID] = true + } +} + +// parseState reads conda-meta/state's saved activation variables through the +// process allowlist. The unset sentinel means the variable is absent. +func (a *CondaConfigSnapshot) parseState(f *model.CondaConfigFile, data []byte) { + var doc struct { + EnvVars map[string]json.RawMessage `json:"env_vars"` + } + if !utf8.Valid(data) || json.Unmarshal(data, &doc) != nil { + a.fileReason(f, "invalid", "parse_error") + return + } + f.Status = "present" + values, proxies := map[string]string{}, map[string]string{} + read := func(name string) (string, bool) { + raw := doc.EnvVars[name] + if raw == nil { + return "", false + } + var v string + if json.Unmarshal(raw, &v) != nil || !composerSafeText(v, 4096) { + a.fileReason(f, "invalid", "parse_error") + return "", false + } + if v == "***unset***" || v == "" { + return "", false + } + return v, true + } + for _, pair := range condaEnvSettings { + v, ok := read(pair[0]) + alias, ok2 := "", false + if pair[1] != "" { + alias, ok2 = read(pair[1]) + } + switch { + case ok && ok2: + a.limitation(f, "unsupported_format") + case ok: + values[pair[0]] = v + case ok2: + values[pair[0]] = alias + } + } + a.settings(f, values, false) + for _, p := range condaProxyEnv { + for _, name := range []string{p[0], strings.ToLower(p[0])} { + if v, ok := read(name); ok { + proxies[p[1]+"\x00"+name] = v + if !strings.Contains(v, "://") { + v = "http://" + v + } + if condaURLCredential(v) { + a.literal[f.SourceID] = true + a.setting(f, "auth_configured", "true", true) + } + } + } + } + a.proxies(f, proxies) +} + +// Root observes an installation root's own search-path files as system +// scope. Which root a past invocation used is unknowable, so more than one +// root with a present file leaves the ambient context partial. +func (a *CondaConfigSnapshot) Root(path string, partial bool) { + if a.scope.Username == "" || path == "" { + return + } + if partial { + a.rootsPartial = true + } + if a.rootKeys[a.key(path)] { + return + } + a.rootKeys[a.key(path)] = true + files := a.entries("system", path) + if slices.ContainsFunc(files, func(f *model.CondaConfigFile) bool { return f != nil && f.Status == "present" }) { + a.rootsPresent++ + } + a.roots = append(a.roots, files...) +} + +// Environment observes one prefix's own files and saved state variables; its +// context is emitted by Finish once every root is known. A root prefix's +// files are already its system-scope root files. +func (a *CondaConfigSnapshot) Environment(prefix string, partial bool) { + if a.scope.Username == "" || prefix == "" || a.contexts[a.key(prefix)] { + return + } + a.contexts[a.key(prefix)] = true + var own []*model.CondaConfigFile + if !a.rootKeys[a.key(prefix)] { + own = a.entries("environment", prefix) + } + state := a.record("environment", filepath.Join(prefix, "conda-meta", "state")) + if state != nil { + a.readState(state) + } + a.environments = append(a.environments, condaEnvironmentContext{prefix: prefix, own: own, state: state, partial: partial}) +} + +func (a *CondaConfigSnapshot) readState(f *model.CondaConfigFile) { + if a.ctx.Err() != nil { + a.fileReason(f, "unreadable", "deadline_exceeded") + return + } + p := f.Path + files := a.exec.GuardedFiles([]string{filepath.Dir(p), p}, a.ContentGuard, condaConfigBytes) + info, err := files.Stat(p) + switch { + case errors.Is(err, fs.ErrNotExist): + return + case err != nil: + a.readFailure(f, err, p) + return + case !info.Mode().IsRegular(): + a.fileReason(f, "unsupported", "unsupported_entry") + return + case info.Size() > condaConfigBytes: + a.fileReason(f, "unreadable", "size_limit") + return + } + data, err := files.ReadFile(p) + if err != nil { + a.readFailure(f, err, p) + return + } + a.parseState(f, data) +} + +// chain lists applicable sources highest precedence first: process +// variables, saved state, the explicit CONDARC file, the prefix's own files, +// then user, installation-root and system files. +func (a *CondaConfigSnapshot) chain(own []*model.CondaConfigFile, state *model.CondaConfigFile) ([]string, bool) { + ids, partial := []string{}, a.partial || a.rootsPartial + add := func(files ...*model.CondaConfigFile) { + for _, f := range slices.Backward(files) { + if f == nil { + partial = true + continue + } + ids = append(ids, f.SourceID) + if f.Status != "present" && f.Status != "absent" { + partial = true + } + } + } + add(a.process) + if state != nil { + add(state) + } + if a.condarc != nil { + add(a.condarc) + } + add(own...) + add(a.user...) + add(a.roots...) + add(a.system...) + return ids, partial || a.rootsPresent > 1 && a.env["CONDA_ROOT"] == "" +} + +func condaSelection(partial bool) string { + if partial { + return "partial" + } + return "observed" +} + +// Manifest records a recognized environment manifest's declared channels as +// a project-scope source with its own context. It reports whether that +// context is partial. +func (a *CondaConfigSnapshot) Manifest(path string, channels []string, status string, reasons []string) bool { + if a.scope.Username == "" { + return true + } + f := a.record("project", path) + if f == nil { + return true + } + f.Status, f.Reasons = status, append([]string{}, reasons...) + // Unsupported manifest content may hide channels, so its context is partial. + partial := a.partial || status != "present" || len(reasons) > 0 + if status == "present" { + literal := false + for i, c := range channels { + if i >= condaSettingLimit { + a.limitation(f, "record_limit") + partial = true + break + } + display, redacted := CondaChannelDisplay(c) + literal = literal || condaURLCredential(c) + a.setting(f, "channels."+strconv.Itoa(i)+".url", display, redacted) + } + if literal { + a.setting(f, "auth_configured", "true", true) + a.literal[f.SourceID] = true + } + } + a.context(model.CondaConfigContext{Manager: "conda", ProjectPath: filepath.Dir(path), ConfigSourceIDs: []string{f.SourceID}, SelectionStatus: condaSelection(partial)}) + return partial +} + +func (a *CondaConfigSnapshot) context(c model.CondaConfigContext) { + if a.limited { + return + } + a.audit.Contexts = append(a.audit.Contexts, c) + a.bytes += len(c.EnvironmentPath) + len(c.ProjectPath) + len(c.ConfigSourceIDs)*70 + 128 +} + +func (a *CondaConfigSnapshot) credential(path string) { + if a.limited || a.credentials[a.key(path)] { + return + } + a.credentials[a.key(path)] = true + c := model.CondaCredentialFile{Path: path, Status: "present", Presence: "present"} + if a.ctx.Err() != nil { + c.Status, c.Presence = "unreadable", "unknown" + a.degrade("deadline_exceeded") + } else { + info, err := a.exec.GuardedFiles([]string{path}, a.scope.Protected, 0).Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + c.Status, c.Presence = "absent", "absent" + case err != nil: + c.Status, c.Presence = "unreadable", "unknown" + reason := CargoReadReason(err, path, a.scope.Volume) + if reason == "skipped_protected" || reason == "refused_network_volume" { + c.Status = "skipped_protected" + } + a.degrade(reason) + case filepath.Base(path) == "data" && !info.IsDir(), filepath.Base(path) != "data" && !info.Mode().IsRegular(): + c.Status, c.Presence = "unsupported", "unknown" + a.degrade("unsupported_entry") + } + } + a.audit.CredentialFiles = append(a.audit.CredentialFiles, c) + a.bytes += len(path) + 128 +} + +func (a *CondaConfigSnapshot) Finish() model.CondaConfigAudit { + if a.process != nil { + ids, partial := a.chain(nil, nil) + a.context(model.CondaConfigContext{Manager: "conda", ConfigSourceIDs: ids, SelectionStatus: condaSelection(partial)}) + } + for _, e := range a.environments { + ids, partial := a.chain(e.own, e.state) + a.context(model.CondaConfigContext{Manager: "conda", EnvironmentPath: e.prefix, ConfigSourceIDs: ids, SelectionStatus: condaSelection(e.partial || partial)}) + } + for _, f := range a.files { + // An incomplete source has no current settings or findings. + if f.Status != "present" { + f.Settings = []model.CargoConfigSetting{} + } + slices.Sort(f.Reasons) + slices.SortFunc(f.Settings, func(x, y model.CargoConfigSetting) int { + return strings.Compare(x.Key+"\x00"+x.Display, y.Key+"\x00"+y.Display) + }) + a.audit.Files = append(a.audit.Files, *f) + a.audit.Findings = append(a.audit.Findings, condaFindings(*f, a.literal[f.SourceID])...) + for _, r := range f.Reasons { + a.degrade(r) + } + } + slices.SortFunc(a.audit.Files, func(x, y model.CondaConfigFile) int { + return strings.Compare(x.Scope+"\x00"+x.Path, y.Scope+"\x00"+y.Path) + }) + // Two manifests in one directory share a project path; their chains order them. + slices.SortFunc(a.audit.Contexts, func(x, y model.CondaConfigContext) int { + return strings.Compare(x.EnvironmentPath+"\x00"+x.ProjectPath+"\x00"+strings.Join(x.ConfigSourceIDs, ","), y.EnvironmentPath+"\x00"+y.ProjectPath+"\x00"+strings.Join(y.ConfigSourceIDs, ",")) + }) + slices.SortFunc(a.audit.CredentialFiles, func(x, y model.CondaCredentialFile) int { return strings.Compare(x.Path, y.Path) }) + slices.SortFunc(a.audit.Findings, func(x, y model.CargoConfigFinding) int { + return strings.Compare(x.Code+x.SourceID+x.Key, y.Code+y.SourceID+y.Key) + }) + slices.Sort(a.audit.Reasons) + data, _ := json.Marshal(a.audit) + if len(data) > maxCondaAuditBytes { + a.audit = emptyCondaAudit() + a.degrade("output_size_limit") + } + return a.audit +} + +// condaFindings derives 001/002/004 from kept settings. 003 comes from the +// pre-redaction literal-credential flag and never carries secret text. +func condaFindings(f model.CondaConfigFile, literal bool) []model.CargoConfigFinding { + var out []model.CargoConfigFinding + seen := map[string]bool{} + add := func(code, key, detail string) { + if !seen[code+"\x00"+key] { + seen[code+"\x00"+key] = true + out = append(out, model.CargoConfigFinding{SourceID: f.SourceID, Code: code, Severity: "MEDIUM", Key: key, Detail: detail}) + } + } + for _, s := range f.Settings { + lower := strings.ToLower(s.Display) + channel := s.Key == "channel_alias" || strings.HasSuffix(s.Key, ".url") && !strings.HasPrefix(s.Key, "proxy_servers.") || strings.HasSuffix(s.Key, ".urls") + switch { + case channel && strings.Contains(lower, "http://"): + add("conda-001", s.Key, "Conda package channel uses an unencrypted connection.") + case s.Key == "ssl_verify" && s.Display == "false": + add("conda-002", s.Key, "TLS certificate verification is disabled by Conda configuration.") + case s.Key == "safety_checks" && s.Display == "disabled": + add("conda-004", s.Key, "Conda package safety checks are disabled.") + case s.Key == "auth_configured" && literal && f.Scope != "process": + add("conda-003", s.Key, "Credentials are embedded in Conda configuration.") + } + } + return out +} diff --git a/internal/detector/configaudit/condaconfig_test.go b/internal/detector/configaudit/condaconfig_test.go new file mode 100644 index 00000000..876f680f --- /dev/null +++ b/internal/detector/configaudit/condaconfig_test.go @@ -0,0 +1,546 @@ +package configaudit + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +type condaConfigMock struct{ *composerConfigMock } + +func (m *condaConfigMock) GuardedFiles([]string, func(string) string, int64) executor.Executor { + return m +} +func (m *condaConfigMock) ReadDirLimit(path string, max int) ([]os.DirEntry, bool, error) { + if !m.DirExists(path) { + return nil, false, os.ErrNotExist + } + return m.Mock.ReadDirLimit(path, max) +} +func condaConfigTest(t *testing.T) (*condaConfigMock, CondaConfigScope) { + t.Helper() + inner, scope := composerConfigTest(t) + return &condaConfigMock{inner}, CondaConfigScope{Username: scope.Username, Home: scope.Home, ProcessVerified: true} +} +func condaFile(t *testing.T, audit model.CondaConfigAudit, scope, path string) model.CondaConfigFile { + t.Helper() + for _, f := range audit.Files { + if f.Scope == scope && f.Path == path { + return f + } + } + t.Fatalf("no %s file %q in %+v", scope, path, audit.Files) + return model.CondaConfigFile{} +} +func condaSettings(f model.CondaConfigFile) map[string]string { + out := map[string]string{} + for _, s := range f.Settings { + out[s.Key] = s.Display + } + return out +} +func condaFindingKeys(audit model.CondaConfigAudit) map[string]bool { + got := map[string]bool{} + for _, f := range audit.Findings { + for _, file := range audit.Files { + if file.SourceID == f.SourceID { + got[f.Code+" "+file.Scope+" "+f.Key] = true + } + } + } + return got +} + +func TestCondaConfigAllowlistRedactionAndFindings(t *testing.T) { + m, scope := condaConfigTest(t) + home := scope.Home + m.SetEnv("HTTPS_PROXY", "https://alice:CANARY_PROXY@proxy.example.invalid:8443/?CANARY_QUERY") + m.SetEnv("http_proxy", "proxy.example.invalid:3128") + m.SetEnv("CONDA_CHANNEL_ALIAS", "https://conda.example.invalid/t/CANARY_TOKEN") + m.SetEnv("CONDA_CHANNELS", "conda-forge, http://user:CANARY_PASS@mirror.example.invalid/ch ,") + m.SetEnv("CONDA_ENVS_DIRS", filepath.Join(home, "envs-a")+":relative/envs:"+filepath.Join(home, "envs-a")) + m.SetEnv("CONDA_PKGS_DIRS", "~/pkgs-a,$HOME/pkgs-b") + m.SetEnv("CONDA_SSL_VERIFY", "truststore") + m.SetEnv("CONDA_SAFETY_CHECKS", "Warn") + m.SetEnv("CONDA_CANARY_UNKNOWN", "CANARY_UNKNOWN") + m.SetEnv("NO_PROXY", "CANARY_NOPROXY") + m.SetFile(filepath.Join(home, ".condarc"), []byte(`channels: + - http://mirror.example.invalid/ch + - https://user:CANARY_PASS@private.example.invalid/ch + - conda-forge + - bad channel +default_channels: + - https://repo.anaconda.com/pkgs/main +channel_alias: https://conda.anaconda.org/t/CANARY_TOKEN +custom_channels: + internal: https://internal.example.invalid/t/CANARY_TOKEN/conda + "weird@name": http://weird.example.invalid +custom_multichannels: + mine: + - https://a.example.invalid/x + - file:///srv/conda/mine +envs_dirs: + - ~/my-envs + - /opt/shared/envs + - relative/envs + - $CONDA_ROOT/envs +pkgs_dirs: ~/my-pkgs +ssl_verify: false +proxy_servers: + http: http://CANARY_USER:CANARY_PASS@proxy.example.invalid:8080/t/CANARY_PROXY_TOKEN + https://secure.example.invalid: proxy.example.invalid:8080 +offline: yes +extra_safety_checks: "off" +channel_priority: true +safety_checks: disabled +allowlist_channels: + - conda-forge + - https://user:CANARY_PASS@allowed.example.invalid +denylist_channels: [bad-channel] +cooldown: "2024-01-01" +auto_activate_base: false +CANARY_KEY: CANARY_VALUE +`)) + a := NewCondaConfigDetector(m).Detect(context.Background(), scope) + manifest := filepath.Join(home, "proj", "environment.yml") + partial := a.Manifest(manifest, []string{"conda-forge", "http://insecure.example.invalid/ch", "https://user:CANARY_PASS@private.example.invalid/ch"}, "present", nil) + audit := a.Finish() + data, _ := json.Marshal(audit) + if strings.Contains(string(data), "CANARY") { + t.Fatalf("canary survived: %s", data) + } + if !partial { + t.Error("manifest context ignored the partial ambient configuration") + } + if !slices.Equal(a.EnvsDirs, []string{filepath.Join(home, "envs-a"), filepath.Join(home, "my-envs"), "/opt/shared/envs"}) || !slices.Equal(a.PkgsDirs, []string{filepath.Join(home, "pkgs-a"), filepath.Join(home, "my-pkgs")}) { + t.Errorf("leads envs %v pkgs %v", a.EnvsDirs, a.PkgsDirs) + } + process := condaSettings(condaFile(t, audit, "process", "")) + for key, want := range map[string]string{ + "channel_alias": "https://conda.example.invalid", "channels.0.url": "conda-forge", "channels.1.url": "http://mirror.example.invalid/ch", + "envs_dirs": `["` + filepath.Join(home, "envs-a") + `","relative/envs","` + filepath.Join(home, "envs-a") + `"]`, "pkgs_dirs": `["~/pkgs-a","$HOME/pkgs-b"]`, + "ssl_verify": "truststore", "safety_checks": "warn", "auth_configured": "true", + "proxy_servers.0.scheme": "http", "proxy_servers.0.url": "http://proxy.example.invalid:3128", "proxy_servers.1.scheme": "https", "proxy_servers.1.url": "https://proxy.example.invalid:8443/", + } { + if process[key] != want { + t.Errorf("process %s=%q want %q", key, process[key], want) + } + } + if len(process) != 12 { + t.Errorf("process settings %+v", process) + } + user := condaFile(t, audit, "user", filepath.Join(home, ".condarc")) + settings := condaSettings(user) + for key, want := range map[string]string{ + "channels.0.url": "http://mirror.example.invalid/ch", "channels.1.url": "https://private.example.invalid/ch", "channels.2.url": "conda-forge", "channels.3.url": goRedacted, + "default_channels.0.url": "https://repo.anaconda.com/pkgs/main", "channel_alias": "https://conda.anaconda.org", + "custom_channels.0.name": "internal", "custom_channels.0.url": "https://internal.example.invalid/conda", "custom_channels.1.name": goRedacted, "custom_channels.1.url": "http://weird.example.invalid", + "custom_multichannels.0.name": "mine", "custom_multichannels.0.urls": `["https://a.example.invalid/x","file:///srv/conda/mine"]`, + "envs_dirs": `["~/my-envs","/opt/shared/envs","relative/envs","$CONDA_ROOT/envs"]`, "pkgs_dirs": `["~/my-pkgs"]`, + "ssl_verify": "false", "proxy_servers.0.scheme": "http", "proxy_servers.0.url": "http://proxy.example.invalid:8080", "proxy_servers.1.scheme": "https://secure.example.invalid", "proxy_servers.1.url": "http://proxy.example.invalid:8080", + "offline": "true", "extra_safety_checks": "false", "channel_priority": "flexible", "safety_checks": "disabled", + "allowlist_channels": `["conda-forge","https://allowed.example.invalid"]`, "denylist_channels": `["bad-channel"]`, "exclude_newer": "2024-01-01", "auth_configured": "true", + } { + if settings[key] != want { + t.Errorf("user %s=%q want %q", key, settings[key], want) + } + } + if len(settings) != 27 || user.Status != "present" || len(user.Reasons) != 0 { + t.Errorf("user file %+v", user) + } + if _, ok := settings["auto_activate_base"]; ok { + t.Error("non-allowlisted key was serialized") + } + project := condaSettings(condaFile(t, audit, "project", manifest)) + if project["channels.0.url"] != "conda-forge" || project["channels.1.url"] != "http://insecure.example.invalid/ch" || project["channels.2.url"] != "https://private.example.invalid/ch" || project["auth_configured"] != "true" || len(project) != 4 { + t.Errorf("project %+v", project) + } + got := condaFindingKeys(audit) + for _, want := range []string{ + "conda-001 user channels.0.url", "conda-001 user custom_channels.1.url", "conda-002 user ssl_verify", "conda-003 user auth_configured", "conda-004 user safety_checks", + "conda-001 project channels.1.url", "conda-003 project auth_configured", "conda-001 process channels.1.url", + } { + if !got[want] { + t.Errorf("missing finding %s in %v", want, got) + } + } + if len(got) != 8 { + t.Errorf("findings %v; process credentials and proxies must not raise findings", got) + } + for _, f := range audit.Findings { + if f.Severity != "MEDIUM" || strings.Contains(f.Detail, "CANARY") { + t.Errorf("finding %+v", f) + } + } + if len(audit.CredentialFiles) != 3 { + t.Errorf("credential files %+v", audit.CredentialFiles) + } + for _, c := range audit.CredentialFiles { + if c.Presence != "absent" || c.Status != "absent" { + t.Errorf("credential file %+v", c) + } + } + if len(audit.Contexts) != 2 { + t.Fatalf("contexts %+v", audit.Contexts) + } + ambient, proj := audit.Contexts[0], audit.Contexts[1] + if ambient.EnvironmentPath != "" || ambient.ProjectPath != "" || ambient.SelectionStatus != "partial" || ambient.ConfigSourceIDs[0] != condaFile(t, audit, "process", "").SourceID || !slices.Contains(ambient.ConfigSourceIDs, user.SourceID) { + t.Errorf("ambient context %+v", ambient) + } + if proj.ProjectPath != filepath.Dir(manifest) || proj.SelectionStatus != "partial" || len(proj.ConfigSourceIDs) != 1 { + t.Errorf("project context %+v", proj) + } + if audit.Status != "partial" || !slices.Equal(audit.Reasons, []string{"path_unresolved"}) { + t.Errorf("audit %s %v", audit.Status, audit.Reasons) + } +} + +func TestCondaConfigYAMLShapes(t *testing.T) { + for _, tc := range []struct { + name, in, status string + reasons []string + settings int + }{ + {"empty file", "", "present", nil, 0}, + {"comment flags ignored", "channels: #!final\n - conda-forge #!top\n", "present", nil, 1}, + {"alias conflict drops both", "channel:\n - a\nchannels:\n - b\n", "present", []string{"unsupported_format"}, 0}, + {"anchor on relevant key", "channels: &c\n - conda-forge\ndefault_channels: *c\n", "present", []string{"unsupported_format"}, 0}, + {"anchor on irrelevant key kept", "other: &o 1\nchannels:\n - conda-forge\n", "present", nil, 1}, + {"merge key", "<<: {channels: [x]}\nchannels:\n - conda-forge\n", "unsupported", []string{"unsupported_format"}, 0}, + {"channels mapping", "channels:\n a: b\n", "invalid", []string{"parse_error"}, 0}, + {"custom_channels list", "custom_channels:\n - x\n", "invalid", []string{"parse_error"}, 0}, + {"custom_channels multi-url", "custom_channels:\n x:\n - a\n - b\n", "invalid", []string{"parse_error"}, 0}, + {"empty ssl_verify", "ssl_verify:\n", "present", []string{"unsupported_format"}, 0}, + {"ca bundle path", "ssl_verify: /etc/ssl/CANARY@bundle.pem\n", "present", nil, 1}, + {"bad boolean", "offline: maybe\n", "invalid", []string{"parse_error"}, 0}, + {"bad enum", "safety_checks: sometimes\n", "invalid", []string{"parse_error"}, 0}, + {"channel_priority enum", "channel_priority: disabled\n", "present", nil, 1}, + {"token reference is not a literal credential", "channels:\n - https://example.invalid/t/${TOKEN}/private\n - https://$USER:%PASS%@example.invalid/ch\n", "present", nil, 2}, + {"alias without scheme", "channel_alias: conda.example.invalid\n", "invalid", []string{"parse_error"}, 0}, + {"exclude_newer unsupported", "exclude_newer: 'CANARY;rm -rf'\n", "present", []string{"unsupported_format"}, 0}, + {"exclude_newer unquoted timestamp", "exclude_newer: 2024-01-01\n", "present", []string{"unsupported_format"}, 0}, + {"scalar channel", "channels: conda-forge\n", "present", nil, 1}, + {"null channels", "channels:\n", "present", nil, 0}, + {"sequence root", "- a\n", "invalid", []string{"parse_error"}, 0}, + {"invalid utf8", "\xff", "invalid", []string{"parse_error"}, 0}, + {"block scalar", "channel_alias: |\n https://x\n", "present", []string{"unsupported_format"}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + m, scope := condaConfigTest(t) + path := filepath.Join(scope.Home, ".condarc") + m.SetFile(path, []byte(tc.in)) + audit := NewCondaConfigDetector(m).Detect(context.Background(), scope).Finish() + f := condaFile(t, audit, "user", path) + if f.Status != tc.status || !slices.Equal(f.Reasons, append([]string{}, tc.reasons...)) || len(f.Settings) != tc.settings { + t.Errorf("%+v", f) + } + if data, _ := json.Marshal(audit); strings.Contains(string(data), "CANARY") { + t.Errorf("canary survived: %s", data) + } + }) + } +} + +func TestCondaConfigSearchPathRootsAndEnvironments(t *testing.T) { + m, scope := condaConfigTest(t) + home := scope.Home + xdg := filepath.Join(home, "xdg") + m.SetEnv("XDG_CONFIG_HOME", xdg) + m.SetEnv("CONDARC", filepath.Join(home, "custom", "ci.yaml")) + m.SetFile("/etc/conda/.condarc", []byte("channels:\n - system\n")) + m.SetFile("/etc/conda/condarc", []byte("channels:\n - system-plain\n")) + m.SetDirEntries("/etc/conda/condarc.d", []os.DirEntry{executor.MockDirEntry("zz.yml", false), executor.MockDirEntry("aa.yaml", false), executor.MockDirEntry("notes.txt", false), executor.MockDirEntry("sub.yml", true)}) + m.SetFile("/etc/conda/condarc.d/aa.yaml", []byte("channels:\n - frag-a\n")) + m.SetFile("/etc/conda/condarc.d/zz.yml", []byte("channels:\n - frag-z\n")) + m.SetFile(filepath.Join(xdg, "conda", ".condarc"), []byte("channels:\n - xdg\n")) + m.SetFile(filepath.Join(home, ".conda", "condarc"), []byte("channels:\n - dotconda\n")) + m.SetFile(filepath.Join(home, ".condarc"), []byte("channels:\n - user\n")) + m.SetFile(filepath.Join(home, "custom", "ci.yaml"), []byte("channels:\n - ci\n")) + root := filepath.Join(home, "miniforge3") + m.SetFile(filepath.Join(root, ".condarc"), []byte("channels:\n - root\n")) + other := filepath.Join(home, "anaconda3") + m.SetFile(filepath.Join(other, "condarc"), []byte("channels:\n - other-root\n")) + env := filepath.Join(root, "envs", "app") + m.SetFile(filepath.Join(env, ".condarc"), []byte("channels:\n - env\n")) + m.SetFile(filepath.Join(env, "conda-meta", "state"), []byte(`{"env_vars":{"CONDA_SSL_VERIFY":"false","CONDA_CHANNELS":"***unset***","CONDA_VERIFY_SSL":"***unset***","HTTPS_PROXY":"https://user:CANARY@proxy.example.invalid:8443","CANARY_OTHER":"CANARY"}}`)) + a := NewCondaConfigDetector(m).Detect(context.Background(), scope) + a.Root(root, false) + a.Environment(env, false) + a.Environment(root, false) + a.Environment(env, false) + audit := a.Finish() + if data, _ := json.Marshal(audit); strings.Contains(string(data), "CANARY") { + t.Fatalf("canary survived: %s", data) + } + ids := map[string]string{} + for _, f := range audit.Files { + ids[f.SourceID] = f.Scope + ":" + strings.TrimPrefix(f.Path, home) + } + want := map[string][]string{ + "": {"process:", "user:/custom/ci.yaml", "user:/.condarc", "user:/.conda/condarc", "user:/.conda/.condarc", "user:/.config/conda/condarc", "user:/.config/conda/.condarc", "user:/xdg/conda/condarc", "user:/xdg/conda/.condarc", + "system:" + root[len(home):] + "/condarc", "system:" + root[len(home):] + "/.condarc", "system:/var/lib/conda/condarc", "system:/var/lib/conda/.condarc", + "system:/etc/conda/condarc.d/zz.yml", "system:/etc/conda/condarc.d/aa.yaml", "system:/etc/conda/condarc", "system:/etc/conda/.condarc"}, + } + want[env] = append([]string{"process:", "environment:" + env[len(home):] + "/conda-meta/state", "user:/custom/ci.yaml", "environment:" + env[len(home):] + "/condarc", "environment:" + env[len(home):] + "/.condarc"}, want[""][2:]...) + want[root] = append([]string{"process:", "environment:" + root[len(home):] + "/conda-meta/state", "user:/custom/ci.yaml"}, want[""][2:]...) + if len(audit.Contexts) != 3 { + t.Fatalf("contexts %+v", audit.Contexts) + } + for _, c := range audit.Contexts { + chain := []string{} + for _, id := range c.ConfigSourceIDs { + chain = append(chain, ids[id]) + } + if !slices.Equal(chain, want[c.EnvironmentPath]) || c.SelectionStatus != "observed" { + t.Errorf("context %q %s: %v", c.EnvironmentPath, c.SelectionStatus, chain) + } + } + state := condaFile(t, audit, "environment", filepath.Join(env, "conda-meta", "state")) + if s := condaSettings(state); s["ssl_verify"] != "false" || s["proxy_servers.0.url"] != "https://proxy.example.invalid:8443" || s["auth_configured"] != "true" || len(s) != 4 || state.Status != "present" { + t.Errorf("state %+v", state) + } + got := condaFindingKeys(audit) + if !got["conda-002 environment ssl_verify"] || !got["conda-003 environment auth_configured"] || len(got) != 2 { + t.Errorf("findings %v", got) + } + if f := condaFile(t, audit, "system", "/etc/conda/condarc.d/aa.yaml"); condaSettings(f)["channels.0.url"] != "frag-a" { + t.Errorf("fragment %+v", f) + } + for _, f := range audit.Files { + if strings.HasSuffix(f.Path, "notes.txt") || strings.HasSuffix(f.Path, "sub.yml") { + t.Errorf("non-fragment listed: %+v", f) + } + } + + // A second installation root with its own file makes the ambient chain + // ambiguous unless CONDA_ROOT names the active one. + a = NewCondaConfigDetector(m).Detect(context.Background(), scope) + a.Root(root, false) + a.Environment(env, false) + a.Root(other, false) + audit = a.Finish() + if audit.Contexts[0].SelectionStatus != "partial" { + t.Errorf("two roots left ambient %+v", audit.Contexts[0]) + } + // A root found after the environment still applies to it. + if c := audit.Contexts[1]; c.EnvironmentPath != env || c.SelectionStatus != "partial" || !slices.Contains(c.ConfigSourceIDs, condaFile(t, audit, "system", filepath.Join(other, "condarc")).SourceID) { + t.Errorf("environment context finalized early: %+v", c) + } + m.SetEnv("CONDA_ROOT", other) + a = NewCondaConfigDetector(m).Detect(context.Background(), scope) + a.Root(root, false) + a.Root(other, false) + audit = a.Finish() + if audit.Contexts[0].SelectionStatus != "observed" { + t.Errorf("CONDA_ROOT did not settle ambient %+v", audit.Contexts[0]) + } + if !slices.Contains(a.Prefixes, other) { + t.Errorf("prefix leads %v", a.Prefixes) + } + + // CONDARC that the loader would ignore is unresolved and never read. + m.SetEnv("CONDARC", filepath.Join(home, "custom", "ci.txt")) + m.SetFile(filepath.Join(home, "custom", "ci.txt"), []byte("channels:\n - CANARY_TXT\n")) + audit = NewCondaConfigDetector(m).Detect(context.Background(), scope).Finish() + if data, _ := json.Marshal(audit); strings.Contains(string(data), "CANARY") || !slices.Contains(audit.Reasons, "path_unresolved") { + t.Errorf("non-admitted CONDARC: %s %v", data, audit.Reasons) + } + // A partial root or environment leaves its context partial. + a = NewCondaConfigDetector(m).Detect(context.Background(), scope) + a.Root(root, true) + a.Environment(env, false) + for _, c := range a.Finish().Contexts { + if c.SelectionStatus != "partial" { + t.Errorf("partial root left context %+v", c) + } + } +} + +func TestCondaConfigProcessVariants(t *testing.T) { + t.Run("windows", func(t *testing.T) { + // Path joining follows the host; only the Windows-specific rules are + // exercised here. + m, scope := condaConfigTest(t) + m.SetGOOS(model.PlatformWindows) + home := scope.Home + envA, envB := filepath.Join(home, "envs-a"), filepath.Join(home, "envs-b") + local := filepath.Join(home, "AppData", "Local") + m.SetEnv("CONDA_ENVS_PATH", envA+";"+envB+";"+envA) + m.SetEnv("HTTP_PROXY", "http://proxy.example.invalid:3128") + m.SetEnv("http_proxy", "http://CANARY_LOWER:x@proxy.example.invalid:3128") + m.SetEnv("CONDA_CHANNEL_PRIORITY", "false") + m.SetEnv("PROGRAMDATA", filepath.Join(home, "CANARY_PROGRAMDATA")) + m.SetEnv("LOCALAPPDATA", local) + system := filepath.Join(`C:\ProgramData`, "conda", ".condarc") + m.SetFile(system, []byte("channels:\n - system\n")) + a := NewCondaConfigDetector(m).Detect(context.Background(), scope) + audit := a.Finish() + if data, _ := json.Marshal(audit); strings.Contains(string(data), "CANARY") { + t.Fatalf("canary survived: %s", data) + } + process := condaSettings(condaFile(t, audit, "process", "")) + dirs, _ := json.Marshal([]string{envA, envB, envA}) + if process["envs_dirs"] != string(dirs) || process["proxy_servers.0.url"] != "http://proxy.example.invalid:3128" || process["channel_priority"] != "disabled" || len(process) != 4 { + t.Errorf("process %+v", process) + } + if !slices.Equal(a.EnvsDirs, []string{envA, envB}) || a.Env("LOCALAPPDATA") != local { + t.Errorf("leads %v env %q", a.EnvsDirs, a.Env("LOCALAPPDATA")) + } + condaFile(t, audit, "system", system) + paths := []string{} + for _, c := range audit.CredentialFiles { + paths = append(paths, c.Path) + } + if !slices.Equal(paths, []string{filepath.Join(home, ".netrc"), filepath.Join(local, "ContinuumIO", "binstar", "data"), filepath.Join(home, "_netrc")}) { + t.Errorf("credential files %v", paths) + } + }) + t.Run("alias conflict", func(t *testing.T) { + m, scope := condaConfigTest(t) + m.SetEnv("CONDA_SSL_VERIFY", "false") + m.SetEnv("CONDA_VERIFY_SSL", "true") + m.SetEnv("CONDA_WHITELIST_CHANNELS", "conda-forge") + m.SetEnv("CONDA_OFFLINE", "sometimes") + m.SetEnv("CONDA_PREFIX", "relative/prefix") + audit := NewCondaConfigDetector(m).Detect(context.Background(), scope).Finish() + process := condaFile(t, audit, "process", "") + if len(process.Settings) != 0 || process.Status != "invalid" || !slices.Equal(slices.Sorted(slices.Values(process.Reasons)), []string{"parse_error", "unsupported_format"}) { + t.Errorf("process %+v", process) + } + if !slices.Contains(audit.Reasons, "path_unresolved") { + t.Errorf("relative prefix accepted: %v", audit.Reasons) + } + }) + t.Run("unverified process", func(t *testing.T) { + m, scope := condaConfigTest(t) + scope.ProcessVerified = false + m.SetEnv("CONDA_CHANNELS", "CANARY") + m.SetEnv("HTTPS_PROXY", "https://CANARY@x") + audit := NewCondaConfigDetector(m).Detect(context.Background(), scope).Finish() + process := condaFile(t, audit, "process", "") + if process.Status != "unsupported" || !slices.Equal(process.Reasons, []string{"user_unresolved"}) || len(process.Settings) != 0 { + t.Errorf("process %+v", process) + } + if data, _ := json.Marshal(audit); strings.Contains(string(data), "CANARY") || audit.Contexts[0].SelectionStatus != "partial" { + t.Errorf("unverified process: %s", data) + } + }) + t.Run("credential files present and guarded", func(t *testing.T) { + m, scope := condaConfigTest(t) + netrc := filepath.Join(scope.Home, ".netrc") + binstar := filepath.Join(scope.Home, ".config", "binstar", "data") + m.SetFile(netrc, []byte("machine x login CANARY password CANARY\n")) + m.SetDir(binstar) + a := NewCondaConfigDetector(m).Detect(context.Background(), scope) + if a.ContentGuard(netrc) != "skipped_protected" || a.ContentGuard(filepath.Join(binstar, "token")) != "skipped_protected" || a.ContentGuard(filepath.Join(scope.Home, ".condarc")) != "" { + t.Error("credential paths not guarded") + } + audit := a.Finish() + present := 0 + for _, c := range audit.CredentialFiles { + if c.Presence == "present" { + present++ + } + } + if present != 2 || audit.Status != "complete" { + t.Errorf("credential files %+v", audit.CredentialFiles) + } + }) +} + +func TestCondaSanitizeURL(t *testing.T) { + for _, tc := range []struct { + in, want string + redacted bool + }{ + {"https://conda.anaconda.org/conda-forge", "https://conda.anaconda.org/conda-forge", false}, + {"https://conda.anaconda.org/t/CANARY-token/conda-forge", "https://conda.anaconda.org/conda-forge", true}, + {"https://conda.anaconda.org/t/CANARY/t/CANARY2/x", "https://conda.anaconda.org/x", true}, + {"https://conda.anaconda.org/t/", "https://conda.anaconda.org", true}, + {"https://conda.anaconda.org/t/CANARY_under.score/x", "https://conda.anaconda.org/x", true}, + {"https://conda.anaconda.org%2Ft%2FCANARY/x", goRedacted, true}, + {"https://user:CANARY@repo.example.invalid/ch?x=CANARY#CANARY", "https://repo.example.invalid/ch", true}, + {"git@github.com:CANARY/repo.git", goRedacted, true}, + {"not a url\x00", goRedacted, true}, + {"conda-forge", "conda-forge", false}, + } { + got, redacted := CondaSanitizeURL(tc.in) + if got != tc.want || redacted != tc.redacted { + t.Errorf("CondaSanitizeURL(%q) = %q %v", tc.in, got, redacted) + } + } + for _, tc := range []struct { + in, want string + redacted bool + }{ + {"conda-forge", "conda-forge", false}, + {"pkgs/main", "pkgs/main", false}, + {"https://user:CANARY@x.example.invalid/ch", "https://x.example.invalid/ch", true}, + {"/srv/conda/local", "/srv/conda/local", false}, + {`C:\conda\local`, `C:\conda\local`, false}, + {"file:///srv/conda/local", "file:///srv/conda/local", false}, + {"bad channel", goRedacted, true}, + {"-leading", goRedacted, true}, + {strings.Repeat("a", 257), goRedacted, true}, + } { + got, redacted := CondaChannelDisplay(tc.in) + if got != tc.want || redacted != tc.redacted { + t.Errorf("CondaChannelDisplay(%q) = %q %v", tc.in, got, redacted) + } + } + for _, tc := range []struct { + in, want string + bad bool + }{ + {"http", "http", false}, + {"https://secure.example.invalid:8443", "https://secure.example.invalid:8443", false}, + {"https://user:CANARY@secure.example.invalid", goRedacted, true}, + {"https://secure.example.invalid/path", goRedacted, true}, + {"HTTP", goRedacted, true}, + } { + got, bad := condaProxySelector(tc.in) + if got != tc.want || bad != tc.bad { + t.Errorf("condaProxySelector(%q) = %q %v", tc.in, got, bad) + } + } +} + +func TestCondaConfigBudget(t *testing.T) { + old := maxCondaAuditBytes + t.Cleanup(func() { maxCondaAuditBytes = old }) + m, scope := condaConfigTest(t) + m.SetFile(filepath.Join(scope.Home, ".condarc"), []byte("channels:\n - conda-forge\n")) + maxCondaAuditBytes = 100 + audit := NewCondaConfigDetector(m).Detect(context.Background(), scope).Finish() + if audit.Status != "partial" || !slices.Contains(audit.Reasons, "output_size_limit") || len(audit.Files) != 0 { + t.Errorf("bounded audit %s %v %d files", audit.Status, audit.Reasons, len(audit.Files)) + } + maxCondaAuditBytes = 600 + audit = NewCondaConfigDetector(m).Detect(context.Background(), scope).Finish() + if audit.Status != "partial" || !slices.Contains(audit.Reasons, "output_size_limit") || len(audit.Files) == 0 { + t.Errorf("bounded audit kept nothing: %s %v", audit.Status, audit.Reasons) + } + maxCondaAuditBytes = old + entries := make([]os.DirEntry, condaSettingLimit+1) + for i := range entries { + entries[i] = executor.MockDirEntry("frag.yml", false) + } + m.SetDirEntries(filepath.Join(scope.Home, ".conda", "condarc.d"), entries) + audit = NewCondaConfigDetector(m).Detect(context.Background(), scope).Finish() + if !slices.Contains(audit.Reasons, "entry_limit") || audit.Contexts[0].SelectionStatus != "partial" { + t.Errorf("truncated fragment listing %v %+v", audit.Reasons, audit.Contexts) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + audit = NewCondaConfigDetector(m).Detect(ctx, scope).Finish() + if audit.Status != "partial" || !slices.Equal(audit.Reasons, []string{"deadline_exceeded"}) || len(audit.Files) != 0 { + t.Errorf("cancelled audit %s %v", audit.Status, audit.Reasons) + } +} diff --git a/internal/detector/fuzz_test.go b/internal/detector/fuzz_test.go index b62e021a..66d440ae 100644 --- a/internal/detector/fuzz_test.go +++ b/internal/detector/fuzz_test.go @@ -9,6 +9,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/detector/configaudit" "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" ) func FuzzNodeLockfiles(f *testing.F) { @@ -144,3 +145,88 @@ func FuzzRubyMetadata(f *testing.F) { } }) } + +// FuzzCondaMetadata feeds one input to every Conda parser: each must be +// deterministic, leave its input untouched, admit only valid identities and +// never pass a credential, token or query through to output. +func FuzzCondaMetadata(f *testing.F) { + for _, s := range []string{ + condaZlibReceipt, + `{"name":"x","version":"1","build":"0","channel":"https://u:p@h/t/tok/c/noarch","url":"https://u:p@h/t/tok/c/noarch/x-1-0.conda","requested_specs":["c::x","https://u:p@h/x"]}`, + "name: a\nchannels:\n - conda-forge\ndependencies:\n - python=3.12\n - conda-forge::numpy>=1.26\n - pip:\n - requests\n - sel(win): m2\n", + `{"channels":["conda-forge"],"dependencies":["python"]}`, + "# platform: linux-64\n@EXPLICIT\nhttps://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-h4bc722e_7.conda#" + condaMD5A + "\nfile:///c/noarch/x-1-0.tar.bz2\n", + "version: 1\npackage:\n- manager: conda\n name: x\n version: '1'\n platform: linux-64\n url: https://c/linux-64/x-1-0.conda\n hash:\n md5: " + condaMD5A + "\n", + "version: 6\nenvironments:\n default:\n packages:\n linux-64:\n - conda: https://c/linux-64/x-1-0.conda\npackages:\n - conda: https://c/linux-64/x-1-0.conda\n sha256: " + condaSHA_A + "\n", + "version: 7\nplatforms:\n - name: linux-64\nenvironments: {}\npackages: []\n", + "", "{", "\xff\x00", "a: &x [1]\nb: *x\n", + } { + f.Add([]byte(s)) + } + leak := func(s string) bool { + return strings.ContainsAny(s, "@?#") || strings.Contains(s, "/t/") || strings.Contains(s, "%2F") || strings.Contains(s, "%2f") + } + check := func(t *testing.T, pkgs []model.CondaPackage) { + t.Helper() + for _, p := range pkgs { + if _, ok := condaName(p.PackageName); !ok || condaVirtual(p.PackageName) { + t.Fatalf("invalid package name %q", p.PackageName) + } + if len(p.RequestedSpecs) > maxCondaRequestedSpecs { + t.Fatalf("%d requested specs", len(p.RequestedSpecs)) + } + for _, s := range append([]string{p.ArtifactURL, p.Origin.URL}, p.RequestedSpecs...) { + if s != condaRedacted && leak(s) { + t.Fatalf("credential text survived: %q", s) + } + } + for _, h := range p.RecordedChecksums { + if !condaChecksum(h.Algorithm, h.Value) { + t.Fatalf("invalid checksum %+v", h) + } + } + } + } + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 16*1024 { + t.Skip() + } + before := bytes.Clone(data) + r, err := parseCondaRecord(data) + if r2, err2 := parseCondaRecord(data); !reflect.DeepEqual(r, r2) || (err == nil) != (err2 == nil) { + t.Fatal("record parse is not deterministic") + } + if err == nil { + check(t, []model.CondaPackage{condaFact(r)}) + } + m, err := parseCondaEnvironment(data) + if m2, err2 := parseCondaEnvironment(data); !reflect.DeepEqual(m, m2) || (err == nil) != (err2 == nil) { + t.Fatal("manifest parse is not deterministic") + } + if err == nil { + for _, d := range m.declarations { + if _, ok := condaName(d.name); !ok || condaVirtual(d.name) || d.spec != condaRedacted && leak(d.spec) || d.channel != condaRedacted && leak(d.channel) { + t.Fatalf("invalid declaration %+v", d) + } + } + } + for _, parse := range []func([]byte) ([]condaLockEntry, []string, error){parseCondaExplicit, parseCondaLockYAML} { + entries, reasons, err := parse(data) + entries2, reasons2, err2 := parse(data) + if !reflect.DeepEqual(entries, entries2) || !slices.Equal(reasons, reasons2) || (err == nil) != (err2 == nil) { + t.Fatal("lock parse is not deterministic") + } + pkgs := []model.CondaPackage{} + for _, e := range entries { + if e.pkg.VersionStatus != "known" || e.pkg.Version == "" { + t.Fatalf("locked row without a version %+v", e.pkg) + } + pkgs = append(pkgs, e.pkg) + } + check(t, pkgs) + } + if !bytes.Equal(data, before) { + t.Fatal("parser mutated input") + } + }) +} diff --git a/internal/model/conda_golden_test.go b/internal/model/conda_golden_test.go new file mode 100644 index 00000000..9ae4dd1c --- /dev/null +++ b/internal/model/conda_golden_test.go @@ -0,0 +1,96 @@ +package model + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "reflect" + "strconv" + "testing" +) + +func TestCondaGoldenContract(t *testing.T) { + raw, err := os.ReadFile("testdata/conda_inventory_v1_golden.json") + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != "c1f8ecfe26760e3d92dd5210557fb14441ce7152e02fcbb62ad742b4cd22c491" { + t.Fatal("shared Conda fixture digest changed; update both producer and consumer after review") + } + var pair struct { + Inventory *CondaInventory `json:"conda_inventory"` + Audit *CondaConfigAudit `json:"conda_config_audit"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&pair); err != nil { + t.Fatal(err) + } + if pair.Inventory == nil || pair.Audit == nil { + t.Fatal("missing section") + } + encoded, err := json.Marshal(pair) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(decodeGeneric(t, encoded), decodeGeneric(t, raw)) { + t.Fatal("wire round-trip dropped a field") + } + seen := map[string]bool{} + for _, s := range pair.Inventory.Sources { + seen["status:"+s.Status], seen["kind:"+s.Kind] = true, true + for _, r := range s.Reasons { + seen["reason:"+r] = true + } + } + for _, p := range pair.Inventory.Packages { + seen["scope:"+p.Scope], seen["evidence:"+p.Evidence], seen["checksum:"+p.ChecksumStatus], seen["origin:"+p.Origin.Kind], seen["relation:"+p.DependencyRelation] = true, true, true, true, true + seen["subdir:"+p.Subdir], seen["version:"+p.VersionStatus] = true, true + if p.BuildNumber != nil { + seen["build_number:"+strconv.FormatInt(*p.BuildNumber, 10)] = true + } + if p.LockEnvironment != "" { + seen["lock_environment"] = true + } + if p.TargetPlatform != "" { + seen["target_platform"] = true + } + for _, s := range p.RequestedSpecs { + if s == "[redacted]" { + seen["redacted_spec"] = true + } + } + if p.Installation != nil { + seen["presence:"+p.Installation.Presence] = true + } + } + for _, c := range pair.Audit.Contexts { + switch { + case c.EnvironmentPath != "": + seen["context:environment"] = true + case c.ProjectPath != "": + seen["context:project"] = true + default: + seen["context:ambient"] = true + } + } + for _, f := range pair.Audit.Findings { + seen["finding:"+f.Code] = true + } + for _, key := range []string{ + "status:complete", "status:partial", "scope:project", "scope:user", + "kind:project_search_root", "kind:environment_root", "kind:environment", "kind:manifest", "kind:lockfile", "kind:installed_metadata", "kind:cache_root", "kind:cached_metadata", + "evidence:declared_requirement", "evidence:locked_package", "evidence:installed_package", "evidence:cached_package", + "checksum:recorded", "checksum:partial", "checksum:absent", "origin:channel", "origin:local", "origin:unknown", + "relation:direct", "relation:unknown", "version:known", "version:unknown", "subdir:noarch", "build_number:0", "lock_environment", "target_platform", "redacted_spec", "presence:present", + "reason:identity_mismatch", "reason:unsupported_format", "reason:extraction_incomplete", "reason:path_unresolved", "reason:checksum_invalid", "reason:parse_error", + "context:ambient", "context:environment", "context:project", "finding:conda-001", "finding:conda-002", "finding:conda-003", "finding:conda-004", + } { + if !seen[key] { + t.Errorf("fixture does not exercise %s", key) + } + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 668e61fb..3964549b 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1900,3 +1900,118 @@ type RubyConfigContext struct { ConfigSourceIDs []string `json:"config_source_ids"` // highest precedence first SelectionStatus string `json:"selection_status"` // observed | partial } + +// CondaInventorySchemaVersion versions conda_inventory and conda_config_audit. +const CondaInventorySchemaVersion = 1 + +// CondaSource reuses the Cargo source shape. Kinds: project_search_root | +// environment_root | environment | manifest | lockfile | installed_metadata | +// cache_root | cached_metadata. A cached_metadata path is the distribution's +// info directory, whichever metadata file inside it was readable. +type CondaSource = CargoSource + +// CondaRecordedChecksum is a recorded artifact digest (sha256 or md5), never +// a hash DMG computed or verified. +type CondaRecordedChecksum = ComposerRecordedChecksum + +// CondaCredentialFile reports existence only; contents are never read. +type CondaCredentialFile = CargoCredentialFile + +// CondaConfigFile reuses the Ruby file shape with manager "conda" and scope +// system | user | environment | project | process. +type CondaConfigFile = RubyConfigFile + +// CondaInventory covers Conda, Mamba, Micromamba and Pixi prefixes from +// passive metadata reads; no conda, mamba, python or shell runs. Packages are +// language-independent. Declarations, locks, prefix receipts and extracted +// caches stay separate evidence; an environment is its source plus each +// installed package's environment_path, never a separate array. +type CondaInventory struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` // complete | partial + Reasons []string `json:"reasons"` + Sources []CondaSource `json:"sources"` + Projects []CondaProject `json:"projects"` + Packages []CondaPackage `json:"packages"` +} + +// CondaProject is one recognized environment manifest. Conda has no +// manifest-to-lock linkage, so LockfilePaths lists the supported locks found +// beside it; a standalone lock has no project. +type CondaProject struct { + ManifestSourceID string `json:"manifest_source_id"` + ManifestPath string `json:"manifest_path"` + ProjectPath string `json:"project_path"` + SelectionStatus string `json:"selection_status"` // observed | partial + LockfilePaths []string `json:"lockfile_paths"` +} + +// CondaPackage is one observation. Name is lowercase with separators kept +// distinct; version and build are opaque; subdir is the recorded artifact +// platform (noarch is real), never the scanning host. +type CondaPackage struct { + SourceID string `json:"source_id"` + SourcePath string `json:"source_path"` + PackageName string `json:"package_name"` + Evidence string `json:"evidence"` // declared_requirement | locked_package | installed_package | cached_package + ProjectPath string `json:"project_path,omitempty"` + EnvironmentPath string `json:"environment_path,omitempty"` // installed evidence: the prefix + Scope string `json:"scope"` // project | user | system | unknown + Reasons []string `json:"reasons"` + VersionStatus string `json:"version_status"` // known | unknown (declarations) + Version string `json:"version,omitempty"` + Build string `json:"build,omitempty"` + Subdir string `json:"subdir,omitempty"` + BuildNumber *int64 `json:"build_number,omitempty"` // zero is a recorded value + // LockEnvironment and TargetPlatform are a lock's selection labels and + // part of occurrence identity; neither claims installation. + LockEnvironment string `json:"lock_environment,omitempty"` + TargetPlatform string `json:"target_platform,omitempty"` + RequestedSpecs []string `json:"requested_specs"` // sanitized, always an array + DependencyRelation string `json:"dependency_relation"` // direct | unknown + Origin CondaOrigin `json:"origin"` + ArtifactURL string `json:"artifact_url,omitempty"` // sanitized download URL, distinct from the channel + ChecksumStatus string `json:"checksum_status"` // absent | recorded | partial + RecordedChecksums []CondaRecordedChecksum `json:"recorded_checksums"` + Installation *CondaInstallation `json:"installation,omitempty"` // installed evidence only +} + +// CondaOrigin is a safely established channel. A bare label without a +// trustworthy recorded artifact mapping stays unknown rather than being +// rewritten with today's channel_alias. +type CondaOrigin struct { + Kind string `json:"kind"` // channel | local | unknown + URL string `json:"url,omitempty"` + Path string `json:"path,omitempty"` +} + +// CondaInstallation is prefix presence from the receipt observation, not +// payload integrity; Conda has no package-specific directory. +type CondaInstallation struct { + Path string `json:"path"` + PathStatus string `json:"path_status"` // resolved + Presence string `json:"presence"` // present | absent | unknown +} + +// CondaConfigAudit follows the Ruby observed-source model: per-file settings +// with ordered applicable source IDs per context, never one effective view. +type CondaConfigAudit struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` // complete | partial + Reasons []string `json:"reasons"` + Files []CondaConfigFile `json:"files"` + Contexts []CondaConfigContext `json:"contexts"` + CredentialFiles []CondaCredentialFile `json:"credential_files"` + Findings []CargoConfigFinding `json:"findings"` +} + +// CondaConfigContext is the precedence chain for one target: the ambient +// chain has neither path, a discovered prefix has EnvironmentPath, and a +// recognized manifest's declared channels have ProjectPath. +type CondaConfigContext struct { + Manager string `json:"manager"` // conda + EnvironmentPath string `json:"environment_path,omitempty"` + ProjectPath string `json:"project_path,omitempty"` + ConfigSourceIDs []string `json:"config_source_ids"` // highest precedence first + SelectionStatus string `json:"selection_status"` // observed | partial +} diff --git a/internal/model/testdata/conda_inventory_v1_golden.json b/internal/model/testdata/conda_inventory_v1_golden.json new file mode 100644 index 00000000..cb2be948 --- /dev/null +++ b/internal/model/testdata/conda_inventory_v1_golden.json @@ -0,0 +1,2785 @@ +{ + "conda_inventory": { + "schema_version": 1, + "status": "partial", + "reasons": [ + "checksum_invalid", + "extraction_incomplete", + "identity_mismatch", + "parse_error", + "path_unresolved", + "unsupported_format" + ], + "sources": [ + { + "source_id": "266f67373bf5dc891480aa8476916fcc532cdae720870f240a266c7186445608", + "kind": "environment_root", + "path": "/home/conda-fixture/.conda", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [ + "5095d71153a7a9dd9325f7e6d68dde71a15f69375cff45892dd65c59e77ac369", + "590b2b4eec96442d5c5377f70557ec07bbefd9909f7bb8e39ca4046e1e855322" + ] + }, + { + "source_id": "590b2b4eec96442d5c5377f70557ec07bbefd9909f7bb8e39ca4046e1e855322", + "kind": "environment", + "path": "/home/conda-fixture/.conda/envs/old", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "266f67373bf5dc891480aa8476916fcc532cdae720870f240a266c7186445608", + "discovered_sources": [] + }, + { + "source_id": "5095d71153a7a9dd9325f7e6d68dde71a15f69375cff45892dd65c59e77ac369", + "kind": "cache_root", + "path": "/home/conda-fixture/.conda/pkgs", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "266f67373bf5dc891480aa8476916fcc532cdae720870f240a266c7186445608", + "discovered_sources": [] + }, + { + "source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "kind": "project_search_root", + "path": "/home/conda-fixture/code", + "status": "partial", + "presence": "present", + "reasons": [ + "checksum_invalid", + "parse_error", + "path_unresolved", + "unsupported_format" + ], + "discovered_sources": [ + "8568924ffe60831511f5475f4551661b50d5f1a3b59abf69d82f2b0eec930b2c", + "8882f2f353a0f5fab7d5b5c93b358ceb516bb68f3aedf78a784f54a7a4fb4365", + "92c28c0ba712bfb535e951797e04a774844a97103e725e301c747562ee7394c8", + "b0d66dc8152740865f0a2e2b86436817a30ce99fca52fb188a4fe055bb66dedd", + "b840da77d8f9482a0939bfb8f1fc6da557d6f4d8ee3776a83954c6554d8f1f3e", + "d9a75864c8f41e89b10bdf5b56a7000918aedc7e5bf94850766c73e477a51a6f", + "e5f3f1c7edfefe89d273e70b9499c9d8f1c06d5e20c82b550cb065c3107a8503", + "ecb5f6f5ec7650b18091fab5e3c543516242acad7ecbb4d38d406659ea1bab9d", + "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "f5f006a186c729de2b3b53b8de79bdfe06462f89795c336e47c85153940a46a4" + ] + }, + { + "source_id": "92c28c0ba712bfb535e951797e04a774844a97103e725e301c747562ee7394c8", + "kind": "manifest", + "path": "/home/conda-fixture/code/legacy/environment.yaml", + "status": "partial", + "presence": "present", + "reasons": [ + "unsupported_format" + ], + "parent_source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "discovered_sources": [] + }, + { + "source_id": "e5f3f1c7edfefe89d273e70b9499c9d8f1c06d5e20c82b550cb065c3107a8503", + "kind": "lockfile", + "path": "/home/conda-fixture/code/lockonly/conda-lock.yaml", + "status": "partial", + "presence": "present", + "reasons": [ + "unsupported_format" + ], + "parent_source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "discovered_sources": [] + }, + { + "source_id": "8568924ffe60831511f5475f4551661b50d5f1a3b59abf69d82f2b0eec930b2c", + "kind": "environment", + "path": "/home/conda-fixture/code/proj/.conda", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "discovered_sources": [ + "7eec6a439266f25fff0fa95bff382f6482dc77d686ee26eb5c42a1e7148cc150" + ] + }, + { + "source_id": "7eec6a439266f25fff0fa95bff382f6482dc77d686ee26eb5c42a1e7148cc150", + "kind": "installed_metadata", + "path": "/home/conda-fixture/code/proj/.conda/conda-meta/tzdata-2024b-hc8b5060_0.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "8568924ffe60831511f5475f4551661b50d5f1a3b59abf69d82f2b0eec930b2c", + "discovered_sources": [] + }, + { + "source_id": "8882f2f353a0f5fab7d5b5c93b358ceb516bb68f3aedf78a784f54a7a4fb4365", + "kind": "environment", + "path": "/home/conda-fixture/code/proj/.pixi/envs/default", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "discovered_sources": [ + "8b81732cb2f96e82db8406a9866e79fdfa721669b7f67b5c2e87f314f2d8d0d2" + ] + }, + { + "source_id": "8b81732cb2f96e82db8406a9866e79fdfa721669b7f67b5c2e87f314f2d8d0d2", + "kind": "installed_metadata", + "path": "/home/conda-fixture/code/proj/.pixi/envs/default/conda-meta/bzip2-1.0.8-h99b78c6_7.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "8882f2f353a0f5fab7d5b5c93b358ceb516bb68f3aedf78a784f54a7a4fb4365", + "discovered_sources": [] + }, + { + "source_id": "b840da77d8f9482a0939bfb8f1fc6da557d6f4d8ee3776a83954c6554d8f1f3e", + "kind": "lockfile", + "path": "/home/conda-fixture/code/proj/conda-dev.lock", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "discovered_sources": [] + }, + { + "source_id": "f5f006a186c729de2b3b53b8de79bdfe06462f89795c336e47c85153940a46a4", + "kind": "lockfile", + "path": "/home/conda-fixture/code/proj/conda-lock.yml", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "discovered_sources": [] + }, + { + "source_id": "d9a75864c8f41e89b10bdf5b56a7000918aedc7e5bf94850766c73e477a51a6f", + "kind": "manifest", + "path": "/home/conda-fixture/code/proj/environment.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "discovered_sources": [] + }, + { + "source_id": "ecb5f6f5ec7650b18091fab5e3c543516242acad7ecbb4d38d406659ea1bab9d", + "kind": "manifest", + "path": "/home/conda-fixture/code/proj/environment.yml", + "status": "partial", + "presence": "present", + "reasons": [ + "unsupported_format" + ], + "parent_source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "discovered_sources": [] + }, + { + "source_id": "b0d66dc8152740865f0a2e2b86436817a30ce99fca52fb188a4fe055bb66dedd", + "kind": "lockfile", + "path": "/home/conda-fixture/code/proj/explicit.txt", + "status": "partial", + "presence": "present", + "reasons": [ + "checksum_invalid", + "path_unresolved", + "unsupported_format" + ], + "parent_source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "discovered_sources": [] + }, + { + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "kind": "lockfile", + "path": "/home/conda-fixture/code/proj/pixi.lock", + "status": "partial", + "presence": "present", + "reasons": [ + "parse_error", + "path_unresolved", + "unsupported_format" + ], + "parent_source_id": "169b92ae182586d6f8fdcb30c48f8c3e958231e6899504caf1dd614bf08d7f54", + "discovered_sources": [] + }, + { + "source_id": "e7ec231863b1bc8edab1b685308e90dd04f182fad925819dac32e79edf614572", + "kind": "environment", + "path": "/home/conda-fixture/elsewhere/env", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [ + "bb32d82dc06eb9c8097c67967b5e9df0d14a4d3fc8dfd02e9e7c04a340987899" + ] + }, + { + "source_id": "bb32d82dc06eb9c8097c67967b5e9df0d14a4d3fc8dfd02e9e7c04a340987899", + "kind": "installed_metadata", + "path": "/home/conda-fixture/elsewhere/env/conda-meta/tzdata-2024b-hc8b5060_0.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "e7ec231863b1bc8edab1b685308e90dd04f182fad925819dac32e79edf614572", + "discovered_sources": [] + }, + { + "source_id": "da693a486ea65aeea89bab365b6eef47d5c51705ce9747b681b7e01dba81add2", + "kind": "environment", + "path": "/home/conda-fixture/extra-envs/lead", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [ + "8266022daf7ecb5cbfd81157b6fc455ea9302e1ec42b6a9f968c5cf482567946" + ] + }, + { + "source_id": "8266022daf7ecb5cbfd81157b6fc455ea9302e1ec42b6a9f968c5cf482567946", + "kind": "installed_metadata", + "path": "/home/conda-fixture/extra-envs/lead/conda-meta/tzdata-2024b-hc8b5060_0.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "da693a486ea65aeea89bab365b6eef47d5c51705ce9747b681b7e01dba81add2", + "discovered_sources": [] + }, + { + "source_id": "ea9d821958bb278f3e36c927e88e22c145566195e8241996074d27ce16fe487d", + "kind": "cache_root", + "path": "/home/conda-fixture/extra-pkgs", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [ + "73e5a52ccb3f4340673dba807732638de9f420ecc0b403c9fe0c6bec35aba9b1" + ] + }, + { + "source_id": "73e5a52ccb3f4340673dba807732638de9f420ecc0b403c9fe0c6bec35aba9b1", + "kind": "cached_metadata", + "path": "/home/conda-fixture/extra-pkgs/bzip2-1.0.8-h99b78c6_7/info", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "ea9d821958bb278f3e36c927e88e22c145566195e8241996074d27ce16fe487d", + "discovered_sources": [] + }, + { + "source_id": "6396a85b7203ff38792b3d6fff47d5e34a52e026e6be07439bed7b3406766968", + "kind": "environment_root", + "path": "/home/conda-fixture/micromamba", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [ + "30cd09f26710bf9a4fc31c9c0e22d4ed9e683bbbb8f88088dc0101d63560f3be", + "546b6c9a72268becf2d609ea85a89feecad8befed9c24ea232ec03cd231ad136" + ] + }, + { + "source_id": "30cd09f26710bf9a4fc31c9c0e22d4ed9e683bbbb8f88088dc0101d63560f3be", + "kind": "environment", + "path": "/home/conda-fixture/micromamba/envs/mm", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6396a85b7203ff38792b3d6fff47d5e34a52e026e6be07439bed7b3406766968", + "discovered_sources": [ + "0ae9ababd8509cfcfd6bf6c179c8a21f1cd14b5e6c6ade0bbe2611d6f6daa870" + ] + }, + { + "source_id": "0ae9ababd8509cfcfd6bf6c179c8a21f1cd14b5e6c6ade0bbe2611d6f6daa870", + "kind": "installed_metadata", + "path": "/home/conda-fixture/micromamba/envs/mm/conda-meta/tzdata-2024b-hc8b5060_0.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "30cd09f26710bf9a4fc31c9c0e22d4ed9e683bbbb8f88088dc0101d63560f3be", + "discovered_sources": [] + }, + { + "source_id": "546b6c9a72268becf2d609ea85a89feecad8befed9c24ea232ec03cd231ad136", + "kind": "cache_root", + "path": "/home/conda-fixture/micromamba/pkgs", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6396a85b7203ff38792b3d6fff47d5e34a52e026e6be07439bed7b3406766968", + "discovered_sources": [ + "65faf540894f460bf17ffd8d4b48e13cc8104f3547614fa111d61485c23c860e" + ] + }, + { + "source_id": "65faf540894f460bf17ffd8d4b48e13cc8104f3547614fa111d61485c23c860e", + "kind": "cached_metadata", + "path": "/home/conda-fixture/micromamba/pkgs/https/conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0/info", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "546b6c9a72268becf2d609ea85a89feecad8befed9c24ea232ec03cd231ad136", + "discovered_sources": [] + }, + { + "source_id": "8dc721ae571bee611bdd909718abdf2e2bd9bab27066a89da47da3062d804433", + "kind": "environment", + "path": "/home/conda-fixture/miniforge3", + "status": "partial", + "presence": "present", + "reasons": [ + "identity_mismatch", + "parse_error" + ], + "parent_source_id": "4e3181daa840753de0e00f23864850f814f84bb1d6c5cfbaa4e4428a381c5895", + "discovered_sources": [ + "28567b858752581faee43baa3b8f6601ab39dbec21cfcdc5083af1b5f2a8599e", + "3de8bf25aa0f52a9a71dd53b7fec7deb0c68e9c938721095b43adfcfaf7172cb", + "67fc599074c9aeb3bd787b82b3389011ff4571e6234e2f04423e7e1f2c671099", + "8d17336200358fa5f7177968135a9c4a9f1444caab629d30be91d3c644daaf95" + ] + }, + { + "source_id": "4e3181daa840753de0e00f23864850f814f84bb1d6c5cfbaa4e4428a381c5895", + "kind": "environment_root", + "path": "/home/conda-fixture/miniforge3", + "status": "partial", + "presence": "present", + "reasons": [ + "checksum_invalid", + "extraction_incomplete", + "identity_mismatch", + "parse_error", + "unsupported_format" + ], + "discovered_sources": [ + "26968fd601fe9a0c5e9391f1ae73450ad3147be6afa0467b9ac850b4d5452167", + "8133a77545a0f880bd67c7a3c67117cf8a70e117e46e5b5350b64708b3c7fb32", + "848afec415da3c5b8d15de7836f2c8847d20204f472a9a6e52fd1df5669c2867", + "8dc721ae571bee611bdd909718abdf2e2bd9bab27066a89da47da3062d804433", + "f4edad469ac296456cf650e3e36d71248cbe1f477fa5104e9acba134abda9ea0" + ] + }, + { + "source_id": "28567b858752581faee43baa3b8f6601ab39dbec21cfcdc5083af1b5f2a8599e", + "kind": "installed_metadata", + "path": "/home/conda-fixture/miniforge3/conda-meta/_openmp_mutex-4.5-2_gnu.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "8dc721ae571bee611bdd909718abdf2e2bd9bab27066a89da47da3062d804433", + "discovered_sources": [] + }, + { + "source_id": "3de8bf25aa0f52a9a71dd53b7fec7deb0c68e9c938721095b43adfcfaf7172cb", + "kind": "installed_metadata", + "path": "/home/conda-fixture/miniforge3/conda-meta/broken-1.0-0.json", + "status": "partial", + "presence": "present", + "reasons": [ + "parse_error" + ], + "parent_source_id": "8dc721ae571bee611bdd909718abdf2e2bd9bab27066a89da47da3062d804433", + "discovered_sources": [] + }, + { + "source_id": "67fc599074c9aeb3bd787b82b3389011ff4571e6234e2f04423e7e1f2c671099", + "kind": "installed_metadata", + "path": "/home/conda-fixture/miniforge3/conda-meta/conda-launchers-1.0.0-h57928b3_0.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "8dc721ae571bee611bdd909718abdf2e2bd9bab27066a89da47da3062d804433", + "discovered_sources": [] + }, + { + "source_id": "8d17336200358fa5f7177968135a9c4a9f1444caab629d30be91d3c644daaf95", + "kind": "installed_metadata", + "path": "/home/conda-fixture/miniforge3/conda-meta/renamed-1.0-0.json", + "status": "partial", + "presence": "present", + "reasons": [ + "identity_mismatch" + ], + "parent_source_id": "8dc721ae571bee611bdd909718abdf2e2bd9bab27066a89da47da3062d804433", + "discovered_sources": [] + }, + { + "source_id": "f4edad469ac296456cf650e3e36d71248cbe1f477fa5104e9acba134abda9ea0", + "kind": "environment", + "path": "/home/conda-fixture/miniforge3/envs/app", + "status": "partial", + "presence": "present", + "reasons": [ + "checksum_invalid" + ], + "parent_source_id": "4e3181daa840753de0e00f23864850f814f84bb1d6c5cfbaa4e4428a381c5895", + "discovered_sources": [ + "088aee0136ff849dc07e9f3ac5f9f329f2c3e1e2313c35b38595e52f75fe64d8", + "5be61c62f0aeb093e2c43b57e7471562846395315697b2053fcfba6a80334924", + "6e78693c21dbcf7c38ae8283b6c35c38affac5b61327933ca1a5ad162e3e62d7", + "7e8790301fe206973d89fe936416f826046c84a7c2033c188bdcde1be5555802", + "c67c447db0f264c541d513759e109da8f0e4ccf1a46b813e213ddc2c2e08cd15" + ] + }, + { + "source_id": "088aee0136ff849dc07e9f3ac5f9f329f2c3e1e2313c35b38595e52f75fe64d8", + "kind": "installed_metadata", + "path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/ansi2html-1.9.5-py3_none_any_0.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "f4edad469ac296456cf650e3e36d71248cbe1f477fa5104e9acba134abda9ea0", + "discovered_sources": [] + }, + { + "source_id": "7e8790301fe206973d89fe936416f826046c84a7c2033c188bdcde1be5555802", + "kind": "installed_metadata", + "path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/badsum-1.0-0.json", + "status": "partial", + "presence": "present", + "reasons": [ + "checksum_invalid" + ], + "parent_source_id": "f4edad469ac296456cf650e3e36d71248cbe1f477fa5104e9acba134abda9ea0", + "discovered_sources": [] + }, + { + "source_id": "6e78693c21dbcf7c38ae8283b6c35c38affac5b61327933ca1a5ad162e3e62d7", + "kind": "installed_metadata", + "path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/packaging-24.1-pyhd8ed1ab_0.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "f4edad469ac296456cf650e3e36d71248cbe1f477fa5104e9acba134abda9ea0", + "discovered_sources": [] + }, + { + "source_id": "5be61c62f0aeb093e2c43b57e7471562846395315697b2053fcfba6a80334924", + "kind": "installed_metadata", + "path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/secret-1.0-0.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "f4edad469ac296456cf650e3e36d71248cbe1f477fa5104e9acba134abda9ea0", + "discovered_sources": [] + }, + { + "source_id": "c67c447db0f264c541d513759e109da8f0e4ccf1a46b813e213ddc2c2e08cd15", + "kind": "installed_metadata", + "path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/zlib-1.3.1-h4e2bd7c_1.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "f4edad469ac296456cf650e3e36d71248cbe1f477fa5104e9acba134abda9ea0", + "discovered_sources": [] + }, + { + "source_id": "8133a77545a0f880bd67c7a3c67117cf8a70e117e46e5b5350b64708b3c7fb32", + "kind": "environment", + "path": "/home/conda-fixture/miniforge3/envs/empty", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "4e3181daa840753de0e00f23864850f814f84bb1d6c5cfbaa4e4428a381c5895", + "discovered_sources": [] + }, + { + "source_id": "848afec415da3c5b8d15de7836f2c8847d20204f472a9a6e52fd1df5669c2867", + "kind": "environment", + "path": "/home/conda-fixture/miniforge3/envs/nohistory", + "status": "partial", + "presence": "present", + "reasons": [ + "extraction_incomplete" + ], + "parent_source_id": "4e3181daa840753de0e00f23864850f814f84bb1d6c5cfbaa4e4428a381c5895", + "discovered_sources": [ + "1042b2d3f1008bf6e471905758dcd8a65f0703dbf35d5f5aa3c63c58ba5ed72d" + ] + }, + { + "source_id": "1042b2d3f1008bf6e471905758dcd8a65f0703dbf35d5f5aa3c63c58ba5ed72d", + "kind": "installed_metadata", + "path": "/home/conda-fixture/miniforge3/envs/nohistory/conda-meta/tzdata-2024b-hc8b5060_0.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "848afec415da3c5b8d15de7836f2c8847d20204f472a9a6e52fd1df5669c2867", + "discovered_sources": [] + }, + { + "source_id": "26968fd601fe9a0c5e9391f1ae73450ad3147be6afa0467b9ac850b4d5452167", + "kind": "cache_root", + "path": "/home/conda-fixture/miniforge3/pkgs", + "status": "partial", + "presence": "present", + "reasons": [ + "parse_error", + "unsupported_format" + ], + "parent_source_id": "4e3181daa840753de0e00f23864850f814f84bb1d6c5cfbaa4e4428a381c5895", + "discovered_sources": [ + "6ec6d9deeda82a8c15f90197596e4daaee61d758e1d18e35dbf63168095487f9", + "7edc5fbd88f02c1e8dbae3d221010b0f3464eea40f3005ac46e9f085cd7b5f6d", + "b984f4e0492abcf8510c8eec68789e20ab8c6419553573d8e3997bcc1008bdb7" + ] + }, + { + "source_id": "7edc5fbd88f02c1e8dbae3d221010b0f3464eea40f3005ac46e9f085cd7b5f6d", + "kind": "cached_metadata", + "path": "/home/conda-fixture/miniforge3/pkgs/broken-1.0-0/info", + "status": "partial", + "presence": "present", + "reasons": [ + "parse_error" + ], + "parent_source_id": "26968fd601fe9a0c5e9391f1ae73450ad3147be6afa0467b9ac850b4d5452167", + "discovered_sources": [] + }, + { + "source_id": "6ec6d9deeda82a8c15f90197596e4daaee61d758e1d18e35dbf63168095487f9", + "kind": "cached_metadata", + "path": "/home/conda-fixture/miniforge3/pkgs/packaging-24.1-pyhd8ed1ab_0/info", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "26968fd601fe9a0c5e9391f1ae73450ad3147be6afa0467b9ac850b4d5452167", + "discovered_sources": [] + }, + { + "source_id": "b984f4e0492abcf8510c8eec68789e20ab8c6419553573d8e3997bcc1008bdb7", + "kind": "cached_metadata", + "path": "/home/conda-fixture/miniforge3/pkgs/zlib-1.3.1-h4e2bd7c_1/info", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "26968fd601fe9a0c5e9391f1ae73450ad3147be6afa0467b9ac850b4d5452167", + "discovered_sources": [] + } + ], + "projects": [ + { + "manifest_source_id": "92c28c0ba712bfb535e951797e04a774844a97103e725e301c747562ee7394c8", + "manifest_path": "/home/conda-fixture/code/legacy/environment.yaml", + "project_path": "/home/conda-fixture/code/legacy", + "selection_status": "partial", + "lockfile_paths": [] + }, + { + "manifest_source_id": "d9a75864c8f41e89b10bdf5b56a7000918aedc7e5bf94850766c73e477a51a6f", + "manifest_path": "/home/conda-fixture/code/proj/environment.json", + "project_path": "/home/conda-fixture/code/proj", + "selection_status": "observed", + "lockfile_paths": [ + "/home/conda-fixture/code/proj/conda-dev.lock", + "/home/conda-fixture/code/proj/conda-lock.yml", + "/home/conda-fixture/code/proj/explicit.txt", + "/home/conda-fixture/code/proj/pixi.lock" + ] + }, + { + "manifest_source_id": "ecb5f6f5ec7650b18091fab5e3c543516242acad7ecbb4d38d406659ea1bab9d", + "manifest_path": "/home/conda-fixture/code/proj/environment.yml", + "project_path": "/home/conda-fixture/code/proj", + "selection_status": "partial", + "lockfile_paths": [ + "/home/conda-fixture/code/proj/conda-dev.lock", + "/home/conda-fixture/code/proj/conda-lock.yml", + "/home/conda-fixture/code/proj/explicit.txt", + "/home/conda-fixture/code/proj/pixi.lock" + ] + } + ], + "packages": [ + { + "source_id": "92c28c0ba712bfb535e951797e04a774844a97103e725e301c747562ee7394c8", + "source_path": "/home/conda-fixture/code/legacy/environment.yaml", + "package_name": "python", + "evidence": "declared_requirement", + "project_path": "/home/conda-fixture/code/legacy", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_specs": [ + "python" + ], + "dependency_relation": "direct", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "7eec6a439266f25fff0fa95bff382f6482dc77d686ee26eb5c42a1e7148cc150", + "source_path": "/home/conda-fixture/code/proj/.conda/conda-meta/tzdata-2024b-hc8b5060_0.json", + "package_name": "tzdata", + "evidence": "installed_package", + "project_path": "/home/conda-fixture/code/proj", + "environment_path": "/home/conda-fixture/code/proj/.conda", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "build_number": 0, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "7eec6a439266f25fff0fa95bff382f6482dc77d686ee26eb5c42a1e7148cc150", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/code/proj/.conda/conda-meta/tzdata-2024b-hc8b5060_0.json", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "7eec6a439266f25fff0fa95bff382f6482dc77d686ee26eb5c42a1e7148cc150", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/code/proj/.conda/conda-meta/tzdata-2024b-hc8b5060_0.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/code/proj/.conda", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "8b81732cb2f96e82db8406a9866e79fdfa721669b7f67b5c2e87f314f2d8d0d2", + "source_path": "/home/conda-fixture/code/proj/.pixi/envs/default/conda-meta/bzip2-1.0.8-h99b78c6_7.json", + "package_name": "bzip2", + "evidence": "installed_package", + "project_path": "/home/conda-fixture/code/proj", + "environment_path": "/home/conda-fixture/code/proj/.pixi/envs/default", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.0.8", + "build": "h99b78c6_7", + "subdir": "osx-arm64", + "build_number": 0, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://prefix.dev/conda-forge" + }, + "artifact_url": "https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-h99b78c6_7.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "8b81732cb2f96e82db8406a9866e79fdfa721669b7f67b5c2e87f314f2d8d0d2", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/code/proj/.pixi/envs/default/conda-meta/bzip2-1.0.8-h99b78c6_7.json", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "8b81732cb2f96e82db8406a9866e79fdfa721669b7f67b5c2e87f314f2d8d0d2", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/code/proj/.pixi/envs/default/conda-meta/bzip2-1.0.8-h99b78c6_7.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/code/proj/.pixi/envs/default", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "b840da77d8f9482a0939bfb8f1fc6da557d6f4d8ee3776a83954c6554d8f1f3e", + "source_path": "/home/conda-fixture/code/proj/conda-dev.lock", + "package_name": "_libgcc_mutex", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "0.1", + "build": "conda_forge", + "subdir": "linux-64", + "target_platform": "linux-64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "b840da77d8f9482a0939bfb8f1fc6da557d6f4d8ee3776a83954c6554d8f1f3e", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/conda-dev.lock", + "verification": "not_verified" + } + ] + }, + { + "source_id": "f5f006a186c729de2b3b53b8de79bdfe06462f89795c336e47c85153940a46a4", + "source_path": "/home/conda-fixture/code/proj/conda-lock.yml", + "package_name": "_libgcc_mutex", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "0.1", + "build": "conda_forge", + "subdir": "linux-64", + "target_platform": "linux-64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "f5f006a186c729de2b3b53b8de79bdfe06462f89795c336e47c85153940a46a4", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/conda-lock.yml", + "verification": "not_verified" + } + ] + }, + { + "source_id": "f5f006a186c729de2b3b53b8de79bdfe06462f89795c336e47c85153940a46a4", + "source_path": "/home/conda-fixture/code/proj/conda-lock.yml", + "package_name": "private", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.0", + "build": "0", + "subdir": "linux-64", + "target_platform": "linux-64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/private" + }, + "artifact_url": "https://conda.anaconda.org/private/linux-64/private-1.0-0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "f5f006a186c729de2b3b53b8de79bdfe06462f89795c336e47c85153940a46a4", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/conda-lock.yml", + "verification": "not_verified" + } + ] + }, + { + "source_id": "f5f006a186c729de2b3b53b8de79bdfe06462f89795c336e47c85153940a46a4", + "source_path": "/home/conda-fixture/code/proj/conda-lock.yml", + "package_name": "tzdata", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "target_platform": "linux-64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "f5f006a186c729de2b3b53b8de79bdfe06462f89795c336e47c85153940a46a4", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/conda-lock.yml", + "verification": "not_verified" + } + ] + }, + { + "source_id": "f5f006a186c729de2b3b53b8de79bdfe06462f89795c336e47c85153940a46a4", + "source_path": "/home/conda-fixture/code/proj/conda-lock.yml", + "package_name": "tzdata", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "target_platform": "osx-arm64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "f5f006a186c729de2b3b53b8de79bdfe06462f89795c336e47c85153940a46a4", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/conda-lock.yml", + "verification": "not_verified" + } + ] + }, + { + "source_id": "d9a75864c8f41e89b10bdf5b56a7000918aedc7e5bf94850766c73e477a51a6f", + "source_path": "/home/conda-fixture/code/proj/environment.json", + "package_name": "python", + "evidence": "declared_requirement", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_specs": [ + "python=3.12" + ], + "dependency_relation": "direct", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "ecb5f6f5ec7650b18091fab5e3c543516242acad7ecbb4d38d406659ea1bab9d", + "source_path": "/home/conda-fixture/code/proj/environment.yml", + "package_name": "numpy", + "evidence": "declared_requirement", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_specs": [ + "conda-forge::numpy\u003e=1.26" + ], + "dependency_relation": "direct", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "ecb5f6f5ec7650b18091fab5e3c543516242acad7ecbb4d38d406659ea1bab9d", + "source_path": "/home/conda-fixture/code/proj/environment.yml", + "package_name": "pip", + "evidence": "declared_requirement", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_specs": [ + "pip" + ], + "dependency_relation": "direct", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "ecb5f6f5ec7650b18091fab5e3c543516242acad7ecbb4d38d406659ea1bab9d", + "source_path": "/home/conda-fixture/code/proj/environment.yml", + "package_name": "python", + "evidence": "declared_requirement", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_specs": [ + "python=3.12" + ], + "dependency_relation": "direct", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "b0d66dc8152740865f0a2e2b86436817a30ce99fca52fb188a4fe055bb66dedd", + "source_path": "/home/conda-fixture/code/proj/explicit.txt", + "package_name": "bzip2", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.0.8", + "build": "h93a5062_5", + "subdir": "osx-arm64", + "target_platform": "osx-arm64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "b0d66dc8152740865f0a2e2b86436817a30ce99fca52fb188a4fe055bb66dedd", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/explicit.txt", + "verification": "not_verified" + } + ] + }, + { + "source_id": "b0d66dc8152740865f0a2e2b86436817a30ce99fca52fb188a4fe055bb66dedd", + "source_path": "/home/conda-fixture/code/proj/explicit.txt", + "package_name": "libffi", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [ + "checksum_invalid" + ], + "version_status": "known", + "version": "3.4.2", + "build": "h3422bc3_5", + "subdir": "osx-arm64", + "target_platform": "osx-arm64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2", + "checksum_status": "partial", + "recorded_checksums": [] + }, + { + "source_id": "b0d66dc8152740865f0a2e2b86436817a30ce99fca52fb188a4fe055bb66dedd", + "source_path": "/home/conda-fixture/code/proj/explicit.txt", + "package_name": "local", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.0", + "build": "0", + "subdir": "osx-arm64", + "target_platform": "osx-arm64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "local", + "path": "/opt/channel" + }, + "artifact_url": "/opt/channel/osx-arm64/local-1.0-0.conda", + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "b0d66dc8152740865f0a2e2b86436817a30ce99fca52fb188a4fe055bb66dedd", + "source_path": "/home/conda-fixture/code/proj/explicit.txt", + "package_name": "relative", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [ + "path_unresolved" + ], + "version_status": "known", + "version": "1.0", + "build": "0", + "target_platform": "osx-arm64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "b0d66dc8152740865f0a2e2b86436817a30ce99fca52fb188a4fe055bb66dedd", + "source_path": "/home/conda-fixture/code/proj/explicit.txt", + "package_name": "tzdata", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "target_platform": "osx-arm64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "b0d66dc8152740865f0a2e2b86436817a30ce99fca52fb188a4fe055bb66dedd", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/explicit.txt", + "verification": "not_verified" + } + ] + }, + { + "source_id": "b0d66dc8152740865f0a2e2b86436817a30ce99fca52fb188a4fe055bb66dedd", + "source_path": "/home/conda-fixture/code/proj/explicit.txt", + "package_name": "zlib", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.2.13", + "build": "h53f4e23_5", + "subdir": "osx-arm64", + "target_platform": "osx-arm64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/private" + }, + "artifact_url": "https://conda.anaconda.org/private/osx-arm64/zlib-1.2.13-h53f4e23_5.conda", + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "source_path": "/home/conda-fixture/code/proj/pixi.lock", + "package_name": "bzip2", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.0.8", + "build": "h99b78c6_7", + "subdir": "osx-arm64", + "lock_environment": "default", + "target_platform": "osx-arm64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://prefix.dev/conda-forge" + }, + "artifact_url": "https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-h99b78c6_7.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/pixi.lock", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/pixi.lock", + "verification": "not_verified" + } + ] + }, + { + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "source_path": "/home/conda-fixture/code/proj/pixi.lock", + "package_name": "minimal-project", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [ + "path_unresolved" + ], + "version_status": "known", + "version": "0.1", + "build": "hbf21a9e_0", + "subdir": "linux-64", + "lock_environment": "default", + "target_platform": "linux-64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "source_path": "/home/conda-fixture/code/proj/pixi.lock", + "package_name": "tzdata", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "lock_environment": "default", + "target_platform": "linux-64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://prefix.dev/conda-forge" + }, + "artifact_url": "https://prefix.dev/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/pixi.lock", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/pixi.lock", + "verification": "not_verified" + } + ] + }, + { + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "source_path": "/home/conda-fixture/code/proj/pixi.lock", + "package_name": "tzdata", + "evidence": "locked_package", + "project_path": "/home/conda-fixture/code/proj", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "lock_environment": "default", + "target_platform": "osx-arm64", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://prefix.dev/conda-forge" + }, + "artifact_url": "https://prefix.dev/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/pixi.lock", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "f2e191ea4e4f880646dba9600ec70347b47b925bb5a5e70f028b5a5c0b930218", + "source_kind": "lockfile", + "source_path": "/home/conda-fixture/code/proj/pixi.lock", + "verification": "not_verified" + } + ] + }, + { + "source_id": "bb32d82dc06eb9c8097c67967b5e9df0d14a4d3fc8dfd02e9e7c04a340987899", + "source_path": "/home/conda-fixture/elsewhere/env/conda-meta/tzdata-2024b-hc8b5060_0.json", + "package_name": "tzdata", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/elsewhere/env", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "build_number": 0, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "bb32d82dc06eb9c8097c67967b5e9df0d14a4d3fc8dfd02e9e7c04a340987899", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/elsewhere/env/conda-meta/tzdata-2024b-hc8b5060_0.json", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "bb32d82dc06eb9c8097c67967b5e9df0d14a4d3fc8dfd02e9e7c04a340987899", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/elsewhere/env/conda-meta/tzdata-2024b-hc8b5060_0.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/elsewhere/env", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "8266022daf7ecb5cbfd81157b6fc455ea9302e1ec42b6a9f968c5cf482567946", + "source_path": "/home/conda-fixture/extra-envs/lead/conda-meta/tzdata-2024b-hc8b5060_0.json", + "package_name": "tzdata", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/extra-envs/lead", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "build_number": 0, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "8266022daf7ecb5cbfd81157b6fc455ea9302e1ec42b6a9f968c5cf482567946", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/extra-envs/lead/conda-meta/tzdata-2024b-hc8b5060_0.json", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "8266022daf7ecb5cbfd81157b6fc455ea9302e1ec42b6a9f968c5cf482567946", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/extra-envs/lead/conda-meta/tzdata-2024b-hc8b5060_0.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/extra-envs/lead", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "73e5a52ccb3f4340673dba807732638de9f420ecc0b403c9fe0c6bec35aba9b1", + "source_path": "/home/conda-fixture/extra-pkgs/bzip2-1.0.8-h99b78c6_7/info", + "package_name": "bzip2", + "evidence": "cached_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.0.8", + "build": "h99b78c6_7", + "subdir": "osx-arm64", + "build_number": 7, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "0ae9ababd8509cfcfd6bf6c179c8a21f1cd14b5e6c6ade0bbe2611d6f6daa870", + "source_path": "/home/conda-fixture/micromamba/envs/mm/conda-meta/tzdata-2024b-hc8b5060_0.json", + "package_name": "tzdata", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/micromamba/envs/mm", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "build_number": 0, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "0ae9ababd8509cfcfd6bf6c179c8a21f1cd14b5e6c6ade0bbe2611d6f6daa870", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/micromamba/envs/mm/conda-meta/tzdata-2024b-hc8b5060_0.json", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "0ae9ababd8509cfcfd6bf6c179c8a21f1cd14b5e6c6ade0bbe2611d6f6daa870", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/micromamba/envs/mm/conda-meta/tzdata-2024b-hc8b5060_0.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/micromamba/envs/mm", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "65faf540894f460bf17ffd8d4b48e13cc8104f3547614fa111d61485c23c860e", + "source_path": "/home/conda-fixture/micromamba/pkgs/https/conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0/info", + "package_name": "tzdata", + "evidence": "cached_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "build_number": 0, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "28567b858752581faee43baa3b8f6601ab39dbec21cfcdc5083af1b5f2a8599e", + "source_path": "/home/conda-fixture/miniforge3/conda-meta/_openmp_mutex-4.5-2_gnu.json", + "package_name": "_openmp_mutex", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/miniforge3", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "4.5", + "build": "2_gnu", + "subdir": "linux-64", + "build_number": 2, + "requested_specs": [ + "_openmp_mutex=4.5=2_gnu" + ], + "dependency_relation": "direct", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "28567b858752581faee43baa3b8f6601ab39dbec21cfcdc5083af1b5f2a8599e", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/conda-meta/_openmp_mutex-4.5-2_gnu.json", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "28567b858752581faee43baa3b8f6601ab39dbec21cfcdc5083af1b5f2a8599e", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/conda-meta/_openmp_mutex-4.5-2_gnu.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/miniforge3", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "67fc599074c9aeb3bd787b82b3389011ff4571e6234e2f04423e7e1f2c671099", + "source_path": "/home/conda-fixture/miniforge3/conda-meta/conda-launchers-1.0.0-h57928b3_0.json", + "package_name": "conda-launchers", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/miniforge3", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "build": "h57928b3_0", + "subdir": "win-64", + "build_number": 0, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/win-64/conda-launchers-1.0.0-h57928b3_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "67fc599074c9aeb3bd787b82b3389011ff4571e6234e2f04423e7e1f2c671099", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/conda-meta/conda-launchers-1.0.0-h57928b3_0.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/miniforge3", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "088aee0136ff849dc07e9f3ac5f9f329f2c3e1e2313c35b38595e52f75fe64d8", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/ansi2html-1.9.5-py3_none_any_0.json", + "package_name": "ansi2html", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/miniforge3/envs/app", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.9.5", + "build": "py3_none_any_0", + "subdir": "noarch", + "build_number": 0, + "requested_specs": [ + "conda-pypi::ansi2html" + ], + "dependency_relation": "direct", + "origin": { + "kind": "unknown" + }, + "artifact_url": "https://files.pythonhosted.org/packages/ab/cd/ansi2html-1.9.5-py3-none-any.whl", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "088aee0136ff849dc07e9f3ac5f9f329f2c3e1e2313c35b38595e52f75fe64d8", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/ansi2html-1.9.5-py3_none_any_0.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/miniforge3/envs/app", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "7e8790301fe206973d89fe936416f826046c84a7c2033c188bdcde1be5555802", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/badsum-1.0-0.json", + "package_name": "badsum", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/miniforge3/envs/app", + "scope": "user", + "reasons": [ + "checksum_invalid" + ], + "version_status": "known", + "version": "1.0", + "build": "0", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "partial", + "recorded_checksums": [], + "installation": { + "path": "/home/conda-fixture/miniforge3/envs/app", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "6e78693c21dbcf7c38ae8283b6c35c38affac5b61327933ca1a5ad162e3e62d7", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/packaging-24.1-pyhd8ed1ab_0.json", + "package_name": "packaging", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/miniforge3/envs/app", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "24.1", + "build": "pyhd8ed1ab_0", + "subdir": "noarch", + "build_number": 0, + "requested_specs": [ + "packaging" + ], + "dependency_relation": "direct", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/noarch/packaging-24.1-pyhd8ed1ab_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "6e78693c21dbcf7c38ae8283b6c35c38affac5b61327933ca1a5ad162e3e62d7", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/packaging-24.1-pyhd8ed1ab_0.json", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "6e78693c21dbcf7c38ae8283b6c35c38affac5b61327933ca1a5ad162e3e62d7", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/packaging-24.1-pyhd8ed1ab_0.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/miniforge3/envs/app", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "5be61c62f0aeb093e2c43b57e7471562846395315697b2053fcfba6a80334924", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/secret-1.0-0.json", + "package_name": "secret", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/miniforge3/envs/app", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.0", + "build": "0", + "subdir": "osx-arm64", + "build_number": 0, + "requested_specs": [ + "https://private.example.invalid/ch::secret", + "[redacted]" + ], + "dependency_relation": "direct", + "origin": { + "kind": "channel", + "url": "https://private.example.invalid/ch" + }, + "artifact_url": "https://private.example.invalid/ch/osx-arm64/secret-1.0-0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "5be61c62f0aeb093e2c43b57e7471562846395315697b2053fcfba6a80334924", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/secret-1.0-0.json", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "5be61c62f0aeb093e2c43b57e7471562846395315697b2053fcfba6a80334924", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/secret-1.0-0.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/miniforge3/envs/app", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "c67c447db0f264c541d513759e109da8f0e4ccf1a46b813e213ddc2c2e08cd15", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/zlib-1.3.1-h4e2bd7c_1.json", + "package_name": "zlib", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/miniforge3/envs/app", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.3.1", + "build": "h4e2bd7c_1", + "subdir": "osx-arm64", + "build_number": 0, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.1-h4e2bd7c_1.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "c67c447db0f264c541d513759e109da8f0e4ccf1a46b813e213ddc2c2e08cd15", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/zlib-1.3.1-h4e2bd7c_1.json", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "c67c447db0f264c541d513759e109da8f0e4ccf1a46b813e213ddc2c2e08cd15", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/zlib-1.3.1-h4e2bd7c_1.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/miniforge3/envs/app", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "1042b2d3f1008bf6e471905758dcd8a65f0703dbf35d5f5aa3c63c58ba5ed72d", + "source_path": "/home/conda-fixture/miniforge3/envs/nohistory/conda-meta/tzdata-2024b-hc8b5060_0.json", + "package_name": "tzdata", + "evidence": "installed_package", + "environment_path": "/home/conda-fixture/miniforge3/envs/nohistory", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2024b", + "build": "hc8b5060_0", + "subdir": "noarch", + "build_number": 0, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "1042b2d3f1008bf6e471905758dcd8a65f0703dbf35d5f5aa3c63c58ba5ed72d", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/envs/nohistory/conda-meta/tzdata-2024b-hc8b5060_0.json", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "1042b2d3f1008bf6e471905758dcd8a65f0703dbf35d5f5aa3c63c58ba5ed72d", + "source_kind": "installed_metadata", + "source_path": "/home/conda-fixture/miniforge3/envs/nohistory/conda-meta/tzdata-2024b-hc8b5060_0.json", + "verification": "not_verified" + } + ], + "installation": { + "path": "/home/conda-fixture/miniforge3/envs/nohistory", + "path_status": "resolved", + "presence": "present" + } + }, + { + "source_id": "7edc5fbd88f02c1e8dbae3d221010b0f3464eea40f3005ac46e9f085cd7b5f6d", + "source_path": "/home/conda-fixture/miniforge3/pkgs/broken-1.0-0/info", + "package_name": "broken", + "evidence": "cached_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.0", + "build": "0", + "subdir": "noarch", + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "6ec6d9deeda82a8c15f90197596e4daaee61d758e1d18e35dbf63168095487f9", + "source_path": "/home/conda-fixture/miniforge3/pkgs/packaging-24.1-pyhd8ed1ab_0/info", + "package_name": "packaging", + "evidence": "cached_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "24.1", + "build": "pyhd8ed1ab_0", + "subdir": "noarch", + "build_number": 0, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "channel", + "url": "https://conda.anaconda.org/conda-forge" + }, + "artifact_url": "https://conda.anaconda.org/conda-forge/noarch/packaging-24.1-pyhd8ed1ab_0.conda", + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91", + "source_id": "6ec6d9deeda82a8c15f90197596e4daaee61d758e1d18e35dbf63168095487f9", + "source_kind": "cached_metadata", + "source_path": "/home/conda-fixture/miniforge3/pkgs/packaging-24.1-pyhd8ed1ab_0/info", + "verification": "not_verified" + }, + { + "algorithm": "md5", + "value": "f77c7d05dc47868e181135af65cb6e26", + "source_id": "6ec6d9deeda82a8c15f90197596e4daaee61d758e1d18e35dbf63168095487f9", + "source_kind": "cached_metadata", + "source_path": "/home/conda-fixture/miniforge3/pkgs/packaging-24.1-pyhd8ed1ab_0/info", + "verification": "not_verified" + } + ] + }, + { + "source_id": "b984f4e0492abcf8510c8eec68789e20ab8c6419553573d8e3997bcc1008bdb7", + "source_path": "/home/conda-fixture/miniforge3/pkgs/zlib-1.3.1-h4e2bd7c_1/info", + "package_name": "zlib", + "evidence": "cached_package", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.3.1", + "build": "h4e2bd7c_1", + "subdir": "osx-arm64", + "build_number": 1, + "requested_specs": [], + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "absent", + "recorded_checksums": [] + } + ] + }, + "conda_config_audit": { + "schema_version": 1, + "status": "partial", + "reasons": [ + "unsupported_format" + ], + "files": [ + { + "source_id": "3f4fcd270ad1354c965f6204b48485437e499f9a35039c4a365002cda8c80238", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/.conda/envs/old/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "fccd20fd38f19103ee9e898e9422c3c3fc1cbf2d69fef24b4cf48dc38ed03004", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/.conda/envs/old/conda-meta/state", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "5eb09960ea8b8bf26b987a0a8a796ef06c7870bb1cf86a758106b51140c49ef0", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/.conda/envs/old/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "1e28888b9a4db7e19673916da27a4eac6c20f2ff68f344ad8ecccbd3a421955f", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/code/proj/.conda/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "889b06a123f92046a753bc9b4bf3263d399001d2bc572e6320959ef362c2f2a4", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/code/proj/.conda/conda-meta/state", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "d284b01066b6cc18752be9014f81224f2c949be9832a4821d269bebbc9149327", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/code/proj/.conda/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "9791d0de6cef0838c33f42bbf1dfb30fd64ed9e831a6fb46168ba671bcec5831", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/code/proj/.pixi/envs/default/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "37dd009d09b27382142b7dc2b8e8241ffd2d29b0deacd0f0a7c2077d8548cea6", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/code/proj/.pixi/envs/default/conda-meta/state", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "1b7fefff2bfc0db28c45a72663337e6e5c75add692d2091c7ee59823691886dc", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/code/proj/.pixi/envs/default/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "2c0f3089f5cb4efb77575fe896ecec5fa1034ec6cf8bdabda70d7d1c733fd76e", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/elsewhere/env/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "549717b2c640d853c64952518cb21c98e2cc9eafd8cf9645c86b2481d864869d", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/elsewhere/env/conda-meta/state", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "45f0a0749bcb86da6d4638522fe2e2c983ba79cf8b2d97db0841c7765f1db5a0", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/elsewhere/env/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "295a087fa62bf4d3795f0be102cd989e3a6a8f609c657eae959193df9fb7c857", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/extra-envs/lead/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "401058b3a6425a060a5540b041e3635eca9c872df607be1754ef3baf008ec1a3", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/extra-envs/lead/conda-meta/state", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "ae4d9f193358b4d232745dde475c1b575f62a9db71f7e7d0cc943a2a725a965f", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/extra-envs/lead/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "46529beeda2468e4856195f6a78713c158e7c2775c272699b45cb597e3acf2cc", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/micromamba/envs/mm/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "b6d0957f4870cf09c2383748bc6fff24d16522c57b99da37e1fe6caea1743f3f", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/micromamba/envs/mm/conda-meta/state", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "c4aa99146c6017f72342fa3022cca1ec724505a121141908e2bc6e3606330b18", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/micromamba/envs/mm/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "1acadae2c8e71507e068f1f3ba6ad0edc29a0bd9d5a93a450a583285ea69884f", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/miniforge3/conda-meta/state", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "1acadae2c8e71507e068f1f3ba6ad0edc29a0bd9d5a93a450a583285ea69884f", + "redacted": true + }, + { + "key": "proxy_servers.0.scheme", + "display": "https", + "source_id": "1acadae2c8e71507e068f1f3ba6ad0edc29a0bd9d5a93a450a583285ea69884f", + "redacted": false + }, + { + "key": "proxy_servers.0.url", + "display": "https://proxy.example.invalid:8443", + "source_id": "1acadae2c8e71507e068f1f3ba6ad0edc29a0bd9d5a93a450a583285ea69884f", + "redacted": true + }, + { + "key": "ssl_verify", + "display": "false", + "source_id": "1acadae2c8e71507e068f1f3ba6ad0edc29a0bd9d5a93a450a583285ea69884f", + "redacted": false + } + ] + }, + { + "source_id": "347a0dfc1b0792db4029fceb84251ae12cd6e04756578381f6d464b25af232cc", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/miniforge3/envs/app/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "4bf38d8673aa1f442245baac2df4b4779bbe04ad50b10eafb8a6e90b417138dd", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/miniforge3/envs/app/conda-meta/state", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "b84ee83aaf19eac0302c263463b261b3b525b92a101bc6306e3521cf94536b71", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/miniforge3/envs/app/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "a2d2261fa814eb2a5f560d0c5bffb39abf838d6b82fb6a31d5a4a56f14a67c0e", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/miniforge3/envs/empty/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "ce1382901006c68f19fc14e67560c8602dc00b77a600117365f391797991055e", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/miniforge3/envs/empty/conda-meta/state", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "2edf851d23dd7479cacfe7763464c12065ee735ca57b6a7b9878c1f96b852c35", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/miniforge3/envs/empty/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "76b2d728713ac77a946f0fe383edf7bf0a5a35d49693e25a7e53d4a006810400", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/miniforge3/envs/nohistory/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "28a7a5d48ed0ea91d9ab5401ae2be0e4bdfa8f48dfa25d4126351b44c852a101", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/miniforge3/envs/nohistory/conda-meta/state", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "9808c5338d578d4ddcc8080547c0438b7a38874ffe251a17b9bb120168edf3c4", + "manager": "conda", + "scope": "environment", + "path": "/home/conda-fixture/miniforge3/envs/nohistory/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "manager": "conda", + "scope": "process", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "redacted": true + }, + { + "key": "channel_alias", + "display": "https://conda.example.invalid", + "source_id": "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "redacted": true + }, + { + "key": "envs_dirs", + "display": "[\"/home/conda-fixture/extra-envs\"]", + "source_id": "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "redacted": false + }, + { + "key": "pkgs_dirs", + "display": "[\"/home/conda-fixture/extra-pkgs\"]", + "source_id": "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "redacted": false + }, + { + "key": "proxy_servers.0.scheme", + "display": "https", + "source_id": "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "redacted": false + }, + { + "key": "proxy_servers.0.url", + "display": "https://proxy.example.invalid:8443/", + "source_id": "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "redacted": true + } + ] + }, + { + "source_id": "64800ed55ac4a98fd02daf8fce379686137f59f28d0c6eb924e5bee954dcc33c", + "manager": "conda", + "scope": "project", + "path": "/home/conda-fixture/code/legacy/environment.yaml", + "status": "present", + "reasons": [ + "unsupported_format" + ], + "settings": [ + { + "key": "channels.0.url", + "display": "conda-forge", + "source_id": "64800ed55ac4a98fd02daf8fce379686137f59f28d0c6eb924e5bee954dcc33c", + "redacted": false + } + ] + }, + { + "source_id": "fd030ee54d2fde1e5e0463e9f2cdf06715ef46c02da7a75b7d0e0e575dc1834e", + "manager": "conda", + "scope": "project", + "path": "/home/conda-fixture/code/proj/environment.json", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "channels.0.url", + "display": "conda-forge", + "source_id": "fd030ee54d2fde1e5e0463e9f2cdf06715ef46c02da7a75b7d0e0e575dc1834e", + "redacted": false + } + ] + }, + { + "source_id": "48d0d8ae5865101a5c9940715c137925bb30c04c4dca1da46f347e987a935334", + "manager": "conda", + "scope": "project", + "path": "/home/conda-fixture/code/proj/environment.yml", + "status": "present", + "reasons": [ + "unsupported_format" + ], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "48d0d8ae5865101a5c9940715c137925bb30c04c4dca1da46f347e987a935334", + "redacted": true + }, + { + "key": "channels.0.url", + "display": "nodefaults", + "source_id": "48d0d8ae5865101a5c9940715c137925bb30c04c4dca1da46f347e987a935334", + "redacted": false + }, + { + "key": "channels.1.url", + "display": "http://mirror.example.invalid/conda", + "source_id": "48d0d8ae5865101a5c9940715c137925bb30c04c4dca1da46f347e987a935334", + "redacted": false + }, + { + "key": "channels.2.url", + "display": "https://conda.anaconda.org/private", + "source_id": "48d0d8ae5865101a5c9940715c137925bb30c04c4dca1da46f347e987a935334", + "redacted": true + }, + { + "key": "channels.3.url", + "display": "conda-forge", + "source_id": "48d0d8ae5865101a5c9940715c137925bb30c04c4dca1da46f347e987a935334", + "redacted": false + } + ] + }, + { + "source_id": "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556", + "manager": "conda", + "scope": "system", + "path": "/etc/conda/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "manager": "conda", + "scope": "system", + "path": "/etc/conda/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "manager": "conda", + "scope": "system", + "path": "/home/conda-fixture/.conda/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "manager": "conda", + "scope": "system", + "path": "/home/conda-fixture/.conda/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "manager": "conda", + "scope": "system", + "path": "/home/conda-fixture/micromamba/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "manager": "conda", + "scope": "system", + "path": "/home/conda-fixture/micromamba/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "manager": "conda", + "scope": "system", + "path": "/home/conda-fixture/miniforge3/.condarc", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "redacted": true + }, + { + "key": "channels.0.url", + "display": "https://private.example.invalid/ch", + "source_id": "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "redacted": true + }, + { + "key": "channels.1.url", + "display": "conda-forge", + "source_id": "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "redacted": false + }, + { + "key": "safety_checks", + "display": "disabled", + "source_id": "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "redacted": false + }, + { + "key": "ssl_verify", + "display": "false", + "source_id": "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "redacted": false + } + ] + }, + { + "source_id": "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "manager": "conda", + "scope": "system", + "path": "/home/conda-fixture/miniforge3/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "manager": "conda", + "scope": "system", + "path": "/var/lib/conda/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "manager": "conda", + "scope": "system", + "path": "/var/lib/conda/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "manager": "conda", + "scope": "user", + "path": "/home/conda-fixture/.conda/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "manager": "conda", + "scope": "user", + "path": "/home/conda-fixture/.conda/condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "manager": "conda", + "scope": "user", + "path": "/home/conda-fixture/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "manager": "conda", + "scope": "user", + "path": "/home/conda-fixture/.config/conda/.condarc", + "status": "absent", + "reasons": [], + "settings": [] + }, + { + "source_id": "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "manager": "conda", + "scope": "user", + "path": "/home/conda-fixture/.config/conda/condarc", + "status": "absent", + "reasons": [], + "settings": [] + } + ], + "contexts": [ + { + "manager": "conda", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "observed" + }, + { + "manager": "conda", + "project_path": "/home/conda-fixture/code/legacy", + "config_source_ids": [ + "64800ed55ac4a98fd02daf8fce379686137f59f28d0c6eb924e5bee954dcc33c" + ], + "selection_status": "partial" + }, + { + "manager": "conda", + "project_path": "/home/conda-fixture/code/proj", + "config_source_ids": [ + "48d0d8ae5865101a5c9940715c137925bb30c04c4dca1da46f347e987a935334" + ], + "selection_status": "partial" + }, + { + "manager": "conda", + "project_path": "/home/conda-fixture/code/proj", + "config_source_ids": [ + "fd030ee54d2fde1e5e0463e9f2cdf06715ef46c02da7a75b7d0e0e575dc1834e" + ], + "selection_status": "observed" + }, + { + "manager": "conda", + "environment_path": "/home/conda-fixture/.conda/envs/old", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "fccd20fd38f19103ee9e898e9422c3c3fc1cbf2d69fef24b4cf48dc38ed03004", + "5eb09960ea8b8bf26b987a0a8a796ef06c7870bb1cf86a758106b51140c49ef0", + "3f4fcd270ad1354c965f6204b48485437e499f9a35039c4a365002cda8c80238", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "observed" + }, + { + "manager": "conda", + "environment_path": "/home/conda-fixture/code/proj/.conda", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "889b06a123f92046a753bc9b4bf3263d399001d2bc572e6320959ef362c2f2a4", + "d284b01066b6cc18752be9014f81224f2c949be9832a4821d269bebbc9149327", + "1e28888b9a4db7e19673916da27a4eac6c20f2ff68f344ad8ecccbd3a421955f", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "observed" + }, + { + "manager": "conda", + "environment_path": "/home/conda-fixture/code/proj/.pixi/envs/default", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "37dd009d09b27382142b7dc2b8e8241ffd2d29b0deacd0f0a7c2077d8548cea6", + "1b7fefff2bfc0db28c45a72663337e6e5c75add692d2091c7ee59823691886dc", + "9791d0de6cef0838c33f42bbf1dfb30fd64ed9e831a6fb46168ba671bcec5831", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "observed" + }, + { + "manager": "conda", + "environment_path": "/home/conda-fixture/elsewhere/env", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "549717b2c640d853c64952518cb21c98e2cc9eafd8cf9645c86b2481d864869d", + "45f0a0749bcb86da6d4638522fe2e2c983ba79cf8b2d97db0841c7765f1db5a0", + "2c0f3089f5cb4efb77575fe896ecec5fa1034ec6cf8bdabda70d7d1c733fd76e", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "observed" + }, + { + "manager": "conda", + "environment_path": "/home/conda-fixture/extra-envs/lead", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "401058b3a6425a060a5540b041e3635eca9c872df607be1754ef3baf008ec1a3", + "ae4d9f193358b4d232745dde475c1b575f62a9db71f7e7d0cc943a2a725a965f", + "295a087fa62bf4d3795f0be102cd989e3a6a8f609c657eae959193df9fb7c857", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "observed" + }, + { + "manager": "conda", + "environment_path": "/home/conda-fixture/micromamba/envs/mm", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "b6d0957f4870cf09c2383748bc6fff24d16522c57b99da37e1fe6caea1743f3f", + "c4aa99146c6017f72342fa3022cca1ec724505a121141908e2bc6e3606330b18", + "46529beeda2468e4856195f6a78713c158e7c2775c272699b45cb597e3acf2cc", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "observed" + }, + { + "manager": "conda", + "environment_path": "/home/conda-fixture/miniforge3", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "1acadae2c8e71507e068f1f3ba6ad0edc29a0bd9d5a93a450a583285ea69884f", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "partial" + }, + { + "manager": "conda", + "environment_path": "/home/conda-fixture/miniforge3/envs/app", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "4bf38d8673aa1f442245baac2df4b4779bbe04ad50b10eafb8a6e90b417138dd", + "b84ee83aaf19eac0302c263463b261b3b525b92a101bc6306e3521cf94536b71", + "347a0dfc1b0792db4029fceb84251ae12cd6e04756578381f6d464b25af232cc", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "partial" + }, + { + "manager": "conda", + "environment_path": "/home/conda-fixture/miniforge3/envs/empty", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "ce1382901006c68f19fc14e67560c8602dc00b77a600117365f391797991055e", + "2edf851d23dd7479cacfe7763464c12065ee735ca57b6a7b9878c1f96b852c35", + "a2d2261fa814eb2a5f560d0c5bffb39abf838d6b82fb6a31d5a4a56f14a67c0e", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "observed" + }, + { + "manager": "conda", + "environment_path": "/home/conda-fixture/miniforge3/envs/nohistory", + "config_source_ids": [ + "6211332a1c2ba3de4c64a9b0d567e00f9d2594fdce980d91c1c411881320eb83", + "28a7a5d48ed0ea91d9ab5401ae2be0e4bdfa8f48dfa25d4126351b44c852a101", + "9808c5338d578d4ddcc8080547c0438b7a38874ffe251a17b9bb120168edf3c4", + "76b2d728713ac77a946f0fe383edf7bf0a5a35d49693e25a7e53d4a006810400", + "69092840e38aced3e075fcaa3826e06ff228cff25fb4a9ab35c52d61206f617e", + "95d4b99914fd72e55cd32f14ba8e93120e9f35d9161dbfeb158c3fab829092f7", + "6ccfc02ecad6754b7372ad51785c44e0bf1e7e251d839964014f40be470380ae", + "69321c71222bd1f7c06cd9228172108986dbf19beb76065a92ab6c853d0710e9", + "fe90b147ff7a2fe722897c7d838ab293463748446272958b289f198e98c740be", + "274c91f8fd0e518487dfa98f8f8327aafa0982a74181241380076c35f395dec9", + "3873dcd0567c95bdea8fa213d6b0377f516b2603d6ebb3bcf79d2d7c4e791989", + "e983bfe89cff4ef8dfc3807076f9db16e69e51ff6893e54b0aaf51f7e39ffbd5", + "e9a02e27193f4db654aa53c71545cd07e7573203cba76d647db9afb9d00cbbb1", + "95340df0e40b2879337cd85eaba4c0f653f4bbaa03855aca2679cb9c5fbcdced", + "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "d79c35e22696941959a5d538b2dc377fbdc108b41860f7b5fd619ab11e8bc841", + "b3a3776f29a7885df6cd8b06bfffe31b22285d5def6a767f5d6abe602b3937d8", + "41a9b9e6f1b5e09efc95e6153e74c6a78f477062764f819e726ec4aafcc4765e", + "cc14367d378ef1288ad328fb0dbc11ceb53cd7aa54ba384b515b52a2c891c556" + ], + "selection_status": "partial" + } + ], + "credential_files": [ + { + "path": "/home/conda-fixture/.netrc", + "status": "present", + "presence": "present" + }, + { + "path": "/home/conda-fixture/Library/Application Support/binstar/data", + "status": "absent", + "presence": "absent" + }, + { + "path": "/home/conda-fixture/_netrc", + "status": "absent", + "presence": "absent" + } + ], + "findings": [ + { + "code": "conda-001", + "severity": "MEDIUM", + "source_id": "48d0d8ae5865101a5c9940715c137925bb30c04c4dca1da46f347e987a935334", + "key": "channels.1.url", + "detail": "Conda package channel uses an unencrypted connection." + }, + { + "code": "conda-002", + "severity": "MEDIUM", + "source_id": "1acadae2c8e71507e068f1f3ba6ad0edc29a0bd9d5a93a450a583285ea69884f", + "key": "ssl_verify", + "detail": "TLS certificate verification is disabled by Conda configuration." + }, + { + "code": "conda-002", + "severity": "MEDIUM", + "source_id": "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "key": "ssl_verify", + "detail": "TLS certificate verification is disabled by Conda configuration." + }, + { + "code": "conda-003", + "severity": "MEDIUM", + "source_id": "1acadae2c8e71507e068f1f3ba6ad0edc29a0bd9d5a93a450a583285ea69884f", + "key": "auth_configured", + "detail": "Credentials are embedded in Conda configuration." + }, + { + "code": "conda-003", + "severity": "MEDIUM", + "source_id": "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "key": "auth_configured", + "detail": "Credentials are embedded in Conda configuration." + }, + { + "code": "conda-003", + "severity": "MEDIUM", + "source_id": "48d0d8ae5865101a5c9940715c137925bb30c04c4dca1da46f347e987a935334", + "key": "auth_configured", + "detail": "Credentials are embedded in Conda configuration." + }, + { + "code": "conda-004", + "severity": "MEDIUM", + "source_id": "268d472eed2eaabd567a0f2373cb72738408224caf7fe91b25f96afb86040559", + "key": "safety_checks", + "detail": "Conda package safety checks are disabled." + } + ] + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 7544ea2a..5654d642 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -138,6 +138,8 @@ type Payload struct { NuGetConfigAudit *model.NuGetConfigAudit `json:"nuget_config_audit,omitempty"` RubyInventory *model.RubyInventory `json:"ruby_inventory,omitempty"` RubyConfigAudit *model.RubyConfigAudit `json:"ruby_config_audit,omitempty"` + CondaInventory *model.CondaInventory `json:"conda_inventory,omitempty"` + CondaConfigAudit *model.CondaConfigAudit `json:"conda_config_audit,omitempty"` ExecutionLogs *ExecutionLogs `json:"execution_logs,omitempty"` PerformanceMetrics *PerformanceMetrics `json:"performance_metrics,omitempty"` @@ -1168,6 +1170,14 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err endPhase(phaseCtx, phaseCancel, tracker, log, "ruby_scan") postPhase() + // Conda metadata uses the raw executor and the same developer as Go/Cargo. + phaseCtx, phaseCancel = startPhase(ctx, tracker, "conda_scan") + log.Progress("Collecting Conda packages and configuration...") + condaInventory, condaConfigAudit := detector.NewCondaScanner(exec, log).Scan(phaseCtx, browserTarget, searchDirs, cfg.IncludeNetworkVolumes) + log.Progress(" Conda: %d projects, %d package facts (inventory %s, config %s)", len(condaInventory.Projects), len(condaInventory.Packages), condaInventory.Status, condaConfigAudit.Status) + endPhase(phaseCtx, phaseCancel, tracker, log, "conda_scan") + postPhase() + // npm + pip configuration audits — surface-only inventory of every // .npmrc and pip.conf on the host, plus the merged effective views // each tool would resolve. We use the user-aware executor so npm and @@ -1337,6 +1347,8 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err NuGetConfigAudit: nugetConfigAudit, RubyInventory: rubyInventory, RubyConfigAudit: rubyConfigAudit, + CondaInventory: condaInventory, + CondaConfigAudit: condaConfigAudit, ExecutionLogs: &ExecutionLogs{ OutputBase64: execLogsBase64, diff --git a/internal/telemetry/telemetry_out_test.go b/internal/telemetry/telemetry_out_test.go index 4c641d1e..e4bee8c6 100644 --- a/internal/telemetry/telemetry_out_test.go +++ b/internal/telemetry/telemetry_out_test.go @@ -256,3 +256,32 @@ func TestPayloadRubySections(t *testing.T) { } } } + +func TestPayloadCondaSections(t *testing.T) { + raw, err := os.ReadFile("../model/testdata/conda_inventory_v1_golden.json") + if err != nil { + t.Fatal(err) + } + var payload Payload + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&payload); err != nil { + t.Fatalf("Conda golden does not fit Payload: %v", err) + } + if payload.CondaInventory == nil || payload.CondaConfigAudit == nil { + t.Fatal("Conda sections not decoded into Payload") + } + data, err := json.Marshal(&Payload{}) + if err != nil { + t.Fatal(err) + } + var empty map[string]json.RawMessage + if err := json.Unmarshal(data, &empty); err != nil { + t.Fatal(err) + } + for _, key := range []string{"conda_inventory", "conda_config_audit"} { + if _, exists := empty[key]; exists { + t.Errorf("unrun section %s was emitted", key) + } + } +} From 2394fe24531d2fb489bea125696e5a3cfc2ea77c Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Mon, 26 Oct 2026 01:16:12 +0530 Subject: [PATCH 6/6] fix(detector): redact Conda requests carrying a query or fragment A requested spec with ? or # is a URL-like request rather than a match spec, so it is redacted whole like one with userinfo or a token path. The fuzz oracle now looks for token components only inside a URL path: a host or local directory named "t" is not a token. Keep the three crasher inputs as regression seeds. --- internal/detector/condametadata.go | 5 +++-- internal/detector/condametadata_test.go | 4 ++-- internal/detector/fuzz_test.go | 13 ++++++++++++- .../fuzz/FuzzCondaMetadata/0a46148db4a15c1e | 2 ++ .../fuzz/FuzzCondaMetadata/4f4fc47dd2e0ce91 | 2 ++ .../fuzz/FuzzCondaMetadata/c26098e69d8dcc68 | 2 ++ 6 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 internal/detector/testdata/fuzz/FuzzCondaMetadata/0a46148db4a15c1e create mode 100644 internal/detector/testdata/fuzz/FuzzCondaMetadata/4f4fc47dd2e0ce91 create mode 100644 internal/detector/testdata/fuzz/FuzzCondaMetadata/c26098e69d8dcc68 diff --git a/internal/detector/condametadata.go b/internal/detector/condametadata.go index 7d6072b2..1a73f957 100644 --- a/internal/detector/condametadata.go +++ b/internal/detector/condametadata.go @@ -205,7 +205,7 @@ func condaSanitizeSpec(spec string) string { } if channel, rest, ok := strings.Cut(spec, "::"); ok { display, _ := configaudit.CondaChannelDisplay(channel) - if strings.ContainsAny(rest, "@:") || strings.Contains(rest, "/t/") { + if strings.ContainsAny(rest, "@:?#") || strings.Contains(rest, "/t/") { return condaRedacted } return display + "::" + rest @@ -217,7 +217,8 @@ func condaSanitizeSpec(spec string) string { } display, _ := configaudit.CondaSanitizeURL(spec) return display - case strings.ContainsAny(spec, "@") || strings.Contains(spec, "/t/"): + case strings.ContainsAny(spec, "@?#") || strings.Contains(spec, "/t/"): + // A query or fragment marks a URL-like request, not a match spec. return condaRedacted } return spec diff --git a/internal/detector/condametadata_test.go b/internal/detector/condametadata_test.go index 9c752ee5..d3d6d9d9 100644 --- a/internal/detector/condametadata_test.go +++ b/internal/detector/condametadata_test.go @@ -199,13 +199,13 @@ func TestCondaRecord(t *testing.T) { modern := `{"name":"NumPy","version":"1.26.4","build":"py312h8813227_0","build_number":0,"subdir":"osx-arm64","channel":"conda-forge", "url":"https://conda.anaconda.org/conda-forge/osx-arm64/numpy-1.26.4-py312h8813227_0.conda","md5":"` + condaMD5A + `","sha256":"` + strings.ToUpper(condaSHA_A) + `", - "requested_specs":["numpy>=1.26","https://CANARY:x@repo.example.invalid/t/CANARY/ch::numpy","numpy>=1.26"],"requested_spec":"ignored","package_type":"noarch_python"}` + "requested_specs":["numpy>=1.26","https://CANARY:x@repo.example.invalid/t/CANARY/ch::numpy","numpy>=1.26","./numpy.conda#CANARY_FRAGMENT","ch::numpy?CANARY_QUERY"],"requested_spec":"ignored","package_type":"noarch_python"}` r, err = parseCondaRecord([]byte(modern)) if err != nil { t.Fatal(err) } p = condaFact(r) - if p.PackageName != "numpy" || p.DependencyRelation != "direct" || !slices.Equal(p.RequestedSpecs, []string{"numpy>=1.26", "https://repo.example.invalid/ch::numpy"}) || p.BuildNumber == nil || *p.BuildNumber != 0 || + if p.PackageName != "numpy" || p.DependencyRelation != "direct" || !slices.Equal(p.RequestedSpecs, []string{"numpy>=1.26", "https://repo.example.invalid/ch::numpy", "[redacted]"}) || p.BuildNumber == nil || *p.BuildNumber != 0 || p.Origin != (model.CondaOrigin{Kind: "channel", URL: "https://conda.anaconda.org/conda-forge"}) || len(p.RecordedChecksums) != 2 || p.RecordedChecksums[0].Algorithm != "sha256" || p.RecordedChecksums[0].Value != condaSHA_A { t.Errorf("modern fact %+v", p) } diff --git a/internal/detector/fuzz_test.go b/internal/detector/fuzz_test.go index 66d440ae..7b6b88e1 100644 --- a/internal/detector/fuzz_test.go +++ b/internal/detector/fuzz_test.go @@ -164,7 +164,18 @@ func FuzzCondaMetadata(f *testing.F) { f.Add([]byte(s)) } leak := func(s string) bool { - return strings.ContainsAny(s, "@?#") || strings.Contains(s, "/t/") || strings.Contains(s, "%2F") || strings.Contains(s, "%2f") + // A token lives in a URL path; a host named "t" or a local + // directory named "t" is not one. + path := "" + if i := strings.Index(s, "://"); i >= 0 { + path = strings.TrimLeft(s[i+3:], "/") + if j := strings.IndexByte(path, '/'); j >= 0 { + path = path[j:] + } else { + path = "" + } + } + return strings.ContainsAny(s, "@?#") || strings.Contains(path, "/t/") || strings.Contains(s, "%2F") || strings.Contains(s, "%2f") } check := func(t *testing.T, pkgs []model.CondaPackage) { t.Helper() diff --git a/internal/detector/testdata/fuzz/FuzzCondaMetadata/0a46148db4a15c1e b/internal/detector/testdata/fuzz/FuzzCondaMetadata/0a46148db4a15c1e new file mode 100644 index 00000000..3fdbb1ba --- /dev/null +++ b/internal/detector/testdata/fuzz/FuzzCondaMetadata/0a46148db4a15c1e @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"name\":\"0\",\"version\":\"0\",\"build\":\"0\",\"url\":\"A0://a:a@\",\"requested_specs\":[\"#\"]}") diff --git a/internal/detector/testdata/fuzz/FuzzCondaMetadata/4f4fc47dd2e0ce91 b/internal/detector/testdata/fuzz/FuzzCondaMetadata/4f4fc47dd2e0ce91 new file mode 100644 index 00000000..0c28263e --- /dev/null +++ b/internal/detector/testdata/fuzz/FuzzCondaMetadata/4f4fc47dd2e0ce91 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"name\":\"0\",\"version\":\"0\",\"build\":\"00\",\"url\":\"/t/\"}") diff --git a/internal/detector/testdata/fuzz/FuzzCondaMetadata/c26098e69d8dcc68 b/internal/detector/testdata/fuzz/FuzzCondaMetadata/c26098e69d8dcc68 new file mode 100644 index 00000000..660776a9 --- /dev/null +++ b/internal/detector/testdata/fuzz/FuzzCondaMetadata/c26098e69d8dcc68 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"name\":\"0\",\"version\":\"0\",\"build\":\"0\",\"channel\":\"A00://000@t/\"}")