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"},