Skip to content
Merged
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
144 changes: 144 additions & 0 deletions attestation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package sdk

// PackageAttestation records a signed statement about how a package was built
// or published: an in-toto statement such as SLSA provenance, or a
// publish-time signature.
//
// Bomly does not fetch or verify attestations today. The type exists so a
// matcher that does can attach what it found without a model change, and so
// consumers can tell a verified statement from one that was merely present --
// a distinction that matters more than the statement itself, and that is
// easily lost when provenance data is carried in untyped metadata.
type PackageAttestation struct {
// PredicateType identifies what the statement asserts, using the in-toto
// predicate vocabulary (for example "https://slsa.dev/provenance/v1").
PredicateType string `json:"predicate_type,omitempty"`
// Source names the component or service that attached the statement, in
// the same style as PackageScorecard.Source.
Source string `json:"source,omitempty"`
// URL is where the statement can be fetched.
URL string `json:"url,omitempty"`
// Digest identifies the statement itself, so two fetches of one URL can be
// told apart.
Digest *Digest `json:"digest,omitempty"`
// Issuer is the identity that signed the statement -- an OIDC identity, a
// key id, or a registry account -- as reported by whatever verified it.
Issuer string `json:"issuer,omitempty"`
// Verified records that the component attaching this statement checked its
// signature. False means the statement was found but not verified, which is
// weaker evidence rather than evidence of tampering; consumers must not
// present an unverified statement as proof of provenance.
Verified bool `json:"verified,omitempty"`
}

// mergeAttestations folds incoming statements into p, keeping one record per
// distinct statement. Several components can attest to one package -- a build
// provenance statement from one, a publish signature from another -- so this
// unions rather than keeping whichever arrived first, the way vulnerabilities
// already do. When two records describe the same statement and either verified
// it, the merged record is verified: verification is a fact one component
// established, not an opinion.
func (p *Package) mergeAttestations(incoming []PackageAttestation) {
if len(incoming) == 0 {
return
}
for _, candidate := range incoming {
merged := false
for i := range p.Attestations {
if !p.Attestations[i].describesSame(candidate) {
continue
}
p.Attestations[i].absorb(candidate)
merged = true
break
}
if !merged {
p.Attestations = append(p.Attestations, candidate.Clone())
}
}
}

// describesSame reports whether two records can be folded into one. Verification
// is a fact about a statement *and a signer*, so records fold only when they
// agree on the issuer -- or when one of them claims nothing that could be
// misattributed.
//
// A record with no issuer and no verification says only that the statement
// exists, which any other record for it already says, so it folds into
// anything. A record with no issuer that *was* verified is a real claim ("this
// was verified, signer unrecorded") and stays separate from a record naming an
// issuer: merging them would report that issuer as verified on the strength of
// a verification that may have been of someone else's signature.
func (a PackageAttestation) describesSame(other PackageAttestation) bool {
if a.key() != other.key() {
return false
}
switch {
case a.Issuer == other.Issuer:
return true
case a.claimsNothing(), other.claimsNothing():
return true
default:
return false
}
}

// claimsNothing reports whether a asserts anything beyond the statement's
// existence.
func (a PackageAttestation) claimsNothing() bool {
return a.Issuer == "" && !a.Verified
}

// absorb folds a record describing the same statement into a. Verification
// never moves between issuers: it travels only when this record claims nothing,
// in which case the other record replaces it wholesale.
func (a *PackageAttestation) absorb(other PackageAttestation) {
switch {
case a.claimsNothing():
*a = other.Clone()
case other.claimsNothing():
// Nothing to take.
case other.Verified:
// Same issuer, so the verification is this issuer's.
a.Verified = true
}
}

// attestationKey identifies one statement for deduplication. Issuer is
// deliberately absent: it is compared separately, because an unknown issuer is
// compatible with a known one while two known issuers are not.
type attestationKey struct {
source string
predicateType string
url string
digestAlgorithm DigestAlgorithm
digestValue string
digestSubject DigestSubject
}

// key returns a's deduplication identity.
func (a PackageAttestation) key() attestationKey {
key := attestationKey{source: a.Source, predicateType: a.PredicateType, url: a.URL}
if a.Digest != nil {
// The three parts stay separate rather than joined: plugin-supplied
// values can contain the separator, and joining lets two different
// digests produce one key.
//
// Subject is part of the identity: the same bytes hashed over a source
// tree and over an artifact are different claims.
key.digestAlgorithm = a.Digest.Algorithm
key.digestValue = a.Digest.Value
key.digestSubject = a.Digest.Subject
}
return key
}

