From 8d90c749a8cb0a06d29d9550cbb05bbef03a1d6f Mon Sep 17 00:00:00 2001 From: Abdel Fane Date: Mon, 27 Jul 2026 14:02:02 -0600 Subject: [PATCH] Make CBOM output conform to the CycloneDX 1.6 schema Validated against the official bom-1.6.schema.json, the emitted CBOM failed on three counts, so no document this tool produced passed the CycloneDX CLI. serialNumber was built with time.Now().Format("20060102-150405-000000000"). That is a timestamp, not a UUID, and it does not match the required urn:uuid pattern. Replaced with a random RFC 4122 version 4 UUID. algorithmToPrimitive returned "hybrid" for hybrid constructions and "key-agreement" for key agreement. Neither is a member of the spec's closed primitive enum, and one non-member value invalidates the whole document. CycloneDX spells these "combiner" and "key-agree". The hybrid case now also precedes the post-quantum cases so a hybrid ML-KEM construction is reported as the combiner it is rather than as a plain KEM. categoryToAssetType matched lowercase category names that no pattern actually uses. The real categories are "Secret Detection" and "Certificate", so every finding fell through to the default and was emitted as an algorithm. A detected private key became assetType "algorithm" named "Private Key Header", which is the incomplete key material representation described in the report. Key material is now emitted as related-crypto-material carrying its material type and size. Evidence locations were absolute paths, embedding the scanning machine's directory layout and username into an artifact meant to be committed and shared, and making identical scans differ between machines. They are now relative to the scan root, falling back to the original path when no relative path exists. Two existing tests asserted the invalid values ("key-agreement" and key mapping to certificate) and were updated, since they locked in output the spec rejects. Fixes #1 --- docs/testing/release-smoke.md | 55 +++++++ pkg/reporter/cbom.go | 166 +++++++++++++++++--- pkg/reporter/cbom_schema_test.go | 260 +++++++++++++++++++++++++++++++ pkg/reporter/reporter_test.go | 13 +- 4 files changed, 467 insertions(+), 27 deletions(-) create mode 100644 docs/testing/release-smoke.md create mode 100644 pkg/reporter/cbom_schema_test.go diff --git a/docs/testing/release-smoke.md b/docs/testing/release-smoke.md new file mode 100644 index 0000000..e8a722c --- /dev/null +++ b/docs/testing/release-smoke.md @@ -0,0 +1,55 @@ +# Release Smoke Test: cryptoscan + +Manual pre-release walkthrough. Run this before every deploy or publish. +Use real keyboard and mouse input, not synthetic events. Record the actual +output, not a summary. + +## 1. Build and start clean + +- [ ] Fresh install succeeds (`npm ci` or equivalent) with no errors. +- [ ] Build succeeds (`npm run build`). +- [ ] App starts locally (`npm run dev`) with no console errors. + +## 2. Core user paths + +List the three most important things a real visitor does on this site or +with this tool, then walk each one by hand. + +- [ ] Path 1: +- [ ] Path 2: +- [ ] Path 3: + +## 3. Accessibility and audience fit + +CSNP serves non-technical and vulnerable users. Verify the experience holds +for them. + +- [ ] Plain language: no unexplained jargon in user-facing copy. +- [ ] Keyboard navigable: every interactive element reachable by Tab. +- [ ] Screen-reader labels present on buttons, links, and form fields. +- [ ] Color contrast meets WCAG AA. + +## 4. Responsive parity + +- [ ] Mobile (375px): nav, menus, and layout all work. +- [ ] Desktop (1280px): no overflow or broken grids. +- [ ] Dark mode (if supported) renders correctly. + +## 5. Data and links + +- [ ] Every download link resolves (no 404s). +- [ ] Any displayed number or statistic traces to a real source. +- [ ] No placeholder or fabricated content shipped. + +## 6. Security + +- [ ] No secrets in the bundle or committed files. +- [ ] `.env` and credential files are gitignored and excluded. +- [ ] Forms validate and sanitize input. + +## Result + +- Date: +- Tester: +- Verdict: PASS / FAIL +- Notes: diff --git a/pkg/reporter/cbom.go b/pkg/reporter/cbom.go index 834ec5e..a46f3e3 100644 --- a/pkg/reporter/cbom.go +++ b/pkg/reporter/cbom.go @@ -4,8 +4,11 @@ package reporter import ( + "crypto/rand" "encoding/json" "fmt" + "os" + "path/filepath" "strings" "time" @@ -62,11 +65,21 @@ type cbomComponent struct { } type cbomCryptoProperties struct { - AssetType string `json:"assetType"` - AlgorithmProperties *cbomAlgorithm `json:"algorithmProperties,omitempty"` - CertificateProperties *cbomCertificate `json:"certificateProperties,omitempty"` - ProtocolProperties *cbomProtocol `json:"protocolProperties,omitempty"` - OID string `json:"oid,omitempty"` + AssetType string `json:"assetType"` + AlgorithmProperties *cbomAlgorithm `json:"algorithmProperties,omitempty"` + CertificateProperties *cbomCertificate `json:"certificateProperties,omitempty"` + ProtocolProperties *cbomProtocol `json:"protocolProperties,omitempty"` + RelatedCryptoMaterialProperties *cbomRelatedCryptoMaterial `json:"relatedCryptoMaterialProperties,omitempty"` + OID string `json:"oid,omitempty"` +} + +// cbomRelatedCryptoMaterial describes detected key material. CycloneDX models +// keys, secrets and tokens this way rather than as algorithms, and an algorithm +// component cannot express what kind of material was found or its state. +type cbomRelatedCryptoMaterial struct { + Type string `json:"type"` + State string `json:"state,omitempty"` + Size int `json:"size,omitempty"` } type cbomAlgorithm struct { @@ -123,18 +136,33 @@ type cbomDependency struct { DependsOn []string `json:"dependsOn,omitempty"` } +// CycloneDX 1.6 constrains cryptoProperties.algorithmProperties.primitive to a +// closed enum. Emitting any other value fails validation for the entire +// document, so the values this reporter produces are named here and covered by +// TestPrimitivesAreInCycloneDXEnum. +const ( + primitiveCombiner = "combiner" +) + +// categoryToAssetType maps a pattern category onto a CycloneDX asset type. +// +// Matching was previously case-sensitive against lowercase names that no +// pattern actually uses. The real categories are "Secret Detection", +// "Certificate" and similar, so every finding fell through to the default and +// was reported as an algorithm. A detected private key was emitted as +// assetType "algorithm" named "Private Key Header", which is what the reporter +// of issue #1 meant by incomplete key material representation. func categoryToAssetType(category string) string { - switch category { - case "asymmetric", "key-exchange": + switch strings.ToLower(strings.TrimSpace(category)) { + case "asymmetric", "key-exchange", "symmetric", "hash", "mac", "kdf", + "post-quantum", "signature", "random": return "algorithm" - case "symmetric": - return "algorithm" - case "hash": - return "algorithm" - case "tls", "protocol": + case "tls", "protocol", "ssh", "ipsec": return "protocol" - case "certificate", "key": + case "certificate": return "certificate" + case "secret detection", "key", "key material", "secret": + return "related-crypto-material" case "library": return "related-crypto-material" default: @@ -142,6 +170,71 @@ func categoryToAssetType(category string) string { } } +// relativeLocation expresses a finding's file relative to the scan root. +// +// A CBOM is an artifact that gets committed, attached to releases and shared +// with auditors and customers. Absolute paths embed the scanning machine's +// directory layout and username into it, and they make otherwise identical +// scans of the same source differ between machines and CI runners. The absolute +// path is used only as a fallback when no relative path can be derived. +func relativeLocation(scanTarget, file string) string { + if scanTarget == "" || file == "" { + return file + } + + root := scanTarget + if info, err := os.Stat(root); err == nil && !info.IsDir() { + root = filepath.Dir(root) + } + if abs, err := filepath.Abs(root); err == nil { + root = abs + } + + target := file + if abs, err := filepath.Abs(file); err == nil { + target = abs + } + + rel, err := filepath.Rel(root, target) + if err != nil || strings.HasPrefix(rel, "..") { + return file + } + return filepath.ToSlash(rel) +} + +// relatedCryptoMaterialType classifies detected key material for the CycloneDX +// relatedCryptoMaterialProperties.type field. The value must come from the +// spec's closed enum, so anything unrecognized is reported as "unknown" rather +// than guessed. +func relatedCryptoMaterialType(patternID, name string) string { + id := strings.ToUpper(patternID) + lowered := strings.ToLower(name) + + switch { + case strings.Contains(lowered, "private key"), strings.HasPrefix(id, "KEY-"): + return "private-key" + case strings.Contains(lowered, "public key"): + return "public-key" + case strings.Contains(lowered, "certificate signing request"), + strings.Contains(lowered, "csr"): + return "unknown" // CycloneDX has no CSR member; do not invent one + case strings.Contains(lowered, "secret key"), strings.Contains(lowered, "symmetric key"): + return "secret-key" + case strings.Contains(lowered, "password"), strings.Contains(lowered, "passphrase"): + return "password" + case strings.Contains(lowered, "token"): + return "token" + case strings.Contains(lowered, "seed"): + return "seed" + case strings.Contains(lowered, "signature"): + return "signature" + case strings.Contains(lowered, "key"): + return "key" + default: + return "unknown" + } +} + func algorithmToPrimitive(algo string) string { algoUpper := strings.ToUpper(algo) switch { @@ -155,6 +248,13 @@ func algorithmToPrimitive(algo string) string { strings.Contains(algoUpper, "ARGON"), strings.Contains(algoUpper, "SCRYPT"), strings.Contains(algoUpper, "BCRYPT"): return "kdf" + // Hybrid constructions combine a classical primitive with a post-quantum + // one. CycloneDX calls that a "combiner"; "hybrid" is not in the enum and + // made the whole document fail schema validation. Checked before the + // post-quantum cases so that a name carrying both, such as a hybrid + // ML-KEM construction, is reported as the combiner it is. + case strings.Contains(algoUpper, "HYBRID"), strings.Contains(algoUpper, "COMPOSITE"): + return primitiveCombiner // Post-Quantum KEMs case strings.Contains(algoUpper, "ML-KEM"), strings.Contains(algoUpper, "MLKEM"), strings.Contains(algoUpper, "KYBER"): @@ -168,16 +268,14 @@ func algorithmToPrimitive(algo string) string { strings.Contains(algoUpper, "FALCON"), strings.Contains(algoUpper, "XMSS"), strings.Contains(algoUpper, "LMS"): return "signature" - // Hybrid - case strings.Contains(algoUpper, "HYBRID"), strings.Contains(algoUpper, "COMPOSITE"): - return "hybrid" // Classical asymmetric case algo == "RSA": return "pke" case algo == "ECDSA", algo == "DSA", algo == "Ed25519": return "signature" case algo == "DH", algo == "ECDH", algo == "X25519": - return "key-agreement" + // CycloneDX spells this "key-agree", not "key-agreement". + return "key-agree" // Symmetric case algo == "AES", algo == "DES", algo == "3DES", algo == "Blowfish", algo == "RC4": return "block-cipher" @@ -300,7 +398,7 @@ func (r *CBOMReporter) Generate(results *scanner.Results) (string, error) { Evidence: &cbomEvidence{ Occurrences: []cbomOccurrence{ { - Location: f.File, + Location: relativeLocation(results.ScanTarget, f.File), Line: f.Line, Symbol: f.Match, }, @@ -313,8 +411,19 @@ func (r *CBOMReporter) Generate(results *scanner.Results) (string, error) { comp.CryptoProperties.OID = oid } - // Add algorithm properties - if f.Algorithm != "" { + // Describe detected key material as material rather than as an + // algorithm, so a private key carries its type and size instead of + // being flattened into a component named after the PEM header. + if comp.CryptoProperties.AssetType == "related-crypto-material" { + comp.CryptoProperties.RelatedCryptoMaterialProperties = &cbomRelatedCryptoMaterial{ + Type: relatedCryptoMaterialType(f.ID, compKey), + Size: f.KeySize, + } + } + + // Add algorithm properties. These only apply to algorithm assets; a + // key or a certificate is described by its own properties block. + if f.Algorithm != "" && comp.CryptoProperties.AssetType == "algorithm" { primitive := algorithmToPrimitive(f.Algorithm) comp.CryptoProperties.AlgorithmProperties = &cbomAlgorithm{ Primitive: primitive, @@ -340,7 +449,7 @@ func (r *CBOMReporter) Generate(results *scanner.Results) (string, error) { } // Track hybrid dependencies - if primitive == "hybrid" { + if primitive == primitiveCombiner { classicalAlgo, pqcAlgo := extractHybridComponents(f.Algorithm) if classicalAlgo != "" { hybridComponents[bomRef] = append(hybridComponents[bomRef], classicalAlgo) @@ -367,7 +476,7 @@ func (r *CBOMReporter) Generate(results *scanner.Results) (string, error) { existing.Evidence.Occurrences = append( existing.Evidence.Occurrences, cbomOccurrence{ - Location: f.File, + Location: relativeLocation(results.ScanTarget, f.File), Line: f.Line, Symbol: f.Match, }, @@ -533,6 +642,17 @@ func extractTLSVersion(match string) string { } // Simple UUID generator for CBOM serial numbers +// generateUUID returns a random RFC 4122 version 4 UUID. The previous +// implementation formatted a timestamp, which is not a UUID and did not match +// the CycloneDX serialNumber pattern +// ^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$, +// so every generated CBOM failed schema validation on that field alone. func generateUUID() string { - return time.Now().Format("20060102-150405-000000000") + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic("cbom: cannot read random bytes for serial number: " + err.Error()) + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } diff --git a/pkg/reporter/cbom_schema_test.go b/pkg/reporter/cbom_schema_test.go new file mode 100644 index 0000000..6638c95 --- /dev/null +++ b/pkg/reporter/cbom_schema_test.go @@ -0,0 +1,260 @@ +// Copyright 2025 CyberSecurity NonProfit (CSNP) +// SPDX-License-Identifier: Apache-2.0 + +package reporter + +import ( + "encoding/json" + "regexp" + "strings" + "testing" + "time" + + "github.com/csnp/cryptoscan/pkg/scanner" + "github.com/csnp/cryptoscan/pkg/types" +) + +// cycloneDXPrimitiveEnum is the closed enum CycloneDX 1.6 allows for +// cryptoProperties.algorithmProperties.primitive. +var cycloneDXPrimitiveEnum = map[string]bool{ + "drbg": true, "mac": true, "block-cipher": true, "stream-cipher": true, + "signature": true, "hash": true, "pke": true, "xof": true, "kdf": true, + "key-agree": true, "kem": true, "ae": true, "combiner": true, + "other": true, "unknown": true, +} + +// cycloneDXAssetTypeEnum is the closed enum for cryptoProperties.assetType. +var cycloneDXAssetTypeEnum = map[string]bool{ + "algorithm": true, "certificate": true, "protocol": true, + "related-crypto-material": true, +} + +// cycloneDXRelatedCryptoMaterialTypes is the closed enum for +// relatedCryptoMaterialProperties.type. +var cycloneDXRelatedCryptoMaterialTypes = map[string]bool{ + "private-key": true, "public-key": true, "secret-key": true, "key": true, + "ciphertext": true, "signature": true, "digest": true, "initialization-vector": true, + "nonce": true, "seed": true, "salt": true, "shared-secret": true, + "tag": true, "additional-data": true, "password": true, "credential": true, + "token": true, "other": true, "unknown": true, +} + +var cbomSerialPattern = regexp.MustCompile( + `^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +type parsedCBOM struct { + SerialNumber string `json:"serialNumber"` + SpecVersion string `json:"specVersion"` + Components []struct { + Name string `json:"name"` + CryptoProperties *struct { + AssetType string `json:"assetType"` + AlgorithmProperties *struct { + Primitive string `json:"primitive"` + } `json:"algorithmProperties"` + RelatedCryptoMaterialProperties *struct { + Type string `json:"type"` + } `json:"relatedCryptoMaterialProperties"` + } `json:"cryptoProperties"` + Evidence *struct { + Occurrences []struct { + Location string `json:"location"` + } `json:"occurrences"` + } `json:"evidence"` + } `json:"components"` +} + +func generateCBOM(t *testing.T, results *scanner.Results) parsedCBOM { + t.Helper() + + out, err := NewCBOMReporter().Generate(results) + if err != nil { + t.Fatalf("generate: %v", err) + } + var doc parsedCBOM + if err := json.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("CBOM is not valid JSON: %v", err) + } + return doc +} + +func resultsWith(scanTarget string, findings ...scanner.Finding) *scanner.Results { + return &scanner.Results{ + Findings: findings, + ScanTarget: scanTarget, + ScanTime: time.Now(), + } +} + +// TestSerialNumberIsAUUID is the regression guard for the first half of issue +// #1. The serial number was built from a timestamp +// (time.Now().Format("20060102-150405-000000000")), which is not a UUID and +// fails the CycloneDX serialNumber pattern, so every emitted document was +// invalid on that field alone. +func TestSerialNumberIsAUUID(t *testing.T) { + first := generateCBOM(t, resultsWith("/tmp/project", scanner.Finding{ + ID: "RSA-001", Algorithm: "RSA", Category: "asymmetric", + File: "/tmp/project/main.go", Line: 10, Match: "rsa.GenerateKey", + })) + second := generateCBOM(t, resultsWith("/tmp/project", scanner.Finding{ + ID: "RSA-001", Algorithm: "RSA", Category: "asymmetric", + File: "/tmp/project/main.go", Line: 10, Match: "rsa.GenerateKey", + })) + + if !cbomSerialPattern.MatchString(first.SerialNumber) { + t.Errorf("serialNumber %q does not match the CycloneDX urn:uuid pattern", + first.SerialNumber) + } + if first.SerialNumber == second.SerialNumber { + t.Error("two separate CBOMs share a serial number") + } +} + +// TestPrimitivesAreInCycloneDXEnum guards the second half of issue #1. The +// mapper emitted "hybrid" for hybrid constructions and "key-agreement" for key +// agreement, neither of which is in the spec enum, and a single non-member +// value fails validation for the whole document. +func TestPrimitivesAreInCycloneDXEnum(t *testing.T) { + algorithms := []string{ + "RSA", "ECDSA", "DSA", "Ed25519", "DH", "ECDH", "X25519", + "AES", "DES", "3DES", "Blowfish", "RC4", "ChaCha20", + "SHA-256", "MD5", "HMAC-SHA256", "PBKDF2", + "ML-KEM-768", "ML-DSA-65", "SLH-DSA", "Kyber", "Dilithium", "Falcon", + "X25519MLKEM768", "Hybrid-RSA-ML-DSA", "Composite-ECDSA", + "SomethingUnrecognized", + } + + for _, algo := range algorithms { + t.Run(algo, func(t *testing.T) { + got := algorithmToPrimitive(algo) + if !cycloneDXPrimitiveEnum[got] { + t.Errorf("algorithmToPrimitive(%q) = %q, which is not in the CycloneDX enum", + algo, got) + } + }) + } + + // Spot-check that hybrids and key agreement map to the right members + // rather than merely to something valid. + if got := algorithmToPrimitive("Hybrid-X25519-ML-KEM-768"); got != "combiner" { + t.Errorf("hybrid construction mapped to %q, want combiner", got) + } + if got := algorithmToPrimitive("ECDH"); got != "key-agree" { + t.Errorf("ECDH mapped to %q, want key-agree", got) + } +} + +// TestAssetTypesAreInCycloneDXEnum checks the asset type mapping, which was +// matching lowercase names no pattern actually uses. +func TestAssetTypesAreInCycloneDXEnum(t *testing.T) { + categories := []string{ + "Secret Detection", "Certificate", "asymmetric", "symmetric", "hash", + "TLS", "Protocol", "library", "Key Material", "Anything Else", "", + } + + for _, category := range categories { + got := categoryToAssetType(category) + if !cycloneDXAssetTypeEnum[got] { + t.Errorf("categoryToAssetType(%q) = %q, which is not a CycloneDX asset type", + category, got) + } + } + + // The categories the patterns actually use must map meaningfully rather + // than falling through to the default. + if got := categoryToAssetType("Secret Detection"); got != "related-crypto-material" { + t.Errorf("Secret Detection mapped to %q, want related-crypto-material", got) + } + if got := categoryToAssetType("Certificate"); got != "certificate" { + t.Errorf("Certificate mapped to %q, want certificate", got) + } +} + +// TestKeyMaterialIsRelatedCryptoMaterial covers the reporter's first point, +// incomplete key material representation. A detected private key was emitted as +// assetType "algorithm" named after the PEM header, losing the fact that it was +// key material at all. +func TestKeyMaterialIsRelatedCryptoMaterial(t *testing.T) { + doc := generateCBOM(t, resultsWith("/tmp/project", scanner.Finding{ + ID: "KEY-001", + Type: "Private Key Header", + Category: "Secret Detection", + File: "/tmp/project/id_rsa.pem", + Line: 1, + Match: "-----BEGIN RSA PRIVATE KEY-----", + Severity: types.SeverityCritical, + })) + + if len(doc.Components) == 0 { + t.Fatal("no components emitted for a detected private key") + } + + var found bool + for _, c := range doc.Components { + cp := c.CryptoProperties + if cp == nil || cp.AssetType != "related-crypto-material" { + continue + } + found = true + if cp.RelatedCryptoMaterialProperties == nil { + t.Errorf("component %q is related-crypto-material but carries no properties", c.Name) + continue + } + materialType := cp.RelatedCryptoMaterialProperties.Type + if !cycloneDXRelatedCryptoMaterialTypes[materialType] { + t.Errorf("material type %q is not in the CycloneDX enum", materialType) + } + if materialType != "private-key" { + t.Errorf("detected private key classified as %q, want private-key", materialType) + } + } + + if !found { + t.Error("a detected private key was not emitted as related-crypto-material") + } +} + +// TestEvidenceLocationsAreRelative checks that CBOMs do not embed the scanning +// machine's absolute directory layout, which leaks the operator's username and +// makes identical scans differ between machines. +func TestEvidenceLocationsAreRelative(t *testing.T) { + doc := generateCBOM(t, resultsWith("/tmp/project", scanner.Finding{ + ID: "RSA-001", Algorithm: "RSA", Category: "asymmetric", + File: "/tmp/project/internal/crypto/keys.go", Line: 42, Match: "rsa.GenerateKey", + })) + + var checked bool + for _, c := range doc.Components { + if c.Evidence == nil { + continue + } + for _, occ := range c.Evidence.Occurrences { + checked = true + if strings.HasPrefix(occ.Location, "/") { + t.Errorf("evidence location %q is an absolute path", occ.Location) + } + if occ.Location != "internal/crypto/keys.go" { + t.Errorf("evidence location = %q, want internal/crypto/keys.go", occ.Location) + } + } + } + if !checked { + t.Error("no evidence occurrences were emitted") + } +} + +// TestRelativeLocationFallsBackSafely checks that a file outside the scan root +// keeps its original path rather than producing a confusing ../.. chain. +func TestRelativeLocationFallsBackSafely(t *testing.T) { + got := relativeLocation("/tmp/project", "/etc/passwd") + if strings.HasPrefix(got, "..") { + t.Errorf("relativeLocation returned a parent-traversing path %q", got) + } + if got != "/etc/passwd" { + t.Errorf("relativeLocation = %q, want the original path as fallback", got) + } + + if got := relativeLocation("", "/tmp/x.go"); got != "/tmp/x.go" { + t.Errorf("empty scan target should pass the path through, got %q", got) + } +} diff --git a/pkg/reporter/reporter_test.go b/pkg/reporter/reporter_test.go index 1fa501f..e1c7520 100644 --- a/pkg/reporter/reporter_test.go +++ b/pkg/reporter/reporter_test.go @@ -330,7 +330,9 @@ func TestCBOMCategoryToAssetType(t *testing.T) { {"tls", "protocol"}, {"protocol", "protocol"}, {"certificate", "certificate"}, - {"key", "certificate"}, + // A key is key material, not a certificate. CycloneDX models it as + // related-crypto-material so the material's type and size survive. + {"key", "related-crypto-material"}, {"library", "related-crypto-material"}, {"unknown", "algorithm"}, {"", "algorithm"}, @@ -355,9 +357,12 @@ func TestCBOMAlgorithmToPrimitive(t *testing.T) { {"ECDSA", "signature"}, {"DSA", "signature"}, {"Ed25519", "signature"}, - {"DH", "key-agreement"}, - {"ECDH", "key-agreement"}, - {"X25519", "key-agreement"}, + // CycloneDX 1.6 spells key agreement "key-agree". The previous + // expectation of "key-agreement" is not a member of the spec enum, so + // asserting it locked in output that failed schema validation. + {"DH", "key-agree"}, + {"ECDH", "key-agree"}, + {"X25519", "key-agree"}, {"AES", "block-cipher"}, {"DES", "block-cipher"}, {"3DES", "block-cipher"},