scan

package
v0.14.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 19, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package scan defines the scan record: the document one run of `bomly scan` produces, in the shape its JSON output has always had -- three collections, manifests, packages and findings, joined by package URL -- plus what that output never said about itself: what was scanned, when, by which components, and what the verdict was.

A record relates to an SBOM in both directions. Its manifests section is what package sbom projects into a document and reads back out of one, so an SBOM can be produced from a record with the codec that already exists, and a scan that ingested an SBOM yields a record whose manifest carries that document's own assertions. Its packages and findings sections hold what no SBOM can say: enrichment, policy outcomes, waivers, a verdict.

The schema is versioned by SchemaVersion and grows only additively within a version: readers ignore keys they do not know, and Decode refuses a record of another version rather than guessing at it. Encode is byte-stable -- every collection is written in a fixed order -- so a digest over a record's bytes identifies its content, and each section carries its own digest so a reader can tell which of the three changed.

Index

Examples

Constants

View Source
const (
	// MaxRecordBytes bounds one encoded record. A record's packages section
	// is one registry payload and its manifests are a lean projection of one
	// graph, so the model's payload bound covers it.
	MaxRecordBytes = model.MaxPayloadBytes
	// MaxManifests bounds the manifests of one record.
	MaxManifests = 1 << 16
	// MaxFindings bounds the findings of one record.
	MaxFindings = 1 << 20
)

Decode bounds: dumb bytes and counts, frozen once pinned.

View Source
const SchemaVersion = "bomly.scan.v1"

SchemaVersion names the record schema this package writes and reads.

Variables

View Source
var (
	// ErrRecordTooLarge is returned, wrapped, when a record exceeds a bound.
	ErrRecordTooLarge = errors.New("scan record exceeds the decode bound")
	// ErrSchemaVersion is returned when a record names a schema this package
	// does not read.
	ErrSchemaVersion = errors.New("unsupported scan record schema")
	// ErrDigestMismatch is returned when a section's recorded digest does
	// not match its content.
	ErrDigestMismatch = errors.New("scan record section digest does not match its content")
)
View Source
var IteratedCollections = []string{
	"manifests", "packages", "findings", "warnings", "waivers",
	"manifests[].dependencies",
	"manifests[].dependencies[].depends_on", "manifests[].dependencies[].licenses",
	"packages[].licenses", "packages[].vulnerabilities",
}

IteratedCollections are the record's collections that are always written, as [] when empty, because they are the ones a consumer iterates: a script that walks `.findings[]` or `.manifests[].dependencies[].depends_on[]` must not break on a run that found nothing. An empty array and an absent key mean the same thing in this record -- none recorded -- so writing the array adds no claim; it only makes the shape stable. Other optional fields, collections included, are omitted when empty. Adding a key to this list is additive within bomly.scan.v1: a reader that tolerated its absence also reads an empty array.

Functions

func Digest

func Digest(r *Record) (string, error)

Digest returns "sha256:<hex>" over Encode(r): a content identity for the whole record.

func Encode

func Encode(r *Record) ([]byte, error)

Encode writes a record as its canonical bytes. Every collection is sorted -- manifests by path, dependencies by ID and their edges by target and scopes by name, packages by package URL, findings by ID then package and kind -- and SchemaVersion and the section digests are filled, so two records with the same content encode to the same bytes. The record passed in is not modified.

A section digest is taken over the section's bytes as this function writes them, and Decode verifies it over the section's bytes as it finds them: never over a re-encoding of a decoded value. That is what lets a record written by a later minor of the schema, carrying a key this reader does not know, still verify -- the key is in the bytes on both sides -- and what keeps an open value (a package's metadata, an advisory's database-specific block) out of the question, since its bytes are its bytes.

Types

type AuditSummary

type AuditSummary struct {
	Critical int `json:"critical,omitempty"`
	High     int `json:"high,omitempty"`
	Medium   int `json:"medium,omitempty"`
	Low      int `json:"low,omitempty"`
	Unknown  int `json:"unknown,omitempty"`
	Total    int `json:"total,omitempty"`
}

AuditSummary counts findings by severity.

