264 lines
9.0 KiB
C#
264 lines
9.0 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Collections.Immutable;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Excititor.Attestation.Extensions;
|
|
using StellaOps.Excititor.Attestation;
|
|
using StellaOps.Excititor.Attestation.Transparency;
|
|
using StellaOps.Excititor.ArtifactStores.S3.Extensions;
|
|
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
|
|
using StellaOps.Excititor.Core;
|
|
using StellaOps.Excititor.Export;
|
|
using StellaOps.Excititor.Formats.CSAF;
|
|
using StellaOps.Excititor.Formats.CycloneDX;
|
|
using StellaOps.Excititor.Formats.OpenVEX;
|
|
using StellaOps.Excititor.Policy;
|
|
using StellaOps.Excititor.Storage.Mongo;
|
|
using StellaOps.Excititor.WebService.Endpoints;
|
|
using StellaOps.Excititor.WebService.Services;
|
|
using StellaOps.Excititor.Core;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
var configuration = builder.Configuration;
|
|
var services = builder.Services;
|
|
|
|
services.AddOptions<VexMongoStorageOptions>()
|
|
.Bind(configuration.GetSection("Excititor:Storage:Mongo"))
|
|
.ValidateOnStart();
|
|
|
|
services.AddExcititorMongoStorage();
|
|
services.AddCsafNormalizer();
|
|
services.AddCycloneDxNormalizer();
|
|
services.AddOpenVexNormalizer();
|
|
services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>();
|
|
services.AddScoped<IVexIngestOrchestrator, VexIngestOrchestrator>();
|
|
services.AddVexExportEngine();
|
|
services.AddVexExportCacheServices();
|
|
services.AddVexAttestation();
|
|
services.Configure<VexAttestationClientOptions>(configuration.GetSection("Excititor:Attestation:Client"));
|
|
services.AddVexPolicy();
|
|
services.AddRedHatCsafConnector();
|
|
services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName));
|
|
services.AddSingleton<MirrorRateLimiter>();
|
|
|
|
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
|
|
if (rekorSection.Exists())
|
|
{
|
|
services.AddVexRekorClient(opts => rekorSection.Bind(opts));
|
|
}
|
|
|
|
var fileSystemSection = configuration.GetSection("Excititor:Artifacts:FileSystem");
|
|
if (fileSystemSection.Exists())
|
|
{
|
|
services.AddVexFileSystemArtifactStore(opts => fileSystemSection.Bind(opts));
|
|
}
|
|
else
|
|
{
|
|
services.AddVexFileSystemArtifactStore(_ => { });
|
|
}
|
|
|
|
var s3Section = configuration.GetSection("Excititor:Artifacts:S3");
|
|
if (s3Section.Exists())
|
|
{
|
|
services.AddVexS3ArtifactClient(opts => s3Section.GetSection("Client").Bind(opts));
|
|
services.AddSingleton<IVexArtifactStore, S3ArtifactStore>(provider =>
|
|
{
|
|
var options = new S3ArtifactStoreOptions();
|
|
s3Section.GetSection("Store").Bind(options);
|
|
return new S3ArtifactStore(
|
|
provider.GetRequiredService<IS3ArtifactClient>(),
|
|
Microsoft.Extensions.Options.Options.Create(options),
|
|
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<S3ArtifactStore>>());
|
|
});
|
|
}
|
|
|
|
var offlineSection = configuration.GetSection("Excititor:Artifacts:OfflineBundle");
|
|
if (offlineSection.Exists())
|
|
{
|
|
services.AddVexOfflineBundleArtifactStore(opts => offlineSection.Bind(opts));
|
|
}
|
|
|
|
services.AddEndpointsApiExplorer();
|
|
services.AddHealthChecks();
|
|
services.AddSingleton(TimeProvider.System);
|
|
services.AddMemoryCache();
|
|
services.AddAuthentication();
|
|
services.AddAuthorization();
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapGet("/excititor/status", async (HttpContext context,
|
|
IEnumerable<IVexArtifactStore> artifactStores,
|
|
IOptions<VexMongoStorageOptions> mongoOptions,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var payload = new StatusResponse(
|
|
timeProvider.GetUtcNow(),
|
|
mongoOptions.Value.RawBucketName,
|
|
mongoOptions.Value.GridFsInlineThresholdBytes,
|
|
artifactStores.Select(store => store.GetType().Name).ToArray());
|
|
|
|
context.Response.ContentType = "application/json";
|
|
await System.Text.Json.JsonSerializer.SerializeAsync(context.Response.Body, payload);
|
|
});
|
|
|
|
app.MapHealthChecks("/excititor/health");
|
|
|
|
app.MapPost("/excititor/statements", async (
|
|
VexStatementIngestRequest request,
|
|
IVexClaimStore claimStore,
|
|
TimeProvider timeProvider,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (request?.Statements is null || request.Statements.Count == 0)
|
|
{
|
|
return Results.BadRequest("At least one statement must be provided.");
|
|
}
|
|
|
|
var claims = request.Statements.Select(statement => statement.ToDomainClaim());
|
|
await claimStore.AppendAsync(claims, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
|
return Results.Accepted();
|
|
});
|
|
|
|
app.MapGet("/excititor/statements/{vulnerabilityId}/{productKey}", async (
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
DateTimeOffset? since,
|
|
IVexClaimStore claimStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
|
|
{
|
|
return Results.BadRequest("vulnerabilityId and productKey are required.");
|
|
}
|
|
|
|
var claims = await claimStore.FindAsync(vulnerabilityId.Trim(), productKey.Trim(), since, cancellationToken).ConfigureAwait(false);
|
|
return Results.Ok(claims);
|
|
});
|
|
|
|
app.MapPost("/excititor/admin/backfill-statements", async (
|
|
VexStatementBackfillRequest? request,
|
|
VexStatementBackfillService backfillService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
request ??= new VexStatementBackfillRequest();
|
|
var result = await backfillService.RunAsync(request, cancellationToken).ConfigureAwait(false);
|
|
var message = FormattableString.Invariant(
|
|
$"Backfill completed: evaluated {result.DocumentsEvaluated}, backfilled {result.DocumentsBackfilled}, claims written {result.ClaimsWritten}, skipped {result.SkippedExisting}, failures {result.NormalizationFailures}.");
|
|
|
|
return Results.Ok(new
|
|
{
|
|
message,
|
|
summary = result
|
|
});
|
|
});
|
|
|
|
IngestEndpoints.MapIngestEndpoints(app);
|
|
ResolveEndpoint.MapResolveEndpoint(app);
|
|
MirrorEndpoints.MapMirrorEndpoints(app);
|
|
|
|
app.Run();
|
|
|
|
public partial class Program;
|
|
|
|
internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores);
|
|
|
|
internal sealed record VexStatementIngestRequest(IReadOnlyList<VexStatementEntry> Statements);
|
|
|
|
internal sealed record VexStatementEntry(
|
|
string VulnerabilityId,
|
|
string ProviderId,
|
|
string ProductKey,
|
|
string? ProductName,
|
|
string? ProductVersion,
|
|
string? ProductPurl,
|
|
string? ProductCpe,
|
|
IReadOnlyList<string>? ComponentIdentifiers,
|
|
VexClaimStatus Status,
|
|
VexJustification? Justification,
|
|
string? Detail,
|
|
DateTimeOffset FirstSeen,
|
|
DateTimeOffset LastSeen,
|
|
VexDocumentFormat DocumentFormat,
|
|
string DocumentDigest,
|
|
string DocumentUri,
|
|
string? DocumentRevision,
|
|
VexSignatureMetadataRequest? Signature,
|
|
VexConfidenceRequest? Confidence,
|
|
VexSignalRequest? Signals,
|
|
IReadOnlyDictionary<string, string>? Metadata)
|
|
{
|
|
public VexClaim ToDomainClaim()
|
|
{
|
|
var product = new VexProduct(
|
|
ProductKey,
|
|
ProductName,
|
|
ProductVersion,
|
|
ProductPurl,
|
|
ProductCpe,
|
|
ComponentIdentifiers ?? Array.Empty<string>());
|
|
|
|
if (!Uri.TryCreate(DocumentUri, UriKind.Absolute, out var uri))
|
|
{
|
|
throw new InvalidOperationException($"DocumentUri '{DocumentUri}' is not a valid absolute URI.");
|
|
}
|
|
|
|
var document = new VexClaimDocument(
|
|
DocumentFormat,
|
|
DocumentDigest,
|
|
uri,
|
|
DocumentRevision,
|
|
Signature?.ToDomain());
|
|
|
|
var additionalMetadata = Metadata is null
|
|
? ImmutableDictionary<string, string>.Empty
|
|
: Metadata.ToImmutableDictionary(StringComparer.Ordinal);
|
|
|
|
return new VexClaim(
|
|
VulnerabilityId,
|
|
ProviderId,
|
|
product,
|
|
Status,
|
|
document,
|
|
FirstSeen,
|
|
LastSeen,
|
|
Justification,
|
|
Detail,
|
|
Confidence?.ToDomain(),
|
|
Signals?.ToDomain(),
|
|
additionalMetadata);
|
|
}
|
|
}
|
|
|
|
internal sealed record VexSignatureMetadataRequest(
|
|
string Type,
|
|
string? Subject,
|
|
string? Issuer,
|
|
string? KeyId,
|
|
DateTimeOffset? VerifiedAt,
|
|
string? TransparencyLogReference)
|
|
{
|
|
public VexSignatureMetadata ToDomain()
|
|
=> new(Type, Subject, Issuer, KeyId, VerifiedAt, TransparencyLogReference);
|
|
}
|
|
|
|
internal sealed record VexConfidenceRequest(string Level, double? Score, string? Method)
|
|
{
|
|
public VexConfidence ToDomain() => new(Level, Score, Method);
|
|
}
|
|
|
|
internal sealed record VexSignalRequest(VexSeveritySignalRequest? Severity, bool? Kev, double? Epss)
|
|
{
|
|
public VexSignalSnapshot ToDomain()
|
|
=> new(Severity?.ToDomain(), Kev, Epss);
|
|
}
|
|
|
|
internal sealed record VexSeveritySignalRequest(string Scheme, double? Score, string? Label, string? Vector)
|
|
{
|
|
public VexSeveritySignal ToDomain() => new(Scheme, Score, Label, Vector);
|
|
}
|