// Clone returns a deep copy.
func (a PackageAttestation) Clone() PackageAttestation {
clone := a
if a.Digest != nil {
digest := *a.Digest
clone.Digest = &digest
}
return clone
}
12 changes: 8 additions & 4 deletions container.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,14 @@ func addNodeIfMissing(g *Graph, node *Dependency) error {
if existing, ok := g.Node(node.ID); ok && existing != nil {
existing.Relationship = MergeDependencyRelationship(existing.Relationship, node.Relationship)
mergeDependencyLocations(existing, clone.Locations)
// One node is one package. Where two records of it disagree about
// where it came from, the merge settles to no origin rather than
// to whichever arrived first.
existing.Origin = ReconcileOrigin(existing.Origin, clone.Origin)
// Merging fills gaps rather than resolving conflicts: locations
// union, and an origin fills in from whichever record has one, so
// it survives regardless of manifest order. Where both records
// assert different origins -- rare -- the existing one stays,
// deterministically.
if existing.Origin.Empty() {
existing.Origin = clone.Origin
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return nil
}
Expand Down
16 changes: 11 additions & 5 deletions dependency.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,17 @@ type Dependency struct {
Digests []Digest `json:"digests,omitempty"`
Copyright string `json:"copyright,omitempty"`
FoundBy string `json:"found_by,omitempty"`
ResolvedURL string `json:"resolved_url,omitempty"`
// Origin is where this dependency was resolved from, as recorded by the
// detector that read the manifest. Read it through Origin.Normalized().
Origin *PackageOrigin `json:"origin,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
// ResolvedURL is the manifest's resolution field verbatim -- it may be a
// pseudo-URL, a registry or index root, or a local path, and is never
// published. It is raw evidence: Origin is the validated assertion
// distilled from it and from the manifest's other source fields.
ResolvedURL string `json:"resolved_url,omitempty"`
// Origin is where this dependency was resolved from, distilled by the
// detector from the manifest's structured source fields. Read it through
// Origin.Normalized(): ResolvedURL is the raw evidence, Origin the
// validated assertion, Normalized() the view consumers publish.
Origin *DependencyOrigin `json:"origin,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`

// Matched is true when the referenced package was enriched by a matcher.
Matched bool `json:"matched,omitempty"`
Expand Down
52 changes: 52 additions & 0 deletions digest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package sdk

// Digest captures integrity information for a package artifact.
type Digest struct {
Algorithm DigestAlgorithm `json:"algorithm,omitempty"`
Value string `json:"value,omitempty"`
// Subject says what the digest covers. Empty means the published artifact,
// which is what most ecosystems record and what a consumer should assume.
// It exists because some ecosystems record a hash that is not a hash of a
// file: a Go module's "h1:" value is SHA-256 over a manifest of the source
// tree's file hashes, not over the module zip, so a consumer that treats it
// as an artifact digest and compares it against a downloaded file will
// always find a mismatch.
Subject DigestSubject `json:"subject,omitempty"`
}

// DigestSubject identifies what a digest was computed over.
type DigestSubject string

const (
// DigestSubjectArtifact is a digest of the published file itself. It is
// the zero value: a producer that does not say means the artifact.
DigestSubjectArtifact DigestSubject = ""
// DigestSubjectSourceTree is a digest over a source tree or over a
// manifest of its file hashes, such as a Go module "h1:" dirhash.
DigestSubjectSourceTree DigestSubject = "source-tree"
// DigestSubjectMetadata is a digest of a package's metadata document
// rather than of the package itself, such as a manifest or lockfile entry.
DigestSubjectMetadata DigestSubject = "metadata"
)

// mergeDigests unions digests rather than keeping whichever record arrived
// first. Two records can carry genuinely different claims about one package --
// a hash of the published artifact from one source and a hash over the source
// tree from another -- and Subject is what tells them apart, so dropping a
// later slice would lose provenance on merge order alone.
func (p *Package) mergeDigests(incoming []Digest) {
if len(incoming) == 0 {
return
}
seen := make(map[Digest]struct{}, len(p.Digests)+len(incoming))
for _, digest := range p.Digests {
seen[digest] = struct{}{}
}
for _, digest := range incoming {
if _, found := seen[digest]; found {
continue
}
seen[digest] = struct{}{}
p.Digests = append(p.Digests, digest)
}
}
21 changes: 5 additions & 16 deletions fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,10 @@ func requireFuzzGraphValid(t *testing.T, graph *Graph) {
})
}

// FuzzPackageOrigin drives the origin rule with arbitrary lockfile-derived
// FuzzDependencyOrigin drives the origin rule with arbitrary lockfile-derived
// strings: detectors pass raw manifest fields straight through, so whatever a
// repository can put in a lockfile reaches these constructors.
func FuzzPackageOrigin(f *testing.F) {
func FuzzDependencyOrigin(f *testing.F) {
f.Add("https://registry.npmjs.org/react/-/react-18.2.0.tgz", "")
f.Add("https://github.com/owner/repo.git", "9f8e7d6c5b4a3928176554433221100ffeeddcc0")
f.Add("https://github.com/example/helper?rev=main#abc123", "v1.2.3")
Expand All @@ -172,29 +172,18 @@ func FuzzPackageOrigin(f *testing.F) {
artifact, repository := ArtifactOrigin(rawURL), RepositoryOrigin(rawURL, revision)
assertPublishableOrigin(t, artifact)
assertPublishableOrigin(t, repository)
// Reading back what was written must reach the same conclusion, and
// reconciling a record with itself must not change it.
// Reading back what was written must reach the same conclusion.
assertPublishableOrigin(t, repository.Normalized())
// Normalizing an already-normalized origin must be a fixed point.
if once, twice := repository.Normalized(), repository.Normalized().Normalized(); !sameOrigin(once, twice) {
t.Fatalf("normalizing twice changed the origin: %+v then %+v", once, twice)
}
if settled, again := ReconcileOrigin(repository, repository), repository.Normalized(); !sameOrigin(settled, again) {
t.Fatalf("reconciling a record with itself changed it: %+v then %+v", again, settled)
}
// A disagreement is recorded rather than resolved, whatever the inputs.
normalized := artifact.Normalized()
if other := ArtifactOrigin("https://registry.example.test/other/pkg-1.0.0.tgz"); normalized != nil && normalized.ArtifactURL != other.ArtifactURL {
if settled := ReconcileOrigin(artifact, other); !settled.Empty() {
t.Fatalf("two different origins settled on %+v", settled)
}
}
})
}

// assertPublishableOrigin fails when an origin carries anything a published
// document must never show.
func assertPublishableOrigin(t *testing.T, origin *PackageOrigin) {
func assertPublishableOrigin(t *testing.T, origin *DependencyOrigin) {
t.Helper()
normalized := origin.Normalized()
if normalized == nil {
Expand Down Expand Up @@ -242,7 +231,7 @@ func assertPublishableOrigin(t *testing.T, origin *PackageOrigin) {
}

// sameOrigin compares two origins that may be nil.
func sameOrigin(left, right *PackageOrigin) bool {
func sameOrigin(left, right *DependencyOrigin) bool {
switch {
case left == nil && right == nil:
return true
Expand Down
16 changes: 5 additions & 11 deletions matcherkit/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,15 @@ func RegistryPackagesForGraph(g *sdk.Graph, reg *sdk.PackageRegistry, target *sd
}
dep.PackageRef = purl

pkg, ok := reg.Get(purl)
if !ok {
pkg = reg.Add(sdk.PackageFromDependency(dep))
} else if pkg != nil {
// A package is enriched once, but every occurrence of it still
// gets a say about where it came from: two manifests resolving
// one package from different places disagree, and the package
// must not report whichever was walked first.
pkg.Origin = sdk.ReconcileOrigin(pkg.Origin, dep.Origin)
}

if _, alreadySeen := seen[purl]; alreadySeen {
continue
}
seen[purl] = struct{}{}

pkg, ok := reg.Get(purl)
if !ok {
pkg = reg.Add(sdk.PackageFromDependency(dep))
}
if pkg != nil {
out = append(out, pkg)
}
Expand Down
72 changes: 0 additions & 72 deletions matcherkit/registry_origin_test.go

This file was deleted.

Loading
Loading