diff --git a/syft/pkg/cataloger/debian/capabilities.yaml b/syft/pkg/cataloger/debian/capabilities.yaml index 6d1f21e9923..fee11b8dded 100644 --- a/syft/pkg/cataloger/debian/capabilities.yaml +++ b/syft/pkg/cataloger/debian/capabilities.yaml @@ -75,6 +75,7 @@ catalogers: method: glob # AUTO-GENERATED criteria: # AUTO-GENERATED - '**/*.deb' + - '**/*.ipk' metadata_types: # AUTO-GENERATED - pkg.DpkgArchiveEntry package_types: # AUTO-GENERATED diff --git a/syft/pkg/cataloger/debian/cataloger.go b/syft/pkg/cataloger/debian/cataloger.go index 183b9d95ca1..9a96a9ae582 100644 --- a/syft/pkg/cataloger/debian/cataloger.go +++ b/syft/pkg/cataloger/debian/cataloger.go @@ -21,5 +21,5 @@ func NewDBCataloger() pkg.Cataloger { // NewArchiveCataloger returns a new Debian package cataloger object capable of parsing .deb archive files func NewArchiveCataloger() pkg.Cataloger { return generic.NewCataloger("deb-archive-cataloger"). - WithParserByGlobs(parseDebArchive, "**/*.deb") + WithParserByGlobs(parseDebArchive, "**/*.deb", "**/*.ipk") } diff --git a/syft/pkg/cataloger/debian/cataloger_test.go b/syft/pkg/cataloger/debian/cataloger_test.go index 4d5deb9fbf3..6e842eafeb6 100644 --- a/syft/pkg/cataloger/debian/cataloger_test.go +++ b/syft/pkg/cataloger/debian/cataloger_test.go @@ -276,6 +276,36 @@ func TestDpkgArchiveCataloger(t *testing.T) { }, }, }, + }, { + name: "image-single-ipk", + expected: []pkg.Package{ + { + Name: "libz1", + Version: "1.3.2-r0", + FoundBy: "deb-archive-cataloger", + Locations: file.NewLocationSet( + file.NewLocation("/libz1_1.3.2-r0_x86-64-v3.ipk"), + ), + PURL: "pkg:deb/libz1@1.3.2-r0?arch=x86-64-v3&upstream=zlib_1.3.2.bb", + Type: pkg.DebPkg, + Metadata: pkg.DpkgArchiveEntry{ + Package: "libz1", + Source: "zlib_1.3.2.bb", + Version: "1.3.2-r0", + Architecture: "x86-64-v3", + Maintainer: "Poky Maintainers ", + InstalledSize: 0, + Description: `Zlib Compression Library + Zlib is a general-purpose, patent-free, lossless data compression library + which is used by many different programs.`, + Provides: []string{"zlib"}, + Depends: []string{"musl (>= 1.2.6+git0+9fa28ece75)"}, + }, + Licenses: pkg.NewLicenseSet( + pkg.NewLicenseFromLocationsWithContext(ctx, "Zlib"), + ), + }, + }, }, } diff --git a/syft/pkg/cataloger/debian/parse_deb_archive.go b/syft/pkg/cataloger/debian/parse_deb_archive.go index b3b5f923f41..f21001c6ed3 100644 --- a/syft/pkg/cataloger/debian/parse_deb_archive.go +++ b/syft/pkg/cataloger/debian/parse_deb_archive.go @@ -2,6 +2,7 @@ package debian import ( "archive/tar" + "bytes" "context" "fmt" "io" @@ -28,10 +29,16 @@ import ( // // This function extracts and processes the control information to create package metadata. func parseDebArchive(ctx context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { - arReader := ar.NewReader(reader) + validatedReader, err := newValidatedArReader(reader) + if err != nil { + return nil, nil, fmt.Errorf("invalid ar archive: %w", err) + } + + arReader := ar.NewReader(validatedReader) var metadata *pkg.DpkgArchiveEntry var licenses []string + var ctrlLicenses []string var unknownErr error for { header, err := arReader.Next() @@ -49,7 +56,7 @@ func parseDebArchive(ctx context.Context, _ file.Resolver, _ *generic.Environmen if err != nil { return nil, nil, unknown.New(reader.Location, fmt.Errorf("failed to decompress control.tar.* file: %w", err)) } - metadata, err = processControlTar(dcReader) + metadata, ctrlLicenses, err = processControlTar(dcReader) if err != nil { return nil, nil, unknown.New(reader.Location, fmt.Errorf("failed to process control.tar.* file: %w", err)) } @@ -70,6 +77,10 @@ func parseDebArchive(ctx context.Context, _ file.Resolver, _ *generic.Environmen return nil, nil, unknown.New(reader.Location, fmt.Errorf("no application found described in .dpkg archive")) } + if len(licenses) == 0 && len(ctrlLicenses) > 0 { + licenses = ctrlLicenses + } + return []pkg.Package{ newDebArchivePackage(ctx, reader.Location, *metadata, licenses), }, nil, nil @@ -103,7 +114,7 @@ func processDataTar(dcReader io.ReadCloser) ([]string, error) { return licenses, nil } -func processControlTar(dcReader io.ReadCloser) (*pkg.DpkgArchiveEntry, error) { +func processControlTar(dcReader io.ReadCloser) (*pkg.DpkgArchiveEntry, []string, error) { defer internal.CloseAndLogError(dcReader, "") tarReader := tar.NewReader(dcReader) @@ -111,6 +122,7 @@ func processControlTar(dcReader io.ReadCloser) (*pkg.DpkgArchiveEntry, error) { var metadata *pkg.DpkgArchiveEntry var files []pkg.DpkgFileRecord var confFileRecords []pkg.DpkgFileRecord + var licenses []string for { header, err := tarReader.Next() @@ -118,7 +130,7 @@ func processControlTar(dcReader io.ReadCloser) (*pkg.DpkgArchiveEntry, error) { break } if err != nil { - return nil, fmt.Errorf("failed to read control tar: %w", err) + return nil, nil, fmt.Errorf("failed to read control tar: %w", err) } switch filepath.Base(header.Name) { @@ -126,12 +138,13 @@ func processControlTar(dcReader io.ReadCloser) (*pkg.DpkgArchiveEntry, error) { // parseDpkgStatus already streams via bufio.Reader entries, err := parseDpkgStatus(tarReader) if err != nil { - return nil, fmt.Errorf("failed to parse control file: %w", err) + return nil, nil, fmt.Errorf("failed to parse control file: %w", err) } if len(entries) == 0 { - return nil, fmt.Errorf("no package entries found in control file") + return nil, nil, fmt.Errorf("no package entries found in control file") } entry := pkg.DpkgArchiveEntry(entries[0].toDpkgEntry()) + licenses = normalizeControlLicenses(entries[0].License) metadata = &entry case "md5sums": // parseDpkgMD5Info already streams via bufio.Scanner @@ -143,7 +156,7 @@ func processControlTar(dcReader io.ReadCloser) (*pkg.DpkgArchiveEntry, error) { } if metadata == nil { - return nil, fmt.Errorf("control file not found in archive") + return nil, nil, fmt.Errorf("control file not found in archive") } if len(confFileRecords) > 0 && len(files) > 0 { @@ -159,7 +172,48 @@ func processControlTar(dcReader io.ReadCloser) (*pkg.DpkgArchiveEntry, error) { } metadata.Files = files - return metadata, nil + return metadata, licenses, nil +} + +func normalizeControlLicenses(rawLicense string) []string { + parts := strings.Split(rawLicense, "&") + licenses := make([]string, 0, len(parts)) + + for _, part := range parts { + license := strings.TrimSpace(part) + if license == "" { + continue + } + + if strings.HasPrefix(license, "(") && strings.HasSuffix(license, ")") { + license = strings.TrimSpace(strings.TrimPrefix(strings.TrimSuffix(license, ")"), "(")) + } + + if strings.Contains(license, "|") { + alternatives := strings.Split(license, "|") + for i := range alternatives { + alternatives[i] = strings.TrimSpace(alternatives[i]) + } + license = strings.Join(alternatives, " or ") + } + + licenses = append(licenses, license) + } + + return licenses +} + +func newValidatedArReader(reader io.ReadCloser) (io.ReadCloser, error) { + prefix := make([]byte, len(ar.GLOBAL_HEADER)) + if _, err := io.ReadFull(reader, prefix); err != nil { + return nil, fmt.Errorf("failed to read ar header: %w", err) + } + + if !bytes.Equal(prefix, []byte(ar.GLOBAL_HEADER)) { + return nil, fmt.Errorf("expected ar header %q, got %q", string(ar.GLOBAL_HEADER), string(prefix)) + } + + return io.NopCloser(io.MultiReader(bytes.NewReader(prefix), reader)), nil } func decompressionStream(ctx context.Context, r io.Reader, filePath string) (io.ReadCloser, error) { diff --git a/syft/pkg/cataloger/debian/parse_deb_archive_test.go b/syft/pkg/cataloger/debian/parse_deb_archive_test.go index c9baee7389f..be11a5f3698 100644 --- a/syft/pkg/cataloger/debian/parse_deb_archive_test.go +++ b/syft/pkg/cataloger/debian/parse_deb_archive_test.go @@ -3,22 +3,26 @@ package debian import ( "archive/tar" "bytes" + "context" "io" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/file" ) func TestProcessControlTar(t *testing.T) { tarBytes := createTestTarWithControlFiles(t) - metadata, err := processControlTar(io.NopCloser(bytes.NewReader(tarBytes))) + metadata, licenses, err := processControlTar(io.NopCloser(bytes.NewReader(tarBytes))) require.NoError(t, err) require.NotNil(t, metadata) assert.Equal(t, "test-package", metadata.Package) + assert.Equal(t, []string{"MIT"}, licenses) assert.Equal(t, "1.0.0", metadata.Version) // md5sums should have been parsed into file records @@ -30,6 +34,14 @@ func TestProcessControlTar(t *testing.T) { assert.True(t, metadata.Files[0].IsConfigFile, "file listed in conffiles should be marked as config") } +func TestParseDebArchive_RejectsInvalidArchiveHeader(t *testing.T) { + reader := file.NewLocationReadCloser(file.NewLocation("test.deb"), io.NopCloser(bytes.NewReader([]byte("not-an-ar-archive")))) + + _, _, err := parseDebArchive(context.Background(), nil, nil, reader) + require.Error(t, err) + assert.Contains(t, err.Error(), "ar archive") +} + func TestProcessControlTar_ConfigFileMarking(t *testing.T) { // Create a tar where conffiles lists paths that overlap with md5sums entries var buf bytes.Buffer @@ -48,9 +60,10 @@ func TestProcessControlTar_ConfigFileMarking(t *testing.T) { require.NoError(t, tw.Close()) - metadata, err := processControlTar(io.NopCloser(bytes.NewReader(buf.Bytes()))) + metadata, licenses, err := processControlTar(io.NopCloser(bytes.NewReader(buf.Bytes()))) require.NoError(t, err) require.Len(t, metadata.Files, 3) + assert.Empty(t, licenses) assert.True(t, metadata.Files[0].IsConfigFile, "first file should be marked as config file") assert.True(t, metadata.Files[1].IsConfigFile, "second file should be marked as config file") @@ -62,7 +75,7 @@ func createTestTarWithControlFiles(t *testing.T) []byte { var buf bytes.Buffer tw := tar.NewWriter(&buf) - controlContent := "Package: test-package\nVersion: 1.0.0\nArchitecture: all\nMaintainer: Test \nDescription: Test package\n" + controlContent := "Package: test-package\nVersion: 1.0.0\nArchitecture: all\nMaintainer: Test \nDescription: Test package\nLicense: MIT\n" writeTarEntry(t, tw, "control", controlContent) md5Content := "d41d8cd98f00b204e9800998ecf8427e usr/bin/test-command\n" @@ -85,3 +98,18 @@ func writeTarEntry(t *testing.T, tw *tar.Writer, name, content string) { _, err := tw.Write([]byte(content)) require.NoError(t, err) } + +func TestProcessControlTar_NormalizesLicenseList(t *testing.T) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + controlContent := "Package: test-package\nVersion: 1.0.0\nArchitecture: all\nLicense: MIT & (BSD-3-Clause | Apache-2.0) & ( GPL-2.0-or-later )\n" + writeTarEntry(t, tw, "control", controlContent) + + require.NoError(t, tw.Close()) + + metadata, licenses, err := processControlTar(io.NopCloser(bytes.NewReader(buf.Bytes()))) + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, []string{"MIT", "BSD-3-Clause or Apache-2.0", "GPL-2.0-or-later"}, licenses) +} diff --git a/syft/pkg/cataloger/debian/testdata/image-single-ipk/Dockerfile b/syft/pkg/cataloger/debian/testdata/image-single-ipk/Dockerfile new file mode 100644 index 00000000000..feeb4494d51 --- /dev/null +++ b/syft/pkg/cataloger/debian/testdata/image-single-ipk/Dockerfile @@ -0,0 +1,2 @@ +FROM scratch +ADD *.ipk / diff --git a/syft/pkg/cataloger/debian/testdata/image-single-ipk/Dockerfile.build b/syft/pkg/cataloger/debian/testdata/image-single-ipk/Dockerfile.build new file mode 100644 index 00000000000..7feaa74b122 --- /dev/null +++ b/syft/pkg/cataloger/debian/testdata/image-single-ipk/Dockerfile.build @@ -0,0 +1,34 @@ +# Based on the Yocto Project Quick Build guide: https://docs.yoctoproject.org/brief-yoctoprojectqs/index.html +FROM debian:stable-slim AS build + +RUN apt-get update && apt-get install -y \ + build-essential chrpath cpio debianutils diffstat file gawk gcc git \ + iputils-ping libacl1 libcrypt-dev locales python3 python3-git \ + python3-jinja2 python3-pexpect python3-pip python3-subunit socat texinfo \ + unzip wget xz-utils zstd + +ADD https://git.openembedded.org/bitbake.git /opt/bitbake + +ENV LANG=en_GB.UTF-8 +RUN sed -i -e '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen + +RUN useradd -m -s /bin/bash user + +USER user +WORKDIR /home/user + +RUN /opt/bitbake/bin/bitbake-setup init --non-interactive poky-wrynose poky machine/qemux86-64 distro/poky-tiny + +# Cofigure to build IPK packages +COPY <