type Component

type Component struct {
	Name    string `json:"name,omitempty"`
	Version string `json:"version,omitempty"`
	Role    string `json:"role,omitempty"`
	Origin  string `json:"origin,omitempty"`
}

Component is one detector, matcher, auditor or analyzer that took part.

type Dependency

type Dependency struct {
	ID      string `json:"id,omitempty"`
	Name    string `json:"name,omitempty"`
	Version string `json:"version,omitempty"`
	PURL    string `json:"purl,omitempty"`
	// Source is where the dependency was resolved from -- a registry, Git,
	// a URL, a file, a workspace -- as the detector recorded it; a change
	// between scans is a review-worthy transition the comparison reports.
	Source model.DependencySource `json:"source,omitempty"`
	// Relationship is whether the dependency was declared directly by its
	// manifest or reached through another, as the detector recorded it. A
	// record lists no manifest nodes, so a comparison rebuilt from one has
	// no structural root to derive this from; the stated value stands in,
	// and the graph comparison honors a stated relationship over a derived
	// one.
	Relationship model.DependencyRelationship `json:"relationship,omitempty"`
	Scopes       []model.Scope                `json:"scopes,omitempty"`
	DependsOn    []string                     `json:"depends_on"`
	Matched      bool                         `json:"matched,omitempty"`
	PackageRef   string                       `json:"package_ref,omitempty"`
	Locations    []model.PackageLocation      `json:"locations,omitempty"`
	Licenses     []model.PackageLicense       `json:"licenses"`
}

Dependency is the lean projection of a graph node: identity, scope, edges, and the detection-time facts a manifest states. Enrichment lives once, on the package PackageRef names.

func (Dependency) MarshalJSON added in v0.14.4

func (d Dependency) MarshalJSON() ([]byte, error)

MarshalJSON writes the gated form, with its edges and licenses written even when empty.

func (Dependency) Normalized added in v0.14.4

func (d Dependency) Normalized() Dependency

Normalized returns the dependency held to its gate: Relationship passes model.ParseDependencyRelationship, so a value outside the vocabulary -- which the graph comparison would otherwise take as a stated relationship and report against the canonical one -- is cleared rather than carried.

func (*Dependency) UnmarshalJSON added in v0.14.4

func (d *Dependency) UnmarshalJSON(data []byte) error

UnmarshalJSON reads through the gate. The wire value starts from the receiver, as encoding/json's default decoding would, so a partial object applied to an existing dependency keeps the fields it does not name.

type Diff

type Diff struct {
	Dependencies           model.Diff
	AddedVulnerabilities   []VulnerabilityRef
	RemovedVulnerabilities []VulnerabilityRef
	AddedFindings          []FindingRef
	RemovedFindings        []FindingRef
}

Diff is what changed between two records: the dependency changes the graph comparison reports, and the advisories and findings that appeared or disappeared, by identity.

func Compare

func Compare(base, head *Record) (Diff, error)

Compare reports what changed from base to head. Dependencies are compared by rebuilding each record's dependency graph -- across all its manifests, folded by identity -- and handing the pair to model.Compare, so the answer is the graph comparison's, not a second one. Advisories and findings are compared by identity: an advisory is (package, source, ID), a finding is (ID, package, kind, vulnerability, rule).

type FindingRef

type FindingRef struct {
	ID              string            `json:"id,omitempty"`
	PackageRef      string            `json:"package_ref,omitempty"`
	Kind            model.FindingKind `json:"kind,omitempty"`
	VulnerabilityID string            `json:"vulnerability_id,omitempty"`
	RuleID          string            `json:"rule_id,omitempty"`
}

FindingRef names one finding: the identity Encode orders findings by, less the provenance (auditor, source), which does not make a second finding of a second finding. A vulnerability finding and a policy finding under one ID on one package are two findings.

type Manifest

