Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
269 lines
8.5 KiB
C#
269 lines
8.5 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Primitives;
|
|
using MongoDB.Bson;
|
|
using StellaOps.Excititor.Core;
|
|
using StellaOps.Excititor.Core.Aoc;
|
|
using StellaOps.Excititor.Storage.Mongo;
|
|
using StellaOps.Excititor.WebService.Contracts;
|
|
using StellaOps.Excititor.WebService.Services;
|
|
public partial class Program
|
|
{
|
|
private const string TenantHeaderName = "X-Stella-Tenant";
|
|
|
|
private static bool TryResolveTenant(HttpContext context, VexMongoStorageOptions options, bool requireHeader, out string tenant, out IResult? problem)
|
|
{
|
|
tenant = options.DefaultTenant;
|
|
problem = null;
|
|
|
|
if (context.Request.Headers.TryGetValue(TenantHeaderName, out var headerValues) && headerValues.Count > 0)
|
|
{
|
|
var requestedTenant = headerValues[0]?.Trim();
|
|
if (string.IsNullOrEmpty(requestedTenant))
|
|
{
|
|
problem = Results.Problem(detail: "X-Stella-Tenant header must not be empty.", statusCode: StatusCodes.Status400BadRequest, title: "Validation error");
|
|
return false;
|
|
}
|
|
|
|
if (!string.Equals(requestedTenant, options.DefaultTenant, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var detail = string.Format(CultureInfo.InvariantCulture, "Tenant '{0}' is not allowed for this Excititor deployment.", requestedTenant);
|
|
problem = Results.Problem(detail: detail, statusCode: StatusCodes.Status403Forbidden, title: "Forbidden");
|
|
return false;
|
|
}
|
|
|
|
tenant = requestedTenant;
|
|
return true;
|
|
}
|
|
|
|
if (requireHeader)
|
|
{
|
|
var detail = string.Format(CultureInfo.InvariantCulture, "{0} header is required.", TenantHeaderName);
|
|
problem = Results.Problem(detail: detail, statusCode: StatusCodes.Status400BadRequest, title: "Validation error");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, string> ReadMetadata(BsonValue value)
|
|
{
|
|
if (value is not BsonDocument doc || doc.ElementCount == 0)
|
|
{
|
|
return new Dictionary<string, string>(StringComparer.Ordinal);
|
|
}
|
|
|
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var element in doc.Elements)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(element.Name))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
result[element.Name] = element.Value?.ToString() ?? string.Empty;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static bool TryDecodeCursor(string? cursor, out DateTimeOffset timestamp, out string digest)
|
|
{
|
|
timestamp = default;
|
|
digest = string.Empty;
|
|
if (string.IsNullOrWhiteSpace(cursor))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
var payload = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
|
|
var parts = payload.Split('|');
|
|
if (parts.Length != 2)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!DateTimeOffset.TryParse(parts[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out timestamp))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
digest = parts[1];
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string EncodeCursor(DateTime timestamp, string digest)
|
|
{
|
|
var payload = string.Format(CultureInfo.InvariantCulture, "{0:O}|{1}", timestamp, digest);
|
|
return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
|
|
}
|
|
|
|
private static IResult ValidationProblem(string message)
|
|
=> Results.Problem(detail: message, statusCode: StatusCodes.Status400BadRequest, title: "Validation error");
|
|
|
|
private static IResult MapGuardException(ExcititorAocGuardException exception)
|
|
{
|
|
var violations = exception.Violations.Select(violation => new
|
|
{
|
|
code = violation.ErrorCode,
|
|
path = violation.Path,
|
|
message = violation.Message
|
|
});
|
|
|
|
return Results.Problem(
|
|
detail: "VEX document failed Aggregation-Only Contract validation.",
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "AOC violation",
|
|
extensions: new Dictionary<string, object?>
|
|
{
|
|
["violations"] = violations.ToArray(),
|
|
["primaryCode"] = exception.PrimaryErrorCode,
|
|
});
|
|
}
|
|
|
|
private static ImmutableHashSet<string> BuildStringFilterSet(StringValues values)
|
|
{
|
|
if (values.Count == 0)
|
|
{
|
|
return ImmutableHashSet<string>.Empty;
|
|
}
|
|
|
|
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var value in values)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
builder.Add(value.Trim());
|
|
}
|
|
}
|
|
|
|
return builder.ToImmutable();
|
|
}
|
|
|
|
private static ImmutableHashSet<VexClaimStatus> BuildStatusFilter(StringValues values)
|
|
{
|
|
if (values.Count == 0)
|
|
{
|
|
return ImmutableHashSet<VexClaimStatus>.Empty;
|
|
}
|
|
|
|
var builder = ImmutableHashSet.CreateBuilder<VexClaimStatus>();
|
|
foreach (var value in values)
|
|
{
|
|
if (Enum.TryParse<VexClaimStatus>(value, ignoreCase: true, out var status))
|
|
{
|
|
builder.Add(status);
|
|
}
|
|
}
|
|
|
|
return builder.ToImmutable();
|
|
}
|
|
|
|
private static DateTimeOffset? ParseSinceTimestamp(StringValues values)
|
|
{
|
|
if (values.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var candidate = values[0];
|
|
return DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
|
|
? parsed
|
|
: null;
|
|
}
|
|
|
|
private static int ResolveLimit(StringValues values, int defaultValue, int min, int max)
|
|
{
|
|
if (values.Count == 0)
|
|
{
|
|
return defaultValue;
|
|
}
|
|
|
|
if (!int.TryParse(values[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
|
{
|
|
return defaultValue;
|
|
}
|
|
|
|
return Math.Clamp(parsed, min, max);
|
|
}
|
|
|
|
private static IReadOnlyList<string> NormalizePurls(string[]? purls)
|
|
{
|
|
if (purls is null || purls.Length == 0)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var ordered = new List<string>(purls.Length);
|
|
foreach (var purl in purls)
|
|
{
|
|
var trimmed = purl?.Trim();
|
|
if (string.IsNullOrWhiteSpace(trimmed))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var normalized = trimmed.ToLowerInvariant();
|
|
if (seen.Add(normalized))
|
|
{
|
|
ordered.Add(normalized);
|
|
}
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
private static VexObservationStatementResponse ToResponse(VexObservationStatementProjection projection)
|
|
{
|
|
var scope = projection.Scope;
|
|
var document = projection.Document;
|
|
var signature = projection.Signature;
|
|
|
|
return new VexObservationStatementResponse(
|
|
projection.ObservationId,
|
|
projection.ProviderId,
|
|
projection.Status.ToString().ToLowerInvariant(),
|
|
projection.Justification?.ToString().ToLowerInvariant(),
|
|
projection.Detail,
|
|
projection.FirstSeen,
|
|
projection.LastSeen,
|
|
new VexObservationScopeResponse(
|
|
scope.Key,
|
|
scope.Name,
|
|
scope.Version,
|
|
scope.Purl,
|
|
scope.Cpe,
|
|
scope.ComponentIdentifiers),
|
|
projection.Anchors,
|
|
new VexObservationDocumentResponse(
|
|
document.Digest,
|
|
document.Format.ToString().ToLowerInvariant(),
|
|
document.Revision,
|
|
document.SourceUri.ToString()),
|
|
signature is null
|
|
? null
|
|
: new VexObservationSignatureResponse(
|
|
signature.Type,
|
|
signature.KeyId,
|
|
signature.Issuer,
|
|
signature.VerifiedAt));
|
|
}
|
|
|
|
private sealed record CachedGraphOverlay(
|
|
IReadOnlyList<GraphOverlayItem> Items,
|
|
DateTimeOffset CachedAt);
|
|
}
|