Rename Feedser to Concelier
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class AdvisoryProvenanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void FieldMask_NormalizesAndDeduplicates()
|
||||
{
|
||||
var timestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var provenance = new AdvisoryProvenance(
|
||||
source: "nvd",
|
||||
kind: "map",
|
||||
value: "CVE-2025-0001",
|
||||
recordedAt: timestamp,
|
||||
fieldMask: new[] { " AffectedPackages[] ", "affectedpackages[]", "references[]" });
|
||||
|
||||
Assert.Equal(timestamp, provenance.RecordedAt);
|
||||
Assert.Collection(
|
||||
provenance.FieldMask,
|
||||
mask => Assert.Equal("affectedpackages[]", mask),
|
||||
mask => Assert.Equal("references[]", mask));
|
||||
Assert.Null(provenance.DecisionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyProvenance_ExposesEmptyFieldMask()
|
||||
{
|
||||
Assert.True(AdvisoryProvenance.Empty.FieldMask.IsEmpty);
|
||||
Assert.Null(AdvisoryProvenance.Empty.DecisionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecisionReason_IsTrimmed()
|
||||
{
|
||||
var timestamp = DateTimeOffset.Parse("2025-03-01T00:00:00Z");
|
||||
var provenance = new AdvisoryProvenance(
|
||||
source: "merge",
|
||||
kind: "precedence",
|
||||
value: "summary",
|
||||
recordedAt: timestamp,
|
||||
fieldMask: new[] { ProvenanceFieldMasks.Advisory },
|
||||
decisionReason: " freshness_override ");
|
||||
|
||||
Assert.Equal("freshness_override", provenance.DecisionReason);
|
||||
}
|
||||
}
|
||||
62
src/StellaOps.Concelier.Models.Tests/AdvisoryTests.cs
Normal file
62
src/StellaOps.Concelier.Models.Tests/AdvisoryTests.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class AdvisoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void CanonicalizesAliasesAndReferences()
|
||||
{
|
||||
var advisory = new Advisory(
|
||||
advisoryKey: "TEST-123",
|
||||
title: "Sample Advisory",
|
||||
summary: " summary with spaces ",
|
||||
language: "EN",
|
||||
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
|
||||
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
|
||||
severity: "CRITICAL",
|
||||
exploitKnown: true,
|
||||
aliases: new[] { " CVE-2024-0001", "GHSA-aaaa", "cve-2024-0001" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://example.com/b", "patch", null, null, AdvisoryProvenance.Empty),
|
||||
new AdvisoryReference("https://example.com/a", null, null, null, AdvisoryProvenance.Empty),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
type: AffectedPackageTypes.SemVer,
|
||||
identifier: "pkg:npm/sample",
|
||||
platform: "node",
|
||||
versionRanges: new[]
|
||||
{
|
||||
new AffectedVersionRange("semver", "1.0.0", "1.0.1", null, null, AdvisoryProvenance.Empty),
|
||||
new AffectedVersionRange("semver", "1.0.0", "1.0.1", null, null, AdvisoryProvenance.Empty),
|
||||
new AffectedVersionRange("semver", "0.9.0", null, "0.9.9", null, AdvisoryProvenance.Empty),
|
||||
},
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("nvd", "map", "", DateTimeOffset.Parse("2024-01-01T00:00:00Z")),
|
||||
new AdvisoryProvenance("vendor", "map", "", DateTimeOffset.Parse("2024-01-02T00:00:00Z")),
|
||||
})
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("nvd", "map", "", DateTimeOffset.Parse("2024-01-01T00:00:00Z")),
|
||||
new AdvisoryProvenance("vendor", "map", "", DateTimeOffset.Parse("2024-01-02T00:00:00Z")),
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { "CVE-2024-0001", "GHSA-aaaa" }, advisory.Aliases);
|
||||
Assert.Equal(new[] { "https://example.com/a", "https://example.com/b" }, advisory.References.Select(r => r.Url));
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"semver|0.9.0||0.9.9|",
|
||||
"semver|1.0.0|1.0.1||",
|
||||
},
|
||||
advisory.AffectedPackages.Single().VersionRanges.Select(r => r.CreateDeterministicKey()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class AffectedPackageStatusTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Known_Affected", AffectedPackageStatusCatalog.KnownAffected)]
|
||||
[InlineData("KNOWN-NOT-AFFECTED", AffectedPackageStatusCatalog.KnownNotAffected)]
|
||||
[InlineData("Under Investigation", AffectedPackageStatusCatalog.UnderInvestigation)]
|
||||
[InlineData("Fixed", AffectedPackageStatusCatalog.Fixed)]
|
||||
[InlineData("Known not vulnerable", AffectedPackageStatusCatalog.KnownNotAffected)]
|
||||
[InlineData("Impacted", AffectedPackageStatusCatalog.Affected)]
|
||||
[InlineData("Not Vulnerable", AffectedPackageStatusCatalog.NotAffected)]
|
||||
[InlineData("Analysis in progress", AffectedPackageStatusCatalog.UnderInvestigation)]
|
||||
[InlineData("Patch Available", AffectedPackageStatusCatalog.Fixed)]
|
||||
[InlineData("Workaround available", AffectedPackageStatusCatalog.Mitigated)]
|
||||
[InlineData("Does Not Apply", AffectedPackageStatusCatalog.NotApplicable)]
|
||||
[InlineData("Awaiting fix", AffectedPackageStatusCatalog.Pending)]
|
||||
[InlineData("TBD", AffectedPackageStatusCatalog.Unknown)]
|
||||
public void Constructor_NormalizesStatus(string input, string expected)
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("test", "status", "value", DateTimeOffset.UtcNow);
|
||||
var status = new AffectedPackageStatus(input, provenance);
|
||||
|
||||
Assert.Equal(expected, status.Status);
|
||||
Assert.Equal(provenance, status.Provenance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsForUnknownStatus()
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("test", "status", "value", DateTimeOffset.UtcNow);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new AffectedPackageStatus("unsupported", provenance));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Not Impacted", AffectedPackageStatusCatalog.NotAffected)]
|
||||
[InlineData("Resolved", AffectedPackageStatusCatalog.Fixed)]
|
||||
[InlineData("Mitigation provided", AffectedPackageStatusCatalog.Mitigated)]
|
||||
[InlineData("Out of scope", AffectedPackageStatusCatalog.NotApplicable)]
|
||||
public void TryNormalize_ReturnsExpectedValue(string input, string expected)
|
||||
{
|
||||
Assert.True(AffectedPackageStatusCatalog.TryNormalize(input, out var normalized));
|
||||
Assert.Equal(expected, normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalize_ReturnsFalseForUnknown()
|
||||
{
|
||||
Assert.False(AffectedPackageStatusCatalog.TryNormalize("unsupported", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allowed_ReturnsCanonicalStatuses()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
AffectedPackageStatusCatalog.KnownAffected,
|
||||
AffectedPackageStatusCatalog.KnownNotAffected,
|
||||
AffectedPackageStatusCatalog.UnderInvestigation,
|
||||
AffectedPackageStatusCatalog.Fixed,
|
||||
AffectedPackageStatusCatalog.FirstFixed,
|
||||
AffectedPackageStatusCatalog.Mitigated,
|
||||
AffectedPackageStatusCatalog.NotApplicable,
|
||||
AffectedPackageStatusCatalog.Affected,
|
||||
AffectedPackageStatusCatalog.NotAffected,
|
||||
AffectedPackageStatusCatalog.Pending,
|
||||
AffectedPackageStatusCatalog.Unknown,
|
||||
};
|
||||
|
||||
Assert.Equal(expected, AffectedPackageStatusCatalog.Allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class AffectedVersionRangeExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_UsesNevraPrimitivesWhenAvailable()
|
||||
{
|
||||
var range = new AffectedVersionRange(
|
||||
rangeKind: "nevra",
|
||||
introducedVersion: "raw-introduced",
|
||||
fixedVersion: "raw-fixed",
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: null,
|
||||
provenance: AdvisoryProvenance.Empty,
|
||||
primitives: new RangePrimitives(
|
||||
SemVer: null,
|
||||
Nevra: new NevraPrimitive(
|
||||
new NevraComponent("pkg", 0, "1.0.0", "1", "x86_64"),
|
||||
new NevraComponent("pkg", 0, "1.2.0", "2", "x86_64"),
|
||||
null),
|
||||
Evr: null,
|
||||
VendorExtensions: null));
|
||||
|
||||
var rule = range.ToNormalizedVersionRule();
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionSchemes.Nevra, rule!.Scheme);
|
||||
Assert.Equal("pkg-1.0.0-1.x86_64", rule.Min);
|
||||
Assert.Equal("pkg-1.2.0-2.x86_64", rule.Max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_FallsBackForEvrWhenPrimitivesMissing()
|
||||
{
|
||||
var range = new AffectedVersionRange(
|
||||
rangeKind: "EVR",
|
||||
introducedVersion: "1:1.0.0-1",
|
||||
fixedVersion: "1:1.2.0-1ubuntu2",
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: null,
|
||||
provenance: new AdvisoryProvenance("debian", "range", "pkg", DateTimeOffset.UtcNow),
|
||||
primitives: null);
|
||||
|
||||
var rule = range.ToNormalizedVersionRule("fallback");
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionSchemes.Evr, rule!.Scheme);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.Range, rule.Type);
|
||||
Assert.Equal("1:1.0.0-1", rule.Min);
|
||||
Assert.Equal("1:1.2.0-1ubuntu2", rule.Max);
|
||||
Assert.Equal("fallback", rule.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_ReturnsNullForUnknownKind()
|
||||
{
|
||||
var range = new AffectedVersionRange(
|
||||
rangeKind: "vendor",
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: null,
|
||||
provenance: AdvisoryProvenance.Empty,
|
||||
primitives: null);
|
||||
|
||||
var rule = range.ToNormalizedVersionRule();
|
||||
|
||||
Assert.Null(rule);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class AliasSchemeRegistryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("cve-2024-1234", AliasSchemes.Cve, "CVE-2024-1234")]
|
||||
[InlineData("GHSA-xxxx-yyyy-zzzz", AliasSchemes.Ghsa, "GHSA-xxxx-yyyy-zzzz")]
|
||||
[InlineData("osv-2023-15", AliasSchemes.OsV, "OSV-2023-15")]
|
||||
[InlineData("jvndb-2023-123456", AliasSchemes.Jvndb, "JVNDB-2023-123456")]
|
||||
[InlineData("vu#123456", AliasSchemes.Vu, "VU#123456")]
|
||||
[InlineData("pkg:maven/org.example/app@1.0.0", AliasSchemes.Purl, "pkg:maven/org.example/app@1.0.0")]
|
||||
[InlineData("cpe:/a:vendor:product:1.0", AliasSchemes.Cpe, "cpe:/a:vendor:product:1.0")]
|
||||
public void TryNormalize_DetectsSchemeAndCanonicalizes(string input, string expectedScheme, string expectedAlias)
|
||||
{
|
||||
var success = AliasSchemeRegistry.TryNormalize(input, out var normalized, out var scheme);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal(expectedScheme, scheme);
|
||||
Assert.Equal(expectedAlias, normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalize_ReturnsFalseForUnknownAlias()
|
||||
{
|
||||
var success = AliasSchemeRegistry.TryNormalize("custom-identifier", out var normalized, out var scheme);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Equal("custom-identifier", normalized);
|
||||
Assert.Equal(string.Empty, scheme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_NormalizesAliasWhenRecognized()
|
||||
{
|
||||
var result = Validation.TryNormalizeAlias(" rhsa-2024:0252 ", out var normalized);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.NotNull(normalized);
|
||||
Assert.Equal("RHSA-2024:0252", normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_RejectsEmptyAlias()
|
||||
{
|
||||
var result = Validation.TryNormalizeAlias(" ", out var normalized);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Null(normalized);
|
||||
}
|
||||
}
|
||||
195
src/StellaOps.Concelier.Models.Tests/CanonicalExampleFactory.cs
Normal file
195
src/StellaOps.Concelier.Models.Tests/CanonicalExampleFactory.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
internal static class CanonicalExampleFactory
|
||||
{
|
||||
public static IEnumerable<(string Name, Advisory Advisory)> GetExamples()
|
||||
{
|
||||
yield return ("nvd-basic", CreateNvdExample());
|
||||
yield return ("psirt-overlay", CreatePsirtOverlay());
|
||||
yield return ("ghsa-semver", CreateGhsaSemVer());
|
||||
yield return ("kev-flag", CreateKevFlag());
|
||||
}
|
||||
|
||||
private static Advisory CreateNvdExample()
|
||||
{
|
||||
var provenance = Provenance("nvd", "map", "cve-2024-1234", "2024-08-01T12:00:00Z");
|
||||
return new Advisory(
|
||||
advisoryKey: "CVE-2024-1234",
|
||||
title: "Integer overflow in ExampleCMS",
|
||||
summary: "An integer overflow in ExampleCMS allows remote attackers to escalate privileges.",
|
||||
language: "en",
|
||||
published: ParseDate("2024-07-15T00:00:00Z"),
|
||||
modified: ParseDate("2024-07-16T10:35:00Z"),
|
||||
severity: "high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-1234" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference(
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2024-1234",
|
||||
kind: "advisory",
|
||||
sourceTag: "nvd",
|
||||
summary: "NVD entry",
|
||||
provenance: provenance),
|
||||
new AdvisoryReference(
|
||||
"https://example.org/security/CVE-2024-1234",
|
||||
kind: "advisory",
|
||||
sourceTag: "vendor",
|
||||
summary: "Vendor bulletin",
|
||||
provenance: Provenance("example", "fetch", "bulletin", "2024-07-14T15:00:00Z")),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
type: AffectedPackageTypes.Cpe,
|
||||
identifier: "cpe:/a:examplecms:examplecms:1.0",
|
||||
platform: null,
|
||||
versionRanges: new[]
|
||||
{
|
||||
new AffectedVersionRange("version", "1.0", "1.0.5", null, null, provenance),
|
||||
},
|
||||
statuses: new[]
|
||||
{
|
||||
new AffectedPackageStatus("affected", provenance),
|
||||
},
|
||||
provenance: new[] { provenance }),
|
||||
},
|
||||
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", provenance),
|
||||
},
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
private static Advisory CreatePsirtOverlay()
|
||||
{
|
||||
var rhsaProv = Provenance("redhat", "map", "rhsa-2024:0252", "2024-05-11T09:00:00Z");
|
||||
var cveProv = Provenance("redhat", "enrich", "cve-2024-5678", "2024-05-11T09:05:00Z");
|
||||
return new Advisory(
|
||||
advisoryKey: "RHSA-2024:0252",
|
||||
title: "Important: kernel security update",
|
||||
summary: "Updates the Red Hat Enterprise Linux kernel to address CVE-2024-5678.",
|
||||
language: "en",
|
||||
published: ParseDate("2024-05-10T19:28:00Z"),
|
||||
modified: ParseDate("2024-05-11T08:15:00Z"),
|
||||
severity: "critical",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "RHSA-2024:0252", "CVE-2024-5678" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference(
|
||||
"https://access.redhat.com/errata/RHSA-2024:0252",
|
||||
kind: "advisory",
|
||||
sourceTag: "redhat",
|
||||
summary: "Red Hat security advisory",
|
||||
provenance: rhsaProv),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
type: AffectedPackageTypes.Rpm,
|
||||
identifier: "kernel-0:4.18.0-553.el8.x86_64",
|
||||
platform: "rhel-8",
|
||||
versionRanges: new[]
|
||||
{
|
||||
new AffectedVersionRange("nevra", "0:4.18.0-553.el8", null, null, null, rhsaProv),
|
||||
},
|
||||
statuses: new[]
|
||||
{
|
||||
new AffectedPackageStatus("fixed", rhsaProv),
|
||||
},
|
||||
provenance: new[] { rhsaProv, cveProv }),
|
||||
},
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric("3.1", "CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H", 6.7, "medium", rhsaProv),
|
||||
},
|
||||
provenance: new[] { rhsaProv, cveProv });
|
||||
}
|
||||
|
||||
private static Advisory CreateGhsaSemVer()
|
||||
{
|
||||
var provenance = Provenance("ghsa", "map", "ghsa-aaaa-bbbb-cccc", "2024-03-05T10:00:00Z");
|
||||
return new Advisory(
|
||||
advisoryKey: "GHSA-aaaa-bbbb-cccc",
|
||||
title: "Prototype pollution in widget.js",
|
||||
summary: "A crafted payload can pollute Object.prototype leading to RCE.",
|
||||
language: "en",
|
||||
published: ParseDate("2024-03-04T00:00:00Z"),
|
||||
modified: ParseDate("2024-03-04T12:00:00Z"),
|
||||
severity: "high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "GHSA-aaaa-bbbb-cccc", "CVE-2024-2222" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference(
|
||||
"https://github.com/example/widget/security/advisories/GHSA-aaaa-bbbb-cccc",
|
||||
kind: "advisory",
|
||||
sourceTag: "ghsa",
|
||||
summary: "GitHub Security Advisory",
|
||||
provenance: provenance),
|
||||
new AdvisoryReference(
|
||||
"https://github.com/example/widget/commit/abcd1234",
|
||||
kind: "patch",
|
||||
sourceTag: "ghsa",
|
||||
summary: "Patch commit",
|
||||
provenance: provenance),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
type: AffectedPackageTypes.SemVer,
|
||||
identifier: "pkg:npm/example-widget",
|
||||
platform: null,
|
||||
versionRanges: new[]
|
||||
{
|
||||
new AffectedVersionRange("semver", null, "2.5.1", null, ">=0.0.0 <2.5.1", provenance),
|
||||
new AffectedVersionRange("semver", "3.0.0", "3.2.4", null, null, provenance),
|
||||
},
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { provenance }),
|
||||
},
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H", 8.8, "high", provenance),
|
||||
},
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
private static Advisory CreateKevFlag()
|
||||
{
|
||||
var provenance = Provenance("cisa-kev", "annotate", "kev", "2024-02-10T09:30:00Z");
|
||||
return new Advisory(
|
||||
advisoryKey: "CVE-2023-9999",
|
||||
title: "Remote code execution in LegacyServer",
|
||||
summary: "Unauthenticated RCE due to unsafe deserialization.",
|
||||
language: "en",
|
||||
published: ParseDate("2023-11-20T00:00:00Z"),
|
||||
modified: ParseDate("2024-02-09T16:22:00Z"),
|
||||
severity: "critical",
|
||||
exploitKnown: true,
|
||||
aliases: new[] { "CVE-2023-9999" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference(
|
||||
"https://www.cisa.gov/known-exploited-vulnerabilities-catalog",
|
||||
kind: "kev",
|
||||
sourceTag: "cisa",
|
||||
summary: "CISA KEV entry",
|
||||
provenance: provenance),
|
||||
},
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
private static AdvisoryProvenance Provenance(string source, string kind, string value, string recordedAt)
|
||||
=> new(source, kind, value, ParseDate(recordedAt));
|
||||
|
||||
private static DateTimeOffset ParseDate(string value)
|
||||
=> DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).ToUniversalTime();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class CanonicalExamplesTests
|
||||
{
|
||||
private static readonly string FixtureRoot = Path.Combine(GetProjectRoot(), "Fixtures");
|
||||
private const string UpdateEnvVar = "UPDATE_GOLDENS";
|
||||
|
||||
[Trait("Category", "GoldenSnapshots")]
|
||||
[Fact]
|
||||
public void CanonicalExamplesMatchGoldenSnapshots()
|
||||
{
|
||||
Directory.CreateDirectory(FixtureRoot);
|
||||
var envValue = Environment.GetEnvironmentVariable(UpdateEnvVar);
|
||||
var updateGoldens = string.Equals(envValue, "1", StringComparison.OrdinalIgnoreCase);
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var (name, advisory) in CanonicalExampleFactory.GetExamples())
|
||||
{
|
||||
var snapshot = SnapshotSerializer.ToSnapshot(advisory).Replace("\r\n", "\n");
|
||||
var fixturePath = Path.Combine(FixtureRoot, $"{name}.json");
|
||||
|
||||
if (updateGoldens)
|
||||
{
|
||||
File.WriteAllText(fixturePath, snapshot);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!File.Exists(fixturePath))
|
||||
{
|
||||
failures.Add($"Missing golden fixture: {fixturePath}. Set {UpdateEnvVar}=1 to generate.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var expected = File.ReadAllText(fixturePath).Replace("\r\n", "\n");
|
||||
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(FixtureRoot, $"{name}.actual.json");
|
||||
File.WriteAllText(actualPath, snapshot);
|
||||
failures.Add($"Fixture mismatch for {name}. Set {UpdateEnvVar}=1 to regenerate.");
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var failure in failures)
|
||||
{
|
||||
builder.AppendLine(failure);
|
||||
}
|
||||
|
||||
Assert.Fail(builder.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetProjectRoot()
|
||||
=> Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class CanonicalJsonSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializesPropertiesInDeterministicOrder()
|
||||
{
|
||||
var advisory = new Advisory(
|
||||
advisoryKey: "TEST-321",
|
||||
title: "Ordering",
|
||||
summary: null,
|
||||
language: null,
|
||||
published: null,
|
||||
modified: null,
|
||||
severity: null,
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "b", "a" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: Array.Empty<AdvisoryProvenance>());
|
||||
|
||||
var json = CanonicalJsonSerializer.Serialize(advisory);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var propertyNames = document.RootElement.EnumerateObject().Select(p => p.Name).ToArray();
|
||||
|
||||
var sorted = propertyNames.OrderBy(name => name, StringComparer.Ordinal).ToArray();
|
||||
Assert.Equal(sorted, propertyNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotSerializerProducesStableOutput()
|
||||
{
|
||||
var advisory = new Advisory(
|
||||
advisoryKey: "TEST-999",
|
||||
title: "Snapshot",
|
||||
summary: "Example",
|
||||
language: "EN",
|
||||
published: DateTimeOffset.Parse("2024-06-01T00:00:00Z"),
|
||||
modified: DateTimeOffset.Parse("2024-06-01T01:00:00Z"),
|
||||
severity: "high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "ALIAS-1" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://example.com/a", "advisory", null, null, AdvisoryProvenance.Empty),
|
||||
},
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: Array.Empty<AdvisoryProvenance>());
|
||||
|
||||
var snap1 = SnapshotSerializer.ToSnapshot(advisory);
|
||||
var snap2 = SnapshotSerializer.ToSnapshot(advisory);
|
||||
|
||||
Assert.Equal(snap1, snap2);
|
||||
var normalized1 = snap1.Replace("\r\n", "\n");
|
||||
var normalized2 = snap2.Replace("\r\n", "\n");
|
||||
Assert.Equal(normalized1, normalized2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializesRangePrimitivesPayload()
|
||||
{
|
||||
var recordedAt = new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var provenance = new AdvisoryProvenance("connector-x", "map", "segment-1", recordedAt);
|
||||
var primitives = new RangePrimitives(
|
||||
new SemVerPrimitive(
|
||||
Introduced: "2.0.0",
|
||||
IntroducedInclusive: true,
|
||||
Fixed: "2.3.4",
|
||||
FixedInclusive: false,
|
||||
LastAffected: "2.3.3",
|
||||
LastAffectedInclusive: true,
|
||||
ConstraintExpression: ">=2.0.0 <2.3.4"),
|
||||
new NevraPrimitive(
|
||||
Introduced: new NevraComponent("pkg", 0, "2.0.0", "1", "x86_64"),
|
||||
Fixed: null,
|
||||
LastAffected: new NevraComponent("pkg", 0, "2.3.3", "3", "x86_64")),
|
||||
new EvrPrimitive(
|
||||
Introduced: new EvrComponent(1, "2.0.0", "1"),
|
||||
Fixed: new EvrComponent(1, "2.3.4", null),
|
||||
LastAffected: null),
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["channel"] = "stable",
|
||||
});
|
||||
|
||||
var range = new AffectedVersionRange(
|
||||
rangeKind: "semver",
|
||||
introducedVersion: "2.0.0",
|
||||
fixedVersion: "2.3.4",
|
||||
lastAffectedVersion: "2.3.3",
|
||||
rangeExpression: ">=2.0.0 <2.3.4",
|
||||
provenance,
|
||||
primitives);
|
||||
|
||||
var package = new AffectedPackage(
|
||||
type: "semver",
|
||||
identifier: "pkg@2.x",
|
||||
platform: "linux",
|
||||
versionRanges: new[] { range },
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { provenance });
|
||||
|
||||
var advisory = new Advisory(
|
||||
advisoryKey: "TEST-PRIM",
|
||||
title: "Range primitive serialization",
|
||||
summary: null,
|
||||
language: null,
|
||||
published: recordedAt,
|
||||
modified: recordedAt,
|
||||
severity: null,
|
||||
exploitKnown: false,
|
||||
aliases: Array.Empty<string>(),
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[] { package },
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { provenance });
|
||||
|
||||
var json = CanonicalJsonSerializer.Serialize(advisory);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var rangeElement = document.RootElement
|
||||
.GetProperty("affectedPackages")[0]
|
||||
.GetProperty("versionRanges")[0];
|
||||
|
||||
Assert.True(rangeElement.TryGetProperty("primitives", out var primitivesElement));
|
||||
|
||||
var semver = primitivesElement.GetProperty("semVer");
|
||||
Assert.Equal("2.0.0", semver.GetProperty("introduced").GetString());
|
||||
Assert.True(semver.GetProperty("introducedInclusive").GetBoolean());
|
||||
Assert.Equal("2.3.4", semver.GetProperty("fixed").GetString());
|
||||
Assert.False(semver.GetProperty("fixedInclusive").GetBoolean());
|
||||
Assert.Equal("2.3.3", semver.GetProperty("lastAffected").GetString());
|
||||
|
||||
var nevra = primitivesElement.GetProperty("nevra");
|
||||
Assert.Equal("pkg", nevra.GetProperty("introduced").GetProperty("name").GetString());
|
||||
Assert.Equal(0, nevra.GetProperty("introduced").GetProperty("epoch").GetInt32());
|
||||
|
||||
var evr = primitivesElement.GetProperty("evr");
|
||||
Assert.Equal(1, evr.GetProperty("introduced").GetProperty("epoch").GetInt32());
|
||||
Assert.Equal("2.3.4", evr.GetProperty("fixed").GetProperty("upstreamVersion").GetString());
|
||||
|
||||
var extensions = primitivesElement.GetProperty("vendorExtensions");
|
||||
Assert.Equal("stable", extensions.GetProperty("channel").GetString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class EvrPrimitiveExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_ProducesRangeForIntroducedAndFixed()
|
||||
{
|
||||
var primitive = new EvrPrimitive(
|
||||
Introduced: new EvrComponent(1, "1.2.3", "4"),
|
||||
Fixed: new EvrComponent(1, "1.2.9", "0ubuntu1"),
|
||||
LastAffected: null);
|
||||
|
||||
var rule = primitive.ToNormalizedVersionRule("ubuntu:focal");
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionSchemes.Evr, rule!.Scheme);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.Range, rule.Type);
|
||||
Assert.Equal("1:1.2.3-4", rule.Min);
|
||||
Assert.True(rule.MinInclusive);
|
||||
Assert.Equal("1:1.2.9-0ubuntu1", rule.Max);
|
||||
Assert.False(rule.MaxInclusive);
|
||||
Assert.Equal("ubuntu:focal", rule.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_GreaterThanOrEqualWhenOnlyIntroduced()
|
||||
{
|
||||
var primitive = new EvrPrimitive(
|
||||
Introduced: new EvrComponent(0, "2.0.0", null),
|
||||
Fixed: null,
|
||||
LastAffected: null);
|
||||
|
||||
var rule = primitive.ToNormalizedVersionRule();
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.GreaterThanOrEqual, rule!.Type);
|
||||
Assert.Equal("2.0.0", rule.Min);
|
||||
Assert.True(rule.MinInclusive);
|
||||
}
|
||||
}
|
||||
124
src/StellaOps.Concelier.Models.Tests/Fixtures/ghsa-semver.json
Normal file
124
src/StellaOps.Concelier.Models.Tests/Fixtures/ghsa-semver.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"advisoryKey": "GHSA-aaaa-bbbb-cccc",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "semver",
|
||||
"identifier": "pkg:npm/example-widget",
|
||||
"platform": null,
|
||||
"versionRanges": [
|
||||
{
|
||||
"fixedVersion": "2.5.1",
|
||||
"introducedVersion": null,
|
||||
"lastAffectedVersion": null,
|
||||
"primitives": null,
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "map",
|
||||
"value": "ghsa-aaaa-bbbb-cccc",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-03-05T10:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"rangeExpression": ">=0.0.0 <2.5.1",
|
||||
"rangeKind": "semver"
|
||||
},
|
||||
{
|
||||
"fixedVersion": "3.2.4",
|
||||
"introducedVersion": "3.0.0",
|
||||
"lastAffectedVersion": null,
|
||||
"primitives": null,
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "map",
|
||||
"value": "ghsa-aaaa-bbbb-cccc",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-03-05T10:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"rangeExpression": null,
|
||||
"rangeKind": "semver"
|
||||
}
|
||||
],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "ghsa",
|
||||
"kind": "map",
|
||||
"value": "ghsa-aaaa-bbbb-cccc",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-03-05T10:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CVE-2024-2222",
|
||||
"GHSA-aaaa-bbbb-cccc"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [
|
||||
{
|
||||
"baseScore": 8.8,
|
||||
"baseSeverity": "high",
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "map",
|
||||
"value": "ghsa-aaaa-bbbb-cccc",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-03-05T10:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
|
||||
"version": "3.1"
|
||||
}
|
||||
],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": "2024-03-04T12:00:00+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"source": "ghsa",
|
||||
"kind": "map",
|
||||
"value": "ghsa-aaaa-bbbb-cccc",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-03-05T10:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
],
|
||||
"published": "2024-03-04T00:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "patch",
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "map",
|
||||
"value": "ghsa-aaaa-bbbb-cccc",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-03-05T10:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "ghsa",
|
||||
"summary": "Patch commit",
|
||||
"url": "https://github.com/example/widget/commit/abcd1234"
|
||||
},
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "map",
|
||||
"value": "ghsa-aaaa-bbbb-cccc",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-03-05T10:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "ghsa",
|
||||
"summary": "GitHub Security Advisory",
|
||||
"url": "https://github.com/example/widget/security/advisories/GHSA-aaaa-bbbb-cccc"
|
||||
}
|
||||
],
|
||||
"severity": "high",
|
||||
"summary": "A crafted payload can pollute Object.prototype leading to RCE.",
|
||||
"title": "Prototype pollution in widget.js"
|
||||
}
|
||||
42
src/StellaOps.Concelier.Models.Tests/Fixtures/kev-flag.json
Normal file
42
src/StellaOps.Concelier.Models.Tests/Fixtures/kev-flag.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"advisoryKey": "CVE-2023-9999",
|
||||
"affectedPackages": [],
|
||||
"aliases": [
|
||||
"CVE-2023-9999"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": true,
|
||||
"language": "en",
|
||||
"modified": "2024-02-09T16:22:00+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"source": "cisa-kev",
|
||||
"kind": "annotate",
|
||||
"value": "kev",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-02-10T09:30:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
],
|
||||
"published": "2023-11-20T00:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "kev",
|
||||
"provenance": {
|
||||
"source": "cisa-kev",
|
||||
"kind": "annotate",
|
||||
"value": "kev",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-02-10T09:30:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "cisa",
|
||||
"summary": "CISA KEV entry",
|
||||
"url": "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"
|
||||
}
|
||||
],
|
||||
"severity": "critical",
|
||||
"summary": "Unauthenticated RCE due to unsafe deserialization.",
|
||||
"title": "Remote code execution in LegacyServer"
|
||||
}
|
||||
119
src/StellaOps.Concelier.Models.Tests/Fixtures/nvd-basic.json
Normal file
119
src/StellaOps.Concelier.Models.Tests/Fixtures/nvd-basic.json
Normal file
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"advisoryKey": "CVE-2024-1234",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "cpe",
|
||||
"identifier": "cpe:/a:examplecms:examplecms:1.0",
|
||||
"platform": null,
|
||||
"versionRanges": [
|
||||
{
|
||||
"fixedVersion": "1.0.5",
|
||||
"introducedVersion": "1.0",
|
||||
"lastAffectedVersion": null,
|
||||
"primitives": null,
|
||||
"provenance": {
|
||||
"source": "nvd",
|
||||
"kind": "map",
|
||||
"value": "cve-2024-1234",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-08-01T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"rangeExpression": null,
|
||||
"rangeKind": "version"
|
||||
}
|
||||
],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "nvd",
|
||||
"kind": "map",
|
||||
"value": "cve-2024-1234",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-08-01T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "affected"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "nvd",
|
||||
"kind": "map",
|
||||
"value": "cve-2024-1234",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-08-01T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CVE-2024-1234"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [
|
||||
{
|
||||
"baseScore": 9.8,
|
||||
"baseSeverity": "critical",
|
||||
"provenance": {
|
||||
"source": "nvd",
|
||||
"kind": "map",
|
||||
"value": "cve-2024-1234",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-08-01T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"version": "3.1"
|
||||
}
|
||||
],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": "2024-07-16T10:35:00+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"source": "nvd",
|
||||
"kind": "map",
|
||||
"value": "cve-2024-1234",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-08-01T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
],
|
||||
"published": "2024-07-15T00:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "example",
|
||||
"kind": "fetch",
|
||||
"value": "bulletin",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-07-14T15:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "vendor",
|
||||
"summary": "Vendor bulletin",
|
||||
"url": "https://example.org/security/CVE-2024-1234"
|
||||
},
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "nvd",
|
||||
"kind": "map",
|
||||
"value": "cve-2024-1234",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-08-01T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "nvd",
|
||||
"summary": "NVD entry",
|
||||
"url": "https://nvd.nist.gov/vuln/detail/CVE-2024-1234"
|
||||
}
|
||||
],
|
||||
"severity": "high",
|
||||
"summary": "An integer overflow in ExampleCMS allows remote attackers to escalate privileges.",
|
||||
"title": "Integer overflow in ExampleCMS"
|
||||
}
|
||||
122
src/StellaOps.Concelier.Models.Tests/Fixtures/psirt-overlay.json
Normal file
122
src/StellaOps.Concelier.Models.Tests/Fixtures/psirt-overlay.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"advisoryKey": "RHSA-2024:0252",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "rpm",
|
||||
"identifier": "kernel-0:4.18.0-553.el8.x86_64",
|
||||
"platform": "rhel-8",
|
||||
"versionRanges": [
|
||||
{
|
||||
"fixedVersion": null,
|
||||
"introducedVersion": "0:4.18.0-553.el8",
|
||||
"lastAffectedVersion": null,
|
||||
"primitives": null,
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "map",
|
||||
"value": "rhsa-2024:0252",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-05-11T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"rangeExpression": null,
|
||||
"rangeKind": "nevra"
|
||||
}
|
||||
],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "map",
|
||||
"value": "rhsa-2024:0252",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-05-11T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "fixed"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "enrich",
|
||||
"value": "cve-2024-5678",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-05-11T09:05:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "map",
|
||||
"value": "rhsa-2024:0252",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-05-11T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CVE-2024-5678",
|
||||
"RHSA-2024:0252"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [
|
||||
{
|
||||
"baseScore": 6.7,
|
||||
"baseSeverity": "medium",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "map",
|
||||
"value": "rhsa-2024:0252",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-05-11T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"vector": "CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H",
|
||||
"version": "3.1"
|
||||
}
|
||||
],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": "2024-05-11T08:15:00+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "enrich",
|
||||
"value": "cve-2024-5678",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-05-11T09:05:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "map",
|
||||
"value": "rhsa-2024:0252",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-05-11T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
],
|
||||
"published": "2024-05-10T19:28:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "map",
|
||||
"value": "rhsa-2024:0252",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-05-11T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "redhat",
|
||||
"summary": "Red Hat security advisory",
|
||||
"url": "https://access.redhat.com/errata/RHSA-2024:0252"
|
||||
}
|
||||
],
|
||||
"severity": "critical",
|
||||
"summary": "Updates the Red Hat Enterprise Linux kernel to address CVE-2024-5678.",
|
||||
"title": "Important: kernel security update"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class NevraPrimitiveExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_ProducesRangeWhenBoundsAvailable()
|
||||
{
|
||||
var primitive = new NevraPrimitive(
|
||||
Introduced: new NevraComponent("openssl", 1, "1.1.1k", "4", "x86_64"),
|
||||
Fixed: new NevraComponent("openssl", 1, "1.1.1n", "5", "x86_64"),
|
||||
LastAffected: null);
|
||||
|
||||
var rule = primitive.ToNormalizedVersionRule("rhel-8");
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionSchemes.Nevra, rule!.Scheme);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.Range, rule.Type);
|
||||
Assert.Equal("openssl-1:1.1.1k-4.x86_64", rule.Min);
|
||||
Assert.True(rule.MinInclusive);
|
||||
Assert.Equal("openssl-1:1.1.1n-5.x86_64", rule.Max);
|
||||
Assert.False(rule.MaxInclusive);
|
||||
Assert.Equal("rhel-8", rule.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_UsesLastAffectedAsInclusiveUpperBound()
|
||||
{
|
||||
var primitive = new NevraPrimitive(
|
||||
Introduced: null,
|
||||
Fixed: null,
|
||||
LastAffected: new NevraComponent("kernel", 0, "5.15.0", "1024.18", "x86_64"));
|
||||
|
||||
var rule = primitive.ToNormalizedVersionRule();
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionSchemes.Nevra, rule!.Scheme);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.LessThanOrEqual, rule.Type);
|
||||
Assert.Equal("kernel-5.15.0-1024.18.x86_64", rule.Max);
|
||||
Assert.True(rule.MaxInclusive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class NormalizedVersionRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalizedVersions_AreDeduplicatedAndOrdered()
|
||||
{
|
||||
var recordedAt = DateTimeOffset.Parse("2025-01-05T00:00:00Z");
|
||||
var provenance = new AdvisoryProvenance("ghsa", "map", "GHSA-abc", recordedAt);
|
||||
var package = new AffectedPackage(
|
||||
type: AffectedPackageTypes.SemVer,
|
||||
identifier: "pkg:npm/example",
|
||||
versionRanges: Array.Empty<AffectedVersionRange>(),
|
||||
normalizedVersions: new[]
|
||||
{
|
||||
new NormalizedVersionRule("SemVer", "Exact", value: "1.0.0 "),
|
||||
new NormalizedVersionRule("semver", "range", min: "1.2.0", minInclusive: true, max: "2.0.0", maxInclusive: false, notes: "GHSA-abc"),
|
||||
new NormalizedVersionRule("semver", "range", min: "1.2.0", minInclusive: true, max: "2.0.0", maxInclusive: false, notes: "GHSA-abc"),
|
||||
new NormalizedVersionRule("semver", "gt", min: "0.9.0", minInclusive: false, notes: " originating nvd "),
|
||||
},
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { provenance });
|
||||
|
||||
var normalized = package.NormalizedVersions.ToArray();
|
||||
Assert.Equal(3, normalized.Length);
|
||||
|
||||
Assert.Collection(
|
||||
normalized,
|
||||
rule =>
|
||||
{
|
||||
Assert.Equal("semver", rule.Scheme);
|
||||
Assert.Equal("exact", rule.Type);
|
||||
Assert.Equal("1.0.0", rule.Value);
|
||||
Assert.Null(rule.Min);
|
||||
Assert.Null(rule.Max);
|
||||
},
|
||||
rule =>
|
||||
{
|
||||
Assert.Equal("semver", rule.Scheme);
|
||||
Assert.Equal("gt", rule.Type);
|
||||
Assert.Equal("0.9.0", rule.Min);
|
||||
Assert.False(rule.MinInclusive);
|
||||
Assert.Equal("originating nvd", rule.Notes);
|
||||
},
|
||||
rule =>
|
||||
{
|
||||
Assert.Equal("semver", rule.Scheme);
|
||||
Assert.Equal("range", rule.Type);
|
||||
Assert.Equal("1.2.0", rule.Min);
|
||||
Assert.True(rule.MinInclusive);
|
||||
Assert.Equal("2.0.0", rule.Max);
|
||||
Assert.False(rule.MaxInclusive);
|
||||
Assert.Equal("GHSA-abc", rule.Notes);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizedVersionRule_NormalizesTypeSeparators()
|
||||
{
|
||||
var rule = new NormalizedVersionRule("semver", "tie_breaker", value: "1.2.3");
|
||||
Assert.Equal("tie-breaker", rule.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class OsvGhsaParityDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordReport_EmitsTotalAndIssues()
|
||||
{
|
||||
var issues = ImmutableArray.Create(
|
||||
new OsvGhsaParityIssue(
|
||||
GhsaId: "GHSA-AAA",
|
||||
IssueKind: "missing_osv",
|
||||
Detail: "",
|
||||
FieldMask: ImmutableArray.Create(ProvenanceFieldMasks.AffectedPackages)),
|
||||
new OsvGhsaParityIssue(
|
||||
GhsaId: "GHSA-BBB",
|
||||
IssueKind: "severity_mismatch",
|
||||
Detail: "",
|
||||
FieldMask: ImmutableArray<string>.Empty));
|
||||
var report = new OsvGhsaParityReport(2, issues);
|
||||
|
||||
var measurements = new List<(string Instrument, long Value, IReadOnlyDictionary<string, object?> Tags)>();
|
||||
using var listener = CreateListener(measurements);
|
||||
|
||||
OsvGhsaParityDiagnostics.RecordReport(report, "QA");
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Equal(3, measurements.Count);
|
||||
|
||||
var total = Assert.Single(measurements, m => m.Instrument == "concelier.osv_ghsa.total");
|
||||
Assert.Equal(2, total.Value);
|
||||
Assert.Equal("qa", total.Tags["dataset"]);
|
||||
|
||||
var missing = Assert.Single(measurements, m => m.Tags.TryGetValue("issueKind", out var kind) && string.Equals((string)kind!, "missing_osv", StringComparison.Ordinal));
|
||||
Assert.Equal("affectedpackages[]", missing.Tags["fieldMask"]);
|
||||
|
||||
var severity = Assert.Single(measurements, m => m.Tags.TryGetValue("issueKind", out var kind) && string.Equals((string)kind!, "severity_mismatch", StringComparison.Ordinal));
|
||||
Assert.Equal("none", severity.Tags["fieldMask"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordReport_NoIssues_OnlyEmitsTotal()
|
||||
{
|
||||
var report = new OsvGhsaParityReport(0, ImmutableArray<OsvGhsaParityIssue>.Empty);
|
||||
var measurements = new List<(string Instrument, long Value, IReadOnlyDictionary<string, object?> Tags)>();
|
||||
using var listener = CreateListener(measurements);
|
||||
|
||||
OsvGhsaParityDiagnostics.RecordReport(report, "");
|
||||
|
||||
listener.Dispose();
|
||||
Assert.Empty(measurements);
|
||||
}
|
||||
|
||||
private static MeterListener CreateListener(List<(string Instrument, long Value, IReadOnlyDictionary<string, object?> Tags)> measurements)
|
||||
{
|
||||
var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Meter.Name.StartsWith("StellaOps.Concelier.Models.OsvGhsaParity", StringComparison.Ordinal))
|
||||
{
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
dict[tag.Key] = tag.Value;
|
||||
}
|
||||
|
||||
measurements.Add((instrument.Name, measurement, dict));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
return listener;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class OsvGhsaParityInspectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compare_ReturnsNoIssues_WhenDatasetsMatch()
|
||||
{
|
||||
var ghsaId = "GHSA-1111";
|
||||
var osv = CreateOsvAdvisory(ghsaId, severity: "high", includeRanges: true);
|
||||
var ghsa = CreateGhsaAdvisory(ghsaId, severity: "high", includeRanges: true);
|
||||
|
||||
var report = OsvGhsaParityInspector.Compare(new[] { osv }, new[] { ghsa });
|
||||
|
||||
Assert.False(report.HasIssues);
|
||||
Assert.Equal(1, report.TotalGhsaIds);
|
||||
Assert.Empty(report.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_FlagsMissingOsvEntry()
|
||||
{
|
||||
var ghsaId = "GHSA-2222";
|
||||
var ghsa = CreateGhsaAdvisory(ghsaId, severity: "medium", includeRanges: true);
|
||||
|
||||
var report = OsvGhsaParityInspector.Compare(Array.Empty<Advisory>(), new[] { ghsa });
|
||||
|
||||
var issue = Assert.Single(report.Issues);
|
||||
Assert.Equal("missing_osv", issue.IssueKind);
|
||||
Assert.Equal(ghsaId, issue.GhsaId);
|
||||
Assert.Contains(ProvenanceFieldMasks.AffectedPackages, issue.FieldMask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_FlagsMissingGhsaEntry()
|
||||
{
|
||||
var ghsaId = "GHSA-2424";
|
||||
var osv = CreateOsvAdvisory(ghsaId, severity: "medium", includeRanges: true);
|
||||
|
||||
var report = OsvGhsaParityInspector.Compare(new[] { osv }, Array.Empty<Advisory>());
|
||||
|
||||
var issue = Assert.Single(report.Issues);
|
||||
Assert.Equal("missing_ghsa", issue.IssueKind);
|
||||
Assert.Equal(ghsaId, issue.GhsaId);
|
||||
Assert.Contains(ProvenanceFieldMasks.AffectedPackages, issue.FieldMask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_FlagsSeverityMismatch()
|
||||
{
|
||||
var ghsaId = "GHSA-3333";
|
||||
var osv = CreateOsvAdvisory(ghsaId, severity: "low", includeRanges: true);
|
||||
var ghsa = CreateGhsaAdvisory(ghsaId, severity: "critical", includeRanges: true);
|
||||
|
||||
var report = OsvGhsaParityInspector.Compare(new[] { osv }, new[] { ghsa });
|
||||
|
||||
var issue = Assert.Single(report.Issues, i => i.IssueKind == "severity_mismatch");
|
||||
Assert.Equal(ghsaId, issue.GhsaId);
|
||||
Assert.Contains(ProvenanceFieldMasks.Advisory, issue.FieldMask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_FlagsRangeMismatch()
|
||||
{
|
||||
var ghsaId = "GHSA-4444";
|
||||
var osv = CreateOsvAdvisory(ghsaId, severity: "high", includeRanges: false);
|
||||
var ghsa = CreateGhsaAdvisory(ghsaId, severity: "high", includeRanges: true);
|
||||
|
||||
var report = OsvGhsaParityInspector.Compare(new[] { osv }, new[] { ghsa });
|
||||
|
||||
var issue = Assert.Single(report.Issues, i => i.IssueKind == "range_mismatch");
|
||||
Assert.Equal(ghsaId, issue.GhsaId);
|
||||
Assert.Contains(ProvenanceFieldMasks.VersionRanges, issue.FieldMask);
|
||||
}
|
||||
|
||||
private static Advisory CreateOsvAdvisory(string ghsaId, string? severity, bool includeRanges)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
return new Advisory(
|
||||
advisoryKey: $"osv-{ghsaId.ToLowerInvariant()}",
|
||||
title: $"OSV {ghsaId}",
|
||||
summary: null,
|
||||
language: null,
|
||||
published: timestamp,
|
||||
modified: timestamp,
|
||||
severity: severity,
|
||||
exploitKnown: false,
|
||||
aliases: new[] { ghsaId },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: includeRanges ? new[] { CreatePackage(timestamp, includeRanges) } : Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("osv", "map", ghsaId, timestamp, new[] { ProvenanceFieldMasks.Advisory })
|
||||
});
|
||||
}
|
||||
|
||||
private static Advisory CreateGhsaAdvisory(string ghsaId, string? severity, bool includeRanges)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
return new Advisory(
|
||||
advisoryKey: ghsaId.ToLowerInvariant(),
|
||||
title: $"GHSA {ghsaId}",
|
||||
summary: null,
|
||||
language: null,
|
||||
published: timestamp,
|
||||
modified: timestamp,
|
||||
severity: severity,
|
||||
exploitKnown: false,
|
||||
aliases: new[] { ghsaId },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: includeRanges ? new[] { CreatePackage(timestamp, includeRanges) } : Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("ghsa", "map", ghsaId, timestamp, new[] { ProvenanceFieldMasks.Advisory })
|
||||
});
|
||||
}
|
||||
|
||||
private static AffectedPackage CreatePackage(DateTimeOffset recordedAt, bool includeRanges)
|
||||
{
|
||||
var ranges = includeRanges
|
||||
? new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "semver",
|
||||
introducedVersion: "1.0.0",
|
||||
fixedVersion: "1.2.0",
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: null,
|
||||
provenance: new AdvisoryProvenance("mapper", "range", "package@1", recordedAt, new[] { ProvenanceFieldMasks.VersionRanges }),
|
||||
primitives: null)
|
||||
}
|
||||
: Array.Empty<AffectedVersionRange>();
|
||||
|
||||
return new AffectedPackage(
|
||||
type: "semver",
|
||||
identifier: "pkg@1",
|
||||
platform: null,
|
||||
versionRanges: ranges,
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { new AdvisoryProvenance("mapper", "package", "pkg@1", recordedAt, new[] { ProvenanceFieldMasks.AffectedPackages }) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class ProvenanceDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordMissing_AddsExpectedTagsAndDeduplicates()
|
||||
{
|
||||
ResetState();
|
||||
|
||||
var measurements = new List<(string Instrument, long Value, IReadOnlyDictionary<string, object?> Tags)>();
|
||||
using var listener = CreateListener(measurements);
|
||||
|
||||
var baseline = DateTimeOffset.UtcNow;
|
||||
ProvenanceDiagnostics.RecordMissing("source-A", "range:pkg", baseline, new[] { ProvenanceFieldMasks.VersionRanges });
|
||||
ProvenanceDiagnostics.RecordMissing("source-A", "range:pkg", baseline.AddMinutes(5), new[] { ProvenanceFieldMasks.VersionRanges });
|
||||
ProvenanceDiagnostics.RecordMissing("source-A", "reference:https://example", baseline.AddMinutes(10), new[] { ProvenanceFieldMasks.References });
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Equal(2, measurements.Count);
|
||||
|
||||
var first = measurements[0];
|
||||
Assert.Equal(1, first.Value);
|
||||
Assert.Equal("concelier.provenance.missing", first.Instrument);
|
||||
Assert.Equal("source-A", first.Tags["source"]);
|
||||
Assert.Equal("range:pkg", first.Tags["component"]);
|
||||
Assert.Equal("range", first.Tags["category"]);
|
||||
Assert.Equal("high", first.Tags["severity"]);
|
||||
Assert.Equal(ProvenanceFieldMasks.VersionRanges, first.Tags["fieldMask"]);
|
||||
|
||||
var second = measurements[1];
|
||||
Assert.Equal("concelier.provenance.missing", second.Instrument);
|
||||
Assert.Equal("reference", second.Tags["category"]);
|
||||
Assert.Equal("low", second.Tags["severity"]);
|
||||
Assert.Equal(ProvenanceFieldMasks.References, second.Tags["fieldMask"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportResumeWindow_ClearsTrackedEntries_WhenWindowBackfills()
|
||||
{
|
||||
ResetState();
|
||||
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
ProvenanceDiagnostics.RecordMissing("source-B", "package:lib", timestamp);
|
||||
|
||||
var (recorded, earliest, syncRoot) = GetInternalState();
|
||||
lock (syncRoot)
|
||||
{
|
||||
Assert.True(earliest.ContainsKey("source-B"));
|
||||
Assert.Contains(recorded, entry => entry.StartsWith("source-B|", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
ProvenanceDiagnostics.ReportResumeWindow("source-B", timestamp.AddMinutes(-5), NullLogger.Instance);
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
Assert.False(earliest.ContainsKey("source-B"));
|
||||
Assert.DoesNotContain(recorded, entry => entry.StartsWith("source-B|", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportResumeWindow_RetainsEntries_WhenWindowTooRecent()
|
||||
{
|
||||
ResetState();
|
||||
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
ProvenanceDiagnostics.RecordMissing("source-C", "range:pkg", timestamp);
|
||||
|
||||
ProvenanceDiagnostics.ReportResumeWindow("source-C", timestamp.AddMinutes(1), NullLogger.Instance);
|
||||
|
||||
var (recorded, earliest, syncRoot) = GetInternalState();
|
||||
lock (syncRoot)
|
||||
{
|
||||
Assert.True(earliest.ContainsKey("source-C"));
|
||||
Assert.Contains(recorded, entry => entry.StartsWith("source-C|", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordRangePrimitive_EmitsCoverageMetric()
|
||||
{
|
||||
var range = new AffectedVersionRange(
|
||||
rangeKind: "evr",
|
||||
introducedVersion: "1:1.1.1n-0+deb11u2",
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: null,
|
||||
provenance: new AdvisoryProvenance("source-D", "range", "pkg", DateTimeOffset.UtcNow),
|
||||
primitives: new RangePrimitives(
|
||||
SemVer: null,
|
||||
Nevra: null,
|
||||
Evr: new EvrPrimitive(
|
||||
new EvrComponent(1, "1.1.1n", "0+deb11u2"),
|
||||
null,
|
||||
null),
|
||||
VendorExtensions: new Dictionary<string, string> { ["debian.release"] = "bullseye" }));
|
||||
|
||||
var measurements = new List<(string Instrument, long Value, IReadOnlyDictionary<string, object?> Tags)>();
|
||||
using var listener = CreateListener(measurements, "concelier.range.primitives");
|
||||
|
||||
ProvenanceDiagnostics.RecordRangePrimitive("source-D", range);
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
var record = Assert.Single(measurements);
|
||||
Assert.Equal("concelier.range.primitives", record.Instrument);
|
||||
Assert.Equal(1, record.Value);
|
||||
Assert.Equal("source-D", record.Tags["source"]);
|
||||
Assert.Equal("evr", record.Tags["rangeKind"]);
|
||||
Assert.Equal("evr", record.Tags["primitiveKinds"]);
|
||||
Assert.Equal("true", record.Tags["hasVendorExtensions"]);
|
||||
}
|
||||
|
||||
private static MeterListener CreateListener(
|
||||
List<(string Instrument, long Value, IReadOnlyDictionary<string, object?> Tags)> measurements,
|
||||
params string[] instrumentNames)
|
||||
{
|
||||
var allowed = instrumentNames is { Length: > 0 } ? instrumentNames : new[] { "concelier.provenance.missing" };
|
||||
var allowedSet = new HashSet<string>(allowed, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Meter.Name == "StellaOps.Concelier.Models.Provenance" && allowedSet.Contains(instrument.Name))
|
||||
{
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
dict[tag.Key] = tag.Value;
|
||||
}
|
||||
|
||||
measurements.Add((instrument.Name, measurement, dict));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
return listener;
|
||||
}
|
||||
|
||||
private static void ResetState()
|
||||
{
|
||||
var (_, _, syncRoot) = GetInternalState();
|
||||
lock (syncRoot)
|
||||
{
|
||||
var (recorded, earliest, _) = GetInternalState();
|
||||
recorded.Clear();
|
||||
earliest.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static (HashSet<string> Recorded, Dictionary<string, DateTimeOffset> Earliest, object SyncRoot) GetInternalState()
|
||||
{
|
||||
var type = typeof(ProvenanceDiagnostics);
|
||||
var recordedField = type.GetField("RecordedComponents", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException("RecordedComponents not found");
|
||||
var earliestField = type.GetField("EarliestMissing", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException("EarliestMissing not found");
|
||||
var syncField = type.GetField("SyncRoot", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException("SyncRoot not found");
|
||||
|
||||
var recorded = (HashSet<string>)recordedField.GetValue(null)!;
|
||||
var earliest = (Dictionary<string, DateTimeOffset>)earliestField.GetValue(null)!;
|
||||
var sync = syncField.GetValue(null)!;
|
||||
return (recorded, earliest, sync);
|
||||
}
|
||||
}
|
||||
41
src/StellaOps.Concelier.Models.Tests/RangePrimitivesTests.cs
Normal file
41
src/StellaOps.Concelier.Models.Tests/RangePrimitivesTests.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class RangePrimitivesTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetCoverageTag_ReturnsSpecificKinds()
|
||||
{
|
||||
var primitives = new RangePrimitives(
|
||||
new SemVerPrimitive("1.0.0", true, "1.2.0", false, null, false, null),
|
||||
new NevraPrimitive(null, null, null),
|
||||
null,
|
||||
null);
|
||||
|
||||
Assert.Equal("nevra+semver", primitives.GetCoverageTag());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCoverageTag_ReturnsVendorWhenOnlyExtensions()
|
||||
{
|
||||
var primitives = new RangePrimitives(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new Dictionary<string, string> { ["vendor.status"] = "beta" });
|
||||
|
||||
Assert.True(primitives.HasVendorExtensions);
|
||||
Assert.Equal("vendor", primitives.GetCoverageTag());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCoverageTag_ReturnsNoneWhenEmpty()
|
||||
{
|
||||
var primitives = new RangePrimitives(null, null, null, null);
|
||||
Assert.False(primitives.HasVendorExtensions);
|
||||
Assert.Equal("none", primitives.GetCoverageTag());
|
||||
}
|
||||
}
|
||||
189
src/StellaOps.Concelier.Models.Tests/SemVerPrimitiveTests.cs
Normal file
189
src/StellaOps.Concelier.Models.Tests/SemVerPrimitiveTests.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class SemVerPrimitiveTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1.0.0", true, "2.0.0", false, null, false, null, null, SemVerPrimitiveStyles.Range)]
|
||||
[InlineData("1.0.0", true, null, false, null, false, null, null, SemVerPrimitiveStyles.GreaterThanOrEqual)]
|
||||
[InlineData("1.0.0", false, null, false, null, false, null, null, SemVerPrimitiveStyles.GreaterThan)]
|
||||
[InlineData(null, true, "2.0.0", false, null, false, null, null, SemVerPrimitiveStyles.LessThan)]
|
||||
[InlineData(null, true, "2.0.0", true, null, false, null, null, SemVerPrimitiveStyles.LessThanOrEqual)]
|
||||
[InlineData(null, true, null, false, "2.0.0", true, null, null, SemVerPrimitiveStyles.LessThanOrEqual)]
|
||||
[InlineData(null, true, null, false, "2.0.0", false, null, null, SemVerPrimitiveStyles.LessThan)]
|
||||
[InlineData(null, true, null, false, null, false, null, "1.5.0", SemVerPrimitiveStyles.Exact)]
|
||||
public void StyleReflectsSemantics(
|
||||
string? introduced,
|
||||
bool introducedInclusive,
|
||||
string? fixedVersion,
|
||||
bool fixedInclusive,
|
||||
string? lastAffected,
|
||||
bool lastAffectedInclusive,
|
||||
string? constraintExpression,
|
||||
string? exactValue,
|
||||
string expectedStyle)
|
||||
{
|
||||
var primitive = new SemVerPrimitive(
|
||||
introduced,
|
||||
introducedInclusive,
|
||||
fixedVersion,
|
||||
fixedInclusive,
|
||||
lastAffected,
|
||||
lastAffectedInclusive,
|
||||
constraintExpression,
|
||||
exactValue);
|
||||
|
||||
Assert.Equal(expectedStyle, primitive.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualityIncludesExactValue()
|
||||
{
|
||||
var baseline = new SemVerPrimitive(
|
||||
Introduced: null,
|
||||
IntroducedInclusive: true,
|
||||
Fixed: null,
|
||||
FixedInclusive: false,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: false,
|
||||
ConstraintExpression: null);
|
||||
|
||||
var variant = baseline with { ExactValue = "1.2.3" };
|
||||
|
||||
Assert.NotEqual(baseline, variant);
|
||||
Assert.Equal(SemVerPrimitiveStyles.Exact, variant.Style);
|
||||
Assert.Equal(SemVerPrimitiveStyles.Range, baseline.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_MapsRangeBounds()
|
||||
{
|
||||
var primitive = new SemVerPrimitive(
|
||||
Introduced: "1.0.0",
|
||||
IntroducedInclusive: true,
|
||||
Fixed: "2.0.0",
|
||||
FixedInclusive: false,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: false,
|
||||
ConstraintExpression: ">=1.0.0 <2.0.0");
|
||||
|
||||
var rule = primitive.ToNormalizedVersionRule();
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionSchemes.SemVer, rule!.Scheme);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.Range, rule.Type);
|
||||
Assert.Equal("1.0.0", rule.Min);
|
||||
Assert.True(rule.MinInclusive);
|
||||
Assert.Equal("2.0.0", rule.Max);
|
||||
Assert.False(rule.MaxInclusive);
|
||||
Assert.Null(rule.Value);
|
||||
Assert.Equal(">=1.0.0 <2.0.0", rule.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_ExactUsesExactValue()
|
||||
{
|
||||
var primitive = new SemVerPrimitive(
|
||||
Introduced: null,
|
||||
IntroducedInclusive: true,
|
||||
Fixed: null,
|
||||
FixedInclusive: false,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: false,
|
||||
ConstraintExpression: null,
|
||||
ExactValue: "3.1.4");
|
||||
|
||||
var rule = primitive.ToNormalizedVersionRule("from-ghsa");
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionSchemes.SemVer, rule!.Scheme);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.Exact, rule.Type);
|
||||
Assert.Null(rule.Min);
|
||||
Assert.Null(rule.Max);
|
||||
Assert.Equal("3.1.4", rule.Value);
|
||||
Assert.Equal("from-ghsa", rule.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_GreaterThanMapsMinimum()
|
||||
{
|
||||
var primitive = new SemVerPrimitive(
|
||||
Introduced: "1.5.0",
|
||||
IntroducedInclusive: false,
|
||||
Fixed: null,
|
||||
FixedInclusive: false,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: false,
|
||||
ConstraintExpression: null);
|
||||
|
||||
var rule = primitive.ToNormalizedVersionRule();
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionSchemes.SemVer, rule!.Scheme);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.GreaterThan, rule.Type);
|
||||
Assert.Equal("1.5.0", rule.Min);
|
||||
Assert.False(rule.MinInclusive);
|
||||
Assert.Null(rule.Max);
|
||||
Assert.Null(rule.Value);
|
||||
Assert.Null(rule.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_UsesConstraintExpressionAsFallbackNotes()
|
||||
{
|
||||
var primitive = new SemVerPrimitive(
|
||||
Introduced: "1.4.0",
|
||||
IntroducedInclusive: false,
|
||||
Fixed: null,
|
||||
FixedInclusive: false,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: false,
|
||||
ConstraintExpression: "> 1.4.0");
|
||||
|
||||
var rule = primitive.ToNormalizedVersionRule();
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal("> 1.4.0", rule!.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_ExactCarriesConstraintExpressionWhenNotesMissing()
|
||||
{
|
||||
var primitive = new SemVerPrimitive(
|
||||
Introduced: null,
|
||||
IntroducedInclusive: true,
|
||||
Fixed: null,
|
||||
FixedInclusive: false,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: false,
|
||||
ConstraintExpression: "= 3.2.1",
|
||||
ExactValue: "3.2.1");
|
||||
|
||||
var rule = primitive.ToNormalizedVersionRule();
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.Exact, rule!.Type);
|
||||
Assert.Equal("3.2.1", rule.Value);
|
||||
Assert.Equal("= 3.2.1", rule.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_ExplicitNotesOverrideConstraintExpression()
|
||||
{
|
||||
var primitive = new SemVerPrimitive(
|
||||
Introduced: "1.0.0",
|
||||
IntroducedInclusive: true,
|
||||
Fixed: "1.1.0",
|
||||
FixedInclusive: false,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: false,
|
||||
ConstraintExpression: ">=1.0.0 <1.1.0");
|
||||
|
||||
var rule = primitive.ToNormalizedVersionRule("ghsa:range");
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal("ghsa:range", rule!.Notes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class SerializationDeterminismTests
|
||||
{
|
||||
private static readonly string[] Cultures =
|
||||
{
|
||||
"en-US",
|
||||
"fr-FR",
|
||||
"tr-TR",
|
||||
"ja-JP",
|
||||
"ar-SA"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void CanonicalSerializer_ProducesStableJsonAcrossCultures()
|
||||
{
|
||||
var examples = CanonicalExampleFactory.GetExamples().ToArray();
|
||||
var baseline = SerializeUnderCulture(CultureInfo.InvariantCulture, examples);
|
||||
|
||||
foreach (var cultureName in Cultures)
|
||||
{
|
||||
var culture = CultureInfo.GetCultureInfo(cultureName);
|
||||
var serialized = SerializeUnderCulture(culture, examples);
|
||||
|
||||
Assert.Equal(baseline.Count, serialized.Count);
|
||||
for (var i = 0; i < baseline.Count; i++)
|
||||
{
|
||||
Assert.Equal(baseline[i].Compact, serialized[i].Compact);
|
||||
Assert.Equal(baseline[i].Indented, serialized[i].Indented);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<(string Name, string Compact, string Indented)> SerializeUnderCulture(
|
||||
CultureInfo culture,
|
||||
IReadOnlyList<(string Name, Advisory Advisory)> examples)
|
||||
{
|
||||
var originalCulture = CultureInfo.CurrentCulture;
|
||||
var originalUiCulture = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = culture;
|
||||
CultureInfo.CurrentUICulture = culture;
|
||||
|
||||
var results = new List<(string Name, string Compact, string Indented)>(examples.Count);
|
||||
foreach (var (name, advisory) in examples)
|
||||
{
|
||||
var compact = CanonicalJsonSerializer.Serialize(advisory);
|
||||
var indented = CanonicalJsonSerializer.SerializeIndented(advisory);
|
||||
results.Add((name, compact, indented));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = originalCulture;
|
||||
CultureInfo.CurrentUICulture = originalUiCulture;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Models.Tests;
|
||||
|
||||
public sealed class SeverityNormalizationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("CRITICAL", "critical")]
|
||||
[InlineData("Important", "high")]
|
||||
[InlineData("moderate", "medium")]
|
||||
[InlineData("Minor", "low")]
|
||||
[InlineData("Info", "informational")]
|
||||
[InlineData("negligible", "none")]
|
||||
[InlineData("unknown", "unknown")]
|
||||
[InlineData("Sev Critical", "critical")]
|
||||
[InlineData("HIGH vendor", "high")]
|
||||
[InlineData("Informative", "informational")]
|
||||
[InlineData("Not Applicable", "none")]
|
||||
[InlineData("Undetermined", "unknown")]
|
||||
[InlineData("Priority 0", "critical")]
|
||||
[InlineData("Priority-2", "medium")]
|
||||
[InlineData("N/A", "none")]
|
||||
[InlineData("custom-level", "custom-level")]
|
||||
public void Normalize_ReturnsExpectedCanonicalValue(string input, string expected)
|
||||
{
|
||||
var normalized = SeverityNormalization.Normalize(input);
|
||||
Assert.Equal(expected, normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ReturnsNullWhenInputNullOrWhitespace()
|
||||
{
|
||||
Assert.Null(SeverityNormalization.Normalize(null));
|
||||
Assert.Null(SeverityNormalization.Normalize(" "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UpdateGoldens)' == 'true'">
|
||||
<EnvironmentVariables Include="UPDATE_GOLDENS">
|
||||
<Value>1</Value>
|
||||
</EnvironmentVariables>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user