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 ¶
- Constants
- Variables
- func Digest(r *Record) (string, error)
- func Encode(r *Record) ([]byte, error)
- type AuditSummary
- type Component
- type Dependency
- type Diff
- type FindingRef
- type Manifest
- type Metadata
- type Options
- type Package
- type PolicyRef
- type Record
- type Run
- type SectionDigests
- type Subject
- type Tool
- type Verdict
- type VulnerabilityRef
- type Waiver
Examples ¶
Constants ¶
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.
const SchemaVersion = "bomly.scan.v1"
SchemaVersion names the record schema this package writes and reads.
Variables ¶
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") )
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 ¶
Digest returns "sha256:<hex>" over Encode(r): a content identity for the whole record.
func Encode ¶
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 ¶
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
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
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
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
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
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 ¶
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
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 ¶
MarshalJSON writes the gated form.
func (Subject) Normalized ¶
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 ¶
UnmarshalJSON reads through the gate.
type Verdict ¶
type Verdict string
Verdict is the policy outcome of a run.
func VerdictOf ¶
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.