diff --git a/CLA_SIGNATURES.md b/CLA_SIGNATURES.md index 8d5a5e5..381b929 100644 --- a/CLA_SIGNATURES.md +++ b/CLA_SIGNATURES.md @@ -5,4 +5,5 @@ julien-boost - Julien Champoux GuillaumeRoss - Guillaume Ross c0tton-fluff - Michal Ambrozkiewicz tveronezi - Thiago Veronezi -stlef14 - Stephan Lefrancois \ No newline at end of file +stlef14 - Stephan Lefrancois +smithjw - James Smith diff --git a/README.md b/README.md index 4d3a5d4..35c1914 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,8 @@ Each probe declares its scope (user/system), paths touched, env vars read, and r | `jetbrains` | JetBrains IDE configuration | JetBrains IDE workspace files and configuration for embedded secrets | | `gh` | GitHub CLI | GitHub CLI authentication tokens and configuration | | `ai_cli` | AI CLI tools | Credential files and chat logs for Gemini, Codex, Claude, and OpenCode | +| `mise` | [`mise`](https://mise.jdx.dev) configuration files | Plaintext secrets in `[env]` tables, `[[env]]` array-of-tables, and `[tasks.*]` env/run blocks | +| `mise_tasks` | `mise` file-task scripts | Plaintext secrets in script bodies and in `#MISE env={...}` / `# [MISE] env=` / `//MISE env=` header directives | ### Current Detectors diff --git a/bagel.yaml b/bagel.yaml index b3f606a..3dc558c 100644 --- a/bagel.yaml +++ b/bagel.yaml @@ -24,6 +24,32 @@ probes: # max_file_size: maximum bytes to read from a chat file before skipping it. # Prevents scan hangs on large conversation histories. Default: 1048576 (1 MB). # max_file_size: 1048576 + mise: + # Scans mise (https://mise.jdx.dev) configuration files for plaintext + # secrets in [env] tables AND in inline [tasks.*] env/run blocks. + # Covers global config plus the mise.toml family (mise.toml, .mise.toml, + # mise.local.toml, env-specific variants like mise.production.toml, idiomatic + # dir forms, and legacy .rtx.toml). The `redact = true` table form is reported + # as a finding (only suppresses output; the secret is still plaintext on disk). + enabled: true + flags: + # max_file_size: maximum bytes to read from a mise config before + # skipping it. Mise configs are typically <100KB. Default: 4194304 (4 MB). + # max_file_size: 4194304 + mise_tasks: + # Scans mise file-task scripts under mise-tasks/, .mise-tasks/, + # mise/tasks/, .mise/tasks/, and .config/mise/tasks/ (at up to 3 + # levels of sub-directory depth). Each script is line-scanned for + # plaintext secrets and its `#MISE env={...}` (or `# [MISE] env=`, + # `//MISE env=`) header directives are decoded as inline TOML and + # scanned the same way the config probe handles [env] tables. + # Findings are tagged `mise_task_file: true` and carry the derived + # task name (e.g. `test:units` for `mise-tasks/test/units`). + enabled: true + flags: + # max_file_size: maximum bytes to read from a mise file-task + # script before skipping it. Default: 4194304 (4 MB). + # max_file_size: 4194304 privacy: redact_paths: [] exclude_env_prefixes: [] diff --git a/cmd/bagel/scan.go b/cmd/bagel/scan.go index 343ee53..99e647b 100644 --- a/cmd/bagel/scan.go +++ b/cmd/bagel/scan.go @@ -217,5 +217,18 @@ func initializeProbes(cfg *models.Config) []probe.Probe { probes = append(probes, probe.NewContextProbe(cfg.Probes.AIContext, registry)) } + // mise probe - plaintext secrets in mise.toml [env] tables and + // inline [tasks.*] blocks. + if cfg.Probes.Mise.Enabled { + probes = append(probes, probe.NewMiseProbe(cfg.Probes.Mise, registry)) + } + + // mise_tasks probe - plaintext secrets in file-task scripts + // under mise-tasks/, .mise-tasks/, mise/tasks/, .mise/tasks/, + // and .config/mise/tasks/. + if cfg.Probes.MiseTasks.Enabled { + probes = append(probes, probe.NewMiseTasksProbe(cfg.Probes.MiseTasks, registry)) + } + return probes } diff --git a/go.mod b/go.mod index 67a83db..eb28ddc 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/uuid v1.6.0 github.com/mattn/go-isatty v0.0.20 github.com/olekukonko/tablewriter v1.1.0 + github.com/pelletier/go-toml/v2 v2.2.4 github.com/rs/zerolog v1.34.0 github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/cobra v1.10.1 @@ -29,7 +30,6 @@ require ( github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.0.9 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect diff --git a/pkg/config/config.go b/pkg/config/config.go index 2240199..3fa0d27 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -105,6 +105,8 @@ func setDefaults(v *viper.Viper) { v.SetDefault("probes.iac.enabled", true) v.SetDefault("probes.ai_mcp.enabled", true) v.SetDefault("probes.ai_context.enabled", true) + v.SetDefault("probes.mise.enabled", true) + v.SetDefault("probes.mise_tasks.enabled", true) v.SetDefault("output.include_file_hashes", false) v.SetDefault("output.include_file_content", false) @@ -414,6 +416,79 @@ func setDefaults(v *viper.Viper) { // macOS "Library/Preferences/helm/repositories.yaml", }, "type": "glob"}, + + // mise (https://mise.jdx.dev) - polyglot tool / version / + // env manager. The [env] table sets shell env vars and is a + // common landing site for plaintext tokens. + // + // Pattern set tracks `LOCAL_CONFIG_FILENAMES` in mise's + // src/config/mod.rs plus the env-specific variants enumerated + // in `DEFAULT_CONFIG_FILENAMES`. The probe classifies each + // matched file at runtime (global vs project, local-override, + // env-specific) based on its path and basename. + // + // `.rtx.*` are mise's legacy (pre-rename) names; mise still + // reads them, so we include them here. + {"name": "mise_config", "patterns": []string{ + // Project-level basenames (match at any depth) + "mise.toml", + ".mise.toml", + "mise.*.toml", // mise.local.toml, mise.production.toml, mise.production.local.toml + ".mise.*.toml", // .mise.local.toml, .mise.production.toml, etc. + ".rtx.toml", + ".rtx.*.toml", + // Idiomatic-dir forms (Unix + project-nested copies) + "mise/config.toml", + "mise/config.*.toml", + ".mise/config.toml", + ".mise/config.*.toml", + ".config/mise.toml", + ".config/mise.*.toml", + ".config/mise/config.toml", + ".config/mise/config.*.toml", + ".config/mise/mise.toml", + ".config/mise/mise.*.toml", + ".config/mise/conf.d/*.toml", + // Windows: %APPDATA%\mise\... + "AppData/Roaming/mise/config.toml", + "AppData/Roaming/mise/config.*.toml", + "AppData/Roaming/mise/conf.d/*.toml", + }, "type": "glob"}, + + // mise file-task scripts. Each entry is a directory whose + // files are the task scripts (shell, python, node, deno, ...). + // Sub-directories are valid — mise composes the task name + // from the path. We enumerate 1-3 levels deep, which covers + // the typical `mise-tasks///` + // shape; deeper nesting is unusual. + // + // Windows: the docs don't mention Windows-specific paths + // for file tasks. Since these are user-authored scripts + // kept inside repos, the Unix paths apply via WSL or as + // repo-relative paths on plain Windows too. + {"name": "mise_task_file", "patterns": []string{ + // mise-tasks/ + "mise-tasks/*", + "mise-tasks/*/*", + "mise-tasks/*/*/*", + // .mise-tasks/ + ".mise-tasks/*", + ".mise-tasks/*/*", + ".mise-tasks/*/*/*", + // mise/tasks/ + "mise/tasks/*", + "mise/tasks/*/*", + "mise/tasks/*/*/*", + // .mise/tasks/ + ".mise/tasks/*", + ".mise/tasks/*/*", + ".mise/tasks/*/*/*", + // .config/mise/tasks/ (covers ~/.config/mise/tasks/ when + // the home dir is walked + project-nested copies) + ".config/mise/tasks/*", + ".config/mise/tasks/*/*", + ".config/mise/tasks/*/*/*", + }, "type": "glob"}, }) } diff --git a/pkg/models/config.go b/pkg/models/config.go index 38e3c47..6aa1a21 100644 --- a/pkg/models/config.go +++ b/pkg/models/config.go @@ -49,6 +49,8 @@ type ProbeConfig struct { IaC ProbeSettings `yaml:"iac" mapstructure:"iac"` AIMCP ProbeSettings `yaml:"ai_mcp" mapstructure:"ai_mcp"` AIContext ProbeSettings `yaml:"ai_context" mapstructure:"ai_context"` + Mise ProbeSettings `yaml:"mise" mapstructure:"mise"` + MiseTasks ProbeSettings `yaml:"mise_tasks" mapstructure:"mise_tasks"` } // ProbeSettings contains settings for a specific probe diff --git a/pkg/probe/mise.go b/pkg/probe/mise.go new file mode 100644 index 0000000..0d4840f --- /dev/null +++ b/pkg/probe/mise.go @@ -0,0 +1,277 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package probe + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + + "github.com/boostsecurityio/bagel/pkg/detector" + "github.com/boostsecurityio/bagel/pkg/fileindex" + "github.com/boostsecurityio/bagel/pkg/models" + "github.com/pelletier/go-toml/v2" + "github.com/rs/zerolog/log" +) + +// MiseProbe scans mise (https://mise.jdx.dev) configuration files +// for plaintext secrets in [env] tables, [[env]] array-of-tables, +// and inline [tasks.*] env/run blocks. The `redact = true` table +// form only suppresses values from `mise env` output at runtime - +// the secret sits in plaintext on disk and is read by every shell +// that activates mise. +// +// File coverage tracks mise's own LOCAL_CONFIG_FILENAMES list (see +// src/config/mod.rs in jdx/mise) plus the env-specific variants +// from DEFAULT_CONFIG_FILENAMES. classifyMiseFile tags each +// discovered file with its role (global vs project, local-override, +// env-specific, conf.d fragment, legacy rtx). +// +// File-task scripts under mise-tasks/, .mise-tasks/, mise/tasks/, +// .mise/tasks/, .config/mise/tasks/ are handled by MiseTasksProbe. +// +// Finding metadata keys emitted: +// +// mise_env_var string - env var name (structured walks only) +// mise_redact_flag bool - value carried `redact = true` +// mise_task_name string - task name (when in [tasks.]) +// mise_task_field string - "env" or "run" +// mise_file_kind string - "global" | "project" | "system" +// mise_file_local bool - filename ends with .local.toml +// mise_file_legacy bool - .rtx.* family +// mise_file_fragment bool - under a conf.d/ directory +// mise_file string - basename of the config file +// mise_file_env string - MISE_ENV scope from filename +// +// Boolean keys are omitted when false to keep JSON output compact. +type MiseProbe struct { + enabled bool + config models.ProbeSettings + detectorRegistry *detector.Registry + fileIndex *fileindex.FileIndex + maxFileSize int64 + userHome string + userAppData string +} + +// NewMiseProbe creates the mise config probe. Accepts an optional +// "max_file_size" flag (int / int64 / float64, bytes) overriding +// the default 4 MB read cap. +func NewMiseProbe(config models.ProbeSettings, registry *detector.Registry) *MiseProbe { + home, appData := resolveMiseUserDirs() + return &MiseProbe{ + enabled: config.Enabled, + config: config, + detectorRegistry: registry, + maxFileSize: readMaxFileSizeFlag(config.Flags, defaultMiseMaxFileSize), + userHome: home, + userAppData: appData, + } +} + +// Name returns the probe name. +func (p *MiseProbe) Name() string { return "mise" } + +// IsEnabled returns whether the probe is enabled. +func (p *MiseProbe) IsEnabled() bool { return p.enabled } + +// SetFingerprintSalt sets the fingerprint salt on the detector registry. +func (p *MiseProbe) SetFingerprintSalt(salt string) { + p.detectorRegistry.SetFingerprintSalt(salt) +} + +// SetFileIndex sets the file index for this probe. +func (p *MiseProbe) SetFileIndex(index *fileindex.FileIndex) { + p.fileIndex = index +} + +// Execute walks every indexed mise config file. Honours context +// cancellation between files so a slow walk on a large monorepo +// can be aborted promptly. +func (p *MiseProbe) Execute(ctx context.Context) ([]models.Finding, error) { + if p.fileIndex == nil { + log.Ctx(ctx).Warn().Str("probe", p.Name()).Msg("File index not available, skipping mise probe") + return nil, nil + } + paths := p.fileIndex.Get("mise_config") + log.Ctx(ctx).Debug().Int("mise_config_count", len(paths)).Msg("Found mise config files") + + var findings []models.Finding + for _, path := range paths { + if err := ctx.Err(); err != nil { + return findings, fmt.Errorf("mise probe canceled: %w", err) + } + findings = append(findings, p.processFile(ctx, path)...) + } + return findings, nil +} + +// processFile runs the structured TOML walk and the line-scan +// safety net against `path`, dedup'ing line-scan findings whose +// fingerprint already appears in the structured set. Internal +// dedup keeps the metadata-richer structured finding even when +// cross-probe reporter ordering would otherwise drop it. +func (p *MiseProbe) processFile(ctx context.Context, path string) []models.Finding { + content := readBoundedMiseFile(ctx, p.Name(), path, p.maxFileSize) + if content == nil { + return nil + } + + scan := miseScanCtx{ + registry: p.detectorRegistry, + probeName: p.Name(), + path: path, + class: classifyMiseFile(path, p.userHome, p.userAppData), + } + + structured := p.scanStructured(ctx, content, scan) + findings := append(make([]models.Finding, 0, len(structured)+2), structured...) + + seen := make(map[string]struct{}, len(structured)) + for _, f := range structured { + if f.Fingerprint != "" { + seen[f.Fingerprint] = struct{}{} + } + } + // Pass `0` so scanReaderLines uses its safe default (1 MB per + // line). The 4MB file cap bounds total bytes; the per-line cap + // bounds regex slot size against adversarial single-line input. + for _, f := range scanReaderLines(ctx, "file:"+path, bytes.NewReader(content), p.Name(), p.detectorRegistry, 0) { + if _, dup := seen[f.Fingerprint]; dup { + continue + } + findings = append(findings, f) + } + return findings +} + +// scanStructured parses the TOML and walks every [env] and +// [tasks.*] table. Failures to parse return nil; the line-scan +// pass in the caller still runs. +// +// Top-level [env] shapes supported: +// +// [env] - table - doc["env"] is map[string]any +// [[env]] - array - doc["env"] is []any of maps (used to +// group multiple env._.source directives) +// +// [tasks] shape: trivial form `tasks. = "command"` and +// detailed form `tasks. = { env = {...}, run = "..." | [...], ... }`. +func (p *MiseProbe) scanStructured(ctx context.Context, content []byte, scan miseScanCtx) []models.Finding { + doc, ok := p.decodeTOML(ctx, content, scan.path) + if !ok { + return nil + } + var findings []models.Finding + switch e := doc["env"].(type) { + case map[string]any: + findings = append(findings, scan.scanEnvTable(e, "")...) + case []any: + for _, item := range e { + if m, ok := item.(map[string]any); ok { + findings = append(findings, scan.scanEnvTable(m, "")...) + } + } + } + if tasks, ok := doc["tasks"].(map[string]any); ok { + findings = append(findings, p.scanTasksTable(tasks, scan)...) + } + return findings +} + +// decodeTOML wraps toml.Unmarshal in a recover() so a parser panic +// on adversarial input (deeply nested tables, malformed structures +// that trigger a future parser bug) is contained per file. The +// caller's line-scan pass still runs on the raw bytes regardless. +func (p *MiseProbe) decodeTOML(ctx context.Context, content []byte, path string) (doc map[string]any, ok bool) { + defer func() { + if r := recover(); r != nil { + log.Ctx(ctx).Debug().Str("file", path).Interface("panic", r).Msg("Recovered from panic in mise TOML decoder") + doc, ok = nil, false + } + }() + if err := toml.Unmarshal(content, &doc); err != nil { + log.Ctx(ctx).Debug().Err(err).Str("file", path).Msg("Cannot parse mise TOML") + return nil, false + } + return doc, doc != nil +} + +// scanTasksTable walks a [tasks] table. The probe scans: +// +// tasks..env - same shape as top-level [env] +// tasks..run - string or array of strings +// tasks. - bare string (trivial form, treated as run) +// +// Other fields (description, depends, sources, outputs, alias, +// dir, shell, file, usage, ...) are not secret-bearing and skipped. +func (p *MiseProbe) scanTasksTable(tasks map[string]any, scan miseScanCtx) []models.Finding { + var findings []models.Finding + for name, raw := range tasks { + switch v := raw.(type) { + case string: + findings = append(findings, p.scanTaskRun(name, v, scan)...) + case map[string]any: + if env, ok := v["env"].(map[string]any); ok { + findings = append(findings, scan.scanEnvTable(env, name)...) + } + switch run := v["run"].(type) { + case string: + findings = append(findings, p.scanTaskRun(name, run, scan)...) + case []any: + for _, item := range run { + if s, ok := item.(string); ok { + findings = append(findings, p.scanTaskRun(name, s, scan)...) + } + } + } + } + } + return findings +} + +// scanTaskRun runs the detector registry over a task's run string +// (or one element of a run array, or the value of a trivial +// string-form task) and tags findings with the task name. +func (p *MiseProbe) scanTaskRun(taskName, run string, scan miseScanCtx) []models.Finding { + if scan.registry == nil || run == "" { + return nil + } + detCtx := models.NewDetectionContext(models.NewDetectionContextInput{ + Source: "file:" + scan.path, + ProbeName: scan.probeName, + }) + raw := scan.registry.DetectAll(run, detCtx) + if len(raw) == 0 { + return nil + } + findings := make([]models.Finding, 0, len(raw)) + for _, f := range raw { + if f.Metadata == nil { + f.Metadata = make(map[string]interface{}) + } + f.Probe = scan.probeName + f.Path = "file:" + scan.path + f.Metadata["mise_task_name"] = taskName + f.Metadata["mise_task_field"] = "run" + f.Metadata["mise_file_kind"] = scan.class.Kind + f.Metadata["mise_file"] = filepath.Base(scan.path) + if scan.class.IsLocal { + f.Metadata["mise_file_local"] = true + } + if scan.class.IsLegacy { + f.Metadata["mise_file_legacy"] = true + } + if scan.class.IsFragment { + f.Metadata["mise_file_fragment"] = true + } + if scan.class.EnvName != "" { + f.Metadata["mise_file_env"] = scan.class.EnvName + } + f.Message = fmt.Sprintf("In file:%s: task %q `run` contains a detected secret", scan.path, taskName) + findings = append(findings, f) + } + return findings +} diff --git a/pkg/probe/mise_common.go b/pkg/probe/mise_common.go new file mode 100644 index 0000000..5b98495 --- /dev/null +++ b/pkg/probe/mise_common.go @@ -0,0 +1,373 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package probe + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/boostsecurityio/bagel/pkg/detector" + "github.com/boostsecurityio/bagel/pkg/models" + "github.com/rs/zerolog/log" +) + +// Shared helpers for the mise (config-file) and mise_tasks (file- +// task script) probes. Both target mise (https://mise.jdx.dev) and +// emit findings with the same `mise_*` metadata vocabulary. + +// defaultMiseMaxFileSize caps each mise file read. Mise configs are +// typically <100KB; 4MB matches the IaC probe. +const defaultMiseMaxFileSize = 4 * 1024 * 1024 // 4 MB + +// File-kind values emitted in the mise_file_kind metadata field. +const ( + miseFileKindGlobal = "global" + miseFileKindProject = "project" + miseFileKindSystem = "system" +) + +// envNameDenylist holds filename middle segments that look like +// MISE_ENV scopes but aren't (`mise.config.toml`, `mise.backup.toml`). +// Hitting any of these clears EnvName so consumers don't see a +// meaningless environment tag. Add to the list when noisy false +// positives turn up. +var envNameDenylist = map[string]struct{}{ + "config": {}, + "backup": {}, + "old": {}, + "example": {}, + "sample": {}, + "bak": {}, + "orig": {}, +} + +// miseFileClassification captures the diagnostic role of a mise +// file so consumers can prioritise findings (a leak in the global +// config affects every shell of this user; a leak in +// mise.local.toml is usually gitignored but still readable). +type miseFileClassification struct { + // Kind is one of miseFileKindGlobal / Project / System. + Kind string + // IsLocal is true for `.local.toml` files (gitignore territory). + IsLocal bool + // EnvName is the MISE_ENV scope embedded in the filename + // ("production" for mise.production.toml). Empty for base + // forms, for conf.d fragments, and for denylisted middle + // segments. + EnvName string + // IsLegacy is true for the pre-rename `.rtx.*` family. + IsLegacy bool + // IsFragment is true when the file lives under a `conf.d/` + // directory; the filename prefix is a sort key, not an env + // scope. + IsFragment bool +} + +// classifyMiseFile inspects a path and returns its diagnostic role. +// Pure function (no FS access) so tests can drive it with synthetic +// paths. `home` and `appData` are the user's home directory and +// Windows APPDATA respectively; either may be empty (the function +// degrades to a substring heuristic). +func classifyMiseFile(path, home, appData string) miseFileClassification { + base := filepath.Base(path) + cls := miseFileClassification{Kind: miseFileKindProject} + + switch { + case isMiseGlobalPath(path, home, appData): + cls.Kind = miseFileKindGlobal + case isMiseSystemPath(path): + cls.Kind = miseFileKindSystem + } + + // conf.d fragment detection runs before env-name extraction so a + // filename like `01-go.toml` doesn't get "01-go" tagged as a + // MISE_ENV scope. + if strings.Contains(filepath.ToSlash(path), "/conf.d/") { + cls.IsFragment = true + } + + stem := strings.TrimSuffix(base, ".toml") + if strings.HasPrefix(base, ".rtx.") || base == ".rtx.toml" { + cls.IsLegacy = true + } + if strings.HasSuffix(stem, ".local") { + cls.IsLocal = true + stem = strings.TrimSuffix(stem, ".local") + } + + if cls.IsFragment { + return cls + } + for _, prefix := range []string{"mise.", ".mise.", "config.", ".rtx."} { + if !strings.HasPrefix(stem, prefix) { + continue + } + rest := strings.TrimPrefix(stem, prefix) + if rest == "" || !validMiseEnvName(rest) { + break + } + if _, deny := envNameDenylist[rest]; deny { + break + } + cls.EnvName = rest + break + } + return cls +} + +// validMiseEnvName reports whether s is plausibly a MISE_ENV value. +// Real env names are alphanumeric with `-`, `_`, or `.` separators +// (e.g. "production", "staging-1", "ci.linux"); anything containing +// whitespace, path separators, or other punctuation almost certainly +// came from a malformed filename and is rejected. +func validMiseEnvName(s string) bool { + if s == "" { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '-' || r == '_' || r == '.': + default: + return false + } + } + return true +} + +// isMiseGlobalPath reports whether `path` lives inside the mise +// user-global config directory. Anchored to `home`/`appData` so a +// project-checked-in dotfiles tree at /.config/mise/ isn't +// misclassified as global. Falls back to a substring heuristic +// when no anchor is available - the kind classification is purely +// diagnostic, never security-deciding. +func isMiseGlobalPath(path, home, appData string) bool { + p := filepath.ToSlash(path) + if home != "" { + homeSlash := filepath.ToSlash(home) + if strings.HasPrefix(p, homeSlash+"/.config/mise/") || + strings.HasPrefix(p, homeSlash+"/AppData/Roaming/mise/") { + return true + } + } + if appData != "" { + if strings.HasPrefix(p, filepath.ToSlash(appData)+"/mise/") { + return true + } + } + if home == "" && appData == "" { + if runtime.GOOS == "windows" { + return strings.Contains(p, "/AppData/Roaming/mise/") + } + return strings.Contains(p, "/.config/mise/") + } + return false +} + +// isMiseSystemPath reports whether a path is under /etc/mise. +func isMiseSystemPath(path string) bool { + return strings.HasPrefix(filepath.ToSlash(path), "/etc/mise/") +} + +// resolveMiseUserDirs returns the user's home directory and the +// Windows APPDATA directory (empty string when either lookup +// fails). Both probes use these to anchor file-kind classification. +func resolveMiseUserDirs() (home, appData string) { + home, _ = os.UserHomeDir() // empty home is tolerated by the classifier + appData = os.Getenv("APPDATA") + return +} + +// extractMiseEnvValue normalises a mise env-table entry into a +// (string, redactFlag, ok) triple. Returns ok=false for boolean, +// numeric, or empty-array values that can't carry secrets. +// +// Supported shapes (per mise docs at /environments/): +// +// FOO = "string" bare +// FOO = ["a", "b"] array, joined with \n +// FOO = { value = "...", redact = true } table +// FOO = { file = "/path", redact = true } dotenv-file reference +// FOO = { path = "/path", redact = true } synonym of `file` +// +// `tools = true` / `templated = true` flags are runtime evaluation +// hints; the string value still lives on disk and goes through the +// detector regardless. +func extractMiseEnvValue(raw any) (value string, redact, ok bool) { + switch v := raw.(type) { + case string: + return v, false, true + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + parts = append(parts, s) + } + } + if len(parts) == 0 { + return "", false, false + } + return strings.Join(parts, "\n"), false, true + case map[string]any: + for _, k := range []string{"value", "file", "path"} { + if s, ok := v[k].(string); ok { + r, _ := v["redact"].(bool) + return s, r, true + } + } + } + return "", false, false +} + +// readBoundedMiseFile reads `path` enforcing maxFileSize via +// io.LimitReader. `os.Stat` is a fast-path gate; the LimitReader +// is the authoritative TOCTOU-safe bound. Returns nil on any +// stat/open/read failure (logged at debug). +func readBoundedMiseFile(ctx context.Context, probeName, path string, maxFileSize int64) []byte { + info, err := os.Stat(path) + if err != nil { + log.Ctx(ctx).Debug().Err(err).Str("probe", probeName).Str("file", path).Msg("Cannot stat mise file") + return nil + } + if info.Size() > maxFileSize { + log.Ctx(ctx).Debug(). + Str("probe", probeName).Str("file", path). + Int64("size_bytes", info.Size()).Int64("max_size_bytes", maxFileSize). + Msg("Skipping oversized mise file") + return nil + } + f, err := os.Open(path) //nolint:gosec // path is from the file index, which enforces base-dir + symlink policy + if err != nil { + log.Ctx(ctx).Debug().Err(err).Str("probe", probeName).Str("file", path).Msg("Cannot open mise file") + return nil + } + defer func() { + if cerr := f.Close(); cerr != nil { + log.Ctx(ctx).Debug().Err(cerr).Str("probe", probeName).Str("file", path).Msg("Cannot close mise file") + } + }() + content, err := io.ReadAll(io.LimitReader(f, maxFileSize)) + if err != nil { + log.Ctx(ctx).Debug().Err(err).Str("probe", probeName).Str("file", path).Msg("Cannot read mise file") + return nil + } + return content +} + +// miseScanCtx bundles the per-file context shared by every +// env/value-scanning helper call within one file. Pass-by-value +// because all fields are small (one pointer, three strings, one +// flat struct) and the call frequency is low. +type miseScanCtx struct { + registry *detector.Registry + probeName string + path string + class miseFileClassification +} + +// scanEnvTable walks a mise [env] table (or any equivalent +// map[string]any) and runs the detector registry over each value. +// taskName is "" for top-level [env]; non-empty for [tasks.].env +// or for the env block of a file-task header. +// +// The "_" key holds mise directives (`_.file`, `_.source`, `_.path`, +// `_.python.venv`) and is skipped. The line-scan safety net in the +// caller still scans the raw bytes, so a value like +// `_.file = "https://user:pw@host/.env"` is still caught. +func (c miseScanCtx) scanEnvTable(env map[string]any, taskName string) []models.Finding { + var findings []models.Finding + for key, raw := range env { + if key == "_" { + continue + } + value, redact, ok := extractMiseEnvValue(raw) + if !ok { + continue + } + findings = append(findings, c.scanEnvValue(key, value, taskName, redact)...) + } + return findings +} + +// scanEnvValue runs the detector registry over one env-var value +// and re-tags every finding with mise-specific metadata. Path, +// Probe, and metadata are reasserted on the finding so the contract +// is robust against detector-side changes. +func (c miseScanCtx) scanEnvValue(key, value, taskName string, redact bool) []models.Finding { + if c.registry == nil || value == "" { + return nil + } + detCtx := models.NewDetectionContext(models.NewDetectionContextInput{ + Source: "file:" + c.path, + ProbeName: c.probeName, + }).WithEnvVarName(key) + + raw := c.registry.DetectAll(value, detCtx) + if len(raw) == 0 { + return nil + } + findings := make([]models.Finding, 0, len(raw)) + for _, f := range raw { + c.annotateEnvFinding(&f, key, taskName, redact) + findings = append(findings, f) + } + return findings +} + +// annotateEnvFinding sets the mise-specific metadata, Path, Probe, +// Message, and Description fields on an env-var-derived finding. +// Boolean keys with `false` value are omitted from metadata to keep +// the JSON output compact; consumers should rely on key presence, +// not value. +func (c miseScanCtx) annotateEnvFinding(f *models.Finding, key, taskName string, redact bool) { + if f.Metadata == nil { + f.Metadata = make(map[string]interface{}) + } + f.Probe = c.probeName + f.Path = "file:" + c.path + f.Metadata["mise_env_var"] = key + f.Metadata["mise_file_kind"] = c.class.Kind + f.Metadata["mise_file"] = filepath.Base(c.path) + if taskName != "" { + f.Metadata["mise_task_name"] = taskName + f.Metadata["mise_task_field"] = "env" + } + if redact { + f.Metadata["mise_redact_flag"] = true + } + if c.class.IsLocal { + f.Metadata["mise_file_local"] = true + } + if c.class.IsLegacy { + f.Metadata["mise_file_legacy"] = true + } + if c.class.IsFragment { + f.Metadata["mise_file_fragment"] = true + } + if c.class.EnvName != "" { + f.Metadata["mise_file_env"] = c.class.EnvName + } + if redact { + // `redact = true` only suppresses values in `mise env` + // output; the secret itself sits in plaintext on disk. + // Call this out so users don't treat the flag as + // encryption. + f.Description = strings.TrimRight(f.Description, " ") + + " Note: this entry sets `redact = true`, which only " + + "suppresses the value from `mise env` output. The secret " + + "itself remains in plaintext on disk." + } + if taskName != "" { + f.Message = fmt.Sprintf("In file:%s: task %q env var %s contains a detected secret", c.path, taskName, key) + } else { + f.Message = fmt.Sprintf("In file:%s: env var %s contains a detected secret", c.path, key) + } +} diff --git a/pkg/probe/mise_tasks.go b/pkg/probe/mise_tasks.go new file mode 100644 index 0000000..c615b5d --- /dev/null +++ b/pkg/probe/mise_tasks.go @@ -0,0 +1,241 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package probe + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/boostsecurityio/bagel/pkg/detector" + "github.com/boostsecurityio/bagel/pkg/fileindex" + "github.com/boostsecurityio/bagel/pkg/models" + "github.com/pelletier/go-toml/v2" + "github.com/rs/zerolog/log" +) + +// MiseTasksProbe scans mise file-task scripts for plaintext +// secrets. File tasks are executable scripts under any of: +// +// mise-tasks/ +// .mise-tasks/ +// mise/tasks/ +// .mise/tasks/ +// .config/mise/tasks/ +// +// Sub-directories are valid - mise composes `test:units` from +// `mise-tasks/test/units`. Scripts can be any interpreter (bash, +// python, node, deno, ...) selected by a shebang, with mise-specific +// configuration in `#MISE`/`# [MISE]`/`//MISE` header comments. The +// `env={...}` header is an inline TOML table. +// +// Two passes: +// +// 1. Header parse - every `#MISE env={...}` directive is decoded as +// inline TOML and walked through the detector registry. +// Findings carry mise_task_field=env. +// 2. Line scan - body content is regex-scanned for plaintext +// secrets (e.g. curl invocations with embedded bearer tokens). +// +// Findings from both passes are dedup'd by fingerprint inside the +// probe so the metadata-richer header finding wins over a line- +// scan duplicate. +type MiseTasksProbe struct { + enabled bool + config models.ProbeSettings + detectorRegistry *detector.Registry + fileIndex *fileindex.FileIndex + maxFileSize int64 + userHome string + userAppData string +} + +// NewMiseTasksProbe creates the mise file-tasks probe. Accepts an +// optional "max_file_size" flag (int / int64 / float64, bytes) +// overriding the default 4 MB read cap. +func NewMiseTasksProbe(config models.ProbeSettings, registry *detector.Registry) *MiseTasksProbe { + home, appData := resolveMiseUserDirs() + return &MiseTasksProbe{ + enabled: config.Enabled, + config: config, + detectorRegistry: registry, + maxFileSize: readMaxFileSizeFlag(config.Flags, defaultMiseMaxFileSize), + userHome: home, + userAppData: appData, + } +} + +// Name returns the probe name. +func (p *MiseTasksProbe) Name() string { return "mise_tasks" } + +// IsEnabled returns whether the probe is enabled. +func (p *MiseTasksProbe) IsEnabled() bool { return p.enabled } + +// SetFingerprintSalt sets the fingerprint salt on the detector registry. +func (p *MiseTasksProbe) SetFingerprintSalt(salt string) { + p.detectorRegistry.SetFingerprintSalt(salt) +} + +// SetFileIndex sets the file index for this probe. +func (p *MiseTasksProbe) SetFileIndex(index *fileindex.FileIndex) { + p.fileIndex = index +} + +// Execute walks every indexed file-task script. +func (p *MiseTasksProbe) Execute(ctx context.Context) ([]models.Finding, error) { + if p.fileIndex == nil { + log.Ctx(ctx).Warn().Str("probe", p.Name()).Msg("File index not available, skipping mise_tasks probe") + return nil, nil + } + paths := p.fileIndex.Get("mise_task_file") + log.Ctx(ctx).Debug().Int("mise_task_file_count", len(paths)).Msg("Found mise file-task scripts") + + var findings []models.Finding + for _, path := range paths { + if err := ctx.Err(); err != nil { + return findings, fmt.Errorf("mise_tasks probe canceled: %w", err) + } + findings = append(findings, p.processTaskFile(ctx, path)...) + } + return findings, nil +} + +func (p *MiseTasksProbe) processTaskFile(ctx context.Context, path string) []models.Finding { + content := readBoundedMiseFile(ctx, p.Name(), path, p.maxFileSize) + if content == nil { + return nil + } + + scan := miseScanCtx{ + registry: p.detectorRegistry, + probeName: p.Name(), + path: path, + class: classifyMiseFile(path, p.userHome, p.userAppData), + } + taskName := deriveMiseTaskName(path) + + var findings []models.Finding + if env := parseMiseTaskHeaderEnv(ctx, content, path); env != nil { + structured := scan.scanEnvTable(env, taskName) + for i := range structured { + structured[i].Metadata["mise_task_file"] = true + } + findings = structured + } + + seen := make(map[string]struct{}, len(findings)) + for _, f := range findings { + if f.Fingerprint != "" { + seen[f.Fingerprint] = struct{}{} + } + } + for _, f := range scanReaderLines(ctx, "file:"+path, bytes.NewReader(content), p.Name(), p.detectorRegistry, 0) { + if _, dup := seen[f.Fingerprint]; dup { + continue + } + if f.Metadata == nil { + f.Metadata = make(map[string]interface{}) + } + f.Metadata["mise_task_file"] = true + f.Metadata["mise_task_name"] = taskName + f.Metadata["mise_file_kind"] = scan.class.Kind + f.Metadata["mise_file"] = filepath.Base(path) + findings = append(findings, f) + } + return findings +} + +// miseTaskRoots is the list of directory markers mise loads file +// tasks from, in the order documented at /tasks/file-tasks.html. +// Each entry is slash-prefixed so it survives intact through +// filepath.ToSlash. +var miseTaskRoots = []string{ + "/mise-tasks/", + "/.mise-tasks/", + "/mise/tasks/", + "/.mise/tasks/", + "/.config/mise/tasks/", +} + +// deriveMiseTaskName converts an absolute file-task path into the +// task name mise itself would assign. Sub-directories become `:`- +// separated components (`mise-tasks/test/units` → `test:units`); +// the special `_default` filename collapses to its containing +// directory (`mise-tasks/test/_default` → `test`). Falls back to +// the basename when no task-root marker is present. +func deriveMiseTaskName(path string) string { + p := filepath.ToSlash(path) + for _, root := range miseTaskRoots { + idx := strings.Index(p, root) + if idx < 0 { + continue + } + tail := strings.TrimSuffix(p[idx+len(root):], "/_default") + if tail == "" { + break + } + return strings.ReplaceAll(tail, "/", ":") + } + return filepath.Base(path) +} + +// miseTaskHeaderEnvRe matches mise task header `env=` directives in +// the four documented spellings: +// +// #MISE env=... bash, python, ruby, powershell +// # [MISE] env=... formatter-safe alternative +// //MISE env=... js, ts, deno, node +// // [MISE] env=... formatter-safe js/ts alternative +// +// The capture holds whatever follows `env=` (typically an inline +// TOML table `{...}`); decode happens in parseMiseTaskHeaderEnv. +// +// `# MISE` with a space is intentionally NOT matched - mise itself +// ignores that spelling to avoid formatter rewrites changing +// semantics. The `# [MISE]` form is the documented workaround. +var miseTaskHeaderEnvRe = regexp.MustCompile( + `(?m)^\s*(?://|#)\s*(?:MISE|\[MISE\])\s+env\s*=\s*(.+?)\s*$`, +) + +// parseMiseTaskHeaderEnv scans the script for `#MISE env=...` +// directives and returns the merged env map. Multiple headers +// merge with last-writer-wins (matching mise's own semantics). +// Returns nil when no header is found or none decode. +func parseMiseTaskHeaderEnv(ctx context.Context, content []byte, path string) map[string]any { + matches := miseTaskHeaderEnvRe.FindAllSubmatch(content, -1) + if len(matches) == 0 { + return nil + } + merged := make(map[string]any) + for _, m := range matches { + // Synthesise a `env = ` TOML document so the parser + // handles inline-table escaping for us. + doc := append([]byte("env = "), m[1]...) + var parsed map[string]any + if err := toml.Unmarshal(doc, &parsed); err != nil { + // Log neither the header value nor the parse error: the + // value can hold a plaintext secret, and go-toml error + // messages interpolate the offending input byte (%c/%#U) + // at the failure offset. bagel never logs secret material, + // so we record only the file path and the value length. + log.Ctx(ctx).Debug().Str("file", path).Int("header_bytes", len(m[1])). + Msg("Cannot parse #MISE env header value as TOML") + continue + } + env, ok := parsed["env"].(map[string]any) + if !ok { + continue + } + for k, v := range env { + merged[k] = v + } + } + if len(merged) == 0 { + return nil + } + return merged +} diff --git a/pkg/probe/mise_tasks_test.go b/pkg/probe/mise_tasks_test.go new file mode 100644 index 0000000..12e4364 --- /dev/null +++ b/pkg/probe/mise_tasks_test.go @@ -0,0 +1,447 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package probe + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/boostsecurityio/bagel/pkg/detector" + "github.com/boostsecurityio/bagel/pkg/fileindex" + "github.com/boostsecurityio/bagel/pkg/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMiseTasksProbe_Name(t *testing.T) { + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, detector.NewRegistry()) + assert.Equal(t, "mise_tasks", p.Name()) +} + +func TestMiseTasksProbe_IsEnabled(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + enabled bool + }{{"enabled", true}, {"disabled", false}} { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: tt.enabled}, detector.NewRegistry()) + assert.Equal(t, tt.enabled, p.IsEnabled()) + }) + } +} + +func TestMiseTasksProbe_ExecuteWithoutFileIndex(t *testing.T) { + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + assert.Empty(t, findings) +} + +func TestMiseTasksProbe_ExecuteEmptyIndex(t *testing.T) { + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(fileindex.NewFileIndex()) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + assert.Empty(t, findings) +} + +func TestMiseTasksProbe_LineScansBody(t *testing.T) { + // The most common shape: a bash script in mise-tasks/ with a + // token pasted into a curl invocation. The line scan must catch + // it, with the finding tagged mise_task_file=true and + // mise_task_name derived from the path. + tmp := t.TempDir() + taskDir := filepath.Join(tmp, "myrepo", "mise-tasks") + require.NoError(t, os.MkdirAll(taskDir, 0o700)) + taskPath := filepath.Join(taskDir, "deploy") + body := `#!/usr/bin/env bash +set -euo pipefail +curl -H "Authorization: Bearer ` + fakeGitHubPAT + `" https://api.example.com +` + require.NoError(t, os.WriteFile(taskPath, []byte(body), 0o700)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_task_file", taskPath) + + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var f *models.Finding + for i := range findings { + if findings[i].ID == "github-token-classic-pat" { + f = &findings[i] + break + } + } + require.NotNil(t, f, "line scan should find the token in the script body") + assert.Equal(t, true, f.Metadata["mise_task_file"]) + assert.Equal(t, "deploy", f.Metadata["mise_task_name"]) + assert.Equal(t, "mise_tasks", f.Probe) + assert.Equal(t, "file:"+taskPath, f.Path) +} + +func TestMiseTasksProbe_ParsesHashMiseEnvHeader(t *testing.T) { + // `#MISE env={ TOKEN = "..." }` should be decoded as inline TOML + // and surfaced as a structured finding with mise_task_field=env. + tmp := t.TempDir() + taskDir := filepath.Join(tmp, "myrepo", "mise-tasks") + require.NoError(t, os.MkdirAll(taskDir, 0o700)) + taskPath := filepath.Join(taskDir, "build") + body := `#!/usr/bin/env bash +#MISE description="Build the CLI" +#MISE env={ MISE_GITHUB_TOKEN = "` + fakeGitHubPAT + `" } +cargo build +` + require.NoError(t, os.WriteFile(taskPath, []byte(body), 0o700)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_task_file", taskPath) + + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + if findings[i].ID == "github-token-classic-pat" { + if _, ok := findings[i].Metadata["mise_env_var"]; ok { + structured = &findings[i] + break + } + } + } + require.NotNil(t, structured, "header parse should produce a structured finding") + assert.Equal(t, "MISE_GITHUB_TOKEN", structured.Metadata["mise_env_var"]) + assert.Equal(t, "env", structured.Metadata["mise_task_field"]) + assert.Equal(t, "build", structured.Metadata["mise_task_name"]) + assert.Equal(t, true, structured.Metadata["mise_task_file"]) +} + +func TestMiseTasksProbe_HandlesSlashSlashMiseHeader(t *testing.T) { + // JS/TS/Deno tasks use `//MISE` comments instead of `#MISE`. + tmp := t.TempDir() + taskDir := filepath.Join(tmp, "myrepo", "mise-tasks") + require.NoError(t, os.MkdirAll(taskDir, 0o700)) + taskPath := filepath.Join(taskDir, "greet") + body := `#!/usr/bin/env node +//MISE description="Greet the world" +//MISE env={ TOKEN = "` + fakeGitHubPAT + `" } +console.log("hello"); +` + require.NoError(t, os.WriteFile(taskPath, []byte(body), 0o700)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_task_file", taskPath) + + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + hasStructured := false + for _, f := range findings { + if f.ID == "github-token-classic-pat" { + if _, ok := f.Metadata["mise_env_var"]; ok { + hasStructured = true + assert.Equal(t, "TOKEN", f.Metadata["mise_env_var"]) + } + } + } + assert.True(t, hasStructured, "//MISE header should be parsed") +} + +func TestMiseTasksProbe_HandlesMiseBracketWorkaround(t *testing.T) { + // `# [MISE] env={...}` is the documented workaround for + // formatters that rewrite `#MISE` to `# MISE`. + tmp := t.TempDir() + taskDir := filepath.Join(tmp, "myrepo", "mise-tasks") + require.NoError(t, os.MkdirAll(taskDir, 0o700)) + taskPath := filepath.Join(taskDir, "test") + body := `#!/usr/bin/env bash +# [MISE] description="Test the thing" +# [MISE] env={ MISE_GITHUB_TOKEN = "` + fakeGitHubPAT + `" } +cargo test +` + require.NoError(t, os.WriteFile(taskPath, []byte(body), 0o700)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_task_file", taskPath) + + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + hasStructured := false + for _, f := range findings { + if f.ID == "github-token-classic-pat" { + if _, ok := f.Metadata["mise_env_var"]; ok { + hasStructured = true + } + } + } + assert.True(t, hasStructured, "# [MISE] header should be parsed") +} + +func TestMiseTasksProbe_MergesMultipleHeaders(t *testing.T) { + // Multiple `#MISE env=` headers must merge into one env map + // before scanning. Each header's inline TOML is decoded + // independently; last writer wins on key conflicts. + tmp := t.TempDir() + taskDir := filepath.Join(tmp, "myrepo", "mise-tasks") + require.NoError(t, os.MkdirAll(taskDir, 0o700)) + taskPath := filepath.Join(taskDir, "multi") + body := `#!/usr/bin/env bash +#MISE env={ TOKEN_A = "` + fakeGitHubPAT + `" } +#MISE env={ TOKEN_B = "harmless" } +echo "go" +` + require.NoError(t, os.WriteFile(taskPath, []byte(body), 0o700)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_task_file", taskPath) + + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + if findings[i].ID == "github-token-classic-pat" { + if _, ok := findings[i].Metadata["mise_env_var"]; ok { + structured = &findings[i] + } + } + } + require.NotNil(t, structured) + assert.Equal(t, "TOKEN_A", structured.Metadata["mise_env_var"]) +} + +func TestMiseTasksProbe_CleanScriptNoFindings(t *testing.T) { + tmp := t.TempDir() + taskDir := filepath.Join(tmp, "myrepo", "mise-tasks") + require.NoError(t, os.MkdirAll(taskDir, 0o700)) + taskPath := filepath.Join(taskDir, "build") + body := `#!/usr/bin/env bash +#MISE description="Build the CLI" +#MISE env={ NODE_ENV = "production" } +set -euo pipefail +cargo build +` + require.NoError(t, os.WriteFile(taskPath, []byte(body), 0o700)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_task_file", taskPath) + + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + assert.Empty(t, findings) +} + +func TestMiseTasksProbe_RespectsContextCancellation(t *testing.T) { + tmp := t.TempDir() + taskDir := filepath.Join(tmp, "mise-tasks") + require.NoError(t, os.MkdirAll(taskDir, 0o700)) + idx := fileindex.NewFileIndex() + for _, name := range []string{"a", "b", "c"} { + p := filepath.Join(taskDir, name) + require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\necho ok\n"), 0o700)) + idx.Add("mise_task_file", p) + } + + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := p.Execute(ctx) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestDeriveMiseTaskName(t *testing.T) { + t.Parallel() + tests := []struct { + name string + path string + want string + }{ + {"mise-tasks single", "/home/x/repo/mise-tasks/build", "build"}, + {"mise-tasks nested", "/home/x/repo/mise-tasks/test/units", "test:units"}, + {"mise-tasks _default collapses", "/home/x/repo/mise-tasks/test/_default", "test"}, + {"mise-tasks 3 levels", "/home/x/repo/mise-tasks/a/b/c", "a:b:c"}, + {".mise-tasks nested", "/home/x/repo/.mise-tasks/lint/go", "lint:go"}, + {"mise/tasks/", "/home/x/repo/mise/tasks/build", "build"}, + {".mise/tasks/", "/home/x/repo/.mise/tasks/build", "build"}, + {".config/mise/tasks/ (global)", "/home/x/.config/mise/tasks/global-build", "global-build"}, + {".config/mise/tasks/ nested", "/home/x/.config/mise/tasks/db/migrate", "db:migrate"}, + // Fallback: path lacks any task-root marker. Returns basename. + {"unknown path falls back to basename", "/tmp/loose-script.sh", "loose-script.sh"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, deriveMiseTaskName(tt.path)) + }) + } +} + +func TestParseMiseTaskHeaderEnv(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want map[string]any + }{ + { + "no header", + "#!/usr/bin/env bash\necho hi\n", + nil, + }, + { + "single hash MISE", + "#!/usr/bin/env bash\n#MISE env={ FOO = \"bar\" }\n", + map[string]any{"FOO": "bar"}, + }, + { + "slash-slash MISE", + "#!/usr/bin/env node\n//MISE env={ FOO = \"bar\" }\n", + map[string]any{"FOO": "bar"}, + }, + { + "bracket workaround", + "#!/usr/bin/env bash\n# [MISE] env={ FOO = \"bar\" }\n", + map[string]any{"FOO": "bar"}, + }, + { + "multiple headers merge", + "#MISE env={ A = \"1\" }\n#MISE env={ B = \"2\" }\n", + map[string]any{"A": "1", "B": "2"}, + }, + { + "multiple headers last writer wins on conflict", + "#MISE env={ KEY = \"first\" }\n#MISE env={ KEY = \"second\" }\n", + map[string]any{"KEY": "second"}, + }, + { + "unparseable header is dropped, others still parse", + "#MISE env=not-a-toml-value\n#MISE env={ OK = \"v\" }\n", + map[string]any{"OK": "v"}, + }, + { + "#MISE without env directive is ignored", + "#MISE description=\"hi\"\n", + nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := parseMiseTaskHeaderEnv(context.Background(), []byte(tt.content), "") + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMiseTasksProbe_GlobalTaskClassifiedAsGlobal(t *testing.T) { + // A file task under ~/.config/mise/tasks/ should be tagged + // mise_file_kind=global because the path is under the resolved + // home dir's .config/mise/. + home := t.TempDir() + taskDir := filepath.Join(home, ".config", "mise", "tasks") + require.NoError(t, os.MkdirAll(taskDir, 0o700)) + taskPath := filepath.Join(taskDir, "global-task") + body := `#!/usr/bin/env bash +curl -H "Authorization: Bearer ` + fakeGitHubPAT + `" https://api.example.com +` + require.NoError(t, os.WriteFile(taskPath, []byte(body), 0o700)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_task_file", taskPath) + + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.userHome = home // inject synthetic home for hermetic test + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var f *models.Finding + for i := range findings { + if findings[i].ID == "github-token-classic-pat" { + f = &findings[i] + break + } + } + require.NotNil(t, f) + assert.Equal(t, "global", f.Metadata["mise_file_kind"]) + assert.Equal(t, "global-task", f.Metadata["mise_task_name"]) +} + +func TestMiseTasksProbe_LineScanFindingsNotDuplicatedAgainstHeader(t *testing.T) { + // When the same token appears in both a `#MISE env=` header + // AND the body of a script, probe-internal dedup must collapse + // to a single finding (the header-derived one with full + // metadata). + tmp := t.TempDir() + taskDir := filepath.Join(tmp, "repo", "mise-tasks") + require.NoError(t, os.MkdirAll(taskDir, 0o700)) + taskPath := filepath.Join(taskDir, "dupe") + body := `#!/usr/bin/env bash +#MISE env={ MISE_GITHUB_TOKEN = "` + fakeGitHubPAT + `" } +echo "$MISE_GITHUB_TOKEN" +curl -H "Authorization: Bearer ` + fakeGitHubPAT + `" https://example.com +` + require.NoError(t, os.WriteFile(taskPath, []byte(body), 0o700)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_task_file", taskPath) + + p := NewMiseTasksProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + pat := 0 + for _, f := range findings { + if f.ID == "github-token-classic-pat" { + pat++ + } + } + assert.Equal(t, 1, pat, "structured header finding should suppress line-scan duplicates") +} + +func TestMiseTasksProbe_OversizedFileSkipped(t *testing.T) { + tmp := t.TempDir() + taskDir := filepath.Join(tmp, "mise-tasks") + require.NoError(t, os.MkdirAll(taskDir, 0o700)) + taskPath := filepath.Join(taskDir, "big") + body := `#!/usr/bin/env bash +curl -H "Authorization: Bearer ` + fakeGitHubPAT + `" https://api.example.com +` + require.NoError(t, os.WriteFile(taskPath, []byte(body), 0o700)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_task_file", taskPath) + + p := NewMiseTasksProbe( + models.ProbeSettings{Enabled: true, Flags: map[string]interface{}{"max_file_size": 1}}, + newMiseTestRegistry(t), + ) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + assert.Empty(t, findings, "oversized task file should produce zero findings") +} diff --git a/pkg/probe/mise_test.go b/pkg/probe/mise_test.go new file mode 100644 index 0000000..401af02 --- /dev/null +++ b/pkg/probe/mise_test.go @@ -0,0 +1,1040 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package probe + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/boostsecurityio/bagel/pkg/detector" + "github.com/boostsecurityio/bagel/pkg/fileindex" + "github.com/boostsecurityio/bagel/pkg/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeGitHubPAT is a syntactically valid GitHub Classic PAT (ghp_ + 36 +// alphanumerics) used purely as a detector fixture. Built at runtime +// from string concatenation so static scanners (gitleaks etc.) don't +// flag it on this test file. +var fakeGitHubPAT = "ghp_" + strings.Repeat("A", 36) + +// newMiseTestRegistry returns a detector registry populated with the +// detectors the mise probe is expected to drive. Keep this list aligned +// with cmd/bagel/scan.go's registry so test findings match production. +func newMiseTestRegistry(t *testing.T) *detector.Registry { + t.Helper() + r := detector.NewRegistry() + r.Register(detector.NewGitHubPATDetector()) + r.Register(detector.NewAIServiceDetector()) + r.Register(detector.NewCloudCredentialsDetector()) + return r +} + +func TestMiseProbe_Name(t *testing.T) { + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, detector.NewRegistry()) + assert.Equal(t, "mise", p.Name()) +} + +func TestMiseProbe_IsEnabled(t *testing.T) { + t.Parallel() + tests := []struct { + name string + enabled bool + }{ + {"enabled", true}, + {"disabled", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := NewMiseProbe(models.ProbeSettings{Enabled: tt.enabled}, detector.NewRegistry()) + assert.Equal(t, tt.enabled, p.IsEnabled()) + }) + } +} + +func TestMiseProbe_ExecuteWithoutFileIndex(t *testing.T) { + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + assert.Empty(t, findings) +} + +func TestMiseProbe_ExecuteEmptyIndex(t *testing.T) { + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(fileindex.NewFileIndex()) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + assert.Empty(t, findings) +} + +func TestMiseProbe_MissingFileFailsSoft(t *testing.T) { + idx := fileindex.NewFileIndex() + idx.Add("mise_config", filepath.Join(t.TempDir(), "does-not-exist", "mise.toml")) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + assert.Empty(t, findings) +} + +func TestMiseProbe_InvalidTOMLLineScansAnyway(t *testing.T) { + // Garbled TOML should not cause the probe to error; the line-scan + // safety net still runs over the raw bytes and surfaces secrets. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := "this is not = valid [toml\nMISE_GITHUB_TOKEN = " + fakeGitHubPAT + "\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, findings, "line scan should find token even with broken TOML") + + hasGitHub := false + for _, f := range findings { + if f.ID == "github-token-classic-pat" { + hasGitHub = true + } + } + assert.True(t, hasGitHub, "expected github-token-classic-pat finding from line scan") +} + +func TestExtractMiseEnvValue(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw any + wantValue string + wantRedact bool + wantOK bool + }{ + {"bare string", "hello", "hello", false, true}, + {"table value", map[string]any{"value": "hello"}, "hello", false, true}, + { + "table value with redact", + map[string]any{"value": "secret", "redact": true}, + "secret", true, true, + }, + { + "table file reference", + map[string]any{"file": "/etc/secret"}, + "/etc/secret", false, true, + }, + { + "table path reference (synonym for file)", + map[string]any{"path": "/etc/secret", "redact": true}, + "/etc/secret", true, true, + }, + { + "array of strings joined", + []any{"a", "b", "c"}, + "a\nb\nc", false, true, + }, + { + "array with non-string mixed in", + []any{"a", 42, "c"}, + "a\nc", false, true, + }, + {"empty array", []any{}, "", false, false}, + {"array of non-strings only", []any{1, 2, 3}, "", false, false}, + {"table with non-string value", map[string]any{"value": 42}, "", false, false}, + {"bool", true, "", false, false}, + {"int", 42, "", false, false}, + {"float", 1.5, "", false, false}, + {"nil", nil, "", false, false}, + {"empty map", map[string]any{}, "", false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + v, redact, ok := extractMiseEnvValue(tt.raw) + assert.Equal(t, tt.wantOK, ok) + if ok { + assert.Equal(t, tt.wantValue, v) + assert.Equal(t, tt.wantRedact, redact) + } + }) + } +} + +func TestMiseProbe_DetectsPlaintextSecretInBareEnv(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[env] +MISE_GITHUB_TOKEN = "` + fakeGitHubPAT + `" +NORMAL_VAR = "harmless" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, findings) + + // Probe-internal dedup means we get exactly one finding per token + // per file: the structured-walk finding with full mise metadata. + // The line-scan finding for the same fingerprint is suppressed. + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID != "github-token-classic-pat" { + continue + } + require.Contains(t, f.Metadata, "mise_env_var", + "only structured walk should produce a github-token finding (line-scan dup should be suppressed)") + structured = &findings[i] + } + require.NotNil(t, structured) + assert.Equal(t, "MISE_GITHUB_TOKEN", structured.Metadata["mise_env_var"]) + assert.NotContains(t, structured.Metadata, "mise_redact_flag", + "redact omitted when false") + assert.Equal(t, "project", structured.Metadata["mise_file_kind"]) + assert.NotContains(t, structured.Metadata, "mise_file_local", "false bool omitted") + assert.NotContains(t, structured.Metadata, "mise_file_legacy", "false bool omitted") + assert.NotContains(t, structured.Metadata, "mise_file_env", "no env scope on mise.toml") + assert.Equal(t, "mise.toml", structured.Metadata["mise_file"]) + assert.Equal(t, "file:"+path, structured.Path) + assert.Equal(t, "mise", structured.Probe) + assert.Contains(t, structured.Message, "env var MISE_GITHUB_TOKEN") + assert.Contains(t, structured.Message, "file:"+path, + "message should use file: URI prefix, not raw absolute path") +} + +func TestMiseProbe_TableFormWithRedactFlag(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, ".mise.toml") + content := `[env] +MISE_GITHUB_TOKEN = { value = "` + fakeGitHubPAT + `", redact = true } +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID == "github-token-classic-pat" { + if _, ok := f.Metadata["mise_env_var"]; ok { + structured = &findings[i] + break + } + } + } + require.NotNil(t, structured, "structured walk should annotate the redacted entry") + assert.Equal(t, true, structured.Metadata["mise_redact_flag"]) + assert.Contains(t, structured.Description, "redact = true", + "description should call out the redact-illusion") +} + +func TestMiseProbe_FileReferenceWithRedact(t *testing.T) { + // `{ file = "...", redact = true }` and `{ path = "...", redact = true }` + // forms reference dotenv files. The probe extracts the path and runs + // the detector registry against it - useful when the path itself + // embeds credentials (e.g. http://user:pw@host/secrets.env). + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[env] +_VALUE_REF = { file = "https://abuser:` + fakeGitHubPAT + `@example.com/.env", redact = true } +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID == "github-token-classic-pat" { + if _, ok := f.Metadata["mise_env_var"]; ok { + structured = &findings[i] + break + } + } + } + require.NotNil(t, structured) + assert.Equal(t, "_VALUE_REF", structured.Metadata["mise_env_var"]) + assert.Equal(t, true, structured.Metadata["mise_redact_flag"]) +} + +func TestMiseProbe_GlobalConfigKind(t *testing.T) { + // Simulate the user's resolved home dir + .config/mise/config.toml. + // classifyMiseFile should tag mise_file_kind="global" because the + // path starts with `home + /.config/mise/`. + home := t.TempDir() + globalDir := filepath.Join(home, ".config", "mise") + require.NoError(t, os.MkdirAll(globalDir, 0o700)) + path := filepath.Join(globalDir, "config.toml") + content := `[env] +MISE_GITHUB_TOKEN = "` + fakeGitHubPAT + `" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + // Inject the synthetic home so classifyMiseFile anchors correctly + // without depending on the test machine's real $HOME. + p.userHome = home + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID == "github-token-classic-pat" { + if _, ok := f.Metadata["mise_env_var"]; ok { + structured = &findings[i] + break + } + } + } + require.NotNil(t, structured) + assert.Equal(t, "global", structured.Metadata["mise_file_kind"]) +} + +func TestMiseProbe_DotfilesProjectClassifiedAsProject(t *testing.T) { + // A dotfiles repo checked out at ~/work/myrepo/.config/mise/config.toml + // must NOT be classified as "global" - the global tag affects how + // downstream consumers triage findings. + home := t.TempDir() + dotfilesPath := filepath.Join(home, "work", "myrepo", ".config", "mise", "config.toml") + require.NoError(t, os.MkdirAll(filepath.Dir(dotfilesPath), 0o700)) + content := `[env] +TOKEN = "` + fakeGitHubPAT + `" +` + require.NoError(t, os.WriteFile(dotfilesPath, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", dotfilesPath) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.userHome = home + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID == "github-token-classic-pat" { + if _, ok := f.Metadata["mise_env_var"]; ok { + structured = &findings[i] + break + } + } + } + require.NotNil(t, structured) + assert.Equal(t, "project", structured.Metadata["mise_file_kind"], + "a .config/mise/ inside a project tree must NOT be tagged global") +} + +func TestMiseProbe_UnderscoreDirectivesIgnored(t *testing.T) { + // `_.file`, `_.path`, `_.python.venv` are mise directives, not + // env-var assignments. They must NOT produce structured findings + // (line scan can still scan them - but here the values are benign). + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[env] +_.file = ".env" +_.path = "./bin" +NORMAL = "ok" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + for _, f := range findings { + if v, ok := f.Metadata["mise_env_var"]; ok { + assert.NotEqual(t, "_", v, "_ directives should not produce env-var findings") + } + } +} + +func TestMiseProbe_NoEnvTable(t *testing.T) { + // A mise config without [env] (e.g., one that only configures + // [tools]) should produce no structured findings. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[tools] +go = "latest" +node = "20" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + assert.Empty(t, findings) +} + +func TestMiseProbe_CleanConfigProducesNoFindings(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[env] +NODE_ENV = "development" +GOOS = "darwin" + +[tools] +node = "20" + +[tasks.build] +run = "go build ./..." +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + assert.Empty(t, findings) +} + +func TestMiseProbe_OversizedFileSkipped(t *testing.T) { + // File contains a real-shaped token; size cap=1 means the read is + // rejected before any scan happens. Neither pass should fire. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[env] +TOKEN = "` + fakeGitHubPAT + `" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe( + models.ProbeSettings{Enabled: true, Flags: map[string]interface{}{"max_file_size": 1}}, + newMiseTestRegistry(t), + ) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + assert.Empty(t, findings, "oversized file should produce zero findings even when it contains a token") +} + +func TestMiseProbe_ArrayOfTablesEnv(t *testing.T) { + // [[env]] is the mise array-of-tables form for grouping multiple + // `env._.source` directives. The structured walk must descend into + // each table element, not bail on the type switch. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[[env]] +TOKEN_A = "` + fakeGitHubPAT + `" + +[[env]] +NORMAL = "ok" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID == "github-token-classic-pat" { + if _, ok := f.Metadata["mise_env_var"]; ok { + structured = &findings[i] + break + } + } + } + require.NotNil(t, structured, "structured walk should descend into [[env]] tables") + assert.Equal(t, "TOKEN_A", structured.Metadata["mise_env_var"]) +} + +func TestMiseProbe_ArrayValueScanned(t *testing.T) { + // Array values like ["a", "b", token] must be scanned by the + // structured pass - not just the line scan. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[env] +TOKENS = ["harmless1", "harmless2", "` + fakeGitHubPAT + `"] +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID == "github-token-classic-pat" { + if _, ok := f.Metadata["mise_env_var"]; ok { + structured = &findings[i] + break + } + } + } + require.NotNil(t, structured, "array value should be scanned by structured pass") + assert.Equal(t, "TOKENS", structured.Metadata["mise_env_var"]) +} + +func TestMiseProbe_LineScanFindingsNotDuplicatedAgainstStructured(t *testing.T) { + // Probe-internal dedup must prevent the same token appearing twice + // when both the structured walk and the line scan see it. The + // expected outcome: exactly one finding for the token, tagged with + // mise_env_var. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[env] +MISE_GITHUB_TOKEN = "` + fakeGitHubPAT + `" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + pat := 0 + for _, f := range findings { + if f.ID == "github-token-classic-pat" { + pat++ + } + } + assert.Equal(t, 1, pat, "probe should emit exactly one github-token finding per (token, file)") +} + +func TestMiseProbe_LineScanStillSurfacesNonStructured(t *testing.T) { + // A token that appears outside [env] (e.g., inside a [tasks.*].run + // string) is not visible to the structured walk. The line scan + // must still surface it - internal dedup only suppresses matches + // that share a fingerprint with the structured set. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[tasks.deploy] +run = "curl -H 'Authorization: Bearer ` + fakeGitHubPAT + `' https://api.example.com" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + found := false + for _, f := range findings { + if f.ID == "github-token-classic-pat" { + found = true + // Token in [tasks] doesn't have mise_env_var (only line scan saw it). + _, hasMiseEnvVar := f.Metadata["mise_env_var"] + assert.False(t, hasMiseEnvVar, "tasks.run is invisible to structured walk; line scan owns the finding") + } + } + assert.True(t, found, "line scan should surface a token in [tasks.deploy].run") +} + +func TestMiseProbe_RespectsContextCancellation(t *testing.T) { + // Build a file index with several files. Cancel before Execute + // runs; the probe must return promptly with the cancellation error. + tmp := t.TempDir() + idx := fileindex.NewFileIndex() + for i := range 5 { + path := filepath.Join(tmp, "mise.toml") + if i > 0 { + path = filepath.Join(tmp, "mise."+string(rune('a'+i))+".toml") + } + require.NoError(t, os.WriteFile(path, []byte("[env]\nFOO=\"bar\"\n"), 0o600)) + idx.Add("mise_config", path) + } + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := p.Execute(ctx) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestClassifyMiseFile(t *testing.T) { + // Drive classifyMiseFile through every documented file shape so + // the broadening surface has table coverage. Synthetic paths use + // forward slashes; classifyMiseFile normalises internally. + t.Parallel() + const home = "/home/james" + const appData = "C:/Users/James/AppData/Roaming" + + tests := []struct { + name string + path string + wantKind string + wantLocal bool + wantEnv string + wantRtx bool + wantFragment bool + }{ + // ---- Project basenames ----------------------------------------- + {"project mise.toml", home + "/repo/mise.toml", "project", false, "", false, false}, + {"project .mise.toml", home + "/repo/.mise.toml", "project", false, "", false, false}, + {"project mise.local.toml", home + "/repo/mise.local.toml", "project", true, "", false, false}, + {"project .mise.local.toml", home + "/repo/.mise.local.toml", "project", true, "", false, false}, + {"project mise.production.toml", home + "/repo/mise.production.toml", "project", false, "production", false, false}, + {"project .mise.dev.toml", home + "/repo/.mise.dev.toml", "project", false, "dev", false, false}, + {"project mise.staging.local.toml", home + "/repo/mise.staging.local.toml", "project", true, "staging", false, false}, + {"project .mise.staging.local.toml", home + "/repo/.mise.staging.local.toml", "project", true, "staging", false, false}, + // `mise.config.toml` is a user-named non-env file. The denylist + // must clear EnvName so consumers don't see a meaningless + // "config" environment tag. + {"project mise.config.toml (denylisted)", home + "/repo/mise.config.toml", "project", false, "", false, false}, + {"project mise.backup.toml (denylisted)", home + "/repo/mise.backup.toml", "project", false, "", false, false}, + // `mise.local.local.toml` -> strip .local once -> stem = mise.local + // -> EnvName = "local". This preserves literal intent without + // silently normalising user typos. + {"project mise.local.local.toml", home + "/repo/mise.local.local.toml", "project", true, "local", false, false}, + // ---- Idiomatic dir forms (project nested copies) ---------------- + {"project mise/config.toml", home + "/repo/mise/config.toml", "project", false, "", false, false}, + {"project mise/config.local.toml", home + "/repo/mise/config.local.toml", "project", true, "", false, false}, + {"project mise/config.dev.toml", home + "/repo/mise/config.dev.toml", "project", false, "dev", false, false}, + {"project .mise/config.toml", home + "/repo/.mise/config.toml", "project", false, "", false, false}, + {"project .mise/config.prod.local.toml", home + "/repo/.mise/config.prod.local.toml", "project", true, "prod", false, false}, + // `.config/mise.toml` under a project dir -> project (not global, + // because the global anchor is `/.config/mise/`). + {"project .config/mise.toml", home + "/repo/.config/mise.toml", "project", false, "", false, false}, + {"project .config/mise.dev.toml", home + "/repo/.config/mise.dev.toml", "project", false, "dev", false, false}, + // Dotfiles repo with .config/mise/ buried inside a project tree - + // regression for the substring-match false-positive. + {"dotfiles project .config/mise/config.toml", home + "/work/myrepo/.config/mise/config.toml", "project", false, "", false, false}, + // ---- Global (home-anchored) ----------------------------------- + {"global config.toml", home + "/.config/mise/config.toml", "global", false, "", false, false}, + {"global config.local.toml", home + "/.config/mise/config.local.toml", "global", true, "", false, false}, + {"global config.dev.toml", home + "/.config/mise/config.dev.toml", "global", false, "dev", false, false}, + {"global config.dev.local.toml", home + "/.config/mise/config.dev.local.toml", "global", true, "dev", false, false}, + {"global mise.toml under .config/mise", home + "/.config/mise/mise.toml", "global", false, "", false, false}, + // conf.d fragments: the filename prefix is a sort key, not an env scope. + {"global conf.d fragment", home + "/.config/mise/conf.d/01-go.toml", "global", false, "", false, true}, + // ---- Global on Windows (anchored to APPDATA) ------------------ + {"windows global config.toml", appData + "/mise/config.toml", "global", false, "", false, false}, + {"windows global config.local.toml", appData + "/mise/config.local.toml", "global", true, "", false, false}, + // ---- Legacy rtx ------------------------------------------------ + {"legacy .rtx.toml", home + "/repo/.rtx.toml", "project", false, "", true, false}, + {"legacy .rtx.local.toml", home + "/repo/.rtx.local.toml", "project", true, "", true, false}, + {"legacy .rtx.prod.toml", home + "/repo/.rtx.prod.toml", "project", false, "prod", true, false}, + // ---- System --------------------------------------------------- + {"system config.toml", "/etc/mise/config.toml", "system", false, "", false, false}, + {"system conf.d fragment", "/etc/mise/conf.d/00-base.toml", "system", false, "", false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := classifyMiseFile(tt.path, home, appData) + assert.Equal(t, tt.wantKind, got.Kind, "kind") + assert.Equal(t, tt.wantLocal, got.IsLocal, "local") + assert.Equal(t, tt.wantEnv, got.EnvName, "env") + assert.Equal(t, tt.wantRtx, got.IsLegacy, "legacy") + assert.Equal(t, tt.wantFragment, got.IsFragment, "fragment") + }) + } +} + +func TestClassifyMiseFile_NoHomeFallback(t *testing.T) { + // When UserHomeDir failed, classifyMiseFile falls back to a + // substring heuristic. Document the behaviour so it can't regress + // silently - and so we know it's a permissive, diagnostic-only + // fallback (any path containing /.config/mise/ is "global"). + got := classifyMiseFile("/home/anon/.config/mise/config.toml", "", "") + assert.Equal(t, "global", got.Kind, "substring fallback when home is unknown") +} + +func TestMiseProbe_BroadenedFileShapesAnnotateRole(t *testing.T) { + // Exercise the full per-file annotation path for every + // significant role. Each fixture is a real file in t.TempDir() + // with the same content; we only vary the basename so + // classifyMiseFile sees the role on the path. + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mise", "conf.d"), 0o700)) + require.NoError(t, os.MkdirAll(filepath.Join(home, "repo"), 0o700)) + + content := []byte(`[env] +MISE_GITHUB_TOKEN = "` + fakeGitHubPAT + `" +`) + + type expect struct { + path string + kind string + local bool + envName string + legacy bool + fragment bool + } + cases := []expect{ + {filepath.Join(home, "repo", "mise.toml"), "project", false, "", false, false}, + {filepath.Join(home, "repo", ".mise.toml"), "project", false, "", false, false}, + {filepath.Join(home, "repo", "mise.local.toml"), "project", true, "", false, false}, + {filepath.Join(home, "repo", "mise.production.toml"), "project", false, "production", false, false}, + {filepath.Join(home, "repo", "mise.staging.local.toml"), "project", true, "staging", false, false}, + {filepath.Join(home, "repo", ".rtx.toml"), "project", false, "", true, false}, + {filepath.Join(home, ".config", "mise", "config.toml"), "global", false, "", false, false}, + {filepath.Join(home, ".config", "mise", "config.dev.toml"), "global", false, "dev", false, false}, + {filepath.Join(home, ".config", "mise", "config.local.toml"), "global", true, "", false, false}, + // conf.d fragments: filename prefix is a sort key, NOT an env scope. + {filepath.Join(home, ".config", "mise", "conf.d", "01-tools.toml"), "global", false, "", false, true}, + } + + idx := fileindex.NewFileIndex() + for _, c := range cases { + require.NoError(t, os.WriteFile(c.path, content, 0o600)) + idx.Add("mise_config", c.path) + } + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.userHome = home + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, findings) + + byPath := make(map[string]models.Finding) + for _, f := range findings { + if _, isStruct := f.Metadata["mise_env_var"]; !isStruct { + continue + } + byPath[strings.TrimPrefix(f.Path, "file:")] = f + } + + for _, c := range cases { + t.Run(filepath.Base(c.path), func(t *testing.T) { + f, ok := byPath[c.path] + require.True(t, ok, "expected structured finding for %s", c.path) + assert.Equal(t, c.kind, f.Metadata["mise_file_kind"], "mise_file_kind") + assertOptionalBool(t, f.Metadata, "mise_file_local", c.local) + assertOptionalBool(t, f.Metadata, "mise_file_legacy", c.legacy) + assertOptionalBool(t, f.Metadata, "mise_file_fragment", c.fragment) + if c.envName != "" { + assert.Equal(t, c.envName, f.Metadata["mise_file_env"]) + } else { + assert.NotContains(t, f.Metadata, "mise_file_env", "no env scope expected") + } + }) + } +} + +// assertOptionalBool checks that an optional bool-valued metadata key +// is either absent (when want=false) or present and true (when +// want=true). The mise probe omits false-valued bool keys to keep +// the JSON output compact. +func assertOptionalBool(t *testing.T, md map[string]interface{}, key string, want bool) { + t.Helper() + if want { + assert.Equal(t, true, md[key], "%s should be present and true", key) + } else { + assert.NotContains(t, md, key, "%s should be omitted when false", key) + } +} + +func TestMiseProbe_TaskEnvBlock(t *testing.T) { + // Secrets in `[tasks.].env` must produce structured + // findings annotated with mise_task_name + mise_task_field=env. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[tasks.lint] +description = "Lint the code" +env = { MISE_GITHUB_TOKEN = "` + fakeGitHubPAT + `" } +run = "cargo clippy" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID != "github-token-classic-pat" { + continue + } + if _, ok := f.Metadata["mise_task_name"]; ok { + structured = &findings[i] + } + } + require.NotNil(t, structured, "structured walk should annotate task env") + assert.Equal(t, "lint", structured.Metadata["mise_task_name"]) + assert.Equal(t, "env", structured.Metadata["mise_task_field"]) + assert.Equal(t, "MISE_GITHUB_TOKEN", structured.Metadata["mise_env_var"]) +} + +func TestMiseProbe_TaskEnvSubtable(t *testing.T) { + // `[tasks..env]` sub-table syntax (TOML-equivalent to the + // inline `env = {...}` form) must produce the same finding. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[tasks.lint] +description = "Lint the code" +run = "cargo clippy" + +[tasks.lint.env] +MISE_GITHUB_TOKEN = "` + fakeGitHubPAT + `" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID == "github-token-classic-pat" { + if _, ok := f.Metadata["mise_task_name"]; ok { + structured = &findings[i] + break + } + } + } + require.NotNil(t, structured) + assert.Equal(t, "lint", structured.Metadata["mise_task_name"]) + assert.Equal(t, "env", structured.Metadata["mise_task_field"]) +} + +func TestMiseProbe_TaskRunString(t *testing.T) { + // A `run` string containing a curl with embedded token must + // produce a finding with mise_task_field=run. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[tasks.deploy] +run = "curl -H 'Authorization: Bearer ` + fakeGitHubPAT + `' https://api.example.com" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID == "github-token-classic-pat" { + if v, ok := f.Metadata["mise_task_field"]; ok && v == "run" { + structured = &findings[i] + break + } + } + } + require.NotNil(t, structured, "task run string should produce a structured finding tagged field=run") + assert.Equal(t, "deploy", structured.Metadata["mise_task_name"]) + assert.NotContains(t, structured.Metadata, "mise_env_var", "run findings don't have env var names") +} + +func TestMiseProbe_TaskRunArray(t *testing.T) { + // `run` can be an array of strings; each element should be + // scanned. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[tasks.deploy] +run = [ + "echo starting", + "curl -H 'Authorization: Bearer ` + fakeGitHubPAT + `' https://api.example.com", + "echo done", +] +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + hasRunFinding := false + for _, f := range findings { + if f.ID == "github-token-classic-pat" { + if v, ok := f.Metadata["mise_task_field"]; ok && v == "run" { + hasRunFinding = true + assert.Equal(t, "deploy", f.Metadata["mise_task_name"]) + } + } + } + assert.True(t, hasRunFinding, "run array element should produce a structured finding") +} + +func TestMiseProbe_TrivialTaskString(t *testing.T) { + // Trivial task form: `tasks. = "command"`. The string is + // treated as the task's run value. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[tasks] +deploy = "curl -H 'Authorization: Bearer ` + fakeGitHubPAT + `' https://api.example.com" +build = "cargo build" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + var structured *models.Finding + for i := range findings { + f := findings[i] + if f.ID == "github-token-classic-pat" { + if v, ok := f.Metadata["mise_task_name"]; ok && v == "deploy" { + structured = &findings[i] + break + } + } + } + require.NotNil(t, structured) + assert.Equal(t, "run", structured.Metadata["mise_task_field"]) +} + +// FuzzClassifyMiseFile guards classifyMiseFile against panics and +// invariant violations on adversarial input. The function is pure +// and string-driven, so the fuzz is cheap. +func FuzzClassifyMiseFile(f *testing.F) { + for _, p := range []string{ + "/home/user/mise.toml", + "/home/user/.config/mise/config.toml", + "/home/user/.config/mise/conf.d/01-tools.toml", + "/home/user/repo/mise.production.local.toml", + "/home/user/repo/.rtx.toml", + "C:/Users/u/AppData/Roaming/mise/config.toml", + "/etc/mise/config.toml", + "", + "...", + "/", + strings.Repeat("/", 100) + "mise.toml", + `mise.\.toml`, // regression: backslash in middle segment + "mise. .toml", // regression: whitespace in middle segment + "mise.\x00.toml", // regression: NUL byte in middle segment + } { + f.Add(p, "/home/user", "") + } + f.Fuzz(func(t *testing.T, path, home, appData string) { + got := classifyMiseFile(path, home, appData) + + switch got.Kind { + case miseFileKindGlobal, miseFileKindProject, miseFileKindSystem: + default: + t.Fatalf("invalid Kind %q for path %q", got.Kind, path) + } + + // EnvName must never match the denylist - that's exactly + // the case classifyMiseFile is designed to filter out. + if _, deny := envNameDenylist[got.EnvName]; deny { + t.Fatalf("EnvName %q matched denylist for path %q", got.EnvName, path) + } + + // conf.d fragments never carry an env scope; the filename + // prefix is a sort key. + if got.IsFragment && got.EnvName != "" { + t.Fatalf("fragment %q must not have EnvName %q", path, got.EnvName) + } + + // EnvName should never contain a path separator - it's + // extracted from a single basename segment. + if strings.ContainsAny(got.EnvName, "/\\") { + t.Fatalf("EnvName %q contains a path separator (path=%q)", got.EnvName, path) + } + }) +} + +func TestMiseProbe_NestedSubTablesAreNotEnvVars(t *testing.T) { + // `[env.foo]` is NOT documented as profile scoping in mise (the + // documented profile mechanism is per-file `mise..toml`). + // The probe's structured walk treats `env.foo` as a plain nested + // table - extractMiseEnvValue rejects it because there's no + // `value`/`file`/`path` string key. The line-scan safety net + // still catches secrets via the raw bytes. + tmp := t.TempDir() + path := filepath.Join(tmp, "mise.toml") + content := `[env.production] +TOKEN = "` + fakeGitHubPAT + `" +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + idx := fileindex.NewFileIndex() + idx.Add("mise_config", path) + + p := NewMiseProbe(models.ProbeSettings{Enabled: true}, newMiseTestRegistry(t)) + p.SetFileIndex(idx) + findings, err := p.Execute(context.Background()) + require.NoError(t, err) + + hasFinding := false + for _, f := range findings { + if f.ID == "github-token-classic-pat" { + hasFinding = true + _, hasMiseMeta := f.Metadata["mise_env_var"] + assert.False(t, hasMiseMeta, "nested [env.production] table not annotated by structured walk") + } + } + assert.True(t, hasFinding, "line scan should still find the token") +}