up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record LinksetSeverityDto
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public double? Score { get; init; }
|
||||
|
||||
[JsonPropertyName("vector")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Vector { get; init; }
|
||||
|
||||
[JsonPropertyName("origin")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Origin { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[JsonPropertyName("raw")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyDictionary<string, object?>? Raw { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LinksetConflictDto
|
||||
{
|
||||
[JsonPropertyName("field")]
|
||||
public string Field { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("values")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? Values { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceIds")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? SourceIds { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LinksetSummaryDto
|
||||
{
|
||||
[JsonPropertyName("advisoryId")]
|
||||
public string AdvisoryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public double? Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("observationIds")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? ObservationIds { get; init; }
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? References { get; init; }
|
||||
|
||||
[JsonPropertyName("severities")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<LinksetSeverityDto>? Severities { get; init; }
|
||||
|
||||
[JsonPropertyName("conflicts")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<LinksetConflictDto>? Conflicts { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record LinksetSummaryRequestDto
|
||||
{
|
||||
[JsonPropertyName("advisoryIds")]
|
||||
public IReadOnlyList<string> AdvisoryIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("includePolicyOverlay")]
|
||||
public bool IncludePolicyOverlay { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LinksetSummaryResponseDto
|
||||
{
|
||||
[JsonPropertyName("linksets")]
|
||||
public IReadOnlyList<LinksetSummaryDto> Linksets { get; init; } = Array.Empty<LinksetSummaryDto>();
|
||||
|
||||
[JsonPropertyName("policy")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public RuntimePolicyImageResponseDto? Policy { get; init; }
|
||||
}
|
||||
@@ -62,7 +62,12 @@ public sealed record ReportDocumentDto
|
||||
[JsonPropertyName("surface")]
|
||||
[JsonPropertyOrder(8)]
|
||||
public SurfacePointersDto? Surface { get; init; }
|
||||
}
|
||||
|
||||
[JsonPropertyName("linksets")]
|
||||
[JsonPropertyOrder(9)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<LinksetSummaryDto>? Linksets { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReportPolicyDto
|
||||
{
|
||||
|
||||
@@ -66,14 +66,18 @@ public sealed record RuntimePolicyImageResponseDto
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? QuietedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("buildIds")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? BuildIds { get; init; }
|
||||
}
|
||||
[JsonPropertyName("metadata")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("buildIds")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? BuildIds { get; init; }
|
||||
|
||||
[JsonPropertyName("linksets")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<LinksetSummaryDto>? Linksets { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RuntimePolicyRekorDto
|
||||
{
|
||||
|
||||
@@ -83,18 +83,30 @@ internal static class PolicyEndpoints
|
||||
return operation;
|
||||
});
|
||||
|
||||
policyGroup.MapPost("/overlay", HandlePolicyOverlayAsync)
|
||||
.WithName("scanner.policy.overlay")
|
||||
.Produces<PolicyOverlayResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Request policy overlays for graph nodes.";
|
||||
operation.Description = "Returns deterministic policy overlays with runtime evidence for graph nodes (Cartographer integration). Overlay IDs are computed as sha256(tenant|nodeId|overlayKind).";
|
||||
return operation;
|
||||
});
|
||||
}
|
||||
policyGroup.MapPost("/overlay", HandlePolicyOverlayAsync)
|
||||
.WithName("scanner.policy.overlay")
|
||||
.Produces<PolicyOverlayResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Request policy overlays for graph nodes.";
|
||||
operation.Description = "Returns deterministic policy overlays with runtime evidence for graph nodes (Cartographer integration). Overlay IDs are computed as sha256(tenant|nodeId|overlayKind).";
|
||||
return operation;
|
||||
});
|
||||
|
||||
policyGroup.MapPost("/linksets", HandleLinksetSummaryAsync)
|
||||
.WithName("scanner.policy.linksets")
|
||||
.Produces<LinksetSummaryResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Fetch advisory linkset summaries with optional policy overlay.";
|
||||
operation.Description = "Returns linkset severities/conflicts for advisory IDs and, when requested, runtime policy overlay for the provided image digest.";
|
||||
return operation;
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult HandleSchemaAsync(HttpContext context)
|
||||
{
|
||||
@@ -188,11 +200,11 @@ internal static class PolicyEndpoints
|
||||
return Json(payload);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRuntimePolicyAsync(
|
||||
RuntimePolicyRequestDto request,
|
||||
IRuntimePolicyService runtimePolicyService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
private static async Task<IResult> HandleRuntimePolicyAsync(
|
||||
RuntimePolicyRequestDto request,
|
||||
IRuntimePolicyService runtimePolicyService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(runtimePolicyService);
|
||||
@@ -273,8 +285,96 @@ internal static class PolicyEndpoints
|
||||
var evaluation = await runtimePolicyService.EvaluateAsync(evaluationRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var resultPayload = MapRuntimePolicyResponse(evaluation);
|
||||
return Json(resultPayload);
|
||||
}
|
||||
return Json(resultPayload);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleLinksetSummaryAsync(
|
||||
LinksetSummaryRequestDto request,
|
||||
ILinksetResolver linksetResolver,
|
||||
IRuntimePolicyService runtimePolicyService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(linksetResolver);
|
||||
ArgumentNullException.ThrowIfNull(runtimePolicyService);
|
||||
|
||||
if (request.AdvisoryIds is null || request.AdvisoryIds.Count == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid linkset request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "advisoryIds must include at least one value.");
|
||||
}
|
||||
|
||||
if (request.IncludePolicyOverlay && string.IsNullOrWhiteSpace(request.ImageDigest))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid linkset request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "imageDigest is required when includePolicyOverlay is true.");
|
||||
}
|
||||
|
||||
var linksets = await linksetResolver.ResolveByAdvisoryIdsAsync(request.AdvisoryIds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
RuntimePolicyImageResponseDto? policy = null;
|
||||
if (request.IncludePolicyOverlay && !string.IsNullOrWhiteSpace(request.ImageDigest))
|
||||
{
|
||||
var runtimeRequest = new RuntimePolicyRequestDto
|
||||
{
|
||||
Images = new[] { request.ImageDigest!.Trim() }
|
||||
};
|
||||
|
||||
var evaluation = await runtimePolicyService.EvaluateAsync(
|
||||
new RuntimePolicyEvaluationRequest(
|
||||
runtimeRequest.Namespace,
|
||||
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal)),
|
||||
runtimeRequest.Images),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (evaluation.Results.TryGetValue(request.ImageDigest!.Trim(), out var decision))
|
||||
{
|
||||
RuntimePolicyRekorDto? rekor = null;
|
||||
if (decision.Rekor is not null)
|
||||
{
|
||||
rekor = new RuntimePolicyRekorDto
|
||||
{
|
||||
Uuid = decision.Rekor.Uuid,
|
||||
Url = decision.Rekor.Url,
|
||||
Verified = decision.Rekor.Verified
|
||||
};
|
||||
}
|
||||
|
||||
policy = new RuntimePolicyImageResponseDto
|
||||
{
|
||||
PolicyVerdict = decision.PolicyVerdict.ToString().ToLowerInvariant(),
|
||||
Signed = decision.Signed,
|
||||
HasSbomReferrers = decision.HasSbomReferrers,
|
||||
HasSbomLegacy = decision.HasSbomReferrers,
|
||||
Reasons = decision.Reasons,
|
||||
Rekor = rekor,
|
||||
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
|
||||
Quieted = decision.Quieted,
|
||||
QuietedBy = decision.QuietedBy,
|
||||
Metadata = decision.Metadata is { Count: > 0 } ? JsonSerializer.Serialize(decision.Metadata) : null,
|
||||
BuildIds = decision.BuildIds,
|
||||
Linksets = decision.Linksets
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var response = new LinksetSummaryResponseDto
|
||||
{
|
||||
Linksets = linksets,
|
||||
Policy = policy
|
||||
};
|
||||
|
||||
return Json(response);
|
||||
}
|
||||
|
||||
private static string NormalizeSegment(string segment)
|
||||
{
|
||||
@@ -322,15 +422,16 @@ internal static class PolicyEndpoints
|
||||
Signed = decision.Signed,
|
||||
HasSbomReferrers = decision.HasSbomReferrers,
|
||||
HasSbomLegacy = decision.HasSbomReferrers,
|
||||
Reasons = decision.Reasons.ToArray(),
|
||||
Rekor = rekor,
|
||||
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
|
||||
Quieted = decision.Quieted,
|
||||
QuietedBy = decision.QuietedBy,
|
||||
Metadata = metadata,
|
||||
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null
|
||||
};
|
||||
}
|
||||
Reasons = decision.Reasons.ToArray(),
|
||||
Rekor = rekor,
|
||||
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
|
||||
Quieted = decision.Quieted,
|
||||
QuietedBy = decision.QuietedBy,
|
||||
Metadata = metadata,
|
||||
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null,
|
||||
Linksets = decision.Linksets is { Count: > 0 } ? decision.Linksets.ToArray() : null
|
||||
};
|
||||
}
|
||||
|
||||
return new RuntimePolicyResponseDto
|
||||
{
|
||||
|
||||
@@ -57,6 +57,7 @@ internal static class ReportEndpoints
|
||||
TimeProvider timeProvider,
|
||||
IReportEventDispatcher eventDispatcher,
|
||||
ISurfacePointerService surfacePointerService,
|
||||
ILinksetResolver linksetResolver,
|
||||
ILoggerFactory loggerFactory,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -67,6 +68,7 @@ internal static class ReportEndpoints
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(eventDispatcher);
|
||||
ArgumentNullException.ThrowIfNull(surfacePointerService);
|
||||
ArgumentNullException.ThrowIfNull(linksetResolver);
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
var logger = loggerFactory.CreateLogger("Scanner.WebService.Reports");
|
||||
|
||||
@@ -128,15 +130,16 @@ internal static class ReportEndpoints
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
var projectedVerdicts = preview.Diffs
|
||||
.Select(diff => PolicyDtoMapper.ToVerdictDto(diff.Projected))
|
||||
.ToArray();
|
||||
|
||||
var issuesDto = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
|
||||
var projectedVerdicts = preview.Diffs
|
||||
.Select(diff => PolicyDtoMapper.ToVerdictDto(diff.Projected))
|
||||
.ToArray();
|
||||
|
||||
var issuesDto = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
|
||||
var summary = BuildSummary(projectedVerdicts);
|
||||
var verdict = ComputeVerdict(projectedVerdicts);
|
||||
var reportId = CreateReportId(request.ImageDigest!, preview.PolicyDigest);
|
||||
var generatedAt = timeProvider.GetUtcNow();
|
||||
var linksets = await linksetResolver.ResolveAsync(request.Findings, cancellationToken).ConfigureAwait(false);
|
||||
SurfacePointersDto? surfacePointers = null;
|
||||
|
||||
try
|
||||
@@ -171,7 +174,8 @@ internal static class ReportEndpoints
|
||||
Summary = summary,
|
||||
Verdicts = projectedVerdicts,
|
||||
Issues = issuesDto,
|
||||
Surface = surfacePointers
|
||||
Surface = surfacePointers,
|
||||
Linksets = linksets.Count == 0 ? null : linksets
|
||||
};
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Scanner.WebService.Options;
|
||||
|
||||
public sealed class ConcelierLinksetOptions
|
||||
{
|
||||
public const string SectionName = "scanner:concelier";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public string? BaseUrl { get; set; }
|
||||
public string? ApiKey { get; set; }
|
||||
public string ApiKeyHeader { get; set; } = "Authorization";
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
@@ -17,6 +18,7 @@ using StellaOps.Configuration;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Cache;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
@@ -30,6 +32,7 @@ using StellaOps.Scanner.WebService.Endpoints;
|
||||
using StellaOps.Scanner.WebService.Extensions;
|
||||
using StellaOps.Scanner.WebService.Hosting;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Replay;
|
||||
@@ -205,6 +208,7 @@ builder.Services.AddSingleton<IPostConfigureOptions<ScannerStorageOptions>, Scan
|
||||
builder.Services.AddSingleton<RuntimeEventRateLimiter>();
|
||||
builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>();
|
||||
builder.Services.AddSingleton<IRuntimeAttestationVerifier, RuntimeAttestationVerifier>();
|
||||
builder.Services.AddSingleton<ILinksetResolver, LinksetResolver>();
|
||||
builder.Services.AddSingleton<IRuntimePolicyService, RuntimePolicyService>();
|
||||
|
||||
var pluginHostOptions = ScannerPluginHostFactory.Build(bootstrapOptions, contentRoot);
|
||||
@@ -429,3 +433,36 @@ internal sealed class SurfaceCacheOptionsConfigurator : IConfigureOptions<Surfac
|
||||
options.RootDirectory = settings.CacheRoot.FullName;
|
||||
}
|
||||
}
|
||||
builder.Services.Configure<ConcelierLinksetOptions>(builder.Configuration.GetSection(ConcelierLinksetOptions.SectionName));
|
||||
|
||||
builder.Services.AddHttpClient<ConcelierHttpLinksetQueryService>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ConcelierLinksetOptions>>().Value;
|
||||
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||
{
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
}
|
||||
|
||||
client.Timeout = TimeSpan.FromSeconds(Math.Max(1, options.TimeoutSeconds));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ApiKey))
|
||||
{
|
||||
var header = string.IsNullOrWhiteSpace(options.ApiKeyHeader) ? "Authorization" : options.ApiKeyHeader;
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(header, options.ApiKey);
|
||||
}
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.All
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IAdvisoryLinksetQueryService>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ConcelierLinksetOptions>>().Value;
|
||||
if (options.Enabled && !string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||
{
|
||||
return sp.GetRequiredService<ConcelierHttpLinksetQueryService>();
|
||||
}
|
||||
|
||||
return new NullAdvisoryLinksetQueryService();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal sealed class ConcelierHttpLinksetQueryService : IAdvisoryLinksetQueryService
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly ConcelierLinksetOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public ConcelierHttpLinksetQueryService(HttpClient client, IOptions<ConcelierLinksetOptions> options)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public async Task<AdvisoryLinksetQueryResult> QueryAsync(AdvisoryLinksetQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (options.AdvisoryIds is null)
|
||||
{
|
||||
return new AdvisoryLinksetQueryResult(ImmutableArray<AdvisoryLinkset>.Empty, null, false);
|
||||
}
|
||||
|
||||
var results = ImmutableArray.CreateBuilder<AdvisoryLinkset>();
|
||||
foreach (var advisoryId in options.AdvisoryIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = $"/v1/lnm/linksets/{Uri.EscapeDataString(advisoryId)}?tenant={Uri.EscapeDataString(options.Tenant)}&includeConflicts=true&includeObservations=false&includeTimeline=false";
|
||||
try
|
||||
{
|
||||
using var response = await _client.GetAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<LinksetDetailResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (payload?.Linksets is null || payload.Linksets.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var linkset in payload.Linksets)
|
||||
{
|
||||
results.Add(Map(linkset));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// swallow and continue; caller will see partial results
|
||||
}
|
||||
}
|
||||
|
||||
var linksets = results.ToImmutable();
|
||||
return new AdvisoryLinksetQueryResult(linksets, null, false);
|
||||
}
|
||||
|
||||
private static AdvisoryLinkset Map(LinksetDto dto)
|
||||
{
|
||||
var normalized = dto.Normalized is null
|
||||
? null
|
||||
: new AdvisoryLinksetNormalized(
|
||||
dto.Normalized.Purls,
|
||||
dto.Normalized.Cpes,
|
||||
dto.Normalized.Versions,
|
||||
dto.Normalized.Ranges,
|
||||
dto.Normalized.Severities);
|
||||
|
||||
var conflicts = dto.Conflicts is null
|
||||
? null
|
||||
: dto.Conflicts.Select(c => new AdvisoryLinksetConflict(c.Field, c.Reason ?? string.Empty, c.Values, c.SourceIds)).ToList();
|
||||
|
||||
return new AdvisoryLinkset(
|
||||
TenantId: dto.Tenant ?? string.Empty,
|
||||
Source: dto.Source ?? string.Empty,
|
||||
AdvisoryId: dto.AdvisoryId ?? string.Empty,
|
||||
ObservationIds: dto.ObservationIds?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
|
||||
Normalized: normalized,
|
||||
Provenance: null,
|
||||
Confidence: dto.Confidence,
|
||||
Conflicts: conflicts,
|
||||
CreatedAt: dto.CreatedAt ?? DateTimeOffset.MinValue,
|
||||
BuiltByJobId: dto.BuiltByJobId);
|
||||
}
|
||||
|
||||
private sealed record LinksetDetailResponse([property: JsonPropertyName("linksets")] LinksetDto[] Linksets);
|
||||
|
||||
private sealed record LinksetDto
|
||||
{
|
||||
[JsonPropertyName("advisoryId")]
|
||||
public string? AdvisoryId { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double? Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("builtByJobId")]
|
||||
public string? BuiltByJobId { get; init; }
|
||||
|
||||
[JsonPropertyName("observationIds")]
|
||||
public string[]? ObservationIds { get; init; }
|
||||
|
||||
[JsonPropertyName("normalized")]
|
||||
public LinksetNormalizedDto? Normalized { get; init; }
|
||||
|
||||
[JsonPropertyName("conflicts")]
|
||||
public LinksetConflictDto[]? Conflicts { get; init; }
|
||||
}
|
||||
|
||||
private sealed record LinksetNormalizedDto
|
||||
{
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string>? Purls { get; init; }
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string>? Cpes { get; init; }
|
||||
|
||||
[JsonPropertyName("versions")]
|
||||
public IReadOnlyList<string>? Versions { get; init; }
|
||||
|
||||
[JsonPropertyName("ranges")]
|
||||
public IReadOnlyList<Dictionary<string, object?>>? Ranges { get; init; }
|
||||
|
||||
[JsonPropertyName("severities")]
|
||||
public IReadOnlyList<Dictionary<string, object?>>? Severities { get; init; }
|
||||
}
|
||||
|
||||
private sealed record LinksetConflictDto
|
||||
{
|
||||
[JsonPropertyName("field")]
|
||||
public string Field { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("values")]
|
||||
public IReadOnlyList<string>? Values { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceIds")]
|
||||
public IReadOnlyList<string>? SourceIds { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal interface ILinksetResolver
|
||||
{
|
||||
Task<IReadOnlyList<LinksetSummaryDto>> ResolveAsync(IEnumerable<PolicyPreviewFindingDto>? findings, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<LinksetSummaryDto>> ResolveAsync(IEnumerable<StellaOps.Policy.PolicyVerdict> verdicts, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<LinksetSummaryDto>> ResolveByAdvisoryIdsAsync(IEnumerable<string> advisoryIds, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class LinksetResolver : ILinksetResolver
|
||||
{
|
||||
private readonly IAdvisoryLinksetQueryService _queryService;
|
||||
private readonly ISurfaceEnvironment _surfaceEnvironment;
|
||||
private readonly ILogger<LinksetResolver> _logger;
|
||||
|
||||
public LinksetResolver(
|
||||
IAdvisoryLinksetQueryService queryService,
|
||||
ISurfaceEnvironment surfaceEnvironment,
|
||||
ILogger<LinksetResolver> logger)
|
||||
{
|
||||
_queryService = queryService ?? throw new ArgumentNullException(nameof(queryService));
|
||||
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LinksetSummaryDto>> ResolveAsync(IEnumerable<PolicyPreviewFindingDto>? findings, CancellationToken cancellationToken)
|
||||
{
|
||||
var advisoryIds = findings?
|
||||
.SelectMany(f => new[] { f?.Id, f?.Cve })
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
|
||||
return ResolveInternalAsync(advisoryIds, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LinksetSummaryDto>> ResolveAsync(IEnumerable<StellaOps.Policy.PolicyVerdict> verdicts, CancellationToken cancellationToken)
|
||||
{
|
||||
var advisoryIds = verdicts?
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.FindingId))
|
||||
.Select(v => v.FindingId!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
|
||||
return ResolveInternalAsync(advisoryIds, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LinksetSummaryDto>> ResolveByAdvisoryIdsAsync(IEnumerable<string> advisoryIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = advisoryIds?
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
|
||||
return ResolveInternalAsync(normalized, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<LinksetSummaryDto>> ResolveInternalAsync(IReadOnlyList<string> advisoryIds, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (advisoryIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<LinksetSummaryDto>();
|
||||
}
|
||||
|
||||
var tenant = string.IsNullOrWhiteSpace(_surfaceEnvironment.Settings.Tenant)
|
||||
? "default"
|
||||
: _surfaceEnvironment.Settings.Tenant.Trim();
|
||||
|
||||
try
|
||||
{
|
||||
var options = new AdvisoryLinksetQueryOptions(tenant, advisoryIds, Sources: null, Limit: advisoryIds.Count);
|
||||
var result = await _queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Linksets.IsDefaultOrEmpty)
|
||||
{
|
||||
return Array.Empty<LinksetSummaryDto>();
|
||||
}
|
||||
|
||||
return result.Linksets
|
||||
.Select(MapSummary)
|
||||
.OrderBy(ls => ls.AdvisoryId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to resolve linksets for {Count} advisories (tenant={Tenant}).", advisoryIds.Count, tenant);
|
||||
return Array.Empty<LinksetSummaryDto>();
|
||||
}
|
||||
}
|
||||
|
||||
private static LinksetSummaryDto MapSummary(AdvisoryLinkset linkset)
|
||||
{
|
||||
var severities = linkset.Normalized?.Severities?.Select(MapSeverity).ToArray();
|
||||
var conflicts = linkset.Conflicts?.Select(MapConflict).ToArray();
|
||||
|
||||
return new LinksetSummaryDto
|
||||
{
|
||||
AdvisoryId = linkset.AdvisoryId,
|
||||
Source = linkset.Source,
|
||||
Confidence = linkset.Confidence,
|
||||
ObservationIds = linkset.ObservationIds.Length > 0 ? linkset.ObservationIds : null,
|
||||
References = null,
|
||||
Severities = severities?.Length > 0 ? severities : null,
|
||||
Conflicts = conflicts?.Length > 0 ? conflicts : null
|
||||
};
|
||||
}
|
||||
|
||||
private static LinksetSeverityDto MapSeverity(Dictionary<string, object?> payload)
|
||||
{
|
||||
payload ??= new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
|
||||
string? GetString(string key)
|
||||
=> payload.TryGetValue(key, out var value) ? value?.ToString() : null;
|
||||
|
||||
double? GetDouble(string key)
|
||||
{
|
||||
if (!payload.TryGetValue(key, out var value) || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is double d)
|
||||
{
|
||||
return d;
|
||||
}
|
||||
|
||||
if (value is float f)
|
||||
{
|
||||
return Convert.ToDouble(f, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (double.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var labels = payload.TryGetValue("labels", out var labelsValue) && labelsValue is Dictionary<string, object?> labelsDict
|
||||
? labelsDict.ToDictionary(kv => kv.Key, kv => kv.Value?.ToString() ?? string.Empty, StringComparer.Ordinal)
|
||||
: null;
|
||||
|
||||
var raw = payload.Count == 0
|
||||
? null
|
||||
: payload.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal);
|
||||
|
||||
return new LinksetSeverityDto
|
||||
{
|
||||
Source = GetString("source"),
|
||||
Type = GetString("type"),
|
||||
Score = GetDouble("score"),
|
||||
Vector = GetString("vector"),
|
||||
Origin = GetString("origin"),
|
||||
Labels = labels,
|
||||
Raw = raw
|
||||
};
|
||||
}
|
||||
|
||||
private static LinksetConflictDto MapConflict(AdvisoryLinksetConflict conflict)
|
||||
{
|
||||
return new LinksetConflictDto
|
||||
{
|
||||
Field = conflict.Field,
|
||||
Reason = conflict.Reason,
|
||||
Values = conflict.Values,
|
||||
SourceIds = conflict.SourceIds
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal sealed class NullAdvisoryLinksetQueryService : IAdvisoryLinksetQueryService
|
||||
{
|
||||
public Task<AdvisoryLinksetQueryResult> QueryAsync(AdvisoryLinksetQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new AdvisoryLinksetQueryResult(ImmutableArray<AdvisoryLinkset>.Empty, null, false));
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,14 @@ using System.Linq;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
|
||||
using CanonicalPolicyVerdict = StellaOps.Policy.PolicyVerdict;
|
||||
using CanonicalPolicyVerdictStatus = StellaOps.Policy.PolicyVerdictStatus;
|
||||
@@ -35,35 +36,38 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
|
||||
private readonly LinkRepository _linkRepository;
|
||||
private readonly ArtifactRepository _artifactRepository;
|
||||
private readonly RuntimeEventRepository _runtimeEventRepository;
|
||||
private readonly PolicySnapshotStore _policySnapshotStore;
|
||||
private readonly PolicyPreviewService _policyPreviewService;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IRuntimeAttestationVerifier _attestationVerifier;
|
||||
private readonly ILogger<RuntimePolicyService> _logger;
|
||||
private readonly RuntimeEventRepository _runtimeEventRepository;
|
||||
private readonly PolicySnapshotStore _policySnapshotStore;
|
||||
private readonly PolicyPreviewService _policyPreviewService;
|
||||
private readonly ILinksetResolver _linksetResolver;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IRuntimeAttestationVerifier _attestationVerifier;
|
||||
private readonly ILogger<RuntimePolicyService> _logger;
|
||||
|
||||
public RuntimePolicyService(
|
||||
LinkRepository linkRepository,
|
||||
ArtifactRepository artifactRepository,
|
||||
RuntimeEventRepository runtimeEventRepository,
|
||||
PolicySnapshotStore policySnapshotStore,
|
||||
PolicyPreviewService policyPreviewService,
|
||||
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
IRuntimeAttestationVerifier attestationVerifier,
|
||||
ILogger<RuntimePolicyService> logger)
|
||||
{
|
||||
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
|
||||
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
|
||||
_runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository));
|
||||
_policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore));
|
||||
_policyPreviewService = policyPreviewService ?? throw new ArgumentNullException(nameof(policyPreviewService));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
LinkRepository linkRepository,
|
||||
ArtifactRepository artifactRepository,
|
||||
RuntimeEventRepository runtimeEventRepository,
|
||||
PolicySnapshotStore policySnapshotStore,
|
||||
PolicyPreviewService policyPreviewService,
|
||||
ILinksetResolver linksetResolver,
|
||||
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
IRuntimeAttestationVerifier attestationVerifier,
|
||||
ILogger<RuntimePolicyService> logger)
|
||||
{
|
||||
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
|
||||
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
|
||||
_runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository));
|
||||
_policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore));
|
||||
_policyPreviewService = policyPreviewService ?? throw new ArgumentNullException(nameof(policyPreviewService));
|
||||
_linksetResolver = linksetResolver ?? throw new ArgumentNullException(nameof(linksetResolver));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -118,13 +122,14 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
heuristicReasons.Add("policy.snapshot.missing");
|
||||
}
|
||||
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts = ImmutableArray<CanonicalPolicyVerdict>.Empty;
|
||||
ImmutableArray<PolicyIssue> issues = ImmutableArray<PolicyIssue>.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
if (!findings.IsDefaultOrEmpty && findings.Length > 0)
|
||||
{
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts = ImmutableArray<CanonicalPolicyVerdict>.Empty;
|
||||
ImmutableArray<PolicyIssue> issues = ImmutableArray<PolicyIssue>.Empty;
|
||||
IReadOnlyList<LinksetSummaryDto> linksets = Array.Empty<LinksetSummaryDto>();
|
||||
|
||||
try
|
||||
{
|
||||
if (!findings.IsDefaultOrEmpty && findings.Length > 0)
|
||||
{
|
||||
var previewRequest = new PolicyPreviewRequest(
|
||||
image,
|
||||
findings,
|
||||
@@ -133,14 +138,15 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
ProposedPolicy: null);
|
||||
|
||||
var preview = await _policyPreviewService.PreviewAsync(previewRequest, cancellationToken).ConfigureAwait(false);
|
||||
issues = preview.Issues;
|
||||
if (!preview.Diffs.IsDefaultOrEmpty)
|
||||
{
|
||||
projectedVerdicts = preview.Diffs.Select(diff => diff.Projected).ToImmutableArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
issues = preview.Issues;
|
||||
if (!preview.Diffs.IsDefaultOrEmpty)
|
||||
{
|
||||
projectedVerdicts = preview.Diffs.Select(diff => diff.Projected).ToImmutableArray();
|
||||
linksets = await _linksetResolver.ResolveAsync(projectedVerdicts, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image);
|
||||
}
|
||||
@@ -151,12 +157,13 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
var decision = await BuildDecisionAsync(
|
||||
image,
|
||||
metadata,
|
||||
heuristicReasons,
|
||||
projectedVerdicts,
|
||||
issues,
|
||||
policyDigest,
|
||||
buildIdObservation?.BuildIds,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
heuristicReasons,
|
||||
projectedVerdicts,
|
||||
issues,
|
||||
policyDigest,
|
||||
linksets,
|
||||
buildIdObservation?.BuildIds,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results[image] = decision;
|
||||
|
||||
@@ -279,12 +286,13 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
private async Task<RuntimePolicyImageDecision> BuildDecisionAsync(
|
||||
string imageDigest,
|
||||
RuntimeImageMetadata metadata,
|
||||
List<string> heuristicReasons,
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
||||
ImmutableArray<PolicyIssue> issues,
|
||||
string? policyDigest,
|
||||
IReadOnlyList<string>? buildIds,
|
||||
CancellationToken cancellationToken)
|
||||
List<string> heuristicReasons,
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
||||
ImmutableArray<PolicyIssue> issues,
|
||||
string? policyDigest,
|
||||
IReadOnlyList<LinksetSummaryDto> linksets,
|
||||
IReadOnlyList<string>? buildIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var reasons = new List<string>(heuristicReasons);
|
||||
|
||||
@@ -330,18 +338,19 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new RuntimePolicyImageDecision(
|
||||
overallVerdict,
|
||||
metadata.Signed,
|
||||
metadata.HasSbomReferrers,
|
||||
normalizedReasons,
|
||||
rekor,
|
||||
metadataPayload,
|
||||
confidence,
|
||||
quieted,
|
||||
quietedBy,
|
||||
buildIds);
|
||||
}
|
||||
return new RuntimePolicyImageDecision(
|
||||
overallVerdict,
|
||||
metadata.Signed,
|
||||
metadata.HasSbomReferrers,
|
||||
normalizedReasons,
|
||||
rekor,
|
||||
metadataPayload,
|
||||
confidence,
|
||||
quieted,
|
||||
quietedBy,
|
||||
buildIds,
|
||||
linksets);
|
||||
}
|
||||
|
||||
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
|
||||
{
|
||||
@@ -501,17 +510,18 @@ internal sealed record RuntimePolicyEvaluationResult(
|
||||
string? PolicyRevision,
|
||||
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Results);
|
||||
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
RuntimePolicyVerdict PolicyVerdict,
|
||||
bool Signed,
|
||||
bool HasSbomReferrers,
|
||||
IReadOnlyList<string> Reasons,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
IDictionary<string, object?>? Metadata,
|
||||
double Confidence,
|
||||
bool Quieted,
|
||||
string? QuietedBy,
|
||||
IReadOnlyList<string>? BuildIds);
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
RuntimePolicyVerdict PolicyVerdict,
|
||||
bool Signed,
|
||||
bool HasSbomReferrers,
|
||||
IReadOnlyList<string> Reasons,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
IDictionary<string, object?>? Metadata,
|
||||
double Confidence,
|
||||
bool Quieted,
|
||||
string? QuietedBy,
|
||||
IReadOnlyList<string>? BuildIds,
|
||||
IReadOnlyList<LinksetSummaryDto> Linksets);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
|
||||
@@ -36,5 +36,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="../../Zastava/__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj" />
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -28,6 +28,8 @@ public sealed class ScannerWorkerOptions
|
||||
|
||||
public StellaOpsCryptoOptions Crypto { get; } = new();
|
||||
|
||||
public SigningOptions Signing { get; } = new();
|
||||
|
||||
public DeterminismOptions Determinism { get; } = new();
|
||||
|
||||
public sealed class QueueOptions
|
||||
@@ -208,4 +210,35 @@ public sealed class ScannerWorkerOptions
|
||||
/// </summary>
|
||||
public int? ConcurrencyLimit { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable DSSE signing for surface artifacts (composition recipe, layer fragments).
|
||||
/// When disabled, the worker will fall back to deterministic hash envelopes.
|
||||
/// </summary>
|
||||
public bool EnableDsseSigning { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifier recorded in DSSE signatures.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = "scanner-hmac";
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret material for HMAC-based DSSE signatures (base64 or hex).
|
||||
/// Prefer <see cref=\"SharedSecretFile\"/> for file-based loading.
|
||||
/// </summary>
|
||||
public string? SharedSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to a file containing the shared secret (base64 or hex).
|
||||
/// </summary>
|
||||
public string? SharedSecretFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow deterministic fallback when signing is enabled but no secret is provided.
|
||||
/// Keeps offline determinism while avoiding hard failures in sealed-mode runs.
|
||||
/// </summary>
|
||||
public bool AllowDeterministicFallback { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Options;
|
||||
|
||||
@@ -89,11 +90,21 @@ public sealed class ScannerWorkerOptionsValidator : IValidateOptions<ScannerWork
|
||||
}
|
||||
}
|
||||
|
||||
if (options.Shutdown.Timeout < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Shutdown:Timeout must be at least 5 seconds to allow lease completion.");
|
||||
}
|
||||
|
||||
if (options.Shutdown.Timeout < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Shutdown:Timeout must be at least 5 seconds to allow lease completion.");
|
||||
}
|
||||
|
||||
if (options.Signing.EnableDsseSigning)
|
||||
{
|
||||
var hasSecret = !string.IsNullOrWhiteSpace(options.Signing.SharedSecret)
|
||||
|| (!string.IsNullOrWhiteSpace(options.Signing.SharedSecretFile) && File.Exists(options.Signing.SharedSecretFile));
|
||||
if (!hasSecret && !options.Signing.AllowDeterministicFallback)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Signing requires SharedSecret or SharedSecretFile when EnableDsseSigning is true and AllowDeterministicFallback is false.");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.Telemetry.EnableTelemetry)
|
||||
{
|
||||
if (!options.Telemetry.EnableMetrics && !options.Telemetry.EnableTracing)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Buffers.Text;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing.Surface;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope signer that prefers an HMAC key (deterministic) and falls back to
|
||||
/// the deterministic hash-only signer when no key is configured.
|
||||
/// </summary>
|
||||
internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner, IDisposable
|
||||
{
|
||||
private readonly ILogger<HmacDsseEnvelopeSigner> _logger;
|
||||
private readonly ScannerWorkerOptions _options;
|
||||
private readonly DeterministicDsseEnvelopeSigner _deterministic = new();
|
||||
private readonly HMACSHA256? _hmac;
|
||||
private readonly string _keyId;
|
||||
|
||||
public HmacDsseEnvelopeSigner(
|
||||
IOptions<ScannerWorkerOptions> options,
|
||||
ILogger<HmacDsseEnvelopeSigner> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
var signing = _options.Signing;
|
||||
_keyId = string.IsNullOrWhiteSpace(signing.KeyId) ? "scanner-hmac" : signing.KeyId.Trim();
|
||||
|
||||
if (!signing.EnableDsseSigning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var secretBytes = LoadSecret(signing);
|
||||
if (secretBytes is not null && secretBytes.Length > 0)
|
||||
{
|
||||
_hmac = new HMACSHA256(secretBytes);
|
||||
_logger.LogInformation("DSSE signing enabled using HMAC-SHA256 with key id {KeyId}", _keyId);
|
||||
}
|
||||
else if (!signing.AllowDeterministicFallback)
|
||||
{
|
||||
throw new InvalidOperationException("DSSE signing enabled but no shared secret provided and deterministic fallback is disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<DsseEnvelope> SignAsync(string payloadType, ReadOnlyMemory<byte> content, string suggestedKind, string merkleRoot, string? view, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_hmac is null)
|
||||
{
|
||||
return _deterministic.SignAsync(payloadType, content, suggestedKind, merkleRoot, view, cancellationToken);
|
||||
}
|
||||
|
||||
var pae = BuildPae(payloadType, content.Span);
|
||||
var signatureBytes = _hmac.ComputeHash(pae);
|
||||
var envelope = new
|
||||
{
|
||||
payloadType,
|
||||
payload = Base64UrlEncode(content.Span),
|
||||
signatures = new[]
|
||||
{
|
||||
new { keyid = _keyId, sig = Base64UrlEncode(signatureBytes) }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var digest = $"sha256:{ComputeSha256Hex(content.Span)}";
|
||||
var uri = $"cas://attestations/{suggestedKind}/{digest}.json";
|
||||
|
||||
return Task.FromResult(new DsseEnvelope("application/vnd.dsse+json", uri, digest, bytes));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hmac?.Dispose();
|
||||
}
|
||||
|
||||
private static byte[]? LoadSecret(ScannerWorkerOptions.SigningOptions signing)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signing.SharedSecretFile) && File.Exists(signing.SharedSecretFile))
|
||||
{
|
||||
var fileContent = File.ReadAllText(signing.SharedSecretFile).Trim();
|
||||
var fromFile = DecodeFlexible(fileContent);
|
||||
if (fromFile is not null)
|
||||
{
|
||||
return fromFile;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(signing.SharedSecret))
|
||||
{
|
||||
var inline = DecodeFlexible(signing.SharedSecret);
|
||||
if (inline is not null)
|
||||
{
|
||||
return inline;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? DecodeFlexible(string value)
|
||||
{
|
||||
// Try base64 (std)
|
||||
if (Convert.TryFromBase64String(value, Span<byte>.Empty, out var needed))
|
||||
{
|
||||
var buffer = new byte[needed];
|
||||
if (Convert.TryFromBase64String(value, buffer, out _))
|
||||
{
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
// Try base64url
|
||||
if (Base64UrlDecode(value) is { } base64Url)
|
||||
{
|
||||
return base64Url;
|
||||
}
|
||||
|
||||
// Try hex
|
||||
if (value.Length % 2 == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromHexString(value);
|
||||
return bytes;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to UTF-8 bytes as last resort (deterministic but not recommended)
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
const string prefix = "DSSEv1";
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLen = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
|
||||
var payloadLen = Encoding.UTF8.GetBytes(payload.Length.ToString());
|
||||
|
||||
var total = prefix.Length + 1 + typeLen.Length + 1 + typeBytes.Length + 1 + payloadLen.Length + 1 + payload.Length;
|
||||
var buffer = new byte[total];
|
||||
var offset = 0;
|
||||
|
||||
Encoding.UTF8.GetBytes(prefix, buffer.AsSpan(offset));
|
||||
offset += prefix.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
typeLen.CopyTo(buffer.AsSpan(offset));
|
||||
offset += typeLen.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
typeBytes.CopyTo(buffer.AsSpan(offset));
|
||||
offset += typeBytes.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
payloadLen.CopyTo(buffer.AsSpan(offset));
|
||||
offset += payloadLen.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
payload.CopyTo(buffer.AsSpan(offset));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(data, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var len = Base64.GetMaxEncodedToUtf8Length(data.Length);
|
||||
Span<byte> buffer = stackalloc byte[len];
|
||||
Base64.EncodeToUtf8(data, buffer, out _, out var written);
|
||||
var encoded = Encoding.UTF8.GetString(buffer[..written]);
|
||||
return encoded.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
private static byte[]? Base64UrlDecode(string value)
|
||||
{
|
||||
var normalized = value.Replace('-', '+').Replace('_', '/');
|
||||
while (normalized.Length % 4 != 0)
|
||||
{
|
||||
normalized += "=";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(normalized);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ if (!string.IsNullOrWhiteSpace(connectionString))
|
||||
builder.Services.AddSingleton<IConfigureOptions<ScannerStorageOptions>, ScannerStorageSurfaceSecretConfigurator>();
|
||||
builder.Services.AddSingleton<ISurfaceManifestPublisher, SurfaceManifestPublisher>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, SurfaceManifestStageExecutor>();
|
||||
builder.Services.AddSingleton<IDsseEnvelopeSigner, DeterministicDsseEnvelopeSigner>();
|
||||
builder.Services.AddSingleton<IDsseEnvelopeSigner, HmacDsseEnvelopeSigner>();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class LinksetResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResolveAsync_MapsSeveritiesAndConflicts()
|
||||
{
|
||||
var linkset = new AdvisoryLinkset(
|
||||
TenantId: "tenant-a",
|
||||
Source: "osv",
|
||||
AdvisoryId: "CVE-2025-0001",
|
||||
ObservationIds: ImmutableArray<string>.Empty,
|
||||
Normalized: new AdvisoryLinksetNormalized(
|
||||
Purls: new[] { "pkg:npm/demo@1.0.0" },
|
||||
Cpes: Array.Empty<string>(),
|
||||
Versions: Array.Empty<string>(),
|
||||
Ranges: Array.Empty<Dictionary<string, object?>>(),
|
||||
Severities: new[]
|
||||
{
|
||||
new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["source"] = "nvd",
|
||||
["type"] = "cvssv3",
|
||||
["score"] = 9.8,
|
||||
["vector"] = "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
["labels"] = new Dictionary<string, object?> { ["preferred"] = "true" }
|
||||
}
|
||||
}),
|
||||
Provenance: null,
|
||||
Confidence: 0.91,
|
||||
Conflicts: new[] { new AdvisoryLinksetConflict("severity", "disagree", new[] { "cvssv2", "cvssv3" }) },
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
BuiltByJobId: "job-1");
|
||||
|
||||
var resolver = new LinksetResolver(
|
||||
new FakeLinksetQueryService(linkset),
|
||||
new FakeSurfaceEnvironment(),
|
||||
NullLogger<LinksetResolver>.Instance);
|
||||
|
||||
var result = await resolver.ResolveAsync(new[]
|
||||
{
|
||||
new PolicyPreviewFindingDto { Id = "CVE-2025-0001" }
|
||||
}, CancellationToken.None);
|
||||
|
||||
var summary = Assert.Single(result);
|
||||
Assert.Equal("CVE-2025-0001", summary.AdvisoryId);
|
||||
Assert.Equal("osv", summary.Source);
|
||||
Assert.Equal(0.91, summary.Confidence);
|
||||
|
||||
var severity = Assert.Single(summary.Severities!);
|
||||
Assert.Equal("nvd", severity.Source);
|
||||
Assert.Equal("cvssv3", severity.Type);
|
||||
Assert.Equal(9.8, severity.Score);
|
||||
Assert.Equal("AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", severity.Vector);
|
||||
Assert.NotNull(severity.Labels);
|
||||
|
||||
var conflict = Assert.Single(summary.Conflicts!);
|
||||
Assert.Equal("severity", conflict.Field);
|
||||
Assert.Equal("disagree", conflict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ReturnsEmptyWhenNoIds()
|
||||
{
|
||||
var resolver = new LinksetResolver(
|
||||
new FakeLinksetQueryService(),
|
||||
new FakeSurfaceEnvironment(),
|
||||
NullLogger<LinksetResolver>.Instance);
|
||||
|
||||
var result = await resolver.ResolveAsync(Array.Empty<PolicyPreviewFindingDto>(), CancellationToken.None);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
private sealed class FakeLinksetQueryService : IAdvisoryLinksetQueryService
|
||||
{
|
||||
private readonly AdvisoryLinkset[] _linksets;
|
||||
|
||||
public FakeLinksetQueryService(params AdvisoryLinkset[] linksets)
|
||||
{
|
||||
_linksets = linksets;
|
||||
}
|
||||
|
||||
public Task<AdvisoryLinksetQueryResult> QueryAsync(AdvisoryLinksetQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var matched = _linksets
|
||||
.Where(ls => options.AdvisoryIds?.Contains(ls.AdvisoryId, StringComparer.OrdinalIgnoreCase) == true)
|
||||
.ToImmutableArray();
|
||||
return Task.FromResult(new AdvisoryLinksetQueryResult(matched, null, false));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSurfaceEnvironment : ISurfaceEnvironment
|
||||
{
|
||||
public SurfaceEnvironmentSettings Settings { get; } = new()
|
||||
{
|
||||
Tenant = "tenant-a"
|
||||
};
|
||||
|
||||
public IReadOnlyDictionary<string, string> RawVariables { get; } = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["SCANNER__TENANT"] = "tenant-a"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing.Surface;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class HmacDsseEnvelopeSignerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SignAsync_UsesHmac_WhenSecretProvided()
|
||||
{
|
||||
var options = BuildOptions(signing =>
|
||||
{
|
||||
signing.EnableDsseSigning = true;
|
||||
signing.SharedSecret = "a2V5LXNlY3JldA=="; // base64("key-secret")
|
||||
signing.KeyId = "scanner-hmac";
|
||||
});
|
||||
|
||||
var signer = new HmacDsseEnvelopeSigner(options, NullLogger<HmacDsseEnvelopeSigner>.Instance);
|
||||
var payload = Encoding.UTF8.GetBytes("{\"hello\":\"world\"}");
|
||||
|
||||
var envelope = await signer.SignAsync("application/json", payload, "test.kind", "root", view: null, CancellationToken.None);
|
||||
|
||||
var json = JsonDocument.Parse(envelope.Content.Span);
|
||||
var sig = json.RootElement.GetProperty("signatures")[0].GetProperty("sig").GetString();
|
||||
|
||||
var expectedSig = ComputeExpectedSignature("application/json", payload, "a2V5LXNlY3JldA==");
|
||||
Assert.Equal(expectedSig, sig);
|
||||
Assert.Equal("application/vnd.dsse+json", envelope.MediaType);
|
||||
Assert.Equal("scanner-hmac", json.RootElement.GetProperty("signatures")[0].GetProperty("keyid").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_FallsBackToDeterministic_WhenSecretMissing()
|
||||
{
|
||||
var options = BuildOptions(signing =>
|
||||
{
|
||||
signing.EnableDsseSigning = true;
|
||||
signing.SharedSecret = null;
|
||||
signing.SharedSecretFile = null;
|
||||
signing.AllowDeterministicFallback = true;
|
||||
});
|
||||
|
||||
var signer = new HmacDsseEnvelopeSigner(options, NullLogger<HmacDsseEnvelopeSigner>.Instance);
|
||||
var payload = Encoding.UTF8.GetBytes("abc");
|
||||
|
||||
var envelope = await signer.SignAsync("text/plain", payload, "kind", "root", view: null, CancellationToken.None);
|
||||
var json = JsonDocument.Parse(envelope.Content.Span);
|
||||
var sig = json.RootElement.GetProperty("signatures")[0].GetProperty("sig").GetString();
|
||||
|
||||
// Deterministic signer encodes sha256 hex of payload as signature.
|
||||
var expected = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(payload)).ToLowerInvariant();
|
||||
var expectedBase64Url = Base64UrlEncode(Encoding.UTF8.GetBytes(expected));
|
||||
Assert.Equal(expectedBase64Url, sig);
|
||||
}
|
||||
|
||||
private static IOptions<ScannerWorkerOptions> BuildOptions(Action<ScannerWorkerOptions.SigningOptions> configure)
|
||||
{
|
||||
var options = new ScannerWorkerOptions();
|
||||
configure(options.Signing);
|
||||
return Microsoft.Extensions.Options.Options.Create(options);
|
||||
}
|
||||
|
||||
private static string ComputeExpectedSignature(string payloadType, byte[] payload, string base64Secret)
|
||||
{
|
||||
var secret = Convert.FromBase64String(base64Secret);
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA256(secret);
|
||||
var pae = BuildPae(payloadType, payload);
|
||||
var signature = hmac.ComputeHash(pae);
|
||||
return Base64UrlEncode(signature);
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
const string prefix = "DSSEv1";
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLen = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
|
||||
var payloadLen = Encoding.UTF8.GetBytes(payload.Length.ToString());
|
||||
|
||||
var total = prefix.Length + 1 + typeLen.Length + 1 + typeBytes.Length + 1 + payloadLen.Length + 1 + payload.Length;
|
||||
var buffer = new byte[total];
|
||||
var offset = 0;
|
||||
|
||||
Encoding.UTF8.GetBytes(prefix, buffer.AsSpan(offset));
|
||||
offset += prefix.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
typeLen.CopyTo(buffer.AsSpan(offset));
|
||||
offset += typeLen.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
typeBytes.CopyTo(buffer.AsSpan(offset));
|
||||
offset += typeBytes.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
payloadLen.CopyTo(buffer.AsSpan(offset));
|
||||
offset += payloadLen.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
payload.CopyTo(buffer.AsSpan(offset));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(data);
|
||||
return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user