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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,10 @@ lists every flag.

Records are NDJSON, one per line. Diagnostics go to stderr as NDJSON. Each
run ends with a `scan_summary` record; receivers use it to decide whether
to promote a run to current state. See [docs/transport.md](docs/transport.md)
to promote a run to current state. `scan_summary.counts` always includes
`package` and `finding`, and also includes one key for each ecosystem that
emitted package records (for example `npm`, `pypi`, `go`, etc.). See
[docs/transport.md](docs/transport.md)
for HTTPS/file output and [docs/state-model.md](docs/state-model.md) for the
receiver-side current-state model.

Expand Down Expand Up @@ -287,4 +290,4 @@ catalog list and review guidance.

## License

Apache License 2.0. See [LICENSE](LICENSE).
Apache License 2.0. See [LICENSE](LICENSE).
10 changes: 9 additions & 1 deletion cmd/bumblebee/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,18 @@ func runScan(args []string) int {
for _, r := range roots {
summaryRoots = append(summaryRoots, model.SummaryRoot{Path: r.Path, Kind: r.Kind})
}
counts := map[string]int{
// counts is deterministically ordered by model.Counts.MarshalJSON:
// "package", "finding", then each ecosystem in
// model.SupportedEcosystems() order (only keys actually present).
counts := model.Counts{
model.RecordTypePackage: res.RecordsEmitted,
model.RecordTypeFinding: res.FindingsEmitted,
}
for _, eco := range model.SupportedEcosystems() {
if n := res.RecordsByEcosystem[eco]; n > 0 {
counts[eco] = n
}
}
if err := emitter.EmitSummary(model.ScanSummary{
SchemaVersion: model.SchemaVersion,
ScannerName: model.ScannerName,
Expand Down
58 changes: 58 additions & 0 deletions cmd/bumblebee/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -751,3 +751,61 @@ func TestRunRootsRejectsUnknownProfile(t *testing.T) {
t.Fatalf("runRoots --profile=scheduled exit = %d, want 2 (unknown profile)", code)
}
}

func writeMainTestFile(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}

// TestRunScanSummaryCountsDeterministicOrder is the end-to-end test for
// issue #52: it runs a real multi-ecosystem scan through runScan and
// asserts the on-disk scan_summary.counts JSON has package, finding,
// then ecosystem keys in model.SupportedEcosystemOrder — not Go's
// randomized native map order.
func TestRunScanSummaryCountsDeterministicOrder(t *testing.T) {
root := t.TempDir()
writeMainTestFile(t, filepath.Join(root, "proj", "package-lock.json"),
`{"lockfileVersion":3,"packages":{"node_modules/lodash":{"version":"4.17.21"}}}`)
writeMainTestFile(t, filepath.Join(root, "gomod", "go.sum"),
"github.com/example/foo v1.2.3 h1:abc=\ngithub.com/example/foo v1.2.3/go.mod h1:def=\n")
writeMainTestFile(t, filepath.Join(root, "rb", "Gemfile.lock"),
"GEM\n remote: https://rubygems.org/\n specs:\n rack (3.0.8)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rack\n")

out := filepath.Join(t.TempDir(), "out.ndjson")
code := runScan([]string{"--profile", "project", "--root", root, "--output", "file", "--output-file", out})
if code != 0 {
t.Fatalf("runScan exit = %d", code)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
var summaryLine string
for _, line := range strings.Split(string(data), "\n") {
if strings.Contains(line, `"record_type":"scan_summary"`) {
summaryLine = line
break
}
}
if summaryLine == "" {
t.Fatalf("no scan_summary line found in %s", out)
}

wantOrder := []string{`"package"`, `"finding"`, `"npm"`, `"go"`, `"rubygems"`}
lastIdx := -1
for _, key := range wantOrder {
idx := strings.Index(summaryLine, key+":")
if idx == -1 {
t.Fatalf("key %s not found in scan_summary: %s", key, summaryLine)
}
if idx < lastIdx {
t.Fatalf("key %s out of order (idx=%d, prev=%d) in scan_summary: %s", key, idx, lastIdx, summaryLine)
}
lastIdx = idx
}
}
117 changes: 90 additions & 27 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package model
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -204,37 +205,95 @@ type Finding struct {
Evidence string `json:"evidence,omitempty"`
}

// Counts is a per-record-type/per-ecosystem count map used in
// ScanSummary.Counts. It always marshals its keys in a deterministic
// order — "package", "finding", then each ecosystem in
// supportedEcosystemOrder (only keys actually present are emitted),
// then any remaining unrecognized keys sorted alphabetically — so
// scan_summary.counts key order is stable across runs and hosts
// regardless of Go's randomized native map iteration order.
type Counts map[string]int

// MarshalJSON implements a deterministic key order for Counts. Standard
// map[string]int marshaling sorts keys alphabetically, which would put
// ecosystem keys (e.g. "go", "npm") out of the intended
// package/finding/ecosystem-order shape; this override fixes that.
func (c Counts) MarshalJSON() ([]byte, error) {
if c == nil {
return []byte("null"), nil
}
order := make([]string, 0, len(c))
seen := make(map[string]bool, len(c))
for _, k := range []string{RecordTypePackage, RecordTypeFinding} {
if _, ok := c[k]; ok {
order = append(order, k)
seen[k] = true
}
}
for _, k := range supportedEcosystemOrder {
if _, ok := c[k]; ok {
order = append(order, k)
seen[k] = true
}
}
var rest []string
for k := range c {
if !seen[k] {
rest = append(rest, k)
}
}
sort.Strings(rest)
order = append(order, rest...)

var buf strings.Builder
buf.WriteByte('{')
for i, k := range order {
if i > 0 {
buf.WriteByte(',')
}
kb, err := json.Marshal(k)
if err != nil {
return nil, err
}
buf.Write(kb)
buf.WriteByte(':')
buf.WriteString(strconv.Itoa(c[k]))
}
buf.WriteByte('}')
return []byte(buf.String()), nil
}

// ScanSummary is a per-run terminator record emitted to the same sink as
// package and finding records. Receivers should only promote a run to
// current state after a matching scan_summary with status=complete has
// arrived.
type ScanSummary struct {
RecordType string `json:"record_type"`
RecordID string `json:"record_id"`
SchemaVersion string `json:"schema_version"`
ScannerName string `json:"scanner_name"`
ScannerVersion string `json:"scanner_version"`
RunID string `json:"run_id"`
ScanTime string `json:"scan_time"`
EndTime string `json:"end_time"`
Endpoint Endpoint `json:"endpoint"`
Profile string `json:"profile"`
Status string `json:"status"`
Roots []SummaryRoot `json:"roots,omitempty"`
Counts map[string]int `json:"counts,omitempty"`
PackageRecordsEmitted int `json:"package_records_emitted"`
PackageRecordsSuppressed int `json:"package_records_suppressed,omitempty"`
FindingsEmitted int `json:"findings_emitted"`
Duplicates int `json:"duplicates"`
DiagnosticsCount int `json:"diagnostics_count"`
FilesConsidered int `json:"files_considered"`
TimedOut bool `json:"timed_out"`
DurationMS int64 `json:"duration_ms"`
HTTPBatchesAttempted int `json:"http_batches_attempted,omitempty"`
HTTPBatchesSucceeded int `json:"http_batches_succeeded,omitempty"`
HTTPBatchesFailed int `json:"http_batches_failed,omitempty"`
HTTPLastStatus int `json:"http_last_status,omitempty"`
Error string `json:"error,omitempty"`
RecordType string `json:"record_type"`
RecordID string `json:"record_id"`
SchemaVersion string `json:"schema_version"`
ScannerName string `json:"scanner_name"`
ScannerVersion string `json:"scanner_version"`
RunID string `json:"run_id"`
ScanTime string `json:"scan_time"`
EndTime string `json:"end_time"`
Endpoint Endpoint `json:"endpoint"`
Profile string `json:"profile"`
Status string `json:"status"`
Roots []SummaryRoot `json:"roots,omitempty"`
Counts Counts `json:"counts,omitempty"`
PackageRecordsEmitted int `json:"package_records_emitted"`
PackageRecordsSuppressed int `json:"package_records_suppressed,omitempty"`
FindingsEmitted int `json:"findings_emitted"`
Duplicates int `json:"duplicates"`
DiagnosticsCount int `json:"diagnostics_count"`
FilesConsidered int `json:"files_considered"`
TimedOut bool `json:"timed_out"`
DurationMS int64 `json:"duration_ms"`
HTTPBatchesAttempted int `json:"http_batches_attempted,omitempty"`
HTTPBatchesSucceeded int `json:"http_batches_succeeded,omitempty"`
HTTPBatchesFailed int `json:"http_batches_failed,omitempty"`
HTTPLastStatus int `json:"http_last_status,omitempty"`
Error string `json:"error,omitempty"`
}

// SummaryRoot is one entry in ScanSummary.Roots — path plus the root kind
Expand Down Expand Up @@ -368,7 +427,11 @@ func joinSorted(values []string) string {
return joinWithUnitSeparator(sorted)
}

func canonicalCounts(counts map[string]int) string {
// canonicalCounts renders counts in a stable, alphabetically-sorted form
// for hashing purposes only. This is independent of Counts.MarshalJSON's
// display order: the StableID hash just needs any deterministic order,
// not the human-facing package/finding/ecosystem order.
func canonicalCounts(counts Counts) string {
if len(counts) == 0 {
return ""
}
Expand Down
13 changes: 13 additions & 0 deletions internal/scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ type Result struct {
Diagnostics int
TimedOut bool
Duration time.Duration
// RecordsByEcosystem counts, per ecosystem, the package records that
// were actually written to the records sink. It mirrors RecordEmitted
// semantics exactly: deduplicated/suppressed records and records
// skipped by --findings-only are not counted.
RecordsByEcosystem map[string]int
}

// Run executes one scan and returns aggregate counters. It blocks until
Expand All @@ -148,6 +153,8 @@ func Run(ctx context.Context, cfg Config) (Result, error) {
var findingsMu sync.Mutex
var packageRecordsSuppressed int
var suppressedMu sync.Mutex
recordsByEcosystem := make(map[string]int)
var recordsByEcosystemMu sync.Mutex
var emitErr error
var emitErrMu sync.Mutex
setEmitErr := func(err error) {
Expand Down Expand Up @@ -187,6 +194,9 @@ func Run(ctx context.Context, cfg Config) (Result, error) {
setEmitErr(fmt.Errorf("emit package record: %w", err))
return
}
recordsByEcosystemMu.Lock()
recordsByEcosystem[r.Ecosystem]++
recordsByEcosystemMu.Unlock()
}
}
if !written {
Expand Down Expand Up @@ -501,6 +511,9 @@ func Run(ctx context.Context, cfg Config) (Result, error) {
suppressedMu.Lock()
res.PackageRecordsSuppressed = packageRecordsSuppressed
suppressedMu.Unlock()
recordsByEcosystemMu.Lock()
res.RecordsByEcosystem = recordsByEcosystem
recordsByEcosystemMu.Unlock()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
res.TimedOut = true
}
Expand Down
Loading