372 lines
16 KiB
C#
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;
|
|
}
|
|
}
|