Expose RPM signature information (for RPM DB and RPM archives) (#3179)

* feat: expose rpm signature information

This helps with more confident identification of an rpm.

In theory, two rpms can be built that have the same purl string, and
otherwise look identical in syft's output, but the PGP information
would distinguish them as signed either by different keys, or signed at
different times.

In practice, this usually makes no difference since rpms tend to have
unique name/version/release strings. This just gives increased
confidence about the identity of the rpm found in the db.

Signed-off-by: Ralph Bean <rbean@redhat.com>

* chore: generate json schema

Signed-off-by: Ralph Bean <rbean@redhat.com>

* re-generate json schema

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

* rename to a more generic signature field

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

* rename rpm.pgp to rpm.signatures

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

* split out signature fields

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

* bump json schema

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

* include RPM archives

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

* update json schema

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

* dont fail on unknown signature type

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

---------

Signed-off-by: Ralph Bean <rbean@redhat.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Ralph Bean 2025-05-15 12:01:00 -04:00 committed by GitHub
parent 5effed06a8
commit b369b02f4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 3775 additions and 40 deletions

View File

@ -3,18 +3,18 @@ package integration
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
func TestPhotonPackageRegression(t *testing.T) { // Regression: https://github.com/anchore/syft/pull/1997
sbom, _ := catalogFixtureImage(t, "image-photon-all-layers", source.AllLayersScope)
var packages []pkg.Package
for p := range sbom.Artifacts.Packages.Enumerate() {
packages = append(packages, p)
var count int
for range sbom.Artifacts.Packages.Enumerate(pkg.RpmPkg) {
count++
}
if len(packages) < 1 {
t.Errorf("failed to find packages for photon distro; wanted > 0 got 0")
}
assert.Greater(t, count, 0, "expected to find RPM packages in the SBOM (but did not)")
}

5
go.mod
View File

@ -100,7 +100,7 @@ require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/hcsshim v0.11.7 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/ProtonMail/go-crypto v1.2.0 // indirect
github.com/STARRY-S/zip v0.2.1 // indirect
github.com/agext/levenshtein v1.2.1 // indirect; indirectt
github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 // indirect
@ -263,3 +263,6 @@ retract (
v0.53.2
v0.53.1 // Published accidentally with incorrect license in depdencies
)
// adds RSA Headers from RPMDB via https://github.com/knqyf263/go-rpmdb/pull/58 (in review)
replace github.com/knqyf263/go-rpmdb => github.com/anchore/go-rpmdb v0.0.0-20250515153519-38be2efee8ed

8
go.sum
View File

@ -78,8 +78,8 @@ github.com/Microsoft/hcsshim v0.11.7/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs=
github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg=
github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
@ -110,6 +110,8 @@ github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 h1:2SqmFgE7h+Ql4
github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722/go.mod h1:oFuE8YuTCM+spgMXhePGzk3asS94yO9biUfDzVTFqNw=
github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb h1:iDMnx6LIjtjZ46C0akqveX83WFzhpTD3eqOthawb5vU=
github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb/go.mod h1:DmTY2Mfcv38hsHbG78xMiTDdxFtkHpgYNVDPsF2TgHk=
github.com/anchore/go-rpmdb v0.0.0-20250515153519-38be2efee8ed h1:ixtJ1s+L3+AvxlGn4Tm5VcV7RiMVLcUGmNOGXQ6U9Gg=
github.com/anchore/go-rpmdb v0.0.0-20250515153519-38be2efee8ed/go.mod h1:0A7fN6+ED0l7YrO4GNEz6kgDmkKUwzK2bDl2v0E2Hog=
github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc=
github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA=
github.com/anchore/go-sync v0.0.0-20250326131806-4eda43a485b6 h1:Ha+LSCVuXYSYGi7wIkJK6G8g6jI3LH7y6LbyEVyp4Io=
@ -548,8 +550,6 @@ github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYW
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/knqyf263/go-rpmdb v0.1.1 h1:oh68mTCvp1XzxdU7EfafcWzzfstUZAEa3MW0IJye584=
github.com/knqyf263/go-rpmdb v0.1.1/go.mod h1:9LQcoMCMQ9vrF7HcDtXfvqGO4+ddxFQ8+YF/0CVGDww=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=

View File

@ -3,5 +3,5 @@ package internal
const (
// JSONSchemaVersion is the current schema version output by the JSON encoder
// This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment.
JSONSchemaVersion = "16.0.32"
JSONSchemaVersion = "16.0.33"
)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "anchore.io/schema/syft/json/16.0.32/document",
"$id": "anchore.io/schema/syft/json/16.0.33/document",
"$ref": "#/$defs/Document",
"$defs": {
"AlpmDbEntry": {
@ -2677,6 +2677,12 @@
"sourceRpm": {
"type": "string"
},
"signatures": {
"items": {
"$ref": "#/$defs/RpmSignature"
},
"type": "array"
},
"size": {
"type": "integer"
},
@ -2745,6 +2751,12 @@
"sourceRpm": {
"type": "string"
},
"signatures": {
"items": {
"$ref": "#/$defs/RpmSignature"
},
"type": "array"
},
"size": {
"type": "integer"
},
@ -2821,6 +2833,29 @@
"flags"
]
},
"RpmSignature": {
"properties": {
"algo": {
"type": "string"
},
"hash": {
"type": "string"
},
"created": {
"type": "string"
},
"issuer": {
"type": "string"
}
},
"type": "object",
"required": [
"algo",
"hash",
"created",
"issuer"
]
},
"RubyGemspec": {
"properties": {
"name": {

View File

@ -96,6 +96,10 @@ func (p *CatalogTester) WithContext(ctx context.Context) *CatalogTester {
func (p *CatalogTester) FromDirectory(t *testing.T, path string) *CatalogTester {
t.Helper()
if path == "" {
return p
}
s, err := directorysource.NewFromPath(path)
require.NoError(t, err)
@ -109,6 +113,10 @@ func (p *CatalogTester) FromDirectory(t *testing.T, path string) *CatalogTester
func (p *CatalogTester) FromFile(t *testing.T, path string) *CatalogTester {
t.Helper()
if path == "" {
return p
}
fixture, err := os.Open(path)
require.NoError(t, err)
@ -157,6 +165,11 @@ func (p *CatalogTester) WithResolver(r file.Resolver) *CatalogTester {
func (p *CatalogTester) WithImageResolver(t *testing.T, fixtureName string) *CatalogTester {
t.Helper()
if fixtureName == "" {
return p
}
img := imagetest.GetFixtureImage(t, "docker-archive", fixtureName)
s := stereoscopesource.New(img, stereoscopesource.ImageConfig{

View File

@ -7,6 +7,7 @@ import (
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "modernc.org/sqlite"
"github.com/anchore/syft/syft/artifact"
@ -29,14 +30,15 @@ func Test_DBCataloger(t *testing.T) {
FoundBy: "rpm-db-cataloger",
PURL: "pkg:rpm/basesystem@11-13.el9?arch=noarch&upstream=basesystem-11-13.el9.src.rpm",
Metadata: pkg.RpmDBEntry{
Name: "basesystem",
Version: "11",
Arch: "noarch",
Release: "13.el9",
SourceRpm: "basesystem-11-13.el9.src.rpm",
Size: 0,
Vendor: "Rocky Enterprise Software Foundation",
Provides: []string{"basesystem"},
Name: "basesystem",
Version: "11",
Arch: "noarch",
Release: "13.el9",
SourceRpm: "basesystem-11-13.el9.src.rpm",
Size: 0,
Vendor: "Rocky Enterprise Software Foundation",
Signatures: mustParseSignatures(t, "RSA/SHA256, Wed May 11 11:12:32 2022, Key ID 702d426d350d275d"),
Provides: []string{"basesystem"},
Requires: []string{
"filesystem",
"rpmlib(CompressedFileNames)",
@ -65,6 +67,7 @@ func Test_DBCataloger(t *testing.T) {
Release: "6.el9_1",
SourceRpm: "bash-5.1.8-6.el9_1.src.rpm",
Size: 7738634,
Signatures: mustParseSignatures(t, "RSA/SHA256, Mon Jan 23 22:49:22 2023, Key ID 702d426d350d275d"),
ModularityLabel: strRef(""),
Vendor: "Rocky Enterprise Software Foundation",
Provides: []string{
@ -117,6 +120,7 @@ func Test_DBCataloger(t *testing.T) {
Release: "2.el9",
SourceRpm: "filesystem-3.16-2.el9.src.rpm",
Size: 106,
Signatures: mustParseSignatures(t, "RSA/SHA256, Mon May 16 12:32:55 2022, Key ID 702d426d350d275d"),
ModularityLabel: strRef(""),
Vendor: "Rocky Enterprise Software Foundation",
Provides: []string{
@ -334,3 +338,9 @@ func Test_denySelfReferences(t *testing.T) {
})
}
}
func mustParseSignatures(t testing.TB, sigs ...string) []pkg.RpmSignature {
signatures, err := parseSignatures(sigs...)
require.NoError(t, err)
return signatures
}

View File

@ -1,9 +1,14 @@
package redhat
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"sort"
"strconv"
"time"
rpmdb "github.com/knqyf263/go-rpmdb/pkg"
"github.com/sassoftware/go-rpmutils"
@ -15,6 +20,42 @@ import (
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
type pgpSig struct {
_ [3]byte
Date int32
KeyID [8]byte
PubKeyAlgo uint8
HashAlgo uint8
}
type textSig struct {
_ [2]byte
PubKeyAlgo uint8
HashAlgo uint8
_ [4]byte
Date int32
_ [4]byte
KeyID [8]byte
}
type pgp4Sig struct {
_ [2]byte
PubKeyAlgo uint8
HashAlgo uint8
_ [17]byte
KeyID [8]byte
_ [2]byte
Date int32
}
var pubKeyLookup = map[uint8]string{
0x01: "RSA",
}
var hashLookup = map[uint8]string{
0x02: "SHA1",
0x08: "SHA256",
}
// parseRpmArchive parses a single RPM
func parseRpmArchive(ctx context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
rpm, err := rpmutils.ReadRpm(reader)
@ -44,21 +85,140 @@ func parseRpmArchive(ctx context.Context, _ file.Resolver, _ *generic.Environmen
files, err := rpm.Header.GetFiles()
logRpmArchiveErr(reader.Location, "files", err)
rsa, err := rpm.Header.GetBytes(rpmutils.SIG_RSA)
logRpmArchiveErr(reader.Location, "rsa signature", err)
pgp, err := rpm.Header.GetBytes(rpmutils.SIG_PGP)
logRpmArchiveErr(reader.Location, "pgp signature", err)
var allSigs [][]byte
allSigs = append(allSigs, rsa)
allSigs = append(allSigs, pgp)
sigs, err := parseSignatureHeaders(allSigs)
logRpmArchiveErr(reader.Location, "signature", err)
metadata := pkg.RpmArchive{
Name: nevra.Name,
Version: nevra.Version,
Epoch: parseEpoch(nevra.Epoch),
Arch: nevra.Arch,
Release: nevra.Release,
SourceRpm: sourceRpm,
Vendor: vendor,
Size: int(size),
Files: mapFiles(files, digestAlgorithm),
Name: nevra.Name,
Version: nevra.Version,
Epoch: parseEpoch(nevra.Epoch),
Arch: nevra.Arch,
Release: nevra.Release,
SourceRpm: sourceRpm,
Signatures: sigs,
Vendor: vendor,
Size: int(size),
Files: mapFiles(files, digestAlgorithm),
}
return []pkg.Package{newArchivePackage(ctx, reader.Location, metadata, licenses)}, nil, nil
}
func parseSignatureHeaders(data [][]byte) ([]pkg.RpmSignature, error) {
sigMap := make(map[string]pkg.RpmSignature)
var keys []string
for _, sig := range data {
if len(sig) == 0 {
continue
}
s, err := parsePGP(sig)
if err != nil {
log.WithFields("error", err).Trace("unable to parse RPM archive signature")
return nil, err
}
k := s.String()
if _, ok := sigMap[k]; ok {
// if we have a duplicate signature, just skip it
continue
}
sigMap[k] = *s
keys = append(keys, k)
}
var signatures []pkg.RpmSignature
sort.Strings(keys)
for _, k := range keys {
signatures = append(signatures, sigMap[k])
}
return signatures, nil
}
func parsePGP(data []byte) (*pkg.RpmSignature, error) {
var tag, signatureType, version uint8
r := bytes.NewReader(data)
err := binary.Read(r, binary.BigEndian, &tag)
if err != nil {
return nil, err
}
err = binary.Read(r, binary.BigEndian, &signatureType)
if err != nil {
return nil, err
}
err = binary.Read(r, binary.BigEndian, &version)
if err != nil {
return nil, err
}
switch signatureType {
case 0x01:
switch version {
case 0x1c:
sig := textSig{}
err = binary.Read(r, binary.BigEndian, &sig)
if err != nil {
return nil, fmt.Errorf("invalid PGP signature on decode: %w", err)
}
return &pkg.RpmSignature{
PublicKeyAlgorithm: pubKeyLookup[sig.PubKeyAlgo],
HashAlgorithm: hashLookup[sig.HashAlgo],
Created: time.Unix(int64(sig.Date), 0).UTC().Format("Mon Jan _2 15:04:05 2006"),
IssuerKeyID: fmt.Sprintf("%x", sig.KeyID),
}, nil
default:
return decodePGPSig(version, r)
}
case 0x02:
return decodePGPSig(version, r)
}
return nil, fmt.Errorf("unknown signature type: %d", signatureType)
}
func decodePGPSig(version uint8, r io.Reader) (*pkg.RpmSignature, error) {
var pubKeyAlgo, hashAlgo, pkgDate string
var keyID [8]byte
switch {
case version > 0x15:
sig := pgp4Sig{}
err := binary.Read(r, binary.BigEndian, &sig)
if err != nil {
return nil, fmt.Errorf("invalid PGP v4 signature on decode: %w", err)
}
pubKeyAlgo = pubKeyLookup[sig.PubKeyAlgo]
hashAlgo = hashLookup[sig.HashAlgo]
pkgDate = time.Unix(int64(sig.Date), 0).UTC().Format("Mon Jan _2 15:04:05 2006")
keyID = sig.KeyID
default:
sig := pgpSig{}
err := binary.Read(r, binary.BigEndian, &sig)
if err != nil {
return nil, fmt.Errorf("invalid PGP signature on decode: %w", err)
}
pubKeyAlgo = pubKeyLookup[sig.PubKeyAlgo]
hashAlgo = hashLookup[sig.HashAlgo]
pkgDate = time.Unix(int64(sig.Date), 0).UTC().Format("Mon Jan _2 15:04:05 2006")
keyID = sig.KeyID
}
return &pkg.RpmSignature{
PublicKeyAlgorithm: pubKeyAlgo,
HashAlgorithm: hashAlgo,
Created: pkgDate,
IssuerKeyID: fmt.Sprintf("%x", keyID),
}, nil
}
func getDigestAlgorithm(location file.Location, header *rpmutils.RpmHeader) string {
digestAlgorithm, err := header.GetString(rpmutils.FILEDIGESTALGO)
logRpmArchiveErr(location, "file digest algo", err)
@ -109,6 +269,6 @@ func parseEpoch(epoch string) *int {
func logRpmArchiveErr(location file.Location, operation string, err error) {
if err != nil {
log.Debugf("ERROR in parse_rpm_archive %s file: %s: %v", operation, location.RealPath, err)
log.WithFields("error", err, "operation", operation, "path", location.RealPath).Trace("unable to parse RPM archive")
}
}

View File

@ -2,8 +2,14 @@ package redhat
import (
"context"
"encoding/hex"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
@ -14,11 +20,15 @@ func TestParseRpmFiles(t *testing.T) {
abcRpmLocation := file.NewLocation("abc-1.01-9.hg20160905.el7.x86_64.rpm")
zorkRpmLocation := file.NewLocation("zork-1.0.3-1.el7.x86_64.rpm")
tests := []struct {
fixture string
expected []pkg.Package
name string
fixtureDir string
fixtureImage string
skipFiles bool
expected []pkg.Package
}{
{
fixture: "test-fixtures/rpms",
name: "go case",
fixtureDir: "test-fixtures/rpms",
expected: []pkg.Package{
{
Name: "abc",
@ -37,8 +47,16 @@ func TestParseRpmFiles(t *testing.T) {
Release: "9.hg20160905.el7",
Version: "1.01",
SourceRpm: "abc-1.01-9.hg20160905.el7.src.rpm",
Size: 17396,
Vendor: "Fedora Project",
Signatures: []pkg.RpmSignature{
{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA256",
Created: "Wed Sep 21 07:09:44 2016",
IssuerKeyID: "6a2faea2352c64e5",
},
},
Size: 17396,
Vendor: "Fedora Project",
Files: []pkg.RpmFileRecord{
{"/usr/bin/abc", 33261, 7120, file.Digest{"sha256", "8f8495a65c66762b60afa0c3949d81b275ca6fa0601696caba5af762f455d0b9"}, "root", "root", ""},
{"/usr/share/doc/abc-1.01", 16877, 4096, file.Digest{}, "root", "root", ""},
@ -66,7 +84,15 @@ func TestParseRpmFiles(t *testing.T) {
Version: "1.0.3",
SourceRpm: "zork-1.0.3-1.el7.src.rpm",
Size: 262367,
Vendor: "Fedora Project",
Signatures: []pkg.RpmSignature{
{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA256",
Created: "Tue Mar 2 17:32:21 2021",
IssuerKeyID: "6a2faea2352c64e5",
},
},
Vendor: "Fedora Project",
Files: []pkg.RpmFileRecord{
{"/usr/bin/zork", 33261, 115440, file.Digest{"sha256", "31b2ffc20b676a8fff795a45308f584273b9c47e8f7e196b4f36220b2734b472"}, "root", "root", ""},
{"/usr/share/doc/zork-1.0.3", 16877, 38, file.Digest{}, "root", "root", ""},
@ -83,20 +109,128 @@ func TestParseRpmFiles(t *testing.T) {
},
},
{
fixture: "test-fixtures/bad",
name: "bad rpms",
fixtureDir: "test-fixtures/bad",
},
{
name: "rpms with signatures from RSA header",
fixtureImage: "image-rpm-archive",
skipFiles: true,
expected: []pkg.Package{
{
Name: "postgresql14-server",
Version: "0:14.10-1PGDG.rhel9",
PURL: "pkg:rpm/postgresql14-server@14.10-1PGDG.rhel9?arch=x86_64&epoch=0&upstream=postgresql14-14.10-1PGDG.rhel9.src.rpm",
Locations: file.NewLocationSet(file.NewLocation("/postgresql14-server-14.10-1PGDG.rhel9.x86_64.rpm")),
FoundBy: "rpm-archive-cataloger",
Type: pkg.RpmPkg,
Licenses: pkg.NewLicenseSet(pkg.NewLicenseFromLocations("PostgreSQL", file.NewLocation("/postgresql14-server-14.10-1PGDG.rhel9.x86_64.rpm"))),
Language: "",
CPEs: nil,
Metadata: pkg.RpmArchive{
Name: "postgresql14-server",
Version: "14.10",
Epoch: ref(0),
Arch: "x86_64",
Release: "1PGDG.rhel9",
SourceRpm: "postgresql14-14.10-1PGDG.rhel9.src.rpm",
Size: 24521699,
Signatures: []pkg.RpmSignature{
{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA256",
Created: "Tue Jan 2 16:45:56 2024",
IssuerKeyID: "40bca2b408b40d20",
},
},
Vendor: "PostgreSQL Global Development Group",
// note: files are not asserted in this test
},
},
},
},
}
for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
var opts []cmp.Option
if test.skipFiles {
opts = append(opts, cmpopts.IgnoreFields(pkg.RpmArchive{}, "Files"))
}
pkgtest.NewCatalogTester().
FromDirectory(t, test.fixture).
WithCompareOptions(opts...).
FromDirectory(t, test.fixtureDir).
WithImageResolver(t, test.fixtureImage).
IgnoreLocationLayer().
Expects(test.expected, nil).
TestCataloger(t, NewArchiveCataloger())
})
}
}
func Test_parseRSA(t *testing.T) {
tests := []struct {
name string
data string
want *pkg.RpmSignature
wantErr assert.ErrorAssertionFunc
}{
{
name: "older RSA header",
data: "89021503050058d3e39b0946fca2c105b9de0102b12a1000a2b3d347b51142e83b2de5e03ba9096f6330b72c140e46200d662b01c78534d14fab2ad4f07325119386830dd590219f27a22e420680283c500c40e6fba95404884b0a0abca8f198030ddc03653b7db2883b8230687e9e73d43eb5a24dbabfa48bbb3d1151ed264744e5e8ca169b0c4673a1440a9b99e53e693c9722f6423833cd7795e3044227fb922e21b7c007f03e923fae3f04d1ac2e8581e68c6790115b6dccfc02c8cb41681ed84785df086d6e26008c257d088a524ba2e7a7a5f41ad26b106c67b87fe48118b69662db612c23d2140059286f1ba7764627def6867ad0e11fe3a01fb1422dabe6f5cdf4cd876dc4fadfd2364bc3ba3758db94aaf3b82368cba65cf762287f713eb7ddc773acf93b083c739577a7eaf1f99e7dcbb8db1da050490e9fb67c838448db060a9e619d318c96f03e4363808d84ce29e8c102c290cc2bfab5746f3d9ddc9eb8b428f3ad2678abb2d46e846ddca7fc41322d76a97be6d416b4750f23320ec725e082be4496483b4cd3a3d2c515b3c8a6e27541139d809245140303877b84842ed2dd0454a78b2dfb7d6213784697077a8167942ebda5995a28d8256957e33e301706c35944ae05c7a54a4dd89be654d26cefa5cf0f616bbeaf317138371b09c5bbd5531f716020e553354ce5dbce3d9bb72f21e1857408dfd5a35250ff40f61ae1e25409ae9d21db76b8878341f4762a22be2189",
// RSA/SHA1, Thu Mar 23 15:02:51 2017, Key ID 0946fca2c105b9de
want: &pkg.RpmSignature{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA1",
Created: "Thu Mar 23 15:02:51 2017",
IssuerKeyID: "0946fca2c105b9de",
},
},
{
name: "newer RSA header",
data: "89024a04000108003416210421cb256ae16fc54c6e652949702d426d350d275d050262804369161c72656c656e6740726f636b796c696e75782e6f7267000a0910702d426d350d275dc8910ffd14f0f80297481fea648e7ba5a74bce10c5faccc2bbe588caece04be34d304a6a445538afc97a7033d43c983d27cc8f5ee515b2dd92f3e03354c413e55372a4d19386eb0f2354f9a26ee5fc2e56dfda49555e4a58b49279b70cd2036b04f28125f85942f640f2984e29e079f26bf6f76831d83d95983aa084a3e7b6327be2e23d0d799c4b4d1cfb36147ddfb782bf9df7b331d97f4f46b38f968b6130d87b0ef6bb0d424390fe34e38092babed37440569a93f55f50a2bdb58be0259f35badf7e728bd49824ed47f69fa53b6e26736bde4d8358d959b090e88054c3e179745dc7377e41b54b4e10223f4859e88162c7c5ec64b78d36cf8a914c1c2deb8c4f19a70d406e70756a89195d6aee488a9b40b9dbb76b2c38e528eb88d08ec35774a48ed9ce4e0dfac45cb7613ad5921f54c61d3aae5d7b3ab0e2e6ff867ac8f395b37af78b5c01022a4a4e62f7a99425fccb7439880cd6b393a3050b2e9512693bc36f6fe9de2921dda59710a1508965065244cf9f0f8cfc5bd554777f1a84d2249339234d62f2441249f617ad7df4fb01367a91d3a880e86fdb84bc6d03a127b44a28c6ceadef89e438db9640aa59b8a3f460b07272511f8187a5f3b163c8fd1caa61667401bce2ccdb1c176c46be10ef8033903132cca5889fa3661b2fba590c41fa1c104c08426677bdbf745a52ccd28f581960cf9d7e4ede3b9584aacb2f20ef93",
// RSA/SHA256, Sun May 15 00:03:53 2022, Key ID 702d426d350d275d
want: &pkg.RpmSignature{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA256",
Created: "Sun May 15 00:03:53 2022",
IssuerKeyID: "702d426d350d275d",
},
},
{
name: "example from rocky9",
data: "8901b304000108001d162104d4bf08ae67a0b4c7a1dbccd240bca2b408b40d20050265943dc4000a091040bca2b408b40d203b270bff71678ffeb190833a19a82112f59eee64cba186ab454d4526e0b3c8797e723f6916daff1b1f18cbf53c0da5d398a3a42065e79e5ca939f721652f38400dd4cac1107a902b1dae880649437ad0242444f3f07115172cae0a207b7cf8340af2f4a94976325f1dc165d5c2a564be322c4e130adb6217e7138b689f08898c407b223aa1ff8f8d592f31eba2256c02fae70ce4022d688a487972646b8bf1b518b5d6549c1e60fd812134422d9fdb41cf799f5eab80e48b4ab7cff84362dc867ed1af1416dd78e92bcc59217de7064b9a015d94a5097788689b9b6fbdeea679cfe4a6947f73dc3a6c810f2cb999d279b01564422d1500fc1bd8bd1eefa2d60660127ffef24067354660f93c0faf81f4edd599dd7e4b77fe4bff6c7a0ea83530c817c38d1f2364175883c6ef7b6dec86ad282bdd5138b8597567db96810c4ed6454a4ab1d98f0425dcd8892a5d46ed9289cb3ae3e1f1e2663d3e8188e873428f6cf7163563ed3860edc4fee81522389508847e692e2d13310eb4b40f7fdd7eb364a0b2dc",
// RSA/SHA256, Tue Jan 2 16:45:56 2024, Key ID 40bca2b408b40d20
want: &pkg.RpmSignature{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA256",
Created: "Tue Jan 2 16:45:56 2024",
IssuerKeyID: "40bca2b408b40d20",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.wantErr == nil {
tt.wantErr = assert.NoError
}
data, err := hex.DecodeString(tt.data)
require.NoError(t, err)
got, err := parsePGP(data)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}
func ref[T any](v T) *T {
return &v
}
func Test_corruptRpmArchive(t *testing.T) {
pkgtest.NewCatalogTester().
FromFile(t, "test-fixtures/bad/bad.rpm").

View File

@ -2,9 +2,11 @@ package redhat
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
rpmdb "github.com/knqyf263/go-rpmdb/pkg"
@ -69,6 +71,14 @@ func parseRpmDB(ctx context.Context, resolver file.Resolver, env *generic.Enviro
files, err := extractRpmFileRecords(resolver, *entry)
errs = unknown.Join(errs, err)
// there is a period of time when RPM DB entries contain both PGP and RSA signatures that are the same.
// This appears to be a holdover, where nowadays only the RSA Header is used.
sigs, err := parseSignatures(strings.TrimSpace(entry.PGP), strings.TrimSpace(entry.RSAHeader))
if err != nil {
log.WithFields("error", err, "location", reader.RealPath, "pkg", fmt.Sprintf("%s@%s", entry.Name, entry.Version)).Trace("unable to parse signatures for package %s", entry.Name)
sigs = nil
}
metadata := pkg.RpmDBEntry{
Name: entry.Name,
Version: entry.Version,
@ -76,6 +86,7 @@ func parseRpmDB(ctx context.Context, resolver file.Resolver, env *generic.Enviro
Arch: entry.Arch,
Release: entry.Release,
SourceRpm: entry.SourceRpm,
Signatures: sigs,
Vendor: entry.Vendor,
Size: entry.Size,
ModularityLabel: &entry.Modularitylabel,
@ -110,6 +121,70 @@ func parseRpmDB(ctx context.Context, resolver file.Resolver, env *generic.Enviro
return allPkgs, nil, errs
}
func parseSignatures(sigs ...string) ([]pkg.RpmSignature, error) {
var parsedSigs []pkg.RpmSignature
var errs error
for _, sig := range sigs {
if sig == "" {
continue
}
parts := strings.Split(sig, ",")
if len(parts) != 3 {
errs = errors.Join(fmt.Errorf("invalid signature format: %s", sig))
continue
}
methodParts := strings.SplitN(strings.TrimSpace(parts[0]), "/", 2)
if len(methodParts) != 2 {
errs = errors.Join(fmt.Errorf("invalid signature method format: %s", parts[0]))
continue
}
pka := strings.TrimSpace(methodParts[0])
hash := strings.TrimSpace(methodParts[1])
if pka == "" || hash == "" {
errs = errors.Join(fmt.Errorf("invalid signature method values: public-key=%q hash=%q", pka, hash))
continue
}
created := strings.TrimSpace(parts[1])
if created == "" {
errs = errors.Join(fmt.Errorf("invalid signature created value: %q", parts[1]))
continue
}
issuerFields := strings.Split(strings.TrimSpace(parts[2]), " ")
var issuer string
switch len(issuerFields) {
case 0:
errs = errors.Join(fmt.Errorf("no signature issuer value: %q", parts[2]))
case 1:
issuer = issuerFields[0]
default:
issuer = issuerFields[len(issuerFields)-1]
if issuer == "" {
errs = errors.Join(fmt.Errorf("invalid signature issuer value: %q", parts[2]))
continue
}
}
if len(issuer) < 5 {
errs = errors.Join(fmt.Errorf("invalid signature issuer length: %q", parts[2]))
continue
}
parsedSig := pkg.RpmSignature{
PublicKeyAlgorithm: pka,
HashAlgorithm: hash,
Created: created,
IssuerKeyID: issuer,
}
parsedSigs = append(parsedSigs, parsedSig)
}
return parsedSigs, errs
}
// The RPM naming scheme is [name]-[version]-[release]-[arch], where version is implicitly expands to [epoch]:[version].
// RPM version comparison depends on comparing at least the version and release fields together as a subset of the
// naming scheme. This toELVersion function takes a RPM DB package information and converts it into a minimally comparable

View File

@ -7,6 +7,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
@ -222,6 +223,132 @@ func Test_corruptRpmDbEntry(t *testing.T) {
TestParser(t, parseRpmDB)
}
func TestParseSignatures(t *testing.T) {
tests := []struct {
name string
sigs []string
expected []pkg.RpmSignature
expectedError require.ErrorAssertionFunc
}{
{
name: "valid signature",
sigs: []string{"RSA/SHA256, Mon May 16 12:32:55 2022, Key ID 702d426d350d275d"},
expected: []pkg.RpmSignature{
{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA256",
Created: "Mon May 16 12:32:55 2022",
IssuerKeyID: "702d426d350d275d",
},
},
},
{
name: "multiple valid signatures",
sigs: []string{
"RSA/SHA256, Mon May 16 12:32:55 2022, Key ID 702d426d350d275d",
"DSA/SHA1, Tue Jun 14 09:45:12 2023, Key ID 123abc456def789",
},
expected: []pkg.RpmSignature{
{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA256",
Created: "Mon May 16 12:32:55 2022",
IssuerKeyID: "702d426d350d275d",
},
{
PublicKeyAlgorithm: "DSA",
HashAlgorithm: "SHA1",
Created: "Tue Jun 14 09:45:12 2023",
IssuerKeyID: "123abc456def789",
},
},
},
{
name: "no signatures",
sigs: []string{},
expected: nil,
},
{
name: "empty signatures",
sigs: []string{"", "", ""},
expected: nil,
},
{
name: "invalid parts count",
sigs: []string{"RSA/SHA256, Mon May 16 12:32:55 2022"},
expected: nil,
expectedError: require.Error,
},
{
name: "invalid method format",
sigs: []string{"RSASHA256, Mon May 16 12:32:55 2022, Key ID 702d426d350d275d"},
expected: nil,
expectedError: require.Error,
},
{
name: "empty method values",
sigs: []string{"/, Mon May 16 12:32:55 2022, Key ID 702d426d350d275d"},
expected: nil,
expectedError: require.Error,
},
{
name: "empty created value",
sigs: []string{"RSA/SHA256, , Key ID 702d426d350d275d"},
expected: nil,
expectedError: require.Error,
},
{
name: "empty issuer value",
sigs: []string{"RSA/SHA256, Mon May 16 12:32:55 2022, Key ID "},
expected: nil,
expectedError: require.Error,
},
{
name: "issuer without prefix",
sigs: []string{"RSA/SHA256, Mon May 16 12:32:55 2022, 702d426d350d275d"},
expected: []pkg.RpmSignature{
{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA256",
Created: "Mon May 16 12:32:55 2022",
IssuerKeyID: "702d426d350d275d",
},
},
},
{
name: "mixed valid and invalid signatures",
sigs: []string{
"RSA/SHA256, Mon May 16 12:32:55 2022, Key ID 702d426d350d275d",
"DSASHA1, Tue Jun 14 09:45:12 2023, Key ID 123abc456def789",
},
expected: []pkg.RpmSignature{
{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA256",
Created: "Mon May 16 12:32:55 2022",
IssuerKeyID: "702d426d350d275d",
},
},
expectedError: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.expectedError == nil {
tt.expectedError = require.NoError
}
got, err := parseSignatures(tt.sigs...)
tt.expectedError(t, err)
if err != nil {
return
}
require.Equal(t, tt.expected, got)
})
}
}
func intRef(i int) *int {
return &i
}

View File

@ -0,0 +1,26 @@
FROM --platform=linux/amd64 rockylinux:9 AS rpm-downloader
# download a signed RPM (PostgreSQL in this example, known to be signed)
# using PostgreSQL official RPM which is signed with their GPG key
# $ rpm -Kv postgresql14-server-14.10-1PGDG.rhel9.x86_64.rpm
# postgresql14-server-14.10-1PGDG.rhel9.x86_64.rpm:
# Header V4 RSA/SHA256 Signature, key ID 08b40d20: NOKEY
# Header SHA256 digest: OK
# Header SHA1 digest: OK
# Payload SHA256 digest: OK
# MD5 digest: OK
#
# $ rpm -ivh --nodeps --force postgresql14-server-14.10-1PGDG.rhel9.x86_64.rpm
# warning: postgresql14-server-14.10-1PGDG.rhel9.x86_64.rpm: Header V4 RSA/SHA256 Signature, key ID 08b40d20: NOKEY
# Verifying... ################################# [100%]
# Preparing... ################################# [100%]
#
# $ rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE} %{RSAHEADER:pgpsig}\n' postgresql14-server-14.10-1PGDG.rhel9.x86_64
# postgresql14-server-14.10-1PGDG.rhel9 RSA/SHA256, Tue Jan 2 16:45:56 2024, Key ID 40bca2b408b40d20
RUN curl -O https://download.postgresql.org/pub/repos/yum/14/redhat/rhel-9-x86_64/postgresql14-server-14.10-1PGDG.rhel9.x86_64.rpm
FROM scratch
COPY --from=rpm-downloader /postgresql14-server-14.10-1PGDG.rhel9.x86_64.rpm /postgresql14-server-14.10-1PGDG.rhel9.x86_64.rpm

View File

@ -2,6 +2,7 @@ package pkg
import (
"sort"
"strings"
"github.com/scylladb/go-set/strset"
@ -32,6 +33,7 @@ type RpmDBEntry struct {
Arch string `json:"architecture"`
Release string `json:"release" cyclonedx:"release"`
SourceRpm string `json:"sourceRpm" cyclonedx:"sourceRpm"`
Signatures []RpmSignature `json:"signatures,omitempty" cyclonedx:"signatures"`
Size int `json:"size" cyclonedx:"size"`
Vendor string `json:"vendor"`
ModularityLabel *string `json:"modularityLabel,omitempty"`
@ -40,6 +42,22 @@ type RpmDBEntry struct {
Files []RpmFileRecord `json:"files"`
}
type RpmSignature struct {
PublicKeyAlgorithm string `json:"algo"`
HashAlgorithm string `json:"hash"`
Created string `json:"created"`
IssuerKeyID string `json:"issuer"`
}
func (s RpmSignature) String() string {
if s.PublicKeyAlgorithm == "" && s.HashAlgorithm == "" && s.Created == "" && s.IssuerKeyID == "" {
return ""
}
// mimics the output you would see from rpm -q --qf "%{RSAHEADER}"
// e.g."RSA/SHA256, Mon May 16 12:32:55 2022, Key ID 702d426d350d275d"
return strings.Join([]string{s.PublicKeyAlgorithm + "/" + s.HashAlgorithm, s.Created, "Key ID " + s.IssuerKeyID}, ", ")
}
// RpmFileRecord represents the file metadata for a single file attributed to a RPM package.
type RpmFileRecord struct {
Path string `json:"path"`

View File

@ -5,6 +5,7 @@ import (
"testing"
"github.com/go-test/deep"
"github.com/stretchr/testify/assert"
)
func TestRpmMetadata_FileOwner(t *testing.T) {
@ -46,3 +47,49 @@ func TestRpmMetadata_FileOwner(t *testing.T) {
})
}
}
func TestRpmSignature_String(t *testing.T) {
tests := []struct {
name string
signature RpmSignature
expected string
}{
{
name: "standard signature",
signature: RpmSignature{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "SHA256",
Created: "Mon May 16 12:32:55 2022",
IssuerKeyID: "702d426d350d275d",
},
expected: "RSA/SHA256, Mon May 16 12:32:55 2022, Key ID 702d426d350d275d",
},
{
name: "empty fields",
signature: RpmSignature{
PublicKeyAlgorithm: "",
HashAlgorithm: "",
Created: "",
IssuerKeyID: "",
},
expected: "",
},
{
name: "partial empty fields",
signature: RpmSignature{
PublicKeyAlgorithm: "RSA",
HashAlgorithm: "",
Created: "Mon May 16 12:32:55 2022",
IssuerKeyID: "",
},
expected: "RSA/, Mon May 16 12:32:55 2022, Key ID ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.signature.String()
assert.Equal(t, tt.expected, result)
})
}
}