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
1 change: 1 addition & 0 deletions syft/pkg/cataloger/debian/capabilities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ catalogers:
method: glob # AUTO-GENERATED
criteria: # AUTO-GENERATED
- '**/*.deb'
- '**/*.ipk'
metadata_types: # AUTO-GENERATED
- pkg.DpkgArchiveEntry
package_types: # AUTO-GENERATED
Expand Down
2 changes: 1 addition & 1 deletion syft/pkg/cataloger/debian/cataloger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
30 changes: 30 additions & 0 deletions syft/pkg/cataloger/debian/cataloger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <poky@lists.yoctoproject.org>",
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"),
),
},
},
},
}

Expand Down
70 changes: 62 additions & 8 deletions syft/pkg/cataloger/debian/parse_deb_archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package debian

import (
"archive/tar"
"bytes"
"context"
"fmt"
"io"
Expand All @@ -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()
Expand All @@ -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))
}
Expand All @@ -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
Expand Down Expand Up @@ -103,35 +114,37 @@ 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)

var metadata *pkg.DpkgArchiveEntry
var files []pkg.DpkgFileRecord
var confFileRecords []pkg.DpkgFileRecord
var licenses []string

for {
header, err := tarReader.Next()
if err == io.EOF {
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) {
case "control":
// 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
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down
34 changes: 31 additions & 3 deletions syft/pkg/cataloger/debian/parse_deb_archive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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 <test@example.com>\nDescription: Test package\n"
controlContent := "Package: test-package\nVersion: 1.0.0\nArchitecture: all\nMaintainer: Test <test@example.com>\nDescription: Test package\nLicense: MIT\n"
writeTarEntry(t, tw, "control", controlContent)

md5Content := "d41d8cd98f00b204e9800998ecf8427e usr/bin/test-command\n"
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FROM scratch
ADD *.ipk /
Original file line number Diff line number Diff line change
@@ -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 <<EOF /home/user/bitbake-builds/poky-tiny-wrynose/build/conf/local.conf
PACKAGE_CLASSES ?= "package_ipk"
EOF

# Build the zlib package
RUN . /home/user/bitbake-builds/poky-tiny-wrynose/build/init-build-env && bitbake zlib

USER root
RUN find /home/user/bitbake-builds/poky-tiny-wrynose/build/tmp/deploy/ipk -name 'libz1_*.ipk' -execdir cp -v '{}' / \;

FROM scratch AS final
COPY --from=build /*.ipk /
Binary file not shown.