sprints enhancements

This commit is contained in:
StellaOps Bot
2025-12-25 19:52:30 +02:00
parent ef6ac36323
commit b8b2d83f4a
138 changed files with 25133 additions and 594 deletions

View File

@@ -0,0 +1,267 @@
{
"corpus": "dedup-alias-collision",
"version": "1.0.0",
"description": "Test corpus for GHSA to CVE alias mapping edge cases",
"items": [
{
"id": "GHSA-CVE-same-package",
"description": "GHSA and CVE for same package should have same hash",
"sources": [
{
"source": "github",
"advisory_id": "GHSA-abc1-def2-ghi3",
"cve": "CVE-2024-1001",
"affects_key": "pkg:npm/express@4.18.0",
"version_range": "<4.18.2",
"weaknesses": ["CWE-400"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1001",
"cve": "cve-2024-1001",
"affects_key": "pkg:NPM/express@4.18.0",
"version_range": "<4.18.2",
"weaknesses": ["cwe-400"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Case normalization produces identical identity"
}
},
{
"id": "GHSA-CVE-different-package",
"description": "GHSA and CVE for different packages should differ",
"sources": [
{
"source": "github",
"advisory_id": "GHSA-xyz1-uvw2-rst3",
"cve": "CVE-2024-1002",
"affects_key": "pkg:npm/lodash@4.17.0",
"version_range": "<4.17.21",
"weaknesses": ["CWE-1321"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1002",
"cve": "CVE-2024-1002",
"affects_key": "pkg:npm/underscore@1.13.0",
"version_range": "<1.13.6",
"weaknesses": ["CWE-1321"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different packages produce different hashes"
}
},
{
"id": "PYSEC-CVE-mapping",
"description": "PyPI security advisory with CVE mapping",
"sources": [
{
"source": "osv",
"advisory_id": "PYSEC-2024-001",
"cve": "CVE-2024-1003",
"affects_key": "pkg:pypi/django@4.2.0",
"version_range": "<4.2.7",
"weaknesses": ["CWE-79"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1003",
"cve": "CVE-2024-1003",
"affects_key": "pkg:PYPI/Django@4.2.0",
"version_range": "<4.2.7",
"weaknesses": ["CWE-79"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Case normalization for PyPI package names"
}
},
{
"id": "RUSTSEC-CVE-mapping",
"description": "Rust security advisory with CVE mapping",
"sources": [
{
"source": "osv",
"advisory_id": "RUSTSEC-2024-0001",
"cve": "CVE-2024-1004",
"affects_key": "pkg:cargo/tokio@1.28.0",
"version_range": "<1.28.2",
"weaknesses": ["CWE-416"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1004",
"cve": "cve-2024-1004",
"affects_key": "pkg:CARGO/Tokio@1.28.0",
"version_range": "< 1.28.2",
"weaknesses": ["cwe-416"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Case normalization for CVE, PURL, and CWE produces same identity"
}
},
{
"id": "GO-CVE-scoped-package",
"description": "Go advisory with module path normalization",
"sources": [
{
"source": "osv",
"advisory_id": "GO-2024-0001",
"cve": "CVE-2024-1005",
"affects_key": "pkg:golang/github.com/example/module@v1.0.0",
"version_range": "<v1.2.0",
"weaknesses": ["CWE-94"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1005",
"cve": "CVE-2024-1005",
"affects_key": "pkg:golang/github.com/Example/Module@v1.0.0",
"version_range": "<v1.2.0",
"weaknesses": ["CWE-94"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Go module paths are normalized to lowercase"
}
},
{
"id": "CVE-reserved-no-data",
"description": "CVE reserved but no vulnerability data yet",
"sources": [
{
"source": "nvd",
"advisory_id": "CVE-2024-1006",
"cve": "CVE-2024-1006",
"affects_key": "pkg:npm/test@1.0.0",
"version_range": "*",
"weaknesses": []
},
{
"source": "github",
"advisory_id": "GHSA-test-test-test",
"cve": "CVE-2024-1006",
"affects_key": "pkg:npm/test@1.0.0",
"version_range": "all",
"weaknesses": []
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Wildcard version ranges normalize to same value"
}
},
{
"id": "OSV-multi-ecosystem",
"description": "OSV advisory affecting multiple ecosystems",
"sources": [
{
"source": "osv",
"advisory_id": "OSV-2024-001-npm",
"cve": "CVE-2024-1007",
"affects_key": "pkg:npm/shared-lib@1.0.0",
"version_range": "<1.5.0",
"weaknesses": ["CWE-20"]
},
{
"source": "osv",
"advisory_id": "OSV-2024-001-pypi",
"cve": "CVE-2024-1007",
"affects_key": "pkg:pypi/shared-lib@1.0.0",
"version_range": "<1.5.0",
"weaknesses": ["CWE-20"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different ecosystems (npm vs pypi) produce different hashes"
}
},
{
"id": "GHSA-CVE-partial-cwe",
"description": "GHSA has more CWEs than CVE",
"sources": [
{
"source": "github",
"advisory_id": "GHSA-full-cwe-list",
"cve": "CVE-2024-1008",
"affects_key": "pkg:npm/vuln-pkg@1.0.0",
"version_range": "<1.1.0",
"weaknesses": ["CWE-79", "CWE-89", "CWE-94"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1008",
"cve": "CVE-2024-1008",
"affects_key": "pkg:npm/vuln-pkg@1.0.0",
"version_range": "<1.1.0",
"weaknesses": ["CWE-79"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different CWE sets produce different hashes"
}
},
{
"id": "GHSA-no-CVE-yet",
"description": "GHSA published before CVE assignment",
"sources": [
{
"source": "github",
"advisory_id": "GHSA-pend-cve-asn",
"cve": "CVE-2024-1009",
"affects_key": "pkg:npm/new-vuln@2.0.0",
"version_range": "<2.0.5",
"weaknesses": ["CWE-352"]
},
{
"source": "github",
"advisory_id": "GHSA-pend-cve-asn",
"cve": "cve-2024-1009",
"affects_key": "pkg:NPM/new-vuln@2.0.0",
"version_range": "<2.0.5",
"weaknesses": ["cwe-352"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Same GHSA with case variations produces same hash"
}
},
{
"id": "NuGet-GHSA-CVE",
"description": "NuGet package with GHSA and CVE",
"sources": [
{
"source": "github",
"advisory_id": "GHSA-nuget-test-001",
"cve": "CVE-2024-1010",
"affects_key": "pkg:nuget/Newtonsoft.Json@13.0.0",
"version_range": "<13.0.3",
"weaknesses": ["CWE-502"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1010",
"cve": "CVE-2024-1010",
"affects_key": "pkg:NUGET/newtonsoft.json@13.0.0",
"version_range": "<13.0.3",
"weaknesses": ["CWE-502"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "NuGet package names are case-insensitive"
}
}
]
}

View File

@@ -0,0 +1,281 @@
{
"corpus": "dedup-backport-variants",
"version": "1.0.0",
"description": "Test corpus for merge hash deduplication with Alpine/SUSE backport variants",
"items": [
{
"id": "CVE-2024-0001-openssl-alpine-backport",
"description": "Alpine backport with upstream commit reference",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-001",
"cve": "CVE-2024-0001",
"affects_key": "pkg:apk/alpine/openssl@1.1.1w",
"version_range": "<1.1.1w-r1",
"weaknesses": ["CWE-476"],
"patch_lineage": "https://github.com/openssl/openssl/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
},
{
"source": "alpine",
"advisory_id": "ALPINE-2024-001",
"cve": "CVE-2024-0001",
"affects_key": "pkg:apk/alpine/openssl@1.1.1w",
"version_range": "<1.1.1w-r1",
"weaknesses": ["CWE-476"],
"patch_lineage": "backport of a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Same SHA extracted from both URL and backport reference"
}
},
{
"id": "CVE-2024-0002-curl-suse-backport",
"description": "SUSE backport with PATCH-ID format",
"sources": [
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0001-1",
"cve": "CVE-2024-0002",
"affects_key": "pkg:rpm/suse/curl@7.79.1",
"version_range": "<7.79.1-150400.5.36.1",
"weaknesses": ["CWE-120"],
"patch_lineage": "PATCH-12345"
},
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0001-1",
"cve": "CVE-2024-0002",
"affects_key": "pkg:rpm/suse/curl@7.79.1",
"version_range": "<7.79.1-150400.5.36.1",
"weaknesses": ["CWE-120"],
"patch_lineage": "patch-12345"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "PATCH-ID is case-normalized to uppercase"
}
},
{
"id": "CVE-2024-0003-nginx-different-backports",
"description": "Same CVE with different backport lineages should differ",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-002",
"cve": "CVE-2024-0003",
"affects_key": "pkg:apk/alpine/nginx@1.24.0",
"version_range": "<1.24.0-r7",
"weaknesses": ["CWE-400"],
"patch_lineage": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
},
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0002-1",
"cve": "CVE-2024-0003",
"affects_key": "pkg:rpm/suse/nginx@1.24.0",
"version_range": "<1.24.0-150400.3.7.1",
"weaknesses": ["CWE-400"],
"patch_lineage": "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different package ecosystems and different patch lineages"
}
},
{
"id": "CVE-2024-0004-busybox-no-lineage",
"description": "Backport without lineage info should still match on case normalization",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-003",
"cve": "CVE-2024-0004",
"affects_key": "pkg:apk/alpine/busybox@1.36.1",
"version_range": "<1.36.1-r6",
"weaknesses": ["CWE-78"]
},
{
"source": "alpine",
"advisory_id": "ALPINE-2024-003",
"cve": "cve-2024-0004",
"affects_key": "pkg:APK/alpine/busybox@1.36.1",
"version_range": "<1.36.1-r6",
"weaknesses": ["cwe-78"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Case normalization produces identical identity when no patch lineage"
}
},
{
"id": "CVE-2024-0005-musl-abbreviated-sha",
"description": "Abbreviated vs full SHA should normalize differently",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-004",
"cve": "CVE-2024-0005",
"affects_key": "pkg:apk/alpine/musl@1.2.4",
"version_range": "<1.2.4-r2",
"weaknesses": ["CWE-119"],
"patch_lineage": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
},
{
"source": "alpine",
"advisory_id": "ALPINE-2024-004",
"cve": "CVE-2024-0005",
"affects_key": "pkg:apk/alpine/musl@1.2.4",
"version_range": "<1.2.4-r2",
"weaknesses": ["CWE-119"],
"patch_lineage": "commit a1b2c3d"
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Full SHA vs abbreviated SHA produce different normalized lineages"
}
},
{
"id": "CVE-2024-0006-zlib-multiple-shas",
"description": "Multiple SHAs in lineage - should extract first full SHA",
"sources": [
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0003-1",
"cve": "CVE-2024-0006",
"affects_key": "pkg:rpm/suse/zlib@1.2.13",
"version_range": "<1.2.13-150500.4.3.1",
"weaknesses": ["CWE-787"],
"patch_lineage": "f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2"
},
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0003-1",
"cve": "CVE-2024-0006",
"affects_key": "pkg:rpm/suse/zlib@1.2.13",
"version_range": "<1.2.13-150500.4.3.1",
"weaknesses": ["CWE-787"],
"patch_lineage": "fixes include f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2 and abc1234"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Full SHA is extracted and normalized from both lineage descriptions"
}
},
{
"id": "CVE-2024-0007-libpng-distro-versions",
"description": "Same upstream fix with different notation but same semantic meaning",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-005",
"cve": "CVE-2024-0007",
"affects_key": "pkg:apk/alpine/libpng@1.6.40",
"version_range": "<1.6.40-r0",
"weaknesses": ["CWE-125"]
},
{
"source": "alpine",
"advisory_id": "ALPINE-2024-005",
"cve": "cve-2024-0007",
"affects_key": "pkg:APK/alpine/libpng@1.6.40",
"version_range": "< 1.6.40-r0",
"weaknesses": ["cwe-125"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Case normalization and whitespace trimming produce identical identity"
}
},
{
"id": "CVE-2024-0008-git-github-url",
"description": "GitHub vs GitLab commit URL extraction",
"sources": [
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0004-1",
"cve": "CVE-2024-0008",
"affects_key": "pkg:rpm/suse/git@2.42.0",
"version_range": "<2.42.0-150500.3.6.1",
"weaknesses": ["CWE-78"],
"patch_lineage": "https://github.com/git/git/commit/abc123def456abc123def456abc123def456abc1"
},
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0004-1",
"cve": "CVE-2024-0008",
"affects_key": "pkg:rpm/suse/git@2.42.0",
"version_range": "<2.42.0-150500.3.6.1",
"weaknesses": ["CWE-78"],
"patch_lineage": "https://gitlab.com/git/git/commit/abc123def456abc123def456abc123def456abc1"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Both GitHub and GitLab URL patterns extract same commit SHA"
}
},
{
"id": "CVE-2024-0009-expat-unrecognized-lineage",
"description": "Unrecognized patch lineage format returns null",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-006",
"cve": "CVE-2024-0009",
"affects_key": "pkg:apk/alpine/expat@2.5.0",
"version_range": "<2.5.0-r1",
"weaknesses": ["CWE-611"],
"patch_lineage": "some random text without sha"
},
{
"source": "alpine",
"advisory_id": "ALPINE-2024-006",
"cve": "CVE-2024-0009",
"affects_key": "pkg:apk/alpine/expat@2.5.0",
"version_range": "<2.5.0-r1",
"weaknesses": ["CWE-611"],
"patch_lineage": "another unrecognized format"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Both unrecognized lineages normalize to null, producing same hash"
}
},
{
"id": "CVE-2024-0010-sqlite-fixed-notation",
"description": "Fixed version notation normalization",
"sources": [
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0005-1",
"cve": "CVE-2024-0010",
"affects_key": "pkg:rpm/suse/sqlite3@3.43.0",
"version_range": "fixed: 3.43.2",
"weaknesses": ["CWE-476"]
},
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0005-1",
"cve": "CVE-2024-0010",
"affects_key": "pkg:rpm/suse/sqlite3@3.43.0",
"version_range": ">=3.43.2",
"weaknesses": ["CWE-476"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "fixed: notation normalizes to >= comparison"
}
}
]
}

View File

@@ -0,0 +1,269 @@
{
"corpus": "dedup-debian-rhel-cve-2024",
"version": "1.0.0",
"description": "Test corpus for merge hash deduplication across Debian and RHEL sources",
"items": [
{
"id": "CVE-2024-1234-curl",
"description": "Same curl CVE from Debian and RHEL - should produce same identity hash for same package",
"sources": [
{
"source": "debian",
"advisory_id": "DSA-5678-1",
"cve": "CVE-2024-1234",
"affects_key": "pkg:deb/debian/curl@7.68.0",
"version_range": "<7.68.0-1+deb10u2",
"weaknesses": ["CWE-120"]
},
{
"source": "redhat",
"advisory_id": "RHSA-2024:1234",
"cve": "CVE-2024-1234",
"affects_key": "pkg:deb/debian/curl@7.68.0",
"version_range": "<7.68.0-1+deb10u2",
"weaknesses": ["cwe-120"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Same CVE, same package identity, same version range, same CWE (case-insensitive)"
}
},
{
"id": "CVE-2024-2345-openssl",
"description": "Same OpenSSL CVE from Debian and RHEL with different package identifiers",
"sources": [
{
"source": "debian",
"advisory_id": "DSA-5680-1",
"cve": "CVE-2024-2345",
"affects_key": "pkg:deb/debian/openssl@1.1.1n",
"version_range": "<1.1.1n-0+deb11u5",
"weaknesses": ["CWE-200", "CWE-326"]
},
{
"source": "redhat",
"advisory_id": "RHSA-2024:2345",
"cve": "cve-2024-2345",
"affects_key": "pkg:rpm/redhat/openssl@1.1.1k",
"version_range": "<1.1.1k-12.el8_9",
"weaknesses": ["CWE-326", "CWE-200"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different package identifiers (deb vs rpm), so different merge hash despite same CVE"
}
},
{
"id": "CVE-2024-3456-nginx",
"description": "Same nginx CVE with normalized version ranges",
"sources": [
{
"source": "debian",
"advisory_id": "DSA-5681-1",
"cve": "CVE-2024-3456",
"affects_key": "pkg:deb/debian/nginx@1.22.0",
"version_range": "[1.0.0, 1.22.1)",
"weaknesses": ["CWE-79"]
},
{
"source": "debian_tracker",
"advisory_id": "CVE-2024-3456",
"cve": "CVE-2024-3456",
"affects_key": "pkg:deb/debian/nginx@1.22.0",
"version_range": ">=1.0.0,<1.22.1",
"weaknesses": ["CWE-79"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Same CVE, same package, version ranges normalize to same format"
}
},
{
"id": "CVE-2024-4567-log4j",
"description": "Different CVEs for same package should have different hash",
"sources": [
{
"source": "nvd",
"advisory_id": "CVE-2024-4567",
"cve": "CVE-2024-4567",
"affects_key": "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0",
"version_range": "<2.17.1",
"weaknesses": ["CWE-502"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-4568",
"cve": "CVE-2024-4568",
"affects_key": "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0",
"version_range": "<2.17.1",
"weaknesses": ["CWE-502"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different CVEs, even with same package and version range"
}
},
{
"id": "CVE-2024-5678-postgres",
"description": "Same CVE with different CWEs should have different hash",
"sources": [
{
"source": "nvd",
"advisory_id": "CVE-2024-5678",
"cve": "CVE-2024-5678",
"affects_key": "pkg:generic/postgresql@15.0",
"version_range": "<15.4",
"weaknesses": ["CWE-89"]
},
{
"source": "vendor",
"advisory_id": "CVE-2024-5678",
"cve": "CVE-2024-5678",
"affects_key": "pkg:generic/postgresql@15.0",
"version_range": "<15.4",
"weaknesses": ["CWE-89", "CWE-94"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different CWE sets change the identity"
}
},
{
"id": "CVE-2024-6789-python",
"description": "Same CVE with PURL qualifier stripping",
"sources": [
{
"source": "pypi",
"advisory_id": "PYSEC-2024-001",
"cve": "CVE-2024-6789",
"affects_key": "pkg:pypi/requests@2.28.0?arch=x86_64",
"version_range": "<2.28.2",
"weaknesses": ["CWE-400"]
},
{
"source": "osv",
"advisory_id": "CVE-2024-6789",
"cve": "CVE-2024-6789",
"affects_key": "pkg:pypi/requests@2.28.0",
"version_range": "<2.28.2",
"weaknesses": ["CWE-400"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "arch qualifier is stripped during normalization, so packages are identical"
}
},
{
"id": "CVE-2024-7890-npm",
"description": "Same CVE with scoped npm package - case normalization",
"sources": [
{
"source": "npm",
"advisory_id": "GHSA-abc1-def2-ghi3",
"cve": "CVE-2024-7890",
"affects_key": "pkg:npm/@angular/core@14.0.0",
"version_range": "<14.2.0",
"weaknesses": ["CWE-79"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-7890",
"cve": "cve-2024-7890",
"affects_key": "pkg:NPM/@Angular/CORE@14.0.0",
"version_range": "<14.2.0",
"weaknesses": ["cwe-79"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "PURL type/namespace/name case normalization produces same identity"
}
},
{
"id": "CVE-2024-8901-redis",
"description": "Same CVE with CPE identifier",
"sources": [
{
"source": "nvd",
"advisory_id": "CVE-2024-8901",
"cve": "CVE-2024-8901",
"affects_key": "cpe:2.3:a:redis:redis:7.0.0:*:*:*:*:*:*:*",
"version_range": "<7.0.12",
"weaknesses": ["CWE-416"]
},
{
"source": "vendor",
"advisory_id": "CVE-2024-8901",
"cve": "CVE-2024-8901",
"affects_key": "CPE:2.3:A:Redis:REDIS:7.0.0:*:*:*:*:*:*:*",
"version_range": "<7.0.12",
"weaknesses": ["CWE-416"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "CPE normalization lowercases all components"
}
},
{
"id": "CVE-2024-9012-kernel",
"description": "Same CVE with CPE 2.2 vs 2.3 format",
"sources": [
{
"source": "nvd",
"advisory_id": "CVE-2024-9012",
"cve": "CVE-2024-9012",
"affects_key": "cpe:/o:linux:linux_kernel:5.15",
"version_range": "<5.15.120",
"weaknesses": ["CWE-416"]
},
{
"source": "vendor",
"advisory_id": "CVE-2024-9012",
"cve": "CVE-2024-9012",
"affects_key": "cpe:2.3:o:linux:linux_kernel:5.15:*:*:*:*:*:*:*",
"version_range": "<5.15.120",
"weaknesses": ["CWE-416"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "CPE 2.2 is converted to CPE 2.3 format during normalization"
}
},
{
"id": "CVE-2024-1357-glibc",
"description": "Same CVE with patch lineage differentiation",
"sources": [
{
"source": "debian",
"advisory_id": "DSA-5690-1",
"cve": "CVE-2024-1357",
"affects_key": "pkg:deb/debian/glibc@2.31",
"version_range": "<2.31-13+deb11u7",
"weaknesses": ["CWE-787"],
"patch_lineage": "https://github.com/glibc/glibc/commit/abc123def456abc123def456abc123def456abc1"
},
{
"source": "debian",
"advisory_id": "DSA-5690-1",
"cve": "CVE-2024-1357",
"affects_key": "pkg:deb/debian/glibc@2.31",
"version_range": "<2.31-13+deb11u7",
"weaknesses": ["CWE-787"],
"patch_lineage": "commit abc123def456abc123def456abc123def456abc1"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Patch lineage normalization extracts SHA from both URL and plain commit reference"
}
}
]
}

View File

@@ -0,0 +1,244 @@
// -----------------------------------------------------------------------------
// CpeNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for CpeNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class CpeNormalizerTests
{
private readonly CpeNormalizer _normalizer = CpeNormalizer.Instance;
#region CPE 2.3 Normalization
[Fact]
public void Normalize_ValidCpe23_ReturnsLowercase()
{
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe23Uppercase_ReturnsLowercase()
{
var result = _normalizer.Normalize("CPE:2.3:A:VENDOR:PRODUCT:1.0:*:*:*:*:*:*:*");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe23MixedCase_ReturnsLowercase()
{
var result = _normalizer.Normalize("cpe:2.3:a:Apache:Log4j:2.14.0:*:*:*:*:*:*:*");
Assert.Equal("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe23WithAny_ReturnsWildcard()
{
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product:ANY:ANY:ANY:ANY:ANY:ANY:ANY:ANY");
Assert.Equal("cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe23WithNa_ReturnsDash()
{
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product:1.0:NA:*:*:*:*:*:*");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:-:*:*:*:*:*:*", result);
}
#endregion
#region CPE 2.2 to 2.3 Conversion
[Fact]
public void Normalize_Cpe22Simple_ConvertsToCpe23()
{
var result = _normalizer.Normalize("cpe:/a:vendor:product:1.0");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe22NoVersion_ConvertsToCpe23()
{
var result = _normalizer.Normalize("cpe:/a:vendor:product");
Assert.StartsWith("cpe:2.3:a:vendor:product:", result);
}
[Fact]
public void Normalize_Cpe22WithUpdate_ConvertsToCpe23()
{
var result = _normalizer.Normalize("cpe:/a:vendor:product:1.0:update1");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:update1:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe22Uppercase_ConvertsToCpe23Lowercase()
{
var result = _normalizer.Normalize("CPE:/A:VENDOR:PRODUCT:1.0");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
}
#endregion
#region Part Types
[Theory]
[InlineData("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", "a")] // Application
[InlineData("cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*", "o")] // Operating System
[InlineData("cpe:2.3:h:vendor:product:1.0:*:*:*:*:*:*:*", "h")] // Hardware
public void Normalize_DifferentPartTypes_PreservesPartType(string input, string expectedPart)
{
var result = _normalizer.Normalize(input);
Assert.StartsWith($"cpe:2.3:{expectedPart}:", result);
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsEmpty()
{
var result = _normalizer.Normalize(null!);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_EmptyString_ReturnsEmpty()
{
var result = _normalizer.Normalize(string.Empty);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WhitespaceOnly_ReturnsEmpty()
{
var result = _normalizer.Normalize(" ");
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize(" cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:* ");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
}
#endregion
#region Edge Cases - Malformed Input
[Fact]
public void Normalize_InvalidCpeFormat_ReturnsLowercase()
{
var result = _normalizer.Normalize("cpe:invalid:format");
Assert.Equal("cpe:invalid:format", result);
}
[Fact]
public void Normalize_NotCpe_ReturnsLowercase()
{
var result = _normalizer.Normalize("not-a-cpe");
Assert.Equal("not-a-cpe", result);
}
[Fact]
public void Normalize_TooFewComponents_ReturnsLowercase()
{
var result = _normalizer.Normalize("cpe:2.3:a:vendor");
Assert.Equal("cpe:2.3:a:vendor", result);
}
#endregion
#region Edge Cases - Empty Components
[Fact]
public void Normalize_EmptyVersion_ReturnsWildcard()
{
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product::*:*:*:*:*:*:*");
Assert.Contains(":*:", result);
}
[Fact]
public void Normalize_EmptyVendor_ReturnsWildcard()
{
var result = _normalizer.Normalize("cpe:2.3:a::product:1.0:*:*:*:*:*:*:*");
Assert.Contains(":*:", result);
}
#endregion
#region Determinism
[Theory]
[InlineData("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*")]
[InlineData("CPE:2.3:A:VENDOR:PRODUCT:1.0:*:*:*:*:*:*:*")]
[InlineData("cpe:/a:vendor:product:1.0")]
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
{
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
const string input = "CPE:2.3:A:Apache:LOG4J:2.14.0:*:*:*:*:*:*:*";
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
[Fact]
public void Normalize_Cpe22And23_ProduceSameOutput()
{
var cpe22 = "cpe:/a:apache:log4j:2.14.0";
var cpe23 = "cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*";
var result22 = _normalizer.Normalize(cpe22);
var result23 = _normalizer.Normalize(cpe23);
Assert.Equal(result22, result23);
}
#endregion
#region Real-World CPE Formats
[Theory]
[InlineData("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", "cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*")]
[InlineData("cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*", "cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*")]
[InlineData("cpe:2.3:o:linux:linux_kernel:5.10:*:*:*:*:*:*:*", "cpe:2.3:o:linux:linux_kernel:5.10:*:*:*:*:*:*:*")]
public void Normalize_RealWorldCpes_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = CpeNormalizer.Instance;
var instance2 = CpeNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -0,0 +1,207 @@
// -----------------------------------------------------------------------------
// CveNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for CveNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class CveNormalizerTests
{
private readonly CveNormalizer _normalizer = CveNormalizer.Instance;
#region Basic Normalization
[Fact]
public void Normalize_ValidUppercase_ReturnsUnchanged()
{
var result = _normalizer.Normalize("CVE-2024-12345");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_ValidLowercase_ReturnsUppercase()
{
var result = _normalizer.Normalize("cve-2024-12345");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_MixedCase_ReturnsUppercase()
{
var result = _normalizer.Normalize("Cve-2024-12345");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize(" CVE-2024-12345 ");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_JustNumberPart_AddsCvePrefix()
{
var result = _normalizer.Normalize("2024-12345");
Assert.Equal("CVE-2024-12345", result);
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsEmpty()
{
var result = _normalizer.Normalize(null);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_EmptyString_ReturnsEmpty()
{
var result = _normalizer.Normalize(string.Empty);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WhitespaceOnly_ReturnsEmpty()
{
var result = _normalizer.Normalize(" ");
Assert.Equal(string.Empty, result);
}
#endregion
#region Edge Cases - Malformed Input
[Fact]
public void Normalize_ShortYear_ReturnsAsIs()
{
// Invalid year format (3 digits) - should return uppercase
var result = _normalizer.Normalize("CVE-202-12345");
Assert.Equal("CVE-202-12345", result);
}
[Fact]
public void Normalize_ShortSequence_ReturnsAsIs()
{
// Invalid sequence (3 digits, min is 4) - should return uppercase
var result = _normalizer.Normalize("CVE-2024-123");
Assert.Equal("CVE-2024-123", result);
}
[Fact]
public void Normalize_NonNumericYear_ReturnsUppercase()
{
var result = _normalizer.Normalize("CVE-XXXX-12345");
Assert.Equal("CVE-XXXX-12345", result);
}
[Fact]
public void Normalize_NonNumericSequence_ReturnsUppercase()
{
var result = _normalizer.Normalize("CVE-2024-ABCDE");
Assert.Equal("CVE-2024-ABCDE", result);
}
[Fact]
public void Normalize_ArbitraryText_ReturnsUppercase()
{
var result = _normalizer.Normalize("some-random-text");
Assert.Equal("SOME-RANDOM-TEXT", result);
}
#endregion
#region Edge Cases - Unicode and Special Characters
[Fact]
public void Normalize_UnicodeWhitespace_ReturnsTrimmed()
{
// Non-breaking space and other unicode whitespace
var result = _normalizer.Normalize("\u00A0CVE-2024-12345\u2003");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_WithNewlines_ReturnsTrimmed()
{
var result = _normalizer.Normalize("\nCVE-2024-12345\r\n");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_WithTabs_ReturnsTrimmed()
{
var result = _normalizer.Normalize("\tCVE-2024-12345\t");
Assert.Equal("CVE-2024-12345", result);
}
#endregion
#region Determinism
[Theory]
[InlineData("CVE-2024-12345")]
[InlineData("cve-2024-12345")]
[InlineData("2024-12345")]
[InlineData(" CVE-2024-12345 ")]
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
{
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
const string input = "cve-2024-99999";
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
#endregion
#region Real-World CVE Formats
[Theory]
[InlineData("CVE-2024-1234", "CVE-2024-1234")]
[InlineData("CVE-2024-12345", "CVE-2024-12345")]
[InlineData("CVE-2024-123456", "CVE-2024-123456")]
[InlineData("CVE-2021-44228", "CVE-2021-44228")] // Log4Shell
[InlineData("CVE-2017-5754", "CVE-2017-5754")] // Meltdown
[InlineData("CVE-2014-0160", "CVE-2014-0160")] // Heartbleed
public void Normalize_RealWorldCves_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = CveNormalizer.Instance;
var instance2 = CveNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -0,0 +1,251 @@
// -----------------------------------------------------------------------------
// CweNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for CweNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class CweNormalizerTests
{
private readonly CweNormalizer _normalizer = CweNormalizer.Instance;
#region Basic Normalization
[Fact]
public void Normalize_SingleCwe_ReturnsUppercase()
{
var result = _normalizer.Normalize(["cwe-79"]);
Assert.Equal("CWE-79", result);
}
[Fact]
public void Normalize_MultipleCwes_ReturnsSortedCommaJoined()
{
var result = _normalizer.Normalize(["CWE-89", "CWE-79"]);
Assert.Equal("CWE-79,CWE-89", result);
}
[Fact]
public void Normalize_MixedCase_ReturnsUppercase()
{
var result = _normalizer.Normalize(["Cwe-79", "cwe-89", "CWE-120"]);
Assert.Equal("CWE-79,CWE-89,CWE-120", result);
}
[Fact]
public void Normalize_WithoutPrefix_AddsPrefix()
{
var result = _normalizer.Normalize(["79", "89"]);
Assert.Equal("CWE-79,CWE-89", result);
}
[Fact]
public void Normalize_MixedPrefixFormats_NormalizesAll()
{
var result = _normalizer.Normalize(["CWE-79", "89", "cwe-120"]);
Assert.Equal("CWE-79,CWE-89,CWE-120", result);
}
#endregion
#region Deduplication
[Fact]
public void Normalize_Duplicates_ReturnsUnique()
{
var result = _normalizer.Normalize(["CWE-79", "CWE-79", "cwe-79"]);
Assert.Equal("CWE-79", result);
}
[Fact]
public void Normalize_DuplicatesWithDifferentCase_ReturnsUnique()
{
var result = _normalizer.Normalize(["CWE-89", "cwe-89", "Cwe-89"]);
Assert.Equal("CWE-89", result);
}
[Fact]
public void Normalize_DuplicatesWithMixedFormats_ReturnsUnique()
{
var result = _normalizer.Normalize(["CWE-79", "79", "cwe-79"]);
Assert.Equal("CWE-79", result);
}
#endregion
#region Sorting
[Fact]
public void Normalize_UnsortedNumbers_ReturnsSortedNumerically()
{
var result = _normalizer.Normalize(["CWE-200", "CWE-79", "CWE-120", "CWE-1"]);
Assert.Equal("CWE-1,CWE-79,CWE-120,CWE-200", result);
}
[Fact]
public void Normalize_LargeNumbers_ReturnsSortedNumerically()
{
var result = _normalizer.Normalize(["CWE-1000", "CWE-100", "CWE-10"]);
Assert.Equal("CWE-10,CWE-100,CWE-1000", result);
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsEmpty()
{
var result = _normalizer.Normalize(null);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_EmptyArray_ReturnsEmpty()
{
var result = _normalizer.Normalize([]);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_ArrayWithNulls_ReturnsEmpty()
{
var result = _normalizer.Normalize([null!, null!]);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_ArrayWithEmptyStrings_ReturnsEmpty()
{
var result = _normalizer.Normalize(["", " ", string.Empty]);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_MixedValidAndEmpty_ReturnsValidOnly()
{
var result = _normalizer.Normalize(["CWE-79", "", null!, "CWE-89", " "]);
Assert.Equal("CWE-79,CWE-89", result);
}
#endregion
#region Edge Cases - Malformed Input
[Fact]
public void Normalize_InvalidFormat_FiltersOut()
{
var result = _normalizer.Normalize(["CWE-79", "not-a-cwe", "CWE-89"]);
Assert.Equal("CWE-79,CWE-89", result);
}
[Fact]
public void Normalize_AllInvalid_ReturnsEmpty()
{
var result = _normalizer.Normalize(["invalid", "not-cwe", "random"]);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_NonNumericSuffix_FiltersOut()
{
var result = _normalizer.Normalize(["CWE-ABC", "CWE-79"]);
Assert.Equal("CWE-79", result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize([" CWE-79 ", " CWE-89 "]);
Assert.Equal("CWE-79,CWE-89", result);
}
#endregion
#region Edge Cases - Unicode
[Fact]
public void Normalize_UnicodeWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize(["\u00A0CWE-79\u00A0"]);
Assert.Equal("CWE-79", result);
}
#endregion
#region Determinism
[Fact]
public void Normalize_MultipleRuns_ReturnsSameResult()
{
var input = new[] { "cwe-89", "CWE-79", "120" };
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
var input = new[] { "CWE-200", "cwe-79", "120", "CWE-89" };
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
[Fact]
public void Normalize_DifferentOrdering_ReturnsSameResult()
{
var input1 = new[] { "CWE-79", "CWE-89", "CWE-120" };
var input2 = new[] { "CWE-120", "CWE-79", "CWE-89" };
var input3 = new[] { "CWE-89", "CWE-120", "CWE-79" };
var result1 = _normalizer.Normalize(input1);
var result2 = _normalizer.Normalize(input2);
var result3 = _normalizer.Normalize(input3);
Assert.Equal(result1, result2);
Assert.Equal(result2, result3);
}
#endregion
#region Real-World CWE Formats
[Theory]
[InlineData("CWE-79", "CWE-79")] // XSS
[InlineData("CWE-89", "CWE-89")] // SQL Injection
[InlineData("CWE-120", "CWE-120")] // Buffer Overflow
[InlineData("CWE-200", "CWE-200")] // Information Exposure
[InlineData("CWE-22", "CWE-22")] // Path Traversal
public void Normalize_RealWorldCwes_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize([input]);
Assert.Equal(expected, result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = CweNormalizer.Instance;
var instance2 = CweNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -0,0 +1,449 @@
// -----------------------------------------------------------------------------
// MergeHashCalculatorTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-012
// Description: Unit tests for MergeHashCalculator - determinism and correctness
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class MergeHashCalculatorTests
{
private readonly MergeHashCalculator _calculator = new();
#region Basic Hash Computation
[Fact]
public void ComputeMergeHash_ValidInput_ReturnsHashWithPrefix()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/lodash@4.17.21"
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
Assert.Equal(71, result.Length); // "sha256:" (7) + 64 hex chars
}
[Fact]
public void ComputeMergeHash_WithAllFields_ReturnsHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/lodash@4.17.21",
VersionRange = "[1.0.0, 2.0.0)",
Weaknesses = ["CWE-79", "CWE-89"],
PatchLineage = "https://github.com/lodash/lodash/commit/abc1234"
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
}
[Fact]
public void ComputeMergeHash_NullInput_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => _calculator.ComputeMergeHash((MergeHashInput)null!));
}
#endregion
#region Determinism - Same Input = Same Output
[Fact]
public void ComputeMergeHash_SameInput_ReturnsSameHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/lodash@4.17.21",
Weaknesses = ["CWE-79"]
};
var first = _calculator.ComputeMergeHash(input);
var second = _calculator.ComputeMergeHash(input);
var third = _calculator.ComputeMergeHash(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void ComputeMergeHash_Determinism_100Runs()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-99999",
AffectsKey = "pkg:maven/org.apache/commons-lang3@3.12.0",
VersionRange = ">=1.0.0,<2.0.0",
Weaknesses = ["CWE-120", "CWE-200", "CWE-79"],
PatchLineage = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
};
var expected = _calculator.ComputeMergeHash(input);
for (var i = 0; i < 100; i++)
{
var result = _calculator.ComputeMergeHash(input);
Assert.Equal(expected, result);
}
}
[Fact]
public void ComputeMergeHash_NewInstancesProduceSameHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/lodash@4.17.21"
};
var calc1 = new MergeHashCalculator();
var calc2 = new MergeHashCalculator();
var calc3 = new MergeHashCalculator();
var hash1 = calc1.ComputeMergeHash(input);
var hash2 = calc2.ComputeMergeHash(input);
var hash3 = calc3.ComputeMergeHash(input);
Assert.Equal(hash1, hash2);
Assert.Equal(hash2, hash3);
}
#endregion
#region Normalization Integration
[Fact]
public void ComputeMergeHash_CveNormalization_CaseInsensitive()
{
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
var input2 = new MergeHashInput { Cve = "cve-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
var input3 = new MergeHashInput { Cve = "Cve-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
var hash3 = _calculator.ComputeMergeHash(input3);
Assert.Equal(hash1, hash2);
Assert.Equal(hash2, hash3);
}
[Fact]
public void ComputeMergeHash_PurlNormalization_TypeCaseInsensitive()
{
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/lodash@1.0" };
var input2 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:NPM/lodash@1.0" };
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.Equal(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_CweNormalization_OrderIndependent()
{
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = ["CWE-79", "CWE-89", "CWE-120"]
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = ["CWE-120", "CWE-79", "CWE-89"]
};
var input3 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = ["cwe-89", "CWE-120", "cwe-79"]
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
var hash3 = _calculator.ComputeMergeHash(input3);
Assert.Equal(hash1, hash2);
Assert.Equal(hash2, hash3);
}
[Fact]
public void ComputeMergeHash_VersionRangeNormalization_EquivalentFormats()
{
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
VersionRange = "[1.0.0, 2.0.0)"
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
VersionRange = ">=1.0.0,<2.0.0"
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.Equal(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_PatchLineageNormalization_ShaExtraction()
{
// Both inputs contain the same SHA in different formats
// The normalizer extracts "abc1234567" from both
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
PatchLineage = "commit abc1234567"
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
PatchLineage = "fix abc1234567 applied"
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.Equal(hash1, hash2);
}
#endregion
#region Different Inputs = Different Hashes
[Fact]
public void ComputeMergeHash_DifferentCve_DifferentHash()
{
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
var input2 = new MergeHashInput { Cve = "CVE-2024-5678", AffectsKey = "pkg:npm/test@1.0" };
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_DifferentPackage_DifferentHash()
{
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/lodash@1.0" };
var input2 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/underscore@1.0" };
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_DifferentVersion_DifferentHash()
{
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
VersionRange = "<1.0.0"
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
VersionRange = "<2.0.0"
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_DifferentWeaknesses_DifferentHash()
{
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = ["CWE-79"]
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = ["CWE-89"]
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_DifferentPatchLineage_DifferentHash()
{
// Use full SHA hashes (40 chars) that will be recognized
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
PatchLineage = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
PatchLineage = "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.NotEqual(hash1, hash2);
}
#endregion
#region Edge Cases - Optional Fields
[Fact]
public void ComputeMergeHash_NoVersionRange_ReturnsHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
VersionRange = null
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
}
[Fact]
public void ComputeMergeHash_EmptyWeaknesses_ReturnsHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = []
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
}
[Fact]
public void ComputeMergeHash_NoPatchLineage_ReturnsHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
PatchLineage = null
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
}
[Fact]
public void ComputeMergeHash_MinimalInput_ReturnsHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0"
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
}
#endregion
#region Cross-Source Deduplication Scenarios
[Fact]
public void ComputeMergeHash_SameCveDifferentDistros_SameHash()
{
// Same CVE from Debian and RHEL should have same merge hash
// when identity components match
var debianInput = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:deb/debian/curl@7.68.0",
VersionRange = "<7.68.0-1",
Weaknesses = ["CWE-120"]
};
var rhelInput = new MergeHashInput
{
Cve = "cve-2024-1234", // Different case
AffectsKey = "pkg:deb/debian/curl@7.68.0", // Same package identity
VersionRange = "[,7.68.0-1)", // Equivalent interval
Weaknesses = ["cwe-120"] // Different case
};
var debianHash = _calculator.ComputeMergeHash(debianInput);
var rhelHash = _calculator.ComputeMergeHash(rhelInput);
// These should produce the same hash after normalization
Assert.Equal(debianHash, rhelHash);
}
#endregion
#region Hash Format Validation
[Fact]
public void ComputeMergeHash_ValidHashFormat()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0"
};
var result = _calculator.ComputeMergeHash(input);
// Should be "sha256:" followed by 64 lowercase hex chars
Assert.Matches(@"^sha256:[0-9a-f]{64}$", result);
}
[Fact]
public void ComputeMergeHash_HashIsLowercase()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0"
};
var result = _calculator.ComputeMergeHash(input);
var hashPart = result["sha256:".Length..];
Assert.Equal(hashPart.ToLowerInvariant(), hashPart);
}
#endregion
}

View File

@@ -0,0 +1,457 @@
// -----------------------------------------------------------------------------
// MergeHashDeduplicationIntegrationTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-021
// Description: Integration tests validating same CVE from different connectors
// produces identical merge hash when semantically equivalent
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Merge.Tests.Identity;
/// <summary>
/// Integration tests that verify merge hash deduplication behavior
/// when the same CVE is ingested from multiple source connectors.
/// </summary>
public sealed class MergeHashDeduplicationIntegrationTests
{
private readonly MergeHashCalculator _calculator = new();
[Fact]
public void SameCve_FromDebianAndRhel_WithSamePackage_ProducesSameMergeHash()
{
// Arrange - Debian advisory for curl vulnerability
var debianProvenance = new AdvisoryProvenance(
"debian", "dsa", "DSA-5678-1", DateTimeOffset.Parse("2024-02-15T00:00:00Z"));
var debianAdvisory = new Advisory(
"CVE-2024-1234",
"curl - security update",
"Buffer overflow in curl HTTP library",
"en",
DateTimeOffset.Parse("2024-02-10T00:00:00Z"),
DateTimeOffset.Parse("2024-02-15T12:00:00Z"),
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-1234", "DSA-5678-1" },
references: new[]
{
new AdvisoryReference("https://security-tracker.debian.org/tracker/CVE-2024-1234", "advisory", "debian", "Debian tracker", debianProvenance)
},
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.Deb,
"pkg:deb/debian/curl@7.68.0",
"linux",
new[]
{
new AffectedVersionRange("semver", null, "7.68.0-1+deb10u2", null, "<7.68.0-1+deb10u2", debianProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { debianProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { debianProvenance },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-120", null, null, ImmutableArray.Create(debianProvenance))
});
// Arrange - RHEL advisory for the same curl vulnerability
var rhelProvenance = new AdvisoryProvenance(
"redhat", "rhsa", "RHSA-2024:1234", DateTimeOffset.Parse("2024-02-16T00:00:00Z"));
var rhelAdvisory = new Advisory(
"CVE-2024-1234",
"Moderate: curl security update",
"curl: buffer overflow vulnerability",
"en",
DateTimeOffset.Parse("2024-02-12T00:00:00Z"),
DateTimeOffset.Parse("2024-02-16T08:00:00Z"),
"moderate",
exploitKnown: false,
aliases: new[] { "CVE-2024-1234", "RHSA-2024:1234" },
references: new[]
{
new AdvisoryReference("https://access.redhat.com/errata/RHSA-2024:1234", "advisory", "redhat", "Red Hat errata", rhelProvenance)
},
affectedPackages: new[]
{
// Same logical package, just different distro versioning
new AffectedPackage(
AffectedPackageTypes.Deb,
"pkg:deb/debian/curl@7.68.0",
"linux",
new[]
{
new AffectedVersionRange("semver", null, "7.68.0-1+deb10u2", null, "<7.68.0-1+deb10u2", rhelProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { rhelProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { rhelProvenance },
cwes: new[]
{
// Same CWE but lowercase - should normalize
new AdvisoryWeakness("cwe", "cwe-120", null, null, ImmutableArray.Create(rhelProvenance))
});
// Act
var debianHash = _calculator.ComputeMergeHash(debianAdvisory);
var rhelHash = _calculator.ComputeMergeHash(rhelAdvisory);
// Assert - Same CVE, same package, same version range, same CWE => same hash
Assert.Equal(debianHash, rhelHash);
Assert.StartsWith("sha256:", debianHash);
}
[Fact]
public void SameCve_FromNvdAndGhsa_WithDifferentPackages_ProducesDifferentMergeHash()
{
// Arrange - NVD advisory affecting lodash
var nvdProvenance = new AdvisoryProvenance(
"nvd", "cve", "CVE-2024-5678", DateTimeOffset.Parse("2024-03-01T00:00:00Z"));
var nvdAdvisory = new Advisory(
"CVE-2024-5678",
"Prototype pollution in lodash",
"lodash before 4.17.21 is vulnerable to prototype pollution",
"en",
DateTimeOffset.Parse("2024-02-28T00:00:00Z"),
DateTimeOffset.Parse("2024-03-01T00:00:00Z"),
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-5678" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/lodash@4.17.0",
null,
new[]
{
new AffectedVersionRange("semver", "0", "4.17.21", null, "<4.17.21", nvdProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { nvdProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { nvdProvenance },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-1321", null, null, ImmutableArray.Create(nvdProvenance))
});
// Arrange - Same CVE but for underscore (related but different package)
var ghsaProvenance = new AdvisoryProvenance(
"ghsa", "advisory", "GHSA-xyz-abc-123", DateTimeOffset.Parse("2024-03-02T00:00:00Z"));
var ghsaAdvisory = new Advisory(
"CVE-2024-5678",
"Prototype pollution in underscore",
"underscore before 1.13.6 is vulnerable",
"en",
DateTimeOffset.Parse("2024-03-01T00:00:00Z"),
DateTimeOffset.Parse("2024-03-02T00:00:00Z"),
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-5678", "GHSA-xyz-abc-123" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/underscore@1.13.0",
null,
new[]
{
new AffectedVersionRange("semver", "0", "1.13.6", null, "<1.13.6", ghsaProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { ghsaProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { ghsaProvenance },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-1321", null, null, ImmutableArray.Create(ghsaProvenance))
});
// Act
var nvdHash = _calculator.ComputeMergeHash(nvdAdvisory);
var ghsaHash = _calculator.ComputeMergeHash(ghsaAdvisory);
// Assert - Same CVE but different packages => different hash
Assert.NotEqual(nvdHash, ghsaHash);
}
[Fact]
public void SameCve_WithCaseVariations_ProducesSameMergeHash()
{
// Arrange - Advisory with uppercase identifiers
var upperProvenance = new AdvisoryProvenance(
"nvd", "cve", "CVE-2024-9999", DateTimeOffset.UtcNow);
var upperAdvisory = new Advisory(
"CVE-2024-9999",
"Test vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-9999" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:NPM/@angular/CORE@14.0.0",
null,
new[]
{
new AffectedVersionRange("semver", null, "14.2.0", null, "<14.2.0", upperProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { upperProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { upperProvenance },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-79", null, null, ImmutableArray.Create(upperProvenance))
});
// Arrange - Same advisory with lowercase identifiers
var lowerProvenance = new AdvisoryProvenance(
"osv", "advisory", "cve-2024-9999", DateTimeOffset.UtcNow);
var lowerAdvisory = new Advisory(
"cve-2024-9999",
"Test vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"high",
exploitKnown: false,
aliases: new[] { "cve-2024-9999" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/@angular/core@14.0.0",
null,
new[]
{
new AffectedVersionRange("semver", null, "14.2.0", null, "<14.2.0", lowerProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { lowerProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { lowerProvenance },
cwes: new[]
{
new AdvisoryWeakness("cwe", "cwe-79", null, null, ImmutableArray.Create(lowerProvenance))
});
// Act
var upperHash = _calculator.ComputeMergeHash(upperAdvisory);
var lowerHash = _calculator.ComputeMergeHash(lowerAdvisory);
// Assert - Case normalization produces identical hash
Assert.Equal(upperHash, lowerHash);
}
[Fact]
public void SameCve_WithDifferentCweSet_ProducesDifferentMergeHash()
{
// Arrange - Advisory with one CWE
var prov1 = new AdvisoryProvenance("nvd", "cve", "CVE-2024-1111", DateTimeOffset.UtcNow);
var advisory1 = new Advisory(
"CVE-2024-1111",
"Test vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-1111" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/test@1.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { prov1 })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { prov1 },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-79", null, null, ImmutableArray.Create(prov1))
});
// Arrange - Same CVE but with additional CWEs
var prov2 = new AdvisoryProvenance("ghsa", "advisory", "CVE-2024-1111", DateTimeOffset.UtcNow);
var advisory2 = new Advisory(
"CVE-2024-1111",
"Test vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-1111" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/test@1.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { prov2 })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { prov2 },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-79", null, null, ImmutableArray.Create(prov2)),
new AdvisoryWeakness("cwe", "CWE-89", null, null, ImmutableArray.Create(prov2))
});
// Act
var hash1 = _calculator.ComputeMergeHash(advisory1);
var hash2 = _calculator.ComputeMergeHash(advisory2);
// Assert - Different CWE sets produce different hashes
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void MultiplePackageAdvisory_ComputesHashFromFirstPackage()
{
// Arrange - Advisory affecting multiple packages
var provenance = new AdvisoryProvenance(
"osv", "advisory", "CVE-2024-MULTI", DateTimeOffset.UtcNow);
var multiPackageAdvisory = new Advisory(
"CVE-2024-MULTI",
"Multi-package vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"critical",
exploitKnown: false,
aliases: new[] { "CVE-2024-MULTI" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/first-package@1.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { provenance }),
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/second-package@2.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { provenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
// Arrange - Advisory with only the first package
var singlePackageAdvisory = new Advisory(
"CVE-2024-MULTI",
"Single package vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"critical",
exploitKnown: false,
aliases: new[] { "CVE-2024-MULTI" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/first-package@1.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { provenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
// Act
var multiHash = _calculator.ComputeMergeHash(multiPackageAdvisory);
var singleHash = _calculator.ComputeMergeHash(singlePackageAdvisory);
// Assert - Both use first package, so hashes should match
Assert.Equal(multiHash, singleHash);
}
[Fact]
public void MergeHash_SpecificPackage_ComputesDifferentHashPerPackage()
{
// Arrange
var provenance = new AdvisoryProvenance(
"osv", "advisory", "CVE-2024-PERPACK", DateTimeOffset.UtcNow);
var multiPackageAdvisory = new Advisory(
"CVE-2024-PERPACK",
"Multi-package vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"critical",
exploitKnown: false,
aliases: new[] { "CVE-2024-PERPACK" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/package-a@1.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { provenance }),
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/package-b@2.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { provenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
// Act - Compute hash for each affected package
var hashA = _calculator.ComputeMergeHash(multiPackageAdvisory, multiPackageAdvisory.AffectedPackages[0]);
var hashB = _calculator.ComputeMergeHash(multiPackageAdvisory, multiPackageAdvisory.AffectedPackages[1]);
// Assert - Different packages produce different hashes
Assert.NotEqual(hashA, hashB);
Assert.StartsWith("sha256:", hashA);
Assert.StartsWith("sha256:", hashB);
}
}

View File

@@ -0,0 +1,429 @@
// -----------------------------------------------------------------------------
// MergeHashFuzzingTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-017
// Description: Fuzzing tests for malformed version ranges and unusual PURLs
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class MergeHashFuzzingTests
{
private readonly MergeHashCalculator _calculator = new();
private readonly Random _random = new(42); // Fixed seed for reproducibility
private const int FuzzIterations = 1000;
#region PURL Fuzzing
[Fact]
[Trait("Category", "Fuzzing")]
public void PurlNormalizer_RandomInputs_DoesNotThrow()
{
var normalizer = PurlNormalizer.Instance;
for (var i = 0; i < FuzzIterations; i++)
{
var input = GenerateRandomPurl();
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
}
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("pkg:")]
[InlineData("pkg:npm")]
[InlineData("pkg:npm/")]
[InlineData("pkg:npm//")]
[InlineData("pkg:npm/@/")]
[InlineData("pkg:npm/@scope/")]
[InlineData("pkg:npm/pkg@")]
[InlineData("pkg:npm/pkg@version?")]
[InlineData("pkg:npm/pkg@version?qualifier")]
[InlineData("pkg:npm/pkg@version?key=")]
[InlineData("pkg:npm/pkg@version?=value")]
[InlineData("pkg:npm/pkg#")]
[InlineData("pkg:npm/pkg#/")]
[InlineData("pkg:///")]
[InlineData("pkg:type/ns/name@v?q=v#sp")]
[InlineData("pkg:UNKNOWN/package@1.0.0")]
public void PurlNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = PurlNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("pkg:npm/\0package@1.0.0")]
[InlineData("pkg:npm/package\u0000@1.0.0")]
[InlineData("pkg:npm/package@1.0.0\t")]
[InlineData("pkg:npm/package@1.0.0\n")]
[InlineData("pkg:npm/package@1.0.0\r")]
[InlineData("pkg:npm/päckage@1.0.0")]
[InlineData("pkg:npm/包裹@1.0.0")]
[InlineData("pkg:npm/📦@1.0.0")]
public void PurlNormalizer_SpecialCharacters_DoesNotThrow(string input)
{
var normalizer = PurlNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
#endregion
#region Version Range Fuzzing
[Fact]
[Trait("Category", "Fuzzing")]
public void VersionRangeNormalizer_RandomInputs_DoesNotThrow()
{
var normalizer = VersionRangeNormalizer.Instance;
for (var i = 0; i < FuzzIterations; i++)
{
var input = GenerateRandomVersionRange();
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
}
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("[")]
[InlineData("(")]
[InlineData("]")]
[InlineData(")")]
[InlineData("[,")]
[InlineData(",]")]
[InlineData("[,]")]
[InlineData("(,)")]
[InlineData("[1.0")]
[InlineData("1.0]")]
[InlineData("[1.0,")]
[InlineData(",1.0]")]
[InlineData(">=")]
[InlineData("<=")]
[InlineData(">")]
[InlineData("<")]
[InlineData("=")]
[InlineData("!=")]
[InlineData("~")]
[InlineData("^")]
[InlineData(">=<")]
[InlineData("<=>")]
[InlineData(">=1.0<2.0")]
[InlineData("1.0-2.0")]
[InlineData("1.0..2.0")]
[InlineData("v1.0.0")]
[InlineData("version1")]
public void VersionRangeNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = VersionRangeNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
#endregion
#region CPE Fuzzing
[Fact]
[Trait("Category", "Fuzzing")]
public void CpeNormalizer_RandomInputs_DoesNotThrow()
{
var normalizer = CpeNormalizer.Instance;
for (var i = 0; i < FuzzIterations; i++)
{
var input = GenerateRandomCpe();
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
}
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("cpe:")]
[InlineData("cpe:/")]
[InlineData("cpe://")]
[InlineData("cpe:2.3")]
[InlineData("cpe:2.3:")]
[InlineData("cpe:2.3:a")]
[InlineData("cpe:2.3:a:")]
[InlineData("cpe:2.3:x:vendor:product:1.0:*:*:*:*:*:*:*")]
[InlineData("cpe:1.0:a:vendor:product:1.0")]
[InlineData("cpe:3.0:a:vendor:product:1.0")]
[InlineData("cpe:2.3:a:::::::::")]
[InlineData("cpe:2.3:a:vendor:product:::::::::")]
public void CpeNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = CpeNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
#endregion
#region CVE Fuzzing
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("CVE")]
[InlineData("CVE-")]
[InlineData("CVE-2024")]
[InlineData("CVE-2024-")]
[InlineData("CVE-2024-1")]
[InlineData("CVE-2024-12")]
[InlineData("CVE-2024-123")]
[InlineData("CVE-24-1234")]
[InlineData("CVE-202-1234")]
[InlineData("CVE-20245-1234")]
[InlineData("CVE2024-1234")]
[InlineData("CVE_2024_1234")]
[InlineData("cve:2024:1234")]
public void CveNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = CveNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
#endregion
#region CWE Fuzzing
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("CWE")]
[InlineData("CWE-")]
[InlineData("CWE-abc")]
[InlineData("CWE--79")]
[InlineData("CWE79")]
[InlineData("cwe79")]
[InlineData("79CWE")]
[InlineData("-79")]
public void CweNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = CweNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize([input]));
Assert.Null(exception);
}
[Fact]
[Trait("Category", "Fuzzing")]
public void CweNormalizer_LargeLists_DoesNotThrow()
{
var normalizer = CweNormalizer.Instance;
// Test with large list of CWEs
var largeCweList = Enumerable.Range(1, 1000)
.Select(i => $"CWE-{i}")
.ToList();
var exception = Record.Exception(() => normalizer.Normalize(largeCweList));
Assert.Null(exception);
}
#endregion
#region Patch Lineage Fuzzing
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("abc")]
[InlineData("abc123")]
[InlineData("abc12")]
[InlineData("12345")]
[InlineData("GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG")]
[InlineData("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")]
[InlineData("https://")]
[InlineData("https://github.com")]
[InlineData("https://github.com/")]
[InlineData("https://github.com/owner")]
[InlineData("https://github.com/owner/repo")]
[InlineData("https://github.com/owner/repo/")]
[InlineData("https://github.com/owner/repo/commit")]
[InlineData("https://github.com/owner/repo/commit/")]
[InlineData("PATCH")]
[InlineData("PATCH-")]
[InlineData("PATCH-abc")]
[InlineData("patch12345")]
public void PatchLineageNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = PatchLineageNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
#endregion
#region Full Hash Calculator Fuzzing
[Fact]
[Trait("Category", "Fuzzing")]
public void MergeHashCalculator_RandomInputs_AlwaysProducesValidHash()
{
for (var i = 0; i < FuzzIterations; i++)
{
var input = GenerateRandomMergeHashInput();
var hash = _calculator.ComputeMergeHash(input);
Assert.NotNull(hash);
Assert.StartsWith("sha256:", hash);
Assert.Equal(71, hash.Length); // sha256: + 64 hex chars
Assert.Matches(@"^sha256:[0-9a-f]{64}$", hash);
}
}
[Fact]
[Trait("Category", "Fuzzing")]
public void MergeHashCalculator_RandomInputs_IsDeterministic()
{
var inputs = new List<MergeHashInput>();
for (var i = 0; i < 100; i++)
{
inputs.Add(GenerateRandomMergeHashInput());
}
// First pass
var firstHashes = inputs.Select(i => _calculator.ComputeMergeHash(i)).ToList();
// Second pass
var secondHashes = inputs.Select(i => _calculator.ComputeMergeHash(i)).ToList();
// All should match
for (var i = 0; i < inputs.Count; i++)
{
Assert.Equal(firstHashes[i], secondHashes[i]);
}
}
#endregion
#region Random Input Generators
private string GenerateRandomPurl()
{
var types = new[] { "npm", "maven", "pypi", "nuget", "gem", "golang", "deb", "rpm", "apk", "cargo" };
var type = types[_random.Next(types.Length)];
var hasNamespace = _random.Next(2) == 1;
var hasVersion = _random.Next(2) == 1;
var hasQualifiers = _random.Next(2) == 1;
var hasSubpath = _random.Next(2) == 1;
var sb = new System.Text.StringBuilder();
sb.Append("pkg:");
sb.Append(type);
sb.Append('/');
if (hasNamespace)
{
sb.Append(GenerateRandomString(5));
sb.Append('/');
}
sb.Append(GenerateRandomString(8));
if (hasVersion)
{
sb.Append('@');
sb.Append(GenerateRandomVersion());
}
if (hasQualifiers)
{
sb.Append('?');
sb.Append(GenerateRandomString(3));
sb.Append('=');
sb.Append(GenerateRandomString(5));
}
if (hasSubpath)
{
sb.Append('#');
sb.Append(GenerateRandomString(10));
}
return sb.ToString();
}
private string GenerateRandomVersionRange()
{
var patterns = new Func<string>[]
{
() => $"[{GenerateRandomVersion()}, {GenerateRandomVersion()})",
() => $"({GenerateRandomVersion()}, {GenerateRandomVersion()}]",
() => $">={GenerateRandomVersion()}",
() => $"<{GenerateRandomVersion()}",
() => $"={GenerateRandomVersion()}",
() => $">={GenerateRandomVersion()},<{GenerateRandomVersion()}",
() => $"fixed:{GenerateRandomVersion()}",
() => "*",
() => GenerateRandomVersion(),
() => GenerateRandomString(10)
};
return patterns[_random.Next(patterns.Length)]();
}
private string GenerateRandomCpe()
{
if (_random.Next(2) == 0)
{
// CPE 2.3
var part = new[] { "a", "o", "h" }[_random.Next(3)];
return $"cpe:2.3:{part}:{GenerateRandomString(6)}:{GenerateRandomString(8)}:{GenerateRandomVersion()}:*:*:*:*:*:*:*";
}
else
{
// CPE 2.2
var part = new[] { "a", "o", "h" }[_random.Next(3)];
return $"cpe:/{part}:{GenerateRandomString(6)}:{GenerateRandomString(8)}:{GenerateRandomVersion()}";
}
}
private MergeHashInput GenerateRandomMergeHashInput()
{
return new MergeHashInput
{
Cve = $"CVE-{2020 + _random.Next(5)}-{_random.Next(10000, 99999)}",
AffectsKey = GenerateRandomPurl(),
VersionRange = _random.Next(3) > 0 ? GenerateRandomVersionRange() : null,
Weaknesses = Enumerable.Range(0, _random.Next(0, 5))
.Select(_ => $"CWE-{_random.Next(1, 1000)}")
.ToList(),
PatchLineage = _random.Next(3) > 0 ? GenerateRandomHex(40) : null
};
}
private string GenerateRandomVersion()
{
return $"{_random.Next(0, 20)}.{_random.Next(0, 50)}.{_random.Next(0, 100)}";
}
private string GenerateRandomString(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyz0123456789-_";
return new string(Enumerable.Range(0, length)
.Select(_ => chars[_random.Next(chars.Length)])
.ToArray());
}
private string GenerateRandomHex(int length)
{
const string hexChars = "0123456789abcdef";
return new string(Enumerable.Range(0, length)
.Select(_ => hexChars[_random.Next(hexChars.Length)])
.ToArray());
}
#endregion
}

View File

@@ -0,0 +1,313 @@
// -----------------------------------------------------------------------------
// MergeHashGoldenCorpusTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-016
// Description: Golden corpus tests for merge hash validation
// -----------------------------------------------------------------------------
using System.Text.Json;
using StellaOps.Concelier.Merge.Identity;
namespace StellaOps.Concelier.Merge.Tests.Identity;
/// <summary>
/// Tests that validate merge hash computations against golden corpus files.
/// Each corpus file contains pairs of advisory sources that should produce
/// the same or different merge hashes based on identity normalization.
/// </summary>
public sealed class MergeHashGoldenCorpusTests
{
private readonly MergeHashCalculator _calculator = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
private static string GetCorpusPath(string corpusName)
{
// Try multiple paths for test execution context
var paths = new[]
{
Path.Combine("Fixtures", "Golden", corpusName),
Path.Combine("..", "..", "..", "Fixtures", "Golden", corpusName),
Path.Combine(AppContext.BaseDirectory, "Fixtures", "Golden", corpusName)
};
foreach (var path in paths)
{
if (File.Exists(path))
{
return path;
}
}
throw new FileNotFoundException(string.Format("Corpus file not found: {0}", corpusName));
}
#region Debian-RHEL Corpus Tests
[Fact]
public void DeduplicateDebianRhelCorpus_AllItemsValidated()
{
var corpusPath = GetCorpusPath("dedup-debian-rhel-cve-2024.json");
var corpus = LoadCorpus(corpusPath);
Assert.NotNull(corpus);
Assert.NotEmpty(corpus.Items);
foreach (var item in corpus.Items)
{
ValidateCorpusItem(item);
}
}
[Fact]
public void DeduplicateDebianRhelCorpus_SameMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-debian-rhel-cve-2024.json");
var corpus = LoadCorpus(corpusPath);
var sameHashItems = corpus.Items.Where(i => i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(sameHashItems);
foreach (var item in sameHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count == 1, $"Item {item.Id}: Expected same merge hash but got {hashes.Count} distinct values: [{string.Join(", ", hashes)}]. Rationale: {item.Expected.Rationale}");
}
}
[Fact]
public void DeduplicateDebianRhelCorpus_DifferentMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-debian-rhel-cve-2024.json");
var corpus = LoadCorpus(corpusPath);
var differentHashItems = corpus.Items.Where(i => !i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(differentHashItems);
foreach (var item in differentHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count > 1, $"Item {item.Id}: Expected different merge hashes but got same. Rationale: {item.Expected.Rationale}");
}
}
#endregion
#region Backport Variants Corpus Tests
[Fact]
public void BackportVariantsCorpus_AllItemsValidated()
{
var corpusPath = GetCorpusPath("dedup-backport-variants.json");
var corpus = LoadCorpus(corpusPath);
Assert.NotNull(corpus);
Assert.NotEmpty(corpus.Items);
foreach (var item in corpus.Items)
{
ValidateCorpusItem(item);
}
}
[Fact]
public void BackportVariantsCorpus_SameMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-backport-variants.json");
var corpus = LoadCorpus(corpusPath);
var sameHashItems = corpus.Items.Where(i => i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(sameHashItems);
foreach (var item in sameHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count == 1, $"Item {item.Id}: Expected same merge hash but got {hashes.Count} distinct values: [{string.Join(", ", hashes)}]. Rationale: {item.Expected.Rationale}");
}
}
[Fact]
public void BackportVariantsCorpus_DifferentMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-backport-variants.json");
var corpus = LoadCorpus(corpusPath);
var differentHashItems = corpus.Items.Where(i => !i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(differentHashItems);
foreach (var item in differentHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count > 1, $"Item {item.Id}: Expected different merge hashes but got same. Rationale: {item.Expected.Rationale}");
}
}
#endregion
#region Alias Collision Corpus Tests
[Fact]
public void AliasCollisionCorpus_AllItemsValidated()
{
var corpusPath = GetCorpusPath("dedup-alias-collision.json");
var corpus = LoadCorpus(corpusPath);
Assert.NotNull(corpus);
Assert.NotEmpty(corpus.Items);
foreach (var item in corpus.Items)
{
ValidateCorpusItem(item);
}
}
[Fact]
public void AliasCollisionCorpus_SameMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-alias-collision.json");
var corpus = LoadCorpus(corpusPath);
var sameHashItems = corpus.Items.Where(i => i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(sameHashItems);
foreach (var item in sameHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count == 1, $"Item {item.Id}: Expected same merge hash but got {hashes.Count} distinct values: [{string.Join(", ", hashes)}]. Rationale: {item.Expected.Rationale}");
}
}
[Fact]
public void AliasCollisionCorpus_DifferentMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-alias-collision.json");
var corpus = LoadCorpus(corpusPath);
var differentHashItems = corpus.Items.Where(i => !i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(differentHashItems);
foreach (var item in differentHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count > 1, $"Item {item.Id}: Expected different merge hashes but got same. Rationale: {item.Expected.Rationale}");
}
}
#endregion
#region Helper Methods
private GoldenCorpus LoadCorpus(string path)
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<GoldenCorpus>(json, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize corpus: {path}");
}
private void ValidateCorpusItem(CorpusItem item)
{
Assert.False(string.IsNullOrEmpty(item.Id), "Corpus item must have an ID");
Assert.NotEmpty(item.Sources);
Assert.NotNull(item.Expected);
// Validate each source produces a valid hash
foreach (var source in item.Sources)
{
var hash = ComputeHashFromSource(source);
Assert.StartsWith("sha256:", hash);
Assert.Equal(71, hash.Length); // sha256: + 64 hex chars
}
}
private string ComputeHashFromSource(CorpusSource source)
{
var input = new MergeHashInput
{
Cve = source.Cve,
AffectsKey = source.AffectsKey,
VersionRange = source.VersionRange,
Weaknesses = source.Weaknesses ?? [],
PatchLineage = source.PatchLineage
};
return _calculator.ComputeMergeHash(input);
}
#endregion
#region Corpus Models
private sealed record GoldenCorpus
{
public string Corpus { get; init; } = string.Empty;
public string Version { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public IReadOnlyList<CorpusItem> Items { get; init; } = [];
}
private sealed record CorpusItem
{
public string Id { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public IReadOnlyList<CorpusSource> Sources { get; init; } = [];
public CorpusExpected Expected { get; init; } = new();
}
private sealed record CorpusSource
{
public string Source { get; init; } = string.Empty;
public string AdvisoryId { get; init; } = string.Empty;
public string Cve { get; init; } = string.Empty;
public string AffectsKey { get; init; } = string.Empty;
public string? VersionRange { get; init; }
public IReadOnlyList<string>? Weaknesses { get; init; }
public string? PatchLineage { get; init; }
}
private sealed record CorpusExpected
{
public bool SameMergeHash { get; init; }
public string Rationale { get; init; } = string.Empty;
}
#endregion
}

View File

@@ -0,0 +1,281 @@
// -----------------------------------------------------------------------------
// PatchLineageNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for PatchLineageNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class PatchLineageNormalizerTests
{
private readonly PatchLineageNormalizer _normalizer = PatchLineageNormalizer.Instance;
#region Full SHA Extraction
[Fact]
public void Normalize_FullSha_ReturnsLowercase()
{
var sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
var result = _normalizer.Normalize(sha);
Assert.Equal(sha.ToLowerInvariant(), result);
}
[Fact]
public void Normalize_FullShaUppercase_ReturnsLowercase()
{
var sha = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2";
var result = _normalizer.Normalize(sha);
Assert.Equal(sha.ToLowerInvariant(), result);
}
[Fact]
public void Normalize_FullShaMixedCase_ReturnsLowercase()
{
var sha = "A1b2C3d4E5f6A1b2C3d4E5f6A1b2C3d4E5f6A1b2";
var result = _normalizer.Normalize(sha);
Assert.Equal(sha.ToLowerInvariant(), result);
}
#endregion
#region Abbreviated SHA Extraction
[Fact]
public void Normalize_AbbrevShaWithContext_ExtractsSha()
{
var result = _normalizer.Normalize("fix: abc1234 addresses the issue");
Assert.Equal("abc1234", result);
}
[Fact]
public void Normalize_AbbrevShaWithCommitKeyword_ExtractsSha()
{
var result = _normalizer.Normalize("commit abc1234567");
Assert.Equal("abc1234567", result);
}
[Fact]
public void Normalize_AbbrevShaSeven_ExtractsSha()
{
var result = _normalizer.Normalize("patch: fix in abc1234");
Assert.Equal("abc1234", result);
}
[Fact]
public void Normalize_AbbrevShaTwelve_ExtractsSha()
{
var result = _normalizer.Normalize("backport of abc123456789");
Assert.Equal("abc123456789", result);
}
#endregion
#region GitHub/GitLab URL Extraction
[Fact]
public void Normalize_GitHubCommitUrl_ExtractsSha()
{
var url = "https://github.com/owner/repo/commit/abc123def456abc123def456abc123def456abc1";
var result = _normalizer.Normalize(url);
Assert.Equal("abc123def456abc123def456abc123def456abc1", result);
}
[Fact]
public void Normalize_GitLabCommitUrl_ExtractsSha()
{
var url = "https://gitlab.com/owner/repo/commit/abc123def456";
var result = _normalizer.Normalize(url);
Assert.Equal("abc123def456", result);
}
[Fact]
public void Normalize_GitHubUrlAbbrevSha_ExtractsSha()
{
var url = "https://github.com/apache/log4j/commit/abc1234";
var result = _normalizer.Normalize(url);
Assert.Equal("abc1234", result);
}
#endregion
#region Patch ID Extraction
[Fact]
public void Normalize_PatchIdUppercase_ReturnsUppercase()
{
var result = _normalizer.Normalize("PATCH-12345");
Assert.Equal("PATCH-12345", result);
}
[Fact]
public void Normalize_PatchIdLowercase_ReturnsUppercase()
{
var result = _normalizer.Normalize("patch-12345");
Assert.Equal("PATCH-12345", result);
}
[Fact]
public void Normalize_PatchIdInText_ExtractsPatchId()
{
var result = _normalizer.Normalize("Applied PATCH-67890 to fix issue");
Assert.Equal("PATCH-67890", result);
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsNull()
{
var result = _normalizer.Normalize(null);
Assert.Null(result);
}
[Fact]
public void Normalize_EmptyString_ReturnsNull()
{
var result = _normalizer.Normalize(string.Empty);
Assert.Null(result);
}
[Fact]
public void Normalize_WhitespaceOnly_ReturnsNull()
{
var result = _normalizer.Normalize(" ");
Assert.Null(result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var sha = " a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ";
var result = _normalizer.Normalize(sha);
Assert.Equal("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", result);
}
#endregion
#region Edge Cases - Unrecognized Patterns
[Fact]
public void Normalize_NoRecognizablePattern_ReturnsNull()
{
var result = _normalizer.Normalize("some random text without sha or patch id");
Assert.Null(result);
}
[Fact]
public void Normalize_ShortHex_ReturnsNull()
{
// Less than 7 hex chars shouldn't match abbreviated SHA
var result = _normalizer.Normalize("abc12 is too short");
Assert.Null(result);
}
[Fact]
public void Normalize_NonHexChars_ReturnsNull()
{
var result = _normalizer.Normalize("ghijkl is not hex");
Assert.Null(result);
}
[Fact]
public void Normalize_PatchIdNoNumber_ReturnsNull()
{
var result = _normalizer.Normalize("PATCH-abc is invalid");
Assert.Null(result);
}
#endregion
#region Priority Testing
[Fact]
public void Normalize_UrlOverPlainSha_PrefersUrl()
{
// When URL contains SHA, should extract from URL pattern
var input = "https://github.com/owner/repo/commit/abcdef1234567890abcdef1234567890abcdef12";
var result = _normalizer.Normalize(input);
Assert.Equal("abcdef1234567890abcdef1234567890abcdef12", result);
}
[Fact]
public void Normalize_FullShaOverAbbrev_PrefersFullSha()
{
// When both full and abbreviated SHA present, should prefer full
var input = "abc1234 mentioned in commit a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
var result = _normalizer.Normalize(input);
Assert.Equal("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", result);
}
#endregion
#region Determinism
[Theory]
[InlineData("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")]
[InlineData("https://github.com/owner/repo/commit/abc1234")]
[InlineData("PATCH-12345")]
[InlineData("commit abc1234567")]
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
{
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
const string input = "https://github.com/apache/log4j/commit/abc123def456abc123def456abc123def456abc1";
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
#endregion
#region Real-World Lineage Formats
[Theory]
[InlineData("https://github.com/apache/logging-log4j2/commit/7fe72d6", "7fe72d6")]
[InlineData("backport of abc123def456", "abc123def456")]
public void Normalize_RealWorldLineages_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
[Fact]
public void Normalize_PatchId_ExtractsAndUppercases()
{
// PATCH-NNNNN format is recognized and uppercased
var result = _normalizer.Normalize("Applied patch-12345 to fix issue");
Assert.Equal("PATCH-12345", result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = PatchLineageNormalizer.Instance;
var instance2 = PatchLineageNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -0,0 +1,295 @@
// -----------------------------------------------------------------------------
// PurlNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for PurlNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class PurlNormalizerTests
{
private readonly PurlNormalizer _normalizer = PurlNormalizer.Instance;
#region Basic Normalization
[Fact]
public void Normalize_SimplePurl_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21");
Assert.Equal("pkg:npm/lodash@4.17.21", result);
}
[Fact]
public void Normalize_UppercaseType_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:NPM/lodash@4.17.21");
Assert.Equal("pkg:npm/lodash@4.17.21", result);
}
[Fact]
public void Normalize_WithNamespace_ReturnsNormalized()
{
var result = _normalizer.Normalize("pkg:maven/org.apache.commons/commons-lang3@3.12.0");
Assert.Equal("pkg:maven/org.apache.commons/commons-lang3@3.12.0", result);
}
#endregion
#region Scoped NPM Packages
[Fact]
public void Normalize_NpmScopedPackage_ReturnsLowercaseScope()
{
var result = _normalizer.Normalize("pkg:npm/@Angular/core@14.0.0");
Assert.StartsWith("pkg:npm/", result);
Assert.Contains("angular", result.ToLowerInvariant());
Assert.Contains("core", result.ToLowerInvariant());
}
[Fact]
public void Normalize_NpmScopedPackageEncoded_DecodesAndNormalizes()
{
var result = _normalizer.Normalize("pkg:npm/%40angular/core@14.0.0");
Assert.Contains("angular", result.ToLowerInvariant());
}
#endregion
#region Qualifier Stripping
[Fact]
public void Normalize_WithArchQualifier_StripsArch()
{
var result = _normalizer.Normalize("pkg:deb/debian/curl@7.68.0-1?arch=amd64");
Assert.DoesNotContain("arch=", result);
}
[Fact]
public void Normalize_WithTypeQualifier_StripsType()
{
var result = _normalizer.Normalize("pkg:maven/org.apache/commons@1.0?type=jar");
Assert.DoesNotContain("type=", result);
}
[Fact]
public void Normalize_WithChecksumQualifier_StripsChecksum()
{
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21?checksum=sha256:abc123");
Assert.DoesNotContain("checksum=", result);
}
[Fact]
public void Normalize_WithPlatformQualifier_StripsPlatform()
{
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21?platform=linux");
Assert.DoesNotContain("platform=", result);
}
[Fact]
public void Normalize_WithMultipleQualifiers_StripsNonIdentity()
{
var result = _normalizer.Normalize("pkg:deb/debian/curl@7.68.0-1?arch=amd64&distro=bullseye");
Assert.DoesNotContain("arch=", result);
Assert.Contains("distro=bullseye", result);
}
[Fact]
public void Normalize_WithIdentityQualifiers_KeepsIdentity()
{
var result = _normalizer.Normalize("pkg:deb/debian/curl@7.68.0-1?distro=bullseye");
Assert.Contains("distro=bullseye", result);
}
#endregion
#region Qualifier Sorting
[Fact]
public void Normalize_UnsortedQualifiers_ReturnsSorted()
{
var result = _normalizer.Normalize("pkg:npm/pkg@1.0?z=1&a=2&m=3");
// Qualifiers should be sorted alphabetically
var queryStart = result.IndexOf('?');
if (queryStart > 0)
{
var qualifiers = result[(queryStart + 1)..].Split('&');
var sorted = qualifiers.OrderBy(q => q).ToArray();
Assert.Equal(sorted, qualifiers);
}
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsEmpty()
{
var result = _normalizer.Normalize(null!);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_EmptyString_ReturnsEmpty()
{
var result = _normalizer.Normalize(string.Empty);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WhitespaceOnly_ReturnsEmpty()
{
var result = _normalizer.Normalize(" ");
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize(" pkg:npm/lodash@4.17.21 ");
Assert.Equal("pkg:npm/lodash@4.17.21", result);
}
#endregion
#region Edge Cases - Non-PURL Input
[Fact]
public void Normalize_CpeInput_ReturnsAsIs()
{
var input = "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*";
var result = _normalizer.Normalize(input);
Assert.Equal(input, result);
}
[Fact]
public void Normalize_PlainPackageName_ReturnsLowercase()
{
var result = _normalizer.Normalize("SomePackage");
Assert.Equal("somepackage", result);
}
[Fact]
public void Normalize_InvalidPurlFormat_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:invalid");
Assert.Equal("pkg:invalid", result);
}
#endregion
#region Edge Cases - Special Characters
[Fact]
public void Normalize_WithSubpath_StripsSubpath()
{
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21#src/index.js");
Assert.DoesNotContain("#", result);
}
[Fact]
public void Normalize_UrlEncodedName_DecodesAndNormalizes()
{
var result = _normalizer.Normalize("pkg:npm/%40scope%2Fpkg@1.0.0");
// Should decode and normalize
Assert.StartsWith("pkg:npm/", result);
}
#endregion
#region Ecosystem-Specific Behavior
[Fact]
public void Normalize_GolangPackage_PreservesNameCase()
{
var result = _normalizer.Normalize("pkg:golang/github.com/User/Repo@v1.0.0");
// Go namespace is lowercased but name retains original chars
// The current normalizer lowercases everything except golang name
Assert.StartsWith("pkg:golang/", result);
Assert.Contains("repo", result.ToLowerInvariant());
}
[Fact]
public void Normalize_NugetPackage_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:nuget/Newtonsoft.Json@13.0.1");
Assert.Contains("newtonsoft.json", result.ToLowerInvariant());
}
[Fact]
public void Normalize_DebianPackage_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:deb/debian/CURL@7.68.0-1");
Assert.Contains("curl", result.ToLowerInvariant());
}
[Fact]
public void Normalize_RpmPackage_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:rpm/redhat/OPENSSL@1.1.1");
Assert.Contains("openssl", result.ToLowerInvariant());
}
#endregion
#region Determinism
[Theory]
[InlineData("pkg:npm/lodash@4.17.21")]
[InlineData("pkg:NPM/LODASH@4.17.21")]
[InlineData("pkg:npm/@angular/core@14.0.0")]
[InlineData("pkg:maven/org.apache/commons@1.0")]
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
{
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
const string input = "pkg:npm/@SCOPE/Package@1.0.0?arch=amd64&distro=bullseye";
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
#endregion
#region Real-World PURL Formats
[Theory]
[InlineData("pkg:npm/lodash@4.17.21", "pkg:npm/lodash@4.17.21")]
[InlineData("pkg:pypi/requests@2.28.0", "pkg:pypi/requests@2.28.0")]
[InlineData("pkg:gem/rails@7.0.0", "pkg:gem/rails@7.0.0")]
public void Normalize_RealWorldPurls_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = PurlNormalizer.Instance;
var instance2 = PurlNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -0,0 +1,286 @@
// -----------------------------------------------------------------------------
// VersionRangeNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for VersionRangeNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class VersionRangeNormalizerTests
{
private readonly VersionRangeNormalizer _normalizer = VersionRangeNormalizer.Instance;
#region Interval Notation
[Fact]
public void Normalize_ClosedOpen_ConvertsToComparison()
{
var result = _normalizer.Normalize("[1.0.0, 2.0.0)");
Assert.Equal(">=1.0.0,<2.0.0", result);
}
[Fact]
public void Normalize_OpenClosed_ConvertsToComparison()
{
var result = _normalizer.Normalize("(1.0.0, 2.0.0]");
Assert.Equal(">1.0.0,<=2.0.0", result);
}
[Fact]
public void Normalize_ClosedClosed_ConvertsToComparison()
{
var result = _normalizer.Normalize("[1.0.0, 2.0.0]");
Assert.Equal(">=1.0.0,<=2.0.0", result);
}
[Fact]
public void Normalize_OpenOpen_ConvertsToComparison()
{
var result = _normalizer.Normalize("(1.0.0, 2.0.0)");
Assert.Equal(">1.0.0,<2.0.0", result);
}
[Fact]
public void Normalize_IntervalWithSpaces_ConvertsToComparison()
{
var result = _normalizer.Normalize("[ 1.0.0 , 2.0.0 )");
Assert.Equal(">=1.0.0,<2.0.0", result);
}
[Fact]
public void Normalize_LeftOpenInterval_ConvertsToUpperBound()
{
var result = _normalizer.Normalize("(, 2.0.0)");
Assert.Equal("<2.0.0", result);
}
[Fact]
public void Normalize_RightOpenInterval_ConvertsToLowerBound()
{
var result = _normalizer.Normalize("[1.0.0,)");
Assert.Equal(">=1.0.0", result);
}
#endregion
#region Comparison Operators
[Theory]
[InlineData(">= 1.0.0", ">=1.0.0")]
[InlineData(">=1.0.0", ">=1.0.0")]
[InlineData("> 1.0.0", ">1.0.0")]
[InlineData("<= 2.0.0", "<=2.0.0")]
[InlineData("< 2.0.0", "<2.0.0")]
[InlineData("= 1.0.0", "=1.0.0")]
[InlineData("!= 1.0.0", "!=1.0.0")]
public void Normalize_ComparisonOperators_NormalizesWhitespace(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("~= 1.0.0", "~=1.0.0")]
[InlineData("~> 1.0.0", "~=1.0.0")]
[InlineData("^ 1.0.0", "^1.0.0")]
public void Normalize_SemverOperators_Normalizes(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Multi-Constraint
[Fact]
public void Normalize_MultipleConstraints_SortsAndJoins()
{
var result = _normalizer.Normalize("<2.0.0,>=1.0.0");
// Should be sorted alphabetically
Assert.Contains("<2.0.0", result);
Assert.Contains(">=1.0.0", result);
}
[Fact]
public void Normalize_DuplicateConstraints_Deduplicates()
{
var result = _normalizer.Normalize(">= 1.0.0, >=1.0.0");
// Should deduplicate
var count = result.Split(',').Count(s => s == ">=1.0.0");
Assert.Equal(1, count);
}
#endregion
#region Fixed Version
[Fact]
public void Normalize_FixedNotation_ConvertsToGreaterOrEqual()
{
var result = _normalizer.Normalize("fixed: 1.5.1");
Assert.Equal(">=1.5.1", result);
}
[Fact]
public void Normalize_FixedNotationNoSpace_ConvertsToGreaterOrEqual()
{
var result = _normalizer.Normalize("fixed:1.5.1");
Assert.Equal(">=1.5.1", result);
}
#endregion
#region Wildcard
[Theory]
[InlineData("*", "*")]
[InlineData("all", "*")]
[InlineData("any", "*")]
public void Normalize_WildcardMarkers_ReturnsAsterisk(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Plain Version
[Fact]
public void Normalize_PlainVersion_ConvertsToExact()
{
var result = _normalizer.Normalize("1.0.0");
Assert.Equal("=1.0.0", result);
}
[Fact]
public void Normalize_PlainVersionWithPatch_ConvertsToExact()
{
var result = _normalizer.Normalize("1.2.3");
Assert.Equal("=1.2.3", result);
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsEmpty()
{
var result = _normalizer.Normalize(null);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_EmptyString_ReturnsEmpty()
{
var result = _normalizer.Normalize(string.Empty);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WhitespaceOnly_ReturnsEmpty()
{
var result = _normalizer.Normalize(" ");
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize(" >= 1.0.0 ");
Assert.Equal(">=1.0.0", result);
}
#endregion
#region Edge Cases - Malformed Input
[Fact]
public void Normalize_UnrecognizedFormat_ReturnsAsIs()
{
var result = _normalizer.Normalize("some-weird-format");
Assert.Equal("some-weird-format", result);
}
[Fact]
public void Normalize_MalformedInterval_ReturnsAsIs()
{
var result = _normalizer.Normalize("[1.0.0");
// Should return as-is if can't parse
Assert.Contains("1.0.0", result);
}
#endregion
#region Determinism
[Theory]
[InlineData("[1.0.0, 2.0.0)")]
[InlineData(">= 1.0.0")]
[InlineData("fixed: 1.5.1")]
[InlineData("*")]
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
{
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
const string input = "[1.0.0, 2.0.0)";
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
[Fact]
public void Normalize_EquivalentFormats_ProduceSameOutput()
{
// Different ways to express the same range
var interval = _normalizer.Normalize("[1.0.0, 2.0.0)");
var comparison = _normalizer.Normalize(">=1.0.0,<2.0.0");
Assert.Equal(interval, comparison);
}
#endregion
#region Real-World Version Ranges
[Theory]
[InlineData("<7.68.0-1+deb10u2", "<7.68.0-1+deb10u2")]
[InlineData(">=0,<1.2.3", ">=0,<1.2.3")]
public void Normalize_RealWorldRanges_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = VersionRangeNormalizer.Instance;
var instance2 = VersionRangeNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -302,9 +302,9 @@ public sealed class MergePropertyTests
// Assert - merge provenance trace should contain all original sources
var mergeProvenance = result.Provenance.FirstOrDefault(p => p.Source == "merge");
mergeProvenance.Should().NotBeNull();
mergeProvenance!.Value.Should().Contain("redhat", StringComparison.OrdinalIgnoreCase);
mergeProvenance.Value.Should().Contain("ghsa", StringComparison.OrdinalIgnoreCase);
mergeProvenance.Value.Should().Contain("osv", StringComparison.OrdinalIgnoreCase);
mergeProvenance!.Value.ToLowerInvariant().Should().Contain("redhat");
mergeProvenance.Value.ToLowerInvariant().Should().Contain("ghsa");
mergeProvenance.Value.ToLowerInvariant().Should().Contain("osv");
}
[Fact]

View File

@@ -4,6 +4,8 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />