Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.Acsc;
|
||||
using StellaOps.Concelier.Connector.Acsc.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class AcscConnectorFetchTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri BaseEndpoint = new("https://origin.example/");
|
||||
private static readonly Uri RelayEndpoint = new("https://relay.example/");
|
||||
private static readonly Uri AlertsDirectUri = new(BaseEndpoint, "/feeds/alerts/rss");
|
||||
private static readonly Uri AlertsRelayUri = new(RelayEndpoint, "/feeds/alerts/rss");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public AcscConnectorFetchTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_DirectSuccessAdvancesCursor()
|
||||
{
|
||||
await using var provider = await BuildProviderAsync(preferRelay: false);
|
||||
|
||||
var connector = provider.GetRequiredService<AcscConnector>();
|
||||
SeedRssResponse(AlertsDirectUri, "direct", DateTimeOffset.Parse("2025-10-10T02:15:00Z"), DateTimeOffset.Parse("2025-10-11T05:30:00Z"));
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_handler.AssertNoPendingResponses();
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal("Direct", state!.Cursor.GetValue("preferredEndpoint").AsString);
|
||||
|
||||
var feeds = state.Cursor.GetValue("feeds").AsBsonDocument;
|
||||
Assert.True(feeds.TryGetValue("alerts", out var published));
|
||||
Assert.Equal(DateTime.Parse("2025-10-11T05:30:00Z").ToUniversalTime(), published.ToUniversalTime());
|
||||
|
||||
var pendingDocuments = state.Cursor.GetValue("pendingDocuments").AsBsonArray;
|
||||
Assert.Single(pendingDocuments);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var documentId = Guid.Parse(pendingDocuments[0]!.AsString);
|
||||
var document = await documentStore.FindAsync(documentId, CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(DocumentStatuses.PendingParse, document!.Status);
|
||||
var directMetadata = document.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
Assert.True(directMetadata.TryGetValue("acsc.fetch.mode", out var mode));
|
||||
Assert.Equal("direct", mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_DirectFailureFallsBackToRelay()
|
||||
{
|
||||
await using var provider = await BuildProviderAsync(preferRelay: false);
|
||||
|
||||
var connector = provider.GetRequiredService<AcscConnector>();
|
||||
_handler.AddException(HttpMethod.Get, AlertsDirectUri, new HttpRequestException("HTTP/2 reset"));
|
||||
SeedRssResponse(AlertsRelayUri, "relay", DateTimeOffset.Parse("2025-10-09T10:00:00Z"), DateTimeOffset.Parse("2025-10-11T00:00:00Z"));
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_handler.AssertNoPendingResponses();
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal("Relay", state!.Cursor.GetValue("preferredEndpoint").AsString);
|
||||
|
||||
var feeds = state.Cursor.GetValue("feeds").AsBsonDocument;
|
||||
Assert.True(feeds.TryGetValue("alerts", out var published));
|
||||
Assert.Equal(DateTime.Parse("2025-10-11T00:00:00Z").ToUniversalTime(), published.ToUniversalTime());
|
||||
|
||||
var pendingDocuments = state.Cursor.GetValue("pendingDocuments").AsBsonArray;
|
||||
Assert.Single(pendingDocuments);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var documentId = Guid.Parse(pendingDocuments[0]!.AsString);
|
||||
var document = await documentStore.FindAsync(documentId, CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(DocumentStatuses.PendingParse, document!.Status);
|
||||
var metadata = document.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
Assert.True(metadata.TryGetValue("acsc.fetch.mode", out var mode));
|
||||
Assert.Equal("relay", mode);
|
||||
|
||||
Assert.Collection(_handler.Requests,
|
||||
request =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
Assert.Equal(AlertsDirectUri, request.Uri);
|
||||
},
|
||||
request =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
Assert.Equal(AlertsRelayUri, request.Uri);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InitializeAsync() => await Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task<ServiceProvider> BuildProviderAsync(bool preferRelay)
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddAcscConnector(options =>
|
||||
{
|
||||
options.BaseEndpoint = BaseEndpoint;
|
||||
options.RelayEndpoint = RelayEndpoint;
|
||||
options.EnableRelayFallback = true;
|
||||
options.PreferRelayByDefault = preferRelay;
|
||||
options.ForceRelay = false;
|
||||
options.RequestTimeout = TimeSpan.FromSeconds(10);
|
||||
options.Feeds.Clear();
|
||||
options.Feeds.Add(new AcscFeedOptions
|
||||
{
|
||||
Slug = "alerts",
|
||||
RelativePath = "/feeds/alerts/rss",
|
||||
Enabled = true,
|
||||
});
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(AcscOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
|
||||
});
|
||||
services.Configure<SourceHttpClientOptions>(AcscOptions.HttpClientName, options =>
|
||||
{
|
||||
options.MaxAttempts = 1;
|
||||
options.BaseDelay = TimeSpan.Zero;
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedRssResponse(Uri uri, string mode, DateTimeOffset first, DateTimeOffset second)
|
||||
{
|
||||
var payload = CreateRssPayload(first, second);
|
||||
_handler.AddResponse(HttpMethod.Get, uri, _ =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/rss+xml"),
|
||||
};
|
||||
|
||||
response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue($"\"{mode}-etag\"");
|
||||
response.Content.Headers.LastModified = second;
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private static string CreateRssPayload(DateTimeOffset first, DateTimeOffset second)
|
||||
{
|
||||
return $$"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Alerts</title>
|
||||
<link>https://origin.example/feeds/alerts</link>
|
||||
<item>
|
||||
<title>First</title>
|
||||
<link>https://origin.example/alerts/first</link>
|
||||
<pubDate>{{first.ToString("r", CultureInfo.InvariantCulture)}}</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Second</title>
|
||||
<link>https://origin.example/alerts/second</link>
|
||||
<pubDate>{{second.ToString("r", CultureInfo.InvariantCulture)}}</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Acsc;
|
||||
using StellaOps.Concelier.Connector.Acsc.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
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 Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class AcscConnectorParseTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri BaseEndpoint = new("https://origin.example/");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public AcscConnectorParseTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_PersistsDtoAndAdvancesCursor()
|
||||
{
|
||||
await using var provider = await BuildProviderAsync();
|
||||
var connector = provider.GetRequiredService<AcscConnector>();
|
||||
|
||||
var feedUri = new Uri(BaseEndpoint, "/feeds/alerts/rss");
|
||||
SeedRssResponse(feedUri);
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_handler.AssertNoPendingResponses();
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var document = await documentStore.FindBySourceAndUriAsync(AcscConnectorPlugin.SourceName, feedUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
|
||||
var refreshed = await documentStore.FindAsync(document!.Id, CancellationToken.None);
|
||||
Assert.NotNull(refreshed);
|
||||
Assert.Equal(DocumentStatuses.PendingMap, refreshed!.Status);
|
||||
|
||||
var dtoStore = provider.GetRequiredService<IDtoStore>();
|
||||
var dtoRecord = await dtoStore.FindByDocumentIdAsync(document.Id, CancellationToken.None);
|
||||
Assert.NotNull(dtoRecord);
|
||||
Assert.Equal("acsc.feed.v1", dtoRecord!.SchemaVersion);
|
||||
|
||||
var payload = dtoRecord.Payload;
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("alerts", payload.GetValue("feedSlug").AsString);
|
||||
Assert.Single(payload.GetValue("entries").AsBsonArray);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.DoesNotContain(document.Id.ToString(), state!.Cursor.GetValue("pendingDocuments").AsBsonArray.Select(v => v.AsString));
|
||||
Assert.Contains(document.Id.ToString(), state.Cursor.GetValue("pendingMappings").AsBsonArray.Select(v => v.AsString));
|
||||
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoriesStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoriesStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Single(advisories);
|
||||
|
||||
var ordered = advisories
|
||||
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
WriteOrAssertSnapshot(
|
||||
SnapshotSerializer.ToSnapshot(ordered),
|
||||
"acsc-advisories.snapshot.json");
|
||||
|
||||
var mappedDocument = await documentStore.FindAsync(document.Id, CancellationToken.None);
|
||||
Assert.NotNull(mappedDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, mappedDocument!.Status);
|
||||
|
||||
state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.GetValue("pendingMappings").AsBsonArray.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapAsync_MultiEntryFeedProducesExpectedSnapshot()
|
||||
{
|
||||
await using var provider = await BuildProviderAsync(options =>
|
||||
{
|
||||
options.Feeds.Clear();
|
||||
options.Feeds.Add(new AcscFeedOptions
|
||||
{
|
||||
Slug = "multi",
|
||||
RelativePath = "/feeds/multi/rss",
|
||||
Enabled = true,
|
||||
});
|
||||
});
|
||||
var connector = provider.GetRequiredService<AcscConnector>();
|
||||
|
||||
var feedUri = new Uri(BaseEndpoint, "/feeds/multi/rss");
|
||||
SeedMultiEntryResponse(feedUri);
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var dtoStore = provider.GetRequiredService<IDtoStore>();
|
||||
var document = await documentStore.FindBySourceAndUriAsync(AcscConnectorPlugin.SourceName, feedUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
var dtoRecord = await dtoStore.FindByDocumentIdAsync(document!.Id, CancellationToken.None);
|
||||
Assert.NotNull(dtoRecord);
|
||||
var payload = dtoRecord!.Payload;
|
||||
Assert.NotNull(payload);
|
||||
var entries = payload.GetValue("entries").AsBsonArray;
|
||||
Assert.Equal(2, entries.Count);
|
||||
var fields = entries[0].AsBsonDocument.GetValue("fields").AsBsonDocument;
|
||||
Assert.Equal("Critical", fields.GetValue("severity").AsString);
|
||||
Assert.Equal("ExampleCo Router X, ExampleCo Router Y", fields.GetValue("systemsAffected").AsString);
|
||||
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
|
||||
var ordered = advisories
|
||||
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
WriteOrAssertSnapshot(
|
||||
SnapshotSerializer.ToSnapshot(ordered),
|
||||
"acsc-advisories-multi.snapshot.json");
|
||||
|
||||
var affected = ordered.First(advisory => advisory.AffectedPackages.Any());
|
||||
Assert.Contains("ExampleCo Router X", affected.AffectedPackages[0].Identifier);
|
||||
Assert.Equal("critical", ordered.First(a => a.Severity is not null).Severity, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task<ServiceProvider> BuildProviderAsync(Action<AcscOptions>? configure = null)
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddAcscConnector(options =>
|
||||
{
|
||||
options.BaseEndpoint = BaseEndpoint;
|
||||
options.RelayEndpoint = null;
|
||||
options.PreferRelayByDefault = false;
|
||||
options.ForceRelay = false;
|
||||
options.EnableRelayFallback = false;
|
||||
options.RequestTimeout = TimeSpan.FromSeconds(10);
|
||||
options.Feeds.Clear();
|
||||
options.Feeds.Add(new AcscFeedOptions
|
||||
{
|
||||
Slug = "alerts",
|
||||
RelativePath = "/feeds/alerts/rss",
|
||||
Enabled = true,
|
||||
});
|
||||
configure?.Invoke(options);
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(AcscOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
|
||||
});
|
||||
|
||||
services.Configure<SourceHttpClientOptions>(AcscOptions.HttpClientName, options =>
|
||||
{
|
||||
options.MaxAttempts = 1;
|
||||
options.BaseDelay = TimeSpan.Zero;
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedRssResponse(Uri uri)
|
||||
{
|
||||
const string payload = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<title>ACSC Alerts</title>
|
||||
<link>https://origin.example/feeds/alerts</link>
|
||||
<lastBuildDate>Sun, 12 Oct 2025 04:20:00 GMT</lastBuildDate>
|
||||
<item>
|
||||
<title>ACSC-2025-001 Example Advisory</title>
|
||||
<link>https://origin.example/advisories/example</link>
|
||||
<guid>https://origin.example/advisories/example</guid>
|
||||
<pubDate>Sun, 12 Oct 2025 03:00:00 GMT</pubDate>
|
||||
<content:encoded><![CDATA[
|
||||
<p><strong>Serial number:</strong> ACSC-2025-001</p>
|
||||
<p><strong>Advisory type:</strong> Alert</p>
|
||||
<p>First paragraph describing issue.</p>
|
||||
<p>Second paragraph with <a href="https://vendor.example/patch">Vendor patch</a>.</p>
|
||||
]]></content:encoded>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""";
|
||||
|
||||
_handler.AddResponse(HttpMethod.Get, uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/rss+xml"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"parse-etag\"");
|
||||
response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 12, 4, 20, 0, TimeSpan.Zero);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private void SeedMultiEntryResponse(Uri uri)
|
||||
{
|
||||
const string payload = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<title>ACSC Advisories</title>
|
||||
<link>https://origin.example/feeds/advisories</link>
|
||||
<lastBuildDate>Sun, 12 Oct 2025 05:00:00 GMT</lastBuildDate>
|
||||
<item>
|
||||
<title>Critical router vulnerability</title>
|
||||
<link>https://origin.example/advisories/router-critical</link>
|
||||
<guid>https://origin.example/advisories/router-critical</guid>
|
||||
<pubDate>Sun, 12 Oct 2025 04:45:00 GMT</pubDate>
|
||||
<content:encoded><![CDATA[
|
||||
<p><strong>Serial number:</strong> ACSC-2025-010</p>
|
||||
<p><strong>Severity:</strong> Critical</p>
|
||||
<p><strong>Systems affected:</strong> ExampleCo Router X, ExampleCo Router Y</p>
|
||||
<p>Remote code execution on ExampleCo routers. See <a href="https://vendor.example/router/patch">vendor patch</a>.</p>
|
||||
<p>CVE references: CVE-2025-0001</p>
|
||||
]]></content:encoded>
|
||||
</item>
|
||||
<item>
|
||||
<title>Information bulletin</title>
|
||||
<link>https://origin.example/advisories/info-bulletin</link>
|
||||
<guid>https://origin.example/advisories/info-bulletin</guid>
|
||||
<pubDate>Sun, 12 Oct 2025 02:30:00 GMT</pubDate>
|
||||
<content:encoded><![CDATA[
|
||||
<p><strong>Serial number:</strong> ACSC-2025-011</p>
|
||||
<p><strong>Advisory type:</strong> Bulletin</p>
|
||||
<p>General guidance bulletin.</p>
|
||||
]]></content:encoded>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""";
|
||||
|
||||
_handler.AddResponse(HttpMethod.Get, uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/rss+xml"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"multi-etag\"");
|
||||
response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 12, 5, 0, 0, TimeSpan.Zero);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private static void WriteOrAssertSnapshot(string snapshot, string filename)
|
||||
{
|
||||
if (ShouldUpdateFixtures() || !FixtureExists(filename))
|
||||
{
|
||||
var writable = GetWritableFixturePath(filename);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(writable)!);
|
||||
File.WriteAllText(writable, Normalize(snapshot));
|
||||
return;
|
||||
}
|
||||
|
||||
var expected = Normalize(File.ReadAllText(GetExistingFixturePath(filename)));
|
||||
var actual = Normalize(snapshot);
|
||||
|
||||
if (!string.Equals(expected, actual, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(Path.GetDirectoryName(GetWritableFixturePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
|
||||
File.WriteAllText(actualPath, actual);
|
||||
}
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
private static bool ShouldUpdateFixtures()
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable("UPDATE_ACSC_FIXTURES");
|
||||
return string.Equals(value, "1", StringComparison.Ordinal)
|
||||
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetExistingFixturePath(string filename)
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var primary = Path.Combine(baseDir, "Acsc", "Fixtures", filename);
|
||||
if (File.Exists(primary))
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = Path.Combine(baseDir, "Fixtures", filename);
|
||||
if (File.Exists(secondary))
|
||||
{
|
||||
return secondary;
|
||||
}
|
||||
|
||||
var projectRelative = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Acsc", "Fixtures", filename);
|
||||
if (File.Exists(projectRelative))
|
||||
{
|
||||
return Path.GetFullPath(projectRelative);
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Fixture '{filename}' not found.", filename);
|
||||
}
|
||||
|
||||
private static string GetWritableFixturePath(string filename)
|
||||
=> Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Acsc", "Fixtures", filename);
|
||||
|
||||
private static string Normalize(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();
|
||||
|
||||
private static bool FixtureExists(string filename)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = GetExistingFixturePath(filename);
|
||||
return true;
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Acsc.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
|
||||
|
||||
public sealed class AcscHttpClientConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddAcscConnector_ConfiguresHttpClientOptions()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAcscConnector(options =>
|
||||
{
|
||||
options.BaseEndpoint = new Uri("https://origin.example/");
|
||||
options.RelayEndpoint = new Uri("https://relay.example/");
|
||||
options.RequestTimeout = TimeSpan.FromSeconds(42);
|
||||
options.Feeds.Clear();
|
||||
options.Feeds.Add(new AcscFeedOptions
|
||||
{
|
||||
Slug = "alerts",
|
||||
RelativePath = "/feeds/alerts/rss",
|
||||
Enabled = true,
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var monitor = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>();
|
||||
var options = monitor.Get(AcscOptions.HttpClientName);
|
||||
|
||||
Assert.Equal("StellaOps/Concelier (+https://stella-ops.org)", options.UserAgent);
|
||||
Assert.Equal(HttpVersion.Version20, options.RequestVersion);
|
||||
Assert.Equal(HttpVersionPolicy.RequestVersionOrLower, options.VersionPolicy);
|
||||
Assert.Equal(TimeSpan.FromSeconds(42), options.Timeout);
|
||||
Assert.Contains("origin.example", options.AllowedHosts, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("relay.example", options.AllowedHosts, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Equal("application/rss+xml, application/atom+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.7", options.DefaultRequestHeaders["Accept"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
[
|
||||
{
|
||||
"advisoryKey": "acsc/multi/https-origin-example-advisories-info-bulletin",
|
||||
"affectedPackages": [],
|
||||
"aliases": [
|
||||
"ACSC-2025-011",
|
||||
"Bulletin",
|
||||
"https://origin.example/advisories/info-bulletin"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": null,
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "document",
|
||||
"value": "https://origin.example/feeds/multi/rss",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "feed",
|
||||
"value": "multi",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "mapping",
|
||||
"value": "https://origin.example/advisories/info-bulletin",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
}
|
||||
],
|
||||
"published": "2025-10-12T02:30:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "acsc",
|
||||
"kind": "reference",
|
||||
"value": "https://origin.example/advisories/info-bulletin",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "multi",
|
||||
"summary": "Information bulletin",
|
||||
"url": "https://origin.example/advisories/info-bulletin"
|
||||
}
|
||||
],
|
||||
"severity": null,
|
||||
"summary": "Serial number: ACSC-2025-011\n\nAdvisory type: Bulletin\n\nGeneral guidance bulletin.",
|
||||
"title": "Information bulletin"
|
||||
},
|
||||
{
|
||||
"advisoryKey": "acsc/multi/https-origin-example-advisories-router-critical",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "vendor",
|
||||
"identifier": "ExampleCo Router X",
|
||||
"platform": null,
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "affected",
|
||||
"value": "ExampleCo Router X",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vendor",
|
||||
"identifier": "ExampleCo Router Y",
|
||||
"platform": null,
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "affected",
|
||||
"value": "ExampleCo Router Y",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"ACSC-2025-010",
|
||||
"CVE-2025-0001",
|
||||
"https://origin.example/advisories/router-critical"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": null,
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "document",
|
||||
"value": "https://origin.example/feeds/multi/rss",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "feed",
|
||||
"value": "multi",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "mapping",
|
||||
"value": "https://origin.example/advisories/router-critical",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
}
|
||||
],
|
||||
"published": "2025-10-12T04:45:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "acsc",
|
||||
"kind": "reference",
|
||||
"value": "https://origin.example/advisories/router-critical",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "multi",
|
||||
"summary": "Critical router vulnerability",
|
||||
"url": "https://origin.example/advisories/router-critical"
|
||||
},
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"source": "acsc",
|
||||
"kind": "reference",
|
||||
"value": "https://vendor.example/router/patch",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "vendor patch",
|
||||
"url": "https://vendor.example/router/patch"
|
||||
}
|
||||
],
|
||||
"severity": "critical",
|
||||
"summary": "Serial number: ACSC-2025-010\n\nSeverity: Critical\n\nSystems affected: ExampleCo Router X, ExampleCo Router Y\n\nRemote code execution on ExampleCo routers. See vendor patch.\n\nCVE references: CVE-2025-0001",
|
||||
"title": "Critical router vulnerability"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
[
|
||||
{
|
||||
"advisoryKey": "acsc/alerts/https-origin-example-advisories-example",
|
||||
"affectedPackages": [],
|
||||
"aliases": [
|
||||
"ACSC-2025-001",
|
||||
"Alert",
|
||||
"https://origin.example/advisories/example"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": null,
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "document",
|
||||
"value": "https://origin.example/feeds/alerts/rss",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "feed",
|
||||
"value": "alerts",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "mapping",
|
||||
"value": "https://origin.example/advisories/example",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
}
|
||||
],
|
||||
"published": "2025-10-12T03:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "acsc",
|
||||
"kind": "reference",
|
||||
"value": "https://origin.example/advisories/example",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "alerts",
|
||||
"summary": "ACSC-2025-001 Example Advisory",
|
||||
"url": "https://origin.example/advisories/example"
|
||||
},
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"source": "acsc",
|
||||
"kind": "reference",
|
||||
"value": "https://vendor.example/patch",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "Vendor patch",
|
||||
"url": "https://vendor.example/patch"
|
||||
}
|
||||
],
|
||||
"severity": null,
|
||||
"summary": "Serial number: ACSC-2025-001\n\nAdvisory type: Alert\n\nFirst paragraph describing issue.\n\nSecond paragraph with Vendor patch.",
|
||||
"title": "ACSC-2025-001 Example Advisory"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Acsc/StellaOps.Concelier.Connector.Acsc.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Acsc/Fixtures/**" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
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.Cccs;
|
||||
using StellaOps.Concelier.Connector.Cccs.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cccs.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class CccsConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri FeedUri = new("https://test.local/api/cccs/threats/v1/get?lang=en&content_type=cccs_threat");
|
||||
private static readonly Uri TaxonomyUri = new("https://test.local/api/cccs/taxonomy/v1/get?lang=en&vocabulary=cccs_alert_type");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public CccsConnectorTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_ProducesCanonicalAdvisory()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedFeedResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CccsConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
advisories.Should().HaveCount(1);
|
||||
|
||||
var advisory = advisories[0];
|
||||
advisory.AdvisoryKey.Should().Be("TEST-001");
|
||||
advisory.Title.Should().Be("Test Advisory Title");
|
||||
advisory.Aliases.Should().Contain(new[] { "TEST-001", "CVE-2020-1234", "CVE-2021-9999" });
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://www.cyber.gc.ca/en/contact-cyber-centre?lang=en");
|
||||
advisory.AffectedPackages.Should().ContainSingle(pkg => pkg.Identifier == "Vendor Widget 1.0");
|
||||
advisory.AffectedPackages.Should().Contain(pkg => pkg.Identifier == "Vendor Widget 2.0");
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CccsConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
state!.Cursor.Should().NotBeNull();
|
||||
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
|
||||
pendingDocs!.AsBsonArray.Should().BeEmpty();
|
||||
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
|
||||
pendingMappings!.AsBsonArray.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_PersistsRawDocumentWithMetadata()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedFeedResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CccsConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var document = await documentStore.FindBySourceAndUriAsync(CccsConnectorPlugin.SourceName, "https://www.cyber.gc.ca/en/alerts-advisories/test-advisory", CancellationToken.None);
|
||||
document.Should().NotBeNull();
|
||||
document!.Status.Should().Be(DocumentStatuses.PendingParse);
|
||||
document.Metadata.Should().ContainKey("cccs.language").WhoseValue.Should().Be("en");
|
||||
document.Metadata.Should().ContainKey("cccs.serialNumber").WhoseValue.Should().Be("TEST-001");
|
||||
document.ContentType.Should().Be("application/json");
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync()
|
||||
{
|
||||
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.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddCccsConnector(options =>
|
||||
{
|
||||
options.Feeds.Clear();
|
||||
options.Feeds.Add(new CccsFeedEndpoint("en", FeedUri));
|
||||
options.RequestDelay = TimeSpan.Zero;
|
||||
options.MaxEntriesPerFetch = 10;
|
||||
options.MaxKnownEntries = 32;
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(CccsOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
{
|
||||
builder.PrimaryHandler = _handler;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedFeedResponses()
|
||||
{
|
||||
AddJsonResponse(FeedUri, ReadFixture("cccs-feed-en.json"));
|
||||
AddJsonResponse(TaxonomyUri, ReadFixture("cccs-taxonomy-en.json"));
|
||||
}
|
||||
|
||||
private void AddJsonResponse(Uri uri, string json, string? etag = null)
|
||||
{
|
||||
_handler.AddResponse(uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(etag))
|
||||
{
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private static string ReadFixture(string fileName)
|
||||
=> System.IO.File.ReadAllText(System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName));
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"ERROR": false,
|
||||
"response": [
|
||||
{
|
||||
"nid": 1001,
|
||||
"title": "Test Advisory Title",
|
||||
"uuid": "uuid-test-001",
|
||||
"banner": null,
|
||||
"lang": "en",
|
||||
"date_modified": "2025-08-11",
|
||||
"date_modified_ts": "2025-08-11T12:00:00Z",
|
||||
"date_created": "2025-08-10T15:30:00Z",
|
||||
"summary": "Summary of advisory.",
|
||||
"body": [
|
||||
"<article><p><strong>Number: TEST-001<br/>Date: 14 April 2018</strong></p><h2>Affected Products</h2><ul><li>Vendor Widget 1.0</li><li>Vendor Widget 2.0</li></ul><p>See <a href=\"https://example.com/details?utm_source=rss&utm_medium=email\">Details Link</a>.</p><p>Internal link <a href=\"/en/contact-cyber-centre?utm_campaign=newsletter\">Contact</a>.</p><p>Mitigation for CVE-2020-1234 and CVE-2021-9999.</p></article>"
|
||||
],
|
||||
"url": "/en/alerts-advisories/test-advisory",
|
||||
"alert_type": 397,
|
||||
"serial_number": "TEST-001",
|
||||
"subject": "Infrastructure",
|
||||
"moderation_state": "published",
|
||||
"external_url": "https://example.com/external/advisory"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"sourceId": "TEST-002-FR",
|
||||
"serialNumber": "TEST-002-FR",
|
||||
"uuid": "uuid-test-002",
|
||||
"language": "fr",
|
||||
"title": "Avis de sécurité – Mise à jour urgente",
|
||||
"summary": "Résumé de l'avis en français.",
|
||||
"canonicalUrl": "https://www.cyber.gc.ca/fr/alertes-avis/test-avis",
|
||||
"externalUrl": "https://exemple.ca/avis",
|
||||
"bodyHtml": "<article><p><strong>Numéro : TEST-002-FR<br/>Date : 15 août 2025</strong></p><h2>Produits touchés</h2><div class=\"product-list\"><ul><li>Produit Exemple 3.1</li><li>Produit Exemple 3.2<ul><li>Variante 3.2.1</li></ul></li></ul></div><p>Voir <a href=\"https://exemple.ca/details?utm_campaign=mailing\">Lien de détails</a>.</p><p>Lien interne <a href=\"/fr/contact-centre-cyber\">Contactez-nous</a>.</p><p>Correctifs pour CVE-2024-1111.</p></article>",
|
||||
"bodySegments": [
|
||||
"<article><p><strong>Numéro : TEST-002-FR<br/>Date : 15 août 2025</strong></p><h2>Produits touchés</h2><div class=\"product-list\"><ul><li>Produit Exemple 3.1</li><li>Produit Exemple 3.2<ul><li>Variante 3.2.1</li></ul></li></ul></div><p>Voir <a href=\"https://exemple.ca/details?utm_campaign=mailing\">Lien de détails</a>.</p><p>Lien interne <a href=\"/fr/contact-centre-cyber\">Contactez-nous</a>.</p><p>Correctifs pour CVE-2024-1111.</p></article>"
|
||||
],
|
||||
"alertType": "Alerte",
|
||||
"subject": "Infrastructure critique",
|
||||
"banner": null,
|
||||
"published": "2025-08-15T13:45:00Z",
|
||||
"modified": "2025-08-16T09:15:00Z",
|
||||
"rawCreated": "15 août 2025",
|
||||
"rawModified": "2025-08-16T09:15:00Z"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"sourceId": "TEST-001",
|
||||
"serialNumber": "TEST-001",
|
||||
"uuid": "uuid-test-001",
|
||||
"language": "en",
|
||||
"title": "Test Advisory Title",
|
||||
"summary": "Summary of advisory.",
|
||||
"canonicalUrl": "https://www.cyber.gc.ca/en/alerts-advisories/test-advisory",
|
||||
"externalUrl": "https://example.com/external/advisory",
|
||||
"bodyHtml": "<article><p><strong>Number: TEST-001<br/>Date: 14 April 2018</strong></p><h2>Affected Products</h2><ul><li>Vendor Widget 1.0</li><li>Vendor Widget 2.0</li></ul><p>See <a href=\"https://example.com/details?utm_source=rss&utm_medium=email\">Details Link</a>.</p><p>Internal link <a href=\"/en/contact-cyber-centre?utm_campaign=newsletter\">Contact</a>.</p><p>Mitigation for CVE-2020-1234 and CVE-2021-9999.</p></article>",
|
||||
"bodySegments": [
|
||||
"<article><p><strong>Number: TEST-001<br/>Date: 14 April 2018</strong></p><h2>Affected Products</h2><ul><li>Vendor Widget 1.0</li><li>Vendor Widget 2.0</li></ul><p>See <a href=\"https://example.com/details?utm_source=rss&utm_medium=email\">Details Link</a>.</p><p>Internal link <a href=\"/en/contact-cyber-centre?utm_campaign=newsletter\">Contact</a>.</p><p>Mitigation for CVE-2020-1234 and CVE-2021-9999.</p></article>"
|
||||
],
|
||||
"alertType": "Advisory",
|
||||
"subject": "Infrastructure",
|
||||
"banner": null,
|
||||
"published": "2025-08-10T15:30:00Z",
|
||||
"modified": "2025-08-11T12:00:00Z",
|
||||
"rawCreated": "August 10, 2025",
|
||||
"rawModified": "2025-08-11T12:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"ERROR": false,
|
||||
"response": [
|
||||
{
|
||||
"id": 396,
|
||||
"title": "Advisory"
|
||||
},
|
||||
{
|
||||
"id": 397,
|
||||
"title": "Alert"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.Connector.Cccs.Internal;
|
||||
using StellaOps.Concelier.Connector.Common.Html;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cccs.Tests.Internal;
|
||||
|
||||
public sealed class CccsHtmlParserTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
private static readonly HtmlContentSanitizer Sanitizer = new();
|
||||
private static readonly CccsHtmlParser Parser = new(Sanitizer);
|
||||
|
||||
public CccsHtmlParserTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> ParserCases()
|
||||
{
|
||||
yield return new object[]
|
||||
{
|
||||
"cccs-raw-advisory.json",
|
||||
"TEST-001",
|
||||
"en",
|
||||
new[] { "Vendor Widget 1.0", "Vendor Widget 2.0" },
|
||||
new[]
|
||||
{
|
||||
"https://example.com/details",
|
||||
"https://www.cyber.gc.ca/en/contact-cyber-centre?lang=en"
|
||||
},
|
||||
new[] { "CVE-2020-1234", "CVE-2021-9999" }
|
||||
};
|
||||
|
||||
yield return new object[]
|
||||
{
|
||||
"cccs-raw-advisory-fr.json",
|
||||
"TEST-002-FR",
|
||||
"fr",
|
||||
new[] { "Produit Exemple 3.1", "Produit Exemple 3.2", "Variante 3.2.1" },
|
||||
new[]
|
||||
{
|
||||
"https://exemple.ca/details",
|
||||
"https://www.cyber.gc.ca/fr/contact-centre-cyber"
|
||||
},
|
||||
new[] { "CVE-2024-1111" }
|
||||
};
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParserCases))]
|
||||
public void Parse_ExtractsExpectedFields(
|
||||
string fixtureName,
|
||||
string expectedSerial,
|
||||
string expectedLanguage,
|
||||
string[] expectedProducts,
|
||||
string[] expectedReferenceUrls,
|
||||
string[] expectedCves)
|
||||
{
|
||||
var raw = LoadFixture<CccsRawAdvisoryDocument>(fixtureName);
|
||||
|
||||
var dto = Parser.Parse(raw);
|
||||
|
||||
_output.WriteLine("Products: {0}", string.Join("|", dto.Products));
|
||||
_output.WriteLine("References: {0}", string.Join("|", dto.References.Select(r => $"{r.Url} ({r.Label})")));
|
||||
_output.WriteLine("CVEs: {0}", string.Join("|", dto.CveIds));
|
||||
|
||||
dto.SerialNumber.Should().Be(expectedSerial);
|
||||
dto.Language.Should().Be(expectedLanguage);
|
||||
dto.Products.Should().BeEquivalentTo(expectedProducts);
|
||||
foreach (var url in expectedReferenceUrls)
|
||||
{
|
||||
dto.References.Should().Contain(reference => reference.Url == url);
|
||||
}
|
||||
|
||||
dto.CveIds.Should().BeEquivalentTo(expectedCves);
|
||||
dto.ContentHtml.Should().Contain("<ul>").And.Contain("<li>");
|
||||
dto.ContentHtml.Should().Contain("<h2", because: "heading structure must survive sanitisation for UI rendering");
|
||||
}
|
||||
|
||||
internal static T LoadFixture<T>(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<T>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.Connector.Cccs.Internal;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Html;
|
||||
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" });
|
||||
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);
|
||||
advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Cccs/StellaOps.Concelier.Connector.Cccs.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
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.CertBund.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
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 Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertBund.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class CertBundConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri FeedUri = new("https://test.local/content/public/securityAdvisory/rss");
|
||||
private static readonly Uri PortalUri = new("https://test.local/portal/");
|
||||
private static readonly Uri DetailUri = new("https://test.local/portal/api/securityadvisory?name=WID-SEC-2025-2264");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public CertBundConnectorTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_ProducesCanonicalAdvisory()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CertBundConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
|
||||
advisories.Should().HaveCount(1);
|
||||
|
||||
var advisory = advisories[0];
|
||||
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
|
||||
advisory.Aliases.Should().Contain("CVE-2025-1234");
|
||||
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
|
||||
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
|
||||
advisory.Language.Should().Be("de");
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
state!.Cursor.Should().NotBeNull();
|
||||
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
|
||||
pendingDocs!.AsBsonArray.Should().BeEmpty();
|
||||
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
|
||||
pendingMappings!.AsBsonArray.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_PersistsDocumentWithMetadata()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CertBundConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var document = await documentStore.FindBySourceAndUriAsync(CertBundConnectorPlugin.SourceName, DetailUri.ToString(), CancellationToken.None);
|
||||
document.Should().NotBeNull();
|
||||
document!.Metadata.Should().ContainKey("certbund.advisoryId").WhoseValue.Should().Be("WID-SEC-2025-2264");
|
||||
document.Metadata.Should().ContainKey("certbund.category");
|
||||
document.Metadata.Should().ContainKey("certbund.published");
|
||||
document.Status.Should().Be(DocumentStatuses.PendingParse);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
state!.Cursor.Should().NotBeNull();
|
||||
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
|
||||
pendingDocs!.AsBsonArray.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync()
|
||||
{
|
||||
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.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddCertBundConnector(options =>
|
||||
{
|
||||
options.FeedUri = FeedUri;
|
||||
options.PortalBootstrapUri = PortalUri;
|
||||
options.DetailApiUri = new Uri("https://test.local/portal/api/securityadvisory");
|
||||
options.RequestDelay = TimeSpan.Zero;
|
||||
options.MaxAdvisoriesPerFetch = 10;
|
||||
options.MaxKnownAdvisories = 32;
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(CertBundOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
{
|
||||
builder.PrimaryHandler = _handler;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedResponses()
|
||||
{
|
||||
AddJsonResponse(DetailUri, ReadFixture("certbund-detail.json"));
|
||||
AddXmlResponse(FeedUri, ReadFixture("certbund-feed.xml"), "application/rss+xml");
|
||||
AddHtmlResponse(PortalUri, "<html><body>OK</body></html>");
|
||||
}
|
||||
|
||||
private void AddJsonResponse(Uri uri, string json, string? etag = null)
|
||||
{
|
||||
_handler.AddResponse(uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(etag))
|
||||
{
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private void AddXmlResponse(Uri uri, string xml, string contentType)
|
||||
{
|
||||
_handler.AddResponse(uri, () => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(xml, Encoding.UTF8, contentType),
|
||||
});
|
||||
}
|
||||
|
||||
private void AddHtmlResponse(Uri uri, string html)
|
||||
{
|
||||
_handler.AddResponse(uri, () => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(html, Encoding.UTF8, "text/html"),
|
||||
});
|
||||
}
|
||||
|
||||
private static string ReadFixture(string fileName)
|
||||
=> System.IO.File.ReadAllText(System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName));
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "WID-SEC-2025-2264",
|
||||
"title": "Ivanti Endpoint Manager: Mehrere Schwachstellen ermöglichen Codeausführung",
|
||||
"summary": "Ein entfernter, anonymer Angreifer kann mehrere Schwachstellen in Ivanti Endpoint Manager ausnutzen.",
|
||||
"description": "<p>Ivanti Endpoint Manager weist mehrere Schwachstellen auf.</p><p>Ein Angreifer kann beliebigen Code ausführen.</p>",
|
||||
"severity": "hoch",
|
||||
"language": "de",
|
||||
"published": "2025-10-14T06:24:49Z",
|
||||
"updated": "2025-10-14T07:00:00Z",
|
||||
"cveIds": [
|
||||
"CVE-2025-1234",
|
||||
"CVE-2025-5678"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"url": "https://example.com/vendor/advisory",
|
||||
"label": "Vendor Advisory"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/mitre",
|
||||
"label": "MITRE"
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"vendor": "Ivanti",
|
||||
"name": "Endpoint Manager",
|
||||
"versions": "2023.1 bis 2024.2"
|
||||
},
|
||||
{
|
||||
"vendor": "Ivanti",
|
||||
"name": "Endpoint Manager Cloud",
|
||||
"versions": "alle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>BSI Warn- und Informationsdienst</title>
|
||||
<link>https://wid.cert-bund.de/portal/wid/securityadvisory</link>
|
||||
<description>Test feed</description>
|
||||
<pubDate>Tue, 14 Oct 2025 07:06:21 GMT</pubDate>
|
||||
<item>
|
||||
<title>[hoch] Ivanti Endpoint Manager: Mehrere Schwachstellen ermöglichen Codeausführung</title>
|
||||
<link>https://wid.cert-bund.de/portal/wid/securityadvisory?name=WID-SEC-2025-2264</link>
|
||||
<category>hoch</category>
|
||||
<pubDate>Tue, 14 Oct 2025 06:24:49 GMT</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.CertBund/StellaOps.Concelier.Connector.CertBund.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Fixtures\*.xml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,263 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.CertCc;
|
||||
using StellaOps.Concelier.Connector.CertCc.Configuration;
|
||||
using StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Cursors;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class CertCcConnectorFetchTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestNoteId = "294418";
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
private ServiceProvider? _serviceProvider;
|
||||
|
||||
public CertCcConnectorFetchTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 8, 0, 0, TimeSpan.Zero));
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact(Skip = "Superseded by snapshot regression coverage (FEEDCONN-CERTCC-02-005).")]
|
||||
public async Task FetchAsync_PersistsSummaryAndDetailDocumentsAndUpdatesCursor()
|
||||
{
|
||||
var template = new CertCcOptions
|
||||
{
|
||||
BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute),
|
||||
SummaryWindow = new TimeWindowCursorOptions
|
||||
{
|
||||
WindowSize = TimeSpan.FromDays(30),
|
||||
Overlap = TimeSpan.FromDays(5),
|
||||
InitialBackfill = TimeSpan.FromDays(60),
|
||||
MinimumWindowSize = TimeSpan.FromDays(1),
|
||||
},
|
||||
MaxMonthlySummaries = 3,
|
||||
MaxNotesPerFetch = 3,
|
||||
DetailRequestDelay = TimeSpan.Zero,
|
||||
};
|
||||
|
||||
await EnsureServiceProviderAsync(template);
|
||||
var provider = _serviceProvider!;
|
||||
|
||||
_handler.Clear();
|
||||
|
||||
var planner = provider.GetRequiredService<CertCcSummaryPlanner>();
|
||||
var plan = planner.CreatePlan(state: null);
|
||||
Assert.NotEmpty(plan.Requests);
|
||||
|
||||
foreach (var request in plan.Requests)
|
||||
{
|
||||
_handler.AddJsonResponse(request.Uri, BuildSummaryPayload());
|
||||
}
|
||||
|
||||
RegisterDetailResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CertCcConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
foreach (var request in plan.Requests)
|
||||
{
|
||||
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, request.Uri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
|
||||
Assert.NotNull(record.Metadata);
|
||||
Assert.Equal(request.Scope.ToString().ToLowerInvariant(), record.Metadata!["certcc.scope"]);
|
||||
Assert.Equal(request.Year.ToString("D4"), record.Metadata["certcc.year"]);
|
||||
if (request.Month.HasValue)
|
||||
{
|
||||
Assert.Equal(request.Month.Value.ToString("D2"), record.Metadata["certcc.month"]);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.False(record.Metadata.ContainsKey("certcc.month"));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var uri in EnumerateDetailUris())
|
||||
{
|
||||
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
|
||||
Assert.NotNull(record.Metadata);
|
||||
Assert.Equal(TestNoteId, record.Metadata!["certcc.noteId"]);
|
||||
}
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
|
||||
BsonValue summaryValue;
|
||||
Assert.True(state!.Cursor.TryGetValue("summary", out summaryValue));
|
||||
var summaryDocument = Assert.IsType<BsonDocument>(summaryValue);
|
||||
Assert.True(summaryDocument.TryGetValue("start", out _));
|
||||
Assert.True(summaryDocument.TryGetValue("end", out _));
|
||||
|
||||
var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
|
||||
? pendingNotesValue.AsBsonArray.Count
|
||||
: 0;
|
||||
Assert.Equal(0, pendingNotesCount);
|
||||
|
||||
var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
|
||||
? pendingSummariesValue.AsBsonArray.Count
|
||||
: 0;
|
||||
Assert.Equal(0, pendingSummariesCount);
|
||||
|
||||
Assert.True(state.Cursor.TryGetValue("lastRun", out _));
|
||||
|
||||
Assert.True(_handler.Requests.Count >= plan.Requests.Count);
|
||||
foreach (var request in _handler.Requests)
|
||||
{
|
||||
if (request.Headers.TryGetValue("Accept", out var accept))
|
||||
{
|
||||
Assert.Contains("application/json", accept, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSummaryPayload()
|
||||
{
|
||||
return $$"""
|
||||
{
|
||||
"count": 1,
|
||||
"notes": [
|
||||
"VU#{TestNoteId}"
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private void RegisterDetailResponses()
|
||||
{
|
||||
foreach (var uri in EnumerateDetailUris())
|
||||
{
|
||||
var fixtureName = uri.AbsolutePath.EndsWith("/vendors/", StringComparison.OrdinalIgnoreCase)
|
||||
? "vu-294418-vendors.json"
|
||||
: uri.AbsolutePath.EndsWith("/vuls/", StringComparison.OrdinalIgnoreCase)
|
||||
? "vu-294418-vuls.json"
|
||||
: "vu-294418.json";
|
||||
|
||||
_handler.AddJsonResponse(uri, ReadFixture(fixtureName));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Uri> EnumerateDetailUris()
|
||||
{
|
||||
var baseUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
|
||||
yield return new Uri(baseUri, $"{TestNoteId}/");
|
||||
yield return new Uri(baseUri, $"{TestNoteId}/vendors/");
|
||||
yield return new Uri(baseUri, $"{TestNoteId}/vuls/");
|
||||
}
|
||||
|
||||
private async Task EnsureServiceProviderAsync(CertCcOptions template)
|
||||
{
|
||||
await DisposeServiceProviderAsync();
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddSingleton(_handler);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
options.RawDocumentRetention = TimeSpan.Zero;
|
||||
options.RawDocumentRetentionTtlGrace = TimeSpan.FromMinutes(5);
|
||||
options.RawDocumentRetentionSweepInterval = TimeSpan.FromHours(1);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddCertCcConnector(options =>
|
||||
{
|
||||
options.BaseApiUri = template.BaseApiUri;
|
||||
options.SummaryWindow = new TimeWindowCursorOptions
|
||||
{
|
||||
WindowSize = template.SummaryWindow.WindowSize,
|
||||
Overlap = template.SummaryWindow.Overlap,
|
||||
InitialBackfill = template.SummaryWindow.InitialBackfill,
|
||||
MinimumWindowSize = template.SummaryWindow.MinimumWindowSize,
|
||||
};
|
||||
options.MaxMonthlySummaries = template.MaxMonthlySummaries;
|
||||
options.MaxNotesPerFetch = template.MaxNotesPerFetch;
|
||||
options.DetailRequestDelay = template.DetailRequestDelay;
|
||||
options.EnableDetailMapping = template.EnableDetailMapping;
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
|
||||
});
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
var bootstrapper = _serviceProvider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task DisposeServiceProviderAsync()
|
||||
{
|
||||
if (_serviceProvider is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_serviceProvider is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_serviceProvider.Dispose();
|
||||
}
|
||||
|
||||
_serviceProvider = null;
|
||||
}
|
||||
|
||||
private static string ReadFixture(string filename)
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var primary = Path.Combine(baseDirectory, "Fixtures", filename);
|
||||
if (File.Exists(primary))
|
||||
{
|
||||
return File.ReadAllText(primary);
|
||||
}
|
||||
|
||||
return File.ReadAllText(Path.Combine(baseDirectory, filename));
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_handler.Clear();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await DisposeServiceProviderAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.CertCc;
|
||||
using StellaOps.Concelier.Connector.CertCc.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Cursors;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class CertCcConnectorSnapshotTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri SeptemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/09/summary/");
|
||||
private static readonly Uri OctoberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/");
|
||||
private static readonly Uri NovemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/11/summary/");
|
||||
private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/");
|
||||
private static readonly Uri VendorsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/");
|
||||
private static readonly Uri VulsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/");
|
||||
private static readonly Uri VendorStatusesDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/");
|
||||
|
||||
private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private ConnectorTestHarness? _harness;
|
||||
|
||||
public CertCcConnectorSnapshotTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchSummaryAndDetails_ProducesDeterministicSnapshots()
|
||||
{
|
||||
var initialTime = new DateTimeOffset(2025, 11, 1, 8, 0, 0, TimeSpan.Zero);
|
||||
var harness = await EnsureHarnessAsync(initialTime);
|
||||
|
||||
RegisterSummaryResponses(harness.Handler);
|
||||
RegisterDetailResponses(harness.Handler);
|
||||
|
||||
var connector = harness.ServiceProvider.GetRequiredService<CertCcConnector>();
|
||||
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
|
||||
var documentsSnapshot = await BuildDocumentsSnapshotAsync(harness.ServiceProvider);
|
||||
WriteOrAssertSnapshot(documentsSnapshot, "certcc-documents.snapshot.json");
|
||||
|
||||
var stateSnapshot = await BuildStateSnapshotAsync(harness.ServiceProvider);
|
||||
WriteOrAssertSnapshot(stateSnapshot, "certcc-state.snapshot.json");
|
||||
|
||||
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
|
||||
var advisoriesSnapshot = await BuildAdvisoriesSnapshotAsync(harness.ServiceProvider);
|
||||
WriteOrAssertSnapshot(advisoriesSnapshot, "certcc-advisories.snapshot.json");
|
||||
|
||||
harness.TimeProvider.Advance(TimeSpan.FromMinutes(30));
|
||||
RegisterSummaryNotModifiedResponses(harness.Handler);
|
||||
|
||||
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
var recordedRequests = harness.Handler.Requests
|
||||
.Select(request => request.Uri.ToString())
|
||||
.ToArray();
|
||||
recordedRequests.Should().Equal(new[]
|
||||
{
|
||||
SeptemberSummaryUri.ToString(),
|
||||
OctoberSummaryUri.ToString(),
|
||||
NoteDetailUri.ToString(),
|
||||
VendorsDetailUri.ToString(),
|
||||
VulsDetailUri.ToString(),
|
||||
VendorStatusesDetailUri.ToString(),
|
||||
YearlySummaryUri.ToString(),
|
||||
OctoberSummaryUri.ToString(),
|
||||
NovemberSummaryUri.ToString(),
|
||||
YearlySummaryUri.ToString(),
|
||||
});
|
||||
harness.Handler.AssertNoPendingResponses();
|
||||
|
||||
var requestsSnapshot = BuildRequestsSnapshot(harness.Handler.Requests);
|
||||
WriteOrAssertSnapshot(requestsSnapshot, "certcc-requests.snapshot.json");
|
||||
}
|
||||
|
||||
private async Task<ConnectorTestHarness> EnsureHarnessAsync(DateTimeOffset initialTime)
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
return _harness;
|
||||
}
|
||||
|
||||
var harness = new ConnectorTestHarness(_fixture, initialTime, CertCcOptions.HttpClientName);
|
||||
await harness.EnsureServiceProviderAsync(services =>
|
||||
{
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddCertCcConnector(options =>
|
||||
{
|
||||
options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
|
||||
options.SummaryWindow = new TimeWindowCursorOptions
|
||||
{
|
||||
WindowSize = TimeSpan.FromDays(30),
|
||||
Overlap = TimeSpan.FromDays(3),
|
||||
InitialBackfill = TimeSpan.FromDays(45),
|
||||
MinimumWindowSize = TimeSpan.FromDays(1),
|
||||
};
|
||||
options.MaxMonthlySummaries = 2;
|
||||
options.MaxNotesPerFetch = 1;
|
||||
options.DetailRequestDelay = TimeSpan.Zero;
|
||||
options.EnableDetailMapping = true;
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, options =>
|
||||
{
|
||||
options.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = harness.Handler);
|
||||
});
|
||||
});
|
||||
|
||||
_harness = harness;
|
||||
return harness;
|
||||
}
|
||||
|
||||
private static async Task<string> BuildDocumentsSnapshotAsync(IServiceProvider provider)
|
||||
{
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var uris = new[]
|
||||
{
|
||||
SeptemberSummaryUri,
|
||||
OctoberSummaryUri,
|
||||
NovemberSummaryUri,
|
||||
YearlySummaryUri,
|
||||
NoteDetailUri,
|
||||
VendorsDetailUri,
|
||||
VulsDetailUri,
|
||||
VendorStatusesDetailUri,
|
||||
};
|
||||
|
||||
var records = new List<object>(uris.Length);
|
||||
foreach (var uri in uris)
|
||||
{
|
||||
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
|
||||
if (record is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var lastModified = record.Headers is not null
|
||||
&& record.Headers.TryGetValue("Last-Modified", out var lastModifiedHeader)
|
||||
&& DateTimeOffset.TryParse(lastModifiedHeader, out var parsedLastModified)
|
||||
? parsedLastModified.ToUniversalTime().ToString("O")
|
||||
: record.LastModified?.ToUniversalTime().ToString("O");
|
||||
|
||||
records.Add(new
|
||||
{
|
||||
record.Uri,
|
||||
record.Status,
|
||||
record.Sha256,
|
||||
record.ContentType,
|
||||
LastModified = lastModified,
|
||||
Metadata = record.Metadata is null
|
||||
? null
|
||||
: record.Metadata
|
||||
.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase),
|
||||
record.Etag,
|
||||
});
|
||||
}
|
||||
|
||||
var ordered = records
|
||||
.OrderBy(static entry => entry.GetType().GetProperty("Uri")?.GetValue(entry)?.ToString(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return SnapshotSerializer.ToSnapshot(ordered);
|
||||
}
|
||||
|
||||
private static async Task<string> BuildStateSnapshotAsync(IServiceProvider provider)
|
||||
{
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
|
||||
var cursor = state!.Cursor ?? new BsonDocument();
|
||||
|
||||
BsonDocument? summaryDocument = null;
|
||||
if (cursor.TryGetValue("summary", out var summaryValue) && summaryValue is BsonDocument summaryDoc)
|
||||
{
|
||||
summaryDocument = summaryDoc;
|
||||
}
|
||||
|
||||
var summary = summaryDocument is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
Start = summaryDocument.TryGetValue("start", out var startValue) ? ToIsoString(startValue) : null,
|
||||
End = summaryDocument.TryGetValue("end", out var endValue) ? ToIsoString(endValue) : null,
|
||||
};
|
||||
|
||||
var snapshot = new
|
||||
{
|
||||
Summary = summary,
|
||||
PendingNotes = cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
|
||||
? pendingNotesValue.AsBsonArray.Select(static value => value.ToString()).OrderBy(static note => note, StringComparer.OrdinalIgnoreCase).ToArray()
|
||||
: Array.Empty<string>(),
|
||||
PendingSummaries = cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
|
||||
? pendingSummariesValue.AsBsonArray.Select(static value => value.ToString()).OrderBy(static item => item, StringComparer.OrdinalIgnoreCase).ToArray()
|
||||
: Array.Empty<string>(),
|
||||
LastRun = cursor.TryGetValue("lastRun", out var lastRunValue) ? ToIsoString(lastRunValue) : null,
|
||||
state.LastSuccess,
|
||||
state.LastFailure,
|
||||
state.FailCount,
|
||||
state.BackoffUntil,
|
||||
};
|
||||
|
||||
return SnapshotSerializer.ToSnapshot(snapshot);
|
||||
}
|
||||
|
||||
private static async Task<string> BuildAdvisoriesSnapshotAsync(IServiceProvider provider)
|
||||
{
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = new List<Advisory>();
|
||||
await foreach (var advisory in advisoryStore.StreamAsync(CancellationToken.None))
|
||||
{
|
||||
advisories.Add(advisory);
|
||||
}
|
||||
|
||||
var ordered = advisories
|
||||
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return SnapshotSerializer.ToSnapshot(ordered);
|
||||
}
|
||||
|
||||
private static string BuildRequestsSnapshot(IReadOnlyCollection<CannedHttpMessageHandler.CannedRequestRecord> requests)
|
||||
{
|
||||
var ordered = requests
|
||||
.OrderBy(static request => request.Timestamp)
|
||||
.Select(static request => new
|
||||
{
|
||||
request.Method.Method,
|
||||
Uri = request.Uri.ToString(),
|
||||
Headers = new
|
||||
{
|
||||
Accept = TryGetHeader(request.Headers, "Accept"),
|
||||
IfNoneMatch = TryGetHeader(request.Headers, "If-None-Match"),
|
||||
IfModifiedSince = TryGetHeader(request.Headers, "If-Modified-Since"),
|
||||
},
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return SnapshotSerializer.ToSnapshot(ordered);
|
||||
}
|
||||
|
||||
private static void RegisterSummaryResponses(CannedHttpMessageHandler handler)
|
||||
{
|
||||
AddJsonResponse(handler, SeptemberSummaryUri, "summary-2025-09.json", "\"certcc-summary-2025-09\"", new DateTimeOffset(2025, 9, 30, 12, 0, 0, TimeSpan.Zero));
|
||||
AddJsonResponse(handler, OctoberSummaryUri, "summary-2025-10.json", "\"certcc-summary-2025-10\"", new DateTimeOffset(2025, 10, 31, 12, 0, 0, TimeSpan.Zero));
|
||||
AddJsonResponse(handler, YearlySummaryUri, "summary-2025.json", "\"certcc-summary-2025\"", new DateTimeOffset(2025, 10, 31, 12, 1, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
private static void RegisterSummaryNotModifiedResponses(CannedHttpMessageHandler handler)
|
||||
{
|
||||
AddNotModified(handler, OctoberSummaryUri, "\"certcc-summary-2025-10\"");
|
||||
AddNotModified(handler, NovemberSummaryUri, "\"certcc-summary-2025-11\"");
|
||||
AddNotModified(handler, YearlySummaryUri, "\"certcc-summary-2025\"");
|
||||
}
|
||||
|
||||
private static void RegisterDetailResponses(CannedHttpMessageHandler handler)
|
||||
{
|
||||
AddJsonResponse(handler, NoteDetailUri, "vu-294418.json", "\"certcc-note-294418\"", new DateTimeOffset(2025, 10, 9, 16, 52, 0, TimeSpan.Zero));
|
||||
AddJsonResponse(handler, VendorsDetailUri, "vu-294418-vendors.json", "\"certcc-vendors-294418\"", new DateTimeOffset(2025, 10, 9, 17, 5, 0, TimeSpan.Zero));
|
||||
AddJsonResponse(handler, VulsDetailUri, "vu-294418-vuls.json", "\"certcc-vuls-294418\"", new DateTimeOffset(2025, 10, 9, 17, 10, 0, TimeSpan.Zero));
|
||||
AddJsonResponse(handler, VendorStatusesDetailUri, "vendor-statuses-294418.json", "\"certcc-vendor-statuses-294418\"", new DateTimeOffset(2025, 10, 9, 17, 12, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
private static void AddJsonResponse(CannedHttpMessageHandler handler, Uri uri, string fixtureName, string etag, DateTimeOffset lastModified)
|
||||
{
|
||||
var payload = ReadFixture(fixtureName);
|
||||
handler.AddResponse(HttpMethod.Get, uri, _ =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
response.Headers.TryAddWithoutValidation("Last-Modified", lastModified.ToString("R"));
|
||||
response.Content.Headers.LastModified = lastModified;
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddNotModified(CannedHttpMessageHandler handler, Uri uri, string etag)
|
||||
{
|
||||
handler.AddResponse(HttpMethod.Get, uri, _ =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private static string ReadFixture(string filename)
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var primary = Path.Combine(baseDir, "Fixtures", filename);
|
||||
if (File.Exists(primary))
|
||||
{
|
||||
return File.ReadAllText(primary);
|
||||
}
|
||||
|
||||
var fallback = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
|
||||
if (File.Exists(fallback))
|
||||
{
|
||||
return File.ReadAllText(fallback);
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Missing CERT/CC fixture '{filename}'.");
|
||||
}
|
||||
|
||||
private static string? TryGetHeader(IReadOnlyDictionary<string, string> headers, string key)
|
||||
=> headers.TryGetValue(key, out var value) ? value : null;
|
||||
|
||||
private static string? ToIsoString(BsonValue value)
|
||||
{
|
||||
return value.BsonType switch
|
||||
{
|
||||
BsonType.DateTime => value.ToUniversalTime().ToString("O"),
|
||||
BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime().ToString("O"),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static void WriteOrAssertSnapshot(string snapshot, string filename)
|
||||
{
|
||||
var normalizedSnapshot = Normalize(snapshot);
|
||||
if (ShouldUpdateFixtures() || !FixtureExists(filename))
|
||||
{
|
||||
var path = GetWritablePath(filename);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, normalizedSnapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
var expected = ReadFixture(filename);
|
||||
var normalizedExpected = Normalize(expected);
|
||||
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(Path.GetDirectoryName(GetWritablePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
|
||||
File.WriteAllText(actualPath, normalizedSnapshot);
|
||||
}
|
||||
|
||||
Assert.Equal(normalizedExpected, normalizedSnapshot);
|
||||
}
|
||||
|
||||
private static string GetWritablePath(string filename)
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
return Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
|
||||
}
|
||||
|
||||
private static string Normalize(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
|
||||
|
||||
private static bool ShouldUpdateFixtures()
|
||||
{
|
||||
var flag = Environment.GetEnvironmentVariable("UPDATE_CERTCC_FIXTURES");
|
||||
return string.Equals(flag, "1", StringComparison.Ordinal) || string.Equals(flag, "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool FixtureExists(string filename)
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var primary = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
|
||||
if (File.Exists(primary))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var fallback = Path.Combine(baseDir, "Fixtures", filename);
|
||||
return File.Exists(fallback);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Connector.CertCc;
|
||||
using StellaOps.Concelier.Connector.CertCc.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Cursors;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class CertCcConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri MonthlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/");
|
||||
private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/");
|
||||
private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/");
|
||||
private static readonly Uri VendorsUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/");
|
||||
private static readonly Uri VulsUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/");
|
||||
private static readonly Uri VendorStatusesUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public CertCcConnectorTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 9, 30, 0, TimeSpan.Zero));
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_ProducesCanonicalAdvisory()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedSummaryResponses();
|
||||
SeedDetailResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CertCcConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
advisories.Should().NotBeNull();
|
||||
advisories.Should().HaveCountGreaterThan(0);
|
||||
|
||||
var advisory = advisories.FirstOrDefault(a => a.AdvisoryKey == "certcc/vu-294418");
|
||||
advisory.Should().NotBeNull();
|
||||
advisory!.Title.Should().ContainEquivalentOf("DrayOS");
|
||||
advisory.Summary.Should().NotBeNullOrWhiteSpace();
|
||||
advisory.Aliases.Should().Contain("VU#294418");
|
||||
advisory.Aliases.Should().Contain("CVE-2025-10547");
|
||||
advisory.AffectedPackages.Should().NotBeNull();
|
||||
advisory.AffectedPackages.Should().HaveCountGreaterThan(0);
|
||||
advisory.AffectedPackages![0].NormalizedVersions.Should().NotBeNull();
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
|
||||
? pendingDocsValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingDocuments.Should().Be(0);
|
||||
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
|
||||
? pendingMappingsValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingMappings.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_PersistsSummaryAndDetailDocuments()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedSummaryResponses();
|
||||
SeedDetailResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CertCcConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
|
||||
var summaryDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, MonthlySummaryUri.ToString(), CancellationToken.None);
|
||||
summaryDocument.Should().NotBeNull();
|
||||
summaryDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
|
||||
|
||||
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
|
||||
noteDocument.Should().NotBeNull();
|
||||
noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
|
||||
noteDocument.Metadata.Should().NotBeNull();
|
||||
noteDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("note");
|
||||
noteDocument.Metadata.Should().ContainKey("certcc.noteId").WhoseValue.Should().Be("294418");
|
||||
noteDocument.Metadata.Should().ContainKey("certcc.vuid").WhoseValue.Should().Be("VU#294418");
|
||||
|
||||
var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None);
|
||||
vendorsDocument.Should().NotBeNull();
|
||||
vendorsDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
|
||||
vendorsDocument.Metadata.Should().NotBeNull();
|
||||
vendorsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors");
|
||||
|
||||
var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None);
|
||||
vulsDocument.Should().NotBeNull();
|
||||
vulsDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
|
||||
vulsDocument.Metadata.Should().NotBeNull();
|
||||
vulsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vuls");
|
||||
|
||||
var vendorStatusesDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorStatusesUri.ToString(), CancellationToken.None);
|
||||
vendorStatusesDocument.Should().NotBeNull();
|
||||
vendorStatusesDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
|
||||
vendorStatusesDocument.Metadata.Should().NotBeNull();
|
||||
vendorStatusesDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors-vuls");
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
state!.Cursor.Should().NotBeNull();
|
||||
var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
|
||||
? pendingNotesValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingNotesCount.Should().Be(0);
|
||||
var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
|
||||
? pendingSummariesValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingSummariesCount.Should().Be(0);
|
||||
|
||||
var pendingDocumentsCount = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue)
|
||||
? pendingDocumentsValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingDocumentsCount.Should().Be(4);
|
||||
|
||||
var pendingMappingsCount = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
|
||||
? pendingMappingsValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingMappingsCount.Should().Be(0);
|
||||
|
||||
_handler.Requests.Should().Contain(request => request.Uri == NoteDetailUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_ReusesConditionalRequestsOnSubsequentRun()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedSummaryResponses(summaryEtag: "\"summary-oct\"", yearlyEtag: "\"summary-year\"");
|
||||
SeedDetailResponses(detailEtag: "\"note-etag\"", vendorsEtag: "\"vendors-etag\"", vulsEtag: "\"vuls-etag\"", vendorStatusesEtag: "\"vendor-statuses-etag\"");
|
||||
|
||||
var connector = provider.GetRequiredService<CertCcConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
_handler.Clear();
|
||||
SeedSummaryNotModifiedResponses("\"summary-oct\"", "\"summary-year\"");
|
||||
SeedDetailNotModifiedResponses("\"note-etag\"", "\"vendors-etag\"", "\"vuls-etag\"", "\"vendor-statuses-etag\"");
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(15));
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
var requests = _handler.Requests.ToArray();
|
||||
requests.Should().OnlyContain(r =>
|
||||
r.Uri == MonthlySummaryUri
|
||||
|| r.Uri == YearlySummaryUri
|
||||
|| r.Uri == NoteDetailUri
|
||||
|| r.Uri == VendorsUri
|
||||
|| r.Uri == VulsUri
|
||||
|| r.Uri == VendorStatusesUri);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
var pendingNotesCount = state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
|
||||
? pendingNotesValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingNotesCount.Should().Be(0);
|
||||
var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
|
||||
? pendingSummariesValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingSummaries.Should().Be(0);
|
||||
|
||||
var pendingDocuments = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue)
|
||||
? pendingDocumentsValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingDocuments.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_DetailFailureRecordsBackoffAndKeepsPendingNote()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedSummaryResponses();
|
||||
SeedDetailResponses(vendorsStatus: HttpStatusCode.InternalServerError);
|
||||
|
||||
var connector = provider.GetRequiredService<CertCcConnector>();
|
||||
var failure = await Assert.ThrowsAnyAsync<Exception>(() => connector.FetchAsync(provider, CancellationToken.None));
|
||||
Assert.True(failure is HttpRequestException || failure is InvalidOperationException);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
state!.FailCount.Should().BeGreaterThan(0);
|
||||
state.BackoffUntil.Should().NotBeNull();
|
||||
state.BackoffUntil.Should().BeAfter(_timeProvider.GetUtcNow());
|
||||
state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue();
|
||||
pendingNotesValue!.AsBsonArray.Should().Contain(value => value.AsString == "294418");
|
||||
var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
|
||||
? pendingSummariesValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingSummaries.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_PartialDetailEndpointsMissing_CompletesAndMaps()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedSummaryResponses();
|
||||
SeedDetailResponses(
|
||||
vulsStatus: HttpStatusCode.NotFound,
|
||||
vendorStatusesStatus: HttpStatusCode.NotFound);
|
||||
|
||||
var connector = provider.GetRequiredService<CertCcConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
advisories.Should().NotBeNull();
|
||||
advisories!.Should().Contain(advisory => advisory.AdvisoryKey == "certcc/vu-294418");
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None);
|
||||
vendorsDocument.Should().NotBeNull();
|
||||
vendorsDocument!.Status.Should().Be(DocumentStatuses.Mapped);
|
||||
var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None);
|
||||
vulsDocument.Should().BeNull();
|
||||
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
|
||||
noteDocument.Should().NotBeNull();
|
||||
noteDocument!.Status.Should().Be(DocumentStatuses.Mapped);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue();
|
||||
pendingNotesValue!.AsBsonArray.Should().BeEmpty();
|
||||
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue).Should().BeTrue();
|
||||
pendingDocsValue!.AsBsonArray.Should().BeEmpty();
|
||||
state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue).Should().BeTrue();
|
||||
pendingMappingsValue!.AsBsonArray.Should().BeEmpty();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAndMap_SkipWhenDetailMappingDisabled()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync(enableDetailMapping: false);
|
||||
SeedSummaryResponses();
|
||||
SeedDetailResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CertCcConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
advisories.Should().BeNullOrEmpty();
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
|
||||
noteDocument.Should().NotBeNull();
|
||||
noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
|
||||
? pendingDocsValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingDocuments.Should().BeGreaterThan(0);
|
||||
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
|
||||
? pendingMappingsValue!.AsBsonArray.Count
|
||||
: 0;
|
||||
pendingMappings.Should().Be(0);
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync(bool enableDetailMapping = true)
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddSingleton(_handler);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddCertCcConnector(options =>
|
||||
{
|
||||
options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/");
|
||||
options.SummaryWindow = new TimeWindowCursorOptions
|
||||
{
|
||||
WindowSize = TimeSpan.FromDays(1),
|
||||
Overlap = TimeSpan.Zero,
|
||||
InitialBackfill = TimeSpan.FromDays(1),
|
||||
MinimumWindowSize = TimeSpan.FromHours(6),
|
||||
};
|
||||
options.MaxMonthlySummaries = 1;
|
||||
options.MaxNotesPerFetch = 5;
|
||||
options.DetailRequestDelay = TimeSpan.Zero;
|
||||
options.EnableDetailMapping = enableDetailMapping;
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
{
|
||||
builder.PrimaryHandler = _handler;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedSummaryResponses(string summaryEtag = "\"summary-oct\"", string yearlyEtag = "\"summary-year\"")
|
||||
{
|
||||
AddJsonResponse(MonthlySummaryUri, ReadFixture("summary-2025-10.json"), summaryEtag);
|
||||
AddJsonResponse(YearlySummaryUri, ReadFixture("summary-2025.json"), yearlyEtag);
|
||||
}
|
||||
|
||||
private void SeedSummaryNotModifiedResponses(string summaryEtag, string yearlyEtag)
|
||||
{
|
||||
AddNotModifiedResponse(MonthlySummaryUri, summaryEtag);
|
||||
AddNotModifiedResponse(YearlySummaryUri, yearlyEtag);
|
||||
}
|
||||
|
||||
private void SeedDetailResponses(
|
||||
string detailEtag = "\"note-etag\"",
|
||||
string vendorsEtag = "\"vendors-etag\"",
|
||||
string vulsEtag = "\"vuls-etag\"",
|
||||
string vendorStatusesEtag = "\"vendor-statuses-etag\"",
|
||||
HttpStatusCode vendorsStatus = HttpStatusCode.OK,
|
||||
HttpStatusCode vulsStatus = HttpStatusCode.OK,
|
||||
HttpStatusCode vendorStatusesStatus = HttpStatusCode.OK)
|
||||
{
|
||||
AddJsonResponse(NoteDetailUri, ReadFixture("vu-294418.json"), detailEtag);
|
||||
|
||||
if (vendorsStatus == HttpStatusCode.OK)
|
||||
{
|
||||
AddJsonResponse(VendorsUri, ReadFixture("vu-294418-vendors.json"), vendorsEtag);
|
||||
}
|
||||
else
|
||||
{
|
||||
_handler.AddResponse(VendorsUri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(vendorsStatus)
|
||||
{
|
||||
Content = new StringContent("vendors error", Encoding.UTF8, "text/plain"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue(vendorsEtag);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
if (vulsStatus == HttpStatusCode.OK)
|
||||
{
|
||||
AddJsonResponse(VulsUri, ReadFixture("vu-294418-vuls.json"), vulsEtag);
|
||||
}
|
||||
else
|
||||
{
|
||||
_handler.AddResponse(VulsUri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(vulsStatus)
|
||||
{
|
||||
Content = new StringContent("vuls error", Encoding.UTF8, "text/plain"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue(vulsEtag);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
if (vendorStatusesStatus == HttpStatusCode.OK)
|
||||
{
|
||||
AddJsonResponse(VendorStatusesUri, ReadFixture("vendor-statuses-294418.json"), vendorStatusesEtag);
|
||||
}
|
||||
else
|
||||
{
|
||||
_handler.AddResponse(VendorStatusesUri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(vendorStatusesStatus)
|
||||
{
|
||||
Content = new StringContent("vendor statuses error", Encoding.UTF8, "text/plain"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue(vendorStatusesEtag);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void SeedDetailNotModifiedResponses(string detailEtag, string vendorsEtag, string vulsEtag, string vendorStatusesEtag)
|
||||
{
|
||||
AddNotModifiedResponse(NoteDetailUri, detailEtag);
|
||||
AddNotModifiedResponse(VendorsUri, vendorsEtag);
|
||||
AddNotModifiedResponse(VulsUri, vulsEtag);
|
||||
AddNotModifiedResponse(VendorStatusesUri, vendorStatusesEtag);
|
||||
}
|
||||
|
||||
private void AddJsonResponse(Uri uri, string json, string etag)
|
||||
{
|
||||
_handler.AddResponse(uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private void AddNotModifiedResponse(Uri uri, string etag)
|
||||
{
|
||||
_handler.AddResponse(uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private static string ReadFixture(string filename)
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var candidate = Path.Combine(baseDirectory, "Source", "CertCc", "Fixtures", filename);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return File.ReadAllText(candidate);
|
||||
}
|
||||
|
||||
var fallback = Path.Combine(baseDirectory, "Fixtures", filename);
|
||||
return File.ReadAllText(fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
[
|
||||
{
|
||||
"advisoryKey": "certcc/vu-294418",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "vendor",
|
||||
"identifier": "DrayTek Corporation",
|
||||
"platform": null,
|
||||
"versionRanges": [
|
||||
{
|
||||
"fixedVersion": null,
|
||||
"introducedVersion": null,
|
||||
"lastAffectedVersion": null,
|
||||
"primitives": {
|
||||
"evr": null,
|
||||
"hasVendorExtensions": true,
|
||||
"nevra": null,
|
||||
"semVer": null,
|
||||
"vendorExtensions": {
|
||||
"certcc.vendor.name": "DrayTek Corporation",
|
||||
"certcc.vendor.statement.raw": "The issue is confirmed, and here is the patch list\nV3912/V3910/V2962/V1000B 4.4.3.6/4.4.5.1\nV2927/V2865/V2866 4.5.1\nV2765/V2766/V2763/V2135 4.5.1\nV2915 4.4.6.1\nV2862/V2926 3.9.9.12\nV2952/3220 3.9.8.8\nV2860/V2925 3.9.8.6\nV2133/V2762/V2832 3.9.9.4\nV2620/LTE200 3.9.9.5",
|
||||
"certcc.vendor.contactDate": "2025-09-15T19:03:33.6643450+00:00",
|
||||
"certcc.vendor.statementDate": "2025-09-16T02:27:51.3463350+00:00",
|
||||
"certcc.vendor.updated": "2025-10-03T11:35:31.1906610+00:00",
|
||||
"certcc.vendor.statuses": "CVE-2025-10547=affected",
|
||||
"certcc.vendor.patches": "3220=3.9.8.8;LTE200=3.9.9.5;V1000B=4.4.5.1;V2133=3.9.9.4;V2135=4.5.1;V2620=3.9.9.5;V2762=3.9.9.4;V2763=4.5.1;V2765=4.5.1;V2766=4.5.1;V2832=3.9.9.4;V2860=3.9.8.6;V2862=3.9.9.12;V2865=4.5.1;V2866=4.5.1;V2915=4.4.6.1;V2925=3.9.8.6;V2926=3.9.9.12;V2927=4.5.1;V2952=3.9.8.8;V2962=4.4.5.1;V3910=4.4.3.6;V3912=4.4.3.6"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"source": "cert-cc",
|
||||
"kind": "vendor-range",
|
||||
"value": "DrayTek Corporation",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-11-01T08:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"rangeExpression": null,
|
||||
"rangeKind": "vendor"
|
||||
}
|
||||
],
|
||||
"normalizedVersions": [
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.8.6",
|
||||
"notes": "DrayTek Corporation::V2860"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.8.6",
|
||||
"notes": "DrayTek Corporation::V2925"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.8.8",
|
||||
"notes": "DrayTek Corporation::3220"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.8.8",
|
||||
"notes": "DrayTek Corporation::V2952"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.9.12",
|
||||
"notes": "DrayTek Corporation::V2862"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.9.12",
|
||||
"notes": "DrayTek Corporation::V2926"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.9.4",
|
||||
"notes": "DrayTek Corporation::V2133"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.9.4",
|
||||
"notes": "DrayTek Corporation::V2762"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.9.4",
|
||||
"notes": "DrayTek Corporation::V2832"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.9.5",
|
||||
"notes": "DrayTek Corporation::LTE200"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "3.9.9.5",
|
||||
"notes": "DrayTek Corporation::V2620"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.4.3.6",
|
||||
"notes": "DrayTek Corporation::V3910"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.4.3.6",
|
||||
"notes": "DrayTek Corporation::V3912"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.4.5.1",
|
||||
"notes": "DrayTek Corporation::V1000B"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.4.5.1",
|
||||
"notes": "DrayTek Corporation::V2962"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.4.6.1",
|
||||
"notes": "DrayTek Corporation::V2915"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.5.1",
|
||||
"notes": "DrayTek Corporation::V2135"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.5.1",
|
||||
"notes": "DrayTek Corporation::V2763"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.5.1",
|
||||
"notes": "DrayTek Corporation::V2765"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.5.1",
|
||||
"notes": "DrayTek Corporation::V2766"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.5.1",
|
||||
"notes": "DrayTek Corporation::V2865"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.5.1",
|
||||
"notes": "DrayTek Corporation::V2866"
|
||||
},
|
||||
{
|
||||
"scheme": "certcc.vendor",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "4.5.1",
|
||||
"notes": "DrayTek Corporation::V2927"
|
||||
}
|
||||
],
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "cert-cc",
|
||||
"kind": "vendor-status",
|
||||
"value": "DrayTek Corporation:CVE-2025-10547",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-11-01T08:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "affected"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "cert-cc",
|
||||
"kind": "vendor",
|
||||
"value": "DrayTek Corporation",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-11-01T08:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CVE-2025-10547",
|
||||
"VU#294418"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": "2025-10-03T11:40:09.876722+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"source": "cert-cc",
|
||||
"kind": "document",
|
||||
"value": "https://www.kb.cert.org/vuls/api/294418/",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-11-01T08:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
{
|
||||
"source": "cert-cc",
|
||||
"kind": "map",
|
||||
"value": "VU#294418",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-11-01T08:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
],
|
||||
"published": "2025-10-03T11:35:31.026053+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"source": "cert-cc",
|
||||
"kind": "reference",
|
||||
"value": "https://www.kb.cert.org/vuls/id/294418",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-11-01T08:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "certcc.public",
|
||||
"summary": null,
|
||||
"url": "https://www.draytek.com/about/security-advisory/use-of-uninitialized-variable-vulnerabilities/"
|
||||
},
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"source": "cert-cc",
|
||||
"kind": "reference",
|
||||
"value": "https://www.kb.cert.org/vuls/id/294418",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-11-01T08:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "certcc.public",
|
||||
"summary": null,
|
||||
"url": "https://www.draytek.com/support/resources?type=version"
|
||||
},
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "cert-cc",
|
||||
"kind": "reference",
|
||||
"value": "https://www.kb.cert.org/vuls/id/294418",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-11-01T08:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "certcc.note",
|
||||
"summary": null,
|
||||
"url": "https://www.kb.cert.org/vuls/id/294418"
|
||||
}
|
||||
],
|
||||
"severity": null,
|
||||
"summary": "Overview\nA remote code execution (RCE) vulnerability, tracked as CVE-2025-10547, was discovered through the EasyVPN and LAN web administration interface of Vigor routers by Draytek. A script in the LAN web administration interface uses an unitialized variable, allowing an attacker to send specially crafted HTTP requests that cause memory corruption and potentially allow arbitrary code execution.\nDescription\nVigor routers are business-grade routers, designed for small to medium-sized businesses, made by Draytek. These routers provide routing, firewall, VPN, content-filtering, bandwidth management, LAN (local area network), and multi-WAN (wide area network) features. Draytek utilizes a proprietary firmware, DrayOS, on the Vigor router line. DrayOS features the EasyVPN and LAN Web Administrator tool s to facilitate LAN and VPN setup. According to the DrayTek website, \"with EasyVPN, users no longer need to generate WireGuard keys, import OpenVPN configuration files, or upload certificates. Instead, VPN can be successfully established by simply entering the username and password or getting the OTP code by email.\"\nThe LAN Web Administrator provides a browser-based user interface for router management. When a user interacts with the LAN Web Administration interface, the user interface elements trigger actions that generate HTTP requests to interact with the local server. This process contains an uninitialized variable. Due to the uninitialized variable, an unauthenticated attacker could perform memory corruption on the router via specially crafted HTTP requests to hijack execution or inject malicious payloads. If EasyVPN is enabled, the flaw could be remotely exploited through the VPN interface.\nImpact\nA remote, unathenticated attacker can exploit this vulnerability through accessing the LAN interface—or potentially the WAN interface—if EasyVPN is enabled or remote administration over the internet is activated. If a remote, unauthenticated attacker leverages this vulnerability, they can execute arbitrary code on the router (RCE) and gain full control of the device. A successful attack could result in a attacker gaining root access to a Vigor router to then install backdoors, reconfigure network settings, or block traffic. An attacker may also pivot for lateral movement via intercepting internal communications and bypassing VPNs.\nSolution\nThe DrayTek Security team has developed a series of patches to remediate the vulnerability, and all users of Vigor routers should upgrade to the latest version ASAP. The patches can be found on the resources page of the DrayTek webpage, and the security advisory can be found within the about section of the DrayTek webpage. Consult either the CVE listing or the advisory page for a full list of affected products.\nAcknowledgements\nThanks to the reporter, Pierre-Yves MAES of ChapsVision (pymaes@chapsvision.com). This document was written by Ayushi Kriplani.",
|
||||
"title": "Vigor routers running DrayOS are vulnerable to RCE via EasyVPN and LAN web administration interface"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
[
|
||||
{
|
||||
"contentType": "application/json; charset=utf-8",
|
||||
"etag": "\"certcc-summary-2025-09\"",
|
||||
"lastModified": "2025-09-30T12:00:00.0000000+00:00",
|
||||
"metadata": {
|
||||
"attempts": "1",
|
||||
"certcc.month": "09",
|
||||
"certcc.scope": "monthly",
|
||||
"certcc.year": "2025",
|
||||
"fetchedAt": "2025-11-01T08:00:00.0000000+00:00"
|
||||
},
|
||||
"sha256": "0475f0766d6b96d7dc7683cf6b418055c8ecbef88a73ab5d75ce428fbd0900fc",
|
||||
"status": "pending-parse",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/2025/09/summary/"
|
||||
},
|
||||
{
|
||||
"contentType": "application/json; charset=utf-8",
|
||||
"etag": "\"certcc-summary-2025-10\"",
|
||||
"lastModified": "2025-10-31T12:00:00.0000000+00:00",
|
||||
"metadata": {
|
||||
"attempts": "1",
|
||||
"certcc.month": "10",
|
||||
"certcc.scope": "monthly",
|
||||
"certcc.year": "2025",
|
||||
"fetchedAt": "2025-11-01T08:00:00.0000000+00:00"
|
||||
},
|
||||
"sha256": "363e3ddcd31770e5f41913328318ca0e5bf384bb059d5673ba14392f29f7296f",
|
||||
"status": "pending-parse",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/2025/10/summary/"
|
||||
},
|
||||
{
|
||||
"contentType": "application/json; charset=utf-8",
|
||||
"etag": "\"certcc-summary-2025\"",
|
||||
"lastModified": "2025-10-31T12:01:00.0000000+00:00",
|
||||
"metadata": {
|
||||
"attempts": "1",
|
||||
"certcc.scope": "yearly",
|
||||
"certcc.year": "2025",
|
||||
"fetchedAt": "2025-11-01T08:00:00.0000000+00:00"
|
||||
},
|
||||
"sha256": "363e3ddcd31770e5f41913328318ca0e5bf384bb059d5673ba14392f29f7296f",
|
||||
"status": "pending-parse",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/2025/summary/"
|
||||
},
|
||||
{
|
||||
"contentType": "application/json; charset=utf-8",
|
||||
"etag": "\"certcc-note-294418\"",
|
||||
"lastModified": "2025-10-09T16:52:00.0000000+00:00",
|
||||
"metadata": {
|
||||
"attempts": "1",
|
||||
"certcc.endpoint": "note",
|
||||
"certcc.noteId": "294418",
|
||||
"certcc.vuid": "VU#294418",
|
||||
"fetchedAt": "2025-11-01T08:00:00.0000000+00:00"
|
||||
},
|
||||
"sha256": "5dd5c9bcd6ed6f20a2fc07a308af9f420b9a07120fe5934de2a1c26724eb36d3",
|
||||
"status": "pending-parse",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/294418/"
|
||||
},
|
||||
{
|
||||
"contentType": "application/json; charset=utf-8",
|
||||
"etag": "\"certcc-vendors-294418\"",
|
||||
"lastModified": "2025-10-09T17:05:00.0000000+00:00",
|
||||
"metadata": {
|
||||
"attempts": "1",
|
||||
"certcc.endpoint": "vendors",
|
||||
"certcc.noteId": "294418",
|
||||
"certcc.vuid": "VU#294418",
|
||||
"fetchedAt": "2025-11-01T08:00:00.0000000+00:00"
|
||||
},
|
||||
"sha256": "b81aad835ab289c2ac68262825d0f0d5eb9212bc7b3569c84921d0fe5160734f",
|
||||
"status": "pending-parse",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/294418/vendors/"
|
||||
},
|
||||
{
|
||||
"contentType": "application/json; charset=utf-8",
|
||||
"etag": "\"certcc-vendor-statuses-294418\"",
|
||||
"lastModified": "2025-10-09T17:12:00.0000000+00:00",
|
||||
"metadata": {
|
||||
"attempts": "1",
|
||||
"certcc.endpoint": "vendors-vuls",
|
||||
"certcc.noteId": "294418",
|
||||
"certcc.vuid": "VU#294418",
|
||||
"fetchedAt": "2025-11-01T08:00:00.0000000+00:00"
|
||||
},
|
||||
"sha256": "6ad928c8a1b0410693417869d83062347747a79da6946404d94d14a2458c23ea",
|
||||
"status": "pending-parse",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/294418/vendors/vuls/"
|
||||
},
|
||||
{
|
||||
"contentType": "application/json; charset=utf-8",
|
||||
"etag": "\"certcc-vuls-294418\"",
|
||||
"lastModified": "2025-10-09T17:10:00.0000000+00:00",
|
||||
"metadata": {
|
||||
"attempts": "1",
|
||||
"certcc.endpoint": "vuls",
|
||||
"certcc.noteId": "294418",
|
||||
"certcc.vuid": "VU#294418",
|
||||
"fetchedAt": "2025-11-01T08:00:00.0000000+00:00"
|
||||
},
|
||||
"sha256": "5de3b82f360e1ff06f15873f55ff10b7c4fc11ca65a5f77a3941a82018a8a7de",
|
||||
"status": "pending-parse",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/294418/vuls/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
[
|
||||
{
|
||||
"headers": {
|
||||
"accept": "application/json",
|
||||
"ifModifiedSince": null,
|
||||
"ifNoneMatch": null
|
||||
},
|
||||
"method": "GET",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/2025/09/summary/"
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"accept": "application/json",
|
||||
"ifModifiedSince": null,
|
||||
"ifNoneMatch": null
|
||||
},
|
||||
"method": "GET",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/2025/10/summary/"
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"accept": "application/json",
|
||||
"ifModifiedSince": null,
|
||||
"ifNoneMatch": null
|
||||
},
|
||||
"method": "GET",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/294418/"
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"accept": "application/json",
|
||||
"ifModifiedSince": null,
|
||||
"ifNoneMatch": null
|
||||
},
|
||||
"method": "GET",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/294418/vendors/"
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"accept": "application/json",
|
||||
"ifModifiedSince": null,
|
||||
"ifNoneMatch": null
|
||||
},
|
||||
"method": "GET",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/294418/vuls/"
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"accept": "application/json",
|
||||
"ifModifiedSince": null,
|
||||
"ifNoneMatch": null
|
||||
},
|
||||
"method": "GET",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/294418/vendors/vuls/"
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"accept": "application/json",
|
||||
"ifModifiedSince": null,
|
||||
"ifNoneMatch": null
|
||||
},
|
||||
"method": "GET",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/2025/summary/"
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"accept": "application/json",
|
||||
"ifModifiedSince": "Fri, 31 Oct 2025 12:00:00 GMT",
|
||||
"ifNoneMatch": "\"certcc-summary-2025-10\""
|
||||
},
|
||||
"method": "GET",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/2025/10/summary/"
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"accept": "application/json",
|
||||
"ifModifiedSince": null,
|
||||
"ifNoneMatch": null
|
||||
},
|
||||
"method": "GET",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/2025/11/summary/"
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"accept": "application/json",
|
||||
"ifModifiedSince": "Fri, 31 Oct 2025 12:01:00 GMT",
|
||||
"ifNoneMatch": "\"certcc-summary-2025\""
|
||||
},
|
||||
"method": "GET",
|
||||
"uri": "https://www.kb.cert.org/vuls/api/2025/summary/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"backoffUntil": null,
|
||||
"failCount": 0,
|
||||
"lastFailure": null,
|
||||
"lastRun": "2025-11-01T08:00:00.0000000Z",
|
||||
"lastSuccess": "2025-11-01T08:00:00+00:00",
|
||||
"pendingNotes": [],
|
||||
"pendingSummaries": [],
|
||||
"summary": {
|
||||
"end": "2025-10-17T08:00:00.0000000Z",
|
||||
"start": "2025-09-17T08:00:00.0000000Z"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"count": 0,
|
||||
"notes": []
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"count": 1,
|
||||
"notes": [
|
||||
"VU#294418"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"count": 0,
|
||||
"notes": []
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"count": 1,
|
||||
"notes": [
|
||||
"VU#294418"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"vul": "CVE-2025-10547",
|
||||
"vendor": "DrayTek Corporation",
|
||||
"status": "Affected",
|
||||
"date_added": "2025-10-03T11:35:31.202991Z",
|
||||
"dateupdated": "2025-10-03T11:40:09.944401Z",
|
||||
"references": null,
|
||||
"statement": null
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"note": "294418",
|
||||
"contact_date": "2025-09-15T19:03:33.664345Z",
|
||||
"vendor": "DrayTek Corporation",
|
||||
"references": "",
|
||||
"statement": "The issue is confirmed, and here is the patch list\r\n\r\nV3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\r\nV2927/V2865/V2866\t4.5.1\r\nV2765/V2766/V2763/V2135\t4.5.1\r\nV2915\t4.4.6.1\r\nV2862/V2926\t3.9.9.12\r\nV2952/3220\t3.9.8.8\r\nV2860/V2925\t3.9.8.6\r\nV2133/V2762/V2832\t3.9.9.4\r\nV2620/LTE200\t3.9.9.5",
|
||||
"dateupdated": "2025-10-03T11:35:31.190661Z",
|
||||
"statement_date": "2025-09-16T02:27:51.346335Z",
|
||||
"addendum": null
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"vuid": "VU#257161",
|
||||
"idnumber": "257161",
|
||||
"name": "Treck IP stacks contain multiple vulnerabilities",
|
||||
"keywords": null,
|
||||
"overview": "### Overview\r\nTreck IP stack implementations for embedded systems are affected by multiple vulnerabilities. This set of vulnerabilities was researched and reported by JSOF, who calls them [Ripple20](https://www.jsof-tech.com/ripple20/).\r\n\r\n### Description\r\nTreck IP network stack software is designed for and used in a variety of embedded systems. The software can be licensed and integrated in various ways, including compiled from source, licensed for modification and reuse and finally as a dynamic or static linked library. Treck IP software contains multiple vulnerabilities, most of which are caused by [memory management bugs](https://wiki.sei.cmu.edu/confluence/pages/viewpage.action?pageId=87152142). For more details on the vulnerabilities introduced by these bugs, see Treck's [ Vulnerability Response Information](https://treck.com/vulnerability-response-information/) and JSOF's [Ripple20 advisory](https://www.jsof-tech.com/ripple20/).\r\n\r\nHistorically-related KASAGO TCP/IP middleware from Zuken Elmic (formerly Elmic Systems) is also affected by some of these vulnerabilities. \r\n\r\nThese vulnerabilities likely affect industrial control systems and medical devices. Please see ICS-CERT Advisory [ICSA-20-168-01](https://www.us-cert.gov/ics/advisories/icsa-20-168-01) for more information.\r\n\r\n### Impact ###\r\nThe impact of these vulnerabilities will vary due to the combination of build and runtime options used while developing different embedded systems. This diversity of implementations and the lack of supply chain visibility has exasperated the problem of accurately assessing the impact of these vulnerabilities. In summary, a remote, unauthenticated attacker may be able to use specially-crafted network packets to cause a denial of service, disclose information, or execute arbitrary code.\r\n\r\n### Solution\r\n#### Apply updates\r\nUpdate to the latest stable version of Treck IP stack software (6.0.1.67 or later). Please contact Treck at <security@treck.com>. Downstream users of embedded systems that incorporate Treck IP stacks should contact their embedded system vendor.\r\n\r\n#### Block anomalous IP traffic\r\nConsider blocking network attacks via deep packet inspection. In some cases, modern switches, routers, and firewalls will drop malformed packets with no additional configuration. It is recommended that such security features are not disabled. Below is a list of possible mitigations that can be applied as appropriate to your network environment.\r\n\r\n* Normalize or reject IP fragmented packets (IP Fragments) if not supported in your environment \r\n* Disable or block IP tunneling, both IPv6-in-IPv4 or IP-in-IP tunneling if not required\r\n* Block IP source routing and any IPv6 deprecated features like routing headers (see also [VU#267289](https://www.kb.cert.org/vuls/id/267289))\r\n* Enforce TCP inspection and reject malformed TCP packets \r\n* Block unused ICMP control messages such MTU Update and Address Mask updates\r\n* Normalize DNS through a secure recursive server or application layer firewall\r\n* Ensure that you are using reliable OSI layer 2 equipment (Ethernet)\r\n* Provide DHCP/DHCPv6 security with feature like DHCP snooping\r\n* Disable or block IPv6 multicast if not used in switching infrastructure\r\n\r\nFurther recommendations are available [here](https://github.com/CERTCC/PoC-Exploits/blob/master/vu-257161/recommendations.md).\r\n\r\n#### Detect anomalous IP traffic\r\nSuricata IDS has built-in decoder-event rules that can be customized to detect attempts to exploit these vulnerabilities. See the rule below for an example. A larger set of selected [vu-257161.rules](https://github.com/CERTCC/PoC-Exploits/blob/master/vu-257161/vu-257161.rules) are available from the CERT/CC Github repository.\r\n\r\n`#IP-in-IP tunnel with fragments` \r\n`alert ip any any -> any any (msg:\"VU#257161:CVE-2020-11896, CVE-2020-11900 Fragments inside IP-in-IP tunnel https://kb.cert.org/vuls/id/257161\"; ip_proto:4; fragbits:M; sid:1367257161; rev:1;)`\r\n\r\n### Acknowledgements\r\nMoshe Kol and Shlomi Oberman of JSOF https://jsof-tech.com researched and reported these vulnerabilities. Treck worked closely with us and other stakeholders to coordinate the disclosure of these vulnerabilities.\r\n\r\nThis document was written by Vijay Sarvepalli.",
|
||||
"clean_desc": null,
|
||||
"impact": null,
|
||||
"resolution": null,
|
||||
"workarounds": null,
|
||||
"sysaffected": null,
|
||||
"thanks": null,
|
||||
"author": null,
|
||||
"public": [
|
||||
"https://www.jsof-tech.com/ripple20/",
|
||||
"https://treck.com/vulnerability-response-information/",
|
||||
"https://www.us-cert.gov/ics/advisories/icsa-20-168-01",
|
||||
"https://jvn.jp/vu/JVNVU94736763/index.html"
|
||||
],
|
||||
"cveids": [
|
||||
"CVE-2020-11902",
|
||||
"CVE-2020-11913",
|
||||
"CVE-2020-11898",
|
||||
"CVE-2020-11907",
|
||||
"CVE-2020-11901",
|
||||
"CVE-2020-11903",
|
||||
"CVE-2020-11904",
|
||||
"CVE-2020-11906",
|
||||
"CVE-2020-11910",
|
||||
"CVE-2020-11911",
|
||||
"CVE-2020-11912",
|
||||
"CVE-2020-11914",
|
||||
"CVE-2020-11899",
|
||||
"CVE-2020-11896",
|
||||
"CVE-2020-11897",
|
||||
"CVE-2020-11905",
|
||||
"CVE-2020-11908",
|
||||
"CVE-2020-11900",
|
||||
"CVE-2020-11909",
|
||||
"CVE-2020-0597",
|
||||
"CVE-2020-0595",
|
||||
"CVE-2020-8674",
|
||||
"CVE-2020-0594"
|
||||
],
|
||||
"certadvisory": null,
|
||||
"uscerttechnicalalert": null,
|
||||
"datecreated": "2020-06-16T17:13:53.220714Z",
|
||||
"publicdate": "2020-06-16T00:00:00Z",
|
||||
"datefirstpublished": "2020-06-16T17:13:53.238540Z",
|
||||
"dateupdated": "2022-09-20T01:54:35.485507Z",
|
||||
"revision": 48,
|
||||
"vrda_d1_directreport": null,
|
||||
"vrda_d1_population": null,
|
||||
"vrda_d1_impact": null,
|
||||
"cam_widelyknown": null,
|
||||
"cam_exploitation": null,
|
||||
"cam_internetinfrastructure": null,
|
||||
"cam_population": null,
|
||||
"cam_impact": null,
|
||||
"cam_easeofexploitation": null,
|
||||
"cam_attackeraccessrequired": null,
|
||||
"cam_scorecurrent": null,
|
||||
"cam_scorecurrentwidelyknown": null,
|
||||
"cam_scorecurrentwidelyknownexploited": null,
|
||||
"ipprotocol": null,
|
||||
"cvss_accessvector": null,
|
||||
"cvss_accesscomplexity": null,
|
||||
"cvss_authentication": null,
|
||||
"cvss_confidentialityimpact": null,
|
||||
"cvss_integrityimpact": null,
|
||||
"cvss_availabilityimpact": null,
|
||||
"cvss_exploitablity": null,
|
||||
"cvss_remediationlevel": null,
|
||||
"cvss_reportconfidence": null,
|
||||
"cvss_collateraldamagepotential": null,
|
||||
"cvss_targetdistribution": null,
|
||||
"cvss_securityrequirementscr": null,
|
||||
"cvss_securityrequirementsir": null,
|
||||
"cvss_securityrequirementsar": null,
|
||||
"cvss_basescore": null,
|
||||
"cvss_basevector": null,
|
||||
"cvss_temporalscore": null,
|
||||
"cvss_environmentalscore": null,
|
||||
"cvss_environmentalvector": null,
|
||||
"metric": null,
|
||||
"vulnote": 7
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"note": "294418",
|
||||
"contact_date": "2025-09-15T19:03:33.664345Z",
|
||||
"vendor": "DrayTek Corporation",
|
||||
"references": "",
|
||||
"statement": "The issue is confirmed, and here is the patch list\r\n\r\nV3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\r\nV2927/V2865/V2866\t4.5.1\r\nV2765/V2766/V2763/V2135\t4.5.1\r\nV2915\t4.4.6.1\r\nV2862/V2926\t3.9.9.12\r\nV2952/3220\t3.9.8.8\r\nV2860/V2925\t3.9.8.6\r\nV2133/V2762/V2832\t3.9.9.4\r\nV2620/LTE200\t3.9.9.5",
|
||||
"dateupdated": "2025-10-03T11:35:31.190661Z",
|
||||
"statement_date": "2025-09-16T02:27:51.346335Z",
|
||||
"addendum": null
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"note": "294418",
|
||||
"cve": "2025-10547",
|
||||
"description": "An uninitialized variable in the HTTP CGI request arguments processing component of Vigor Routers running DrayOS may allow an attacker the ability to perform RCE on the appliance through memory corruption.",
|
||||
"uid": "CVE-2025-10547",
|
||||
"case_increment": 1,
|
||||
"date_added": "2025-10-03T11:35:31.177872Z",
|
||||
"dateupdated": "2025-10-03T11:40:09.915649Z"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"vuid": "VU#294418",
|
||||
"idnumber": "294418",
|
||||
"name": "Vigor routers running DrayOS are vulnerable to RCE via EasyVPN and LAN web administration interface",
|
||||
"keywords": null,
|
||||
"overview": "### Overview\r\nA remote code execution (RCE) vulnerability, tracked as CVE-2025-10547, was discovered through the EasyVPN and LAN web administration interface of Vigor routers by Draytek. A script in the LAN web administration interface uses an unitialized variable, allowing an attacker to send specially crafted HTTP requests that cause memory corruption and potentially allow arbitrary code execution.\r\n\t\r\n### Description\r\nVigor routers are business-grade routers, designed for small to medium-sized businesses, made by Draytek. These routers provide routing, firewall, VPN, content-filtering, bandwidth management, LAN (local area network), and multi-WAN (wide area network) features. Draytek utilizes a proprietary firmware, DrayOS, on the Vigor router line. DrayOS features the EasyVPN and LAN Web Administrator tool s to facilitate LAN and VPN setup. According to the DrayTek [website](https://www.draytek.com/support/knowledge-base/12023), \"with EasyVPN, users no longer need to generate WireGuard keys, import OpenVPN configuration files, or upload certificates. Instead, VPN can be successfully established by simply entering the username and password or getting the OTP code by email.\" \r\n\r\nThe LAN Web Administrator provides a browser-based user interface for router management. When a user interacts with the LAN Web Administration interface, the user interface elements trigger actions that generate HTTP requests to interact with the local server. This process contains an uninitialized variable. Due to the uninitialized variable, an unauthenticated attacker could perform memory corruption on the router via specially crafted HTTP requests to hijack execution or inject malicious payloads. If EasyVPN is enabled, the flaw could be remotely exploited through the VPN interface.\r\n\r\n### Impact\r\nA remote, unathenticated attacker can exploit this vulnerability through accessing the LAN interface\u2014or potentially the WAN interface\u2014if EasyVPN is enabled or remote administration over the internet is activated. If a remote, unauthenticated attacker leverages this vulnerability, they can execute arbitrary code on the router (RCE) and gain full control of the device. A successful attack could result in a attacker gaining root access to a Vigor router to then install backdoors, reconfigure network settings, or block traffic. An attacker may also pivot for lateral movement via intercepting internal communications and bypassing VPNs. \r\n\r\n### Solution\r\nThe DrayTek Security team has developed a series of patches to remediate the vulnerability, and all users of Vigor routers should upgrade to the latest version ASAP. The patches can be found on the [resources](https://www.draytek.com/support/resources?type=version) page of the DrayTek webpage, and the security advisory can be found within the [about](https://www.draytek.com/about/security-advisory/use-of-uninitialized-variable-vulnerabilities/) section of the DrayTek webpage. Consult either the CVE [listing](https://nvd.nist.gov/vuln/detail/CVE-2025-10547) or the [advisory page](https://www.draytek.com/about/security-advisory/use-of-uninitialized-variable-vulnerabilities/) for a full list of affected products. \r\n\r\n### Acknowledgements\r\nThanks to the reporter, Pierre-Yves MAES of ChapsVision (pymaes@chapsvision.com). This document was written by Ayushi Kriplani.",
|
||||
"clean_desc": null,
|
||||
"impact": null,
|
||||
"resolution": null,
|
||||
"workarounds": null,
|
||||
"sysaffected": null,
|
||||
"thanks": null,
|
||||
"author": null,
|
||||
"public": [
|
||||
"https://www.draytek.com/about/security-advisory/use-of-uninitialized-variable-vulnerabilities/",
|
||||
"https://www.draytek.com/support/resources?type=version"
|
||||
],
|
||||
"cveids": [
|
||||
"CVE-2025-10547"
|
||||
],
|
||||
"certadvisory": null,
|
||||
"uscerttechnicalalert": null,
|
||||
"datecreated": "2025-10-03T11:35:31.224065Z",
|
||||
"publicdate": "2025-10-03T11:35:31.026053Z",
|
||||
"datefirstpublished": "2025-10-03T11:35:31.247121Z",
|
||||
"dateupdated": "2025-10-03T11:40:09.876722Z",
|
||||
"revision": 2,
|
||||
"vrda_d1_directreport": null,
|
||||
"vrda_d1_population": null,
|
||||
"vrda_d1_impact": null,
|
||||
"cam_widelyknown": null,
|
||||
"cam_exploitation": null,
|
||||
"cam_internetinfrastructure": null,
|
||||
"cam_population": null,
|
||||
"cam_impact": null,
|
||||
"cam_easeofexploitation": null,
|
||||
"cam_attackeraccessrequired": null,
|
||||
"cam_scorecurrent": null,
|
||||
"cam_scorecurrentwidelyknown": null,
|
||||
"cam_scorecurrentwidelyknownexploited": null,
|
||||
"ipprotocol": null,
|
||||
"cvss_accessvector": null,
|
||||
"cvss_accesscomplexity": null,
|
||||
"cvss_authentication": null,
|
||||
"cvss_confidentialityimpact": null,
|
||||
"cvss_integrityimpact": null,
|
||||
"cvss_availabilityimpact": null,
|
||||
"cvss_exploitablity": null,
|
||||
"cvss_remediationlevel": null,
|
||||
"cvss_reportconfidence": null,
|
||||
"cvss_collateraldamagepotential": null,
|
||||
"cvss_targetdistribution": null,
|
||||
"cvss_securityrequirementscr": null,
|
||||
"cvss_securityrequirementsir": null,
|
||||
"cvss_securityrequirementsar": null,
|
||||
"cvss_basescore": null,
|
||||
"cvss_basevector": null,
|
||||
"cvss_temporalscore": null,
|
||||
"cvss_environmentalscore": null,
|
||||
"cvss_environmentalvector": null,
|
||||
"metric": null,
|
||||
"vulnote": 142
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"note": "294418",
|
||||
"cve": "2025-10547",
|
||||
"description": "An uninitialized variable in the HTTP CGI request arguments processing component of Vigor Routers running DrayOS may allow an attacker the ability to perform RCE on the appliance through memory corruption.",
|
||||
"uid": "CVE-2025-10547",
|
||||
"case_increment": 1,
|
||||
"date_added": "2025-10-03T11:35:31.177872Z",
|
||||
"dateupdated": "2025-10-03T11:40:09.915649Z"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
|
||||
|
||||
public sealed class CertCcSummaryParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseNotes_ReturnsTokens_FromStringArray()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"notes\":[\"VU#123456\",\"VU#654321\"]}");
|
||||
|
||||
var notes = CertCcSummaryParser.ParseNotes(payload);
|
||||
|
||||
Assert.Equal(new[] { "VU#123456", "VU#654321" }, notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseNotes_DeduplicatesTokens_IgnoringCaseAndWhitespace()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"notes\":[\"VU#123456\",\"vu#123456\",\" 123456 \"]}");
|
||||
|
||||
var notes = CertCcSummaryParser.ParseNotes(payload);
|
||||
|
||||
Assert.Single(notes);
|
||||
Assert.Equal("VU#123456", notes[0], ignoreCase: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseNotes_ReadsTokens_FromObjectEntries()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"notes\":[{\"id\":\"VU#294418\"},{\"idnumber\":\"257161\"}]}");
|
||||
|
||||
var notes = CertCcSummaryParser.ParseNotes(payload);
|
||||
|
||||
Assert.Equal(new[] { "VU#294418", "257161" }, notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseNotes_SupportsArrayRoot()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("[\"VU#360686\",\"VU#760160\"]");
|
||||
|
||||
var notes = CertCcSummaryParser.ParseNotes(payload);
|
||||
|
||||
Assert.Equal(new[] { "VU#360686", "VU#760160" }, notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseNotes_InvalidStructure_Throws()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("\"invalid\"");
|
||||
|
||||
Assert.Throws<JsonException>(() => CertCcSummaryParser.ParseNotes(payload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.CertCc.Configuration;
|
||||
using StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
using StellaOps.Concelier.Connector.Common.Cursors;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
|
||||
|
||||
public sealed class CertCcSummaryPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreatePlan_UsesInitialBackfillWindow()
|
||||
{
|
||||
var options = Options.Create(new CertCcOptions
|
||||
{
|
||||
SummaryWindow = new TimeWindowCursorOptions
|
||||
{
|
||||
WindowSize = TimeSpan.FromDays(30),
|
||||
Overlap = TimeSpan.FromDays(3),
|
||||
InitialBackfill = TimeSpan.FromDays(120),
|
||||
MinimumWindowSize = TimeSpan.FromDays(1),
|
||||
},
|
||||
});
|
||||
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-10T12:00:00Z"));
|
||||
var planner = new CertCcSummaryPlanner(options, timeProvider);
|
||||
|
||||
var plan = planner.CreatePlan(state: null);
|
||||
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-06-12T12:00:00Z"), plan.Window.Start);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-07-12T12:00:00Z"), plan.Window.End);
|
||||
|
||||
Assert.Equal(3, plan.Requests.Count);
|
||||
|
||||
var monthly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Monthly).ToArray();
|
||||
Assert.Collection(monthly,
|
||||
request =>
|
||||
{
|
||||
Assert.Equal(2025, request.Year);
|
||||
Assert.Equal(6, request.Month);
|
||||
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/06/summary/", request.Uri.AbsoluteUri);
|
||||
},
|
||||
request =>
|
||||
{
|
||||
Assert.Equal(2025, request.Year);
|
||||
Assert.Equal(7, request.Month);
|
||||
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/07/summary/", request.Uri.AbsoluteUri);
|
||||
});
|
||||
|
||||
var yearly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Yearly).ToArray();
|
||||
Assert.Single(yearly);
|
||||
Assert.Equal(2025, yearly[0].Year);
|
||||
Assert.Null(yearly[0].Month);
|
||||
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/summary/", yearly[0].Uri.AbsoluteUri);
|
||||
|
||||
Assert.Equal(plan.Window.End, plan.NextState.LastWindowEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatePlan_AdvancesWindowRespectingOverlap()
|
||||
{
|
||||
var options = Options.Create(new CertCcOptions
|
||||
{
|
||||
SummaryWindow = new TimeWindowCursorOptions
|
||||
{
|
||||
WindowSize = TimeSpan.FromDays(30),
|
||||
Overlap = TimeSpan.FromDays(10),
|
||||
InitialBackfill = TimeSpan.FromDays(90),
|
||||
MinimumWindowSize = TimeSpan.FromDays(1),
|
||||
},
|
||||
});
|
||||
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-12-01T00:00:00Z"));
|
||||
var planner = new CertCcSummaryPlanner(options, timeProvider);
|
||||
|
||||
var first = planner.CreatePlan(null);
|
||||
var second = planner.CreatePlan(first.NextState);
|
||||
|
||||
Assert.True(second.Window.Start < second.Window.End);
|
||||
Assert.Equal(first.Window.End - options.Value.SummaryWindow.Overlap, second.Window.Start);
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public TestTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
|
||||
|
||||
public sealed class CertCcVendorStatementParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_ReturnsPatchesForTabDelimitedList()
|
||||
{
|
||||
const string statement =
|
||||
"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 patches = CertCcVendorStatementParser.Parse(statement);
|
||||
|
||||
Assert.Equal(11, patches.Count);
|
||||
Assert.Contains(patches, patch => patch.Product == "V3912" && patch.Version == "4.4.3.6");
|
||||
Assert.Contains(patches, patch => patch.Product == "V2962" && patch.Version == "4.4.5.1");
|
||||
Assert.Equal(7, patches.Count(patch => patch.Version == "4.5.1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ReturnsEmptyWhenStatementMissing()
|
||||
{
|
||||
var patches = CertCcVendorStatementParser.Parse(null);
|
||||
Assert.Empty(patches);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.CertCc/StellaOps.Concelier.Connector.CertCc.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Connector.CertFr;
|
||||
using StellaOps.Concelier.Connector.CertFr.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
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.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertFr.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class CertFrConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri FeedUri = new("https://www.cert.ssi.gouv.fr/feed/alertes/");
|
||||
private static readonly Uri FirstDetailUri = new("https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/");
|
||||
private static readonly Uri SecondDetailUri = new("https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public CertFrConnectorTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 10, 3, 0, 0, 0, TimeSpan.Zero));
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_ProducesDeterministicSnapshot()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedFeed();
|
||||
SeedDetailResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CertFrConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
|
||||
var snapshot = SnapshotSerializer.ToSnapshot(advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray());
|
||||
var expected = ReadFixture("certfr-advisories.snapshot.json");
|
||||
var normalizedSnapshot = Normalize(snapshot);
|
||||
var normalizedExpected = Normalize(expected);
|
||||
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(AppContext.BaseDirectory, "Source", "CertFr", "Fixtures", "certfr-advisories.actual.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
|
||||
File.WriteAllText(actualPath, snapshot);
|
||||
}
|
||||
|
||||
Assert.Equal(normalizedExpected, normalizedSnapshot);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var firstDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, FirstDetailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(firstDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, firstDocument!.Status);
|
||||
|
||||
var secondDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, SecondDetailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(secondDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, secondDocument!.Status);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertFrConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
|
||||
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) && pendingMaps.AsBsonArray.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchFailure_RecordsBackoffAndReason()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
_handler.AddResponse(FeedUri, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("feed error", Encoding.UTF8, "text/plain"),
|
||||
});
|
||||
|
||||
var connector = provider.GetRequiredService<CertFrConnector>();
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertFrConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal(1, state!.FailCount);
|
||||
Assert.NotNull(state.LastFailureReason);
|
||||
Assert.Contains("500", state.LastFailureReason, StringComparison.Ordinal);
|
||||
Assert.NotNull(state.BackoffUntil);
|
||||
Assert.True(state.BackoffUntil > _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_NotModifiedResponsesMaintainDocumentState()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedFeed();
|
||||
SeedDetailResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CertFrConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var firstDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, FirstDetailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(firstDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, firstDocument!.Status);
|
||||
|
||||
var secondDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, SecondDetailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(secondDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, secondDocument!.Status);
|
||||
|
||||
SeedFeed();
|
||||
SeedNotModifiedDetailResponses();
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
firstDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, FirstDetailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(firstDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, firstDocument!.Status);
|
||||
|
||||
secondDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, SecondDetailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(secondDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, secondDocument!.Status);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertFrConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
|
||||
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) && pendingMaps.AsBsonArray.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_DuplicateContentSkipsRequeue()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
SeedFeed();
|
||||
SeedDetailResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<CertFrConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var firstDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, FirstDetailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(firstDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, firstDocument!.Status);
|
||||
|
||||
var secondDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, SecondDetailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(secondDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, secondDocument!.Status);
|
||||
|
||||
SeedFeed();
|
||||
SeedDetailResponses();
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
firstDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, FirstDetailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(firstDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, firstDocument!.Status);
|
||||
|
||||
secondDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, SecondDetailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(secondDocument);
|
||||
Assert.Equal(DocumentStatuses.Mapped, secondDocument!.Status);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertFrConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
|
||||
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) && pendingMaps.AsBsonArray.Count == 0);
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync()
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddSingleton(_handler);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddCertFrConnector(opts =>
|
||||
{
|
||||
opts.FeedUri = FeedUri;
|
||||
opts.InitialBackfill = TimeSpan.FromDays(30);
|
||||
opts.WindowOverlap = TimeSpan.FromDays(2);
|
||||
opts.MaxItemsPerFetch = 50;
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(CertFrOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
{
|
||||
builder.PrimaryHandler = _handler;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedFeed()
|
||||
{
|
||||
_handler.AddTextResponse(FeedUri, ReadFixture("certfr-feed.xml"), "application/atom+xml");
|
||||
}
|
||||
|
||||
private void SeedDetailResponses()
|
||||
{
|
||||
AddDetailResponse(FirstDetailUri, "certfr-detail-AV-2024-001.html", "\"certfr-001\"");
|
||||
AddDetailResponse(SecondDetailUri, "certfr-detail-AV-2024-002.html", "\"certfr-002\"");
|
||||
}
|
||||
|
||||
private void SeedNotModifiedDetailResponses()
|
||||
{
|
||||
AddNotModifiedResponse(FirstDetailUri, "\"certfr-001\"");
|
||||
AddNotModifiedResponse(SecondDetailUri, "\"certfr-002\"");
|
||||
}
|
||||
|
||||
private void AddDetailResponse(Uri uri, string fixture, string? etag)
|
||||
{
|
||||
_handler.AddResponse(uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(etag))
|
||||
{
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private void AddNotModifiedResponse(Uri uri, string? etag)
|
||||
{
|
||||
_handler.AddResponse(uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
|
||||
if (!string.IsNullOrEmpty(etag))
|
||||
{
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private static string ReadFixture(string filename)
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var primary = Path.Combine(baseDirectory, "Source", "CertFr", "Fixtures", filename);
|
||||
if (File.Exists(primary))
|
||||
{
|
||||
return File.ReadAllText(primary);
|
||||
}
|
||||
|
||||
var fallback = Path.Combine(baseDirectory, "CertFr", "Fixtures", filename);
|
||||
return File.ReadAllText(fallback);
|
||||
}
|
||||
|
||||
private static string Normalize(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
[
|
||||
{
|
||||
"advisoryKey": "cert-fr/AV-2024.001",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"identifier": "AV-2024.001",
|
||||
"platform": null,
|
||||
"provenance": [
|
||||
{
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/"
|
||||
}
|
||||
],
|
||||
"statuses": [],
|
||||
"type": "vendor",
|
||||
"versionRanges": [
|
||||
{
|
||||
"fixedVersion": null,
|
||||
"introducedVersion": null,
|
||||
"lastAffectedVersion": null,
|
||||
"primitives": {
|
||||
"evr": null,
|
||||
"hasVendorExtensions": true,
|
||||
"nevra": null,
|
||||
"semVer": null,
|
||||
"vendorExtensions": {
|
||||
"certfr.summary": "Résumé de la première alerte.",
|
||||
"certfr.content": "AV-2024.001 Alerte CERT-FR AV-2024.001 L'exploitation active de la vulnérabilité est surveillée. Consultez les indications du fournisseur .",
|
||||
"certfr.reference.count": "1"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/"
|
||||
},
|
||||
"rangeExpression": null,
|
||||
"rangeKind": "vendor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CERT-FR:AV-2024.001"
|
||||
],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": false,
|
||||
"language": "fr",
|
||||
"modified": null,
|
||||
"provenance": [
|
||||
{
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/"
|
||||
}
|
||||
],
|
||||
"published": "2024-10-03T00:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/"
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": null,
|
||||
"url": "https://vendor.example.com/patch"
|
||||
},
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/"
|
||||
},
|
||||
"sourceTag": "cert-fr",
|
||||
"summary": "Résumé de la première alerte.",
|
||||
"url": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/"
|
||||
}
|
||||
],
|
||||
"severity": null,
|
||||
"summary": "Résumé de la première alerte.",
|
||||
"title": "AV-2024.001 - Première alerte"
|
||||
},
|
||||
{
|
||||
"advisoryKey": "cert-fr/AV-2024.002",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"identifier": "AV-2024.002",
|
||||
"platform": null,
|
||||
"provenance": [
|
||||
{
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/"
|
||||
}
|
||||
],
|
||||
"statuses": [],
|
||||
"type": "vendor",
|
||||
"versionRanges": [
|
||||
{
|
||||
"fixedVersion": null,
|
||||
"introducedVersion": null,
|
||||
"lastAffectedVersion": null,
|
||||
"primitives": {
|
||||
"evr": null,
|
||||
"hasVendorExtensions": true,
|
||||
"nevra": null,
|
||||
"semVer": null,
|
||||
"vendorExtensions": {
|
||||
"certfr.summary": "Résumé de la deuxième alerte.",
|
||||
"certfr.content": "AV-2024.002 Alerte CERT-FR AV-2024.002 Des correctifs sont disponibles pour plusieurs produits. Note de mise à jour Correctif",
|
||||
"certfr.reference.count": "2"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/"
|
||||
},
|
||||
"rangeExpression": null,
|
||||
"rangeKind": "vendor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CERT-FR:AV-2024.002"
|
||||
],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": false,
|
||||
"language": "fr",
|
||||
"modified": null,
|
||||
"provenance": [
|
||||
{
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/"
|
||||
}
|
||||
],
|
||||
"published": "2024-10-03T00:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/"
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": null,
|
||||
"url": "https://support.example.com/kb/KB-1234"
|
||||
},
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/"
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": null,
|
||||
"url": "https://support.example.com/kb/KB-5678"
|
||||
},
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-10-03T00:01:00+00:00",
|
||||
"source": "cert-fr",
|
||||
"value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/"
|
||||
},
|
||||
"sourceTag": "cert-fr",
|
||||
"summary": "Résumé de la deuxième alerte.",
|
||||
"url": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/"
|
||||
}
|
||||
],
|
||||
"severity": null,
|
||||
"summary": "Résumé de la deuxième alerte.",
|
||||
"title": "AV-2024.002 - Deuxième alerte"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
<html>
|
||||
<head><title>AV-2024.001</title></head>
|
||||
<body>
|
||||
<h1>Alerte CERT-FR AV-2024.001</h1>
|
||||
<p>L'exploitation active de la vulnérabilité est surveillée.</p>
|
||||
<p>Consultez les indications du <a href="https://vendor.example.com/patch">fournisseur</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<html>
|
||||
<head><title>AV-2024.002</title></head>
|
||||
<body>
|
||||
<h1>Alerte CERT-FR AV-2024.002</h1>
|
||||
<p>Des correctifs sont disponibles pour plusieurs produits.</p>
|
||||
<ul>
|
||||
<li><a href="https://support.example.com/kb/KB-1234">Note de mise à jour</a></li>
|
||||
<li><a href="https://support.example.com/kb/KB-5678">Correctif</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>CERT-FR Alertes</title>
|
||||
<link>https://www.cert.ssi.gouv.fr/</link>
|
||||
<description>Alertes example feed</description>
|
||||
<item>
|
||||
<title>AV-2024.001 - Première alerte</title>
|
||||
<link>https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/</link>
|
||||
<description><![CDATA[Résumé de la première alerte.]]></description>
|
||||
<pubDate>Thu, 03 Oct 2024 09:00:00 +0000</pubDate>
|
||||
<guid>AV-2024.001</guid>
|
||||
</item>
|
||||
<item>
|
||||
<title>AV-2024.002 - Deuxième alerte</title>
|
||||
<link>https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/</link>
|
||||
<description><![CDATA[Résumé de la deuxième alerte.]]></description>
|
||||
<pubDate>Thu, 03 Oct 2024 11:30:00 +0000</pubDate>
|
||||
<guid>AV-2024.002</guid>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.CertFr/StellaOps.Concelier.Connector.CertFr.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="CertFr/Fixtures/**" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,350 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.CertIn;
|
||||
using StellaOps.Concelier.Connector.CertIn.Configuration;
|
||||
using StellaOps.Concelier.Connector.CertIn.Internal;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
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;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertIn.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class CertInConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
private ServiceProvider? _serviceProvider;
|
||||
|
||||
public CertInConnectorTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 4, 20, 0, 0, 0, TimeSpan.Zero));
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_GeneratesExpectedSnapshot()
|
||||
{
|
||||
var options = new CertInOptions
|
||||
{
|
||||
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
|
||||
WindowSize = TimeSpan.FromDays(60),
|
||||
WindowOverlap = TimeSpan.FromDays(7),
|
||||
MaxPagesPerFetch = 1,
|
||||
RequestDelay = TimeSpan.Zero,
|
||||
};
|
||||
|
||||
await EnsureServiceProviderAsync(options);
|
||||
var provider = _serviceProvider!;
|
||||
|
||||
_handler.Clear();
|
||||
|
||||
_handler.AddTextResponse(options.AlertsEndpoint, ReadFixture("alerts-page1.json"), "application/json");
|
||||
var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
|
||||
_handler.AddTextResponse(detailUri, ReadFixture("detail-CIAD-2024-0005.html"), "text/html");
|
||||
|
||||
var connector = new CertInConnectorPlugin().Create(provider);
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
|
||||
Assert.Single(advisories);
|
||||
var canonical = SnapshotSerializer.ToSnapshot(advisories.Single());
|
||||
var expected = ReadFixture("expected-advisory.json");
|
||||
var normalizedExpected = NormalizeLineEndings(expected);
|
||||
var normalizedActual = NormalizeLineEndings(canonical);
|
||||
if (!string.Equals(normalizedExpected, normalizedActual, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = ResolveFixturePath("expected-advisory.actual.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
|
||||
File.WriteAllText(actualPath, canonical);
|
||||
}
|
||||
|
||||
Assert.Equal(normalizedExpected, normalizedActual);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pending));
|
||||
Assert.Empty(pending.AsBsonArray);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchFailure_RecordsBackoffAndReason()
|
||||
{
|
||||
var options = new CertInOptions
|
||||
{
|
||||
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
|
||||
WindowSize = TimeSpan.FromDays(60),
|
||||
WindowOverlap = TimeSpan.FromDays(7),
|
||||
MaxPagesPerFetch = 1,
|
||||
RequestDelay = TimeSpan.Zero,
|
||||
};
|
||||
|
||||
await EnsureServiceProviderAsync(options);
|
||||
_handler.Clear();
|
||||
_handler.AddResponse(options.AlertsEndpoint, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
});
|
||||
|
||||
var provider = _serviceProvider!;
|
||||
var connector = new CertInConnectorPlugin().Create(provider);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal(1, state!.FailCount);
|
||||
Assert.NotNull(state.LastFailureReason);
|
||||
Assert.Contains("500", state.LastFailureReason, StringComparison.Ordinal);
|
||||
Assert.True(state.BackoffUntil.HasValue);
|
||||
Assert.True(state.BackoffUntil!.Value > _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_NotModifiedMaintainsDocumentState()
|
||||
{
|
||||
var options = new CertInOptions
|
||||
{
|
||||
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
|
||||
WindowSize = TimeSpan.FromDays(30),
|
||||
WindowOverlap = TimeSpan.FromDays(7),
|
||||
MaxPagesPerFetch = 1,
|
||||
RequestDelay = TimeSpan.Zero,
|
||||
};
|
||||
|
||||
await EnsureServiceProviderAsync(options);
|
||||
var provider = _serviceProvider!;
|
||||
_handler.Clear();
|
||||
|
||||
var listingPayload = ReadFixture("alerts-page1.json");
|
||||
var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
|
||||
var detailHtml = ReadFixture("detail-CIAD-2024-0005.html");
|
||||
var etag = new EntityTagHeaderValue("\"certin-2024-0005\"");
|
||||
var lastModified = new DateTimeOffset(2024, 4, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
|
||||
_handler.AddResponse(detailUri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(detailHtml, Encoding.UTF8, "text/html"),
|
||||
};
|
||||
|
||||
response.Headers.ETag = etag;
|
||||
response.Content.Headers.LastModified = lastModified;
|
||||
return response;
|
||||
});
|
||||
|
||||
var connector = new CertInConnectorPlugin().Create(provider);
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
|
||||
Assert.Equal(etag.Tag, document.Etag);
|
||||
|
||||
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
|
||||
_handler.AddResponse(detailUri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.NotModified)
|
||||
{
|
||||
Content = new StringContent(string.Empty)
|
||||
};
|
||||
response.Headers.ETag = etag;
|
||||
return response;
|
||||
});
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
|
||||
Assert.Equal(0, pendingDocs.AsBsonArray.Count);
|
||||
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
|
||||
Assert.Equal(0, pendingMappings.AsBsonArray.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_DuplicateContentSkipsRequeue()
|
||||
{
|
||||
var options = new CertInOptions
|
||||
{
|
||||
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
|
||||
WindowSize = TimeSpan.FromDays(30),
|
||||
WindowOverlap = TimeSpan.FromDays(7),
|
||||
MaxPagesPerFetch = 1,
|
||||
RequestDelay = TimeSpan.Zero,
|
||||
};
|
||||
|
||||
await EnsureServiceProviderAsync(options);
|
||||
var provider = _serviceProvider!;
|
||||
_handler.Clear();
|
||||
|
||||
var listingPayload = ReadFixture("alerts-page1.json");
|
||||
var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
|
||||
var detailHtml = ReadFixture("detail-CIAD-2024-0005.html");
|
||||
|
||||
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
|
||||
_handler.AddTextResponse(detailUri, detailHtml, "text/html");
|
||||
|
||||
var connector = new CertInConnectorPlugin().Create(provider);
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
|
||||
|
||||
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
|
||||
_handler.AddTextResponse(detailUri, detailHtml, "text/html");
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
|
||||
Assert.Equal(0, pendingDocs.AsBsonArray.Count);
|
||||
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
|
||||
Assert.Equal(0, pendingMappings.AsBsonArray.Count);
|
||||
}
|
||||
|
||||
private async Task EnsureServiceProviderAsync(CertInOptions template)
|
||||
{
|
||||
if (_serviceProvider is not null)
|
||||
{
|
||||
await ResetDatabaseAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddSingleton(_handler);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddCertInConnector(opts =>
|
||||
{
|
||||
opts.AlertsEndpoint = template.AlertsEndpoint;
|
||||
opts.WindowSize = template.WindowSize;
|
||||
opts.WindowOverlap = template.WindowOverlap;
|
||||
opts.MaxPagesPerFetch = template.MaxPagesPerFetch;
|
||||
opts.RequestDelay = template.RequestDelay;
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(CertInOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
{
|
||||
builder.PrimaryHandler = _handler;
|
||||
});
|
||||
});
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
var bootstrapper = _serviceProvider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private Task ResetDatabaseAsync()
|
||||
=> _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
|
||||
private static string ReadFixture(string filename)
|
||||
=> File.ReadAllText(ResolveFixturePath(filename));
|
||||
|
||||
private static string ResolveFixturePath(string filename)
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var primary = Path.Combine(baseDirectory, "Source", "CertIn", "Fixtures", filename);
|
||||
if (File.Exists(primary) || filename.EndsWith(".actual.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
return Path.Combine(baseDirectory, "CertIn", "Fixtures", filename);
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndings(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_serviceProvider is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_serviceProvider?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"advisoryId": "CIAD-2024-0005",
|
||||
"title": "Multiple vulnerabilities in Example Gateway",
|
||||
"publishedOn": "2024-04-15T10:00:00Z",
|
||||
"detailUrl": "https://cert-in.example/advisory/CIAD-2024-0005",
|
||||
"summary": "Example Gateway devices vulnerable to remote code execution (CVE-2024-9990)."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Multiple vulnerabilities in Example Gateway</title>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Multiple vulnerabilities in Example Gateway</h1>
|
||||
<p>Severity: High</p>
|
||||
<p>Vendor: Example Gateway Technologies Pvt Ltd</p>
|
||||
<p>Organisation: Partner Systems Inc.</p>
|
||||
<p>CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands.</p>
|
||||
<p>Further information is available from the <a href="https://vendor.example.com/advisories/example-gateway-bulletin">vendor bulletin</a>.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"advisoryKey": "CIAD-2024-0005",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"identifier": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the",
|
||||
"platform": null,
|
||||
"provenance": [
|
||||
{
|
||||
"fieldMask": [],
|
||||
"kind": "affected",
|
||||
"recordedAt": "2024-04-20T00:01:00+00:00",
|
||||
"source": "cert-in",
|
||||
"value": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the"
|
||||
}
|
||||
],
|
||||
"statuses": [],
|
||||
"type": "ics-vendor",
|
||||
"versionRanges": [
|
||||
{
|
||||
"fixedVersion": null,
|
||||
"introducedVersion": null,
|
||||
"lastAffectedVersion": null,
|
||||
"primitives": {
|
||||
"evr": null,
|
||||
"hasVendorExtensions": true,
|
||||
"nevra": null,
|
||||
"semVer": null,
|
||||
"vendorExtensions": {
|
||||
"certin.vendor": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the "
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "affected",
|
||||
"recordedAt": "2024-04-20T00:01:00+00:00",
|
||||
"source": "cert-in",
|
||||
"value": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the"
|
||||
},
|
||||
"rangeExpression": null,
|
||||
"rangeKind": "vendor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CIAD-2024-0005",
|
||||
"CVE-2024-9990",
|
||||
"CVE-2024-9991"
|
||||
],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": "2024-04-15T10:00:00+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"fieldMask": [],
|
||||
"kind": "document",
|
||||
"recordedAt": "2024-04-20T00:00:00+00:00",
|
||||
"source": "cert-in",
|
||||
"value": "https://cert-in.example/advisory/CIAD-2024-0005"
|
||||
},
|
||||
{
|
||||
"fieldMask": [],
|
||||
"kind": "mapping",
|
||||
"recordedAt": "2024-04-20T00:01:00+00:00",
|
||||
"source": "cert-in",
|
||||
"value": "CIAD-2024-0005"
|
||||
}
|
||||
],
|
||||
"published": "2024-04-15T10:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "reference",
|
||||
"recordedAt": "2024-04-20T00:01:00+00:00",
|
||||
"source": "cert-in",
|
||||
"value": "https://cert-in.example/advisory/CIAD-2024-0005"
|
||||
},
|
||||
"sourceTag": "cert-in",
|
||||
"summary": null,
|
||||
"url": "https://cert-in.example/advisory/CIAD-2024-0005"
|
||||
},
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "reference",
|
||||
"recordedAt": "2024-04-20T00:01:00+00:00",
|
||||
"source": "cert-in",
|
||||
"value": "https://vendor.example.com/advisories/example-gateway-bulletin"
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": null,
|
||||
"url": "https://vendor.example.com/advisories/example-gateway-bulletin"
|
||||
},
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "reference",
|
||||
"recordedAt": "2024-04-20T00:01:00+00:00",
|
||||
"source": "cert-in",
|
||||
"value": "https://www.cve.org/CVERecord?id=CVE-2024-9990"
|
||||
},
|
||||
"sourceTag": "CVE-2024-9990",
|
||||
"summary": null,
|
||||
"url": "https://www.cve.org/CVERecord?id=CVE-2024-9990"
|
||||
},
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"fieldMask": [],
|
||||
"kind": "reference",
|
||||
"recordedAt": "2024-04-20T00:01:00+00:00",
|
||||
"source": "cert-in",
|
||||
"value": "https://www.cve.org/CVERecord?id=CVE-2024-9991"
|
||||
},
|
||||
"sourceTag": "CVE-2024-9991",
|
||||
"summary": null,
|
||||
"url": "https://www.cve.org/CVERecord?id=CVE-2024-9991"
|
||||
}
|
||||
],
|
||||
"severity": "high",
|
||||
"summary": "Example Gateway devices vulnerable to remote code execution (CVE-2024-9990).",
|
||||
"title": "Multiple vulnerabilities in Example Gateway"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.CertIn/StellaOps.Concelier.Connector.CertIn.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="CertIn/Fixtures/**" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests;
|
||||
|
||||
public sealed class CannedHttpMessageHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendAsync_RecordsRequestsAndSupportsFallback()
|
||||
{
|
||||
var handler = new CannedHttpMessageHandler();
|
||||
var requestUri = new Uri("https://example.test/api/resource");
|
||||
handler.AddResponse(HttpMethod.Get, requestUri, () => new HttpResponseMessage(HttpStatusCode.OK));
|
||||
handler.SetFallback(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
|
||||
using var client = handler.CreateClient();
|
||||
var firstResponse = await client.GetAsync(requestUri);
|
||||
var secondResponse = await client.GetAsync(new Uri("https://example.test/other"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, secondResponse.StatusCode);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
handler.AssertNoPendingResponses();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddException_ThrowsDuringSend()
|
||||
{
|
||||
var handler = new CannedHttpMessageHandler();
|
||||
var requestUri = new Uri("https://example.test/api/error");
|
||||
handler.AddException(HttpMethod.Get, requestUri, new InvalidOperationException("boom"));
|
||||
|
||||
using var client = handler.CreateClient();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetAsync(requestUri));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using StellaOps.Concelier.Connector.Common.Html;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests;
|
||||
|
||||
public sealed class HtmlContentSanitizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sanitize_RemovesScriptAndDangerousAttributes()
|
||||
{
|
||||
var sanitizer = new HtmlContentSanitizer();
|
||||
var input = "<div onclick=\"alert(1)\"><script>alert('bad')</script><a href='/foo' target='_blank'>link</a></div>";
|
||||
|
||||
var sanitized = sanitizer.Sanitize(input, new Uri("https://example.test/base/"));
|
||||
|
||||
Assert.DoesNotContain("script", sanitized, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("onclick", sanitized, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("https://example.test/foo", sanitized, StringComparison.Ordinal);
|
||||
Assert.Contains("rel=\"noopener nofollow noreferrer\"", sanitized, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_PreservesBasicFormatting()
|
||||
{
|
||||
var sanitizer = new HtmlContentSanitizer();
|
||||
var input = "<p><strong>Hello</strong> <em>world</em></p>";
|
||||
|
||||
var sanitized = sanitizer.Sanitize(input);
|
||||
|
||||
Assert.Equal("<p><strong>Hello</strong> <em>world</em></p>", sanitized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_PreservesHeadingsAndLists()
|
||||
{
|
||||
var sanitizer = new HtmlContentSanitizer();
|
||||
var input = "<section><h2>Affected Products</h2><ul><li>Example One</li></ul></section>";
|
||||
|
||||
var sanitized = sanitizer.Sanitize(input);
|
||||
|
||||
Assert.Contains("<h2>Affected Products</h2>", sanitized, StringComparison.Ordinal);
|
||||
Assert.Contains("<ul><li>Example One</li></ul>", sanitized, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using NuGet.Versioning;
|
||||
using StellaOps.Concelier.Connector.Common.Packages;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests;
|
||||
|
||||
public sealed class PackageCoordinateHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParsePackageUrl_ReturnsCanonicalForm()
|
||||
{
|
||||
var success = PackageCoordinateHelper.TryParsePackageUrl("pkg:npm/@scope/example@1.0.0?env=prod", out var coordinates);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.NotNull(coordinates);
|
||||
Assert.Equal("pkg:npm/@scope/example@1.0.0?env=prod", coordinates!.Canonical);
|
||||
Assert.Equal("npm", coordinates.Type);
|
||||
Assert.Equal("example", coordinates.Name);
|
||||
Assert.Equal("1.0.0", coordinates.Version);
|
||||
Assert.Equal("prod", coordinates.Qualifiers["env"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseSemVer_NormalizesVersion()
|
||||
{
|
||||
var success = PackageCoordinateHelper.TryParseSemVer("1.2.3+build", out var version, out var normalized);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal(SemanticVersion.Parse("1.2.3"), version);
|
||||
Assert.Equal("1.2.3", normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseSemVerRange_SupportsCaret()
|
||||
{
|
||||
var success = PackageCoordinateHelper.TryParseSemVerRange("^1.2.3", out var range);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.NotNull(range);
|
||||
Assert.True(range!.Satisfies(NuGetVersion.Parse("1.3.0")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using StellaOps.Concelier.Connector.Common.Pdf;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests;
|
||||
|
||||
public sealed class PdfTextExtractorTests
|
||||
{
|
||||
private const string SamplePdfBase64 = "JVBERi0xLjEKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA0NCA+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjcyIDcyMCBUZAooSGVsbG8gV29ybGQpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKNSAwIG9iago8PCAvVHlwZSAvRm9udCAvU3VidHlwZSAvVHlwZTEgL0Jhc2VGb250IC9IZWx2ZXRpY2EgPj4KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDU2IDAwMDAwIG4gCjAwMDAwMDAxMTMgMDAwMDAgbiAKMDAwMDAwMDIxMCAwMDAwMCBuIAowMDAwMDAwMzExIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1Jvb3QgMSAwIFIgL1NpemUgNiA+PgpzdGFydHhyZWYKMzc3CiUlRU9G";
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractTextAsync_ReturnsPageText()
|
||||
{
|
||||
var bytes = Convert.FromBase64String(SamplePdfBase64);
|
||||
using var stream = new MemoryStream(bytes);
|
||||
var extractor = new PdfTextExtractor();
|
||||
|
||||
var result = await extractor.ExtractTextAsync(stream, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Contains("Hello World", result.Text);
|
||||
Assert.Equal(1, result.PagesProcessed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Core.Aoc;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests;
|
||||
|
||||
public sealed class SourceFetchServiceGuardTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
private readonly IMongoDatabase _database;
|
||||
private readonly RawDocumentStorage _rawStorage;
|
||||
|
||||
public SourceFetchServiceGuardTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
var client = new MongoClient(_runner.ConnectionString);
|
||||
_database = client.GetDatabase($"source-fetch-guard-{Guid.NewGuid():N}");
|
||||
_rawStorage = new RawDocumentStorage(_database);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_ValidatesWithGuardBeforePersisting()
|
||||
{
|
||||
var responsePayload = "{\"id\":\"CVE-2025-1111\"}";
|
||||
var handler = new StaticHttpMessageHandler(() => CreateSuccessResponse(responsePayload));
|
||||
var client = new HttpClient(handler, disposeHandler: false);
|
||||
var httpClientFactory = new StaticHttpClientFactory(client);
|
||||
var documentStore = new RecordingDocumentStore();
|
||||
var guard = new RecordingAdvisoryRawWriteGuard();
|
||||
var jitter = new NoJitterSource();
|
||||
|
||||
var httpOptions = new TestOptionsMonitor<StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions>(new StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions());
|
||||
var storageOptions = Options.Create(new MongoStorageOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
DatabaseName = _database.DatabaseNamespace.DatabaseName,
|
||||
});
|
||||
|
||||
var linksetMapper = new NoopAdvisoryLinksetMapper();
|
||||
|
||||
var service = new SourceFetchService(
|
||||
httpClientFactory,
|
||||
_rawStorage,
|
||||
documentStore,
|
||||
NullLogger<SourceFetchService>.Instance,
|
||||
jitter,
|
||||
guard,
|
||||
linksetMapper,
|
||||
TimeProvider.System,
|
||||
httpOptions,
|
||||
storageOptions);
|
||||
|
||||
var request = new SourceFetchRequest("client", "vndr.msrc", new Uri("https://example.test/advisories/ADV-1234"))
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["upstream.id"] = "ADV-1234",
|
||||
["content.format"] = "csaf",
|
||||
["msrc.lastModified"] = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"),
|
||||
}
|
||||
};
|
||||
|
||||
var result = await service.FetchAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(guard.LastDocument);
|
||||
Assert.Equal("tenant-default", guard.LastDocument!.Tenant);
|
||||
Assert.Equal("msrc", guard.LastDocument.Source.Vendor);
|
||||
Assert.Equal("ADV-1234", guard.LastDocument.Upstream.UpstreamId);
|
||||
var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(responsePayload))).ToLowerInvariant();
|
||||
Assert.Equal(expectedHash, guard.LastDocument.Upstream.ContentHash);
|
||||
Assert.NotNull(documentStore.LastRecord);
|
||||
Assert.True(documentStore.UpsertCount > 0);
|
||||
Assert.Equal("msrc", documentStore.LastRecord!.Metadata!["source.vendor"]);
|
||||
Assert.Equal("tenant-default", documentStore.LastRecord.Metadata!["tenant"]);
|
||||
|
||||
// verify raw payload stored
|
||||
var filesCollection = _database.GetCollection<BsonDocument>("documents.files");
|
||||
var count = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_WhenGuardThrows_DoesNotPersist()
|
||||
{
|
||||
var handler = new StaticHttpMessageHandler(() => CreateSuccessResponse("{\"id\":\"CVE-2025-2222\"}"));
|
||||
var client = new HttpClient(handler, disposeHandler: false);
|
||||
var httpClientFactory = new StaticHttpClientFactory(client);
|
||||
var documentStore = new RecordingDocumentStore();
|
||||
var guard = new RecordingAdvisoryRawWriteGuard { ShouldThrow = true };
|
||||
var jitter = new NoJitterSource();
|
||||
|
||||
var httpOptions = new TestOptionsMonitor<StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions>(new StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions());
|
||||
var storageOptions = Options.Create(new MongoStorageOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
DatabaseName = _database.DatabaseNamespace.DatabaseName,
|
||||
});
|
||||
|
||||
var linksetMapper = new NoopAdvisoryLinksetMapper();
|
||||
|
||||
var service = new SourceFetchService(
|
||||
httpClientFactory,
|
||||
_rawStorage,
|
||||
documentStore,
|
||||
NullLogger<SourceFetchService>.Instance,
|
||||
jitter,
|
||||
guard,
|
||||
linksetMapper,
|
||||
TimeProvider.System,
|
||||
httpOptions,
|
||||
storageOptions);
|
||||
|
||||
var request = new SourceFetchRequest("client", "nvd", new Uri("https://example.test/data/XYZ"))
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vulnerability.id"] = "CVE-2025-2222",
|
||||
}
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<ConcelierAocGuardException>(() => service.FetchAsync(request, CancellationToken.None));
|
||||
Assert.Equal(0, documentStore.UpsertCount);
|
||||
|
||||
var filesCollection = _database.GetCollection<BsonDocument>("documents.files");
|
||||
var count = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateSuccessResponse(string payload)
|
||||
{
|
||||
var message = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
message.Headers.ETag = new EntityTagHeaderValue("\"etag\"");
|
||||
message.Content.Headers.LastModified = DateTimeOffset.UtcNow.AddHours(-1);
|
||||
return message;
|
||||
}
|
||||
|
||||
private sealed class StaticHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public StaticHttpClientFactory(HttpClient client) => _client = client;
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class StaticHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpResponseMessage> _responseFactory;
|
||||
|
||||
public StaticHttpMessageHandler(Func<HttpResponseMessage> responseFactory) => _responseFactory = responseFactory;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_responseFactory());
|
||||
}
|
||||
|
||||
private sealed class RecordingDocumentStore : IDocumentStore
|
||||
{
|
||||
public DocumentRecord? LastRecord { get; private set; }
|
||||
|
||||
public int UpsertCount { get; private set; }
|
||||
|
||||
public Task<DocumentRecord> UpsertAsync(DocumentRecord record, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
UpsertCount++;
|
||||
LastRecord = record;
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<DocumentRecord?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> Task.FromResult<DocumentRecord?>(null);
|
||||
|
||||
public Task<DocumentRecord?> FindAsync(Guid id, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> Task.FromResult<DocumentRecord?>(null);
|
||||
|
||||
public Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
|
||||
private sealed class RecordingAdvisoryRawWriteGuard : IAdvisoryRawWriteGuard
|
||||
{
|
||||
public AdvisoryRawDocument? LastDocument { get; private set; }
|
||||
|
||||
public bool ShouldThrow { get; set; }
|
||||
|
||||
public void EnsureValid(AdvisoryRawDocument document)
|
||||
{
|
||||
LastDocument = document;
|
||||
if (ShouldThrow)
|
||||
{
|
||||
var violation = AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "test");
|
||||
throw new ConcelierAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoJitterSource : IJitterSource
|
||||
{
|
||||
public TimeSpan Next(TimeSpan minInclusive, TimeSpan maxInclusive) => minInclusive;
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
where T : class, new()
|
||||
{
|
||||
private readonly T _options;
|
||||
|
||||
public TestOptionsMonitor(T options) => _options = options;
|
||||
|
||||
public T CurrentValue => _options;
|
||||
|
||||
public T Get(string? name) => _options;
|
||||
|
||||
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static NullDisposable Instance { get; } = new();
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopAdvisoryLinksetMapper : IAdvisoryLinksetMapper
|
||||
{
|
||||
public RawLinkset Map(AdvisoryRawDocument document) => new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests;
|
||||
|
||||
public sealed class SourceFetchServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateHttpRequestMessage_DefaultsToJsonAccept()
|
||||
{
|
||||
var request = new SourceFetchRequest("client", "source", new Uri("https://example.test/data"));
|
||||
|
||||
using var message = SourceFetchService.CreateHttpRequestMessage(request);
|
||||
|
||||
Assert.Single(message.Headers.Accept);
|
||||
Assert.Equal("application/json", message.Headers.Accept.First().MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHttpRequestMessage_UsesAcceptOverrides()
|
||||
{
|
||||
var request = new SourceFetchRequest("client", "source", new Uri("https://example.test/data"))
|
||||
{
|
||||
AcceptHeaders = new[]
|
||||
{
|
||||
"text/html",
|
||||
"application/xhtml+xml;q=0.9",
|
||||
}
|
||||
};
|
||||
|
||||
using var message = SourceFetchService.CreateHttpRequestMessage(request);
|
||||
|
||||
Assert.Equal(2, message.Headers.Accept.Count);
|
||||
Assert.Contains(message.Headers.Accept, h => h.MediaType == "text/html");
|
||||
Assert.Contains(message.Headers.Accept, h => h.MediaType == "application/xhtml+xml");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests;
|
||||
|
||||
public sealed class SourceHttpClientBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddSourceHttpClient_ConfiguresVersionAndHandler()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
|
||||
bool configureInvoked = false;
|
||||
bool? observedEnableMultiple = null;
|
||||
SocketsHttpHandler? capturedHandler = null;
|
||||
|
||||
services.AddSourceHttpClient("source.test", (_, options) =>
|
||||
{
|
||||
options.AllowedHosts.Add("example.test");
|
||||
options.RequestVersion = HttpVersion.Version20;
|
||||
options.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
|
||||
options.EnableMultipleHttp2Connections = false;
|
||||
options.ConfigureHandler = handler =>
|
||||
{
|
||||
capturedHandler = handler;
|
||||
observedEnableMultiple = handler.EnableMultipleHttp2Connections;
|
||||
configureInvoked = true;
|
||||
};
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var factory = provider.GetRequiredService<IHttpClientFactory>();
|
||||
|
||||
var client = factory.CreateClient("source.test");
|
||||
|
||||
Assert.Equal(HttpVersion.Version20, client.DefaultRequestVersion);
|
||||
Assert.Equal(HttpVersionPolicy.RequestVersionOrLower, client.DefaultVersionPolicy);
|
||||
Assert.True(configureInvoked);
|
||||
Assert.False(observedEnableMultiple);
|
||||
Assert.NotNull(capturedHandler);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSourceHttpClient_LoadsProxyConfiguration()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyAddressKey}"] = "http://proxy.local:8080",
|
||||
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyBypassOnLocalKey}"] = "false",
|
||||
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyBypassListKey}:0"] = "localhost",
|
||||
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyBypassListKey}:1"] = "127.0.0.1",
|
||||
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyUseDefaultCredentialsKey}"] = "false",
|
||||
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyUsernameKey}"] = "svc-concelier",
|
||||
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyPasswordKey}"] = "s3cr3t!",
|
||||
})
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
|
||||
services.AddSourceHttpClient("source.icscisa", (_, options) =>
|
||||
{
|
||||
options.AllowedHosts.Add("content.govdelivery.com");
|
||||
options.ProxyAddress = new Uri("http://configure.local:9000");
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
_ = provider.GetRequiredService<IHttpClientFactory>().CreateClient("source.icscisa");
|
||||
|
||||
var resolvedConfiguration = provider.GetRequiredService<IConfiguration>();
|
||||
var proxySection = resolvedConfiguration
|
||||
.GetSection("concelier")
|
||||
.GetSection("httpClients")
|
||||
.GetSection("source.icscisa")
|
||||
.GetSection("proxy");
|
||||
Assert.True(proxySection.Exists());
|
||||
Assert.Equal("http://proxy.local:8080", proxySection[ProxyAddressKey]);
|
||||
|
||||
var configuredOptions = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>().Get("source.icscisa");
|
||||
Assert.NotNull(configuredOptions.ProxyAddress);
|
||||
Assert.Equal(new Uri("http://proxy.local:8080"), configuredOptions.ProxyAddress);
|
||||
Assert.False(configuredOptions.ProxyBypassOnLocal);
|
||||
Assert.Contains("localhost", configuredOptions.ProxyBypassList, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("127.0.0.1", configuredOptions.ProxyBypassList);
|
||||
Assert.False(configuredOptions.ProxyUseDefaultCredentials);
|
||||
Assert.Equal("svc-concelier", configuredOptions.ProxyUsername);
|
||||
Assert.Equal("s3cr3t!", configuredOptions.ProxyPassword);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSourceHttpClient_UsesConfigurationToBypassValidation()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
using var trustedRoot = CreateSelfSignedCertificate();
|
||||
var pemPath = Path.Combine(Path.GetTempPath(), $"stellaops-trust-{Guid.NewGuid():N}.pem");
|
||||
WriteCertificatePem(trustedRoot, pemPath);
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"concelier:httpClients:source.acsc:{AllowInvalidKey}"] = "true",
|
||||
[$"concelier:httpClients:source.acsc:{TrustedRootPathsKey}:0"] = pemPath,
|
||||
})
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
|
||||
bool configureInvoked = false;
|
||||
SocketsHttpHandler? capturedHandler = null;
|
||||
|
||||
services.AddSourceHttpClient("source.acsc", (_, options) =>
|
||||
{
|
||||
options.AllowedHosts.Add("example.test");
|
||||
options.ConfigureHandler = handler =>
|
||||
{
|
||||
capturedHandler = handler;
|
||||
configureInvoked = true;
|
||||
};
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var factory = provider.GetRequiredService<IHttpClientFactory>();
|
||||
|
||||
var client = factory.CreateClient("source.acsc");
|
||||
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>();
|
||||
var configuredOptions = optionsMonitor.Get("source.acsc");
|
||||
|
||||
Assert.True(configureInvoked);
|
||||
Assert.NotNull(capturedHandler);
|
||||
Assert.True(configuredOptions.AllowInvalidServerCertificates);
|
||||
Assert.NotNull(capturedHandler!.SslOptions.RemoteCertificateValidationCallback);
|
||||
|
||||
var callback = capturedHandler.SslOptions.RemoteCertificateValidationCallback!;
|
||||
#pragma warning disable SYSLIB0057
|
||||
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
|
||||
#pragma warning restore SYSLIB0057
|
||||
var result = callback(new object(), serverCertificate, null, SslPolicyErrors.RemoteCertificateChainErrors);
|
||||
Assert.True(result);
|
||||
|
||||
File.Delete(pemPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSourceHttpClient_LoadsTrustedRootsFromOfflineRoot()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
using var trustedRoot = CreateSelfSignedCertificate();
|
||||
var offlineRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"stellaops-offline-{Guid.NewGuid():N}"));
|
||||
var relativePath = Path.Combine("trust", "root.pem");
|
||||
var certificatePath = Path.Combine(offlineRoot.FullName, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(certificatePath)!);
|
||||
WriteCertificatePem(trustedRoot, certificatePath);
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"concelier:{OfflineRootKey}"] = offlineRoot.FullName,
|
||||
[$"concelier:httpClients:source.nkcki:{TrustedRootPathsKey}:0"] = relativePath,
|
||||
})
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
|
||||
SocketsHttpHandler? capturedHandler = null;
|
||||
services.AddSourceHttpClient("source.nkcki", (_, options) =>
|
||||
{
|
||||
options.AllowedHosts.Add("example.test");
|
||||
options.ConfigureHandler = handler => capturedHandler = handler;
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var factory = provider.GetRequiredService<IHttpClientFactory>();
|
||||
_ = factory.CreateClient("source.nkcki");
|
||||
|
||||
var monitor = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>();
|
||||
var configuredOptions = monitor.Get("source.nkcki");
|
||||
|
||||
Assert.False(configuredOptions.AllowInvalidServerCertificates);
|
||||
Assert.NotEmpty(configuredOptions.TrustedRootCertificates);
|
||||
|
||||
using (var manualChain = new X509Chain())
|
||||
{
|
||||
manualChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
manualChain.ChainPolicy.CustomTrustStore.AddRange(configuredOptions.TrustedRootCertificates.ToArray());
|
||||
manualChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
manualChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
||||
#pragma warning disable SYSLIB0057
|
||||
using var manualServerCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
|
||||
#pragma warning restore SYSLIB0057
|
||||
Assert.True(manualChain.Build(manualServerCertificate));
|
||||
}
|
||||
Assert.All(configuredOptions.TrustedRootCertificates, certificate => Assert.NotEqual(IntPtr.Zero, certificate.Handle));
|
||||
|
||||
Assert.NotNull(capturedHandler);
|
||||
var callback = capturedHandler!.SslOptions.RemoteCertificateValidationCallback;
|
||||
Assert.NotNull(callback);
|
||||
#pragma warning disable SYSLIB0057
|
||||
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
|
||||
#pragma warning restore SYSLIB0057
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.CustomTrustStore.Add(serverCertificate);
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
_ = chain.Build(serverCertificate);
|
||||
var validationResult = callback!(new object(), serverCertificate, chain, SslPolicyErrors.RemoteCertificateChainErrors);
|
||||
Assert.True(validationResult);
|
||||
|
||||
Directory.Delete(offlineRoot.FullName, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSourceHttpClient_LoadsConfigurationFromSourceHttpSection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
using var trustedRoot = CreateSelfSignedCertificate();
|
||||
var offlineRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"stellaops-offline-{Guid.NewGuid():N}"));
|
||||
var relativePath = Path.Combine("certs", "root.pem");
|
||||
var certificatePath = Path.Combine(offlineRoot.FullName, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(certificatePath)!);
|
||||
WriteCertificatePem(trustedRoot, certificatePath);
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"concelier:{OfflineRootKey}"] = offlineRoot.FullName,
|
||||
[$"concelier:sources:nkcki:http:{TrustedRootPathsKey}:0"] = relativePath,
|
||||
})
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
|
||||
SocketsHttpHandler? capturedHandler = null;
|
||||
services.AddSourceHttpClient("source.nkcki", (_, options) =>
|
||||
{
|
||||
options.AllowedHosts.Add("example.test");
|
||||
options.ConfigureHandler = handler => capturedHandler = handler;
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
_ = provider.GetRequiredService<IHttpClientFactory>().CreateClient("source.nkcki");
|
||||
|
||||
var configuredOptions = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>().Get("source.nkcki");
|
||||
Assert.False(configuredOptions.AllowInvalidServerCertificates);
|
||||
Assert.NotEmpty(configuredOptions.TrustedRootCertificates);
|
||||
|
||||
using (var manualChain = new X509Chain())
|
||||
{
|
||||
manualChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
manualChain.ChainPolicy.CustomTrustStore.AddRange(configuredOptions.TrustedRootCertificates.ToArray());
|
||||
manualChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
manualChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
||||
#pragma warning disable SYSLIB0057
|
||||
using var manualServerCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
|
||||
#pragma warning restore SYSLIB0057
|
||||
Assert.True(manualChain.Build(manualServerCertificate));
|
||||
}
|
||||
Assert.All(configuredOptions.TrustedRootCertificates, certificate => Assert.NotEqual(IntPtr.Zero, certificate.Handle));
|
||||
|
||||
Assert.NotNull(capturedHandler);
|
||||
var callback = capturedHandler!.SslOptions.RemoteCertificateValidationCallback;
|
||||
Assert.NotNull(callback);
|
||||
#pragma warning disable SYSLIB0057
|
||||
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
|
||||
#pragma warning restore SYSLIB0057
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.CustomTrustStore.Add(serverCertificate);
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
_ = chain.Build(serverCertificate);
|
||||
var validationResult = callback!(new object(), serverCertificate, chain, SslPolicyErrors.RemoteCertificateChainErrors);
|
||||
Assert.True(validationResult);
|
||||
|
||||
Directory.Delete(offlineRoot.FullName, recursive: true);
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateSelfSignedCertificate()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=StellaOps Test Root", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
|
||||
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
|
||||
|
||||
return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5));
|
||||
}
|
||||
|
||||
private static void WriteCertificatePem(X509Certificate2 certificate, string path)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
builder.AppendLine(Convert.ToBase64String(certificate.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks));
|
||||
builder.AppendLine("-----END CERTIFICATE-----");
|
||||
File.WriteAllText(path, builder.ToString(), Encoding.ASCII);
|
||||
}
|
||||
|
||||
private const string AllowInvalidKey = "allowInvalidCertificates";
|
||||
private const string TrustedRootPathsKey = "trustedRootPaths";
|
||||
private const string OfflineRootKey = "offlineRoot";
|
||||
private const string ProxySection = "proxy";
|
||||
private const string ProxyAddressKey = "address";
|
||||
private const string ProxyBypassOnLocalKey = "bypassOnLocal";
|
||||
private const string ProxyBypassListKey = "bypassList";
|
||||
private const string ProxyUseDefaultCredentialsKey = "useDefaultCredentials";
|
||||
private const string ProxyUsernameKey = "username";
|
||||
private const string ProxyPasswordKey = "password";
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.Common.Cursors;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests;
|
||||
|
||||
public sealed class TimeWindowCursorPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetNextWindow_UsesInitialBackfillWhenStateEmpty()
|
||||
{
|
||||
var now = new DateTimeOffset(2024, 10, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
var options = new TimeWindowCursorOptions
|
||||
{
|
||||
WindowSize = TimeSpan.FromHours(4),
|
||||
Overlap = TimeSpan.FromMinutes(15),
|
||||
InitialBackfill = TimeSpan.FromDays(2),
|
||||
MinimumWindowSize = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
var window = TimeWindowCursorPlanner.GetNextWindow(now, null, options);
|
||||
|
||||
Assert.Equal(now - options.InitialBackfill, window.Start);
|
||||
Assert.Equal(window.Start + options.WindowSize, window.End);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextWindow_ClampsEndToNowWhenWindowExtendPastPresent()
|
||||
{
|
||||
var now = new DateTimeOffset(2024, 10, 10, 0, 0, 0, TimeSpan.Zero);
|
||||
var options = new TimeWindowCursorOptions
|
||||
{
|
||||
WindowSize = TimeSpan.FromHours(6),
|
||||
Overlap = TimeSpan.FromMinutes(30),
|
||||
InitialBackfill = TimeSpan.FromDays(3),
|
||||
MinimumWindowSize = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
var previousEnd = now - TimeSpan.FromMinutes(10);
|
||||
var state = new TimeWindowCursorState(previousEnd - options.WindowSize, previousEnd);
|
||||
|
||||
var window = TimeWindowCursorPlanner.GetNextWindow(now, state, options);
|
||||
|
||||
var expectedStart = previousEnd - options.Overlap;
|
||||
var earliest = now - options.InitialBackfill;
|
||||
if (expectedStart < earliest)
|
||||
{
|
||||
expectedStart = earliest;
|
||||
}
|
||||
|
||||
Assert.Equal(expectedStart, window.Start);
|
||||
Assert.Equal(now, window.End);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeWindowCursorState_RoundTripThroughBson()
|
||||
{
|
||||
var state = new TimeWindowCursorState(
|
||||
new DateTimeOffset(2024, 9, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2024, 9, 1, 6, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var document = new BsonDocument
|
||||
{
|
||||
["preserve"] = "value",
|
||||
};
|
||||
|
||||
state.WriteTo(document);
|
||||
var roundTripped = TimeWindowCursorState.FromBsonDocument(document);
|
||||
|
||||
Assert.Equal(state.LastWindowStart, roundTripped.LastWindowStart);
|
||||
Assert.Equal(state.LastWindowEnd, roundTripped.LastWindowEnd);
|
||||
Assert.Equal("value", document["preserve"].AsString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PaginationPlanner_EnumeratesAdditionalPages()
|
||||
{
|
||||
var indices = PaginationPlanner.EnumerateAdditionalPages(4500, 2000).ToArray();
|
||||
Assert.Equal(new[] { 2000, 4000 }, indices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PaginationPlanner_ReturnsEmptyWhenSinglePage()
|
||||
{
|
||||
var indices = PaginationPlanner.EnumerateAdditionalPages(1000, 2000).ToArray();
|
||||
Assert.Empty(indices);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using StellaOps.Concelier.Connector.Common.Url;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests;
|
||||
|
||||
public sealed class UrlNormalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryNormalize_ResolvesRelative()
|
||||
{
|
||||
var success = UrlNormalizer.TryNormalize("/foo/bar", new Uri("https://example.test/base/"), out var normalized);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal("https://example.test/foo/bar", normalized!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalize_StripsFragment()
|
||||
{
|
||||
var success = UrlNormalizer.TryNormalize("https://example.test/path#section", null, out var normalized);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal("https://example.test/path", normalized!.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.Connector.Common.Json;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests.Json;
|
||||
|
||||
public sealed class JsonSchemaValidatorTests
|
||||
{
|
||||
private static JsonSchema CreateSchema()
|
||||
=> JsonSchema.FromText("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"count": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["id", "count"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
""");
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllowsDocumentsMatchingSchema()
|
||||
{
|
||||
var schema = CreateSchema();
|
||||
using var document = JsonDocument.Parse("""{"id":"abc","count":2}""");
|
||||
var validator = new JsonSchemaValidator(NullLogger<JsonSchemaValidator>.Instance);
|
||||
|
||||
var exception = Record.Exception(() => validator.Validate(document, schema, "valid-doc"));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ThrowsWithDetailedViolations()
|
||||
{
|
||||
var schema = CreateSchema();
|
||||
using var document = JsonDocument.Parse("""{"count":0,"extra":"nope"}""");
|
||||
var validator = new JsonSchemaValidator(NullLogger<JsonSchemaValidator>.Instance);
|
||||
|
||||
var ex = Assert.Throws<JsonSchemaValidationException>(() => validator.Validate(document, schema, "invalid-doc"));
|
||||
|
||||
Assert.Equal("invalid-doc", ex.DocumentName);
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors, error => error.Keyword == "required");
|
||||
Assert.Contains(ex.Errors, error => error.SchemaLocation.Contains("#/additionalProperties", StringComparison.Ordinal));
|
||||
Assert.Contains(ex.Errors, error => error.Keyword == "minimum");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.IO;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using System.Xml.Schema;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ConcelierXmlSchemaValidator = StellaOps.Concelier.Connector.Common.Xml.XmlSchemaValidator;
|
||||
using ConcelierXmlSchemaValidationException = StellaOps.Concelier.Connector.Common.Xml.XmlSchemaValidationException;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Tests.Xml;
|
||||
|
||||
public sealed class XmlSchemaValidatorTests
|
||||
{
|
||||
private static XmlSchemaSet CreateSchema()
|
||||
{
|
||||
var set = new XmlSchemaSet();
|
||||
set.Add(string.Empty, XmlReader.Create(new StringReader("""
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="root">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="id" type="xs:string" />
|
||||
<xs:element name="count" type="xs:int" />
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
""")));
|
||||
set.CompilationSettings = new XmlSchemaCompilationSettings { EnableUpaCheck = true };
|
||||
set.Compile();
|
||||
return set;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllowsCompliantDocument()
|
||||
{
|
||||
var schemaSet = CreateSchema();
|
||||
var document = XDocument.Parse("<root><id>abc</id><count>3</count></root>");
|
||||
var validator = new ConcelierXmlSchemaValidator(NullLogger<ConcelierXmlSchemaValidator>.Instance);
|
||||
|
||||
var exception = Record.Exception(() => validator.Validate(document, schemaSet, "valid.xml"));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ThrowsWithDetailedErrors()
|
||||
{
|
||||
var schemaSet = CreateSchema();
|
||||
var document = XDocument.Parse("<root><id>missing-count</id></root>");
|
||||
var validator = new ConcelierXmlSchemaValidator(NullLogger<ConcelierXmlSchemaValidator>.Instance);
|
||||
|
||||
var ex = Assert.Throws<ConcelierXmlSchemaValidationException>(() => validator.Validate(document, schemaSet, "invalid.xml"));
|
||||
|
||||
Assert.Equal("invalid.xml", ex.DocumentName);
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors, error => error.Message.Contains("count", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Connector.Cve.Configuration;
|
||||
using StellaOps.Concelier.Connector.Cve.Internal;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cve.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class CveConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private ConnectorTestHarness? _harness;
|
||||
|
||||
public CveConnectorTests(MongoIntegrationFixture fixture, ITestOutputHelper output)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_EmitsCanonicalAdvisory()
|
||||
{
|
||||
var initialTime = new DateTimeOffset(2024, 10, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
await EnsureHarnessAsync(initialTime);
|
||||
var harness = _harness!;
|
||||
|
||||
var since = initialTime - TimeSpan.FromDays(30);
|
||||
var listUri = new Uri($"https://cve.test/api/cve?time_modified.gte={Uri.EscapeDataString(since.ToString("O"))}&time_modified.lte={Uri.EscapeDataString(initialTime.ToString("O"))}&page=1&size=5");
|
||||
harness.Handler.AddJsonResponse(listUri, ReadFixture("Fixtures/cve-list.json"));
|
||||
harness.Handler.SetFallback(request =>
|
||||
{
|
||||
if (request.RequestUri is null)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
if (request.RequestUri.AbsoluteUri.Equals("https://cve.test/api/cve/CVE-2024-0001", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(ReadFixture("Fixtures/cve-CVE-2024-0001.json"), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
});
|
||||
|
||||
var metrics = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, meterListener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == CveDiagnostics.MeterName)
|
||||
{
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
|
||||
{
|
||||
if (metrics.TryGetValue(instrument.Name, out var existing))
|
||||
{
|
||||
metrics[instrument.Name] = existing + value;
|
||||
}
|
||||
else
|
||||
{
|
||||
metrics[instrument.Name] = value;
|
||||
}
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
var connector = new CveConnectorPlugin().Create(harness.ServiceProvider);
|
||||
|
||||
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisory = await advisoryStore.FindAsync("CVE-2024-0001", CancellationToken.None);
|
||||
Assert.NotNull(advisory);
|
||||
|
||||
var snapshot = SnapshotSerializer.ToSnapshot(advisory!).Replace("\r\n", "\n").TrimEnd();
|
||||
var expected = ReadFixture("Fixtures/expected-CVE-2024-0001.json").Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
|
||||
{
|
||||
var actualPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "expected-CVE-2024-0001.actual.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
|
||||
File.WriteAllText(actualPath, snapshot);
|
||||
}
|
||||
|
||||
Assert.Equal(expected, snapshot);
|
||||
harness.Handler.AssertNoPendingResponses();
|
||||
|
||||
_output.WriteLine("CVE connector smoke metrics:");
|
||||
foreach (var entry in metrics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
_output.WriteLine($" {entry.Key} = {entry.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchWithoutCredentials_SeedsFromDirectory()
|
||||
{
|
||||
var initialTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var projectRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
|
||||
var repositoryRoot = Path.GetFullPath(Path.Combine(projectRoot, "..", ".."));
|
||||
var seedDirectory = Path.Combine(repositoryRoot, "seed-data", "cve", "2025-10-15");
|
||||
Assert.True(Directory.Exists(seedDirectory), $"Seed directory '{seedDirectory}' was not found.");
|
||||
|
||||
await using var harness = new ConnectorTestHarness(_fixture, initialTime, CveOptions.HttpClientName);
|
||||
await harness.EnsureServiceProviderAsync(services =>
|
||||
{
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.ClearProviders();
|
||||
builder.AddProvider(new TestOutputLoggerProvider(_output, LogLevel.Information));
|
||||
builder.SetMinimumLevel(LogLevel.Information);
|
||||
});
|
||||
services.AddCveConnector(options =>
|
||||
{
|
||||
options.BaseEndpoint = new Uri("https://cve.test/api/", UriKind.Absolute);
|
||||
options.SeedDirectory = seedDirectory;
|
||||
options.PageSize = 5;
|
||||
options.MaxPagesPerFetch = 1;
|
||||
options.InitialBackfill = TimeSpan.FromDays(30);
|
||||
options.RequestDelay = TimeSpan.Zero;
|
||||
});
|
||||
});
|
||||
|
||||
var connector = new CveConnectorPlugin().Create(harness.ServiceProvider);
|
||||
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
|
||||
Assert.Empty(harness.Handler.Requests);
|
||||
|
||||
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
var keys = advisories.Select(advisory => advisory.AdvisoryKey).ToArray();
|
||||
|
||||
Assert.Contains("CVE-2024-0001", keys);
|
||||
Assert.Contains("CVE-2024-4567", keys);
|
||||
}
|
||||
|
||||
private async Task EnsureHarnessAsync(DateTimeOffset initialTime)
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var harness = new ConnectorTestHarness(_fixture, initialTime, CveOptions.HttpClientName);
|
||||
await harness.EnsureServiceProviderAsync(services =>
|
||||
{
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.ClearProviders();
|
||||
builder.AddProvider(new TestOutputLoggerProvider(_output, LogLevel.Information));
|
||||
builder.SetMinimumLevel(LogLevel.Information);
|
||||
});
|
||||
services.AddCveConnector(options =>
|
||||
{
|
||||
options.BaseEndpoint = new Uri("https://cve.test/api/", UriKind.Absolute);
|
||||
options.ApiOrg = "test-org";
|
||||
options.ApiUser = "test-user";
|
||||
options.ApiKey = "test-key";
|
||||
options.InitialBackfill = TimeSpan.FromDays(30);
|
||||
options.PageSize = 5;
|
||||
options.MaxPagesPerFetch = 2;
|
||||
options.RequestDelay = TimeSpan.Zero;
|
||||
});
|
||||
});
|
||||
|
||||
_harness = harness;
|
||||
}
|
||||
|
||||
private static string ReadFixture(string relativePath)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, relativePath);
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestOutputLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
private readonly LogLevel _minLevel;
|
||||
|
||||
public TestOutputLoggerProvider(ITestOutputHelper output, LogLevel minLevel)
|
||||
{
|
||||
_output = output;
|
||||
_minLevel = minLevel;
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new TestOutputLogger(_output, _minLevel);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class TestOutputLogger : ILogger
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
private readonly LogLevel _minLevel;
|
||||
|
||||
public TestOutputLogger(ITestOutputHelper output, LogLevel minLevel)
|
||||
{
|
||||
_output = output;
|
||||
_minLevel = minLevel;
|
||||
}
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullLogger.Instance.BeginScope(state);
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLevel;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (IsEnabled(logLevel))
|
||||
{
|
||||
_output.WriteLine(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"dataType": "CVE_RECORD",
|
||||
"dataVersion": "5.0",
|
||||
"cveMetadata": {
|
||||
"cveId": "CVE-2024-0001",
|
||||
"assignerShortName": "ExampleOrg",
|
||||
"state": "PUBLISHED",
|
||||
"dateReserved": "2024-01-01T00:00:00Z",
|
||||
"datePublished": "2024-09-10T12:00:00Z",
|
||||
"dateUpdated": "2024-09-15T12:00:00Z"
|
||||
},
|
||||
"containers": {
|
||||
"cna": {
|
||||
"title": "Example Product Remote Code Execution",
|
||||
"descriptions": [
|
||||
{
|
||||
"lang": "en",
|
||||
"value": "An example vulnerability allowing remote attackers to execute arbitrary code."
|
||||
}
|
||||
],
|
||||
"affected": [
|
||||
{
|
||||
"vendor": "ExampleVendor",
|
||||
"product": "ExampleProduct",
|
||||
"platform": "linux",
|
||||
"defaultStatus": "affected",
|
||||
"versions": [
|
||||
{
|
||||
"status": "affected",
|
||||
"version": "1.0.0",
|
||||
"lessThan": "1.2.0",
|
||||
"versionType": "semver"
|
||||
},
|
||||
{
|
||||
"status": "unaffected",
|
||||
"version": "1.2.0",
|
||||
"versionType": "semver"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"url": "https://example.com/security/advisory",
|
||||
"name": "Vendor Advisory",
|
||||
"tags": [
|
||||
"vendor-advisory"
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "https://cve.example.com/CVE-2024-0001",
|
||||
"tags": [
|
||||
"third-party-advisory"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"cvssV3_1": {
|
||||
"version": "3.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"GHSA-xxxx-yyyy-zzzz"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"dataType": "CVE_RECORD_LIST",
|
||||
"dataVersion": "5.0",
|
||||
"data": [
|
||||
{
|
||||
"cveMetadata": {
|
||||
"cveId": "CVE-2024-0001",
|
||||
"state": "PUBLISHED",
|
||||
"dateUpdated": "2024-09-15T12:00:00Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"totalCount": 1,
|
||||
"itemsPerPage": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
{
|
||||
"advisoryKey": "CVE-2024-0001",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "vendor",
|
||||
"identifier": "examplevendor:exampleproduct",
|
||||
"platform": "linux",
|
||||
"versionRanges": [
|
||||
{
|
||||
"fixedVersion": "1.2.0",
|
||||
"introducedVersion": "1.0.0",
|
||||
"lastAffectedVersion": null,
|
||||
"primitives": {
|
||||
"evr": null,
|
||||
"hasVendorExtensions": true,
|
||||
"nevra": null,
|
||||
"semVer": {
|
||||
"constraintExpression": "version=1.0.0, < 1.2.0",
|
||||
"exactValue": null,
|
||||
"fixed": "1.2.0",
|
||||
"fixedInclusive": false,
|
||||
"introduced": "1.0.0",
|
||||
"introducedInclusive": true,
|
||||
"lastAffected": null,
|
||||
"lastAffectedInclusive": true,
|
||||
"style": "range"
|
||||
},
|
||||
"vendorExtensions": {
|
||||
"vendor": "ExampleVendor",
|
||||
"product": "ExampleProduct",
|
||||
"platform": "linux",
|
||||
"version": "1.0.0",
|
||||
"lessThan": "1.2.0",
|
||||
"versionType": "semver"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"source": "cve",
|
||||
"kind": "affected-range",
|
||||
"value": "examplevendor:exampleproduct",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-10-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"rangeExpression": "version=1.0.0, < 1.2.0",
|
||||
"rangeKind": "semver"
|
||||
},
|
||||
{
|
||||
"fixedVersion": "1.2.0",
|
||||
"introducedVersion": "1.2.0",
|
||||
"lastAffectedVersion": "1.2.0",
|
||||
"primitives": {
|
||||
"evr": null,
|
||||
"hasVendorExtensions": true,
|
||||
"nevra": null,
|
||||
"semVer": {
|
||||
"constraintExpression": "version=1.2.0",
|
||||
"exactValue": null,
|
||||
"fixed": "1.2.0",
|
||||
"fixedInclusive": false,
|
||||
"introduced": "1.2.0",
|
||||
"introducedInclusive": true,
|
||||
"lastAffected": "1.2.0",
|
||||
"lastAffectedInclusive": true,
|
||||
"style": "range"
|
||||
},
|
||||
"vendorExtensions": {
|
||||
"vendor": "ExampleVendor",
|
||||
"product": "ExampleProduct",
|
||||
"platform": "linux",
|
||||
"version": "1.2.0",
|
||||
"versionType": "semver"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"source": "cve",
|
||||
"kind": "affected-range",
|
||||
"value": "examplevendor:exampleproduct",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-10-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"rangeExpression": "version=1.2.0",
|
||||
"rangeKind": "semver"
|
||||
}
|
||||
],
|
||||
"normalizedVersions": [
|
||||
{
|
||||
"scheme": "semver",
|
||||
"type": "exact",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": null,
|
||||
"maxInclusive": null,
|
||||
"value": "1.2.0",
|
||||
"notes": "cve:cve-2024-0001:examplevendor:exampleproduct"
|
||||
},
|
||||
{
|
||||
"scheme": "semver",
|
||||
"type": "range",
|
||||
"min": "1.0.0",
|
||||
"minInclusive": true,
|
||||
"max": "1.2.0",
|
||||
"maxInclusive": false,
|
||||
"value": null,
|
||||
"notes": "cve:cve-2024-0001:examplevendor:exampleproduct"
|
||||
}
|
||||
],
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "cve",
|
||||
"kind": "affected-status",
|
||||
"value": "examplevendor:exampleproduct",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-10-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "affected"
|
||||
},
|
||||
{
|
||||
"provenance": {
|
||||
"source": "cve",
|
||||
"kind": "affected-status",
|
||||
"value": "examplevendor:exampleproduct",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-10-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "not_affected"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "cve",
|
||||
"kind": "affected",
|
||||
"value": "examplevendor:exampleproduct",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-10-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CVE-2024-0001",
|
||||
"GHSA-xxxx-yyyy-zzzz"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [
|
||||
{
|
||||
"baseScore": 9.8,
|
||||
"baseSeverity": "critical",
|
||||
"provenance": {
|
||||
"source": "cve",
|
||||
"kind": "cvss",
|
||||
"value": "cve/CVE-2024-0001",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-10-01T00: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-09-15T12:00:00+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"source": "cve",
|
||||
"kind": "document",
|
||||
"value": "cve/CVE-2024-0001",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-10-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
{
|
||||
"source": "cve",
|
||||
"kind": "mapping",
|
||||
"value": "CVE-2024-0001",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-10-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
],
|
||||
"published": "2024-09-10T12:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "third-party-advisory",
|
||||
"provenance": {
|
||||
"source": "cve",
|
||||
"kind": "reference",
|
||||
"value": "https://cve.example.com/CVE-2024-0001",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-10-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": null,
|
||||
"url": "https://cve.example.com/CVE-2024-0001"
|
||||
},
|
||||
{
|
||||
"kind": "vendor-advisory",
|
||||
"provenance": {
|
||||
"source": "cve",
|
||||
"kind": "reference",
|
||||
"value": "https://example.com/security/advisory",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2024-10-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "Vendor Advisory",
|
||||
"summary": null,
|
||||
"url": "https://example.com/security/advisory"
|
||||
}
|
||||
],
|
||||
"severity": "critical",
|
||||
"summary": "An example vulnerability allowing remote attackers to execute arbitrary code.",
|
||||
"title": "Example Product Remote Code Execution"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Cve/StellaOps.Concelier.Connector.Cve.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures/*.json" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Connector.Distro.Debian.Configuration;
|
||||
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 Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class DebianConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri ListUri = new("https://salsa.debian.org/security-tracker-team/security-tracker/-/raw/master/data/DSA/list");
|
||||
private static readonly Uri DetailResolved = new("https://security-tracker.debian.org/tracker/DSA-2024-123");
|
||||
private static readonly Uri DetailOpen = new("https://security-tracker.debian.org/tracker/DSA-2024-124");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
private readonly Dictionary<Uri, Func<HttpRequestMessage, HttpResponseMessage>> _fallbackFactories = new();
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public DebianConnectorTests(MongoIntegrationFixture fixture, ITestOutputHelper output)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
_handler.SetFallback(request =>
|
||||
{
|
||||
if (request.RequestUri is null)
|
||||
{
|
||||
throw new InvalidOperationException("Request URI required for fallback response.");
|
||||
}
|
||||
|
||||
if (_fallbackFactories.TryGetValue(request.RequestUri, out var factory))
|
||||
{
|
||||
return factory(request);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"No canned or fallback response registered for {request.Method} {request.RequestUri}.");
|
||||
});
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 9, 12, 0, 0, 0, TimeSpan.Zero));
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_PopulatesRangePrimitivesAndResumesWithNotModified()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
|
||||
SeedInitialResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<DebianConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
|
||||
var resolved = advisories.Single(a => a.AdvisoryKey == "DSA-2024-123");
|
||||
_output.WriteLine("Resolved aliases: " + string.Join(",", resolved.Aliases));
|
||||
var resolvedBookworm = Assert.Single(resolved.AffectedPackages, p => p.Platform == "bookworm");
|
||||
var resolvedRange = Assert.Single(resolvedBookworm.VersionRanges);
|
||||
Assert.Equal("evr", resolvedRange.RangeKind);
|
||||
Assert.Equal("1:1.1.1n-0+deb11u2", resolvedRange.IntroducedVersion);
|
||||
Assert.Equal("1:1.1.1n-0+deb11u5", resolvedRange.FixedVersion);
|
||||
Assert.NotNull(resolvedRange.Primitives);
|
||||
Assert.NotNull(resolvedRange.Primitives!.Evr);
|
||||
Assert.Equal(1, resolvedRange.Primitives.Evr!.Introduced!.Epoch);
|
||||
Assert.Equal("1.1.1n", resolvedRange.Primitives.Evr.Introduced.UpstreamVersion);
|
||||
|
||||
var open = advisories.Single(a => a.AdvisoryKey == "DSA-2024-124");
|
||||
var openBookworm = Assert.Single(open.AffectedPackages, p => p.Platform == "bookworm");
|
||||
var openRange = Assert.Single(openBookworm.VersionRanges);
|
||||
Assert.Equal("evr", openRange.RangeKind);
|
||||
Assert.Equal("1:1.3.1-1", openRange.IntroducedVersion);
|
||||
Assert.Null(openRange.FixedVersion);
|
||||
Assert.NotNull(openRange.Primitives);
|
||||
Assert.NotNull(openRange.Primitives!.Evr);
|
||||
|
||||
// Ensure data persisted through Mongo round-trip.
|
||||
var found = await advisoryStore.FindAsync("DSA-2024-123", CancellationToken.None);
|
||||
Assert.NotNull(found);
|
||||
var persistedRange = Assert.Single(found!.AffectedPackages, pkg => pkg.Platform == "bookworm").VersionRanges.Single();
|
||||
Assert.NotNull(persistedRange.Primitives);
|
||||
Assert.NotNull(persistedRange.Primitives!.Evr);
|
||||
|
||||
// Second run should issue conditional requests and no additional parsing/mapping.
|
||||
SeedNotModifiedResponses();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var documents = provider.GetRequiredService<IDocumentStore>();
|
||||
var listDoc = await documents.FindBySourceAndUriAsync(DebianConnectorPlugin.SourceName, DetailResolved.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(listDoc);
|
||||
|
||||
var refreshed = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(2, refreshed.Count);
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync()
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName, CancellationToken.None);
|
||||
_handler.Clear();
|
||||
_fallbackFactories.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(new TestOutputLoggerProvider(_output)));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddSingleton(_handler);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddDebianConnector(options =>
|
||||
{
|
||||
options.ListEndpoint = ListUri;
|
||||
options.DetailBaseUri = new Uri("https://security-tracker.debian.org/tracker/");
|
||||
options.MaxAdvisoriesPerFetch = 10;
|
||||
options.RequestDelay = TimeSpan.Zero;
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(DebianOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
{
|
||||
builder.PrimaryHandler = _handler;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedInitialResponses()
|
||||
{
|
||||
AddListResponse("debian-list.txt", "\"list-v1\"");
|
||||
AddDetailResponse(DetailResolved, "debian-detail-dsa-2024-123.html", "\"detail-123\"");
|
||||
AddDetailResponse(DetailOpen, "debian-detail-dsa-2024-124.html", "\"detail-124\"");
|
||||
}
|
||||
|
||||
private void SeedNotModifiedResponses()
|
||||
{
|
||||
AddNotModifiedResponse(ListUri, "\"list-v1\"");
|
||||
AddNotModifiedResponse(DetailResolved, "\"detail-123\"");
|
||||
AddNotModifiedResponse(DetailOpen, "\"detail-124\"");
|
||||
}
|
||||
|
||||
private void AddListResponse(string fixture, string etag)
|
||||
{
|
||||
RegisterResponseFactory(ListUri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/plain"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private void AddDetailResponse(Uri uri, string fixture, string etag)
|
||||
{
|
||||
RegisterResponseFactory(uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private void AddNotModifiedResponse(Uri uri, string etag)
|
||||
{
|
||||
RegisterResponseFactory(uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private void RegisterResponseFactory(Uri uri, Func<HttpResponseMessage> factory)
|
||||
{
|
||||
_handler.AddResponse(uri, () => factory());
|
||||
_fallbackFactories[uri] = _ => factory();
|
||||
}
|
||||
|
||||
private static string ReadFixture(string filename)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "Debian", "Fixtures", filename),
|
||||
Path.Combine(AppContext.BaseDirectory, "Distro", "Debian", "Fixtures", filename),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Source", "Distro", "Debian", "Fixtures", filename),
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(candidate);
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
return File.ReadAllText(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Fixture '{filename}' not found", filename);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private sealed class TestOutputLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public TestOutputLoggerProvider(ITestOutputHelper output) => _output = output;
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new TestOutputLogger(_output);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class TestOutputLogger : ILogger
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public TestOutputLogger(ITestOutputHelper output) => _output = output;
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullLogger.Instance.BeginScope(state);
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => false;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (IsEnabled(logLevel))
|
||||
{
|
||||
_output.WriteLine(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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);
|
||||
Assert.NotNull(evr.Fixed);
|
||||
Assert.Equal(1, evr.Fixed!.Epoch);
|
||||
Assert.Equal("1.1.1n", evr.Fixed.UpstreamVersion);
|
||||
Assert.Equal("0+deb11u5", evr.Fixed.Revision);
|
||||
|
||||
var normalizedRule = Assert.Single(resolvedPackage.NormalizedVersions);
|
||||
Assert.Equal(NormalizedVersionSchemes.Evr, normalizedRule.Scheme);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.Range, normalizedRule.Type);
|
||||
Assert.Equal("1:1.1.1n-0+deb11u2", normalizedRule.Min);
|
||||
Assert.True(normalizedRule.MinInclusive);
|
||||
Assert.Equal("1:1.1.1n-0+deb11u5", normalizedRule.Max);
|
||||
Assert.False(normalizedRule.MaxInclusive);
|
||||
Assert.Equal("debian:bullseye", normalizedRule.Notes);
|
||||
|
||||
var openPackage = Assert.Single(advisory.AffectedPackages, p => p.Platform == "bookworm");
|
||||
Assert.Empty(openPackage.VersionRanges);
|
||||
Assert.Empty(openPackage.NormalizedVersions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>DSA-2024-123</title>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>DSA-2024-123</h1></header>
|
||||
<table>
|
||||
<tr><td><b>Name</b></td><td>DSA-2024-123</td></tr>
|
||||
<tr><td><b>Description</b></td><td>openssl - security update</td></tr>
|
||||
<tr><td><b>Source</b></td><td><a href="https://www.debian.org/security/dsa-2024-123">Debian</a></td></tr>
|
||||
<tr><td><b>References</b></td><td><a href="/tracker/CVE-2024-1000">CVE-2024-1000</a>, <a href="/tracker/CVE-2024-1001">CVE-2024-1001</a></td></tr>
|
||||
</table>
|
||||
<h2>Vulnerable and fixed packages</h2>
|
||||
<table>
|
||||
<tr><th>Source Package</th><th>Release</th><th>Version</th><th>Status</th></tr>
|
||||
<tr><td><a href="/tracker/source-package/openssl">openssl</a></td><td>bookworm</td><td><span class="red">1:1.1.1n-0+deb11u2</span></td><td><span class="red">vulnerable</span></td></tr>
|
||||
<tr><td></td><td>bookworm (security)</td><td>1:1.1.1n-0+deb11u5</td><td>fixed</td></tr>
|
||||
<tr><td></td><td>trixie</td><td><span class="red">3.0.8-2</span></td><td><span class="red">vulnerable</span></td></tr>
|
||||
<tr><td></td><td>trixie (security)</td><td>3.0.12-1</td><td>fixed</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>DSA-2024-124</title>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>DSA-2024-124</h1></header>
|
||||
<table>
|
||||
<tr><td><b>Name</b></td><td>DSA-2024-124</td></tr>
|
||||
<tr><td><b>Description</b></td><td>zlib - security update</td></tr>
|
||||
<tr><td><b>Source</b></td><td><a href="https://www.debian.org/security/dsa-2024-124">Debian</a></td></tr>
|
||||
<tr><td><b>References</b></td><td><a href="/tracker/CVE-2024-2000">CVE-2024-2000</a></td></tr>
|
||||
</table>
|
||||
<h2>Vulnerable and fixed packages</h2>
|
||||
<table>
|
||||
<tr><th>Source Package</th><th>Release</th><th>Version</th><th>Status</th></tr>
|
||||
<tr><td><a href="/tracker/source-package/zlib">zlib</a></td><td>bookworm</td><td><span class="red">1:1.3.1-1</span></td><td><span class="red">vulnerable</span></td></tr>
|
||||
<tr><td></td><td>trixie</td><td><span class="red">1:1.3.1-2</span></td><td><span class="red">vulnerable</span></td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
[12 Sep 2024] DSA-2024-123 openssl - security update
|
||||
{CVE-2024-1000 CVE-2024-1001}
|
||||
[bookworm] - openssl 1:1.1.1n-0+deb11u5
|
||||
[trixie] - openssl 3.0.12-1
|
||||
[10 Sep 2024] DSA-2024-124 zlib - security update
|
||||
{CVE-2024-2000}
|
||||
[bookworm] - zlib 1:1.3.2-1
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Distro.Debian/StellaOps.Concelier.Connector.Distro.Debian.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"document": {
|
||||
"aggregate_severity": {
|
||||
"text": "Important"
|
||||
},
|
||||
"lang": "en",
|
||||
"notes": [
|
||||
{
|
||||
"category": "summary",
|
||||
"text": "An update fixes a critical kernel issue."
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"category": "self",
|
||||
"summary": "RHSA advisory",
|
||||
"url": "https://access.redhat.com/errata/RHSA-2025:0001"
|
||||
}
|
||||
],
|
||||
"title": "Red Hat Security Advisory: Example kernel update",
|
||||
"tracking": {
|
||||
"id": "RHSA-2025:0001",
|
||||
"initial_release_date": "2025-10-02T00:00:00+00:00",
|
||||
"current_release_date": "2025-10-03T00:00:00+00:00"
|
||||
}
|
||||
},
|
||||
"product_tree": {
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_family",
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_name",
|
||||
"product": {
|
||||
"name": "Red Hat Enterprise Linux 8",
|
||||
"product_id": "8Base-RHEL-8",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/o:redhat:enterprise_linux:8"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "product_release",
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_version",
|
||||
"product": {
|
||||
"name": "kernel-0:4.18.0-513.5.1.el8.x86_64",
|
||||
"product_id": "kernel-0:4.18.0-513.5.1.el8.x86_64",
|
||||
"product_identification_helper": {
|
||||
"purl": "pkg:rpm/redhat/kernel@4.18.0-513.5.1.el8?arch=x86_64"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-0001",
|
||||
"references": [
|
||||
{
|
||||
"category": "external",
|
||||
"summary": "CVE record",
|
||||
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0001"
|
||||
}
|
||||
],
|
||||
"scores": [
|
||||
{
|
||||
"cvss_v3": {
|
||||
"baseScore": 9.8,
|
||||
"baseSeverity": "CRITICAL",
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"version": "3.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"product_status": {
|
||||
"fixed": [
|
||||
"8Base-RHEL-8:kernel-0:4.18.0-513.5.1.el8.x86_64"
|
||||
],
|
||||
"first_fixed": [
|
||||
"8Base-RHEL-8:kernel-0:4.18.0-513.5.1.el8.x86_64"
|
||||
],
|
||||
"known_affected": [
|
||||
"8Base-RHEL-8",
|
||||
"8Base-RHEL-8:kernel-0:4.18.0-500.1.0.el8.x86_64"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"document": {
|
||||
"aggregate_severity": {
|
||||
"text": "Moderate"
|
||||
},
|
||||
"lang": "en",
|
||||
"notes": [
|
||||
{
|
||||
"category": "summary",
|
||||
"text": "Second advisory covering unaffected packages."
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"category": "self",
|
||||
"summary": "RHSA advisory",
|
||||
"url": "https://access.redhat.com/errata/RHSA-2025:0002"
|
||||
}
|
||||
],
|
||||
"title": "Red Hat Security Advisory: Follow-up kernel status",
|
||||
"tracking": {
|
||||
"id": "RHSA-2025:0002",
|
||||
"initial_release_date": "2025-10-05T12:00:00+00:00",
|
||||
"current_release_date": "2025-10-05T12:00:00+00:00"
|
||||
}
|
||||
},
|
||||
"product_tree": {
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_family",
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_name",
|
||||
"product": {
|
||||
"name": "Red Hat Enterprise Linux 9",
|
||||
"product_id": "9Base-RHEL-9",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/o:redhat:enterprise_linux:9"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "product_release",
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_version",
|
||||
"product": {
|
||||
"name": "kernel-0:5.14.0-400.el9.x86_64",
|
||||
"product_id": "kernel-0:5.14.0-400.el9.x86_64",
|
||||
"product_identification_helper": {
|
||||
"purl": "pkg:rpm/redhat/kernel@5.14.0-400.el9?arch=x86_64"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-0002",
|
||||
"references": [
|
||||
{
|
||||
"category": "external",
|
||||
"summary": "CVE record",
|
||||
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0002"
|
||||
}
|
||||
],
|
||||
"product_status": {
|
||||
"known_not_affected": [
|
||||
"9Base-RHEL-9",
|
||||
"9Base-RHEL-9:kernel-0:5.14.0-400.el9.x86_64"
|
||||
],
|
||||
"under_investigation": [
|
||||
"9Base-RHEL-9:kernel-0:5.14.0-401.el9.x86_64"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"document": {
|
||||
"aggregate_severity": {
|
||||
"text": "Important"
|
||||
},
|
||||
"lang": "en",
|
||||
"notes": [
|
||||
{
|
||||
"category": "summary",
|
||||
"text": "Advisory with mixed reference sources to verify dedupe ordering."
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"category": "self",
|
||||
"summary": "Primary advisory",
|
||||
"url": "https://access.redhat.com/errata/RHSA-2025:0003"
|
||||
},
|
||||
{
|
||||
"category": "self",
|
||||
"summary": "",
|
||||
"url": "https://access.redhat.com/errata/RHSA-2025:0003"
|
||||
},
|
||||
{
|
||||
"category": "mitigation",
|
||||
"summary": "Knowledge base guidance",
|
||||
"url": "https://access.redhat.com/solutions/999999"
|
||||
}
|
||||
],
|
||||
"title": "Red Hat Security Advisory: Reference dedupe validation",
|
||||
"tracking": {
|
||||
"id": "RHSA-2025:0003",
|
||||
"initial_release_date": "2025-10-06T09:00:00+00:00",
|
||||
"current_release_date": "2025-10-06T09:00:00+00:00"
|
||||
}
|
||||
},
|
||||
"product_tree": {
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_family",
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_name",
|
||||
"product": {
|
||||
"name": "Red Hat Enterprise Linux 9",
|
||||
"product_id": "9Base-RHEL-9",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/o:redhat:enterprise_linux:9"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-0003",
|
||||
"references": [
|
||||
{
|
||||
"category": "external",
|
||||
"summary": "CVE record",
|
||||
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0003"
|
||||
},
|
||||
{
|
||||
"category": "external",
|
||||
"summary": "",
|
||||
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0003"
|
||||
},
|
||||
{
|
||||
"category": "exploit",
|
||||
"summary": "Exploit tracking",
|
||||
"url": "https://bugzilla.redhat.com/show_bug.cgi?id=2222222"
|
||||
}
|
||||
],
|
||||
"scores": [
|
||||
{
|
||||
"cvss_v3": {
|
||||
"baseScore": 7.5,
|
||||
"baseSeverity": "HIGH",
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
|
||||
"version": "3.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"product_status": {
|
||||
"known_affected": [
|
||||
"9Base-RHEL-9"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"advisoryKey": "RHSA-2025:0001",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "cpe",
|
||||
"identifier": "cpe:2.3:o:redhat:enterprise_linux:8:*:*:*:*:*:*:*",
|
||||
"platform": "Red Hat Enterprise Linux 8",
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "oval",
|
||||
"value": "8Base-RHEL-8",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "known_affected"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "oval",
|
||||
"value": "8Base-RHEL-8",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rpm",
|
||||
"identifier": "kernel-0:4.18.0-513.5.1.el8.x86_64",
|
||||
"platform": "Red Hat Enterprise Linux 8",
|
||||
"versionRanges": [
|
||||
{
|
||||
"fixedVersion": "kernel-0:4.18.0-513.5.1.el8.x86_64",
|
||||
"introducedVersion": null,
|
||||
"lastAffectedVersion": "kernel-0:4.18.0-500.1.0.el8.x86_64",
|
||||
"primitives": {
|
||||
"evr": null,
|
||||
"hasVendorExtensions": false,
|
||||
"nevra": {
|
||||
"fixed": {
|
||||
"architecture": "x86_64",
|
||||
"epoch": 0,
|
||||
"name": "kernel",
|
||||
"release": "513.5.1.el8",
|
||||
"version": "4.18.0"
|
||||
},
|
||||
"introduced": null,
|
||||
"lastAffected": {
|
||||
"architecture": "x86_64",
|
||||
"epoch": 0,
|
||||
"name": "kernel",
|
||||
"release": "500.1.0.el8",
|
||||
"version": "4.18.0"
|
||||
}
|
||||
},
|
||||
"semVer": null,
|
||||
"vendorExtensions": null
|
||||
},
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "package.nevra",
|
||||
"value": "kernel-0:4.18.0-513.5.1.el8.x86_64",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"rangeExpression": null,
|
||||
"rangeKind": "nevra"
|
||||
}
|
||||
],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "package.nevra",
|
||||
"value": "kernel-0:4.18.0-513.5.1.el8.x86_64",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CVE-2025-0001",
|
||||
"RHSA-2025:0001"
|
||||
],
|
||||
"canonicalMetricId": null,
|
||||
"credits": [],
|
||||
"cvssMetrics": [
|
||||
{
|
||||
"baseScore": 9.8,
|
||||
"baseSeverity": "critical",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "cvss",
|
||||
"value": "CVE-2025-0001",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T00: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"
|
||||
}
|
||||
],
|
||||
"cwes": [],
|
||||
"description": null,
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": "2025-10-03T00:00:00+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "advisory",
|
||||
"value": "RHSA-2025:0001",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
],
|
||||
"published": "2025-10-02T00:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "self",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "reference",
|
||||
"value": "https://access.redhat.com/errata/RHSA-2025:0001",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "RHSA advisory",
|
||||
"url": "https://access.redhat.com/errata/RHSA-2025:0001"
|
||||
},
|
||||
{
|
||||
"kind": "external",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "reference",
|
||||
"value": "https://www.cve.org/CVERecord?id=CVE-2025-0001",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "CVE record",
|
||||
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0001"
|
||||
}
|
||||
],
|
||||
"severity": "high",
|
||||
"summary": "An update fixes a critical kernel issue.",
|
||||
"title": "Red Hat Security Advisory: Example kernel update"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"advisoryKey": "RHSA-2025:0002",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "cpe",
|
||||
"identifier": "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
"platform": "Red Hat Enterprise Linux 9",
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "oval",
|
||||
"value": "9Base-RHEL-9",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "known_not_affected"
|
||||
},
|
||||
{
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "oval",
|
||||
"value": "9Base-RHEL-9",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "under_investigation"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "oval",
|
||||
"value": "9Base-RHEL-9",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rpm",
|
||||
"identifier": "kernel-0:5.14.0-400.el9.x86_64",
|
||||
"platform": "Red Hat Enterprise Linux 9",
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "package.nevra",
|
||||
"value": "kernel-0:5.14.0-400.el9.x86_64",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "known_not_affected"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "package.nevra",
|
||||
"value": "kernel-0:5.14.0-400.el9.x86_64",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CVE-2025-0002",
|
||||
"RHSA-2025:0002"
|
||||
],
|
||||
"canonicalMetricId": null,
|
||||
"credits": [],
|
||||
"cvssMetrics": [],
|
||||
"cwes": [],
|
||||
"description": null,
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": "2025-10-05T12:00:00+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "advisory",
|
||||
"value": "RHSA-2025:0002",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
],
|
||||
"published": "2025-10-05T12:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "self",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "reference",
|
||||
"value": "https://access.redhat.com/errata/RHSA-2025:0002",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "RHSA advisory",
|
||||
"url": "https://access.redhat.com/errata/RHSA-2025:0002"
|
||||
},
|
||||
{
|
||||
"kind": "external",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "reference",
|
||||
"value": "https://www.cve.org/CVERecord?id=CVE-2025-0002",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-05T12:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "CVE record",
|
||||
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0002"
|
||||
}
|
||||
],
|
||||
"severity": "medium",
|
||||
"summary": "Second advisory covering unaffected packages.",
|
||||
"title": "Red Hat Security Advisory: Follow-up kernel status"
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"advisoryKey": "RHSA-2025:0003",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "cpe",
|
||||
"identifier": "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
"platform": "Red Hat Enterprise Linux 9",
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "oval",
|
||||
"value": "9Base-RHEL-9",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-06T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "known_affected"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "oval",
|
||||
"value": "9Base-RHEL-9",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-06T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CVE-2025-0003",
|
||||
"RHSA-2025:0003"
|
||||
],
|
||||
"canonicalMetricId": null,
|
||||
"credits": [],
|
||||
"cvssMetrics": [
|
||||
{
|
||||
"baseScore": 7.5,
|
||||
"baseSeverity": "high",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "cvss",
|
||||
"value": "CVE-2025-0003",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-06T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
|
||||
"version": "3.1"
|
||||
}
|
||||
],
|
||||
"cwes": [],
|
||||
"description": null,
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": "2025-10-06T09:00:00+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"source": "redhat",
|
||||
"kind": "advisory",
|
||||
"value": "RHSA-2025:0003",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-06T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
}
|
||||
],
|
||||
"published": "2025-10-06T09:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "self",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "reference",
|
||||
"value": "https://access.redhat.com/errata/RHSA-2025:0003",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-06T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "Primary advisory",
|
||||
"url": "https://access.redhat.com/errata/RHSA-2025:0003"
|
||||
},
|
||||
{
|
||||
"kind": "mitigation",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "reference",
|
||||
"value": "https://access.redhat.com/solutions/999999",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-06T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "Knowledge base guidance",
|
||||
"url": "https://access.redhat.com/solutions/999999"
|
||||
},
|
||||
{
|
||||
"kind": "exploit",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "reference",
|
||||
"value": "https://bugzilla.redhat.com/show_bug.cgi?id=2222222",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-06T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "Exploit tracking",
|
||||
"url": "https://bugzilla.redhat.com/show_bug.cgi?id=2222222"
|
||||
},
|
||||
{
|
||||
"kind": "external",
|
||||
"provenance": {
|
||||
"source": "redhat",
|
||||
"kind": "reference",
|
||||
"value": "https://www.cve.org/CVERecord?id=CVE-2025-0003",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-06T09:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "CVE record",
|
||||
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0003"
|
||||
}
|
||||
],
|
||||
"severity": "high",
|
||||
"summary": "Advisory with mixed reference sources to verify dedupe ordering.",
|
||||
"title": "Red Hat Security Advisory: Reference dedupe validation"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"RHSA": "RHSA-2025:0001",
|
||||
"severity": "important",
|
||||
"released_on": "2025-10-03T00:00:00Z",
|
||||
"resource_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0001.json"
|
||||
},
|
||||
{
|
||||
"RHSA": "RHSA-2025:0002",
|
||||
"severity": "moderate",
|
||||
"released_on": "2025-10-05T12:00:00Z",
|
||||
"resource_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0002.json"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"RHSA": "RHSA-2025:0001",
|
||||
"severity": "important",
|
||||
"released_on": "2025-10-03T12:00:00Z",
|
||||
"resource_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0001.json"
|
||||
},
|
||||
{
|
||||
"RHSA": "RHSA-2025:0002",
|
||||
"severity": "moderate",
|
||||
"released_on": "2025-10-05T12:00:00Z",
|
||||
"resource_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0002.json"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"RHSA": "RHSA-2025:0002",
|
||||
"severity": "moderate",
|
||||
"released_on": "2025-10-05T12:00:00Z",
|
||||
"resource_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0002.json"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"RHSA": "RHSA-2025:0003",
|
||||
"severity": "important",
|
||||
"released_on": "2025-10-06T09:00:00Z",
|
||||
"resource_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0003.json"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Connector.Distro.RedHat;
|
||||
using StellaOps.Concelier.Connector.Distro.RedHat.Configuration;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using StellaOps.Concelier.Testing;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.RedHat.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class RedHatConnectorHarnessTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConnectorTestHarness _harness;
|
||||
|
||||
public RedHatConnectorHarnessTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_harness = new ConnectorTestHarness(fixture, new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero), RedHatOptions.HttpClientName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_WithHarness_ProducesCanonicalAdvisory()
|
||||
{
|
||||
await _harness.ResetAsync();
|
||||
|
||||
var options = new RedHatOptions
|
||||
{
|
||||
BaseEndpoint = new Uri("https://access.redhat.com/hydra/rest/securitydata"),
|
||||
PageSize = 10,
|
||||
MaxPagesPerFetch = 2,
|
||||
MaxAdvisoriesPerFetch = 5,
|
||||
InitialBackfill = TimeSpan.FromDays(1),
|
||||
Overlap = TimeSpan.Zero,
|
||||
FetchTimeout = TimeSpan.FromSeconds(30),
|
||||
UserAgent = "StellaOps.Tests.RedHatHarness/1.0",
|
||||
};
|
||||
|
||||
var handler = _harness.Handler;
|
||||
var timeProvider = _harness.TimeProvider;
|
||||
|
||||
var summaryUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-04&per_page=10&page=1");
|
||||
var summaryUriPost = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-05&per_page=10&page=1");
|
||||
var summaryUriPostPage2 = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-05&per_page=10&page=2");
|
||||
var detailUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0001.json");
|
||||
var detailUri2 = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0002.json");
|
||||
|
||||
handler.AddJsonResponse(summaryUri, ReadFixture("summary-page1-repeat.json"));
|
||||
handler.AddJsonResponse(summaryUriPost, "[]");
|
||||
handler.AddJsonResponse(summaryUriPostPage2, "[]");
|
||||
handler.AddJsonResponse(detailUri, ReadFixture("csaf-rhsa-2025-0001.json"));
|
||||
handler.AddJsonResponse(detailUri2, ReadFixture("csaf-rhsa-2025-0002.json"));
|
||||
|
||||
await _harness.EnsureServiceProviderAsync(services =>
|
||||
{
|
||||
services.AddRedHatConnector(opts =>
|
||||
{
|
||||
opts.BaseEndpoint = options.BaseEndpoint;
|
||||
opts.PageSize = options.PageSize;
|
||||
opts.MaxPagesPerFetch = options.MaxPagesPerFetch;
|
||||
opts.MaxAdvisoriesPerFetch = options.MaxAdvisoriesPerFetch;
|
||||
opts.InitialBackfill = options.InitialBackfill;
|
||||
opts.Overlap = options.Overlap;
|
||||
opts.FetchTimeout = options.FetchTimeout;
|
||||
opts.UserAgent = options.UserAgent;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = _harness.ServiceProvider;
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
await stateRepository.UpsertAsync(
|
||||
new SourceStateRecord(
|
||||
RedHatConnectorPlugin.SourceName,
|
||||
Enabled: true,
|
||||
Paused: false,
|
||||
Cursor: new BsonDocument(),
|
||||
LastSuccess: null,
|
||||
LastFailure: null,
|
||||
FailCount: 0,
|
||||
BackoffUntil: null,
|
||||
UpdatedAt: timeProvider.GetUtcNow(),
|
||||
LastFailureReason: null),
|
||||
CancellationToken.None);
|
||||
|
||||
var connector = new RedHatConnectorPlugin().Create(provider);
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
var advisory = advisories.Single(a => string.Equals(a.AdvisoryKey, "RHSA-2025:0001", StringComparison.Ordinal));
|
||||
Assert.Equal("high", advisory.Severity);
|
||||
Assert.Contains(advisory.Aliases, alias => alias == "CVE-2025-0001");
|
||||
Assert.Empty(advisory.Provenance.Where(p => p.Source == "redhat" && p.Kind == "fetch"));
|
||||
|
||||
var secondAdvisory = advisories.Single(a => string.Equals(a.AdvisoryKey, "RHSA-2025:0002", StringComparison.Ordinal));
|
||||
Assert.Equal("medium", secondAdvisory.Severity, ignoreCase: true);
|
||||
Assert.Contains(secondAdvisory.Aliases, alias => alias == "CVE-2025-0002");
|
||||
|
||||
var state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
|
||||
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings) && pendingMappings.AsBsonArray.Count == 0);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => _harness.ResetAsync();
|
||||
|
||||
private static string ReadFixture(string filename)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "RedHat", "Fixtures", filename);
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Connector.Distro.RedHat;
|
||||
using StellaOps.Concelier.Connector.Distro.RedHat.Configuration;
|
||||
using StellaOps.Concelier.Connector.Distro.RedHat.Internal;
|
||||
using StellaOps.Concelier.Models;
|
||||
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.Plugin;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.RedHat.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class RedHatConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly DateTimeOffset _initialNow;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private ServiceProvider? _serviceProvider;
|
||||
private const bool ForceUpdateGoldens = false;
|
||||
|
||||
public RedHatConnectorTests(MongoIntegrationFixture fixture, ITestOutputHelper output)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_initialNow = new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider = new FakeTimeProvider(_initialNow);
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_ProducesCanonicalAdvisory()
|
||||
{
|
||||
await ResetDatabaseAsync();
|
||||
|
||||
var options = new RedHatOptions
|
||||
{
|
||||
BaseEndpoint = new Uri("https://access.redhat.com/hydra/rest/securitydata"),
|
||||
PageSize = 10,
|
||||
MaxPagesPerFetch = 2,
|
||||
MaxAdvisoriesPerFetch = 25,
|
||||
InitialBackfill = TimeSpan.FromDays(1),
|
||||
Overlap = TimeSpan.Zero,
|
||||
FetchTimeout = TimeSpan.FromSeconds(30),
|
||||
UserAgent = "StellaOps.Tests.RedHat/1.0",
|
||||
};
|
||||
|
||||
await EnsureServiceProviderAsync(options);
|
||||
var provider = _serviceProvider!;
|
||||
|
||||
var configuredOptions = provider.GetRequiredService<IOptions<RedHatOptions>>().Value;
|
||||
Assert.Equal(10, configuredOptions.PageSize);
|
||||
Assert.Equal(TimeSpan.FromDays(1), configuredOptions.InitialBackfill);
|
||||
Assert.Equal(TimeSpan.Zero, configuredOptions.Overlap);
|
||||
_output.WriteLine($"InitialBackfill configured: {configuredOptions.InitialBackfill}");
|
||||
_output.WriteLine($"TimeProvider now: {_timeProvider.GetUtcNow():O}");
|
||||
|
||||
var summaryUriBackfill = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-03&per_page=10&page=1");
|
||||
var summaryUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-04&per_page=10&page=1");
|
||||
var summaryUriPost = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-05&per_page=10&page=1");
|
||||
var summaryUriPostPage2 = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-05&per_page=10&page=2");
|
||||
var detailUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0001.json");
|
||||
var detailUri2 = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0002.json");
|
||||
|
||||
_output.WriteLine($"Registering summary URI: {summaryUriBackfill}");
|
||||
_output.WriteLine($"Registering summary URI (overlap): {summaryUri}");
|
||||
_handler.AddJsonResponse(summaryUriBackfill, ReadFixture("summary-page1.json"));
|
||||
_handler.AddJsonResponse(summaryUri, ReadFixture("summary-page1-repeat.json"));
|
||||
_handler.AddJsonResponse(summaryUriPost, "[]");
|
||||
_handler.AddJsonResponse(summaryUriPostPage2, "[]");
|
||||
_handler.AddJsonResponse(detailUri, ReadFixture("csaf-rhsa-2025-0001.json"));
|
||||
_handler.AddJsonResponse(detailUri2, ReadFixture("csaf-rhsa-2025-0002.json"));
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
await stateRepository.UpsertAsync(
|
||||
new SourceStateRecord(
|
||||
RedHatConnectorPlugin.SourceName,
|
||||
Enabled: true,
|
||||
Paused: false,
|
||||
Cursor: new BsonDocument(),
|
||||
LastSuccess: null,
|
||||
LastFailure: null,
|
||||
FailCount: 0,
|
||||
BackoffUntil: null,
|
||||
UpdatedAt: _timeProvider.GetUtcNow(),
|
||||
LastFailureReason: null),
|
||||
CancellationToken.None);
|
||||
|
||||
var connector = new RedHatConnectorPlugin().Create(provider);
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
|
||||
foreach (var request in _handler.Requests)
|
||||
{
|
||||
_output.WriteLine($"Captured request: {request.Uri}");
|
||||
}
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
var advisory = advisories.Single(a => string.Equals(a.AdvisoryKey, "RHSA-2025:0001", StringComparison.Ordinal));
|
||||
Assert.Equal("red hat security advisory: example kernel update", advisory.Title.ToLowerInvariant());
|
||||
Assert.Contains("RHSA-2025:0001", advisory.Aliases);
|
||||
Assert.Contains("CVE-2025-0001", advisory.Aliases);
|
||||
Assert.Equal("high", advisory.Severity);
|
||||
Assert.Equal("en", advisory.Language);
|
||||
|
||||
var rpmPackage = advisory.AffectedPackages.Single(pkg => pkg.Type == AffectedPackageTypes.Rpm);
|
||||
_output.WriteLine($"RPM statuses count: {rpmPackage.Statuses.Length}");
|
||||
_output.WriteLine($"RPM ranges count: {rpmPackage.VersionRanges.Length}");
|
||||
foreach (var range in rpmPackage.VersionRanges)
|
||||
{
|
||||
_output.WriteLine($"Range fixed={range.FixedVersion}, last={range.LastAffectedVersion}, expr={range.RangeExpression}");
|
||||
}
|
||||
Assert.Equal("kernel-0:4.18.0-513.5.1.el8.x86_64", rpmPackage.Identifier);
|
||||
var fixedRange = Assert.Single(
|
||||
rpmPackage.VersionRanges,
|
||||
range => string.Equals(range.FixedVersion, "kernel-0:4.18.0-513.5.1.el8.x86_64", StringComparison.Ordinal));
|
||||
Assert.Equal("kernel-0:4.18.0-500.1.0.el8.x86_64", fixedRange.LastAffectedVersion);
|
||||
var nevraPrimitive = fixedRange.Primitives?.Nevra;
|
||||
Assert.NotNull(nevraPrimitive);
|
||||
Assert.Null(nevraPrimitive!.Introduced);
|
||||
Assert.Equal("kernel", nevraPrimitive.Fixed?.Name);
|
||||
|
||||
var cpePackage = advisory.AffectedPackages.Single(pkg => pkg.Type == AffectedPackageTypes.Cpe);
|
||||
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:8:*:*:*:*:*:*:*", cpePackage.Identifier);
|
||||
|
||||
Assert.Contains(advisory.References, reference => reference.Url == "https://access.redhat.com/errata/RHSA-2025:0001");
|
||||
Assert.Contains(advisory.References, reference => reference.Url == "https://www.cve.org/CVERecord?id=CVE-2025-0001");
|
||||
|
||||
var snapshot = SnapshotSerializer.ToSnapshot(advisory).Replace("\r\n", "\n");
|
||||
_output.WriteLine("-- RHSA-2025:0001 snapshot --\n" + snapshot);
|
||||
var snapshotPath = ProjectFixturePath("rhsa-2025-0001.snapshot.json");
|
||||
if (ShouldUpdateGoldens())
|
||||
{
|
||||
File.WriteAllText(snapshotPath, snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
var expectedSnapshot = File.ReadAllText(snapshotPath);
|
||||
Assert.Equal(NormalizeLineEndings(expectedSnapshot), NormalizeLineEndings(snapshot));
|
||||
|
||||
var state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs2) && pendingDocs2.AsBsonArray.Count == 0);
|
||||
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings2) && pendingMappings2.AsBsonArray.Count == 0);
|
||||
|
||||
const string fetchKind = "source:redhat:fetch";
|
||||
const string parseKind = "source:redhat:parse";
|
||||
const string mapKind = "source:redhat:map";
|
||||
|
||||
var schedulerOptions = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<JobSchedulerOptions>>().Value;
|
||||
Assert.True(schedulerOptions.Definitions.TryGetValue(fetchKind, out var fetchDefinition));
|
||||
Assert.True(schedulerOptions.Definitions.TryGetValue(parseKind, out var parseDefinition));
|
||||
Assert.True(schedulerOptions.Definitions.TryGetValue(mapKind, out var mapDefinition));
|
||||
|
||||
Assert.Equal("RedHatFetchJob", fetchDefinition.JobType.Name);
|
||||
Assert.Equal(TimeSpan.FromMinutes(12), fetchDefinition.Timeout);
|
||||
Assert.Equal(TimeSpan.FromMinutes(6), fetchDefinition.LeaseDuration);
|
||||
Assert.Equal("0,15,30,45 * * * *", fetchDefinition.CronExpression);
|
||||
Assert.True(fetchDefinition.Enabled);
|
||||
|
||||
Assert.Equal("RedHatParseJob", parseDefinition.JobType.Name);
|
||||
Assert.Equal(TimeSpan.FromMinutes(15), parseDefinition.Timeout);
|
||||
Assert.Equal(TimeSpan.FromMinutes(6), parseDefinition.LeaseDuration);
|
||||
Assert.Equal("5,20,35,50 * * * *", parseDefinition.CronExpression);
|
||||
Assert.True(parseDefinition.Enabled);
|
||||
|
||||
Assert.Equal("RedHatMapJob", mapDefinition.JobType.Name);
|
||||
Assert.Equal(TimeSpan.FromMinutes(20), mapDefinition.Timeout);
|
||||
Assert.Equal(TimeSpan.FromMinutes(6), mapDefinition.LeaseDuration);
|
||||
Assert.Equal("10,25,40,55 * * * *", mapDefinition.CronExpression);
|
||||
Assert.True(mapDefinition.Enabled);
|
||||
|
||||
var summaryUriRepeat = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-03&per_page=10&page=1");
|
||||
var summaryUriSecondPage = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-03&per_page=10&page=2");
|
||||
var summaryUriRepeatOverlap = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-04&per_page=10&page=1");
|
||||
var summaryUriSecondPageOverlap = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-04&per_page=10&page=2");
|
||||
|
||||
_output.WriteLine($"Registering repeat summary URI: {summaryUriRepeat}");
|
||||
_output.WriteLine($"Registering second page summary URI: {summaryUriSecondPage}");
|
||||
_output.WriteLine($"Registering overlap repeat summary URI: {summaryUriRepeatOverlap}");
|
||||
_output.WriteLine($"Registering overlap second page summary URI: {summaryUriSecondPageOverlap}");
|
||||
_handler.AddJsonResponse(summaryUriRepeat, ReadFixture("summary-page1-repeat.json"));
|
||||
_handler.AddJsonResponse(summaryUriSecondPage, ReadFixture("summary-page2.json"));
|
||||
_handler.AddJsonResponse(summaryUriRepeatOverlap, ReadFixture("summary-page1-repeat.json"));
|
||||
_handler.AddJsonResponse(summaryUriSecondPageOverlap, ReadFixture("summary-page2.json"));
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
|
||||
var secondAdvisory = advisories.Single(a => string.Equals(a.AdvisoryKey, "RHSA-2025:0002", StringComparison.Ordinal));
|
||||
var rpm2 = secondAdvisory.AffectedPackages.Single(pkg => pkg.Type == AffectedPackageTypes.Rpm);
|
||||
Assert.Equal("kernel-0:5.14.0-400.el9.x86_64", rpm2.Identifier);
|
||||
const string knownNotAffected = "known_not_affected";
|
||||
|
||||
foreach (var status in rpm2.Statuses)
|
||||
{
|
||||
_output.WriteLine($"RPM2 status: {status.Status}");
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(rpm2.VersionRanges, range => string.Equals(range.RangeExpression, knownNotAffected, StringComparison.Ordinal));
|
||||
Assert.Contains(rpm2.Statuses, status => status.Status == knownNotAffected);
|
||||
|
||||
var cpe2 = secondAdvisory.AffectedPackages.Single(pkg => pkg.Type == AffectedPackageTypes.Cpe);
|
||||
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", cpe2.Identifier);
|
||||
Assert.Empty(cpe2.VersionRanges);
|
||||
Assert.Contains(cpe2.Statuses, status => status.Status == knownNotAffected);
|
||||
|
||||
state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs3) && pendingDocs3.AsBsonArray.Count == 0);
|
||||
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings3) && pendingMappings3.AsBsonArray.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoldenFixturesMatchSnapshots()
|
||||
{
|
||||
var fixtures = new[]
|
||||
{
|
||||
new GoldenFixtureCase(
|
||||
AdvisoryId: "RHSA-2025:0001",
|
||||
InputFile: "csaf-rhsa-2025-0001.json",
|
||||
SnapshotFile: "rhsa-2025-0001.snapshot.json",
|
||||
ValidatedAt: DateTimeOffset.Parse("2025-10-05T00:00:00Z")),
|
||||
new GoldenFixtureCase(
|
||||
AdvisoryId: "RHSA-2025:0002",
|
||||
InputFile: "csaf-rhsa-2025-0002.json",
|
||||
SnapshotFile: "rhsa-2025-0002.snapshot.json",
|
||||
ValidatedAt: DateTimeOffset.Parse("2025-10-05T12:00:00Z")),
|
||||
new GoldenFixtureCase(
|
||||
AdvisoryId: "RHSA-2025:0003",
|
||||
InputFile: "csaf-rhsa-2025-0003.json",
|
||||
SnapshotFile: "rhsa-2025-0003.snapshot.json",
|
||||
ValidatedAt: DateTimeOffset.Parse("2025-10-06T09:00:00Z")),
|
||||
};
|
||||
|
||||
var updateGoldens = ShouldUpdateGoldens();
|
||||
|
||||
foreach (var fixture in fixtures)
|
||||
{
|
||||
var snapshot = MapFixtureToSnapshot(fixture);
|
||||
var snapshotPath = ProjectFixturePath(fixture.SnapshotFile);
|
||||
|
||||
if (updateGoldens)
|
||||
{
|
||||
File.WriteAllText(snapshotPath, snapshot);
|
||||
continue;
|
||||
}
|
||||
|
||||
var expected = File.ReadAllText(snapshotPath).Replace("\r\n", "\n");
|
||||
Assert.Equal(expected, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resume_CompletesPendingDocumentsAfterRestart()
|
||||
{
|
||||
await ResetDatabaseAsync();
|
||||
|
||||
var options = new RedHatOptions
|
||||
{
|
||||
BaseEndpoint = new Uri("https://access.redhat.com/hydra/rest/securitydata"),
|
||||
PageSize = 10,
|
||||
MaxPagesPerFetch = 2,
|
||||
MaxAdvisoriesPerFetch = 25,
|
||||
InitialBackfill = TimeSpan.FromDays(1),
|
||||
Overlap = TimeSpan.Zero,
|
||||
FetchTimeout = TimeSpan.FromSeconds(30),
|
||||
UserAgent = "StellaOps.Tests.RedHat/1.0",
|
||||
};
|
||||
|
||||
var summaryUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-04&per_page=10&page=1");
|
||||
var summaryUriPost = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-05&per_page=10&page=1");
|
||||
var summaryUriPostPage2 = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-05&per_page=10&page=2");
|
||||
var detailUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0001.json");
|
||||
var detailUri2 = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0002.json");
|
||||
|
||||
var fetchHandler = new CannedHttpMessageHandler();
|
||||
fetchHandler.AddJsonResponse(summaryUri, ReadFixture("summary-page1-repeat.json"));
|
||||
fetchHandler.AddJsonResponse(summaryUriPost, "[]");
|
||||
fetchHandler.AddJsonResponse(summaryUriPostPage2, "[]");
|
||||
fetchHandler.AddJsonResponse(detailUri, ReadFixture("csaf-rhsa-2025-0001.json"));
|
||||
fetchHandler.AddJsonResponse(detailUri2, ReadFixture("csaf-rhsa-2025-0002.json"));
|
||||
|
||||
Guid[] pendingDocumentIds;
|
||||
await using (var fetchProvider = await CreateServiceProviderAsync(options, fetchHandler))
|
||||
{
|
||||
var stateRepository = fetchProvider.GetRequiredService<ISourceStateRepository>();
|
||||
await stateRepository.UpsertAsync(
|
||||
new SourceStateRecord(
|
||||
RedHatConnectorPlugin.SourceName,
|
||||
Enabled: true,
|
||||
Paused: false,
|
||||
Cursor: new BsonDocument(),
|
||||
LastSuccess: null,
|
||||
LastFailure: null,
|
||||
FailCount: 0,
|
||||
BackoffUntil: null,
|
||||
UpdatedAt: _timeProvider.GetUtcNow(),
|
||||
LastFailureReason: null),
|
||||
CancellationToken.None);
|
||||
|
||||
var connector = new RedHatConnectorPlugin().Create(fetchProvider);
|
||||
await connector.FetchAsync(fetchProvider, CancellationToken.None);
|
||||
|
||||
var state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
var pendingDocs = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
|
||||
? pendingDocsValue.AsBsonArray
|
||||
: new BsonArray();
|
||||
Assert.NotEmpty(pendingDocs);
|
||||
pendingDocumentIds = pendingDocs.Select(value => Guid.Parse(value.AsString)).ToArray();
|
||||
}
|
||||
|
||||
var resumeHandler = new CannedHttpMessageHandler();
|
||||
await using (var resumeProvider = await CreateServiceProviderAsync(options, resumeHandler))
|
||||
{
|
||||
var resumeConnector = new RedHatConnectorPlugin().Create(resumeProvider);
|
||||
|
||||
await resumeConnector.ParseAsync(resumeProvider, CancellationToken.None);
|
||||
await resumeConnector.MapAsync(resumeProvider, CancellationToken.None);
|
||||
|
||||
var documentStore = resumeProvider.GetRequiredService<IDocumentStore>();
|
||||
foreach (var documentId in pendingDocumentIds)
|
||||
{
|
||||
var document = await documentStore.FindAsync(documentId, CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
|
||||
}
|
||||
|
||||
var advisoryStore = resumeProvider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.NotEmpty(advisories);
|
||||
|
||||
var stateRepository = resumeProvider.GetRequiredService<ISourceStateRepository>();
|
||||
var finalState = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(finalState);
|
||||
var finalPendingDocs = finalState!.Cursor.TryGetValue("pendingDocuments", out var docsValue) ? docsValue.AsBsonArray : new BsonArray();
|
||||
Assert.Empty(finalPendingDocs);
|
||||
var finalPendingMappings = finalState.Cursor.TryGetValue("pendingMappings", out var mappingsValue) ? mappingsValue.AsBsonArray : new BsonArray();
|
||||
Assert.Empty(finalPendingMappings);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapAsync_DeduplicatesReferencesAndOrdersDeterministically()
|
||||
{
|
||||
await ResetDatabaseAsync();
|
||||
|
||||
var options = new RedHatOptions
|
||||
{
|
||||
BaseEndpoint = new Uri("https://access.redhat.com/hydra/rest/securitydata"),
|
||||
PageSize = 10,
|
||||
MaxPagesPerFetch = 2,
|
||||
MaxAdvisoriesPerFetch = 10,
|
||||
InitialBackfill = TimeSpan.FromDays(7),
|
||||
Overlap = TimeSpan.Zero,
|
||||
FetchTimeout = TimeSpan.FromSeconds(30),
|
||||
UserAgent = "StellaOps.Tests.RedHat/1.0",
|
||||
};
|
||||
|
||||
await EnsureServiceProviderAsync(options);
|
||||
var provider = _serviceProvider!;
|
||||
|
||||
var summaryUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-09-28&per_page=10&page=1");
|
||||
var summaryUriPost = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-05&per_page=10&page=1");
|
||||
var detailUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0003.json");
|
||||
|
||||
_handler.AddJsonResponse(summaryUri, ReadFixture("summary-page3.json"));
|
||||
_handler.AddJsonResponse(summaryUriPost, "[]");
|
||||
_handler.AddJsonResponse(detailUri, ReadFixture("csaf-rhsa-2025-0003.json"));
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
await stateRepository.UpsertAsync(
|
||||
new SourceStateRecord(
|
||||
RedHatConnectorPlugin.SourceName,
|
||||
Enabled: true,
|
||||
Paused: false,
|
||||
Cursor: new BsonDocument(),
|
||||
LastSuccess: null,
|
||||
LastFailure: null,
|
||||
FailCount: 0,
|
||||
BackoffUntil: null,
|
||||
UpdatedAt: _timeProvider.GetUtcNow(),
|
||||
LastFailureReason: null),
|
||||
CancellationToken.None);
|
||||
|
||||
var connector = new RedHatConnectorPlugin().Create(provider);
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisory = (await advisoryStore.GetRecentAsync(10, CancellationToken.None))
|
||||
.Single(a => string.Equals(a.AdvisoryKey, "RHSA-2025:0003", StringComparison.Ordinal));
|
||||
|
||||
var references = advisory.References.ToArray();
|
||||
Assert.Collection(
|
||||
references,
|
||||
reference =>
|
||||
{
|
||||
Assert.Equal("self", reference.Kind);
|
||||
Assert.Equal("https://access.redhat.com/errata/RHSA-2025:0003", reference.Url);
|
||||
Assert.Equal("Primary advisory", reference.Summary);
|
||||
},
|
||||
reference =>
|
||||
{
|
||||
Assert.Equal("mitigation", reference.Kind);
|
||||
Assert.Equal("https://access.redhat.com/solutions/999999", reference.Url);
|
||||
Assert.Equal("Knowledge base guidance", reference.Summary);
|
||||
},
|
||||
reference =>
|
||||
{
|
||||
Assert.Equal("exploit", reference.Kind);
|
||||
Assert.Equal("https://bugzilla.redhat.com/show_bug.cgi?id=2222222", reference.Url);
|
||||
Assert.Equal("Exploit tracking", reference.Summary);
|
||||
},
|
||||
reference =>
|
||||
{
|
||||
Assert.Equal("external", reference.Kind);
|
||||
Assert.Equal("https://www.cve.org/CVERecord?id=CVE-2025-0003", reference.Url);
|
||||
Assert.Equal("CVE record", reference.Summary);
|
||||
});
|
||||
Assert.Equal(4, references.Length);
|
||||
|
||||
Assert.Equal("self", references[0].Kind);
|
||||
Assert.Equal("https://access.redhat.com/errata/RHSA-2025:0003", references[0].Url);
|
||||
Assert.Equal("Primary advisory", references[0].Summary);
|
||||
|
||||
Assert.Equal("mitigation", references[1].Kind);
|
||||
Assert.Equal("https://access.redhat.com/solutions/999999", references[1].Url);
|
||||
Assert.Equal("Knowledge base guidance", references[1].Summary);
|
||||
|
||||
Assert.Equal("exploit", references[2].Kind);
|
||||
Assert.Equal("https://bugzilla.redhat.com/show_bug.cgi?id=2222222", references[2].Url);
|
||||
|
||||
Assert.Equal("external", references[3].Kind);
|
||||
Assert.Equal("https://www.cve.org/CVERecord?id=CVE-2025-0003", references[3].Url);
|
||||
Assert.Equal("CVE record", references[3].Summary);
|
||||
}
|
||||
|
||||
private static string MapFixtureToSnapshot(GoldenFixtureCase fixture)
|
||||
{
|
||||
var jsonPath = ProjectFixturePath(fixture.InputFile);
|
||||
var json = File.ReadAllText(jsonPath);
|
||||
|
||||
using var jsonDocument = JsonDocument.Parse(json);
|
||||
var bson = BsonDocument.Parse(json);
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["advisoryId"] = fixture.AdvisoryId,
|
||||
};
|
||||
|
||||
var document = new DocumentRecord(
|
||||
Guid.NewGuid(),
|
||||
RedHatConnectorPlugin.SourceName,
|
||||
$"https://access.redhat.com/hydra/rest/securitydata/csaf/{fixture.AdvisoryId}.json",
|
||||
fixture.ValidatedAt,
|
||||
new string('0', 64),
|
||||
DocumentStatuses.Mapped,
|
||||
"application/json",
|
||||
Headers: null,
|
||||
Metadata: metadata,
|
||||
Etag: null,
|
||||
LastModified: fixture.ValidatedAt,
|
||||
GridFsId: null);
|
||||
|
||||
var dto = new DtoRecord(Guid.NewGuid(), document.Id, RedHatConnectorPlugin.SourceName, "redhat.csaf.v2", bson, fixture.ValidatedAt);
|
||||
|
||||
var advisory = RedHatMapper.Map(RedHatConnectorPlugin.SourceName, dto, document, jsonDocument);
|
||||
Assert.NotNull(advisory);
|
||||
|
||||
return SnapshotSerializer.ToSnapshot(advisory!).Replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
private static bool ShouldUpdateGoldens()
|
||||
=> ForceUpdateGoldens
|
||||
|| IsTruthy(Environment.GetEnvironmentVariable("UPDATE_GOLDENS"))
|
||||
|| IsTruthy(Environment.GetEnvironmentVariable("DOTNET_TEST_UPDATE_GOLDENS"));
|
||||
|
||||
private static bool IsTruthy(string? value)
|
||||
=> !string.IsNullOrWhiteSpace(value)
|
||||
&& (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private sealed record GoldenFixtureCase(string AdvisoryId, string InputFile, string SnapshotFile, DateTimeOffset ValidatedAt);
|
||||
|
||||
private static string ProjectFixturePath(string filename)
|
||||
=> Path.Combine(GetProjectRoot(), "RedHat", "Fixtures", filename);
|
||||
|
||||
private static string GetProjectRoot()
|
||||
=> Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
|
||||
|
||||
private async Task EnsureServiceProviderAsync(RedHatOptions options)
|
||||
{
|
||||
if (_serviceProvider is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_serviceProvider = await CreateServiceProviderAsync(options, _handler);
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> CreateServiceProviderAsync(RedHatOptions options, CannedHttpMessageHandler handler)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddSingleton(handler);
|
||||
|
||||
services.AddMongoStorage(storageOptions =>
|
||||
{
|
||||
storageOptions.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
storageOptions.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
storageOptions.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddRedHatConnector(opts =>
|
||||
{
|
||||
opts.BaseEndpoint = options.BaseEndpoint;
|
||||
opts.SummaryPath = options.SummaryPath;
|
||||
opts.PageSize = options.PageSize;
|
||||
opts.MaxPagesPerFetch = options.MaxPagesPerFetch;
|
||||
opts.MaxAdvisoriesPerFetch = options.MaxAdvisoriesPerFetch;
|
||||
opts.InitialBackfill = options.InitialBackfill;
|
||||
opts.Overlap = options.Overlap;
|
||||
opts.FetchTimeout = options.FetchTimeout;
|
||||
opts.UserAgent = options.UserAgent;
|
||||
});
|
||||
|
||||
services.Configure<JobSchedulerOptions>(schedulerOptions =>
|
||||
{
|
||||
var fetchType = Type.GetType("StellaOps.Concelier.Connector.Distro.RedHat.RedHatFetchJob, StellaOps.Concelier.Connector.Distro.RedHat", throwOnError: true)!;
|
||||
var parseType = Type.GetType("StellaOps.Concelier.Connector.Distro.RedHat.RedHatParseJob, StellaOps.Concelier.Connector.Distro.RedHat", throwOnError: true)!;
|
||||
var mapType = Type.GetType("StellaOps.Concelier.Connector.Distro.RedHat.RedHatMapJob, StellaOps.Concelier.Connector.Distro.RedHat", throwOnError: true)!;
|
||||
|
||||
schedulerOptions.Definitions["source:redhat:fetch"] = new JobDefinition("source:redhat:fetch", fetchType, TimeSpan.FromMinutes(12), TimeSpan.FromMinutes(6), "0,15,30,45 * * * *", true);
|
||||
schedulerOptions.Definitions["source:redhat:parse"] = new JobDefinition("source:redhat:parse", parseType, TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(6), "5,20,35,50 * * * *", true);
|
||||
schedulerOptions.Definitions["source:redhat:map"] = new JobDefinition("source:redhat:map", mapType, TimeSpan.FromMinutes(20), TimeSpan.FromMinutes(6), "10,25,40,55 * * * *", true);
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(RedHatOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
{
|
||||
builder.PrimaryHandler = handler;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private Task ResetDatabaseAsync()
|
||||
{
|
||||
return ResetDatabaseInternalAsync();
|
||||
}
|
||||
|
||||
private async Task ResetDatabaseInternalAsync()
|
||||
{
|
||||
if (_serviceProvider is not null)
|
||||
{
|
||||
if (_serviceProvider is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_serviceProvider.Dispose();
|
||||
}
|
||||
|
||||
_serviceProvider = null;
|
||||
}
|
||||
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
_timeProvider.SetUtcNow(_initialNow);
|
||||
}
|
||||
|
||||
private static string ReadFixture(string name)
|
||||
=> File.ReadAllText(ResolveFixturePath(name));
|
||||
|
||||
private static string ResolveFixturePath(string filename)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "RedHat", "Fixtures", filename),
|
||||
Path.Combine(AppContext.BaseDirectory, "RedHat", "Fixtures", filename),
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Fixture '{filename}' not found in output directory.", filename);
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndings(string value)
|
||||
{
|
||||
var normalized = value.Replace("\r\n", "\n").Replace('\r', '\n');
|
||||
return normalized.TrimEnd('\n');
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await ResetDatabaseInternalAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Distro.RedHat/StellaOps.Concelier.Connector.Distro.RedHat.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="RedHat/Fixtures/*.json" CopyToOutputDirectory="Always" TargetPath="Source/Distro/RedHat/Fixtures/%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
"suse-su-2025_0001-1.json","2025-01-21T10:00:00Z"
|
||||
"suse-su-2025_0002-1.json","2025-01-22T08:30:00Z"
|
||||
|
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"document": {
|
||||
"title": "openssl - security update",
|
||||
"tracking": {
|
||||
"id": "SUSE-SU-2025:0001-1",
|
||||
"initial_release_date": "2025-01-21T00:00:00Z",
|
||||
"current_release_date": "2025-01-21T00:00:00Z"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"category": "self",
|
||||
"summary": "SUSE notice",
|
||||
"url": "https://www.suse.com/security/cve/CVE-2025-0001/"
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"category": "summary",
|
||||
"text": "Security update for openssl"
|
||||
}
|
||||
]
|
||||
},
|
||||
"product_tree": {
|
||||
"branches": [
|
||||
{
|
||||
"category": "vendor",
|
||||
"name": "SUSE",
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_family",
|
||||
"name": "SUSE Linux Enterprise Server 15 SP5",
|
||||
"branches": [
|
||||
{
|
||||
"category": "architecture",
|
||||
"name": "x86_64",
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_version",
|
||||
"name": "openssl-1.1.1w-150500.17.25.1.x86_64",
|
||||
"product": {
|
||||
"name": "openssl-1.1.1w-150500.17.25.1.x86_64",
|
||||
"product_id": "SUSE Linux Enterprise Server 15 SP5:openssl-1.1.1w-150500.17.25.1.x86_64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-0001",
|
||||
"product_status": {
|
||||
"recommended": [
|
||||
"SUSE Linux Enterprise Server 15 SP5:openssl-1.1.1w-150500.17.25.1.x86_64"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"document": {
|
||||
"title": "postgresql - investigation update",
|
||||
"tracking": {
|
||||
"id": "SUSE-SU-2025:0002-1",
|
||||
"initial_release_date": "2025-01-22T00:00:00Z",
|
||||
"current_release_date": "2025-01-22T00:00:00Z"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"category": "external",
|
||||
"summary": "Upstream CVE",
|
||||
"url": "https://www.postgresql.org/support/security/CVE-2025-0002/"
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"category": "summary",
|
||||
"text": "Investigation ongoing for postgresql security issue."
|
||||
}
|
||||
]
|
||||
},
|
||||
"product_tree": {
|
||||
"branches": [
|
||||
{
|
||||
"category": "vendor",
|
||||
"name": "SUSE",
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_family",
|
||||
"name": "openSUSE Tumbleweed",
|
||||
"branches": [
|
||||
{
|
||||
"category": "architecture",
|
||||
"name": "x86_64",
|
||||
"branches": [
|
||||
{
|
||||
"category": "product_version",
|
||||
"name": "postgresql16-16.3-2.1.x86_64",
|
||||
"product": {
|
||||
"name": "postgresql16-16.3-2.1.x86_64",
|
||||
"product_id": "openSUSE Tumbleweed:postgresql16-16.3-2.1.x86_64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-0002",
|
||||
"product_status": {
|
||||
"known_affected": [
|
||||
"openSUSE Tumbleweed:postgresql16-16.3-2.1.x86_64"
|
||||
],
|
||||
"under_investigation": [
|
||||
"openSUSE Tumbleweed:postgresql16-16.3-2.1.x86_64"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Distro.Suse/StellaOps.Concelier.Connector.Distro.Suse.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Source\Distro\Suse\Fixtures\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Connector.Distro.Suse;
|
||||
using StellaOps.Concelier.Connector.Distro.Suse.Configuration;
|
||||
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 Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class SuseConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri ChangesUri = new("https://ftp.suse.com/pub/projects/security/csaf/changes.csv");
|
||||
private static readonly Uri AdvisoryResolvedUri = new("https://ftp.suse.com/pub/projects/security/csaf/suse-su-2025_0001-1.json");
|
||||
private static readonly Uri AdvisoryOpenUri = new("https://ftp.suse.com/pub/projects/security/csaf/suse-su-2025_0002-1.json");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public SuseConnectorTests(MongoIntegrationFixture fixture, ITestOutputHelper output)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 22, 0, 0, 0, TimeSpan.Zero));
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_ProcessesResolvedAndOpenNotices()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
|
||||
SeedInitialResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<SuseConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
|
||||
var resolved = advisories.Single(a => a.AdvisoryKey == "SUSE-SU-2025:0001-1");
|
||||
var resolvedPackage = Assert.Single(resolved.AffectedPackages);
|
||||
var resolvedRange = Assert.Single(resolvedPackage.VersionRanges);
|
||||
Assert.Equal("nevra", resolvedRange.RangeKind);
|
||||
Assert.NotNull(resolvedRange.Primitives);
|
||||
Assert.NotNull(resolvedRange.Primitives!.Nevra?.Fixed);
|
||||
|
||||
var open = advisories.Single(a => a.AdvisoryKey == "SUSE-SU-2025:0002-1");
|
||||
var openPackage = Assert.Single(open.AffectedPackages);
|
||||
Assert.Equal(AffectedPackageStatusCatalog.UnderInvestigation, openPackage.Statuses.Single().Status);
|
||||
|
||||
SeedNotModifiedResponses();
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
_handler.AssertNoPendingResponses();
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync()
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddSingleton(_handler);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddSuseConnector(options =>
|
||||
{
|
||||
options.ChangesEndpoint = ChangesUri;
|
||||
options.AdvisoryBaseUri = new Uri("https://ftp.suse.com/pub/projects/security/csaf/");
|
||||
options.MaxAdvisoriesPerFetch = 5;
|
||||
options.RequestDelay = TimeSpan.Zero;
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(SuseOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
{
|
||||
builder.PrimaryHandler = _handler;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedInitialResponses()
|
||||
{
|
||||
_handler.AddResponse(ChangesUri, () => BuildResponse(HttpStatusCode.OK, "suse-changes.csv", "\"changes-v1\""));
|
||||
_handler.AddResponse(AdvisoryResolvedUri, () => BuildResponse(HttpStatusCode.OK, "suse-su-2025_0001-1.json", "\"adv-1\""));
|
||||
_handler.AddResponse(AdvisoryOpenUri, () => BuildResponse(HttpStatusCode.OK, "suse-su-2025_0002-1.json", "\"adv-2\""));
|
||||
}
|
||||
|
||||
private void SeedNotModifiedResponses()
|
||||
{
|
||||
_handler.AddResponse(ChangesUri, () => BuildResponse(HttpStatusCode.NotModified, "suse-changes.csv", "\"changes-v1\""));
|
||||
}
|
||||
|
||||
private HttpResponseMessage BuildResponse(HttpStatusCode statusCode, string fixture, string etag)
|
||||
{
|
||||
var response = new HttpResponseMessage(statusCode);
|
||||
if (statusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var contentType = fixture.EndsWith(".csv", StringComparison.OrdinalIgnoreCase) ? "text/csv" : "application/json";
|
||||
response.Content = new StringContent(ReadFixture(Path.Combine("Source", "Distro", "Suse", "Fixtures", fixture)), Encoding.UTF8, contentType);
|
||||
}
|
||||
|
||||
response.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string ReadFixture(string relativePath)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Fixture '{relativePath}' not found.", path);
|
||||
}
|
||||
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.Connector.Distro.Suse.Internal;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Tests;
|
||||
|
||||
public sealed class SuseCsafParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_ProducesRecommendedAndAffectedPackages()
|
||||
{
|
||||
var json = ReadFixture("Source/Distro/Suse/Fixtures/suse-su-2025_0001-1.json");
|
||||
var dto = SuseCsafParser.Parse(json);
|
||||
|
||||
Assert.Equal("SUSE-SU-2025:0001-1", dto.AdvisoryId);
|
||||
Assert.Contains("CVE-2025-0001", dto.CveIds);
|
||||
var package = Assert.Single(dto.Packages);
|
||||
Assert.Equal("openssl", package.Package);
|
||||
Assert.Equal("resolved", package.Status);
|
||||
Assert.NotNull(package.FixedVersion);
|
||||
Assert.Equal("SUSE Linux Enterprise Server 15 SP5", package.Platform);
|
||||
Assert.Equal("openssl-1.1.1w-150500.17.25.1.x86_64", package.CanonicalNevra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_HandlesOpenInvestigation()
|
||||
{
|
||||
var json = ReadFixture("Source/Distro/Suse/Fixtures/suse-su-2025_0002-1.json");
|
||||
var dto = SuseCsafParser.Parse(json);
|
||||
|
||||
Assert.Equal("SUSE-SU-2025:0002-1", dto.AdvisoryId);
|
||||
Assert.Contains("CVE-2025-0002", dto.CveIds);
|
||||
var package = Assert.Single(dto.Packages);
|
||||
Assert.Equal("open", package.Status);
|
||||
Assert.Equal("postgresql16", package.Package);
|
||||
Assert.NotNull(package.LastAffectedVersion);
|
||||
}
|
||||
|
||||
private static string ReadFixture(string relativePath)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Fixture '{relativePath}' not found.", path);
|
||||
}
|
||||
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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);
|
||||
Assert.Equal("nevra", range.RangeKind);
|
||||
Assert.NotNull(range.Primitives);
|
||||
Assert.NotNull(range.Primitives!.Nevra);
|
||||
Assert.NotNull(range.Primitives.Nevra!.Fixed);
|
||||
Assert.Equal("openssl", range.Primitives.Nevra.Fixed!.Name);
|
||||
Assert.Equal("SUSE Linux Enterprise Server 15 SP5", package.Platform);
|
||||
|
||||
var normalizedRule = Assert.Single(package.NormalizedVersions);
|
||||
Assert.Equal(NormalizedVersionSchemes.Nevra, normalizedRule.Scheme);
|
||||
Assert.Equal($"suse:{package.Platform}", normalizedRule.Notes);
|
||||
var primitives = range.Primitives.Nevra;
|
||||
if (primitives!.Introduced is not null)
|
||||
{
|
||||
Assert.Equal(primitives.Introduced.ToCanonicalString(), normalizedRule.Min);
|
||||
}
|
||||
if (primitives.Fixed is not null)
|
||||
{
|
||||
Assert.Equal(primitives.Fixed.ToCanonicalString(), normalizedRule.Max);
|
||||
}
|
||||
if (primitives.LastAffected is not null && normalizedRule.Type is NormalizedVersionRuleTypes.Range or NormalizedVersionRuleTypes.LessThanOrEqual)
|
||||
{
|
||||
Assert.Equal(primitives.LastAffected.ToCanonicalString(), normalizedRule.Max);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"offset": 0,
|
||||
"limit": 1,
|
||||
"total_results": 2,
|
||||
"notices": [
|
||||
{
|
||||
"id": "USN-9001-1",
|
||||
"title": "Kernel update",
|
||||
"summary": "Kernel fixes",
|
||||
"published": "2025-01-20T08:30:00Z",
|
||||
"cves_ids": [
|
||||
"CVE-2025-2000"
|
||||
],
|
||||
"cves": [
|
||||
{
|
||||
"id": "CVE-2025-2000"
|
||||
}
|
||||
],
|
||||
"references": [],
|
||||
"release_packages": {
|
||||
"noble": [
|
||||
{
|
||||
"name": "linux-image",
|
||||
"version": "6.8.0-1010.11",
|
||||
"pocket": "security",
|
||||
"is_source": false
|
||||
}
|
||||
],
|
||||
"focal": [
|
||||
{
|
||||
"name": "linux-image",
|
||||
"version": "5.15.0-200.0",
|
||||
"pocket": "esm-infra",
|
||||
"is_source": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"offset": 1,
|
||||
"limit": 1,
|
||||
"total_results": 2,
|
||||
"notices": [
|
||||
{
|
||||
"id": "USN-9000-1",
|
||||
"title": "Example security update",
|
||||
"summary": "Package fixes",
|
||||
"published": "2025-01-15T12:00:00Z",
|
||||
"cves_ids": [
|
||||
"CVE-2025-1000",
|
||||
"CVE-2025-1001"
|
||||
],
|
||||
"cves": [
|
||||
{
|
||||
"id": "CVE-2025-1000"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2025-1001"
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"url": "https://ubuntu.com/security/USN-9000-1",
|
||||
"category": "self",
|
||||
"summary": "USN"
|
||||
}
|
||||
],
|
||||
"release_packages": {
|
||||
"jammy": [
|
||||
{
|
||||
"name": "examplepkg",
|
||||
"version": "1.2.3-0ubuntu0.22.04.1",
|
||||
"pocket": "security",
|
||||
"is_source": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Distro.Ubuntu/StellaOps.Concelier.Connector.Distro.Ubuntu.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Connector.Distro.Ubuntu;
|
||||
using StellaOps.Concelier.Connector.Distro.Ubuntu.Configuration;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Ubuntu.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class UbuntuConnectorTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri IndexPage0Uri = new("https://ubuntu.com/security/notices.json?offset=0&limit=1");
|
||||
private static readonly Uri IndexPage1Uri = new("https://ubuntu.com/security/notices.json?offset=1&limit=1");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public UbuntuConnectorTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 25, 0, 0, 0, TimeSpan.Zero));
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_GeneratesEvrRangePrimitives()
|
||||
{
|
||||
await using var provider = await BuildServiceProviderAsync();
|
||||
|
||||
SeedInitialResponses();
|
||||
|
||||
var connector = provider.GetRequiredService<UbuntuConnector>();
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
|
||||
var kernelNotice = advisories.Single(a => a.AdvisoryKey == "USN-9001-1");
|
||||
var noblePackage = Assert.Single(kernelNotice.AffectedPackages, pkg => pkg.Platform == "noble");
|
||||
var range = Assert.Single(noblePackage.VersionRanges);
|
||||
Assert.Equal("evr", range.RangeKind);
|
||||
Assert.NotNull(range.Primitives);
|
||||
Assert.NotNull(range.Primitives!.Evr?.Fixed);
|
||||
Assert.Contains("CVE-2025-2000", kernelNotice.Aliases);
|
||||
var normalizedRule = Assert.Single(noblePackage.NormalizedVersions);
|
||||
Assert.Equal(NormalizedVersionSchemes.Evr, normalizedRule.Scheme);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.LessThan, normalizedRule.Type);
|
||||
Assert.Equal(range.Primitives.Evr!.Fixed!.ToCanonicalString(), normalizedRule.Max);
|
||||
Assert.Equal("ubuntu:noble", normalizedRule.Notes);
|
||||
|
||||
SeedNotModifiedResponses();
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
_handler.AssertNoPendingResponses();
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync()
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddSingleton(_handler);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddUbuntuConnector(options =>
|
||||
{
|
||||
options.NoticesEndpoint = new Uri("https://ubuntu.com/security/notices.json");
|
||||
options.NoticeDetailBaseUri = new Uri("https://ubuntu.com/security/");
|
||||
options.MaxNoticesPerFetch = 2;
|
||||
options.IndexPageSize = 1;
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(UbuntuOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
{
|
||||
builder.PrimaryHandler = _handler;
|
||||
});
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedInitialResponses()
|
||||
{
|
||||
_handler.AddResponse(IndexPage0Uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(ReadFixture("Fixtures/ubuntu-notices-page0.json"), Encoding.UTF8, "application/json")
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"index-page0-v1\"");
|
||||
return response;
|
||||
});
|
||||
|
||||
_handler.AddResponse(IndexPage1Uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(ReadFixture("Fixtures/ubuntu-notices-page1.json"), Encoding.UTF8, "application/json")
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"index-page1-v1\"");
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private void SeedNotModifiedResponses()
|
||||
{
|
||||
_handler.AddResponse(IndexPage0Uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"index-page0-v1\"");
|
||||
return response;
|
||||
});
|
||||
|
||||
// Page 1 remains cached; the connector should skip fetching it when page 0 is unchanged.
|
||||
}
|
||||
|
||||
private static string ReadFixture(string relativePath)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Fixture '{relativePath}' not found.", path);
|
||||
}
|
||||
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"advisoryKey": "GHSA-qqqq-wwww-eeee",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "semver",
|
||||
"identifier": "npm:conflict/package",
|
||||
"platform": null,
|
||||
"versionRanges": [
|
||||
{
|
||||
"fixedVersion": "1.4.0",
|
||||
"introducedVersion": null,
|
||||
"lastAffectedVersion": null,
|
||||
"primitives": {
|
||||
"evr": null,
|
||||
"hasVendorExtensions": true,
|
||||
"nevra": null,
|
||||
"semVer": {
|
||||
"constraintExpression": "< 1.4.0",
|
||||
"exactValue": null,
|
||||
"fixed": "1.4.0",
|
||||
"fixedInclusive": false,
|
||||
"introduced": null,
|
||||
"introducedInclusive": true,
|
||||
"lastAffected": null,
|
||||
"lastAffectedInclusive": false,
|
||||
"style": "lessThan"
|
||||
},
|
||||
"vendorExtensions": {
|
||||
"ecosystem": "npm",
|
||||
"package": "conflict/package"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "affected-range",
|
||||
"value": "npm:conflict/package",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-03-04T08:30:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages[].versionranges[]"
|
||||
]
|
||||
},
|
||||
"rangeExpression": "< 1.4.0",
|
||||
"rangeKind": "semver"
|
||||
}
|
||||
],
|
||||
"normalizedVersions": [
|
||||
{
|
||||
"scheme": "semver",
|
||||
"type": "lt",
|
||||
"min": null,
|
||||
"minInclusive": null,
|
||||
"max": "1.4.0",
|
||||
"maxInclusive": false,
|
||||
"value": null,
|
||||
"notes": "ghsa:npm:conflict/package"
|
||||
}
|
||||
],
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "affected-status",
|
||||
"value": "npm:conflict/package",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-03-04T08:30:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages[].statuses[]"
|
||||
]
|
||||
},
|
||||
"status": "affected"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "ghsa",
|
||||
"kind": "affected",
|
||||
"value": "npm:conflict/package",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-03-04T08:30:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages[]"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"CVE-2025-4242",
|
||||
"GHSA-qqqq-wwww-eeee"
|
||||
],
|
||||
"canonicalMetricId": "ghsa:severity/high",
|
||||
"credits": [
|
||||
{
|
||||
"displayName": "maintainer-team",
|
||||
"role": "remediation_developer",
|
||||
"contacts": [
|
||||
"https://github.com/conflict/package"
|
||||
],
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "credit",
|
||||
"value": "maintainer-team",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-03-04T08:30:00+00:00",
|
||||
"fieldMask": [
|
||||
"credits[]"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"displayName": "security-researcher",
|
||||
"role": "reporter",
|
||||
"contacts": [
|
||||
"https://github.com/sec-researcher"
|
||||
],
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "credit",
|
||||
"value": "security-researcher",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-03-04T08:30:00+00:00",
|
||||
"fieldMask": [
|
||||
"credits[]"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"cvssMetrics": [],
|
||||
"cwes": [],
|
||||
"description": "Container escape vulnerability allowing privilege escalation in conflict-package.",
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": "2025-03-02T12:00:00+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"source": "ghsa",
|
||||
"kind": "document",
|
||||
"value": "https://github.com/advisories/GHSA-qqqq-wwww-eeee",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-03-03T18:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"advisory"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "ghsa",
|
||||
"kind": "mapping",
|
||||
"value": "GHSA-qqqq-wwww-eeee",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-03-04T08:30:00+00:00",
|
||||
"fieldMask": [
|
||||
"advisory"
|
||||
]
|
||||
}
|
||||
],
|
||||
"published": "2025-02-25T00:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "reference",
|
||||
"value": "https://github.com/advisories/GHSA-qqqq-wwww-eeee",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-03-04T08:30:00+00:00",
|
||||
"fieldMask": [
|
||||
"references[]"
|
||||
]
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": null,
|
||||
"url": "https://github.com/advisories/GHSA-qqqq-wwww-eeee"
|
||||
},
|
||||
{
|
||||
"kind": "fix",
|
||||
"provenance": {
|
||||
"source": "ghsa",
|
||||
"kind": "reference",
|
||||
"value": "https://github.com/conflict/package/releases/tag/v1.4.0",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-03-04T08:30:00+00:00",
|
||||
"fieldMask": [
|
||||
"references[]"
|
||||
]
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": null,
|
||||
"url": "https://github.com/conflict/package/releases/tag/v1.4.0"
|
||||
}
|
||||
],
|
||||
"severity": "high",
|
||||
"summary": "Container escape in conflict-package",
|
||||
"title": "Container escape in conflict-package"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user