From 6c5aefd0f541ef2c4b7e8af19be76c1e44f13260 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 30 Apr 2026 13:18:08 +0530 Subject: [PATCH] feat: add audit and drift detection for npm rc Signed-off-by: Swarit Pandey --- cmd/stepsecurity-dev-machine-guard/main.go | 64 ++ internal/cli/cli.go | 5 + internal/detector/npmrc.go | 666 +++++++++++++++++++++ internal/detector/npmrc_attribution.go | 324 ++++++++++ internal/detector/npmrc_diff.go | 297 +++++++++ internal/detector/npmrc_diff_test.go | 336 +++++++++++ internal/detector/npmrc_parse.go | 200 +++++++ internal/detector/npmrc_parse_test.go | 250 ++++++++ internal/detector/npmrc_snapshot.go | 199 ++++++ internal/detector/npmrc_stat_unix.go | 39 ++ internal/detector/npmrc_stat_windows.go | 10 + internal/detector/npmrc_test.go | 511 ++++++++++++++++ internal/model/model.go | 255 ++++++++ internal/output/html.go | 54 ++ internal/output/npmrc_verbose.go | 513 ++++++++++++++++ internal/output/npmrc_verbose_test.go | 169 ++++++ internal/output/pretty.go | 92 +++ internal/scan/scanner.go | 16 + internal/telemetry/telemetry.go | 31 + 19 files changed, 4031 insertions(+) create mode 100644 internal/detector/npmrc.go create mode 100644 internal/detector/npmrc_attribution.go create mode 100644 internal/detector/npmrc_diff.go create mode 100644 internal/detector/npmrc_diff_test.go create mode 100644 internal/detector/npmrc_parse.go create mode 100644 internal/detector/npmrc_parse_test.go create mode 100644 internal/detector/npmrc_snapshot.go create mode 100644 internal/detector/npmrc_stat_unix.go create mode 100644 internal/detector/npmrc_stat_windows.go create mode 100644 internal/detector/npmrc_test.go create mode 100644 internal/output/npmrc_verbose.go create mode 100644 internal/output/npmrc_verbose_test.go diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 3a6742f..b5d1c27 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -1,15 +1,22 @@ package main import ( + "context" + "encoding/json" "fmt" + "io" "os" "runtime" + "time" "github.com/step-security/dev-machine-guard/internal/buildinfo" "github.com/step-security/dev-machine-guard/internal/cli" "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/detector" + "github.com/step-security/dev-machine-guard/internal/device" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/launchd" + "github.com/step-security/dev-machine-guard/internal/output" "github.com/step-security/dev-machine-guard/internal/progress" "github.com/step-security/dev-machine-guard/internal/scan" "github.com/step-security/dev-machine-guard/internal/schtasks" @@ -160,6 +167,17 @@ func main() { } default: + // --npmrc: focused, verbose pretty audit of npm config only. + // Bypasses all other detectors so the run is fast (~1s) and the + // output is exclusively about npm config — useful when the user is + // debugging a specific .npmrc / registry / token issue. + if cfg.NPMRCOnly { + if err := runNPMRCOnly(exec, cfg); err != nil { + log.Error("%v", err) + os.Exit(1) + } + return + } // Community mode or auto-detect enterprise switch { case cfg.OutputFormatSet || cfg.HTMLOutputFile != "": @@ -184,3 +202,49 @@ func main() { } } } + +// runNPMRCOnly executes only the npmrc detector and renders the verbose +// pretty view (or JSON when --json is also passed). Skips the inventory of +// IDEs / AI tools / brew / node / etc. for speed. +func runNPMRCOnly(exec executor.Executor, cfg *cli.Config) error { + ctx := context.Background() + + // Resolve search dirs the same way the main scan does, so project-level + // .npmrc discovery walks the same tree. + searchDirs := make([]string, 0, len(cfg.SearchDirs)) + for _, d := range cfg.SearchDirs { + if d == "$HOME" { + if u, err := exec.LoggedInUser(); err == nil { + d = u.HomeDir + } + } + searchDirs = append(searchDirs, d) + } + + dev := device.Gather(ctx, exec) + loggedInUser, _ := exec.LoggedInUser() + + d := detector.NewNPMRCDetector(exec) + audit := d.Detect(ctx, searchDirs, loggedInUser) + // Phase B: snapshot diff. Errors are non-fatal (would just produce a + // FirstRun=true diff next time around). + _ = detector.AttachDiff(ctx, exec, &audit, time.Now().Unix(), dev.Hostname) + + // JSON path: emit just the audit object so callers can pipe it into jq + // without wading through the rest of ScanResult. + if cfg.OutputFormat == "json" { + enc := jsonEncoder(os.Stdout) + return enc.Encode(audit) + } + + output.PrettyNPMRC(os.Stdout, &audit, dev, cfg.ColorMode) + return nil +} + +// jsonEncoder returns a 2-space-indented JSON encoder that doesn't HTML-escape. +func jsonEncoder(w io.Writer) *json.Encoder { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 04e1515..93b4e19 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -22,6 +22,7 @@ type Config struct { EnableBrewScan *bool // nil=auto, true/false=explicit EnablePythonScan *bool // nil=auto, true/false=explicit IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output + NPMRCOnly bool // --npmrc: run only the npmrc audit, render verbose pretty output, skip everything else SearchDirs []string // defaults to ["$HOME"] } @@ -87,6 +88,8 @@ func Parse(args []string) (*Config, error) { cfg.EnablePythonScan = &v case arg == "--include-bundled-plugins": cfg.IncludeBundledPlugins = true + case arg == "--npmrc": + cfg.NPMRCOnly = true case strings.HasPrefix(arg, "--color="): mode := strings.TrimPrefix(arg, "--color=") if mode != "auto" && mode != "always" && mode != "never" { @@ -163,6 +166,8 @@ Options: --enable-python-scan Enable Python package scanning --disable-python-scan Disable Python package scanning --include-bundled-plugins Include bundled/platform plugins in output (Windows) + --npmrc Run ONLY the npm config audit and print a verbose pretty view + (skips IDE / AI / Brew / Python / Node scans for speed) --log-level=LEVEL Log level: error | warn | info | debug (default: info) --verbose Shortcut for --log-level=debug --color=WHEN Color mode: auto | always | never (default: auto) diff --git a/internal/detector/npmrc.go b/internal/detector/npmrc.go new file mode 100644 index 0000000..69a9cbb --- /dev/null +++ b/internal/detector/npmrc.go @@ -0,0 +1,666 @@ +package detector + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io/fs" + "os" + "os/user" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// maxNPMRCFiles caps the number of .npmrc files we report. Even on big +// monorepos this should be ample; the cap exists only to prevent a +// pathological case (someone committed `.npmrc` into thousands of subdirs) +// from blowing up the JSON payload. +const maxNPMRCFiles = 1000 + +// maxPerProjectEvaluations caps how many project-scope files we re-evaluate +// effective config for. Each evaluation is a `npm config ls -l --json` +// invocation in that project's directory (~200ms apiece), so this is a +// runtime budget, not a correctness cap. Anything past this gets the +// per-file static view but no overrides analysis. +const maxPerProjectEvaluations = 25 + +// npmEnvVars is the set of environment variables we always record on the +// audit, regardless of whether they are set. Recording an unset var lets the +// diff layer notice when one is *added* between runs (a common worm +// behavior — set NPM_TOKEN and run npm publish). +var npmEnvVars = []string{ + "NPM_TOKEN", + "NPM_CONFIG_USERCONFIG", + "NPM_CONFIG_GLOBALCONFIG", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + "npm_config__authToken", + "npm_config__auth", + "NODE_OPTIONS", + "NODE_TLS_REJECT_UNAUTHORIZED", +} + +// secretEnvNamePattern matches env var names that should be redacted on output. +// The npm config layer accepts both `npm_config_*` (lowercase) and +// `NPM_CONFIG_*` (uppercase) — and any *_TOKEN / *_PASSWORD / *_KEY value +// is treated as a secret regardless of source. +var secretEnvNamePattern = regexp.MustCompile(`(?i)(token|password|secret|_auth|key)`) + +// NPMRCDetector audits npm configuration: discovers all .npmrc files, parses +// them, captures the merged effective view, and surfaces relevant env vars. +// +// The detector intentionally keeps file metadata collection (owner, mode, +// hashes) and git-tracking checks pluggable so unit tests don't need real +// syscalls or a git binary. +type NPMRCDetector struct { + exec executor.Executor + + // ownerLookup returns owner info for a path. Defaults to the real + // platform-specific impl in npmrc_stat_*.go; tests can override. + ownerLookup func(path string) ownerInfo + // gitTracked returns whether the file is tracked by git. Defaults to + // shelling out via the executor; tests can override to a stub. + gitTracked func(ctx context.Context, path string) bool + // inGitRepo walks parent dirs looking for .git. Defaults to a + // filesystem walk; tests can override. + inGitRepo func(path string) bool +} + +type ownerInfo struct { + UID int + GID int + OwnerName string + GroupName string + OK bool +} + +// NewNPMRCDetector returns a detector with default platform-specific +// metadata helpers wired in. +func NewNPMRCDetector(exec executor.Executor) *NPMRCDetector { + d := &NPMRCDetector{exec: exec} + d.ownerLookup = func(p string) ownerInfo { return statOwner(p) } + d.gitTracked = d.defaultGitTracked + d.inGitRepo = defaultInGitRepo + return d +} + +// Detect runs the full audit. searchDirs are the dirs to walk for project- +// level .npmrc files (typically the user's $HOME plus any extra dirs +// configured by the operator). loggedInUser is the username whose ~/.npmrc +// we resolve for the user-scope file. +func (d *NPMRCDetector) Detect(ctx context.Context, searchDirs []string, loggedInUser *user.User) model.NPMRCAudit { + audit := model.NPMRCAudit{ + Files: []model.NPMRCFile{}, + Env: d.collectEnv(), + } + + npmPath, npmErr := d.exec.LookPath("npm") + if npmErr == nil { + audit.Available = true + audit.NPMPath = npmPath + audit.NPMVersion = d.npmVersion(ctx) + } + + // Resolve the four scopes. Each step is independent: if one fails (e.g. + // `npm config get globalconfig` returns nothing), the rest still run. + files := make([]model.NPMRCFile, 0, 8) + seen := make(map[string]bool) + add := func(scope, path string) { + if path == "" { + return + } + abs, err := filepath.Abs(path) + if err == nil { + path = abs + } + if seen[path] { + return + } + seen[path] = true + files = append(files, d.collectFile(ctx, path, scope)) + } + + add("builtin", d.npmConfigGet(ctx, "builtinconfig")) + + if v := d.exec.Getenv("NPM_CONFIG_GLOBALCONFIG"); v != "" { + add("global", v) + } else { + add("global", d.npmConfigGet(ctx, "globalconfig")) + } + + if v := d.exec.Getenv("NPM_CONFIG_USERCONFIG"); v != "" { + add("user", v) + } else if loggedInUser != nil && loggedInUser.HomeDir != "" { + add("user", filepath.Join(loggedInUser.HomeDir, ".npmrc")) + } + + for _, dir := range searchDirs { + for _, p := range d.findProjectNPMRCs(dir) { + if len(files) >= maxNPMRCFiles { + break + } + add("project", p) + } + } + + audit.Files = files + if eff := d.captureEffective(ctx); eff != nil { + audit.Effective = eff + } + + // Per-project effective evaluation: for every project file we found, run + // `npm config ls -l --json` from that file's directory and diff against + // the baseline. The result tells us "if a developer cd's into this + // project and runs npm install, here's what actually changes" — which is + // the threat model for cloned-repo supply-chain attacks. + if audit.Available && audit.Effective != nil && audit.Effective.Error == "" { + d.populateProjectOverrides(ctx, &audit) + } + + return audit +} + +// populateProjectOverrides re-runs `npm config ls -l --json` from each +// project-scope file's directory and computes the diff against the baseline +// effective config. Updates Files in-place (Files is a slice, but we look +// each file up by index since we mutate the embedded record). +// +// Bounded by maxPerProjectEvaluations to keep total runtime sane; projects +// past the cap are left without override info (they still show their +// static parsed contents). +// +// Auth-scope keys (//host/:_authToken etc.) are stripped from npm's +// effective JSON output, so we additionally diff the parsed entries of the +// project file against the baseline set of auth keys (user + global) to +// catch credentials a cloned repo silently ships. +func (d *NPMRCDetector) populateProjectOverrides(ctx context.Context, audit *model.NPMRCAudit) { + baseline := audit.Effective.Config + baselineSources := audit.Effective.SourceByKey + baselineAuthKeys := collectBaselineAuthKeys(audit.Files) + + evaluated := 0 + for i := range audit.Files { + f := &audit.Files[i] + if f.Scope != "project" || !f.Exists || !f.Readable { + continue + } + if evaluated >= maxPerProjectEvaluations { + f.OverrideError = "skipped: per-project evaluation budget exhausted" + continue + } + evaluated++ + + projectDir := filepath.Dir(f.Path) + projectConfig, projectSources, err := d.evaluateInDir(ctx, projectDir) + if err != nil { + f.OverrideError = err.Error() + continue + } + + overrides := computeOverrides(baseline, baselineSources, projectConfig, projectSources) + // Append auth-key overrides that npm's JSON wouldn't surface. + overrides = append(overrides, authOverridesFromEntries(f.Entries, baselineAuthKeys)...) + // Re-sort: auth first, then alphabetical. + sort.SliceStable(overrides, func(i, j int) bool { + if overrides[i].IsAuth != overrides[j].IsAuth { + return overrides[i].IsAuth + } + return overrides[i].Key < overrides[j].Key + }) + f.EffectiveOverrides = overrides + } +} + +// collectBaselineAuthKeys returns the set of auth-key strings that already +// exist in the user or global scope. Used so per-project diffs can flag +// auth keys that appear *only* in the project file as new credentials. +func collectBaselineAuthKeys(files []model.NPMRCFile) map[string]struct{} { + keys := map[string]struct{}{} + for _, f := range files { + if f.Scope != "user" && f.Scope != "global" && f.Scope != "builtin" { + continue + } + for _, e := range f.Entries { + if e.IsAuth { + keys[e.Key] = struct{}{} + } + } + } + return keys +} + +// authOverridesFromEntries returns NPMRCOverride records for auth keys in a +// project file's parsed entries that aren't already present in the baseline +// (user/global) auth-key set. The DisplayValue (already redacted) is used +// for the project-side value; baseline is "". +func authOverridesFromEntries(projectEntries []model.NPMRCEntry, baselineAuthKeys map[string]struct{}) []model.NPMRCOverride { + var out []model.NPMRCOverride + for _, e := range projectEntries { + if !e.IsAuth { + continue + } + if _, exists := baselineAuthKeys[e.Key]; exists { + continue + } + out = append(out, model.NPMRCOverride{ + Key: e.Key, + BaselineValue: "", + ProjectValue: e.DisplayValue, // already redacted + ProjectSource: "project", + IsAuth: true, + IsNew: true, + }) + } + return out +} + +// evaluateInDir runs `npm config ls -l --json` and `npm config ls -l` from +// the given directory and returns the merged config map and the per-key +// source attribution. Errors are returned with enough context for the +// override-error string in the audit to be actionable. +func (d *NPMRCDetector) evaluateInDir(ctx context.Context, dir string) (map[string]any, map[string]string, error) { + stdoutJSON, _, exit, _ := d.exec.RunInDir(ctx, dir, 15*time.Second, "npm", "config", "ls", "-l", "--json") + if exit != 0 { + return nil, nil, fmt.Errorf("npm config ls -l --json (cwd=%s) exited %d", dir, exit) + } + var cfg map[string]any + if err := json.Unmarshal([]byte(stdoutJSON), &cfg); err != nil { + return nil, nil, fmt.Errorf("npm config ls -l --json (cwd=%s) decode: %w", dir, err) + } + stdoutText, _, _, _ := d.exec.RunInDir(ctx, dir, 15*time.Second, "npm", "config", "ls", "-l") + sources := parseSourceAttribution(stdoutText) + return cfg, sources, nil +} + +// computeOverrides diffs a project-cwd effective config against the baseline +// (typically $HOME) and returns the keys whose effective value changes. +// Auth-scoped keys are flagged so the renderer can surface them prominently. +func computeOverrides(baseline map[string]any, baselineSrc map[string]string, project map[string]any, projectSrc map[string]string) []model.NPMRCOverride { + if baseline == nil || project == nil { + return nil + } + + // Walk every key in either map. Skip keys that come from npm's own + // defaults on both sides — those are noise, not overrides the project + // caused. + seen := make(map[string]struct{}, len(baseline)+len(project)) + for k := range baseline { + seen[k] = struct{}{} + } + for k := range project { + seen[k] = struct{}{} + } + + var out []model.NPMRCOverride + for key := range seen { + bv, bok := baseline[key] + pv, pok := project[key] + + bsrc := baselineSrc[key] + psrc := projectSrc[key] + + if bok && pok && jsonEqual(bv, pv) { + continue // value unchanged + } + if !bok && !pok { + continue + } + // Value identical AND both sides come from npm's compiled-in + // defaults — no override happened. + if jsonEqual(bv, pv) && bsrc == "default" && psrc == "default" { + continue + } + + ov := model.NPMRCOverride{ + Key: key, + BaselineValue: formatOverrideValue(bv, bok), + BaselineSource: bsrc, + ProjectValue: formatOverrideValue(pv, pok), + ProjectSource: psrc, + IsAuth: isAuthKey(key), + } + switch { + case !bok: + ov.IsNew = true + case !pok: + ov.IsRemoved = true + } + out = append(out, ov) + } + + // Stable order: auth keys first (most actionable), then alphabetical. + sort.SliceStable(out, func(i, j int) bool { + if out[i].IsAuth != out[j].IsAuth { + return out[i].IsAuth + } + return out[i].Key < out[j].Key + }) + return out +} + +// formatOverrideValue stringifies a config value for display. Returns +// "" when the key wasn't present at all (distinct from being set to +// an empty string or null). +func formatOverrideValue(v any, present bool) string { + if !present { + return "" + } + if v == nil { + return "null" + } + if s, ok := v.(string); ok { + return s + } + return fmt.Sprintf("%v", v) +} + +// jsonEqual compares two values that came out of json.Unmarshal. Strings, +// numbers, and bools work as-is; slices/maps go through fmt.Sprintf which is +// good enough for this detection (we'd rather false-positive than miss a +// real change). +func jsonEqual(a, b any) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) +} + +// findProjectNPMRCs walks dir looking for .npmrc files, applying the same +// directory-skip rules as the node project scanner plus a small set of +// well-known cache locations (Go module cache, vendor dirs) — random .npmrc +// files inside cached/vendored dependencies aren't config the user authored +// and would only add noise to the audit. Returns absolute paths. +func (d *NPMRCDetector) findProjectNPMRCs(dir string) []string { + if dir == "" { + return nil + } + var results []string + _ = filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return nil + } + if entry.IsDir() { + if shouldSkipNPMRCDir(path, entry.Name(), dir) { + return filepath.SkipDir + } + return nil + } + if entry.Name() == ".npmrc" { + if abs, err := filepath.Abs(path); err == nil { + results = append(results, abs) + } else { + results = append(results, path) + } + } + return nil + }) + return results +} + +// shouldSkipNPMRCDir returns true when the directory should be skipped during +// project-level .npmrc discovery. Mirrors nodescan.go's exclusions and adds +// well-known dependency-cache locations (Go module cache, vendor dirs, +// language-specific caches under $HOME). +func shouldSkipNPMRCDir(path, name, root string) bool { + switch name { + case "node_modules", ".git", ".cache", "vendor": + return true + } + if strings.HasPrefix(name, ".") && path != root { + return true + } + // Path-based skips for caches whose dir names alone aren't distinctive. + slashed := filepath.ToSlash(path) + if strings.HasSuffix(slashed, "/pkg/mod") || strings.Contains(slashed, "/pkg/mod/") { + return true + } + if strings.Contains(slashed, "/Library/Caches/") { + return true + } + return false +} + +// collectFile gathers everything we know about one .npmrc path. Always +// returns a record — non-existent files are surfaced with Exists=false so +// the caller can see "we looked here, nothing was there." +func (d *NPMRCDetector) collectFile(ctx context.Context, path, scope string) model.NPMRCFile { + f := model.NPMRCFile{ + Path: path, + Scope: scope, + } + + // Lstat first so a symlink doesn't get followed silently. + linfo, err := os.Lstat(path) + if err != nil { + // Distinguish "not found" from "not readable" so the user can act. + if os.IsNotExist(err) { + f.Exists = false + return f + } + f.Exists = true + f.ParseError = "lstat: " + err.Error() + return f + } + f.Exists = true + + if linfo.Mode()&os.ModeSymlink != 0 { + if target, err := os.Readlink(path); err == nil { + f.SymlinkTo = target + } + } + + // Stat (follows symlinks) for size/mtime/mode. + info, err := os.Stat(path) + if err != nil { + f.Readable = false + f.ParseError = "stat: " + err.Error() + return f + } + f.SizeBytes = info.Size() + f.ModTimeUnix = info.ModTime().Unix() + f.Mode = fmt.Sprintf("%#o", info.Mode().Perm()) + + if info.IsDir() { + f.ParseError = "path is a directory" + return f + } + + if d.ownerLookup != nil { + if oi := d.ownerLookup(path); oi.OK { + f.OwnerUID = oi.UID + f.GroupGID = oi.GID + f.OwnerName = oi.OwnerName + f.GroupName = oi.GroupName + } + } + + data, err := os.ReadFile(path) + if err != nil { + f.Readable = false + f.ParseError = "read: " + err.Error() + return f + } + f.Readable = true + + sum := sha256.Sum256(data) + f.SHA256 = hex.EncodeToString(sum[:]) + + f.Entries = parseNPMRC(data) + + if d.inGitRepo != nil && d.inGitRepo(path) { + f.InGitRepo = true + if d.gitTracked != nil && d.gitTracked(ctx, path) { + f.GitTracked = true + } + } + + return f +} + +// captureEffective runs `npm config ls -l --json` and `npm config ls -l` for +// source attribution. Returns nil when npm is unavailable. +func (d *NPMRCDetector) captureEffective(ctx context.Context) *model.NPMRCEffective { + if _, err := d.exec.LookPath("npm"); err != nil { + return nil + } + eff := &model.NPMRCEffective{ + SourceByKey: map[string]string{}, + Config: map[string]any{}, + } + + stdoutJSON, _, exit, _ := d.exec.RunWithTimeout(ctx, 15*time.Second, "npm", "config", "ls", "-l", "--json") + if exit == 0 && strings.TrimSpace(stdoutJSON) != "" { + var parsed map[string]any + if err := json.Unmarshal([]byte(stdoutJSON), &parsed); err != nil { + eff.Error = "json decode: " + err.Error() + } else { + eff.Config = parsed + } + } else if eff.Error == "" && exit != 0 { + eff.Error = fmt.Sprintf("npm config ls -l --json exited with %d", exit) + } + + stdoutText, _, exitText, _ := d.exec.RunWithTimeout(ctx, 15*time.Second, "npm", "config", "ls", "-l") + if exitText == 0 && stdoutText != "" { + eff.SourceByKey = parseSourceAttribution(stdoutText) + } + + return eff +} + +// parseSourceAttribution scans the textual output of `npm config ls -l`, +// which groups keys under `; "" config from ""` headers. +// +// ; "user" config from "/Users/me/.npmrc" +// registry = "https://registry.npmjs.org/" +// ; "default" values +// access = null +// +// We map each non-comment, non-section key to the most recent header seen. +func parseSourceAttribution(text string) map[string]string { + out := map[string]string{} + headerRE := regexp.MustCompile(`^;\s*"([^"]+)"\s*(?:config from\s*"([^"]+)")?`) + currentSource := "default" + for _, line := range strings.Split(text, "\n") { + raw := strings.TrimRight(line, "\r") + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, ";") { + if m := headerRE.FindStringSubmatch(trimmed); m != nil { + if m[2] != "" { + currentSource = m[2] // path is more specific than label + } else { + currentSource = m[1] + } + } + continue + } + // `key = value` or `@scope:registry = value`. + if eq := strings.IndexByte(trimmed, '='); eq > 0 { + key := strings.TrimSpace(trimmed[:eq]) + if key != "" { + out[key] = currentSource + } + } + } + return out +} + +// npmVersion returns the npm CLI's version string, "unknown" on failure. +func (d *NPMRCDetector) npmVersion(ctx context.Context) string { + stdout, _, exit, _ := d.exec.RunWithTimeout(ctx, 5*time.Second, "npm", "--version") + if exit != 0 { + return "unknown" + } + v := strings.TrimSpace(stdout) + if v == "" { + return "unknown" + } + return v +} + +// npmConfigGet runs `npm config get ` and returns the trimmed value, or +// empty if the call failed or the value is "undefined" (npm's literal output +// for an unset key). +func (d *NPMRCDetector) npmConfigGet(ctx context.Context, key string) string { + stdout, _, exit, _ := d.exec.RunWithTimeout(ctx, 5*time.Second, "npm", "config", "get", key) + if exit != 0 { + return "" + } + v := strings.TrimSpace(stdout) + if v == "undefined" || v == "null" { + return "" + } + return v +} + +// collectEnv builds a snapshot of the npm-relevant environment. Sensitive +// values are redacted; the SHA-256 lets the change-tracking layer notice +// rotation without ever surfacing the secret. +func (d *NPMRCDetector) collectEnv() []model.NPMRCEnvVar { + out := make([]model.NPMRCEnvVar, 0, len(npmEnvVars)) + for _, name := range npmEnvVars { + v := d.exec.Getenv(name) + ev := model.NPMRCEnvVar{Name: name, Set: v != ""} + if v != "" { + ev.ValueSHA256 = sha256Hex(v) + if secretEnvNamePattern.MatchString(name) { + ev.DisplayValue = redactSecret(v) + } else { + ev.DisplayValue = v + } + } + out = append(out, ev) + } + return out +} + +// defaultGitTracked shells out to git to check if a file is tracked. +// Returns false on any error (git not installed, not in a repo, untracked). +func (d *NPMRCDetector) defaultGitTracked(ctx context.Context, path string) bool { + dir := filepath.Dir(path) + base := filepath.Base(path) + _, _, exit, err := d.exec.RunWithTimeout(ctx, 5*time.Second, "git", "-C", dir, "ls-files", "--error-unmatch", base) + return err == nil && exit == 0 +} + +// defaultInGitRepo walks parent directories looking for a .git entry. +// Stops at the filesystem root. +func defaultInGitRepo(path string) bool { + dir := filepath.Dir(path) + for { + gitPath := filepath.Join(dir, ".git") + if info, err := os.Stat(gitPath); err == nil { + // .git can be a directory (regular repo) or a file (worktree). + _ = info + return true + } + parent := filepath.Dir(dir) + if parent == dir { + return false + } + dir = parent + } +} + +// sha256Hex returns the hex SHA-256 of a string. +func sha256Hex(s string) string { + if s == "" { + return "" + } + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/detector/npmrc_attribution.go b/internal/detector/npmrc_attribution.go new file mode 100644 index 0000000..2febc31 --- /dev/null +++ b/internal/detector/npmrc_attribution.go @@ -0,0 +1,324 @@ +package detector + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// suspectCommExact is the allowlist of process command-name basenames worth +// reporting as candidates for "who modified this .npmrc." Match is exact +// against the COMM column from `ps` (the binary basename without args), so +// "gnome-shell" doesn't match "sh" the way a substring filter would. +// +// Editors and tools that frequently launch with absolute or hyphenated +// paths still match because we strip to basename before comparing. +var suspectCommExact = map[string]struct{}{ + "npm": {}, + "npx": {}, + "yarn": {}, + "pnpm": {}, + "bun": {}, + "node": {}, + "nodejs": {}, + "sh": {}, + "bash": {}, + "zsh": {}, + "dash": {}, + "fish": {}, + "vi": {}, + "vim": {}, + "nvim": {}, + "nano": {}, + "emacs": {}, + "git": {}, + "curl": {}, + "wget": {}, + "python": {}, + "python3": {}, + "perl": {}, + "ruby": {}, + // Editors that show up under their full binary name on most distros. + "code": {}, + "cursor": {}, + "windsurf": {}, +} + +// suspectArgsContains catches processes whose comm doesn't match exactly +// but whose argv string indicates an npmrc-relevant action. We keep this +// list short and specific so it stays low-noise. Substrings are matched +// against the full args string, lowercased. +var suspectArgsContains = []string{ + "npm install", "npm publish", "npm config", + "yarn install", "yarn add", + "pnpm install", "pnpm add", + ".npmrc", +} + +// EnrichAttribution adds human-readable notes and (when the file changed +// recently) a process-list snapshot to each NPMRCFileModification on the +// diff. The diff struct is mutated in place. Safe to call with a nil diff +// or a diff that has no modifications. +// +// Attribution is best-effort. We never claim "process X did it"; we say +// "here are the candidate processes that were running when we observed +// the change." Forensic-grade attribution requires audit logs that we +// don't have access to from a normal-priv agent. +func EnrichAttribution(ctx context.Context, exec executor.Executor, diff *model.NPMRCDiff, scanTimeUnix int64) { + if diff == nil || len(diff.ModifiedFiles) == 0 { + return + } + + // Lazy-load the process list: only run `ps` once even if multiple + // modified files all qualify for the recent-mtime path. + var procListCached []model.NPMRCSuspect + procListLoaded := false + + loadProcs := func() []model.NPMRCSuspect { + if procListLoaded { + return procListCached + } + procListCached = captureSuspects(ctx, exec) + procListLoaded = true + return procListCached + } + + for i := range diff.ModifiedFiles { + mod := &diff.ModifiedFiles[i] + + // Owner change is the strongest "different writer" signal. + if mod.OwnerChanged != nil { + mod.AttributionNotes = append(mod.AttributionNotes, + fmt.Sprintf("file owner changed from %q to %q — write performed by a different user account", + mod.OwnerChanged.From, mod.OwnerChanged.To)) + } + if mod.GroupChanged != nil { + mod.AttributionNotes = append(mod.AttributionNotes, + fmt.Sprintf("file group changed from %q to %q", + mod.GroupChanged.From, mod.GroupChanged.To)) + } + if mod.ModeChanged != nil { + note := fmt.Sprintf("file mode changed from %s to %s", mod.ModeChanged.From, mod.ModeChanged.To) + // Loosened-mode flag: if the new mode is more permissive than + // the old one (e.g. 0600 → 0644), call it out. + if isModeRelaxed(mod.ModeChanged.From, mod.ModeChanged.To) { + note += " — permissions relaxed" + } + mod.AttributionNotes = append(mod.AttributionNotes, note) + } + + // Recent-mtime path: snapshot processes if we can. We don't have + // the per-file mtime on the modification record itself; we need + // to look it up from the audit. This function takes scanTimeUnix + // and trusts the caller to have set ContentChanged/etc. correctly, + // but we re-derive recency from the size/mode/content change as a + // proxy: if the file was modified in this run, we capture + // suspects unconditionally. (Fine — it's bounded to ModifiedFiles.) + _ = scanTimeUnix + if mod.ContentChanged || mod.ModeChanged != nil || mod.OwnerChanged != nil { + suspects := loadProcs() + if len(suspects) > 0 { + mod.Suspects = suspects + mod.AttributionNotes = append(mod.AttributionNotes, + fmt.Sprintf("%d candidate process(es) running at scan time", len(suspects))) + } + } + } +} + +// isModeRelaxed reports whether the to-mode is more permissive than the +// from-mode. Used to flag permission relaxation (`0600 → 0644` on a user +// `.npmrc` is suspicious — the file became world-readable). +func isModeRelaxed(from, to string) bool { + fp, ok1 := parseOctalMode(from) + tp, ok2 := parseOctalMode(to) + if !ok1 || !ok2 { + return false + } + // "Relaxed" = any bit added in `to` that wasn't in `from`. That + // catches owner-restricted → group/world-readable, and read-only → + // writable, etc. + return tp & ^fp != 0 +} + +// parseOctalMode strips a leading "0" or "0o" and parses the rest as +// octal. The mode strings we record are like "0600" or "0644". +func parseOctalMode(s string) (uint32, bool) { + if s == "" { + return 0, false + } + s = strings.TrimPrefix(s, "0o") + s = strings.TrimPrefix(s, "0") + v, err := strconv.ParseUint(s, 8, 32) + if err != nil { + return 0, false + } + return uint32(v), true +} + +// captureSuspects runs the platform-appropriate process-list command, +// parses it, and filters to commands matching our suspect-pattern list. +// On any error we return nil — attribution is informational, not load-bearing. +func captureSuspects(ctx context.Context, exec executor.Executor) []model.NPMRCSuspect { + switch exec.GOOS() { + case "windows": + stdout, _, _, err := exec.RunWithTimeout(ctx, 5*time.Second, "tasklist", "/fo", "csv", "/nh") + if err != nil { + return nil + } + return parseTasklistCSV(stdout) + default: + // `ps -eo pid,user,comm,args` is portable across Linux + macOS. + stdout, _, _, err := exec.RunWithTimeout(ctx, 5*time.Second, "ps", "-eo", "pid,user,comm,args") + if err != nil { + return nil + } + return parsePSOutput(stdout) + } +} + +// parsePSOutput parses `ps -eo pid,user,comm,args` output into a filtered +// suspect list. Header line is skipped. +func parsePSOutput(stdout string) []model.NPMRCSuspect { + var out []model.NPMRCSuspect + lines := strings.Split(stdout, "\n") + for i, line := range lines { + if i == 0 { + continue // header + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Format is: " PID USER COMM ARGS..." + // COMM is the short command name; ARGS is everything after it. + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + user := fields[1] + comm := fields[2] + // Reconstruct args: everything after fields[2]. Use the original + // line so we keep original spacing. + argsIdx := strings.Index(line, fields[2]) + args := "" + if argsIdx >= 0 { + tail := line[argsIdx:] + // Skip past comm + whitespace. + rest := strings.TrimSpace(strings.TrimPrefix(tail, fields[2])) + args = rest + } + if !commMatchesSuspect(comm, args) { + continue + } + cmd := args + if cmd == "" { + cmd = comm + } + out = append(out, model.NPMRCSuspect{PID: pid, User: user, Cmd: truncateCmd(cmd, 200)}) + } + return out +} + +// parseTasklistCSV parses Windows `tasklist /fo csv /nh` output. Format: +// +// "image_name","PID","Session Name","Session#","Mem Usage" +// +// We only have image name + PID. Better than nothing for cross-platform. +func parseTasklistCSV(stdout string) []model.NPMRCSuspect { + var out []model.NPMRCSuspect + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Naive CSV parse — fields are quoted, comma-separated. + fields := splitCSVLine(line) + if len(fields) < 2 { + continue + } + image := strings.TrimSpace(fields[0]) + pidStr := strings.TrimSpace(fields[1]) + pid, err := strconv.Atoi(pidStr) + if err != nil { + continue + } + if !commMatchesSuspect(image, "") { + continue + } + out = append(out, model.NPMRCSuspect{PID: pid, Cmd: image}) + } + return out +} + +// splitCSVLine handles a single line of double-quoted, comma-separated +// values. It's not RFC 4180-complete (no escaped quotes), but tasklist +// output doesn't use those. +func splitCSVLine(line string) []string { + var out []string + inQuotes := false + start := 0 + for i, r := range line { + switch r { + case '"': + inQuotes = !inQuotes + case ',': + if !inQuotes { + out = append(out, strings.Trim(line[start:i], `" `)) + start = i + 1 + } + } + } + out = append(out, strings.Trim(line[start:], `" `)) + return out +} + +// commMatchesSuspect returns true when the process is a plausible writer +// of a .npmrc file. The comm field is matched exactly (basename) against +// suspectCommExact; the args string is checked for substrings that +// indicate an npm-relevant invocation regardless of which interpreter +// fronts it (e.g., a `python install_npm.py` script that contains +// "npm install" in argv would surface). +func commMatchesSuspect(comm, args string) bool { + // Strip any leading path so /usr/bin/node → "node". + base := comm + if idx := strings.LastIndexAny(base, `/\`); idx >= 0 { + base = base[idx+1:] + } + base = strings.ToLower(strings.TrimSuffix(base, ".exe")) + if _, ok := suspectCommExact[base]; ok { + return true + } + if args != "" { + argsLow := strings.ToLower(args) + for _, pat := range suspectArgsContains { + if strings.Contains(argsLow, pat) { + return true + } + } + } + return false +} + +// truncateCmd shortens a command line to a max length, ellipsizing the +// middle. Keeps both ends because trailing args (like "install ") +// are often the most informative bit. +func truncateCmd(s string, max int) string { + if len(s) <= max { + return s + } + if max < 8 { + return s[:max] + } + half := (max - 3) / 2 + return s[:half] + "..." + s[len(s)-half:] +} diff --git a/internal/detector/npmrc_diff.go b/internal/detector/npmrc_diff.go new file mode 100644 index 0000000..197a3b0 --- /dev/null +++ b/internal/detector/npmrc_diff.go @@ -0,0 +1,297 @@ +package detector + +import ( + "fmt" + "sort" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +// DiffNPMRC produces an NPMRCDiff describing how a fresh audit differs from +// the previous snapshot. +// +// - prev == nil → FirstRun=true; everything else empty. +// - per-file: appeared / disappeared / sha256 differs / metadata differs / entry list differs +// - per env var: set transitions, value-sha rotation +// +// The diff never references plaintext: it operates only on +// already-redacted display values (in NPMRCFile.Entries) and SHA-256 +// fingerprints. That guarantee is a property of NPMRCSnapshot's schema — +// see the comment on NPMRCEntryDigest. +func DiffNPMRC(prev *model.NPMRCSnapshot, current *model.NPMRCAudit, currentTakenAtUnix int64) *model.NPMRCDiff { + if current == nil { + return nil + } + diff := &model.NPMRCDiff{ + CurrentAt: currentTakenAtUnix, + } + if prev == nil { + diff.FirstRun = true + return diff + } + diff.PreviousAt = prev.TakenAt + + // Index previous-state files by path for O(1) lookup. Same for current + // audit's entries, which we'll need by-path-then-by-key. + prevByPath := make(map[string]model.NPMRCFileSnapshot, len(prev.Files)) + for _, f := range prev.Files { + prevByPath[f.Path] = f + } + currentByPath := make(map[string]model.NPMRCFile, len(current.Files)) + for _, f := range current.Files { + currentByPath[f.Path] = f + } + + // Walk current files: anything new is "added"; anything also in prev is + // either unchanged or modified. + for _, cur := range current.Files { + ps, hadBefore := prevByPath[cur.Path] + if !hadBefore { + // Surface the file as added if we actually see content for it. + // A file we resolved a path for but that doesn't exist on disk + // would have Exists=false; treating that as "added" is noisy. + if cur.Exists { + diff.AddedFiles = append(diff.AddedFiles, model.NPMRCFileChange{Path: cur.Path, Scope: cur.Scope}) + } + continue + } + // Was-existing-now-existing: full modification check. + // Was-existing-now-missing or vice versa is captured by the existence + // flag — surface it as a removal/addition instead of a modification. + if ps.Exists && !cur.Exists { + diff.RemovedFiles = append(diff.RemovedFiles, model.NPMRCFileChange{Path: cur.Path, Scope: cur.Scope}) + continue + } + if !ps.Exists && cur.Exists { + diff.AddedFiles = append(diff.AddedFiles, model.NPMRCFileChange{Path: cur.Path, Scope: cur.Scope}) + continue + } + if !cur.Exists && !ps.Exists { + continue // both missing; nothing to say + } + mod, changed := diffFile(ps, cur) + if changed { + diff.ModifiedFiles = append(diff.ModifiedFiles, mod) + } + } + + // Walk previous files: paths that are no longer in the current audit + // at all → file disappeared (e.g. a project was deleted). + for _, ps := range prev.Files { + if _, stillThere := currentByPath[ps.Path]; stillThere { + continue + } + if ps.Exists { + diff.RemovedFiles = append(diff.RemovedFiles, model.NPMRCFileChange{Path: ps.Path, Scope: ps.Scope}) + } + } + + // Stable order so the JSON is reproducible. + sort.SliceStable(diff.AddedFiles, func(i, j int) bool { return diff.AddedFiles[i].Path < diff.AddedFiles[j].Path }) + sort.SliceStable(diff.RemovedFiles, func(i, j int) bool { return diff.RemovedFiles[i].Path < diff.RemovedFiles[j].Path }) + sort.SliceStable(diff.ModifiedFiles, func(i, j int) bool { return diff.ModifiedFiles[i].Path < diff.ModifiedFiles[j].Path }) + + curEnvSnap := envToSnapshot(current.Env) + diff.EnvChanges = diffEnv(prev.Env, curEnvSnap) + + return diff +} + +// envToSnapshot converts the live env-var slice on an audit to the digest +// form used for diffing. The display value is dropped — only Set + SHA +// matter across runs. +func envToSnapshot(env []model.NPMRCEnvVar) []model.NPMRCEnvVarSnapshot { + out := make([]model.NPMRCEnvVarSnapshot, len(env)) + for i, e := range env { + out[i] = model.NPMRCEnvVarSnapshot{Name: e.Name, Set: e.Set, ValueSHA256: e.ValueSHA256} + } + return out +} + +// diffFile compares one file's previous snapshot to its current state and +// returns the modification record. The bool indicates whether anything +// actually changed — false means we should drop the record (an +// unchanged-but-existing file is not interesting). +func diffFile(prev model.NPMRCFileSnapshot, cur model.NPMRCFile) (model.NPMRCFileModification, bool) { + mod := model.NPMRCFileModification{Path: cur.Path, Scope: cur.Scope} + changed := false + + if prev.SHA256 != cur.SHA256 { + mod.ContentChanged = true + changed = true + } + if prev.OwnerName != cur.OwnerName { + mod.OwnerChanged = &model.NPMRCStringChange{From: prev.OwnerName, To: cur.OwnerName} + changed = true + } + if prev.GroupName != cur.GroupName { + mod.GroupChanged = &model.NPMRCStringChange{From: prev.GroupName, To: cur.GroupName} + changed = true + } + if prev.Mode != cur.Mode { + mod.ModeChanged = &model.NPMRCStringChange{From: prev.Mode, To: cur.Mode} + changed = true + } + if prev.SizeBytes != cur.SizeBytes { + mod.SizeChanged = &model.NPMRCInt64Change{From: prev.SizeBytes, To: cur.SizeBytes} + changed = true + } + + // Entry-level diff. Use compound keys (key + array-suffix) so two + // `key[]=` lines don't collide. Within an array, we identify each + // distinct entry by its position-stable signature: key + value SHA. + // This means an array entry with a rotated value reads as + // removed-then-added, which is fine — surfaced clearly enough. + prevEntries := indexEntries(prev.Entries) + curEntries := indexEntries(toDigests(cur.Entries)) + + addedKeys := []string{} + removedKeys := []string{} + for sig, e := range curEntries { + if _, ok := prevEntries[sig]; !ok { + addedKeys = append(addedKeys, sig) + mod.AddedEntries = append(mod.AddedEntries, e) + changed = true + } + } + for sig, e := range prevEntries { + if _, ok := curEntries[sig]; !ok { + removedKeys = append(removedKeys, sig) + mod.RemovedEntries = append(mod.RemovedEntries, e) + changed = true + } + } + + // Value-changed: same key, distinct sha. Walk each entry by name and + // match where the value SHA differs. + prevByKey := groupByKey(prev.Entries) + curByKey := groupByKey(toDigests(cur.Entries)) + for key, curList := range curByKey { + prevList := prevByKey[key] + // Single-value keys: compare directly. + if len(curList) == 1 && len(prevList) == 1 { + if prevList[0].ValueSHA256 != curList[0].ValueSHA256 { + mod.ChangedEntries = append(mod.ChangedEntries, model.NPMRCEntryValueDiff{ + Key: key, + IsAuth: curList[0].IsAuth, + PreviousSHA256: prevList[0].ValueSHA256, + CurrentSHA256: curList[0].ValueSHA256, + }) + changed = true + // Don't double-count this as added/removed. + removeFromList(&mod.AddedEntries, key, curList[0].ValueSHA256) + removeFromList(&mod.RemovedEntries, key, prevList[0].ValueSHA256) + } + } + // Multi-value keys (array form): the added/removed lists already + // describe the change at the SHA-pair granularity, which is + // sufficient. Fancier matching isn't worth the complexity for now. + } + + if mod.AddedEntries == nil { + mod.AddedEntries = nil // keep nil-vs-[] consistent for JSON + } + + sort.SliceStable(mod.AddedEntries, func(i, j int) bool { return mod.AddedEntries[i].Key < mod.AddedEntries[j].Key }) + sort.SliceStable(mod.RemovedEntries, func(i, j int) bool { return mod.RemovedEntries[i].Key < mod.RemovedEntries[j].Key }) + sort.SliceStable(mod.ChangedEntries, func(i, j int) bool { + // Auth changes float to the top — they're the most actionable. + if mod.ChangedEntries[i].IsAuth != mod.ChangedEntries[j].IsAuth { + return mod.ChangedEntries[i].IsAuth + } + return mod.ChangedEntries[i].Key < mod.ChangedEntries[j].Key + }) + + return mod, changed +} + +// removeFromList drops the entry matching (key, valueSHA256) from a slice +// of NPMRCEntryDigest. Used to suppress double-counting an entry as both +// "value changed" and "added/removed". Stable; preserves order of the rest. +func removeFromList(list *[]model.NPMRCEntryDigest, key, valueSHA string) { + if list == nil || *list == nil { + return + } + out := (*list)[:0] + for _, e := range *list { + if e.Key == key && e.ValueSHA256 == valueSHA { + continue + } + out = append(out, e) + } + *list = out +} + +// indexEntries keys each entry by a stable signature (key + value SHA) so +// two "same key, same value" entries collapse and two "same key, different +// value" entries don't. +func indexEntries(entries []model.NPMRCEntryDigest) map[string]model.NPMRCEntryDigest { + out := make(map[string]model.NPMRCEntryDigest, len(entries)) + for _, e := range entries { + sig := fmt.Sprintf("%s|%s", e.Key, e.ValueSHA256) + out[sig] = e + } + return out +} + +// groupByKey groups entry digests by their key — used for the +// value-changed pass that matches same-key pairs across snapshots. +func groupByKey(entries []model.NPMRCEntryDigest) map[string][]model.NPMRCEntryDigest { + out := make(map[string][]model.NPMRCEntryDigest, len(entries)) + for _, e := range entries { + out[e.Key] = append(out[e.Key], e) + } + return out +} + +// toDigests converts a slice of full entries (from a fresh audit) to the +// digest form (which is what diffing operates on). +func toDigests(entries []model.NPMRCEntry) []model.NPMRCEntryDigest { + out := make([]model.NPMRCEntryDigest, len(entries)) + for i, e := range entries { + out[i] = model.NPMRCEntryDigest{ + Key: e.Key, + ValueSHA256: e.ValueSHA256, + IsAuth: e.IsAuth, + IsArray: e.IsArray, + } + } + return out +} + +// diffEnv compares two slices of env-var snapshots by Name and emits one +// change record per transition. Names absent from both sides are skipped. +func diffEnv(prev, current []model.NPMRCEnvVarSnapshot) []model.NPMRCEnvChange { + prevByName := make(map[string]model.NPMRCEnvVarSnapshot, len(prev)) + for _, e := range prev { + prevByName[e.Name] = e + } + currentByName := make(map[string]model.NPMRCEnvVarSnapshot, len(current)) + for _, e := range current { + currentByName[e.Name] = e + } + + var out []model.NPMRCEnvChange + for _, ce := range current { + pe, hadBefore := prevByName[ce.Name] + switch { + case !hadBefore: + // New name in our watch list (e.g. we expanded the list). + // Treat as a transition only if it became set. + if ce.Set { + out = append(out, model.NPMRCEnvChange{Name: ce.Name, Type: "appeared", CurrentSHA256: ce.ValueSHA256}) + } + case !pe.Set && ce.Set: + out = append(out, model.NPMRCEnvChange{Name: ce.Name, Type: "appeared", CurrentSHA256: ce.ValueSHA256}) + case pe.Set && !ce.Set: + out = append(out, model.NPMRCEnvChange{Name: ce.Name, Type: "disappeared", PreviousSHA256: pe.ValueSHA256}) + case pe.Set && ce.Set && pe.ValueSHA256 != ce.ValueSHA256: + out = append(out, model.NPMRCEnvChange{Name: ce.Name, Type: "value_changed", PreviousSHA256: pe.ValueSHA256, CurrentSHA256: ce.ValueSHA256}) + } + } + // Names removed from the watch list aren't a "change" — they're a + // schema change. Skip. + + sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/internal/detector/npmrc_diff_test.go b/internal/detector/npmrc_diff_test.go new file mode 100644 index 0000000..4164f63 --- /dev/null +++ b/internal/detector/npmrc_diff_test.go @@ -0,0 +1,336 @@ +package detector + +import ( + "context" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// fakeAudit is a small builder for a fresh audit used across diff tests. +// One user .npmrc with three entries; the env var slice is empty unless +// the test customizes it. Every test starts from this and tweaks for +// scenario-specific changes. +func fakeAudit() *model.NPMRCAudit { + return &model.NPMRCAudit{ + Available: true, + Files: []model.NPMRCFile{ + { + Path: "/u/.npmrc", + Scope: "user", + Exists: true, + Readable: true, + SHA256: "sha-A", + SizeBytes: 100, + Mode: "0600", + OwnerName: "alice", + GroupName: "alice", + ModTimeUnix: 1_700_000_000, + Entries: []model.NPMRCEntry{ + {Key: "registry", DisplayValue: "https://npm.org/", ValueSHA256: "v-reg-A"}, + {Key: "//npm.org/:_authToken", DisplayValue: "***1234", ValueSHA256: "v-auth-A", IsAuth: true}, + {Key: "ca", DisplayValue: "ca-data", ValueSHA256: "v-ca-A", IsArray: true}, + }, + }, + }, + Env: []model.NPMRCEnvVar{ + {Name: "NPM_TOKEN", Set: false}, + {Name: "NODE_OPTIONS", Set: false}, + }, + } +} + +// snapshotOf converts the fake audit into the on-disk snapshot form so +// diff tests can pretend a previous scan happened. +func snapshotOf(a *model.NPMRCAudit, takenAt int64) *model.NPMRCSnapshot { + s := BuildNPMRCSnapshot(a, takenAt, "host") + return &s +} + +func TestDiffNPMRC_FirstRun(t *testing.T) { + cur := fakeAudit() + d := DiffNPMRC(nil, cur, 1_700_000_500) + if d == nil { + t.Fatal("nil diff") + } + if !d.FirstRun { + t.Error("FirstRun should be true when prev is nil") + } + if d.HasChanges() { + t.Error("first run should have no listed changes") + } +} + +func TestDiffNPMRC_NoChange(t *testing.T) { + cur := fakeAudit() + prev := snapshotOf(cur, 1_700_000_000) + d := DiffNPMRC(prev, cur, 1_700_000_500) + if d.FirstRun { + t.Error("not a first run") + } + if d.HasChanges() { + t.Errorf("expected no changes, got %+v", d) + } +} + +func TestDiffNPMRC_FileAdded(t *testing.T) { + cur := fakeAudit() + // prev had no files at all. + prev := &model.NPMRCSnapshot{SnapshotVersion: CurrentNPMRCSnapshotVersion, TakenAt: 1_700_000_000} + d := DiffNPMRC(prev, cur, 1_700_000_500) + if len(d.AddedFiles) != 1 || d.AddedFiles[0].Path != "/u/.npmrc" { + t.Errorf("expected /u/.npmrc to be added, got %+v", d.AddedFiles) + } +} + +func TestDiffNPMRC_FileRemoved(t *testing.T) { + prev := snapshotOf(fakeAudit(), 1_700_000_000) + // current has no files (e.g., user wiped their config). + cur := &model.NPMRCAudit{Files: []model.NPMRCFile{}, Env: fakeAudit().Env} + d := DiffNPMRC(prev, cur, 1_700_000_500) + if len(d.RemovedFiles) != 1 || d.RemovedFiles[0].Path != "/u/.npmrc" { + t.Errorf("expected /u/.npmrc to be removed, got %+v", d.RemovedFiles) + } +} + +func TestDiffNPMRC_OwnerAndModeChange(t *testing.T) { + prev := snapshotOf(fakeAudit(), 1_700_000_000) + + cur := fakeAudit() + cur.Files[0].OwnerName = "root" + cur.Files[0].Mode = "0666" + + d := DiffNPMRC(prev, cur, 1_700_000_500) + if len(d.ModifiedFiles) != 1 { + t.Fatalf("expected 1 modified file, got %d", len(d.ModifiedFiles)) + } + mod := d.ModifiedFiles[0] + if mod.OwnerChanged == nil || mod.OwnerChanged.From != "alice" || mod.OwnerChanged.To != "root" { + t.Errorf("owner change wrong: %+v", mod.OwnerChanged) + } + if mod.ModeChanged == nil || mod.ModeChanged.From != "0600" || mod.ModeChanged.To != "0666" { + t.Errorf("mode change wrong: %+v", mod.ModeChanged) + } +} + +func TestDiffNPMRC_EntryRotated(t *testing.T) { + prev := snapshotOf(fakeAudit(), 1_700_000_000) + + cur := fakeAudit() + // auth value rotated; sha changes. + cur.Files[0].Entries[1].ValueSHA256 = "v-auth-B" + cur.Files[0].SHA256 = "sha-B" // file content also differs in reality + + d := DiffNPMRC(prev, cur, 1_700_000_500) + if len(d.ModifiedFiles) != 1 { + t.Fatalf("expected 1 modified, got %d: %+v", len(d.ModifiedFiles), d) + } + mod := d.ModifiedFiles[0] + + // Should have a value-changed for the auth key. + var foundAuth bool + for _, ce := range mod.ChangedEntries { + if ce.Key == "//npm.org/:_authToken" { + foundAuth = true + if !ce.IsAuth { + t.Errorf("expected IsAuth on auth diff") + } + if ce.PreviousSHA256 != "v-auth-A" || ce.CurrentSHA256 != "v-auth-B" { + t.Errorf("sha pair wrong: %+v", ce) + } + } + } + if !foundAuth { + t.Errorf("auth-token rotation not detected: %+v", mod.ChangedEntries) + } + // Should NOT show the rotated entry as added or removed (we suppress + // double-counting). + for _, e := range mod.AddedEntries { + if e.Key == "//npm.org/:_authToken" { + t.Errorf("rotated auth key should not appear in AddedEntries: %+v", e) + } + } + for _, e := range mod.RemovedEntries { + if e.Key == "//npm.org/:_authToken" { + t.Errorf("rotated auth key should not appear in RemovedEntries: %+v", e) + } + } + // Auth diff sorts to the top. + if mod.ChangedEntries[0].Key != "//npm.org/:_authToken" { + t.Errorf("auth change should be first: %+v", mod.ChangedEntries) + } +} + +func TestDiffNPMRC_EntryAdded(t *testing.T) { + prev := snapshotOf(fakeAudit(), 1_700_000_000) + + cur := fakeAudit() + cur.Files[0].SHA256 = "sha-B" + cur.Files[0].Entries = append(cur.Files[0].Entries, model.NPMRCEntry{ + Key: "ignore-scripts", DisplayValue: "true", ValueSHA256: "v-ig", + }) + + d := DiffNPMRC(prev, cur, 1_700_000_500) + if len(d.ModifiedFiles) != 1 { + t.Fatalf("expected 1 modified, got %d", len(d.ModifiedFiles)) + } + mod := d.ModifiedFiles[0] + if len(mod.AddedEntries) != 1 || mod.AddedEntries[0].Key != "ignore-scripts" { + t.Errorf("expected ignore-scripts added, got %+v", mod.AddedEntries) + } +} + +func TestDiffNPMRC_EnvAppearedAndRotated(t *testing.T) { + prev := snapshotOf(fakeAudit(), 1_700_000_000) + + cur := fakeAudit() + // NPM_TOKEN newly set. + cur.Env[0].Set = true + cur.Env[0].ValueSHA256 = "tok-1" + // NODE_OPTIONS unchanged. + + d := DiffNPMRC(prev, cur, 1_700_000_500) + if len(d.EnvChanges) != 1 { + t.Fatalf("expected 1 env change, got %d: %+v", len(d.EnvChanges), d.EnvChanges) + } + if d.EnvChanges[0].Type != "appeared" || d.EnvChanges[0].Name != "NPM_TOKEN" { + t.Errorf("wrong env change: %+v", d.EnvChanges[0]) + } + + // Now rotate it. + prev2 := snapshotOf(cur, 1_700_000_500) + cur2 := fakeAudit() + cur2.Env[0].Set = true + cur2.Env[0].ValueSHA256 = "tok-2" + d2 := DiffNPMRC(prev2, cur2, 1_700_000_900) + if len(d2.EnvChanges) != 1 || d2.EnvChanges[0].Type != "value_changed" { + t.Fatalf("expected value_changed, got %+v", d2.EnvChanges) + } +} + +func TestSaveLoadNPMRCSnapshot_RoundTrip(t *testing.T) { + // Use HOME-redirect via t.TempDir + os.Setenv so we don't write to the + // real ~/.stepsecurity in CI. + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + s := snapshotOf(fakeAudit(), 1_700_000_000) + if err := SaveNPMRCSnapshot(s); err != nil { + t.Fatalf("save: %v", err) + } + loaded, err := LoadNPMRCSnapshot() + if err != nil { + t.Fatalf("load: %v", err) + } + if loaded == nil { + t.Fatal("loaded snapshot is nil") + } + if loaded.SnapshotVersion != CurrentNPMRCSnapshotVersion { + t.Errorf("version: got %d", loaded.SnapshotVersion) + } + if len(loaded.Files) != 1 || loaded.Files[0].Path != "/u/.npmrc" { + t.Errorf("file roundtrip failed: %+v", loaded.Files) + } +} + +func TestLoadNPMRCSnapshot_MissingReturnsNilNil(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + got, err := LoadNPMRCSnapshot() + if err != nil { + t.Errorf("missing snapshot should not return an error, got %v", err) + } + if got != nil { + t.Errorf("missing snapshot should return nil, got %+v", got) + } +} + +func TestLoadNPMRCSnapshot_VersionMismatch(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + // Write a snapshot with a future version. + bad := &model.NPMRCSnapshot{SnapshotVersion: 999, TakenAt: 1} + if err := SaveNPMRCSnapshot(bad); err != nil { + t.Fatalf("seed save: %v", err) + } + got, err := LoadNPMRCSnapshot() + if got != nil { + t.Errorf("expected nil snapshot for version mismatch, got %+v", got) + } + if err == nil || !strings.Contains(err.Error(), "version mismatch") { + t.Errorf("expected version-mismatch error, got %v", err) + } +} + +func TestEnrichAttribution_OwnerAndMode(t *testing.T) { + mock := executor.NewMock() + // Stub `ps` so attribution can also append a process snapshot. + mock.SetCommand(` PID USER COMM ARGS + 101 alice sh sh -c npm install + 202 alice npm npm install evil-pkg + 303 alice cat cat /home/alice/.npmrc +`, "", 0, "ps", "-eo", "pid,user,comm,args") + + diff := &model.NPMRCDiff{ + ModifiedFiles: []model.NPMRCFileModification{ + { + Path: "/u/.npmrc", + OwnerChanged: &model.NPMRCStringChange{From: "alice", To: "root"}, + ModeChanged: &model.NPMRCStringChange{From: "0600", To: "0666"}, + ContentChanged: true, + }, + }, + } + EnrichAttribution(context.Background(), mock, diff, 1_700_000_500) + + mod := diff.ModifiedFiles[0] + if len(mod.AttributionNotes) == 0 { + t.Fatal("expected notes") + } + combined := strings.Join(mod.AttributionNotes, " ") + if !strings.Contains(combined, "owner changed") { + t.Errorf("missing owner-change note: %q", combined) + } + if !strings.Contains(combined, "permissions relaxed") { + t.Errorf("expected permissions-relaxed note: %q", combined) + } + // `cat` is in the suspect list (we match "cat" via "sh"-pattern? no — check) + // `sh` and `npm` are explicit. "cat" is NOT — that's correct, we only + // flag plausible-writers. + if len(mod.Suspects) == 0 { + t.Fatal("expected at least one suspect (sh or npm)") + } + // At least one suspect should mention npm or sh. + var sawWriter bool + for _, s := range mod.Suspects { + if strings.Contains(strings.ToLower(s.Cmd), "npm") || strings.Contains(strings.ToLower(s.Cmd), "sh ") { + sawWriter = true + } + } + if !sawWriter { + t.Errorf("expected npm or sh in suspects, got %+v", mod.Suspects) + } +} + +func TestIsModeRelaxed(t *testing.T) { + cases := []struct { + from, to string + want bool + }{ + {"0600", "0644", true}, // group/world read added + {"0600", "0666", true}, // group/world write added + {"0644", "0600", false}, // tightened + {"0644", "0644", false}, // unchanged + {"0700", "0755", true}, // group/world rx added + } + for _, c := range cases { + got := isModeRelaxed(c.from, c.to) + if got != c.want { + t.Errorf("isModeRelaxed(%q→%q) = %v, want %v", c.from, c.to, got, c.want) + } + } +} diff --git a/internal/detector/npmrc_parse.go b/internal/detector/npmrc_parse.go new file mode 100644 index 0000000..eab4077 --- /dev/null +++ b/internal/detector/npmrc_parse.go @@ -0,0 +1,200 @@ +package detector + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/hex" + "regexp" + "strings" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +// parseNPMRC parses the contents of a .npmrc file into a slice of NPMRCEntry. +// It is intentionally tolerant: malformed lines are skipped without aborting +// the whole parse, so a single garbage line doesn't hide useful entries. +// +// Behavior matches the npm/ini parser's surface in the ways that matter for +// audit: +// - `;` and `#` start a comment when they are the first non-whitespace char +// on a line (inline `key=value ; comment` is NOT treated as a comment; +// npm/ini retains it as part of the value). +// - `key[]=value` denotes an array entry. +// - URI-prefixed keys (`//host/path/:_authToken`) are valid keys. +// - Surrounding double quotes on a value are unwrapped, but the fact that +// the value was quoted is preserved on the entry. +// - `${VAR}` references are NEVER expanded — preserving the literal form is +// load-bearing for the audit (it's how we tell hardcoded secrets apart +// from env-referenced ones). +// - Section headers `[section]` are accepted (rare in npmrc) but ignored; +// keys are still emitted at the top level. +// +// Returns the parsed entries. Caller decides how to attach them to a file. +func parseNPMRC(data []byte) []model.NPMRCEntry { + var entries []model.NPMRCEntry + + // Strip UTF-8 BOM if present so the first line parses correctly. + data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) + + scanner := bufio.NewScanner(bytes.NewReader(data)) + // Allow large lines (some users base64-pin a CA into `cafile`). + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + lineNum := 0 + for scanner.Scan() { + lineNum++ + raw := scanner.Text() + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + continue + } + // Comment lines. + if trimmed[0] == ';' || trimmed[0] == '#' { + continue + } + // Section header — accept and ignore. + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + continue + } + // Find the first '=' — npm/ini uses the first one as the separator. + eq := strings.IndexByte(trimmed, '=') + if eq < 0 { + // No '=' — treat as a key with empty value (matches npm/ini). + key := strings.TrimSpace(trimmed) + if key == "" { + continue + } + entries = append(entries, buildEntry(key, "", false, lineNum)) + continue + } + key := strings.TrimSpace(trimmed[:eq]) + value := strings.TrimSpace(trimmed[eq+1:]) + + if key == "" { + continue + } + + quoted := false + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + value = value[1 : len(value)-1] + quoted = true + } + + entries = append(entries, buildEntry(key, value, quoted, lineNum)) + } + + return entries +} + +// buildEntry classifies a key/value into an NPMRCEntry, populating the +// security-relevant flags (auth, env-ref) and a safe-to-display value. +func buildEntry(key, value string, quoted bool, lineNum int) model.NPMRCEntry { + isArray := false + if strings.HasSuffix(key, "[]") { + isArray = true + key = key[:len(key)-2] + } + + isAuth := isAuthKey(key) + envRefVars, isEnvRef := extractEnvRefs(value) + + display := value + if isAuth && !isEnvRef && value != "" { + display = redactSecret(value) + } + + return model.NPMRCEntry{ + Key: key, + DisplayValue: display, + LineNum: lineNum, + IsArray: isArray, + IsAuth: isAuth, + IsEnvRef: isEnvRef, + EnvRefVars: envRefVars, + ValueSHA256: hashValue(value), + Quoted: quoted, + } +} + +// authKeySuffixes are the trailing key segments that mean "this is a credential." +// We compare against the suffix because npm scopes auth keys with a URI prefix: +// +// //registry.npmjs.org/:_authToken=... +// +// so we have to look at the part after the final `:`. +var authKeySuffixes = []string{ + "_auth", + "_authtoken", + "_password", + "username", + "email", + "cafile", + "certfile", + "keyfile", + // Deprecated but still seen in the wild: + "cert", + "key", +} + +func isAuthKey(key string) bool { + // Compare against the segment after the final `:` (URI-scoped keys) or + // against the full key (non-scoped legacy form). + suffix := key + if idx := strings.LastIndex(key, ":"); idx >= 0 { + suffix = key[idx+1:] + } + suffix = strings.ToLower(suffix) + for _, s := range authKeySuffixes { + if suffix == s { + return true + } + } + return false +} + +// envRefPattern matches ${VAR}, ${VAR:-default}, and ${VAR?error} forms. +// We only care about the VAR name; default/error sub-syntax is captured but +// not used. +var envRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[?:][^}]*)?\}`) + +func extractEnvRefs(value string) ([]string, bool) { + matches := envRefPattern.FindAllStringSubmatch(value, -1) + if len(matches) == 0 { + return nil, false + } + seen := make(map[string]struct{}, len(matches)) + out := make([]string, 0, len(matches)) + for _, m := range matches { + name := m[1] + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + return out, true +} + +// redactSecret returns a safe-to-display form of an auth value. We keep the +// last 4 characters when the secret is long enough to make rotation tracking +// useful; for short secrets we collapse to `***` so we never leak meaningful +// material. The full value is never returned. +func redactSecret(v string) string { + if len(v) <= 8 { + return "***" + } + return "***" + v[len(v)-4:] +} + +// hashValue returns the hex SHA-256 of a value. We hash the raw value (before +// redaction) so two different secrets produce different hashes — that's what +// lets the change-tracking phase notice rotation without ever storing the +// plaintext. +func hashValue(v string) string { + if v == "" { + return "" + } + sum := sha256.Sum256([]byte(v)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/detector/npmrc_parse_test.go b/internal/detector/npmrc_parse_test.go new file mode 100644 index 0000000..ae3b16a --- /dev/null +++ b/internal/detector/npmrc_parse_test.go @@ -0,0 +1,250 @@ +package detector + +import ( + "strings" + "testing" +) + +func TestParseNPMRC_Basic(t *testing.T) { + input := ` +; this is a comment +# also a comment +registry = https://registry.npmjs.org/ +@mycompany:registry=https://npm.mycompany.com/ +strict-ssl=false +` + entries := parseNPMRC([]byte(input)) + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(entries)) + } + + want := map[string]string{ + "registry": "https://registry.npmjs.org/", + "@mycompany:registry": "https://npm.mycompany.com/", + "strict-ssl": "false", + } + for _, e := range entries { + if got := want[e.Key]; got != e.DisplayValue { + t.Errorf("key %q: want %q, got %q", e.Key, got, e.DisplayValue) + } + if e.IsAuth { + t.Errorf("key %q should not be auth", e.Key) + } + } +} + +func TestParseNPMRC_AuthRedaction(t *testing.T) { + input := `//registry.npmjs.org/:_authToken=npm_AbCdEfGhIjKlMnOpQrStUv1234WXYZ +//npm.mycompany.com/:_authToken=short +//registry.yarnpkg.com/:_password=plainpassword123 +` + entries := parseNPMRC([]byte(input)) + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(entries)) + } + + for _, e := range entries { + if !e.IsAuth { + t.Errorf("key %q should be auth", e.Key) + } + if strings.Contains(e.DisplayValue, "AbCdEf") || strings.Contains(e.DisplayValue, "plainpassword") { + t.Errorf("key %q: raw secret leaked through DisplayValue=%q", e.Key, e.DisplayValue) + } + if !strings.HasPrefix(e.DisplayValue, "***") { + t.Errorf("key %q: expected redacted prefix, got %q", e.Key, e.DisplayValue) + } + if e.ValueSHA256 == "" { + t.Errorf("key %q: expected ValueSHA256 to be populated", e.Key) + } + } + + // The "short" token should collapse to plain "***" with no last-4 leak. + for _, e := range entries { + if e.Key == "//npm.mycompany.com/:_authToken" && e.DisplayValue != "***" { + t.Errorf("short secret should redact to ***, got %q", e.DisplayValue) + } + } +} + +func TestParseNPMRC_EnvRefPreserved(t *testing.T) { + input := `//registry.npmjs.org/:_authToken=${NPM_TOKEN} +//npm.mycompany.com/:_authToken=${COMPANY_TOKEN:-fallback} +cache = ${HOME}/.npm-packages +` + entries := parseNPMRC([]byte(input)) + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(entries)) + } + + for _, e := range entries { + if !e.IsEnvRef { + t.Errorf("key %q: IsEnvRef should be true (value=%q)", e.Key, e.DisplayValue) + } + // For env-ref auth values, we KEEP the literal — that's the whole + // point. Hardcoded vs ${VAR} is the most important distinction in + // the audit. + if !strings.Contains(e.DisplayValue, "${") { + t.Errorf("key %q: env-ref form should be preserved, got %q", e.Key, e.DisplayValue) + } + } + + // Auth + env-ref should still record the var name. + for _, e := range entries { + if e.Key == "//registry.npmjs.org/:_authToken" { + if len(e.EnvRefVars) != 1 || e.EnvRefVars[0] != "NPM_TOKEN" { + t.Errorf("EnvRefVars: want [NPM_TOKEN], got %v", e.EnvRefVars) + } + } + } +} + +func TestParseNPMRC_ArraySyntax(t *testing.T) { + input := `ca[]=cert1 +ca[]=cert2 +` + entries := parseNPMRC([]byte(input)) + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + for _, e := range entries { + if e.Key != "ca" { + t.Errorf("expected key=ca, got %q", e.Key) + } + if !e.IsArray { + t.Errorf("expected IsArray=true") + } + } +} + +func TestParseNPMRC_QuotedValue(t *testing.T) { + input := `node-options = "--max-old-space-size=4096 --require=/tmp/x.js"` + entries := parseNPMRC([]byte(input)) + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if !entries[0].Quoted { + t.Errorf("expected Quoted=true") + } + if strings.HasPrefix(entries[0].DisplayValue, `"`) || strings.HasSuffix(entries[0].DisplayValue, `"`) { + t.Errorf("quotes should be stripped from DisplayValue, got %q", entries[0].DisplayValue) + } +} + +func TestParseNPMRC_Comments(t *testing.T) { + // Both `;` and `#` at start of line are comments; inline `;` is NOT. + input := `; pure comment +# pure comment +key1 = value1 ; this stays in the value (npm/ini behavior) +# trailing comment line +` + entries := parseNPMRC([]byte(input)) + if len(entries) != 1 { + t.Fatalf("expected 1 entry (only key1), got %d: %+v", len(entries), entries) + } + if !strings.Contains(entries[0].DisplayValue, ";") { + t.Errorf("inline ; should remain in value, got %q", entries[0].DisplayValue) + } +} + +func TestParseNPMRC_BOM(t *testing.T) { + input := "\xEF\xBB\xBFregistry=https://registry.npmjs.org/" + entries := parseNPMRC([]byte(input)) + if len(entries) != 1 || entries[0].Key != "registry" { + t.Fatalf("BOM not stripped; got entries=%+v", entries) + } +} + +func TestParseNPMRC_EmptyAndMalformed(t *testing.T) { + input := ` +=novalue +keyonly +key= + +[section] +key2=value2 +` + entries := parseNPMRC([]byte(input)) + // Expect: keyonly (empty value), key (empty value), key2=value2. + // `=novalue` has empty key so it's skipped. `[section]` is skipped. + keys := make([]string, 0, len(entries)) + for _, e := range entries { + keys = append(keys, e.Key) + } + wantKeys := []string{"keyonly", "key", "key2"} + if len(keys) != len(wantKeys) { + t.Fatalf("want keys %v, got %v", wantKeys, keys) + } + for i, k := range wantKeys { + if keys[i] != k { + t.Errorf("position %d: want %q, got %q", i, k, keys[i]) + } + } +} + +func TestIsAuthKey(t *testing.T) { + cases := map[string]bool{ + "//registry.npmjs.org/:_authToken": true, + "//npm.com/path/:_password": true, + "//registry.npmjs.org/:_AUTHTOKEN": true, // case-insensitive + "_auth": true, // legacy unscoped + "username": true, + "email": true, + "cafile": true, + "cert": true, // deprecated but still flagged + "registry": false, + "@scope:registry": false, + "strict-ssl": false, + "ignore-scripts": false, + } + for k, want := range cases { + if got := isAuthKey(k); got != want { + t.Errorf("isAuthKey(%q) = %v, want %v", k, got, want) + } + } +} + +func TestExtractEnvRefs(t *testing.T) { + cases := []struct { + in string + wantEnv bool + wantVars []string + }{ + {"plain", false, nil}, + {"${VAR}", true, []string{"VAR"}}, + {"${A}/${B}", true, []string{"A", "B"}}, + {"${VAR:-default}", true, []string{"VAR"}}, + {"${VAR?missing}", true, []string{"VAR"}}, + {"${SAME}/${SAME}", true, []string{"SAME"}}, // dedup + {"$VAR", false, nil}, // we only match ${...} + } + for _, c := range cases { + gotVars, gotIs := extractEnvRefs(c.in) + if gotIs != c.wantEnv { + t.Errorf("extractEnvRefs(%q) is=%v want=%v", c.in, gotIs, c.wantEnv) + } + if len(gotVars) != len(c.wantVars) { + t.Errorf("extractEnvRefs(%q) vars=%v want=%v", c.in, gotVars, c.wantVars) + continue + } + for i, v := range c.wantVars { + if gotVars[i] != v { + t.Errorf("extractEnvRefs(%q) var[%d]=%q want %q", c.in, i, gotVars[i], v) + } + } + } +} + +func TestRedactSecret(t *testing.T) { + cases := map[string]string{ + "": "***", // empty stays *** (defensive; redactSecret isn't called for empty in practice) + "abc": "***", + "abcdefgh": "***", // exactly 8 chars: still *** + "abcdefghi": "***fghi", // 9+ chars: ***last4 + "npm_xxxxxxXYZ1234": "***1234", + } + for in, want := range cases { + if got := redactSecret(in); got != want { + t.Errorf("redactSecret(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/detector/npmrc_snapshot.go b/internal/detector/npmrc_snapshot.go new file mode 100644 index 0000000..114ed14 --- /dev/null +++ b/internal/detector/npmrc_snapshot.go @@ -0,0 +1,199 @@ +package detector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/step-security/dev-machine-guard/internal/buildinfo" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// CurrentNPMRCSnapshotVersion is the schema version we write today. Bump +// this whenever the on-disk shape changes incompatibly. Loaders should +// treat any other version as "no prior snapshot" — better to start fresh +// than to mis-diff old data. +const CurrentNPMRCSnapshotVersion = 1 + +// npmrcStateDir returns the directory we persist the snapshot in. We mirror +// the convention used by internal/config (~/.stepsecurity/) and put state +// under a dedicated `state/` subdir so config and state stay separate +// concerns. +func npmrcStateDir() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + // Fallback: emit into the temp dir so save/load doesn't crash, even + // in environments without a home (some launchd / systemd contexts). + // The diff will work for the lifetime of that tempdir; that's fine + // for the contexts we care about. + return filepath.Join(os.TempDir(), "stepsecurity-state") + } + return filepath.Join(home, ".stepsecurity", "state") +} + +// NPMRCSnapshotFilePath is the absolute path to the snapshot file. Exposed +// so tests and the verbose pretty view can reference it. +func NPMRCSnapshotFilePath() string { + return filepath.Join(npmrcStateDir(), "npmrc.json") +} + +// BuildNPMRCSnapshot extracts a digest snapshot from a fresh audit. It's +// pure — no I/O, no time-dependent fields except the timestamp which the +// caller can override via the audit's existing data. +func BuildNPMRCSnapshot(audit *model.NPMRCAudit, takenAtUnix int64, hostname string) model.NPMRCSnapshot { + files := make([]model.NPMRCFileSnapshot, 0, len(audit.Files)) + for _, f := range audit.Files { + entries := make([]model.NPMRCEntryDigest, 0, len(f.Entries)) + for _, e := range f.Entries { + entries = append(entries, model.NPMRCEntryDigest{ + Key: e.Key, + ValueSHA256: e.ValueSHA256, + IsAuth: e.IsAuth, + IsArray: e.IsArray, + }) + } + files = append(files, model.NPMRCFileSnapshot{ + Path: f.Path, + Scope: f.Scope, + Exists: f.Exists, + SHA256: f.SHA256, + SizeBytes: f.SizeBytes, + ModTimeUnix: f.ModTimeUnix, + Mode: f.Mode, + OwnerName: f.OwnerName, + GroupName: f.GroupName, + Entries: entries, + }) + } + + envs := make([]model.NPMRCEnvVarSnapshot, 0, len(audit.Env)) + for _, e := range audit.Env { + envs = append(envs, model.NPMRCEnvVarSnapshot{ + Name: e.Name, + Set: e.Set, + ValueSHA256: e.ValueSHA256, + }) + } + + return model.NPMRCSnapshot{ + SnapshotVersion: CurrentNPMRCSnapshotVersion, + AgentVersion: buildinfo.Version, + TakenAt: takenAtUnix, + Hostname: hostname, + Files: files, + Env: envs, + } +} + +// LoadNPMRCSnapshot reads the previous snapshot from disk. Returns nil if: +// - the file does not exist (first run) +// - the file is corrupt / unparseable (treat as first run; better than crashing) +// - the schema version doesn't match (treat as first run; old data is +// not safe to diff against) +// +// Callers should NOT treat a nil return as an error condition; the diff +// layer will produce a FirstRun=true diff and the next save establishes +// the new baseline. +func LoadNPMRCSnapshot() (*model.NPMRCSnapshot, error) { + data, err := os.ReadFile(NPMRCSnapshotFilePath()) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + var snap model.NPMRCSnapshot + if err := json.Unmarshal(data, &snap); err != nil { + // Corrupt — log via returned error and let the caller decide. The + // scanner ignores the error and continues with a FirstRun diff. + return nil, fmt.Errorf("npmrc snapshot decode: %w", err) + } + if snap.SnapshotVersion != CurrentNPMRCSnapshotVersion { + return nil, fmt.Errorf("npmrc snapshot version mismatch (got %d, want %d) — treating as first run", + snap.SnapshotVersion, CurrentNPMRCSnapshotVersion) + } + return &snap, nil +} + +// AttachDiff loads the previous snapshot, computes a diff against the +// current audit, enriches the diff with attribution, then writes the +// current state as the new baseline. It's the single entry point the +// scanner / telemetry / npmrc-only paths call to wire change tracking +// into an audit run. +// +// All errors are non-fatal: we want change tracking to be best-effort, not +// a reason for a scan to fail. Callers can still log returned errors but +// shouldn't propagate them. +func AttachDiff(ctx context.Context, exec executor.Executor, audit *model.NPMRCAudit, scanTimeUnix int64, hostname string) error { + if audit == nil { + return nil + } + prev, loadErr := LoadNPMRCSnapshot() + // loadErr != nil is OK — we just treat as first run. + diff := DiffNPMRC(prev, audit, scanTimeUnix) + if diff != nil && len(diff.ModifiedFiles) > 0 { + EnrichAttribution(ctx, exec, diff, scanTimeUnix) + } + audit.Diff = diff + + // Save current as the new baseline. Build snapshot from the audit AS + // IT IS (post-diff is fine — Diff is metadata, not part of the + // snapshot). If save fails, the diff against next run will be a + // false-positive "first run" — annoying but not a security issue. + curSnap := BuildNPMRCSnapshot(audit, scanTimeUnix, hostname) + saveErr := SaveNPMRCSnapshot(&curSnap) + + if loadErr != nil { + return fmt.Errorf("loading previous snapshot: %w", loadErr) + } + return saveErr +} + +// SaveNPMRCSnapshot writes the snapshot atomically: write to a temp file in +// the same directory, then rename. This way an interrupted run never leaves +// a partial snapshot that the next run would mis-diff against. Mode 0600 +// because the snapshot includes file paths, env-var names, and SHA-256 +// fingerprints of secrets — nothing sensitive in plaintext, but still +// owner-only is the right default. +func SaveNPMRCSnapshot(snap *model.NPMRCSnapshot) error { + dir := npmrcStateDir() + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating state dir: %w", err) + } + finalPath := NPMRCSnapshotFilePath() + + data, err := json.MarshalIndent(snap, "", " ") + if err != nil { + return fmt.Errorf("marshaling snapshot: %w", err) + } + + // Write to a sibling temp file then rename. os.Rename is atomic on + // POSIX and on NTFS for same-volume moves. + tmp, err := os.CreateTemp(dir, ".npmrc-*.json.tmp") + if err != nil { + return fmt.Errorf("creating temp snapshot: %w", err) + } + tmpPath := tmp.Name() + defer func() { + // Best-effort cleanup if rename never happened. + _ = os.Remove(tmpPath) + }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("writing temp snapshot: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing temp snapshot: %w", err) + } + if err := os.Chmod(tmpPath, 0o600); err != nil { + return fmt.Errorf("chmod temp snapshot: %w", err) + } + if err := os.Rename(tmpPath, finalPath); err != nil { + return fmt.Errorf("renaming snapshot: %w", err) + } + return nil +} diff --git a/internal/detector/npmrc_stat_unix.go b/internal/detector/npmrc_stat_unix.go new file mode 100644 index 0000000..58af9c1 --- /dev/null +++ b/internal/detector/npmrc_stat_unix.go @@ -0,0 +1,39 @@ +//go:build !windows + +package detector + +import ( + "os" + "os/user" + "strconv" + "syscall" +) + +// statOwner returns the owning uid/gid of a path. We deliberately bypass the +// Executor interface here because: +// 1. uid/gid is exposed only via syscall.Stat_t on the Sys() of an os.FileInfo +// and the mock executor's mockFileInfo can't represent that. +// 2. The detector exposes ownerLookup as a hook so tests substitute a stub +// and never reach this function. +func statOwner(path string) ownerInfo { + info, err := os.Stat(path) + if err != nil { + return ownerInfo{} + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return ownerInfo{} + } + oi := ownerInfo{ + UID: int(st.Uid), + GID: int(st.Gid), + OK: true, + } + if u, err := user.LookupId(strconv.Itoa(oi.UID)); err == nil { + oi.OwnerName = u.Username + } + if g, err := user.LookupGroupId(strconv.Itoa(oi.GID)); err == nil { + oi.GroupName = g.Name + } + return oi +} diff --git a/internal/detector/npmrc_stat_windows.go b/internal/detector/npmrc_stat_windows.go new file mode 100644 index 0000000..b8a08b9 --- /dev/null +++ b/internal/detector/npmrc_stat_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package detector + +// statOwner is a no-op on Windows: getting a meaningful owner string from a +// SID is non-trivial and not actionable for the audit's first cut. The +// detector handles ownerInfo.OK == false by leaving owner fields empty. +func statOwner(_ string) ownerInfo { + return ownerInfo{} +} diff --git a/internal/detector/npmrc_test.go b/internal/detector/npmrc_test.go new file mode 100644 index 0000000..c4cfe8a --- /dev/null +++ b/internal/detector/npmrc_test.go @@ -0,0 +1,511 @@ +package detector + +import ( + "context" + "os" + "os/user" + "path/filepath" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// fixedOwner returns an ownerLookup hook with fixed values, used to keep +// tests deterministic across platforms (no real syscall.Stat involved). +func fixedOwner() func(string) ownerInfo { + return func(_ string) ownerInfo { + return ownerInfo{UID: 1000, GID: 1000, OwnerName: "tester", GroupName: "staff", OK: true} + } +} + +func TestNPMRCDetector_Discovery_AllScopes(t *testing.T) { + tmp := t.TempDir() + + // User config + userPath := filepath.Join(tmp, "home", ".npmrc") + mustWriteFile(t, userPath, "registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=npm_AbCdEfGhIjKlMnOpQrStUv\n") + + // Global config (e.g. /etc/npmrc) + globalPath := filepath.Join(tmp, "etc", "npmrc") + mustWriteFile(t, globalPath, "strict-ssl=true\n") + + // Builtin config (npm install dir) + builtinPath := filepath.Join(tmp, "lib", "node_modules", "npm", "npmrc") + mustWriteFile(t, builtinPath, "; default builtin config\n") + + // Project-level config inside a search dir + projectDir := filepath.Join(tmp, "code", "myapp") + projectPath := filepath.Join(projectDir, ".npmrc") + mustWriteFile(t, projectPath, "@mycompany:registry=https://npm.mycompany.com/\n//npm.mycompany.com/:_authToken=${COMPANY_TOKEN}\n") + + // Mock npm command outputs + mock := executor.NewMock() + mock.SetPath("npm", filepath.Join(tmp, "bin", "npm")) + mock.SetCommand("11.0.0\n", "", 0, "npm", "--version") + mock.SetCommand(builtinPath+"\n", "", 0, "npm", "config", "get", "builtinconfig") + mock.SetCommand(globalPath+"\n", "", 0, "npm", "config", "get", "globalconfig") + mock.SetCommand(`{"registry":"https://registry.npmjs.org/","strict-ssl":true,"_authToken":"(protected)"}`, "", 0, "npm", "config", "ls", "-l", "--json") + mock.SetCommand(`; "user" config from "`+userPath+`" +registry = "https://registry.npmjs.org/" +; "default" values +strict-ssl = true +`, "", 0, "npm", "config", "ls", "-l") + mock.SetHomeDir(filepath.Join(tmp, "home")) + + d := NewNPMRCDetector(mock) + d.ownerLookup = fixedOwner() + // Disable git checks so tests don't depend on git being installed. + d.gitTracked = func(_ context.Context, _ string) bool { return false } + d.inGitRepo = func(_ string) bool { return false } + + loggedIn := &user.User{Username: "tester", HomeDir: filepath.Join(tmp, "home")} + audit := d.Detect(context.Background(), []string{filepath.Join(tmp, "code")}, loggedIn) + + if !audit.Available { + t.Fatalf("expected npm to be available") + } + if audit.NPMVersion != "11.0.0" { + t.Errorf("npm version = %q, want 11.0.0", audit.NPMVersion) + } + + // We should have discovered all four scopes. + gotScopes := map[string]string{} + for _, f := range audit.Files { + gotScopes[f.Scope] = f.Path + } + for _, want := range []string{"builtin", "global", "user", "project"} { + if _, ok := gotScopes[want]; !ok { + t.Errorf("missing scope %q in output (got: %v)", want, gotScopes) + } + } + + // User file: should have parsed entries with redacted auth. + for _, f := range audit.Files { + if f.Scope != "user" { + continue + } + if !f.Exists || !f.Readable { + t.Errorf("user file should be readable: %+v", f) + } + if f.SHA256 == "" { + t.Errorf("user file should have sha256") + } + if f.OwnerName != "tester" { + t.Errorf("owner name = %q, want tester", f.OwnerName) + } + var sawAuth bool + for _, e := range f.Entries { + if e.IsAuth { + sawAuth = true + if strings.Contains(e.DisplayValue, "AbCdEf") { + t.Errorf("auth value leaked: %q", e.DisplayValue) + } + } + } + if !sawAuth { + t.Errorf("expected to see an auth entry in user file") + } + } + + // Project file: env-ref auth should be preserved. + for _, f := range audit.Files { + if f.Scope != "project" { + continue + } + var sawEnvRef bool + for _, e := range f.Entries { + if e.IsEnvRef { + sawEnvRef = true + if !strings.Contains(e.DisplayValue, "${") { + t.Errorf("env-ref form should be preserved: %q", e.DisplayValue) + } + } + } + if !sawEnvRef { + t.Errorf("expected env-ref entry in project file") + } + } + + // Effective view should populate config + sources. + if audit.Effective == nil { + t.Fatalf("expected effective view") + } + if _, ok := audit.Effective.Config["registry"]; !ok { + t.Errorf("effective config missing registry") + } + if src := audit.Effective.SourceByKey["registry"]; src != userPath { + t.Errorf("expected registry source %q, got %q", userPath, src) + } +} + +func TestNPMRCDetector_NPMNotInstalled(t *testing.T) { + tmp := t.TempDir() + userPath := filepath.Join(tmp, "home", ".npmrc") + mustWriteFile(t, userPath, "registry=https://npm.example.com/\n") + + mock := executor.NewMock() + // No SetPath("npm", ...) -> LookPath fails. + mock.SetHomeDir(filepath.Join(tmp, "home")) + + d := NewNPMRCDetector(mock) + d.ownerLookup = fixedOwner() + d.gitTracked = func(_ context.Context, _ string) bool { return false } + d.inGitRepo = func(_ string) bool { return false } + + loggedIn := &user.User{Username: "tester", HomeDir: filepath.Join(tmp, "home")} + audit := d.Detect(context.Background(), nil, loggedIn) + + if audit.Available { + t.Errorf("npm should not be marked available") + } + if audit.Effective != nil { + t.Errorf("effective view should be nil when npm missing, got %+v", audit.Effective) + } + // User file should still be discovered and parsed. + if len(audit.Files) != 1 || audit.Files[0].Scope != "user" { + t.Fatalf("expected exactly the user file, got %+v", audit.Files) + } + if !audit.Files[0].Readable { + t.Errorf("user file should be readable even with no npm") + } +} + +func TestNPMRCDetector_MissingFiles(t *testing.T) { + tmp := t.TempDir() + mock := executor.NewMock() + mock.SetPath("npm", "/usr/bin/npm") + mock.SetCommand("9.0.0\n", "", 0, "npm", "--version") + mock.SetCommand("/nonexistent/builtin\n", "", 0, "npm", "config", "get", "builtinconfig") + mock.SetCommand("/nonexistent/global\n", "", 0, "npm", "config", "get", "globalconfig") + mock.SetCommand("{}", "", 0, "npm", "config", "ls", "-l", "--json") + mock.SetCommand("", "", 0, "npm", "config", "ls", "-l") + + d := NewNPMRCDetector(mock) + d.ownerLookup = fixedOwner() + d.gitTracked = func(_ context.Context, _ string) bool { return false } + d.inGitRepo = func(_ string) bool { return false } + + loggedIn := &user.User{Username: "tester", HomeDir: filepath.Join(tmp, "nohome")} + audit := d.Detect(context.Background(), nil, loggedIn) + + // Even though no real files exist, the discovery records the absence. + for _, f := range audit.Files { + if f.Exists { + t.Errorf("scope %q at %q should not exist", f.Scope, f.Path) + } + if len(f.Entries) != 0 { + t.Errorf("missing file should have no entries, got %+v", f.Entries) + } + } +} + +func TestNPMRCDetector_EnvVarRedaction(t *testing.T) { + mock := executor.NewMock() + mock.SetEnv("NPM_TOKEN", "npm_LongTokenValueZ1234") + mock.SetEnv("npm_config__authToken", "npm_AnotherSecretValue999") + mock.SetEnv("NPM_CONFIG_REGISTRY", "https://registry.npmjs.org/") + + d := NewNPMRCDetector(mock) + d.ownerLookup = fixedOwner() + d.gitTracked = func(_ context.Context, _ string) bool { return false } + d.inGitRepo = func(_ string) bool { return false } + + envs := d.collectEnv() + + for _, e := range envs { + switch e.Name { + case "NPM_TOKEN": + if !e.Set { + t.Error("NPM_TOKEN should be Set=true") + } + if !strings.HasPrefix(e.DisplayValue, "***") { + t.Errorf("NPM_TOKEN should be redacted, got %q", e.DisplayValue) + } + if strings.Contains(e.DisplayValue, "Long") { + t.Errorf("NPM_TOKEN raw value leaked: %q", e.DisplayValue) + } + if e.ValueSHA256 == "" { + t.Error("NPM_TOKEN should have SHA-256 set") + } + case "npm_config__authToken": + if !strings.HasPrefix(e.DisplayValue, "***") { + t.Errorf("npm_config__authToken should be redacted, got %q", e.DisplayValue) + } + case "NPM_CONFIG_REGISTRY": + // Not a secret; should pass through. + if e.DisplayValue != "https://registry.npmjs.org/" { + t.Errorf("registry env var should not be redacted, got %q", e.DisplayValue) + } + } + } +} + +func TestParseSourceAttribution(t *testing.T) { + in := `; "default" values +access = null +audit = true + +; "user" config from "/Users/me/.npmrc" +registry = "https://registry.npmjs.org/" +//registry.npmjs.org/:_authToken = "(protected)" + +; "project" config from "/Users/me/code/myapp/.npmrc" +@mycompany:registry = "https://npm.mycompany.com/" +strict-ssl = false +` + got := parseSourceAttribution(in) + + cases := map[string]string{ + "access": "default", + "audit": "default", + "registry": "/Users/me/.npmrc", + "//registry.npmjs.org/:_authToken": "/Users/me/.npmrc", + "@mycompany:registry": "/Users/me/code/myapp/.npmrc", + "strict-ssl": "/Users/me/code/myapp/.npmrc", + } + for k, want := range cases { + if got[k] != want { + t.Errorf("source[%q] = %q, want %q", k, got[k], want) + } + } +} + +func TestNPMRCDetector_ProjectWalkSkipsExcludedDirs(t *testing.T) { + tmp := t.TempDir() + + // Should be picked up. + keep := filepath.Join(tmp, "real", ".npmrc") + mustWriteFile(t, keep, "registry=https://kept/\n") + + // Should be skipped (inside node_modules). + mustWriteFile(t, filepath.Join(tmp, "real", "node_modules", "lib", ".npmrc"), "registry=https://skipped/\n") + + // Should be skipped (inside .git). + mustWriteFile(t, filepath.Join(tmp, "real", ".git", ".npmrc"), "registry=https://skipped/\n") + + // Should be skipped (hidden dir). + mustWriteFile(t, filepath.Join(tmp, "real", ".cache", ".npmrc"), "registry=https://skipped/\n") + + mock := executor.NewMock() + d := NewNPMRCDetector(mock) + results := d.findProjectNPMRCs(tmp) + + if len(results) != 1 { + t.Fatalf("expected exactly 1 .npmrc, got %d: %v", len(results), results) + } + if !strings.HasSuffix(results[0], filepath.Join("real", ".npmrc")) { + t.Errorf("wrong file kept: %q", results[0]) + } +} + +func TestNPMRCDetector_RespectsEnvOverridesForUserAndGlobal(t *testing.T) { + tmp := t.TempDir() + + // User config redirected via NPM_CONFIG_USERCONFIG. + overriddenUser := filepath.Join(tmp, "elsewhere", "myrc") + mustWriteFile(t, overriddenUser, "registry=https://overridden/\n") + + // Global config redirected via NPM_CONFIG_GLOBALCONFIG. + overriddenGlobal := filepath.Join(tmp, "elsewhere", "globalrc") + mustWriteFile(t, overriddenGlobal, "audit=false\n") + + mock := executor.NewMock() + mock.SetPath("npm", "/usr/bin/npm") + mock.SetCommand("11.0.0\n", "", 0, "npm", "--version") + mock.SetCommand("/should/not/be/used\n", "", 0, "npm", "config", "get", "builtinconfig") + // NB: globalconfig command should NOT be called when env var is set. + // We still register it so test fails loud if it is. + mock.SetCommand("/should/not/be/used/global\n", "", 0, "npm", "config", "get", "globalconfig") + mock.SetCommand("{}", "", 0, "npm", "config", "ls", "-l", "--json") + mock.SetCommand("", "", 0, "npm", "config", "ls", "-l") + + mock.SetEnv("NPM_CONFIG_USERCONFIG", overriddenUser) + mock.SetEnv("NPM_CONFIG_GLOBALCONFIG", overriddenGlobal) + mock.SetHomeDir(filepath.Join(tmp, "home")) + + d := NewNPMRCDetector(mock) + d.ownerLookup = fixedOwner() + d.gitTracked = func(_ context.Context, _ string) bool { return false } + d.inGitRepo = func(_ string) bool { return false } + + loggedIn := &user.User{Username: "tester", HomeDir: filepath.Join(tmp, "home")} + audit := d.Detect(context.Background(), nil, loggedIn) + + pathsByScope := map[string]string{} + for _, f := range audit.Files { + pathsByScope[f.Scope] = f.Path + } + if got := pathsByScope["user"]; got != overriddenUser { + t.Errorf("user scope path = %q, want %q (NPM_CONFIG_USERCONFIG should win over $HOME/.npmrc)", got, overriddenUser) + } + if got := pathsByScope["global"]; got != overriddenGlobal { + t.Errorf("global scope path = %q, want %q (NPM_CONFIG_GLOBALCONFIG should win)", got, overriddenGlobal) + } +} + +func TestComputeOverrides_SignalsTheRightThings(t *testing.T) { + baseline := map[string]any{ + "registry": "https://registry.npmjs.org/", + "strict-ssl": true, + "audit-level": "moderate", + "unrelated-flag": "x", + } + baselineSrc := map[string]string{ + "registry": "user", + "strict-ssl": "default", + "audit-level": "global", + "unrelated-flag": "default", + } + project := map[string]any{ + "registry": "https://jfrog.somecorp.com/", + "//jfrog.somecorp.com/:_authToken": "(protected)", // npm always redacts in JSON + "strict-ssl": true, // unchanged from baseline + "audit-level": "moderate", + "unrelated-flag": "x", + } + projectSrc := map[string]string{ + "registry": "project", + "//jfrog.somecorp.com/:_authToken": "project", + "strict-ssl": "default", + "audit-level": "global", + "unrelated-flag": "default", + } + + overrides := computeOverrides(baseline, baselineSrc, project, projectSrc) + + got := map[string]model.NPMRCOverride{} + for _, o := range overrides { + got[o.Key] = o + } + + // registry should be detected as a changed value. + regOv, ok := got["registry"] + if !ok { + t.Fatalf("expected registry override; got %+v", overrides) + } + if regOv.BaselineValue != "https://registry.npmjs.org/" || regOv.ProjectValue != "https://jfrog.somecorp.com/" { + t.Errorf("registry override values wrong: %+v", regOv) + } + if regOv.IsNew || regOv.IsRemoved { + t.Errorf("registry should be a value change, not new/removed: %+v", regOv) + } + + // auth token should be detected as new + IsAuth=true. + authOv, ok := got["//jfrog.somecorp.com/:_authToken"] + if !ok { + t.Fatalf("expected auth-token override; got %+v", overrides) + } + if !authOv.IsAuth || !authOv.IsNew { + t.Errorf("auth override should be IsAuth + IsNew: %+v", authOv) + } + + // strict-ssl, audit-level, unrelated-flag should NOT be in overrides + // — value identical between baseline and project. + for _, k := range []string{"strict-ssl", "audit-level", "unrelated-flag"} { + if _, present := got[k]; present { + t.Errorf("%q should not appear as an override (value unchanged)", k) + } + } + + // Auth override sorts before non-auth. + if !overrides[0].IsAuth { + t.Errorf("auth override should sort first, got first key=%q", overrides[0].Key) + } +} + +func TestComputeOverrides_HandlesEmptyInputs(t *testing.T) { + // Either side nil → no overrides (we only diff when both views exist). + if got := computeOverrides(nil, nil, map[string]any{"k": "v"}, nil); got != nil { + t.Errorf("nil baseline should yield nil overrides, got %+v", got) + } + if got := computeOverrides(map[string]any{"k": "v"}, nil, nil, nil); got != nil { + t.Errorf("nil project should yield nil overrides, got %+v", got) + } +} + +func TestNPMRCDetector_PerProjectOverridesPopulated(t *testing.T) { + tmp := t.TempDir() + + // Baseline: user .npmrc with the official registry. + userPath := filepath.Join(tmp, "home", ".npmrc") + mustWriteFile(t, userPath, "registry=https://registry.npmjs.org/\n") + + // Project: pretend a cloned repo with a hostile registry override. + projectDir := filepath.Join(tmp, "code", "cloned-repo") + projectPath := filepath.Join(projectDir, ".npmrc") + mustWriteFile(t, projectPath, "registry=https://jfrog.somecorp.com/\n//jfrog.somecorp.com/:_authToken=stolen_token\n") + + mock := executor.NewMock() + mock.SetPath("npm", "/usr/bin/npm") + mock.SetCommand("11.0.0\n", "", 0, "npm", "--version") + mock.SetCommand("", "", 0, "npm", "config", "get", "builtinconfig") + mock.SetCommand("", "", 0, "npm", "config", "get", "globalconfig") + + // Baseline (run from $HOME / no specific cwd): registry = npmjs.org + mock.SetCommand(`{"registry":"https://registry.npmjs.org/"}`, "", 0, "npm", "config", "ls", "-l", "--json") + mock.SetCommand(`; "user" config from "`+userPath+`" +registry = "https://registry.npmjs.org/" +`, "", 0, "npm", "config", "ls", "-l") + + // The Mock's RunInDir falls through to Run, so the SAME command-key map + // is consulted for the per-project run. Re-stub the same key with the + // project-cwd response — last write wins for SetCommand. + // (We accept that the mock can't differentiate by cwd — for this test + // we check the wiring up to evaluateInDir, then rely on + // TestComputeOverrides_SignalsTheRightThings for the diff logic.) + + d := NewNPMRCDetector(mock) + d.ownerLookup = fixedOwner() + d.gitTracked = func(_ context.Context, _ string) bool { return false } + d.inGitRepo = func(_ string) bool { return false } + + loggedIn := &user.User{Username: "tester", HomeDir: filepath.Join(tmp, "home")} + audit := d.Detect(context.Background(), []string{filepath.Join(tmp, "code")}, loggedIn) + + // Find the project file record. The mock returns the same effective + // config for both invocations, so the JSON-based diff yields nothing — + // but the project file's parsed entries still include an auth token + // that doesn't exist in the user file. That auth key MUST surface as a + // new-auth override (npm strips auth keys from the JSON view, so we + // rely on parsed-entry diffs to catch them). + var found bool + for _, f := range audit.Files { + if f.Scope != "project" || f.Path != projectPath { + continue + } + found = true + if len(f.EffectiveOverrides) != 1 { + t.Fatalf("expected exactly one auth-only override, got %+v", f.EffectiveOverrides) + } + ov := f.EffectiveOverrides[0] + if !ov.IsAuth || !ov.IsNew { + t.Errorf("expected IsAuth + IsNew, got %+v", ov) + } + if ov.Key != "//jfrog.somecorp.com/:_authToken" { + t.Errorf("unexpected key %q", ov.Key) + } + if ov.BaselineValue != "" { + t.Errorf("baseline should be , got %q", ov.BaselineValue) + } + if !strings.HasPrefix(ov.ProjectValue, "***") { + t.Errorf("project value should be redacted, got %q", ov.ProjectValue) + } + break + } + if !found { + t.Fatalf("project file not found in audit: %+v", audit.Files) + } +} + +// mustWriteFile creates parent dirs as needed and writes the content. +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 91079d2..9575019 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -26,6 +26,7 @@ type ScanResult struct { SnapPackages []SystemPackage `json:"snap_packages"` FlatpakPkgManager *PkgManager `json:"flatpak_package_manager,omitempty"` FlatpakPackages []SystemPackage `json:"flatpak_packages"` + NPMRCAudit *NPMRCAudit `json:"npmrc_audit,omitempty"` Summary Summary `json:"summary"` } @@ -218,6 +219,260 @@ type PythonScanResult struct { ScanDurationMs int64 `json:"scan_duration_ms"` } +// --- npmrc audit --------------------------------------------------------- +// +// NPMRCAudit is the top-level structure produced by the npmrc detector. +// It captures every `.npmrc` discovered on disk, the merged effective config +// as npm itself would resolve it, and the relevant pieces of process env. +// +// Diff is populated by the change-tracking step: it describes how this run's +// state differs from the last persisted snapshot. On a first run Diff is +// non-nil with FirstRun=true and otherwise empty. +type NPMRCAudit struct { + Available bool `json:"npm_available"` + NPMVersion string `json:"npm_version,omitempty"` + NPMPath string `json:"npm_path,omitempty"` + Files []NPMRCFile `json:"files"` + Effective *NPMRCEffective `json:"effective,omitempty"` + Env []NPMRCEnvVar `json:"env"` + Diff *NPMRCDiff `json:"diff,omitempty"` + DiscoveryError string `json:"discovery_error,omitempty"` +} + +// NPMRCFile is a single .npmrc file. Metadata is best-effort: fields that +// could not be determined (e.g. owner_name on Windows) are omitted. +type NPMRCFile struct { + Path string `json:"path"` + Scope string `json:"scope"` // builtin | global | user | project + Exists bool `json:"exists"` + Readable bool `json:"readable"` + SizeBytes int64 `json:"size_bytes,omitempty"` + ModTimeUnix int64 `json:"mtime_unix,omitempty"` + Mode string `json:"mode,omitempty"` + OwnerUID int `json:"owner_uid,omitempty"` + OwnerName string `json:"owner_name,omitempty"` + GroupGID int `json:"group_gid,omitempty"` + GroupName string `json:"group_name,omitempty"` + SHA256 string `json:"sha256,omitempty"` + SymlinkTo string `json:"symlink_target,omitempty"` + InGitRepo bool `json:"in_git_repo,omitempty"` + GitTracked bool `json:"git_tracked,omitempty"` + Entries []NPMRCEntry `json:"entries,omitempty"` + ParseError string `json:"parse_error,omitempty"` + + // EffectiveOverrides is populated only for project-scope files. Each + // entry describes how the *effective* npm config differs when the user + // runs `npm install` from inside this project, compared to running it + // from $HOME. This is the actionable supply-chain signal: a cloned repo + // that silently flips the registry or ships an auth token surfaces here. + EffectiveOverrides []NPMRCOverride `json:"effective_overrides,omitempty"` + // OverrideError is set when we couldn't compute EffectiveOverrides + // (e.g. npm not found, command timed out). Empty on success. + OverrideError string `json:"override_error,omitempty"` +} + +// NPMRCOverride describes a single key whose effective value changes when +// npm is invoked from a project directory rather than $HOME. +type NPMRCOverride struct { + Key string `json:"key"` + BaselineValue string `json:"baseline_value"` // string-formatted; "" when key absent in baseline + BaselineSource string `json:"baseline_source,omitempty"` + ProjectValue string `json:"project_value"` // "" when key absent under project + ProjectSource string `json:"project_source,omitempty"` + IsAuth bool `json:"is_auth,omitempty"` // true when the key looks like an auth-scoped key + IsNew bool `json:"is_new,omitempty"` // baseline didn't have this key at all + IsRemoved bool `json:"is_removed,omitempty"` // project hides a key that was in baseline +} + +// NPMRCEntry is one parsed line of a .npmrc file. +// +// DisplayValue is always safe to print: auth values are redacted to +// `***last4` (or `***` when the secret is short). The raw value is never +// stored — ValueSHA256 is the only fingerprint kept for diffing. +type NPMRCEntry struct { + Key string `json:"key"` + DisplayValue string `json:"display_value"` + LineNum int `json:"line_num"` + IsArray bool `json:"is_array,omitempty"` + IsAuth bool `json:"is_auth,omitempty"` + IsEnvRef bool `json:"is_env_ref,omitempty"` + EnvRefVars []string `json:"env_ref_vars,omitempty"` + ValueSHA256 string `json:"value_sha256,omitempty"` + Quoted bool `json:"quoted,omitempty"` +} + +// NPMRCEffective mirrors the merged-config view emitted by +// `npm config ls -l --json`. Auth values are returned by npm as +// "(protected)" — that's what we surface. +type NPMRCEffective struct { + SourceByKey map[string]string `json:"source_by_key,omitempty"` + Config map[string]any `json:"config,omitempty"` + Error string `json:"error,omitempty"` +} + +// NPMRCEnvVar is a single npm-relevant process environment variable. +// We record presence and a hash so changes are detectable across runs +// without ever storing the secret value. +type NPMRCEnvVar struct { + Name string `json:"name"` + Set bool `json:"set"` + DisplayValue string `json:"display_value,omitempty"` + ValueSHA256 string `json:"value_sha256,omitempty"` +} + +// --- Phase B: change tracking --------------------------------------------- +// +// The detector takes a digest snapshot of every audit and writes it to disk +// before returning. On the next run it loads the previous snapshot and +// produces an NPMRCDiff describing what's changed. The snapshot stores +// SHA-256 fingerprints rather than raw values, so diffing detects rotation +// (the registry url changed, the auth token rotated, an env var appeared) +// without ever persisting plaintext credentials. + +// NPMRCSnapshot is the on-disk representation of an audit at a point in +// time. SnapshotVersion exists so future schema changes can be detected and +// gracefully handled (treat older versions as "no prior snapshot"). +type NPMRCSnapshot struct { + SnapshotVersion int `json:"snapshot_version"` + AgentVersion string `json:"agent_version"` + TakenAt int64 `json:"taken_at"` + Hostname string `json:"hostname,omitempty"` + Files []NPMRCFileSnapshot `json:"files"` + Env []NPMRCEnvVarSnapshot `json:"env"` +} + +// NPMRCFileSnapshot is the digest of a single .npmrc at snapshot time. +// Only fields needed for diffing are kept — line numbers, display values, +// parse error strings, etc. don't help detect change. +type NPMRCFileSnapshot struct { + Path string `json:"path"` + Scope string `json:"scope"` + Exists bool `json:"exists"` + SHA256 string `json:"sha256,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + ModTimeUnix int64 `json:"mtime_unix,omitempty"` + Mode string `json:"mode,omitempty"` + OwnerName string `json:"owner_name,omitempty"` + GroupName string `json:"group_name,omitempty"` + Entries []NPMRCEntryDigest `json:"entries,omitempty"` +} + +// NPMRCEntryDigest is the per-key fingerprint kept across runs. Plaintext +// is intentionally absent: the value SHA is enough to notice rotation. +type NPMRCEntryDigest struct { + Key string `json:"key"` + ValueSHA256 string `json:"value_sha256"` + IsAuth bool `json:"is_auth,omitempty"` + IsArray bool `json:"is_array,omitempty"` +} + +// NPMRCEnvVarSnapshot mirrors NPMRCEnvVar but drops the display value. +type NPMRCEnvVarSnapshot struct { + Name string `json:"name"` + Set bool `json:"set"` + ValueSHA256 string `json:"value_sha256,omitempty"` +} + +// NPMRCDiff is the human-readable answer to "what changed since the last +// scan?" It's emitted on every run; on the very first run all fields are +// empty (FirstRun=true is the only useful flag). +type NPMRCDiff struct { + FirstRun bool `json:"first_run,omitempty"` + PreviousAt int64 `json:"previous_at,omitempty"` + CurrentAt int64 `json:"current_at,omitempty"` + AddedFiles []NPMRCFileChange `json:"added_files,omitempty"` + RemovedFiles []NPMRCFileChange `json:"removed_files,omitempty"` + ModifiedFiles []NPMRCFileModification `json:"modified_files,omitempty"` + EnvChanges []NPMRCEnvChange `json:"env_changes,omitempty"` +} + +// HasChanges reports whether the diff is non-trivial (something actually +// changed). Used by the formatter to decide whether to render the section. +func (d *NPMRCDiff) HasChanges() bool { + if d == nil { + return false + } + return len(d.AddedFiles) > 0 || len(d.RemovedFiles) > 0 || + len(d.ModifiedFiles) > 0 || len(d.EnvChanges) > 0 +} + +// NPMRCFileChange identifies a file that newly appeared or disappeared. +// Existence transitions don't carry sub-detail — the file either is or +// isn't there. +type NPMRCFileChange struct { + Path string `json:"path"` + Scope string `json:"scope"` +} + +// NPMRCFileModification is the rich record for a file present in both +// snapshots whose content / metadata / entries differ. +type NPMRCFileModification struct { + Path string `json:"path"` + Scope string `json:"scope"` + + ContentChanged bool `json:"content_changed,omitempty"` // sha256 differs + + // Metadata transitions — pointers because most changes touch only a + // subset and we don't want to emit zero-value structs for unchanged + // fields. + OwnerChanged *NPMRCStringChange `json:"owner_changed,omitempty"` + GroupChanged *NPMRCStringChange `json:"group_changed,omitempty"` + ModeChanged *NPMRCStringChange `json:"mode_changed,omitempty"` + SizeChanged *NPMRCInt64Change `json:"size_changed,omitempty"` + + AddedEntries []NPMRCEntryDigest `json:"added_entries,omitempty"` + RemovedEntries []NPMRCEntryDigest `json:"removed_entries,omitempty"` + ChangedEntries []NPMRCEntryValueDiff `json:"changed_entries,omitempty"` + + // Best-effort attribution. Notes accumulate human-readable summaries + // (e.g. "owner changed from fedora to root — write performed by a + // different user"). Suspects is the candidate process list captured + // when ModTime is within ~5 min of the scan (heuristic; see the + // detector for the exact rule). + AttributionNotes []string `json:"attribution_notes,omitempty"` + Suspects []NPMRCSuspect `json:"suspects,omitempty"` +} + +// NPMRCEntryValueDiff records that a key kept its name but its value +// rotated. Plaintext is never emitted — the SHA pair is enough to prove +// the change happened. +type NPMRCEntryValueDiff struct { + Key string `json:"key"` + IsAuth bool `json:"is_auth,omitempty"` + PreviousSHA256 string `json:"previous_value_sha256"` + CurrentSHA256 string `json:"current_value_sha256"` +} + +// NPMRCEnvChange describes how a single env var transitioned. +type NPMRCEnvChange struct { + Name string `json:"name"` + Type string `json:"type"` // "appeared" | "disappeared" | "value_changed" + PreviousSHA256 string `json:"previous_value_sha256,omitempty"` + CurrentSHA256 string `json:"current_value_sha256,omitempty"` +} + +// NPMRCStringChange is a generic "from / to" tuple for string-typed +// metadata. +type NPMRCStringChange struct { + From string `json:"from"` + To string `json:"to"` +} + +// NPMRCInt64Change is a generic "from / to" tuple for int64 metadata. +type NPMRCInt64Change struct { + From int64 `json:"from"` + To int64 `json:"to"` +} + +// NPMRCSuspect is one process that was running near the file's mtime — +// best-effort, not authoritative. Cmd is the truncated command line as +// reported by `ps -ef` / `tasklist`. +type NPMRCSuspect struct { + PID int `json:"pid"` + User string `json:"user,omitempty"` + Cmd string `json:"cmd"` +} + // FilterUserInstalledExtensions removes bundled/platform extensions, // keeping only user-installed, marketplace, and dropins extensions. func FilterUserInstalledExtensions(exts []Extension) []Extension { diff --git a/internal/output/html.go b/internal/output/html.go index 554a295..3a320b5 100644 --- a/internal/output/html.go +++ b/internal/output/html.go @@ -26,6 +26,7 @@ type htmlData struct { PythonPkgManagers []model.PkgManager PythonPackages []model.PythonPackage PythonProjects []model.ProjectInfo + NPMRCAudit *model.NPMRCAudit Summary model.Summary } @@ -68,6 +69,7 @@ func HTML(outputFile string, result *model.ScanResult) error { PythonPkgManagers: result.PythonPkgManagers, PythonPackages: result.PythonPackages, PythonProjects: result.PythonProjects, + NPMRCAudit: result.NPMRCAudit, Summary: result.Summary, } @@ -313,6 +315,58 @@ const htmlTemplate = ` {{end}} +{{if .NPMRCAudit}} +
+
+

npm Configuration Audit {{len .NPMRCAudit.Files}} files

+ +
+
+

+ {{if .NPMRCAudit.Available}}npm v{{.NPMRCAudit.NPMVersion}} · {{.NPMRCAudit.NPMPath}}{{else}}npm not found in PATH (file-only audit){{end}} +

+ {{range .NPMRCAudit.Files}} +

+ [{{.Scope}}] {{.Path}} + {{if not .Exists}}(missing){{end}} + {{if .GitTracked}}git-tracked{{else if .InGitRepo}}in git repo{{end}} +

+ {{if .Exists}} +

+ mode={{.Mode}} · size={{.SizeBytes}}b + {{if .OwnerName}} · owner={{.OwnerName}}:{{.GroupName}}{{end}} + {{if .SymlinkTo}} · symlink→{{.SymlinkTo}}{{end}} +

+ {{if .ParseError}}

parse error: {{.ParseError}}

{{end}} + {{if .Entries}} + + + {{range .Entries}} + + + + + + {{end}} +
KeyValueFlags
{{.Key}}{{if .IsArray}}[]{{end}}{{.DisplayValue}} + {{if .IsAuth}}auth{{end}} + {{if .IsEnvRef}}env-ref{{end}} +
+ {{end}} + {{end}} + {{end}} + {{if .NPMRCAudit.Env}} +

npm-relevant environment variables

+ + + {{range .NPMRCAudit.Env}} + {{end}} +
VariableSetValue
{{.Name}}{{if .Set}}yes{{else}}no{{end}}{{.DisplayValue}}
+ {{end}} +
+
+{{end}} + {{if .PythonPkgManagers}}
diff --git a/internal/output/npmrc_verbose.go b/internal/output/npmrc_verbose.go new file mode 100644 index 0000000..259b6bd --- /dev/null +++ b/internal/output/npmrc_verbose.go @@ -0,0 +1,513 @@ +package output + +import ( + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +// securityRelevantKeys are the npm config keys that materially change install +// behavior or trust posture. We highlight them in the verbose view so they +// stand out from the 100+ other keys npm exposes. +var securityRelevantKeys = map[string]string{ + "registry": "where unscoped packages come from", + "strict-ssl": "TLS verification on registry traffic", + "ignore-scripts": "block lifecycle scripts (preinstall/install/postinstall)", + "script-shell": "shell used by lifecycle scripts and `npm run`", + "node-options": "NODE_OPTIONS for spawned scripts (--require= is dangerous)", + "min-release-age": "skip versions newer than N days (defense vs just-published worms)", + "audit": "run npm audit on install", + "audit-level": "severity threshold for audit failures", + "package-lock": "honor lockfile for reproducible installs", + "replace-registry-host": "rewrite registry host in lockfile entries at install", + "ca": "inline-trusted CA cert(s)", + "cafile": "path to CA cert bundle", + "proxy": "outbound proxy", + "https-proxy": "outbound HTTPS proxy", + "prefix": "global install location", + "cache": "fetched-tarball cache directory", + "globalconfig": "path to global .npmrc", + "userconfig": "path to user .npmrc", +} + +// PrettyNPMRC renders an NPMRCAudit as a verbose, terminal-friendly report. +// It's the implementation behind `--npmrc`: focused, deeper than the default +// summary, but still scannable. +// +//nolint:errcheck // terminal output +func PrettyNPMRC(w io.Writer, audit *model.NPMRCAudit, dev model.Device, colorMode string) { + c := setupColors(colorMode) + + hr := strings.Repeat("─", 76) + fmt.Fprintf(w, "%s%s%s\n", c.purple, hr, c.reset) + fmt.Fprintf(w, "%s%s NPM CONFIG AUDIT %s\n", c.purple, c.bold, c.reset) + fmt.Fprintf(w, "%s%s%s\n", c.purple, hr, c.reset) + fmt.Fprintf(w, " host: %s%s%s user: %s%s%s platform: %s\n", + c.bold, dev.Hostname, c.reset, c.bold, dev.UserIdentity, c.reset, dev.Platform) + if audit.Available { + fmt.Fprintf(w, " npm: %s%s%s @ %s\n", c.green, audit.NPMVersion, c.reset, audit.NPMPath) + } else { + fmt.Fprintf(w, " npm: %s(not found in PATH — file-only audit)%s\n", c.dim, c.reset) + } + if audit.DiscoveryError != "" { + fmt.Fprintf(w, " %swarn: %s%s\n", c.dim, audit.DiscoveryError, c.reset) + } + fmt.Fprintln(w) + + // --- changes since last scan (Phase B) --- + if audit.Diff != nil { + printDiff(w, c, audit.Diff) + } + + // --- discovered files --- + fmt.Fprintf(w, "%s%s┌── DISCOVERED .npmrc FILES (%d) %s\n", + c.purple, c.bold, len(audit.Files), c.reset) + if len(audit.Files) == 0 { + fmt.Fprintf(w, " %sno .npmrc files at any scope%s\n", c.dim, c.reset) + } + // Stable display order: builtin → global → user → project (then by path). + files := append([]model.NPMRCFile(nil), audit.Files...) + sort.SliceStable(files, func(i, j int) bool { + if scopeRank(files[i].Scope) != scopeRank(files[j].Scope) { + return scopeRank(files[i].Scope) < scopeRank(files[j].Scope) + } + return files[i].Path < files[j].Path + }) + for _, f := range files { + printNPMRCFileVerbose(w, c, f) + } + fmt.Fprintln(w) + + // --- effective config --- + if audit.Effective != nil { + printEffectiveVerbose(w, c, audit.Effective) + } + + // --- env vars --- + printEnvVerbose(w, c, audit.Env) +} + +func scopeRank(s string) int { + switch s { + case "builtin": + return 0 + case "global": + return 1 + case "user": + return 2 + case "project": + return 3 + } + return 99 +} + +//nolint:errcheck // terminal output +func printNPMRCFileVerbose(w io.Writer, c *colors, f model.NPMRCFile) { + scopeTag := strings.ToUpper(f.Scope) + fmt.Fprintf(w, "│\n│ %s%s[%s]%s %s\n", c.purple, c.bold, scopeTag, c.reset, f.Path) + + if !f.Exists { + fmt.Fprintf(w, "│ %s(file does not exist — npm would skip this scope)%s\n", c.dim, c.reset) + return + } + + // Metadata line. + mtime := "" + if f.ModTimeUnix > 0 { + mtime = time.Unix(f.ModTimeUnix, 0).Format("2006-01-02 15:04:05") + } + owner := "?" + if f.OwnerName != "" { + owner = fmt.Sprintf("%s:%s", f.OwnerName, f.GroupName) + } + sha := f.SHA256 + if len(sha) > 12 { + sha = sha[:12] + } + fmt.Fprintf(w, "│ %smode=%s size=%db owner=%s mtime=%s sha=%s%s\n", + c.dim, f.Mode, f.SizeBytes, owner, mtime, sha, c.reset) + + // Notable flags. + flags := []string{} + if f.SymlinkTo != "" { + flags = append(flags, fmt.Sprintf("symlink → %s", f.SymlinkTo)) + } + if f.GitTracked { + flags = append(flags, c.bold+"GIT-TRACKED"+c.reset+" (committed — credentials would be exposed wherever the repo is)") + } else if f.InGitRepo { + flags = append(flags, "inside a git repo (untracked)") + } + if len(flags) > 0 { + for _, fl := range flags { + fmt.Fprintf(w, "│ %s· %s%s\n", c.dim, fl, c.reset) + } + } + + if f.ParseError != "" { + fmt.Fprintf(w, "│ %sparse error: %s%s\n", c.dim, f.ParseError, c.reset) + return + } + + if len(f.Entries) == 0 { + fmt.Fprintf(w, "│ %s(empty file)%s\n", c.dim, c.reset) + return + } + + // Entries — each one with line number, classification badge, key, redacted value. + fmt.Fprintf(w, "│ %sentries (%d):%s\n", c.dim, len(f.Entries), c.reset) + for _, e := range f.Entries { + key := e.Key + if e.IsArray { + key += "[]" + } + + // Classification badges + badges := []string{} + switch { + case e.IsAuth && e.IsEnvRef: + badges = append(badges, c.green+"AUTH:env-ref"+c.reset) + case e.IsAuth: + badges = append(badges, c.bold+"AUTH:hardcoded"+c.reset) + case e.IsEnvRef: + badges = append(badges, "env-ref") + } + if _, ok := securityRelevantKeys[e.Key]; ok { + badges = append(badges, c.purple+"sec-relevant"+c.reset) + } + badgeStr := "" + if len(badges) > 0 { + badgeStr = " " + strings.Join(badges, " ") + } + + fmt.Fprintf(w, "│ %s%4d:%s %-42s = %s%s%s%s\n", + c.dim, e.LineNum, c.reset, key, c.dim, e.DisplayValue, c.reset, badgeStr) + if e.IsAuth && e.IsEnvRef && len(e.EnvRefVars) > 0 { + fmt.Fprintf(w, "│ %s resolves from env: %s%s\n", + c.dim, strings.Join(e.EnvRefVars, ", "), c.reset) + } + } + + // Per-project effective-override panel: only populated for project-scope + // files. Shows what flips when a developer cd's into this project. + printOverrides(w, c, f) +} + +//nolint:errcheck // terminal output +func printOverrides(w io.Writer, c *colors, f model.NPMRCFile) { + if f.OverrideError != "" { + fmt.Fprintf(w, "│ %s↳ effective-override eval failed: %s%s\n", c.dim, f.OverrideError, c.reset) + return + } + if len(f.EffectiveOverrides) == 0 { + if f.Scope == "project" { + fmt.Fprintf(w, "│ %s↳ no effective overrides — running npm here resolves the same as $HOME%s\n", c.dim, c.reset) + } + return + } + + // Header — bold + colored to make the section visually distinct from + // the static entries above. Auth-bearing override gets an extra warning. + authCount := 0 + newCount := 0 + for _, ov := range f.EffectiveOverrides { + if ov.IsAuth { + authCount++ + } + if ov.IsNew { + newCount++ + } + } + fmt.Fprintf(w, "│\n│ %s%s★ EFFECTIVE OVERRIDES%s — running npm in this dir flips %d key(s) vs $HOME", + c.purple, c.bold, c.reset, len(f.EffectiveOverrides)) + if authCount > 0 { + fmt.Fprintf(w, " %s%s[%d auth]%s", c.bold, c.green, authCount, c.reset) + } + if newCount > 0 { + fmt.Fprintf(w, " %s[%d new]%s", c.dim, newCount, c.reset) + } + fmt.Fprintln(w) + + for _, ov := range f.EffectiveOverrides { + // Render three-line per override: bold key + flag, then baseline, + // then project. The bold key already pops visually; no glyph needed. + flag := "" + switch { + case ov.IsAuth && ov.IsNew: + flag = " " + c.bold + c.green + "[NEW AUTH]" + c.reset + case ov.IsAuth: + flag = " " + c.bold + c.green + "[AUTH]" + c.reset + case ov.IsNew: + flag = " " + c.dim + "[new]" + c.reset + case ov.IsRemoved: + flag = " " + c.dim + "[removed]" + c.reset + } + fmt.Fprintf(w, "│ %s%s%s\n", c.bold, ov.Key, flag+c.reset) + fmt.Fprintf(w, "│ %sbaseline%s (%s) %s%s%s\n", + c.dim, c.reset, sourceLabel(ov.BaselineSource), c.dim, ov.BaselineValue, c.reset) + fmt.Fprintf(w, "│ %sin project%s (%s) %s%s%s\n", + c.dim, c.reset, sourceLabel(ov.ProjectSource), c.dim, ov.ProjectValue, c.reset) + } +} + +func sourceLabel(s string) string { + if s == "" { + return "default/none" + } + return s +} + +//nolint:errcheck // terminal output +func printDiff(w io.Writer, c *colors, diff *model.NPMRCDiff) { + if diff.FirstRun { + fmt.Fprintf(w, "%s%s┌── CHANGES SINCE LAST SCAN %s\n", c.purple, c.bold, c.reset) + fmt.Fprintf(w, "│ %sfirst run — current state will become the baseline for the next scan%s\n", c.dim, c.reset) + fmt.Fprintln(w) + return + } + if !diff.HasChanges() { + fmt.Fprintf(w, "%s%s┌── CHANGES SINCE LAST SCAN %s\n", c.purple, c.bold, c.reset) + fmt.Fprintf(w, "│ %sno changes since last scan%s", c.dim, c.reset) + if diff.PreviousAt > 0 { + fmt.Fprintf(w, " %s(previous: %s)%s", + c.dim, formatRelativeTime(diff.PreviousAt, diff.CurrentAt), c.reset) + } + fmt.Fprintln(w) + fmt.Fprintln(w) + return + } + + fmt.Fprintf(w, "%s%s┌── CHANGES SINCE LAST SCAN %s\n", c.purple, c.bold, c.reset) + if diff.PreviousAt > 0 { + fmt.Fprintf(w, "│ %sprevious scan: %s ago%s\n", + c.dim, formatRelativeTime(diff.PreviousAt, diff.CurrentAt), c.reset) + } + fmt.Fprintf(w, "│ %s+%d added · -%d removed · ~%d modified · %d env change(s)%s\n", + c.dim, len(diff.AddedFiles), len(diff.RemovedFiles), len(diff.ModifiedFiles), len(diff.EnvChanges), c.reset) + fmt.Fprintln(w, "│") + + for _, a := range diff.AddedFiles { + fmt.Fprintf(w, "│ %s+ ADDED %s[%s]%s %s%s%s\n", c.green, c.dim, a.Scope, c.reset, c.bold, a.Path, c.reset) + } + for _, r := range diff.RemovedFiles { + fmt.Fprintf(w, "│ %s- REMOVED%s %s[%s]%s %s%s%s\n", c.bold, c.reset, c.dim, r.Scope, c.reset, c.bold, r.Path, c.reset) + } + for _, m := range diff.ModifiedFiles { + printModification(w, c, m) + } + for _, e := range diff.EnvChanges { + marker := " " + switch e.Type { + case "appeared": + marker = c.green + "+ENV" + c.reset + case "disappeared": + marker = c.dim + "-ENV" + c.reset + case "value_changed": + marker = c.bold + "~ENV" + c.reset + } + fmt.Fprintf(w, "│ %s %s %s%s%s\n", marker, e.Type, c.bold, e.Name, c.reset) + } + fmt.Fprintln(w) +} + +//nolint:errcheck // terminal output +func printModification(w io.Writer, c *colors, m model.NPMRCFileModification) { + fmt.Fprintf(w, "│ %s~ MODIFIED%s %s[%s]%s %s%s%s\n", + c.bold, c.reset, c.dim, m.Scope, c.reset, c.bold, m.Path, c.reset) + + if m.OwnerChanged != nil { + fmt.Fprintf(w, "│ owner %s%s%s → %s%s%s\n", c.dim, m.OwnerChanged.From, c.reset, c.bold, m.OwnerChanged.To, c.reset) + } + if m.GroupChanged != nil { + fmt.Fprintf(w, "│ group %s%s%s → %s%s%s\n", c.dim, m.GroupChanged.From, c.reset, c.bold, m.GroupChanged.To, c.reset) + } + if m.ModeChanged != nil { + fmt.Fprintf(w, "│ mode %s%s%s → %s%s%s\n", c.dim, m.ModeChanged.From, c.reset, c.bold, m.ModeChanged.To, c.reset) + } + if m.SizeChanged != nil { + fmt.Fprintf(w, "│ size %s%db%s → %s%db%s\n", c.dim, m.SizeChanged.From, c.reset, c.bold, m.SizeChanged.To, c.reset) + } + if m.ContentChanged && m.SizeChanged == nil { + fmt.Fprintf(w, "│ content sha256 changed (same size)\n") + } + + for _, e := range m.AddedEntries { + flag := "" + if e.IsAuth { + flag = " " + c.bold + "[AUTH]" + c.reset + } + fmt.Fprintf(w, "│ %s+ entry%s %s%s\n", c.green, c.reset, e.Key, flag) + } + for _, e := range m.RemovedEntries { + flag := "" + if e.IsAuth { + flag = " " + c.bold + "[AUTH]" + c.reset + } + fmt.Fprintf(w, "│ %s- entry%s %s%s\n", c.dim, c.reset, e.Key, flag) + } + for _, e := range m.ChangedEntries { + flag := "" + if e.IsAuth { + flag = " " + c.bold + "[AUTH ROTATED]" + c.reset + } + fmt.Fprintf(w, "│ %s~ value%s %s%s %s(sha %s → %s)%s\n", + c.bold, c.reset, e.Key, flag, c.dim, e.PreviousSHA256[:8], e.CurrentSHA256[:8], c.reset) + } + for _, note := range m.AttributionNotes { + fmt.Fprintf(w, "│ %s· %s%s\n", c.dim, note, c.reset) + } + if len(m.Suspects) > 0 { + shown := m.Suspects + more := 0 + if len(shown) > 8 { + more = len(shown) - 8 + shown = shown[:8] + } + fmt.Fprintf(w, "│ %scandidate processes:%s\n", c.dim, c.reset) + for _, s := range shown { + fmt.Fprintf(w, "│ %s[pid %d %s]%s %s\n", c.dim, s.PID, s.User, c.reset, s.Cmd) + } + if more > 0 { + fmt.Fprintf(w, "│ %s(+%d more)%s\n", c.dim, more, c.reset) + } + } +} + +// formatRelativeTime renders a "5m ago", "3h ago", "2d ago" string given +// two unix timestamps. Used for the diff header so "previous scan was…" +// reads naturally without forcing the reader to do clock math. +func formatRelativeTime(prev, current int64) string { + if prev == 0 || current == 0 { + return "unknown" + } + delta := current - prev + if delta < 0 { + delta = -delta + } + switch { + case delta < 60: + return fmt.Sprintf("%ds", delta) + case delta < 3600: + return fmt.Sprintf("%dm", delta/60) + case delta < 86400: + return fmt.Sprintf("%dh", delta/3600) + default: + return fmt.Sprintf("%dd", delta/86400) + } +} + +//nolint:errcheck // terminal output +func printEffectiveVerbose(w io.Writer, c *colors, eff *model.NPMRCEffective) { + fmt.Fprintf(w, "%s%s┌── EFFECTIVE CONFIG (what npm would actually use) %s\n", + c.purple, c.bold, c.reset) + + if eff.Error != "" { + fmt.Fprintf(w, "│ %swarn: %s%s\n│\n", c.dim, eff.Error, c.reset) + } + + // Group keys by source so the layered structure is visible at a glance. + bySource := map[string][]string{} + for k := range eff.Config { + src := eff.SourceByKey[k] + if src == "" { + src = "default" + } + bySource[src] = append(bySource[src], k) + } + for _, ks := range bySource { + sort.Strings(ks) + } + + // Display order: paths/explicit-layer sources first (those are what the + // user changed); "default" last (compiled-in baseline, usually noise). + sources := make([]string, 0, len(bySource)) + for s := range bySource { + sources = append(sources, s) + } + sort.SliceStable(sources, func(i, j int) bool { + // "default" always sorts last. + if sources[i] == "default" { + return false + } + if sources[j] == "default" { + return true + } + return sources[i] < sources[j] + }) + + for _, src := range sources { + keys := bySource[src] + isDefault := src == "default" + + // In default-section, only show keys that are security-relevant — + // printing 100+ default values is noise. + shown := keys + hidden := 0 + if isDefault { + filtered := keys[:0] + for _, k := range keys { + if _, ok := securityRelevantKeys[k]; ok { + filtered = append(filtered, k) + } + } + hidden = len(keys) - len(filtered) + shown = filtered + } + + fmt.Fprintf(w, "│\n│ %sfrom %s%s%s (%d keys)\n", + c.dim, c.bold, src, c.reset, len(keys)) + + for _, k := range shown { + v := formatEffValue(eff.Config[k]) + marker := " " + if _, ok := securityRelevantKeys[k]; ok { + marker = c.purple + "★ " + c.reset + } + fmt.Fprintf(w, "│ %s%-42s = %s%s%s\n", marker, k, c.dim, v, c.reset) + } + if isDefault && hidden > 0 { + fmt.Fprintf(w, "│ %s(+%d default values not shown)%s\n", c.dim, hidden, c.reset) + } + } + fmt.Fprintln(w) +} + +// formatEffValue stringifies an arbitrary JSON value from npm config. +// Strings render bare; everything else uses fmt's default %v. +func formatEffValue(v any) string { + if v == nil { + return "null" + } + if s, ok := v.(string); ok { + return s + } + return fmt.Sprintf("%v", v) +} + +//nolint:errcheck // terminal output +func printEnvVerbose(w io.Writer, c *colors, env []model.NPMRCEnvVar) { + fmt.Fprintf(w, "%s%s┌── npm-RELEVANT ENVIRONMENT VARIABLES %s\n", + c.purple, c.bold, c.reset) + setCount := 0 + for _, e := range env { + if e.Set { + setCount++ + } + } + fmt.Fprintf(w, "│ %s%d set, %d unset (unset names are recorded so Phase B can detect transitions)%s\n", + c.dim, setCount, len(env)-setCount, c.reset) + fmt.Fprintln(w, "│") + for _, e := range env { + state := c.dim + "unset" + c.reset + val := "" + if e.Set { + state = c.green + " set " + c.reset + val = " = " + c.dim + e.DisplayValue + c.reset + } + fmt.Fprintf(w, "│ [%s] %s%s\n", state, e.Name, val) + } + fmt.Fprintln(w) +} diff --git a/internal/output/npmrc_verbose_test.go b/internal/output/npmrc_verbose_test.go new file mode 100644 index 0000000..7b9828c --- /dev/null +++ b/internal/output/npmrc_verbose_test.go @@ -0,0 +1,169 @@ +package output + +import ( + "bytes" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +// fakeAudit returns a representative NPMRCAudit covering all the verbose +// view's branches: a missing scope, a present file with auth + env-ref + a +// security-relevant key, a git-tracked project file, and an env var that +// must come out redacted. +func fakeAudit() *model.NPMRCAudit { + return &model.NPMRCAudit{ + Available: true, + NPMVersion: "10.9.4", + NPMPath: "/usr/bin/npm", + Files: []model.NPMRCFile{ + { + Path: "/etc/npmrc", + Scope: "global", + Exists: false, + }, + { + Path: "/home/test/.npmrc", + Scope: "user", + Exists: true, + Readable: true, + SizeBytes: 200, + ModTimeUnix: 1730000000, + Mode: "0600", + OwnerName: "test", + GroupName: "test", + SHA256: "deadbeefcafebabe0123456789abcdef", + Entries: []model.NPMRCEntry{ + {Key: "registry", DisplayValue: "https://registry.npmjs.org/", LineNum: 1}, + {Key: "//registry.npmjs.org/:_authToken", DisplayValue: "***1234", LineNum: 2, IsAuth: true}, + {Key: "//npm.mycompany.com/:_authToken", DisplayValue: "${COMPANY_TOKEN}", LineNum: 3, IsAuth: true, IsEnvRef: true, EnvRefVars: []string{"COMPANY_TOKEN"}}, + {Key: "strict-ssl", DisplayValue: "false", LineNum: 4}, + }, + }, + { + Path: "/home/test/proj/.npmrc", + Scope: "project", + Exists: true, + Readable: true, + SizeBytes: 80, + Mode: "0644", + OwnerName: "test", + GroupName: "test", + SHA256: "abc123", + InGitRepo: true, + GitTracked: true, + Entries: []model.NPMRCEntry{ + {Key: "ignore-scripts", DisplayValue: "true", LineNum: 1}, + }, + }, + }, + Effective: &model.NPMRCEffective{ + Config: map[string]any{ + "registry": "https://registry.npmjs.org/", + "strict-ssl": false, + "ignore-scripts": true, + "audit-level": "moderate", + "long": true, // not security-relevant; should hide if from default + }, + SourceByKey: map[string]string{ + "registry": "user", + "strict-ssl": "user", + "ignore-scripts": "user", + "audit-level": "global", + "long": "default", + }, + }, + Env: []model.NPMRCEnvVar{ + {Name: "NPM_TOKEN", Set: true, DisplayValue: "***f00d"}, + {Name: "NPM_CONFIG_USERCONFIG", Set: false}, + }, + } +} + +func TestPrettyNPMRC_RedactsAuthAndShowsEnvRef(t *testing.T) { + var buf bytes.Buffer + PrettyNPMRC(&buf, fakeAudit(), model.Device{Hostname: "h", UserIdentity: "u", Platform: "linux"}, "never") + out := buf.String() + + // Auth-hardcoded should NEVER print the raw token; should print the + // already-redacted DisplayValue plus the AUTH:hardcoded badge. + if !strings.Contains(out, "***1234") { + t.Errorf("expected redacted display ***1234 in output") + } + if !strings.Contains(out, "AUTH:hardcoded") { + t.Errorf("expected AUTH:hardcoded badge") + } + if !strings.Contains(out, "AUTH:env-ref") { + t.Errorf("expected AUTH:env-ref badge for ${VAR} reference") + } + if !strings.Contains(out, "${COMPANY_TOKEN}") { + t.Errorf("env-ref literal should be preserved verbatim") + } + if !strings.Contains(out, "resolves from env: COMPANY_TOKEN") { + t.Errorf("expected env var name in resolves-from line") + } +} + +func TestPrettyNPMRC_HighlightsGitTracked(t *testing.T) { + var buf bytes.Buffer + PrettyNPMRC(&buf, fakeAudit(), model.Device{}, "never") + out := buf.String() + + if !strings.Contains(out, "GIT-TRACKED") { + t.Errorf("git-tracked file should surface a GIT-TRACKED warning, got:\n%s", out) + } +} + +func TestPrettyNPMRC_GroupsEffectiveBySource(t *testing.T) { + var buf bytes.Buffer + PrettyNPMRC(&buf, fakeAudit(), model.Device{}, "never") + out := buf.String() + + for _, want := range []string{"from user", "from global", "from default"} { + if !strings.Contains(out, want) { + t.Errorf("expected effective view to include %q grouping", want) + } + } + + // Security-relevant keys from non-default sources are always shown. + for _, key := range []string{"registry", "strict-ssl", "ignore-scripts", "audit-level"} { + if !strings.Contains(out, key) { + t.Errorf("expected security-relevant key %q in effective view", key) + } + } + + // Default-section non-security keys should be hidden behind the + // "+N default values not shown" line. + if strings.Contains(out, "long ") && !strings.Contains(out, "default values not shown") { + t.Errorf("non-security default key 'long' should not be expanded") + } +} + +func TestPrettyNPMRC_ShowsMissingFiles(t *testing.T) { + var buf bytes.Buffer + PrettyNPMRC(&buf, fakeAudit(), model.Device{}, "never") + out := buf.String() + + if !strings.Contains(out, "/etc/npmrc") { + t.Errorf("missing global file should still be listed") + } + if !strings.Contains(out, "file does not exist") { + t.Errorf("missing files should be marked clearly") + } +} + +func TestPrettyNPMRC_HandlesNoNPM(t *testing.T) { + a := fakeAudit() + a.Available = false + a.NPMVersion = "" + a.NPMPath = "" + + var buf bytes.Buffer + PrettyNPMRC(&buf, a, model.Device{}, "never") + out := buf.String() + + if !strings.Contains(out, "not found in PATH") { + t.Errorf("expected fallback message when npm missing") + } +} diff --git a/internal/output/pretty.go b/internal/output/pretty.go index dd4aee5..58f3271 100644 --- a/internal/output/pretty.go +++ b/internal/output/pretty.go @@ -262,9 +262,101 @@ func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error { fmt.Fprintln(w) } + // NPM CONFIG AUDIT + if result.NPMRCAudit != nil { + printNPMRCAudit(w, c, result.NPMRCAudit) + } + return nil } +//nolint:errcheck // terminal output +func printNPMRCAudit(w io.Writer, c *colors, a *model.NPMRCAudit) { + fmt.Fprintf(w, " %s%sNPM CONFIG AUDIT%s\n", c.purple, c.bold, c.reset) + fmt.Fprintln(w) + if a.Available { + fmt.Fprintf(w, " %snpm:%s %s @ %s\n", c.dim, c.reset, a.NPMVersion, a.NPMPath) + } else { + fmt.Fprintf(w, " %snpm:%s not found in PATH (file-only audit)\n", c.dim, c.reset) + } + if a.DiscoveryError != "" { + fmt.Fprintf(w, " %sdiscovery error:%s %s\n", c.dim, c.reset, a.DiscoveryError) + } + fmt.Fprintln(w) + + // Files + printSectionHeader(w, c, ".npmrc files", len(a.Files)) + if len(a.Files) == 0 { + fmt.Fprintf(w, " %sno .npmrc files found%s\n", c.dim, c.reset) + } + for _, f := range a.Files { + printNPMRCFile(w, c, f) + } + fmt.Fprintln(w) + + // Env vars (only show set ones; absent vars are part of the JSON for diffing). + setEnv := make([]model.NPMRCEnvVar, 0, len(a.Env)) + for _, e := range a.Env { + if e.Set { + setEnv = append(setEnv, e) + } + } + if len(setEnv) > 0 { + printSectionHeader(w, c, "npm-relevant env vars", len(setEnv)) + for _, e := range setEnv { + fmt.Fprintf(w, " %-30s %s%s%s\n", e.Name, c.dim, e.DisplayValue, c.reset) + } + fmt.Fprintln(w) + } +} + +//nolint:errcheck // terminal output +func printNPMRCFile(w io.Writer, c *colors, f model.NPMRCFile) { + tag := strings.ToUpper(f.Scope) + if !f.Exists { + fmt.Fprintf(w, " %s[%s]%s %s%s%s %s(missing)%s\n", + c.dim, tag, c.reset, c.bold, f.Path, c.reset, c.dim, c.reset) + return + } + flags := "" + if f.GitTracked { + flags += " git-tracked" + } else if f.InGitRepo { + flags += " in-git-repo" + } + if f.SymlinkTo != "" { + flags += " symlink→" + f.SymlinkTo + } + owner := "" + if f.OwnerName != "" { + owner = fmt.Sprintf("%s:%s ", f.OwnerName, f.GroupName) + } + fmt.Fprintf(w, " %s[%s]%s %s%s%s %s%smode=%s %s%db%s%s\n", + c.dim, tag, c.reset, c.bold, f.Path, c.reset, + c.dim, owner, f.Mode, "", f.SizeBytes, flags, c.reset) + if f.ParseError != "" { + fmt.Fprintf(w, " %sparse error: %s%s\n", c.dim, f.ParseError, c.reset) + return + } + if len(f.Entries) == 0 { + fmt.Fprintf(w, " %s(empty)%s\n", c.dim, c.reset) + return + } + for _, e := range f.Entries { + marker := " " + if e.IsAuth { + marker = "AU" + } else if e.IsEnvRef { + marker = "EV" + } + key := e.Key + if e.IsArray { + key += "[]" + } + fmt.Fprintf(w, " %s %-40s %s%s%s\n", marker, key, c.dim, e.DisplayValue, c.reset) + } +} + //nolint:errcheck // terminal output func printSectionHeader(w io.Writer, c *colors, title string, count int) { padding := 35 - len(title) diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 0220169..a7c8f7c 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -206,6 +206,21 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { log.StepSkip("disabled (use --enable-python-scan to enable)") } + // npm config audit — always on. Cheap (a few stat calls + at most two npm + // invocations) and high-value: every recent npm-supply-chain worm targets + // .npmrc as a credential-harvest artifact, so surfacing what's there is + // the baseline observability we want available without an opt-in flag. + log.StepStart("Auditing npm configuration") + start = time.Now() + npmrcDetector := detector.NewNPMRCDetector(exec) + loggedInUser, _ := exec.LoggedInUser() + npmrcAudit := npmrcDetector.Detect(ctx, searchDirs, loggedInUser) + // Phase B: load previous snapshot, diff, persist new baseline. Best-effort. + if err := detector.AttachDiff(ctx, exec, &npmrcAudit, time.Now().Unix(), dev.Hostname); err != nil { + log.Debug("npmrc snapshot diff: %v", err) + } + log.StepDone(time.Since(start)) + // Ensure no nil slices (JSON must emit [] not null) if aiTools == nil { aiTools = []model.AITool{} @@ -274,6 +289,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { SnapPackages: snapPackages, FlatpakPkgManager: flatpakPkgManager, FlatpakPackages: flatpakPackages, + NPMRCAudit: &npmrcAudit, Summary: model.Summary{ AIAgentsAndToolsCount: len(aiTools), IDEInstallationsCount: len(ides), diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 82a9e72..5c4e0c2 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "os/signal" + "os/user" "sync/atomic" "syscall" "time" @@ -53,6 +54,7 @@ type Payload struct { SystemPackageScans []model.SystemPackageScanResult `json:"system_package_scans"` AIAgents []model.AITool `json:"ai_agents"` MCPConfigs []model.MCPConfigEnterprise `json:"mcp_configs"` + NPMRCAudit *model.NPMRCAudit `json:"npmrc_audit,omitempty"` ExecutionLogs *ExecutionLogs `json:"execution_logs,omitempty"` PerformanceMetrics *PerformanceMetrics `json:"performance_metrics,omitempty"` @@ -508,6 +510,23 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err systemPackageScans = []model.SystemPackageScanResult{} } + // npm config audit — always on. Same rationale as community mode: the + // data is small and high-value. We use the user-aware executor so npm + // runs in the logged-in user's PATH (catches nvm/fnm/brew installs that + // root's PATH wouldn't see). + log.Progress("Auditing npm configuration...") + npmrcAudit := detector.NewNPMRCDetector(userExec).Detect(ctx, searchDirs, mustLoggedInUser(exec)) + // Phase B: snapshot diff. Best-effort — never fail the run on this. + if err := detector.AttachDiff(ctx, exec, &npmrcAudit, time.Now().Unix(), dev.Hostname); err != nil { + log.Debug("npmrc snapshot diff: %v", err) + } + if npmrcAudit.Diff != nil && npmrcAudit.Diff.HasChanges() { + log.Progress(" changes since last scan: +%d files / -%d files / ~%d modified", + len(npmrcAudit.Diff.AddedFiles), len(npmrcAudit.Diff.RemovedFiles), len(npmrcAudit.Diff.ModifiedFiles)) + } + log.Progress(" npm available: %v, files discovered: %d", npmrcAudit.Available, len(npmrcAudit.Files)) + fmt.Fprintln(os.Stderr) + // Finalize execution logs before building payload execLogsBase64 := capture.Finalize() endTime := time.Now() @@ -540,6 +559,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err SystemPackageScans: systemPackageScans, AIAgents: allAI, MCPConfigs: mcpConfigs, + NPMRCAudit: &npmrcAudit, ExecutionLogs: &ExecutionLogs{ OutputBase64: execLogsBase64, @@ -757,6 +777,17 @@ func gzipBytes(data []byte) ([]byte, error) { return buf.Bytes(), nil } +// mustLoggedInUser returns the logged-in user, or nil if it can't be resolved. +// The npmrc detector tolerates a nil user (it just skips the user-scope file +// lookup), so we don't propagate the error. +func mustLoggedInUser(exec executor.Executor) *user.User { + u, err := exec.LoggedInUser() + if err != nil { + return nil + } + return u +} + func resolveSearchDirs(exec executor.Executor, dirs []string) []string { resolved := make([]string, 0, len(dirs)) for _, d := range dirs {