type Manifest struct {
	Path           string                    `json:"path,omitempty"`
	Kind           model.ManifestKind        `json:"kind,omitempty"`
	Subproject     string                    `json:"subproject,omitempty"`
	Ecosystem      model.Ecosystem           `json:"ecosystem,omitempty"`
	PackageManager model.PackageManager      `json:"package_manager,omitempty"`
	Detector       string                    `json:"detector,omitempty"`
	Resolution     *model.ResolutionMetadata `json:"resolution,omitempty"`
	// Document carries what the source SBOM said about itself when the
	// manifest is an ingested document -- identity, version, checksum,
	// creators, data license, format -- so a record restates the document's
	// provenance rather than only its contents. Gated by its own codec.
	Document     *model.DocumentAssertions `json:"document,omitempty"`
	Dependencies []Dependency              `json:"dependencies"`
}

Manifest is one resolved manifest and the dependencies it declares.

func (Manifest) MarshalJSON added in v0.14.6

func (m Manifest) MarshalJSON() ([]byte, error)

MarshalJSON writes the dependency list even when it is empty.

type Metadata

type Metadata struct {
	DurationMS          int64                               `json:"duration_ms,omitempty"`
	ReachabilityEnabled bool                                `json:"reachability_enabled,omitempty"`
	ScorecardEnabled    bool                                `json:"scorecard_enabled,omitempty"`
	AnalyzerRuns        []string                            `json:"analyzer_runs,omitempty"`
	AnalyzerStats       map[string]plugin.ReachabilityStats `json:"analyzer_stats,omitempty"`
}

Metadata carries run statistics.

type Options

type Options struct {
	Enrich  bool     `json:"enrich,omitempty"`
	Analyze bool     `json:"analyze,omitempty"`
	Audit   bool     `json:"audit,omitempty"`
	FailOn  []string `json:"fail_on,omitempty"`
}

Options are the run's stage selections and policy inputs.

type Package added in v0.14.6

type Package struct {
	*model.Package
}

Package is a registry package as a document writes it: the SDK package, gated by its own rules, with its licenses and vulnerabilities written as [] when it has none. model.Package omits them, because on the plugin wire every field is optional by contract; a document a consumer iterates is held to IteratedCollections instead, so this type carries that shape for the record and for any other document built from the same packages.

func Packages added in v0.14.6

func Packages(packages []*model.Package) []Package

Packages wraps registry packages for a document, so each one writes its iterated collections. Nil packages are kept as they are.

func (Package) MarshalJSON added in v0.14.6

func (p Package) MarshalJSON() ([]byte, error)

MarshalJSON writes the package through the same gate model.Package's own codec applies, then with its licenses and vulnerabilities always present. The holder's package is not edited.

func (*Package) UnmarshalJSON added in v0.14.6

func (p *Package) UnmarshalJSON(data []byte) error

UnmarshalJSON reads a package through model.Package's own codec. Without it, decoding into a zero Package would call the embedded pointer's unmarshaler with that pointer still nil and panic; a document type that is exported must read untrusted JSON safely. null leaves the package nil.

The input is bounded before it is parsed, as every untrusted parser here is: a package read on its own, outside Decode, does not inherit the record's byte bound, so it applies the same one -- one package can be no larger than the record that would hold it.

type PolicyRef

type PolicyRef struct {
	Name        string    `json:"name,omitempty"`
	Digest      string    `json:"digest,omitempty"`
	EvaluatedAt time.Time `json:"evaluated_at,omitzero"`
}

PolicyRef names the policy a run evaluated against.

type Record

