feat: Add CVSS receipt management endpoints and related functionality
- Introduced new API endpoints for creating, retrieving, amending, and listing CVSS receipts. - Updated IPolicyEngineClient interface to include methods for CVSS receipt operations. - Implemented PolicyEngineClient to handle CVSS receipt requests. - Enhanced Program.cs to map new CVSS receipt routes with appropriate authorization. - Added necessary models and contracts for CVSS receipt requests and responses. - Integrated Postgres document store for managing CVSS receipts and related data. - Updated database schema with new migrations for source documents and payload storage. - Refactored existing components to support new CVSS functionality.
This commit is contained in:
@@ -6,36 +6,36 @@ using StellaOps.Concelier.Connector.Common.Html;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cccs.Tests.Internal;
|
||||
|
||||
public sealed class CccsMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_CreatesCanonicalAdvisory()
|
||||
{
|
||||
var raw = CccsHtmlParserTests.LoadFixture<CccsRawAdvisoryDocument>("cccs-raw-advisory.json");
|
||||
var dto = new CccsHtmlParser(new HtmlContentSanitizer()).Parse(raw);
|
||||
var document = new DocumentRecord(
|
||||
Guid.NewGuid(),
|
||||
CccsConnectorPlugin.SourceName,
|
||||
dto.CanonicalUrl,
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha-test",
|
||||
DocumentStatuses.PendingMap,
|
||||
"application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: null,
|
||||
LastModified: dto.Modified,
|
||||
GridFsId: null);
|
||||
|
||||
var recordedAt = DateTimeOffset.Parse("2025-08-12T00:00:00Z");
|
||||
var advisory = CccsMapper.Map(dto, document, recordedAt);
|
||||
|
||||
advisory.AdvisoryKey.Should().Be("TEST-001");
|
||||
advisory.Title.Should().Be(dto.Title);
|
||||
advisory.Aliases.Should().Contain(new[] { "TEST-001", "CVE-2020-1234", "CVE-2021-9999" });
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cccs.Tests.Internal;
|
||||
|
||||
public sealed class CccsMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_CreatesCanonicalAdvisory()
|
||||
{
|
||||
var raw = CccsHtmlParserTests.LoadFixture<CccsRawAdvisoryDocument>("cccs-raw-advisory.json");
|
||||
var dto = new CccsHtmlParser(new HtmlContentSanitizer()).Parse(raw);
|
||||
var document = new DocumentRecord(
|
||||
Guid.NewGuid(),
|
||||
CccsConnectorPlugin.SourceName,
|
||||
dto.CanonicalUrl,
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha-test",
|
||||
DocumentStatuses.PendingMap,
|
||||
"application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: null,
|
||||
LastModified: dto.Modified,
|
||||
PayloadId: null);
|
||||
|
||||
var recordedAt = DateTimeOffset.Parse("2025-08-12T00:00:00Z");
|
||||
var advisory = CccsMapper.Map(dto, document, recordedAt);
|
||||
|
||||
advisory.AdvisoryKey.Should().Be("TEST-001");
|
||||
advisory.Title.Should().Be(dto.Title);
|
||||
advisory.Aliases.Should().Contain(new[] { "TEST-001", "CVE-2020-1234", "CVE-2021-9999" });
|
||||
advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details");
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
|
||||
advisory.AffectedPackages.Should().HaveCount(2);
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
|
||||
|
||||
public sealed class CertCcMapperTests
|
||||
{
|
||||
private static readonly DateTimeOffset PublishedAt = DateTimeOffset.Parse("2025-10-03T11:35:31Z", CultureInfo.InvariantCulture);
|
||||
|
||||
[Fact]
|
||||
public void Map_ProducesCanonicalAdvisoryWithVendorPrimitives()
|
||||
{
|
||||
const string vendorStatement =
|
||||
"The issue is confirmed, and here is the patch list\n\n" +
|
||||
"V3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\n" +
|
||||
"V2927/V2865/V2866\t4.5.1\n" +
|
||||
"V2765/V2766/V2763/V2135\t4.5.1";
|
||||
|
||||
var vendor = new CertCcVendorDto(
|
||||
"DrayTek Corporation",
|
||||
ContactDate: PublishedAt.AddDays(-10),
|
||||
StatementDate: PublishedAt.AddDays(-5),
|
||||
Updated: PublishedAt,
|
||||
Statement: vendorStatement,
|
||||
Addendum: null,
|
||||
References: new[] { "https://www.draytek.com/support/resources?type=version" });
|
||||
|
||||
var vendorStatus = new CertCcVendorStatusDto(
|
||||
Vendor: "DrayTek Corporation",
|
||||
CveId: "CVE-2025-10547",
|
||||
Status: "Affected",
|
||||
Statement: null,
|
||||
References: Array.Empty<string>(),
|
||||
DateAdded: PublishedAt,
|
||||
DateUpdated: PublishedAt);
|
||||
|
||||
var vulnerability = new CertCcVulnerabilityDto(
|
||||
CveId: "CVE-2025-10547",
|
||||
Description: null,
|
||||
DateAdded: PublishedAt,
|
||||
DateUpdated: PublishedAt);
|
||||
|
||||
var metadata = new CertCcNoteMetadata(
|
||||
VuId: "VU#294418",
|
||||
IdNumber: "294418",
|
||||
Title: "Vigor routers running DrayOS RCE via EasyVPN",
|
||||
Overview: "Overview",
|
||||
Summary: "Summary",
|
||||
Published: PublishedAt,
|
||||
Updated: PublishedAt.AddMinutes(5),
|
||||
Created: PublishedAt,
|
||||
Revision: 2,
|
||||
CveIds: new[] { "CVE-2025-10547" },
|
||||
PublicUrls: new[]
|
||||
{
|
||||
"https://www.draytek.com/about/security-advisory/use-of-uninitialized-variable-vulnerabilities/",
|
||||
"https://www.draytek.com/support/resources?type=version"
|
||||
},
|
||||
PrimaryUrl: "https://www.kb.cert.org/vuls/id/294418/");
|
||||
|
||||
var dto = new CertCcNoteDto(
|
||||
metadata,
|
||||
Vendors: new[] { vendor },
|
||||
VendorStatuses: new[] { vendorStatus },
|
||||
Vulnerabilities: new[] { vulnerability });
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Guid.NewGuid(),
|
||||
"cert-cc",
|
||||
"https://www.kb.cert.org/vuls/id/294418/",
|
||||
PublishedAt,
|
||||
Sha256: new string('0', 64),
|
||||
Status: "pending-map",
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: null,
|
||||
LastModified: PublishedAt,
|
||||
GridFsId: null);
|
||||
|
||||
var dtoRecord = new DtoRecord(
|
||||
Id: Guid.NewGuid(),
|
||||
DocumentId: document.Id,
|
||||
SourceName: "cert-cc",
|
||||
SchemaVersion: "certcc.vince.note.v1",
|
||||
Payload: new BsonDocument(),
|
||||
ValidatedAt: PublishedAt.AddMinutes(1));
|
||||
|
||||
var advisory = CertCcMapper.Map(dto, document, dtoRecord, "cert-cc");
|
||||
|
||||
Assert.Equal("certcc/vu-294418", advisory.AdvisoryKey);
|
||||
Assert.Contains("VU#294418", advisory.Aliases);
|
||||
Assert.Contains("CVE-2025-10547", advisory.Aliases);
|
||||
Assert.Equal("en", advisory.Language);
|
||||
Assert.Equal(PublishedAt, advisory.Published);
|
||||
|
||||
Assert.Contains(advisory.References, reference => reference.Url.Contains("/vuls/id/294418", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var affected = Assert.Single(advisory.AffectedPackages);
|
||||
Assert.Equal("vendor", affected.Type);
|
||||
Assert.Equal("DrayTek Corporation", affected.Identifier);
|
||||
Assert.Contains(affected.Statuses, status => status.Status == AffectedPackageStatusCatalog.Affected);
|
||||
|
||||
var range = Assert.Single(affected.VersionRanges);
|
||||
Assert.NotNull(range.Primitives);
|
||||
Assert.NotNull(range.Primitives!.VendorExtensions);
|
||||
Assert.Contains(range.Primitives.VendorExtensions!, kvp => kvp.Key == "certcc.vendor.patches");
|
||||
|
||||
Assert.NotEmpty(affected.NormalizedVersions);
|
||||
Assert.Contains(affected.NormalizedVersions, rule => rule.Scheme == "certcc.vendor" && rule.Value == "4.5.1");
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
|
||||
|
||||
public sealed class CertCcMapperTests
|
||||
{
|
||||
private static readonly DateTimeOffset PublishedAt = DateTimeOffset.Parse("2025-10-03T11:35:31Z", CultureInfo.InvariantCulture);
|
||||
|
||||
[Fact]
|
||||
public void Map_ProducesCanonicalAdvisoryWithVendorPrimitives()
|
||||
{
|
||||
const string vendorStatement =
|
||||
"The issue is confirmed, and here is the patch list\n\n" +
|
||||
"V3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\n" +
|
||||
"V2927/V2865/V2866\t4.5.1\n" +
|
||||
"V2765/V2766/V2763/V2135\t4.5.1";
|
||||
|
||||
var vendor = new CertCcVendorDto(
|
||||
"DrayTek Corporation",
|
||||
ContactDate: PublishedAt.AddDays(-10),
|
||||
StatementDate: PublishedAt.AddDays(-5),
|
||||
Updated: PublishedAt,
|
||||
Statement: vendorStatement,
|
||||
Addendum: null,
|
||||
References: new[] { "https://www.draytek.com/support/resources?type=version" });
|
||||
|
||||
var vendorStatus = new CertCcVendorStatusDto(
|
||||
Vendor: "DrayTek Corporation",
|
||||
CveId: "CVE-2025-10547",
|
||||
Status: "Affected",
|
||||
Statement: null,
|
||||
References: Array.Empty<string>(),
|
||||
DateAdded: PublishedAt,
|
||||
DateUpdated: PublishedAt);
|
||||
|
||||
var vulnerability = new CertCcVulnerabilityDto(
|
||||
CveId: "CVE-2025-10547",
|
||||
Description: null,
|
||||
DateAdded: PublishedAt,
|
||||
DateUpdated: PublishedAt);
|
||||
|
||||
var metadata = new CertCcNoteMetadata(
|
||||
VuId: "VU#294418",
|
||||
IdNumber: "294418",
|
||||
Title: "Vigor routers running DrayOS RCE via EasyVPN",
|
||||
Overview: "Overview",
|
||||
Summary: "Summary",
|
||||
Published: PublishedAt,
|
||||
Updated: PublishedAt.AddMinutes(5),
|
||||
Created: PublishedAt,
|
||||
Revision: 2,
|
||||
CveIds: new[] { "CVE-2025-10547" },
|
||||
PublicUrls: new[]
|
||||
{
|
||||
"https://www.draytek.com/about/security-advisory/use-of-uninitialized-variable-vulnerabilities/",
|
||||
"https://www.draytek.com/support/resources?type=version"
|
||||
},
|
||||
PrimaryUrl: "https://www.kb.cert.org/vuls/id/294418/");
|
||||
|
||||
var dto = new CertCcNoteDto(
|
||||
metadata,
|
||||
Vendors: new[] { vendor },
|
||||
VendorStatuses: new[] { vendorStatus },
|
||||
Vulnerabilities: new[] { vulnerability });
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Guid.NewGuid(),
|
||||
"cert-cc",
|
||||
"https://www.kb.cert.org/vuls/id/294418/",
|
||||
PublishedAt,
|
||||
Sha256: new string('0', 64),
|
||||
Status: "pending-map",
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: null,
|
||||
LastModified: PublishedAt,
|
||||
PayloadId: null);
|
||||
|
||||
var dtoRecord = new DtoRecord(
|
||||
Id: Guid.NewGuid(),
|
||||
DocumentId: document.Id,
|
||||
SourceName: "cert-cc",
|
||||
SchemaVersion: "certcc.vince.note.v1",
|
||||
Payload: new BsonDocument(),
|
||||
ValidatedAt: PublishedAt.AddMinutes(1));
|
||||
|
||||
var advisory = CertCcMapper.Map(dto, document, dtoRecord, "cert-cc");
|
||||
|
||||
Assert.Equal("certcc/vu-294418", advisory.AdvisoryKey);
|
||||
Assert.Contains("VU#294418", advisory.Aliases);
|
||||
Assert.Contains("CVE-2025-10547", advisory.Aliases);
|
||||
Assert.Equal("en", advisory.Language);
|
||||
Assert.Equal(PublishedAt, advisory.Published);
|
||||
|
||||
Assert.Contains(advisory.References, reference => reference.Url.Contains("/vuls/id/294418", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var affected = Assert.Single(advisory.AffectedPackages);
|
||||
Assert.Equal("vendor", affected.Type);
|
||||
Assert.Equal("DrayTek Corporation", affected.Identifier);
|
||||
Assert.Contains(affected.Statuses, status => status.Status == AffectedPackageStatusCatalog.Affected);
|
||||
|
||||
var range = Assert.Single(affected.VersionRanges);
|
||||
Assert.NotNull(range.Primitives);
|
||||
Assert.NotNull(range.Primitives!.VendorExtensions);
|
||||
Assert.Contains(range.Primitives.VendorExtensions!, kvp => kvp.Key == "certcc.vendor.patches");
|
||||
|
||||
Assert.NotEmpty(affected.NormalizedVersions);
|
||||
Assert.Contains(affected.NormalizedVersions, rule => rule.Scheme == "certcc.vendor" && rule.Value == "4.5.1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ public sealed class SourceStateSeedProcessorTests : IAsyncLifetime
|
||||
Assert.Equal(documentId, storedDocument!.Id);
|
||||
Assert.Equal("application/json", storedDocument.ContentType);
|
||||
Assert.Equal(DocumentStatuses.PendingParse, storedDocument.Status);
|
||||
Assert.NotNull(storedDocument.GridFsId);
|
||||
Assert.NotNull(storedDocument.PayloadId);
|
||||
Assert.NotNull(storedDocument.Headers);
|
||||
Assert.Equal("true", storedDocument.Headers!["X-Test"]);
|
||||
Assert.NotNull(storedDocument.Metadata);
|
||||
@@ -153,7 +153,7 @@ public sealed class SourceStateSeedProcessorTests : IAsyncLifetime
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(existingRecord);
|
||||
var previousGridId = existingRecord!.GridFsId;
|
||||
var previousGridId = existingRecord!.PayloadId;
|
||||
Assert.NotNull(previousGridId);
|
||||
|
||||
var filesCollection = _database.GetCollection<BsonDocument>("documents.files");
|
||||
@@ -189,8 +189,8 @@ public sealed class SourceStateSeedProcessorTests : IAsyncLifetime
|
||||
|
||||
Assert.NotNull(refreshedRecord);
|
||||
Assert.Equal(documentId, refreshedRecord!.Id);
|
||||
Assert.NotNull(refreshedRecord.GridFsId);
|
||||
Assert.NotEqual(previousGridId, refreshedRecord.GridFsId);
|
||||
Assert.NotNull(refreshedRecord.PayloadId);
|
||||
Assert.NotEqual(previousGridId, refreshedRecord.PayloadId);
|
||||
|
||||
var files = await filesCollection.Find(FilterDefinition<BsonDocument>.Empty).ToListAsync();
|
||||
Assert.Single(files);
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Distro.Debian;
|
||||
using StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Tests;
|
||||
|
||||
public sealed class DebianMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_BuildsRangePrimitives_ForResolvedPackage()
|
||||
{
|
||||
var dto = new DebianAdvisoryDto(
|
||||
AdvisoryId: "DSA-2024-123",
|
||||
SourcePackage: "openssl",
|
||||
Title: "Openssl security update",
|
||||
Description: "Fixes multiple issues.",
|
||||
CveIds: new[] { "CVE-2024-1000", "CVE-2024-1001" },
|
||||
Packages: new[]
|
||||
{
|
||||
new DebianPackageStateDto(
|
||||
Package: "openssl",
|
||||
Release: "bullseye",
|
||||
Status: "resolved",
|
||||
IntroducedVersion: "1:1.1.1n-0+deb11u2",
|
||||
FixedVersion: "1:1.1.1n-0+deb11u5",
|
||||
LastAffectedVersion: null,
|
||||
Published: new DateTimeOffset(2024, 9, 1, 0, 0, 0, TimeSpan.Zero)),
|
||||
new DebianPackageStateDto(
|
||||
Package: "openssl",
|
||||
Release: "bookworm",
|
||||
Status: "open",
|
||||
IntroducedVersion: null,
|
||||
FixedVersion: null,
|
||||
LastAffectedVersion: null,
|
||||
Published: null)
|
||||
},
|
||||
References: new[]
|
||||
{
|
||||
new DebianReferenceDto(
|
||||
Url: "https://security-tracker.debian.org/tracker/DSA-2024-123",
|
||||
Kind: "advisory",
|
||||
Title: "Debian Security Advisory 2024-123"),
|
||||
});
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Id: Guid.NewGuid(),
|
||||
SourceName: DebianConnectorPlugin.SourceName,
|
||||
Uri: "https://security-tracker.debian.org/tracker/DSA-2024-123",
|
||||
FetchedAt: new DateTimeOffset(2024, 9, 1, 1, 0, 0, TimeSpan.Zero),
|
||||
Sha256: "sha",
|
||||
Status: "Fetched",
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: null,
|
||||
LastModified: null,
|
||||
GridFsId: null);
|
||||
|
||||
Advisory advisory = DebianMapper.Map(dto, document, new DateTimeOffset(2024, 9, 1, 2, 0, 0, TimeSpan.Zero));
|
||||
|
||||
Assert.Equal("DSA-2024-123", advisory.AdvisoryKey);
|
||||
Assert.Contains("CVE-2024-1000", advisory.Aliases);
|
||||
Assert.Contains("CVE-2024-1001", advisory.Aliases);
|
||||
|
||||
var resolvedPackage = Assert.Single(advisory.AffectedPackages, p => p.Platform == "bullseye");
|
||||
var range = Assert.Single(resolvedPackage.VersionRanges);
|
||||
Assert.Equal("evr", range.RangeKind);
|
||||
Assert.Equal("1:1.1.1n-0+deb11u2", range.IntroducedVersion);
|
||||
Assert.Equal("1:1.1.1n-0+deb11u5", range.FixedVersion);
|
||||
Assert.NotNull(range.Primitives);
|
||||
var evr = range.Primitives!.Evr;
|
||||
Assert.NotNull(evr);
|
||||
Assert.NotNull(evr!.Introduced);
|
||||
Assert.Equal(1, evr.Introduced!.Epoch);
|
||||
Assert.Equal("1.1.1n", evr.Introduced.UpstreamVersion);
|
||||
Assert.Equal("0+deb11u2", evr.Introduced.Revision);
|
||||
using System;
|
||||
using Xunit;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Distro.Debian;
|
||||
using StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Tests;
|
||||
|
||||
public sealed class DebianMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_BuildsRangePrimitives_ForResolvedPackage()
|
||||
{
|
||||
var dto = new DebianAdvisoryDto(
|
||||
AdvisoryId: "DSA-2024-123",
|
||||
SourcePackage: "openssl",
|
||||
Title: "Openssl security update",
|
||||
Description: "Fixes multiple issues.",
|
||||
CveIds: new[] { "CVE-2024-1000", "CVE-2024-1001" },
|
||||
Packages: new[]
|
||||
{
|
||||
new DebianPackageStateDto(
|
||||
Package: "openssl",
|
||||
Release: "bullseye",
|
||||
Status: "resolved",
|
||||
IntroducedVersion: "1:1.1.1n-0+deb11u2",
|
||||
FixedVersion: "1:1.1.1n-0+deb11u5",
|
||||
LastAffectedVersion: null,
|
||||
Published: new DateTimeOffset(2024, 9, 1, 0, 0, 0, TimeSpan.Zero)),
|
||||
new DebianPackageStateDto(
|
||||
Package: "openssl",
|
||||
Release: "bookworm",
|
||||
Status: "open",
|
||||
IntroducedVersion: null,
|
||||
FixedVersion: null,
|
||||
LastAffectedVersion: null,
|
||||
Published: null)
|
||||
},
|
||||
References: new[]
|
||||
{
|
||||
new DebianReferenceDto(
|
||||
Url: "https://security-tracker.debian.org/tracker/DSA-2024-123",
|
||||
Kind: "advisory",
|
||||
Title: "Debian Security Advisory 2024-123"),
|
||||
});
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Id: Guid.NewGuid(),
|
||||
SourceName: DebianConnectorPlugin.SourceName,
|
||||
Uri: "https://security-tracker.debian.org/tracker/DSA-2024-123",
|
||||
FetchedAt: new DateTimeOffset(2024, 9, 1, 1, 0, 0, TimeSpan.Zero),
|
||||
Sha256: "sha",
|
||||
Status: "Fetched",
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: null,
|
||||
LastModified: null,
|
||||
PayloadId: null);
|
||||
|
||||
Advisory advisory = DebianMapper.Map(dto, document, new DateTimeOffset(2024, 9, 1, 2, 0, 0, TimeSpan.Zero));
|
||||
|
||||
Assert.Equal("DSA-2024-123", advisory.AdvisoryKey);
|
||||
Assert.Contains("CVE-2024-1000", advisory.Aliases);
|
||||
Assert.Contains("CVE-2024-1001", advisory.Aliases);
|
||||
|
||||
var resolvedPackage = Assert.Single(advisory.AffectedPackages, p => p.Platform == "bullseye");
|
||||
var range = Assert.Single(resolvedPackage.VersionRanges);
|
||||
Assert.Equal("evr", range.RangeKind);
|
||||
Assert.Equal("1:1.1.1n-0+deb11u2", range.IntroducedVersion);
|
||||
Assert.Equal("1:1.1.1n-0+deb11u5", range.FixedVersion);
|
||||
Assert.NotNull(range.Primitives);
|
||||
var evr = range.Primitives!.Evr;
|
||||
Assert.NotNull(evr);
|
||||
Assert.NotNull(evr!.Introduced);
|
||||
Assert.Equal(1, evr.Introduced!.Epoch);
|
||||
Assert.Equal("1.1.1n", evr.Introduced.UpstreamVersion);
|
||||
Assert.Equal("0+deb11u2", evr.Introduced.Revision);
|
||||
Assert.NotNull(evr.Fixed);
|
||||
Assert.Equal(1, evr.Fixed!.Epoch);
|
||||
Assert.Equal("1.1.1n", evr.Fixed.UpstreamVersion);
|
||||
@@ -94,5 +94,5 @@ public sealed class DebianMapperTests
|
||||
var openPackage = Assert.Single(advisory.AffectedPackages, p => p.Platform == "bookworm");
|
||||
Assert.Empty(openPackage.VersionRanges);
|
||||
Assert.Empty(openPackage.NormalizedVersions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Distro.Suse;
|
||||
using StellaOps.Concelier.Connector.Distro.Suse.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Tests;
|
||||
|
||||
public sealed class SuseMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_BuildsNevraRangePrimitives()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "Suse", "Fixtures", "suse-su-2025_0001-1.json"));
|
||||
var dto = SuseCsafParser.Parse(json);
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Guid.NewGuid(),
|
||||
SuseConnectorPlugin.SourceName,
|
||||
"https://ftp.suse.com/pub/projects/security/csaf/suse-su-2025_0001-1.json",
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256",
|
||||
DocumentStatuses.PendingParse,
|
||||
"application/json",
|
||||
Headers: null,
|
||||
Metadata: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["suse.id"] = dto.AdvisoryId
|
||||
},
|
||||
Etag: "adv-1",
|
||||
LastModified: DateTimeOffset.UtcNow,
|
||||
GridFsId: ObjectId.Empty);
|
||||
|
||||
var mapped = SuseMapper.Map(dto, document, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(dto.AdvisoryId, mapped.AdvisoryKey);
|
||||
var package = Assert.Single(mapped.AffectedPackages);
|
||||
Assert.Equal(AffectedPackageTypes.Rpm, package.Type);
|
||||
var range = Assert.Single(package.VersionRanges);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Distro.Suse;
|
||||
using StellaOps.Concelier.Connector.Distro.Suse.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Tests;
|
||||
|
||||
public sealed class SuseMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_BuildsNevraRangePrimitives()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "Suse", "Fixtures", "suse-su-2025_0001-1.json"));
|
||||
var dto = SuseCsafParser.Parse(json);
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Guid.NewGuid(),
|
||||
SuseConnectorPlugin.SourceName,
|
||||
"https://ftp.suse.com/pub/projects/security/csaf/suse-su-2025_0001-1.json",
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256",
|
||||
DocumentStatuses.PendingParse,
|
||||
"application/json",
|
||||
Headers: null,
|
||||
Metadata: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["suse.id"] = dto.AdvisoryId
|
||||
},
|
||||
Etag: "adv-1",
|
||||
LastModified: DateTimeOffset.UtcNow,
|
||||
PayloadId: ObjectId.Empty);
|
||||
|
||||
var mapped = SuseMapper.Map(dto, document, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(dto.AdvisoryId, mapped.AdvisoryKey);
|
||||
var package = Assert.Single(mapped.AffectedPackages);
|
||||
Assert.Equal(AffectedPackageTypes.Rpm, package.Type);
|
||||
var range = Assert.Single(package.VersionRanges);
|
||||
Assert.Equal("nevra", range.RangeKind);
|
||||
Assert.NotNull(range.Primitives);
|
||||
Assert.NotNull(range.Primitives!.Nevra);
|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Ghsa.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Ghsa.Tests;
|
||||
|
||||
public sealed class GhsaConflictFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConflictFixture_MatchesSnapshot()
|
||||
{
|
||||
var recordedAt = new DateTimeOffset(2025, 3, 4, 8, 30, 0, TimeSpan.Zero);
|
||||
var document = new DocumentRecord(
|
||||
Id: Guid.Parse("2f5c4d67-fcac-4ec9-a8d4-8a9c5a6d0fc9"),
|
||||
SourceName: GhsaConnectorPlugin.SourceName,
|
||||
Uri: "https://github.com/advisories/GHSA-qqqq-wwww-eeee",
|
||||
FetchedAt: new DateTimeOffset(2025, 3, 3, 18, 0, 0, TimeSpan.Zero),
|
||||
Sha256: "sha256-ghsa-conflict-fixture",
|
||||
Status: "completed",
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: "\"etag-ghsa-conflict\"",
|
||||
LastModified: new DateTimeOffset(2025, 3, 3, 18, 0, 0, TimeSpan.Zero),
|
||||
GridFsId: null);
|
||||
|
||||
var dto = new GhsaRecordDto
|
||||
{
|
||||
GhsaId = "GHSA-qqqq-wwww-eeee",
|
||||
Summary = "Container escape in conflict-package",
|
||||
Description = "Container escape vulnerability allowing privilege escalation in conflict-package.",
|
||||
Severity = "HIGH",
|
||||
PublishedAt = new DateTimeOffset(2025, 2, 25, 0, 0, 0, TimeSpan.Zero),
|
||||
UpdatedAt = new DateTimeOffset(2025, 3, 2, 12, 0, 0, TimeSpan.Zero),
|
||||
Aliases = new[] { "GHSA-qqqq-wwww-eeee", "CVE-2025-4242" },
|
||||
References = new[]
|
||||
{
|
||||
new GhsaReferenceDto
|
||||
{
|
||||
Url = "https://github.com/advisories/GHSA-qqqq-wwww-eeee",
|
||||
Type = "ADVISORY"
|
||||
},
|
||||
new GhsaReferenceDto
|
||||
{
|
||||
Url = "https://github.com/conflict/package/releases/tag/v1.4.0",
|
||||
Type = "FIX"
|
||||
}
|
||||
},
|
||||
Affected = new[]
|
||||
{
|
||||
new GhsaAffectedDto
|
||||
{
|
||||
PackageName = "conflict/package",
|
||||
Ecosystem = "npm",
|
||||
VulnerableRange = "< 1.4.0",
|
||||
PatchedVersion = "1.4.0"
|
||||
}
|
||||
},
|
||||
Credits = new[]
|
||||
{
|
||||
new GhsaCreditDto
|
||||
{
|
||||
Type = "reporter",
|
||||
Name = "security-researcher",
|
||||
Login = "sec-researcher",
|
||||
ProfileUrl = "https://github.com/sec-researcher"
|
||||
},
|
||||
new GhsaCreditDto
|
||||
{
|
||||
Type = "remediation_developer",
|
||||
Name = "maintainer-team",
|
||||
Login = "conflict-maintainer",
|
||||
ProfileUrl = "https://github.com/conflict/package"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var advisory = GhsaMapper.Map(dto, document, recordedAt);
|
||||
Assert.Equal("ghsa:severity/high", advisory.CanonicalMetricId);
|
||||
Assert.True(advisory.CvssMetrics.IsEmpty);
|
||||
var snapshot = SnapshotSerializer.ToSnapshot(advisory).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "conflict-ghsa.canonical.json");
|
||||
var expected = File.ReadAllText(expectedPath).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "conflict-ghsa.canonical.actual.json");
|
||||
File.WriteAllText(actualPath, snapshot);
|
||||
}
|
||||
|
||||
Assert.Equal(expected, snapshot);
|
||||
}
|
||||
}
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Ghsa.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Ghsa.Tests;
|
||||
|
||||
public sealed class GhsaConflictFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConflictFixture_MatchesSnapshot()
|
||||
{
|
||||
var recordedAt = new DateTimeOffset(2025, 3, 4, 8, 30, 0, TimeSpan.Zero);
|
||||
var document = new DocumentRecord(
|
||||
Id: Guid.Parse("2f5c4d67-fcac-4ec9-a8d4-8a9c5a6d0fc9"),
|
||||
SourceName: GhsaConnectorPlugin.SourceName,
|
||||
Uri: "https://github.com/advisories/GHSA-qqqq-wwww-eeee",
|
||||
FetchedAt: new DateTimeOffset(2025, 3, 3, 18, 0, 0, TimeSpan.Zero),
|
||||
Sha256: "sha256-ghsa-conflict-fixture",
|
||||
Status: "completed",
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: "\"etag-ghsa-conflict\"",
|
||||
LastModified: new DateTimeOffset(2025, 3, 3, 18, 0, 0, TimeSpan.Zero),
|
||||
PayloadId: null);
|
||||
|
||||
var dto = new GhsaRecordDto
|
||||
{
|
||||
GhsaId = "GHSA-qqqq-wwww-eeee",
|
||||
Summary = "Container escape in conflict-package",
|
||||
Description = "Container escape vulnerability allowing privilege escalation in conflict-package.",
|
||||
Severity = "HIGH",
|
||||
PublishedAt = new DateTimeOffset(2025, 2, 25, 0, 0, 0, TimeSpan.Zero),
|
||||
UpdatedAt = new DateTimeOffset(2025, 3, 2, 12, 0, 0, TimeSpan.Zero),
|
||||
Aliases = new[] { "GHSA-qqqq-wwww-eeee", "CVE-2025-4242" },
|
||||
References = new[]
|
||||
{
|
||||
new GhsaReferenceDto
|
||||
{
|
||||
Url = "https://github.com/advisories/GHSA-qqqq-wwww-eeee",
|
||||
Type = "ADVISORY"
|
||||
},
|
||||
new GhsaReferenceDto
|
||||
{
|
||||
Url = "https://github.com/conflict/package/releases/tag/v1.4.0",
|
||||
Type = "FIX"
|
||||
}
|
||||
},
|
||||
Affected = new[]
|
||||
{
|
||||
new GhsaAffectedDto
|
||||
{
|
||||
PackageName = "conflict/package",
|
||||
Ecosystem = "npm",
|
||||
VulnerableRange = "< 1.4.0",
|
||||
PatchedVersion = "1.4.0"
|
||||
}
|
||||
},
|
||||
Credits = new[]
|
||||
{
|
||||
new GhsaCreditDto
|
||||
{
|
||||
Type = "reporter",
|
||||
Name = "security-researcher",
|
||||
Login = "sec-researcher",
|
||||
ProfileUrl = "https://github.com/sec-researcher"
|
||||
},
|
||||
new GhsaCreditDto
|
||||
{
|
||||
Type = "remediation_developer",
|
||||
Name = "maintainer-team",
|
||||
Login = "conflict-maintainer",
|
||||
ProfileUrl = "https://github.com/conflict/package"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var advisory = GhsaMapper.Map(dto, document, recordedAt);
|
||||
Assert.Equal("ghsa:severity/high", advisory.CanonicalMetricId);
|
||||
Assert.True(advisory.CvssMetrics.IsEmpty);
|
||||
var snapshot = SnapshotSerializer.ToSnapshot(advisory).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "conflict-ghsa.canonical.json");
|
||||
var expected = File.ReadAllText(expectedPath).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "conflict-ghsa.canonical.actual.json");
|
||||
File.WriteAllText(actualPath, snapshot);
|
||||
}
|
||||
|
||||
Assert.Equal(expected, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class GhsaMapperTests
|
||||
Metadata: null,
|
||||
Etag: "\"etag-ghsa-fallback\"",
|
||||
LastModified: recordedAt.AddHours(-3),
|
||||
GridFsId: null);
|
||||
PayloadId: null);
|
||||
|
||||
var dto = new GhsaRecordDto
|
||||
{
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Nvd.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Nvd.Tests;
|
||||
|
||||
public sealed class NvdConflictFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConflictFixture_MatchesSnapshot()
|
||||
{
|
||||
const string payload = """
|
||||
{
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": {
|
||||
"id": "CVE-2025-4242",
|
||||
"published": "2025-03-01T10:15:00Z",
|
||||
"lastModified": "2025-03-03T09:45:00Z",
|
||||
"descriptions": [
|
||||
{ "lang": "en", "value": "NVD baseline summary for conflict-package allowing container escape." }
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"url": "https://nvd.nist.gov/vuln/detail/CVE-2025-4242",
|
||||
"source": "NVD",
|
||||
"tags": ["Vendor Advisory"]
|
||||
}
|
||||
],
|
||||
"weaknesses": [
|
||||
{
|
||||
"description": [
|
||||
{ "lang": "en", "value": "CWE-269" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"metrics": {
|
||||
"cvssMetricV31": [
|
||||
{
|
||||
"cvssData": {
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"baseScore": 9.8,
|
||||
"baseSeverity": "CRITICAL"
|
||||
},
|
||||
"exploitabilityScore": 3.9,
|
||||
"impactScore": 5.9
|
||||
}
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"nodes": [
|
||||
{
|
||||
"cpeMatch": [
|
||||
{
|
||||
"criteria": "cpe:2.3:a:conflict:package:1.0:*:*:*:*:*:*:*",
|
||||
"vulnerable": true,
|
||||
"versionStartIncluding": "1.0",
|
||||
"versionEndExcluding": "1.4"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
|
||||
var sourceDocument = new DocumentRecord(
|
||||
Id: Guid.Parse("1a6a0700-2dd0-4f69-bb37-64ca77e51c91"),
|
||||
SourceName: NvdConnectorPlugin.SourceName,
|
||||
Uri: "https://services.nvd.nist.gov/rest/json/cve/2.0?cveId=CVE-2025-4242",
|
||||
FetchedAt: new DateTimeOffset(2025, 3, 3, 10, 0, 0, TimeSpan.Zero),
|
||||
Sha256: "sha256-nvd-conflict-fixture",
|
||||
Status: "completed",
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: "\"etag-nvd-conflict\"",
|
||||
LastModified: new DateTimeOffset(2025, 3, 3, 9, 45, 0, TimeSpan.Zero),
|
||||
GridFsId: null);
|
||||
|
||||
var advisories = NvdMapper.Map(document, sourceDocument, new DateTimeOffset(2025, 3, 4, 2, 0, 0, TimeSpan.Zero));
|
||||
var advisory = Assert.Single(advisories);
|
||||
|
||||
var snapshot = SnapshotSerializer.ToSnapshot(advisory).Replace("\r\n", "\n").TrimEnd();
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, "Nvd", "Fixtures", "conflict-nvd.canonical.json");
|
||||
var expected = File.ReadAllText(expectedPath).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(AppContext.BaseDirectory, "Nvd", "Fixtures", "conflict-nvd.canonical.actual.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
|
||||
File.WriteAllText(actualPath, snapshot);
|
||||
}
|
||||
|
||||
Assert.Equal(expected, snapshot);
|
||||
}
|
||||
}
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Nvd.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Nvd.Tests;
|
||||
|
||||
public sealed class NvdConflictFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConflictFixture_MatchesSnapshot()
|
||||
{
|
||||
const string payload = """
|
||||
{
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": {
|
||||
"id": "CVE-2025-4242",
|
||||
"published": "2025-03-01T10:15:00Z",
|
||||
"lastModified": "2025-03-03T09:45:00Z",
|
||||
"descriptions": [
|
||||
{ "lang": "en", "value": "NVD baseline summary for conflict-package allowing container escape." }
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"url": "https://nvd.nist.gov/vuln/detail/CVE-2025-4242",
|
||||
"source": "NVD",
|
||||
"tags": ["Vendor Advisory"]
|
||||
}
|
||||
],
|
||||
"weaknesses": [
|
||||
{
|
||||
"description": [
|
||||
{ "lang": "en", "value": "CWE-269" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"metrics": {
|
||||
"cvssMetricV31": [
|
||||
{
|
||||
"cvssData": {
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"baseScore": 9.8,
|
||||
"baseSeverity": "CRITICAL"
|
||||
},
|
||||
"exploitabilityScore": 3.9,
|
||||
"impactScore": 5.9
|
||||
}
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"nodes": [
|
||||
{
|
||||
"cpeMatch": [
|
||||
{
|
||||
"criteria": "cpe:2.3:a:conflict:package:1.0:*:*:*:*:*:*:*",
|
||||
"vulnerable": true,
|
||||
"versionStartIncluding": "1.0",
|
||||
"versionEndExcluding": "1.4"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
|
||||
var sourceDocument = new DocumentRecord(
|
||||
Id: Guid.Parse("1a6a0700-2dd0-4f69-bb37-64ca77e51c91"),
|
||||
SourceName: NvdConnectorPlugin.SourceName,
|
||||
Uri: "https://services.nvd.nist.gov/rest/json/cve/2.0?cveId=CVE-2025-4242",
|
||||
FetchedAt: new DateTimeOffset(2025, 3, 3, 10, 0, 0, TimeSpan.Zero),
|
||||
Sha256: "sha256-nvd-conflict-fixture",
|
||||
Status: "completed",
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: "\"etag-nvd-conflict\"",
|
||||
LastModified: new DateTimeOffset(2025, 3, 3, 9, 45, 0, TimeSpan.Zero),
|
||||
PayloadId: null);
|
||||
|
||||
var advisories = NvdMapper.Map(document, sourceDocument, new DateTimeOffset(2025, 3, 4, 2, 0, 0, TimeSpan.Zero));
|
||||
var advisory = Assert.Single(advisories);
|
||||
|
||||
var snapshot = SnapshotSerializer.ToSnapshot(advisory).Replace("\r\n", "\n").TrimEnd();
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, "Nvd", "Fixtures", "conflict-nvd.canonical.json");
|
||||
var expected = File.ReadAllText(expectedPath).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(AppContext.BaseDirectory, "Nvd", "Fixtures", "conflict-nvd.canonical.actual.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
|
||||
File.WriteAllText(actualPath, snapshot);
|
||||
}
|
||||
|
||||
Assert.Equal(expected, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
using System.Text.Json;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Osv.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Osv.Tests;
|
||||
|
||||
public sealed class OsvConflictFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConflictFixture_MatchesSnapshot()
|
||||
{
|
||||
using var databaseSpecificDoc = JsonDocument.Parse("""{"severity":"medium"}""");
|
||||
|
||||
var dto = new OsvVulnerabilityDto
|
||||
{
|
||||
Id = "OSV-2025-4242",
|
||||
Summary = "Container escape for conflict-package",
|
||||
Details = "OSV captures the latest container escape details including patched version metadata.",
|
||||
Aliases = new[] { "CVE-2025-4242", "GHSA-qqqq-wwww-eeee" },
|
||||
Published = new DateTimeOffset(2025, 2, 28, 0, 0, 0, TimeSpan.Zero),
|
||||
Modified = new DateTimeOffset(2025, 3, 6, 12, 0, 0, TimeSpan.Zero),
|
||||
Severity = new[]
|
||||
{
|
||||
new OsvSeverityDto
|
||||
{
|
||||
Type = "CVSS_V3",
|
||||
Score = "CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:L"
|
||||
}
|
||||
},
|
||||
References = new[]
|
||||
{
|
||||
new OsvReferenceDto
|
||||
{
|
||||
Type = "ADVISORY",
|
||||
Url = "https://osv.dev/vulnerability/OSV-2025-4242"
|
||||
},
|
||||
new OsvReferenceDto
|
||||
{
|
||||
Type = "FIX",
|
||||
Url = "https://github.com/conflict/package/commit/abcdef1234567890"
|
||||
}
|
||||
},
|
||||
Credits = new[]
|
||||
{
|
||||
new OsvCreditDto
|
||||
{
|
||||
Name = "osv-reporter",
|
||||
Type = "reporter",
|
||||
Contact = new[] { "mailto:osv-reporter@example.com" }
|
||||
}
|
||||
},
|
||||
Affected = new[]
|
||||
{
|
||||
new OsvAffectedPackageDto
|
||||
{
|
||||
Package = new OsvPackageDto
|
||||
{
|
||||
Ecosystem = "npm",
|
||||
Name = "conflict/package"
|
||||
},
|
||||
Ranges = new[]
|
||||
{
|
||||
new OsvRangeDto
|
||||
{
|
||||
Type = "SEMVER",
|
||||
Events = new[]
|
||||
{
|
||||
new OsvEventDto { Introduced = "1.0.0" },
|
||||
new OsvEventDto { LastAffected = "1.4.2" },
|
||||
new OsvEventDto { Fixed = "1.5.0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
DatabaseSpecific = databaseSpecificDoc.RootElement.Clone()
|
||||
};
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Id: Guid.Parse("8dd2b0fe-a5f5-4b3b-9f5c-0f3aad6fb6ce"),
|
||||
SourceName: OsvConnectorPlugin.SourceName,
|
||||
Uri: "https://api.osv.dev/v1/vulns/OSV-2025-4242",
|
||||
FetchedAt: new DateTimeOffset(2025, 3, 6, 11, 30, 0, TimeSpan.Zero),
|
||||
Sha256: "sha256-osv-conflict-fixture",
|
||||
Status: "completed",
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: "\"etag-osv-conflict\"",
|
||||
LastModified: new DateTimeOffset(2025, 3, 6, 12, 0, 0, TimeSpan.Zero),
|
||||
GridFsId: null);
|
||||
|
||||
var dtoRecord = new DtoRecord(
|
||||
Id: Guid.Parse("6f7d5ce7-cb47-40a5-8b41-8ad022b5fd5c"),
|
||||
DocumentId: document.Id,
|
||||
SourceName: OsvConnectorPlugin.SourceName,
|
||||
SchemaVersion: "osv.v1",
|
||||
Payload: new BsonDocument("id", dto.Id),
|
||||
ValidatedAt: new DateTimeOffset(2025, 3, 6, 12, 5, 0, TimeSpan.Zero));
|
||||
|
||||
var advisory = OsvMapper.Map(dto, document, dtoRecord, "npm");
|
||||
var snapshot = SnapshotSerializer.ToSnapshot(advisory).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "conflict-osv.canonical.json");
|
||||
var expected = File.ReadAllText(expectedPath).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "conflict-osv.canonical.actual.json");
|
||||
File.WriteAllText(actualPath, snapshot);
|
||||
}
|
||||
|
||||
Assert.Equal(expected, snapshot);
|
||||
}
|
||||
}
|
||||
using System.Text.Json;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Osv.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Osv.Tests;
|
||||
|
||||
public sealed class OsvConflictFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConflictFixture_MatchesSnapshot()
|
||||
{
|
||||
using var databaseSpecificDoc = JsonDocument.Parse("""{"severity":"medium"}""");
|
||||
|
||||
var dto = new OsvVulnerabilityDto
|
||||
{
|
||||
Id = "OSV-2025-4242",
|
||||
Summary = "Container escape for conflict-package",
|
||||
Details = "OSV captures the latest container escape details including patched version metadata.",
|
||||
Aliases = new[] { "CVE-2025-4242", "GHSA-qqqq-wwww-eeee" },
|
||||
Published = new DateTimeOffset(2025, 2, 28, 0, 0, 0, TimeSpan.Zero),
|
||||
Modified = new DateTimeOffset(2025, 3, 6, 12, 0, 0, TimeSpan.Zero),
|
||||
Severity = new[]
|
||||
{
|
||||
new OsvSeverityDto
|
||||
{
|
||||
Type = "CVSS_V3",
|
||||
Score = "CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:L"
|
||||
}
|
||||
},
|
||||
References = new[]
|
||||
{
|
||||
new OsvReferenceDto
|
||||
{
|
||||
Type = "ADVISORY",
|
||||
Url = "https://osv.dev/vulnerability/OSV-2025-4242"
|
||||
},
|
||||
new OsvReferenceDto
|
||||
{
|
||||
Type = "FIX",
|
||||
Url = "https://github.com/conflict/package/commit/abcdef1234567890"
|
||||
}
|
||||
},
|
||||
Credits = new[]
|
||||
{
|
||||
new OsvCreditDto
|
||||
{
|
||||
Name = "osv-reporter",
|
||||
Type = "reporter",
|
||||
Contact = new[] { "mailto:osv-reporter@example.com" }
|
||||
}
|
||||
},
|
||||
Affected = new[]
|
||||
{
|
||||
new OsvAffectedPackageDto
|
||||
{
|
||||
Package = new OsvPackageDto
|
||||
{
|
||||
Ecosystem = "npm",
|
||||
Name = "conflict/package"
|
||||
},
|
||||
Ranges = new[]
|
||||
{
|
||||
new OsvRangeDto
|
||||
{
|
||||
Type = "SEMVER",
|
||||
Events = new[]
|
||||
{
|
||||
new OsvEventDto { Introduced = "1.0.0" },
|
||||
new OsvEventDto { LastAffected = "1.4.2" },
|
||||
new OsvEventDto { Fixed = "1.5.0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
DatabaseSpecific = databaseSpecificDoc.RootElement.Clone()
|
||||
};
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Id: Guid.Parse("8dd2b0fe-a5f5-4b3b-9f5c-0f3aad6fb6ce"),
|
||||
SourceName: OsvConnectorPlugin.SourceName,
|
||||
Uri: "https://api.osv.dev/v1/vulns/OSV-2025-4242",
|
||||
FetchedAt: new DateTimeOffset(2025, 3, 6, 11, 30, 0, TimeSpan.Zero),
|
||||
Sha256: "sha256-osv-conflict-fixture",
|
||||
Status: "completed",
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: "\"etag-osv-conflict\"",
|
||||
LastModified: new DateTimeOffset(2025, 3, 6, 12, 0, 0, TimeSpan.Zero),
|
||||
PayloadId: null);
|
||||
|
||||
var dtoRecord = new DtoRecord(
|
||||
Id: Guid.Parse("6f7d5ce7-cb47-40a5-8b41-8ad022b5fd5c"),
|
||||
DocumentId: document.Id,
|
||||
SourceName: OsvConnectorPlugin.SourceName,
|
||||
SchemaVersion: "osv.v1",
|
||||
Payload: new BsonDocument("id", dto.Id),
|
||||
ValidatedAt: new DateTimeOffset(2025, 3, 6, 12, 5, 0, TimeSpan.Zero));
|
||||
|
||||
var advisory = OsvMapper.Map(dto, document, dtoRecord, "npm");
|
||||
var snapshot = SnapshotSerializer.ToSnapshot(advisory).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "conflict-osv.canonical.json");
|
||||
var expected = File.ReadAllText(expectedPath).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "conflict-osv.canonical.actual.json");
|
||||
File.WriteAllText(actualPath, snapshot);
|
||||
}
|
||||
|
||||
Assert.Equal(expected, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,463 +1,463 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public StellaOpsMirrorConnectorTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_PersistsMirrorArtifacts()
|
||||
{
|
||||
var manifestContent = "{\"domain\":\"primary\",\"files\":[]}";
|
||||
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0001\"}]}";
|
||||
|
||||
var manifestDigest = ComputeDigest(manifestContent);
|
||||
var bundleDigest = ComputeDigest(bundleContent);
|
||||
|
||||
var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: false);
|
||||
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
|
||||
SeedResponses(index, manifestContent, bundleContent, signature: null);
|
||||
|
||||
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var manifestUri = "https://mirror.test/mirror/primary/manifest.json";
|
||||
var bundleUri = "https://mirror.test/mirror/primary/bundle.json";
|
||||
|
||||
var manifestDocument = await documentStore.FindBySourceAndUriAsync(StellaOpsMirrorConnector.Source, manifestUri, CancellationToken.None);
|
||||
Assert.NotNull(manifestDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, manifestDocument!.Status);
|
||||
Assert.Equal(NormalizeDigest(manifestDigest), manifestDocument.Sha256);
|
||||
|
||||
var bundleDocument = await documentStore.FindBySourceAndUriAsync(StellaOpsMirrorConnector.Source, bundleUri, CancellationToken.None);
|
||||
Assert.NotNull(bundleDocument);
|
||||
Assert.Equal(DocumentStatuses.PendingParse, bundleDocument!.Status);
|
||||
Assert.Equal(NormalizeDigest(bundleDigest), bundleDocument.Sha256);
|
||||
|
||||
var rawStorage = provider.GetRequiredService<RawDocumentStorage>();
|
||||
Assert.NotNull(manifestDocument.GridFsId);
|
||||
Assert.NotNull(bundleDocument.GridFsId);
|
||||
|
||||
var manifestBytes = await rawStorage.DownloadAsync(manifestDocument.GridFsId!.Value, CancellationToken.None);
|
||||
var bundleBytes = await rawStorage.DownloadAsync(bundleDocument.GridFsId!.Value, CancellationToken.None);
|
||||
Assert.Equal(manifestContent, Encoding.UTF8.GetString(manifestBytes));
|
||||
Assert.Equal(bundleContent, Encoding.UTF8.GetString(bundleBytes));
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
|
||||
var cursorDocument = state!.Cursor ?? new BsonDocument();
|
||||
var digestValue = cursorDocument.TryGetValue("bundleDigest", out var digestBson) ? digestBson.AsString : string.Empty;
|
||||
Assert.Equal(NormalizeDigest(bundleDigest), NormalizeDigest(digestValue));
|
||||
|
||||
var pendingDocumentsArray = cursorDocument.TryGetValue("pendingDocuments", out var pendingDocsBson) && pendingDocsBson is BsonArray pendingArray
|
||||
? pendingArray
|
||||
: new BsonArray();
|
||||
Assert.Single(pendingDocumentsArray);
|
||||
var pendingDocumentId = Guid.Parse(pendingDocumentsArray[0].AsString);
|
||||
Assert.Equal(bundleDocument.Id, pendingDocumentId);
|
||||
|
||||
var pendingMappingsArray = cursorDocument.TryGetValue("pendingMappings", out var pendingMappingsBson) && pendingMappingsBson is BsonArray mappingsArray
|
||||
? mappingsArray
|
||||
: new BsonArray();
|
||||
Assert.Empty(pendingMappingsArray);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_TamperedSignatureThrows()
|
||||
{
|
||||
var manifestContent = "{\"domain\":\"primary\"}";
|
||||
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0002\"}]}";
|
||||
|
||||
var manifestDigest = ComputeDigest(manifestContent);
|
||||
var bundleDigest = ComputeDigest(bundleContent);
|
||||
var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: true);
|
||||
|
||||
await using var provider = await BuildServiceProviderAsync(options =>
|
||||
{
|
||||
options.Signature.Enabled = true;
|
||||
options.Signature.KeyId = "mirror-key";
|
||||
options.Signature.Provider = "default";
|
||||
});
|
||||
|
||||
var defaultProvider = provider.GetRequiredService<DefaultCryptoProvider>();
|
||||
var signingKey = CreateSigningKey("mirror-key");
|
||||
defaultProvider.UpsertSigningKey(signingKey);
|
||||
|
||||
var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent);
|
||||
// Tamper with signature so verification fails.
|
||||
var tamperedSignature = signatureValue.Replace('a', 'b');
|
||||
|
||||
SeedResponses(index, manifestContent, bundleContent, tamperedSignature);
|
||||
|
||||
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.FailCount >= 1);
|
||||
Assert.False(state.Cursor.TryGetValue("bundleDigest", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_SignatureKeyMismatchThrows()
|
||||
{
|
||||
var manifestContent = "{\"domain\":\"primary\"}";
|
||||
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0003\"}]}";
|
||||
|
||||
var manifestDigest = ComputeDigest(manifestContent);
|
||||
var bundleDigest = ComputeDigest(bundleContent);
|
||||
var index = BuildIndex(
|
||||
manifestDigest,
|
||||
Encoding.UTF8.GetByteCount(manifestContent),
|
||||
bundleDigest,
|
||||
Encoding.UTF8.GetByteCount(bundleContent),
|
||||
includeSignature: true,
|
||||
signatureKeyId: "unexpected-key",
|
||||
signatureProvider: "default");
|
||||
|
||||
var signingKey = CreateSigningKey("unexpected-key");
|
||||
var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent);
|
||||
|
||||
await using var provider = await BuildServiceProviderAsync(options =>
|
||||
{
|
||||
options.Signature.Enabled = true;
|
||||
options.Signature.KeyId = "mirror-key";
|
||||
options.Signature.Provider = "default";
|
||||
});
|
||||
|
||||
SeedResponses(index, manifestContent, bundleContent, signatureValue);
|
||||
|
||||
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_VerifiesSignatureUsingFallbackPublicKey()
|
||||
{
|
||||
var manifestContent = "{\"domain\":\"primary\"}";
|
||||
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0004\"}]}";
|
||||
|
||||
var manifestDigest = ComputeDigest(manifestContent);
|
||||
var bundleDigest = ComputeDigest(bundleContent);
|
||||
var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: true);
|
||||
|
||||
var signingKey = CreateSigningKey("mirror-key");
|
||||
var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent);
|
||||
var publicKeyPath = WritePublicKeyPem(signingKey);
|
||||
|
||||
await using var provider = await BuildServiceProviderAsync(options =>
|
||||
{
|
||||
options.Signature.Enabled = true;
|
||||
options.Signature.KeyId = "mirror-key";
|
||||
options.Signature.Provider = "default";
|
||||
options.Signature.PublicKeyPath = publicKeyPath;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
SeedResponses(index, manifestContent, bundleContent, signatureValue);
|
||||
|
||||
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal(0, state!.FailCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(publicKeyPath))
|
||||
{
|
||||
File.Delete(publicKeyPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_DigestMismatchMarksFailure()
|
||||
{
|
||||
var manifestExpected = "{\"domain\":\"primary\"}";
|
||||
var manifestTampered = "{\"domain\":\"tampered\"}";
|
||||
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0005\"}]}";
|
||||
|
||||
var manifestDigest = ComputeDigest(manifestExpected);
|
||||
var bundleDigest = ComputeDigest(bundleContent);
|
||||
var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestExpected), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: false);
|
||||
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
|
||||
SeedResponses(index, manifestTampered, bundleContent, signature: null);
|
||||
|
||||
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
var cursor = state!.Cursor ?? new BsonDocument();
|
||||
Assert.True(state.FailCount >= 1);
|
||||
Assert.False(cursor.Contains("bundleDigest"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAndMap_PersistAdvisoriesFromBundle()
|
||||
{
|
||||
var bundleDocument = SampleData.CreateBundle();
|
||||
var bundleJson = CanonicalJsonSerializer.SerializeIndented(bundleDocument);
|
||||
var normalizedFixture = FixtureLoader.Read(SampleData.BundleFixture).TrimEnd();
|
||||
Assert.Equal(normalizedFixture, FixtureLoader.Normalize(bundleJson).TrimEnd());
|
||||
|
||||
var advisories = MirrorAdvisoryMapper.Map(bundleDocument);
|
||||
Assert.Single(advisories);
|
||||
var advisory = advisories[0];
|
||||
|
||||
var expectedAdvisoryJson = FixtureLoader.Read(SampleData.AdvisoryFixture).TrimEnd();
|
||||
var mappedJson = CanonicalJsonSerializer.SerializeIndented(advisory);
|
||||
Assert.Equal(expectedAdvisoryJson, FixtureLoader.Normalize(mappedJson).TrimEnd());
|
||||
|
||||
// AdvisoryStore integration validated elsewhere; ensure canonical serialization is stable.
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_handler.Clear();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync(Action<StellaOpsMirrorConnectorOptions>? configureOptions = null)
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton(_handler);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public StellaOpsMirrorConnectorTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_PersistsMirrorArtifacts()
|
||||
{
|
||||
var manifestContent = "{\"domain\":\"primary\",\"files\":[]}";
|
||||
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0001\"}]}";
|
||||
|
||||
var manifestDigest = ComputeDigest(manifestContent);
|
||||
var bundleDigest = ComputeDigest(bundleContent);
|
||||
|
||||
var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: false);
|
||||
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
|
||||
SeedResponses(index, manifestContent, bundleContent, signature: null);
|
||||
|
||||
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var manifestUri = "https://mirror.test/mirror/primary/manifest.json";
|
||||
var bundleUri = "https://mirror.test/mirror/primary/bundle.json";
|
||||
|
||||
var manifestDocument = await documentStore.FindBySourceAndUriAsync(StellaOpsMirrorConnector.Source, manifestUri, CancellationToken.None);
|
||||
Assert.NotNull(manifestDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, manifestDocument!.Status);
|
||||
Assert.Equal(NormalizeDigest(manifestDigest), manifestDocument.Sha256);
|
||||
|
||||
var bundleDocument = await documentStore.FindBySourceAndUriAsync(StellaOpsMirrorConnector.Source, bundleUri, CancellationToken.None);
|
||||
Assert.NotNull(bundleDocument);
|
||||
Assert.Equal(DocumentStatuses.PendingParse, bundleDocument!.Status);
|
||||
Assert.Equal(NormalizeDigest(bundleDigest), bundleDocument.Sha256);
|
||||
|
||||
var rawStorage = provider.GetRequiredService<RawDocumentStorage>();
|
||||
Assert.NotNull(manifestDocument.PayloadId);
|
||||
Assert.NotNull(bundleDocument.PayloadId);
|
||||
|
||||
var manifestBytes = await rawStorage.DownloadAsync(manifestDocument.PayloadId!.Value, CancellationToken.None);
|
||||
var bundleBytes = await rawStorage.DownloadAsync(bundleDocument.PayloadId!.Value, CancellationToken.None);
|
||||
Assert.Equal(manifestContent, Encoding.UTF8.GetString(manifestBytes));
|
||||
Assert.Equal(bundleContent, Encoding.UTF8.GetString(bundleBytes));
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
|
||||
var cursorDocument = state!.Cursor ?? new BsonDocument();
|
||||
var digestValue = cursorDocument.TryGetValue("bundleDigest", out var digestBson) ? digestBson.AsString : string.Empty;
|
||||
Assert.Equal(NormalizeDigest(bundleDigest), NormalizeDigest(digestValue));
|
||||
|
||||
var pendingDocumentsArray = cursorDocument.TryGetValue("pendingDocuments", out var pendingDocsBson) && pendingDocsBson is BsonArray pendingArray
|
||||
? pendingArray
|
||||
: new BsonArray();
|
||||
Assert.Single(pendingDocumentsArray);
|
||||
var pendingDocumentId = Guid.Parse(pendingDocumentsArray[0].AsString);
|
||||
Assert.Equal(bundleDocument.Id, pendingDocumentId);
|
||||
|
||||
var pendingMappingsArray = cursorDocument.TryGetValue("pendingMappings", out var pendingMappingsBson) && pendingMappingsBson is BsonArray mappingsArray
|
||||
? mappingsArray
|
||||
: new BsonArray();
|
||||
Assert.Empty(pendingMappingsArray);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_TamperedSignatureThrows()
|
||||
{
|
||||
var manifestContent = "{\"domain\":\"primary\"}";
|
||||
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0002\"}]}";
|
||||
|
||||
var manifestDigest = ComputeDigest(manifestContent);
|
||||
var bundleDigest = ComputeDigest(bundleContent);
|
||||
var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: true);
|
||||
|
||||
await using var provider = await BuildServiceProviderAsync(options =>
|
||||
{
|
||||
options.Signature.Enabled = true;
|
||||
options.Signature.KeyId = "mirror-key";
|
||||
options.Signature.Provider = "default";
|
||||
});
|
||||
|
||||
var defaultProvider = provider.GetRequiredService<DefaultCryptoProvider>();
|
||||
var signingKey = CreateSigningKey("mirror-key");
|
||||
defaultProvider.UpsertSigningKey(signingKey);
|
||||
|
||||
var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent);
|
||||
// Tamper with signature so verification fails.
|
||||
var tamperedSignature = signatureValue.Replace('a', 'b');
|
||||
|
||||
SeedResponses(index, manifestContent, bundleContent, tamperedSignature);
|
||||
|
||||
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.FailCount >= 1);
|
||||
Assert.False(state.Cursor.TryGetValue("bundleDigest", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_SignatureKeyMismatchThrows()
|
||||
{
|
||||
var manifestContent = "{\"domain\":\"primary\"}";
|
||||
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0003\"}]}";
|
||||
|
||||
var manifestDigest = ComputeDigest(manifestContent);
|
||||
var bundleDigest = ComputeDigest(bundleContent);
|
||||
var index = BuildIndex(
|
||||
manifestDigest,
|
||||
Encoding.UTF8.GetByteCount(manifestContent),
|
||||
bundleDigest,
|
||||
Encoding.UTF8.GetByteCount(bundleContent),
|
||||
includeSignature: true,
|
||||
signatureKeyId: "unexpected-key",
|
||||
signatureProvider: "default");
|
||||
|
||||
var signingKey = CreateSigningKey("unexpected-key");
|
||||
var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent);
|
||||
|
||||
await using var provider = await BuildServiceProviderAsync(options =>
|
||||
{
|
||||
options.Signature.Enabled = true;
|
||||
options.Signature.KeyId = "mirror-key";
|
||||
options.Signature.Provider = "default";
|
||||
});
|
||||
|
||||
SeedResponses(index, manifestContent, bundleContent, signatureValue);
|
||||
|
||||
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_VerifiesSignatureUsingFallbackPublicKey()
|
||||
{
|
||||
var manifestContent = "{\"domain\":\"primary\"}";
|
||||
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0004\"}]}";
|
||||
|
||||
var manifestDigest = ComputeDigest(manifestContent);
|
||||
var bundleDigest = ComputeDigest(bundleContent);
|
||||
var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: true);
|
||||
|
||||
var signingKey = CreateSigningKey("mirror-key");
|
||||
var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent);
|
||||
var publicKeyPath = WritePublicKeyPem(signingKey);
|
||||
|
||||
await using var provider = await BuildServiceProviderAsync(options =>
|
||||
{
|
||||
options.Signature.Enabled = true;
|
||||
options.Signature.KeyId = "mirror-key";
|
||||
options.Signature.Provider = "default";
|
||||
options.Signature.PublicKeyPath = publicKeyPath;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
SeedResponses(index, manifestContent, bundleContent, signatureValue);
|
||||
|
||||
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal(0, state!.FailCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(publicKeyPath))
|
||||
{
|
||||
File.Delete(publicKeyPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_DigestMismatchMarksFailure()
|
||||
{
|
||||
var manifestExpected = "{\"domain\":\"primary\"}";
|
||||
var manifestTampered = "{\"domain\":\"tampered\"}";
|
||||
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0005\"}]}";
|
||||
|
||||
var manifestDigest = ComputeDigest(manifestExpected);
|
||||
var bundleDigest = ComputeDigest(bundleContent);
|
||||
var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestExpected), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: false);
|
||||
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
|
||||
SeedResponses(index, manifestTampered, bundleContent, signature: null);
|
||||
|
||||
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
var cursor = state!.Cursor ?? new BsonDocument();
|
||||
Assert.True(state.FailCount >= 1);
|
||||
Assert.False(cursor.Contains("bundleDigest"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAndMap_PersistAdvisoriesFromBundle()
|
||||
{
|
||||
var bundleDocument = SampleData.CreateBundle();
|
||||
var bundleJson = CanonicalJsonSerializer.SerializeIndented(bundleDocument);
|
||||
var normalizedFixture = FixtureLoader.Read(SampleData.BundleFixture).TrimEnd();
|
||||
Assert.Equal(normalizedFixture, FixtureLoader.Normalize(bundleJson).TrimEnd());
|
||||
|
||||
var advisories = MirrorAdvisoryMapper.Map(bundleDocument);
|
||||
Assert.Single(advisories);
|
||||
var advisory = advisories[0];
|
||||
|
||||
var expectedAdvisoryJson = FixtureLoader.Read(SampleData.AdvisoryFixture).TrimEnd();
|
||||
var mappedJson = CanonicalJsonSerializer.SerializeIndented(advisory);
|
||||
Assert.Equal(expectedAdvisoryJson, FixtureLoader.Normalize(mappedJson).TrimEnd());
|
||||
|
||||
// AdvisoryStore integration validated elsewhere; ensure canonical serialization is stable.
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_handler.Clear();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync(Action<StellaOpsMirrorConnectorOptions>? configureOptions = null)
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton(_handler);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddStellaOpsCrypto();
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["concelier:sources:stellaopsMirror:baseAddress"] = "https://mirror.test/",
|
||||
["concelier:sources:stellaopsMirror:domainId"] = "primary",
|
||||
["concelier:sources:stellaopsMirror:indexPath"] = "/concelier/exports/index.json",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var routine = new StellaOpsMirrorDependencyInjectionRoutine();
|
||||
routine.Register(services, configuration);
|
||||
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
services.PostConfigure(configureOptions);
|
||||
}
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>("stellaops-mirror", builder =>
|
||||
{
|
||||
builder.HttpMessageHandlerBuilderActions.Add(options =>
|
||||
{
|
||||
options.PrimaryHandler = _handler;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedResponses(string indexJson, string manifestContent, string bundleContent, string? signature)
|
||||
{
|
||||
var baseUri = new Uri("https://mirror.test");
|
||||
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "/concelier/exports/index.json"), () => CreateJsonResponse(indexJson));
|
||||
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/manifest.json"), () => CreateJsonResponse(manifestContent));
|
||||
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/bundle.json"), () => CreateJsonResponse(bundleContent));
|
||||
|
||||
if (signature is not null)
|
||||
{
|
||||
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/bundle.json.jws"), () => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(signature, Encoding.UTF8, "application/jose+json"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string content)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(content, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private static string BuildIndex(
|
||||
string manifestDigest,
|
||||
int manifestBytes,
|
||||
string bundleDigest,
|
||||
int bundleBytes,
|
||||
bool includeSignature,
|
||||
string signatureKeyId = "mirror-key",
|
||||
string signatureProvider = "default")
|
||||
{
|
||||
var index = new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
generatedAt = new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero),
|
||||
targetRepository = "repo",
|
||||
domains = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
domainId = "primary",
|
||||
displayName = "Primary",
|
||||
advisoryCount = 1,
|
||||
manifest = new
|
||||
{
|
||||
path = "mirror/primary/manifest.json",
|
||||
sizeBytes = manifestBytes,
|
||||
digest = manifestDigest,
|
||||
signature = (object?)null,
|
||||
},
|
||||
bundle = new
|
||||
{
|
||||
path = "mirror/primary/bundle.json",
|
||||
sizeBytes = bundleBytes,
|
||||
digest = bundleDigest,
|
||||
signature = includeSignature
|
||||
? new
|
||||
{
|
||||
path = "mirror/primary/bundle.json.jws",
|
||||
algorithm = "ES256",
|
||||
keyId = signatureKeyId,
|
||||
provider = signatureProvider,
|
||||
signedAt = new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
sources = Array.Empty<object>(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(index, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
=> digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? digest[7..] : digest;
|
||||
|
||||
private static CryptoSigningKey CreateSigningKey(string keyId)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
return new CryptoSigningKey(new CryptoKeyReference(keyId), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static string WritePublicKeyPem(CryptoSigningKey signingKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
var path = Path.Combine(Path.GetTempPath(), $"stellaops-mirror-{Guid.NewGuid():N}.pem");
|
||||
using var ecdsa = ECDsa.Create(signingKey.PublicParameters);
|
||||
var publicKeyInfo = ecdsa.ExportSubjectPublicKeyInfo();
|
||||
var pem = PemEncoding.Write("PUBLIC KEY", publicKeyInfo);
|
||||
File.WriteAllText(path, pem);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static (string Signature, DateTimeOffset SignedAt) CreateDetachedJws(CryptoSigningKey signingKey, string payload)
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference);
|
||||
var header = new Dictionary<string, object?>
|
||||
{
|
||||
["alg"] = SignatureAlgorithms.Es256,
|
||||
["kid"] = signingKey.Reference.KeyId,
|
||||
["provider"] = provider.Name,
|
||||
["typ"] = "application/vnd.stellaops.concelier.mirror-bundle+jws",
|
||||
["b64"] = false,
|
||||
["crit"] = new[] { "b64" }
|
||||
};
|
||||
|
||||
var headerJson = JsonSerializer.Serialize(header);
|
||||
var encodedHeader = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(headerJson);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var signingInput = BuildSigningInput(encodedHeader, payloadBytes);
|
||||
var signatureBytes = signer.SignAsync(signingInput, CancellationToken.None).GetAwaiter().GetResult();
|
||||
var encodedSignature = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(signatureBytes);
|
||||
return (string.Concat(encodedHeader, "..", encodedSignature), DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static ReadOnlyMemory<byte> BuildSigningInput(string encodedHeader, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var headerBytes = Encoding.ASCII.GetBytes(encodedHeader);
|
||||
var buffer = new byte[headerBytes.Length + 1 + payload.Length];
|
||||
headerBytes.CopyTo(buffer, 0);
|
||||
buffer[headerBytes.Length] = (byte)'.';
|
||||
payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["concelier:sources:stellaopsMirror:baseAddress"] = "https://mirror.test/",
|
||||
["concelier:sources:stellaopsMirror:domainId"] = "primary",
|
||||
["concelier:sources:stellaopsMirror:indexPath"] = "/concelier/exports/index.json",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var routine = new StellaOpsMirrorDependencyInjectionRoutine();
|
||||
routine.Register(services, configuration);
|
||||
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
services.PostConfigure(configureOptions);
|
||||
}
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>("stellaops-mirror", builder =>
|
||||
{
|
||||
builder.HttpMessageHandlerBuilderActions.Add(options =>
|
||||
{
|
||||
options.PrimaryHandler = _handler;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedResponses(string indexJson, string manifestContent, string bundleContent, string? signature)
|
||||
{
|
||||
var baseUri = new Uri("https://mirror.test");
|
||||
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "/concelier/exports/index.json"), () => CreateJsonResponse(indexJson));
|
||||
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/manifest.json"), () => CreateJsonResponse(manifestContent));
|
||||
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/bundle.json"), () => CreateJsonResponse(bundleContent));
|
||||
|
||||
if (signature is not null)
|
||||
{
|
||||
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/bundle.json.jws"), () => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(signature, Encoding.UTF8, "application/jose+json"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string content)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(content, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private static string BuildIndex(
|
||||
string manifestDigest,
|
||||
int manifestBytes,
|
||||
string bundleDigest,
|
||||
int bundleBytes,
|
||||
bool includeSignature,
|
||||
string signatureKeyId = "mirror-key",
|
||||
string signatureProvider = "default")
|
||||
{
|
||||
var index = new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
generatedAt = new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero),
|
||||
targetRepository = "repo",
|
||||
domains = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
domainId = "primary",
|
||||
displayName = "Primary",
|
||||
advisoryCount = 1,
|
||||
manifest = new
|
||||
{
|
||||
path = "mirror/primary/manifest.json",
|
||||
sizeBytes = manifestBytes,
|
||||
digest = manifestDigest,
|
||||
signature = (object?)null,
|
||||
},
|
||||
bundle = new
|
||||
{
|
||||
path = "mirror/primary/bundle.json",
|
||||
sizeBytes = bundleBytes,
|
||||
digest = bundleDigest,
|
||||
signature = includeSignature
|
||||
? new
|
||||
{
|
||||
path = "mirror/primary/bundle.json.jws",
|
||||
algorithm = "ES256",
|
||||
keyId = signatureKeyId,
|
||||
provider = signatureProvider,
|
||||
signedAt = new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
sources = Array.Empty<object>(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(index, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
=> digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? digest[7..] : digest;
|
||||
|
||||
private static CryptoSigningKey CreateSigningKey(string keyId)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
return new CryptoSigningKey(new CryptoKeyReference(keyId), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static string WritePublicKeyPem(CryptoSigningKey signingKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
var path = Path.Combine(Path.GetTempPath(), $"stellaops-mirror-{Guid.NewGuid():N}.pem");
|
||||
using var ecdsa = ECDsa.Create(signingKey.PublicParameters);
|
||||
var publicKeyInfo = ecdsa.ExportSubjectPublicKeyInfo();
|
||||
var pem = PemEncoding.Write("PUBLIC KEY", publicKeyInfo);
|
||||
File.WriteAllText(path, pem);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static (string Signature, DateTimeOffset SignedAt) CreateDetachedJws(CryptoSigningKey signingKey, string payload)
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference);
|
||||
var header = new Dictionary<string, object?>
|
||||
{
|
||||
["alg"] = SignatureAlgorithms.Es256,
|
||||
["kid"] = signingKey.Reference.KeyId,
|
||||
["provider"] = provider.Name,
|
||||
["typ"] = "application/vnd.stellaops.concelier.mirror-bundle+jws",
|
||||
["b64"] = false,
|
||||
["crit"] = new[] { "b64" }
|
||||
};
|
||||
|
||||
var headerJson = JsonSerializer.Serialize(header);
|
||||
var encodedHeader = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(headerJson);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var signingInput = BuildSigningInput(encodedHeader, payloadBytes);
|
||||
var signatureBytes = signer.SignAsync(signingInput, CancellationToken.None).GetAwaiter().GetResult();
|
||||
var encodedSignature = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(signatureBytes);
|
||||
return (string.Concat(encodedHeader, "..", encodedSignature), DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static ReadOnlyMemory<byte> BuildSigningInput(string encodedHeader, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var headerBytes = Encoding.ASCII.GetBytes(encodedHeader);
|
||||
var buffer = new byte[headerBytes.Length + 1 + payload.Length];
|
||||
headerBytes.CopyTo(buffer, 0);
|
||||
buffer[headerBytes.Length] = (byte)'.';
|
||||
payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Vndr.Cisco;
|
||||
using StellaOps.Concelier.Connector.Vndr.Cisco.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Cisco.Tests;
|
||||
|
||||
public sealed class CiscoMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_ProducesCanonicalAdvisory()
|
||||
{
|
||||
var published = new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var updated = published.AddDays(1);
|
||||
|
||||
using FluentAssertions;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Vndr.Cisco;
|
||||
using StellaOps.Concelier.Connector.Vndr.Cisco.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Cisco.Tests;
|
||||
|
||||
public sealed class CiscoMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_ProducesCanonicalAdvisory()
|
||||
{
|
||||
var published = new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var updated = published.AddDays(1);
|
||||
|
||||
var dto = new CiscoAdvisoryDto(
|
||||
AdvisoryId: "CISCO-SA-TEST",
|
||||
Title: "Test Advisory",
|
||||
Summary: "Sample summary",
|
||||
Severity: "High",
|
||||
Published: published,
|
||||
Updated: updated,
|
||||
PublicationUrl: "https://example.com/advisory",
|
||||
CsafUrl: "https://sec.cloudapps.cisco.com/csaf/test.json",
|
||||
CvrfUrl: "https://example.com/cvrf.xml",
|
||||
Published: published,
|
||||
Updated: updated,
|
||||
PublicationUrl: "https://example.com/advisory",
|
||||
CsafUrl: "https://sec.cloudapps.cisco.com/csaf/test.json",
|
||||
CvrfUrl: "https://example.com/cvrf.xml",
|
||||
CvssBaseScore: 9.8,
|
||||
Cves: new List<string> { "CVE-2024-0001" },
|
||||
BugIds: new List<string> { "BUG123" },
|
||||
@@ -39,31 +39,31 @@ public sealed class CiscoMapperTests
|
||||
new("Cisco Widget", "PID-1", "1.2.3", new [] { AffectedPackageStatusCatalog.KnownAffected }),
|
||||
new("Cisco Router", "PID-2", ">=1.0.0 <1.4.0", new [] { AffectedPackageStatusCatalog.KnownAffected })
|
||||
});
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Id: Guid.NewGuid(),
|
||||
SourceName: VndrCiscoConnectorPlugin.SourceName,
|
||||
Uri: "https://api.cisco.com/security/advisories/v2/advisories/CISCO-SA-TEST",
|
||||
FetchedAt: published,
|
||||
Sha256: "abc123",
|
||||
Status: DocumentStatuses.PendingMap,
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: null,
|
||||
LastModified: updated,
|
||||
GridFsId: null);
|
||||
|
||||
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, VndrCiscoConnectorPlugin.SourceName, "cisco.dto.test", new BsonDocument(), updated);
|
||||
|
||||
var advisory = CiscoMapper.Map(dto, document, dtoRecord);
|
||||
|
||||
advisory.AdvisoryKey.Should().Be("CISCO-SA-TEST");
|
||||
advisory.Title.Should().Be("Test Advisory");
|
||||
advisory.Severity.Should().Be("high");
|
||||
advisory.Aliases.Should().Contain(new[] { "CISCO-SA-TEST", "CVE-2024-0001", "BUG123" });
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/advisory");
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://sec.cloudapps.cisco.com/csaf/test.json");
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Id: Guid.NewGuid(),
|
||||
SourceName: VndrCiscoConnectorPlugin.SourceName,
|
||||
Uri: "https://api.cisco.com/security/advisories/v2/advisories/CISCO-SA-TEST",
|
||||
FetchedAt: published,
|
||||
Sha256: "abc123",
|
||||
Status: DocumentStatuses.PendingMap,
|
||||
ContentType: "application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: null,
|
||||
LastModified: updated,
|
||||
PayloadId: null);
|
||||
|
||||
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, VndrCiscoConnectorPlugin.SourceName, "cisco.dto.test", new BsonDocument(), updated);
|
||||
|
||||
var advisory = CiscoMapper.Map(dto, document, dtoRecord);
|
||||
|
||||
advisory.AdvisoryKey.Should().Be("CISCO-SA-TEST");
|
||||
advisory.Title.Should().Be("Test Advisory");
|
||||
advisory.Severity.Should().Be("high");
|
||||
advisory.Aliases.Should().Contain(new[] { "CISCO-SA-TEST", "CVE-2024-0001", "BUG123" });
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/advisory");
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://sec.cloudapps.cisco.com/csaf/test.json");
|
||||
advisory.AffectedPackages.Should().HaveCount(2);
|
||||
|
||||
var package = advisory.AffectedPackages.Single(p => p.Identifier == "Cisco Widget");
|
||||
|
||||
Reference in New Issue
Block a user