- Added `/concelier/advisories/{vulnerabilityKey}/replay` endpoint to return conflict summaries and explainers.
- Introduced `MergeConflictExplainerPayload` to structure conflict details including type, reason, and source rankings.
- Enhanced `MergeConflictSummary` to include structured explainer payloads and hashes for persisted conflicts.
- Updated `MirrorEndpointExtensions` to enforce rate limits and cache headers for mirror distribution endpoints.
- Refactored tests to cover new replay endpoint functionality and validate conflict explainers.
- Documented changes in TASKS.md, noting completion of mirror distribution endpoints and updated operational runbook.
360 lines
16 KiB
C#
360 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using MongoDB.Bson;
|
|
using StellaOps.Concelier.Connector.Common;
|
|
using StellaOps.Concelier.Connector.Common.Fetch;
|
|
using StellaOps.Concelier.Connector.Common.Testing;
|
|
using StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
|
|
using StellaOps.Concelier.Storage.Mongo;
|
|
using StellaOps.Concelier.Storage.Mongo.Documents;
|
|
using StellaOps.Concelier.Testing;
|
|
using StellaOps.Cryptography;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
|
|
|
|
[Collection("mongo-fixture")]
|
|
public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
|
{
|
|
private readonly MongoIntegrationFixture _fixture;
|
|
private readonly CannedHttpMessageHandler _handler;
|
|
|
|
public StellaOpsMirrorConnectorTests(MongoIntegrationFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
_handler = new CannedHttpMessageHandler();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FetchAsync_PersistsMirrorArtifacts()
|
|
{
|
|
var manifestContent = "{\"domain\":\"primary\",\"files\":[]}";
|
|
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0001\"}]}";
|
|
|
|
var manifestDigest = ComputeDigest(manifestContent);
|
|
var bundleDigest = ComputeDigest(bundleContent);
|
|
|
|
var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: false);
|
|
|
|
await using var provider = await BuildServiceProviderAsync();
|
|
|
|
SeedResponses(index, manifestContent, bundleContent, signature: null);
|
|
|
|
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
|
await connector.FetchAsync(provider, CancellationToken.None);
|
|
|
|
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
|
var manifestUri = "https://mirror.test/mirror/primary/manifest.json";
|
|
var bundleUri = "https://mirror.test/mirror/primary/bundle.json";
|
|
|
|
var manifestDocument = await documentStore.FindBySourceAndUriAsync(StellaOpsMirrorConnector.Source, manifestUri, CancellationToken.None);
|
|
Assert.NotNull(manifestDocument);
|
|
Assert.Equal(DocumentStatuses.Mapped, manifestDocument!.Status);
|
|
Assert.Equal(NormalizeDigest(manifestDigest), manifestDocument.Sha256);
|
|
|
|
var bundleDocument = await documentStore.FindBySourceAndUriAsync(StellaOpsMirrorConnector.Source, bundleUri, CancellationToken.None);
|
|
Assert.NotNull(bundleDocument);
|
|
Assert.Equal(DocumentStatuses.PendingParse, bundleDocument!.Status);
|
|
Assert.Equal(NormalizeDigest(bundleDigest), bundleDocument.Sha256);
|
|
|
|
var rawStorage = provider.GetRequiredService<RawDocumentStorage>();
|
|
Assert.NotNull(manifestDocument.GridFsId);
|
|
Assert.NotNull(bundleDocument.GridFsId);
|
|
|
|
var manifestBytes = await rawStorage.DownloadAsync(manifestDocument.GridFsId!.Value, CancellationToken.None);
|
|
var bundleBytes = await rawStorage.DownloadAsync(bundleDocument.GridFsId!.Value, CancellationToken.None);
|
|
Assert.Equal(manifestContent, Encoding.UTF8.GetString(manifestBytes));
|
|
Assert.Equal(bundleContent, Encoding.UTF8.GetString(bundleBytes));
|
|
|
|
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
|
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
|
Assert.NotNull(state);
|
|
|
|
var cursorDocument = state!.Cursor ?? new BsonDocument();
|
|
var digestValue = cursorDocument.TryGetValue("bundleDigest", out var digestBson) ? digestBson.AsString : string.Empty;
|
|
Assert.Equal(NormalizeDigest(bundleDigest), NormalizeDigest(digestValue));
|
|
|
|
var pendingDocumentsArray = cursorDocument.TryGetValue("pendingDocuments", out var pendingDocsBson) && pendingDocsBson is BsonArray pendingArray
|
|
? pendingArray
|
|
: new BsonArray();
|
|
Assert.Single(pendingDocumentsArray);
|
|
var pendingDocumentId = Guid.Parse(pendingDocumentsArray[0].AsString);
|
|
Assert.Equal(bundleDocument.Id, pendingDocumentId);
|
|
|
|
var pendingMappingsArray = cursorDocument.TryGetValue("pendingMappings", out var pendingMappingsBson) && pendingMappingsBson is BsonArray mappingsArray
|
|
? mappingsArray
|
|
: new BsonArray();
|
|
Assert.Empty(pendingMappingsArray);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FetchAsync_TamperedSignatureThrows()
|
|
{
|
|
var manifestContent = "{\"domain\":\"primary\"}";
|
|
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0002\"}]}";
|
|
|
|
var manifestDigest = ComputeDigest(manifestContent);
|
|
var bundleDigest = ComputeDigest(bundleContent);
|
|
var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: true);
|
|
|
|
await using var provider = await BuildServiceProviderAsync(options =>
|
|
{
|
|
options.Signature.Enabled = true;
|
|
options.Signature.KeyId = "mirror-key";
|
|
options.Signature.Provider = "default";
|
|
});
|
|
|
|
var defaultProvider = provider.GetRequiredService<DefaultCryptoProvider>();
|
|
var signingKey = CreateSigningKey("mirror-key");
|
|
defaultProvider.UpsertSigningKey(signingKey);
|
|
|
|
var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent);
|
|
// Tamper with signature so verification fails.
|
|
var tamperedSignature = signatureValue.Replace('a', 'b');
|
|
|
|
SeedResponses(index, manifestContent, bundleContent, tamperedSignature);
|
|
|
|
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
|
|
|
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
|
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
|
Assert.NotNull(state);
|
|
Assert.True(state!.FailCount >= 1);
|
|
Assert.False(state.Cursor.TryGetValue("bundleDigest", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FetchAsync_SignatureKeyMismatchThrows()
|
|
{
|
|
var manifestContent = "{\"domain\":\"primary\"}";
|
|
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0003\"}]}";
|
|
|
|
var manifestDigest = ComputeDigest(manifestContent);
|
|
var bundleDigest = ComputeDigest(bundleContent);
|
|
var index = BuildIndex(
|
|
manifestDigest,
|
|
Encoding.UTF8.GetByteCount(manifestContent),
|
|
bundleDigest,
|
|
Encoding.UTF8.GetByteCount(bundleContent),
|
|
includeSignature: true,
|
|
signatureKeyId: "unexpected-key",
|
|
signatureProvider: "default");
|
|
|
|
var signingKey = CreateSigningKey("unexpected-key");
|
|
var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent);
|
|
|
|
await using var provider = await BuildServiceProviderAsync(options =>
|
|
{
|
|
options.Signature.Enabled = true;
|
|
options.Signature.KeyId = "mirror-key";
|
|
options.Signature.Provider = "default";
|
|
});
|
|
|
|
SeedResponses(index, manifestContent, bundleContent, signatureValue);
|
|
|
|
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
|
}
|
|
|
|
public Task InitializeAsync() => Task.CompletedTask;
|
|
|
|
public Task DisposeAsync()
|
|
{
|
|
_handler.Clear();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task<ServiceProvider> BuildServiceProviderAsync(Action<StellaOpsMirrorConnectorOptions>? configureOptions = null)
|
|
{
|
|
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
|
_handler.Clear();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
|
services.AddSingleton(_handler);
|
|
services.AddSingleton(TimeProvider.System);
|
|
|
|
services.AddMongoStorage(options =>
|
|
{
|
|
options.ConnectionString = _fixture.Runner.ConnectionString;
|
|
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
|
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
|
});
|
|
|
|
services.AddSingleton<DefaultCryptoProvider>();
|
|
services.AddSingleton<ICryptoProvider>(sp => sp.GetRequiredService<DefaultCryptoProvider>());
|
|
services.AddSingleton<ICryptoProviderRegistry>(sp => new CryptoProviderRegistry(sp.GetServices<ICryptoProvider>()));
|
|
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["concelier:sources:stellaopsMirror:baseAddress"] = "https://mirror.test/",
|
|
["concelier:sources:stellaopsMirror:domainId"] = "primary",
|
|
["concelier:sources:stellaopsMirror:indexPath"] = "/concelier/exports/index.json",
|
|
})
|
|
.Build();
|
|
|
|
var routine = new StellaOpsMirrorDependencyInjectionRoutine();
|
|
routine.Register(services, configuration);
|
|
|
|
if (configureOptions is not null)
|
|
{
|
|
services.PostConfigure(configureOptions);
|
|
}
|
|
|
|
services.Configure<HttpClientFactoryOptions>("stellaops-mirror", builder =>
|
|
{
|
|
builder.HttpMessageHandlerBuilderActions.Add(options =>
|
|
{
|
|
options.PrimaryHandler = _handler;
|
|
});
|
|
});
|
|
|
|
var provider = services.BuildServiceProvider();
|
|
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
|
await bootstrapper.InitializeAsync(CancellationToken.None);
|
|
return provider;
|
|
}
|
|
|
|
private void SeedResponses(string indexJson, string manifestContent, string bundleContent, string? signature)
|
|
{
|
|
var baseUri = new Uri("https://mirror.test");
|
|
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "/concelier/exports/index.json"), () => CreateJsonResponse(indexJson));
|
|
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/manifest.json"), () => CreateJsonResponse(manifestContent));
|
|
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/bundle.json"), () => CreateJsonResponse(bundleContent));
|
|
|
|
if (signature is not null)
|
|
{
|
|
_handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/bundle.json.jws"), () => new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(signature, Encoding.UTF8, "application/jose+json"),
|
|
});
|
|
}
|
|
}
|
|
|
|
private static HttpResponseMessage CreateJsonResponse(string content)
|
|
=> new(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(content, Encoding.UTF8, "application/json"),
|
|
};
|
|
|
|
private static string BuildIndex(
|
|
string manifestDigest,
|
|
int manifestBytes,
|
|
string bundleDigest,
|
|
int bundleBytes,
|
|
bool includeSignature,
|
|
string signatureKeyId = "mirror-key",
|
|
string signatureProvider = "default")
|
|
{
|
|
var index = new
|
|
{
|
|
schemaVersion = 1,
|
|
generatedAt = new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero),
|
|
targetRepository = "repo",
|
|
domains = new[]
|
|
{
|
|
new
|
|
{
|
|
domainId = "primary",
|
|
displayName = "Primary",
|
|
advisoryCount = 1,
|
|
manifest = new
|
|
{
|
|
path = "mirror/primary/manifest.json",
|
|
sizeBytes = manifestBytes,
|
|
digest = manifestDigest,
|
|
signature = (object?)null,
|
|
},
|
|
bundle = new
|
|
{
|
|
path = "mirror/primary/bundle.json",
|
|
sizeBytes = bundleBytes,
|
|
digest = bundleDigest,
|
|
signature = includeSignature
|
|
? new
|
|
{
|
|
path = "mirror/primary/bundle.json.jws",
|
|
algorithm = "ES256",
|
|
keyId = signatureKeyId,
|
|
provider = signatureProvider,
|
|
signedAt = new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero),
|
|
}
|
|
: null,
|
|
},
|
|
sources = Array.Empty<object>(),
|
|
}
|
|
}
|
|
};
|
|
|
|
return JsonSerializer.Serialize(index, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false,
|
|
});
|
|
}
|
|
|
|
private static string ComputeDigest(string content)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(content);
|
|
var hash = SHA256.HashData(bytes);
|
|
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
|
|
private static string NormalizeDigest(string digest)
|
|
=> digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? digest[7..] : digest;
|
|
|
|
private static CryptoSigningKey CreateSigningKey(string keyId)
|
|
{
|
|
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
|
return new CryptoSigningKey(new CryptoKeyReference(keyId), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private static (string Signature, DateTimeOffset SignedAt) CreateDetachedJws(CryptoSigningKey signingKey, string payload)
|
|
{
|
|
var provider = new DefaultCryptoProvider();
|
|
provider.UpsertSigningKey(signingKey);
|
|
var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference);
|
|
var header = new Dictionary<string, object?>
|
|
{
|
|
["alg"] = SignatureAlgorithms.Es256,
|
|
["kid"] = signingKey.Reference.KeyId,
|
|
["provider"] = provider.Name,
|
|
["typ"] = "application/vnd.stellaops.concelier.mirror-bundle+jws",
|
|
["b64"] = false,
|
|
["crit"] = new[] { "b64" }
|
|
};
|
|
|
|
var headerJson = JsonSerializer.Serialize(header);
|
|
var encodedHeader = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(headerJson);
|
|
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
|
var signingInput = BuildSigningInput(encodedHeader, payloadBytes);
|
|
var signatureBytes = signer.SignAsync(signingInput, CancellationToken.None).GetAwaiter().GetResult();
|
|
var encodedSignature = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(signatureBytes);
|
|
return (string.Concat(encodedHeader, "..", encodedSignature), DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private static ReadOnlyMemory<byte> BuildSigningInput(string encodedHeader, ReadOnlySpan<byte> payload)
|
|
{
|
|
var headerBytes = Encoding.ASCII.GetBytes(encodedHeader);
|
|
var buffer = new byte[headerBytes.Length + 1 + payload.Length];
|
|
headerBytes.CopyTo(buffer, 0);
|
|
buffer[headerBytes.Length] = (byte)'.';
|
|
payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1));
|
|
return buffer;
|
|
}
|
|
}
|