type Record struct {
	// SchemaVersion is SchemaVersion; the one key every record carries.
	SchemaVersion string `json:"schema_version"`
	// Command is the command that produced the record ("scan").
	Command string `json:"command,omitempty"`
	// Subject is what was scanned, in terms that identify it outside the
	// machine it was scanned on.
	Subject Subject `json:"subject,omitzero"`
	// Run is the execution that produced the record.
	Run Run `json:"run,omitzero"`
	// Manifests are the detection-stage results, one per manifest the scan
	// resolved, each with its lean dependency list. Always written, empty
	// as []: see IteratedCollections.
	Manifests []Manifest `json:"manifests"`
	// Packages is the matching-stage registry: one package per package URL,
	// carrying the enrichment. Sorted by package URL. Always written, and
	// each package's licenses and vulnerabilities with it; see Package.
	Packages []*model.Package `json:"packages"`
	// Findings are the audit-stage results, referencing packages by URL.
	// Always written.
	Findings []model.Finding `json:"findings"`
	// AuditSummary counts the findings by severity.
	AuditSummary *AuditSummary `json:"audit_summary,omitempty"`
	// Warnings are the typed detector warnings the run surfaced.
	Warnings []plugin.DetectorWarning `json:"warnings"`
	// Verdict is the policy outcome of the whole run.
	Verdict Verdict `json:"verdict,omitempty"`
	// Policy names the policy the findings were evaluated against.
	Policy *PolicyRef `json:"policy,omitempty"`
	// Waivers are the accepted findings that shaped the verdict.
	Waivers []Waiver `json:"waivers"`
	// Metadata carries run statistics.
	Metadata Metadata `json:"metadata,omitzero"`
	// Digests are content digests over each section's encoded bytes, filled
	// by Encode and verified by Decode when present.
	Digests *SectionDigests `json:"digests,omitempty"`
}

Record is one scan: what was scanned, what was found, and what was decided.

func Decode

func Decode(data []byte) (*Record, error)

Decode reads a record from its bytes, refusing one over the bounds, of another schema version, or whose section digests do not match their content. Unknown keys are ignored, so a record written by a later minor of the schema still reads -- and still verifies, because a digest is checked over the section's bytes as written (whitespace aside), not over what this reader understood of them.

The byte bound is the memory guard; the count bounds are checked after the whole document is decoded, which is cheaper than a streaming reader over a nested record and, with the byte bound in front, no less safe.

func FromGraphEntries

func FromGraphEntries(entries []model.GraphEntry, registry *model.PackageRegistry, findings []model.Finding) *Record

FromGraphEntries builds a record's three collections from what a pipeline holds: the graph entries detection produced, the registry matching filled, and the findings auditing raised. One Manifest is written per entry; its Subproject, Ecosystem, PackageManager and Detector are not derivable from an entry and are the caller's to fill, as are Subject, Run, Verdict and Policy. The result is not yet canonical; Encode orders it.

A dependency's edges name only nodes the record defines, stepping through manifest nodes the way a document does (graphview.ChildrenAmong): a workspace is module -> child manifest -> child module, and the manifest in the middle is structural.

Example

A record is built from what a pipeline holds, encoded to canonical bytes, and read back.

package main

import (
	"fmt"

	"github.com/bomly-dev/bomly-sdk/model"
	"github.com/bomly-dev/bomly-sdk/scan"
)

func main() {
	g := model.New()
	react, _ := model.NewDependencyNodeFromPURL("pkg:npm/react@18.2.0")
	_ = g.AddNode(react)
	registry := model.NewPackageRegistry()
	registry.Ensure(react.NodeID()).Vulnerabilities = []model.Vulnerability{{ID: "CVE-2024-0001"}}
	findings := []model.Finding{{ID: "CVE-2024-0001", Kind: model.FindingKindVulnerability, PackageRef: react.NodeID(), Severity: "high", PolicyStatus: model.FindingPolicyStatusFail}}

	record := scan.FromGraphEntries([]model.GraphEntry{{Graph: g, Manifest: model.ManifestMetadata{Path: "package-lock.json"}}}, registry, findings)
	record.Verdict = scan.VerdictOf(findings)

	data, err := scan.Encode(record)
	if err != nil {
		panic(err)
	}
	decoded, err := scan.Decode(data)
	if err != nil {
		panic(err)
	}
	fmt.Println(decoded.SchemaVersion, len(decoded.Manifests[0].Dependencies), len(decoded.Packages), decoded.Verdict)
}
Output:
bomly.scan.v1 1 1 fail

func (Record) MarshalJSON added in v0.14.6

func (r Record) MarshalJSON() ([]byte, error)

MarshalJSON writes the record with its iterated collections present even when empty. Encode is the canonical writer; this is what makes a record marshaled directly take the same shape.

type Run

