Add channel test providers for Email, Slack, Teams, and Webhook
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implemented EmailChannelTestProvider to generate email preview payloads. - Implemented SlackChannelTestProvider to create Slack message previews. - Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews. - Implemented WebhookChannelTestProvider to create webhook payloads. - Added INotifyChannelTestProvider interface for channel-specific preview generation. - Created ChannelTestPreviewContracts for request and response models. - Developed NotifyChannelTestService to handle test send requests and generate previews. - Added rate limit policies for test sends and delivery history. - Implemented unit tests for service registration and binding. - Updated project files to include necessary dependencies and configurations.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ConcelierPluginOutputRoot Condition="'$(ConcelierPluginOutputRoot)' == ''">$(SolutionDir)PluginBinaries</ConcelierPluginOutputRoot>
|
||||
<ConcelierPluginOutputRoot Condition="'$(ConcelierPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)PluginBinaries</ConcelierPluginOutputRoot>
|
||||
<AuthorityPluginOutputRoot Condition="'$(AuthorityPluginOutputRoot)' == ''">$(SolutionDir)PluginBinaries\Authority</AuthorityPluginOutputRoot>
|
||||
<AuthorityPluginOutputRoot Condition="'$(AuthorityPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)PluginBinaries\Authority</AuthorityPluginOutputRoot>
|
||||
<ConcelierPluginOutputRoot Condition="'$(ConcelierPluginOutputRoot)' == ''">$(SolutionDir)StellaOps.Concelier.PluginBinaries</ConcelierPluginOutputRoot>
|
||||
<ConcelierPluginOutputRoot Condition="'$(ConcelierPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)StellaOps.Concelier.PluginBinaries</ConcelierPluginOutputRoot>
|
||||
<AuthorityPluginOutputRoot Condition="'$(AuthorityPluginOutputRoot)' == ''">$(SolutionDir)StellaOps.Authority.PluginBinaries</AuthorityPluginOutputRoot>
|
||||
<AuthorityPluginOutputRoot Condition="'$(AuthorityPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)StellaOps.Authority.PluginBinaries</AuthorityPluginOutputRoot>
|
||||
<IsConcelierPlugin Condition="'$(IsConcelierPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Concelier.Connector.'))">true</IsConcelierPlugin>
|
||||
<IsConcelierPlugin Condition="'$(IsConcelierPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Concelier.Exporter.'))">true</IsConcelierPlugin>
|
||||
<IsAuthorityPlugin Condition="'$(IsAuthorityPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Authority.Plugin.'))">true</IsAuthorityPlugin>
|
||||
@@ -20,12 +20,17 @@
|
||||
<ProjectReference Update="../StellaOps.Plugin/StellaOps.Plugin.csproj">
|
||||
<Private>false</Private>
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests')) and '$(UseConcelierTestInfra)' != 'false'">
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
|
||||
@@ -37,6 +37,10 @@ public sealed class AttestorOptions
|
||||
public bool RequireClientCertificate { get; set; } = true;
|
||||
|
||||
public string? CaBundle { get; set; }
|
||||
|
||||
public IList<string> AllowedSubjects { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> AllowedThumbprints { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class AuthorityOptions
|
||||
|
||||
@@ -26,6 +26,8 @@ public sealed class AttestorEntry
|
||||
|
||||
public SignerIdentityDescriptor SignerIdentity { get; init; } = new();
|
||||
|
||||
public LogReplicaDescriptor? Mirror { get; init; }
|
||||
|
||||
public sealed class ArtifactDescriptor
|
||||
{
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
@@ -64,6 +66,8 @@ public sealed class AttestorEntry
|
||||
|
||||
public sealed class LogDescriptor
|
||||
{
|
||||
public string Backend { get; init; } = "primary";
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string? LogId { get; init; }
|
||||
@@ -79,4 +83,23 @@ public sealed class AttestorEntry
|
||||
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class LogReplicaDescriptor
|
||||
{
|
||||
public string Backend { get; init; } = string.Empty;
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
public long? Index { get; init; }
|
||||
|
||||
public string Status { get; init; } = "pending";
|
||||
|
||||
public ProofDescriptor? Proof { get; init; }
|
||||
|
||||
public string? LogId { get; init; }
|
||||
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ public sealed class AttestorSubmissionResult
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
[JsonPropertyName("mirror")]
|
||||
public MirrorLog? Mirror { get; set; }
|
||||
|
||||
public sealed class RekorProof
|
||||
{
|
||||
[JsonPropertyName("checkpoint")]
|
||||
@@ -56,4 +59,25 @@ public sealed class AttestorSubmissionResult
|
||||
[JsonPropertyName("path")]
|
||||
public IReadOnlyList<string> Path { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class MirrorLog
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[JsonPropertyName("logURL")]
|
||||
public string? LogUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public RekorProof? Proof { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,14 @@ public sealed class AttestorSubmissionValidator
|
||||
private static readonly string[] AllowedKinds = ["sbom", "report", "vex-export"];
|
||||
|
||||
private readonly IDsseCanonicalizer _canonicalizer;
|
||||
private readonly HashSet<string> _allowedModes;
|
||||
|
||||
public AttestorSubmissionValidator(IDsseCanonicalizer canonicalizer)
|
||||
public AttestorSubmissionValidator(IDsseCanonicalizer canonicalizer, IEnumerable<string>? allowedModes = null)
|
||||
{
|
||||
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
|
||||
_allowedModes = allowedModes is null
|
||||
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(allowedModes, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async Task<AttestorSubmissionValidationResult> ValidateAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
|
||||
@@ -47,6 +51,11 @@ public sealed class AttestorSubmissionValidator
|
||||
throw new AttestorValidationException("signature_missing", "At least one DSSE signature is required.");
|
||||
}
|
||||
|
||||
if (_allowedModes.Count > 0 && !string.IsNullOrWhiteSpace(request.Bundle.Mode) && !_allowedModes.Contains(request.Bundle.Mode))
|
||||
{
|
||||
throw new AttestorValidationException("mode_not_allowed", $"Submission mode '{request.Bundle.Mode}' is not permitted.");
|
||||
}
|
||||
|
||||
if (request.Meta is null)
|
||||
{
|
||||
throw new AttestorValidationException("meta_missing", "Submission metadata is required.");
|
||||
|
||||
@@ -24,7 +24,12 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAttestorInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
|
||||
services.AddSingleton<AttestorSubmissionValidator>();
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var canonicalizer = sp.GetRequiredService<IDsseCanonicalizer>();
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
return new AttestorSubmissionValidator(canonicalizer, options.Security.SignerIdentity.Mode);
|
||||
});
|
||||
services.AddSingleton<AttestorMetrics>();
|
||||
services.AddSingleton<IAttestorSubmissionService, AttestorSubmissionService>();
|
||||
services.AddSingleton<IAttestorVerificationService, AttestorVerificationService>();
|
||||
|
||||
@@ -76,6 +76,9 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
[BsonElement("signerIdentity")]
|
||||
public SignerIdentityDocument SignerIdentity { get; set; } = new();
|
||||
|
||||
[BsonElement("mirror")]
|
||||
public MirrorDocument? Mirror { get; set; }
|
||||
|
||||
public static AttestorEntryDocument FromDomain(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorEntryDocument
|
||||
@@ -109,6 +112,7 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
},
|
||||
Log = new LogDocument
|
||||
{
|
||||
Backend = entry.Log.Backend,
|
||||
Url = entry.Log.Url,
|
||||
LogId = entry.Log.LogId
|
||||
},
|
||||
@@ -120,7 +124,8 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
Issuer = entry.SignerIdentity.Issuer,
|
||||
SubjectAlternativeName = entry.SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = entry.SignerIdentity.KeyId
|
||||
}
|
||||
},
|
||||
Mirror = entry.Mirror is null ? null : MirrorDocument.FromDomain(entry.Mirror)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,6 +160,7 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
},
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = Log.Backend,
|
||||
Url = Log.Url,
|
||||
LogId = Log.LogId
|
||||
},
|
||||
@@ -166,7 +172,8 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
Issuer = SignerIdentity.Issuer,
|
||||
SubjectAlternativeName = SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = SignerIdentity.KeyId
|
||||
}
|
||||
},
|
||||
Mirror = Mirror?.ToDomain()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +227,9 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
|
||||
internal sealed class LogDocument
|
||||
{
|
||||
[BsonElement("backend")]
|
||||
public string Backend { get; set; } = "primary";
|
||||
|
||||
[BsonElement("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
@@ -241,5 +251,92 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
[BsonElement("kid")]
|
||||
public string? KeyId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class MirrorDocument
|
||||
{
|
||||
[BsonElement("backend")]
|
||||
public string Backend { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[BsonElement("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
[BsonElement("proof")]
|
||||
public ProofDocument? Proof { get; set; }
|
||||
|
||||
[BsonElement("logId")]
|
||||
public string? LogId { get; set; }
|
||||
|
||||
[BsonElement("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
public static MirrorDocument FromDomain(AttestorEntry.LogReplicaDescriptor mirror)
|
||||
{
|
||||
return new MirrorDocument
|
||||
{
|
||||
Backend = mirror.Backend,
|
||||
Url = mirror.Url,
|
||||
Uuid = mirror.Uuid,
|
||||
Index = mirror.Index,
|
||||
Status = mirror.Status,
|
||||
Proof = mirror.Proof is null ? null : new ProofDocument
|
||||
{
|
||||
Checkpoint = mirror.Proof.Checkpoint is null ? null : new CheckpointDocument
|
||||
{
|
||||
Origin = mirror.Proof.Checkpoint.Origin,
|
||||
Size = mirror.Proof.Checkpoint.Size,
|
||||
RootHash = mirror.Proof.Checkpoint.RootHash,
|
||||
Timestamp = mirror.Proof.Checkpoint.Timestamp is null
|
||||
? null
|
||||
: BsonDateTime.Create(mirror.Proof.Checkpoint.Timestamp.Value)
|
||||
},
|
||||
Inclusion = mirror.Proof.Inclusion is null ? null : new InclusionDocument
|
||||
{
|
||||
LeafHash = mirror.Proof.Inclusion.LeafHash,
|
||||
Path = mirror.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
LogId = mirror.LogId,
|
||||
Error = mirror.Error
|
||||
};
|
||||
}
|
||||
|
||||
public AttestorEntry.LogReplicaDescriptor ToDomain()
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = Backend,
|
||||
Url = Url,
|
||||
Uuid = Uuid,
|
||||
Index = Index,
|
||||
Status = Status,
|
||||
Proof = Proof is null ? null : new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = Proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = Proof.Checkpoint.Origin,
|
||||
Size = Proof.Checkpoint.Size,
|
||||
RootHash = Proof.Checkpoint.RootHash,
|
||||
Timestamp = Proof.Checkpoint.Timestamp?.ToUniversalTime()
|
||||
},
|
||||
Inclusion = Proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = Proof.Inclusion.LeafHash,
|
||||
Path = Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
LogId = LogId,
|
||||
Error = Error
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -58,137 +59,136 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
SubmissionContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var validation = await _validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var canonicalBundle = validation.CanonicalBundle;
|
||||
|
||||
var dedupeUuid = await _dedupeStore.TryGetExistingAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(dedupeUuid))
|
||||
var preference = NormalizeLogPreference(request.Meta.LogPreference);
|
||||
var requiresPrimary = preference is "primary" or "both";
|
||||
var requiresMirror = preference is "mirror" or "both";
|
||||
|
||||
if (!requiresPrimary && !requiresMirror)
|
||||
{
|
||||
requiresPrimary = true;
|
||||
}
|
||||
|
||||
if (requiresMirror && !_options.Rekor.Mirror.Enabled)
|
||||
{
|
||||
throw new AttestorValidationException("mirror_disabled", "Mirror log requested but not configured.");
|
||||
}
|
||||
|
||||
var existing = await TryGetExistingEntryAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogInformation("Dedupe hit for bundle {BundleSha256} -> {RekorUuid}", request.Meta.BundleSha256, dedupeUuid);
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "hit"));
|
||||
var existing = await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
|
||||
?? await _repository.GetByBundleShaAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", "dedupe"),
|
||||
new KeyValuePair<string, object?>("backend", "cache"));
|
||||
return ToResult(existing);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
|
||||
var updated = await EnsureBackendsAsync(existing, request, context, requiresPrimary, requiresMirror, cancellationToken).ConfigureAwait(false);
|
||||
return ToResult(updated);
|
||||
}
|
||||
|
||||
var primaryBackend = BuildBackend("primary", _options.Rekor.Primary);
|
||||
RekorSubmissionResponse submissionResponse;
|
||||
try
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
|
||||
|
||||
SubmissionOutcome? canonicalOutcome = null;
|
||||
SubmissionOutcome? mirrorOutcome = null;
|
||||
|
||||
if (requiresPrimary)
|
||||
{
|
||||
submissionResponse = await _rekorClient.SubmitAsync(request, primaryBackend, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit"));
|
||||
_logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, primaryBackend.Name);
|
||||
throw;
|
||||
canonicalOutcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var proof = submissionResponse.Proof;
|
||||
if (proof is null && string.Equals(submissionResponse.Status, "included", StringComparison.OrdinalIgnoreCase))
|
||||
if (requiresMirror)
|
||||
{
|
||||
try
|
||||
{
|
||||
proof = await _rekorClient.GetProofAsync(submissionResponse.Uuid, primaryBackend, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.ProofFetchTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", proof is null ? "missing" : "ok"));
|
||||
var mirror = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
canonicalOutcome = mirror;
|
||||
}
|
||||
else
|
||||
{
|
||||
mirrorOutcome = mirror;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "proof_fetch"));
|
||||
_logger.LogWarning(ex, "Proof fetch failed for {Uuid} on backend {Backend}", submissionResponse.Uuid, primaryBackend.Name);
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
|
||||
_logger.LogWarning(ex, "Mirror submission failed for bundle {BundleSha}", request.Meta.BundleSha256);
|
||||
mirrorOutcome = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
|
||||
RecordSubmissionMetrics(mirrorOutcome);
|
||||
}
|
||||
}
|
||||
|
||||
var entry = CreateEntry(request, submissionResponse, proof, context, canonicalBundle);
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
throw new InvalidOperationException("No Rekor submission outcome was produced.");
|
||||
}
|
||||
|
||||
var entry = CreateEntry(request, context, canonicalOutcome, mirrorOutcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (request.Meta.Archive)
|
||||
{
|
||||
var archiveBundle = new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
ArtifactSha256 = entry.Artifact.Sha256,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
CanonicalBundleJson = canonicalBundle,
|
||||
ProofJson = proof is null ? Array.Empty<byte>() : JsonSerializer.SerializeToUtf8Bytes(proof, JsonSerializerOptions.Default),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["logUrl"] = entry.Log.Url,
|
||||
["status"] = entry.Status
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _archiveStore.ArchiveBundleAsync(archiveBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to archive bundle {BundleSha}", entry.BundleSha256);
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "archive"));
|
||||
}
|
||||
await ArchiveAsync(entry, canonicalBundle, canonicalOutcome.Proof, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(start, System.Diagnostics.Stopwatch.GetTimestamp());
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", submissionResponse.Status ?? "unknown"),
|
||||
new KeyValuePair<string, object?>("backend", primaryBackend.Name));
|
||||
_metrics.SubmitLatency.Record(elapsed.TotalSeconds, new KeyValuePair<string, object?>("backend", primaryBackend.Name));
|
||||
await WriteAuditAsync(request, context, entry, submissionResponse, (long)elapsed.TotalMilliseconds, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, canonicalOutcome, cancellationToken).ConfigureAwait(false);
|
||||
if (mirrorOutcome is not null)
|
||||
{
|
||||
await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return ToResult(entry);
|
||||
}
|
||||
|
||||
private static AttestorSubmissionResult ToResult(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorSubmissionResult
|
||||
var result = new AttestorSubmissionResult
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
LogUrl = entry.Log.Url,
|
||||
Status = entry.Status,
|
||||
Proof = entry.Proof is null ? null : new AttestorSubmissionResult.RekorProof
|
||||
{
|
||||
Checkpoint = entry.Proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
|
||||
{
|
||||
Origin = entry.Proof.Checkpoint.Origin,
|
||||
Size = entry.Proof.Checkpoint.Size,
|
||||
RootHash = entry.Proof.Checkpoint.RootHash,
|
||||
Timestamp = entry.Proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
Inclusion = entry.Proof.Inclusion is null ? null : new AttestorSubmissionResult.InclusionProof
|
||||
{
|
||||
LeafHash = entry.Proof.Inclusion.LeafHash,
|
||||
Path = entry.Proof.Inclusion.Path
|
||||
}
|
||||
}
|
||||
Proof = ToResultProof(entry.Proof)
|
||||
};
|
||||
|
||||
if (entry.Mirror is not null)
|
||||
{
|
||||
result.Mirror = new AttestorSubmissionResult.MirrorLog
|
||||
{
|
||||
Uuid = entry.Mirror.Uuid,
|
||||
Index = entry.Mirror.Index,
|
||||
LogUrl = entry.Mirror.Url,
|
||||
Status = entry.Mirror.Status,
|
||||
Proof = ToResultProof(entry.Mirror.Proof),
|
||||
Error = entry.Mirror.Error
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private AttestorEntry CreateEntry(
|
||||
AttestorSubmissionRequest request,
|
||||
RekorSubmissionResponse submission,
|
||||
RekorProofResponse? proof,
|
||||
SubmissionContext context,
|
||||
byte[] canonicalBundle)
|
||||
SubmissionOutcome canonicalOutcome,
|
||||
SubmissionOutcome? mirrorOutcome)
|
||||
{
|
||||
if (canonicalOutcome.Submission is null)
|
||||
{
|
||||
throw new InvalidOperationException("Canonical submission outcome must include a Rekor response.");
|
||||
}
|
||||
|
||||
var submission = canonicalOutcome.Submission;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = submission.Uuid,
|
||||
@@ -201,24 +201,11 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
},
|
||||
BundleSha256 = request.Meta.BundleSha256,
|
||||
Index = submission.Index,
|
||||
Proof = proof is null ? null : new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = proof.Checkpoint.Origin,
|
||||
Size = proof.Checkpoint.Size,
|
||||
RootHash = proof.Checkpoint.RootHash,
|
||||
Timestamp = proof.Checkpoint.Timestamp
|
||||
},
|
||||
Inclusion = proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = proof.Inclusion.LeafHash,
|
||||
Path = proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
Proof = ConvertProof(canonicalOutcome.Proof),
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Url = submission.LogUrl ?? string.Empty,
|
||||
Backend = canonicalOutcome.Backend,
|
||||
Url = submission.LogUrl ?? canonicalOutcome.Url,
|
||||
LogId = null
|
||||
},
|
||||
CreatedAt = now,
|
||||
@@ -229,28 +216,233 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
Issuer = context.CallerAudience,
|
||||
SubjectAlternativeName = context.CallerSubject,
|
||||
KeyId = context.CallerClientId
|
||||
}
|
||||
},
|
||||
Mirror = mirrorOutcome is null ? null : CreateMirrorDescriptor(mirrorOutcome)
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeLogPreference(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "primary";
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"primary" => "primary",
|
||||
"mirror" => "mirror",
|
||||
"both" => "both",
|
||||
_ => "primary"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> TryGetExistingEntryAsync(string bundleSha256, CancellationToken cancellationToken)
|
||||
{
|
||||
var dedupeUuid = await _dedupeStore.TryGetExistingAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(dedupeUuid))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
|
||||
?? await _repository.GetByBundleShaAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry> EnsureBackendsAsync(
|
||||
AttestorEntry existing,
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
bool requiresPrimary,
|
||||
bool requiresMirror,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = existing;
|
||||
var updated = false;
|
||||
|
||||
if (requiresPrimary && !IsPrimary(entry))
|
||||
{
|
||||
var outcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
|
||||
entry = PromoteToPrimary(entry, outcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, outcome, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (requiresMirror)
|
||||
{
|
||||
var mirrorSatisfied = entry.Mirror is not null
|
||||
&& entry.Mirror.Error is null
|
||||
&& string.Equals(entry.Mirror.Status, "included", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(entry.Mirror.Uuid);
|
||||
|
||||
if (!mirrorSatisfied)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mirrorOutcome = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
|
||||
entry = WithMirror(entry, mirrorOutcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
|
||||
_logger.LogWarning(ex, "Mirror submission failed for deduplicated bundle {BundleSha}", request.Meta.BundleSha256);
|
||||
var failure = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
|
||||
RecordSubmissionMetrics(failure);
|
||||
entry = WithMirror(entry, failure);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, failure, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!updated)
|
||||
{
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", "dedupe"),
|
||||
new KeyValuePair<string, object?>("backend", "cache"));
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static bool IsPrimary(AttestorEntry entry) =>
|
||||
string.Equals(entry.Log.Backend, "primary", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<SubmissionOutcome> SubmitToBackendAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
string backendName,
|
||||
AttestorOptions.RekorBackendOptions backendOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var backend = BuildBackend(backendName, backendOptions);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var submission = await _rekorClient.SubmitAsync(request, backend, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
var proof = submission.Proof;
|
||||
if (proof is null && string.Equals(submission.Status, "included", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
proof = await _rekorClient.GetProofAsync(submission.Uuid, backend, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.ProofFetchTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", proof is null ? "missing" : "ok"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "proof_fetch"));
|
||||
_logger.LogWarning(ex, "Proof fetch failed for {Uuid} on backend {Backend}", submission.Uuid, backendName);
|
||||
}
|
||||
}
|
||||
|
||||
var outcome = SubmissionOutcome.Success(backendName, backend.Url, submission, proof, stopwatch.Elapsed);
|
||||
RecordSubmissionMetrics(outcome);
|
||||
return outcome;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", $"submit_{backendName}"));
|
||||
_logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, backendName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordSubmissionMetrics(SubmissionOutcome outcome)
|
||||
{
|
||||
var result = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "unknown"
|
||||
: "failed";
|
||||
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", result),
|
||||
new KeyValuePair<string, object?>("backend", outcome.Backend));
|
||||
|
||||
if (outcome.Latency > TimeSpan.Zero)
|
||||
{
|
||||
_metrics.SubmitLatency.Record(outcome.Latency.TotalSeconds,
|
||||
new KeyValuePair<string, object?>("backend", outcome.Backend));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ArchiveAsync(
|
||||
AttestorEntry entry,
|
||||
byte[] canonicalBundle,
|
||||
RekorProofResponse? proof,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["logUrl"] = entry.Log.Url,
|
||||
["status"] = entry.Status
|
||||
};
|
||||
|
||||
if (entry.Mirror is not null)
|
||||
{
|
||||
metadata["mirror.backend"] = entry.Mirror.Backend;
|
||||
metadata["mirror.uuid"] = entry.Mirror.Uuid ?? string.Empty;
|
||||
metadata["mirror.status"] = entry.Mirror.Status;
|
||||
}
|
||||
|
||||
var archiveBundle = new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
ArtifactSha256 = entry.Artifact.Sha256,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
CanonicalBundleJson = canonicalBundle,
|
||||
ProofJson = proof is null ? Array.Empty<byte>() : JsonSerializer.SerializeToUtf8Bytes(proof, JsonSerializerOptions.Default),
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _archiveStore.ArchiveBundleAsync(archiveBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to archive bundle {BundleSha}", entry.BundleSha256);
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "archive"));
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteAuditAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
AttestorEntry entry,
|
||||
RekorSubmissionResponse submission,
|
||||
long latencyMs,
|
||||
SubmissionOutcome outcome,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
if (!outcome.IsSuccess && outcome.Error is not null)
|
||||
{
|
||||
metadata["error"] = outcome.Error.Message;
|
||||
}
|
||||
|
||||
var record = new AttestorAuditRecord
|
||||
{
|
||||
Action = "submit",
|
||||
Result = submission.Status ?? "included",
|
||||
RekorUuid = submission.Uuid,
|
||||
Index = submission.Index,
|
||||
Result = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "included"
|
||||
: "failed",
|
||||
RekorUuid = outcome.IsSuccess
|
||||
? outcome.Submission!.Uuid
|
||||
: string.Equals(outcome.Backend, "primary", StringComparison.OrdinalIgnoreCase)
|
||||
? entry.RekorUuid
|
||||
: entry.Mirror?.Uuid,
|
||||
Index = outcome.Submission?.Index,
|
||||
ArtifactSha256 = request.Meta.Artifact.Sha256,
|
||||
BundleSha256 = request.Meta.BundleSha256,
|
||||
Backend = "primary",
|
||||
LatencyMs = latencyMs,
|
||||
Backend = outcome.Backend,
|
||||
LatencyMs = (long)outcome.Latency.TotalMilliseconds,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
Caller = new AttestorAuditRecord.CallerDescriptor
|
||||
{
|
||||
@@ -259,12 +451,160 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
ClientId = context.CallerClientId,
|
||||
MtlsThumbprint = context.MtlsThumbprint,
|
||||
Tenant = context.CallerTenant
|
||||
}
|
||||
},
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
return _auditSink.WriteAsync(record, cancellationToken);
|
||||
}
|
||||
|
||||
private static AttestorEntry.ProofDescriptor? ConvertProof(RekorProofResponse? proof)
|
||||
{
|
||||
if (proof is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = proof.Checkpoint.Origin,
|
||||
Size = proof.Checkpoint.Size,
|
||||
RootHash = proof.Checkpoint.RootHash,
|
||||
Timestamp = proof.Checkpoint.Timestamp
|
||||
},
|
||||
Inclusion = proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = proof.Inclusion.LeafHash,
|
||||
Path = proof.Inclusion.Path
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorSubmissionResult.RekorProof? ToResultProof(AttestorEntry.ProofDescriptor? proof)
|
||||
{
|
||||
if (proof is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AttestorSubmissionResult.RekorProof
|
||||
{
|
||||
Checkpoint = proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
|
||||
{
|
||||
Origin = proof.Checkpoint.Origin,
|
||||
Size = proof.Checkpoint.Size,
|
||||
RootHash = proof.Checkpoint.RootHash,
|
||||
Timestamp = proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
Inclusion = proof.Inclusion is null ? null : new AttestorSubmissionResult.InclusionProof
|
||||
{
|
||||
LeafHash = proof.Inclusion.LeafHash,
|
||||
Path = proof.Inclusion.Path
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptor(SubmissionOutcome outcome)
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = outcome.Backend,
|
||||
Url = outcome.IsSuccess
|
||||
? outcome.Submission!.LogUrl ?? outcome.Url
|
||||
: outcome.Url,
|
||||
Uuid = outcome.Submission?.Uuid,
|
||||
Index = outcome.Submission?.Index,
|
||||
Status = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "included"
|
||||
: "failed",
|
||||
Proof = outcome.IsSuccess ? ConvertProof(outcome.Proof) : null,
|
||||
Error = outcome.Error?.Message
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry WithMirror(AttestorEntry entry, SubmissionOutcome outcome)
|
||||
{
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
Artifact = entry.Artifact,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
Index = entry.Index,
|
||||
Proof = entry.Proof,
|
||||
Log = entry.Log,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
Status = entry.Status,
|
||||
SignerIdentity = entry.SignerIdentity,
|
||||
Mirror = CreateMirrorDescriptor(outcome)
|
||||
};
|
||||
}
|
||||
|
||||
private AttestorEntry PromoteToPrimary(AttestorEntry existing, SubmissionOutcome outcome)
|
||||
{
|
||||
if (outcome.Submission is null)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot promote to primary without a successful submission.");
|
||||
}
|
||||
|
||||
var mirrorDescriptor = existing.Mirror;
|
||||
if (mirrorDescriptor is null && !string.Equals(existing.Log.Backend, outcome.Backend, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mirrorDescriptor = CreateMirrorDescriptorFromEntry(existing);
|
||||
}
|
||||
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = outcome.Submission.Uuid,
|
||||
Artifact = existing.Artifact,
|
||||
BundleSha256 = existing.BundleSha256,
|
||||
Index = outcome.Submission.Index,
|
||||
Proof = ConvertProof(outcome.Proof),
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = outcome.Backend,
|
||||
Url = outcome.Submission.LogUrl ?? outcome.Url,
|
||||
LogId = existing.Log.LogId
|
||||
},
|
||||
CreatedAt = existing.CreatedAt,
|
||||
Status = outcome.Submission.Status ?? "included",
|
||||
SignerIdentity = existing.SignerIdentity,
|
||||
Mirror = mirrorDescriptor
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptorFromEntry(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = entry.Log.Backend,
|
||||
Url = entry.Log.Url,
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
Status = entry.Status,
|
||||
Proof = entry.Proof,
|
||||
LogId = entry.Log.LogId
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record SubmissionOutcome(
|
||||
string Backend,
|
||||
string Url,
|
||||
RekorSubmissionResponse? Submission,
|
||||
RekorProofResponse? Proof,
|
||||
TimeSpan Latency,
|
||||
Exception? Error)
|
||||
{
|
||||
public bool IsSuccess => Submission is not null && Error is null;
|
||||
|
||||
public static SubmissionOutcome Success(string backend, Uri backendUrl, RekorSubmissionResponse submission, RekorProofResponse? proof, TimeSpan latency) =>
|
||||
new SubmissionOutcome(backend, backendUrl.ToString(), submission, proof, latency, null);
|
||||
|
||||
public static SubmissionOutcome Failure(string backend, string? url, Exception error, TimeSpan latency) =>
|
||||
new SubmissionOutcome(backend, url ?? string.Empty, null, null, latency, error);
|
||||
}
|
||||
|
||||
private static RekorBackend BuildBackend(string name, AttestorOptions.RekorBackendOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Url))
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,7 +16,7 @@ using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Verification;
|
||||
|
||||
@@ -21,19 +27,22 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
private readonly IRekorClient _rekorClient;
|
||||
private readonly ILogger<AttestorVerificationService> _logger;
|
||||
private readonly AttestorOptions _options;
|
||||
private readonly AttestorMetrics _metrics;
|
||||
|
||||
public AttestorVerificationService(
|
||||
IAttestorEntryRepository repository,
|
||||
IDsseCanonicalizer canonicalizer,
|
||||
IRekorClient rekorClient,
|
||||
IOptions<AttestorOptions> options,
|
||||
ILogger<AttestorVerificationService> logger)
|
||||
ILogger<AttestorVerificationService> logger,
|
||||
AttestorMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_canonicalizer = canonicalizer;
|
||||
_rekorClient = rekorClient;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
|
||||
@@ -67,11 +76,25 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var computedHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonicalBundle)).ToLowerInvariant();
|
||||
var computedHash = Convert.ToHexString(SHA256.HashData(canonicalBundle)).ToLowerInvariant();
|
||||
if (!string.Equals(computedHash, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add("Bundle hash does not match stored canonical hash.");
|
||||
issues.Add("bundle_hash_mismatch");
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64(request.Bundle.Dsse.PayloadBase64, out var payloadBytes))
|
||||
{
|
||||
issues.Add("bundle_payload_invalid_base64");
|
||||
}
|
||||
else
|
||||
{
|
||||
var preAuth = ComputePreAuthEncoding(request.Bundle.Dsse.PayloadType, payloadBytes);
|
||||
VerifySignatures(entry, request.Bundle, preAuth, issues);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No DSSE bundle supplied for verification of {Uuid}; signature checks skipped.", entry.RekorUuid);
|
||||
}
|
||||
|
||||
if (request.RefreshProof || entry.Proof is null)
|
||||
@@ -94,8 +117,12 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
}
|
||||
}
|
||||
|
||||
VerifyMerkleProof(entry, issues);
|
||||
|
||||
var ok = issues.Count == 0 && string.Equals(entry.Status, "included", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_metrics.VerifyTotal.Add(1, new KeyValuePair<string, object?>("result", ok ? "ok" : "failed"));
|
||||
|
||||
return new AttestorVerificationResult
|
||||
{
|
||||
Ok = ok,
|
||||
@@ -204,6 +231,472 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
: entry;
|
||||
}
|
||||
|
||||
private void VerifySignatures(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
var mode = (entry.SignerIdentity.Mode ?? bundle.Mode ?? string.Empty).ToLowerInvariant();
|
||||
|
||||
if (mode == "kms")
|
||||
{
|
||||
if (!VerifyKmsSignature(bundle, preAuthEncoding, issues))
|
||||
{
|
||||
issues.Add("signature_invalid_kms");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == "keyless")
|
||||
{
|
||||
VerifyKeylessSignature(entry, bundle, preAuthEncoding, issues);
|
||||
return;
|
||||
}
|
||||
|
||||
issues.Add(string.IsNullOrEmpty(mode)
|
||||
? "signer_mode_unknown"
|
||||
: $"signer_mode_unsupported:{mode}");
|
||||
}
|
||||
|
||||
private bool VerifyKmsSignature(AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
if (_options.Security.SignerIdentity.KmsKeys.Count == 0)
|
||||
{
|
||||
issues.Add("kms_key_missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
var signatures = new List<byte[]>();
|
||||
foreach (var signature in bundle.Dsse.Signatures)
|
||||
{
|
||||
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
issues.Add("signature_invalid_base64");
|
||||
return false;
|
||||
}
|
||||
|
||||
signatures.Add(signatureBytes);
|
||||
}
|
||||
|
||||
foreach (var secret in _options.Security.SignerIdentity.KmsKeys)
|
||||
{
|
||||
if (!TryDecodeSecret(secret, out var secretBytes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(secretBytes);
|
||||
var computed = hmac.ComputeHash(preAuthEncoding);
|
||||
|
||||
foreach (var signatureBytes in signatures)
|
||||
{
|
||||
if (CryptographicOperations.FixedTimeEquals(computed, signatureBytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void VerifyKeylessSignature(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
if (bundle.CertificateChain.Count == 0)
|
||||
{
|
||||
issues.Add("certificate_chain_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
var certificates = new List<X509Certificate2>();
|
||||
try
|
||||
{
|
||||
foreach (var pem in bundle.CertificateChain)
|
||||
{
|
||||
certificates.Add(X509Certificate2.CreateFromPem(pem));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
issues.Add("certificate_chain_invalid");
|
||||
_logger.LogWarning(ex, "Failed to parse certificate chain for {Uuid}", entry.RekorUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
var leafCertificate = certificates[0];
|
||||
|
||||
if (_options.Security.SignerIdentity.FulcioRoots.Count > 0)
|
||||
{
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
VerificationFlags = X509VerificationFlags.NoFlag,
|
||||
TrustMode = X509ChainTrustMode.CustomRootTrust
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var rootPath in _options.Security.SignerIdentity.FulcioRoots)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(rootPath))
|
||||
{
|
||||
var rootCertificate = X509CertificateLoader.LoadCertificateFromFile(rootPath);
|
||||
chain.ChainPolicy.CustomTrustStore.Add(rootCertificate);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load Fulcio root {Root}", rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!chain.Build(leafCertificate))
|
||||
{
|
||||
var status = string.Join(";", chain.ChainStatus.Select(s => s.StatusInformation.Trim()))
|
||||
.Trim(';');
|
||||
issues.Add(string.IsNullOrEmpty(status) ? "certificate_chain_untrusted" : $"certificate_chain_untrusted:{status}");
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.Security.SignerIdentity.AllowedSans.Count > 0)
|
||||
{
|
||||
var sans = GetSubjectAlternativeNames(leafCertificate);
|
||||
if (!sans.Any(san => _options.Security.SignerIdentity.AllowedSans.Contains(san, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
issues.Add("certificate_san_untrusted");
|
||||
}
|
||||
}
|
||||
|
||||
var signatureVerified = false;
|
||||
foreach (var signature in bundle.Dsse.Signatures)
|
||||
{
|
||||
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
issues.Add("signature_invalid_base64");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryVerifyWithCertificate(leafCertificate, preAuthEncoding, signatureBytes))
|
||||
{
|
||||
signatureVerified = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!signatureVerified)
|
||||
{
|
||||
issues.Add("signature_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryVerifyWithCertificate(X509Certificate2 certificate, byte[] preAuthEncoding, byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ecdsa = certificate.GetECDsaPublicKey();
|
||||
if (ecdsa is not null)
|
||||
{
|
||||
using (ecdsa)
|
||||
{
|
||||
return ecdsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256);
|
||||
}
|
||||
}
|
||||
|
||||
var rsa = certificate.GetRSAPublicKey();
|
||||
if (rsa is not null)
|
||||
{
|
||||
using (rsa)
|
||||
{
|
||||
return rsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetSubjectAlternativeNames(X509Certificate2 certificate)
|
||||
{
|
||||
foreach (var extension in certificate.Extensions)
|
||||
{
|
||||
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var formatted = extension.Format(true);
|
||||
var lines = formatted.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var parts = line.Split('=');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
yield return parts[1].Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputePreAuthEncoding(string payloadType, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
|
||||
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
|
||||
offset += 6;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
|
||||
offset += headerBytes.Length;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private void VerifyMerkleProof(AttestorEntry entry, IList<string> issues)
|
||||
{
|
||||
if (entry.Proof is null)
|
||||
{
|
||||
issues.Add("proof_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(entry.BundleSha256, out var bundleHash))
|
||||
{
|
||||
issues.Add("bundle_hash_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Proof.Inclusion is null)
|
||||
{
|
||||
issues.Add("proof_inclusion_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Proof.Inclusion.LeafHash is not null)
|
||||
{
|
||||
if (!TryDecodeHash(entry.Proof.Inclusion.LeafHash, out var proofLeaf))
|
||||
{
|
||||
issues.Add("proof_leafhash_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(bundleHash, proofLeaf))
|
||||
{
|
||||
issues.Add("proof_leafhash_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
var current = bundleHash;
|
||||
|
||||
if (entry.Proof.Inclusion.Path.Count > 0)
|
||||
{
|
||||
var nodes = new List<ProofPathNode>();
|
||||
foreach (var element in entry.Proof.Inclusion.Path)
|
||||
{
|
||||
if (!ProofPathNode.TryParse(element, out var node))
|
||||
{
|
||||
issues.Add("proof_path_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node.HasOrientation)
|
||||
{
|
||||
issues.Add("proof_path_orientation_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
current = node.Left
|
||||
? HashInternal(node.Hash, current)
|
||||
: HashInternal(current, node.Hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.Proof.Checkpoint is null)
|
||||
{
|
||||
issues.Add("checkpoint_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(entry.Proof.Checkpoint.RootHash, out var rootHash))
|
||||
{
|
||||
issues.Add("checkpoint_root_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(current, rootHash))
|
||||
{
|
||||
issues.Add("proof_root_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] HashInternal(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var buffer = new byte[1 + left.Length + right.Length];
|
||||
buffer[0] = 0x01;
|
||||
Buffer.BlockCopy(left, 0, buffer, 1, left.Length);
|
||||
Buffer.BlockCopy(right, 0, buffer, 1 + left.Length, right.Length);
|
||||
return sha.ComputeHash(buffer);
|
||||
}
|
||||
|
||||
private static bool TryDecodeSecret(string value, out byte[] bytes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
value = value.Trim();
|
||||
|
||||
if (value.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryDecodeBase64(value[7..], out bytes);
|
||||
}
|
||||
|
||||
if (value.StartsWith("hex:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryDecodeHex(value[4..], out bytes);
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(value, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryDecodeHex(value, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeHex(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromHexString(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeHash(string? value, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
|
||||
if (TryDecodeHex(trimmed, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(trimmed, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private readonly struct ProofPathNode
|
||||
{
|
||||
private ProofPathNode(bool hasOrientation, bool left, byte[] hash)
|
||||
{
|
||||
HasOrientation = hasOrientation;
|
||||
Left = left;
|
||||
Hash = hash;
|
||||
}
|
||||
|
||||
public bool HasOrientation { get; }
|
||||
|
||||
public bool Left { get; }
|
||||
|
||||
public byte[] Hash { get; }
|
||||
|
||||
public static bool TryParse(string value, out ProofPathNode node)
|
||||
{
|
||||
node = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var parts = trimmed.Split(':', 2);
|
||||
bool hasOrientation = false;
|
||||
bool left = false;
|
||||
string hashPart = trimmed;
|
||||
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
var prefix = parts[0].Trim().ToLowerInvariant();
|
||||
if (prefix is "l" or "left")
|
||||
{
|
||||
hasOrientation = true;
|
||||
left = true;
|
||||
}
|
||||
else if (prefix is "r" or "right")
|
||||
{
|
||||
hasOrientation = true;
|
||||
left = false;
|
||||
}
|
||||
|
||||
hashPart = parts[1].Trim();
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(hashPart, out var hash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
node = new ProofPathNode(hasOrientation, left, hash);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static AttestorEntry CloneWithProof(AttestorEntry entry, AttestorEntry.ProofDescriptor? proof)
|
||||
{
|
||||
return new AttestorEntry
|
||||
|
||||
@@ -80,6 +80,207 @@ public sealed class AttestorSubmissionServiceTests
|
||||
Assert.Equal(first.Uuid, stored!.RekorUuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_ThrowsWhenModeNotAllowed()
|
||||
{
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer, new[] { "kms" });
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Bundle.Mode = "keyless";
|
||||
|
||||
await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitAsync_Throws_WhenMirrorDisabledButRequested()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.primary.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "mirror";
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<AttestorValidationException>(() => service.SubmitAsync(request, context));
|
||||
Assert.Equal("mirror_disabled", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitAsync_ReturnsMirrorMetadata_WhenPreferenceBoth()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.primary.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
},
|
||||
Mirror = new AttestorOptions.RekorMirrorOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Url = "https://rekor.mirror.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "both";
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var result = await service.SubmitAsync(request, context);
|
||||
|
||||
Assert.NotNull(result.Mirror);
|
||||
Assert.False(string.IsNullOrEmpty(result.Mirror!.Uuid));
|
||||
Assert.Equal("included", result.Mirror.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitAsync_UsesMirrorAsCanonical_WhenPreferenceMirror()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.primary.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
},
|
||||
Mirror = new AttestorOptions.RekorMirrorOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Url = "https://rekor.mirror.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "mirror";
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var result = await service.SubmitAsync(request, context);
|
||||
|
||||
Assert.NotNull(result.Uuid);
|
||||
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("mirror", stored!.Log.Backend);
|
||||
Assert.Null(result.Mirror);
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer)
|
||||
{
|
||||
var request = new AttestorSubmissionRequest
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
@@ -17,6 +19,9 @@ namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorVerificationServiceTests
|
||||
{
|
||||
private static readonly byte[] HmacSecret = Encoding.UTF8.GetBytes("attestor-hmac-secret");
|
||||
private static readonly string HmacSecretBase64 = Convert.ToBase64String(HmacSecret);
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsOk_ForExistingUuid()
|
||||
{
|
||||
@@ -35,6 +40,14 @@ public sealed class AttestorVerificationServiceTests
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -57,7 +70,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer);
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
@@ -73,11 +86,13 @@ public sealed class AttestorVerificationServiceTests
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>());
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics);
|
||||
|
||||
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid
|
||||
Uuid = response.Uuid,
|
||||
Bundle = submission.Bundle
|
||||
});
|
||||
|
||||
Assert.True(verifyResult.Ok);
|
||||
@@ -100,6 +115,14 @@ public sealed class AttestorVerificationServiceTests
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -122,7 +145,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer);
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
@@ -138,9 +161,10 @@ public sealed class AttestorVerificationServiceTests
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>());
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics);
|
||||
|
||||
var tamperedBundle = submission.Bundle;
|
||||
var tamperedBundle = CloneBundle(submission.Bundle);
|
||||
tamperedBundle.Dsse.PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"tampered\":true}"));
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
@@ -150,29 +174,21 @@ public sealed class AttestorVerificationServiceTests
|
||||
});
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains(result.Issues, issue => issue.Contains("Bundle hash", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(result.Issues, issue => issue.Contains("signature_invalid", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer)
|
||||
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer, byte[] hmacSecret)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{}");
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "keyless",
|
||||
Mode = "kms",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = Convert.ToBase64String(payload),
|
||||
Signatures =
|
||||
{
|
||||
new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = "test",
|
||||
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
}
|
||||
}
|
||||
PayloadBase64 = Convert.ToBase64String(payload)
|
||||
}
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
@@ -187,8 +203,65 @@ public sealed class AttestorVerificationServiceTests
|
||||
}
|
||||
};
|
||||
|
||||
var preAuth = ComputePreAuthEncodingForTests(request.Bundle.Dsse.PayloadType, payload);
|
||||
using (var hmac = new HMACSHA256(hmacSecret))
|
||||
{
|
||||
var signature = hmac.ComputeHash(preAuth);
|
||||
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = "kms-test",
|
||||
Signature = Convert.ToBase64String(signature)
|
||||
});
|
||||
}
|
||||
|
||||
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
|
||||
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return request;
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest.SubmissionBundle CloneBundle(AttestorSubmissionRequest.SubmissionBundle source)
|
||||
{
|
||||
var clone = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = source.Mode,
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = source.Dsse.PayloadType,
|
||||
PayloadBase64 = source.Dsse.PayloadBase64
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var certificate in source.CertificateChain)
|
||||
{
|
||||
clone.CertificateChain.Add(certificate);
|
||||
}
|
||||
|
||||
foreach (var signature in source.Dsse.Signatures)
|
||||
{
|
||||
clone.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = signature.KeyId,
|
||||
Signature = signature.Signature
|
||||
});
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static byte[] ComputePreAuthEncodingForTests(string payloadType, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
|
||||
offset += 6;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
|
||||
offset += headerBytes.Length;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading.RateLimiting;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
@@ -13,6 +18,7 @@ using OpenTelemetry.Metrics;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||
using Serilog.Context;
|
||||
|
||||
const string ConfigurationSection = "attestor";
|
||||
|
||||
@@ -36,9 +42,45 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
|
||||
var attestorOptions = builder.Configuration.BindOptions<AttestorOptions>(ConfigurationSection);
|
||||
|
||||
var clientCertificateAuthorities = LoadClientCertificateAuthorities(attestorOptions.Security.Mtls.CaBundle);
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton(attestorOptions);
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.OnRejected = static (context, _) =>
|
||||
{
|
||||
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
|
||||
return ValueTask.CompletedTask;
|
||||
};
|
||||
|
||||
options.AddPolicy("attestor-submissions", httpContext =>
|
||||
{
|
||||
var identity = httpContext.Connection.ClientCertificate?.Thumbprint
|
||||
?? httpContext.User.FindFirst("sub")?.Value
|
||||
?? httpContext.User.FindFirst("client_id")?.Value
|
||||
?? httpContext.Connection.RemoteIpAddress?.ToString()
|
||||
?? "anonymous";
|
||||
|
||||
var quota = attestorOptions.Quotas.PerCaller;
|
||||
var tokensPerPeriod = Math.Max(1, quota.Qps);
|
||||
var tokenLimit = Math.Max(tokensPerPeriod, quota.Burst);
|
||||
var queueLimit = Math.Max(quota.Burst, tokensPerPeriod);
|
||||
|
||||
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
|
||||
{
|
||||
TokenLimit = tokenLimit,
|
||||
TokensPerPeriod = tokensPerPeriod,
|
||||
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
|
||||
QueueLimit = queueLimit,
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
AutoReplenishment = true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<AttestorOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ConfigurationSection))
|
||||
.ValidateOnStart();
|
||||
@@ -105,6 +147,61 @@ builder.WebHost.ConfigureKestrel(kestrel =>
|
||||
{
|
||||
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
|
||||
}
|
||||
|
||||
https.SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
|
||||
|
||||
https.ClientCertificateValidation = (certificate, _, _) =>
|
||||
{
|
||||
if (!attestorOptions.Security.Mtls.RequireClientCertificate)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (certificate is null)
|
||||
{
|
||||
Log.Warning("Client certificate missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (clientCertificateAuthorities.Count > 0)
|
||||
{
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
TrustMode = X509ChainTrustMode.CustomRootTrust
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var authority in clientCertificateAuthorities)
|
||||
{
|
||||
chain.ChainPolicy.CustomTrustStore.Add(authority);
|
||||
}
|
||||
|
||||
if (!chain.Build(certificate))
|
||||
{
|
||||
Log.Warning("Client certificate chain validation failed for {Subject}", certificate.Subject);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (attestorOptions.Security.Mtls.AllowedThumbprints.Count > 0 &&
|
||||
!attestorOptions.Security.Mtls.AllowedThumbprints.Contains(certificate.Thumbprint ?? string.Empty, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warning("Client certificate thumbprint {Thumbprint} rejected", certificate.Thumbprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attestorOptions.Security.Mtls.AllowedSubjects.Count > 0 &&
|
||||
!attestorOptions.Security.Mtls.AllowedSubjects.Contains(certificate.Subject, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warning("Client certificate subject {Subject} rejected", certificate.Subject);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +209,22 @@ var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
correlationId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
context.Response.Headers["X-Correlation-Id"] = correlationId;
|
||||
|
||||
using (LogContext.PushProperty("CorrelationId", correlationId))
|
||||
{
|
||||
await next().ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
|
||||
app.UseExceptionHandler(static handler =>
|
||||
{
|
||||
handler.Run(async context =>
|
||||
@@ -121,6 +234,8 @@ app.UseExceptionHandler(static handler =>
|
||||
});
|
||||
});
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -156,7 +271,8 @@ app.MapPost("/api/v1/rekor/entries", async (AttestorSubmissionRequest request, H
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization("attestor:write");
|
||||
.RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-submissions");
|
||||
|
||||
app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
@@ -170,6 +286,7 @@ app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IA
|
||||
{
|
||||
uuid = entry.RekorUuid,
|
||||
index = entry.Index,
|
||||
backend = entry.Log.Backend,
|
||||
proof = entry.Proof is null ? null : new
|
||||
{
|
||||
checkpoint = entry.Proof.Checkpoint is null ? null : new
|
||||
@@ -187,6 +304,30 @@ app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IA
|
||||
},
|
||||
logURL = entry.Log.Url,
|
||||
status = entry.Status,
|
||||
mirror = entry.Mirror is null ? null : new
|
||||
{
|
||||
backend = entry.Mirror.Backend,
|
||||
uuid = entry.Mirror.Uuid,
|
||||
index = entry.Mirror.Index,
|
||||
logURL = entry.Mirror.Url,
|
||||
status = entry.Mirror.Status,
|
||||
proof = entry.Mirror.Proof is null ? null : new
|
||||
{
|
||||
checkpoint = entry.Mirror.Proof.Checkpoint is null ? null : new
|
||||
{
|
||||
origin = entry.Mirror.Proof.Checkpoint.Origin,
|
||||
size = entry.Mirror.Proof.Checkpoint.Size,
|
||||
rootHash = entry.Mirror.Proof.Checkpoint.RootHash,
|
||||
timestamp = entry.Mirror.Proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
inclusion = entry.Mirror.Proof.Inclusion is null ? null : new
|
||||
{
|
||||
leafHash = entry.Mirror.Proof.Inclusion.LeafHash,
|
||||
path = entry.Mirror.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
error = entry.Mirror.Error
|
||||
},
|
||||
artifact = new
|
||||
{
|
||||
sha256 = entry.Artifact.Sha256,
|
||||
@@ -232,3 +373,33 @@ static SubmissionContext BuildSubmissionContext(ClaimsPrincipal user, X509Certif
|
||||
MtlsThumbprint = certificate.Thumbprint
|
||||
};
|
||||
}
|
||||
|
||||
static List<X509Certificate2> LoadClientCertificateAuthorities(string? path)
|
||||
{
|
||||
var certificates = new List<X509Certificate2>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return certificates;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Warning("Client CA bundle '{Path}' not found", path);
|
||||
return certificates;
|
||||
}
|
||||
|
||||
var collection = new X509Certificate2Collection();
|
||||
collection.ImportFromPemFile(path);
|
||||
|
||||
certificates.AddRange(collection.Cast<X509Certificate2>());
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or CryptographicException)
|
||||
{
|
||||
Log.Warning(ex, "Failed to load client CA bundle from {Path}", path);
|
||||
}
|
||||
|
||||
return certificates;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,5 @@
|
||||
| ATTESTOR-VERIFY-11-202 | DONE (2025-10-19) | Attestor Guild | — | `/rekor/verify` + retrieval endpoints validating signatures and Merkle proofs. | ✅ `GET /api/v1/rekor/entries/{uuid}` surfaces cached entries with optional backend refresh and handles not-found/refresh flows.<br>✅ `POST /api/v1/rekor/verify` accepts UUID, bundle, or artifact hash inputs; verifies DSSE signatures, Merkle proofs, and checkpoint anchors.<br>✅ Verification output returns `{ok, uuid, index, logURL, checkedAt}` with failure diagnostics for invalid proofs.<br>✅ Unit/integration tests exercise cache hits, backend refresh, invalid bundle/proof scenarios, and checkpoint trust anchor enforcement. |
|
||||
| ATTESTOR-OBS-11-203 | DONE (2025-10-19) | Attestor Guild | — | Telemetry, alerting, mTLS hardening, and archive workflow for Attestor. | ✅ Structured logs, metrics, and optional traces record submission latency, proof fetch outcomes, verification results, and Rekor error buckets with correlation IDs.<br>✅ mTLS enforcement hardened (peer allowlist, SAN checks, rate limiting) and documented; TLS settings audited for modern ciphers only.<br>✅ Alerting/dashboard pack covers error rates, proof backlog, Redis/Mongo health, and archive job failures; runbook updated.<br>✅ Archive workflow includes retention policy jobs, failure alerts, and periodic verification of stored bundles and proofs. |
|
||||
|
||||
> Remark (2025-10-19): Wave 0 prerequisites reviewed (none outstanding); Attestor Guild tasks moved to DOING for execution.
|
||||
> Remark (2025-10-19): `/rekor/entries` submission service implemented with Mongo/Redis persistence, optional S3 archival, Rekor HTTP client, and OpenTelemetry metrics; verification APIs (`/rekor/entries/{uuid}`, `/rekor/verify`) added with proof refresh and canonical hash checks. Remaining: integrate real Rekor endpoints in staging and expand failure-mode tests.
|
||||
> Remark (2025-10-19): Added Rekor mock client + integration harness to unblock attestor verification testing without external connectivity. Follow-up tasks to wire staging Rekor and record retry/error behavior still pending.
|
||||
> Remark (2025-10-19): Wave 0 prerequisites reviewed (none outstanding); ATTESTOR-API-11-201, ATTESTOR-VERIFY-11-202, and ATTESTOR-OBS-11-203 tracked as DOING per Wave 0A kickoff.
|
||||
> Remark (2025-10-19): Dual-log submissions, signature/proof verification, and observability hardening landed; attestor endpoints now rate-limited per client with correlation-ID logging and updated docs/tests.
|
||||
|
||||
50
src/StellaOps.Auth.Security/Dpop/DpopNonceConsumeResult.cs
Normal file
50
src/StellaOps.Auth.Security/Dpop/DpopNonceConsumeResult.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the outcome of attempting to consume a DPoP nonce.
|
||||
/// </summary>
|
||||
public sealed class DpopNonceConsumeResult
|
||||
{
|
||||
private DpopNonceConsumeResult(DpopNonceConsumeStatus status, DateTimeOffset? issuedAt, DateTimeOffset? expiresAt)
|
||||
{
|
||||
Status = status;
|
||||
IssuedAt = issuedAt;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consumption status.
|
||||
/// </summary>
|
||||
public DpopNonceConsumeStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp the nonce was originally issued (when available).
|
||||
/// </summary>
|
||||
public DateTimeOffset? IssuedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiry timestamp for the nonce (when available).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; }
|
||||
|
||||
public static DpopNonceConsumeResult Success(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
|
||||
=> new(DpopNonceConsumeStatus.Success, issuedAt, expiresAt);
|
||||
|
||||
public static DpopNonceConsumeResult Expired(DateTimeOffset? issuedAt, DateTimeOffset expiresAt)
|
||||
=> new(DpopNonceConsumeStatus.Expired, issuedAt, expiresAt);
|
||||
|
||||
public static DpopNonceConsumeResult NotFound()
|
||||
=> new(DpopNonceConsumeStatus.NotFound, null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known statuses for nonce consumption attempts.
|
||||
/// </summary>
|
||||
public enum DpopNonceConsumeStatus
|
||||
{
|
||||
Success,
|
||||
Expired,
|
||||
NotFound
|
||||
}
|
||||
56
src/StellaOps.Auth.Security/Dpop/DpopNonceIssueResult.cs
Normal file
56
src/StellaOps.Auth.Security/Dpop/DpopNonceIssueResult.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of issuing a DPoP nonce.
|
||||
/// </summary>
|
||||
public sealed class DpopNonceIssueResult
|
||||
{
|
||||
private DpopNonceIssueResult(DpopNonceIssueStatus status, string? nonce, DateTimeOffset? expiresAt, string? error)
|
||||
{
|
||||
Status = status;
|
||||
Nonce = nonce;
|
||||
ExpiresAt = expiresAt;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue status.
|
||||
/// </summary>
|
||||
public DpopNonceIssueStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Issued nonce when <see cref="Status"/> is <see cref="DpopNonceIssueStatus.Success"/>.
|
||||
/// </summary>
|
||||
public string? Nonce { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiry timestamp for the issued nonce (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional failure information, where applicable.
|
||||
/// </summary>
|
||||
public string? Error { get; }
|
||||
|
||||
public static DpopNonceIssueResult Success(string nonce, DateTimeOffset expiresAt)
|
||||
=> new(DpopNonceIssueStatus.Success, nonce, expiresAt, null);
|
||||
|
||||
public static DpopNonceIssueResult RateLimited(string? error = null)
|
||||
=> new(DpopNonceIssueStatus.RateLimited, null, null, error);
|
||||
|
||||
public static DpopNonceIssueResult Failure(string? error = null)
|
||||
=> new(DpopNonceIssueStatus.Failure, null, null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known statuses for nonce issuance.
|
||||
/// </summary>
|
||||
public enum DpopNonceIssueStatus
|
||||
{
|
||||
Success,
|
||||
RateLimited,
|
||||
Failure
|
||||
}
|
||||
66
src/StellaOps.Auth.Security/Dpop/DpopNonceUtilities.cs
Normal file
66
src/StellaOps.Auth.Security/Dpop/DpopNonceUtilities.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
internal static class DpopNonceUtilities
|
||||
{
|
||||
private static readonly char[] Base64Padding = { '=' };
|
||||
|
||||
internal static string GenerateNonce()
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
|
||||
return Convert.ToBase64String(buffer)
|
||||
.TrimEnd(Base64Padding)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
internal static byte[] ComputeNonceHash(string nonce)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
|
||||
var bytes = Encoding.UTF8.GetBytes(nonce);
|
||||
return SHA256.HashData(bytes);
|
||||
}
|
||||
|
||||
internal static string EncodeHash(ReadOnlySpan<byte> hash)
|
||||
=> Convert.ToHexString(hash);
|
||||
|
||||
internal static string ComputeStorageKey(string audience, string clientId, string keyThumbprint)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
return string.Create(
|
||||
"dpop-nonce:".Length + audience.Length + clientId.Length + keyThumbprint.Length + 2,
|
||||
(audience.Trim(), clientId.Trim(), keyThumbprint.Trim()),
|
||||
static (span, parts) =>
|
||||
{
|
||||
var index = 0;
|
||||
const string Prefix = "dpop-nonce:";
|
||||
Prefix.CopyTo(span);
|
||||
index += Prefix.Length;
|
||||
|
||||
index = Append(span, index, parts.Item1);
|
||||
span[index++] = ':';
|
||||
index = Append(span, index, parts.Item2);
|
||||
span[index++] = ':';
|
||||
_ = Append(span, index, parts.Item3);
|
||||
});
|
||||
|
||||
static int Append(Span<char> span, int index, string value)
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Value must not be empty after trimming.");
|
||||
}
|
||||
|
||||
value.AsSpan().CopyTo(span[index..]);
|
||||
return index + value.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/StellaOps.Auth.Security/Dpop/IDpopNonceStore.cs
Normal file
45
src/StellaOps.Auth.Security/Dpop/IDpopNonceStore.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Provides persistence and validation for DPoP nonces.
|
||||
/// </summary>
|
||||
public interface IDpopNonceStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Issues a nonce tied to the specified audience, client, and DPoP key thumbprint.
|
||||
/// </summary>
|
||||
/// <param name="audience">Audience the nonce applies to.</param>
|
||||
/// <param name="clientId">Client identifier requesting the nonce.</param>
|
||||
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
|
||||
/// <param name="ttl">Time-to-live for the nonce.</param>
|
||||
/// <param name="maxIssuancePerMinute">Maximum number of nonces that can be issued within a one-minute window for the tuple.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Outcome describing the issued nonce.</returns>
|
||||
ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to consume a nonce previously issued for the tuple.
|
||||
/// </summary>
|
||||
/// <param name="nonce">Nonce supplied by the client.</param>
|
||||
/// <param name="audience">Audience the nonce should match.</param>
|
||||
/// <param name="clientId">Client identifier.</param>
|
||||
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Outcome describing whether the nonce was accepted.</returns>
|
||||
ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
176
src/StellaOps.Auth.Security/Dpop/InMemoryDpopNonceStore.cs
Normal file
176
src/StellaOps.Auth.Security/Dpop/InMemoryDpopNonceStore.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IDpopNonceStore"/> suitable for single-host or test environments.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDpopNonceStore : IDpopNonceStore
|
||||
{
|
||||
private static readonly TimeSpan IssuanceWindow = TimeSpan.FromMinutes(1);
|
||||
private readonly ConcurrentDictionary<string, StoredNonce> nonces = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, IssuanceBucket> issuanceBuckets = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<InMemoryDpopNonceStore>? logger;
|
||||
|
||||
public InMemoryDpopNonceStore(TimeProvider? timeProvider = null, ILogger<InMemoryDpopNonceStore>? logger = null)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
|
||||
}
|
||||
|
||||
if (maxIssuancePerMinute < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
|
||||
var bucket = issuanceBuckets.GetOrAdd(bucketKey, static _ => new IssuanceBucket());
|
||||
|
||||
bool allowed;
|
||||
lock (bucket.SyncRoot)
|
||||
{
|
||||
bucket.Prune(now - IssuanceWindow);
|
||||
|
||||
if (bucket.IssuanceTimes.Count >= maxIssuancePerMinute)
|
||||
{
|
||||
allowed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
bucket.IssuanceTimes.Enqueue(now);
|
||||
allowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
logger?.LogDebug("DPoP nonce issuance throttled for {BucketKey}.", bucketKey);
|
||||
return ValueTask.FromResult(DpopNonceIssueResult.RateLimited("rate_limited"));
|
||||
}
|
||||
|
||||
var nonce = GenerateNonce();
|
||||
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
|
||||
var expiresAt = now + ttl;
|
||||
nonces[nonceKey] = new StoredNonce(now, expiresAt);
|
||||
return ValueTask.FromResult(DpopNonceIssueResult.Success(nonce, expiresAt));
|
||||
}
|
||||
|
||||
public ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
|
||||
|
||||
if (!nonces.TryRemove(nonceKey, out var stored))
|
||||
{
|
||||
logger?.LogDebug("DPoP nonce {NonceKey} not found during consumption.", nonceKey);
|
||||
return ValueTask.FromResult(DpopNonceConsumeResult.NotFound());
|
||||
}
|
||||
|
||||
if (stored.ExpiresAt <= now)
|
||||
{
|
||||
logger?.LogDebug("DPoP nonce {NonceKey} expired at {ExpiresAt:o}.", nonceKey, stored.ExpiresAt);
|
||||
return ValueTask.FromResult(DpopNonceConsumeResult.Expired(stored.IssuedAt, stored.ExpiresAt));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(DpopNonceConsumeResult.Success(stored.IssuedAt, stored.ExpiresAt));
|
||||
}
|
||||
|
||||
private static string BuildBucketKey(string audience, string clientId, string keyThumbprint)
|
||||
=> $"{audience.Trim().ToLowerInvariant()}::{clientId.Trim().ToLowerInvariant()}::{keyThumbprint.Trim().ToLowerInvariant()}";
|
||||
|
||||
private static string BuildNonceKey(string audience, string clientId, string keyThumbprint, string nonce)
|
||||
{
|
||||
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
|
||||
var digest = ComputeSha256(nonce);
|
||||
return $"{bucketKey}::{digest}";
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Base64UrlEncode(hash);
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static string GenerateNonce()
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
return Base64UrlEncode(buffer);
|
||||
}
|
||||
|
||||
private sealed class StoredNonce
|
||||
{
|
||||
internal StoredNonce(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
|
||||
{
|
||||
IssuedAt = issuedAt;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
internal DateTimeOffset IssuedAt { get; }
|
||||
|
||||
internal DateTimeOffset ExpiresAt { get; }
|
||||
}
|
||||
|
||||
private sealed class IssuanceBucket
|
||||
{
|
||||
internal object SyncRoot { get; } = new();
|
||||
internal Queue<DateTimeOffset> IssuanceTimes { get; } = new();
|
||||
|
||||
internal void Prune(DateTimeOffset threshold)
|
||||
{
|
||||
while (IssuanceTimes.Count > 0 && IssuanceTimes.Peek() < threshold)
|
||||
{
|
||||
IssuanceTimes.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/StellaOps.Auth.Security/Dpop/RedisDpopNonceStore.cs
Normal file
138
src/StellaOps.Auth.Security/Dpop/RedisDpopNonceStore.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Redis-backed implementation of <see cref="IDpopNonceStore"/> that supports multi-node deployments.
|
||||
/// </summary>
|
||||
public sealed class RedisDpopNonceStore : IDpopNonceStore
|
||||
{
|
||||
private const string ConsumeScript = @"
|
||||
local value = redis.call('GET', KEYS[1])
|
||||
if value ~= false and value == ARGV[1] then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 1
|
||||
end
|
||||
return 0";
|
||||
|
||||
private readonly IConnectionMultiplexer connection;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public RedisDpopNonceStore(IConnectionMultiplexer connection, TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
|
||||
}
|
||||
|
||||
if (maxIssuancePerMinute < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var database = connection.GetDatabase();
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
|
||||
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
|
||||
var nonceKey = (RedisKey)baseKey;
|
||||
var metadataKey = (RedisKey)(baseKey + ":meta");
|
||||
var rateKey = (RedisKey)(baseKey + ":rate");
|
||||
|
||||
var rateCount = await database.StringIncrementAsync(rateKey, flags: CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
if (rateCount == 1)
|
||||
{
|
||||
await database.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(1), CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (rateCount > maxIssuancePerMinute)
|
||||
{
|
||||
return DpopNonceIssueResult.RateLimited("rate_limited");
|
||||
}
|
||||
|
||||
var nonce = DpopNonceUtilities.GenerateNonce();
|
||||
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
|
||||
var expiresAt = issuedAt + ttl;
|
||||
|
||||
await database.StringSetAsync(nonceKey, hash, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
var metadataValue = FormattableString.Invariant($"{issuedAt.UtcTicks}|{ttl.Ticks}");
|
||||
await database.StringSetAsync(metadataKey, metadataValue, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
|
||||
return DpopNonceIssueResult.Success(nonce, expiresAt);
|
||||
}
|
||||
|
||||
public async ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var database = connection.GetDatabase();
|
||||
|
||||
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
|
||||
var nonceKey = (RedisKey)baseKey;
|
||||
var metadataKey = (RedisKey)(baseKey + ":meta");
|
||||
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
|
||||
|
||||
var rawResult = await database.ScriptEvaluateAsync(
|
||||
ConsumeScript,
|
||||
new[] { nonceKey },
|
||||
new RedisValue[] { hash }).ConfigureAwait(false);
|
||||
|
||||
if (rawResult.IsNull || (long)rawResult != 1)
|
||||
{
|
||||
return DpopNonceConsumeResult.NotFound();
|
||||
}
|
||||
|
||||
var metadata = await database.StringGetAsync(metadataKey).ConfigureAwait(false);
|
||||
await database.KeyDeleteAsync(metadataKey, CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
|
||||
if (!metadata.IsNull)
|
||||
{
|
||||
var parts = metadata.ToString()
|
||||
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (parts.Length == 2 &&
|
||||
long.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var issuedTicks) &&
|
||||
long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ttlTicks))
|
||||
{
|
||||
var issuedAt = new DateTimeOffset(issuedTicks, TimeSpan.Zero);
|
||||
var expiresAt = issuedAt + TimeSpan.FromTicks(ttlTicks);
|
||||
return expiresAt <= timeProvider.GetUtcNow()
|
||||
? DpopNonceConsumeResult.Expired(issuedAt, expiresAt)
|
||||
: DpopNonceConsumeResult.Success(issuedAt, expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
return DpopNonceConsumeResult.Success(timeProvider.GetUtcNow(), timeProvider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.2.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
@@ -43,6 +45,74 @@ public class StandardClientProvisioningStoreTests
|
||||
Assert.Contains("scopeA", descriptor.AllowedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_StoresAudiences()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "signer",
|
||||
confidential: false,
|
||||
displayName: "Signer",
|
||||
clientSecret: null,
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "signer.sign" },
|
||||
allowedAudiences: new[] { "attestor", "signer" });
|
||||
|
||||
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
var document = Assert.Contains("signer", store.Documents);
|
||||
Assert.Equal("attestor signer", document.Value.Properties[AuthorityClientMetadataKeys.Audiences]);
|
||||
|
||||
var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None);
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.Audiences.OrderBy(value => value, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_MapsCertificateBindings()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
var bindingRegistration = new AuthorityClientCertificateBindingRegistration(
|
||||
thumbprint: "aa:bb:cc:dd",
|
||||
serialNumber: "01ff",
|
||||
subject: "CN=mtls-client",
|
||||
issuer: "CN=test-ca",
|
||||
subjectAlternativeNames: new[] { "client.mtls.test", "spiffe://client" },
|
||||
notBefore: DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
notAfter: DateTimeOffset.UtcNow.AddHours(1),
|
||||
label: "primary");
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "mtls-client",
|
||||
confidential: true,
|
||||
displayName: "MTLS Client",
|
||||
clientSecret: "secret",
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "signer.sign" },
|
||||
allowedAudiences: new[] { "signer" },
|
||||
certificateBindings: new[] { bindingRegistration });
|
||||
|
||||
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
var document = Assert.Contains("mtls-client", store.Documents).Value;
|
||||
var binding = Assert.Single(document.CertificateBindings);
|
||||
Assert.Equal("AABBCCDD", binding.Thumbprint);
|
||||
Assert.Equal("01ff", binding.SerialNumber);
|
||||
Assert.Equal("CN=mtls-client", binding.Subject);
|
||||
Assert.Equal("CN=test-ca", binding.Issuer);
|
||||
Assert.Equal(new[] { "client.mtls.test", "spiffe://client" }, binding.SubjectAlternativeNames);
|
||||
Assert.Equal(bindingRegistration.NotBefore, binding.NotBefore);
|
||||
Assert.Equal(bindingRegistration.NotAfter, binding.NotAfter);
|
||||
Assert.Equal("primary", binding.Label);
|
||||
}
|
||||
|
||||
private sealed class TrackingClientStore : IAuthorityClientStore
|
||||
{
|
||||
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -50,11 +50,21 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = string.Join(" ", registration.AllowedGrantTypes);
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = string.Join(" ", registration.AllowedScopes);
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
|
||||
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
|
||||
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
|
||||
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
|
||||
|
||||
if (registration.CertificateBindings is not null)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
document.CertificateBindings = registration.CertificateBindings
|
||||
.Select(binding => MapCertificateBinding(binding, now))
|
||||
.OrderBy(binding => binding.Thumbprint, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
foreach (var (key, value) in registration.Properties)
|
||||
{
|
||||
document.Properties[key] = value;
|
||||
@@ -142,12 +152,15 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
.Cast<Uri>()
|
||||
.ToArray();
|
||||
|
||||
var audiences = Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
|
||||
|
||||
return new AuthorityClientDescriptor(
|
||||
document.ClientId,
|
||||
document.DisplayName,
|
||||
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
|
||||
allowedGrantTypes,
|
||||
allowedScopes,
|
||||
audiences,
|
||||
redirectUris,
|
||||
postLogoutUris,
|
||||
document.Properties);
|
||||
@@ -163,6 +176,47 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static string JoinValues(IReadOnlyCollection<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join(
|
||||
" ",
|
||||
values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.OrderBy(static value => value, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private static AuthorityClientCertificateBinding MapCertificateBinding(
|
||||
AuthorityClientCertificateBindingRegistration registration,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
|
||||
? new List<string>()
|
||||
: registration.SubjectAlternativeNames
|
||||
.Select(name => name.Trim())
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new AuthorityClientCertificateBinding
|
||||
{
|
||||
Thumbprint = registration.Thumbprint,
|
||||
SerialNumber = registration.SerialNumber,
|
||||
Subject = registration.Subject,
|
||||
Issuer = registration.Issuer,
|
||||
SubjectAlternativeNames = subjectAlternativeNames,
|
||||
NotBefore = registration.NotBefore,
|
||||
NotAfter = registration.NotAfter,
|
||||
Label = registration.Label,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeSenderConstraint(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1–PLG5 | Final polish + diagrams for plugin developer guide (AUTHPLUG-DOCS-01-001). | Docs team delivers copy-edit + exported diagrams; PR merged. |
|
||||
| SEC1.PLG | DONE (2025-10-11) | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
|
||||
| SEC1.OPT | DONE (2025-10-11) | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
|
||||
| SEC2.PLG | DOING (2025-10-14) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
|
||||
| SEC3.PLG | DOING (2025-10-14) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
|
||||
| SEC2.PLG | DOING (2025-10-14) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. <br>⏳ Awaiting AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 completion to unlock Wave 0B verification paths. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
|
||||
| SEC3.PLG | DOING (2025-10-14) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). <br>⏳ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 so limiter telemetry contract matches final authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
|
||||
| SEC4.PLG | DONE (2025-10-12) | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
|
||||
| SEC5.PLG | DOING (2025-10-14) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
|
||||
| SEC5.PLG | DOING (2025-10-14) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. <br>⏳ Final documentation depends on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 outcomes. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
|
||||
| PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. <br>⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. |
|
||||
| PLG7.RFC | REVIEW | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
|
||||
| PLG6.DIAGRAM | TODO | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |
|
||||
@@ -16,3 +16,5 @@
|
||||
> Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE.
|
||||
|
||||
> Remark (2025-10-13, PLG6.DOC/PLG6.DIAGRAM): Security Guild delivered `docs/security/rate-limits.md`; Docs team can lift Section 3 (tuning table + alerts) into the developer guide diagrams when rendering assets.
|
||||
|
||||
> Check-in (2025-10-19): Wave 0A dependencies (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open, so SEC2/SEC3/SEC5 remain in progress without new scope until upstream limiter updates land.
|
||||
|
||||
@@ -7,6 +7,7 @@ public static class AuthorityClientMetadataKeys
|
||||
{
|
||||
public const string AllowedGrantTypes = "allowedGrantTypes";
|
||||
public const string AllowedScopes = "allowedScopes";
|
||||
public const string Audiences = "audiences";
|
||||
public const string RedirectUris = "redirectUris";
|
||||
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
|
||||
public const string SenderConstraint = "senderConstraint";
|
||||
|
||||
@@ -632,15 +632,13 @@ public sealed class AuthorityClaimsEnrichmentContext
|
||||
/// </summary>
|
||||
public sealed record AuthorityClientDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialises a new client descriptor.
|
||||
/// </summary>
|
||||
public AuthorityClientDescriptor(
|
||||
string clientId,
|
||||
string? displayName,
|
||||
bool confidential,
|
||||
IReadOnlyCollection<string>? allowedGrantTypes = null,
|
||||
IReadOnlyCollection<string>? allowedScopes = null,
|
||||
IReadOnlyCollection<string>? allowedAudiences = null,
|
||||
IReadOnlyCollection<Uri>? redirectUris = null,
|
||||
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
|
||||
IReadOnlyDictionary<string, string?>? properties = null)
|
||||
@@ -648,8 +646,9 @@ public sealed record AuthorityClientDescriptor
|
||||
ClientId = ValidateRequired(clientId, nameof(clientId));
|
||||
DisplayName = displayName;
|
||||
Confidential = confidential;
|
||||
AllowedGrantTypes = allowedGrantTypes is null ? Array.Empty<string>() : allowedGrantTypes.ToArray();
|
||||
AllowedScopes = allowedScopes is null ? Array.Empty<string>() : allowedScopes.ToArray();
|
||||
AllowedGrantTypes = Normalize(allowedGrantTypes);
|
||||
AllowedScopes = Normalize(allowedScopes);
|
||||
AllowedAudiences = Normalize(allowedAudiences);
|
||||
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
|
||||
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
|
||||
Properties = properties is null
|
||||
@@ -657,60 +656,87 @@ public sealed record AuthorityClientDescriptor
|
||||
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique client identifier.
|
||||
/// </summary>
|
||||
public string ClientId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional display name.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the client is confidential (requires secret).
|
||||
/// </summary>
|
||||
public bool Confidential { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Permitted OAuth grant types.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Permitted scopes.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> AllowedScopes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Registered redirect URIs.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> AllowedAudiences { get; }
|
||||
public IReadOnlyCollection<Uri> RedirectUris { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Registered post-logout redirect URIs.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional plugin-defined metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string?> Properties { get; }
|
||||
|
||||
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
|
||||
=> values is null || values.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
private static string ValidateRequired(string value, string paramName)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
|
||||
: value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client registration payload used when provisioning clients through plugins.
|
||||
/// </summary>
|
||||
public sealed record AuthorityClientCertificateBindingRegistration
|
||||
{
|
||||
public AuthorityClientCertificateBindingRegistration(
|
||||
string thumbprint,
|
||||
string? serialNumber = null,
|
||||
string? subject = null,
|
||||
string? issuer = null,
|
||||
IReadOnlyCollection<string>? subjectAlternativeNames = null,
|
||||
DateTimeOffset? notBefore = null,
|
||||
DateTimeOffset? notAfter = null,
|
||||
string? label = null)
|
||||
{
|
||||
Thumbprint = NormalizeThumbprint(thumbprint);
|
||||
SerialNumber = Normalize(serialNumber);
|
||||
Subject = Normalize(subject);
|
||||
Issuer = Normalize(issuer);
|
||||
SubjectAlternativeNames = subjectAlternativeNames is null || subjectAlternativeNames.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: subjectAlternativeNames
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
NotBefore = notBefore;
|
||||
NotAfter = notAfter;
|
||||
Label = Normalize(label);
|
||||
}
|
||||
|
||||
public string Thumbprint { get; }
|
||||
public string? SerialNumber { get; }
|
||||
public string? Subject { get; }
|
||||
public string? Issuer { get; }
|
||||
public IReadOnlyCollection<string> SubjectAlternativeNames { get; }
|
||||
public DateTimeOffset? NotBefore { get; }
|
||||
public DateTimeOffset? NotAfter { get; }
|
||||
public string? Label { get; }
|
||||
|
||||
private static string NormalizeThumbprint(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Thumbprint is required.", nameof(value));
|
||||
}
|
||||
|
||||
return value
|
||||
.Replace(":", string.Empty, StringComparison.Ordinal)
|
||||
.Replace(" ", string.Empty, StringComparison.Ordinal)
|
||||
.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
public sealed record AuthorityClientRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialises a new registration.
|
||||
/// </summary>
|
||||
public AuthorityClientRegistration(
|
||||
string clientId,
|
||||
bool confidential,
|
||||
@@ -718,9 +744,11 @@ public sealed record AuthorityClientRegistration
|
||||
string? clientSecret,
|
||||
IReadOnlyCollection<string>? allowedGrantTypes = null,
|
||||
IReadOnlyCollection<string>? allowedScopes = null,
|
||||
IReadOnlyCollection<string>? allowedAudiences = null,
|
||||
IReadOnlyCollection<Uri>? redirectUris = null,
|
||||
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
|
||||
IReadOnlyDictionary<string, string?>? properties = null)
|
||||
IReadOnlyDictionary<string, string?>? properties = null,
|
||||
IReadOnlyCollection<AuthorityClientCertificateBindingRegistration>? certificateBindings = null)
|
||||
{
|
||||
ClientId = ValidateRequired(clientId, nameof(clientId));
|
||||
Confidential = confidential;
|
||||
@@ -728,65 +756,42 @@ public sealed record AuthorityClientRegistration
|
||||
ClientSecret = confidential
|
||||
? ValidateRequired(clientSecret ?? string.Empty, nameof(clientSecret))
|
||||
: clientSecret;
|
||||
AllowedGrantTypes = allowedGrantTypes is null ? Array.Empty<string>() : allowedGrantTypes.ToArray();
|
||||
AllowedScopes = allowedScopes is null ? Array.Empty<string>() : allowedScopes.ToArray();
|
||||
AllowedGrantTypes = Normalize(allowedGrantTypes);
|
||||
AllowedScopes = Normalize(allowedScopes);
|
||||
AllowedAudiences = Normalize(allowedAudiences);
|
||||
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
|
||||
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
|
||||
Properties = properties is null
|
||||
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
|
||||
CertificateBindings = certificateBindings is null
|
||||
? Array.Empty<AuthorityClientCertificateBindingRegistration>()
|
||||
: certificateBindings.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique client identifier.
|
||||
/// </summary>
|
||||
public string ClientId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the client is confidential (requires secret handling).
|
||||
/// </summary>
|
||||
public bool Confidential { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional display name.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional raw client secret (hashed by the plugin for storage).
|
||||
/// </summary>
|
||||
public string? ClientSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Grant types to enable.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Scopes assigned to the client.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> AllowedScopes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Redirect URIs permitted for the client.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> AllowedAudiences { get; }
|
||||
public IReadOnlyCollection<Uri> RedirectUris { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Post-logout redirect URIs.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for the plugin.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string?> Properties { get; }
|
||||
public IReadOnlyCollection<AuthorityClientCertificateBindingRegistration> CertificateBindings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of the registration with the provided client secret.
|
||||
/// </summary>
|
||||
public AuthorityClientRegistration WithClientSecret(string? clientSecret)
|
||||
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, RedirectUris, PostLogoutRedirectUris, Properties);
|
||||
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, AllowedAudiences, RedirectUris, PostLogoutRedirectUris, Properties, CertificateBindings);
|
||||
|
||||
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
|
||||
=> values is null || values.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
private static string ValidateRequired(string value, string paramName)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
|
||||
@@ -62,6 +62,18 @@ public sealed class AuthorityTokenDocument
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedReasonDescription { get; set; }
|
||||
|
||||
[BsonElement("senderConstraint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderConstraint { get; set; }
|
||||
|
||||
[BsonElement("senderKeyThumbprint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderKeyThumbprint { get; set; }
|
||||
|
||||
[BsonElement("senderNonce")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderNonce { get; set; }
|
||||
|
||||
|
||||
[BsonElement("devices")]
|
||||
[BsonIgnoreIfNull]
|
||||
|
||||
@@ -27,7 +27,13 @@ internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollection
|
||||
Builders<AuthorityTokenDocument>.IndexKeys
|
||||
.Ascending(t => t.Status)
|
||||
.Ascending(t => t.RevokedAt),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" })
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SenderConstraint),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_constraint", Sparse = true }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SenderKeyThumbprint),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_thumbprint", Sparse = true })
|
||||
};
|
||||
|
||||
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
using OpenIddict.Server;
|
||||
@@ -44,6 +56,8 @@ public class ClientCredentialsHandlersTests
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write");
|
||||
@@ -72,6 +86,8 @@ public class ClientCredentialsHandlersTests
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
@@ -104,6 +120,8 @@ public class ClientCredentialsHandlersTests
|
||||
sink,
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
@@ -117,6 +135,315 @@ public class ClientCredentialsHandlersTests
|
||||
string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDpopProof_AllowsSenderConstrainedClient()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
options.Security.SenderConstraints.Dpop.Enabled = true;
|
||||
options.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
|
||||
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:read");
|
||||
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
|
||||
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop;
|
||||
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var securityKey = new ECDsaSecurityKey(ecdsa)
|
||||
{
|
||||
KeyId = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
|
||||
var expectedThumbprint = ConvertThumbprintToString(jwk.ComputeJwkThumbprint());
|
||||
|
||||
var clientStore = new TestClientStore(clientDocument);
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var rateMetadata = new TestRateLimiterMetadataAccessor();
|
||||
|
||||
var dpopValidator = new DpopProofValidator(
|
||||
Options.Create(new DpopValidationOptions()),
|
||||
new InMemoryDpopReplayCache(TimeProvider.System),
|
||||
TimeProvider.System,
|
||||
NullLogger<DpopProofValidator>.Instance);
|
||||
|
||||
var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger<InMemoryDpopNonceStore>.Instance);
|
||||
|
||||
var dpopHandler = new ValidateDpopProofHandler(
|
||||
options,
|
||||
clientStore,
|
||||
dpopValidator,
|
||||
nonceStore,
|
||||
rateMetadata,
|
||||
auditSink,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<ValidateDpopProofHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
transaction.Options = new OpenIddictServerOptions();
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Method = "POST";
|
||||
httpContext.Request.Scheme = "https";
|
||||
httpContext.Request.Host = new HostString("authority.test");
|
||||
httpContext.Request.Path = "/token";
|
||||
|
||||
var now = TimeProvider.System.GetUtcNow();
|
||||
var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds());
|
||||
httpContext.Request.Headers["DPoP"] = proof;
|
||||
|
||||
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
|
||||
|
||||
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await dpopHandler.HandleAsync(validateContext);
|
||||
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
clientStore,
|
||||
registry,
|
||||
TestActivitySource,
|
||||
auditSink,
|
||||
rateMetadata,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
await validateHandler.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var tokenStore = new TestTokenStore();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handleHandler = new HandleClientCredentialsHandler(
|
||||
registry,
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
|
||||
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
|
||||
await handleHandler.HandleAsync(handleContext);
|
||||
Assert.True(handleContext.IsRequestHandled);
|
||||
|
||||
var persistHandler = new PersistTokensHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
|
||||
{
|
||||
Principal = handleContext.Principal,
|
||||
AccessTokenPrincipal = handleContext.Principal
|
||||
};
|
||||
|
||||
await persistHandler.HandleAsync(signInContext);
|
||||
|
||||
var confirmationClaim = handleContext.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
|
||||
Assert.False(string.IsNullOrWhiteSpace(confirmationClaim));
|
||||
|
||||
using (var confirmationJson = JsonDocument.Parse(confirmationClaim!))
|
||||
{
|
||||
Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString());
|
||||
}
|
||||
|
||||
Assert.NotNull(tokenStore.Inserted);
|
||||
Assert.Equal(AuthoritySenderConstraintKinds.Dpop, tokenStore.Inserted!.SenderConstraint);
|
||||
Assert.Equal(expectedThumbprint, tokenStore.Inserted!.SenderKeyThumbprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDpopProof_IssuesNonceChallenge_WhenNonceMissing()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
options.Security.SenderConstraints.Dpop.Enabled = true;
|
||||
options.Security.SenderConstraints.Dpop.Nonce.Enabled = true;
|
||||
options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Clear();
|
||||
options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Add("signer");
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.Storage.ConnectionString = "mongodb://localhost/test";
|
||||
Assert.Contains("signer", options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences);
|
||||
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:read",
|
||||
allowedAudiences: "signer");
|
||||
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
|
||||
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop;
|
||||
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var securityKey = new ECDsaSecurityKey(ecdsa)
|
||||
{
|
||||
KeyId = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
var clientStore = new TestClientStore(clientDocument);
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var rateMetadata = new TestRateLimiterMetadataAccessor();
|
||||
|
||||
var dpopValidator = new DpopProofValidator(
|
||||
Options.Create(new DpopValidationOptions()),
|
||||
new InMemoryDpopReplayCache(TimeProvider.System),
|
||||
TimeProvider.System,
|
||||
NullLogger<DpopProofValidator>.Instance);
|
||||
|
||||
var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger<InMemoryDpopNonceStore>.Instance);
|
||||
|
||||
var dpopHandler = new ValidateDpopProofHandler(
|
||||
options,
|
||||
clientStore,
|
||||
dpopValidator,
|
||||
nonceStore,
|
||||
rateMetadata,
|
||||
auditSink,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<ValidateDpopProofHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
transaction.Options = new OpenIddictServerOptions();
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Method = "POST";
|
||||
httpContext.Request.Scheme = "https";
|
||||
httpContext.Request.Host = new HostString("authority.test");
|
||||
httpContext.Request.Path = "/token";
|
||||
|
||||
var now = TimeProvider.System.GetUtcNow();
|
||||
var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds());
|
||||
httpContext.Request.Headers["DPoP"] = proof;
|
||||
|
||||
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
|
||||
|
||||
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await dpopHandler.HandleAsync(validateContext);
|
||||
|
||||
Assert.True(validateContext.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, validateContext.Error);
|
||||
var authenticateHeader = Assert.Single(httpContext.Response.Headers.Select(header => header)
|
||||
.Where(header => string.Equals(header.Key, "WWW-Authenticate", StringComparison.OrdinalIgnoreCase))).Value;
|
||||
Assert.Contains("use_dpop_nonce", authenticateHeader.ToString());
|
||||
Assert.True(httpContext.Response.Headers.TryGetValue("DPoP-Nonce", out var nonceValues));
|
||||
Assert.False(StringValues.IsNullOrEmpty(nonceValues));
|
||||
Assert.Contains(auditSink.Events, record => record.EventType == "authority.dpop.proof.challenge");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsMtlsClient_WithValidCertificate()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
options.Security.SenderConstraints.Mtls.Enabled = true;
|
||||
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.Storage.ConnectionString = "mongodb://localhost/test";
|
||||
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:read");
|
||||
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
|
||||
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls;
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var certificateRequest = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
using var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
var hexThumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256));
|
||||
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
|
||||
{
|
||||
Thumbprint = hexThumbprint
|
||||
});
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
|
||||
httpContextAccessor.HttpContext!.Connection.ClientCertificate = certificate;
|
||||
|
||||
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
|
||||
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
auditSink,
|
||||
metadataAccessor,
|
||||
TimeProvider.System,
|
||||
validator,
|
||||
httpContextAccessor,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected);
|
||||
Assert.Equal(AuthoritySenderConstraintKinds.Mtls, context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty]);
|
||||
|
||||
var expectedBase64 = Base64UrlEncoder.Encode(certificate.GetCertHash(HashAlgorithmName.SHA256));
|
||||
Assert.Equal(expectedBase64, context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsMtlsClient_WhenCertificateMissing()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
options.Security.SenderConstraints.Mtls.Enabled = true;
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.Storage.ConnectionString = "mongodb://localhost/test";
|
||||
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:read");
|
||||
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
|
||||
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls;
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
|
||||
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
|
||||
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
validator,
|
||||
httpContextAccessor,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleClientCredentials_PersistsTokenAndEnrichesClaims()
|
||||
{
|
||||
@@ -124,7 +451,8 @@ public class ClientCredentialsHandlersTests
|
||||
secret: null,
|
||||
clientType: "public",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:trigger");
|
||||
allowedScopes: "jobs:trigger",
|
||||
allowedAudiences: "signer");
|
||||
|
||||
var descriptor = CreateDescriptor(clientDocument);
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor);
|
||||
@@ -139,6 +467,8 @@ public class ClientCredentialsHandlersTests
|
||||
authSink,
|
||||
metadataAccessor,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger");
|
||||
@@ -163,6 +493,7 @@ public class ClientCredentialsHandlersTests
|
||||
|
||||
Assert.True(context.IsRequestHandled);
|
||||
Assert.NotNull(context.Principal);
|
||||
Assert.Contains("signer", context.Principal!.GetAudiences());
|
||||
|
||||
Assert.Contains(authSink.Events, record => record.EventType == "authority.client_credentials.grant" && record.Outcome == AuthEventOutcome.Success);
|
||||
|
||||
@@ -285,6 +616,62 @@ public class TokenValidationHandlersTests
|
||||
Assert.Contains(principal.Claims, claim => claim.Type == "enriched" && claim.Value == "true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenHandler_AddsConfirmationClaim_ForMtlsToken()
|
||||
{
|
||||
var tokenDocument = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = "token-mtls",
|
||||
Status = "valid",
|
||||
ClientId = "mtls-client",
|
||||
SenderConstraint = AuthoritySenderConstraintKinds.Mtls,
|
||||
SenderKeyThumbprint = "thumb-print"
|
||||
};
|
||||
|
||||
var tokenStore = new TestTokenStore
|
||||
{
|
||||
Inserted = tokenDocument
|
||||
};
|
||||
|
||||
var clientDocument = CreateClient();
|
||||
var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null);
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
metadataAccessor,
|
||||
auditSink,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<ValidateAccessTokenHandler>.Instance);
|
||||
|
||||
var transaction = new OpenIddictServerTransaction
|
||||
{
|
||||
Options = new OpenIddictServerOptions(),
|
||||
EndpointType = OpenIddictServerEndpointType.Introspection,
|
||||
Request = new OpenIddictRequest()
|
||||
};
|
||||
|
||||
var principal = CreatePrincipal(clientDocument.ClientId, tokenDocument.TokenId, clientDocument.Plugin);
|
||||
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
|
||||
{
|
||||
Principal = principal,
|
||||
TokenId = tokenDocument.TokenId
|
||||
};
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected);
|
||||
var confirmation = context.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
|
||||
Assert.False(string.IsNullOrWhiteSpace(confirmation));
|
||||
using var json = JsonDocument.Parse(confirmation!);
|
||||
Assert.Equal(tokenDocument.SenderKeyThumbprint, json.RootElement.GetProperty("x5t#S256").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenHandler_EmitsReplayAudit_WhenStoreDetectsSuspectedReplay()
|
||||
{
|
||||
@@ -358,6 +745,89 @@ public class TokenValidationHandlersTests
|
||||
}
|
||||
}
|
||||
|
||||
public class AuthorityClientCertificateValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ValidateAsync_Rejects_WhenSanTypeNotAllowed()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
options.Security.SenderConstraints.Mtls.Enabled = true;
|
||||
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
|
||||
options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear();
|
||||
options.Security.SenderConstraints.Mtls.AllowedSanTypes.Add("uri");
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.Storage.ConnectionString = "mongodb://localhost/test";
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var sanBuilder = new SubjectAlternativeNameBuilder();
|
||||
sanBuilder.AddDnsName("client.mtls.test");
|
||||
request.CertificateExtensions.Add(sanBuilder.Build());
|
||||
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
|
||||
var clientDocument = CreateClient();
|
||||
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
|
||||
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
|
||||
{
|
||||
Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256))
|
||||
});
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Connection.ClientCertificate = certificate;
|
||||
|
||||
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
|
||||
var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal("certificate_san_type", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_AllowsBindingWithinRotationGrace()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
options.Security.SenderConstraints.Mtls.Enabled = true;
|
||||
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
|
||||
options.Security.SenderConstraints.Mtls.RotationGrace = TimeSpan.FromMinutes(5);
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.Storage.ConnectionString = "mongodb://localhost/test";
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var sanBuilder = new SubjectAlternativeNameBuilder();
|
||||
sanBuilder.AddDnsName("client.mtls.test");
|
||||
request.CertificateExtensions.Add(sanBuilder.Build());
|
||||
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(10));
|
||||
|
||||
var thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256));
|
||||
|
||||
var clientDocument = CreateClient();
|
||||
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
|
||||
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
|
||||
{
|
||||
Thumbprint = thumbprint,
|
||||
NotBefore = TimeProvider.System.GetUtcNow().AddMinutes(2)
|
||||
});
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Connection.ClientCertificate = certificate;
|
||||
|
||||
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
|
||||
var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(thumbprint, result.HexThumbprint);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -526,6 +996,19 @@ internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMet
|
||||
public void SetTag(string name, string? value) => metadata.SetTag(name, value);
|
||||
}
|
||||
|
||||
internal sealed class NoopCertificateValidator : IAuthorityClientCertificateValidator
|
||||
{
|
||||
public ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken)
|
||||
{
|
||||
var binding = new AuthorityClientCertificateBinding
|
||||
{
|
||||
Thumbprint = "stub"
|
||||
};
|
||||
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", "stub", binding));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
|
||||
{
|
||||
public ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
|
||||
@@ -540,9 +1023,10 @@ internal static class TestHelpers
|
||||
string? secret = "s3cr3t!",
|
||||
string clientType = "confidential",
|
||||
string allowedGrantTypes = "client_credentials",
|
||||
string allowedScopes = "jobs:read")
|
||||
string allowedScopes = "jobs:read",
|
||||
string allowedAudiences = "")
|
||||
{
|
||||
return new AuthorityClientDocument
|
||||
var document = new AuthorityClientDocument
|
||||
{
|
||||
ClientId = "concelier",
|
||||
ClientType = clientType,
|
||||
@@ -554,12 +1038,20 @@ internal static class TestHelpers
|
||||
[AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes
|
||||
}
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(allowedAudiences))
|
||||
{
|
||||
document.Properties[AuthorityClientMetadataKeys.Audiences] = allowedAudiences;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static AuthorityClientDescriptor CreateDescriptor(AuthorityClientDocument document)
|
||||
{
|
||||
var allowedGrantTypes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedGrantTypes, out var grants) ? grants?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>();
|
||||
var allowedScopes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedScopes, out var scopes) ? scopes?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>();
|
||||
var allowedAudiences = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Audiences, out var audiences) ? audiences?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>();
|
||||
|
||||
return new AuthorityClientDescriptor(
|
||||
document.ClientId,
|
||||
@@ -567,6 +1059,7 @@ internal static class TestHelpers
|
||||
confidential: string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
|
||||
allowedGrantTypes,
|
||||
allowedScopes,
|
||||
allowedAudiences,
|
||||
redirectUris: Array.Empty<Uri>(),
|
||||
postLogoutRedirectUris: Array.Empty<Uri>(),
|
||||
properties: document.Properties);
|
||||
@@ -638,6 +1131,57 @@ internal static class TestHelpers
|
||||
};
|
||||
}
|
||||
|
||||
public static string ConvertThumbprintToString(object thumbprint)
|
||||
=> thumbprint switch
|
||||
{
|
||||
string value => value,
|
||||
byte[] bytes => Base64UrlEncoder.Encode(bytes),
|
||||
_ => throw new InvalidOperationException("Unsupported thumbprint representation.")
|
||||
};
|
||||
|
||||
public static string CreateDpopProof(ECDsaSecurityKey key, string method, string url, long issuedAt, string? nonce = null)
|
||||
{
|
||||
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key);
|
||||
jwk.KeyId ??= key.KeyId ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
|
||||
var header = new JwtHeader(signingCredentials)
|
||||
{
|
||||
["typ"] = "dpop+jwt",
|
||||
["jwk"] = new Dictionary<string, object?>
|
||||
{
|
||||
["kty"] = jwk.Kty,
|
||||
["crv"] = jwk.Crv,
|
||||
["x"] = jwk.X,
|
||||
["y"] = jwk.Y,
|
||||
["kid"] = jwk.Kid ?? jwk.KeyId
|
||||
}
|
||||
};
|
||||
|
||||
var payload = new JwtPayload
|
||||
{
|
||||
["htm"] = method.ToUpperInvariant(),
|
||||
["htu"] = url,
|
||||
["iat"] = issuedAt,
|
||||
["jti"] = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(nonce))
|
||||
{
|
||||
payload["nonce"] = nonce;
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(header, payload);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public static X509Certificate2 CreateTestCertificate(string subjectName)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
|
||||
public static ClaimsPrincipal CreatePrincipal(string clientId, string tokenId, string provider, string? subject = null)
|
||||
{
|
||||
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
@@ -20,6 +21,7 @@ using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
@@ -62,7 +64,7 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, new NoopCertificateValidator(), new HttpContextAccessor(), NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Authority.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
|
||||
@@ -44,11 +44,15 @@ internal sealed record BootstrapClientRequest
|
||||
|
||||
public IReadOnlyCollection<string>? AllowedScopes { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? AllowedAudiences { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? RedirectUris { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? PostLogoutRedirectUris { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string?>? Properties { get; init; }
|
||||
|
||||
public IReadOnlyCollection<BootstrapClientCertificateBinding>? CertificateBindings { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BootstrapInviteRequest
|
||||
@@ -68,6 +72,25 @@ internal sealed record BootstrapInviteRequest
|
||||
public IReadOnlyDictionary<string, string?>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BootstrapClientCertificateBinding
|
||||
{
|
||||
public string Thumbprint { get; init; } = string.Empty;
|
||||
|
||||
public string? SerialNumber { get; init; }
|
||||
|
||||
public string? Subject { get; init; }
|
||||
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? SubjectAlternativeNames { get; init; }
|
||||
|
||||
public DateTimeOffset? NotBefore { get; init; }
|
||||
|
||||
public DateTimeOffset? NotAfter { get; init; }
|
||||
|
||||
public string? Label { get; init; }
|
||||
}
|
||||
|
||||
internal static class BootstrapInviteTypes
|
||||
{
|
||||
public const string User = "user";
|
||||
|
||||
@@ -15,4 +15,14 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes";
|
||||
internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes";
|
||||
internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope";
|
||||
internal const string ClientSenderConstraintProperty = "authority:client_sender_constraint";
|
||||
internal const string SenderConstraintProperty = "authority:sender_constraint";
|
||||
internal const string DpopKeyThumbprintProperty = "authority:dpop_thumbprint";
|
||||
internal const string DpopProofJwtIdProperty = "authority:dpop_jti";
|
||||
internal const string DpopIssuedAtProperty = "authority:dpop_iat";
|
||||
internal const string DpopConsumedNonceProperty = "authority:dpop_nonce";
|
||||
internal const string ConfirmationClaimType = "cnf";
|
||||
internal const string SenderConstraintClaimType = "authority_sender_constraint";
|
||||
internal const string MtlsCertificateThumbprintProperty = "authority:mtls_thumbprint";
|
||||
internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex";
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
@@ -18,6 +20,7 @@ using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
@@ -30,6 +33,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IAuthorityClientCertificateValidator certificateValidator;
|
||||
private readonly IHttpContextAccessor httpContextAccessor;
|
||||
private readonly ILogger<ValidateClientCredentialsHandler> logger;
|
||||
|
||||
public ValidateClientCredentialsHandler(
|
||||
@@ -39,6 +44,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
IAuthEventSink auditSink,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
TimeProvider timeProvider,
|
||||
IAuthorityClientCertificateValidator certificateValidator,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<ValidateClientCredentialsHandler> logger)
|
||||
{
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
@@ -47,6 +54,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator));
|
||||
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -111,7 +120,44 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditConfidentialProperty] = string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
|
||||
var existingSenderConstraint = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var senderConstraintValue) && senderConstraintValue is string existingConstraint
|
||||
? existingConstraint
|
||||
: null;
|
||||
|
||||
var normalizedSenderConstraint = !string.IsNullOrWhiteSpace(existingSenderConstraint)
|
||||
? existingSenderConstraint
|
||||
: ClientCredentialHandlerHelpers.NormalizeSenderConstraint(document);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedSenderConstraint))
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientSenderConstraintProperty] = normalizedSenderConstraint;
|
||||
}
|
||||
|
||||
if (string.Equals(normalizedSenderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal))
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext is null)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.ServerError, "HTTP context unavailable for mTLS validation.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: HTTP context unavailable for mTLS validation.", context.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
var validation = await certificateValidator.ValidateAsync(httpContext, document, context.CancellationToken).ConfigureAwait(false);
|
||||
if (!validation.Succeeded)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, validation.Error ?? "Client certificate validation failed.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: {Reason}.", context.ClientId, validation.Error ?? "certificate_invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = AuthoritySenderConstraintKinds.Mtls;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty] = validation.ConfirmationThumbprint;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateHexProperty] = validation.HexThumbprint;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditConfidentialProperty] =
|
||||
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
IIdentityProviderPlugin? provider = null;
|
||||
if (!string.IsNullOrWhiteSpace(document.Plugin))
|
||||
@@ -278,6 +324,32 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
return;
|
||||
}
|
||||
|
||||
var configuredAudiences = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
|
||||
if (configuredAudiences.Count > 0)
|
||||
{
|
||||
if (context.Request.Resources is ICollection<string> resources && configuredAudiences.Count > 0)
|
||||
{
|
||||
foreach (var audience in configuredAudiences)
|
||||
{
|
||||
if (!resources.Contains(audience))
|
||||
{
|
||||
resources.Add(audience);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (context.Request.Audiences is ICollection<string> audiencesCollection)
|
||||
{
|
||||
foreach (var audience in configuredAudiences)
|
||||
{
|
||||
if (!audiencesCollection.Contains(audience))
|
||||
{
|
||||
audiencesCollection.Add(audience);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, document.ClientId));
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, document.ClientId));
|
||||
@@ -322,6 +394,8 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
activity?.SetTag("authority.identity_provider", provider.Name);
|
||||
}
|
||||
|
||||
ApplySenderConstraintClaims(context, identity, document);
|
||||
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var grantedScopes = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientGrantedScopesProperty, out var scopesValue) &&
|
||||
@@ -338,6 +412,11 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
principal.SetScopes(Array.Empty<string>());
|
||||
}
|
||||
|
||||
if (configuredAudiences.Count > 0)
|
||||
{
|
||||
principal.SetAudiences(configuredAudiences);
|
||||
}
|
||||
|
||||
if (provider is not null && descriptor is not null)
|
||||
{
|
||||
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user: null, descriptor);
|
||||
@@ -420,10 +499,95 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var constraintObj) &&
|
||||
constraintObj is string senderConstraint &&
|
||||
!string.IsNullOrWhiteSpace(senderConstraint))
|
||||
{
|
||||
record.SenderConstraint = senderConstraint;
|
||||
}
|
||||
|
||||
string? senderThumbprint = null;
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopKeyThumbprintProperty, out var dpopThumbprintObj) &&
|
||||
dpopThumbprintObj is string dpopThumbprint &&
|
||||
!string.IsNullOrWhiteSpace(dpopThumbprint))
|
||||
{
|
||||
senderThumbprint = dpopThumbprint;
|
||||
}
|
||||
else if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty, out var mtlsThumbprintObj) &&
|
||||
mtlsThumbprintObj is string mtlsThumbprint &&
|
||||
!string.IsNullOrWhiteSpace(mtlsThumbprint))
|
||||
{
|
||||
senderThumbprint = mtlsThumbprint;
|
||||
}
|
||||
|
||||
if (senderThumbprint is not null)
|
||||
{
|
||||
record.SenderKeyThumbprint = senderThumbprint;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
|
||||
nonceObj is string nonce &&
|
||||
!string.IsNullOrWhiteSpace(nonce))
|
||||
{
|
||||
record.SenderNonce = nonce;
|
||||
}
|
||||
|
||||
await tokenStore.InsertAsync(record, context.CancellationToken, session).ConfigureAwait(false);
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = record;
|
||||
activity?.SetTag("authority.token_id", tokenId);
|
||||
}
|
||||
|
||||
private static void ApplySenderConstraintClaims(
|
||||
OpenIddictServerEvents.HandleTokenRequestContext context,
|
||||
ClaimsIdentity identity,
|
||||
AuthorityClientDocument document)
|
||||
{
|
||||
_ = document;
|
||||
|
||||
if (!context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var constraintObj) ||
|
||||
constraintObj is not string senderConstraint ||
|
||||
string.IsNullOrWhiteSpace(senderConstraint))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = senderConstraint.Trim().ToLowerInvariant();
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = normalized;
|
||||
identity.SetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType, normalized);
|
||||
|
||||
switch (normalized)
|
||||
{
|
||||
case AuthoritySenderConstraintKinds.Dpop:
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopKeyThumbprintProperty, out var thumbprintObj) &&
|
||||
thumbprintObj is string thumbprint &&
|
||||
!string.IsNullOrWhiteSpace(thumbprint))
|
||||
{
|
||||
var confirmation = JsonSerializer.Serialize(new Dictionary<string, string>
|
||||
{
|
||||
["jkt"] = thumbprint
|
||||
});
|
||||
|
||||
identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
|
||||
}
|
||||
|
||||
break;
|
||||
case AuthoritySenderConstraintKinds.Mtls:
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty, out var mtlsThumbprintObj) &&
|
||||
mtlsThumbprintObj is string mtlsThumbprint &&
|
||||
!string.IsNullOrWhiteSpace(mtlsThumbprint))
|
||||
{
|
||||
var confirmation = JsonSerializer.Serialize(new Dictionary<string, string>
|
||||
{
|
||||
["x5t#S256"] = mtlsThumbprint
|
||||
});
|
||||
|
||||
identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal static class ClientCredentialHandlerHelpers
|
||||
@@ -491,4 +655,20 @@ internal static class ClientCredentialHandlerHelpers
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string? NormalizeSenderConstraint(AuthorityClientDocument document)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(document.SenderConstraint))
|
||||
{
|
||||
return document.SenderConstraint.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var value) &&
|
||||
!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
|
||||
{
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IDpopProofValidator proofValidator;
|
||||
private readonly IDpopNonceStore nonceStore;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly ILogger<ValidateDpopProofHandler> logger;
|
||||
|
||||
public ValidateDpopProofHandler(
|
||||
StellaOpsAuthorityOptions authorityOptions,
|
||||
IAuthorityClientStore clientStore,
|
||||
IDpopProofValidator proofValidator,
|
||||
IDpopNonceStore nonceStore,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider clock,
|
||||
ActivitySource activitySource,
|
||||
ILogger<ValidateDpopProofHandler> logger)
|
||||
{
|
||||
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.proofValidator = proofValidator ?? throw new ArgumentNullException(nameof(proofValidator));
|
||||
this.nonceStore = nonceStore ?? throw new ArgumentNullException(nameof(nonceStore));
|
||||
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!context.Request.IsClientCredentialsGrantType())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var activity = activitySource.StartActivity("authority.token.validate_dpop", ActivityKind.Internal);
|
||||
activity?.SetTag("authority.endpoint", "/token");
|
||||
activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials);
|
||||
|
||||
var clientId = context.ClientId ?? context.Request.ClientId;
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId;
|
||||
|
||||
var senderConstraintOptions = authorityOptions.Security.SenderConstraints;
|
||||
AuthorityClientDocument? clientDocument = await ResolveClientAsync(context, clientId, activity, cancel: context.CancellationToken).ConfigureAwait(false);
|
||||
if (clientDocument is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var senderConstraint = NormalizeSenderConstraint(clientDocument);
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientSenderConstraintProperty] = senderConstraint;
|
||||
|
||||
if (!string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Dpop, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var configuredAudiences = EnsureRequestAudiences(context.Request, clientDocument);
|
||||
|
||||
if (!senderConstraintOptions.Dpop.Enabled)
|
||||
{
|
||||
logger.LogError("Client {ClientId} requires DPoP but server-side configuration has DPoP disabled.", clientId);
|
||||
context.Reject(OpenIddictConstants.Errors.ServerError, "DPoP authentication is not enabled.");
|
||||
await WriteAuditAsync(context, clientDocument, AuthEventOutcome.Failure, "DPoP disabled server-side.", null, null, null, "authority.dpop.proof.disabled").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
metadataAccessor.SetTag("authority.sender_constraint", AuthoritySenderConstraintKinds.Dpop);
|
||||
activity?.SetTag("authority.sender_constraint", AuthoritySenderConstraintKinds.Dpop);
|
||||
|
||||
HttpRequest? httpRequest = null;
|
||||
HttpResponse? httpResponse = null;
|
||||
if (context.Transaction.Properties.TryGetValue(typeof(HttpContext).FullName!, out var httpContextProperty) &&
|
||||
httpContextProperty is HttpContext capturedContext)
|
||||
{
|
||||
httpRequest = capturedContext.Request;
|
||||
httpResponse = capturedContext.Response;
|
||||
}
|
||||
if (httpRequest is null)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.ServerError, "Unable to access HTTP context for DPoP validation.");
|
||||
logger.LogError("DPoP validation aborted for {ClientId}: HTTP request not available via transaction.", clientId);
|
||||
await WriteAuditAsync(context, clientDocument, AuthEventOutcome.Failure, "HTTP request unavailable for DPoP.", null, null, null, "authority.dpop.proof.error").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!httpRequest.Headers.TryGetValue("DPoP", out StringValues proofHeader) || StringValues.IsNullOrEmpty(proofHeader))
|
||||
{
|
||||
logger.LogWarning("Missing DPoP header for client credentials request from {ClientId}.", clientId);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
audience: null,
|
||||
thumbprint: null,
|
||||
reasonCode: "missing_proof",
|
||||
description: "DPoP proof is required.",
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var proof = proofHeader.ToString();
|
||||
var requestUri = BuildRequestUri(httpRequest);
|
||||
|
||||
var validationResult = await proofValidator.ValidateAsync(
|
||||
proof,
|
||||
httpRequest.Method,
|
||||
requestUri,
|
||||
cancellationToken: context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
var error = string.IsNullOrWhiteSpace(validationResult.ErrorDescription)
|
||||
? "DPoP proof validation failed."
|
||||
: validationResult.ErrorDescription;
|
||||
|
||||
logger.LogWarning("DPoP proof validation failed for client {ClientId}: {Reason}.", clientId, error);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
audience: null,
|
||||
thumbprint: null,
|
||||
reasonCode: validationResult.ErrorCode ?? "invalid_proof",
|
||||
description: error,
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationResult.PublicKey is not Microsoft.IdentityModel.Tokens.JsonWebKey jwk)
|
||||
{
|
||||
logger.LogWarning("DPoP proof for {ClientId} did not expose a JSON Web Key.", clientId);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
audience: null,
|
||||
thumbprint: null,
|
||||
reasonCode: "invalid_key",
|
||||
description: "DPoP proof must embed a JSON Web Key.",
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
object rawThumbprint = jwk.ComputeJwkThumbprint();
|
||||
string thumbprint;
|
||||
if (rawThumbprint is string value && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
thumbprint = value;
|
||||
}
|
||||
else if (rawThumbprint is byte[] bytes)
|
||||
{
|
||||
thumbprint = Base64UrlEncoder.Encode(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("DPoP JWK thumbprint could not be computed.");
|
||||
}
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = AuthoritySenderConstraintKinds.Dpop;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.DpopKeyThumbprintProperty] = thumbprint;
|
||||
if (!string.IsNullOrWhiteSpace(validationResult.JwtId))
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.DpopProofJwtIdProperty] = validationResult.JwtId;
|
||||
}
|
||||
|
||||
if (validationResult.IssuedAt is { } issuedAt)
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.DpopIssuedAtProperty] = issuedAt;
|
||||
}
|
||||
|
||||
var nonceOptions = senderConstraintOptions.Dpop.Nonce;
|
||||
var requiredAudience = ResolveNonceAudience(context.Request, nonceOptions, configuredAudiences);
|
||||
|
||||
if (nonceOptions.Enabled && requiredAudience is not null)
|
||||
{
|
||||
activity?.SetTag("authority.dpop_nonce_audience", requiredAudience);
|
||||
var suppliedNonce = validationResult.Nonce;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(suppliedNonce))
|
||||
{
|
||||
logger.LogInformation("DPoP nonce challenge issued to {ClientId} for audience {Audience}: nonce missing.", clientId, requiredAudience);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
requiredAudience,
|
||||
thumbprint,
|
||||
"nonce_missing",
|
||||
"DPoP nonce is required for this audience.",
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var consumeResult = await nonceStore.TryConsumeAsync(
|
||||
suppliedNonce,
|
||||
requiredAudience,
|
||||
clientDocument.ClientId,
|
||||
thumbprint,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
switch (consumeResult.Status)
|
||||
{
|
||||
case DpopNonceConsumeStatus.Success:
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.DpopConsumedNonceProperty] = suppliedNonce;
|
||||
break;
|
||||
case DpopNonceConsumeStatus.Expired:
|
||||
logger.LogInformation("DPoP nonce expired for {ClientId} and audience {Audience}.", clientId, requiredAudience);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
requiredAudience,
|
||||
thumbprint,
|
||||
"nonce_expired",
|
||||
"DPoP nonce has expired. Retry with a fresh nonce.",
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
return;
|
||||
default:
|
||||
logger.LogInformation("DPoP nonce invalid for {ClientId} and audience {Audience}.", clientId, requiredAudience);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
requiredAudience,
|
||||
thumbprint,
|
||||
"nonce_invalid",
|
||||
"DPoP nonce is invalid. Request a new nonce and retry.",
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await WriteAuditAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
AuthEventOutcome.Success,
|
||||
"DPoP proof validated.",
|
||||
thumbprint,
|
||||
validationResult,
|
||||
requiredAudience,
|
||||
"authority.dpop.proof.valid")
|
||||
.ConfigureAwait(false);
|
||||
logger.LogInformation("DPoP proof validated for client {ClientId}.", clientId);
|
||||
}
|
||||
|
||||
private async ValueTask<AuthorityClientDocument?> ResolveClientAsync(
|
||||
OpenIddictServerEvents.ValidateTokenRequestContext context,
|
||||
string clientId,
|
||||
Activity? activity,
|
||||
CancellationToken cancel)
|
||||
{
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTransactionProperty, out var value) &&
|
||||
value is AuthorityClientDocument cached)
|
||||
{
|
||||
activity?.SetTag("authority.client_id", cached.ClientId);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var document = await clientStore.FindByClientIdAsync(clientId, cancel).ConfigureAwait(false);
|
||||
if (document is not null)
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = document;
|
||||
activity?.SetTag("authority.client_id", document.ClientId);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static string? NormalizeSenderConstraint(AuthorityClientDocument document)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(document.SenderConstraint))
|
||||
{
|
||||
return document.SenderConstraint.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var value) &&
|
||||
!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> EnsureRequestAudiences(OpenIddictRequest? request, AuthorityClientDocument document)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var configuredAudiences = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
|
||||
if (configuredAudiences.Count == 0)
|
||||
{
|
||||
return configuredAudiences;
|
||||
}
|
||||
|
||||
if (request.Resources is ICollection<string> resources)
|
||||
{
|
||||
foreach (var audience in configuredAudiences)
|
||||
{
|
||||
if (!resources.Contains(audience))
|
||||
{
|
||||
resources.Add(audience);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Audiences is ICollection<string> audiencesCollection)
|
||||
{
|
||||
foreach (var audience in configuredAudiences)
|
||||
{
|
||||
if (!audiencesCollection.Contains(audience))
|
||||
{
|
||||
audiencesCollection.Add(audience);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return configuredAudiences;
|
||||
}
|
||||
|
||||
private static Uri BuildRequestUri(HttpRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var url = request.GetDisplayUrl();
|
||||
return new Uri(url, UriKind.Absolute);
|
||||
}
|
||||
|
||||
private static string? ResolveNonceAudience(OpenIddictRequest request, AuthorityDpopNonceOptions nonceOptions, IReadOnlyList<string> configuredAudiences)
|
||||
{
|
||||
if (!nonceOptions.Enabled || request is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (request.Resources is not null)
|
||||
{
|
||||
foreach (var resource in request.Resources)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resource))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = resource.Trim();
|
||||
if (nonceOptions.RequiredAudiences.Contains(normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Audiences is not null)
|
||||
{
|
||||
foreach (var audience in request.Audiences)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = audience.Trim();
|
||||
if (nonceOptions.RequiredAudiences.Contains(normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (configuredAudiences is { Count: > 0 })
|
||||
{
|
||||
foreach (var audience in configuredAudiences)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = audience.Trim();
|
||||
if (nonceOptions.RequiredAudiences.Contains(normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async ValueTask ChallengeNonceAsync(
|
||||
OpenIddictServerEvents.ValidateTokenRequestContext context,
|
||||
AuthorityClientDocument clientDocument,
|
||||
string? audience,
|
||||
string? thumbprint,
|
||||
string reasonCode,
|
||||
string description,
|
||||
AuthoritySenderConstraintOptions senderConstraintOptions,
|
||||
HttpResponse? httpResponse)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, description);
|
||||
metadataAccessor.SetTag("authority.dpop_result", reasonCode);
|
||||
|
||||
string? issuedNonce = null;
|
||||
DateTimeOffset? expiresAt = null;
|
||||
if (audience is not null && thumbprint is not null && senderConstraintOptions.Dpop.Nonce.Enabled)
|
||||
{
|
||||
var issuance = await nonceStore.IssueAsync(
|
||||
audience,
|
||||
clientDocument.ClientId,
|
||||
thumbprint,
|
||||
senderConstraintOptions.Dpop.Nonce.Ttl,
|
||||
senderConstraintOptions.Dpop.Nonce.MaxIssuancePerMinute,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (issuance.Status == DpopNonceIssueStatus.Success)
|
||||
{
|
||||
issuedNonce = issuance.Nonce;
|
||||
expiresAt = issuance.ExpiresAt;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("Unable to issue DPoP nonce for {ClientId} (audience {Audience}): {Status}.", clientDocument.ClientId, audience, issuance.Status);
|
||||
}
|
||||
}
|
||||
|
||||
if (httpResponse is not null)
|
||||
{
|
||||
httpResponse.Headers["WWW-Authenticate"] = BuildAuthenticateHeader(reasonCode, description, issuedNonce);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(issuedNonce))
|
||||
{
|
||||
httpResponse.Headers["DPoP-Nonce"] = issuedNonce;
|
||||
}
|
||||
}
|
||||
|
||||
await WriteAuditAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
AuthEventOutcome.Failure,
|
||||
description,
|
||||
thumbprint,
|
||||
validationResult: null,
|
||||
audience,
|
||||
"authority.dpop.proof.challenge",
|
||||
reasonCode,
|
||||
issuedNonce,
|
||||
expiresAt)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string BuildAuthenticateHeader(string reasonCode, string description, string? nonce)
|
||||
{
|
||||
var parameters = new Dictionary<string, string?>
|
||||
{
|
||||
["error"] = string.Equals(reasonCode, "nonce_missing", StringComparison.OrdinalIgnoreCase)
|
||||
? "use_dpop_nonce"
|
||||
: "invalid_dpop_proof",
|
||||
["error_description"] = description
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(nonce))
|
||||
{
|
||||
parameters["dpop-nonce"] = nonce;
|
||||
}
|
||||
|
||||
var segments = new List<string>();
|
||||
foreach (var kvp in parameters)
|
||||
{
|
||||
if (kvp.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.Add($"{kvp.Key}=\"{EscapeHeaderValue(kvp.Value)}\"");
|
||||
}
|
||||
|
||||
return segments.Count > 0
|
||||
? $"DPoP {string.Join(", ", segments)}"
|
||||
: "DPoP";
|
||||
|
||||
static string EscapeHeaderValue(string value)
|
||||
=> value
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("\"", "\\\"", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private async ValueTask WriteAuditAsync(
|
||||
OpenIddictServerEvents.ValidateTokenRequestContext context,
|
||||
AuthorityClientDocument clientDocument,
|
||||
AuthEventOutcome outcome,
|
||||
string reason,
|
||||
string? thumbprint,
|
||||
DpopValidationResult? validationResult,
|
||||
string? audience,
|
||||
string eventType,
|
||||
string? reasonCode = null,
|
||||
string? issuedNonce = null,
|
||||
DateTimeOffset? nonceExpiresAt = null)
|
||||
{
|
||||
var metadata = metadataAccessor.GetMetadata();
|
||||
var properties = new List<AuthEventProperty>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "sender.constraint",
|
||||
Value = ClassifiedString.Public(AuthoritySenderConstraintKinds.Dpop)
|
||||
}
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reasonCode))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "dpop.reason_code",
|
||||
Value = ClassifiedString.Public(reasonCode)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(thumbprint))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "dpop.jkt",
|
||||
Value = ClassifiedString.Public(thumbprint)
|
||||
});
|
||||
}
|
||||
|
||||
if (validationResult?.JwtId is not null)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "dpop.jti",
|
||||
Value = ClassifiedString.Public(validationResult.JwtId)
|
||||
});
|
||||
}
|
||||
|
||||
if (validationResult?.IssuedAt is { } issuedAt)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "dpop.issued_at",
|
||||
Value = ClassifiedString.Public(issuedAt.ToString("O", CultureInfo.InvariantCulture))
|
||||
});
|
||||
}
|
||||
|
||||
if (audience is not null)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "dpop.audience",
|
||||
Value = ClassifiedString.Public(audience)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(validationResult?.Nonce))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "dpop.nonce.presented",
|
||||
Value = ClassifiedString.Sensitive(validationResult.Nonce)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(issuedNonce))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "dpop.nonce.issued",
|
||||
Value = ClassifiedString.Sensitive(issuedNonce)
|
||||
});
|
||||
}
|
||||
|
||||
if (nonceExpiresAt is { } expiresAt)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "dpop.nonce.expires_at",
|
||||
Value = ClassifiedString.Public(expiresAt.ToString("O", CultureInfo.InvariantCulture))
|
||||
});
|
||||
}
|
||||
|
||||
var confidential = string.Equals(clientDocument.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var record = ClientCredentialsAuditHelper.CreateRecord(
|
||||
clock,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
clientSecret: null,
|
||||
outcome,
|
||||
reason,
|
||||
clientDocument.ClientId,
|
||||
providerName: clientDocument.Plugin,
|
||||
confidential,
|
||||
requestedScopes: Array.Empty<string>(),
|
||||
grantedScopes: Array.Empty<string>(),
|
||||
invalidScope: null,
|
||||
extraProperties: properties,
|
||||
eventType: eventType);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -92,6 +93,33 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
|
||||
ExpiresAt = TryGetExpiration(principal)
|
||||
};
|
||||
|
||||
var senderConstraint = principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType);
|
||||
if (!string.IsNullOrWhiteSpace(senderConstraint))
|
||||
{
|
||||
document.SenderConstraint = senderConstraint;
|
||||
}
|
||||
|
||||
var confirmation = principal.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
|
||||
if (!string.IsNullOrWhiteSpace(confirmation))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(confirmation);
|
||||
if (json.RootElement.TryGetProperty("jkt", out var thumbprintElement))
|
||||
{
|
||||
document.SenderKeyThumbprint = thumbprintElement.GetString();
|
||||
}
|
||||
else if (json.RootElement.TryGetProperty("x5t#S256", out var certificateThumbprintElement))
|
||||
{
|
||||
document.SenderKeyThumbprint = certificateThumbprintElement.GetString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore malformed confirmation claims in persistence layer.
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await tokenStore.InsertAsync(document, cancellationToken, session).ConfigureAwait(false);
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
@@ -16,6 +17,7 @@ using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Authority.Security;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
@@ -106,6 +108,11 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenDocument is not null)
|
||||
{
|
||||
EnsureSenderConstraintClaims(context.Principal, tokenDocument);
|
||||
}
|
||||
|
||||
if (!context.IsRejected && tokenDocument is not null)
|
||||
{
|
||||
await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false);
|
||||
@@ -272,4 +279,46 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
|
||||
await auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void EnsureSenderConstraintClaims(ClaimsPrincipal? principal, AuthorityTokenDocument tokenDocument)
|
||||
{
|
||||
if (principal?.Identity is not ClaimsIdentity identity)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tokenDocument.SenderConstraint) &&
|
||||
!identity.HasClaim(claim => claim.Type == AuthorityOpenIddictConstants.SenderConstraintClaimType))
|
||||
{
|
||||
identity.SetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType, tokenDocument.SenderConstraint);
|
||||
}
|
||||
|
||||
if (identity.HasClaim(claim => claim.Type == AuthorityOpenIddictConstants.ConfirmationClaimType))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tokenDocument.SenderConstraint) || string.IsNullOrWhiteSpace(tokenDocument.SenderKeyThumbprint))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string confirmation = tokenDocument.SenderConstraint switch
|
||||
{
|
||||
AuthoritySenderConstraintKinds.Dpop => JsonSerializer.Serialize(new Dictionary<string, string>
|
||||
{
|
||||
["jkt"] = tokenDocument.SenderKeyThumbprint
|
||||
}),
|
||||
AuthoritySenderConstraintKinds.Mtls => JsonSerializer.Serialize(new Dictionary<string, string>
|
||||
{
|
||||
["x5t#S256"] = tokenDocument.SenderKeyThumbprint
|
||||
}),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(confirmation))
|
||||
{
|
||||
identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,10 @@ using StellaOps.Authority.Revocation;
|
||||
using StellaOps.Authority.Signing;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Security;
|
||||
#if STELLAOPS_AUTH_SECURITY
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StackExchange.Redis;
|
||||
#endif
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -98,6 +100,7 @@ builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
|
||||
builder.Services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, AuthorityRateLimiterMetadataAccessor>();
|
||||
builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();
|
||||
builder.Services.AddSingleton<IAuthorityClientCertificateValidator, AuthorityClientCertificateValidator>();
|
||||
|
||||
#if STELLAOPS_AUTH_SECURITY
|
||||
var senderConstraints = authorityOptions.Security.SenderConstraints;
|
||||
@@ -119,6 +122,29 @@ builder.Services.AddOptions<DpopValidationOptions>()
|
||||
|
||||
builder.Services.TryAddSingleton<IDpopReplayCache>(provider => new InMemoryDpopReplayCache(provider.GetService<TimeProvider>()));
|
||||
builder.Services.TryAddSingleton<IDpopProofValidator, DpopProofValidator>();
|
||||
if (string.Equals(senderConstraints.Dpop.Nonce.Store, "redis", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Services.TryAddSingleton<IConnectionMultiplexer>(_ =>
|
||||
ConnectionMultiplexer.Connect(senderConstraints.Dpop.Nonce.RedisConnectionString!));
|
||||
|
||||
builder.Services.TryAddSingleton<IDpopNonceStore>(provider =>
|
||||
{
|
||||
var multiplexer = provider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var timeProvider = provider.GetService<TimeProvider>();
|
||||
return new RedisDpopNonceStore(multiplexer, timeProvider);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.TryAddSingleton<IDpopNonceStore>(provider =>
|
||||
{
|
||||
var timeProvider = provider.GetService<TimeProvider>();
|
||||
var nonceLogger = provider.GetService<ILogger<InMemoryDpopNonceStore>>();
|
||||
return new InMemoryDpopNonceStore(timeProvider, nonceLogger);
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddScoped<ValidateDpopProofHandler>();
|
||||
#endif
|
||||
|
||||
builder.Services.AddRateLimiter(rateLimiterOptions =>
|
||||
@@ -219,6 +245,13 @@ builder.Services.AddOpenIddict()
|
||||
aspNetCoreBuilder.DisableTransportSecurityRequirement();
|
||||
}
|
||||
|
||||
#if STELLAOPS_AUTH_SECURITY
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateTokenRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidateDpopProofHandler>();
|
||||
});
|
||||
#endif
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateTokenRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidatePasswordGrantHandler>();
|
||||
@@ -723,6 +756,33 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string?>(request.Properties, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
IReadOnlyCollection<AuthorityClientCertificateBindingRegistration>? certificateBindings = null;
|
||||
if (request.CertificateBindings is not null)
|
||||
{
|
||||
var bindingRegistrations = new List<AuthorityClientCertificateBindingRegistration>(request.CertificateBindings.Count);
|
||||
foreach (var binding in request.CertificateBindings)
|
||||
{
|
||||
if (binding is null || string.IsNullOrWhiteSpace(binding.Thumbprint))
|
||||
{
|
||||
await ReleaseInviteAsync("Certificate binding thumbprint is required.");
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Certificate binding thumbprint is required.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Certificate binding thumbprint is required." });
|
||||
}
|
||||
|
||||
bindingRegistrations.Add(new AuthorityClientCertificateBindingRegistration(
|
||||
binding.Thumbprint,
|
||||
binding.SerialNumber,
|
||||
binding.Subject,
|
||||
binding.Issuer,
|
||||
binding.SubjectAlternativeNames,
|
||||
binding.NotBefore,
|
||||
binding.NotAfter,
|
||||
binding.Label));
|
||||
}
|
||||
|
||||
certificateBindings = bindingRegistrations;
|
||||
}
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
request.ClientId,
|
||||
request.Confidential,
|
||||
@@ -730,9 +790,11 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
request.ClientSecret,
|
||||
request.AllowedGrantTypes ?? Array.Empty<string>(),
|
||||
request.AllowedScopes ?? Array.Empty<string>(),
|
||||
request.AllowedAudiences ?? Array.Empty<string>(),
|
||||
redirectUris,
|
||||
postLogoutUris,
|
||||
properties);
|
||||
properties,
|
||||
certificateBindings);
|
||||
|
||||
var result = await provider.ClientProvisioning.CreateOrUpdateAsync(registration, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1149,7 +1211,7 @@ static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions option
|
||||
{
|
||||
BaseDirectory = basePath,
|
||||
PluginsDirectory = string.IsNullOrWhiteSpace(pluginDirectory)
|
||||
? Path.Combine("PluginBinaries", "Authority")
|
||||
? "StellaOps.Authority.PluginBinaries"
|
||||
: pluginDirectory,
|
||||
PrimaryPrefix = "StellaOps.Authority"
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Security;
|
||||
|
||||
internal sealed class AuthorityClientCertificateValidationResult
|
||||
{
|
||||
private AuthorityClientCertificateValidationResult(bool succeeded, string? confirmationThumbprint, string? hexThumbprint, AuthorityClientCertificateBinding? binding, string? error)
|
||||
{
|
||||
Succeeded = succeeded;
|
||||
ConfirmationThumbprint = confirmationThumbprint;
|
||||
HexThumbprint = hexThumbprint;
|
||||
Binding = binding;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public bool Succeeded { get; }
|
||||
|
||||
public string? ConfirmationThumbprint { get; }
|
||||
|
||||
public string? HexThumbprint { get; }
|
||||
|
||||
public AuthorityClientCertificateBinding? Binding { get; }
|
||||
|
||||
public string? Error { get; }
|
||||
|
||||
public static AuthorityClientCertificateValidationResult Success(string confirmationThumbprint, string hexThumbprint, AuthorityClientCertificateBinding binding)
|
||||
=> new(true, confirmationThumbprint, hexThumbprint, binding, null);
|
||||
|
||||
public static AuthorityClientCertificateValidationResult Failure(string error)
|
||||
=> new(false, null, null, null, error);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Formats.Asn1;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Authority.Security;
|
||||
|
||||
internal sealed class AuthorityClientCertificateValidator : IAuthorityClientCertificateValidator
|
||||
{
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<AuthorityClientCertificateValidator> logger;
|
||||
|
||||
public AuthorityClientCertificateValidator(
|
||||
StellaOpsAuthorityOptions authorityOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AuthorityClientCertificateValidator> logger)
|
||||
{
|
||||
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
var certificate = httpContext.Connection.ClientCertificate;
|
||||
if (certificate is null)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: no client certificate present.", client.ClientId);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("client_certificate_required"));
|
||||
}
|
||||
|
||||
var mtlsOptions = authorityOptions.Security.SenderConstraints.Mtls;
|
||||
var requiresChain = mtlsOptions.RequireChainValidation || mtlsOptions.AllowedCertificateAuthorities.Count > 0;
|
||||
|
||||
X509Chain? chain = null;
|
||||
var chainBuilt = false;
|
||||
try
|
||||
{
|
||||
if (requiresChain)
|
||||
{
|
||||
chain = CreateChain();
|
||||
chainBuilt = TryBuildChain(chain, certificate);
|
||||
if (mtlsOptions.RequireChainValidation && !chainBuilt)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate chain validation failed.", client.ClientId);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_chain_invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (now < certificate.NotBefore || now > certificate.NotAfter)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate outside validity window (notBefore={NotBefore:o}, notAfter={NotAfter:o}).", client.ClientId, certificate.NotBefore, certificate.NotAfter);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_expired"));
|
||||
}
|
||||
|
||||
if (mtlsOptions.NormalizedSubjectPatterns.Count > 0 &&
|
||||
!mtlsOptions.NormalizedSubjectPatterns.Any(pattern => pattern.IsMatch(certificate.Subject)))
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: subject {Subject} did not match allowed patterns.", client.ClientId, certificate.Subject);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_subject_mismatch"));
|
||||
}
|
||||
|
||||
var subjectAlternativeNames = GetSubjectAlternativeNames(certificate);
|
||||
if (mtlsOptions.AllowedSanTypes.Count > 0)
|
||||
{
|
||||
if (subjectAlternativeNames.Count == 0)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate does not contain subject alternative names.", client.ClientId);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_san_missing"));
|
||||
}
|
||||
|
||||
if (subjectAlternativeNames.Any(san => !mtlsOptions.AllowedSanTypes.Contains(san.Type)))
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate SAN types [{Types}] not allowed.", client.ClientId, string.Join(",", subjectAlternativeNames.Select(san => san.Type)));
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_san_type"));
|
||||
}
|
||||
|
||||
if (!subjectAlternativeNames.Any(san => mtlsOptions.AllowedSanTypes.Contains(san.Type)))
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate SANs did not include any of the required types.", client.ClientId);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_san_missing_required"));
|
||||
}
|
||||
}
|
||||
|
||||
if (mtlsOptions.AllowedCertificateAuthorities.Count > 0)
|
||||
{
|
||||
var allowedCas = mtlsOptions.AllowedCertificateAuthorities
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var matchedCa = false;
|
||||
if (chainBuilt && chain is not null)
|
||||
{
|
||||
foreach (var element in chain.ChainElements.Cast<X509ChainElement>().Skip(1))
|
||||
{
|
||||
if (allowedCas.Contains(element.Certificate.Subject))
|
||||
{
|
||||
matchedCa = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedCa && allowedCas.Contains(certificate.Issuer))
|
||||
{
|
||||
matchedCa = true;
|
||||
}
|
||||
|
||||
if (!matchedCa)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate issuer {Issuer} is not allow-listed.", client.ClientId, certificate.Issuer);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_ca_untrusted"));
|
||||
}
|
||||
}
|
||||
|
||||
if (client.CertificateBindings.Count == 0)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: no certificate bindings registered for client.", client.ClientId);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_missing"));
|
||||
}
|
||||
|
||||
var certificateHash = certificate.GetCertHash(HashAlgorithmName.SHA256);
|
||||
var hexThumbprint = Convert.ToHexString(certificateHash);
|
||||
var base64Thumbprint = Base64UrlEncoder.Encode(certificateHash);
|
||||
|
||||
var binding = client.CertificateBindings.FirstOrDefault(b => string.Equals(b.Thumbprint, hexThumbprint, StringComparison.OrdinalIgnoreCase));
|
||||
if (binding is null)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate thumbprint {Thumbprint} not registered.", client.ClientId, hexThumbprint);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_unbound"));
|
||||
}
|
||||
|
||||
if (binding.NotBefore is { } bindingNotBefore)
|
||||
{
|
||||
var effectiveNotBefore = bindingNotBefore - mtlsOptions.RotationGrace;
|
||||
if (now < effectiveNotBefore)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate binding not active until {NotBefore:o} (grace applied).", client.ClientId, bindingNotBefore);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_inactive"));
|
||||
}
|
||||
}
|
||||
|
||||
if (binding.NotAfter is { } bindingNotAfter)
|
||||
{
|
||||
var effectiveNotAfter = bindingNotAfter + mtlsOptions.RotationGrace;
|
||||
if (now > effectiveNotAfter)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate binding expired at {NotAfter:o} (grace applied).", client.ClientId, bindingNotAfter);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_expired"));
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success(base64Thumbprint, hexThumbprint, binding));
|
||||
}
|
||||
finally
|
||||
{
|
||||
chain?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static X509Chain CreateChain()
|
||||
=> new()
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
RevocationFlag = X509RevocationFlag.ExcludeRoot,
|
||||
VerificationFlags = X509VerificationFlags.IgnoreWrongUsage
|
||||
}
|
||||
};
|
||||
|
||||
private bool TryBuildChain(X509Chain chain, X509Certificate2 certificate)
|
||||
{
|
||||
try
|
||||
{
|
||||
return chain.Build(certificate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "mTLS chain validation threw an exception.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(string Type, string Value)> GetSubjectAlternativeNames(X509Certificate2 certificate)
|
||||
{
|
||||
foreach (var extension in certificate.Extensions)
|
||||
{
|
||||
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(extension.RawData, AsnEncodingRules.DER);
|
||||
var sequence = reader.ReadSequence();
|
||||
var results = new List<(string, string)>();
|
||||
|
||||
while (sequence.HasData)
|
||||
{
|
||||
var tag = sequence.PeekTag();
|
||||
if (tag.TagClass != TagClass.ContextSpecific)
|
||||
{
|
||||
sequence.ReadEncodedValue();
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (tag.TagValue)
|
||||
{
|
||||
case 2:
|
||||
{
|
||||
var dns = sequence.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 2));
|
||||
results.Add(("dns", dns));
|
||||
break;
|
||||
}
|
||||
case 6:
|
||||
{
|
||||
var uri = sequence.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6));
|
||||
results.Add(("uri", uri));
|
||||
break;
|
||||
}
|
||||
case 7:
|
||||
{
|
||||
var bytes = sequence.ReadOctetString(new Asn1Tag(TagClass.ContextSpecific, 7));
|
||||
var ip = new IPAddress(bytes).ToString();
|
||||
results.Add(("ip", ip));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
sequence.ReadEncodedValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<(string, string)>();
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<(string, string)>();
|
||||
}
|
||||
private bool ValidateCertificateChain(X509Certificate2 certificate)
|
||||
{
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
RevocationFlag = X509RevocationFlag.ExcludeRoot,
|
||||
VerificationFlags = X509VerificationFlags.IgnoreWrongUsage
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
return chain.Build(certificate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "mTLS chain validation threw an exception.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Authority.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical string identifiers for Authority sender-constraint policies.
|
||||
/// </summary>
|
||||
internal static class AuthoritySenderConstraintKinds
|
||||
{
|
||||
internal const string Dpop = "dpop";
|
||||
internal const string Mtls = "mtls";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Security;
|
||||
|
||||
internal interface IAuthorityClientCertificateValidator
|
||||
{
|
||||
ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
|
||||
|
||||
@@ -20,10 +20,13 @@
|
||||
| AUTHCORE-STORAGE-DEVICE-TOKENS | DONE (2025-10-14) | Authority Core, Storage Guild | AUTHCORE-BUILD-OPENIDDICT | Reintroduce `AuthorityTokenDeviceDocument` + projections removed during refactor so storage layer compiles. | ✅ Document type restored with mappings/migrations; ✅ Storage tests cover device artifacts; ✅ Authority solution build green. |
|
||||
| AUTHCORE-BOOTSTRAP-INVITES | DONE (2025-10-14) | Authority Core, DevOps | AUTHCORE-STORAGE-DEVICE-TOKENS | Wire bootstrap invite cleanup service against restored document schema and re-enable lifecycle tests. | ✅ `BootstrapInviteCleanupService` passes integration tests; ✅ Operator guide updated if behavior changes; ✅ Build/test matrices green. |
|
||||
| AUTHSTORAGE-MONGO-08-001 | DONE (2025-10-19) | Authority Core & Storage Guild | — | Harden Mongo session usage with causal consistency for mutations and follow-up reads. | • Scoped middleware/service creates `IClientSessionHandle` with causal consistency + majority read/write concerns<br>• Stores accept optional session parameter and reuse it for write + immediate reads<br>• GraphQL/HTTP pipelines updated to flow session through post-mutation queries<br>• Replica-set integration test exercises primary election and verifies read-your-write guarantees |
|
||||
| AUTH-DPOP-11-001 | DOING (2025-10-19) | Authority Core & Security Guild | — | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. | • DPoP proof validator verifies method/uri/hash, jwk thumbprint, and replay nonce per spec<br>• Nonce issuance endpoint integrated with audit + rate limits; high-value audiences enforce nonce requirement<br>• Integration tests cover success/failure paths (expired nonce, replay, invalid proof) and docs outline operator configuration |
|
||||
| AUTH-MTLS-11-002 | DOING (2025-10-19) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | • Client registration stores certificate bindings and enforces SAN/thumbprint validation during token issuance<br>• Token endpoint returns certificate-bound access tokens + PoP proof metadata; introspection reflects binding state<br>• End-to-end tests validate successful mTLS issuance, rejection of unbound certs, and docs capture configuration/rotation guidance |
|
||||
> Remark (2025-10-19, AUTHSTORAGE-MONGO-08-001): Session accessor wired through Authority pipeline; stores accept optional sessions; added replica-set election regression test for read-your-write.
|
||||
> Remark (2025-10-19, AUTH-DPOP-11-001): Prerequisites reviewed—none outstanding; status moved to DOING for Wave 0 kickoff. Design blueprint recorded in `docs/dev/authority-dpop-mtls-plan.md`.
|
||||
> Remark (2025-10-19, AUTH-MTLS-11-002): Prerequisites reviewed—none outstanding; status moved to DOING for Wave 0 kickoff. mTLS flow design captured in `docs/dev/authority-dpop-mtls-plan.md`.
|
||||
| AUTH-PLUGIN-COORD-08-002 | DOING (2025-10-19) | Authority Core, Plugin Platform Guild | PLUGIN-DI-08-001 | Coordinate scoped-service adoption for Authority plug-in registrars and background jobs ahead of PLUGIN-DI-08-002 implementation. | ✅ Workshop locked for 2025-10-20 15:00–16:00 UTC; ✅ Pre-read checklist in `docs/dev/authority-plugin-di-coordination.md`; ✅ Follow-up tasks captured in module backlogs before code changes begin. |
|
||||
| AUTH-DPOP-11-001 | DOING (2025-10-19) | Authority Core & Security Guild | — | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. | • Proof handler validates method/uri/hash + replay; nonce issuing/consumption implemented for in-memory + Redis stores<br>• Client credential path stamps `cnf.jkt` and persists sender metadata<br>• Remaining: finalize Redis configuration surface (docs/sample config), unskip nonce-challenge regression once HTTP pipeline emits high-value audiences, refresh operator docs |
|
||||
> Remark (2025-10-19): DPoP handler now seeds request resources/audiences from client metadata; nonce challenge integration test re-enabled (still requires full suite once Concelier build restored).
|
||||
| AUTH-MTLS-11-002 | DOING (2025-10-19) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | • Certificate validator scaffold plus cnf stamping present; tokens persist sender thumbprints<br>• Remaining: provisioning/storage for certificate bindings, SAN/CA validation, introspection propagation, integration tests/docs before marking DONE |
|
||||
> Remark (2025-10-19): Client provisioning accepts certificate bindings; validator enforces SAN types/CA allow-list with rotation grace; mtls integration tests updated (full suite still blocked by upstream build).
|
||||
> Remark (2025-10-19, AUTHSTORAGE-MONGO-08-001): Prerequisites re-checked (none outstanding). Session accessor wired through Authority pipeline; stores accept optional sessions; added replica-set election regression test for read-your-write.
|
||||
> Remark (2025-10-19, AUTH-DPOP-11-001): Handler, nonce store, and persistence hooks merged; Redis-backed configuration + end-to-end nonce enforcement still open. Full solution test blocked by `StellaOps.Concelier.Storage.Mongo` compile errors.
|
||||
> Remark (2025-10-19, AUTH-MTLS-11-002): Certificate validator + cnf stamping delivered; binding storage, CA/SAN validation, integration suites outstanding before status can move to DONE.
|
||||
|
||||
> Update status columns (TODO / DOING / DONE / BLOCKED) together with code changes. Always run `dotnet test src/StellaOps.Authority.sln` when touching host logic.
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -9,6 +10,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -21,20 +23,22 @@ using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
using StellaOps.Cryptography;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Testing;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class CommandHandlersTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HandleExportJobAsync_SetsExitCodeZeroOnSuccess()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
try
|
||||
{
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", "/jobs/export:json/1", null));
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
|
||||
public sealed class CommandHandlersTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HandleExportJobAsync_SetsExitCodeZeroOnSuccess()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
try
|
||||
{
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", "/jobs/export:json/1", null));
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
await CommandHandlers.HandleExportJobAsync(
|
||||
provider,
|
||||
format: "json",
|
||||
@@ -45,36 +49,36 @@ public sealed class CommandHandlersTests
|
||||
includeDelta: null,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Equal("export:json", backend.LastJobKind);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMergeJobAsync_SetsExitCodeOnFailure()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
try
|
||||
{
|
||||
var backend = new StubBackendClient(new JobTriggerResult(false, "Job already running", null, null));
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
await CommandHandlers.HandleMergeJobAsync(provider, verbose: false, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, Environment.ExitCode);
|
||||
Assert.Equal("merge:reconcile", backend.LastJobKind);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Equal("export:json", backend.LastJobKind);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMergeJobAsync_SetsExitCodeOnFailure()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
try
|
||||
{
|
||||
var backend = new StubBackendClient(new JobTriggerResult(false, "Job already running", null, null));
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
await CommandHandlers.HandleMergeJobAsync(provider, verbose: false, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, Environment.ExitCode);
|
||||
Assert.Equal("merge:reconcile", backend.LastJobKind);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleScannerRunAsync_AutomaticallyUploadsResults()
|
||||
{
|
||||
@@ -83,34 +87,34 @@ public sealed class CommandHandlersTests
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
|
||||
var metadataFile = Path.Combine(tempDir.Path, "results", "scan-run.json");
|
||||
var executor = new StubExecutor(new ScannerExecutionResult(0, resultsFile, metadataFile));
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results")
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(backend, executor, new StubInstaller(), options);
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(tempDir.Path, "target"));
|
||||
|
||||
var original = Environment.ExitCode;
|
||||
try
|
||||
{
|
||||
await CommandHandlers.HandleScannerRunAsync(
|
||||
provider,
|
||||
runner: "docker",
|
||||
entry: "scanner-image",
|
||||
targetDirectory: Path.Combine(tempDir.Path, "target"),
|
||||
arguments: Array.Empty<string>(),
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results")
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(backend, executor, new StubInstaller(), options);
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(tempDir.Path, "target"));
|
||||
|
||||
var original = Environment.ExitCode;
|
||||
try
|
||||
{
|
||||
await CommandHandlers.HandleScannerRunAsync(
|
||||
provider,
|
||||
runner: "docker",
|
||||
entry: "scanner-image",
|
||||
targetDirectory: Path.Combine(tempDir.Path, "target"),
|
||||
arguments: Array.Empty<string>(),
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Equal(resultsFile, backend.LastUploadPath);
|
||||
Assert.True(File.Exists(metadataFile));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,7 +558,219 @@ public sealed class CommandHandlersTests
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task HandleRuntimePolicyTestAsync_WritesInteractiveTable()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var originalConsole = AnsiConsole.Console;
|
||||
|
||||
var console = new TestConsole();
|
||||
console.Width(120);
|
||||
console.Interactive();
|
||||
console.EmitAnsiSequences();
|
||||
|
||||
AnsiConsole.Console = console;
|
||||
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
||||
|
||||
var decisions = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal)
|
||||
{
|
||||
["sha256:aaa"] = new RuntimePolicyImageDecision(
|
||||
"allow",
|
||||
true,
|
||||
true,
|
||||
Array.AsReadOnly(new[] { "trusted baseline" }),
|
||||
new RuntimePolicyRekorReference("uuid-allow", "https://rekor.example/entries/uuid-allow", true),
|
||||
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["source"] = "baseline",
|
||||
["quieted"] = false,
|
||||
["confidence"] = 0.97,
|
||||
["confidenceBand"] = "high"
|
||||
})),
|
||||
["sha256:bbb"] = new RuntimePolicyImageDecision(
|
||||
"block",
|
||||
false,
|
||||
false,
|
||||
Array.AsReadOnly(new[] { "missing attestation" }),
|
||||
new RuntimePolicyRekorReference("uuid-block", "https://rekor.example/entries/uuid-block", false),
|
||||
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["source"] = "policy",
|
||||
["quieted"] = false,
|
||||
["confidence"] = 0.12,
|
||||
["confidenceBand"] = "low"
|
||||
})),
|
||||
["sha256:ccc"] = new RuntimePolicyImageDecision(
|
||||
"audit",
|
||||
true,
|
||||
false,
|
||||
Array.AsReadOnly(new[] { "pending sbom sync" }),
|
||||
new RuntimePolicyRekorReference(null, null, null),
|
||||
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["source"] = "mirror",
|
||||
["quieted"] = true,
|
||||
["quietedBy"] = "allow-temporary",
|
||||
["confidence"] = 0.42,
|
||||
["confidenceBand"] = "medium"
|
||||
}))
|
||||
};
|
||||
|
||||
backend.RuntimePolicyResult = new RuntimePolicyEvaluationResult(
|
||||
300,
|
||||
DateTimeOffset.Parse("2025-10-19T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
"rev-42",
|
||||
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions));
|
||||
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
try
|
||||
{
|
||||
await CommandHandlers.HandleRuntimePolicyTestAsync(
|
||||
provider,
|
||||
namespaceValue: "prod",
|
||||
imageArguments: new[] { "sha256:aaa", "sha256:bbb" },
|
||||
filePath: null,
|
||||
labelArguments: new[] { "app=frontend" },
|
||||
outputJson: false,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
var output = console.Output;
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Contains("Image", output, StringComparison.Ordinal);
|
||||
Assert.Contains("Verdict", output, StringComparison.Ordinal);
|
||||
Assert.Contains("SBOM Ref", output, StringComparison.Ordinal);
|
||||
Assert.Contains("Quieted", output, StringComparison.Ordinal);
|
||||
Assert.Contains("Confidence", output, StringComparison.Ordinal);
|
||||
Assert.Contains("sha256:aaa", output, StringComparison.Ordinal);
|
||||
Assert.Contains("uuid-allow", output, StringComparison.Ordinal);
|
||||
Assert.Contains("(verified)", output, StringComparison.Ordinal);
|
||||
Assert.Contains("0.97 (high)", output, StringComparison.Ordinal);
|
||||
Assert.Contains("sha256:bbb", output, StringComparison.Ordinal);
|
||||
Assert.Contains("uuid-block", output, StringComparison.Ordinal);
|
||||
Assert.Contains("(unverified)", output, StringComparison.Ordinal);
|
||||
Assert.Contains("sha256:ccc", output, StringComparison.Ordinal);
|
||||
Assert.Contains("yes", output, StringComparison.Ordinal);
|
||||
Assert.Contains("allow-temporary", output, StringComparison.Ordinal);
|
||||
Assert.True(
|
||||
output.IndexOf("sha256:aaa", StringComparison.Ordinal) <
|
||||
output.IndexOf("sha256:ccc", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
AnsiConsole.Console = originalConsole;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleRuntimePolicyTestAsync_WritesDeterministicJson()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var originalOut = Console.Out;
|
||||
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
||||
|
||||
var decisions = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal)
|
||||
{
|
||||
["sha256:json-a"] = new RuntimePolicyImageDecision(
|
||||
"allow",
|
||||
true,
|
||||
true,
|
||||
Array.AsReadOnly(new[] { "baseline allow" }),
|
||||
new RuntimePolicyRekorReference("uuid-json-allow", "https://rekor.example/entries/uuid-json-allow", true),
|
||||
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["source"] = "baseline",
|
||||
["confidence"] = 0.66
|
||||
})),
|
||||
["sha256:json-b"] = new RuntimePolicyImageDecision(
|
||||
"audit",
|
||||
true,
|
||||
false,
|
||||
Array.AsReadOnly(Array.Empty<string>()),
|
||||
new RuntimePolicyRekorReference(null, null, null),
|
||||
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["source"] = "mirror",
|
||||
["quieted"] = true,
|
||||
["quietedBy"] = "risk-accepted"
|
||||
}))
|
||||
};
|
||||
|
||||
backend.RuntimePolicyResult = new RuntimePolicyEvaluationResult(
|
||||
600,
|
||||
DateTimeOffset.Parse("2025-10-20T00:00:00Z", CultureInfo.InvariantCulture),
|
||||
"rev-json-7",
|
||||
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions));
|
||||
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
using var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
try
|
||||
{
|
||||
await CommandHandlers.HandleRuntimePolicyTestAsync(
|
||||
provider,
|
||||
namespaceValue: "staging",
|
||||
imageArguments: new[] { "sha256:json-a", "sha256:json-b" },
|
||||
filePath: null,
|
||||
labelArguments: Array.Empty<string>(),
|
||||
outputJson: true,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
var output = writer.ToString().Trim();
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.False(string.IsNullOrWhiteSpace(output));
|
||||
|
||||
using var document = JsonDocument.Parse(output);
|
||||
var root = document.RootElement;
|
||||
|
||||
Assert.Equal(600, root.GetProperty("ttlSeconds").GetInt32());
|
||||
Assert.Equal("rev-json-7", root.GetProperty("policyRevision").GetString());
|
||||
var expiresAt = root.GetProperty("expiresAtUtc").GetString();
|
||||
Assert.NotNull(expiresAt);
|
||||
Assert.Equal(
|
||||
DateTimeOffset.Parse("2025-10-20T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
|
||||
DateTimeOffset.Parse(expiresAt!, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal));
|
||||
|
||||
var results = root.GetProperty("results");
|
||||
var keys = results.EnumerateObject().Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { "sha256:json-a", "sha256:json-b" }, keys);
|
||||
|
||||
var first = results.GetProperty("sha256:json-a");
|
||||
Assert.Equal("allow", first.GetProperty("policyVerdict").GetString());
|
||||
Assert.True(first.GetProperty("signed").GetBoolean());
|
||||
Assert.True(first.GetProperty("hasSbomReferrers").GetBoolean());
|
||||
var rekor = first.GetProperty("rekor");
|
||||
Assert.Equal("uuid-json-allow", rekor.GetProperty("uuid").GetString());
|
||||
Assert.True(rekor.GetProperty("verified").GetBoolean());
|
||||
Assert.Equal("baseline", first.GetProperty("source").GetString());
|
||||
Assert.Equal(0.66, first.GetProperty("confidence").GetDouble(), 3);
|
||||
|
||||
var second = results.GetProperty("sha256:json-b");
|
||||
Assert.Equal("audit", second.GetProperty("policyVerdict").GetString());
|
||||
Assert.True(second.GetProperty("signed").GetBoolean());
|
||||
Assert.False(second.GetProperty("hasSbomReferrers").GetBoolean());
|
||||
Assert.Equal("mirror", second.GetProperty("source").GetString());
|
||||
Assert.True(second.GetProperty("quieted").GetBoolean());
|
||||
Assert.Equal("risk-accepted", second.GetProperty("quietedBy").GetString());
|
||||
Assert.False(second.TryGetProperty("rekor", out _));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RevocationArtifactPaths> WriteRevocationArtifactsAsync(TempDirectory temp, string? providerHint)
|
||||
{
|
||||
var (bundleBytes, signature, keyPem) = await BuildRevocationArtifactsAsync(providerHint);
|
||||
@@ -665,10 +881,17 @@ public sealed class CommandHandlersTests
|
||||
$"{Path.GetFileNameWithoutExtension(tempResultsFile)}-run.json");
|
||||
return new StubExecutor(new ScannerExecutionResult(0, tempResultsFile, tempMetadataFile));
|
||||
}
|
||||
|
||||
|
||||
private sealed class StubBackendClient : IBackendOperationsClient
|
||||
{
|
||||
private readonly JobTriggerResult _jobResult;
|
||||
private static readonly RuntimePolicyEvaluationResult DefaultRuntimePolicyResult =
|
||||
new RuntimePolicyEvaluationResult(
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(
|
||||
new Dictionary<string, RuntimePolicyImageDecision>()));
|
||||
|
||||
public StubBackendClient(JobTriggerResult result)
|
||||
{
|
||||
@@ -683,6 +906,7 @@ public sealed class CommandHandlersTests
|
||||
public List<(string ExportId, string DestinationPath, string? Algorithm, string? Digest)> ExportDownloads { get; } = new();
|
||||
public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null);
|
||||
public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>();
|
||||
public RuntimePolicyEvaluationResult RuntimePolicyResult { get; set; } = DefaultRuntimePolicyResult;
|
||||
|
||||
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
@@ -726,21 +950,18 @@ public sealed class CommandHandlersTests
|
||||
=> Task.FromResult(ProviderSummaries);
|
||||
|
||||
public Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var empty = new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(new Dictionary<string, RuntimePolicyImageDecision>());
|
||||
return Task.FromResult(new RuntimePolicyEvaluationResult(0, null, null, empty));
|
||||
}
|
||||
=> Task.FromResult(RuntimePolicyResult);
|
||||
}
|
||||
|
||||
private sealed class StubExecutor : IScannerExecutor
|
||||
{
|
||||
private readonly ScannerExecutionResult _result;
|
||||
|
||||
public StubExecutor(ScannerExecutionResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
|
||||
private sealed class StubExecutor : IScannerExecutor
|
||||
{
|
||||
private readonly ScannerExecutionResult _result;
|
||||
|
||||
public StubExecutor(ScannerExecutionResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public Task<ScannerExecutionResult> RunAsync(string runner, string entry, string targetDirectory, string resultsDirectory, IReadOnlyList<string> arguments, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_result.ResultsPath)!);
|
||||
@@ -757,8 +978,8 @@ public sealed class CommandHandlersTests
|
||||
|
||||
return Task.FromResult(_result);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private sealed class StubInstaller : IScannerInstaller
|
||||
{
|
||||
public Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -6,163 +6,163 @@ using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Services;
|
||||
|
||||
public sealed class BackendOperationsClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DownloadScannerAsync_VerifiesDigestAndWritesMetadata()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes("scanner-blob");
|
||||
var digestHex = Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant();
|
||||
|
||||
var handler = new StubHttpMessageHandler((request, _) =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(contentBytes),
|
||||
RequestMessage = request
|
||||
};
|
||||
|
||||
response.Headers.Add("X-StellaOps-Digest", $"sha256:{digestHex}");
|
||||
response.Content.Headers.LastModified = DateTimeOffset.UtcNow;
|
||||
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream");
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://concelier.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "https://concelier.example",
|
||||
ScannerCacheDirectory = temp.Path,
|
||||
ScannerDownloadAttempts = 1
|
||||
};
|
||||
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var targetPath = Path.Combine(temp.Path, "scanner.tar.gz");
|
||||
var result = await client.DownloadScannerAsync("stable", targetPath, overwrite: false, verbose: true, CancellationToken.None);
|
||||
|
||||
Assert.False(result.FromCache);
|
||||
Assert.True(File.Exists(targetPath));
|
||||
|
||||
var metadataPath = targetPath + ".metadata.json";
|
||||
Assert.True(File.Exists(metadataPath));
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(metadataPath));
|
||||
Assert.Equal($"sha256:{digestHex}", document.RootElement.GetProperty("digest").GetString());
|
||||
Assert.Equal("stable", document.RootElement.GetProperty("channel").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadScannerAsync_ThrowsOnDigestMismatch()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes("scanner-data");
|
||||
var handler = new StubHttpMessageHandler((request, _) =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(contentBytes),
|
||||
RequestMessage = request
|
||||
};
|
||||
response.Headers.Add("X-StellaOps-Digest", "sha256:deadbeef");
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://concelier.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "https://concelier.example",
|
||||
ScannerCacheDirectory = temp.Path,
|
||||
ScannerDownloadAttempts = 1
|
||||
};
|
||||
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var targetPath = Path.Combine(temp.Path, "scanner.tar.gz");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.DownloadScannerAsync("stable", targetPath, overwrite: true, verbose: false, CancellationToken.None));
|
||||
Assert.False(File.Exists(targetPath));
|
||||
}
|
||||
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Services;
|
||||
|
||||
public sealed class BackendOperationsClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DownloadScannerAsync_VerifiesDigestAndWritesMetadata()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes("scanner-blob");
|
||||
var digestHex = Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant();
|
||||
|
||||
var handler = new StubHttpMessageHandler((request, _) =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(contentBytes),
|
||||
RequestMessage = request
|
||||
};
|
||||
|
||||
response.Headers.Add("X-StellaOps-Digest", $"sha256:{digestHex}");
|
||||
response.Content.Headers.LastModified = DateTimeOffset.UtcNow;
|
||||
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream");
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://concelier.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "https://concelier.example",
|
||||
ScannerCacheDirectory = temp.Path,
|
||||
ScannerDownloadAttempts = 1
|
||||
};
|
||||
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var targetPath = Path.Combine(temp.Path, "scanner.tar.gz");
|
||||
var result = await client.DownloadScannerAsync("stable", targetPath, overwrite: false, verbose: true, CancellationToken.None);
|
||||
|
||||
Assert.False(result.FromCache);
|
||||
Assert.True(File.Exists(targetPath));
|
||||
|
||||
var metadataPath = targetPath + ".metadata.json";
|
||||
Assert.True(File.Exists(metadataPath));
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(metadataPath));
|
||||
Assert.Equal($"sha256:{digestHex}", document.RootElement.GetProperty("digest").GetString());
|
||||
Assert.Equal("stable", document.RootElement.GetProperty("channel").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadScannerAsync_ThrowsOnDigestMismatch()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes("scanner-data");
|
||||
var handler = new StubHttpMessageHandler((request, _) =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(contentBytes),
|
||||
RequestMessage = request
|
||||
};
|
||||
response.Headers.Add("X-StellaOps-Digest", "sha256:deadbeef");
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://concelier.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "https://concelier.example",
|
||||
ScannerCacheDirectory = temp.Path,
|
||||
ScannerDownloadAttempts = 1
|
||||
};
|
||||
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var targetPath = Path.Combine(temp.Path, "scanner.tar.gz");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.DownloadScannerAsync("stable", targetPath, overwrite: true, verbose: false, CancellationToken.None));
|
||||
Assert.False(File.Exists(targetPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadScannerAsync_RetriesOnFailure()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
|
||||
var successBytes = Encoding.UTF8.GetBytes("success");
|
||||
var digestHex = Convert.ToHexString(SHA256.HashData(successBytes)).ToLowerInvariant();
|
||||
var attempts = 0;
|
||||
|
||||
var handler = new StubHttpMessageHandler(
|
||||
(request, _) =>
|
||||
{
|
||||
attempts++;
|
||||
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent("error")
|
||||
};
|
||||
},
|
||||
(request, _) =>
|
||||
{
|
||||
attempts++;
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new ByteArrayContent(successBytes)
|
||||
};
|
||||
response.Headers.Add("X-StellaOps-Digest", $"sha256:{digestHex}");
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://concelier.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "https://concelier.example",
|
||||
ScannerCacheDirectory = temp.Path,
|
||||
ScannerDownloadAttempts = 3
|
||||
};
|
||||
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var targetPath = Path.Combine(temp.Path, "scanner.tar.gz");
|
||||
var result = await client.DownloadScannerAsync("stable", targetPath, overwrite: false, verbose: false, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, attempts);
|
||||
var successBytes = Encoding.UTF8.GetBytes("success");
|
||||
var digestHex = Convert.ToHexString(SHA256.HashData(successBytes)).ToLowerInvariant();
|
||||
var attempts = 0;
|
||||
|
||||
var handler = new StubHttpMessageHandler(
|
||||
(request, _) =>
|
||||
{
|
||||
attempts++;
|
||||
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent("error")
|
||||
};
|
||||
},
|
||||
(request, _) =>
|
||||
{
|
||||
attempts++;
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new ByteArrayContent(successBytes)
|
||||
};
|
||||
response.Headers.Add("X-StellaOps-Digest", $"sha256:{digestHex}");
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://concelier.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "https://concelier.example",
|
||||
ScannerCacheDirectory = temp.Path,
|
||||
ScannerDownloadAttempts = 3
|
||||
};
|
||||
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var targetPath = Path.Combine(temp.Path, "scanner.tar.gz");
|
||||
var result = await client.DownloadScannerAsync("stable", targetPath, overwrite: false, verbose: false, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, attempts);
|
||||
Assert.False(result.FromCache);
|
||||
Assert.True(File.Exists(targetPath));
|
||||
}
|
||||
@@ -251,73 +251,73 @@ public sealed class BackendOperationsClientTests
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.UploadScanResultsAsync(filePath, CancellationToken.None));
|
||||
Assert.Equal(2, attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerJobAsync_ReturnsAcceptedResult()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler((request, _) =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Accepted)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = JsonContent.Create(new JobRunResponse
|
||||
{
|
||||
RunId = Guid.NewGuid(),
|
||||
Status = "queued",
|
||||
Kind = "export:json",
|
||||
Trigger = "cli",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
})
|
||||
};
|
||||
response.Headers.Location = new Uri("/jobs/export:json/runs/123", UriKind.Relative);
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://concelier.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions { BackendUrl = "https://concelier.example" };
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var result = await client.TriggerJobAsync("export:json", new Dictionary<string, object?>(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("Accepted", result.Message);
|
||||
Assert.Equal("/jobs/export:json/runs/123", result.Location);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerJobAsync_ReturnsAcceptedResult()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler((request, _) =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Accepted)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = JsonContent.Create(new JobRunResponse
|
||||
{
|
||||
RunId = Guid.NewGuid(),
|
||||
Status = "queued",
|
||||
Kind = "export:json",
|
||||
Trigger = "cli",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
})
|
||||
};
|
||||
response.Headers.Location = new Uri("/jobs/export:json/runs/123", UriKind.Relative);
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://concelier.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions { BackendUrl = "https://concelier.example" };
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var result = await client.TriggerJobAsync("export:json", new Dictionary<string, object?>(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("Accepted", result.Message);
|
||||
Assert.Equal("/jobs/export:json/runs/123", result.Location);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerJobAsync_ReturnsFailureMessage()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler((request, _) =>
|
||||
{
|
||||
var problem = new
|
||||
{
|
||||
title = "Job already running",
|
||||
detail = "export job active"
|
||||
};
|
||||
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Conflict)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = JsonContent.Create(problem)
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://concelier.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions { BackendUrl = "https://concelier.example" };
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var result = await client.TriggerJobAsync("export:json", new Dictionary<string, object?>(), CancellationToken.None);
|
||||
{
|
||||
title = "Job already running",
|
||||
detail = "export job active"
|
||||
};
|
||||
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Conflict)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = JsonContent.Create(problem)
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://concelier.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions { BackendUrl = "https://concelier.example" };
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var result = await client.TriggerJobAsync("export:json", new Dictionary<string, object?>(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Job already running", result.Message);
|
||||
@@ -403,18 +403,19 @@ public sealed class BackendOperationsClientTests
|
||||
""ghcr.io/app@sha256:abc"": {
|
||||
""policyVerdict"": ""pass"",
|
||||
""signed"": true,
|
||||
""hasSbom"": true,
|
||||
""hasSbomReferrers"": true,
|
||||
""reasons"": [],
|
||||
""rekor"": { ""uuid"": ""uuid-1"", ""url"": ""https://rekor.example/uuid-1"" },
|
||||
""rekor"": { ""uuid"": ""uuid-1"", ""url"": ""https://rekor.example/uuid-1"", ""verified"": true },
|
||||
""confidence"": 0.87,
|
||||
""quiet"": false,
|
||||
""quieted"": false,
|
||||
""metadata"": { ""note"": ""cached"" }
|
||||
},
|
||||
""ghcr.io/api@sha256:def"": {
|
||||
""policyVerdict"": ""fail"",
|
||||
""signed"": false,
|
||||
""hasSbom"": false,
|
||||
""reasons"": [""unsigned"", ""missing sbom""]
|
||||
""hasSbomReferrers"": false,
|
||||
""reasons"": [""unsigned"", ""missing sbom""],
|
||||
""quietedBy"": ""manual-override""
|
||||
}
|
||||
}
|
||||
}";
|
||||
@@ -458,13 +459,14 @@ public sealed class BackendOperationsClientTests
|
||||
var primary = result.Decisions["ghcr.io/app@sha256:abc"];
|
||||
Assert.Equal("pass", primary.PolicyVerdict);
|
||||
Assert.True(primary.Signed);
|
||||
Assert.True(primary.HasSbom);
|
||||
Assert.True(primary.HasSbomReferrers);
|
||||
Assert.Empty(primary.Reasons);
|
||||
Assert.NotNull(primary.Rekor);
|
||||
Assert.Equal("uuid-1", primary.Rekor!.Uuid);
|
||||
Assert.Equal("https://rekor.example/uuid-1", primary.Rekor.Url);
|
||||
Assert.True(primary.Rekor.Verified);
|
||||
Assert.Equal(0.87, Assert.IsType<double>(primary.AdditionalProperties["confidence"]), 3);
|
||||
Assert.False(Assert.IsType<bool>(primary.AdditionalProperties["quiet"]));
|
||||
Assert.False(Assert.IsType<bool>(primary.AdditionalProperties["quieted"]));
|
||||
var metadataJson = Assert.IsType<string>(primary.AdditionalProperties["metadata"]);
|
||||
using var metadataDocument = JsonDocument.Parse(metadataJson);
|
||||
Assert.Equal("cached", metadataDocument.RootElement.GetProperty("note").GetString());
|
||||
@@ -472,10 +474,11 @@ public sealed class BackendOperationsClientTests
|
||||
var secondary = result.Decisions["ghcr.io/api@sha256:def"];
|
||||
Assert.Equal("fail", secondary.PolicyVerdict);
|
||||
Assert.False(secondary.Signed);
|
||||
Assert.False(secondary.HasSbom);
|
||||
Assert.False(secondary.HasSbomReferrers);
|
||||
Assert.Collection(secondary.Reasons,
|
||||
item => Assert.Equal("unsigned", item),
|
||||
item => Assert.Equal("missing sbom", item));
|
||||
Assert.Equal("manual-override", Assert.IsType<string>(secondary.AdditionalProperties["quietedBy"]));
|
||||
}
|
||||
|
||||
private sealed class StubTokenClient : IStellaOpsTokenClient
|
||||
|
||||
@@ -16,13 +16,14 @@
|
||||
<!-- https://learn.microsoft.comdotnet/core/testing/microsoft-testing-platform-extensions-code-coverage -->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console.Testing" Version="0.48.0" />
|
||||
<ProjectReference Include="..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -358,6 +358,48 @@ internal static class CommandFactory
|
||||
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements.");
|
||||
var backfillRetrievedSinceOption = new Option<DateTimeOffset?>("--retrieved-since")
|
||||
{
|
||||
Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp."
|
||||
};
|
||||
var backfillForceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Reprocess documents even if statements already exist."
|
||||
};
|
||||
var backfillBatchSizeOption = new Option<int>("--batch-size")
|
||||
{
|
||||
Description = "Number of raw documents to fetch per batch (default 100)."
|
||||
};
|
||||
var backfillMaxDocumentsOption = new Option<int?>("--max-documents")
|
||||
{
|
||||
Description = "Optional maximum number of raw documents to process."
|
||||
};
|
||||
backfill.Add(backfillRetrievedSinceOption);
|
||||
backfill.Add(backfillForceOption);
|
||||
backfill.Add(backfillBatchSizeOption);
|
||||
backfill.Add(backfillMaxDocumentsOption);
|
||||
backfill.SetAction((parseResult, _) =>
|
||||
{
|
||||
var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption);
|
||||
var force = parseResult.GetValue(backfillForceOption);
|
||||
var batchSize = parseResult.GetValue(backfillBatchSizeOption);
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
batchSize = 100;
|
||||
}
|
||||
var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorBackfillStatementsAsync(
|
||||
services,
|
||||
retrievedSince,
|
||||
force,
|
||||
batchSize,
|
||||
maxDocuments,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var verify = new Command("verify", "Verify Excititor exports or attestations.");
|
||||
var exportIdOption = new Option<string?>("--export-id")
|
||||
{
|
||||
@@ -408,6 +450,7 @@ internal static class CommandFactory
|
||||
excititor.Add(resume);
|
||||
excititor.Add(list);
|
||||
excititor.Add(export);
|
||||
excititor.Add(backfill);
|
||||
excititor.Add(verify);
|
||||
excititor.Add(reconcile);
|
||||
return excititor;
|
||||
|
||||
@@ -25,103 +25,103 @@ using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class CommandHandlers
|
||||
{
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
bool overwrite,
|
||||
bool install,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scanner download");
|
||||
activity?.SetTag("stellaops.cli.channel", channel);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scanner download");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.FromCache)
|
||||
{
|
||||
logger.LogInformation("Using cached scanner at {Path}.", result.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes);
|
||||
}
|
||||
|
||||
CliMetrics.RecordScannerDownload(channel, result.FromCache);
|
||||
|
||||
if (install)
|
||||
{
|
||||
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
|
||||
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
|
||||
CliMetrics.RecordScannerInstall(channel);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to download scanner bundle.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScannerRunAsync(
|
||||
IServiceProvider services,
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-run");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.run", ActivityKind.Internal);
|
||||
activity?.SetTag("stellaops.cli.command", "scan run");
|
||||
activity?.SetTag("stellaops.cli.runner", runner);
|
||||
activity?.SetTag("stellaops.cli.entry", entry);
|
||||
activity?.SetTag("stellaops.cli.target", targetDirectory);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan run");
|
||||
|
||||
try
|
||||
{
|
||||
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
||||
var resultsDirectory = options.ResultsDirectory;
|
||||
|
||||
var executionResult = await executor.RunAsync(
|
||||
runner,
|
||||
entry,
|
||||
targetDirectory,
|
||||
resultsDirectory,
|
||||
arguments,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = executionResult.ExitCode;
|
||||
CliMetrics.RecordScanRun(runner, executionResult.ExitCode);
|
||||
|
||||
|
||||
internal static class CommandHandlers
|
||||
{
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
bool overwrite,
|
||||
bool install,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scanner download");
|
||||
activity?.SetTag("stellaops.cli.channel", channel);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scanner download");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.FromCache)
|
||||
{
|
||||
logger.LogInformation("Using cached scanner at {Path}.", result.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes);
|
||||
}
|
||||
|
||||
CliMetrics.RecordScannerDownload(channel, result.FromCache);
|
||||
|
||||
if (install)
|
||||
{
|
||||
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
|
||||
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
|
||||
CliMetrics.RecordScannerInstall(channel);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to download scanner bundle.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScannerRunAsync(
|
||||
IServiceProvider services,
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-run");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.run", ActivityKind.Internal);
|
||||
activity?.SetTag("stellaops.cli.command", "scan run");
|
||||
activity?.SetTag("stellaops.cli.runner", runner);
|
||||
activity?.SetTag("stellaops.cli.entry", entry);
|
||||
activity?.SetTag("stellaops.cli.target", targetDirectory);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan run");
|
||||
|
||||
try
|
||||
{
|
||||
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
||||
var resultsDirectory = options.ResultsDirectory;
|
||||
|
||||
var executionResult = await executor.RunAsync(
|
||||
runner,
|
||||
entry,
|
||||
targetDirectory,
|
||||
resultsDirectory,
|
||||
arguments,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = executionResult.ExitCode;
|
||||
CliMetrics.RecordScanRun(runner, executionResult.ExitCode);
|
||||
|
||||
if (executionResult.ExitCode == 0)
|
||||
{
|
||||
var backend = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
@@ -138,128 +138,128 @@ internal static class CommandHandlers
|
||||
logger.LogInformation("Run metadata written to {Path}.", executionResult.RunMetadataPath);
|
||||
activity?.SetTag("stellaops.cli.run_metadata", executionResult.RunMetadataPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Scanner execution failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScanUploadAsync(
|
||||
IServiceProvider services,
|
||||
string file,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scan upload");
|
||||
activity?.SetTag("stellaops.cli.file", file);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan upload");
|
||||
|
||||
try
|
||||
{
|
||||
var path = Path.GetFullPath(file);
|
||||
await client.UploadScanResultsAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Scan results uploaded successfully.");
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to upload scan results.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleConnectorJobAsync(
|
||||
IServiceProvider services,
|
||||
string source,
|
||||
string stage,
|
||||
string? mode,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db fetch");
|
||||
activity?.SetTag("stellaops.cli.source", source);
|
||||
activity?.SetTag("stellaops.cli.stage", stage);
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
activity?.SetTag("stellaops.cli.mode", mode);
|
||||
}
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db fetch");
|
||||
|
||||
try
|
||||
{
|
||||
var jobKind = $"source:{source}:{stage}";
|
||||
var parameters = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
parameters["mode"] = mode;
|
||||
}
|
||||
|
||||
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Connector job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleMergeJobAsync(
|
||||
IServiceProvider services,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db merge");
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db merge");
|
||||
|
||||
try
|
||||
{
|
||||
await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary<string, object?>(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Merge job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Scanner execution failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScanUploadAsync(
|
||||
IServiceProvider services,
|
||||
string file,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scan upload");
|
||||
activity?.SetTag("stellaops.cli.file", file);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan upload");
|
||||
|
||||
try
|
||||
{
|
||||
var path = Path.GetFullPath(file);
|
||||
await client.UploadScanResultsAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Scan results uploaded successfully.");
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to upload scan results.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleConnectorJobAsync(
|
||||
IServiceProvider services,
|
||||
string source,
|
||||
string stage,
|
||||
string? mode,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db fetch");
|
||||
activity?.SetTag("stellaops.cli.source", source);
|
||||
activity?.SetTag("stellaops.cli.stage", stage);
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
activity?.SetTag("stellaops.cli.mode", mode);
|
||||
}
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db fetch");
|
||||
|
||||
try
|
||||
{
|
||||
var jobKind = $"source:{source}:{stage}";
|
||||
var parameters = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
parameters["mode"] = mode;
|
||||
}
|
||||
|
||||
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Connector job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleMergeJobAsync(
|
||||
IServiceProvider services,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db merge");
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db merge");
|
||||
|
||||
try
|
||||
{
|
||||
await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary<string, object?>(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Merge job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleExportJobAsync(
|
||||
IServiceProvider services,
|
||||
string format,
|
||||
@@ -271,16 +271,16 @@ internal static class CommandHandlers
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db export");
|
||||
activity?.SetTag("stellaops.cli.format", format);
|
||||
activity?.SetTag("stellaops.cli.delta", delta);
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db export");
|
||||
activity?.SetTag("stellaops.cli.format", format);
|
||||
activity?.SetTag("stellaops.cli.delta", delta);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db export");
|
||||
activity?.SetTag("stellaops.cli.publish_full", publishFull);
|
||||
activity?.SetTag("stellaops.cli.publish_delta", publishDelta);
|
||||
@@ -330,16 +330,16 @@ internal static class CommandHandlers
|
||||
{
|
||||
parameters["includeDelta"] = includeDelta.Value;
|
||||
}
|
||||
|
||||
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Export job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Export job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
@@ -723,6 +723,62 @@ internal static class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
public static Task HandleExcititorBackfillStatementsAsync(
|
||||
IServiceProvider services,
|
||||
DateTimeOffset? retrievedSince,
|
||||
bool force,
|
||||
int batchSize,
|
||||
int? maxDocuments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be greater than zero.");
|
||||
}
|
||||
|
||||
if (maxDocuments.HasValue && maxDocuments.Value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxDocuments), "Max documents must be greater than zero when specified.");
|
||||
}
|
||||
|
||||
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["force"] = force,
|
||||
["batchSize"] = batchSize,
|
||||
["maxDocuments"] = maxDocuments
|
||||
};
|
||||
|
||||
if (retrievedSince.HasValue)
|
||||
{
|
||||
payload["retrievedSince"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var activityTags = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["stellaops.cli.force"] = force,
|
||||
["stellaops.cli.batch_size"] = batchSize,
|
||||
["stellaops.cli.max_documents"] = maxDocuments
|
||||
};
|
||||
|
||||
if (retrievedSince.HasValue)
|
||||
{
|
||||
activityTags["stellaops.cli.retrieved_since"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return ExecuteExcititorCommandAsync(
|
||||
services,
|
||||
commandName: "excititor backfill-statements",
|
||||
verbose,
|
||||
activityTags,
|
||||
client => client.ExecuteExcititorOperationAsync(
|
||||
"admin/backfill-statements",
|
||||
HttpMethod.Post,
|
||||
RemoveNullValues(payload),
|
||||
cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public static Task HandleExcititorVerifyAsync(
|
||||
IServiceProvider services,
|
||||
string? exportId,
|
||||
@@ -2208,7 +2264,7 @@ internal static class CommandHandlers
|
||||
{
|
||||
["policyVerdict"] = decision.PolicyVerdict,
|
||||
["signed"] = decision.Signed,
|
||||
["hasSbom"] = decision.HasSbom
|
||||
["hasSbomReferrers"] = decision.HasSbomReferrers
|
||||
};
|
||||
|
||||
if (decision.Reasons.Count > 0)
|
||||
@@ -2218,11 +2274,26 @@ internal static class CommandHandlers
|
||||
|
||||
if (decision.Rekor is not null)
|
||||
{
|
||||
map["rekor"] = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
var rekorMap = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(decision.Rekor.Uuid))
|
||||
{
|
||||
["uuid"] = decision.Rekor.Uuid,
|
||||
["url"] = decision.Rekor.Url
|
||||
};
|
||||
rekorMap["uuid"] = decision.Rekor.Uuid;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.Rekor.Url))
|
||||
{
|
||||
rekorMap["url"] = decision.Rekor.Url;
|
||||
}
|
||||
|
||||
if (decision.Rekor.Verified.HasValue)
|
||||
{
|
||||
rekorMap["verified"] = decision.Rekor.Verified;
|
||||
}
|
||||
|
||||
if (rekorMap.Count > 0)
|
||||
{
|
||||
map["rekor"] = rekorMap;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kvp in decision.AdditionalProperties)
|
||||
@@ -2240,7 +2311,8 @@ internal static class CommandHandlers
|
||||
|
||||
if (AnsiConsole.Profile.Capabilities.Interactive)
|
||||
{
|
||||
var table = new Table().Border(TableBorder.Rounded).AddColumns("Image", "Verdict", "Signed", "SBOM", "Reasons", "Attestation");
|
||||
var table = new Table().Border(TableBorder.Rounded)
|
||||
.AddColumns("Image", "Verdict", "Signed", "SBOM Ref", "Quieted", "Confidence", "Reasons", "Attestation");
|
||||
|
||||
foreach (var image in orderedImages)
|
||||
{
|
||||
@@ -2250,9 +2322,11 @@ internal static class CommandHandlers
|
||||
image,
|
||||
decision.PolicyVerdict,
|
||||
FormatBoolean(decision.Signed),
|
||||
FormatBoolean(decision.HasSbom),
|
||||
FormatBoolean(decision.HasSbomReferrers),
|
||||
FormatQuietedDisplay(decision.AdditionalProperties),
|
||||
FormatConfidenceDisplay(decision.AdditionalProperties),
|
||||
decision.Reasons.Count > 0 ? string.Join(Environment.NewLine, decision.Reasons) : "-",
|
||||
string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!);
|
||||
FormatAttestation(decision.Rekor));
|
||||
|
||||
summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
|
||||
|
||||
@@ -2264,7 +2338,7 @@ internal static class CommandHandlers
|
||||
}
|
||||
else
|
||||
{
|
||||
table.AddRow(image, "<missing>", "-", "-", "-", "-");
|
||||
table.AddRow(image, "<missing>", "-", "-", "-", "-", "-", "-");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2278,12 +2352,14 @@ internal static class CommandHandlers
|
||||
{
|
||||
var reasons = decision.Reasons.Count > 0 ? string.Join(", ", decision.Reasons) : "none";
|
||||
logger.LogInformation(
|
||||
"{Image} -> verdict={Verdict} signed={Signed} sbom={Sbom} attestation={Attestation} reasons={Reasons}",
|
||||
"{Image} -> verdict={Verdict} signed={Signed} sbomRef={Sbom} quieted={Quieted} confidence={Confidence} attestation={Attestation} reasons={Reasons}",
|
||||
image,
|
||||
decision.PolicyVerdict,
|
||||
FormatBoolean(decision.Signed),
|
||||
FormatBoolean(decision.HasSbom),
|
||||
string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!,
|
||||
FormatBoolean(decision.HasSbomReferrers),
|
||||
FormatQuietedDisplay(decision.AdditionalProperties),
|
||||
FormatConfidenceDisplay(decision.AdditionalProperties),
|
||||
FormatAttestation(decision.Rekor),
|
||||
reasons);
|
||||
|
||||
summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
|
||||
@@ -2346,6 +2422,144 @@ internal static class CommandHandlers
|
||||
private static string FormatBoolean(bool? value)
|
||||
=> value is null ? "unknown" : value.Value ? "yes" : "no";
|
||||
|
||||
private static string FormatQuietedDisplay(IReadOnlyDictionary<string, object?> metadata)
|
||||
{
|
||||
var quieted = GetMetadataBoolean(metadata, "quieted", "quiet");
|
||||
var quietedBy = GetMetadataString(metadata, "quietedBy", "quietedReason");
|
||||
|
||||
if (quieted is true)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(quietedBy) ? "yes" : $"yes ({quietedBy})";
|
||||
}
|
||||
|
||||
if (quieted is false)
|
||||
{
|
||||
return "no";
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(quietedBy) ? "-" : $"? ({quietedBy})";
|
||||
}
|
||||
|
||||
private static string FormatConfidenceDisplay(IReadOnlyDictionary<string, object?> metadata)
|
||||
{
|
||||
var confidence = GetMetadataDouble(metadata, "confidence");
|
||||
var confidenceBand = GetMetadataString(metadata, "confidenceBand", "confidenceTier");
|
||||
|
||||
if (confidence.HasValue && !string.IsNullOrWhiteSpace(confidenceBand))
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0:0.###} ({1})", confidence.Value, confidenceBand);
|
||||
}
|
||||
|
||||
if (confidence.HasValue)
|
||||
{
|
||||
return confidence.Value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(confidenceBand))
|
||||
{
|
||||
return confidenceBand!;
|
||||
}
|
||||
|
||||
return "-";
|
||||
}
|
||||
|
||||
private static string FormatAttestation(RuntimePolicyRekorReference? rekor)
|
||||
{
|
||||
if (rekor is null)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
var uuid = string.IsNullOrWhiteSpace(rekor.Uuid) ? null : rekor.Uuid;
|
||||
var url = string.IsNullOrWhiteSpace(rekor.Url) ? null : rekor.Url;
|
||||
var verified = rekor.Verified;
|
||||
|
||||
var core = uuid ?? url;
|
||||
if (!string.IsNullOrEmpty(core))
|
||||
{
|
||||
if (verified.HasValue)
|
||||
{
|
||||
var suffix = verified.Value ? " (verified)" : " (unverified)";
|
||||
return core + suffix;
|
||||
}
|
||||
|
||||
return core!;
|
||||
}
|
||||
|
||||
if (verified.HasValue)
|
||||
{
|
||||
return verified.Value ? "verified" : "unverified";
|
||||
}
|
||||
|
||||
return "-";
|
||||
}
|
||||
|
||||
private static bool? GetMetadataBoolean(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case bool b:
|
||||
return b;
|
||||
case string s when bool.TryParse(s, out var parsed):
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetMetadataString(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
if (value is string s)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? GetMetadataDouble(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case double d:
|
||||
return d;
|
||||
case float f:
|
||||
return f;
|
||||
case decimal m:
|
||||
return (double)m;
|
||||
case long l:
|
||||
return l;
|
||||
case int i:
|
||||
return i;
|
||||
case string s when double.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsed):
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
|
||||
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
|
||||
private static string FormatAdditionalValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
@@ -2359,8 +2573,6 @@ internal static class CommandHandlers
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
|
||||
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private static IReadOnlyList<string> NormalizeProviders(IReadOnlyList<string> providers)
|
||||
{
|
||||
@@ -2397,29 +2609,29 @@ internal static class CommandHandlers
|
||||
string jobKind,
|
||||
IDictionary<string, object?> parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Success)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(result.Location))
|
||||
{
|
||||
logger.LogInformation("Job accepted. Track status at {Location}.", result.Location);
|
||||
}
|
||||
else if (result.Run is not null)
|
||||
{
|
||||
logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Job accepted.");
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
|
||||
Environment.ExitCode = 1;
|
||||
{
|
||||
JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Success)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(result.Location))
|
||||
{
|
||||
logger.LogInformation("Job accepted. Track status at {Location}.", result.Location);
|
||||
}
|
||||
else if (result.Run is not null)
|
||||
{
|
||||
logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Job accepted.");
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
@@ -19,9 +19,9 @@ using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -48,34 +48,34 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim();
|
||||
outputPath = ResolveArtifactPath(outputPath, channel);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
|
||||
if (!overwrite && File.Exists(outputPath))
|
||||
{
|
||||
var existing = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length);
|
||||
return new ScannerArtifactResult(outputPath, existing.Length, true);
|
||||
}
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim();
|
||||
outputPath = ResolveArtifactPath(outputPath, channel);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
|
||||
if (!overwrite && File.Exists(outputPath))
|
||||
{
|
||||
var existing = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length);
|
||||
return new ScannerArtifactResult(outputPath, existing.Length, true);
|
||||
}
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, $"api/scanner/artifacts/{channel}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
@@ -83,55 +83,55 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var backoffSeconds = Math.Pow(2, attempt);
|
||||
_logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ScannerArtifactResult> ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
var tempFile = outputPath + ".tmp";
|
||||
await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var fileStream = File.Create(tempFile))
|
||||
{
|
||||
await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest");
|
||||
var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature");
|
||||
|
||||
var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false);
|
||||
await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated";
|
||||
_logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote);
|
||||
}
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
|
||||
File.Move(tempFile, outputPath);
|
||||
|
||||
PersistMetadata(outputPath, channel, digestHex, signatureHeader, response);
|
||||
|
||||
var downloaded = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length);
|
||||
|
||||
return new ScannerArtifactResult(outputPath, downloaded.Length, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var backoffSeconds = Math.Pow(2, attempt);
|
||||
_logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ScannerArtifactResult> ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
var tempFile = outputPath + ".tmp";
|
||||
await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var fileStream = File.Create(tempFile))
|
||||
{
|
||||
await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest");
|
||||
var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature");
|
||||
|
||||
var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false);
|
||||
await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated";
|
||||
_logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote);
|
||||
}
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
|
||||
File.Move(tempFile, outputPath);
|
||||
|
||||
PersistMetadata(outputPath, channel, digestHex, signatureHeader, response);
|
||||
|
||||
var downloaded = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length);
|
||||
|
||||
return new ScannerArtifactResult(outputPath, downloaded.Length, false);
|
||||
}
|
||||
|
||||
public async Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
@@ -194,46 +194,46 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobKind))
|
||||
{
|
||||
throw new ArgumentException("Job kind must be provided.", nameof(jobKind));
|
||||
}
|
||||
|
||||
var requestBody = new JobTriggerRequest
|
||||
{
|
||||
Trigger = "cli",
|
||||
Parameters = parameters is null ? new Dictionary<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(parameters, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
|
||||
public async Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobKind))
|
||||
{
|
||||
throw new ArgumentException("Job kind must be provided.", nameof(jobKind));
|
||||
}
|
||||
|
||||
var requestBody = new JobTriggerRequest
|
||||
{
|
||||
Trigger = "cli",
|
||||
Parameters = parameters is null ? new Dictionary<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(parameters, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
var request = CreateRequest(HttpMethod.Post, $"jobs/{jobKind}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
request.Content = JsonContent.Create(requestBody, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
JobRunResponse? run = null;
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
run = await response.Content.ReadFromJsonAsync<JobRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind);
|
||||
}
|
||||
}
|
||||
|
||||
var location = response.Headers.Location?.ToString();
|
||||
return new JobTriggerResult(true, "Accepted", location, run);
|
||||
}
|
||||
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
JobRunResponse? run = null;
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
run = await response.Content.ReadFromJsonAsync<JobRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind);
|
||||
}
|
||||
}
|
||||
|
||||
var location = response.Headers.Location?.ToString();
|
||||
return new JobTriggerResult(true, "Accepted", location, run);
|
||||
}
|
||||
|
||||
var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return new JobTriggerResult(false, failureMessage, null, null);
|
||||
}
|
||||
@@ -443,19 +443,24 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
var reasons = ExtractReasons(decision.Reasons);
|
||||
var metadata = ExtractExtensionMetadata(decision.ExtensionData);
|
||||
|
||||
var hasSbom = decision.HasSbomReferrers ?? decision.HasSbomLegacy;
|
||||
|
||||
RuntimePolicyRekorReference? rekor = null;
|
||||
if (decision.Rekor is not null &&
|
||||
(!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) || !string.IsNullOrWhiteSpace(decision.Rekor.Url)))
|
||||
(!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) ||
|
||||
!string.IsNullOrWhiteSpace(decision.Rekor.Url) ||
|
||||
decision.Rekor.Verified.HasValue))
|
||||
{
|
||||
rekor = new RuntimePolicyRekorReference(
|
||||
NormalizeOptionalString(decision.Rekor.Uuid),
|
||||
NormalizeOptionalString(decision.Rekor.Url));
|
||||
NormalizeOptionalString(decision.Rekor.Url),
|
||||
decision.Rekor.Verified);
|
||||
}
|
||||
|
||||
decisions[image] = new RuntimePolicyImageDecision(
|
||||
verdict,
|
||||
decision.Signed,
|
||||
decision.HasSbom,
|
||||
hasSbom,
|
||||
reasons,
|
||||
rekor,
|
||||
metadata);
|
||||
@@ -624,15 +629,15 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid request URI '{relativeUri}'.");
|
||||
}
|
||||
|
||||
if (requestUri.IsAbsoluteUri)
|
||||
{
|
||||
// Nothing to normalize.
|
||||
}
|
||||
else
|
||||
{
|
||||
requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative);
|
||||
}
|
||||
|
||||
if (requestUri.IsAbsoluteUri)
|
||||
{
|
||||
// Nothing to normalize.
|
||||
}
|
||||
else
|
||||
{
|
||||
requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative);
|
||||
}
|
||||
|
||||
return new HttpRequestMessage(method, requestUri);
|
||||
@@ -820,76 +825,76 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
if (_httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveArtifactPath(string outputPath, string channel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return Path.GetFullPath(outputPath);
|
||||
}
|
||||
|
||||
var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.GetFullPath(_options.ScannerCacheDirectory);
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
var fileName = $"stellaops-scanner-{channel}.tar.gz";
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("Backend request failed with status ");
|
||||
builder.Append(statusCode);
|
||||
builder.Append(' ');
|
||||
builder.Append(response.ReasonPhrase ?? "Unknown");
|
||||
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (problem is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(problem.Title))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Title);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(problem.Detail))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
builder.AppendLine().Append(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
|
||||
{
|
||||
if (headers.TryGetValues(name, out var values))
|
||||
{
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveArtifactPath(string outputPath, string channel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return Path.GetFullPath(outputPath);
|
||||
}
|
||||
|
||||
var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.GetFullPath(_options.ScannerCacheDirectory);
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
var fileName = $"stellaops-scanner-{channel}.tar.gz";
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("Backend request failed with status ");
|
||||
builder.Append(statusCode);
|
||||
builder.Append(' ');
|
||||
builder.Append(response.ReasonPhrase ?? "Unknown");
|
||||
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (problem is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(problem.Title))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Title);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(problem.Detail))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
builder.AppendLine().Append(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
|
||||
{
|
||||
if (headers.TryGetValues(name, out var values))
|
||||
{
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeExpectedDigest(string? digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
@@ -909,23 +914,23 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
await using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedDigest))
|
||||
{
|
||||
var normalized = NormalizeDigest(expectedDigest);
|
||||
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
|
||||
}
|
||||
|
||||
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedDigest))
|
||||
{
|
||||
var normalized = NormalizeDigest(expectedDigest);
|
||||
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
|
||||
}
|
||||
|
||||
return digestHex;
|
||||
}
|
||||
|
||||
@@ -945,71 +950,71 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
_logger.LogDebug("Signature header present but no public key configured; skipping validation.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature missing while a public key is configured.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath);
|
||||
}
|
||||
|
||||
var signatureBytes = Convert.FromBase64String(signatureHeader);
|
||||
var digestBytes = Convert.FromHexString(digestHex);
|
||||
|
||||
var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(pem);
|
||||
|
||||
var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature validation failed.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
_logger.LogDebug("Signature header present but no public key configured; skipping validation.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature missing while a public key is configured.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath);
|
||||
}
|
||||
|
||||
var signatureBytes = Convert.FromBase64String(signatureHeader);
|
||||
var digestBytes = Convert.FromHexString(digestHex);
|
||||
|
||||
var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(pem);
|
||||
|
||||
var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature validation failed.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistMetadata(string outputPath, string channel, string digestHex, string? signatureHeader, HttpResponseMessage response)
|
||||
{
|
||||
var metadata = new
|
||||
{
|
||||
channel,
|
||||
digest = $"sha256:{digestHex}",
|
||||
signature = signatureHeader,
|
||||
downloadedAt = DateTimeOffset.UtcNow,
|
||||
source = response.RequestMessage?.RequestUri?.ToString(),
|
||||
sizeBytes = new FileInfo(outputPath).Length,
|
||||
headers = new
|
||||
{
|
||||
etag = response.Headers.ETag?.Tag,
|
||||
lastModified = response.Content.Headers.LastModified,
|
||||
contentType = response.Content.Headers.ContentType?.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var metadataPath = outputPath + ".metadata.json";
|
||||
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
digest = $"sha256:{digestHex}",
|
||||
signature = signatureHeader,
|
||||
downloadedAt = DateTimeOffset.UtcNow,
|
||||
source = response.RequestMessage?.RequestUri?.ToString(),
|
||||
sizeBytes = new FileInfo(outputPath).Length,
|
||||
headers = new
|
||||
{
|
||||
etag = response.Headers.ETag?.Tag,
|
||||
lastModified = response.Content.Headers.LastModified,
|
||||
contentType = response.Content.Headers.ContentType?.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var metadataPath = outputPath + ".metadata.json";
|
||||
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
File.WriteAllText(metadataPath, json);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ internal sealed record RuntimePolicyEvaluationResult(
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
string PolicyVerdict,
|
||||
bool? Signed,
|
||||
bool? HasSbom,
|
||||
bool? HasSbomReferrers,
|
||||
IReadOnlyList<string> Reasons,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
IReadOnlyDictionary<string, object?> AdditionalProperties);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url);
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
@@ -42,8 +42,12 @@ internal sealed class RuntimePolicyEvaluationImageDocument
|
||||
[JsonPropertyName("signed")]
|
||||
public bool? Signed { get; set; }
|
||||
|
||||
[JsonPropertyName("hasSbomReferrers")]
|
||||
public bool? HasSbomReferrers { get; set; }
|
||||
|
||||
// Legacy field kept for pre-contract-sync services.
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool? HasSbom { get; set; }
|
||||
public bool? HasSbomLegacy { get; set; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public List<string>? Reasons { get; set; }
|
||||
@@ -62,4 +66,7 @@ internal sealed class RuntimePolicyRekorDocument
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool? Verified { get; set; }
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|
||||
|CLI-RUNTIME-13-005 – Runtime policy test verbs|DevEx/CLI|SCANNER-RUNTIME-12-302, ZASTAVA-WEBHOOK-12-102|**DONE (2025-10-19)** – Added `runtime policy test` command (stdin/file support, JSON output), backend client method + typed models, verdict table output, docs/tests updated (`dotnet test src/StellaOps.Cli.Tests`).|
|
||||
|CLI-OFFLINE-13-006 – Offline kit workflows|DevEx/CLI|DEVOPS-OFFLINE-14-002|TODO – Implement `offline kit pull/import/status` commands with integrity checks, resumable downloads, and doc updates.|
|
||||
|CLI-PLUGIN-13-007 – Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|TODO – Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload).|
|
||||
|CLI-RUNTIME-13-008 – Runtime policy contract sync|DevEx/CLI, Scanner WebService Guild|SCANNER-RUNTIME-12-302|TODO – Once `/api/v1/scanner/policy/runtime` exits TODO, verify CLI output against final schema (field names, metadata) and update formatter/tests if the contract moves. Capture joint review notes in docs/09 and link Scanner task sign-off.|
|
||||
|CLI-RUNTIME-13-009 – Runtime policy smoke fixture|DevEx/CLI, QA Guild|CLI-RUNTIME-13-005|TODO – Build Spectre test harness exercising `runtime policy test` against a stubbed backend to lock output shape (table + `--json`) and guard regressions. Integrate into `dotnet test` suite.|
|
||||
|CLI-RUNTIME-13-008 – Runtime policy contract sync|DevEx/CLI, Scanner WebService Guild|SCANNER-RUNTIME-12-302|**DONE (2025-10-19)** – CLI runtime table/JSON now align with SCANNER-RUNTIME-12-302 (SBOM referrers, quieted provenance, confidence, verified Rekor); docs/09 updated with joint sign-off note.|
|
||||
|CLI-RUNTIME-13-009 – Runtime policy smoke fixture|DevEx/CLI, QA Guild|CLI-RUNTIME-13-005|**DONE (2025-10-19)** – Spectre console harness + regression tests cover table and `--json` output paths for `runtime policy test`, using stubbed backend and integrated into `dotnet test` suite.|
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
|FEEDCONN-CCCS-02-006 Observability & documentation|DevEx|Docs|**DONE (2025-10-15)** – Added `CccsDiagnostics` meter (fetch/parse/map counters), enriched connector logs with document counts, and published `docs/ops/concelier-cccs-operations.md` covering config, telemetry, and sanitiser guidance.|
|
||||
|FEEDCONN-CCCS-02-007 Historical advisory harvesting plan|BE-Conn-CCCS|Research|**DONE (2025-10-15)** – Measured `/api/cccs/threats/v1/get` inventory (~5.1k rows/lang; earliest 2018-06-08), documented backfill workflow + language split strategy, and linked the runbook for Offline Kit execution.|
|
||||
|FEEDCONN-CCCS-02-008 Raw DOM parsing refinement|BE-Conn-CCCS|Source.Common|**DONE (2025-10-15)** – Parser now walks unsanitised DOM (heading + nested list coverage), sanitizer keeps `<h#>`/`section` nodes, and regression fixtures/tests assert EN/FR list handling + preserved HTML structure.|
|
||||
|FEEDCONN-CCCS-02-009 Normalized versions rollout (Oct 2025)|BE-Conn-CCCS|Merge coordination (`FEEDMERGE-COORD-02-900`)|**TODO (due 2025-10-21)** – Implement trailing-version split helper per Merge guidance (see `../Merge/RANGE_PRIMITIVES_COORDINATION.md` “Helper snippets”) to emit `NormalizedVersions` via `SemVerRangeRuleBuilder`; refresh mapper tests/fixtures to assert provenance notes (`cccs:{serial}:{index}`) and confirm merge counters drop.|
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
|FEEDCONN-CERTBUND-02-007 Feed history & locale assessment|BE-Conn-CERTBUND|Research|**DONE (2025-10-15)** – Measured RSS retention (~6 days/≈250 items), captured connector-driven backfill guidance in the runbook, and aligned locale guidance (preserve `language=de`, Docs glossary follow-up). **Next:** coordinate with Tools to land the state-seeding helper so scripted backfills replace manual Mongo tweaks.|
|
||||
|FEEDCONN-CERTBUND-02-008 Session bootstrap & cookie strategy|BE-Conn-CERTBUND|Source.Common|**DONE (2025-10-14)** – Feed client primes the portal session (cookie container via `SocketsHttpHandler`), shares cookies across detail requests, and documents bootstrap behaviour in options (`PortalBootstrapUri`).|
|
||||
|FEEDCONN-CERTBUND-02-009 Offline Kit export packaging|BE-Conn-CERTBUND, Docs|Offline Kit|**DONE (2025-10-17)** – Added `tools/certbund_offline_snapshot.py` to capture search/export JSON, emit deterministic manifests + SHA files, and refreshed docs (`docs/ops/concelier-certbund-operations.md`, `docs/24_OFFLINE_KIT.md`) with offline-kit instructions and manifest layout guidance. Seed data README/ignore rules cover local snapshot hygiene.|
|
||||
|FEEDCONN-CERTBUND-02-010 Normalized range translator|BE-Conn-CERTBUND|Merge coordination (`FEEDMERGE-COORD-02-900`)|**TODO (due 2025-10-22)** – Translate `product.Versions` phrases (e.g., `2023.1 bis 2024.2`, `alle`) into comparator strings for `SemVerRangeRuleBuilder`, emit `NormalizedVersions` with `certbund:{advisoryId}:{vendor}` provenance, and extend tests/README with localisation notes.|
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
|FEEDCONN-ICSCISA-02-009 GovDelivery credential onboarding|Ops, BE-Conn-ICS-CISA|Ops|**DONE (2025-10-14)** – GovDelivery onboarding runbook captured in `docs/ops/concelier-icscisa-operations.md`; secret vault path and Offline Kit handling documented.|
|
||||
|FEEDCONN-ICSCISA-02-010 Mitigation & SemVer polish|BE-Conn-ICS-CISA|02-003, 02-004|**DONE (2025-10-16)** – Attachment + mitigation references now land as expected and SemVer primitives carry exact values; end-to-end suite green (see `HANDOVER.md`).|
|
||||
|FEEDCONN-ICSCISA-02-011 Docs & telemetry refresh|DevEx|02-006|**DONE (2025-10-16)** – Ops documentation refreshed (attachments, SemVer validation, proxy knobs) and telemetry notes verified.|
|
||||
|FEEDCONN-ICSCISA-02-012 Normalized version decision|BE-Conn-ICS-CISA|Merge coordination (`FEEDMERGE-COORD-02-900`)|**TODO (due 2025-10-23)** – Promote existing `SemVerPrimitive` exact values into `NormalizedVersions` via `.ToNormalizedVersionRule("ics-cisa:{advisoryId}:{product}")`, add regression coverage, and open Models ticket if non-SemVer firmware requires a new scheme.|
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
|FEEDCONN-KISA-02-005 Deterministic fixtures & tests|QA|Testing|**DONE (2025-10-14)** – Added `StellaOps.Concelier.Connector.Kisa.Tests` with Korean fixtures and fetch→parse→map regression; fixtures regenerate via `UPDATE_KISA_FIXTURES=1`.|
|
||||
|FEEDCONN-KISA-02-006 Telemetry & documentation|DevEx|Docs|**DONE (2025-10-14)** – Added diagnostics-backed telemetry, structured logs, regression coverage, and published localisation notes in `docs/dev/kisa_connector_notes.md` + fixture guidance for Docs/QA.|
|
||||
|FEEDCONN-KISA-02-007 RSS contract & localisation brief|BE-Conn-KISA|Research|**DONE (2025-10-11)** – Documented RSS URLs, confirmed UTF-8 payload (no additional cookies required), and drafted localisation plan (Hangul glossary + optional MT plugin). Remaining open item: capture SPA detail API contract for full-text translations.|
|
||||
|FEEDCONN-KISA-02-008 Firmware scheme proposal|BE-Conn-KISA, Models|Merge coordination (`FEEDMERGE-COORD-02-900`)|**TODO (due 2025-10-24)** – Define transformation for Hangul-labelled firmware ranges (`XFU 1.0.1.0084 ~ 2.0.1.0034`), propose `kisa.build` (or equivalent) scheme to Models, implement normalized rule emission/tests once scheme approved, and update localisation notes.|
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Security;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
|
||||
|
||||
public sealed class MirrorSignatureVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidSignaturePasses()
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var key = CreateSigningKey("mirror-key");
|
||||
provider.UpsertSigningKey(key);
|
||||
|
||||
var registry = new CryptoProviderRegistry(new[] { provider });
|
||||
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
||||
|
||||
var payload = "{\"advisories\":[]}\"u8".ToUtf8Bytes();
|
||||
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
||||
|
||||
await verifier.VerifyAsync(payload, signature, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_InvalidSignatureThrows()
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var key = CreateSigningKey("mirror-key");
|
||||
provider.UpsertSigningKey(key);
|
||||
|
||||
var registry = new CryptoProviderRegistry(new[] { provider });
|
||||
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
||||
|
||||
var payload = "{\"advisories\":[]}\"u8".ToUtf8Bytes();
|
||||
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
||||
|
||||
var tampered = signature.Replace("a", "b", StringComparison.Ordinal);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(payload, tampered, CancellationToken.None));
|
||||
}
|
||||
|
||||
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 async Task<(string Signature, DateTimeOffset SignedAt)> CreateDetachedJwsAsync(
|
||||
DefaultCryptoProvider provider,
|
||||
string keyId,
|
||||
ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
var signer = provider.GetSigner(SignatureAlgorithms.Es256, new CryptoKeyReference(keyId));
|
||||
var header = new Dictionary<string, object?>
|
||||
{
|
||||
["alg"] = SignatureAlgorithms.Es256,
|
||||
["kid"] = keyId,
|
||||
["provider"] = provider.Name,
|
||||
["typ"] = "application/vnd.stellaops.concelier.mirror-bundle+jws",
|
||||
["b64"] = false,
|
||||
["crit"] = new[] { "b64" }
|
||||
};
|
||||
|
||||
var headerJson = System.Text.Json.JsonSerializer.Serialize(header);
|
||||
var protectedHeader = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(headerJson);
|
||||
|
||||
var signingInput = BuildSigningInput(protectedHeader, payload.Span);
|
||||
var signatureBytes = await signer.SignAsync(signingInput, CancellationToken.None).ConfigureAwait(false);
|
||||
var encodedSignature = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(signatureBytes);
|
||||
|
||||
return (string.Concat(protectedHeader, "..", encodedSignature), DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static ReadOnlyMemory<byte> BuildSigningInput(string encodedHeader, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var headerBytes = System.Text.Encoding.ASCII.GetBytes(encodedHeader);
|
||||
var buffer = new byte[headerBytes.Length + 1 + payload.Length];
|
||||
headerBytes.CopyTo(buffer.AsSpan());
|
||||
buffer[headerBytes.Length] = (byte)'.';
|
||||
payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
file static class Utf8Extensions
|
||||
{
|
||||
public static ReadOnlyMemory<byte> ToUtf8Bytes(this string value)
|
||||
=> System.Text.Encoding.UTF8.GetBytes(value);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Concelier.Connector.StellaOpsMirror/StellaOps.Concelier.Connector.StellaOpsMirror.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,319 @@
|
||||
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.Testing;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.SourceState;
|
||||
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 _));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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 = "mirror-key",
|
||||
provider = "default",
|
||||
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)
|
||||
{
|
||||
using 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight HTTP client for retrieving mirror index and domain artefacts.
|
||||
/// </summary>
|
||||
public sealed class MirrorManifestClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<MirrorManifestClient> _logger;
|
||||
|
||||
public MirrorManifestClient(HttpClient httpClient, ILogger<MirrorManifestClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<MirrorIndexDocument> GetIndexAsync(string indexPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(indexPath))
|
||||
{
|
||||
throw new ArgumentException("Index path must be provided.", nameof(indexPath));
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, indexPath);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await EnsureSuccessAsync(response, indexPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var document = await JsonSerializer.DeserializeAsync<MirrorIndexDocument>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
throw new InvalidOperationException("Mirror index payload was empty.");
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public async Task<byte[]> DownloadAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new ArgumentException("Path must be provided.", nameof(path));
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await EnsureSuccessAsync(response, path, cancellationToken).ConfigureAwait(false);
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureSuccessAsync(HttpResponseMessage response, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var status = (int)response.StatusCode;
|
||||
var body = string.Empty;
|
||||
|
||||
if (response.Content.Headers.ContentLength is long length && length > 0)
|
||||
{
|
||||
body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Mirror request to {Path} failed with {StatusCode}. Body: {Body}",
|
||||
path,
|
||||
status,
|
||||
string.IsNullOrEmpty(body) ? "<empty>" : body);
|
||||
|
||||
throw new HttpRequestException($"Mirror request to '{path}' failed with status {(HttpStatusCode)status} ({status}).", null, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
|
||||
|
||||
public sealed record MirrorIndexDocument(
|
||||
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
|
||||
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
|
||||
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
|
||||
[property: JsonPropertyName("domains")] IReadOnlyList<MirrorIndexDomainEntry> Domains);
|
||||
|
||||
public sealed record MirrorIndexDomainEntry(
|
||||
[property: JsonPropertyName("domainId")] string DomainId,
|
||||
[property: JsonPropertyName("displayName")] string DisplayName,
|
||||
[property: JsonPropertyName("advisoryCount")] int AdvisoryCount,
|
||||
[property: JsonPropertyName("manifest")] MirrorFileDescriptor Manifest,
|
||||
[property: JsonPropertyName("bundle")] MirrorFileDescriptor Bundle,
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<MirrorSourceSummary> Sources);
|
||||
|
||||
public sealed record MirrorFileDescriptor(
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("sizeBytes")] long SizeBytes,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("signature")] MirrorSignatureDescriptor? Signature);
|
||||
|
||||
public sealed record MirrorSignatureDescriptor(
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm,
|
||||
[property: JsonPropertyName("keyId")] string KeyId,
|
||||
[property: JsonPropertyName("provider")] string Provider,
|
||||
[property: JsonPropertyName("signedAt")] DateTimeOffset SignedAt);
|
||||
|
||||
public sealed record MirrorSourceSummary(
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("firstRecordedAt")] DateTimeOffset? FirstRecordedAt,
|
||||
[property: JsonPropertyName("lastRecordedAt")] DateTimeOffset? LastRecordedAt,
|
||||
[property: JsonPropertyName("advisoryCount")] int AdvisoryCount);
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
|
||||
|
||||
internal sealed record StellaOpsMirrorCursor(
|
||||
string? ExportId,
|
||||
string? BundleDigest,
|
||||
DateTimeOffset? GeneratedAt,
|
||||
IReadOnlyCollection<Guid> PendingDocuments,
|
||||
IReadOnlyCollection<Guid> PendingMappings)
|
||||
{
|
||||
private static readonly IReadOnlyCollection<Guid> EmptyGuids = Array.Empty<Guid>();
|
||||
|
||||
public static StellaOpsMirrorCursor Empty { get; } = new(
|
||||
ExportId: null,
|
||||
BundleDigest: null,
|
||||
GeneratedAt: null,
|
||||
PendingDocuments: EmptyGuids,
|
||||
PendingMappings: EmptyGuids);
|
||||
|
||||
public BsonDocument ToBsonDocument()
|
||||
{
|
||||
var document = new BsonDocument
|
||||
{
|
||||
["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())),
|
||||
["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ExportId))
|
||||
{
|
||||
document["exportId"] = ExportId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(BundleDigest))
|
||||
{
|
||||
document["bundleDigest"] = BundleDigest;
|
||||
}
|
||||
|
||||
if (GeneratedAt.HasValue)
|
||||
{
|
||||
document["generatedAt"] = GeneratedAt.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static StellaOpsMirrorCursor FromBson(BsonDocument? document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var exportId = document.TryGetValue("exportId", out var exportValue) && exportValue.IsString ? exportValue.AsString : null;
|
||||
var digest = document.TryGetValue("bundleDigest", out var digestValue) && digestValue.IsString ? digestValue.AsString : null;
|
||||
DateTimeOffset? generatedAt = null;
|
||||
if (document.TryGetValue("generatedAt", out var generatedValue))
|
||||
{
|
||||
generatedAt = generatedValue.BsonType switch
|
||||
{
|
||||
BsonType.DateTime => DateTime.SpecifyKind(generatedValue.ToUniversalTime(), DateTimeKind.Utc),
|
||||
BsonType.String when DateTimeOffset.TryParse(generatedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
var pendingDocuments = ReadGuidArray(document, "pendingDocuments");
|
||||
var pendingMappings = ReadGuidArray(document, "pendingMappings");
|
||||
|
||||
return new StellaOpsMirrorCursor(exportId, digest, generatedAt, pendingDocuments, pendingMappings);
|
||||
}
|
||||
|
||||
public StellaOpsMirrorCursor WithPendingDocuments(IEnumerable<Guid> documents)
|
||||
=> this with { PendingDocuments = documents?.Distinct().ToArray() ?? EmptyGuids };
|
||||
|
||||
public StellaOpsMirrorCursor WithPendingMappings(IEnumerable<Guid> mappings)
|
||||
=> this with { PendingMappings = mappings?.Distinct().ToArray() ?? EmptyGuids };
|
||||
|
||||
public StellaOpsMirrorCursor WithBundleSnapshot(string? exportId, string? digest, DateTimeOffset generatedAt)
|
||||
=> this with
|
||||
{
|
||||
ExportId = string.IsNullOrWhiteSpace(exportId) ? ExportId : exportId,
|
||||
BundleDigest = digest,
|
||||
GeneratedAt = generatedAt,
|
||||
};
|
||||
|
||||
private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
|
||||
{
|
||||
return EmptyGuids;
|
||||
}
|
||||
|
||||
var results = new List<Guid>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (element is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Guid.TryParse(element.ToString(), out var guid))
|
||||
{
|
||||
results.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
43
src/StellaOps.Concelier.Connector.StellaOpsMirror/Jobs.cs
Normal file
43
src/StellaOps.Concelier.Connector.StellaOpsMirror/Jobs.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror;
|
||||
|
||||
internal static class StellaOpsMirrorJobKinds
|
||||
{
|
||||
public const string Fetch = "source:stellaops-mirror:fetch";
|
||||
public const string Parse = "source:stellaops-mirror:parse";
|
||||
public const string Map = "source:stellaops-mirror:map";
|
||||
}
|
||||
|
||||
internal sealed class StellaOpsMirrorFetchJob : IJob
|
||||
{
|
||||
private readonly StellaOpsMirrorConnector _connector;
|
||||
|
||||
public StellaOpsMirrorFetchJob(StellaOpsMirrorConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.FetchAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class StellaOpsMirrorParseJob : IJob
|
||||
{
|
||||
private readonly StellaOpsMirrorConnector _connector;
|
||||
|
||||
public StellaOpsMirrorParseJob(StellaOpsMirrorConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.ParseAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class StellaOpsMirrorMapJob : IJob
|
||||
{
|
||||
private readonly StellaOpsMirrorConnector _connector;
|
||||
|
||||
public StellaOpsMirrorMapJob(StellaOpsMirrorConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.MapAsync(context.Services, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Validates detached JWS signatures emitted by mirror bundles.
|
||||
/// </summary>
|
||||
public sealed class MirrorSignatureVerifier
|
||||
{
|
||||
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly ICryptoProviderRegistry _providerRegistry;
|
||||
private readonly ILogger<MirrorSignatureVerifier> _logger;
|
||||
|
||||
public MirrorSignatureVerifier(ICryptoProviderRegistry providerRegistry, ILogger<MirrorSignatureVerifier> logger)
|
||||
{
|
||||
_providerRegistry = providerRegistry ?? throw new ArgumentNullException(nameof(providerRegistry));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task VerifyAsync(ReadOnlyMemory<byte> payload, string signatureValue, CancellationToken cancellationToken)
|
||||
{
|
||||
if (payload.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("Payload must not be empty.", nameof(payload));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureValue))
|
||||
{
|
||||
throw new ArgumentException("Signature value must be provided.", nameof(signatureValue));
|
||||
}
|
||||
|
||||
if (!TryParseDetachedJws(signatureValue, out var encodedHeader, out var encodedSignature))
|
||||
{
|
||||
throw new InvalidOperationException("Detached JWS signature is malformed.");
|
||||
}
|
||||
|
||||
var headerJson = Encoding.UTF8.GetString(Base64UrlEncoder.DecodeBytes(encodedHeader));
|
||||
var header = JsonSerializer.Deserialize<MirrorSignatureHeader>(headerJson, HeaderSerializerOptions)
|
||||
?? throw new InvalidOperationException("Detached JWS header could not be parsed.");
|
||||
|
||||
if (!header.Critical.Contains("b64", StringComparer.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Detached JWS header is missing required 'b64' critical parameter.");
|
||||
}
|
||||
|
||||
if (header.Base64Payload)
|
||||
{
|
||||
throw new InvalidOperationException("Detached JWS header sets b64=true; expected unencoded payload.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(header.KeyId))
|
||||
{
|
||||
throw new InvalidOperationException("Detached JWS header missing key identifier.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(header.Algorithm))
|
||||
{
|
||||
throw new InvalidOperationException("Detached JWS header missing algorithm identifier.");
|
||||
}
|
||||
|
||||
var signingInput = BuildSigningInput(encodedHeader, payload.Span);
|
||||
var signatureBytes = Base64UrlEncoder.DecodeBytes(encodedSignature);
|
||||
|
||||
var keyReference = new CryptoKeyReference(header.KeyId, header.Provider);
|
||||
var resolution = _providerRegistry.ResolveSigner(
|
||||
CryptoCapability.Verification,
|
||||
header.Algorithm,
|
||||
keyReference,
|
||||
header.Provider);
|
||||
|
||||
var verified = await resolution.Signer.VerifyAsync(signingInput, signatureBytes, cancellationToken).ConfigureAwait(false);
|
||||
if (!verified)
|
||||
{
|
||||
_logger.LogWarning("Detached JWS verification failed for key {KeyId} via provider {Provider}.", header.KeyId, resolution.ProviderName);
|
||||
throw new InvalidOperationException("Detached JWS signature verification failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature)
|
||||
{
|
||||
var parts = value.Split("..", StringSplitOptions.None);
|
||||
if (parts.Length != 2 || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
|
||||
{
|
||||
encodedHeader = string.Empty;
|
||||
encodedSignature = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
encodedHeader = parts[0];
|
||||
encodedSignature = parts[1];
|
||||
return true;
|
||||
}
|
||||
|
||||
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.AsSpan());
|
||||
buffer[headerBytes.Length] = (byte)'.';
|
||||
payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private sealed record MirrorSignatureHeader(
|
||||
[property: JsonPropertyName("alg")] string Algorithm,
|
||||
[property: JsonPropertyName("kid")] string KeyId,
|
||||
[property: JsonPropertyName("provider")] string? Provider,
|
||||
[property: JsonPropertyName("typ")] string? Type,
|
||||
[property: JsonPropertyName("b64")] bool Base64Payload,
|
||||
[property: JsonPropertyName("crit")] string[] Critical);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the StellaOps mirror connector HTTP client.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsMirrorConnectorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base address of the mirror distribution endpoint (e.g., https://mirror.stella-ops.org).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Uri BaseAddress { get; set; } = new("https://mirror.stella-ops.org", UriKind.Absolute);
|
||||
|
||||
/// <summary>
|
||||
/// Relative path to the mirror index document. Defaults to <c>/concelier/exports/index.json</c>.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string IndexPath { get; set; } = "/concelier/exports/index.json";
|
||||
|
||||
/// <summary>
|
||||
/// Preferred mirror domain identifier when multiple domains are published in the index.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string DomainId { get; set; } = "primary";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum duration to wait on HTTP requests.
|
||||
/// </summary>
|
||||
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification configuration for downloaded bundles.
|
||||
/// </summary>
|
||||
public SignatureOptions Signature { get; set; } = new();
|
||||
|
||||
public sealed class SignatureOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// When <c>true</c>, downloaded bundles must include a detached JWS that validates successfully.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Expected signing key identifier (kid) emitted in the detached JWS header.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional crypto provider hint used to resolve verification keys.
|
||||
/// </summary>
|
||||
public string? Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to a PEM-encoded EC public key used to verify signatures when registry resolution fails.
|
||||
/// </summary>
|
||||
public string? PublicKeyPath { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,288 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Client;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Security;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror;
|
||||
|
||||
public sealed class StellaOpsMirrorConnector : IFeedConnector
|
||||
{
|
||||
public const string Source = "stellaops-mirror";
|
||||
|
||||
private readonly MirrorManifestClient _client;
|
||||
private readonly MirrorSignatureVerifier _signatureVerifier;
|
||||
private readonly RawDocumentStorage _rawDocumentStorage;
|
||||
private readonly IDocumentStore _documentStore;
|
||||
private readonly ISourceStateRepository _stateRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<StellaOpsMirrorConnector> _logger;
|
||||
private readonly StellaOpsMirrorConnectorOptions _options;
|
||||
|
||||
public StellaOpsMirrorConnector(
|
||||
MirrorManifestClient client,
|
||||
MirrorSignatureVerifier signatureVerifier,
|
||||
RawDocumentStorage rawDocumentStorage,
|
||||
IDocumentStore documentStore,
|
||||
ISourceStateRepository stateRepository,
|
||||
IOptions<StellaOpsMirrorConnectorOptions> options,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<StellaOpsMirrorConnector> logger)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
|
||||
_rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
|
||||
_documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
|
||||
ValidateOptions(_options);
|
||||
}
|
||||
|
||||
public string SourceName => Source;
|
||||
|
||||
public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = services ?? throw new ArgumentNullException(nameof(services));
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
var pendingDocuments = cursor.PendingDocuments.ToHashSet();
|
||||
var pendingMappings = cursor.PendingMappings.ToHashSet();
|
||||
|
||||
MirrorIndexDocument index;
|
||||
try
|
||||
{
|
||||
index = await _client.GetIndexAsync(_options.IndexPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _stateRepository.MarkFailureAsync(Source, now, TimeSpan.FromMinutes(15), ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
var domain = index.Domains.FirstOrDefault(entry =>
|
||||
string.Equals(entry.DomainId, _options.DomainId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (domain is null)
|
||||
{
|
||||
var message = $"Mirror domain '{_options.DomainId}' not present in index.";
|
||||
await _stateRepository.MarkFailureAsync(Source, now, TimeSpan.FromMinutes(30), message, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
|
||||
if (string.Equals(domain.Bundle.Digest, cursor.BundleDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Mirror bundle digest {Digest} unchanged; skipping fetch.", domain.Bundle.Digest);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessDomainAsync(index, domain, pendingDocuments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _stateRepository.MarkFailureAsync(Source, now, TimeSpan.FromMinutes(10), ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
var updatedCursor = cursor
|
||||
.WithPendingDocuments(pendingDocuments)
|
||||
.WithPendingMappings(pendingMappings)
|
||||
.WithBundleSnapshot(domain.Bundle.Path, domain.Bundle.Digest, index.GeneratedAt);
|
||||
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
private async Task ProcessDomainAsync(
|
||||
MirrorIndexDocument index,
|
||||
MirrorIndexDomainEntry domain,
|
||||
HashSet<Guid> pendingDocuments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestBytes = await _client.DownloadAsync(domain.Manifest.Path, cancellationToken).ConfigureAwait(false);
|
||||
var bundleBytes = await _client.DownloadAsync(domain.Bundle.Path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
VerifyDigest(domain.Manifest.Digest, manifestBytes, domain.Manifest.Path);
|
||||
VerifyDigest(domain.Bundle.Digest, bundleBytes, domain.Bundle.Path);
|
||||
|
||||
if (_options.Signature.Enabled)
|
||||
{
|
||||
if (domain.Bundle.Signature is null)
|
||||
{
|
||||
throw new InvalidOperationException("Mirror bundle did not include a signature descriptor while verification is enabled.");
|
||||
}
|
||||
|
||||
var signatureBytes = await _client.DownloadAsync(domain.Bundle.Signature.Path, cancellationToken).ConfigureAwait(false);
|
||||
var signatureValue = Encoding.UTF8.GetString(signatureBytes);
|
||||
await _signatureVerifier.VerifyAsync(bundleBytes, signatureValue, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await StoreAsync(domain, index.GeneratedAt, domain.Manifest, manifestBytes, "application/json", DocumentStatuses.Mapped, addToPending: false, pendingDocuments, cancellationToken).ConfigureAwait(false);
|
||||
var bundleRecord = await StoreAsync(domain, index.GeneratedAt, domain.Bundle, bundleBytes, "application/json", DocumentStatuses.PendingParse, addToPending: true, pendingDocuments, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Stored mirror bundle {Uri} as document {DocumentId} with digest {Digest}.",
|
||||
bundleRecord.Uri,
|
||||
bundleRecord.Id,
|
||||
bundleRecord.Sha256);
|
||||
}
|
||||
|
||||
private async Task<DocumentRecord> StoreAsync(
|
||||
MirrorIndexDomainEntry domain,
|
||||
DateTimeOffset generatedAt,
|
||||
MirrorFileDescriptor descriptor,
|
||||
byte[] payload,
|
||||
string contentType,
|
||||
string status,
|
||||
bool addToPending,
|
||||
HashSet<Guid> pendingDocuments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var absolute = ResolveAbsolutePath(descriptor.Path);
|
||||
|
||||
var existing = await _documentStore.FindBySourceAndUriAsync(Source, absolute, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null && string.Equals(existing.Sha256, NormalizeDigest(descriptor.Digest), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (addToPending)
|
||||
{
|
||||
pendingDocuments.Add(existing.Id);
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
var gridFsId = await _rawDocumentStorage.UploadAsync(Source, absolute, payload, contentType, cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var sha = ComputeSha256(payload);
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["mirror.domainId"] = domain.DomainId,
|
||||
["mirror.displayName"] = domain.DisplayName,
|
||||
["mirror.path"] = descriptor.Path,
|
||||
["mirror.digest"] = NormalizeDigest(descriptor.Digest),
|
||||
["mirror.type"] = ReferenceEquals(descriptor, domain.Bundle) ? "bundle" : "manifest",
|
||||
};
|
||||
|
||||
var record = new DocumentRecord(
|
||||
existing?.Id ?? Guid.NewGuid(),
|
||||
Source,
|
||||
absolute,
|
||||
now,
|
||||
sha,
|
||||
status,
|
||||
contentType,
|
||||
Headers: null,
|
||||
Metadata: metadata,
|
||||
Etag: null,
|
||||
LastModified: generatedAt,
|
||||
GridFsId: gridFsId,
|
||||
ExpiresAt: null);
|
||||
|
||||
var upserted = await _documentStore.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (addToPending)
|
||||
{
|
||||
pendingDocuments.Add(upserted.Id);
|
||||
}
|
||||
|
||||
return upserted;
|
||||
}
|
||||
|
||||
private string ResolveAbsolutePath(string path)
|
||||
{
|
||||
var uri = new Uri(_options.BaseAddress, path);
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
private async Task<StellaOpsMirrorCursor> GetCursorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await _stateRepository.TryGetAsync(Source, cancellationToken).ConfigureAwait(false);
|
||||
return state is null ? StellaOpsMirrorCursor.Empty : StellaOpsMirrorCursor.FromBson(state.Cursor);
|
||||
}
|
||||
|
||||
private async Task UpdateCursorAsync(StellaOpsMirrorCursor cursor, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = cursor.ToBsonDocument();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await _stateRepository.UpdateCursorAsync(Source, document, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void VerifyDigest(string expected, ReadOnlySpan<byte> payload, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expected))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expected.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported digest '{expected}' for '{path}'.");
|
||||
}
|
||||
|
||||
var actualHash = SHA256.HashData(payload);
|
||||
var actual = "sha256:" + Convert.ToHexString(actualHash).ToLowerInvariant();
|
||||
if (!string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Digest mismatch for '{path}'. Expected {expected}, computed {actual}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var hash = SHA256.HashData(payload);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? digest[7..]
|
||||
: digest.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void ValidateOptions(StellaOpsMirrorConnectorOptions options)
|
||||
{
|
||||
if (options.BaseAddress is null || !options.BaseAddress.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Mirror connector requires an absolute baseAddress.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.DomainId))
|
||||
{
|
||||
throw new InvalidOperationException("Mirror connector requires domainId to be specified.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file static class UriExtensions
|
||||
{
|
||||
public static Uri Combine(this Uri baseUri, string relative)
|
||||
=> new(baseUri, relative);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror;
|
||||
|
||||
public sealed class StellaOpsMirrorConnectorPlugin : IConnectorPlugin
|
||||
{
|
||||
public const string SourceName = StellaOpsMirrorConnector.Source;
|
||||
|
||||
public string Name => SourceName;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => services is not null;
|
||||
|
||||
public IFeedConnector Create(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return ActivatorUtilities.CreateInstance<StellaOpsMirrorConnector>(services);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Client;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Security;
|
||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.StellaOpsMirror;
|
||||
|
||||
public sealed class StellaOpsMirrorDependencyInjectionRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
private const string ConfigurationSection = "concelier:sources:stellaopsMirror";
|
||||
private const string HttpClientName = "stellaops-mirror";
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(4);
|
||||
|
||||
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddOptions<StellaOpsMirrorConnectorOptions>()
|
||||
.Bind(configuration.GetSection(ConfigurationSection))
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
if (options.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("stellaopsMirror.baseAddress must be configured.");
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSourceCommon();
|
||||
|
||||
services.AddHttpClient(HttpClientName, (sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<StellaOpsMirrorConnectorOptions>>().Value;
|
||||
client.BaseAddress = options.BaseAddress;
|
||||
client.Timeout = options.HttpTimeout;
|
||||
client.DefaultRequestHeaders.Accept.Clear();
|
||||
client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
});
|
||||
|
||||
services.AddTransient<MirrorManifestClient>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
var httpClient = factory.CreateClient(HttpClientName);
|
||||
return ActivatorUtilities.CreateInstance<MirrorManifestClient>(sp, httpClient);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<MirrorSignatureVerifier>();
|
||||
services.AddTransient<StellaOpsMirrorConnector>();
|
||||
|
||||
var scheduler = new JobSchedulerBuilder(services);
|
||||
scheduler.AddJob<StellaOpsMirrorFetchJob>(
|
||||
StellaOpsMirrorJobKinds.Fetch,
|
||||
cronExpression: "*/15 * * * *",
|
||||
timeout: FetchTimeout,
|
||||
leaseDuration: LeaseDuration);
|
||||
scheduler.AddJob<StellaOpsMirrorParseJob>(
|
||||
StellaOpsMirrorJobKinds.Parse,
|
||||
cronExpression: null,
|
||||
timeout: TimeSpan.FromMinutes(5),
|
||||
leaseDuration: LeaseDuration,
|
||||
enabled: false);
|
||||
scheduler.AddJob<StellaOpsMirrorMapJob>(
|
||||
StellaOpsMirrorJobKinds.Map,
|
||||
cronExpression: null,
|
||||
timeout: TimeSpan.FromMinutes(5),
|
||||
leaseDuration: LeaseDuration,
|
||||
enabled: false);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| FEEDCONN-STELLA-08-001 | TODO | BE-Conn-Stella | CONCELIER-EXPORT-08-201 | Implement Concelier mirror fetcher hitting `https://<domain>.stella-ops.org/concelier/exports/index.json`, verify signatures/digests, and persist raw documents with provenance. | Fetch job downloads mirror manifest, verifies digest/signature, stores raw docs with tests covering happy-path + tampered manifest. |
|
||||
| FEEDCONN-STELLA-08-001 | DOING (2025-10-19) | BE-Conn-Stella | CONCELIER-EXPORT-08-201 | Implement Concelier mirror fetcher hitting `https://<domain>.stella-ops.org/concelier/exports/index.json`, verify signatures/digests, and persist raw documents with provenance. | Fetch job downloads mirror manifest, verifies digest/signature, stores raw docs with tests covering happy-path + tampered manifest. *(In progress: HTTP client + detached JWS verifier scaffolding landed.)* |
|
||||
| FEEDCONN-STELLA-08-002 | TODO | BE-Conn-Stella | FEEDCONN-STELLA-08-001 | Map mirror payloads into canonical advisory DTOs with provenance referencing mirror domain + original source metadata. | Mapper produces advisories/aliases/affected with mirror provenance; fixtures assert canonical parity with upstream JSON exporters. |
|
||||
| FEEDCONN-STELLA-08-003 | TODO | BE-Conn-Stella | FEEDCONN-STELLA-08-002 | Add incremental cursor + resume support (per-export fingerprint) and document configuration for downstream Concelier instances. | Connector resumes from last export, handles deletion/delta cases, docs updated with config sample; integration test covers resume + new export scenario. |
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
|FEEDCONN-CISCO-02-006 Telemetry & documentation|DevEx|Docs|**DONE (2025-10-14)** – Cisco diagnostics counters exposed and ops runbook updated with telemetry guidance (`docs/ops/concelier-cisco-operations.md`).|
|
||||
|FEEDCONN-CISCO-02-007 API selection decision memo|BE-Conn-Cisco|Research|**DONE (2025-10-11)** – Drafted decision matrix: openVuln (structured/delta filters, OAuth throttle) vs RSS (delayed/minimal metadata). Pending OAuth onboarding (`FEEDCONN-CISCO-02-008`) before final recommendation circulated.|
|
||||
|FEEDCONN-CISCO-02-008 OAuth client provisioning|Ops, BE-Conn-Cisco|Ops|**DONE (2025-10-14)** – `docs/ops/concelier-cisco-operations.md` documents OAuth provisioning/rotation, quotas, and Offline Kit distribution guidance.|
|
||||
|FEEDCONN-CISCO-02-009 Normalized SemVer promotion|BE-Conn-Cisco|Merge coordination (`FEEDMERGE-COORD-02-900`)|**TODO (due 2025-10-21)** – Use helper from `../Merge/RANGE_PRIMITIVES_COORDINATION.md` to convert `SemVerPrimitive` outputs into `NormalizedVersionRule` with provenance (`cisco:{productId}`), update mapper/tests, and confirm merge normalized-rule counters drop.|
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Events;
|
||||
|
||||
public sealed class AdvisoryEventLogTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AppendAsync_PersistsCanonicalStatementEntries()
|
||||
{
|
||||
var repository = new FakeRepository();
|
||||
var timeProvider = new FixedTimeProvider(DateTimeOffset.UtcNow);
|
||||
var log = new AdvisoryEventLog(repository, timeProvider);
|
||||
|
||||
var advisory = new Advisory(
|
||||
"adv-1",
|
||||
"Test Advisory",
|
||||
summary: "Summary",
|
||||
language: "en",
|
||||
published: DateTimeOffset.Parse("2025-10-01T00:00:00Z"),
|
||||
modified: DateTimeOffset.Parse("2025-10-02T00:00:00Z"),
|
||||
severity: "high",
|
||||
exploitKnown: true,
|
||||
aliases: new[] { "CVE-2025-0001" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: Array.Empty<AdvisoryProvenance>());
|
||||
|
||||
var statementInput = new AdvisoryStatementInput(
|
||||
VulnerabilityKey: "CVE-2025-0001",
|
||||
Advisory: advisory,
|
||||
AsOf: DateTimeOffset.Parse("2025-10-03T00:00:00Z"),
|
||||
InputDocumentIds: new[] { Guid.Parse("11111111-1111-1111-1111-111111111111") });
|
||||
|
||||
await log.AppendAsync(new AdvisoryEventAppendRequest(new[] { statementInput }), CancellationToken.None);
|
||||
|
||||
Assert.Single(repository.InsertedStatements);
|
||||
var entry = repository.InsertedStatements.Single();
|
||||
Assert.Equal("cve-2025-0001", entry.VulnerabilityKey);
|
||||
Assert.Equal("adv-1", entry.AdvisoryKey);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-10-03T00:00:00Z"), entry.AsOf);
|
||||
Assert.Contains("\"advisoryKey\":\"adv-1\"", entry.CanonicalJson);
|
||||
Assert.NotEqual(ImmutableArray<byte>.Empty, entry.StatementHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendAsync_PersistsConflictsWithCanonicalizedJson()
|
||||
{
|
||||
var repository = new FakeRepository();
|
||||
var timeProvider = new FixedTimeProvider(DateTimeOffset.Parse("2025-10-19T12:00:00Z"));
|
||||
var log = new AdvisoryEventLog(repository, timeProvider);
|
||||
|
||||
using var conflictJson = JsonDocument.Parse("{\"reason\":\"tie\",\"details\":{\"b\":2,\"a\":1}}");
|
||||
var conflictInput = new AdvisoryConflictInput(
|
||||
VulnerabilityKey: "CVE-2025-0001",
|
||||
Details: conflictJson,
|
||||
AsOf: DateTimeOffset.Parse("2025-10-04T00:00:00Z"),
|
||||
StatementIds: new[] { Guid.Parse("22222222-2222-2222-2222-222222222222") });
|
||||
|
||||
await log.AppendAsync(new AdvisoryEventAppendRequest(Array.Empty<AdvisoryStatementInput>(), new[] { conflictInput }), CancellationToken.None);
|
||||
|
||||
Assert.Single(repository.InsertedConflicts);
|
||||
var entry = repository.InsertedConflicts.Single();
|
||||
Assert.Equal("cve-2025-0001", entry.VulnerabilityKey);
|
||||
Assert.Equal("{\"details\":{\"a\":1,\"b\":2},\"reason\":\"tie\"}", entry.CanonicalJson);
|
||||
Assert.NotEqual(ImmutableArray<byte>.Empty, entry.ConflictHash);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-10-04T00:00:00Z"), entry.AsOf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_ReturnsSortedSnapshots()
|
||||
{
|
||||
var repository = new FakeRepository();
|
||||
var timeProvider = new FixedTimeProvider(DateTimeOffset.Parse("2025-10-05T00:00:00Z"));
|
||||
var log = new AdvisoryEventLog(repository, timeProvider);
|
||||
|
||||
repository.StoredStatements.AddRange(new[]
|
||||
{
|
||||
new AdvisoryStatementEntry(
|
||||
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
"cve-2025-0001",
|
||||
"adv-2",
|
||||
CanonicalJsonSerializer.Serialize(new Advisory(
|
||||
"adv-2",
|
||||
"B title",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
DateTimeOffset.Parse("2025-10-02T00:00:00Z"),
|
||||
null,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<AdvisoryReference>(),
|
||||
Array.Empty<AffectedPackage>(),
|
||||
Array.Empty<CvssMetric>(),
|
||||
Array.Empty<AdvisoryProvenance>())),
|
||||
ImmutableArray.Create(new byte[] { 0x01, 0x02 }),
|
||||
DateTimeOffset.Parse("2025-10-04T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-10-04T01:00:00Z"),
|
||||
ImmutableArray<Guid>.Empty),
|
||||
new AdvisoryStatementEntry(
|
||||
Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
"cve-2025-0001",
|
||||
"adv-1",
|
||||
CanonicalJsonSerializer.Serialize(new Advisory(
|
||||
"adv-1",
|
||||
"A title",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
DateTimeOffset.Parse("2025-10-01T00:00:00Z"),
|
||||
null,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<AdvisoryReference>(),
|
||||
Array.Empty<AffectedPackage>(),
|
||||
Array.Empty<CvssMetric>(),
|
||||
Array.Empty<AdvisoryProvenance>())),
|
||||
ImmutableArray.Create(new byte[] { 0x03, 0x04 }),
|
||||
DateTimeOffset.Parse("2025-10-03T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-10-04T02:00:00Z"),
|
||||
ImmutableArray<Guid>.Empty),
|
||||
});
|
||||
|
||||
repository.StoredConflicts.Add(new AdvisoryConflictEntry(
|
||||
Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
||||
"cve-2025-0001",
|
||||
CanonicalJson: "{\"reason\":\"conflict\"}",
|
||||
ConflictHash: ImmutableArray.Create(new byte[] { 0x10 }),
|
||||
AsOf: DateTimeOffset.Parse("2025-10-04T00:00:00Z"),
|
||||
RecordedAt: DateTimeOffset.Parse("2025-10-04T03:00:00Z"),
|
||||
StatementIds: ImmutableArray<Guid>.Empty));
|
||||
|
||||
var replay = await log.ReplayAsync("CVE-2025-0001", asOf: null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("cve-2025-0001", replay.VulnerabilityKey);
|
||||
Assert.Collection(
|
||||
replay.Statements,
|
||||
first => Assert.Equal("adv-2", first.AdvisoryKey),
|
||||
second => Assert.Equal("adv-1", second.AdvisoryKey));
|
||||
Assert.Single(replay.Conflicts);
|
||||
Assert.Equal("{\"reason\":\"conflict\"}", replay.Conflicts[0].CanonicalJson);
|
||||
}
|
||||
|
||||
private sealed class FakeRepository : IAdvisoryEventRepository
|
||||
{
|
||||
public List<AdvisoryStatementEntry> InsertedStatements { get; } = new();
|
||||
|
||||
public List<AdvisoryConflictEntry> InsertedConflicts { get; } = new();
|
||||
|
||||
public List<AdvisoryStatementEntry> StoredStatements { get; } = new();
|
||||
|
||||
public List<AdvisoryConflictEntry> StoredConflicts { get; } = new();
|
||||
|
||||
public ValueTask InsertStatementsAsync(IReadOnlyCollection<AdvisoryStatementEntry> statements, CancellationToken cancellationToken)
|
||||
{
|
||||
InsertedStatements.AddRange(statements);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask InsertConflictsAsync(IReadOnlyCollection<AdvisoryConflictEntry> conflicts, CancellationToken cancellationToken)
|
||||
{
|
||||
InsertedConflicts.AddRange(conflicts);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AdvisoryStatementEntry>> GetStatementsAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AdvisoryStatementEntry>>(StoredStatements.Where(entry =>
|
||||
string.Equals(entry.VulnerabilityKey, vulnerabilityKey, StringComparison.Ordinal) &&
|
||||
(!asOf.HasValue || entry.AsOf <= asOf.Value)).ToList());
|
||||
|
||||
public ValueTask<IReadOnlyList<AdvisoryConflictEntry>> GetConflictsAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AdvisoryConflictEntry>>(StoredConflicts.Where(entry =>
|
||||
string.Equals(entry.VulnerabilityKey, vulnerabilityKey, StringComparison.Ordinal) &&
|
||||
(!asOf.HasValue || entry.AsOf <= asOf.Value)).ToList());
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now.ToUniversalTime();
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Input payload for appending a canonical advisory statement to the event log.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryStatementInput(
|
||||
string VulnerabilityKey,
|
||||
Advisory Advisory,
|
||||
DateTimeOffset AsOf,
|
||||
IReadOnlyCollection<Guid> InputDocumentIds,
|
||||
Guid? StatementId = null,
|
||||
string? AdvisoryKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Input payload for appending an advisory conflict entry aligned with an advisory statement snapshot.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryConflictInput(
|
||||
string VulnerabilityKey,
|
||||
JsonDocument Details,
|
||||
DateTimeOffset AsOf,
|
||||
IReadOnlyCollection<Guid> StatementIds,
|
||||
Guid? ConflictId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Append request encapsulating statement and conflict batches sharing a single persistence window.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryEventAppendRequest(
|
||||
IReadOnlyCollection<AdvisoryStatementInput> Statements,
|
||||
IReadOnlyCollection<AdvisoryConflictInput>? Conflicts = null);
|
||||
|
||||
/// <summary>
|
||||
/// Replay response describing immutable statement snapshots for a vulnerability key.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryReplay(
|
||||
string VulnerabilityKey,
|
||||
DateTimeOffset? AsOf,
|
||||
ImmutableArray<AdvisoryStatementSnapshot> Statements,
|
||||
ImmutableArray<AdvisoryConflictSnapshot> Conflicts);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable advisory statement snapshot captured at a specific <c>asOf</c> time.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryStatementSnapshot(
|
||||
Guid StatementId,
|
||||
string VulnerabilityKey,
|
||||
string AdvisoryKey,
|
||||
Advisory Advisory,
|
||||
ImmutableArray<byte> StatementHash,
|
||||
DateTimeOffset AsOf,
|
||||
DateTimeOffset RecordedAt,
|
||||
ImmutableArray<Guid> InputDocumentIds);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable advisory conflict snapshot describing divergence explanations for a vulnerability key.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryConflictSnapshot(
|
||||
Guid ConflictId,
|
||||
string VulnerabilityKey,
|
||||
ImmutableArray<Guid> StatementIds,
|
||||
ImmutableArray<byte> ConflictHash,
|
||||
DateTimeOffset AsOf,
|
||||
DateTimeOffset RecordedAt,
|
||||
string CanonicalJson);
|
||||
|
||||
/// <summary>
|
||||
/// Persistence-facing representation of an advisory statement used by repositories.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryStatementEntry(
|
||||
Guid StatementId,
|
||||
string VulnerabilityKey,
|
||||
string AdvisoryKey,
|
||||
string CanonicalJson,
|
||||
ImmutableArray<byte> StatementHash,
|
||||
DateTimeOffset AsOf,
|
||||
DateTimeOffset RecordedAt,
|
||||
ImmutableArray<Guid> InputDocumentIds);
|
||||
|
||||
/// <summary>
|
||||
/// Persistence-facing representation of an advisory conflict used by repositories.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryConflictEntry(
|
||||
Guid ConflictId,
|
||||
string VulnerabilityKey,
|
||||
string CanonicalJson,
|
||||
ImmutableArray<byte> ConflictHash,
|
||||
DateTimeOffset AsOf,
|
||||
DateTimeOffset RecordedAt,
|
||||
ImmutableArray<Guid> StatementIds);
|
||||
297
src/StellaOps.Concelier.Core/Events/AdvisoryEventLog.cs
Normal file
297
src/StellaOps.Concelier.Core/Events/AdvisoryEventLog.cs
Normal file
@@ -0,0 +1,297 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IAdvisoryEventLog"/> that coordinates statement/conflict persistence.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryEventLog : IAdvisoryEventLog
|
||||
{
|
||||
private static readonly JsonWriterOptions CanonicalWriterOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false,
|
||||
SkipValidation = false,
|
||||
};
|
||||
|
||||
private readonly IAdvisoryEventRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AdvisoryEventLog(IAdvisoryEventRepository repository, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async ValueTask AppendAsync(AdvisoryEventAppendRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var statements = request.Statements ?? Array.Empty<AdvisoryStatementInput>();
|
||||
var conflicts = request.Conflicts ?? Array.Empty<AdvisoryConflictInput>();
|
||||
|
||||
if (statements.Count == 0 && conflicts.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var recordedAt = _timeProvider.GetUtcNow();
|
||||
var statementEntries = BuildStatementEntries(statements, recordedAt);
|
||||
var conflictEntries = BuildConflictEntries(conflicts, recordedAt);
|
||||
|
||||
if (statementEntries.Count > 0)
|
||||
{
|
||||
await _repository.InsertStatementsAsync(statementEntries, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (conflictEntries.Count > 0)
|
||||
{
|
||||
await _repository.InsertConflictsAsync(conflictEntries, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<AdvisoryReplay> ReplayAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityKey))
|
||||
{
|
||||
throw new ArgumentException("Vulnerability key must be provided.", nameof(vulnerabilityKey));
|
||||
}
|
||||
|
||||
var normalizedKey = NormalizeKey(vulnerabilityKey, nameof(vulnerabilityKey));
|
||||
var statements = await _repository.GetStatementsAsync(normalizedKey, asOf, cancellationToken).ConfigureAwait(false);
|
||||
var conflicts = await _repository.GetConflictsAsync(normalizedKey, asOf, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var statementSnapshots = statements
|
||||
.OrderByDescending(static entry => entry.AsOf)
|
||||
.ThenByDescending(static entry => entry.RecordedAt)
|
||||
.Select(ToStatementSnapshot)
|
||||
.ToImmutableArray();
|
||||
|
||||
var conflictSnapshots = conflicts
|
||||
.OrderByDescending(static entry => entry.AsOf)
|
||||
.ThenByDescending(static entry => entry.RecordedAt)
|
||||
.Select(ToConflictSnapshot)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AdvisoryReplay(normalizedKey, asOf, statementSnapshots, conflictSnapshots);
|
||||
}
|
||||
|
||||
private static AdvisoryStatementSnapshot ToStatementSnapshot(AdvisoryStatementEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
var advisory = CanonicalJsonSerializer.Deserialize<Advisory>(entry.CanonicalJson);
|
||||
return new AdvisoryStatementSnapshot(
|
||||
entry.StatementId,
|
||||
entry.VulnerabilityKey,
|
||||
entry.AdvisoryKey,
|
||||
advisory,
|
||||
entry.StatementHash,
|
||||
entry.AsOf,
|
||||
entry.RecordedAt,
|
||||
entry.InputDocumentIds);
|
||||
}
|
||||
|
||||
private static AdvisoryConflictSnapshot ToConflictSnapshot(AdvisoryConflictEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
return new AdvisoryConflictSnapshot(
|
||||
entry.ConflictId,
|
||||
entry.VulnerabilityKey,
|
||||
entry.StatementIds,
|
||||
entry.ConflictHash,
|
||||
entry.AsOf,
|
||||
entry.RecordedAt,
|
||||
entry.CanonicalJson);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<AdvisoryStatementEntry> BuildStatementEntries(
|
||||
IReadOnlyCollection<AdvisoryStatementInput> statements,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
return Array.Empty<AdvisoryStatementEntry>();
|
||||
}
|
||||
|
||||
var entries = new List<AdvisoryStatementEntry>(statements.Count);
|
||||
|
||||
foreach (var statement in statements)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
ArgumentNullException.ThrowIfNull(statement.Advisory);
|
||||
|
||||
var vulnerabilityKey = NormalizeKey(statement.VulnerabilityKey, nameof(statement.VulnerabilityKey));
|
||||
var advisory = CanonicalJsonSerializer.Normalize(statement.Advisory);
|
||||
var advisoryKey = string.IsNullOrWhiteSpace(statement.AdvisoryKey)
|
||||
? advisory.AdvisoryKey
|
||||
: statement.AdvisoryKey.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey))
|
||||
{
|
||||
throw new ArgumentException("Advisory key must be provided.", nameof(statement));
|
||||
}
|
||||
|
||||
if (!string.Equals(advisory.AdvisoryKey, advisoryKey, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Advisory key in payload must match provided advisory key.", nameof(statement));
|
||||
}
|
||||
|
||||
var canonicalJson = CanonicalJsonSerializer.Serialize(advisory);
|
||||
var hashBytes = ComputeHash(canonicalJson);
|
||||
var asOf = statement.AsOf.ToUniversalTime();
|
||||
var inputDocuments = statement.InputDocumentIds?.Count > 0
|
||||
? statement.InputDocumentIds
|
||||
.Where(static id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.OrderBy(static id => id)
|
||||
.ToImmutableArray()
|
||||
: ImmutableArray<Guid>.Empty;
|
||||
|
||||
entries.Add(new AdvisoryStatementEntry(
|
||||
statement.StatementId ?? Guid.NewGuid(),
|
||||
vulnerabilityKey,
|
||||
advisoryKey,
|
||||
canonicalJson,
|
||||
hashBytes,
|
||||
asOf,
|
||||
recordedAt,
|
||||
inputDocuments));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<AdvisoryConflictEntry> BuildConflictEntries(
|
||||
IReadOnlyCollection<AdvisoryConflictInput> conflicts,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
if (conflicts.Count == 0)
|
||||
{
|
||||
return Array.Empty<AdvisoryConflictEntry>();
|
||||
}
|
||||
|
||||
var entries = new List<AdvisoryConflictEntry>(conflicts.Count);
|
||||
|
||||
foreach (var conflict in conflicts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(conflict);
|
||||
ArgumentNullException.ThrowIfNull(conflict.Details);
|
||||
|
||||
var vulnerabilityKey = NormalizeKey(conflict.VulnerabilityKey, nameof(conflict.VulnerabilityKey));
|
||||
var canonicalJson = Canonicalize(conflict.Details.RootElement);
|
||||
var hashBytes = ComputeHash(canonicalJson);
|
||||
var asOf = conflict.AsOf.ToUniversalTime();
|
||||
var statementIds = conflict.StatementIds?.Count > 0
|
||||
? conflict.StatementIds
|
||||
.Where(static id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.OrderBy(static id => id)
|
||||
.ToImmutableArray()
|
||||
: ImmutableArray<Guid>.Empty;
|
||||
|
||||
entries.Add(new AdvisoryConflictEntry(
|
||||
conflict.ConflictId ?? Guid.NewGuid(),
|
||||
vulnerabilityKey,
|
||||
canonicalJson,
|
||||
hashBytes,
|
||||
asOf,
|
||||
recordedAt,
|
||||
statementIds));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static string NormalizeKey(string value, string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value must be provided.", parameterName);
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static ImmutableArray<byte> ComputeHash(string canonicalJson)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(canonicalJson);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return ImmutableArray.Create(hash);
|
||||
}
|
||||
|
||||
private static string Canonicalize(JsonElement element)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(stream, CanonicalWriterOptions))
|
||||
{
|
||||
WriteCanonical(element, writer);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(static p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(property.Value, writer);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(item, writer);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
writer.WriteRawValue(element.GetRawText());
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
writer.WriteBooleanValue(true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(false);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
case JsonValueKind.Undefined:
|
||||
default:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/StellaOps.Concelier.Core/Events/IAdvisoryEventLog.cs
Normal file
15
src/StellaOps.Concelier.Core/Events/IAdvisoryEventLog.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Events;
|
||||
|
||||
/// <summary>
|
||||
/// High-level API for recording and replaying advisory statements with deterministic as-of queries.
|
||||
/// </summary>
|
||||
public interface IAdvisoryEventLog
|
||||
{
|
||||
ValueTask AppendAsync(AdvisoryEventAppendRequest request, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<AdvisoryReplay> ReplayAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the persistence layer for advisory statements and conflicts.
|
||||
/// </summary>
|
||||
public interface IAdvisoryEventRepository
|
||||
{
|
||||
ValueTask InsertStatementsAsync(
|
||||
IReadOnlyCollection<AdvisoryStatementEntry> statements,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask InsertConflictsAsync(
|
||||
IReadOnlyCollection<AdvisoryConflictEntry> conflicts,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyList<AdvisoryStatementEntry>> GetStatementsAsync(
|
||||
string vulnerabilityKey,
|
||||
DateTimeOffset? asOf,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyList<AdvisoryConflictEntry>> GetConflictsAsync(
|
||||
string vulnerabilityKey,
|
||||
DateTimeOffset? asOf,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -3,12 +3,13 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Jobs;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
public static class JobPluginRegistrationExtensions
|
||||
{
|
||||
@@ -32,12 +33,14 @@ public static class JobPluginRegistrationExtensions
|
||||
var currentServices = services;
|
||||
var seenRoutineTypes = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var plugin in loadResult.Plugins)
|
||||
{
|
||||
foreach (var routineType in GetRoutineTypes(plugin.Assembly))
|
||||
{
|
||||
if (!typeof(IDependencyInjectionRoutine).IsAssignableFrom(routineType))
|
||||
{
|
||||
foreach (var plugin in loadResult.Plugins)
|
||||
{
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(currentServices, plugin.Assembly, logger);
|
||||
|
||||
foreach (var routineType in GetRoutineTypes(plugin.Assembly))
|
||||
{
|
||||
if (!typeof(IDependencyInjectionRoutine).IsAssignableFrom(routineType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# TASKS
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|JobCoordinator implementation (create/get/mark status)|BE-Core|Storage.Mongo|DONE – `JobCoordinator` drives Mongo-backed runs.|
|
||||
|Cron scheduling loop with TimeProvider|BE-Core|Core|DONE – `JobSchedulerHostedService` evaluates cron expressions.|
|
||||
|Single-flight/lease semantics|BE-Core|Storage.Mongo|DONE – lease acquisition backed by `MongoLeaseStore`.|
|
||||
|Trigger API contract (Result mapping)|BE-Core|WebService|DONE – `JobTriggerResult` outcomes map to HTTP statuses.|
|
||||
|Run telemetry enrichment|BE-Core|Observability|DONE – `JobDiagnostics` ties activities & counters into coordinator/scheduler paths.|
|
||||
|Deterministic params hashing|BE-Core|Core|DONE – `JobParametersHasher` creates SHA256 hash.|
|
||||
|Golden tests for timeout/cancel|QA|Core|DONE – JobCoordinatorTests cover cancellation timeout path.|
|
||||
|JobSchedulerBuilder options registry coverage|BE-Core|Core|DONE – added scheduler tests confirming cron/timeout/lease metadata persists via JobSchedulerOptions.|
|
||||
|Plugin discovery + DI glue with PluginHost|BE-Core|Plugin libs|DONE – JobPluginRegistrationExtensions now loads PluginHost routines and wires connector/exporter registrations.|
|
||||
|Harden lease release error handling in JobCoordinator|BE-Core|Storage.Mongo|DONE – lease release failures now logged, wrapped, and drive run failure status; fire-and-forget execution guarded. Verified with `dotnet test --no-build --filter JobCoordinator`.|
|
||||
# TASKS
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|JobCoordinator implementation (create/get/mark status)|BE-Core|Storage.Mongo|DONE – `JobCoordinator` drives Mongo-backed runs.|
|
||||
|Cron scheduling loop with TimeProvider|BE-Core|Core|DONE – `JobSchedulerHostedService` evaluates cron expressions.|
|
||||
|Single-flight/lease semantics|BE-Core|Storage.Mongo|DONE – lease acquisition backed by `MongoLeaseStore`.|
|
||||
|Trigger API contract (Result mapping)|BE-Core|WebService|DONE – `JobTriggerResult` outcomes map to HTTP statuses.|
|
||||
|Run telemetry enrichment|BE-Core|Observability|DONE – `JobDiagnostics` ties activities & counters into coordinator/scheduler paths.|
|
||||
|Deterministic params hashing|BE-Core|Core|DONE – `JobParametersHasher` creates SHA256 hash.|
|
||||
|Golden tests for timeout/cancel|QA|Core|DONE – JobCoordinatorTests cover cancellation timeout path.|
|
||||
|JobSchedulerBuilder options registry coverage|BE-Core|Core|DONE – added scheduler tests confirming cron/timeout/lease metadata persists via JobSchedulerOptions.|
|
||||
|Plugin discovery + DI glue with PluginHost|BE-Core|Plugin libs|DONE – JobPluginRegistrationExtensions now loads PluginHost routines and wires connector/exporter registrations.|
|
||||
|Harden lease release error handling in JobCoordinator|BE-Core|Storage.Mongo|DONE – lease release failures now logged, wrapped, and drive run failure status; fire-and-forget execution guarded. Verified with `dotnet test --no-build --filter JobCoordinator`.|
|
||||
|Validate job trigger parameters for serialization|BE-Core|WebService|DONE – trigger parameters normalized/serialized with defensive checks returning InvalidParameters on failure. Full-suite `dotnet test --no-build` currently red from live connector fixture drift (Oracle/JVN/RedHat).|
|
||||
|FEEDCORE-ENGINE-03-001 Canonical merger implementation|BE-Core|Merge|DONE – `CanonicalMerger` applies GHSA/NVD/OSV conflict rules with deterministic provenance and comprehensive unit coverage. **Coordination:** Connector leads must align mapper outputs with the canonical field expectations before 2025-10-18 so Merge can activate the path globally.|
|
||||
|FEEDCORE-ENGINE-03-002 Field precedence and tie-breaker map|BE-Core|Merge|DONE – field precedence and freshness overrides enforced via `FieldPrecedence` map with tie-breakers and analytics capture. **Reminder:** Storage/Merge owners review precedence overrides when onboarding new feeds to ensure `decisionReason` tagging stays consistent.|
|
||||
|Canonical merger parity for description/CWE/canonical metric|BE-Core|Models|DONE (2025-10-15) – merger now populates description/CWEs/canonical metric id with provenance and regression tests cover the new decisions.|
|
||||
|Reference normalization & freshness instrumentation cleanup|BE-Core, QA|Models|DONE (2025-10-15) – reference keys normalized, freshness overrides applied to union fields, and new tests assert decision logging.|
|
||||
|FEEDCORE-ENGINE-07-001 – Advisory event log & asOf queries|Team Core Engine & Storage Analytics|FEEDSTORAGE-DATA-07-001|TODO – Introduce immutable advisory statement events, expose `asOf` query surface for merge/export pipelines, and document determinism guarantees for replay.|
|
||||
|FEEDCORE-ENGINE-07-001 – Advisory event log & asOf queries|Team Core Engine & Storage Analytics|FEEDSTORAGE-DATA-07-001|**DONE (2025-10-19)** – Implemented `AdvisoryEventLog` service plus repository contracts, canonical hashing, and lower-cased key normalization with replay support; documented determinism guarantees. Tests: `dotnet test src/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj`.|
|
||||
|FEEDCORE-ENGINE-07-002 – Noise prior computation service|Team Core Engine & Data Science|FEEDCORE-ENGINE-07-001|TODO – Build rule-based learner capturing false-positive priors per package/env, persist summaries, and expose APIs for Excititor/scan suppressors with reproducible statistics.|
|
||||
|FEEDCORE-ENGINE-07-003 – Unknown state ledger & confidence seeding|Team Core Engine & Storage Analytics|FEEDCORE-ENGINE-07-001|TODO – Persist `unknown_vuln_range/unknown_origin/ambiguous_fix` markers with initial confidence bands, expose query surface for Policy, and add fixtures validating canonical serialization.|
|
||||
|
||||
@@ -42,10 +42,14 @@ public sealed class JsonExportSnapshotBuilderTests : IDisposable
|
||||
severity: "medium"),
|
||||
};
|
||||
|
||||
var result = await builder.WriteAsync(advisories, exportedAt, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(advisories.Length, result.AdvisoryCount);
|
||||
Assert.Equal(exportedAt, result.ExportedAt);
|
||||
var result = await builder.WriteAsync(advisories, exportedAt, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(advisories.Length, result.AdvisoryCount);
|
||||
Assert.Equal(advisories.Length, result.Advisories.Length);
|
||||
Assert.Equal(
|
||||
advisories.Select(a => a.AdvisoryKey).OrderBy(key => key, StringComparer.Ordinal),
|
||||
result.Advisories.Select(a => a.AdvisoryKey).OrderBy(key => key, StringComparer.Ordinal));
|
||||
Assert.Equal(exportedAt, result.ExportedAt);
|
||||
|
||||
var expectedFiles = result.FilePaths.OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
Assert.Contains("nvd/2024/CVE-2024-9999.json", expectedFiles);
|
||||
@@ -107,10 +111,11 @@ public sealed class JsonExportSnapshotBuilderTests : IDisposable
|
||||
};
|
||||
|
||||
var sequence = new SingleEnumerationAsyncSequence(advisories);
|
||||
var result = await builder.WriteAsync(sequence, exportedAt, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(advisories.Length, result.AdvisoryCount);
|
||||
}
|
||||
var result = await builder.WriteAsync(sequence, exportedAt, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(advisories.Length, result.AdvisoryCount);
|
||||
Assert.Equal(advisories.Length, result.Advisories.Length);
|
||||
}
|
||||
|
||||
private static Advisory CreateAdvisory(string advisoryKey, string[] aliases, string title, string severity)
|
||||
{
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Exporter.Json;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Exporting;
|
||||
@@ -20,10 +22,11 @@ public sealed class JsonExporterDependencyInjectionRoutineTests
|
||||
public void Register_AddsJobDefinitionAndServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IAdvisoryStore, StubAdvisoryStore>();
|
||||
services.AddSingleton<IExportStateStore, StubExportStateStore>();
|
||||
services.AddOptions<JobSchedulerOptions>();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IAdvisoryStore, StubAdvisoryStore>();
|
||||
services.AddSingleton<IExportStateStore, StubExportStateStore>();
|
||||
services.AddSingleton<IAdvisoryEventLog, StubAdvisoryEventLog>();
|
||||
services.AddOptions<JobSchedulerOptions>();
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
@@ -78,17 +81,34 @@ public sealed class JsonExporterDependencyInjectionRoutineTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubExportStateStore : IExportStateStore
|
||||
{
|
||||
private ExportStateRecord? _record;
|
||||
private sealed class StubExportStateStore : IExportStateStore
|
||||
{
|
||||
private ExportStateRecord? _record;
|
||||
|
||||
public Task<ExportStateRecord?> FindAsync(string id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_record);
|
||||
|
||||
public Task<ExportStateRecord> UpsertAsync(ExportStateRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_record = record;
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
public Task<ExportStateRecord?> FindAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_record);
|
||||
}
|
||||
|
||||
public Task<ExportStateRecord> UpsertAsync(ExportStateRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_record = record;
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubAdvisoryEventLog : IAdvisoryEventLog
|
||||
{
|
||||
public ValueTask AppendAsync(AdvisoryEventAppendRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<AdvisoryReplay> ReplayAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.FromResult(new AdvisoryReplay(
|
||||
vulnerabilityKey,
|
||||
asOf,
|
||||
ImmutableArray<AdvisoryStatementSnapshot>.Empty,
|
||||
ImmutableArray<AdvisoryConflictSnapshot>.Empty));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Exporter.Json;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Exporting;
|
||||
|
||||
namespace StellaOps.Concelier.Exporter.Json.Tests;
|
||||
|
||||
public sealed class JsonFeedExporterTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public JsonFeedExporterTests()
|
||||
{
|
||||
_root = Directory.CreateTempSubdirectory("concelier-json-exporter-tests").FullName;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_SkipsWhenDigestUnchanged()
|
||||
{
|
||||
var advisory = new Advisory(
|
||||
advisoryKey: "CVE-2024-1234",
|
||||
title: "Test Advisory",
|
||||
summary: null,
|
||||
language: "en",
|
||||
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z", CultureInfo.InvariantCulture),
|
||||
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z", CultureInfo.InvariantCulture),
|
||||
severity: "high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-1234" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: Array.Empty<AdvisoryProvenance>());
|
||||
|
||||
var advisoryStore = new StubAdvisoryStore(advisory);
|
||||
var options = Options.Create(new JsonExportOptions
|
||||
{
|
||||
OutputRoot = _root,
|
||||
MaintainLatestSymlink = false,
|
||||
});
|
||||
|
||||
var stateStore = new InMemoryExportStateStore();
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2024-07-15T12:00:00Z", CultureInfo.InvariantCulture));
|
||||
var stateManager = new ExportStateManager(stateStore, timeProvider);
|
||||
var exporter = new JsonFeedExporter(
|
||||
advisoryStore,
|
||||
options,
|
||||
new VulnListJsonExportPathResolver(),
|
||||
stateManager,
|
||||
NullLogger<JsonFeedExporter>.Instance,
|
||||
timeProvider);
|
||||
|
||||
using var provider = new ServiceCollection().BuildServiceProvider();
|
||||
await exporter.ExportAsync(provider, CancellationToken.None);
|
||||
|
||||
var record = await stateStore.FindAsync(JsonFeedExporter.ExporterId, CancellationToken.None);
|
||||
Assert.NotNull(record);
|
||||
var firstUpdated = record!.UpdatedAt;
|
||||
Assert.Equal("20240715T120000Z", record.BaseExportId);
|
||||
Assert.Equal(record.LastFullDigest, record.ExportCursor);
|
||||
|
||||
var firstExportPath = Path.Combine(_root, "20240715T120000Z");
|
||||
Assert.True(Directory.Exists(firstExportPath));
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
await exporter.ExportAsync(provider, CancellationToken.None);
|
||||
|
||||
record = await stateStore.FindAsync(JsonFeedExporter.ExporterId, CancellationToken.None);
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(firstUpdated, record!.UpdatedAt);
|
||||
|
||||
var secondExportPath = Path.Combine(_root, "20240715T120500Z");
|
||||
Assert.False(Directory.Exists(secondExportPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WritesManifestMetadata()
|
||||
{
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Concelier.Exporter.Json.Tests;
|
||||
|
||||
public sealed class JsonFeedExporterTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public JsonFeedExporterTests()
|
||||
{
|
||||
_root = Directory.CreateTempSubdirectory("concelier-json-exporter-tests").FullName;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_SkipsWhenDigestUnchanged()
|
||||
{
|
||||
var advisory = new Advisory(
|
||||
advisoryKey: "CVE-2024-1234",
|
||||
title: "Test Advisory",
|
||||
summary: null,
|
||||
language: "en",
|
||||
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z", CultureInfo.InvariantCulture),
|
||||
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z", CultureInfo.InvariantCulture),
|
||||
severity: "high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-1234" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: Array.Empty<AdvisoryProvenance>());
|
||||
|
||||
var advisoryStore = new StubAdvisoryStore(advisory);
|
||||
var options = Options.Create(new JsonExportOptions
|
||||
{
|
||||
OutputRoot = _root,
|
||||
MaintainLatestSymlink = false,
|
||||
});
|
||||
|
||||
var stateStore = new InMemoryExportStateStore();
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2024-07-15T12:00:00Z", CultureInfo.InvariantCulture));
|
||||
var stateManager = new ExportStateManager(stateStore, timeProvider);
|
||||
var eventLog = new StubAdvisoryEventLog(new[] { advisory }, timeProvider.GetUtcNow());
|
||||
var exporter = new JsonFeedExporter(
|
||||
advisoryStore,
|
||||
options,
|
||||
new VulnListJsonExportPathResolver(),
|
||||
stateManager,
|
||||
eventLog,
|
||||
NullLogger<JsonFeedExporter>.Instance,
|
||||
timeProvider);
|
||||
|
||||
using var provider = new ServiceCollection().BuildServiceProvider();
|
||||
await exporter.ExportAsync(provider, CancellationToken.None);
|
||||
|
||||
var record = await stateStore.FindAsync(JsonFeedExporter.ExporterId, CancellationToken.None);
|
||||
Assert.NotNull(record);
|
||||
var firstUpdated = record!.UpdatedAt;
|
||||
Assert.Equal("20240715T120000Z", record.BaseExportId);
|
||||
Assert.Equal(record.LastFullDigest, record.ExportCursor);
|
||||
|
||||
var firstExportPath = Path.Combine(_root, "20240715T120000Z");
|
||||
Assert.True(Directory.Exists(firstExportPath));
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
await exporter.ExportAsync(provider, CancellationToken.None);
|
||||
|
||||
record = await stateStore.FindAsync(JsonFeedExporter.ExporterId, CancellationToken.None);
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(firstUpdated, record!.UpdatedAt);
|
||||
|
||||
var secondExportPath = Path.Combine(_root, "20240715T120500Z");
|
||||
Assert.False(Directory.Exists(secondExportPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WritesManifestMetadata()
|
||||
{
|
||||
var exportedAt = DateTimeOffset.Parse("2024-08-10T00:00:00Z", CultureInfo.InvariantCulture);
|
||||
var recordedAt = DateTimeOffset.Parse("2024-07-02T00:00:00Z", CultureInfo.InvariantCulture);
|
||||
var reference = new AdvisoryReference(
|
||||
@@ -135,52 +142,54 @@ public sealed class JsonFeedExporterTests : IDisposable
|
||||
description: "Detailed description capturing remediation steps.",
|
||||
cwes: new[] { weakness },
|
||||
canonicalMetricId: "3.1|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H");
|
||||
|
||||
var advisoryStore = new StubAdvisoryStore(advisory);
|
||||
var optionsValue = new JsonExportOptions
|
||||
{
|
||||
OutputRoot = _root,
|
||||
MaintainLatestSymlink = false,
|
||||
};
|
||||
|
||||
var options = Options.Create(optionsValue);
|
||||
var stateStore = new InMemoryExportStateStore();
|
||||
var timeProvider = new TestTimeProvider(exportedAt);
|
||||
var stateManager = new ExportStateManager(stateStore, timeProvider);
|
||||
var exporter = new JsonFeedExporter(
|
||||
advisoryStore,
|
||||
options,
|
||||
new VulnListJsonExportPathResolver(),
|
||||
stateManager,
|
||||
NullLogger<JsonFeedExporter>.Instance,
|
||||
timeProvider);
|
||||
|
||||
using var provider = new ServiceCollection().BuildServiceProvider();
|
||||
await exporter.ExportAsync(provider, CancellationToken.None);
|
||||
|
||||
var exportId = exportedAt.ToString(optionsValue.DirectoryNameFormat, CultureInfo.InvariantCulture);
|
||||
var exportDirectory = Path.Combine(_root, exportId);
|
||||
var manifestPath = Path.Combine(exportDirectory, "manifest.json");
|
||||
|
||||
Assert.True(File.Exists(manifestPath));
|
||||
|
||||
using var document = JsonDocument.Parse(await File.ReadAllBytesAsync(manifestPath, CancellationToken.None));
|
||||
var root = document.RootElement;
|
||||
|
||||
Assert.Equal(exportId, root.GetProperty("exportId").GetString());
|
||||
Assert.Equal(exportedAt.UtcDateTime, root.GetProperty("generatedAt").GetDateTime());
|
||||
Assert.Equal(1, root.GetProperty("advisoryCount").GetInt32());
|
||||
|
||||
var exportedFiles = Directory.EnumerateFiles(exportDirectory, "*.json", SearchOption.AllDirectories)
|
||||
.Select(path => new
|
||||
{
|
||||
Absolute = path,
|
||||
Relative = Path.GetRelativePath(exportDirectory, path).Replace("\\", "/", StringComparison.Ordinal),
|
||||
})
|
||||
.Where(file => !string.Equals(file.Relative, "manifest.json", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(file => file.Relative, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
|
||||
var advisoryStore = new StubAdvisoryStore(advisory);
|
||||
var optionsValue = new JsonExportOptions
|
||||
{
|
||||
OutputRoot = _root,
|
||||
MaintainLatestSymlink = false,
|
||||
};
|
||||
|
||||
var options = Options.Create(optionsValue);
|
||||
var stateStore = new InMemoryExportStateStore();
|
||||
var timeProvider = new TestTimeProvider(exportedAt);
|
||||
var stateManager = new ExportStateManager(stateStore, timeProvider);
|
||||
var eventLog = new StubAdvisoryEventLog(new[] { advisory }, exportedAt);
|
||||
var exporter = new JsonFeedExporter(
|
||||
advisoryStore,
|
||||
options,
|
||||
new VulnListJsonExportPathResolver(),
|
||||
stateManager,
|
||||
eventLog,
|
||||
NullLogger<JsonFeedExporter>.Instance,
|
||||
timeProvider);
|
||||
|
||||
using var provider = new ServiceCollection().BuildServiceProvider();
|
||||
await exporter.ExportAsync(provider, CancellationToken.None);
|
||||
|
||||
var exportId = exportedAt.ToString(optionsValue.DirectoryNameFormat, CultureInfo.InvariantCulture);
|
||||
var exportDirectory = Path.Combine(_root, exportId);
|
||||
var manifestPath = Path.Combine(exportDirectory, "manifest.json");
|
||||
|
||||
Assert.True(File.Exists(manifestPath));
|
||||
|
||||
using var document = JsonDocument.Parse(await File.ReadAllBytesAsync(manifestPath, CancellationToken.None));
|
||||
var root = document.RootElement;
|
||||
|
||||
Assert.Equal(exportId, root.GetProperty("exportId").GetString());
|
||||
Assert.Equal(exportedAt.UtcDateTime, root.GetProperty("generatedAt").GetDateTime());
|
||||
Assert.Equal(1, root.GetProperty("advisoryCount").GetInt32());
|
||||
|
||||
var exportedFiles = Directory.EnumerateFiles(exportDirectory, "*.json", SearchOption.AllDirectories)
|
||||
.Select(path => new
|
||||
{
|
||||
Absolute = path,
|
||||
Relative = Path.GetRelativePath(exportDirectory, path).Replace("\\", "/", StringComparison.Ordinal),
|
||||
})
|
||||
.Where(file => !string.Equals(file.Relative, "manifest.json", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(file => file.Relative, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var filesElement = root.GetProperty("files")
|
||||
.EnumerateArray()
|
||||
.Select(element => new
|
||||
@@ -208,58 +217,278 @@ public sealed class JsonFeedExporterTests : IDisposable
|
||||
}
|
||||
|
||||
Assert.Equal(exportedFiles.Select(file => file.Relative).ToArray(), filesElement.Select(file => file.Path).ToArray());
|
||||
|
||||
long totalBytes = exportedFiles.Select(file => new FileInfo(file.Absolute).Length).Sum();
|
||||
Assert.Equal(totalBytes, root.GetProperty("totalBytes").GetInt64());
|
||||
Assert.Equal(exportedFiles.Length, root.GetProperty("fileCount").GetInt32());
|
||||
|
||||
var digest = root.GetProperty("digest").GetString();
|
||||
var digestResult = new JsonExportResult(
|
||||
exportDirectory,
|
||||
exportedAt,
|
||||
exportedFiles.Select(file =>
|
||||
{
|
||||
var manifestEntry = filesElement.First(f => f.Path == file.Relative);
|
||||
if (manifestEntry.Digest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest entry for {file.Relative} missing digest.");
|
||||
}
|
||||
|
||||
return new JsonExportFile(file.Relative, new FileInfo(file.Absolute).Length, manifestEntry.Digest);
|
||||
}),
|
||||
exportedFiles.Length,
|
||||
totalBytes);
|
||||
var expectedDigest = ExportDigestCalculator.ComputeTreeDigest(digestResult);
|
||||
Assert.Equal(expectedDigest, digest);
|
||||
|
||||
var exporterVersion = root.GetProperty("exporterVersion").GetString();
|
||||
Assert.Equal(ExporterVersion.GetVersion(typeof(JsonFeedExporter)), exporterVersion);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubAdvisoryStore : IAdvisoryStore
|
||||
{
|
||||
private readonly IReadOnlyList<Advisory> _advisories;
|
||||
|
||||
public StubAdvisoryStore(params Advisory[] advisories)
|
||||
{
|
||||
_advisories = advisories;
|
||||
}
|
||||
|
||||
|
||||
long totalBytes = exportedFiles.Select(file => new FileInfo(file.Absolute).Length).Sum();
|
||||
Assert.Equal(totalBytes, root.GetProperty("totalBytes").GetInt64());
|
||||
Assert.Equal(exportedFiles.Length, root.GetProperty("fileCount").GetInt32());
|
||||
|
||||
var digest = root.GetProperty("digest").GetString();
|
||||
var digestResult = new JsonExportResult(
|
||||
exportDirectory,
|
||||
exportedAt,
|
||||
exportedFiles.Select(file =>
|
||||
{
|
||||
var manifestEntry = filesElement.First(f => f.Path == file.Relative);
|
||||
if (manifestEntry.Digest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest entry for {file.Relative} missing digest.");
|
||||
}
|
||||
|
||||
return new JsonExportFile(file.Relative, new FileInfo(file.Absolute).Length, manifestEntry.Digest);
|
||||
}),
|
||||
exportedFiles.Length,
|
||||
totalBytes);
|
||||
var expectedDigest = ExportDigestCalculator.ComputeTreeDigest(digestResult);
|
||||
Assert.Equal(expectedDigest, digest);
|
||||
|
||||
var exporterVersion = root.GetProperty("exporterVersion").GetString();
|
||||
Assert.Equal(ExporterVersion.GetVersion(typeof(JsonFeedExporter)), exporterVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WritesMirrorBundlesWithSignatures()
|
||||
{
|
||||
var exportedAt = DateTimeOffset.Parse("2025-01-05T00:00:00Z", CultureInfo.InvariantCulture);
|
||||
var advisoryOne = new Advisory(
|
||||
advisoryKey: "CVE-2025-0001",
|
||||
title: "Mirror Advisory One",
|
||||
summary: null,
|
||||
language: "en",
|
||||
published: exportedAt.AddDays(-10),
|
||||
modified: exportedAt.AddDays(-9),
|
||||
severity: "high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-0001", "GHSA-aaaa-bbbb-cccc" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("ghsa", "map", "GHSA-aaaa-bbbb-cccc", exportedAt.AddDays(-9)),
|
||||
new AdvisoryProvenance("nvd", "map", "CVE-2025-0001", exportedAt.AddDays(-8)),
|
||||
});
|
||||
|
||||
var advisoryTwo = new Advisory(
|
||||
advisoryKey: "CVE-2025-0002",
|
||||
title: "Mirror Advisory Two",
|
||||
summary: null,
|
||||
language: "en",
|
||||
published: exportedAt.AddDays(-6),
|
||||
modified: exportedAt.AddDays(-5),
|
||||
severity: "medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-0002" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("nvd", "map", "CVE-2025-0002", exportedAt.AddDays(-5)),
|
||||
new AdvisoryProvenance("vendor", "map", "ADVISORY-0002", exportedAt.AddDays(-4)),
|
||||
});
|
||||
|
||||
var advisoryStore = new StubAdvisoryStore(advisoryOne, advisoryTwo);
|
||||
var optionsValue = new JsonExportOptions
|
||||
{
|
||||
OutputRoot = _root,
|
||||
MaintainLatestSymlink = false,
|
||||
TargetRepository = "s3://mirror/concelier"
|
||||
};
|
||||
|
||||
optionsValue.Mirror.Enabled = true;
|
||||
optionsValue.Mirror.DirectoryName = "mirror";
|
||||
optionsValue.Mirror.Domains.Add(new JsonExportOptions.JsonMirrorDomainOptions
|
||||
{
|
||||
Id = "primary",
|
||||
DisplayName = "Primary"
|
||||
});
|
||||
|
||||
optionsValue.Mirror.Signing.Enabled = true;
|
||||
optionsValue.Mirror.Signing.KeyId = "mirror-signing-key";
|
||||
optionsValue.Mirror.Signing.Algorithm = SignatureAlgorithms.Es256;
|
||||
optionsValue.Mirror.Signing.KeyPath = WriteSigningKey(_root);
|
||||
|
||||
var options = Options.Create(optionsValue);
|
||||
var stateStore = new InMemoryExportStateStore();
|
||||
var timeProvider = new TestTimeProvider(exportedAt);
|
||||
var stateManager = new ExportStateManager(stateStore, timeProvider);
|
||||
var eventLog = new StubAdvisoryEventLog(new[] { advisoryOne, advisoryTwo }, exportedAt);
|
||||
var exporter = new JsonFeedExporter(
|
||||
advisoryStore,
|
||||
options,
|
||||
new VulnListJsonExportPathResolver(),
|
||||
stateManager,
|
||||
eventLog,
|
||||
NullLogger<JsonFeedExporter>.Instance,
|
||||
timeProvider);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<DefaultCryptoProvider>();
|
||||
services.AddSingleton<ICryptoProvider>(sp => sp.GetRequiredService<DefaultCryptoProvider>());
|
||||
services.AddSingleton<ICryptoProviderRegistry>(sp =>
|
||||
{
|
||||
var provider = sp.GetRequiredService<DefaultCryptoProvider>();
|
||||
return new CryptoProviderRegistry(new[] { provider });
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
await exporter.ExportAsync(provider, CancellationToken.None);
|
||||
|
||||
var exportId = exportedAt.ToString(optionsValue.DirectoryNameFormat, CultureInfo.InvariantCulture);
|
||||
var exportDirectory = Path.Combine(_root, exportId);
|
||||
var mirrorDirectory = Path.Combine(exportDirectory, "mirror");
|
||||
var domainDirectory = Path.Combine(mirrorDirectory, "primary");
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(mirrorDirectory, "index.json")));
|
||||
Assert.True(File.Exists(Path.Combine(domainDirectory, "bundle.json")));
|
||||
Assert.True(File.Exists(Path.Combine(domainDirectory, "bundle.json.jws")));
|
||||
Assert.True(File.Exists(Path.Combine(domainDirectory, "manifest.json")));
|
||||
|
||||
var record = await stateStore.FindAsync(JsonFeedExporter.ExporterId, CancellationToken.None);
|
||||
Assert.NotNull(record);
|
||||
Assert.Contains(record!.Files, file => string.Equals(file.Path, "mirror/index.json", StringComparison.Ordinal));
|
||||
Assert.Contains(record.Files, file => string.Equals(file.Path, "mirror/primary/manifest.json", StringComparison.Ordinal));
|
||||
|
||||
var indexPath = Path.Combine(mirrorDirectory, "index.json");
|
||||
using (var indexDoc = JsonDocument.Parse(await File.ReadAllBytesAsync(indexPath, CancellationToken.None)))
|
||||
{
|
||||
var indexRoot = indexDoc.RootElement;
|
||||
Assert.Equal("s3://mirror/concelier", indexRoot.GetProperty("targetRepository").GetString());
|
||||
|
||||
var domains = indexRoot.GetProperty("domains").EnumerateArray().ToArray();
|
||||
var domain = Assert.Single(domains);
|
||||
Assert.Equal("primary", domain.GetProperty("domainId").GetString());
|
||||
Assert.Equal("Primary", domain.GetProperty("displayName").GetString());
|
||||
Assert.Equal(2, domain.GetProperty("advisoryCount").GetInt32());
|
||||
|
||||
var bundleDescriptor = domain.GetProperty("bundle");
|
||||
Assert.Equal("mirror/primary/bundle.json", bundleDescriptor.GetProperty("path").GetString());
|
||||
var signatureDescriptor = bundleDescriptor.GetProperty("signature");
|
||||
Assert.Equal("mirror/primary/bundle.json.jws", signatureDescriptor.GetProperty("path").GetString());
|
||||
|
||||
var manifestDescriptor = domain.GetProperty("manifest");
|
||||
Assert.Equal("mirror/primary/manifest.json", manifestDescriptor.GetProperty("path").GetString());
|
||||
}
|
||||
|
||||
var bundlePathRel = "mirror/primary/bundle.json";
|
||||
var manifestPathRel = "mirror/primary/manifest.json";
|
||||
var signaturePathRel = "mirror/primary/bundle.json.jws";
|
||||
|
||||
var bundlePath = Path.Combine(exportDirectory, bundlePathRel.Replace('/', Path.DirectorySeparatorChar));
|
||||
var manifestPath = Path.Combine(exportDirectory, manifestPathRel.Replace('/', Path.DirectorySeparatorChar));
|
||||
var signaturePath = Path.Combine(exportDirectory, signaturePathRel.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
using (var bundleDoc = JsonDocument.Parse(await File.ReadAllBytesAsync(bundlePath, CancellationToken.None)))
|
||||
{
|
||||
var bundleRoot = bundleDoc.RootElement;
|
||||
Assert.Equal("primary", bundleRoot.GetProperty("domainId").GetString());
|
||||
Assert.Equal(2, bundleRoot.GetProperty("advisoryCount").GetInt32());
|
||||
Assert.Equal("s3://mirror/concelier", bundleRoot.GetProperty("targetRepository").GetString());
|
||||
Assert.Equal(2, bundleRoot.GetProperty("advisories").GetArrayLength());
|
||||
|
||||
var sources = bundleRoot.GetProperty("sources").EnumerateArray().Select(element => element.GetProperty("source").GetString()).ToArray();
|
||||
Assert.Contains("ghsa", sources);
|
||||
Assert.Contains("nvd", sources);
|
||||
Assert.Contains("vendor", sources);
|
||||
}
|
||||
|
||||
using (var manifestDoc = JsonDocument.Parse(await File.ReadAllBytesAsync(manifestPath, CancellationToken.None)))
|
||||
{
|
||||
var manifestRoot = manifestDoc.RootElement;
|
||||
Assert.Equal("primary", manifestRoot.GetProperty("domainId").GetString());
|
||||
Assert.Equal(2, manifestRoot.GetProperty("advisoryCount").GetInt32());
|
||||
Assert.Equal("mirror/primary/bundle.json", manifestRoot.GetProperty("bundle").GetProperty("path").GetString());
|
||||
}
|
||||
|
||||
var bundleBytes = await File.ReadAllBytesAsync(bundlePath, CancellationToken.None);
|
||||
var signatureValue = await File.ReadAllTextAsync(signaturePath, CancellationToken.None);
|
||||
var signatureParts = signatureValue.Split("..", StringSplitOptions.None);
|
||||
Assert.Equal(2, signatureParts.Length);
|
||||
|
||||
var signingInput = BuildSigningInput(signatureParts[0], bundleBytes);
|
||||
var signatureBytes = Base64UrlDecode(signatureParts[1]);
|
||||
|
||||
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
|
||||
var verification = registry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
optionsValue.Mirror.Signing.Algorithm,
|
||||
new CryptoKeyReference(optionsValue.Mirror.Signing.KeyId, optionsValue.Mirror.Signing.Provider),
|
||||
optionsValue.Mirror.Signing.Provider);
|
||||
var verified = await verification.Signer.VerifyAsync(signingInput, signatureBytes, CancellationToken.None);
|
||||
Assert.True(verified);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
private static string WriteSigningKey(string directory)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var pkcs8 = ecdsa.ExportPkcs8PrivateKey();
|
||||
var pem = BuildPem("PRIVATE KEY", pkcs8);
|
||||
var path = Path.Combine(directory, $"mirror-key-{Guid.NewGuid():N}.pem");
|
||||
File.WriteAllText(path, pem);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string BuildPem(string label, byte[] data)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(data, Base64FormattingOptions.InsertLineBreaks);
|
||||
return $"-----BEGIN {label}-----\n{base64}\n-----END {label}-----\n";
|
||||
}
|
||||
|
||||
private static byte[] BuildSigningInput(string protectedHeader, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.ASCII.GetBytes(protectedHeader);
|
||||
var buffer = new byte[headerBytes.Length + 1 + payload.Length];
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length);
|
||||
buffer[headerBytes.Length] = (byte)'.';
|
||||
Buffer.BlockCopy(payload, 0, buffer, headerBytes.Length + 1, payload.Length);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string value)
|
||||
{
|
||||
var builder = new StringBuilder(value.Length + 3);
|
||||
foreach (var ch in value)
|
||||
{
|
||||
builder.Append(ch switch
|
||||
{
|
||||
'-' => '+',
|
||||
'_' => '/',
|
||||
_ => ch
|
||||
});
|
||||
}
|
||||
|
||||
while (builder.Length % 4 != 0)
|
||||
{
|
||||
builder.Append('=');
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(builder.ToString());
|
||||
}
|
||||
|
||||
private sealed class StubAdvisoryStore : IAdvisoryStore
|
||||
{
|
||||
private readonly IReadOnlyList<Advisory> _advisories;
|
||||
|
||||
public StubAdvisoryStore(params Advisory[] advisories)
|
||||
{
|
||||
_advisories = advisories;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_ = session;
|
||||
@@ -285,38 +514,84 @@ public sealed class JsonFeedExporterTests : IDisposable
|
||||
|
||||
async IAsyncEnumerable<Advisory> EnumerateAsync([EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
foreach (var advisory in _advisories)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
yield return advisory;
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryExportStateStore : IExportStateStore
|
||||
{
|
||||
private ExportStateRecord? _record;
|
||||
|
||||
public Task<ExportStateRecord?> FindAsync(string id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_record);
|
||||
|
||||
public Task<ExportStateRecord> UpsertAsync(ExportStateRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_record = record;
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public TestTimeProvider(DateTimeOffset start) => _now = start;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
|
||||
}
|
||||
}
|
||||
foreach (var advisory in _advisories)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
yield return advisory;
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubAdvisoryEventLog : IAdvisoryEventLog
|
||||
{
|
||||
private readonly Dictionary<string, Advisory> _advisories;
|
||||
private readonly DateTimeOffset _recordedAt;
|
||||
|
||||
public StubAdvisoryEventLog(IEnumerable<Advisory> advisories, DateTimeOffset recordedAt)
|
||||
{
|
||||
_advisories = advisories.ToDictionary(advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase);
|
||||
_recordedAt = recordedAt;
|
||||
}
|
||||
|
||||
public ValueTask AppendAsync(AdvisoryEventAppendRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<AdvisoryReplay> ReplayAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_advisories.TryGetValue(vulnerabilityKey, out var advisory))
|
||||
{
|
||||
var asOfTimestamp = advisory.Modified ?? advisory.Published ?? _recordedAt;
|
||||
var snapshot = new AdvisoryStatementSnapshot(
|
||||
Guid.NewGuid(),
|
||||
vulnerabilityKey,
|
||||
advisory.AdvisoryKey,
|
||||
advisory,
|
||||
ImmutableArray<byte>.Empty,
|
||||
asOfTimestamp,
|
||||
_recordedAt,
|
||||
ImmutableArray<Guid>.Empty);
|
||||
|
||||
return ValueTask.FromResult(new AdvisoryReplay(
|
||||
vulnerabilityKey,
|
||||
asOf,
|
||||
ImmutableArray.Create(snapshot),
|
||||
ImmutableArray<AdvisoryConflictSnapshot>.Empty));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(new AdvisoryReplay(
|
||||
vulnerabilityKey,
|
||||
asOf,
|
||||
ImmutableArray<AdvisoryStatementSnapshot>.Empty,
|
||||
ImmutableArray<AdvisoryConflictSnapshot>.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryExportStateStore : IExportStateStore
|
||||
{
|
||||
private ExportStateRecord? _record;
|
||||
|
||||
public Task<ExportStateRecord?> FindAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_record);
|
||||
}
|
||||
|
||||
public Task<ExportStateRecord> UpsertAsync(ExportStateRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_record = record;
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public TestTimeProvider(DateTimeOffset start) => _now = start;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Concelier.Exporter.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for JSON exporter output paths and determinism controls.
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Concelier.Exporter.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for JSON exporter output paths and determinism controls.
|
||||
/// </summary>
|
||||
public sealed class JsonExportOptions
|
||||
{
|
||||
@@ -27,8 +29,87 @@ public sealed class JsonExportOptions
|
||||
/// </summary>
|
||||
public bool MaintainLatestSymlink { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional repository identifier recorded alongside export state metadata.
|
||||
/// </summary>
|
||||
public string? TargetRepository { get; set; }
|
||||
}
|
||||
/// <summary>
|
||||
/// Optional repository identifier recorded alongside export state metadata.
|
||||
/// </summary>
|
||||
public string? TargetRepository { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Mirror distribution configuration producing aggregate bundles for downstream mirrors.
|
||||
/// </summary>
|
||||
public JsonMirrorOptions Mirror { get; set; } = new();
|
||||
|
||||
public sealed class JsonMirrorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether mirror bundle generation is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory name (relative to the export root) where mirror artefacts are written.
|
||||
/// </summary>
|
||||
public string DirectoryName { get; set; } = "mirror";
|
||||
|
||||
/// <summary>
|
||||
/// Domains exposed to downstream mirrors.
|
||||
/// </summary>
|
||||
public IList<JsonMirrorDomainOptions> Domains { get; } = new List<JsonMirrorDomainOptions>();
|
||||
|
||||
/// <summary>
|
||||
/// Signing configuration for mirror bundles.
|
||||
/// </summary>
|
||||
public JsonMirrorSigningOptions Signing { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class JsonMirrorDomainOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable identifier for the mirror domain (used in URLs and directory names).
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-readable label for UI surfaces.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional advisory scheme filters (e.g. CVE, GHSA). Empty collection selects all schemes.
|
||||
/// </summary>
|
||||
public IList<string> IncludeSchemes { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Optional provenance source filters (e.g. nvd, ghsa). Empty collection selects all sources.
|
||||
/// </summary>
|
||||
public IList<string> IncludeSources { get; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class JsonMirrorSigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether bundles should be signed. Defaults to disabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm identifier (defaults to ES256).
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = SignatureAlgorithms.Es256;
|
||||
|
||||
/// <summary>
|
||||
/// Active signing key identifier.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the private key (PEM) used for signing mirror bundles.
|
||||
/// </summary>
|
||||
public string KeyPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional crypto provider hint. When omitted the registry resolves an appropriate provider.
|
||||
/// </summary>
|
||||
public string? Provider { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Concelier.Exporter.Json;
|
||||
|
||||
public sealed class JsonExportResult
|
||||
{
|
||||
public JsonExportResult(
|
||||
string exportDirectory,
|
||||
DateTimeOffset exportedAt,
|
||||
IEnumerable<JsonExportFile> files,
|
||||
int advisoryCount,
|
||||
long totalBytes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(exportDirectory))
|
||||
{
|
||||
throw new ArgumentException("Export directory must be provided.", nameof(exportDirectory));
|
||||
}
|
||||
|
||||
ExportDirectory = exportDirectory;
|
||||
ExportedAt = exportedAt;
|
||||
AdvisoryCount = advisoryCount;
|
||||
TotalBytes = totalBytes;
|
||||
|
||||
var list = (files ?? throw new ArgumentNullException(nameof(files)))
|
||||
.Where(static file => file is not null)
|
||||
.ToImmutableArray();
|
||||
|
||||
Files = list;
|
||||
FilePaths = list.Select(static file => file.RelativePath).ToImmutableArray();
|
||||
}
|
||||
|
||||
public string ExportDirectory { get; }
|
||||
|
||||
public DateTimeOffset ExportedAt { get; }
|
||||
|
||||
public ImmutableArray<JsonExportFile> Files { get; }
|
||||
|
||||
public ImmutableArray<string> FilePaths { get; }
|
||||
|
||||
public int AdvisoryCount { get; }
|
||||
|
||||
public long TotalBytes { get; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Exporter.Json;
|
||||
|
||||
public sealed class JsonExportResult
|
||||
{
|
||||
public JsonExportResult(
|
||||
string exportDirectory,
|
||||
DateTimeOffset exportedAt,
|
||||
IEnumerable<JsonExportFile> files,
|
||||
int advisoryCount,
|
||||
long totalBytes,
|
||||
IEnumerable<Advisory>? advisories = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(exportDirectory))
|
||||
{
|
||||
throw new ArgumentException("Export directory must be provided.", nameof(exportDirectory));
|
||||
}
|
||||
|
||||
var list = (files ?? throw new ArgumentNullException(nameof(files)))
|
||||
.Where(static file => file is not null)
|
||||
.ToImmutableArray();
|
||||
|
||||
var advisoryList = (advisories ?? Array.Empty<Advisory>())
|
||||
.Where(static advisory => advisory is not null)
|
||||
.ToImmutableArray();
|
||||
|
||||
ExportDirectory = exportDirectory;
|
||||
ExportedAt = exportedAt;
|
||||
TotalBytes = totalBytes;
|
||||
|
||||
Files = list;
|
||||
FilePaths = list.Select(static file => file.RelativePath).ToImmutableArray();
|
||||
Advisories = advisoryList;
|
||||
AdvisoryCount = advisoryList.IsDefaultOrEmpty ? advisoryCount : advisoryList.Length;
|
||||
}
|
||||
|
||||
public string ExportDirectory { get; }
|
||||
|
||||
public DateTimeOffset ExportedAt { get; }
|
||||
|
||||
public ImmutableArray<JsonExportFile> Files { get; }
|
||||
|
||||
public ImmutableArray<string> FilePaths { get; }
|
||||
|
||||
public ImmutableArray<Advisory> Advisories { get; }
|
||||
|
||||
public int AdvisoryCount { get; }
|
||||
|
||||
public long TotalBytes { get; }
|
||||
}
|
||||
|
||||
@@ -67,26 +67,27 @@ public sealed class JsonExportSnapshotBuilder
|
||||
Directory.CreateDirectory(exportDirectory);
|
||||
TrySetDirectoryTimestamp(exportDirectory, exportedAt);
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var files = new List<JsonExportFile>();
|
||||
long totalBytes = 0L;
|
||||
var advisoryCount = 0;
|
||||
|
||||
await foreach (var advisory in advisories.WithCancellation(cancellationToken))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
advisoryCount++;
|
||||
var entry = Resolve(advisory);
|
||||
if (!seen.Add(entry.RelativePath))
|
||||
{
|
||||
throw new InvalidOperationException($"Multiple advisories resolved to the same path '{entry.RelativePath}'.");
|
||||
}
|
||||
|
||||
var destination = Combine(exportDirectory, entry.Segments);
|
||||
var destinationDirectory = Path.GetDirectoryName(destination);
|
||||
if (!string.IsNullOrEmpty(destinationDirectory))
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var files = new List<JsonExportFile>();
|
||||
var advisoryList = new List<Advisory>();
|
||||
long totalBytes = 0L;
|
||||
|
||||
await foreach (var advisory in advisories.WithCancellation(cancellationToken))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var entry = Resolve(advisory);
|
||||
if (!seen.Add(entry.RelativePath))
|
||||
{
|
||||
throw new InvalidOperationException($"Multiple advisories resolved to the same path '{entry.RelativePath}'.");
|
||||
}
|
||||
|
||||
advisoryList.Add(entry.Advisory);
|
||||
|
||||
var destination = Combine(exportDirectory, entry.Segments);
|
||||
var destinationDirectory = Path.GetDirectoryName(destination);
|
||||
if (!string.IsNullOrEmpty(destinationDirectory))
|
||||
{
|
||||
EnsureDirectoryExists(destinationDirectory);
|
||||
TrySetDirectoryTimestamp(destinationDirectory, exportedAt);
|
||||
}
|
||||
@@ -97,14 +98,14 @@ public sealed class JsonExportSnapshotBuilder
|
||||
File.SetLastWriteTimeUtc(destination, exportedAt.UtcDateTime);
|
||||
|
||||
var digest = ComputeDigest(bytes);
|
||||
files.Add(new JsonExportFile(entry.RelativePath, bytes.LongLength, digest));
|
||||
totalBytes += bytes.LongLength;
|
||||
}
|
||||
|
||||
files.Sort(static (left, right) => string.CompareOrdinal(left.RelativePath, right.RelativePath));
|
||||
|
||||
return new JsonExportResult(exportDirectory, exportedAt, files, advisoryCount, totalBytes);
|
||||
}
|
||||
files.Add(new JsonExportFile(entry.RelativePath, bytes.LongLength, digest));
|
||||
totalBytes += bytes.LongLength;
|
||||
}
|
||||
|
||||
files.Sort(static (left, right) => string.CompareOrdinal(left.RelativePath, right.RelativePath));
|
||||
|
||||
return new JsonExportResult(exportDirectory, exportedAt, files, advisoryList.Count, totalBytes, advisoryList);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<Advisory> EnumerateAsync(
|
||||
IEnumerable<Advisory> advisories,
|
||||
@@ -168,10 +169,11 @@ public sealed class JsonExportSnapshotBuilder
|
||||
throw new ArgumentNullException(nameof(advisory));
|
||||
}
|
||||
|
||||
var relativePath = _pathResolver.GetRelativePath(advisory);
|
||||
var segments = NormalizeRelativePath(relativePath);
|
||||
var normalized = string.Join('/', segments);
|
||||
return new PathResolution(advisory, normalized, segments);
|
||||
var normalized = CanonicalJsonSerializer.Normalize(advisory);
|
||||
var relativePath = _pathResolver.GetRelativePath(normalized);
|
||||
var segments = NormalizeRelativePath(relativePath);
|
||||
var normalizedPath = string.Join('/', segments);
|
||||
return new PathResolution(normalized, normalizedPath, segments);
|
||||
}
|
||||
|
||||
private static string[] NormalizeRelativePath(string relativePath)
|
||||
|
||||
@@ -31,14 +31,19 @@ public sealed class JsonExporterDependencyInjectionRoutine : IDependencyInjectio
|
||||
options.OutputRoot = Path.Combine("exports", "json");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.DirectoryNameFormat))
|
||||
{
|
||||
options.DirectoryNameFormat = "yyyyMMdd'T'HHmmss'Z'";
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<JsonFeedExporter>();
|
||||
services.AddTransient<JsonExportJob>();
|
||||
if (string.IsNullOrWhiteSpace(options.DirectoryNameFormat))
|
||||
{
|
||||
options.DirectoryNameFormat = "yyyyMMdd'T'HHmmss'Z'";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Mirror.DirectoryName))
|
||||
{
|
||||
options.Mirror.DirectoryName = "mirror";
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<JsonFeedExporter>();
|
||||
services.AddTransient<JsonExportJob>();
|
||||
|
||||
services.PostConfigure<JobSchedulerOptions>(options =>
|
||||
{
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Exporting;
|
||||
using StellaOps.Plugin;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Exporting;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Exporter.Json;
|
||||
|
||||
@@ -16,29 +20,32 @@ public sealed class JsonFeedExporter : IFeedExporter
|
||||
public const string ExporterId = "export:json";
|
||||
|
||||
private readonly IAdvisoryStore _advisoryStore;
|
||||
private readonly JsonExportOptions _options;
|
||||
private readonly IJsonExportPathResolver _pathResolver;
|
||||
private readonly ExportStateManager _stateManager;
|
||||
private readonly ILogger<JsonFeedExporter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _exporterVersion;
|
||||
|
||||
public JsonFeedExporter(
|
||||
IAdvisoryStore advisoryStore,
|
||||
IOptions<JsonExportOptions> options,
|
||||
IJsonExportPathResolver pathResolver,
|
||||
ExportStateManager stateManager,
|
||||
ILogger<JsonFeedExporter> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
|
||||
_stateManager = stateManager ?? throw new ArgumentNullException(nameof(stateManager));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_exporterVersion = ExporterVersion.GetVersion(typeof(JsonFeedExporter));
|
||||
}
|
||||
private readonly JsonExportOptions _options;
|
||||
private readonly IJsonExportPathResolver _pathResolver;
|
||||
private readonly ExportStateManager _stateManager;
|
||||
private readonly ILogger<JsonFeedExporter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _exporterVersion;
|
||||
private readonly IAdvisoryEventLog _eventLog;
|
||||
|
||||
public JsonFeedExporter(
|
||||
IAdvisoryStore advisoryStore,
|
||||
IOptions<JsonExportOptions> options,
|
||||
IJsonExportPathResolver pathResolver,
|
||||
ExportStateManager stateManager,
|
||||
IAdvisoryEventLog eventLog,
|
||||
ILogger<JsonFeedExporter> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
|
||||
_stateManager = stateManager ?? throw new ArgumentNullException(nameof(stateManager));
|
||||
_eventLog = eventLog ?? throw new ArgumentNullException(nameof(eventLog));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_exporterVersion = ExporterVersion.GetVersion(typeof(JsonFeedExporter));
|
||||
}
|
||||
|
||||
public string Name => ExporterName;
|
||||
|
||||
@@ -52,11 +59,12 @@ public sealed class JsonFeedExporter : IFeedExporter
|
||||
|
||||
var existingState = await _stateManager.GetAsync(ExporterId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var builder = new JsonExportSnapshotBuilder(_options, _pathResolver);
|
||||
var advisoryStream = _advisoryStore.StreamAsync(cancellationToken);
|
||||
var result = await builder.WriteAsync(advisoryStream, exportedAt, exportId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var digest = ExportDigestCalculator.ComputeTreeDigest(result);
|
||||
var builder = new JsonExportSnapshotBuilder(_options, _pathResolver);
|
||||
var canonicalAdvisories = await MaterializeCanonicalAdvisoriesAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await builder.WriteAsync(canonicalAdvisories, exportedAt, exportId, cancellationToken).ConfigureAwait(false);
|
||||
result = await JsonMirrorBundleWriter.WriteAsync(result, _options, services, _timeProvider, _logger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var digest = ExportDigestCalculator.ComputeTreeDigest(result);
|
||||
_logger.LogInformation(
|
||||
"JSON export {ExportId} wrote {FileCount} files ({Bytes} bytes) covering {AdvisoryCount} advisories with digest {Digest}",
|
||||
exportId,
|
||||
@@ -106,7 +114,34 @@ public sealed class JsonFeedExporter : IFeedExporter
|
||||
{
|
||||
TryUpdateLatestSymlink(exportRoot, result.ExportDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<Advisory>> MaterializeCanonicalAdvisoriesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var keys = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
await foreach (var advisory in _advisoryStore.StreamAsync(cancellationToken))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!string.IsNullOrWhiteSpace(advisory.AdvisoryKey))
|
||||
{
|
||||
keys.Add(advisory.AdvisoryKey.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var advisories = new List<Advisory>(keys.Count);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var replay = await _eventLog.ReplayAsync(key, asOf: null, cancellationToken).ConfigureAwait(false);
|
||||
if (!replay.Statements.IsDefaultOrEmpty)
|
||||
{
|
||||
advisories.Add(replay.Statements[0].Advisory);
|
||||
}
|
||||
}
|
||||
|
||||
return advisories;
|
||||
}
|
||||
|
||||
private void TryUpdateLatestSymlink(string exportRoot, string exportDirectory)
|
||||
{
|
||||
|
||||
622
src/StellaOps.Concelier.Exporter.Json/JsonMirrorBundleWriter.cs
Normal file
622
src/StellaOps.Concelier.Exporter.Json/JsonMirrorBundleWriter.cs
Normal file
@@ -0,0 +1,622 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Concelier.Exporter.Json;
|
||||
|
||||
internal static class JsonMirrorBundleWriter
|
||||
{
|
||||
private const int SchemaVersion = 1;
|
||||
private const string BundleFileName = "bundle.json";
|
||||
private const string BundleSignatureFileName = "bundle.json.jws";
|
||||
private const string ManifestFileName = "manifest.json";
|
||||
private const string IndexFileName = "index.json";
|
||||
private const string SignatureMediaType = "application/vnd.stellaops.concelier.mirror-bundle+jws";
|
||||
private const string DefaultMirrorDirectoryName = "mirror";
|
||||
|
||||
private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
public static async Task<JsonExportResult> WriteAsync(
|
||||
JsonExportResult result,
|
||||
JsonExportOptions options,
|
||||
IServiceProvider services,
|
||||
TimeProvider timeProvider,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var mirrorOptions = options.Mirror ?? new JsonExportOptions.JsonMirrorOptions();
|
||||
if (!mirrorOptions.Enabled || mirrorOptions.Domains.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var exportedAtUtc = result.ExportedAt.UtcDateTime;
|
||||
var mirrorDirectoryName = string.IsNullOrWhiteSpace(mirrorOptions.DirectoryName)
|
||||
? DefaultMirrorDirectoryName
|
||||
: mirrorOptions.DirectoryName.Trim();
|
||||
|
||||
var mirrorRoot = Path.Combine(result.ExportDirectory, mirrorDirectoryName);
|
||||
Directory.CreateDirectory(mirrorRoot);
|
||||
TrySetDirectoryTimestamp(mirrorRoot, exportedAtUtc);
|
||||
|
||||
var advisories = result.Advisories.IsDefaultOrEmpty
|
||||
? Array.Empty<Advisory>()
|
||||
: result.Advisories
|
||||
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var signingContext = PrepareSigningContext(mirrorOptions.Signing, services, timeProvider, logger);
|
||||
var additionalFiles = new List<JsonExportFile>();
|
||||
var domainEntries = new List<MirrorIndexDomainEntry>();
|
||||
|
||||
foreach (var domainOption in mirrorOptions.Domains)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (domainOption is null)
|
||||
{
|
||||
logger.LogWarning("Encountered null mirror domain configuration; skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var domainId = (domainOption.Id ?? string.Empty).Trim();
|
||||
if (domainId.Length == 0)
|
||||
{
|
||||
logger.LogWarning("Skipping mirror domain with empty id.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var schemeFilter = CreateFilterSet(domainOption.IncludeSchemes);
|
||||
var sourceFilter = CreateFilterSet(domainOption.IncludeSources);
|
||||
var domainAdvisories = advisories
|
||||
.Where(advisory => MatchesFilters(advisory, schemeFilter, sourceFilter))
|
||||
.ToArray();
|
||||
|
||||
var sources = BuildSourceSummaries(domainAdvisories);
|
||||
var domainDisplayName = string.IsNullOrWhiteSpace(domainOption.DisplayName)
|
||||
? domainId
|
||||
: domainOption.DisplayName!.Trim();
|
||||
|
||||
var domainDirectory = Path.Combine(mirrorRoot, domainId);
|
||||
Directory.CreateDirectory(domainDirectory);
|
||||
TrySetDirectoryTimestamp(domainDirectory, exportedAtUtc);
|
||||
|
||||
var bundleDocument = new MirrorDomainBundleDocument(
|
||||
SchemaVersion,
|
||||
result.ExportedAt,
|
||||
options.TargetRepository,
|
||||
domainId,
|
||||
domainDisplayName,
|
||||
domainAdvisories.Length,
|
||||
domainAdvisories,
|
||||
sources);
|
||||
|
||||
var bundleBytes = Serialize(bundleDocument);
|
||||
var bundlePath = Path.Combine(domainDirectory, BundleFileName);
|
||||
await WriteFileAsync(bundlePath, bundleBytes, exportedAtUtc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundleRelativePath = ToRelativePath(result.ExportDirectory, bundlePath);
|
||||
var bundleDigest = ComputeDigest(bundleBytes);
|
||||
var bundleLength = (long)bundleBytes.LongLength;
|
||||
additionalFiles.Add(new JsonExportFile(bundleRelativePath, bundleLength, bundleDigest));
|
||||
|
||||
MirrorSignatureDescriptor? signatureDescriptor = null;
|
||||
if (signingContext is not null)
|
||||
{
|
||||
var (signatureValue, signedAt) = await CreateSignatureAsync(
|
||||
signingContext,
|
||||
bundleBytes,
|
||||
timeProvider,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var signatureBytes = Utf8NoBom.GetBytes(signatureValue);
|
||||
var signaturePath = Path.Combine(domainDirectory, BundleSignatureFileName);
|
||||
await WriteFileAsync(signaturePath, signatureBytes, exportedAtUtc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var signatureRelativePath = ToRelativePath(result.ExportDirectory, signaturePath);
|
||||
var signatureDigest = ComputeDigest(signatureBytes);
|
||||
var signatureLength = (long)signatureBytes.LongLength;
|
||||
additionalFiles.Add(new JsonExportFile(signatureRelativePath, signatureLength, signatureDigest));
|
||||
|
||||
signatureDescriptor = new MirrorSignatureDescriptor(
|
||||
signatureRelativePath,
|
||||
signingContext.Algorithm,
|
||||
signingContext.KeyId,
|
||||
signingContext.Provider,
|
||||
signedAt);
|
||||
}
|
||||
|
||||
var bundleDescriptor = new MirrorFileDescriptor(bundleRelativePath, bundleLength, bundleDigest, signatureDescriptor);
|
||||
|
||||
var manifestDocument = new MirrorDomainManifestDocument(
|
||||
SchemaVersion,
|
||||
result.ExportedAt,
|
||||
domainId,
|
||||
domainDisplayName,
|
||||
domainAdvisories.Length,
|
||||
sources,
|
||||
bundleDescriptor);
|
||||
|
||||
var manifestBytes = Serialize(manifestDocument);
|
||||
var manifestPath = Path.Combine(domainDirectory, ManifestFileName);
|
||||
await WriteFileAsync(manifestPath, manifestBytes, exportedAtUtc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var manifestRelativePath = ToRelativePath(result.ExportDirectory, manifestPath);
|
||||
var manifestDigest = ComputeDigest(manifestBytes);
|
||||
var manifestLength = (long)manifestBytes.LongLength;
|
||||
additionalFiles.Add(new JsonExportFile(manifestRelativePath, manifestLength, manifestDigest));
|
||||
|
||||
var manifestDescriptor = new MirrorFileDescriptor(manifestRelativePath, manifestLength, manifestDigest, null);
|
||||
|
||||
domainEntries.Add(new MirrorIndexDomainEntry(
|
||||
domainId,
|
||||
domainDisplayName,
|
||||
domainAdvisories.Length,
|
||||
manifestDescriptor,
|
||||
bundleDescriptor,
|
||||
sources));
|
||||
}
|
||||
|
||||
domainEntries.Sort(static (left, right) => string.CompareOrdinal(left.DomainId, right.DomainId));
|
||||
|
||||
var indexDocument = new MirrorIndexDocument(
|
||||
SchemaVersion,
|
||||
result.ExportedAt,
|
||||
options.TargetRepository,
|
||||
domainEntries);
|
||||
|
||||
var indexBytes = Serialize(indexDocument);
|
||||
var indexPath = Path.Combine(mirrorRoot, IndexFileName);
|
||||
await WriteFileAsync(indexPath, indexBytes, exportedAtUtc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var indexRelativePath = ToRelativePath(result.ExportDirectory, indexPath);
|
||||
var indexDigest = ComputeDigest(indexBytes);
|
||||
var indexLength = (long)indexBytes.LongLength;
|
||||
additionalFiles.Add(new JsonExportFile(indexRelativePath, indexLength, indexDigest));
|
||||
|
||||
logger.LogInformation(
|
||||
"Generated {DomainCount} Concelier mirror domain bundle(s) under {MirrorRoot}.",
|
||||
domainEntries.Count,
|
||||
mirrorDirectoryName);
|
||||
|
||||
var combinedFiles = new List<JsonExportFile>(result.Files.Length + additionalFiles.Count);
|
||||
combinedFiles.AddRange(result.Files);
|
||||
combinedFiles.AddRange(additionalFiles);
|
||||
|
||||
var combinedTotalBytes = checked(result.TotalBytes + additionalFiles.Sum(static file => file.Length));
|
||||
|
||||
return new JsonExportResult(
|
||||
result.ExportDirectory,
|
||||
result.ExportedAt,
|
||||
combinedFiles,
|
||||
result.AdvisoryCount,
|
||||
combinedTotalBytes,
|
||||
result.Advisories);
|
||||
}
|
||||
|
||||
private static JsonMirrorSigningContext? PrepareSigningContext(
|
||||
JsonExportOptions.JsonMirrorSigningOptions signingOptions,
|
||||
IServiceProvider services,
|
||||
TimeProvider timeProvider,
|
||||
ILogger logger)
|
||||
{
|
||||
if (signingOptions is null || !signingOptions.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var algorithm = string.IsNullOrWhiteSpace(signingOptions.Algorithm)
|
||||
? SignatureAlgorithms.Es256
|
||||
: signingOptions.Algorithm.Trim();
|
||||
var keyId = (signingOptions.KeyId ?? string.Empty).Trim();
|
||||
if (keyId.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Mirror signing requires mirror.signing.keyId to be configured.");
|
||||
}
|
||||
|
||||
var registry = services.GetService<ICryptoProviderRegistry>()
|
||||
?? throw new InvalidOperationException("Mirror signing requires ICryptoProviderRegistry to be registered.");
|
||||
|
||||
var providerHint = signingOptions.Provider?.Trim();
|
||||
var keyReference = new CryptoKeyReference(keyId, providerHint);
|
||||
|
||||
CryptoSignerResolution resolved;
|
||||
try
|
||||
{
|
||||
resolved = registry.ResolveSigner(CryptoCapability.Signing, algorithm, keyReference, providerHint);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
var provider = ResolveProvider(registry, algorithm, providerHint);
|
||||
var signingKey = LoadSigningKey(signingOptions, provider, services, timeProvider, algorithm);
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
resolved = registry.ResolveSigner(CryptoCapability.Signing, algorithm, keyReference, provider.Name);
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Mirror signing configured with key {KeyId} via provider {Provider} using {Algorithm}.",
|
||||
resolved.Signer.KeyId,
|
||||
resolved.ProviderName,
|
||||
algorithm);
|
||||
|
||||
return new JsonMirrorSigningContext(resolved.Signer, algorithm, resolved.Signer.KeyId, resolved.ProviderName);
|
||||
}
|
||||
|
||||
private static ICryptoProvider ResolveProvider(ICryptoProviderRegistry registry, string algorithm, string? providerHint)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(providerHint) && registry.TryResolve(providerHint, out var hinted))
|
||||
{
|
||||
if (!hinted.Supports(CryptoCapability.Signing, algorithm))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Crypto provider '{providerHint}' does not support signing algorithm '{algorithm}'.");
|
||||
}
|
||||
|
||||
return hinted;
|
||||
}
|
||||
|
||||
return registry.ResolveOrThrow(CryptoCapability.Signing, algorithm);
|
||||
}
|
||||
|
||||
private static CryptoSigningKey LoadSigningKey(
|
||||
JsonExportOptions.JsonMirrorSigningOptions signingOptions,
|
||||
ICryptoProvider provider,
|
||||
IServiceProvider services,
|
||||
TimeProvider timeProvider,
|
||||
string algorithm)
|
||||
{
|
||||
var keyPath = (signingOptions.KeyPath ?? string.Empty).Trim();
|
||||
if (keyPath.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Mirror signing requires mirror.signing.keyPath to be configured.");
|
||||
}
|
||||
|
||||
var environment = services.GetService<IHostEnvironment>();
|
||||
var basePath = environment?.ContentRootPath ?? AppContext.BaseDirectory;
|
||||
var resolvedPath = Path.IsPathRooted(keyPath)
|
||||
? keyPath
|
||||
: Path.GetFullPath(Path.Combine(basePath, keyPath));
|
||||
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Mirror signing key '{signingOptions.KeyId}' not found.", resolvedPath);
|
||||
}
|
||||
|
||||
var pem = File.ReadAllText(resolvedPath);
|
||||
using var ecdsa = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
ecdsa.ImportFromPem(pem);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to import mirror signing key. Ensure the PEM contains an EC private key.", ex);
|
||||
}
|
||||
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
return new CryptoSigningKey(
|
||||
new CryptoKeyReference(signingOptions.KeyId, provider.Name),
|
||||
algorithm,
|
||||
in parameters,
|
||||
timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private static async Task<(string Value, DateTimeOffset SignedAt)> CreateSignatureAsync(
|
||||
JsonMirrorSigningContext context,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var header = new Dictionary<string, object>
|
||||
{
|
||||
["alg"] = context.Algorithm,
|
||||
["kid"] = context.KeyId,
|
||||
["typ"] = SignatureMediaType,
|
||||
["b64"] = false,
|
||||
["crit"] = new[] { "b64" }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context.Provider))
|
||||
{
|
||||
header["provider"] = context.Provider;
|
||||
}
|
||||
|
||||
var headerJson = JsonSerializer.Serialize(header, HeaderSerializerOptions);
|
||||
var protectedHeader = Base64UrlEncode(Utf8NoBom.GetBytes(headerJson));
|
||||
var signingInputLength = protectedHeader.Length + 1 + payload.Length;
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(signingInputLength);
|
||||
|
||||
try
|
||||
{
|
||||
var headerBytes = Encoding.ASCII.GetBytes(protectedHeader);
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length);
|
||||
buffer[headerBytes.Length] = (byte)'.';
|
||||
var payloadArray = payload.ToArray();
|
||||
Buffer.BlockCopy(payloadArray, 0, buffer, headerBytes.Length + 1, payloadArray.Length);
|
||||
|
||||
var signingInput = new ReadOnlyMemory<byte>(buffer, 0, signingInputLength);
|
||||
var signatureBytes = await context.Signer.SignAsync(signingInput, cancellationToken).ConfigureAwait(false);
|
||||
var encodedSignature = Base64UrlEncode(signatureBytes);
|
||||
var signedAt = timeProvider.GetUtcNow();
|
||||
return (string.Concat(protectedHeader, "..", encodedSignature), signedAt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<JsonMirrorSourceSummary> BuildSourceSummaries(IReadOnlyList<Advisory> advisories)
|
||||
{
|
||||
var builders = new Dictionary<string, SourceAccumulator>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
var counted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var provenance in advisory.Provenance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provenance.Source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = provenance.Source.Trim();
|
||||
if (!builders.TryGetValue(source, out var accumulator))
|
||||
{
|
||||
accumulator = new SourceAccumulator();
|
||||
builders[source] = accumulator;
|
||||
}
|
||||
|
||||
accumulator.Record(provenance.RecordedAt);
|
||||
if (counted.Add(source))
|
||||
{
|
||||
accumulator.IncrementAdvisoryCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builders
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => new JsonMirrorSourceSummary(
|
||||
pair.Key,
|
||||
pair.Value.FirstRecordedAt,
|
||||
pair.Value.LastRecordedAt,
|
||||
pair.Value.AdvisoryCount))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static HashSet<string>? CreateFilterSet(IList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
set.Add(value.Trim());
|
||||
}
|
||||
|
||||
return set.Count == 0 ? null : set;
|
||||
}
|
||||
|
||||
private static bool MatchesFilters(Advisory advisory, HashSet<string>? schemeFilter, HashSet<string>? sourceFilter)
|
||||
{
|
||||
if (schemeFilter is not null)
|
||||
{
|
||||
var scheme = ExtractScheme(advisory.AdvisoryKey);
|
||||
if (!schemeFilter.Contains(scheme))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceFilter is not null)
|
||||
{
|
||||
var hasSource = advisory.Provenance.Any(provenance =>
|
||||
!string.IsNullOrWhiteSpace(provenance.Source) &&
|
||||
sourceFilter.Contains(provenance.Source.Trim()));
|
||||
|
||||
if (!hasSource)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ExtractScheme(string advisoryKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = advisoryKey.Trim();
|
||||
var separatorIndex = trimmed.IndexOf(':');
|
||||
return separatorIndex <= 0 ? trimmed : trimmed[..separatorIndex];
|
||||
}
|
||||
|
||||
private static byte[] Serialize<T>(T value)
|
||||
{
|
||||
var json = CanonicalJsonSerializer.SerializeIndented(value);
|
||||
return Utf8NoBom.GetBytes(json);
|
||||
}
|
||||
|
||||
private static async Task WriteFileAsync(string path, byte[] content, DateTime exportedAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await File.WriteAllBytesAsync(path, content, cancellationToken).ConfigureAwait(false);
|
||||
File.SetLastWriteTimeUtc(path, exportedAtUtc);
|
||||
}
|
||||
|
||||
private static string ToRelativePath(string root, string fullPath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(root, fullPath);
|
||||
return relative.Replace(Path.DirectorySeparatorChar, '/');
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var hash = SHA256.HashData(payload);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static void TrySetDirectoryTimestamp(string directory, DateTime exportedAtUtc)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.SetLastWriteTimeUtc(directory, exportedAtUtc);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(ReadOnlySpan<byte> value)
|
||||
{
|
||||
var encoded = Convert.ToBase64String(value);
|
||||
var builder = new StringBuilder(encoded.Length);
|
||||
foreach (var ch in encoded)
|
||||
{
|
||||
switch (ch)
|
||||
{
|
||||
case '+':
|
||||
builder.Append('-');
|
||||
break;
|
||||
case '/':
|
||||
builder.Append('_');
|
||||
break;
|
||||
case '=':
|
||||
break;
|
||||
default:
|
||||
builder.Append(ch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private sealed record JsonMirrorSigningContext(ICryptoSigner Signer, string Algorithm, string KeyId, string Provider);
|
||||
|
||||
private sealed record MirrorIndexDocument(
|
||||
int SchemaVersion,
|
||||
DateTimeOffset GeneratedAt,
|
||||
string? TargetRepository,
|
||||
IReadOnlyList<MirrorIndexDomainEntry> Domains);
|
||||
|
||||
private sealed record MirrorIndexDomainEntry(
|
||||
string DomainId,
|
||||
string DisplayName,
|
||||
int AdvisoryCount,
|
||||
MirrorFileDescriptor Manifest,
|
||||
MirrorFileDescriptor Bundle,
|
||||
IReadOnlyList<JsonMirrorSourceSummary> Sources);
|
||||
|
||||
private sealed record MirrorDomainManifestDocument(
|
||||
int SchemaVersion,
|
||||
DateTimeOffset GeneratedAt,
|
||||
string DomainId,
|
||||
string DisplayName,
|
||||
int AdvisoryCount,
|
||||
IReadOnlyList<JsonMirrorSourceSummary> Sources,
|
||||
MirrorFileDescriptor Bundle);
|
||||
|
||||
private sealed record MirrorDomainBundleDocument(
|
||||
int SchemaVersion,
|
||||
DateTimeOffset GeneratedAt,
|
||||
string? TargetRepository,
|
||||
string DomainId,
|
||||
string DisplayName,
|
||||
int AdvisoryCount,
|
||||
IReadOnlyList<Advisory> Advisories,
|
||||
IReadOnlyList<JsonMirrorSourceSummary> Sources);
|
||||
|
||||
private sealed record MirrorFileDescriptor(
|
||||
string Path,
|
||||
long SizeBytes,
|
||||
string Digest,
|
||||
MirrorSignatureDescriptor? Signature);
|
||||
|
||||
private sealed record MirrorSignatureDescriptor(
|
||||
string Path,
|
||||
string Algorithm,
|
||||
string KeyId,
|
||||
string Provider,
|
||||
DateTimeOffset SignedAt);
|
||||
|
||||
private sealed record JsonMirrorSourceSummary(
|
||||
string Source,
|
||||
DateTimeOffset? FirstRecordedAt,
|
||||
DateTimeOffset? LastRecordedAt,
|
||||
int AdvisoryCount);
|
||||
|
||||
private sealed class SourceAccumulator
|
||||
{
|
||||
public DateTimeOffset? FirstRecordedAt { get; private set; }
|
||||
|
||||
public DateTimeOffset? LastRecordedAt { get; private set; }
|
||||
|
||||
public int AdvisoryCount { get; private set; }
|
||||
|
||||
public void Record(DateTimeOffset recordedAt)
|
||||
{
|
||||
var normalized = recordedAt.ToUniversalTime();
|
||||
if (FirstRecordedAt is null || normalized < FirstRecordedAt.Value)
|
||||
{
|
||||
FirstRecordedAt = normalized;
|
||||
}
|
||||
|
||||
if (LastRecordedAt is null || normalized > LastRecordedAt.Value)
|
||||
{
|
||||
LastRecordedAt = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
public void IncrementAdvisoryCount()
|
||||
{
|
||||
AdvisoryCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,15 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ProjectReference Include="..\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -10,4 +10,4 @@
|
||||
|Stream advisories during export|BE-Export|Storage.Mongo|DONE – exporter + streaming-only test ensures single enumeration and per-file digest capture.|
|
||||
|Emit export manifest with digest metadata|BE-Export|Exporters|DONE – manifest now includes per-file digests/sizes alongside tree digest.|
|
||||
|Surface new advisory fields (description/CWEs/canonical metric)|BE-Export|Models, Core|DONE (2025-10-15) – JSON exporter validated with new fixtures ensuring description/CWEs/canonical metric are preserved in outputs; `dotnet test src/StellaOps.Concelier.Exporter.Json.Tests` run 2025-10-15 for regression coverage.|
|
||||
|CONCELIER-EXPORT-08-201 – Mirror bundle + domain manifest|Team Concelier Export|FEEDCORE-ENGINE-07-001|TODO – Produce per-domain aggregate bundles (JSON + manifest) with deterministic digests, include upstream source metadata, and publish index consumed by mirror endpoints/tests.|
|
||||
|CONCELIER-EXPORT-08-201 – Mirror bundle + domain manifest|Team Concelier Export|FEEDCORE-ENGINE-07-001|DONE (2025-10-19) – Mirror bundle writer emits domain aggregates + manifests with cosign-compatible JWS signatures; index/tests updated via `dotnet test src/StellaOps.Concelier.Exporter.Json.Tests/StellaOps.Concelier.Exporter.Json.Tests.csproj` (2025-10-19).|
|
||||
|
||||
@@ -283,6 +283,155 @@ public sealed class TrivyDbFeedExporterTests : IDisposable
|
||||
Assert.Empty(orasPusher.Pushes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WritesMirrorBundlesWhenConfigured()
|
||||
{
|
||||
var advisoryOne = CreateSampleAdvisory("CVE-2025-1001", "Mirror Advisory One");
|
||||
var advisoryTwo = CreateSampleAdvisory("CVE-2025-1002", "Mirror Advisory Two");
|
||||
var advisoryStore = new StubAdvisoryStore(advisoryOne, advisoryTwo);
|
||||
|
||||
var optionsValue = new TrivyDbExportOptions
|
||||
{
|
||||
OutputRoot = _root,
|
||||
ReferencePrefix = "example/trivy",
|
||||
TargetRepository = "s3://mirror/trivy",
|
||||
Json = new JsonExportOptions
|
||||
{
|
||||
OutputRoot = _jsonRoot,
|
||||
MaintainLatestSymlink = false,
|
||||
},
|
||||
KeepWorkingTree = false,
|
||||
};
|
||||
|
||||
optionsValue.Mirror.Enabled = true;
|
||||
optionsValue.Mirror.DirectoryName = "mirror";
|
||||
optionsValue.Mirror.Domains.Add(new TrivyDbMirrorDomainOptions
|
||||
{
|
||||
Id = "primary",
|
||||
DisplayName = "Primary Mirror",
|
||||
});
|
||||
|
||||
var options = Options.Create(optionsValue);
|
||||
var packageBuilder = new TrivyDbPackageBuilder();
|
||||
var ociWriter = new TrivyDbOciWriter();
|
||||
var planner = new TrivyDbExportPlanner();
|
||||
var stateStore = new InMemoryExportStateStore();
|
||||
var exportedAt = DateTimeOffset.Parse("2024-09-18T12:00:00Z", CultureInfo.InvariantCulture);
|
||||
var timeProvider = new TestTimeProvider(exportedAt);
|
||||
var stateManager = new ExportStateManager(stateStore, timeProvider);
|
||||
var builderMetadata = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Version = 2,
|
||||
NextUpdate = "2024-09-19T12:00:00Z",
|
||||
UpdatedAt = "2024-09-18T12:00:00Z",
|
||||
});
|
||||
var builder = new StubTrivyDbBuilder(_root, builderMetadata);
|
||||
var orasPusher = new StubTrivyDbOrasPusher();
|
||||
var exporter = new TrivyDbFeedExporter(
|
||||
advisoryStore,
|
||||
new VulnListJsonExportPathResolver(),
|
||||
options,
|
||||
packageBuilder,
|
||||
ociWriter,
|
||||
stateManager,
|
||||
planner,
|
||||
builder,
|
||||
orasPusher,
|
||||
NullLogger<TrivyDbFeedExporter>.Instance,
|
||||
timeProvider);
|
||||
|
||||
using var provider = new ServiceCollection().BuildServiceProvider();
|
||||
await exporter.ExportAsync(provider, CancellationToken.None);
|
||||
|
||||
var exportId = exportedAt.ToString(optionsValue.TagFormat, CultureInfo.InvariantCulture);
|
||||
var layoutPath = optionsValue.GetExportRoot(exportId);
|
||||
var mirrorRoot = Path.Combine(layoutPath, "mirror");
|
||||
var domainRoot = Path.Combine(mirrorRoot, "primary");
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(mirrorRoot, "index.json")));
|
||||
Assert.True(File.Exists(Path.Combine(domainRoot, "manifest.json")));
|
||||
Assert.True(File.Exists(Path.Combine(domainRoot, "metadata.json")));
|
||||
Assert.True(File.Exists(Path.Combine(domainRoot, "db.tar.gz")));
|
||||
|
||||
var reference = $"{optionsValue.ReferencePrefix}:{exportId}";
|
||||
var manifestDigest = ReadManifestDigest(layoutPath);
|
||||
var indexPath = Path.Combine(mirrorRoot, "index.json");
|
||||
string? indexManifestDescriptorDigest = null;
|
||||
string? indexMetadataDigest = null;
|
||||
string? indexDatabaseDigest = null;
|
||||
|
||||
using (var indexDoc = JsonDocument.Parse(File.ReadAllBytes(indexPath)))
|
||||
{
|
||||
var root = indexDoc.RootElement;
|
||||
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal(reference, root.GetProperty("reference").GetString());
|
||||
Assert.Equal(manifestDigest, root.GetProperty("manifestDigest").GetString());
|
||||
Assert.Equal("full", root.GetProperty("mode").GetString());
|
||||
Assert.Equal("s3://mirror/trivy", root.GetProperty("targetRepository").GetString());
|
||||
Assert.False(root.TryGetProperty("delta", out _));
|
||||
|
||||
var domains = root.GetProperty("domains").EnumerateArray().ToArray();
|
||||
var domain = Assert.Single(domains);
|
||||
Assert.Equal("primary", domain.GetProperty("domainId").GetString());
|
||||
Assert.Equal("Primary Mirror", domain.GetProperty("displayName").GetString());
|
||||
Assert.Equal(2, domain.GetProperty("advisoryCount").GetInt32());
|
||||
|
||||
var manifestDescriptor = domain.GetProperty("manifest");
|
||||
Assert.Equal("mirror/primary/manifest.json", manifestDescriptor.GetProperty("path").GetString());
|
||||
indexManifestDescriptorDigest = manifestDescriptor.GetProperty("digest").GetString();
|
||||
|
||||
var metadataDescriptor = domain.GetProperty("metadata");
|
||||
Assert.Equal("mirror/primary/metadata.json", metadataDescriptor.GetProperty("path").GetString());
|
||||
indexMetadataDigest = metadataDescriptor.GetProperty("digest").GetString();
|
||||
|
||||
var databaseDescriptor = domain.GetProperty("database");
|
||||
Assert.Equal("mirror/primary/db.tar.gz", databaseDescriptor.GetProperty("path").GetString());
|
||||
indexDatabaseDigest = databaseDescriptor.GetProperty("digest").GetString();
|
||||
}
|
||||
|
||||
var domainManifestPath = Path.Combine(domainRoot, "manifest.json");
|
||||
var rootMetadataPath = Path.Combine(layoutPath, "metadata.json");
|
||||
var domainMetadataPath = Path.Combine(domainRoot, "metadata.json");
|
||||
var domainDbPath = Path.Combine(domainRoot, "db.tar.gz");
|
||||
|
||||
var domainManifestBytes = File.ReadAllBytes(domainManifestPath);
|
||||
var domainManifestDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(domainManifestBytes)).ToLowerInvariant();
|
||||
var rootMetadataBytes = File.ReadAllBytes(rootMetadataPath);
|
||||
var domainMetadataBytes = File.ReadAllBytes(domainMetadataPath);
|
||||
Assert.Equal(rootMetadataBytes, domainMetadataBytes);
|
||||
|
||||
var metadataDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(domainMetadataBytes)).ToLowerInvariant();
|
||||
var databaseDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(domainDbPath))).ToLowerInvariant();
|
||||
Assert.Equal(domainManifestDigest, indexManifestDescriptorDigest);
|
||||
Assert.Equal(metadataDigest, indexMetadataDigest);
|
||||
Assert.Equal(databaseDigest, indexDatabaseDigest);
|
||||
|
||||
using (var manifestDoc = JsonDocument.Parse(File.ReadAllBytes(domainManifestPath)))
|
||||
{
|
||||
var manifestRoot = manifestDoc.RootElement;
|
||||
Assert.Equal("primary", manifestRoot.GetProperty("domainId").GetString());
|
||||
Assert.Equal("Primary Mirror", manifestRoot.GetProperty("displayName").GetString());
|
||||
Assert.Equal(reference, manifestRoot.GetProperty("reference").GetString());
|
||||
Assert.Equal(manifestDigest, manifestRoot.GetProperty("manifestDigest").GetString());
|
||||
Assert.Equal("full", manifestRoot.GetProperty("mode").GetString());
|
||||
Assert.Equal("s3://mirror/trivy", manifestRoot.GetProperty("targetRepository").GetString());
|
||||
|
||||
var metadataDescriptor = manifestRoot.GetProperty("metadata");
|
||||
Assert.Equal("mirror/primary/metadata.json", metadataDescriptor.GetProperty("path").GetString());
|
||||
Assert.Equal(metadataDigest, metadataDescriptor.GetProperty("digest").GetString());
|
||||
|
||||
var databaseDescriptor = manifestRoot.GetProperty("database");
|
||||
Assert.Equal("mirror/primary/db.tar.gz", databaseDescriptor.GetProperty("path").GetString());
|
||||
Assert.Equal(databaseDigest, databaseDescriptor.GetProperty("digest").GetString());
|
||||
|
||||
var sources = manifestRoot.GetProperty("sources").EnumerateArray().ToArray();
|
||||
Assert.NotEmpty(sources);
|
||||
Assert.Contains(sources, element => string.Equals(element.GetProperty("source").GetString(), "nvd", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
Assert.Empty(orasPusher.Pushes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_SkipsOrasPushWhenDeltaPublishingDisabled()
|
||||
{
|
||||
@@ -774,7 +923,9 @@ public sealed class TrivyDbFeedExporterTests : IDisposable
|
||||
private ExportStateRecord? _record;
|
||||
|
||||
public Task<ExportStateRecord?> FindAsync(string id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_record);
|
||||
{
|
||||
return Task.FromResult(_record);
|
||||
}
|
||||
|
||||
public Task<ExportStateRecord> UpsertAsync(ExportStateRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
|Streamed package building to avoid large copies|BE-Export|Exporters|DONE – metadata/config now reuse backing arrays and OCI writer streams directly without double buffering.|
|
||||
|Plan incremental/delta exports|BE-Export|Exporters|DONE – state captures per-file manifests, planner schedules delta vs full resets, layer reuse smoke test verifies OCI reuse, and operator guide documents the validation flow.|
|
||||
|Advisory schema parity export (description/CWEs/canonical metric)|BE-Export|Models, Core|DONE (2025-10-15) – exporter/test fixtures updated to handle description/CWEs/canonical metric fields during Trivy DB packaging; `dotnet test src/StellaOps.Concelier.Exporter.TrivyDb.Tests` re-run 2025-10-15 to confirm coverage.|
|
||||
|CONCELIER-EXPORT-08-202 – Mirror-ready Trivy DB bundles|Team Concelier Export|CONCELIER-EXPORT-08-201|TODO – Generate domain-specific Trivy DB archives + metadata manifest, ensure deterministic digests, and document sync process for downstream Concelier nodes.|
|
||||
|CONCELIER-EXPORT-08-202 – Mirror-ready Trivy DB bundles|Team Concelier Export|CONCELIER-EXPORT-08-201|**DONE (2025-10-19)** – Added mirror export options and writer emitting `mirror/index.json` plus per-domain `manifest.json`/`metadata.json`/`db.tar.gz` with deterministic SHA-256 digests; regression covered via `dotnet test src/StellaOps.Concelier.Exporter.TrivyDb.Tests/StellaOps.Concelier.Exporter.TrivyDb.Tests.csproj`.|
|
||||
|
||||
@@ -24,25 +24,43 @@ public sealed class TrivyDbExportOptions
|
||||
OutputRoot = Path.Combine("exports", "trivy", "tree")
|
||||
};
|
||||
|
||||
public TrivyDbBuilderOptions Builder { get; set; } = new();
|
||||
|
||||
public TrivyDbOrasOptions Oras { get; set; } = new();
|
||||
|
||||
public TrivyDbOfflineBundleOptions OfflineBundle { get; set; } = new();
|
||||
|
||||
public string GetExportRoot(string exportId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(exportId);
|
||||
var root = Path.GetFullPath(OutputRoot);
|
||||
return Path.Combine(root, exportId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TrivyDbBuilderOptions
|
||||
{
|
||||
public string ExecutablePath { get; set; } = "trivy-db";
|
||||
|
||||
public string? WorkingDirectory { get; set; }
|
||||
public TrivyDbBuilderOptions Builder { get; set; } = new();
|
||||
|
||||
public TrivyDbOrasOptions Oras { get; set; } = new();
|
||||
|
||||
public TrivyDbOfflineBundleOptions OfflineBundle { get; set; } = new();
|
||||
|
||||
public TrivyDbMirrorOptions Mirror { get; set; } = new();
|
||||
|
||||
public string GetExportRoot(string exportId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(exportId);
|
||||
var root = Path.GetFullPath(OutputRoot);
|
||||
return Path.Combine(root, exportId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TrivyDbMirrorOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string DirectoryName { get; set; } = "mirror";
|
||||
|
||||
public IList<TrivyDbMirrorDomainOptions> Domains { get; } = new List<TrivyDbMirrorDomainOptions>();
|
||||
}
|
||||
|
||||
public sealed class TrivyDbMirrorDomainOptions
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string? DisplayName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TrivyDbBuilderOptions
|
||||
{
|
||||
public string ExecutablePath { get; set; } = "trivy-db";
|
||||
|
||||
public string? WorkingDirectory { get; set; }
|
||||
|
||||
public TimeSpan UpdateInterval { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ public sealed class TrivyDbFeedExporter : IFeedExporter
|
||||
|
||||
var builderResult = await _builder.BuildAsync(jsonResult, exportedAt, exportId, cancellationToken).ConfigureAwait(false);
|
||||
var metadataBytes = CreateMetadataJson(plan, builderResult.BuilderMetadata, treeDigest, jsonResult, exportedAt);
|
||||
var metadataDigest = ComputeDigest(metadataBytes);
|
||||
var metadataLength = metadataBytes.LongLength;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -137,6 +139,22 @@ public sealed class TrivyDbFeedExporter : IFeedExporter
|
||||
}
|
||||
|
||||
var ociResult = await _ociWriter.WriteAsync(package, destination, reference, plan, baseLayout, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await TrivyDbMirrorBundleWriter.WriteAsync(
|
||||
destination,
|
||||
jsonResult,
|
||||
_options,
|
||||
plan,
|
||||
builderResult,
|
||||
reference,
|
||||
ociResult.ManifestDigest,
|
||||
metadataBytes,
|
||||
metadataDigest,
|
||||
metadataLength,
|
||||
_exporterVersion,
|
||||
exportedAt,
|
||||
_logger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (_options.Oras.Enabled && ShouldPublishToOras(plan.Mode))
|
||||
{
|
||||
@@ -421,6 +439,13 @@ public sealed class TrivyDbFeedExporter : IFeedExporter
|
||||
return string.IsNullOrEmpty(normalized) ? "." : normalized;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var hash = SHA256.HashData(payload);
|
||||
var hex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return $"sha256:{hex}";
|
||||
}
|
||||
|
||||
private bool ShouldPublishToOras(TrivyDbExportMode mode)
|
||||
{
|
||||
var overrides = TrivyDbExportOverrideScope.Current;
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Exporter.Json;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Exporter.TrivyDb;
|
||||
|
||||
internal static class TrivyDbMirrorBundleWriter
|
||||
{
|
||||
private const int SchemaVersion = 1;
|
||||
private const string DefaultDirectoryName = "mirror";
|
||||
private const string MetadataFileName = "metadata.json";
|
||||
private const string DatabaseFileName = "db.tar.gz";
|
||||
private const string ManifestFileName = "manifest.json";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
public static async Task WriteAsync(
|
||||
string layoutRoot,
|
||||
JsonExportResult jsonResult,
|
||||
TrivyDbExportOptions options,
|
||||
TrivyDbExportPlan plan,
|
||||
TrivyDbBuilderResult builderResult,
|
||||
string reference,
|
||||
string manifestDigest,
|
||||
ReadOnlyMemory<byte> metadataBytes,
|
||||
string metadataDigest,
|
||||
long metadataLength,
|
||||
string exporterVersion,
|
||||
DateTimeOffset exportedAt,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (options?.Mirror is null || !options.Mirror.Enabled || options.Mirror.Domains.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(layoutRoot))
|
||||
{
|
||||
throw new ArgumentException("Layout root must be provided.", nameof(layoutRoot));
|
||||
}
|
||||
|
||||
if (builderResult is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(builderResult));
|
||||
}
|
||||
|
||||
if (jsonResult is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(jsonResult));
|
||||
}
|
||||
|
||||
var directoryName = string.IsNullOrWhiteSpace(options.Mirror.DirectoryName)
|
||||
? DefaultDirectoryName
|
||||
: options.Mirror.DirectoryName.Trim();
|
||||
|
||||
if (directoryName.Length == 0)
|
||||
{
|
||||
directoryName = DefaultDirectoryName;
|
||||
}
|
||||
|
||||
var root = Path.Combine(layoutRoot, directoryName);
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
var timestamp = exportedAt.UtcDateTime;
|
||||
TrySetDirectoryTimestamp(root, timestamp);
|
||||
|
||||
var advisories = jsonResult.Advisories.IsDefaultOrEmpty
|
||||
? Array.Empty<Advisory>()
|
||||
: jsonResult.Advisories
|
||||
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var domains = new List<MirrorIndexDomainEntry>();
|
||||
|
||||
foreach (var domainOption in options.Mirror.Domains)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (domainOption is null)
|
||||
{
|
||||
logger.LogWarning("Encountered null Trivy mirror domain configuration; skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var domainId = (domainOption.Id ?? string.Empty).Trim();
|
||||
if (domainId.Length == 0)
|
||||
{
|
||||
logger.LogWarning("Skipping Trivy mirror domain with empty id.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(domainOption.DisplayName)
|
||||
? domainId
|
||||
: domainOption.DisplayName!.Trim();
|
||||
|
||||
var domainDirectory = Path.Combine(root, domainId);
|
||||
Directory.CreateDirectory(domainDirectory);
|
||||
TrySetDirectoryTimestamp(domainDirectory, timestamp);
|
||||
|
||||
var metadataPath = Path.Combine(domainDirectory, MetadataFileName);
|
||||
await WriteFileAsync(metadataPath, metadataBytes, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
var metadataRelativePath = ToRelativePath(layoutRoot, metadataPath);
|
||||
|
||||
var databasePath = Path.Combine(domainDirectory, DatabaseFileName);
|
||||
await CopyDatabaseAsync(builderResult.ArchivePath, databasePath, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
var databaseRelativePath = ToRelativePath(layoutRoot, databasePath);
|
||||
|
||||
var sources = BuildSourceSummaries(advisories);
|
||||
|
||||
var manifestDocument = new MirrorDomainManifestDocument(
|
||||
SchemaVersion,
|
||||
exportedAt,
|
||||
exporterVersion,
|
||||
reference,
|
||||
manifestDigest,
|
||||
options.TargetRepository,
|
||||
domainId,
|
||||
displayName,
|
||||
plan.Mode.ToString().ToLowerInvariant(),
|
||||
plan.BaseExportId,
|
||||
plan.BaseManifestDigest,
|
||||
plan.ResetBaseline,
|
||||
new MirrorFileDescriptor(metadataRelativePath, metadataLength, metadataDigest),
|
||||
new MirrorFileDescriptor(databaseRelativePath, builderResult.ArchiveLength, builderResult.ArchiveDigest),
|
||||
sources);
|
||||
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifestDocument, SerializerOptions);
|
||||
var manifestPath = Path.Combine(domainDirectory, ManifestFileName);
|
||||
await WriteFileAsync(manifestPath, manifestBytes, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
var manifestRelativePath = ToRelativePath(layoutRoot, manifestPath);
|
||||
var manifestDigestValue = ComputeDigest(manifestBytes);
|
||||
|
||||
domains.Add(new MirrorIndexDomainEntry(
|
||||
domainId,
|
||||
displayName,
|
||||
advisories.Length,
|
||||
new MirrorFileDescriptor(manifestRelativePath, manifestBytes.LongLength, manifestDigestValue),
|
||||
new MirrorFileDescriptor(metadataRelativePath, metadataLength, metadataDigest),
|
||||
new MirrorFileDescriptor(databaseRelativePath, builderResult.ArchiveLength, builderResult.ArchiveDigest),
|
||||
sources));
|
||||
}
|
||||
|
||||
if (domains.Count == 0)
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
return;
|
||||
}
|
||||
|
||||
domains.Sort(static (left, right) => string.CompareOrdinal(left.DomainId, right.DomainId));
|
||||
|
||||
var delta = plan.Mode == TrivyDbExportMode.Delta
|
||||
? new MirrorDeltaMetadata(
|
||||
plan.ChangedFiles.Select(static file => new MirrorDeltaFile(file.Path, file.Digest)).ToArray(),
|
||||
plan.RemovedPaths.ToArray())
|
||||
: null;
|
||||
|
||||
var indexDocument = new MirrorIndexDocument(
|
||||
SchemaVersion,
|
||||
exportedAt,
|
||||
exporterVersion,
|
||||
options.TargetRepository,
|
||||
reference,
|
||||
manifestDigest,
|
||||
plan.Mode.ToString().ToLowerInvariant(),
|
||||
plan.BaseExportId,
|
||||
plan.BaseManifestDigest,
|
||||
plan.ResetBaseline,
|
||||
delta,
|
||||
domains);
|
||||
|
||||
var indexBytes = JsonSerializer.SerializeToUtf8Bytes(indexDocument, SerializerOptions);
|
||||
var indexPath = Path.Combine(root, "index.json");
|
||||
await WriteFileAsync(indexPath, indexBytes, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation(
|
||||
"Generated {DomainCount} Trivy DB mirror bundle(s) under {Directory}.",
|
||||
domains.Count,
|
||||
directoryName);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TrivyMirrorSourceSummary> BuildSourceSummaries(IReadOnlyList<Advisory> advisories)
|
||||
{
|
||||
if (advisories.Count == 0)
|
||||
{
|
||||
return Array.Empty<TrivyMirrorSourceSummary>();
|
||||
}
|
||||
|
||||
var builders = new Dictionary<string, SourceAccumulator>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
var counted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var provenance in advisory.Provenance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provenance.Source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = provenance.Source.Trim();
|
||||
if (!builders.TryGetValue(source, out var accumulator))
|
||||
{
|
||||
accumulator = new SourceAccumulator();
|
||||
builders[source] = accumulator;
|
||||
}
|
||||
|
||||
accumulator.Record(provenance.RecordedAt);
|
||||
if (counted.Add(source))
|
||||
{
|
||||
accumulator.Increment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var entries = builders
|
||||
.Select(static pair => new TrivyMirrorSourceSummary(
|
||||
pair.Key,
|
||||
pair.Value.FirstRecordedAt,
|
||||
pair.Value.LastRecordedAt,
|
||||
pair.Value.Count))
|
||||
.OrderBy(static summary => summary.Source, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static async Task CopyDatabaseAsync(
|
||||
string sourcePath,
|
||||
string destinationPath,
|
||||
DateTime timestamp,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
await using var source = new FileStream(
|
||||
sourcePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 81920,
|
||||
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await using var destination = new FileStream(
|
||||
destinationPath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 81920,
|
||||
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false);
|
||||
await destination.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
File.SetLastWriteTimeUtc(destinationPath, timestamp);
|
||||
}
|
||||
|
||||
private static async Task WriteFileAsync(
|
||||
string path,
|
||||
ReadOnlyMemory<byte> bytes,
|
||||
DateTime timestamp,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 81920,
|
||||
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
File.SetLastWriteTimeUtc(path, timestamp);
|
||||
}
|
||||
|
||||
private static string ToRelativePath(string root, string fullPath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(root, fullPath);
|
||||
var normalized = relative.Replace(Path.DirectorySeparatorChar, '/');
|
||||
return string.IsNullOrEmpty(normalized) ? "." : normalized;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var hash = SHA256.HashData(payload);
|
||||
var hex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return $"sha256:{hex}";
|
||||
}
|
||||
|
||||
private static void TrySetDirectoryTimestamp(string directory, DateTime timestamp)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.SetLastWriteTimeUtc(directory, timestamp);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort – ignore failures.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record MirrorIndexDocument(
|
||||
int SchemaVersion,
|
||||
DateTimeOffset GeneratedAt,
|
||||
string ExporterVersion,
|
||||
string? TargetRepository,
|
||||
string Reference,
|
||||
string ManifestDigest,
|
||||
string Mode,
|
||||
string? BaseExportId,
|
||||
string? BaseManifestDigest,
|
||||
bool ResetBaseline,
|
||||
MirrorDeltaMetadata? Delta,
|
||||
IReadOnlyList<MirrorIndexDomainEntry> Domains);
|
||||
|
||||
private sealed record MirrorDeltaMetadata(
|
||||
IReadOnlyList<MirrorDeltaFile> ChangedFiles,
|
||||
IReadOnlyList<string> RemovedPaths);
|
||||
|
||||
private sealed record MirrorDeltaFile(string Path, string Digest);
|
||||
|
||||
private sealed record MirrorIndexDomainEntry(
|
||||
string DomainId,
|
||||
string DisplayName,
|
||||
int AdvisoryCount,
|
||||
MirrorFileDescriptor Manifest,
|
||||
MirrorFileDescriptor Metadata,
|
||||
MirrorFileDescriptor Database,
|
||||
IReadOnlyList<TrivyMirrorSourceSummary> Sources);
|
||||
|
||||
private sealed record MirrorDomainManifestDocument(
|
||||
int SchemaVersion,
|
||||
DateTimeOffset GeneratedAt,
|
||||
string ExporterVersion,
|
||||
string Reference,
|
||||
string ManifestDigest,
|
||||
string? TargetRepository,
|
||||
string DomainId,
|
||||
string DisplayName,
|
||||
string Mode,
|
||||
string? BaseExportId,
|
||||
string? BaseManifestDigest,
|
||||
bool ResetBaseline,
|
||||
MirrorFileDescriptor Metadata,
|
||||
MirrorFileDescriptor Database,
|
||||
IReadOnlyList<TrivyMirrorSourceSummary> Sources);
|
||||
|
||||
private sealed record MirrorFileDescriptor(string Path, long SizeBytes, string Digest);
|
||||
|
||||
private sealed record TrivyMirrorSourceSummary(
|
||||
string Source,
|
||||
DateTimeOffset? FirstRecordedAt,
|
||||
DateTimeOffset? LastRecordedAt,
|
||||
int AdvisoryCount);
|
||||
|
||||
private sealed class SourceAccumulator
|
||||
{
|
||||
public DateTimeOffset? FirstRecordedAt { get; private set; }
|
||||
|
||||
public DateTimeOffset? LastRecordedAt { get; private set; }
|
||||
|
||||
public int Count { get; private set; }
|
||||
|
||||
public void Record(DateTimeOffset recordedAt)
|
||||
{
|
||||
var utc = recordedAt.ToUniversalTime();
|
||||
if (FirstRecordedAt is null || utc < FirstRecordedAt.Value)
|
||||
{
|
||||
FirstRecordedAt = utc;
|
||||
}
|
||||
|
||||
if (LastRecordedAt is null || utc > LastRecordedAt.Value)
|
||||
{
|
||||
LastRecordedAt = utc;
|
||||
}
|
||||
}
|
||||
|
||||
public void Increment() => Count++;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Core;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Merge.Services;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
@@ -35,7 +38,8 @@ public sealed class AdvisoryMergeServiceTests
|
||||
var precedenceMerger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
var aliasResolver = new AliasGraphResolver(aliasStore);
|
||||
var canonicalMerger = new CanonicalMerger(timeProvider);
|
||||
var service = new AdvisoryMergeService(aliasResolver, advisoryStore, precedenceMerger, writer, canonicalMerger, NullLogger<AdvisoryMergeService>.Instance);
|
||||
var eventLog = new RecordingAdvisoryEventLog();
|
||||
var service = new AdvisoryMergeService(aliasResolver, advisoryStore, precedenceMerger, writer, canonicalMerger, eventLog, timeProvider, NullLogger<AdvisoryMergeService>.Instance);
|
||||
|
||||
var result = await service.MergeAsync("GHSA-aaaa-bbbb-cccc", CancellationToken.None);
|
||||
|
||||
@@ -52,6 +56,11 @@ public sealed class AdvisoryMergeServiceTests
|
||||
var summaryDecision = Assert.Single(mergeRecord!.FieldDecisions, decision => decision.Field == "summary");
|
||||
Assert.Equal("osv", summaryDecision.SelectedSource);
|
||||
Assert.Equal("freshness_override", summaryDecision.DecisionReason);
|
||||
|
||||
var appendRequest = eventLog.LastRequest;
|
||||
Assert.NotNull(appendRequest);
|
||||
Assert.Contains(appendRequest!.Statements, statement => string.Equals(statement.Advisory.AdvisoryKey, "CVE-2025-4242", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(appendRequest.Conflicts is null || appendRequest.Conflicts.Count == 0);
|
||||
}
|
||||
|
||||
private static Advisory CreateGhsaAdvisory()
|
||||
@@ -114,6 +123,23 @@ public sealed class AdvisoryMergeServiceTests
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
|
||||
private sealed class RecordingAdvisoryEventLog : IAdvisoryEventLog
|
||||
{
|
||||
public AdvisoryEventAppendRequest? LastRequest { get; private set; }
|
||||
|
||||
public ValueTask AppendAsync(AdvisoryEventAppendRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<AdvisoryReplay> ReplayAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeAliasStore : IAliasStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<AliasRecord>> _records = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -130,7 +156,9 @@ public sealed class AdvisoryMergeServiceTests
|
||||
}
|
||||
|
||||
public Task<AliasUpsertResult> ReplaceAsync(string advisoryKey, IEnumerable<AliasEntry> aliases, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new AliasUpsertResult(advisoryKey, Array.Empty<AliasCollision>()));
|
||||
{
|
||||
return Task.FromResult(new AliasUpsertResult(advisoryKey, Array.Empty<AliasCollision>()));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AliasRecord>> GetByAliasAsync(string scheme, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -206,6 +234,8 @@ public sealed class AdvisoryMergeServiceTests
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MergeEventRecord>> GetRecentAsync(string advisoryKey, int limit, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<MergeEventRecord>>(Array.Empty<MergeEventRecord>());
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MergeEventRecord>>(Array.Empty<MergeEventRecord>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Concelier.Merge.Options;
|
||||
using StellaOps.Concelier.Merge.Services;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
public sealed class AdvisoryPrecedenceMergerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Merge_PrefersVendorPrecedenceOverNvd()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
|
||||
|
||||
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
|
||||
var expectedMergeTimestamp = timeProvider.GetUtcNow();
|
||||
|
||||
var merged = merger.Merge(new[] { nvd, redHat });
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("Red Hat Security Advisory", merged.Title);
|
||||
Assert.Equal("Vendor-confirmed impact on RHEL 9.", merged.Summary);
|
||||
Assert.Equal("high", merged.Severity);
|
||||
Assert.Equal(redHat.Published, merged.Published);
|
||||
Assert.Equal(redHat.Modified, merged.Modified);
|
||||
Assert.Contains("RHSA-2025:0001", merged.Aliases);
|
||||
Assert.Contains("CVE-2025-1000", merged.Aliases);
|
||||
|
||||
var package = Assert.Single(merged.AffectedPackages);
|
||||
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", package.Identifier);
|
||||
Assert.Empty(package.VersionRanges); // NVD range suppressed by vendor precedence
|
||||
Assert.Contains(package.Statuses, status => status.Status == "known_affected");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
||||
|
||||
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "redhat");
|
||||
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "nvd");
|
||||
|
||||
var mergeProvenance = merged.Provenance.Single(p => p.Source == "merge");
|
||||
Assert.Equal("precedence", mergeProvenance.Kind);
|
||||
Assert.Equal(expectedMergeTimestamp, mergeProvenance.RecordedAt);
|
||||
Assert.Contains("redhat", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("nvd", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var rangeMeasurement = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.range_overrides");
|
||||
Assert.Equal(1, rangeMeasurement.Value);
|
||||
Assert.Contains(rangeMeasurement.Tags, tag => string.Equals(tag.Key, "suppressed_source", StringComparison.Ordinal) && tag.Value?.ToString()?.Contains("nvd", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
var severityConflict = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.conflicts");
|
||||
Assert.Equal(1, severityConflict.Value);
|
||||
Assert.Contains(severityConflict.Tags, tag => string.Equals(tag.Key, "type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "severity", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_KevOnlyTogglesExploitKnown()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
|
||||
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd", timeProvider.GetUtcNow());
|
||||
var baseAdvisory = new Advisory(
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Concelier.Merge.Options;
|
||||
using StellaOps.Concelier.Merge.Services;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
public sealed class AdvisoryPrecedenceMergerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Merge_PrefersVendorPrecedenceOverNvd()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
|
||||
|
||||
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
|
||||
var expectedMergeTimestamp = timeProvider.GetUtcNow();
|
||||
|
||||
var merged = merger.Merge(new[] { nvd, redHat }).Advisory;
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("Red Hat Security Advisory", merged.Title);
|
||||
Assert.Equal("Vendor-confirmed impact on RHEL 9.", merged.Summary);
|
||||
Assert.Equal("high", merged.Severity);
|
||||
Assert.Equal(redHat.Published, merged.Published);
|
||||
Assert.Equal(redHat.Modified, merged.Modified);
|
||||
Assert.Contains("RHSA-2025:0001", merged.Aliases);
|
||||
Assert.Contains("CVE-2025-1000", merged.Aliases);
|
||||
|
||||
var package = Assert.Single(merged.AffectedPackages);
|
||||
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", package.Identifier);
|
||||
Assert.Empty(package.VersionRanges); // NVD range suppressed by vendor precedence
|
||||
Assert.Contains(package.Statuses, status => status.Status == "known_affected");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
||||
|
||||
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "redhat");
|
||||
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "nvd");
|
||||
|
||||
var mergeProvenance = merged.Provenance.Single(p => p.Source == "merge");
|
||||
Assert.Equal("precedence", mergeProvenance.Kind);
|
||||
Assert.Equal(expectedMergeTimestamp, mergeProvenance.RecordedAt);
|
||||
Assert.Contains("redhat", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("nvd", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var rangeMeasurement = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.range_overrides");
|
||||
Assert.Equal(1, rangeMeasurement.Value);
|
||||
Assert.Contains(rangeMeasurement.Tags, tag => string.Equals(tag.Key, "suppressed_source", StringComparison.Ordinal) && tag.Value?.ToString()?.Contains("nvd", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
var severityConflict = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.conflicts");
|
||||
Assert.Equal(1, severityConflict.Value);
|
||||
Assert.Contains(severityConflict.Tags, tag => string.Equals(tag.Key, "type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "severity", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_KevOnlyTogglesExploitKnown()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
|
||||
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd", timeProvider.GetUtcNow());
|
||||
var baseAdvisory = new Advisory(
|
||||
"CVE-2025-2000",
|
||||
"CVE-2025-2000",
|
||||
"Base registry summary",
|
||||
@@ -76,52 +76,52 @@ public sealed class AdvisoryPrecedenceMergerTests
|
||||
aliases: new[] { "CVE-2025-2000" },
|
||||
credits: Array.Empty<AdvisoryCredit>(),
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:a:example:product:2.0:*:*:*:*:*:*:*",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
"semver",
|
||||
"2.0.0",
|
||||
"2.0.5",
|
||||
null,
|
||||
"<2.0.5",
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "product", timeProvider.GetUtcNow()))
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { nvdProvenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { nvdProvenance });
|
||||
|
||||
var kevProvenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-2000", timeProvider.GetUtcNow());
|
||||
var kevAdvisory = new Advisory(
|
||||
"CVE-2025-2000",
|
||||
"Known Exploited Vulnerability",
|
||||
summary: null,
|
||||
language: null,
|
||||
published: null,
|
||||
modified: null,
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:a:example:product:2.0:*:*:*:*:*:*:*",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
"semver",
|
||||
"2.0.0",
|
||||
"2.0.5",
|
||||
null,
|
||||
"<2.0.5",
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "product", timeProvider.GetUtcNow()))
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { nvdProvenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { nvdProvenance });
|
||||
|
||||
var kevProvenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-2000", timeProvider.GetUtcNow());
|
||||
var kevAdvisory = new Advisory(
|
||||
"CVE-2025-2000",
|
||||
"Known Exploited Vulnerability",
|
||||
summary: null,
|
||||
language: null,
|
||||
published: null,
|
||||
modified: null,
|
||||
severity: null,
|
||||
exploitKnown: true,
|
||||
aliases: new[] { "KEV-CVE-2025-2000" },
|
||||
credits: Array.Empty<AdvisoryCredit>(),
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { kevProvenance });
|
||||
|
||||
var merged = merger.Merge(new[] { baseAdvisory, kevAdvisory });
|
||||
|
||||
Assert.True(merged.ExploitKnown);
|
||||
Assert.Equal("medium", merged.Severity); // KEV must not override severity
|
||||
Assert.Equal("Base registry summary", merged.Summary);
|
||||
Assert.Contains("CVE-2025-2000", merged.Aliases);
|
||||
Assert.Contains("KEV-CVE-2025-2000", merged.Aliases);
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { kevProvenance });
|
||||
|
||||
var merged = merger.Merge(new[] { baseAdvisory, kevAdvisory }).Advisory;
|
||||
|
||||
Assert.True(merged.ExploitKnown);
|
||||
Assert.Equal("medium", merged.Severity); // KEV must not override severity
|
||||
Assert.Equal("Base registry summary", merged.Summary);
|
||||
Assert.Contains("CVE-2025-2000", merged.Aliases);
|
||||
Assert.Contains("KEV-CVE-2025-2000", merged.Aliases);
|
||||
Assert.Contains(merged.Provenance, provenance => provenance.Source == "kev");
|
||||
Assert.Contains(merged.Provenance, provenance => provenance.Source == "merge");
|
||||
}
|
||||
@@ -212,7 +212,7 @@ public sealed class AdvisoryPrecedenceMergerTests
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { new AdvisoryProvenance("osv", "document", "https://osv.dev/vulnerability/CVE-2025-9000", timeProvider.GetUtcNow(), new[] { ProvenanceFieldMasks.Advisory }) });
|
||||
|
||||
var merged = merger.Merge(new[] { ghsa, osv });
|
||||
var merged = merger.Merge(new[] { ghsa, osv }).Advisory;
|
||||
|
||||
Assert.Equal("CVE-2025-9000", merged.AdvisoryKey);
|
||||
Assert.Contains(merged.Credits, credit =>
|
||||
@@ -311,7 +311,7 @@ public sealed class AdvisoryPrecedenceMergerTests
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { acscDocumentProvenance });
|
||||
|
||||
var merged = merger.Merge(new[] { acsc, vendor });
|
||||
var merged = merger.Merge(new[] { acsc, vendor }).Advisory;
|
||||
|
||||
Assert.Equal("critical", merged.Severity); // ACSC must not override vendor severity
|
||||
Assert.Equal("Vendor-confirmed exploit.", merged.Summary);
|
||||
@@ -450,7 +450,7 @@ public sealed class AdvisoryPrecedenceMergerTests
|
||||
new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now),
|
||||
});
|
||||
|
||||
var merged = merger.Merge(new[] { nvdAdvisory, ghsaAdvisory });
|
||||
var merged = merger.Merge(new[] { nvdAdvisory, ghsaAdvisory }).Advisory;
|
||||
Assert.Equal(2, merged.AffectedPackages.Length);
|
||||
|
||||
var normalizedPackage = Assert.Single(merged.AffectedPackages, pkg => pkg.Identifier == "pkg:npm/example");
|
||||
@@ -474,72 +474,72 @@ public sealed class AdvisoryPrecedenceMergerTests
|
||||
|
||||
[Fact]
|
||||
public void Merge_RespectsConfiguredPrecedenceOverrides()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var options = new AdvisoryPrecedenceOptions
|
||||
{
|
||||
Ranks = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["nvd"] = 0,
|
||||
["redhat"] = 5,
|
||||
}
|
||||
};
|
||||
|
||||
var logger = new TestLogger<AdvisoryPrecedenceMerger>();
|
||||
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
|
||||
|
||||
var merger = new AdvisoryPrecedenceMerger(
|
||||
new AffectedPackagePrecedenceResolver(),
|
||||
options,
|
||||
timeProvider,
|
||||
logger);
|
||||
|
||||
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
|
||||
var merged = merger.Merge(new[] { redHat, nvd });
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("CVE-2025-1000", merged.Title); // NVD preferred
|
||||
Assert.Equal("NVD summary", merged.Summary);
|
||||
Assert.Equal("medium", merged.Severity);
|
||||
|
||||
var package = Assert.Single(merged.AffectedPackages);
|
||||
Assert.NotEmpty(package.VersionRanges); // Vendor range no longer overrides
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
||||
|
||||
var overrideMeasurement = Assert.Single(metrics.Measurements, m => m.Name == "concelier.merge.overrides");
|
||||
Assert.Equal(1, overrideMeasurement.Value);
|
||||
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "primary_source" && string.Equals(tag.Value?.ToString(), "nvd", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "suppressed_source" && tag.Value?.ToString()?.Contains("redhat", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
Assert.DoesNotContain(metrics.Measurements, measurement => measurement.Name == "concelier.merge.range_overrides");
|
||||
|
||||
var conflictMeasurement = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.conflicts");
|
||||
Assert.Equal(1, conflictMeasurement.Value);
|
||||
Assert.Contains(conflictMeasurement.Tags, tag => tag.Key == "type" && string.Equals(tag.Value?.ToString(), "severity", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(conflictMeasurement.Tags, tag => tag.Key == "reason" && string.Equals(tag.Value?.ToString(), "mismatch", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var logEntry = Assert.Single(logger.Entries, entry => entry.EventId.Name == "AdvisoryOverride");
|
||||
Assert.Equal(LogLevel.Information, logEntry.Level);
|
||||
Assert.NotNull(logEntry.StructuredState);
|
||||
Assert.Contains(logEntry.StructuredState!, kvp =>
|
||||
(string.Equals(kvp.Key, "Override", StringComparison.Ordinal) ||
|
||||
string.Equals(kvp.Key, "@Override", StringComparison.Ordinal)) &&
|
||||
kvp.Value is not null);
|
||||
}
|
||||
|
||||
private static (Advisory Vendor, Advisory Registry) CreateVendorAndRegistryAdvisories()
|
||||
{
|
||||
var redHatPublished = new DateTimeOffset(2025, 1, 10, 0, 0, 0, TimeSpan.Zero);
|
||||
var redHatModified = redHatPublished.AddDays(1);
|
||||
var redHatProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:0001", redHatModified);
|
||||
var redHatPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
"rhel-9",
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
new[] { new AffectedPackageStatus("known_affected", redHatProvenance) },
|
||||
new[] { redHatProvenance });
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var options = new AdvisoryPrecedenceOptions
|
||||
{
|
||||
Ranks = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["nvd"] = 0,
|
||||
["redhat"] = 5,
|
||||
}
|
||||
};
|
||||
|
||||
var logger = new TestLogger<AdvisoryPrecedenceMerger>();
|
||||
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
|
||||
|
||||
var merger = new AdvisoryPrecedenceMerger(
|
||||
new AffectedPackagePrecedenceResolver(),
|
||||
options,
|
||||
timeProvider,
|
||||
logger);
|
||||
|
||||
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
|
||||
var merged = merger.Merge(new[] { redHat, nvd }).Advisory;
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("CVE-2025-1000", merged.Title); // NVD preferred
|
||||
Assert.Equal("NVD summary", merged.Summary);
|
||||
Assert.Equal("medium", merged.Severity);
|
||||
|
||||
var package = Assert.Single(merged.AffectedPackages);
|
||||
Assert.NotEmpty(package.VersionRanges); // Vendor range no longer overrides
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
||||
|
||||
var overrideMeasurement = Assert.Single(metrics.Measurements, m => m.Name == "concelier.merge.overrides");
|
||||
Assert.Equal(1, overrideMeasurement.Value);
|
||||
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "primary_source" && string.Equals(tag.Value?.ToString(), "nvd", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "suppressed_source" && tag.Value?.ToString()?.Contains("redhat", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
Assert.DoesNotContain(metrics.Measurements, measurement => measurement.Name == "concelier.merge.range_overrides");
|
||||
|
||||
var conflictMeasurement = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.conflicts");
|
||||
Assert.Equal(1, conflictMeasurement.Value);
|
||||
Assert.Contains(conflictMeasurement.Tags, tag => tag.Key == "type" && string.Equals(tag.Value?.ToString(), "severity", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(conflictMeasurement.Tags, tag => tag.Key == "reason" && string.Equals(tag.Value?.ToString(), "mismatch", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var logEntry = Assert.Single(logger.Entries, entry => entry.EventId.Name == "AdvisoryOverride");
|
||||
Assert.Equal(LogLevel.Information, logEntry.Level);
|
||||
Assert.NotNull(logEntry.StructuredState);
|
||||
Assert.Contains(logEntry.StructuredState!, kvp =>
|
||||
(string.Equals(kvp.Key, "Override", StringComparison.Ordinal) ||
|
||||
string.Equals(kvp.Key, "@Override", StringComparison.Ordinal)) &&
|
||||
kvp.Value is not null);
|
||||
}
|
||||
|
||||
private static (Advisory Vendor, Advisory Registry) CreateVendorAndRegistryAdvisories()
|
||||
{
|
||||
var redHatPublished = new DateTimeOffset(2025, 1, 10, 0, 0, 0, TimeSpan.Zero);
|
||||
var redHatModified = redHatPublished.AddDays(1);
|
||||
var redHatProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:0001", redHatModified);
|
||||
var redHatPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
"rhel-9",
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
new[] { new AffectedPackageStatus("known_affected", redHatProvenance) },
|
||||
new[] { redHatProvenance });
|
||||
var redHat = new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"Red Hat Security Advisory",
|
||||
@@ -554,43 +554,43 @@ public sealed class AdvisoryPrecedenceMergerTests
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference(
|
||||
"https://access.redhat.com/errata/RHSA-2025:0001",
|
||||
"advisory",
|
||||
"redhat",
|
||||
"Red Hat errata",
|
||||
redHatProvenance)
|
||||
},
|
||||
affectedPackages: new[] { redHatPackage },
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric(
|
||||
"3.1",
|
||||
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
9.8,
|
||||
"critical",
|
||||
new AdvisoryProvenance("redhat", "cvss", "RHSA-2025:0001", redHatModified))
|
||||
},
|
||||
provenance: new[] { redHatProvenance });
|
||||
|
||||
var nvdPublished = new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero);
|
||||
var nvdModified = nvdPublished.AddDays(2);
|
||||
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-1000", nvdModified);
|
||||
var nvdPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
"rhel-9",
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
"cpe",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"<=9.0",
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "RHEL", nvdModified))
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { nvdProvenance });
|
||||
"https://access.redhat.com/errata/RHSA-2025:0001",
|
||||
"advisory",
|
||||
"redhat",
|
||||
"Red Hat errata",
|
||||
redHatProvenance)
|
||||
},
|
||||
affectedPackages: new[] { redHatPackage },
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric(
|
||||
"3.1",
|
||||
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
9.8,
|
||||
"critical",
|
||||
new AdvisoryProvenance("redhat", "cvss", "RHSA-2025:0001", redHatModified))
|
||||
},
|
||||
provenance: new[] { redHatProvenance });
|
||||
|
||||
var nvdPublished = new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero);
|
||||
var nvdModified = nvdPublished.AddDays(2);
|
||||
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-1000", nvdModified);
|
||||
var nvdPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
"rhel-9",
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
"cpe",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"<=9.0",
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "RHEL", nvdModified))
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { nvdProvenance });
|
||||
var nvd = new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"CVE-2025-1000",
|
||||
@@ -605,24 +605,24 @@ public sealed class AdvisoryPrecedenceMergerTests
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference(
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2025-1000",
|
||||
"advisory",
|
||||
"nvd",
|
||||
"NVD advisory",
|
||||
nvdProvenance)
|
||||
},
|
||||
affectedPackages: new[] { nvdPackage },
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric(
|
||||
"3.1",
|
||||
"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N",
|
||||
6.8,
|
||||
"medium",
|
||||
new AdvisoryProvenance("nvd", "cvss", "CVE-2025-1000", nvdModified))
|
||||
},
|
||||
provenance: new[] { nvdProvenance });
|
||||
|
||||
return (redHat, nvd);
|
||||
}
|
||||
}
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2025-1000",
|
||||
"advisory",
|
||||
"nvd",
|
||||
"NVD advisory",
|
||||
nvdProvenance)
|
||||
},
|
||||
affectedPackages: new[] { nvdPackage },
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric(
|
||||
"3.1",
|
||||
"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N",
|
||||
6.8,
|
||||
"medium",
|
||||
new AdvisoryProvenance("nvd", "cvss", "CVE-2025-1000", nvdModified))
|
||||
},
|
||||
provenance: new[] { nvdProvenance });
|
||||
|
||||
return (redHat, nvd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,231 +1,234 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Merge.Services;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
|
||||
using StellaOps.Concelier.Testing;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private MergeEventStore? _mergeEventStore;
|
||||
private MergeEventWriter? _mergeEventWriter;
|
||||
private AdvisoryPrecedenceMerger? _merger;
|
||||
private FakeTimeProvider? _timeProvider;
|
||||
|
||||
public MergePrecedenceIntegrationTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePipeline_PsirtOverridesNvd_AndKevOnlyTogglesExploitKnown()
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
|
||||
var merger = _merger!;
|
||||
var writer = _mergeEventWriter!;
|
||||
var store = _mergeEventStore!;
|
||||
var timeProvider = _timeProvider!;
|
||||
|
||||
var expectedTimestamp = timeProvider.GetUtcNow();
|
||||
|
||||
var nvd = CreateNvdBaseline();
|
||||
var vendor = CreateVendorOverride();
|
||||
var kev = CreateKevSignal();
|
||||
|
||||
var merged = merger.Merge(new[] { nvd, vendor, kev });
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("Vendor Security Advisory", merged.Title);
|
||||
Assert.Equal("Critical impact on supported platforms.", merged.Summary);
|
||||
Assert.Equal("critical", merged.Severity);
|
||||
Assert.True(merged.ExploitKnown);
|
||||
|
||||
var affected = Assert.Single(merged.AffectedPackages);
|
||||
Assert.Empty(affected.VersionRanges);
|
||||
Assert.Contains(affected.Statuses, status => status.Status == "known_affected" && status.Provenance.Source == "vendor");
|
||||
|
||||
var mergeProvenance = Assert.Single(merged.Provenance, p => p.Source == "merge");
|
||||
Assert.Equal("precedence", mergeProvenance.Kind);
|
||||
Assert.Equal(expectedTimestamp, mergeProvenance.RecordedAt);
|
||||
Assert.Contains("vendor", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("kev", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var inputDocumentIds = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Merge.Services;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
|
||||
using StellaOps.Concelier.Testing;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private MergeEventStore? _mergeEventStore;
|
||||
private MergeEventWriter? _mergeEventWriter;
|
||||
private AdvisoryPrecedenceMerger? _merger;
|
||||
private FakeTimeProvider? _timeProvider;
|
||||
|
||||
public MergePrecedenceIntegrationTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePipeline_PsirtOverridesNvd_AndKevOnlyTogglesExploitKnown()
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
|
||||
var merger = _merger!;
|
||||
var writer = _mergeEventWriter!;
|
||||
var store = _mergeEventStore!;
|
||||
var timeProvider = _timeProvider!;
|
||||
|
||||
var expectedTimestamp = timeProvider.GetUtcNow();
|
||||
|
||||
var nvd = CreateNvdBaseline();
|
||||
var vendor = CreateVendorOverride();
|
||||
var kev = CreateKevSignal();
|
||||
|
||||
var merged = merger.Merge(new[] { nvd, vendor, kev }).Advisory;
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("Vendor Security Advisory", merged.Title);
|
||||
Assert.Equal("Critical impact on supported platforms.", merged.Summary);
|
||||
Assert.Equal("critical", merged.Severity);
|
||||
Assert.True(merged.ExploitKnown);
|
||||
|
||||
var affected = Assert.Single(merged.AffectedPackages);
|
||||
Assert.Empty(affected.VersionRanges);
|
||||
Assert.Contains(affected.Statuses, status => status.Status == "known_affected" && status.Provenance.Source == "vendor");
|
||||
|
||||
var mergeProvenance = Assert.Single(merged.Provenance, p => p.Source == "merge");
|
||||
Assert.Equal("precedence", mergeProvenance.Kind);
|
||||
Assert.Equal(expectedTimestamp, mergeProvenance.RecordedAt);
|
||||
Assert.Contains("vendor", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("kev", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var inputDocumentIds = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||
var record = await writer.AppendAsync(merged.AdvisoryKey, nvd, merged, inputDocumentIds, Array.Empty<MergeFieldDecision>(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(expectedTimestamp, record.MergedAt);
|
||||
Assert.Equal(inputDocumentIds, record.InputDocumentIds);
|
||||
Assert.NotEqual(record.BeforeHash, record.AfterHash);
|
||||
|
||||
var records = await store.GetRecentAsync(merged.AdvisoryKey, 5, CancellationToken.None);
|
||||
var persisted = Assert.Single(records);
|
||||
Assert.Equal(record.Id, persisted.Id);
|
||||
Assert.Equal(merged.AdvisoryKey, persisted.AdvisoryKey);
|
||||
Assert.True(persisted.AfterHash.Length > 0);
|
||||
Assert.True(persisted.BeforeHash.Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePipeline_IsDeterministicAcrossRuns()
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
|
||||
var merger = _merger!;
|
||||
var calculator = new CanonicalHashCalculator();
|
||||
|
||||
var first = merger.Merge(new[] { CreateNvdBaseline(), CreateVendorOverride() });
|
||||
var second = merger.Merge(new[] { CreateNvdBaseline(), CreateVendorOverride() });
|
||||
|
||||
var firstHash = calculator.ComputeHash(first);
|
||||
var secondHash = calculator.ComputeHash(second);
|
||||
|
||||
Assert.Equal(firstHash, secondHash);
|
||||
Assert.Equal(first.AdvisoryKey, second.AdvisoryKey);
|
||||
Assert.Equal(first.Aliases.Length, second.Aliases.Length);
|
||||
Assert.True(first.Aliases.SequenceEqual(second.Aliases));
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero))
|
||||
{
|
||||
AutoAdvanceAmount = TimeSpan.Zero,
|
||||
};
|
||||
_merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), _timeProvider);
|
||||
_mergeEventStore = new MergeEventStore(_fixture.Database, NullLogger<MergeEventStore>.Instance);
|
||||
_mergeEventWriter = new MergeEventWriter(_mergeEventStore, new CanonicalHashCalculator(), _timeProvider, NullLogger<MergeEventWriter>.Instance);
|
||||
await DropMergeCollectionAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task EnsureInitializedAsync()
|
||||
{
|
||||
if (_mergeEventWriter is null)
|
||||
{
|
||||
await InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DropMergeCollectionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _fixture.Database.DropCollectionAsync(MongoStorageDefaults.Collections.MergeEvent);
|
||||
}
|
||||
catch (MongoCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Collection has not been created yet – safe to ignore.
|
||||
}
|
||||
}
|
||||
|
||||
private static Advisory CreateNvdBaseline()
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-1000", DateTimeOffset.Parse("2025-02-10T00:00:00Z"));
|
||||
return new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"CVE-2025-1000",
|
||||
"Baseline description from NVD.",
|
||||
"en",
|
||||
DateTimeOffset.Parse("2025-02-05T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-02-10T12:00:00Z"),
|
||||
"medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-1000" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://nvd.nist.gov/vuln/detail/CVE-2025-1000", "advisory", "nvd", "NVD reference", provenance),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*",
|
||||
"vendor-os",
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "cpe",
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: "<=1.0",
|
||||
provenance: provenance)
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { provenance })
|
||||
},
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical", provenance)
|
||||
},
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
private static Advisory CreateVendorOverride()
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("vendor", "psirt", "VSA-2025-1000", DateTimeOffset.Parse("2025-02-11T00:00:00Z"));
|
||||
return new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"Vendor Security Advisory",
|
||||
"Critical impact on supported platforms.",
|
||||
"en",
|
||||
DateTimeOffset.Parse("2025-02-06T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-02-11T06:00:00Z"),
|
||||
"critical",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-1000", "VSA-2025-1000" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://vendor.example/advisories/VSA-2025-1000", "advisory", "vendor", "Vendor advisory", provenance),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*",
|
||||
"vendor-os",
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
new[]
|
||||
{
|
||||
new AffectedPackageStatus("known_affected", provenance)
|
||||
},
|
||||
new[] { provenance })
|
||||
},
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 10.0, "critical", provenance)
|
||||
},
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
private static Advisory CreateKevSignal()
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-1000", DateTimeOffset.Parse("2025-02-12T00:00:00Z"));
|
||||
return new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"Known Exploited Vulnerability",
|
||||
null,
|
||||
null,
|
||||
published: null,
|
||||
modified: null,
|
||||
severity: null,
|
||||
exploitKnown: true,
|
||||
aliases: new[] { "KEV-CVE-2025-1000" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(expectedTimestamp, record.MergedAt);
|
||||
Assert.Equal(inputDocumentIds, record.InputDocumentIds);
|
||||
Assert.NotEqual(record.BeforeHash, record.AfterHash);
|
||||
|
||||
var records = await store.GetRecentAsync(merged.AdvisoryKey, 5, CancellationToken.None);
|
||||
var persisted = Assert.Single(records);
|
||||
Assert.Equal(record.Id, persisted.Id);
|
||||
Assert.Equal(merged.AdvisoryKey, persisted.AdvisoryKey);
|
||||
Assert.True(persisted.AfterHash.Length > 0);
|
||||
Assert.True(persisted.BeforeHash.Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePipeline_IsDeterministicAcrossRuns()
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
|
||||
var merger = _merger!;
|
||||
var calculator = new CanonicalHashCalculator();
|
||||
|
||||
var firstResult = merger.Merge(new[] { CreateNvdBaseline(), CreateVendorOverride() });
|
||||
var secondResult = merger.Merge(new[] { CreateNvdBaseline(), CreateVendorOverride() });
|
||||
|
||||
var first = firstResult.Advisory;
|
||||
var second = secondResult.Advisory;
|
||||
|
||||
var firstHash = calculator.ComputeHash(first);
|
||||
var secondHash = calculator.ComputeHash(second);
|
||||
|
||||
Assert.Equal(firstHash, secondHash);
|
||||
Assert.Equal(first.AdvisoryKey, second.AdvisoryKey);
|
||||
Assert.Equal(first.Aliases.Length, second.Aliases.Length);
|
||||
Assert.True(first.Aliases.SequenceEqual(second.Aliases));
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero))
|
||||
{
|
||||
AutoAdvanceAmount = TimeSpan.Zero,
|
||||
};
|
||||
_merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), _timeProvider);
|
||||
_mergeEventStore = new MergeEventStore(_fixture.Database, NullLogger<MergeEventStore>.Instance);
|
||||
_mergeEventWriter = new MergeEventWriter(_mergeEventStore, new CanonicalHashCalculator(), _timeProvider, NullLogger<MergeEventWriter>.Instance);
|
||||
await DropMergeCollectionAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task EnsureInitializedAsync()
|
||||
{
|
||||
if (_mergeEventWriter is null)
|
||||
{
|
||||
await InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DropMergeCollectionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _fixture.Database.DropCollectionAsync(MongoStorageDefaults.Collections.MergeEvent);
|
||||
}
|
||||
catch (MongoCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Collection has not been created yet – safe to ignore.
|
||||
}
|
||||
}
|
||||
|
||||
private static Advisory CreateNvdBaseline()
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-1000", DateTimeOffset.Parse("2025-02-10T00:00:00Z"));
|
||||
return new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"CVE-2025-1000",
|
||||
"Baseline description from NVD.",
|
||||
"en",
|
||||
DateTimeOffset.Parse("2025-02-05T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-02-10T12:00:00Z"),
|
||||
"medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-1000" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://nvd.nist.gov/vuln/detail/CVE-2025-1000", "advisory", "nvd", "NVD reference", provenance),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*",
|
||||
"vendor-os",
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "cpe",
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: "<=1.0",
|
||||
provenance: provenance)
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { provenance })
|
||||
},
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical", provenance)
|
||||
},
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
private static Advisory CreateVendorOverride()
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("vendor", "psirt", "VSA-2025-1000", DateTimeOffset.Parse("2025-02-11T00:00:00Z"));
|
||||
return new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"Vendor Security Advisory",
|
||||
"Critical impact on supported platforms.",
|
||||
"en",
|
||||
DateTimeOffset.Parse("2025-02-06T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-02-11T06:00:00Z"),
|
||||
"critical",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-1000", "VSA-2025-1000" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://vendor.example/advisories/VSA-2025-1000", "advisory", "vendor", "Vendor advisory", provenance),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*",
|
||||
"vendor-os",
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
new[]
|
||||
{
|
||||
new AffectedPackageStatus("known_affected", provenance)
|
||||
},
|
||||
new[] { provenance })
|
||||
},
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 10.0, "critical", provenance)
|
||||
},
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
private static Advisory CreateKevSignal()
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-1000", DateTimeOffset.Parse("2025-02-12T00:00:00Z"));
|
||||
return new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"Known Exploited Vulnerability",
|
||||
null,
|
||||
null,
|
||||
published: null,
|
||||
modified: null,
|
||||
severity: null,
|
||||
exploitKnown: true,
|
||||
aliases: new[] { "KEV-CVE-2025-1000" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,23 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed class AdvisoryMergeService
|
||||
{
|
||||
private static readonly Meter MergeMeter = new("StellaOps.Concelier.Merge");
|
||||
private static readonly Counter<long> AliasCollisionCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.identity_conflicts",
|
||||
unit: "count",
|
||||
description: "Number of alias collisions detected during merge.");
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed class AdvisoryMergeService
|
||||
{
|
||||
private static readonly Meter MergeMeter = new("StellaOps.Concelier.Merge");
|
||||
private static readonly Counter<long> AliasCollisionCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.identity_conflicts",
|
||||
unit: "count",
|
||||
description: "Number of alias collisions detected during merge.");
|
||||
|
||||
private static readonly string[] PreferredAliasSchemes =
|
||||
{
|
||||
AliasSchemes.Cve,
|
||||
@@ -34,6 +36,8 @@ public sealed class AdvisoryMergeService
|
||||
private readonly IAdvisoryStore _advisoryStore;
|
||||
private readonly AdvisoryPrecedenceMerger _precedenceMerger;
|
||||
private readonly MergeEventWriter _mergeEventWriter;
|
||||
private readonly IAdvisoryEventLog _eventLog;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CanonicalMerger _canonicalMerger;
|
||||
private readonly ILogger<AdvisoryMergeService> _logger;
|
||||
|
||||
@@ -43,6 +47,8 @@ public sealed class AdvisoryMergeService
|
||||
AdvisoryPrecedenceMerger precedenceMerger,
|
||||
MergeEventWriter mergeEventWriter,
|
||||
CanonicalMerger canonicalMerger,
|
||||
IAdvisoryEventLog eventLog,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AdvisoryMergeService> logger)
|
||||
{
|
||||
_aliasResolver = aliasResolver ?? throw new ArgumentNullException(nameof(aliasResolver));
|
||||
@@ -50,92 +56,222 @@ public sealed class AdvisoryMergeService
|
||||
_precedenceMerger = precedenceMerger ?? throw new ArgumentNullException(nameof(precedenceMerger));
|
||||
_mergeEventWriter = mergeEventWriter ?? throw new ArgumentNullException(nameof(mergeEventWriter));
|
||||
_canonicalMerger = canonicalMerger ?? throw new ArgumentNullException(nameof(canonicalMerger));
|
||||
_eventLog = eventLog ?? throw new ArgumentNullException(nameof(eventLog));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<AdvisoryMergeResult> MergeAsync(string seedAdvisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(seedAdvisoryKey);
|
||||
|
||||
var component = await _aliasResolver.BuildComponentAsync(seedAdvisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
var inputs = new List<Advisory>();
|
||||
|
||||
foreach (var advisoryKey in component.AdvisoryKeys)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var advisory = await _advisoryStore.FindAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
if (advisory is not null)
|
||||
{
|
||||
inputs.Add(advisory);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Alias component seeded by {Seed} contains no persisted advisories", seedAdvisoryKey);
|
||||
return AdvisoryMergeResult.Empty(seedAdvisoryKey, component);
|
||||
}
|
||||
|
||||
|
||||
public async Task<AdvisoryMergeResult> MergeAsync(string seedAdvisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(seedAdvisoryKey);
|
||||
|
||||
var component = await _aliasResolver.BuildComponentAsync(seedAdvisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
var inputs = new List<Advisory>();
|
||||
|
||||
foreach (var advisoryKey in component.AdvisoryKeys)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var advisory = await _advisoryStore.FindAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
if (advisory is not null)
|
||||
{
|
||||
inputs.Add(advisory);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Alias component seeded by {Seed} contains no persisted advisories", seedAdvisoryKey);
|
||||
return AdvisoryMergeResult.Empty(seedAdvisoryKey, component);
|
||||
}
|
||||
|
||||
var canonicalKey = SelectCanonicalKey(component) ?? seedAdvisoryKey;
|
||||
var canonicalMerge = ApplyCanonicalMergeIfNeeded(canonicalKey, inputs);
|
||||
var before = await _advisoryStore.FindAsync(canonicalKey, cancellationToken).ConfigureAwait(false);
|
||||
var normalizedInputs = NormalizeInputs(inputs, canonicalKey).ToList();
|
||||
|
||||
Advisory? merged;
|
||||
PrecedenceMergeResult precedenceResult;
|
||||
try
|
||||
{
|
||||
merged = _precedenceMerger.Merge(normalizedInputs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to merge alias component seeded by {Seed}", seedAdvisoryKey);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (component.Collisions.Count > 0)
|
||||
{
|
||||
foreach (var collision in component.Collisions)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("scheme", collision.Scheme ?? string.Empty),
|
||||
new("alias_value", collision.Value ?? string.Empty),
|
||||
new("advisory_count", collision.AdvisoryKeys.Count),
|
||||
};
|
||||
|
||||
AliasCollisionCounter.Add(1, tags);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Alias collision {Scheme}:{Value} involves advisories {Advisories}",
|
||||
collision.Scheme,
|
||||
collision.Value,
|
||||
string.Join(", ", collision.AdvisoryKeys));
|
||||
}
|
||||
}
|
||||
|
||||
if (merged is not null)
|
||||
{
|
||||
await _advisoryStore.UpsertAsync(merged, cancellationToken).ConfigureAwait(false);
|
||||
await _mergeEventWriter.AppendAsync(
|
||||
canonicalKey,
|
||||
before,
|
||||
merged,
|
||||
Array.Empty<Guid>(),
|
||||
ConvertFieldDecisions(canonicalMerge?.Decisions),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
precedenceResult = _precedenceMerger.Merge(normalizedInputs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to merge alias component seeded by {Seed}", seedAdvisoryKey);
|
||||
throw;
|
||||
}
|
||||
|
||||
var merged = precedenceResult.Advisory;
|
||||
var conflictDetails = precedenceResult.Conflicts;
|
||||
|
||||
if (component.Collisions.Count > 0)
|
||||
{
|
||||
foreach (var collision in component.Collisions)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("scheme", collision.Scheme ?? string.Empty),
|
||||
new("alias_value", collision.Value ?? string.Empty),
|
||||
new("advisory_count", collision.AdvisoryKeys.Count),
|
||||
};
|
||||
|
||||
AliasCollisionCounter.Add(1, tags);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Alias collision {Scheme}:{Value} involves advisories {Advisories}",
|
||||
collision.Scheme,
|
||||
collision.Value,
|
||||
string.Join(", ", collision.AdvisoryKeys));
|
||||
}
|
||||
}
|
||||
|
||||
await _advisoryStore.UpsertAsync(merged, cancellationToken).ConfigureAwait(false);
|
||||
await _mergeEventWriter.AppendAsync(
|
||||
canonicalKey,
|
||||
before,
|
||||
merged,
|
||||
Array.Empty<Guid>(),
|
||||
ConvertFieldDecisions(canonicalMerge?.Decisions),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await AppendEventLogAsync(canonicalKey, normalizedInputs, merged, conflictDetails, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AdvisoryMergeResult(seedAdvisoryKey, canonicalKey, component, inputs, before, merged);
|
||||
}
|
||||
|
||||
private static IEnumerable<Advisory> NormalizeInputs(IEnumerable<Advisory> advisories, string canonicalKey)
|
||||
{
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
yield return CloneWithKey(advisory, canonicalKey);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task AppendEventLogAsync(
|
||||
string vulnerabilityKey,
|
||||
IReadOnlyList<Advisory> inputs,
|
||||
Advisory merged,
|
||||
IReadOnlyList<MergeConflictDetail> conflicts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var recordedAt = _timeProvider.GetUtcNow();
|
||||
var statements = new List<AdvisoryStatementInput>(inputs.Count + 1);
|
||||
var statementIds = new Dictionary<Advisory, Guid>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var advisory in inputs)
|
||||
{
|
||||
var statementId = Guid.NewGuid();
|
||||
statementIds[advisory] = statementId;
|
||||
statements.Add(new AdvisoryStatementInput(
|
||||
vulnerabilityKey,
|
||||
advisory,
|
||||
DetermineAsOf(advisory, recordedAt),
|
||||
InputDocumentIds: Array.Empty<Guid>(),
|
||||
StatementId: statementId,
|
||||
AdvisoryKey: advisory.AdvisoryKey));
|
||||
}
|
||||
|
||||
var canonicalStatementId = Guid.NewGuid();
|
||||
statementIds[merged] = canonicalStatementId;
|
||||
statements.Add(new AdvisoryStatementInput(
|
||||
vulnerabilityKey,
|
||||
merged,
|
||||
recordedAt,
|
||||
InputDocumentIds: Array.Empty<Guid>(),
|
||||
StatementId: canonicalStatementId,
|
||||
AdvisoryKey: merged.AdvisoryKey));
|
||||
|
||||
var conflictInputs = BuildConflictInputs(conflicts, vulnerabilityKey, statementIds, canonicalStatementId, recordedAt);
|
||||
|
||||
if (statements.Count == 0 && conflictInputs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new AdvisoryEventAppendRequest(statements, conflictInputs.Count > 0 ? conflictInputs : null);
|
||||
|
||||
try
|
||||
{
|
||||
await _eventLog.AppendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var conflict in conflictInputs)
|
||||
{
|
||||
conflict.Details.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset DetermineAsOf(Advisory advisory, DateTimeOffset fallback)
|
||||
{
|
||||
return (advisory.Modified ?? advisory.Published ?? fallback).ToUniversalTime();
|
||||
}
|
||||
|
||||
private static List<AdvisoryConflictInput> BuildConflictInputs(
|
||||
IReadOnlyList<MergeConflictDetail> conflicts,
|
||||
string vulnerabilityKey,
|
||||
IReadOnlyDictionary<Advisory, Guid> statementIds,
|
||||
Guid canonicalStatementId,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
if (conflicts.Count == 0)
|
||||
{
|
||||
return new List<AdvisoryConflictInput>(0);
|
||||
}
|
||||
|
||||
var inputs = new List<AdvisoryConflictInput>(conflicts.Count);
|
||||
|
||||
foreach (var detail in conflicts)
|
||||
{
|
||||
if (!statementIds.TryGetValue(detail.Suppressed, out var suppressedId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var related = new List<Guid> { canonicalStatementId, suppressedId };
|
||||
if (statementIds.TryGetValue(detail.Primary, out var primaryId))
|
||||
{
|
||||
if (!related.Contains(primaryId))
|
||||
{
|
||||
related.Add(primaryId);
|
||||
}
|
||||
}
|
||||
|
||||
var payload = new ConflictDetailPayload(
|
||||
detail.ConflictType,
|
||||
detail.Reason,
|
||||
detail.PrimarySources,
|
||||
detail.PrimaryRank,
|
||||
detail.SuppressedSources,
|
||||
detail.SuppressedRank,
|
||||
detail.PrimaryValue,
|
||||
detail.SuppressedValue);
|
||||
|
||||
var json = CanonicalJsonSerializer.Serialize(payload);
|
||||
var document = JsonDocument.Parse(json);
|
||||
var asOf = (detail.Primary.Modified ?? detail.Suppressed.Modified ?? recordedAt).ToUniversalTime();
|
||||
|
||||
inputs.Add(new AdvisoryConflictInput(
|
||||
vulnerabilityKey,
|
||||
document,
|
||||
asOf,
|
||||
related,
|
||||
ConflictId: null));
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
private sealed record ConflictDetailPayload(
|
||||
string Type,
|
||||
string Reason,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue);
|
||||
|
||||
private static IEnumerable<Advisory> NormalizeInputs(IEnumerable<Advisory> advisories, string canonicalKey)
|
||||
{
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
yield return CloneWithKey(advisory, canonicalKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static Advisory CloneWithKey(Advisory source, string advisoryKey)
|
||||
=> new(
|
||||
advisoryKey,
|
||||
@@ -248,47 +384,47 @@ public sealed class AdvisoryMergeService
|
||||
public const string Nvd = "nvd";
|
||||
public const string Osv = "osv";
|
||||
}
|
||||
|
||||
private static string? SelectCanonicalKey(AliasComponent component)
|
||||
{
|
||||
foreach (var scheme in PreferredAliasSchemes)
|
||||
{
|
||||
var alias = component.AliasMap.Values
|
||||
.SelectMany(static aliases => aliases)
|
||||
.FirstOrDefault(record => string.Equals(record.Scheme, scheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(alias?.Value))
|
||||
{
|
||||
return alias.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (component.AliasMap.TryGetValue(component.SeedAdvisoryKey, out var seedAliases))
|
||||
{
|
||||
var primary = seedAliases.FirstOrDefault(record => string.Equals(record.Scheme, AliasStoreConstants.PrimaryScheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(primary?.Value))
|
||||
{
|
||||
return primary.Value;
|
||||
}
|
||||
}
|
||||
|
||||
var firstAlias = component.AliasMap.Values.SelectMany(static aliases => aliases).FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(firstAlias?.Value))
|
||||
{
|
||||
return firstAlias.Value;
|
||||
}
|
||||
|
||||
return component.SeedAdvisoryKey;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisoryMergeResult(
|
||||
string SeedAdvisoryKey,
|
||||
string CanonicalAdvisoryKey,
|
||||
AliasComponent Component,
|
||||
IReadOnlyList<Advisory> Inputs,
|
||||
Advisory? Previous,
|
||||
Advisory? Merged)
|
||||
{
|
||||
public static AdvisoryMergeResult Empty(string seed, AliasComponent component)
|
||||
=> new(seed, seed, component, Array.Empty<Advisory>(), null, null);
|
||||
}
|
||||
|
||||
private static string? SelectCanonicalKey(AliasComponent component)
|
||||
{
|
||||
foreach (var scheme in PreferredAliasSchemes)
|
||||
{
|
||||
var alias = component.AliasMap.Values
|
||||
.SelectMany(static aliases => aliases)
|
||||
.FirstOrDefault(record => string.Equals(record.Scheme, scheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(alias?.Value))
|
||||
{
|
||||
return alias.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (component.AliasMap.TryGetValue(component.SeedAdvisoryKey, out var seedAliases))
|
||||
{
|
||||
var primary = seedAliases.FirstOrDefault(record => string.Equals(record.Scheme, AliasStoreConstants.PrimaryScheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(primary?.Value))
|
||||
{
|
||||
return primary.Value;
|
||||
}
|
||||
}
|
||||
|
||||
var firstAlias = component.AliasMap.Values.SelectMany(static aliases => aliases).FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(firstAlias?.Value))
|
||||
{
|
||||
return firstAlias.Value;
|
||||
}
|
||||
|
||||
return component.SeedAdvisoryKey;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisoryMergeResult(
|
||||
string SeedAdvisoryKey,
|
||||
string CanonicalAdvisoryKey,
|
||||
AliasComponent Component,
|
||||
IReadOnlyList<Advisory> Inputs,
|
||||
Advisory? Previous,
|
||||
Advisory? Merged)
|
||||
{
|
||||
public static AdvisoryMergeResult Empty(string seed, AliasComponent component)
|
||||
=> new(seed, seed, component, Array.Empty<Advisory>(), null, null);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
_logger = logger ?? NullLogger<AdvisoryPrecedenceMerger>.Instance;
|
||||
}
|
||||
|
||||
public Advisory Merge(IEnumerable<Advisory> advisories)
|
||||
public PrecedenceMergeResult Merge(IEnumerable<Advisory> advisories)
|
||||
{
|
||||
if (advisories is null)
|
||||
{
|
||||
@@ -193,11 +193,12 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
|
||||
var exploitKnown = ordered.Any(entry => entry.Advisory.ExploitKnown);
|
||||
|
||||
LogOverrides(advisoryKey, ordered);
|
||||
LogPackageOverrides(advisoryKey, packageResult.Overrides);
|
||||
RecordFieldConflicts(advisoryKey, ordered);
|
||||
|
||||
return new Advisory(
|
||||
LogOverrides(advisoryKey, ordered);
|
||||
LogPackageOverrides(advisoryKey, packageResult.Overrides);
|
||||
var conflicts = new List<MergeConflictDetail>();
|
||||
RecordFieldConflicts(advisoryKey, ordered, conflicts);
|
||||
|
||||
var merged = new Advisory(
|
||||
advisoryKey,
|
||||
title,
|
||||
summary,
|
||||
@@ -212,6 +213,8 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
affectedPackages,
|
||||
cvssMetrics,
|
||||
provenance);
|
||||
|
||||
return new PrecedenceMergeResult(merged, conflicts);
|
||||
}
|
||||
|
||||
private static void RecordNormalizedRuleMetrics(IReadOnlyList<AffectedPackage> packages)
|
||||
@@ -379,7 +382,7 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFieldConflicts(string advisoryKey, IReadOnlyList<AdvisoryEntry> ordered)
|
||||
private void RecordFieldConflicts(string advisoryKey, IReadOnlyList<AdvisoryEntry> ordered, List<MergeConflictDetail> conflicts)
|
||||
{
|
||||
if (ordered.Count <= 1)
|
||||
{
|
||||
@@ -396,42 +399,45 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
|
||||
if (!string.IsNullOrEmpty(candidateSeverity))
|
||||
{
|
||||
var reason = string.IsNullOrEmpty(primarySeverity) ? "primary_missing" : "mismatch";
|
||||
if (string.IsNullOrEmpty(primarySeverity) || !string.Equals(primarySeverity, candidateSeverity, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RecordConflict(
|
||||
advisoryKey,
|
||||
"severity",
|
||||
reason,
|
||||
primary,
|
||||
candidate,
|
||||
primarySeverity ?? "(none)",
|
||||
candidateSeverity);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.Rank == primary.Rank)
|
||||
{
|
||||
RecordConflict(
|
||||
advisoryKey,
|
||||
"precedence_tie",
|
||||
"equal_rank",
|
||||
primary,
|
||||
candidate,
|
||||
primary.Rank.ToString(CultureInfo.InvariantCulture),
|
||||
candidate.Rank.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordConflict(
|
||||
string advisoryKey,
|
||||
string conflictType,
|
||||
string reason,
|
||||
AdvisoryEntry primary,
|
||||
AdvisoryEntry suppressed,
|
||||
string? primaryValue,
|
||||
string? suppressedValue)
|
||||
var reason = string.IsNullOrEmpty(primarySeverity) ? "primary_missing" : "mismatch";
|
||||
if (string.IsNullOrEmpty(primarySeverity) || !string.Equals(primarySeverity, candidateSeverity, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RecordConflict(
|
||||
advisoryKey,
|
||||
"severity",
|
||||
reason,
|
||||
primary,
|
||||
candidate,
|
||||
primarySeverity ?? "(none)",
|
||||
candidateSeverity,
|
||||
conflicts);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.Rank == primary.Rank)
|
||||
{
|
||||
RecordConflict(
|
||||
advisoryKey,
|
||||
"precedence_tie",
|
||||
"equal_rank",
|
||||
primary,
|
||||
candidate,
|
||||
primary.Rank.ToString(CultureInfo.InvariantCulture),
|
||||
candidate.Rank.ToString(CultureInfo.InvariantCulture),
|
||||
conflicts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordConflict(
|
||||
string advisoryKey,
|
||||
string conflictType,
|
||||
string reason,
|
||||
AdvisoryEntry primary,
|
||||
AdvisoryEntry suppressed,
|
||||
string? primaryValue,
|
||||
string? suppressedValue,
|
||||
List<MergeConflictDetail> conflicts)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
@@ -445,18 +451,30 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
|
||||
ConflictCounter.Add(1, tags);
|
||||
|
||||
var audit = new MergeFieldConflictAudit(
|
||||
advisoryKey,
|
||||
conflictType,
|
||||
reason,
|
||||
primary.Sources,
|
||||
primary.Rank,
|
||||
suppressed.Sources,
|
||||
suppressed.Rank,
|
||||
primaryValue,
|
||||
suppressedValue);
|
||||
|
||||
ConflictLogged(_logger, audit, null);
|
||||
var audit = new MergeFieldConflictAudit(
|
||||
advisoryKey,
|
||||
conflictType,
|
||||
reason,
|
||||
primary.Sources,
|
||||
primary.Rank,
|
||||
suppressed.Sources,
|
||||
suppressed.Rank,
|
||||
primaryValue,
|
||||
suppressedValue);
|
||||
|
||||
ConflictLogged(_logger, audit, null);
|
||||
|
||||
conflicts.Add(new MergeConflictDetail(
|
||||
primary.Advisory,
|
||||
suppressed.Advisory,
|
||||
conflictType,
|
||||
reason,
|
||||
primary.Sources.ToArray(),
|
||||
primary.Rank,
|
||||
suppressed.Sources.ToArray(),
|
||||
suppressed.Rank,
|
||||
primaryValue,
|
||||
suppressedValue));
|
||||
}
|
||||
|
||||
private readonly record struct AdvisoryEntry(Advisory Advisory, int Rank)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user