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
66 changes: 32 additions & 34 deletions cmd/bear.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import (
"time"

cdx "github.com/CycloneDX/cyclonedx-go"
purl "github.com/package-url/packageurl-go"
"github.com/spf13/cobra"
)

Expand All @@ -37,6 +36,14 @@ var (
bearApiKey string
skipPatterns []string
resilientBearDns bool
// Full purls skipped by exact (case-insensitive) match rather than the
// substring semantics of skipPatterns. Currently only the BOM's own
// metadata component: exact equality is deliberate -- a substring of the
// root's purl would also skip other versions of the same package
// appearing as real dependencies (pkg:npm/a@1.0 is a substring of
// pkg:npm/a@1.0.1), and a substring of just the NAME would skip every
// component whose purl merely contains it.
rootSkipPurls []string
)

const bearBatchSize = 10
Expand Down Expand Up @@ -274,8 +281,9 @@ func enrichFunc() {
return
}

// Extract metadata component purl and add its name to skipPatterns
addMetadataComponentToSkipPatterns(bom)
// The BOM's own root must not be enriched: register its purl for an
// exact-match skip.
addMetadataComponentSelfSkip(bom)

// Collect components that need enrichment (supplier OR license OR copyright)
var purlsToEnrich []string
Expand Down Expand Up @@ -376,9 +384,15 @@ func enrichFunc() {
}
}

// shouldSkipPurl checks if a purl matches any of the skip patterns (case-insensitive)
// shouldSkipPurl checks if a purl is the BOM root (exact, case-insensitive)
// or matches any of the skip patterns (substring, case-insensitive)
func shouldSkipPurl(purl string) bool {
lowerPurl := strings.ToLower(purl)
for _, root := range rootSkipPurls {
if lowerPurl == strings.ToLower(root) {
return true
}
}
for _, pattern := range skipPatterns {
if strings.Contains(lowerPurl, strings.ToLower(pattern)) {
return true
Expand Down Expand Up @@ -525,42 +539,26 @@ func bearEnrichBatchRequest(purls []string) ([]BearComponent, error) {
return response.Data.EnrichBatch, nil
}

// addMetadataComponentToSkipPatterns extracts the metadata->component->purl if present,
// parses it to get the name, and adds .*<name>.* to skipPatterns
func addMetadataComponentToSkipPatterns(bom *cdx.BOM) {
// Check if metadata exists
if bom.Metadata == nil {
return
}

// Check if component exists
if bom.Metadata.Component == nil {
// addMetadataComponentSelfSkip registers the BOM's own metadata component
// purl for an exact-match skip, so the root never gets enriched as if it
// were one of its own dependencies.
//
// This replaces a pattern of the form ".*<name>.*" appended to skipPatterns:
// those are matched by substring, never as a regex, so the old pattern could
// only match a purl containing the literal characters ".*" -- the root
// self-skip had never actually fired.
func addMetadataComponentSelfSkip(bom *cdx.BOM) {
if bom.Metadata == nil || bom.Metadata.Component == nil {
return
}

// Check if purl exists
if bom.Metadata.Component.PackageURL == "" {
rootPurl := bom.Metadata.Component.PackageURL
if rootPurl == "" {
return
}

// Try to parse the purl
parsedPurl, err := purl.FromString(bom.Metadata.Component.PackageURL)
if err != nil {
// Silently fail - just don't add skip pattern
return
}

// Extract name from parsed purl
if parsedPurl.Name == "" {
return
}

// Add .*<name>.* pattern to skipPatterns
pattern := ".*" + parsedPurl.Name + ".*"
skipPatterns = append(skipPatterns, pattern)
rootSkipPurls = append(rootSkipPurls, rootPurl)

if debug == "true" {
fmt.Printf("Auto-added skip pattern from metadata component: %s\n", pattern)
fmt.Printf("Auto-added root self-skip from metadata component: %s\n", rootPurl)
}
}

Expand Down
88 changes: 88 additions & 0 deletions cmd/bear_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cmd

import (
"testing"

cdx "github.com/CycloneDX/cyclonedx-go"
)

func resetSkipState() {
skipPatterns = nil
rootSkipPurls = nil
}

// The root self-skip must match the metadata component's purl exactly. The
// previous implementation appended ".*<name>.*" to skipPatterns — regex
// syntax fed into a substring matcher — which could only ever match a purl
// containing the literal characters ".*", so the feature had never fired.
func TestRootSelfSkipMatchesExactPurl(t *testing.T) {
resetSkipState()
defer resetSkipState()

bom := &cdx.BOM{
Metadata: &cdx.Metadata{
Component: &cdx.Component{PackageURL: "pkg:npm/myapp@1.2.3"},
},
}
addMetadataComponentSelfSkip(bom)

if !shouldSkipPurl("pkg:npm/myapp@1.2.3") {
t.Error("the BOM root's own purl must be skipped")
}
if !shouldSkipPurl("PKG:NPM/MyApp@1.2.3") {
t.Error("root skip must be case-insensitive, matching the pattern matcher")
}
}

// Exact, not substring: other versions of the root's package appearing as
// real dependencies must still be enriched, as must packages whose purl
// merely contains the root's name.
func TestRootSelfSkipDoesNotOverSkip(t *testing.T) {
resetSkipState()
defer resetSkipState()

bom := &cdx.BOM{
Metadata: &cdx.Metadata{
Component: &cdx.Component{PackageURL: "pkg:npm/app@1.0"},
},
}
addMetadataComponentSelfSkip(bom)

if shouldSkipPurl("pkg:npm/app@1.0.1") {
t.Error("a different version of the root package is a real dependency, not the root")
}
if shouldSkipPurl("pkg:npm/application@2.0") {
t.Error("a package whose purl merely contains the root name must not be skipped")
}
}

func TestRootSelfSkipToleratesMissingMetadata(t *testing.T) {
resetSkipState()
defer resetSkipState()

addMetadataComponentSelfSkip(&cdx.BOM{})
addMetadataComponentSelfSkip(&cdx.BOM{Metadata: &cdx.Metadata{}})
addMetadataComponentSelfSkip(&cdx.BOM{
Metadata: &cdx.Metadata{Component: &cdx.Component{}},
})

if len(rootSkipPurls) != 0 {
t.Errorf("no root purl should be registered, got %v", rootSkipPurls)
}
}

func TestSkipPatternsRemainSubstringAndCaseInsensitive(t *testing.T) {
resetSkipState()
defer resetSkipState()

skipPatterns = []string{"pkg:generic/"}
if !shouldSkipPurl("pkg:generic/some-file.h?path=%2Fusr%2Finclude") {
t.Error("configured substring pattern must match")
}
if !shouldSkipPurl("PKG:GENERIC/OTHER") {
t.Error("substring matching must stay case-insensitive")
}
if shouldSkipPurl("pkg:npm/left-pad@1.3.0") {
t.Error("non-matching purl must not be skipped")
}
}
21 changes: 19 additions & 2 deletions cmd/templating.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,15 @@ func validateAndParseBitnamiLines(bitnamiLineCache *[]string, sortedSubstitution
}
}

if len(replacedSubst.Digest) == 0 {
// The block has the shape of a split image, but the tag source has
// nothing for this image. Hand it back unclaimed so the caller
// parses it line by line like any other block -- returning an empty
// slice while still reporting a match silently deleted the whole
// block from the output.
return nil, false
}

if len(replacedSubst.Digest) > 0 {
for _, line := range *bitnamiLineCache {
trimmedLine := strings.Trim(line, " ")
Expand All @@ -254,8 +263,16 @@ func validateAndParseBitnamiLines(bitnamiLineCache *[]string, sortedSubstitution
// Full Bitnami format with separate digest/sha field - tag only
parsedLines = append(parsedLines, prefix+" "+replacedSubst.Tag)
} else if isTagAsDigest {
// No separate digest/sha field - combine tag@digest
parsedLines = append(parsedLines, prefix+" "+replacedSubst.Tag+"@"+replacedSubst.Digest)
// No separate digest/sha field - combine tag@digest.
// With no tag to combine, write the bare digest: charts
// that take a digest through the tag field accept
// "sha256:..." on its own, whereas "@sha256:..." is not
// a valid reference.
if len(replacedSubst.Tag) > 0 {
parsedLines = append(parsedLines, prefix+" "+replacedSubst.Tag+"@"+replacedSubst.Digest)
} else {
parsedLines = append(parsedLines, prefix+" "+replacedSubst.Digest)
}
}
} else if strings.HasPrefix(trimmedLine, "digest: ") {
colonIndex := strings.Index(line, ":")
Expand Down
15 changes: 15 additions & 0 deletions tests/expected_values_tag_as_digest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Chart whose subchart takes a digest through the tag field, with no separate
# digest/sha key (e.g. the Dependency-Track v2 chart).
apiServer:
image:
registry: docker.io
repository: dependencytrack/apiserver
tag: 5.0.9@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
pullPolicy: IfNotPresent
topology: monolith
frontend:
image:
registry: docker.io
repository: dependencytrack/frontend
tag: 5.0.3@sha256:c8bb3f1b806f9ab2a854eec577a97a177d13befd488757f3ed61810d48ae6d9b
pullPolicy: IfNotPresent
15 changes: 15 additions & 0 deletions tests/expected_values_tag_as_digest_no_tag.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Chart whose subchart takes a digest through the tag field, with no separate
# digest/sha key (e.g. the Dependency-Track v2 chart).
apiServer:
image:
registry: docker.io
repository: dependencytrack/apiserver
tag: sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
pullPolicy: IfNotPresent
topology: monolith
frontend:
image:
registry: docker.io
repository: dependencytrack/frontend
tag: 5.0.3@sha256:c8bb3f1b806f9ab2a854eec577a97a177d13befd488757f3ed61810d48ae6d9b
pullPolicy: IfNotPresent
41 changes: 41 additions & 0 deletions tests/replacetags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,44 @@ func TestReplaceTagsBitnamiShaField(t *testing.T) {
t.Fatalf("replaced tags do not equal expected, actual = %s", replacedTags)
}
}

// A chart whose subchart takes the digest through the tag field, with no
// separate digest/sha key. The tag source deliberately covers only one of the
// two images: the matched block is rewritten, and the unmatched one must come
// through untouched. It used to be deleted outright, silently dropping the
// image override and falling back to whatever the subchart shipped.
func TestReplaceTagsTagAsDigest(t *testing.T) {
var replaceTagsVars cmd.ReplaceTagsVars
replaceTagsVars.TagSourceFile = "tag_as_digest_source.txt"
replaceTagsVars.TypeVal = "text"
replaceTagsVars.Infile = "values_tag_as_digest.yaml"

replacedTags := cmd.ReplaceTags(replaceTagsVars)
expectedReplacement, err := os.ReadFile("expected_values_tag_as_digest.yaml")
if err != nil {
t.Fatalf("failed reading expected values file")
}
if replacedTags != string(expectedReplacement) {
t.Fatalf("replaced tags do not equal expected, actual = %s", replacedTags)
}
}

// Same shape, but the source reference carries a digest and no tag. There is
// nothing to combine, so the bare digest goes in on its own -- charts that
// accept a digest through the tag field understand "sha256:...", whereas the
// "@sha256:..." this used to produce is not a valid reference.
func TestReplaceTagsTagAsDigestWithoutTag(t *testing.T) {
var replaceTagsVars cmd.ReplaceTagsVars
replaceTagsVars.TagSourceFile = "tag_as_digest_no_tag_source.txt"
replaceTagsVars.TypeVal = "text"
replaceTagsVars.Infile = "values_tag_as_digest.yaml"

replacedTags := cmd.ReplaceTags(replaceTagsVars)
expectedReplacement, err := os.ReadFile("expected_values_tag_as_digest_no_tag.yaml")
if err != nil {
t.Fatalf("failed reading expected values file")
}
if replacedTags != string(expectedReplacement) {
t.Fatalf("replaced tags do not equal expected, actual = %s", replacedTags)
}
}
1 change: 1 addition & 0 deletions tests/tag_as_digest_no_tag_source.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
docker.io/dependencytrack/apiserver@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
1 change: 1 addition & 0 deletions tests/tag_as_digest_source.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
docker.io/dependencytrack/apiserver:5.0.9@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
15 changes: 15 additions & 0 deletions tests/values_tag_as_digest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Chart whose subchart takes a digest through the tag field, with no separate
# digest/sha key (e.g. the Dependency-Track v2 chart).
apiServer:
image:
registry: docker.io
repository: dependencytrack/apiserver
tag: 5.0.3@sha256:0cf15de2649d75ce4e7736b428d203f9630665d8bc843d15f0c6c5b23b56ada7
pullPolicy: IfNotPresent
topology: monolith
frontend:
image:
registry: docker.io
repository: dependencytrack/frontend
tag: 5.0.3@sha256:c8bb3f1b806f9ab2a854eec577a97a177d13befd488757f3ed61810d48ae6d9b
pullPolicy: IfNotPresent
2 changes: 1 addition & 1 deletion trigger_build
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6
7
Loading