Files
git.stella-ops.org/src/StellaOps.Concelier.Core.Tests/CanonicalMergerTests.cs
2025-10-18 20:04:15 +03:00

372 lines
16 KiB
C#

using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Core.Tests;
public sealed class CanonicalMergerTests
{
private static readonly DateTimeOffset BaseTimestamp = new(2025, 10, 10, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void Merge_PrefersGhsaTitleAndSummaryByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-aaaa-bbbb-cccc",
title: "GHSA Title",
summary: "GHSA Summary",
modified: BaseTimestamp.AddHours(1));
var nvd = CreateAdvisory(
source: "nvd",
advisoryKey: "CVE-2025-0001",
title: "NVD Title",
summary: "NVD Summary",
modified: BaseTimestamp);
var result = merger.Merge("CVE-2025-0001", ghsa, nvd, null);
Assert.Equal("GHSA Title", result.Advisory.Title);
Assert.Equal("GHSA Summary", result.Advisory.Summary);
Assert.Contains(result.Decisions, decision =>
decision.Field == "summary" &&
string.Equals(decision.SelectedSource, "ghsa", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Advisory.Provenance, provenance =>
string.Equals(provenance.Source, "ghsa", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Value, "summary", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_FreshnessOverrideUsesOsvSummaryWhenNewerByThreshold()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(10)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-xxxx-yyyy-zzzz",
title: "Container Escape Vulnerability",
summary: "Initial GHSA summary.",
modified: BaseTimestamp);
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "GHSA-xxxx-yyyy-zzzz",
title: "Container Escape Vulnerability",
summary: "OSV summary with additional mitigation steps.",
modified: BaseTimestamp.AddHours(72));
var result = merger.Merge("CVE-2025-9000", ghsa, null, osv);
Assert.Equal("OSV summary with additional mitigation steps.", result.Advisory.Summary);
Assert.Contains(result.Decisions, decision =>
decision.Field == "summary" &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Advisory.Provenance, provenance =>
string.Equals(provenance.Source, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Value, "summary", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_AffectedPackagesPreferOsvPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(4)));
var ghsaPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.2.3",
lastAffectedVersion: null,
rangeExpression: "<1.2.3",
provenance: CreateProvenance("ghsa", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: new[]
{
new AffectedPackageStatus(
"affected",
CreateProvenance("ghsa", ProvenanceFieldMasks.PackageStatuses))
},
provenance: new[] { CreateProvenance("ghsa", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var nvdPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.2.4",
lastAffectedVersion: null,
rangeExpression: "<1.2.4",
provenance: CreateProvenance("nvd", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("nvd", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var osvPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: "1.0.0",
fixedVersion: "1.2.5",
lastAffectedVersion: null,
rangeExpression: ">=1.0.0,<1.2.5",
provenance: CreateProvenance("osv", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("osv", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var ghsa = CreateAdvisory("ghsa", "GHSA-1234", "GHSA Title", modified: BaseTimestamp.AddHours(1), packages: new[] { ghsaPackage });
var nvd = CreateAdvisory("nvd", "CVE-2025-1111", "NVD Title", modified: BaseTimestamp.AddHours(2), packages: new[] { nvdPackage });
var osv = CreateAdvisory("osv", "OSV-2025-xyz", "OSV Title", modified: BaseTimestamp.AddHours(3), packages: new[] { osvPackage });
var result = merger.Merge("CVE-2025-1111", ghsa, nvd, osv);
var package = Assert.Single(result.Advisory.AffectedPackages);
Assert.Equal("pkg:npm/example@1", package.Identifier);
Assert.Contains(package.Provenance, provenance =>
string.Equals(provenance.Source, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Decisions, decision =>
decision.Field.StartsWith("affectedPackages", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_CvssMetricsOrderedByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(5)));
var nvdMetric = new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 9.8, "critical", CreateProvenance("nvd", ProvenanceFieldMasks.CvssMetrics));
var ghsaMetric = new CvssMetric("3.0", "CVSS:3.0/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H", 7.5, "high", CreateProvenance("ghsa", ProvenanceFieldMasks.CvssMetrics));
var nvd = CreateAdvisory("nvd", "CVE-2025-2000", "NVD Title", severity: null, modified: BaseTimestamp, metrics: new[] { nvdMetric });
var ghsa = CreateAdvisory("ghsa", "GHSA-9999", "GHSA Title", severity: null, modified: BaseTimestamp.AddHours(1), metrics: new[] { ghsaMetric });
var result = merger.Merge("CVE-2025-2000", ghsa, nvd, null);
Assert.Equal(2, result.Advisory.CvssMetrics.Length);
Assert.Equal("nvd", result.Decisions.Single(decision => decision.Field == "cvssMetrics").SelectedSource);
Assert.Equal("critical", result.Advisory.Severity);
Assert.Contains(result.Advisory.CvssMetrics, metric => metric.Vector == "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H");
Assert.Contains(result.Advisory.CvssMetrics, metric => metric.Vector == "CVSS:3.0/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H");
Assert.Equal("3.1|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", result.Advisory.CanonicalMetricId);
}
[Fact]
public void Merge_ReferencesNormalizedAndFreshnessOverrides()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(80)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-ref",
title: "GHSA Title",
references: new[]
{
new AdvisoryReference(
"http://Example.COM/path/resource?b=2&a=1#section",
kind: "advisory",
sourceTag: null,
summary: null,
CreateProvenance("ghsa", ProvenanceFieldMasks.References))
},
modified: BaseTimestamp);
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "OSV-ref",
title: "OSV Title",
references: new[]
{
new AdvisoryReference(
"https://example.com/path/resource?a=1&b=2",
kind: "advisory",
sourceTag: null,
summary: null,
CreateProvenance("osv", ProvenanceFieldMasks.References))
},
modified: BaseTimestamp.AddHours(80));
var result = merger.Merge("CVE-REF-2025-01", ghsa, null, osv);
var reference = Assert.Single(result.Advisory.References);
Assert.Equal("https://example.com/path/resource?a=1&b=2", reference.Url);
var unionDecision = Assert.Single(result.Decisions.Where(decision => decision.Field == "references"));
Assert.Null(unionDecision.SelectedSource);
Assert.Equal("union", unionDecision.DecisionReason);
var itemDecision = Assert.Single(result.Decisions.Where(decision => decision.Field.StartsWith("references[", StringComparison.OrdinalIgnoreCase)));
Assert.Equal("osv", itemDecision.SelectedSource);
Assert.Equal("freshness_override", itemDecision.DecisionReason);
Assert.Contains("https://example.com/path/resource?a=1&b=2", itemDecision.Field, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Merge_DescriptionFreshnessOverride()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(12)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-desc",
title: "GHSA Title",
summary: "Summary",
description: "Initial GHSA description",
modified: BaseTimestamp.AddHours(1));
var nvd = CreateAdvisory(
source: "nvd",
advisoryKey: "CVE-2025-5555",
title: "NVD Title",
summary: "Summary",
description: "NVD baseline description",
modified: BaseTimestamp.AddHours(2));
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "OSV-2025-desc",
title: "OSV Title",
summary: "Summary",
description: "OSV fresher description",
modified: BaseTimestamp.AddHours(72));
var result = merger.Merge("CVE-2025-5555", ghsa, nvd, osv);
Assert.Equal("OSV fresher description", result.Advisory.Description);
Assert.Contains(result.Decisions, decision =>
decision.Field == "description" &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_CwesPreferNvdPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
var ghsaWeakness = CreateWeakness("ghsa", "CWE-79", "cross-site scripting", BaseTimestamp.AddHours(1));
var nvdWeakness = CreateWeakness("nvd", "CWE-79", "Cross-Site Scripting", BaseTimestamp.AddHours(2));
var osvWeakness = CreateWeakness("osv", "CWE-79", "XSS", BaseTimestamp.AddHours(3));
var ghsa = CreateAdvisory("ghsa", "GHSA-weakness", "GHSA Title", weaknesses: new[] { ghsaWeakness }, modified: BaseTimestamp.AddHours(1));
var nvd = CreateAdvisory("nvd", "CVE-2025-7777", "NVD Title", weaknesses: new[] { nvdWeakness }, modified: BaseTimestamp.AddHours(2));
var osv = CreateAdvisory("osv", "OSV-weakness", "OSV Title", weaknesses: new[] { osvWeakness }, modified: BaseTimestamp.AddHours(3));
var result = merger.Merge("CVE-2025-7777", ghsa, nvd, osv);
var weakness = Assert.Single(result.Advisory.Cwes);
Assert.Equal("CWE-79", weakness.Identifier);
Assert.Equal("Cross-Site Scripting", weakness.Name);
Assert.Contains(result.Decisions, decision =>
decision.Field == "cwes[cwe|CWE-79]" &&
string.Equals(decision.SelectedSource, "nvd", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
private static Advisory CreateAdvisory(
string source,
string advisoryKey,
string title,
string? summary = null,
string? description = null,
DateTimeOffset? modified = null,
string? severity = null,
IEnumerable<AffectedPackage>? packages = null,
IEnumerable<CvssMetric>? metrics = null,
IEnumerable<AdvisoryReference>? references = null,
IEnumerable<AdvisoryWeakness>? weaknesses = null,
string? canonicalMetricId = null)
{
var provenance = new AdvisoryProvenance(
source,
kind: "map",
value: advisoryKey,
recordedAt: modified ?? BaseTimestamp,
fieldMask: new[] { ProvenanceFieldMasks.Advisory });
return new Advisory(
advisoryKey,
title,
summary,
language: "en",
published: modified,
modified: modified,
severity: severity,
exploitKnown: false,
aliases: new[] { advisoryKey },
credits: Array.Empty<AdvisoryCredit>(),
references: references ?? Array.Empty<AdvisoryReference>(),
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
cvssMetrics: metrics ?? Array.Empty<CvssMetric>(),
provenance: new[] { provenance },
description: description,
cwes: weaknesses ?? Array.Empty<AdvisoryWeakness>(),
canonicalMetricId: canonicalMetricId);
}
private static AdvisoryProvenance CreateProvenance(string source, string fieldMask)
=> new(
source,
kind: "map",
value: source,
recordedAt: BaseTimestamp,
fieldMask: new[] { fieldMask });
private static AdvisoryWeakness CreateWeakness(string source, string identifier, string? name, DateTimeOffset recordedAt)
{
var provenance = new AdvisoryProvenance(
source,
kind: "map",
value: identifier,
recordedAt: recordedAt,
fieldMask: new[] { ProvenanceFieldMasks.Weaknesses });
return new AdvisoryWeakness("cwe", identifier, name, uri: null, provenance: new[] { provenance });
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}