Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -330,6 +331,10 @@ 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.

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
Expand Down
38 changes: 37 additions & 1 deletion SCAN_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -344,6 +344,42 @@ 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 (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.

**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/<name>-<version>-<build>.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/<token>` 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.
Expand Down
13 changes: 13 additions & 0 deletions internal/detector/composerscan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions internal/detector/conda_golden_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading