Split the sbom.Format interface by encode and decode use cases (#2186)

* split up sbom.Format into encode and decode ops

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* update cmd pkg to inject format configs

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* bump cyclonedx schema to 1.5

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* redact image metadata from github encoder tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* add more testing around format decoder identify

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* add test case for format version options

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix cli tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix CLI test

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* [wip] - review comments

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* keep encoder creation out of post load function

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* keep decider and identify functions

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* add a few more doc comments

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* remove format encoder default function helpers

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* address PR feedback

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* move back to streaming based decode functions

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* with common convention for encoder constructors

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix tests and allow for encoders to be created from cli options

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix cli tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix linting

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* buffer reads from stdin to support seeking

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2023-10-25 13:43:06 +00:00
committed by GitHub
parent 7315f83f9d
commit 7392d607b6
254 changed files with 10450 additions and 2714 deletions
-18
View File
@@ -1,18 +0,0 @@
package syft
import (
"io"
"github.com/anchore/syft/syft/formats"
"github.com/anchore/syft/syft/sbom"
)
// TODO: deprecated, moved to syft/formats/formats.go. will be removed in v1.0.0
func Encode(s sbom.SBOM, f sbom.Format) ([]byte, error) {
return formats.Encode(s, f)
}
// TODO: deprecated, moved to syft/formats/formats.go. will be removed in v1.0.0
func Decode(reader io.Reader) (*sbom.SBOM, sbom.Format, error) {
return formats.Decode(reader)
}
@@ -7,7 +7,7 @@ import (
"github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/formats/common"
"github.com/anchore/syft/syft/format/common"
"github.com/anchore/syft/syft/pkg"
)
@@ -2,56 +2,18 @@ package cyclonedxhelpers
import (
"fmt"
"io"
"strings"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/formats/common"
"github.com/anchore/syft/syft/format/common"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
const cycloneDXXmlSchema = "http://cyclonedx.org/schema/bom"
func GetValidator(format cyclonedx.BOMFileFormat) sbom.Validator {
return func(reader io.Reader) error {
bom := &cyclonedx.BOM{}
err := cyclonedx.NewBOMDecoder(reader, format).Decode(bom)
if err != nil {
return err
}
xmlWithoutNS := format == cyclonedx.BOMFileFormatXML && !strings.Contains(bom.XMLNS, cycloneDXXmlSchema)
xmlWithoutComponents := format == cyclonedx.BOMFileFormatXML && bom.Components == nil
if (cyclonedx.BOM{} == *bom || xmlWithoutComponents || xmlWithoutNS) {
return fmt.Errorf("not a valid CycloneDX document")
}
return nil
}
}
func GetDecoder(format cyclonedx.BOMFileFormat) sbom.Decoder {
return func(reader io.Reader) (*sbom.SBOM, error) {
bom := &cyclonedx.BOM{
Components: &[]cyclonedx.Component{},
}
err := cyclonedx.NewBOMDecoder(reader, format).Decode(bom)
if err != nil {
return nil, err
}
s, err := ToSyftModel(bom)
if err != nil {
return nil, err
}
return s, nil
}
}
func ToSyftModel(bom *cyclonedx.BOM) (*sbom.SBOM, error) {
if bom == nil {
return nil, fmt.Errorf("no content defined in CycloneDX BOM")
@@ -1,8 +1,6 @@
package cyclonedxhelpers
import (
"bytes"
"encoding/json"
"fmt"
"testing"
@@ -327,18 +325,6 @@ func Test_missingDataDecode(t *testing.T) {
assert.Equal(t, pkg.Licenses.Empty(), true)
}
func Test_missingComponentsDecode(t *testing.T) {
bom := &cyclonedx.BOM{
SpecVersion: cyclonedx.SpecVersion1_4,
}
bomBytes, _ := json.Marshal(&bom)
decode := GetDecoder(cyclonedx.BOMFileFormatJSON)
_, err := decode(bytes.NewReader(bomBytes))
assert.NoError(t, err)
}
func Test_decodeDependencies(t *testing.T) {
c1 := cyclonedx.Component{
Name: "c1",
@@ -3,7 +3,7 @@ package cyclonedxhelpers
import (
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/syft/formats/common"
"github.com/anchore/syft/syft/format/common"
)
var (
@@ -19,7 +19,7 @@ import (
"github.com/anchore/syft/internal/spdxlicense"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/formats/common/util"
"github.com/anchore/syft/syft/format/common/util"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
@@ -18,7 +18,7 @@ import (
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cpe"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/formats/common/util"
"github.com/anchore/syft/syft/format/common/util"
"github.com/anchore/syft/syft/license"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
+131
View File
@@ -0,0 +1,131 @@
package cyclonedxjson
import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/format/common/cyclonedxhelpers"
"github.com/anchore/syft/syft/format/internal/cyclonedxutil"
"github.com/anchore/syft/syft/sbom"
)
var _ sbom.FormatDecoder = (*decoder)(nil)
type decoder struct {
decoder cyclonedxutil.Decoder
}
func NewFormatDecoder() sbom.FormatDecoder {
return decoder{
decoder: cyclonedxutil.NewDecoder(cyclonedx.BOMFileFormatJSON),
}
}
func (d decoder) Decode(reader io.ReadSeeker) (*sbom.SBOM, sbom.FormatID, string, error) {
if reader == nil {
return nil, "", "", fmt.Errorf("no SBOM bytes provided")
}
id, version := d.Identify(reader)
if id != ID {
return nil, "", "", fmt.Errorf("not a cyclonedx json document")
}
if version == "" {
return nil, "", "", fmt.Errorf("unsupported cyclonedx json document version")
}
doc, err := d.decoder.Decode(reader)
if err != nil {
return nil, id, version, fmt.Errorf("unable to decode cyclonedx json document: %w", err)
}
s, err := cyclonedxhelpers.ToSyftModel(doc)
if err != nil {
return nil, id, version, err
}
return s, id, version, nil
}
func (d decoder) Identify(reader io.ReadSeeker) (sbom.FormatID, string) {
if reader == nil {
return "", ""
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
log.Debugf("unable to seek to start of CycloneDX JSON SBOM: %+v", err)
return "", ""
}
type Document struct {
JSONSchema string `json:"$schema"`
BOMFormat string `json:"bomFormat"`
SpecVersion string `json:"specVersion"`
}
dec := json.NewDecoder(reader)
var doc Document
err := dec.Decode(&doc)
if err != nil {
// maybe not json? maybe not valid? doesn't matter, we won't process it.
return "", ""
}
id, version := getFormatInfo(doc.JSONSchema, doc.BOMFormat, doc.SpecVersion)
if version == "" || id != ID {
// not a cyclonedx json document that we support
return "", ""
}
return id, version
}
func getFormatInfo(schemaURI, bomFormat string, specVersion any) (sbom.FormatID, string) {
if !strings.Contains(schemaURI, "cyclonedx.org/schema/bom") {
// not a cyclonedx json document
return "", ""
}
if bomFormat != "CycloneDX" {
// not a cyclonedx json document
return "", ""
}
// by this point this looks to be valid cyclonedx json, but we need to know the version
var (
version string
spec cyclonedx.SpecVersion
err error
)
switch s := specVersion.(type) {
case string:
version = s
spec, err = cyclonedxutil.SpecVersionFromString(version)
if err != nil {
// not a supported version, but is cyclonedx json
return ID, ""
}
case cyclonedx.SpecVersion:
spec = s
version = cyclonedxutil.VersionFromSpecVersion(spec)
if version == "" {
// not a supported version, but is cyclonedx json
return ID, ""
}
default:
// bad input provided for version info
return ID, ""
}
if spec < 0 {
// not a supported version, but is cyclonedx json
return ID, ""
}
return ID, version
}
+118
View File
@@ -0,0 +1,118 @@
package cyclonedxjson
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/sbom"
)
func TestDecoder_Decode(t *testing.T) {
tests := []struct {
name string
file string
err bool
distro string
packages []string
}{
{
name: "dir-scan",
file: "snapshot/TestCycloneDxDirectoryEncoder.golden",
distro: "debian:1.2.3",
packages: []string{"package-1:1.0.1", "package-2:2.0.1"},
},
{
name: "image-scan",
file: "snapshot/TestCycloneDxImageEncoder.golden",
distro: "debian:1.2.3",
packages: []string{"package-1:1.0.1", "package-2:2.0.1"},
},
{
name: "not-an-sbom",
file: "bad-sbom",
err: true,
},
}
for _, test := range tests {
t.Run(test.file, func(t *testing.T) {
reader, err := os.Open(filepath.Join("test-fixtures", test.file))
require.NoError(t, err)
dec := NewFormatDecoder()
formatID, formatVersion := dec.Identify(reader)
if test.err {
assert.Equal(t, sbom.FormatID(""), formatID)
assert.Equal(t, "", formatVersion)
_, decodeID, decodeVersion, err := dec.Decode(reader)
require.Error(t, err)
assert.Equal(t, sbom.FormatID(""), decodeID)
assert.Equal(t, "", decodeVersion)
return
}
assert.Equal(t, ID, formatID)
assert.NotEmpty(t, formatVersion)
bom, decodeID, decodeVersion, err := dec.Decode(reader)
require.NotNil(t, bom)
require.NoError(t, err)
assert.Equal(t, ID, decodeID)
assert.Equal(t, formatVersion, decodeVersion)
split := strings.SplitN(test.distro, ":", 2)
distroName := split[0]
distroVersion := split[1]
assert.Equal(t, bom.Artifacts.LinuxDistribution.ID, distroName)
assert.Equal(t, bom.Artifacts.LinuxDistribution.Version, distroVersion)
var pkgs []string
for p := range bom.Artifacts.Packages.Enumerate() {
pkgs = append(pkgs, fmt.Sprintf("%s:%s", p.Name, p.Version))
}
assert.ElementsMatch(t, test.packages, pkgs)
})
}
}
func TestDecoder_Identify(t *testing.T) {
type testCase struct {
name string
file string
id sbom.FormatID
version string
}
var cases []testCase
for _, version := range SupportedVersions() {
cases = append(cases, testCase{
name: fmt.Sprintf("v%s schema", version),
file: fmt.Sprintf("test-fixtures/identify/%s.json", version),
id: ID,
version: version,
})
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
reader, err := os.Open(test.file)
require.NoError(t, err)
dec := NewFormatDecoder()
formatID, formatVersion := dec.Identify(reader)
assert.Equal(t, test.id, formatID)
assert.Equal(t, test.version, formatVersion)
})
}
}
+52
View File
@@ -0,0 +1,52 @@
package cyclonedxjson
import (
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/syft/format/internal/cyclonedxutil"
"github.com/anchore/syft/syft/sbom"
)
const ID = cyclonedxutil.JSONFormatID
func SupportedVersions() []string {
return cyclonedxutil.SupportedVersions(ID)
}
type EncoderConfig struct {
Version string
}
type encoder struct {
cfg EncoderConfig
cyclonedxutil.Encoder
}
func NewFormatEncoderWithConfig(cfg EncoderConfig) (sbom.FormatEncoder, error) {
enc, err := cyclonedxutil.NewEncoder(cfg.Version, cyclonedx.BOMFileFormatJSON)
if err != nil {
return nil, err
}
return encoder{
cfg: cfg,
Encoder: enc,
}, nil
}
func DefaultEncoderConfig() EncoderConfig {
return EncoderConfig{
Version: cyclonedxutil.DefaultVersion,
}
}
func (e encoder) ID() sbom.FormatID {
return ID
}
func (e encoder) Aliases() []string {
return []string{}
}
func (e encoder) Version() string {
return e.cfg.Version
}
+132
View File
@@ -0,0 +1,132 @@
package cyclonedxjson
import (
"bytes"
"flag"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/format/internal/testutil"
"github.com/anchore/syft/syft/sbom"
)
var updateSnapshot = flag.Bool("update-cyclonedx-json", false, "update the *.golden files for cyclone-dx JSON encoders")
var updateImage = flag.Bool("update-image", false, "update the golden image used for image encoder testing")
func getEncoder(t testing.TB) sbom.FormatEncoder {
enc, err := NewFormatEncoderWithConfig(DefaultEncoderConfig())
require.NoError(t, err)
return enc
}
func TestCycloneDxDirectoryEncoder(t *testing.T) {
dir := t.TempDir()
testutil.AssertEncoderAgainstGoldenSnapshot(t,
testutil.EncoderSnapshotTestConfig{
Subject: testutil.DirectoryInput(t, dir),
Format: getEncoder(t),
UpdateSnapshot: *updateSnapshot,
PersistRedactionsInSnapshot: true,
IsJSON: true,
Redactor: redactor(dir),
},
)
}
func TestCycloneDxImageEncoder(t *testing.T) {
testImage := "image-simple"
testutil.AssertEncoderAgainstGoldenImageSnapshot(t,
testutil.ImageSnapshotTestConfig{
Image: testImage,
UpdateImageSnapshot: *updateImage,
},
testutil.EncoderSnapshotTestConfig{
Subject: testutil.ImageInput(t, testImage),
Format: getEncoder(t),
UpdateSnapshot: *updateSnapshot,
PersistRedactionsInSnapshot: true,
IsJSON: true,
Redactor: redactor(),
},
)
}
func redactor(values ...string) testutil.Redactor {
return testutil.NewRedactions().
WithValuesRedacted(values...).
WithPatternRedactors(
map[string]string{
// UUIDs
`urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`: `urn:uuid:redacted`,
// timestamps
`([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\.[0-9]+)?(([Zz])|([+|\-]([01][0-9]|2[0-3]):[0-5][0-9]))`: `timestamp:redacted`,
// image hashes
`sha256:[A-Fa-f0-9]{64}`: `sha256:redacted`,
// BOM refs
`"bom-ref":\s*"[^"]+"`: `"bom-ref":"redacted"`,
},
)
}
func TestSupportedVersions(t *testing.T) {
encs := defaultFormatEncoders()
require.NotEmpty(t, encs)
versions := SupportedVersions()
require.Equal(t, len(versions), len(encs))
subject := testutil.DirectoryInput(t, t.TempDir())
dec := NewFormatDecoder()
for _, enc := range encs {
t.Run(enc.Version(), func(t *testing.T) {
require.Contains(t, versions, enc.Version())
var buf bytes.Buffer
require.NoError(t, enc.Encode(&buf, subject))
id, version := dec.Identify(bytes.NewReader(buf.Bytes()))
require.Equal(t, enc.ID(), id)
require.Equal(t, enc.Version(), version)
var s *sbom.SBOM
var err error
s, id, version, err = dec.Decode(bytes.NewReader(buf.Bytes()))
require.NoError(t, err)
require.Equal(t, enc.ID(), id)
require.Equal(t, enc.Version(), version)
require.NotEmpty(t, s.Artifacts.Packages.PackageCount())
assert.Equal(t, len(subject.Relationships), len(s.Relationships), "mismatched relationship count")
if !assert.Equal(t, subject.Artifacts.Packages.PackageCount(), s.Artifacts.Packages.PackageCount(), "mismatched package count") {
t.Logf("expected: %d", subject.Artifacts.Packages.PackageCount())
for _, p := range subject.Artifacts.Packages.Sorted() {
t.Logf(" - %s", p.String())
}
t.Logf("actual: %d", s.Artifacts.Packages.PackageCount())
for _, p := range s.Artifacts.Packages.Sorted() {
t.Logf(" - %s", p.String())
}
}
})
}
}
func defaultFormatEncoders() []sbom.FormatEncoder {
var encs []sbom.FormatEncoder
for _, version := range SupportedVersions() {
enc, err := NewFormatEncoderWithConfig(EncoderConfig{Version: version})
if err != nil {
panic(err)
}
encs = append(encs, enc)
}
return encs
}
@@ -0,0 +1 @@
not an sbom!
@@ -0,0 +1,33 @@
{
"$schema": "http://cyclonedx.org/schema/bom-1.2.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.2",
"serialNumber": "urn:uuid:8cfb48be-7ed8-4cea-8c23-8992e98d12be",
"version": 1,
"metadata": {
"timestamp": "2023-09-29T12:02:02-04:00",
"tools": [
{
"vendor": "anchore",
"name": "syft",
"version": "[not provided]"
}
],
"component": {
"bom-ref": "a0ff99a6af10f11f",
"type": "file",
"name": "go.mod",
"version": "sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd"
}
},
"components": [
{
"bom-ref": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86",
"type": "library",
"name": "github.com/wagoodman/go-partybus",
"version": "v0.0.0-20230516145632-8ccac152c651",
"cpe": "cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*",
"purl": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651"
}
]
}
@@ -0,0 +1,59 @@
{
"$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.3",
"serialNumber": "urn:uuid:57ce2636-4ca6-46ba-b389-196af071d6fc",
"version": 1,
"metadata": {
"timestamp": "2023-09-29T12:02:02-04:00",
"tools": [
{
"vendor": "anchore",
"name": "syft",
"version": "[not provided]"
}
],
"component": {
"bom-ref": "a0ff99a6af10f11f",
"type": "file",
"name": "go.mod",
"version": "sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd"
}
},
"components": [
{
"bom-ref": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86",
"type": "library",
"name": "github.com/wagoodman/go-partybus",
"version": "v0.0.0-20230516145632-8ccac152c651",
"cpe": "cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*",
"purl": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651",
"properties": [
{
"name": "syft:package:foundBy",
"value": "go-mod-file-cataloger"
},
{
"name": "syft:package:language",
"value": "go"
},
{
"name": "syft:package:metadataType",
"value": "GolangModMetadata"
},
{
"name": "syft:package:type",
"value": "go-module"
},
{
"name": "syft:cpe23",
"value": "cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*"
},
{
"name": "syft:location:0:path",
"value": "/go.mod"
}
]
}
]
}
@@ -0,0 +1,59 @@
{
"$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": "urn:uuid:6be7491f-6a9b-40f0-a60e-f74e2c0e23c3",
"version": 1,
"metadata": {
"timestamp": "2023-09-29T12:02:02-04:00",
"tools": [
{
"vendor": "anchore",
"name": "syft",
"version": "[not provided]"
}
],
"component": {
"bom-ref": "a0ff99a6af10f11f",
"type": "file",
"name": "go.mod",
"version": "sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd"
}
},
"components": [
{
"bom-ref": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86",
"type": "library",
"name": "github.com/wagoodman/go-partybus",
"version": "v0.0.0-20230516145632-8ccac152c651",
"cpe": "cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*",
"purl": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651",
"properties": [
{
"name": "syft:package:foundBy",
"value": "go-mod-file-cataloger"
},
{
"name": "syft:package:language",
"value": "go"
},
{
"name": "syft:package:metadataType",
"value": "GolangModMetadata"
},
{
"name": "syft:package:type",
"value": "go-module"
},
{
"name": "syft:cpe23",
"value": "cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*"
},
{
"name": "syft:location:0:path",
"value": "/go.mod"
}
]
}
]
}
@@ -0,0 +1,59 @@
{
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"serialNumber": "urn:uuid:22c456c5-c2a0-4285-a8ef-01b4382822c3",
"version": 1,
"metadata": {
"timestamp": "2023-09-29T12:02:02-04:00",
"tools": [
{
"vendor": "anchore",
"name": "syft",
"version": "[not provided]"
}
],
"component": {
"bom-ref": "a0ff99a6af10f11f",
"type": "file",
"name": "go.mod",
"version": "sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd"
}
},
"components": [
{
"bom-ref": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86",
"type": "library",
"name": "github.com/wagoodman/go-partybus",
"version": "v0.0.0-20230516145632-8ccac152c651",
"cpe": "cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*",
"purl": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651",
"properties": [
{
"name": "syft:package:foundBy",
"value": "go-mod-file-cataloger"
},
{
"name": "syft:package:language",
"value": "go"
},
{
"name": "syft:package:metadataType",
"value": "GolangModMetadata"
},
{
"name": "syft:package:type",
"value": "go-module"
},
{
"name": "syft:cpe23",
"value": "cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*"
},
{
"name": "syft:location:0:path",
"value": "/go.mod"
}
]
}
]
}
@@ -1,7 +1,7 @@
{
"$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json",
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"specVersion": "1.5",
"serialNumber": "urn:uuid:redacted",
"version": 1,
"metadata": {
@@ -1,7 +1,7 @@
{
"$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json",
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"specVersion": "1.5",
"serialNumber": "urn:uuid:redacted",
"version": 1,
"metadata": {
+106
View File
@@ -0,0 +1,106 @@
package cyclonedxxml
import (
"encoding/xml"
"fmt"
"io"
"strings"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/format/common/cyclonedxhelpers"
"github.com/anchore/syft/syft/format/internal/cyclonedxutil"
"github.com/anchore/syft/syft/sbom"
)
var _ sbom.FormatDecoder = (*decoder)(nil)
type decoder struct {
decoder cyclonedxutil.Decoder
}
func NewFormatDecoder() sbom.FormatDecoder {
return decoder{
decoder: cyclonedxutil.NewDecoder(cyclonedx.BOMFileFormatXML),
}
}
func (d decoder) Decode(reader io.ReadSeeker) (*sbom.SBOM, sbom.FormatID, string, error) {
if reader == nil {
return nil, "", "", fmt.Errorf("no SBOM bytes provided")
}
id, version := d.Identify(reader)
if id != ID {
return nil, "", "", fmt.Errorf("not a cyclonedx xml document")
}
if version == "" {
return nil, "", "", fmt.Errorf("unsupported cyclonedx xml document version")
}
doc, err := d.decoder.Decode(reader)
if err != nil {
return nil, id, version, fmt.Errorf("unable to decode cyclonedx xml document: %w", err)
}
s, err := cyclonedxhelpers.ToSyftModel(doc)
if err != nil {
return nil, id, version, err
}
return s, id, version, nil
}
func (d decoder) Identify(reader io.ReadSeeker) (sbom.FormatID, string) {
if reader == nil {
return "", ""
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
log.Debugf("unable to seek to start of CycloneDX XML SBOM: %+v", err)
return "", ""
}
type Document struct {
XMLNS string `xml:"xmlns,attr"`
}
dec := xml.NewDecoder(reader)
var doc Document
err := dec.Decode(&doc)
if err != nil {
// maybe not xml? maybe not valid? doesn't matter, we won't process it.
return "", ""
}
id, version := getFormatInfo(doc.XMLNS)
if version == "" || id != ID {
// not a cyclonedx xml document that we support
return "", ""
}
return id, version
}
func getFormatInfo(xmlns string) (sbom.FormatID, string) {
version := getVersionFromXMLNS(xmlns)
if !strings.Contains(xmlns, "cyclonedx.org/schema/bom") {
// not a cyclonedx xml document
return "", ""
}
spec, err := cyclonedxutil.SpecVersionFromString(version)
if spec < 0 || err != nil {
// not a supported version, but is cyclonedx xml
return ID, ""
}
return ID, version
}
func getVersionFromXMLNS(xmlns string) string {
fields := strings.Split(xmlns, "/")
return fields[len(fields)-1]
}
+118
View File
@@ -0,0 +1,118 @@
package cyclonedxxml
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/sbom"
)
func TestDecoder_Decode(t *testing.T) {
tests := []struct {
name string
file string
err bool
distro string
packages []string
}{
{
name: "dir-scan",
file: "snapshot/TestCycloneDxDirectoryEncoder.golden",
distro: "debian:1.2.3",
packages: []string{"package-1:1.0.1", "package-2:2.0.1"},
},
{
name: "image-scan",
file: "snapshot/TestCycloneDxImageEncoder.golden",
distro: "debian:1.2.3",
packages: []string{"package-1:1.0.1", "package-2:2.0.1"},
},
{
name: "not-an-sbom",
file: "bad-sbom",
err: true,
},
}
for _, test := range tests {
t.Run(test.file, func(t *testing.T) {
reader, err := os.Open(filepath.Join("test-fixtures", test.file))
require.NoError(t, err)
dec := NewFormatDecoder()
formatID, formatVersion := dec.Identify(reader)
if test.err {
assert.Equal(t, sbom.FormatID(""), formatID)
assert.Equal(t, "", formatVersion)
_, decodeID, decodeVersion, err := dec.Decode(reader)
require.Error(t, err)
assert.Equal(t, sbom.FormatID(""), decodeID)
assert.Equal(t, "", decodeVersion)
return
}
assert.Equal(t, ID, formatID)
assert.NotEmpty(t, formatVersion)
bom, decodeID, decodeVersion, err := dec.Decode(reader)
require.NotNil(t, bom)
require.NoError(t, err)
assert.Equal(t, ID, decodeID)
assert.Equal(t, formatVersion, decodeVersion)
split := strings.SplitN(test.distro, ":", 2)
distroName := split[0]
distroVersion := split[1]
assert.Equal(t, bom.Artifacts.LinuxDistribution.ID, distroName)
assert.Equal(t, bom.Artifacts.LinuxDistribution.Version, distroVersion)
var pkgs []string
for p := range bom.Artifacts.Packages.Enumerate() {
pkgs = append(pkgs, fmt.Sprintf("%s:%s", p.Name, p.Version))
}
assert.ElementsMatch(t, test.packages, pkgs)
})
}
}
func TestDecoder_Identify(t *testing.T) {
type testCase struct {
name string
file string
id sbom.FormatID
version string
}
var cases []testCase
for _, version := range SupportedVersions() {
cases = append(cases, testCase{
name: fmt.Sprintf("v%s schema", version),
file: fmt.Sprintf("test-fixtures/identify/%s.xml", version),
id: ID,
version: version,
})
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
reader, err := os.Open(test.file)
require.NoError(t, err)
dec := NewFormatDecoder()
formatID, formatVersion := dec.Identify(reader)
assert.Equal(t, test.id, formatID)
assert.Equal(t, test.version, formatVersion)
})
}
}
+58
View File
@@ -0,0 +1,58 @@
package cyclonedxxml
import (
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/syft/format/internal/cyclonedxutil"
"github.com/anchore/syft/syft/sbom"
)
var _ sbom.FormatEncoder = (*encoder)(nil)
const ID = cyclonedxutil.XMLFormatID
func SupportedVersions() []string {
return cyclonedxutil.SupportedVersions(ID)
}
type EncoderConfig struct {
Version string
}
type encoder struct {
cfg EncoderConfig
cyclonedxutil.Encoder
}
func NewFormatEncoderWithConfig(cfg EncoderConfig) (sbom.FormatEncoder, error) {
enc, err := cyclonedxutil.NewEncoder(cfg.Version, cyclonedx.BOMFileFormatXML)
if err != nil {
return nil, err
}
return encoder{
cfg: cfg,
Encoder: enc,
}, nil
}
func DefaultEncoderConfig() EncoderConfig {
return EncoderConfig{
Version: cyclonedxutil.DefaultVersion,
}
}
func (e encoder) ID() sbom.FormatID {
return ID
}
func (e encoder) Aliases() []string {
return []string{
"cyclonedx",
"cyclone",
"cdx",
}
}
func (e encoder) Version() string {
return e.cfg.Version
}
+129
View File
@@ -0,0 +1,129 @@
package cyclonedxxml
import (
"bytes"
"flag"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/format/internal/testutil"
"github.com/anchore/syft/syft/sbom"
)
var updateSnapshot = flag.Bool("update-cyclonedx-xml", false, "update the *.golden files for cyclone-dx XML encoders")
var updateImage = flag.Bool("update-image", false, "update the golden image used for image encoder testing")
func getEncoder(t testing.TB) sbom.FormatEncoder {
enc, err := NewFormatEncoderWithConfig(DefaultEncoderConfig())
require.NoError(t, err)
return enc
}
func TestCycloneDxDirectoryEncoder(t *testing.T) {
dir := t.TempDir()
testutil.AssertEncoderAgainstGoldenSnapshot(t,
testutil.EncoderSnapshotTestConfig{
Subject: testutil.DirectoryInput(t, dir),
Format: getEncoder(t),
UpdateSnapshot: *updateSnapshot,
PersistRedactionsInSnapshot: true,
IsJSON: false,
Redactor: redactor(dir),
},
)
}
func TestCycloneDxImageEncoder(t *testing.T) {
testImage := "image-simple"
testutil.AssertEncoderAgainstGoldenImageSnapshot(t,
testutil.ImageSnapshotTestConfig{
Image: testImage,
UpdateImageSnapshot: *updateImage,
},
testutil.EncoderSnapshotTestConfig{
Subject: testutil.ImageInput(t, testImage),
Format: getEncoder(t),
UpdateSnapshot: *updateSnapshot,
PersistRedactionsInSnapshot: true,
IsJSON: false,
Redactor: redactor(),
},
)
}
func redactor(values ...string) testutil.Redactor {
return testutil.NewRedactions().
WithValuesRedacted(values...).
WithPatternRedactors(
map[string]string{
// dates
`([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\.[0-9]+)?(([Zz])|([+|\-]([01][0-9]|2[0-3]):[0-5][0-9]))`: `redacted`,
// image hashes and BOM refs
`sha256:[A-Za-z0-9]{64}`: `sha256:redacted`,
// serial numbers and BOM refs
`(serialNumber|bom-ref)="[^"]+"`: `$1="redacted"`,
},
)
}
func TestSupportedVersions(t *testing.T) {
encs := defaultFormatEncoders()
require.NotEmpty(t, encs)
versions := SupportedVersions()
require.Equal(t, len(versions), len(encs))
subject := testutil.DirectoryInput(t, t.TempDir())
dec := NewFormatDecoder()
for _, enc := range encs {
t.Run(enc.Version(), func(t *testing.T) {
require.Contains(t, versions, enc.Version())
var buf bytes.Buffer
require.NoError(t, enc.Encode(&buf, subject))
id, version := dec.Identify(bytes.NewReader(buf.Bytes()))
require.Equal(t, enc.ID(), id)
require.Equal(t, enc.Version(), version)
var s *sbom.SBOM
var err error
s, id, version, err = dec.Decode(bytes.NewReader(buf.Bytes()))
require.NoError(t, err)
require.Equal(t, enc.ID(), id)
require.Equal(t, enc.Version(), version)
require.NotEmpty(t, s.Artifacts.Packages.PackageCount())
assert.Equal(t, len(subject.Relationships), len(s.Relationships), "mismatched relationship count")
if !assert.Equal(t, subject.Artifacts.Packages.PackageCount(), s.Artifacts.Packages.PackageCount(), "mismatched package count") {
t.Logf("expected: %d", subject.Artifacts.Packages.PackageCount())
for _, p := range subject.Artifacts.Packages.Sorted() {
t.Logf(" - %s", p.String())
}
t.Logf("actual: %d", s.Artifacts.Packages.PackageCount())
for _, p := range s.Artifacts.Packages.Sorted() {
t.Logf(" - %s", p.String())
}
}
})
}
}
func defaultFormatEncoders() []sbom.FormatEncoder {
var encs []sbom.FormatEncoder
for _, version := range SupportedVersions() {
enc, err := NewFormatEncoderWithConfig(EncoderConfig{Version: version})
if err != nil {
panic(err)
}
encs = append(encs, enc)
}
return encs
}
@@ -0,0 +1 @@
not an sbom!
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.0" version="1">
<components>
<component type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
<modified>false</modified>
</component>
</components>
</bom>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.1" serialNumber="urn:uuid:32ad4aec-ddc3-4c26-90e7-796f7ca82a1d" version="1">
<components>
<component bom-ref="pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86" type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
</component>
</components>
</bom>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" serialNumber="urn:uuid:214033a9-6fe2-4f0b-88f2-8dbca251d0e0" version="1">
<metadata>
<timestamp>2023-09-29T11:48:10-04:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>syft</name>
<version>[not provided]</version>
</tool>
</tools>
<component bom-ref="a0ff99a6af10f11f" type="file">
<name>go.mod</name>
<version>sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd</version>
</component>
</metadata>
<components>
<component bom-ref="pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86" type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
</component>
</components>
</bom>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.3" serialNumber="urn:uuid:7ec2b549-3d31-4c7b-85be-fad838c8f312" version="1">
<metadata>
<timestamp>2023-09-29T11:48:10-04:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>syft</name>
<version>[not provided]</version>
</tool>
</tools>
<component bom-ref="a0ff99a6af10f11f" type="file">
<name>go.mod</name>
<version>sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd</version>
</component>
</metadata>
<components>
<component bom-ref="pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86" type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
<properties>
<property name="syft:package:foundBy">go-mod-file-cataloger</property>
<property name="syft:package:language">go</property>
<property name="syft:package:metadataType">GolangModMetadata</property>
<property name="syft:package:type">go-module</property>
<property name="syft:cpe23">cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</property>
<property name="syft:location:0:path">/go.mod</property>
</properties>
</component>
</components>
</bom>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.4" serialNumber="urn:uuid:3e5d8b2a-30cd-409f-9519-558c53e542c5" version="1">
<metadata>
<timestamp>2023-09-29T11:48:10-04:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>syft</name>
<version>[not provided]</version>
</tool>
</tools>
<component bom-ref="a0ff99a6af10f11f" type="file">
<name>go.mod</name>
<version>sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd</version>
</component>
</metadata>
<components>
<component bom-ref="pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86" type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
<properties>
<property name="syft:package:foundBy">go-mod-file-cataloger</property>
<property name="syft:package:language">go</property>
<property name="syft:package:metadataType">GolangModMetadata</property>
<property name="syft:package:type">go-module</property>
<property name="syft:cpe23">cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</property>
<property name="syft:location:0:path">/go.mod</property>
</properties>
</component>
</components>
</bom>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.5" serialNumber="urn:uuid:b2188bff-93b8-47e7-b86e-41c417e0d75e" version="1">
<metadata>
<timestamp>2023-09-29T11:48:10-04:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>syft</name>
<version>[not provided]</version>
</tool>
</tools>
<component bom-ref="a0ff99a6af10f11f" type="file">
<name>go.mod</name>
<version>sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd</version>
</component>
</metadata>
<components>
<component bom-ref="pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86" type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
<properties>
<property name="syft:package:foundBy">go-mod-file-cataloger</property>
<property name="syft:package:language">go</property>
<property name="syft:package:metadataType">GolangModMetadata</property>
<property name="syft:package:type">go-module</property>
<property name="syft:cpe23">cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</property>
<property name="syft:location:0:path">/go.mod</property>
</properties>
</component>
</components>
</bom>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.4" serialNumber="redacted" version="1">
<bom xmlns="http://cyclonedx.org/schema/bom/1.5" serialNumber="redacted" version="1">
<metadata>
<timestamp>redacted</timestamp>
<tools>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.4" serialNumber="redacted" version="1">
<bom xmlns="http://cyclonedx.org/schema/bom/1.5" serialNumber="redacted" version="1">
<metadata>
<timestamp>redacted</timestamp>
<tools>
+91
View File
@@ -0,0 +1,91 @@
package format
import (
"fmt"
"io"
"github.com/anchore/syft/syft/format/cyclonedxjson"
"github.com/anchore/syft/syft/format/cyclonedxxml"
"github.com/anchore/syft/syft/format/spdxjson"
"github.com/anchore/syft/syft/format/spdxtagvalue"
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/sbom"
)
var (
staticDecoders sbom.FormatDecoder
_ sbom.FormatDecoder = (*DecoderCollection)(nil)
)
func init() {
staticDecoders = NewDecoderCollection(Decoders()...)
}
func Decoders() []sbom.FormatDecoder {
return []sbom.FormatDecoder{
syftjson.NewFormatDecoder(),
cyclonedxxml.NewFormatDecoder(),
cyclonedxjson.NewFormatDecoder(),
spdxtagvalue.NewFormatDecoder(),
spdxjson.NewFormatDecoder(),
}
}
type DecoderCollection struct {
decoders []sbom.FormatDecoder
}
func NewDecoderCollection(decoders ...sbom.FormatDecoder) sbom.FormatDecoder {
return &DecoderCollection{
decoders: decoders,
}
}
// Decode takes a set of bytes and attempts to decode it into an SBOM relative to the decoders in the collection.
func (c *DecoderCollection) Decode(reader io.ReadSeeker) (*sbom.SBOM, sbom.FormatID, string, error) {
if reader == nil {
return nil, "", "", fmt.Errorf("no SBOM bytes provided")
}
var bestID sbom.FormatID
for _, d := range c.decoders {
id, version := d.Identify(reader)
if id == "" || version == "" {
if id != "" {
bestID = id
}
continue
}
return d.Decode(reader)
}
if bestID != "" {
return nil, bestID, "", fmt.Errorf("sbom format found to be %q but the version is not supported", bestID)
}
return nil, "", "", fmt.Errorf("sbom format not recognized")
}
// Identify takes a set of bytes and attempts to identify the format of the SBOM relative to the decoders in the collection.
func (c *DecoderCollection) Identify(reader io.ReadSeeker) (sbom.FormatID, string) {
if reader == nil {
return "", ""
}
for _, d := range c.decoders {
id, version := d.Identify(reader)
if id != "" && version != "" {
return id, version
}
}
return "", ""
}
// Identify takes a set of bytes and attempts to identify the format of the SBOM.
func Identify(reader io.ReadSeeker) (sbom.FormatID, string) {
return staticDecoders.Identify(reader)
}
// Decode takes a set of bytes and attempts to decode it into an SBOM.
func Decode(reader io.ReadSeeker) (*sbom.SBOM, sbom.FormatID, string, error) {
return staticDecoders.Decode(reader)
}
+62
View File
@@ -0,0 +1,62 @@
package format
import (
"fmt"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/sbom"
)
func TestIdentify(t *testing.T) {
tests := []struct {
fixture string
id sbom.FormatID
version string
}{
{
fixture: "test-fixtures/alpine-syft.json",
id: syftjson.ID,
version: "1.1.0",
},
}
for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) {
reader, err := os.Open(test.fixture)
assert.NoError(t, err)
id, version := Identify(reader)
assert.Equal(t, test.id, id)
assert.Equal(t, test.version, version)
})
}
}
func TestFormats_EmptyInput(t *testing.T) {
for _, format := range Decoders() {
name := strings.Split(fmt.Sprintf("%#v", format), "{")[0]
t.Run(name, func(t *testing.T) {
t.Run("Decode", func(t *testing.T) {
assert.NotPanics(t, func() {
decodedSBOM, _, _, err := format.Decode(nil)
assert.Error(t, err)
assert.Nil(t, decodedSBOM)
})
})
t.Run("Identify", func(t *testing.T) {
assert.NotPanics(t, func() {
id, version := format.Identify(nil)
assert.Empty(t, id)
assert.Empty(t, version)
})
})
})
}
}
+143
View File
@@ -0,0 +1,143 @@
package format
import (
"bytes"
"fmt"
"regexp"
"sort"
"strings"
"github.com/scylladb/go-set/strset"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/sbom"
)
type EncoderCollection struct {
encoders []sbom.FormatEncoder
}
func NewEncoderCollection(encoders ...sbom.FormatEncoder) *EncoderCollection {
return &EncoderCollection{
encoders: encoders,
}
}
// IDs returns all format IDs represented in the collection.
func (e EncoderCollection) IDs() []sbom.FormatID {
idSet := strset.New()
for _, f := range e.encoders {
idSet.Add(string(f.ID()))
}
idList := idSet.List()
sort.Strings(idList)
var ids []sbom.FormatID
for _, id := range idList {
ids = append(ids, sbom.FormatID(id))
}
return ids
}
// NameVersions returns all formats that are supported by the collection as a list of "name@version" strings.
func (e EncoderCollection) NameVersions() []string {
set := strset.New()
for _, f := range e.encoders {
if f.Version() == sbom.AnyVersion {
set.Add(string(f.ID()))
} else {
set.Add(fmt.Sprintf("%s@%s", f.ID(), f.Version()))
}
}
list := set.List()
sort.Strings(list)
return list
}
// Aliases returns all format aliases represented in the collection (where an ID would be "spdx-tag-value" the alias would be "spdx").
func (e EncoderCollection) Aliases() []string {
aliases := strset.New()
for _, f := range e.encoders {
aliases.Add(f.Aliases()...)
}
lst := aliases.List()
sort.Strings(lst)
return lst
}
// Get returns the contained encoder for a given format name and version.
func (e EncoderCollection) Get(name string, version string) sbom.FormatEncoder {
log.WithFields("name", name, "version", version).Trace("looking for matching encoder")
name = cleanFormatName(name)
var mostRecentFormat sbom.FormatEncoder
for _, f := range e.encoders {
log.WithFields("name", f.ID(), "version", f.Version(), "aliases", f.Aliases()).Trace("considering format")
names := []string{string(f.ID())}
names = append(names, f.Aliases()...)
for _, n := range names {
if cleanFormatName(n) == name && versionMatches(f.Version(), version) {
if mostRecentFormat == nil || f.Version() > mostRecentFormat.Version() {
mostRecentFormat = f
}
}
}
}
if mostRecentFormat != nil {
log.WithFields("name", mostRecentFormat.ID(), "version", mostRecentFormat.Version()).Trace("found matching encoder")
} else {
log.WithFields("search-name", name, "search-version", version).Trace("no matching encoder found")
}
return mostRecentFormat
}
// GetByString accepts a name@version string, such as:
// - json
// - spdx-json@2.1
// - cdx@1.5
func (e EncoderCollection) GetByString(s string) sbom.FormatEncoder {
parts := strings.SplitN(s, "@", 2)
version := sbom.AnyVersion
if len(parts) > 1 {
version = parts[1]
}
return e.Get(parts[0], version)
}
func versionMatches(version string, match string) bool {
if version == sbom.AnyVersion || match == sbom.AnyVersion {
return true
}
match = strings.ReplaceAll(match, ".", "\\.")
match = strings.ReplaceAll(match, "*", ".*")
match = fmt.Sprintf("^%s(\\..*)*$", match)
matcher, err := regexp.Compile(match)
if err != nil {
return false
}
return matcher.MatchString(version)
}
func cleanFormatName(name string) string {
r := strings.NewReplacer("-", "", "_", "")
return strings.ToLower(r.Replace(name))
}
// Encode takes all SBOM elements and a format option and encodes an SBOM document.
func Encode(s sbom.SBOM, f sbom.FormatEncoder) ([]byte, error) {
buff := bytes.Buffer{}
if err := f.Encode(&buff, s); err != nil {
return nil, fmt.Errorf("unable to encode sbom: %w", err)
}
return buff.Bytes(), nil
}
+111
View File
@@ -0,0 +1,111 @@
package format
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/anchore/syft/syft/sbom"
)
func Test_versionMatches(t *testing.T) {
tests := []struct {
name string
version string
match string
matches bool
}{
{
name: "any version matches number",
version: string(sbom.AnyVersion),
match: "6",
matches: true,
},
{
name: "number matches any version",
version: "6",
match: string(sbom.AnyVersion),
matches: true,
},
{
name: "same number matches",
version: "3",
match: "3",
matches: true,
},
{
name: "same major number matches",
version: "3.1",
match: "3",
matches: true,
},
{
name: "same minor number matches",
version: "3.1",
match: "3.1",
matches: true,
},
{
name: "wildcard-version matches minor",
version: "7.1.3",
match: "7.*",
matches: true,
},
{
name: "wildcard-version matches patch",
version: "7.4.8",
match: "7.4.*",
matches: true,
},
{
name: "sub-version matches major",
version: "7.19.11",
match: "7",
matches: true,
},
{
name: "sub-version matches minor",
version: "7.55.2",
match: "7.55",
matches: true,
},
{
name: "sub-version matches patch",
version: "7.32.6",
match: "7.32.6",
matches: true,
},
// negative tests
{
name: "different number does not match",
version: "3",
match: "4",
matches: false,
},
{
name: "sub-version doesn't match major",
version: "7.2.5",
match: "8.2.5",
matches: false,
},
{
name: "sub-version doesn't match minor",
version: "7.2.9",
match: "7.1",
matches: false,
},
{
name: "sub-version doesn't match patch",
version: "7.32.6",
match: "7.32.5",
matches: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matches := versionMatches(test.version, test.match)
assert.Equal(t, test.matches, matches)
})
}
}
+42
View File
@@ -0,0 +1,42 @@
package github
import (
"encoding/json"
"io"
"github.com/anchore/syft/syft/format/github/internal/model"
"github.com/anchore/syft/syft/sbom"
)
const ID sbom.FormatID = "github-json"
type encoder struct {
}
func NewFormatEncoder() sbom.FormatEncoder {
return encoder{}
}
func (e encoder) ID() sbom.FormatID {
return ID
}
func (e encoder) Aliases() []string {
return []string{
"github",
}
}
func (e encoder) Version() string {
return sbom.AnyVersion
}
func (e encoder) Encode(writer io.Writer, s sbom.SBOM) error {
bom := model.ToGithubModel(&s)
enc := json.NewEncoder(writer)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(bom)
}
+57
View File
@@ -0,0 +1,57 @@
package github
import (
"flag"
"testing"
"github.com/anchore/syft/syft/format/internal/testutil"
)
var updateSnapshot = flag.Bool("update-github", false, "update the *.golden files for github encoders")
var updateImage = flag.Bool("update-image", false, "update the golden image used for image encoder testing")
func TestGithubDirectoryEncoder(t *testing.T) {
dir := t.TempDir()
testutil.AssertEncoderAgainstGoldenSnapshot(t,
testutil.EncoderSnapshotTestConfig{
Subject: testutil.DirectoryInput(t, dir),
Format: NewFormatEncoder(),
UpdateSnapshot: *updateSnapshot,
PersistRedactionsInSnapshot: true,
IsJSON: false,
Redactor: redactor(dir),
},
)
}
func TestGithubImageEncoder(t *testing.T) {
testImage := "image-simple"
testutil.AssertEncoderAgainstGoldenImageSnapshot(t,
testutil.ImageSnapshotTestConfig{
Image: testImage,
UpdateImageSnapshot: *updateImage,
},
testutil.EncoderSnapshotTestConfig{
Subject: testutil.ImageInput(t, testImage),
Format: NewFormatEncoder(),
UpdateSnapshot: *updateSnapshot,
PersistRedactionsInSnapshot: true,
IsJSON: false,
Redactor: redactor(),
},
)
}
func redactor(values ...string) testutil.Redactor {
return testutil.NewRedactions().
WithValuesRedacted(values...).
WithPatternRedactors(
map[string]string{
// dates
`"scanned":\s*"[^"]+"`: `"scanned":"redacted"`,
// image metadata
`"syft:filesystem":\s*"[^"]+"`: `"syft:filesystem":"redacted"`,
},
)
}
@@ -1,4 +1,4 @@
package github
package model
// Derived from: https://gist.github.com/reiddraper/fdab2883db0f372c146d1a750fc1c43f
@@ -1,4 +1,4 @@
package github
package model
import (
"fmt"
@@ -14,8 +14,8 @@ import (
"github.com/anchore/syft/syft/source"
)
// toGithubModel converts the provided SBOM to a GitHub dependency model
func toGithubModel(s *sbom.SBOM) DependencySnapshot {
// ToGithubModel converts the provided SBOM to a GitHub dependency model
func ToGithubModel(s *sbom.SBOM) DependencySnapshot {
scanTime := time.Now().Format(time.RFC3339) // TODO is there a record of this somewhere?
v := s.Descriptor.Version
if v == "[not provided]" || v == "" {
@@ -1,4 +1,4 @@
package github
package model
import (
"testing"
@@ -182,7 +182,7 @@ func Test_toGithubModel(t *testing.T) {
if test.metadata != nil {
s.Source.Metadata = test.metadata
}
actual := toGithubModel(&s)
actual := ToGithubModel(&s)
if test.expected != nil {
if d := cmp.Diff(*test.expected, actual, cmpopts.IgnoreFields(DependencySnapshot{}, "Scanned")); d != "" {
@@ -0,0 +1,33 @@
{
"version": 0,
"job": {},
"detector": {
"name": "syft",
"url": "https://github.com/anchore/syft",
"version": "v0.42.0-bogus"
},
"metadata": {
"syft:distro": "pkg:generic/debian@1.2.3?like=like!"
},
"manifests": {
"redacted/some/path/some/path/pkg1": {
"name": "redacted/some/path/some/path/pkg1",
"file": {
"source_location": "redacted/some/path/some/path/pkg1"
},
"resolved": {
"": {
"package_url": "a-purl-2",
"relationship": "direct",
"scope": "runtime"
},
"pkg:deb/debian/package-2@2.0.1": {
"package_url": "pkg:deb/debian/package-2@2.0.1",
"relationship": "direct",
"scope": "runtime"
}
}
}
},
"scanned":"redacted"
}
@@ -0,0 +1,47 @@
{
"version": 0,
"job": {},
"detector": {
"name": "syft",
"url": "https://github.com/anchore/syft",
"version": "v0.42.0-bogus"
},
"metadata": {
"syft:distro": "pkg:generic/debian@1.2.3?like=like!"
},
"manifests": {
"user-image-input:/somefile-1.txt": {
"name": "user-image-input:/somefile-1.txt",
"file": {
"source_location": "user-image-input:/somefile-1.txt"
},
"metadata": {
"syft:filesystem":"redacted"
},
"resolved": {
"": {
"package_url": "a-purl-1",
"relationship": "direct",
"scope": "runtime"
}
}
},
"user-image-input:/somefile-2.txt": {
"name": "user-image-input:/somefile-2.txt",
"file": {
"source_location": "user-image-input:/somefile-2.txt"
},
"metadata": {
"syft:filesystem":"redacted"
},
"resolved": {
"pkg:deb/debian/package-2@2.0.1": {
"package_url": "pkg:deb/debian/package-2@2.0.1",
"relationship": "direct",
"scope": "runtime"
}
}
}
},
"scanned":"redacted"
}
@@ -0,0 +1,33 @@
package cyclonedxutil
import (
"fmt"
"io"
"github.com/CycloneDX/cyclonedx-go"
)
type Decoder struct {
format cyclonedx.BOMFileFormat
}
func NewDecoder(format cyclonedx.BOMFileFormat) Decoder {
return Decoder{
format: format,
}
}
func (d Decoder) Decode(reader io.ReadSeeker) (*cyclonedx.BOM, error) {
doc := &cyclonedx.BOM{
Components: &[]cyclonedx.Component{},
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("unable to seek to start of CycloneDX SBOM: %w", err)
}
err := cyclonedx.NewBOMDecoder(reader, d.format).Decode(doc)
if err != nil {
return nil, err
}
return doc, nil
}
@@ -0,0 +1,21 @@
package cyclonedxutil
import (
"bytes"
"encoding/json"
"testing"
"github.com/CycloneDX/cyclonedx-go"
"github.com/stretchr/testify/assert"
)
func Test_missingComponentsDecode(t *testing.T) {
bom := &cyclonedx.BOM{
SpecVersion: cyclonedx.SpecVersion1_4,
}
bomBytes, _ := json.Marshal(&bom)
dec := NewDecoder(cyclonedx.BOMFileFormatJSON)
_, err := dec.Decode(bytes.NewReader(bomBytes))
assert.NoError(t, err)
}
@@ -0,0 +1,37 @@
package cyclonedxutil
import (
"io"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/syft/format/common/cyclonedxhelpers"
"github.com/anchore/syft/syft/sbom"
)
const DefaultVersion = "1.5"
type Encoder struct {
version cyclonedx.SpecVersion
format cyclonedx.BOMFileFormat
}
func NewEncoder(version string, format cyclonedx.BOMFileFormat) (Encoder, error) {
specVersion, err := SpecVersionFromString(version)
if err != nil {
return Encoder{}, err
}
return Encoder{
version: specVersion,
format: format,
}, nil
}
func (e Encoder) Encode(writer io.Writer, s sbom.SBOM) error {
bom := cyclonedxhelpers.ToFormatModel(s)
enc := cyclonedx.NewBOMEncoder(writer, e.format)
enc.SetPretty(true)
enc.SetEscapeHTML(false)
return enc.EncodeVersion(bom, e.version)
}
@@ -0,0 +1,66 @@
package cyclonedxutil
import (
"fmt"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/syft/sbom"
)
const (
XMLFormatID sbom.FormatID = "cyclonedx-xml"
JSONFormatID sbom.FormatID = "cyclonedx-json"
)
func SupportedVersions(id sbom.FormatID) []string {
versions := []string{
"1.2",
"1.3",
"1.4",
"1.5",
}
if id != JSONFormatID {
// JSON format not supported for version < 1.2
versions = append([]string{"1.0", "1.1"}, versions...)
}
return versions
}
func SpecVersionFromString(v string) (cyclonedx.SpecVersion, error) {
switch v {
case "1.0":
return cyclonedx.SpecVersion1_0, nil
case "1.1":
return cyclonedx.SpecVersion1_1, nil
case "1.2":
return cyclonedx.SpecVersion1_2, nil
case "1.3":
return cyclonedx.SpecVersion1_3, nil
case "1.4":
return cyclonedx.SpecVersion1_4, nil
case "1.5":
return cyclonedx.SpecVersion1_5, nil
}
return -1, fmt.Errorf("unsupported CycloneDX version %q", v)
}
func VersionFromSpecVersion(spec cyclonedx.SpecVersion) string {
switch spec {
case cyclonedx.SpecVersion1_0:
return "1.0"
case cyclonedx.SpecVersion1_1:
return "1.1"
case cyclonedx.SpecVersion1_2:
return "1.2"
case cyclonedx.SpecVersion1_3:
return "1.3"
case cyclonedx.SpecVersion1_4:
return "1.4"
case cyclonedx.SpecVersion1_5:
return "1.5"
}
return ""
}
+26
View File
@@ -0,0 +1,26 @@
package spdxutil
import (
"github.com/anchore/syft/syft/sbom"
)
const DefaultVersion = "2.3"
const (
JSONFormatID sbom.FormatID = "spdx-json"
TagValueFormatID sbom.FormatID = "spdx-tag-value"
)
func SupportedVersions(id sbom.FormatID) []string {
versions := []string{
"2.2",
"2.3",
}
if id != JSONFormatID {
// JSON format is not supported in v2.1
return append([]string{"2.1"}, versions...)
}
return versions
}
@@ -1,4 +1,4 @@
package testutils
package testutil
import (
"os"
@@ -1,4 +1,4 @@
package testutils
package testutil
import (
"math/rand"
@@ -1,9 +1,12 @@
package testutils
package testutil
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/stereoscope/pkg/filetree"
"github.com/anchore/stereoscope/pkg/image"
@@ -25,6 +28,8 @@ func ImageInput(t testing.TB, testImage string, options ...ImageOption) sbom.SBO
opt(&cfg)
}
defer changeToDirectoryWithGoldenFixture(t, testImage)()
switch cfg.fromSnapshot {
case true:
img = imagetest.GetGoldenFixtureImage(t, testImage)
@@ -65,6 +70,30 @@ func ImageInput(t testing.TB, testImage string, options ...ImageOption) sbom.SBO
}
}
func changeToDirectoryWithGoldenFixture(t testing.TB, testImage string) func() {
// check if test fixture exists... if not, check if there is a shared fixture relative to this dir
fn := func() {}
path := filepath.Join("test-fixtures", testImage)
if _, err := os.Stat(path); err != nil {
// change dir, restore as defer
wd, err := os.Getwd()
require.NoError(t, err)
fn = func() {
require.NoError(t, os.Chdir(wd))
}
// change dir to the testutil dir
require.NoError(t, os.Chdir(filepath.Join(wd, "..", "internal", "testutil")))
t.Cleanup(fn)
if _, err := os.Stat(path); err != nil {
t.Fatalf("unable to find test fixture: %s", path)
}
}
return fn
}
func populateImageCatalog(catalog *pkg.Collection, img *image.Image) {
_, ref1, _ := img.SquashedTree().File("/somefile-1.txt", filetree.FollowBasenameLinks)
_, ref2, _ := img.SquashedTree().File("/somefile-2.txt", filetree.FollowBasenameLinks)
@@ -1,4 +1,4 @@
package testutils
package testutil
import (
"bytes"
@@ -1,4 +1,4 @@
package testutils
package testutil
import (
"bytes"
@@ -27,7 +27,7 @@ func FromSnapshot() ImageOption {
type EncoderSnapshotTestConfig struct {
Subject sbom.SBOM
Format sbom.Format
Format sbom.FormatEncoder
UpdateSnapshot bool
PersistRedactionsInSnapshot bool
IsJSON bool
@@ -71,8 +71,9 @@ func AssertEncoderAgainstGoldenSnapshot(t *testing.T, cfg EncoderSnapshotTestCon
func requireEqual(t *testing.T, expected any, actual any) {
if diff := cmp.Diff(expected, actual); diff != "" {
t.Logf("expected: %s", expected)
t.Logf("actual: %s", actual)
// uncomment to debug
// t.Logf("expected: %s", expected)
// t.Logf("actual: %s", actual)
t.Fatalf("mismatched output: %s", diff)
}
}
@@ -84,6 +85,8 @@ type ImageSnapshotTestConfig struct {
func AssertEncoderAgainstGoldenImageSnapshot(t *testing.T, imgCfg ImageSnapshotTestConfig, cfg EncoderSnapshotTestConfig) {
if imgCfg.UpdateImageSnapshot {
defer changeToDirectoryWithGoldenFixture(t, imgCfg.Image)()
// grab the latest image contents and persist
imagetest.UpdateGoldenFixtureImage(t, imgCfg.Image)
}

Some files were not shown because too many files have changed in this diff Show More