type Run struct {
	ID          string      `json:"id,omitempty"`
	Correlator  string      `json:"correlator,omitempty"`
	StartedAt   time.Time   `json:"started_at,omitzero"`
	CompletedAt time.Time   `json:"completed_at,omitzero"`
	Tool        Tool        `json:"tool,omitzero"`
	Components  []Component `json:"components,omitempty"`
	Options     Options     `json:"options,omitzero"`
}

Run describes one execution of the pipeline.

type SectionDigests

type SectionDigests struct {
	Manifests string `json:"manifests,omitempty"`
	Packages  string `json:"packages,omitempty"`
	Findings  string `json:"findings,omitempty"`
}

SectionDigests are "sha256:<hex>" digests over each section's encoded bytes, so a reader can tell which of the three collections changed between two records without comparing them.

type Subject

type Subject struct {
	Kind          plugin.ExecutionTargetKind `json:"kind,omitempty"`
	RepositoryURL string                     `json:"repository_url,omitempty"`
	// Ref is the revision that was asked for; CommitSHA is the one found.
	Ref       string `json:"ref,omitempty"`
	CommitSHA string `json:"commit_sha,omitempty"`
	// ImageReference is the container image reference the scan was asked
	// for, as given (repository and tag, or repository and digest); a
	// public identity, unlike a local path, and the one thing that tells
	// two tagged images' records apart. ImageDigest is the digest part
	// when the reference was pinned by one: an identity, where a tag is
	// not.
	ImageReference string `json:"image_reference,omitempty"`
	ImageDigest    string `json:"image_digest,omitempty"`
}

Subject identifies what was scanned. It deliberately has no field for a local path: a record is read away from the machine that produced it, and a path there identifies nothing here.

func (Subject) MarshalJSON

func (s Subject) MarshalJSON() ([]byte, error)

MarshalJSON writes the gated form.

func (Subject) Normalized

func (s Subject) Normalized() Subject

Normalized returns the subject held to its gate: CommitSHA passes plugin.NormalizeCommitSHA, the same gate the execution target applies, so a record cannot carry a ref name or a padded hash as a commit; the other fields are trimmed.

func (*Subject) UnmarshalJSON

func (s *Subject) UnmarshalJSON(data []byte) error

UnmarshalJSON reads through the gate.

type Tool

type Tool struct {
	Name    string `json:"name,omitempty"`
	Version string `json:"version,omitempty"`
}

Tool is the host that ran the scan.

type Verdict

type Verdict string

Verdict is the policy outcome of a run.

const (
	// VerdictPass means no finding failed policy.
	VerdictPass Verdict = "pass"
	// VerdictWarn means findings exist but none failed policy.
	VerdictWarn Verdict = "warn"
	// VerdictFail means at least one finding failed policy.
	VerdictFail Verdict = "fail"
)

func VerdictOf

func VerdictOf(findings []model.Finding) Verdict

VerdictOf derives the run's verdict from its findings: fail when any finding's policy status ranks as fail -- which an unset status does, and so does one outside the vocabulary, since the codec clears those and a value that reached here another way must not read as gentler -- warn when findings exist but none fail, pass otherwise.

type VulnerabilityRef

type VulnerabilityRef struct {
	PackageRef      string `json:"package_ref,omitempty"`
	VulnerabilityID string `json:"vulnerability_id,omitempty"`
	Source          string `json:"source,omitempty"`
}

VulnerabilityRef names one advisory on one package. The identity is the one Package.mergeVulnerabilities unions by -- source and ID -- so an advisory that moved from one source to another under the same ID is a removal and an addition, not nothing.

type Waiver

type Waiver struct {
	ID              string    `json:"id,omitempty"`
	PackageRef      string    `json:"package_ref,omitempty"`
	VulnerabilityID string    `json:"vulnerability_id,omitempty"`
	RuleID          string    `json:"rule_id,omitempty"`
	Justification   string    `json:"justification,omitempty"`
	ApprovedBy      string    `json:"approved_by,omitempty"`
	CreatedAt       time.Time `json:"created_at,omitzero"`
	ExpiresAt       time.Time `json:"expires_at,omitzero"`
}

Waiver is an accepted finding: which one, on whose say, until when.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL