diff --git a/cmd/bumblebee/main.go b/cmd/bumblebee/main.go index 476fe1a..3975be3 100644 --- a/cmd/bumblebee/main.go +++ b/cmd/bumblebee/main.go @@ -124,6 +124,7 @@ type scanOpts struct { httpGzip bool deviceIDEnv string + quiet bool } func registerScanFlags(fs *flag.FlagSet, o *scanOpts) { @@ -161,6 +162,8 @@ func registerScanFlags(fs *flag.FlagSet, o *scanOpts) { fs.StringVar(&o.deviceIDEnv, "device-id-env", "", "env var holding a stable opaque endpoint/device id (e.g. set by MDM, EDR, or a provisioning script); populates endpoint.device_id when set") + fs.BoolVar(&o.quiet, "quiet", false, "suppress info and warning level diagnostic output to stderr") + fs.BoolVar(&o.quiet, "q", false, "suppress info and warning level diagnostic output to stderr (shorthand)") } // runScan executes the scan subcommand. Returns the process exit code. @@ -221,6 +224,7 @@ func runScan(args []string) int { runID := newRunID() emitter := output.New(recordsW, os.Stderr, runID) + emitter.Quiet = o.quiet for _, n := range diagNotes { emitter.Diag("info", "", n) diff --git a/internal/ecosystem/cargo/cargo.go b/internal/ecosystem/cargo/cargo.go new file mode 100644 index 0000000..9ad00d5 --- /dev/null +++ b/internal/ecosystem/cargo/cargo.go @@ -0,0 +1,109 @@ +package cargo + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/perplexityai/bumblebee/internal/model" +) + +const Ecosystem = model.EcosystemCargo + +type Scanner struct { + MaxFileSize int64 + Emit func(model.Record) + Diag func(level, path, msg string) +} + +func IsCargoLock(base string) bool { return base == "Cargo.lock" } + +func (s *Scanner) ScanCargoLock(path string, base model.Record) error { + data, err := s.readBounded(path) + if err != nil { + return err + } + projectPath := filepath.Dir(path) + + sc := bufio.NewScanner(bytes.NewReader(data)) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + + var inPackage bool + var name, version string + + emitCurrent := func() { + if inPackage && name != "" && version != "" { + r := base + r.Ecosystem = Ecosystem + r.PackageName = name + r.NormalizedName = strings.ToLower(name) + r.Version = version + r.ProjectPath = projectPath + r.PackageManager = "cargo" + r.SourceType = "cargo-lock" + r.SourceFile = path + r.Confidence = "high" + s.Emit(r) + } + inPackage = false + name = "" + version = "" + } + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "[[package]]" { + emitCurrent() + inPackage = true + continue + } + if !inPackage { + continue + } + if strings.HasPrefix(line, "name =") { + name = unquote(strings.TrimSpace(strings.TrimPrefix(line, "name ="))) + } else if strings.HasPrefix(line, "version =") { + version = unquote(strings.TrimSpace(strings.TrimPrefix(line, "version ="))) + } + } + emitCurrent() + + return nil +} + +func unquote(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' { + return s[1 : len(s)-1] + } + return s +} + +func (s *Scanner) readBounded(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, errors.New("not a regular file") + } + if s.MaxFileSize > 0 && info.Size() > s.MaxFileSize { + if s.Diag != nil { + s.Diag("warn", path, fmt.Sprintf("skipping: size %d exceeds max %d", info.Size(), s.MaxFileSize)) + } + return nil, fmt.Errorf("file %s exceeds max size %d", path, s.MaxFileSize) + } + return io.ReadAll(f) +} diff --git a/internal/ecosystem/mcp/mcp.go b/internal/ecosystem/mcp/mcp.go index c55ae10..19812b4 100644 --- a/internal/ecosystem/mcp/mcp.go +++ b/internal/ecosystem/mcp/mcp.go @@ -262,6 +262,18 @@ func (s *Scanner) emitServers(servers map[string]serverEntry, base model.Record, sort.Strings(ids) for _, id := range ids { srv := servers[id] + + if s.Diag != nil { + for envKey, envVal := range srv.Env { + if strVal, ok := envVal.(string); ok && strVal != "" && !looksUnresolvedShellVar(strVal) { + uk := strings.ToUpper(envKey) + if strings.Contains(uk, "KEY") || strings.Contains(uk, "TOKEN") || strings.Contains(uk, "SECRET") || strings.Contains(uk, "PASSWORD") || strings.Contains(uk, "CRED") { + s.Diag("warn", sourcePath, fmt.Sprintf("MCP server %q specifies potential plaintext credential in env var %q", id, envKey)) + } + } + } + } + r := base r.Ecosystem = Ecosystem r.PackageManager = "mcp" diff --git a/internal/model/model.go b/internal/model/model.go index 0ba4639..4c6448c 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -45,6 +45,7 @@ const ( EcosystemBrowserExtension = "browser-extension" EcosystemHomebrew = "homebrew" EcosystemAgentSkill = "agent-skill" + EcosystemCargo = "cargo" ) var supportedEcosystems = map[string]struct{}{ @@ -58,6 +59,7 @@ var supportedEcosystems = map[string]struct{}{ EcosystemBrowserExtension: {}, EcosystemHomebrew: {}, EcosystemAgentSkill: {}, + EcosystemCargo: {}, } var supportedEcosystemOrder = []string{ @@ -71,6 +73,7 @@ var supportedEcosystemOrder = []string{ EcosystemBrowserExtension, EcosystemHomebrew, EcosystemAgentSkill, + EcosystemCargo, } // SupportedEcosystems returns the emitted ecosystem values supported by v0.1. diff --git a/internal/output/output.go b/internal/output/output.go index b903b3e..db53684 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -44,6 +44,8 @@ type Emitter struct { RecordsEmitted int Duplicates int Diagnostics int + + Quiet bool } func New(records, diags io.Writer, runID string) *Emitter { @@ -131,6 +133,11 @@ func (e *Emitter) EmitSummary(s model.ScanSummary) error { func (e *Emitter) Diag(level, path, msg string) { e.mu.Lock() defer e.mu.Unlock() + + if e.Quiet && (level == "info" || level == "warn" || level == "warning") { + return + } + e.Diagnostics++ d := model.Diagnostic{ RecordType: model.RecordTypeDiagnostic, diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index d0de709..2f142b4 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -20,6 +20,7 @@ import ( "github.com/perplexityai/bumblebee/internal/ecosystem/browserext" "github.com/perplexityai/bumblebee/internal/ecosystem/bun" + "github.com/perplexityai/bumblebee/internal/ecosystem/cargo" "github.com/perplexityai/bumblebee/internal/ecosystem/composer" "github.com/perplexityai/bumblebee/internal/ecosystem/editorext" "github.com/perplexityai/bumblebee/internal/ecosystem/gomod" @@ -253,6 +254,7 @@ func Run(ctx context.Context, cfg Config) (Result, error) { extS := &editorext.Scanner{MaxFileSize: cfg.MaxFileSize, Emit: emit, Diag: diag} bxS := &browserext.Scanner{MaxFileSize: cfg.MaxFileSize, Emit: emit, Diag: diag} hbS := &homebrew.Scanner{MaxFileSize: cfg.MaxFileSize, Emit: emit, Diag: diag} + cargoS := &cargo.Scanner{MaxFileSize: cfg.MaxFileSize, Emit: emit, Diag: diag} type job struct { kind string @@ -298,6 +300,8 @@ func Run(ctx context.Context, cfg Config) (Result, error) { err = yarnS.ScanLockfile(j.path, cfg.BaseRecord) case "bun-lock": err = bunS.ScanTextLockfile(j.path, cfg.BaseRecord) + case "cargo-lock": + err = cargoS.ScanCargoLock(j.path, cfg.BaseRecord) case "go-sum": err = goS.ScanGoSum(j.path, cfg.BaseRecord) case "go-mod": @@ -414,6 +418,8 @@ func Run(ctx context.Context, cfg Config) (Result, error) { send(job{kind: "bun-lock", path: path}) case enabled(model.EcosystemNPM) && bun.IsBinaryLockfile(base): bunS.NoteBinaryLockfile(path) + case enabled(model.EcosystemCargo) && cargo.IsCargoLock(base): + send(job{kind: "cargo-lock", path: path}) case enabled(model.EcosystemGo) && gomod.IsGoSum(base): send(job{kind: "go-sum", path: path}) case enabled(model.EcosystemGo) && gomod.IsGoMod(base):