Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Concelier.Merge.Options;
|
||||
using StellaOps.Concelier.Merge.Services;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
public sealed class AdvisoryPrecedenceMergerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Merge_PrefersVendorPrecedenceOverNvd()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
|
||||
|
||||
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
|
||||
var expectedMergeTimestamp = timeProvider.GetUtcNow();
|
||||
|
||||
var merged = merger.Merge(new[] { nvd, redHat }).Advisory;
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("Red Hat Security Advisory", merged.Title);
|
||||
Assert.Equal("Vendor-confirmed impact on RHEL 9.", merged.Summary);
|
||||
Assert.Equal("high", merged.Severity);
|
||||
Assert.Equal(redHat.Published, merged.Published);
|
||||
Assert.Equal(redHat.Modified, merged.Modified);
|
||||
Assert.Contains("RHSA-2025:0001", merged.Aliases);
|
||||
Assert.Contains("CVE-2025-1000", merged.Aliases);
|
||||
|
||||
var package = Assert.Single(merged.AffectedPackages);
|
||||
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", package.Identifier);
|
||||
Assert.Empty(package.VersionRanges); // NVD range suppressed by vendor precedence
|
||||
Assert.Contains(package.Statuses, status => status.Status == "known_affected");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
||||
|
||||
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "redhat");
|
||||
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "nvd");
|
||||
|
||||
var mergeProvenance = merged.Provenance.Single(p => p.Source == "merge");
|
||||
Assert.Equal("precedence", mergeProvenance.Kind);
|
||||
Assert.Equal(expectedMergeTimestamp, mergeProvenance.RecordedAt);
|
||||
Assert.Contains("redhat", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("nvd", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var rangeMeasurement = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.range_overrides");
|
||||
Assert.Equal(1, rangeMeasurement.Value);
|
||||
Assert.Contains(rangeMeasurement.Tags, tag => string.Equals(tag.Key, "suppressed_source", StringComparison.Ordinal) && tag.Value?.ToString()?.Contains("nvd", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
var severityConflict = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.conflicts");
|
||||
Assert.Equal(1, severityConflict.Value);
|
||||
Assert.Contains(severityConflict.Tags, tag => string.Equals(tag.Key, "type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "severity", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_KevOnlyTogglesExploitKnown()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
|
||||
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd", timeProvider.GetUtcNow());
|
||||
var baseAdvisory = new Advisory(
|
||||
"CVE-2025-2000",
|
||||
"CVE-2025-2000",
|
||||
"Base registry summary",
|
||||
"en",
|
||||
new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 1, 6, 0, 0, 0, TimeSpan.Zero),
|
||||
"medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-2000" },
|
||||
credits: Array.Empty<AdvisoryCredit>(),
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:a:example:product:2.0:*:*:*:*:*:*:*",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
"semver",
|
||||
"2.0.0",
|
||||
"2.0.5",
|
||||
null,
|
||||
"<2.0.5",
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "product", timeProvider.GetUtcNow()))
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { nvdProvenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { nvdProvenance });
|
||||
|
||||
var kevProvenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-2000", timeProvider.GetUtcNow());
|
||||
var kevAdvisory = new Advisory(
|
||||
"CVE-2025-2000",
|
||||
"Known Exploited Vulnerability",
|
||||
summary: null,
|
||||
language: null,
|
||||
published: null,
|
||||
modified: null,
|
||||
severity: null,
|
||||
exploitKnown: true,
|
||||
aliases: new[] { "KEV-CVE-2025-2000" },
|
||||
credits: Array.Empty<AdvisoryCredit>(),
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { kevProvenance });
|
||||
|
||||
var merged = merger.Merge(new[] { baseAdvisory, kevAdvisory }).Advisory;
|
||||
|
||||
Assert.True(merged.ExploitKnown);
|
||||
Assert.Equal("medium", merged.Severity); // KEV must not override severity
|
||||
Assert.Equal("Base registry summary", merged.Summary);
|
||||
Assert.Contains("CVE-2025-2000", merged.Aliases);
|
||||
Assert.Contains("KEV-CVE-2025-2000", merged.Aliases);
|
||||
Assert.Contains(merged.Provenance, provenance => provenance.Source == "kev");
|
||||
Assert.Contains(merged.Provenance, provenance => provenance.Source == "merge");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_UnionsCreditsFromSources()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
|
||||
var ghsaCredits = new[]
|
||||
{
|
||||
new AdvisoryCredit(
|
||||
displayName: "maintainer-team",
|
||||
role: "remediation_developer",
|
||||
contacts: new[] { "https://github.com/maintainer-team" },
|
||||
provenance: new AdvisoryProvenance(
|
||||
"ghsa",
|
||||
"credit",
|
||||
"mantainer-team",
|
||||
timeProvider.GetUtcNow(),
|
||||
new[] { ProvenanceFieldMasks.Credits })),
|
||||
new AdvisoryCredit(
|
||||
displayName: "security-reporter",
|
||||
role: "reporter",
|
||||
contacts: new[] { "https://github.com/security-reporter" },
|
||||
provenance: new AdvisoryProvenance(
|
||||
"ghsa",
|
||||
"credit",
|
||||
"security-reporter",
|
||||
timeProvider.GetUtcNow(),
|
||||
new[] { ProvenanceFieldMasks.Credits })),
|
||||
};
|
||||
|
||||
var ghsa = new Advisory(
|
||||
"CVE-2025-9000",
|
||||
"GHSA advisory",
|
||||
"Reported in GHSA",
|
||||
"en",
|
||||
timeProvider.GetUtcNow(),
|
||||
timeProvider.GetUtcNow(),
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "GHSA-aaaa-bbbb-cccc", "CVE-2025-9000" },
|
||||
credits: ghsaCredits,
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories/GHSA-aaaa-bbbb-cccc", timeProvider.GetUtcNow(), new[] { ProvenanceFieldMasks.Advisory }) });
|
||||
|
||||
var osvCredits = new[]
|
||||
{
|
||||
new AdvisoryCredit(
|
||||
displayName: "osv-researcher",
|
||||
role: "reporter",
|
||||
contacts: new[] { "mailto:osv-researcher@example.com" },
|
||||
provenance: new AdvisoryProvenance(
|
||||
"osv",
|
||||
"credit",
|
||||
"osv-researcher",
|
||||
timeProvider.GetUtcNow(),
|
||||
new[] { ProvenanceFieldMasks.Credits })),
|
||||
new AdvisoryCredit(
|
||||
displayName: "maintainer-team",
|
||||
role: "remediation_developer",
|
||||
contacts: new[] { "https://github.com/maintainer-team" },
|
||||
provenance: new AdvisoryProvenance(
|
||||
"osv",
|
||||
"credit",
|
||||
"maintainer-team",
|
||||
timeProvider.GetUtcNow(),
|
||||
new[] { ProvenanceFieldMasks.Credits })),
|
||||
};
|
||||
|
||||
var osv = new Advisory(
|
||||
"CVE-2025-9000",
|
||||
"OSV advisory",
|
||||
"Reported in OSV.dev",
|
||||
"en",
|
||||
timeProvider.GetUtcNow().AddDays(-1),
|
||||
timeProvider.GetUtcNow().AddHours(-1),
|
||||
"medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-9000" },
|
||||
credits: osvCredits,
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { new AdvisoryProvenance("osv", "document", "https://osv.dev/vulnerability/CVE-2025-9000", timeProvider.GetUtcNow(), new[] { ProvenanceFieldMasks.Advisory }) });
|
||||
|
||||
var merged = merger.Merge(new[] { ghsa, osv }).Advisory;
|
||||
|
||||
Assert.Equal("CVE-2025-9000", merged.AdvisoryKey);
|
||||
Assert.Contains(merged.Credits, credit =>
|
||||
string.Equals(credit.DisplayName, "maintainer-team", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(credit.Role, "remediation_developer", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(merged.Credits, credit =>
|
||||
string.Equals(credit.DisplayName, "osv-researcher", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(credit.Role, "reporter", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(merged.Credits, credit =>
|
||||
string.Equals(credit.DisplayName, "security-reporter", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(credit.Role, "reporter", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
Assert.Contains(merged.Credits, credit => credit.Provenance.Source == "ghsa");
|
||||
Assert.Contains(merged.Credits, credit => credit.Provenance.Source == "osv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_AcscActsAsEnrichmentSource()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
|
||||
var vendorDocumentProvenance = new AdvisoryProvenance(
|
||||
source: "vndr-cisco",
|
||||
kind: "document",
|
||||
value: "https://vendor.example/advisories/router-critical",
|
||||
recordedAt: timeProvider.GetUtcNow(),
|
||||
fieldMask: new[] { ProvenanceFieldMasks.Advisory });
|
||||
|
||||
var vendorReference = new AdvisoryReference(
|
||||
"https://vendor.example/advisories/router-critical",
|
||||
kind: "advisory",
|
||||
sourceTag: "vendor",
|
||||
summary: "Vendor advisory",
|
||||
provenance: new AdvisoryProvenance("vndr-cisco", "reference", "https://vendor.example/advisories/router-critical", timeProvider.GetUtcNow()));
|
||||
|
||||
var vendorPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
"ExampleCo Router X",
|
||||
platform: null,
|
||||
versionRanges: Array.Empty<AffectedVersionRange>(),
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
normalizedVersions: Array.Empty<NormalizedVersionRule>(),
|
||||
provenance: new[] { vendorDocumentProvenance });
|
||||
|
||||
var vendor = new Advisory(
|
||||
advisoryKey: "acsc-2025-010",
|
||||
title: "Vendor Critical Router Advisory",
|
||||
summary: "Vendor-confirmed exploit.",
|
||||
language: "en",
|
||||
published: new DateTimeOffset(2025, 10, 11, 23, 0, 0, TimeSpan.Zero),
|
||||
modified: new DateTimeOffset(2025, 10, 11, 23, 30, 0, TimeSpan.Zero),
|
||||
severity: "critical",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "VENDOR-2025-010" },
|
||||
references: new[] { vendorReference },
|
||||
affectedPackages: new[] { vendorPackage },
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { vendorDocumentProvenance });
|
||||
|
||||
var acscDocumentProvenance = new AdvisoryProvenance(
|
||||
source: "acsc",
|
||||
kind: "document",
|
||||
value: "https://origin.example/feeds/alerts/rss",
|
||||
recordedAt: timeProvider.GetUtcNow(),
|
||||
fieldMask: new[] { ProvenanceFieldMasks.Advisory });
|
||||
|
||||
var acscReference = new AdvisoryReference(
|
||||
"https://origin.example/advisories/router-critical",
|
||||
kind: "advisory",
|
||||
sourceTag: "acsc",
|
||||
summary: "ACSC alert",
|
||||
provenance: new AdvisoryProvenance("acsc", "reference", "https://origin.example/advisories/router-critical", timeProvider.GetUtcNow()));
|
||||
|
||||
var acscPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
"ExampleCo Router X",
|
||||
platform: null,
|
||||
versionRanges: Array.Empty<AffectedVersionRange>(),
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
normalizedVersions: Array.Empty<NormalizedVersionRule>(),
|
||||
provenance: new[] { acscDocumentProvenance });
|
||||
|
||||
var acsc = new Advisory(
|
||||
advisoryKey: "acsc-2025-010",
|
||||
title: "ACSC Router Alert",
|
||||
summary: "ACSC recommends installing vendor update.",
|
||||
language: "en",
|
||||
published: new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
|
||||
modified: null,
|
||||
severity: "medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "ACSC-2025-010" },
|
||||
references: new[] { acscReference },
|
||||
affectedPackages: new[] { acscPackage },
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { acscDocumentProvenance });
|
||||
|
||||
var merged = merger.Merge(new[] { acsc, vendor }).Advisory;
|
||||
|
||||
Assert.Equal("critical", merged.Severity); // ACSC must not override vendor severity
|
||||
Assert.Equal("Vendor-confirmed exploit.", merged.Summary);
|
||||
|
||||
Assert.Contains("ACSC-2025-010", merged.Aliases);
|
||||
Assert.Contains("VENDOR-2025-010", merged.Aliases);
|
||||
|
||||
Assert.Contains(merged.References, reference => reference.SourceTag == "vendor" && reference.Url == vendorReference.Url);
|
||||
Assert.Contains(merged.References, reference => reference.SourceTag == "acsc" && reference.Url == acscReference.Url);
|
||||
|
||||
var enrichedPackage = Assert.Single(merged.AffectedPackages, package => package.Identifier == "ExampleCo Router X");
|
||||
Assert.Contains(enrichedPackage.Provenance, provenance => provenance.Source == "vndr-cisco");
|
||||
Assert.Contains(enrichedPackage.Provenance, provenance => provenance.Source == "acsc");
|
||||
|
||||
Assert.Contains(merged.Provenance, provenance => provenance.Source == "acsc");
|
||||
Assert.Contains(merged.Provenance, provenance => provenance.Source == "vndr-cisco");
|
||||
Assert.Contains(merged.Provenance, provenance => provenance.Source == "merge" && (provenance.Value?.Contains("acsc", StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_RecordsNormalizedRuleMetrics()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
|
||||
|
||||
var normalizedRule = new NormalizedVersionRule(
|
||||
NormalizedVersionSchemes.SemVer,
|
||||
NormalizedVersionRuleTypes.Range,
|
||||
min: "1.0.0",
|
||||
minInclusive: true,
|
||||
max: "2.0.0",
|
||||
maxInclusive: false,
|
||||
notes: "ghsa:GHSA-xxxx-yyyy");
|
||||
|
||||
var ghsaProvenance = new AdvisoryProvenance("ghsa", "package", "pkg:npm/example", now);
|
||||
var ghsaPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/example",
|
||||
platform: null,
|
||||
versionRanges: new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
NormalizedVersionSchemes.SemVer,
|
||||
"1.0.0",
|
||||
"2.0.0",
|
||||
null,
|
||||
">= 1.0.0 < 2.0.0",
|
||||
ghsaProvenance)
|
||||
},
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[]
|
||||
{
|
||||
ghsaProvenance,
|
||||
},
|
||||
normalizedVersions: new[] { normalizedRule });
|
||||
|
||||
var nvdPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/example",
|
||||
platform: null,
|
||||
versionRanges: new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
NormalizedVersionSchemes.SemVer,
|
||||
"1.0.0",
|
||||
"2.0.0",
|
||||
null,
|
||||
">= 1.0.0 < 2.0.0",
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "pkg:npm/example", now))
|
||||
},
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now),
|
||||
},
|
||||
normalizedVersions: Array.Empty<NormalizedVersionRule>());
|
||||
|
||||
var nvdExclusivePackage = new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/another",
|
||||
platform: null,
|
||||
versionRanges: new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
NormalizedVersionSchemes.SemVer,
|
||||
"3.0.0",
|
||||
null,
|
||||
null,
|
||||
">= 3.0.0",
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "pkg:npm/another", now))
|
||||
},
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now),
|
||||
},
|
||||
normalizedVersions: Array.Empty<NormalizedVersionRule>());
|
||||
|
||||
var ghsaAdvisory = new Advisory(
|
||||
"CVE-2025-7000",
|
||||
"GHSA advisory",
|
||||
"GHSA summary",
|
||||
"en",
|
||||
now,
|
||||
now,
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-7000", "GHSA-xxxx-yyyy" },
|
||||
credits: Array.Empty<AdvisoryCredit>(),
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[] { ghsaPackage },
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories/GHSA-xxxx-yyyy", now),
|
||||
});
|
||||
|
||||
var nvdAdvisory = new Advisory(
|
||||
"CVE-2025-7000",
|
||||
"NVD entry",
|
||||
"NVD summary",
|
||||
"en",
|
||||
now,
|
||||
now,
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-7000" },
|
||||
credits: Array.Empty<AdvisoryCredit>(),
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[] { nvdPackage, nvdExclusivePackage },
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now),
|
||||
});
|
||||
|
||||
var merged = merger.Merge(new[] { nvdAdvisory, ghsaAdvisory }).Advisory;
|
||||
Assert.Equal(2, merged.AffectedPackages.Length);
|
||||
|
||||
var normalizedPackage = Assert.Single(merged.AffectedPackages, pkg => pkg.Identifier == "pkg:npm/example");
|
||||
Assert.Single(normalizedPackage.NormalizedVersions);
|
||||
|
||||
var missingPackage = Assert.Single(merged.AffectedPackages, pkg => pkg.Identifier == "pkg:npm/another");
|
||||
Assert.Empty(missingPackage.NormalizedVersions);
|
||||
Assert.NotEmpty(missingPackage.VersionRanges);
|
||||
|
||||
var normalizedMeasurements = metrics.Measurements.Where(m => m.Name == "concelier.merge.normalized_rules").ToList();
|
||||
Assert.Contains(normalizedMeasurements, measurement =>
|
||||
measurement.Value == 1
|
||||
&& measurement.Tags.Any(tag => string.Equals(tag.Key, "scheme", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal))
|
||||
&& measurement.Tags.Any(tag => string.Equals(tag.Key, "package_type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal)));
|
||||
|
||||
var missingMeasurements = metrics.Measurements.Where(m => m.Name == "concelier.merge.normalized_rules_missing").ToList();
|
||||
var missingMeasurement = Assert.Single(missingMeasurements);
|
||||
Assert.Equal(1, missingMeasurement.Value);
|
||||
Assert.Contains(missingMeasurement.Tags, tag => string.Equals(tag.Key, "package_type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_RespectsConfiguredPrecedenceOverrides()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var options = new AdvisoryPrecedenceOptions
|
||||
{
|
||||
Ranks = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["nvd"] = 0,
|
||||
["redhat"] = 5,
|
||||
}
|
||||
};
|
||||
|
||||
var logger = new TestLogger<AdvisoryPrecedenceMerger>();
|
||||
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
|
||||
|
||||
var merger = new AdvisoryPrecedenceMerger(
|
||||
new AffectedPackagePrecedenceResolver(),
|
||||
options,
|
||||
timeProvider,
|
||||
logger);
|
||||
|
||||
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
|
||||
var merged = merger.Merge(new[] { redHat, nvd }).Advisory;
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("CVE-2025-1000", merged.Title); // NVD preferred
|
||||
Assert.Equal("NVD summary", merged.Summary);
|
||||
Assert.Equal("medium", merged.Severity);
|
||||
|
||||
var package = Assert.Single(merged.AffectedPackages);
|
||||
Assert.NotEmpty(package.VersionRanges); // Vendor range no longer overrides
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
||||
|
||||
var overrideMeasurement = Assert.Single(metrics.Measurements, m => m.Name == "concelier.merge.overrides");
|
||||
Assert.Equal(1, overrideMeasurement.Value);
|
||||
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "primary_source" && string.Equals(tag.Value?.ToString(), "nvd", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "suppressed_source" && tag.Value?.ToString()?.Contains("redhat", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
Assert.DoesNotContain(metrics.Measurements, measurement => measurement.Name == "concelier.merge.range_overrides");
|
||||
|
||||
var conflictMeasurement = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.conflicts");
|
||||
Assert.Equal(1, conflictMeasurement.Value);
|
||||
Assert.Contains(conflictMeasurement.Tags, tag => tag.Key == "type" && string.Equals(tag.Value?.ToString(), "severity", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(conflictMeasurement.Tags, tag => tag.Key == "reason" && string.Equals(tag.Value?.ToString(), "mismatch", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var logEntry = Assert.Single(logger.Entries, entry => entry.EventId.Name == "AdvisoryOverride");
|
||||
Assert.Equal(LogLevel.Information, logEntry.Level);
|
||||
Assert.NotNull(logEntry.StructuredState);
|
||||
Assert.Contains(logEntry.StructuredState!, kvp =>
|
||||
(string.Equals(kvp.Key, "Override", StringComparison.Ordinal) ||
|
||||
string.Equals(kvp.Key, "@Override", StringComparison.Ordinal)) &&
|
||||
kvp.Value is not null);
|
||||
}
|
||||
|
||||
private static (Advisory Vendor, Advisory Registry) CreateVendorAndRegistryAdvisories()
|
||||
{
|
||||
var redHatPublished = new DateTimeOffset(2025, 1, 10, 0, 0, 0, TimeSpan.Zero);
|
||||
var redHatModified = redHatPublished.AddDays(1);
|
||||
var redHatProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:0001", redHatModified);
|
||||
var redHatPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
"rhel-9",
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
new[] { new AffectedPackageStatus("known_affected", redHatProvenance) },
|
||||
new[] { redHatProvenance });
|
||||
var redHat = new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"Red Hat Security Advisory",
|
||||
"Vendor-confirmed impact on RHEL 9.",
|
||||
"en",
|
||||
redHatPublished,
|
||||
redHatModified,
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-1000", "RHSA-2025:0001" },
|
||||
credits: Array.Empty<AdvisoryCredit>(),
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference(
|
||||
"https://access.redhat.com/errata/RHSA-2025:0001",
|
||||
"advisory",
|
||||
"redhat",
|
||||
"Red Hat errata",
|
||||
redHatProvenance)
|
||||
},
|
||||
affectedPackages: new[] { redHatPackage },
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric(
|
||||
"3.1",
|
||||
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
9.8,
|
||||
"critical",
|
||||
new AdvisoryProvenance("redhat", "cvss", "RHSA-2025:0001", redHatModified))
|
||||
},
|
||||
provenance: new[] { redHatProvenance });
|
||||
|
||||
var nvdPublished = new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero);
|
||||
var nvdModified = nvdPublished.AddDays(2);
|
||||
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-1000", nvdModified);
|
||||
var nvdPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
"rhel-9",
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
"cpe",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"<=9.0",
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "RHEL", nvdModified))
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { nvdProvenance });
|
||||
var nvd = new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"CVE-2025-1000",
|
||||
"NVD summary",
|
||||
"en",
|
||||
nvdPublished,
|
||||
nvdModified,
|
||||
"medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-1000" },
|
||||
credits: Array.Empty<AdvisoryCredit>(),
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference(
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2025-1000",
|
||||
"advisory",
|
||||
"nvd",
|
||||
"NVD advisory",
|
||||
nvdProvenance)
|
||||
},
|
||||
affectedPackages: new[] { nvdPackage },
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric(
|
||||
"3.1",
|
||||
"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N",
|
||||
6.8,
|
||||
"medium",
|
||||
new AdvisoryProvenance("nvd", "cvss", "CVE-2025-1000", nvdModified))
|
||||
},
|
||||
provenance: new[] { nvdProvenance });
|
||||
|
||||
return (redHat, nvd);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user