From 877ef654b1dff0171fba4ea7f3e917bf06c12130 Mon Sep 17 00:00:00 2001 From: knqyf263 Date: Wed, 15 Jul 2026 14:48:05 +0400 Subject: [PATCH 1/3] feat: add neutral cryptographic asset model --- internal/cryptotest/asset.go | 98 +++++++ internal/cryptotest/asset_test.go | 76 ++++++ internal/cryptotest/descriptor.go | 33 +++ pkg/crypto/algorithm.go | 19 ++ pkg/crypto/asset.go | 187 +++++++++++++ pkg/crypto/asset_test.go | 387 +++++++++++++++++++++++++++ pkg/crypto/certificate.go | 31 +++ pkg/crypto/descriptor.go | 275 +++++++++++++++++++ pkg/crypto/descriptor_test.go | 421 ++++++++++++++++++++++++++++++ pkg/crypto/key.go | 44 ++++ pkg/crypto/relationship.go | 35 +++ 11 files changed, 1606 insertions(+) create mode 100644 internal/cryptotest/asset.go create mode 100644 internal/cryptotest/asset_test.go create mode 100644 internal/cryptotest/descriptor.go create mode 100644 pkg/crypto/algorithm.go create mode 100644 pkg/crypto/asset.go create mode 100644 pkg/crypto/asset_test.go create mode 100644 pkg/crypto/certificate.go create mode 100644 pkg/crypto/descriptor.go create mode 100644 pkg/crypto/descriptor_test.go create mode 100644 pkg/crypto/key.go create mode 100644 pkg/crypto/relationship.go diff --git a/internal/cryptotest/asset.go b/internal/cryptotest/asset.go new file mode 100644 index 0000000000..9454ab29c4 --- /dev/null +++ b/internal/cryptotest/asset.go @@ -0,0 +1,98 @@ +// Package cryptotest provides cryptographic asset fixtures for tests. +package cryptotest + +import ( + "strings" + + "github.com/aquasecurity/trivy/pkg/crypto" +) + +// Option customizes an Asset fixture. +type Option func(*crypto.Asset) + +// WithMutate applies mutate after constructing a complete Asset fixture. +func WithMutate(mutate func(*crypto.Asset)) Option { + return mutate +} + +// CertificateAsset returns a valid certificate asset. +func CertificateAsset(opts ...Option) crypto.Asset { + asset := crypto.Asset{ + Kind: crypto.KindCertificate, + Identity: crypto.Identity{ + Method: crypto.MethodSHA256, + Value: strings.Repeat("a", 64), + }, + Name: "example.test", + FilePath: "/etc/example.pem", + Certificate: &crypto.Certificate{ + Subject: "CN=example.test", + Issuer: "CN=Example Test CA", + SerialNumber: "1", + Format: crypto.CertificateFormatX509, + }, + } + return applyOptions(asset, opts) +} + +// PublicKeyAsset returns a valid public key asset. +func PublicKeyAsset(opts ...Option) crypto.Asset { + asset := crypto.Asset{ + Kind: crypto.KindKey, + KeyType: crypto.KeyTypePublic, + Identity: crypto.Identity{ + Method: crypto.MethodSPKISHA256, + Value: strings.Repeat("b", 64), + }, + FilePath: "/etc/example-public.pem", + Key: &crypto.Key{ + Size: 2048, + Format: crypto.KeyFormatPKIX, + Encoding: crypto.EncodingPEM, + }, + } + return applyOptions(asset, opts) +} + +// PrivateKeyAsset returns a valid private key asset. +func PrivateKeyAsset(opts ...Option) crypto.Asset { + asset := PublicKeyAsset() + asset.KeyType = crypto.KeyTypePrivate + asset.FilePath = "/etc/example-private.pem" + asset.Key.Format = crypto.KeyFormatPKCS8 + return applyOptions(asset, opts) +} + +// EncryptedPrivateKeyAsset returns a valid encrypted private key asset. +func EncryptedPrivateKeyAsset(opts ...Option) crypto.Asset { + asset := PrivateKeyAsset() + asset.Identity.Method = crypto.MethodEncryptedPKCS8SHA256 + asset.FilePath = "/etc/example-encrypted-private.pem" + asset.Key.Encrypted = true + return applyOptions(asset, opts) +} + +// AlgorithmAsset returns a valid algorithm asset. +func AlgorithmAsset(opts ...Option) crypto.Asset { + asset := crypto.Asset{ + Kind: crypto.KindAlgorithm, + Identity: crypto.Identity{ + Method: crypto.MethodOID, + Value: "1.2.840.113549.1.1.1", + }, + Name: "RSA", + FilePath: "/etc/example-algorithm.pem", + Algorithm: &crypto.Algorithm{ + Family: "RSA", + Primitive: crypto.PrimitivePKE, + }, + } + return applyOptions(asset, opts) +} + +func applyOptions(asset crypto.Asset, opts []Option) crypto.Asset { + for _, opt := range opts { + opt(&asset) + } + return asset +} diff --git a/internal/cryptotest/asset_test.go b/internal/cryptotest/asset_test.go new file mode 100644 index 0000000000..9a47552c03 --- /dev/null +++ b/internal/cryptotest/asset_test.go @@ -0,0 +1,76 @@ +package cryptotest_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/internal/cryptotest" + "github.com/aquasecurity/trivy/pkg/crypto" +) + +func TestAssets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + asset func(...cryptotest.Option) crypto.Asset + mutate func(*crypto.Asset) + }{ + { + name: "certificate", + asset: cryptotest.CertificateAsset, + mutate: func(asset *crypto.Asset) { + asset.Certificate.Subject = "changed" + }, + }, + { + name: "public key", + asset: cryptotest.PublicKeyAsset, + mutate: func(asset *crypto.Asset) { + asset.Key.Size = 4096 + }, + }, + { + name: "private key", + asset: cryptotest.PrivateKeyAsset, + mutate: func(asset *crypto.Asset) { + asset.Key.Size = 4096 + }, + }, + { + name: "encrypted private key", + asset: cryptotest.EncryptedPrivateKeyAsset, + mutate: func(asset *crypto.Asset) { + asset.Key.Size = 4096 + }, + }, + { + name: "algorithm", + asset: cryptotest.AlgorithmAsset, + mutate: func(asset *crypto.Asset) { + asset.Algorithm.Family = "changed" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + first := tt.asset() + second := tt.asset() + require.NoError(t, first.Validate()) + require.NoError(t, second.Validate()) + assert.NotEmpty(t, first.FilePath) + assert.Equal(t, first, second) + + mutated := tt.asset(cryptotest.WithMutate(tt.mutate)) + assert.NotEqual(t, first, mutated) + + tt.mutate(&first) + assert.NotEqual(t, first, second) + }) + } +} diff --git a/internal/cryptotest/descriptor.go b/internal/cryptotest/descriptor.go new file mode 100644 index 0000000000..04c226851a --- /dev/null +++ b/internal/cryptotest/descriptor.go @@ -0,0 +1,33 @@ +package cryptotest + +import "github.com/aquasecurity/trivy/pkg/crypto" + +// CertificateDescriptor returns the descriptor of CertificateAsset. +func CertificateDescriptor() crypto.Descriptor { + asset := CertificateAsset() + return asset.Descriptor() +} + +// PublicKeyDescriptor returns the descriptor of PublicKeyAsset. +func PublicKeyDescriptor() crypto.Descriptor { + asset := PublicKeyAsset() + return asset.Descriptor() +} + +// PrivateKeyDescriptor returns the descriptor of PrivateKeyAsset. +func PrivateKeyDescriptor() crypto.Descriptor { + asset := PrivateKeyAsset() + return asset.Descriptor() +} + +// EncryptedPrivateKeyDescriptor returns the descriptor of EncryptedPrivateKeyAsset. +func EncryptedPrivateKeyDescriptor() crypto.Descriptor { + asset := EncryptedPrivateKeyAsset() + return asset.Descriptor() +} + +// AlgorithmDescriptor returns the descriptor of AlgorithmAsset. +func AlgorithmDescriptor() crypto.Descriptor { + asset := AlgorithmAsset() + return asset.Descriptor() +} diff --git a/pkg/crypto/algorithm.go b/pkg/crypto/algorithm.go new file mode 100644 index 0000000000..e637ed10bb --- /dev/null +++ b/pkg/crypto/algorithm.go @@ -0,0 +1,19 @@ +package crypto + +// Primitive identifies the cryptographic primitive provided by an algorithm. +type Primitive string + +const ( + // PrimitiveUnknown identifies an algorithm with an unknown primitive. + PrimitiveUnknown Primitive = "unknown" + // PrimitiveSignature identifies a digital signature algorithm. + PrimitiveSignature Primitive = "signature" + // PrimitivePKE identifies a public-key encryption algorithm. + PrimitivePKE Primitive = "pke" +) + +// Algorithm contains algorithm-specific metadata. +type Algorithm struct { + Family string `json:",omitempty"` + Primitive Primitive `json:",omitempty"` +} diff --git a/pkg/crypto/asset.go b/pkg/crypto/asset.go new file mode 100644 index 0000000000..ce76d22b1f --- /dev/null +++ b/pkg/crypto/asset.go @@ -0,0 +1,187 @@ +package crypto + +import ( + "slices" + + "github.com/samber/lo" + "golang.org/x/xerrors" +) + +// Kind identifies the category of a cryptographic asset. +type Kind string + +const ( + // KindCertificate identifies a certificate asset. + KindCertificate Kind = "certificate" + // KindKey identifies a cryptographic key asset. + KindKey Kind = "key" + // KindAlgorithm identifies a cryptographic algorithm asset. + KindAlgorithm Kind = "algorithm" +) + +// IdentityMethod identifies the canonical method used to identify an asset. +type IdentityMethod string + +const ( + // MethodSHA256 identifies KindCertificate assets. The value is the lowercase SHA-256 digest of canonical X.509 DER, and parameters are empty. + MethodSHA256 IdentityMethod = "sha256" + // MethodSPKISHA256 identifies KindKey assets with KeyTypePublic or unencrypted KeyTypePrivate. The value is the lowercase SHA-256 digest of SubjectPublicKeyInfo DER, and parameters are empty. + MethodSPKISHA256 IdentityMethod = "spki-sha256" + // MethodEncryptedPKCS8SHA256 identifies opaque KindKey assets with KeyTypePrivate. The value is the lowercase SHA-256 digest of encrypted PKCS#8 DER, and parameters are empty. + MethodEncryptedPKCS8SHA256 IdentityMethod = "encrypted-pkcs8-sha256" + // MethodOID identifies KindAlgorithm assets. The value is a canonical dotted-decimal OID, and parameters are empty or key-size= / curve= when needed to distinguish the algorithm asset. + MethodOID IdentityMethod = "oid" +) + +// Identity is the method-specific portion of an Asset's identity; Kind and, for key assets, KeyType complete the identity represented by Descriptor. +type Identity struct { + Method IdentityMethod `json:",omitempty"` + Value string `json:",omitempty"` + Parameters string `json:",omitempty"` +} + +// Asset describes a format-neutral cryptographic asset. +type Asset struct { + descriptor *Descriptor + + Kind Kind `json:",omitempty"` + KeyType KeyType `json:",omitempty"` + Identity Identity `json:",omitzero"` + Name string `json:",omitempty"` + FilePath string `json:",omitempty"` + + // TODO: Replace these fields with fanal/types.Layer after layer provenance + // moves to a package that crypto can import without an import cycle. + LayerDigest string `json:",omitempty"` + LayerDiffID string `json:",omitempty"` + + Certificate *Certificate `json:",omitempty"` + Key *Key `json:",omitempty"` + Algorithm *Algorithm `json:",omitempty"` + Relationships []Relationship `json:",omitempty"` +} + +// Descriptor returns the cached comparable identity of the asset. Kind, +// KeyType, and Identity must not change after the first call. FilePath, +// layer fields, details, and relationships remain non-identity fields and may +// be set later. +func (a *Asset) Descriptor() Descriptor { + if a.descriptor == nil { + a.descriptor = new(Descriptor{ + Kind: a.Kind, + KeyType: a.KeyType, + Identity: a.Identity, + }) + } + return *a.descriptor +} + +// Validate checks the intrinsic asset invariants. +func (a *Asset) Validate() error { + descriptor := a.Descriptor() + if err := descriptor.Validate(); err != nil { + return xerrors.Errorf("validate descriptor: %w", err) + } + + detailCount := lo.CountBy([]any{a.Certificate, a.Key, a.Algorithm}, lo.IsNotNil) + if detailCount != 1 { + return xerrors.Errorf("asset must contain exactly one detail, got %d", detailCount) + } + + switch a.Kind { + case KindCertificate: + if a.Certificate == nil { + return xerrors.Errorf("asset kind %q requires certificate details", a.Kind) + } + if err := a.validateCertificate(); err != nil { + return xerrors.Errorf("validate certificate: %w", err) + } + case KindKey: + if a.Key == nil { + return xerrors.Errorf("asset kind %q requires key details", a.Kind) + } + if err := a.validateKey(); err != nil { + return xerrors.Errorf("validate key: %w", err) + } + case KindAlgorithm: + if a.Algorithm == nil { + return xerrors.Errorf("asset kind %q requires algorithm details", a.Kind) + } + if err := a.validateAlgorithm(); err != nil { + return xerrors.Errorf("validate algorithm: %w", err) + } + } + + for i, relationship := range a.Relationships { + if err := relationship.validate(); err != nil { + return xerrors.Errorf("validate relationship %d: %w", i, err) + } + if relationship.RelatedAsset == descriptor { + return xerrors.Errorf("relationship %d refers to the source asset", i) + } + } + return nil +} + +// Clone returns a deep copy of the asset. +func (a *Asset) Clone() Asset { + clone := *a + clone.Relationships = slices.Clone(a.Relationships) + if a.Certificate != nil { + clone.Certificate = new(*a.Certificate) + clone.Certificate.KeyUsage = slices.Clone(a.Certificate.KeyUsage) + clone.Certificate.ExtendedKeyUsage = slices.Clone(a.Certificate.ExtendedKeyUsage) + clone.Certificate.DNSNames = slices.Clone(a.Certificate.DNSNames) + clone.Certificate.EmailAddresses = slices.Clone(a.Certificate.EmailAddresses) + clone.Certificate.IPAddresses = slices.Clone(a.Certificate.IPAddresses) + clone.Certificate.URIs = slices.Clone(a.Certificate.URIs) + } + if a.Key != nil { + clone.Key = new(*a.Key) + } + if a.Algorithm != nil { + clone.Algorithm = new(*a.Algorithm) + } + return clone +} + +func (a *Asset) validateCertificate() error { + if a.Certificate.Format != CertificateFormatX509 { + return xerrors.Errorf("unknown certificate format %q", a.Certificate.Format) + } + if a.Certificate.MaxPathLen < 0 { + return xerrors.Errorf("certificate path length must not be negative") + } + return nil +} + +func (a *Asset) validateKey() error { + if a.Key.Size < 0 { + return xerrors.Errorf("key size must not be negative") + } + switch a.Key.Format { + case "", KeyFormatPKCS1, KeyFormatPKCS8, KeyFormatSEC1, KeyFormatPKIX: + default: + return xerrors.Errorf("unknown key format %q", a.Key.Format) + } + switch a.Key.Encoding { + case "", EncodingPEM, EncodingDER: + default: + return xerrors.Errorf("unknown key encoding %q", a.Key.Encoding) + } + + encrypted := a.KeyType == KeyTypePrivate && a.Identity.Method == MethodEncryptedPKCS8SHA256 + if a.Key.Encrypted != encrypted { + return xerrors.Errorf("key encrypted flag does not match identification method %q", a.Identity.Method) + } + return nil +} + +func (a *Asset) validateAlgorithm() error { + switch a.Algorithm.Primitive { + case PrimitiveUnknown, PrimitiveSignature, PrimitivePKE: + default: + return xerrors.Errorf("unknown algorithm primitive %q", a.Algorithm.Primitive) + } + return nil +} diff --git a/pkg/crypto/asset_test.go b/pkg/crypto/asset_test.go new file mode 100644 index 0000000000..2f2283ab65 --- /dev/null +++ b/pkg/crypto/asset_test.go @@ -0,0 +1,387 @@ +package crypto_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/internal/cryptotest" + "github.com/aquasecurity/trivy/pkg/crypto" +) + +func TestAssetDescriptor(t *testing.T) { + t.Parallel() + + asset := cryptotest.CertificateAsset() + want := crypto.Descriptor{ + Kind: asset.Kind, + KeyType: asset.KeyType, + Identity: asset.Identity, + } + // The first call caches the descriptor's kind, key type, and identity. + assert.Equal(t, want, asset.Descriptor()) + + asset.Kind = crypto.KindKey + asset.KeyType = crypto.KeyTypePrivate + asset.Identity = crypto.Identity{ + Method: crypto.MethodSPKISHA256, + Value: strings.Repeat("b", 64), + } + // Later mutations to all descriptor source fields do not change the cached value. + assert.Equal(t, want, asset.Descriptor()) +} + +func TestAssetDescriptorJSONRoundTrip(t *testing.T) { + t.Parallel() + + source := cryptotest.CertificateAsset() + cached := source.Descriptor() + source.Identity.Value = strings.Repeat("b", 64) + + // Marshal writes only the source fields and excludes the internal descriptor cache. + data, err := json.Marshal(source) + require.NoError(t, err) + assert.NotContains(t, string(data), "descriptor") + assert.NotContains(t, string(data), cached.Identity.Value) + + // Unmarshal restores the source fields without restoring the descriptor cache. + var got crypto.Asset + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, source.Identity, got.Identity) + + // The first Descriptor call on the decoded asset populates a new cache. + want := crypto.Descriptor{ + Kind: got.Kind, + KeyType: got.KeyType, + Identity: got.Identity, + } + assert.Equal(t, want, got.Descriptor()) + + // Mutating the decoded identity proves subsequent calls use that new cache. + got.Identity.Value = strings.Repeat("c", 64) + assert.Equal(t, want, got.Descriptor()) +} + +func TestAssetValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + asset crypto.Asset + wantErr string + }{ + { + name: "certificate", + asset: cryptotest.CertificateAsset(), + wantErr: "", + }, + { + name: "public key", + asset: cryptotest.PublicKeyAsset(), + wantErr: "", + }, + { + name: "derived public key without format or encoding", + asset: cryptotest.PublicKeyAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Key.Format = "" + a.Key.Encoding = "" + })), + wantErr: "", + }, + { + name: "private key", + asset: cryptotest.PrivateKeyAsset(), + wantErr: "", + }, + { + name: "encrypted private key", + asset: cryptotest.EncryptedPrivateKeyAsset(), + wantErr: "", + }, + { + name: "algorithm without parameters", + asset: cryptotest.AlgorithmAsset(), + wantErr: "", + }, + { + name: "algorithm with key size", + asset: cryptotest.AlgorithmAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Identity.Parameters = "key-size=2048" + })), + wantErr: "", + }, + { + name: "algorithm with curve", + asset: cryptotest.AlgorithmAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Identity.Parameters = "curve=P-256" + })), + wantErr: "", + }, + { + name: "missing detail", + asset: cryptotest.CertificateAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Certificate = nil + })), + wantErr: "asset must contain exactly one detail, got 0", + }, + { + name: "multiple details", + asset: cryptotest.CertificateAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Key = &crypto.Key{} + })), + wantErr: "asset must contain exactly one detail, got 2", + }, + { + name: "certificate detail on key", + asset: cryptotest.PublicKeyAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Certificate = &crypto.Certificate{Format: crypto.CertificateFormatX509} + a.Key = nil + })), + wantErr: `asset kind "key" requires key details`, + }, + { + name: "key detail on algorithm", + asset: cryptotest.AlgorithmAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Key = &crypto.Key{} + a.Algorithm = nil + })), + wantErr: `asset kind "algorithm" requires algorithm details`, + }, + { + name: "algorithm detail on certificate", + asset: cryptotest.CertificateAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Algorithm = &crypto.Algorithm{Primitive: crypto.PrimitiveUnknown} + a.Certificate = nil + })), + wantErr: `asset kind "certificate" requires certificate details`, + }, + { + name: "unencrypted encrypted container", + asset: cryptotest.EncryptedPrivateKeyAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Key.Encrypted = false + })), + wantErr: `key encrypted flag does not match identification method "encrypted-pkcs8-sha256"`, + }, + { + name: "encrypted plain key", + asset: cryptotest.PrivateKeyAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Key.Encrypted = true + })), + wantErr: `key encrypted flag does not match identification method "spki-sha256"`, + }, + { + name: "unknown certificate format", + asset: cryptotest.CertificateAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Certificate.Format = "PEM" + })), + wantErr: `unknown certificate format "PEM"`, + }, + { + name: "unknown key format", + asset: cryptotest.PublicKeyAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Key.Format = "OpenSSH" + })), + wantErr: `unknown key format "OpenSSH"`, + }, + { + name: "unknown key encoding", + asset: cryptotest.PublicKeyAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Key.Encoding = "SSH" + })), + wantErr: `unknown key encoding "SSH"`, + }, + { + name: "unknown primitive", + asset: cryptotest.AlgorithmAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Algorithm.Primitive = "hash" + })), + wantErr: `unknown algorithm primitive "hash"`, + }, + { + name: "negative key size", + asset: cryptotest.PublicKeyAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Key.Size = -1 + })), + wantErr: "key size must not be negative", + }, + { + name: "negative normalized path length", + asset: cryptotest.CertificateAsset(cryptotest.WithMutate(func(a *crypto.Asset) { + a.Certificate.MaxPathLen = -1 + })), + wantErr: "certificate path length must not be negative", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.asset.Validate() + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestAssetValidateRelationships(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + relationship crypto.Relationship + selfReference bool + wantErr string + }{ + { + name: "contains", + relationship: crypto.Relationship{ + Type: crypto.RelationshipContains, + RelatedAsset: cryptotest.PublicKeyDescriptor(), + }, + }, + { + name: "signed with", + relationship: crypto.Relationship{ + Type: crypto.RelationshipSignedWith, + RelatedAsset: cryptotest.PublicKeyDescriptor(), + }, + }, + { + name: "used with", + relationship: crypto.Relationship{ + Type: crypto.RelationshipUsedWith, + RelatedAsset: cryptotest.PublicKeyDescriptor(), + }, + }, + { + name: "corresponds to", + relationship: crypto.Relationship{ + Type: crypto.RelationshipCorrespondsTo, + RelatedAsset: cryptotest.PublicKeyDescriptor(), + }, + }, + { + name: "unknown type", + relationship: crypto.Relationship{ + Type: "issued_by", + RelatedAsset: cryptotest.PublicKeyDescriptor(), + }, + wantErr: `unknown relationship type "issued_by"`, + }, + { + name: "invalid related descriptor", + relationship: crypto.Relationship{ + Type: crypto.RelationshipContains, + RelatedAsset: crypto.Descriptor{ + Kind: crypto.KindAlgorithm, + Identity: crypto.Identity{ + Method: crypto.MethodOID, + Value: "1.02.3", + }, + }, + }, + wantErr: "identification value must be a canonical OID", + }, + { + name: "self-reference", + relationship: crypto.Relationship{ + Type: crypto.RelationshipContains, + }, + selfReference: true, + wantErr: "relationship 0 refers to the source asset", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + a := cryptotest.CertificateAsset() + if tt.selfReference { + tt.relationship.RelatedAsset = a.Descriptor() + } + a.Relationships = []crypto.Relationship{tt.relationship} + + err := a.Validate() + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestAssetClone(t *testing.T) { + t.Parallel() + + t.Run("certificate", func(t *testing.T) { + t.Parallel() + + source := cryptotest.CertificateAsset() + source.Certificate.KeyUsage = []string{"digital signature"} + source.Certificate.ExtendedKeyUsage = []string{"server auth"} + source.Certificate.DNSNames = []string{"example.com"} + source.Certificate.EmailAddresses = []string{"security@example.com"} + source.Certificate.IPAddresses = []string{"192.0.2.1"} + source.Certificate.URIs = []string{"spiffe://example.com/service"} + source.Relationships = []crypto.Relationship{{ + Type: crypto.RelationshipContains, + RelatedAsset: cryptotest.PublicKeyDescriptor(), + }} + + clone := source.Clone() + require.NotSame(t, source.Certificate, clone.Certificate) + assert.Equal(t, source, clone) + + // Mutating every nested mutable field proves the clone shares no mutable storage with the source. + clone.Certificate.Subject = "changed" + clone.Certificate.KeyUsage[0] = "changed" + clone.Certificate.ExtendedKeyUsage[0] = "changed" + clone.Certificate.DNSNames[0] = "changed" + clone.Certificate.EmailAddresses[0] = "changed" + clone.Certificate.IPAddresses[0] = "changed" + clone.Certificate.URIs[0] = "changed" + clone.Relationships[0].Type = crypto.RelationshipSignedWith + + assert.Equal(t, "CN=example.test", source.Certificate.Subject) + assert.Equal(t, []string{"digital signature"}, source.Certificate.KeyUsage) + assert.Equal(t, []string{"server auth"}, source.Certificate.ExtendedKeyUsage) + assert.Equal(t, []string{"example.com"}, source.Certificate.DNSNames) + assert.Equal(t, []string{"security@example.com"}, source.Certificate.EmailAddresses) + assert.Equal(t, []string{"192.0.2.1"}, source.Certificate.IPAddresses) + assert.Equal(t, []string{"spiffe://example.com/service"}, source.Certificate.URIs) + assert.Equal(t, crypto.RelationshipContains, source.Relationships[0].Type) + }) + + t.Run("key", func(t *testing.T) { + t.Parallel() + + source := cryptotest.PublicKeyAsset() + clone := source.Clone() + require.NotSame(t, source.Key, clone.Key) + assert.Equal(t, source, clone) + + clone.Key.Size = 4096 + assert.Equal(t, 2048, source.Key.Size) + }) + + t.Run("algorithm", func(t *testing.T) { + t.Parallel() + + source := cryptotest.AlgorithmAsset() + clone := source.Clone() + require.NotSame(t, source.Algorithm, clone.Algorithm) + assert.Equal(t, source, clone) + + clone.Algorithm.Family = "changed" + assert.Equal(t, "RSA", source.Algorithm.Family) + }) +} diff --git a/pkg/crypto/certificate.go b/pkg/crypto/certificate.go new file mode 100644 index 0000000000..78f07dd1e2 --- /dev/null +++ b/pkg/crypto/certificate.go @@ -0,0 +1,31 @@ +package crypto + +import "time" + +// CertificateFormat identifies a certificate serialization format. +type CertificateFormat string + +const ( + // CertificateFormatX509 identifies an X.509 certificate. + CertificateFormatX509 CertificateFormat = "X.509" +) + +// Certificate contains certificate-specific metadata. +type Certificate struct { + Subject string `json:",omitempty"` + Issuer string `json:",omitempty"` + SerialNumber string `json:",omitempty"` + NotBefore time.Time `json:",omitzero"` + NotAfter time.Time `json:",omitzero"` + Format CertificateFormat `json:",omitempty"` + KeyUsage []string `json:",omitempty"` + ExtendedKeyUsage []string `json:",omitempty"` + DNSNames []string `json:",omitempty"` + EmailAddresses []string `json:",omitempty"` + IPAddresses []string `json:",omitempty"` + URIs []string `json:",omitempty"` + BasicConstraintsValid bool `json:",omitempty"` + IsCA bool `json:",omitempty"` + MaxPathLen int `json:",omitempty"` + MaxPathLenZero bool `json:",omitempty"` +} diff --git a/pkg/crypto/descriptor.go b/pkg/crypto/descriptor.go new file mode 100644 index 0000000000..d2cb542368 --- /dev/null +++ b/pkg/crypto/descriptor.go @@ -0,0 +1,275 @@ +package crypto + +import ( + "encoding/json" + "net/url" + "strings" + + "golang.org/x/xerrors" +) + +// Descriptor is the comparable canonical identity of an asset. +type Descriptor struct { + Kind Kind `json:",omitempty"` + KeyType KeyType `json:",omitempty"` + Identity Identity `json:",omitzero"` +} + +// String returns the canonical encoded descriptor. +func (d Descriptor) String() string { + segments := []string{string(d.Kind)} + if d.Kind == KindKey { + segments = append(segments, string(d.KeyType)) + } + // QueryEscape uses the standard library's query-component encoding for + // variable segments. It escapes RFC 3986 reserved characters, including the + // descriptor's colon delimiter, and represents spaces as '+'. + segments = append(segments, string(d.Identity.Method), url.QueryEscape(d.Identity.Value)) + // Parameters distinguish algorithm assets that share an OID but use different + // key sizes or curves. + if d.Identity.Parameters != "" { + segments = append(segments, url.QueryEscape(d.Identity.Parameters)) + } + return strings.Join(segments, ":") +} + +// MarshalJSON validates and encodes the descriptor as its canonical string. +func (d Descriptor) MarshalJSON() ([]byte, error) { + if err := d.Validate(); err != nil { + return nil, xerrors.Errorf("validate descriptor: %w", err) + } + encoded, err := json.Marshal(d.String()) + if err != nil { + return nil, xerrors.Errorf("encode descriptor: %w", err) + } + return encoded, nil +} + +// UnmarshalJSON decodes and validates a descriptor string. +func (d *Descriptor) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return xerrors.Errorf("decode descriptor: %w", err) + } + + descriptor, err := parseDescriptor(s) + if err != nil { + return xerrors.Errorf("parse descriptor: %w", err) + } + *d = descriptor + return nil +} + +// Validate checks that the descriptor is structurally valid and canonical. +func (d Descriptor) Validate() error { + if err := d.validateKindKeyTypeMethod(); err != nil { + return xerrors.Errorf("validate kind, key type, and method: %w", err) + } + if err := d.validateIdentityValue(); err != nil { + return xerrors.Errorf("validate identity value: %w", err) + } + if err := d.validateParameters(); err != nil { + return xerrors.Errorf("validate parameters: %w", err) + } + return nil +} + +func (d Descriptor) validateKindKeyTypeMethod() error { + switch d.Kind { + case KindCertificate: + if d.KeyType != "" { + return xerrors.Errorf("certificate descriptor must not contain key type %q", d.KeyType) + } + if d.Identity.Method != MethodSHA256 { + return xerrors.Errorf("certificate descriptor requires identification method %q", MethodSHA256) + } + case KindKey: + switch d.KeyType { + case KeyTypePublic: + if d.Identity.Method != MethodSPKISHA256 { + return xerrors.Errorf("public key descriptor requires identification method %q", MethodSPKISHA256) + } + case KeyTypePrivate: + if d.Identity.Method != MethodSPKISHA256 && d.Identity.Method != MethodEncryptedPKCS8SHA256 { + return xerrors.Errorf("private key descriptor has unknown identification method %q", d.Identity.Method) + } + default: + return xerrors.Errorf("unknown key type %q", d.KeyType) + } + case KindAlgorithm: + if d.KeyType != "" { + return xerrors.Errorf("algorithm descriptor must not contain key type %q", d.KeyType) + } + if d.Identity.Method != MethodOID { + return xerrors.Errorf("algorithm descriptor requires identification method %q", MethodOID) + } + default: + return xerrors.Errorf("unknown asset kind %q", d.Kind) + } + return nil +} + +func (d Descriptor) validateIdentityValue() error { + switch d.Identity.Method { + case MethodSHA256, MethodSPKISHA256, MethodEncryptedPKCS8SHA256: + if !isLowerSHA256(d.Identity.Value) { + return xerrors.Errorf("identification value must be 64 lowercase hexadecimal characters") + } + case MethodOID: + if !isCanonicalOID(d.Identity.Value) { + return xerrors.Errorf("identification value must be a canonical OID") + } + } + return nil +} + +func (d Descriptor) validateParameters() error { + if d.Identity.Parameters == "" { + return nil + } + if d.Kind != KindAlgorithm || d.Identity.Method != MethodOID { + return xerrors.Errorf("parameters are only valid for OID algorithm descriptors") + } + if err := validateAlgorithmParameters(d.Identity.Parameters); err != nil { + return xerrors.Errorf("validate algorithm parameters: %w", err) + } + return nil +} + +func parseDescriptor(s string) (Descriptor, error) { + segments := strings.Split(s, ":") + var descriptor Descriptor + var valueSegment string + var parametersSegment string + + switch Kind(segments[0]) { + case KindCertificate: + if len(segments) != 3 { + return Descriptor{}, xerrors.Errorf("certificate descriptor must contain 3 segments") + } + descriptor.Kind = KindCertificate + descriptor.Identity.Method = IdentityMethod(segments[1]) + valueSegment = segments[2] + case KindKey: + if len(segments) != 4 { + return Descriptor{}, xerrors.Errorf("key descriptor must contain 4 segments") + } + descriptor.Kind = KindKey + descriptor.KeyType = KeyType(segments[1]) + descriptor.Identity.Method = IdentityMethod(segments[2]) + valueSegment = segments[3] + case KindAlgorithm: + if len(segments) != 3 && len(segments) != 4 { + return Descriptor{}, xerrors.Errorf("algorithm descriptor must contain 3 or 4 segments") + } + descriptor.Kind = KindAlgorithm + descriptor.Identity.Method = IdentityMethod(segments[1]) + valueSegment = segments[2] + if len(segments) == 4 { + parametersSegment = segments[3] + if parametersSegment == "" { + return Descriptor{}, xerrors.Errorf("algorithm descriptor parameters must not be empty") + } + } + default: + return Descriptor{}, xerrors.Errorf("unknown descriptor kind %q", segments[0]) + } + + value, err := url.QueryUnescape(valueSegment) + if err != nil { + return Descriptor{}, xerrors.Errorf("decode identification value: %w", err) + } + descriptor.Identity.Value = value + if len(segments) == 4 && descriptor.Kind == KindAlgorithm { + parameters, err := url.QueryUnescape(parametersSegment) + if err != nil { + return Descriptor{}, xerrors.Errorf("decode identification parameters: %w", err) + } + descriptor.Identity.Parameters = parameters + } + + if err := descriptor.Validate(); err != nil { + return Descriptor{}, xerrors.Errorf("validate descriptor: %w", err) + } + return descriptor, nil +} + +// validateAlgorithmParameters accepts only empty parameters, key-size=, and curve=. +func validateAlgorithmParameters(parameters string) error { + if parameters == "" { + return nil + } + if value, ok := strings.CutPrefix(parameters, "key-size="); ok { + if !isCanonicalPositiveDecimal(value) { + return xerrors.Errorf("key size parameter must be a canonical positive decimal") + } + return nil + } + if value, ok := strings.CutPrefix(parameters, "curve="); ok { + if value == "" { + return xerrors.Errorf("curve parameter must not be empty") + } + return nil + } + return xerrors.Errorf("unknown algorithm parameters %q", parameters) +} + +func isLowerSHA256(value string) bool { + if len(value) != 64 { + return false + } + for i := 0; i < len(value); i++ { + if (value[i] < '0' || value[i] > '9') && (value[i] < 'a' || value[i] > 'f') { + return false + } + } + return true +} + +// isCanonicalOID reports whether value uses RFC 4512 section 1.4's numeric OID +// form and satisfies the root-arc constraints from ITU-T X.660 section 7.6. It +// does not validate every ASN.1 OID notation. +// +// RFC 4512: https://www.rfc-editor.org/rfc/rfc4512.html#section-1.4 +// ITU-T X.660: https://www.itu.int/rec/T-REC-X.660-201107-I/en +func isCanonicalOID(value string) bool { + arcs := strings.Split(value, ".") + if len(arcs) < 2 { + return false + } + for _, arc := range arcs { + if !isCanonicalDecimal(arc) { + return false + } + } + if arcs[0] != "0" && arcs[0] != "1" && arcs[0] != "2" { + return false + } + if arcs[0] != "2" && decimalGreaterThan39(arcs[1]) { + return false + } + return true +} + +func isCanonicalPositiveDecimal(value string) bool { + return value != "0" && isCanonicalDecimal(value) +} + +// Check ASCII digits directly because unicode.IsDigit accepts non-ASCII digits, +// and integer conversion can overflow valid large OID arcs. +func isCanonicalDecimal(value string) bool { + if value == "" || len(value) > 1 && value[0] == '0' { + return false + } + for i := 0; i < len(value); i++ { + if value[i] < '0' || value[i] > '9' { + return false + } + } + return true +} + +func decimalGreaterThan39(value string) bool { + return len(value) > 2 || len(value) == 2 && value > "39" +} diff --git a/pkg/crypto/descriptor_test.go b/pkg/crypto/descriptor_test.go new file mode 100644 index 0000000000..532ddda1be --- /dev/null +++ b/pkg/crypto/descriptor_test.go @@ -0,0 +1,421 @@ +package crypto_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/internal/cryptotest" + "github.com/aquasecurity/trivy/pkg/crypto" +) + +func TestDescriptorString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + desc crypto.Descriptor + want string + }{ + { + name: "certificate", + desc: cryptotest.CertificateDescriptor(), + want: "certificate:sha256:" + strings.Repeat("a", 64), + }, + { + name: "public key", + desc: cryptotest.PublicKeyDescriptor(), + want: "key:public:spki-sha256:" + strings.Repeat("b", 64), + }, + { + name: "private key", + desc: cryptotest.PrivateKeyDescriptor(), + want: "key:private:spki-sha256:" + strings.Repeat("b", 64), + }, + { + name: "encrypted private key", + desc: cryptotest.EncryptedPrivateKeyDescriptor(), + want: "key:private:encrypted-pkcs8-sha256:" + strings.Repeat("b", 64), + }, + { + name: "algorithm without parameters", + desc: cryptotest.AlgorithmDescriptor(), + want: "algorithm:oid:1.2.840.113549.1.1.1", + }, + { + name: "algorithm key size", + desc: crypto.Descriptor{ + Kind: crypto.KindAlgorithm, + Identity: crypto.Identity{ + Method: crypto.MethodOID, + Value: "1.2.840.113549.1.1.1", + Parameters: "key-size=2048", + }, + }, + want: "algorithm:oid:1.2.840.113549.1.1.1:key-size%3D2048", + }, + { + name: "algorithm parameters are escaped", + desc: crypto.Descriptor{ + Kind: crypto.KindAlgorithm, + Identity: crypto.Identity{ + Method: crypto.MethodOID, + Value: "1.2.840.10045.2.1", + Parameters: "curve=P-256:key", + }, + }, + want: "algorithm:oid:1.2.840.10045.2.1:curve%3DP-256%3Akey", + }, + { + name: "algorithm parameter space", + desc: crypto.Descriptor{ + Kind: crypto.KindAlgorithm, + Identity: crypto.Identity{ + Method: crypto.MethodOID, + Value: "1.2.840.10045.2.1", + Parameters: "curve=P 256", + }, + }, + want: "algorithm:oid:1.2.840.10045.2.1:curve%3DP+256", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, tt.desc.String()) + }) + } +} + +func TestDescriptorValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + desc crypto.Descriptor + wantErr string + }{ + {name: "certificate", desc: cryptotest.CertificateDescriptor()}, + {name: "public key", desc: cryptotest.PublicKeyDescriptor()}, + {name: "private key", desc: cryptotest.PrivateKeyDescriptor()}, + {name: "algorithm", desc: cryptotest.AlgorithmDescriptor()}, + { + name: "unknown kind", + desc: crypto.Descriptor{Kind: "unknown"}, + wantErr: `unknown asset kind "unknown"`, + }, + { + name: "missing key type", + desc: crypto.Descriptor{Kind: crypto.KindKey, Identity: cryptotest.PublicKeyDescriptor().Identity}, + wantErr: `unknown key type ""`, + }, + { + name: "unknown key type", + desc: crypto.Descriptor{Kind: crypto.KindKey, KeyType: "secret", Identity: cryptotest.PublicKeyDescriptor().Identity}, + wantErr: `unknown key type "secret"`, + }, + { + name: "key type on certificate", + desc: crypto.Descriptor{Kind: crypto.KindCertificate, KeyType: crypto.KeyTypePublic, Identity: cryptotest.CertificateDescriptor().Identity}, + wantErr: `certificate descriptor must not contain key type "public"`, + }, + { + name: "key type on algorithm", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, KeyType: crypto.KeyTypePrivate, Identity: cryptotest.AlgorithmDescriptor().Identity}, + wantErr: `algorithm descriptor must not contain key type "private"`, + }, + { + name: "wrong certificate identification method", + desc: crypto.Descriptor{Kind: crypto.KindCertificate, Identity: cryptotest.PublicKeyDescriptor().Identity}, + wantErr: `certificate descriptor requires identification method "sha256"`, + }, + { + name: "wrong public key identification method", + desc: crypto.Descriptor{ + Kind: crypto.KindKey, KeyType: crypto.KeyTypePublic, + Identity: crypto.Identity{Method: crypto.MethodEncryptedPKCS8SHA256, Value: strings.Repeat("a", 64)}, + }, + wantErr: `public key descriptor requires identification method "spki-sha256"`, + }, + { + name: "wrong private key identification method", + desc: crypto.Descriptor{ + Kind: crypto.KindKey, KeyType: crypto.KeyTypePrivate, + Identity: crypto.Identity{Method: crypto.MethodSHA256, Value: strings.Repeat("a", 64)}, + }, + wantErr: `private key descriptor has unknown identification method "sha256"`, + }, + { + name: "wrong algorithm identification method", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: cryptotest.CertificateDescriptor().Identity}, + wantErr: `algorithm descriptor requires identification method "oid"`, + }, + { + name: "uppercase hash", + desc: crypto.Descriptor{Kind: crypto.KindCertificate, Identity: crypto.Identity{Method: crypto.MethodSHA256, Value: strings.Repeat("A", 64)}}, + wantErr: "identification value must be 64 lowercase hexadecimal characters", + }, + { + name: "short hash", + desc: crypto.Descriptor{Kind: crypto.KindCertificate, Identity: crypto.Identity{Method: crypto.MethodSHA256, Value: strings.Repeat("a", 63)}}, + wantErr: "identification value must be 64 lowercase hexadecimal characters", + }, + { + name: "non hexadecimal hash", + desc: crypto.Descriptor{Kind: crypto.KindCertificate, Identity: crypto.Identity{Method: crypto.MethodSHA256, Value: strings.Repeat("g", 64)}}, + wantErr: "identification value must be 64 lowercase hexadecimal characters", + }, + { + name: "non canonical OID arc", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "1.02.840.113549"}}, + wantErr: "identification value must be a canonical OID", + }, + { + name: "invalid OID first arc", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "3.1.1"}}, + wantErr: "identification value must be a canonical OID", + }, + { + name: "one OID arc", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "1"}}, + wantErr: "identification value must be a canonical OID", + }, + { + name: "OID root zero second arc boundary", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "0.39"}}, + }, + { + name: "OID root zero second arc above boundary", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "0.40"}}, + wantErr: "identification value must be a canonical OID", + }, + { + name: "OID root one second arc boundary", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "1.39"}}, + }, + { + name: "OID root one second arc above boundary", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "1.40"}}, + wantErr: "identification value must be a canonical OID", + }, + { + name: "OID root two unrestricted second arc", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "2.40"}}, + }, + { + name: "OID arc larger than machine integer", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "2.184467440737095516160"}}, + }, + { + name: "parameters on certificate", + desc: crypto.Descriptor{Kind: crypto.KindCertificate, Identity: crypto.Identity{Method: crypto.MethodSHA256, Value: strings.Repeat("a", 64), Parameters: "curve=P-256"}}, + wantErr: "parameters are only valid for OID algorithm descriptors", + }, + { + name: "parameters on key", + desc: crypto.Descriptor{Kind: crypto.KindKey, KeyType: crypto.KeyTypePublic, Identity: crypto.Identity{Method: crypto.MethodSPKISHA256, Value: strings.Repeat("b", 64), Parameters: "key-size=2048"}}, + wantErr: "parameters are only valid for OID algorithm descriptors", + }, + { + name: "zero key size parameter", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "1.2.3", Parameters: "key-size=0"}}, + wantErr: "key size parameter must be a canonical positive decimal", + }, + { + name: "leading zero key size parameter", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "1.2.3", Parameters: "key-size=02048"}}, + wantErr: "key size parameter must be a canonical positive decimal", + }, + { + name: "empty curve parameter", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "1.2.3", Parameters: "curve="}}, + wantErr: "curve parameter must not be empty", + }, + { + name: "unknown parameter form", + desc: crypto.Descriptor{Kind: crypto.KindAlgorithm, Identity: crypto.Identity{Method: crypto.MethodOID, Value: "1.2.3", Parameters: "mode=GCM"}}, + wantErr: `unknown algorithm parameters "mode=GCM"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.desc.Validate() + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestDescriptorMarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + desc crypto.Descriptor + want string + wantErr bool + }{ + { + name: "certificate", + desc: cryptotest.CertificateDescriptor(), + want: `"certificate:sha256:` + strings.Repeat("a", 64) + `"`, + }, + { + name: "algorithm parameters", + desc: crypto.Descriptor{ + Kind: crypto.KindAlgorithm, + Identity: crypto.Identity{Method: crypto.MethodOID, Value: "1.2.840.113549.1.1.1", Parameters: "key-size=2048"}, + }, + want: `"algorithm:oid:1.2.840.113549.1.1.1:key-size%3D2048"`, + }, + { + name: "invalid descriptor", + desc: crypto.Descriptor{Kind: "unknown"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := tt.desc.MarshalJSON() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + }) + } +} + +func TestDescriptorUnmarshalJSON(t *testing.T) { + t.Parallel() + + hash := strings.Repeat("a", 64) + tests := []struct { + name string + in string + want crypto.Descriptor + wantErr string + }{ + { + name: "certificate", + in: `"certificate:sha256:` + hash + `"`, + want: cryptotest.CertificateDescriptor(), + }, + { + name: "lowercase percent escape", + in: `"algorithm:oid:1.2.3:key-size%3d2048"`, + want: crypto.Descriptor{ + Kind: crypto.KindAlgorithm, + Identity: crypto.Identity{ + Method: crypto.MethodOID, + Value: "1.2.3", + Parameters: "key-size=2048", + }, + }, + }, + { + name: "escaped unreserved byte", + in: `"algorithm:oid:1.2.3:curve%3DP%2D256"`, + want: crypto.Descriptor{ + Kind: crypto.KindAlgorithm, + Identity: crypto.Identity{ + Method: crypto.MethodOID, + Value: "1.2.3", + Parameters: "curve=P-256", + }, + }, + }, + { + name: "raw plus is a space", + in: `"algorithm:oid:1.2.3:curve%3DP+256"`, + want: crypto.Descriptor{ + Kind: crypto.KindAlgorithm, + Identity: crypto.Identity{ + Method: crypto.MethodOID, + Value: "1.2.3", + Parameters: "curve=P 256", + }, + }, + }, + { + name: "empty", + in: `""`, + wantErr: `unknown descriptor kind ""`, + }, + { + name: "unknown kind", + in: `"secret:sha256:` + hash + `"`, + wantErr: `unknown descriptor kind "secret"`, + }, + { + name: "missing segment", + in: `"certificate:sha256"`, + wantErr: "certificate descriptor must contain 3 segments", + }, + { + name: "extra certificate segment", + in: `"certificate:sha256:` + hash + `:extra"`, + wantErr: "certificate descriptor must contain 3 segments", + }, + { + name: "extra key segment", + in: `"key:public:spki-sha256:` + hash + `:extra"`, + wantErr: "key descriptor must contain 4 segments", + }, + { + name: "extra algorithm segment", + in: `"algorithm:oid:1.2.3:key-size%3D2048:extra"`, + wantErr: "algorithm descriptor must contain 3 or 4 segments", + }, + { + name: "malformed short percent escape", + in: `"algorithm:oid:1.2.3:curve%3"`, + wantErr: `invalid URL escape "%3"`, + }, + { + name: "malformed non hexadecimal percent escape", + in: `"algorithm:oid:1.2.3:curve%XZ"`, + wantErr: `invalid URL escape "%XZ"`, + }, + { + name: "empty parameter segment", + in: `"algorithm:oid:1.2.3:"`, + wantErr: "algorithm descriptor parameters must not be empty", + }, + { + name: "non-string JSON", + in: `{}`, + wantErr: "cannot unmarshal object into Go value of type string", + }, + { + name: "invalid JSON", + in: `"certificate`, + wantErr: "unexpected end of JSON input", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var got crypto.Descriptor + err := got.UnmarshalJSON([]byte(tt.in)) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/crypto/key.go b/pkg/crypto/key.go new file mode 100644 index 0000000000..feae8fd4dd --- /dev/null +++ b/pkg/crypto/key.go @@ -0,0 +1,44 @@ +package crypto + +// KeyType identifies whether a key is public or private. +type KeyType string + +const ( + // KeyTypePublic identifies a public key. + KeyTypePublic KeyType = "public" + // KeyTypePrivate identifies a private key. + KeyTypePrivate KeyType = "private" +) + +// Encoding identifies the outer encoding of a key. +type Encoding string + +const ( + // EncodingPEM identifies PEM encoding. + EncodingPEM Encoding = "PEM" + // EncodingDER identifies DER encoding. + EncodingDER Encoding = "DER" +) + +// KeyFormat identifies the serialization format of a key. +type KeyFormat string + +const ( + // KeyFormatPKCS1 identifies the PKCS#1 key format. + KeyFormatPKCS1 KeyFormat = "PKCS#1" + // KeyFormatPKCS8 identifies the PKCS#8 key format. + KeyFormatPKCS8 KeyFormat = "PKCS#8" + // KeyFormatSEC1 identifies the SEC1 key format. + KeyFormatSEC1 KeyFormat = "SEC1" + // KeyFormatPKIX identifies the PKIX public key format. + KeyFormatPKIX KeyFormat = "PKIX" +) + +// Key contains key-specific metadata. +type Key struct { + Size int `json:",omitempty"` + Curve string `json:",omitempty"` + Format KeyFormat `json:",omitempty"` + Encoding Encoding `json:",omitempty"` + Encrypted bool `json:",omitempty"` +} diff --git a/pkg/crypto/relationship.go b/pkg/crypto/relationship.go new file mode 100644 index 0000000000..75e03f517f --- /dev/null +++ b/pkg/crypto/relationship.go @@ -0,0 +1,35 @@ +package crypto + +import "golang.org/x/xerrors" + +// RelationshipType identifies how two cryptographic assets are related. +type RelationshipType string + +const ( + // RelationshipContains indicates containment, for example, a certificate contains its public key. + RelationshipContains RelationshipType = "contains" + // RelationshipSignedWith indicates signing, for example, a certificate is signed with its signature algorithm. + RelationshipSignedWith RelationshipType = "signed_with" + // RelationshipUsedWith indicates use, for example, a key is used with its key algorithm. + RelationshipUsedWith RelationshipType = "used_with" + // RelationshipCorrespondsTo indicates correspondence, for example, a private key corresponds to its derived public key. + RelationshipCorrespondsTo RelationshipType = "corresponds_to" +) + +// Relationship links an asset to another asset descriptor. +type Relationship struct { + Type RelationshipType `json:",omitempty"` + RelatedAsset Descriptor `json:",omitzero"` +} + +func (r Relationship) validate() error { + switch r.Type { + case RelationshipContains, RelationshipSignedWith, RelationshipUsedWith, RelationshipCorrespondsTo: + default: + return xerrors.Errorf("unknown relationship type %q", r.Type) + } + if err := r.RelatedAsset.Validate(); err != nil { + return xerrors.Errorf("validate related asset: %w", err) + } + return nil +} From b7ce181bd6d6bd4e27a45a975faed1deade42587 Mon Sep 17 00:00:00 2001 From: knqyf263 Date: Wed, 15 Jul 2026 14:48:31 +0400 Subject: [PATCH 2/3] feat(image): carry cryptographic assets through fanal --- pkg/fanal/analyzer/analyzer.go | 15 ++++++++++++++- pkg/fanal/analyzer/analyzer_test.go | 25 +++++++++++++++++++++++++ pkg/fanal/analyzer/const.go | 5 +++++ pkg/fanal/artifact/image/image.go | 1 + pkg/fanal/types/artifact.go | 4 ++++ 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/pkg/fanal/analyzer/analyzer.go b/pkg/fanal/analyzer/analyzer.go index 574765280c..5334bbe0d7 100644 --- a/pkg/fanal/analyzer/analyzer.go +++ b/pkg/fanal/analyzer/analyzer.go @@ -16,6 +16,7 @@ import ( "golang.org/x/sync/semaphore" "golang.org/x/xerrors" + "github.com/aquasecurity/trivy/pkg/crypto" fos "github.com/aquasecurity/trivy/pkg/fanal/analyzer/os" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" "github.com/aquasecurity/trivy/pkg/licensing" @@ -183,6 +184,7 @@ type AnalysisResult struct { Misconfigurations []ftypes.Misconfiguration Secrets []ftypes.Secret Licenses []ftypes.LicenseFile + CryptoAssets []crypto.Asset SystemInstalledFiles []string // A list of files installed by OS package manager // Digests contains SHA-256 digests of unpackaged files @@ -204,7 +206,7 @@ func NewAnalysisResult() *AnalysisResult { func (r *AnalysisResult) isEmpty() bool { return lo.IsEmpty(r.OS) && r.Repository == nil && len(r.PackageInfos) == 0 && len(r.Applications) == 0 && - len(r.Misconfigurations) == 0 && len(r.Secrets) == 0 && len(r.Licenses) == 0 && len(r.SystemInstalledFiles) == 0 && + len(r.Misconfigurations) == 0 && len(r.Secrets) == 0 && len(r.Licenses) == 0 && len(r.CryptoAssets) == 0 && len(r.SystemInstalledFiles) == 0 && r.BuildInfo == nil && len(r.Digests) == 0 && len(r.CustomResources) == 0 } @@ -267,6 +269,16 @@ func (r *AnalysisResult) Sort() { return r.Licenses[i].Type < r.Licenses[j].Type }) + + // Cryptographic assets + sort.SliceStable(r.CryptoAssets, func(i, j int) bool { + left := r.CryptoAssets[i].Descriptor().String() + right := r.CryptoAssets[j].Descriptor().String() + if left != right { + return left < right + } + return r.CryptoAssets[i].FilePath < r.CryptoAssets[j].FilePath + }) } func (r *AnalysisResult) Merge(newResult *AnalysisResult) { @@ -301,6 +313,7 @@ func (r *AnalysisResult) Merge(newResult *AnalysisResult) { r.Misconfigurations = append(r.Misconfigurations, newResult.Misconfigurations...) r.Secrets = append(r.Secrets, newResult.Secrets...) r.Licenses = append(r.Licenses, newResult.Licenses...) + r.CryptoAssets = append(r.CryptoAssets, newResult.CryptoAssets...) r.SystemInstalledFiles = append(r.SystemInstalledFiles, newResult.SystemInstalledFiles...) if newResult.BuildInfo != nil { diff --git a/pkg/fanal/analyzer/analyzer_test.go b/pkg/fanal/analyzer/analyzer_test.go index fde7115eff..3006fa331c 100644 --- a/pkg/fanal/analyzer/analyzer_test.go +++ b/pkg/fanal/analyzer/analyzer_test.go @@ -13,6 +13,8 @@ import ( "golang.org/x/sync/semaphore" "golang.org/x/xerrors" + "github.com/aquasecurity/trivy/internal/cryptotest" + "github.com/aquasecurity/trivy/pkg/crypto" "github.com/aquasecurity/trivy/pkg/fanal/analyzer" "github.com/aquasecurity/trivy/pkg/fanal/types" "github.com/aquasecurity/trivy/pkg/javadb" @@ -38,6 +40,7 @@ func TestAnalysisResult_Merge(t *testing.T) { OS types.OS PackageInfos []types.PackageInfo Applications []types.Application + CryptoAssets []crypto.Asset } type args struct { new *analyzer.AnalysisResult @@ -343,6 +346,27 @@ func TestAnalysisResult_Merge(t *testing.T) { }, }, }, + { + name: "merge crypto assets", + fields: fields{ + CryptoAssets: []crypto.Asset{cryptotest.CertificateAsset()}, + }, + args: args{ + new: &analyzer.AnalysisResult{ + CryptoAssets: []crypto.Asset{cryptotest.CertificateAsset(cryptotest.WithMutate(func(asset *crypto.Asset) { + asset.FilePath = "/etc/second.pem" + }))}, + }, + }, + want: analyzer.AnalysisResult{ + CryptoAssets: []crypto.Asset{ + cryptotest.CertificateAsset(), + cryptotest.CertificateAsset(cryptotest.WithMutate(func(asset *crypto.Asset) { + asset.FilePath = "/etc/second.pem" + })), + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -350,6 +374,7 @@ func TestAnalysisResult_Merge(t *testing.T) { OS: tt.fields.OS, PackageInfos: tt.fields.PackageInfos, Applications: tt.fields.Applications, + CryptoAssets: tt.fields.CryptoAssets, } r.Merge(tt.args.new) assert.Equal(t, tt.want, r) diff --git a/pkg/fanal/analyzer/const.go b/pkg/fanal/analyzer/const.go index b13fbc6ab1..425ba263a1 100644 --- a/pkg/fanal/analyzer/const.go +++ b/pkg/fanal/analyzer/const.go @@ -142,6 +142,11 @@ const ( // ======== TypeLicenseFile Type = "license-file" + // ==================== + // Cryptographic Assets + // ==================== + TypeCrypto Type = "crypto" + // ======== // Secrets // ======== diff --git a/pkg/fanal/artifact/image/image.go b/pkg/fanal/artifact/image/image.go index f325622978..f14490047b 100644 --- a/pkg/fanal/artifact/image/image.go +++ b/pkg/fanal/artifact/image/image.go @@ -525,6 +525,7 @@ func (a Artifact) inspectLayer(ctx context.Context, layer types.Layer, disabled Misconfigurations: result.Misconfigurations, Secrets: result.Secrets, Licenses: result.Licenses, + CryptoAssets: result.CryptoAssets, CustomResources: result.CustomResources, // For Red Hat diff --git a/pkg/fanal/types/artifact.go b/pkg/fanal/types/artifact.go index bb721956fd..77fbaa1b9d 100644 --- a/pkg/fanal/types/artifact.go +++ b/pkg/fanal/types/artifact.go @@ -5,6 +5,8 @@ import ( "time" "github.com/samber/lo" + + "github.com/aquasecurity/trivy/pkg/crypto" ) // ArtifactType represents a type of artifact @@ -184,6 +186,7 @@ type BlobInfo struct { Misconfigurations []Misconfiguration `json:",omitempty"` Secrets []Secret `json:",omitempty"` Licenses []LicenseFile `json:",omitempty"` + CryptoAssets []crypto.Asset `json:",omitempty"` // Red Hat distributions have build info per layer. // This information will be embedded into packages when applying layers. @@ -213,6 +216,7 @@ type ArtifactDetail struct { Misconfigurations []Misconfiguration `json:",omitempty"` Secrets Secrets `json:",omitempty"` Licenses LicenseFiles `json:",omitempty"` + CryptoAssets []crypto.Asset `json:",omitempty"` // ImageConfig has information from container image config ImageConfig ImageConfigDetail From fea60fea765eede5c73cda310541f2e1d44bd016 Mon Sep 17 00:00:00 2001 From: knqyf263 Date: Wed, 15 Jul 2026 14:48:50 +0400 Subject: [PATCH 3/3] feat(parser): parse cryptographic PEM and DER objects --- pkg/crypto/parser/x509/parser.go | 294 +++++++++++++++ pkg/crypto/parser/x509/parser_test.go | 449 +++++++++++++++++++++++ pkg/fanal/analyzer/crypto/crypto.go | 14 + pkg/fanal/analyzer/crypto/crypto_test.go | 42 +++ 4 files changed, 799 insertions(+) create mode 100644 pkg/crypto/parser/x509/parser.go create mode 100644 pkg/crypto/parser/x509/parser_test.go create mode 100644 pkg/fanal/analyzer/crypto/crypto.go create mode 100644 pkg/fanal/analyzer/crypto/crypto_test.go diff --git a/pkg/crypto/parser/x509/parser.go b/pkg/crypto/parser/x509/parser.go new file mode 100644 index 0000000000..5f9c271dec --- /dev/null +++ b/pkg/crypto/parser/x509/parser.go @@ -0,0 +1,294 @@ +package x509 + +import ( + "context" + stdcrypto "crypto" + "crypto/dsa" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/sha256" + stdx509 "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "encoding/pem" + "errors" + + cryptotypes "github.com/aquasecurity/trivy/pkg/crypto" + "github.com/aquasecurity/trivy/pkg/log" +) + +// ObjectKind identifies the kind of parsed cryptographic object. +type ObjectKind uint8 + +const ( + // ObjectCertificate identifies an X.509 certificate. + ObjectCertificate ObjectKind = iota + 1 + // ObjectPrivateKey identifies a private key projected to its public key. + ObjectPrivateKey + // ObjectPublicKey identifies a public key. + ObjectPublicKey + // ObjectEncryptedPrivateKey identifies an opaque encrypted PKCS#8 private key. + ObjectEncryptedPrivateKey +) + +// Object is a safe projection of a parsed cryptographic object. +type Object struct { + // Kind identifies the parsed object kind. + Kind ObjectKind + // Certificate contains the parsed certificate for ObjectCertificate. + Certificate *stdx509.Certificate + // PublicKey contains a public key or the public projection of a private key. + PublicKey any + // EncryptedPKCS8SHA256 contains the lowercase SHA-256 digest of an encrypted PKCS#8 container. + EncryptedPKCS8SHA256 string + // Encoding identifies the source encoding. + Encoding cryptotypes.Encoding + // KeyFormat identifies the source key container format. + KeyFormat cryptotypes.KeyFormat +} + +type encryptedPrivateKeyInfo struct { + Algorithm pkix.AlgorithmIdentifier + EncryptedData []byte +} + +var ( + errNotCryptographic = errors.New("not cryptographic") + errUnsupportedCrypto = errors.New("unsupported cryptographic object") + errMalformedCrypto = errors.New("malformed cryptographic object") +) + +// Parse sniffs content because eligible extensions such as .crt, .cer, and .key do not reliably identify PEM or DER. +// It decodes PEM blocks first, then falls back to DER when no valid PEM block is found. +func Parse(ctx context.Context, filePath string, content []byte) []Object { + ctx = log.WithContextPrefix(ctx, "x509") + var objects []Object + var decodedPEM, recognized bool + // pem.Decode scans past malformed leading data and returns the next valid block. + for rest := content; ; { + block, next := pem.Decode(rest) + if block == nil { + break + } + decodedPEM = true + rest = next + + object, err := parsePEMBlock(block) + if errors.Is(err, errNotCryptographic) { + continue + } + recognized = true + if err != nil { + logParseError(ctx, filePath, block.Type, err) + continue + } + objects = append(objects, object) + } + + // A decoded PEM file is complete even when none of its blocks is supported. + if decodedPEM { + if !recognized { + log.DebugContext(ctx, "No cryptographic object found", log.FilePath(filePath)) + } + return objects + } + + // No PEM block was decoded, so try the whole file as DER. + object, err := parseDERObject(content) + if err != nil { + logParseError(ctx, filePath, "", err) + return nil + } + return []Object{object} +} + +func parsePEMBlock(block *pem.Block) (Object, error) { + object, err := parsePEMObject(block.Type, block.Bytes) + if err != nil { + return Object{}, err + } + + object.Encoding = cryptotypes.EncodingPEM + return object, nil +} + +func parsePEMObject(label string, der []byte) (Object, error) { + switch label { + case "CERTIFICATE": + certificate, err := stdx509.ParseCertificate(der) + if err != nil { + return Object{}, errMalformedCrypto + } + return Object{ + Kind: ObjectCertificate, + Certificate: certificate, + Encoding: cryptotypes.EncodingDER, + }, nil + case "PRIVATE KEY": + privateKey, err := stdx509.ParsePKCS8PrivateKey(der) + if err != nil { + return Object{}, errMalformedCrypto + } + return privateKeyToObject(privateKey, cryptotypes.KeyFormatPKCS8) + case "RSA PRIVATE KEY": + privateKey, err := stdx509.ParsePKCS1PrivateKey(der) + if err != nil { + return Object{}, errMalformedCrypto + } + return privateKeyToObject(privateKey, cryptotypes.KeyFormatPKCS1) + case "EC PRIVATE KEY": + privateKey, err := stdx509.ParseECPrivateKey(der) + if err != nil { + return Object{}, errMalformedCrypto + } + return privateKeyToObject(privateKey, cryptotypes.KeyFormatSEC1) + case "PUBLIC KEY": + publicKey, err := stdx509.ParsePKIXPublicKey(der) + if err != nil { + return Object{}, errMalformedCrypto + } + return publicKeyToObject(publicKey) + case "ENCRYPTED PRIVATE KEY": + object, ok := parseEncryptedPKCS8(der) + if !ok { + return Object{}, errMalformedCrypto + } + return object, nil + case "CERTIFICATE REQUEST", + "NEW CERTIFICATE REQUEST", + "X509 CRL", + "OPENSSH PRIVATE KEY", + "RSA PUBLIC KEY", + "DSA PRIVATE KEY", + "DSA PUBLIC KEY", + "EC PARAMETERS", + "DH PARAMETERS", + "TRUSTED CERTIFICATE", + "PKCS7", + "PKCS12": + return Object{}, errUnsupportedCrypto + default: + return Object{}, errNotCryptographic + } +} + +func parseDERObject(der []byte) (Object, error) { + // The target ASN.1 DER structures have no common outer discriminator, so try their schema-specific parsers in order. + if certificate, err := stdx509.ParseCertificate(der); err == nil { + return Object{ + Kind: ObjectCertificate, + Certificate: certificate, + Encoding: cryptotypes.EncodingDER, + }, nil + } + + if privateKey, err := stdx509.ParsePKCS1PrivateKey(der); err == nil { + return privateKeyToObject(privateKey, cryptotypes.KeyFormatPKCS1) + } + + if privateKey, err := stdx509.ParsePKCS8PrivateKey(der); err == nil { + return privateKeyToObject(privateKey, cryptotypes.KeyFormatPKCS8) + } + + if privateKey, err := stdx509.ParseECPrivateKey(der); err == nil { + return privateKeyToObject(privateKey, cryptotypes.KeyFormatSEC1) + } + + if publicKey, err := stdx509.ParsePKIXPublicKey(der); err == nil { + return publicKeyToObject(publicKey) + } + + if object, ok := parseEncryptedPKCS8(der); ok { + return object, nil + } + + if _, err := stdx509.ParseCertificateRequest(der); err == nil { + return Object{}, errUnsupportedCrypto + } + if _, err := stdx509.ParseRevocationList(der); err == nil { + return Object{}, errUnsupportedCrypto + } + + var raw asn1.RawValue + rest, err := asn1.Unmarshal(der, &raw) + if err == nil && len(rest) == 0 && raw.Class == asn1.ClassUniversal && raw.Tag == asn1.TagSequence && raw.IsCompound { + return Object{}, errUnsupportedCrypto + } + if len(der) > 0 && der[0] == byte(asn1.TagSequence)|0x20 { + return Object{}, errMalformedCrypto + } + return Object{}, errNotCryptographic +} + +// privateKeyToObject converts a private key to an Object containing its public projection. +func privateKeyToObject(privateKey any, format cryptotypes.KeyFormat) (Object, error) { + signer, ok := privateKey.(stdcrypto.Signer) + if !ok { + return Object{}, errUnsupportedCrypto + } + publicKey := signer.Public() + if !isSupportedPublicKey(publicKey) { + return Object{}, errUnsupportedCrypto + } + return Object{ + Kind: ObjectPrivateKey, + PublicKey: publicKey, + Encoding: cryptotypes.EncodingDER, + KeyFormat: format, + }, nil +} + +func publicKeyToObject(publicKey any) (Object, error) { + if !isSupportedPublicKey(publicKey) { + return Object{}, errUnsupportedCrypto + } + return Object{ + Kind: ObjectPublicKey, + PublicKey: publicKey, + Encoding: cryptotypes.EncodingDER, + KeyFormat: cryptotypes.KeyFormatPKIX, + }, nil +} + +// parseEncryptedPKCS8 validates only the opaque envelope and retains its digest. +func parseEncryptedPKCS8(der []byte) (Object, bool) { + var encrypted encryptedPrivateKeyInfo + rest, err := asn1.Unmarshal(der, &encrypted) + if err != nil || len(rest) != 0 || len(encrypted.Algorithm.Algorithm) == 0 || len(encrypted.EncryptedData) == 0 { + return Object{}, false + } + digest := sha256.Sum256(der) + return Object{ + Kind: ObjectEncryptedPrivateKey, + EncryptedPKCS8SHA256: hex.EncodeToString(digest[:]), + Encoding: cryptotypes.EncodingDER, + KeyFormat: cryptotypes.KeyFormatPKCS8, + }, true +} + +func isSupportedPublicKey(key any) bool { + switch key.(type) { + case *rsa.PublicKey, *dsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey: + return true + default: + return false + } +} + +func logParseError(ctx context.Context, filePath, pemType string, err error) { + logger := log.With(log.FilePath(filePath)) + if pemType != "" { + logger = logger.With(log.String("pem_type", pemType)) + } + + switch { + case errors.Is(err, errUnsupportedCrypto): + logger.InfoContext(ctx, "Unsupported cryptographic object") + case errors.Is(err, errMalformedCrypto): + logger.WarnContext(ctx, "Malformed cryptographic object") + default: + logger.DebugContext(ctx, "No cryptographic object found") + } +} diff --git a/pkg/crypto/parser/x509/parser_test.go b/pkg/crypto/parser/x509/parser_test.go new file mode 100644 index 0000000000..de75481f31 --- /dev/null +++ b/pkg/crypto/parser/x509/parser_test.go @@ -0,0 +1,449 @@ +package x509_test + +import ( + "bytes" + "crypto/dsa" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + stdx509 "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/pkg/crypto" + cryptox509 "github.com/aquasecurity/trivy/pkg/crypto/parser/x509" +) + +var oidPBES2 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 5, 13} + +type encryptedPrivateKeyInfo struct { + Algorithm pkix.AlgorithmIdentifier + EncryptedData []byte +} + +type parserFixtures struct { + certificate *stdx509.Certificate + rsaPublic *rsa.PublicKey + ecdsaPublic *ecdsa.PublicKey + ed25519Public ed25519.PublicKey + dsaPublic *dsa.PublicKey + certificateDER []byte + certificatePEM []byte + pkcs1DER []byte + pkcs8DER []byte + pkcs8PEM []byte + sec1DER []byte + publicDER []byte + publicPEM []byte + encryptedDER []byte + encryptedPEM []byte + ed25519DER []byte + dsaDER []byte + x25519PKCS8DER []byte + x25519PKIXDER []byte + csrDER []byte + csrPEM []byte + crlDER []byte + crlPEM []byte + unsupportedDER []byte +} + +func TestParse(t *testing.T) { + fixtures := newParserFixtures(t) + encryptedDigest := sha256.Sum256(fixtures.encryptedDER) + malformedPEMPrefix := append([]byte("-----BEGIN CERTIFICATE-----\nmalformed\n"), fixtures.certificatePEM...) + + tests := []struct { + name string + input []byte + want []cryptox509.Object + }{ + { + name: "certificate PEM", + input: fixtures.certificatePEM, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectCertificate, + Certificate: fixtures.certificate, + Encoding: crypto.EncodingPEM, + }}, + }, + { + name: "certificate DER", + input: fixtures.certificateDER, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectCertificate, + Certificate: fixtures.certificate, + Encoding: crypto.EncodingDER, + }}, + }, + { + name: "PKCS1 DER", + input: fixtures.pkcs1DER, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectPrivateKey, + PublicKey: fixtures.rsaPublic, + Encoding: crypto.EncodingDER, + KeyFormat: crypto.KeyFormatPKCS1, + }}, + }, + { + name: "PKCS8 DER", + input: fixtures.pkcs8DER, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectPrivateKey, + PublicKey: fixtures.rsaPublic, + Encoding: crypto.EncodingDER, + KeyFormat: crypto.KeyFormatPKCS8, + }}, + }, + { + name: "PKCS8 PEM", + input: fixtures.pkcs8PEM, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectPrivateKey, + PublicKey: fixtures.rsaPublic, + Encoding: crypto.EncodingPEM, + KeyFormat: crypto.KeyFormatPKCS8, + }}, + }, + { + name: "SEC1 DER", + input: fixtures.sec1DER, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectPrivateKey, + PublicKey: fixtures.ecdsaPublic, + Encoding: crypto.EncodingDER, + KeyFormat: crypto.KeyFormatSEC1, + }}, + }, + { + name: "PKIX public DER", + input: fixtures.publicDER, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectPublicKey, + PublicKey: fixtures.rsaPublic, + Encoding: crypto.EncodingDER, + KeyFormat: crypto.KeyFormatPKIX, + }}, + }, + { + name: "PKIX public PEM", + input: fixtures.publicPEM, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectPublicKey, + PublicKey: fixtures.rsaPublic, + Encoding: crypto.EncodingPEM, + KeyFormat: crypto.KeyFormatPKIX, + }}, + }, + { + name: "encrypted PKCS8 DER", + input: fixtures.encryptedDER, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectEncryptedPrivateKey, + EncryptedPKCS8SHA256: hex.EncodeToString(encryptedDigest[:]), + Encoding: crypto.EncodingDER, + KeyFormat: crypto.KeyFormatPKCS8, + }}, + }, + { + name: "encrypted PKCS8 PEM", + input: fixtures.encryptedPEM, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectEncryptedPrivateKey, + EncryptedPKCS8SHA256: hex.EncodeToString(encryptedDigest[:]), + Encoding: crypto.EncodingPEM, + KeyFormat: crypto.KeyFormatPKCS8, + }}, + }, + { + name: "Ed25519 PKCS8 DER", + input: fixtures.ed25519DER, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectPrivateKey, + PublicKey: fixtures.ed25519Public, + Encoding: crypto.EncodingDER, + KeyFormat: crypto.KeyFormatPKCS8, + }}, + }, + { + name: "DSA PKIX DER", + input: fixtures.dsaDER, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectPublicKey, + PublicKey: fixtures.dsaPublic, + Encoding: crypto.EncodingDER, + KeyFormat: crypto.KeyFormatPKIX, + }}, + }, + { + name: "certificate bundle", + input: bytes.Join([][]byte{fixtures.certificatePEM, fixtures.certificatePEM}, nil), + want: []cryptox509.Object{ + { + Kind: cryptox509.ObjectCertificate, + Certificate: fixtures.certificate, + Encoding: crypto.EncodingPEM, + }, + { + Kind: cryptox509.ObjectCertificate, + Certificate: fixtures.certificate, + Encoding: crypto.EncodingPEM, + }, + }, + }, + { + name: "certificate and private key", + input: bytes.Join([][]byte{fixtures.certificatePEM, fixtures.pkcs8PEM}, nil), + want: []cryptox509.Object{ + { + Kind: cryptox509.ObjectCertificate, + Certificate: fixtures.certificate, + Encoding: crypto.EncodingPEM, + }, + { + Kind: cryptox509.ObjectPrivateKey, + PublicKey: fixtures.rsaPublic, + Encoding: crypto.EncodingPEM, + KeyFormat: crypto.KeyFormatPKCS8, + }, + }, + }, + { + name: "malformed PEM followed by valid certificate", + input: malformedPEMPrefix, + want: []cryptox509.Object{{ + Kind: cryptox509.ObjectCertificate, + Certificate: fixtures.certificate, + Encoding: crypto.EncodingPEM, + }}, + }, + { + name: "malformed supported PEM", + input: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte{0x30, 0x80}}), + }, + { + name: "PKCS1 under PRIVATE KEY", + input: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: fixtures.pkcs1DER}), + }, + { + name: "PKCS8 under RSA PRIVATE KEY", + input: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: fixtures.pkcs8DER}), + }, + { + name: "certificate request under CERTIFICATE", + input: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fixtures.csrDER}), + }, + { + name: "unsupported PKCS8 key under PRIVATE KEY", + input: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: fixtures.x25519PKCS8DER}), + }, + { + name: "unsupported PKIX key under PUBLIC KEY", + input: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: fixtures.x25519PKIXDER}), + }, + { + name: "unsupported X25519 DER", + input: fixtures.x25519PKIXDER, + }, + { + name: "certificate request PEM", + input: fixtures.csrPEM, + }, + { + name: "certificate request DER", + input: fixtures.csrDER, + }, + { + name: "malformed certificate request PEM", + input: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: []byte("malformed")}), + }, + { + name: "certificate revocation list PEM", + input: fixtures.crlPEM, + }, + { + name: "certificate revocation list DER", + input: fixtures.crlDER, + }, + { + name: "malformed certificate revocation list PEM", + input: pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: []byte("malformed")}), + }, + { + name: "OpenSSH private key PEM", + input: pem.EncodeToMemory(&pem.Block{Type: "OPENSSH PRIVATE KEY", Bytes: []byte("opaque")}), + }, + { + name: "unknown PEM label", + input: pem.EncodeToMemory(&pem.Block{Type: "UNKNOWN", Bytes: []byte("opaque")}), + }, + { + name: "structurally malformed PEM", + input: []byte("-----BEGIN CERTIFICATE-----\nmalformed\n"), + }, + { + name: "complete unsupported ASN.1 sequence", + input: fixtures.unsupportedDER, + }, + { + name: "malformed ASN.1 sequence", + input: []byte{0x30, 0x80}, + }, + { + name: "arbitrary bytes", + input: []byte("arbitrary bytes"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, cryptox509.Parse(t.Context(), "candidate.pem", tt.input)) + }) + } +} + +func newParserFixtures(t *testing.T) parserFixtures { + t.Helper() + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + ecdsaKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Self-signed certificate and CRL fixtures. + template := &stdx509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "example.test"}, + SubjectKeyId: []byte{0x01, 0x02, 0x03}, + NotBefore: time.Unix(1, 0), + NotAfter: time.Unix(2, 0), + BasicConstraintsValid: true, + IsCA: true, + KeyUsage: stdx509.KeyUsageCertSign | stdx509.KeyUsageCRLSign | stdx509.KeyUsageDigitalSignature, + } + certificateDER, err := stdx509.CreateCertificate(rand.Reader, template, template, &rsaKey.PublicKey, rsaKey) + require.NoError(t, err) + certificate, err := stdx509.ParseCertificate(certificateDER) + require.NoError(t, err) + crlDER, err := stdx509.CreateRevocationList(rand.Reader, &stdx509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Unix(1, 0), + NextUpdate: time.Unix(2, 0), + }, certificate, rsaKey) + require.NoError(t, err) + + // Supported private and public key encodings. + pkcs8DER, err := stdx509.MarshalPKCS8PrivateKey(rsaKey) + require.NoError(t, err) + sec1DER, err := stdx509.MarshalECPrivateKey(ecdsaKey) + require.NoError(t, err) + publicDER, err := stdx509.MarshalPKIXPublicKey(&rsaKey.PublicKey) + require.NoError(t, err) + ed25519Public, ed25519Private, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + ed25519DER, err := stdx509.MarshalPKCS8PrivateKey(ed25519Private) + require.NoError(t, err) + dsaPublic := &dsa.PublicKey{ + Parameters: dsa.Parameters{ + P: big.NewInt(23), + Q: big.NewInt(11), + G: big.NewInt(2), + }, + Y: big.NewInt(4), + } + dsaDER := marshalDSAPublicKey(t, dsaPublic) + + // Opaque encrypted PKCS#8 container. + encryptedDER, err := asn1.Marshal(encryptedPrivateKeyInfo{ + Algorithm: pkix.AlgorithmIdentifier{Algorithm: oidPBES2}, + EncryptedData: []byte{ + 0x01, 0x02, 0x03, + }, + }) + require.NoError(t, err) + + // Unsupported CSR and X25519 inputs. + csrDER, err := stdx509.CreateCertificateRequest(rand.Reader, &stdx509.CertificateRequest{ + Subject: pkix.Name{CommonName: "example.test"}, + }, rsaKey) + require.NoError(t, err) + x25519Private, err := ecdh.X25519().GenerateKey(rand.Reader) + require.NoError(t, err) + x25519PKCS8DER, err := stdx509.MarshalPKCS8PrivateKey(x25519Private) + require.NoError(t, err) + x25519PKIXDER, err := stdx509.MarshalPKIXPublicKey(x25519Private.PublicKey()) + require.NoError(t, err) + unsupportedDER, err := asn1.Marshal(struct { + Value string + }{Value: "unsupported"}) + require.NoError(t, err) + + return parserFixtures{ + certificate: certificate, + rsaPublic: &rsaKey.PublicKey, + ecdsaPublic: &ecdsaKey.PublicKey, + ed25519Public: ed25519Public, + dsaPublic: dsaPublic, + certificateDER: certificateDER, + certificatePEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificateDER}), + pkcs1DER: stdx509.MarshalPKCS1PrivateKey(rsaKey), + pkcs8DER: pkcs8DER, + pkcs8PEM: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8DER}), + sec1DER: sec1DER, + publicDER: publicDER, + publicPEM: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}), + encryptedDER: encryptedDER, + encryptedPEM: pem.EncodeToMemory(&pem.Block{Type: "ENCRYPTED PRIVATE KEY", Bytes: encryptedDER}), + ed25519DER: ed25519DER, + dsaDER: dsaDER, + x25519PKCS8DER: x25519PKCS8DER, + x25519PKIXDER: x25519PKIXDER, + csrDER: csrDER, + csrPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}), + crlDER: crlDER, + crlPEM: pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: crlDER}), + unsupportedDER: unsupportedDER, + } +} + +func marshalDSAPublicKey(t *testing.T, publicKey *dsa.PublicKey) []byte { + t.Helper() + + parameters, err := asn1.Marshal(struct { + P *big.Int + Q *big.Int + G *big.Int + }{ + P: publicKey.Parameters.P, + Q: publicKey.Parameters.Q, + G: publicKey.Parameters.G, + }) + require.NoError(t, err) + encodedPublicKey, err := asn1.Marshal(publicKey.Y) + require.NoError(t, err) + + der, err := asn1.Marshal(struct { + Algorithm pkix.AlgorithmIdentifier + SubjectPublicKey asn1.BitString + }{ + Algorithm: pkix.AlgorithmIdentifier{ + Algorithm: asn1.ObjectIdentifier{1, 2, 840, 10040, 4, 1}, + Parameters: asn1.RawValue{FullBytes: parameters}, + }, + SubjectPublicKey: asn1.BitString{Bytes: encodedPublicKey, BitLength: len(encodedPublicKey) * 8}, + }) + require.NoError(t, err) + return der +} diff --git a/pkg/fanal/analyzer/crypto/crypto.go b/pkg/fanal/analyzer/crypto/crypto.go new file mode 100644 index 0000000000..8caf583701 --- /dev/null +++ b/pkg/fanal/analyzer/crypto/crypto.go @@ -0,0 +1,14 @@ +package crypto + +import ( + "path/filepath" + + "github.com/aquasecurity/trivy/pkg/set" +) + +var requiredExtensions = set.NewCaseInsensitive(".pem", ".der", ".crt", ".cer", ".key") + +// Required reports whether filePath has an extension that may contain a cryptographic object. +func Required(filePath string) bool { + return requiredExtensions.Contains(filepath.Ext(filePath)) +} diff --git a/pkg/fanal/analyzer/crypto/crypto_test.go b/pkg/fanal/analyzer/crypto/crypto_test.go new file mode 100644 index 0000000000..b2818fc74a --- /dev/null +++ b/pkg/fanal/analyzer/crypto/crypto_test.go @@ -0,0 +1,42 @@ +package crypto_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/aquasecurity/trivy/pkg/fanal/analyzer/crypto" +) + +func TestRequired(t *testing.T) { + tests := []struct { + name string + filePath string + want bool + }{ + {name: "lowercase PEM", filePath: "certificates/server.pem", want: true}, + {name: "uppercase PEM", filePath: "certificates/server.PEM", want: true}, + {name: "mixed-case PEM", filePath: "certificates/server.PeM", want: true}, + {name: "lowercase DER", filePath: "certificates/server.der", want: true}, + {name: "uppercase DER", filePath: "certificates/server.DER", want: true}, + {name: "mixed-case DER", filePath: "certificates/server.DeR", want: true}, + {name: "lowercase CRT", filePath: "certificates/server.crt", want: true}, + {name: "uppercase CRT", filePath: "certificates/server.CRT", want: true}, + {name: "mixed-case CRT", filePath: "certificates/server.CrT", want: true}, + {name: "lowercase CER", filePath: "certificates/server.cer", want: true}, + {name: "uppercase CER", filePath: "certificates/server.CER", want: true}, + {name: "mixed-case CER", filePath: "certificates/server.CeR", want: true}, + {name: "lowercase KEY", filePath: "certificates/server.key", want: true}, + {name: "uppercase KEY", filePath: "certificates/server.KEY", want: true}, + {name: "mixed-case KEY", filePath: "certificates/server.KeY", want: true}, + {name: "public key extension", filePath: "certificates/server.pub"}, + {name: "PKCS12 extension", filePath: "certificates/server.p12"}, + {name: "extensionless", filePath: "certificates/server"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, crypto.Required(tt.filePath)) + }) + } +}