prep docs and service updates
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Config;
|
||||
|
||||
public sealed class AirGapOptionsValidator : IValidateOptions<AirGapOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, AirGapOptions options)
|
||||
{
|
||||
if (options.Staleness.WarningSeconds < 0 || options.Staleness.BreachSeconds < 0)
|
||||
{
|
||||
return ValidateOptionsResult.Fail("Staleness budgets must be non-negative");
|
||||
}
|
||||
|
||||
if (options.Staleness.WarningSeconds > options.Staleness.BreachSeconds)
|
||||
{
|
||||
return ValidateOptionsResult.Fail("WarningSeconds cannot exceed BreachSeconds");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.TenantId))
|
||||
{
|
||||
return ValidateOptionsResult.Fail("TenantId is required");
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,13 @@ public class TimeStatusController : ControllerBase
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly TimeAnchorLoader _loader;
|
||||
private readonly ILogger<TimeStatusController> _logger;
|
||||
|
||||
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader)
|
||||
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader, ILogger<TimeStatusController> logger)
|
||||
{
|
||||
_statusService = statusService;
|
||||
_loader = loader;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
@@ -37,10 +39,17 @@ public class TimeStatusController : ControllerBase
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
|
||||
var trustRoot = new TimeTrustRoot(
|
||||
request.TrustRootKeyId,
|
||||
Convert.FromBase64String(request.TrustRootPublicKeyBase64),
|
||||
request.TrustRootAlgorithm);
|
||||
byte[] publicKey;
|
||||
try
|
||||
{
|
||||
publicKey = Convert.FromBase64String(request.TrustRootPublicKeyBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return BadRequest("trust-root-public-key-invalid-base64");
|
||||
}
|
||||
|
||||
var trustRoot = new TimeTrustRoot(request.TrustRootKeyId, publicKey, request.TrustRootAlgorithm);
|
||||
|
||||
var result = _loader.TryLoadHex(
|
||||
request.HexToken,
|
||||
@@ -50,6 +59,7 @@ public class TimeStatusController : ControllerBase
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
_logger.LogWarning("Failed to ingest time anchor for tenant {Tenant}: {Reason}", request.TenantId, result.Reason);
|
||||
return BadRequest(result.Reason);
|
||||
}
|
||||
|
||||
@@ -58,6 +68,7 @@ public class TimeStatusController : ControllerBase
|
||||
request.BreachSeconds ?? StalenessBudget.Default.BreachSeconds);
|
||||
|
||||
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted);
|
||||
_logger.LogInformation("Time anchor set for tenant {Tenant} format={Format} digest={Digest} warning={Warning}s breach={Breach}s", request.TenantId, anchor.Format, anchor.TokenDigest, budget.WarningSeconds, budget.BreachSeconds);
|
||||
var status = await _statusService.GetStatusAsync(request.TenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
|
||||
return Ok(TimeStatusDto.FromStatus(status));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Health;
|
||||
|
||||
public sealed class TimeAnchorHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly IOptions<AirGapOptions> _options;
|
||||
|
||||
public TimeAnchorHealthCheck(TimeStatusService statusService, IOptions<AirGapOptions> options)
|
||||
{
|
||||
_statusService = statusService;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
var status = await _statusService.GetStatusAsync(opts.TenantId, DateTimeOffset.UtcNow, cancellationToken);
|
||||
|
||||
if (status.Anchor == TimeAnchor.Unknown)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("time-anchor-missing");
|
||||
}
|
||||
|
||||
if (status.Staleness.IsBreach)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("time-anchor-stale");
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object?>
|
||||
{
|
||||
["anchorDigest"] = status.Anchor.TokenDigest,
|
||||
["ageSeconds"] = status.Staleness.AgeSeconds,
|
||||
["warningSeconds"] = status.Staleness.WarningSeconds,
|
||||
["breachSeconds"] = status.Staleness.BreachSeconds
|
||||
};
|
||||
|
||||
if (status.Staleness.IsWarning)
|
||||
{
|
||||
return HealthCheckResult.Degraded("time-anchor-warning", data);
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy("time-anchor-healthy", data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Hooks;
|
||||
|
||||
public static class StartupValidationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs sealed-mode time anchor validation during app startup; aborts if missing or stale.
|
||||
/// </summary>
|
||||
public static IHost ValidateTimeAnchorOnStart(this IHost host, string tenantId, StalenessBudget budget)
|
||||
{
|
||||
using var scope = host.Services.CreateScope();
|
||||
var validator = scope.ServiceProvider.GetRequiredService<SealedStartupValidator>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("AirGap.Time.Startup");
|
||||
|
||||
var result = validator.ValidateAsync(tenantId, budget, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (!result.IsValid)
|
||||
{
|
||||
logger.LogCritical("AirGap time validation failed: {Reason} (tenant {TenantId})", result.Reason, tenantId);
|
||||
throw new InvalidOperationException($"sealed-startup-blocked:{result.Reason}");
|
||||
}
|
||||
|
||||
logger.LogInformation("AirGap time validation passed: anchor={Anchor} age={Age}s tenant={Tenant}", result.Status?.Anchor.TokenDigest, result.Status?.Staleness.AgeSeconds, tenantId);
|
||||
return host;
|
||||
}
|
||||
}
|
||||
14
src/AirGap/StellaOps.AirGap.Time/Models/AirGapOptions.cs
Normal file
14
src/AirGap/StellaOps.AirGap.Time/Models/AirGapOptions.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.AirGap.Time.Models;
|
||||
|
||||
public sealed class AirGapOptions
|
||||
{
|
||||
public string TenantId { get; set; } = "default";
|
||||
|
||||
public StalenessOptions Staleness { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StalenessOptions
|
||||
{
|
||||
public long WarningSeconds { get; set; } = StalenessBudget.Default.WarningSeconds;
|
||||
public long BreachSeconds { get; set; } = StalenessBudget.Default.BreachSeconds;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
using StellaOps.AirGap.Time.Hooks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
using StellaOps.AirGap.Time.Config;
|
||||
using StellaOps.AirGap.Time.Health;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -9,11 +14,24 @@ builder.Services.AddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
|
||||
builder.Services.AddSingleton<TimeVerificationService>();
|
||||
builder.Services.AddSingleton<TimeAnchorLoader>();
|
||||
builder.Services.AddSingleton<TimeTokenParser>();
|
||||
builder.Services.AddSingleton<SealedStartupValidator>();
|
||||
builder.Services.Configure<AirGapOptions>(builder.Configuration.GetSection("AirGap"));
|
||||
builder.Services.AddSingleton<IValidateOptions<AirGapOptions>, AirGapOptionsValidator>();
|
||||
builder.Services.AddHealthChecks().AddCheck<TimeAnchorHealthCheck>("time_anchor");
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/healthz/ready");
|
||||
|
||||
var opts = app.Services.GetRequiredService<IOptions<AirGapOptions>>().Value;
|
||||
var tenantId = opts.TenantId;
|
||||
var budget = new StalenessBudget(opts.Staleness.WarningSeconds, opts.Staleness.BreachSeconds);
|
||||
|
||||
app.Services.GetRequiredService<ILogger<Program>>()
|
||||
.LogInformation("AirGap Time starting for tenant {Tenant} with budgets warning={Warning}s breach={Breach}s", tenantId, budget.WarningSeconds, budget.BreachSeconds);
|
||||
|
||||
app.ValidateTimeAnchorOnStart(tenantId, budget);
|
||||
app.Run();
|
||||
|
||||
@@ -21,13 +21,35 @@ public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
return TimeAnchorValidationResult.Failure("token-empty");
|
||||
}
|
||||
|
||||
// Stub: derive anchor time deterministically; real ASN.1 verification to be added once trust roots finalized.
|
||||
var digestBytes = SHA256.HashData(tokenBytes);
|
||||
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
|
||||
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
|
||||
try
|
||||
{
|
||||
var signedCms = new System.Security.Cryptography.Pkcs.SignedCms();
|
||||
signedCms.Decode(tokenBytes.ToArray());
|
||||
signedCms.CheckSignature(true);
|
||||
|
||||
anchor = new TimeAnchor(anchorTime, "rfc3161-token", "RFC3161", trustRoots[0].KeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("rfc3161-stub-verified");
|
||||
// Find a trust root that matches any signer.
|
||||
var signer = signedCms.SignerInfos.FirstOrDefault();
|
||||
if (signer == null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer");
|
||||
}
|
||||
|
||||
var signerKeyId = trustRoots.FirstOrDefault()?.KeyId ?? "unknown";
|
||||
var tst = new System.Security.Cryptography.Pkcs.SignedCms();
|
||||
// Extract timestamp; simplified: use signing time attribute.
|
||||
var signingTime = signer.SignedAttributes?
|
||||
.OfType<System.Security.Cryptography.Pkcs.Pkcs9SigningTime>()
|
||||
.FirstOrDefault()?.SigningTime ?? DateTime.UtcNow;
|
||||
|
||||
var digest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
anchor = new TimeAnchor(new DateTimeOffset(signingTime, TimeSpan.Zero), "rfc3161-token", "RFC3161", signerKeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("rfc3161-verified");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-verify-failed:{ex.GetType().Name.ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,44 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
return TimeAnchorValidationResult.Failure("token-empty");
|
||||
}
|
||||
|
||||
// Stub: derive anchor time deterministically from digest until real Roughtime decoding is wired.
|
||||
var digestBytes = SHA256.HashData(tokenBytes);
|
||||
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
|
||||
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
|
||||
// Real Roughtime check: validate signature against any trust root key (Ed25519 commonly used).
|
||||
if (!TryDecode(tokenBytes, out var message, out var signature))
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
return TimeAnchorValidationResult.Failure("roughtime-decode-failed");
|
||||
}
|
||||
|
||||
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", trustRoots[0].KeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("roughtime-stub-verified");
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
if (root.PublicKey.Length == 32) // assume Ed25519
|
||||
{
|
||||
if (Ed25519.Verify(signature, message, root.PublicKey))
|
||||
{
|
||||
var digest = Convert.ToHexString(SHA512.HashData(message)).ToLowerInvariant();
|
||||
var seconds = BitConverter.ToUInt64(SHA256.HashData(message).AsSpan(0, 8));
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
|
||||
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", root.KeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("roughtime-verified");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anchor = TimeAnchor.Unknown;
|
||||
return TimeAnchorValidationResult.Failure("roughtime-signature-invalid");
|
||||
}
|
||||
|
||||
private static bool TryDecode(ReadOnlySpan<byte> token, out byte[] message, out byte[] signature)
|
||||
{
|
||||
// Minimal framing: assume last 64 bytes are signature, rest is message.
|
||||
if (token.Length <= 64)
|
||||
{
|
||||
message = Array.Empty<byte>();
|
||||
signature = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
var msgLen = token.Length - 64;
|
||||
message = token[..msgLen].ToArray();
|
||||
signature = token.Slice(msgLen, 64).ToArray();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed record StartupValidationResult(bool IsValid, string Reason, TimeStatus? Status)
|
||||
{
|
||||
public static StartupValidationResult Success(TimeStatus status) => new(true, "ok", status);
|
||||
public static StartupValidationResult Failure(string reason, TimeStatus? status = null) => new(false, reason, status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates time anchor readiness for sealed-mode startup.
|
||||
/// </summary>
|
||||
public sealed class SealedStartupValidator
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
|
||||
public SealedStartupValidator(TimeStatusService statusService)
|
||||
{
|
||||
_statusService = statusService;
|
||||
}
|
||||
|
||||
public async Task<StartupValidationResult> ValidateAsync(string tenantId, StalenessBudget budget, CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await _statusService.GetStatusAsync(tenantId, DateTimeOffset.UtcNow, cancellationToken);
|
||||
|
||||
if (status.Anchor == TimeAnchor.Unknown)
|
||||
{
|
||||
return StartupValidationResult.Failure("time-anchor-missing", status);
|
||||
}
|
||||
|
||||
if (status.Staleness.IsBreach)
|
||||
{
|
||||
return StartupValidationResult.Failure("time-anchor-stale", status);
|
||||
}
|
||||
|
||||
if (status.Budget.WarningSeconds != budget.WarningSeconds || status.Budget.BreachSeconds != budget.BreachSeconds)
|
||||
{
|
||||
// Keep warning but not block; seal handler may choose to fail.
|
||||
return StartupValidationResult.Failure("time-anchor-budget-mismatch", status);
|
||||
}
|
||||
|
||||
return StartupValidationResult.Success(status);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,16 @@ public sealed class TimeAnchorLoader
|
||||
return TimeAnchorValidationResult.Failure("token-empty");
|
||||
}
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("trust-roots-required");
|
||||
}
|
||||
|
||||
if (!AreTrustRootsCompatible(format, trustRoots))
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("trust-roots-incompatible-format");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromHexString(hex.Trim());
|
||||
@@ -34,4 +44,14 @@ public sealed class TimeAnchorLoader
|
||||
return TimeAnchorValidationResult.Failure("token-hex-invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AreTrustRootsCompatible(TimeTokenFormat format, IReadOnlyList<TimeTrustRoot> trustRoots)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
TimeTokenFormat.Roughtime => trustRoots.All(r => r.PublicKey.Length == 32), // Ed25519 size
|
||||
TimeTokenFormat.Rfc3161 => trustRoots.All(r => r.PublicKey.Length >= 128), // expect RSA key info (subject public key info bytes)
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
| AIRGAP-IMP-56-001 | DONE | DSSE verifier, TUF validator, Merkle root calculator + import coordinator; tests passing. | 2025-11-20 |
|
||||
| AIRGAP-IMP-56-002 | DONE | Root rotation policy (dual approval) + trust store; integrated into import validator; tests passing. | 2025-11-20 |
|
||||
| AIRGAP-IMP-57-001 | DONE | In-memory RLS bundle catalog/items repos + schema doc; deterministic ordering and tests passing. | 2025-11-20 |
|
||||
| AIRGAP-TIME-57-001 | DOING | Staleness calculator/budgets, hex loader, fixtures, TimeStatusService/store, stub verification pipeline added; crypto verification pending guild inputs. | 2025-11-20 |
|
||||
| AIRGAP-TIME-57-001 | DONE | Staleness calc, loader/fixtures, TimeStatusService/store, sealed validator, Ed25519 Roughtime + RFC3161 SignedCms verification, APIs + config sample delivered; awaiting final trust roots. | 2025-11-20 |
|
||||
|
||||
@@ -48,21 +48,6 @@ public sealed class AdvisoryObservationEventDocument
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AdvisoryObservationSourceDocument
|
||||
{
|
||||
[BsonElement("vendor")]
|
||||
public string Vendor { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("stream")]
|
||||
public string Stream { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("api")]
|
||||
public string Api { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("collectorVersion")]
|
||||
public string? CollectorVersion { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AdvisoryObservationLinksetSummaryDocument
|
||||
{
|
||||
[BsonElement("aliases")]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using StellaOps.Concelier.Core.Observations;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Observations;
|
||||
@@ -44,19 +45,17 @@ internal sealed class NatsAdvisoryObservationEventPublisher : IAdvisoryObservati
|
||||
_logger.LogDebug("Published advisory.observation.updated@1 to NATS subject {Subject} for observation {ObservationId}", subject, @event.ObservationId);
|
||||
}
|
||||
|
||||
private async Task EnsureStreamAsync(INatsJSContext js, CancellationToken cancellationToken)
|
||||
private async Task EnsureStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
|
||||
{
|
||||
var stream = _options.Stream;
|
||||
try
|
||||
{
|
||||
await js.GetStreamAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
await js.GetStreamAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
{
|
||||
var cfg = new NatsJSStreamConfig
|
||||
var cfg = new StreamConfig(stream, new[] { _options.Subject })
|
||||
{
|
||||
Name = stream,
|
||||
Subjects = new[] { _options.Subject },
|
||||
Description = "Concelier advisory observation events",
|
||||
MaxMsgSize = 512 * 1024,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
using StellaOps.Concelier.Storage.Mongo.ChangeHistory;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
@@ -21,6 +22,7 @@ using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Storage.Mongo.Migrations;
|
||||
using StellaOps.Concelier.Storage.Mongo.Observations;
|
||||
using StellaOps.Concelier.Core.Observations;
|
||||
using StellaOps.Concelier.Storage.Mongo.Linksets;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="NATS.Client.Core" Version="2.0.0" />
|
||||
<PackageReference Include="NATS.Client.JetStream" Version="2.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
|
||||
1
src/Concelier/seed-data
Symbolic link
1
src/Concelier/seed-data
Symbolic link
@@ -0,0 +1 @@
|
||||
../../seed-data
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.WebService.Options;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("v1/graph")]
|
||||
public class GraphController : ControllerBase
|
||||
{
|
||||
private readonly GraphOptions _options;
|
||||
|
||||
public GraphController(IOptions<GraphOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
[HttpPost("linkouts")]
|
||||
public IActionResult Linkouts([FromBody] LinkoutRequest request)
|
||||
{
|
||||
if (request == null || request.Purls == null || request.Purls.Count == 0)
|
||||
{
|
||||
return BadRequest("purls are required");
|
||||
}
|
||||
|
||||
if (request.Purls.Count > _options.MaxPurls)
|
||||
{
|
||||
return BadRequest($"purls limit exceeded (max {_options.MaxPurls})");
|
||||
}
|
||||
|
||||
return StatusCode(503, "Graph linkouts pending storage integration.");
|
||||
}
|
||||
|
||||
[HttpGet("overlays")]
|
||||
public IActionResult Overlays([FromQuery(Name = "purl")] List<string> purls, [FromQuery] bool includeJustifications = false)
|
||||
{
|
||||
if (purls == null || purls.Count == 0)
|
||||
{
|
||||
return BadRequest("purl query parameter is required");
|
||||
}
|
||||
|
||||
if (purls.Count > _options.MaxPurls)
|
||||
{
|
||||
return BadRequest($"purls limit exceeded (max {_options.MaxPurls})");
|
||||
}
|
||||
|
||||
return StatusCode(503, "Graph overlays pending storage integration.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LinkoutRequest
|
||||
{
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
public List<string> Purls { get; init; } = new();
|
||||
public bool IncludeJustifications { get; init; }
|
||||
public bool IncludeProvenance { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Excititor.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for graph linkouts and overlays.
|
||||
/// </summary>
|
||||
public sealed class GraphOptions
|
||||
{
|
||||
public int MaxPurls { get; set; } = 500;
|
||||
public int MaxAdvisoriesPerPurl { get; set; } = 200;
|
||||
public int OverlayTtlSeconds { get; set; } = 300;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// NOTE: Unable to update Program.cs usings via apply_patch because of file size and PTY limits.
|
||||
// Desired additions:
|
||||
// using StellaOps.Excititor.WebService.Options;
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Auth;
|
||||
|
||||
public interface ITenantAuthorityClientFactory
|
||||
{
|
||||
HttpClient Create(string tenant);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal tenant-scoped Authority client factory.
|
||||
/// Throws if tenant is missing or not configured, enforcing tenant isolation.
|
||||
/// </summary>
|
||||
public sealed class TenantAuthorityClientFactory : ITenantAuthorityClientFactory
|
||||
{
|
||||
private readonly TenantAuthorityOptions _options;
|
||||
|
||||
public TenantAuthorityClientFactory(IOptions<TenantAuthorityOptions> options)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public HttpClient Create(string tenant)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant is required for Authority client creation.", nameof(tenant));
|
||||
}
|
||||
|
||||
if (!_options.BaseUrls.TryGetValue(tenant, out var baseUrl) || string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority base URL not configured for tenant '{tenant}'.");
|
||||
}
|
||||
|
||||
var client = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(baseUrl, UriKind.Absolute),
|
||||
};
|
||||
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
client.DefaultRequestHeaders.Add("X-Tenant", tenant);
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Per-tenant Authority endpoints and client credentials used by worker services.
|
||||
/// When DisableConsensus is true, these settings are still required for tenant-scoped provenance checks.
|
||||
/// </summary>
|
||||
public sealed class TenantAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Map of tenant slug → base URL for Authority.
|
||||
/// </summary>
|
||||
public IDictionary<string, string> BaseUrls { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Optional map of tenant slug → clientId.
|
||||
/// </summary>
|
||||
public IDictionary<string, string> ClientIds { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Optional map of tenant slug → clientSecret.
|
||||
/// </summary>
|
||||
public IDictionary<string, string> ClientSecrets { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -11,10 +11,13 @@ public sealed class VexWorkerOptions
|
||||
|
||||
public TimeSpan OfflineInterval { get; set; } = TimeSpan.FromHours(6);
|
||||
|
||||
public TimeSpan DefaultInitialDelay { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public bool OfflineMode { get; set; }
|
||||
|
||||
public TimeSpan DefaultInitialDelay { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public bool OfflineMode { get; set; }
|
||||
|
||||
// Aggregation-only cutover: when true, consensus refresh stays disabled to enforce fact-only ingests.
|
||||
public bool DisableConsensus { get; set; } = true;
|
||||
|
||||
public IList<VexWorkerProviderOptions> Providers { get; } = new List<VexWorkerProviderOptions>();
|
||||
|
||||
public VexWorkerRetryOptions Retry { get; } = new();
|
||||
|
||||
@@ -1,134 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Options;
|
||||
|
||||
internal sealed class VexWorkerOptionsValidator : IValidateOptions<VexWorkerOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, VexWorkerOptions options)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
|
||||
if (options.DefaultInterval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.DefaultInterval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.OfflineInterval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.OfflineInterval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.DefaultInitialDelay < TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.DefaultInitialDelay cannot be negative.");
|
||||
}
|
||||
|
||||
if (options.Retry.BaseDelay <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.BaseDelay must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Retry.MaxDelay < options.Retry.BaseDelay)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.MaxDelay must be greater than or equal to BaseDelay.");
|
||||
}
|
||||
|
||||
if (options.Retry.QuarantineDuration <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.QuarantineDuration must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Retry.FailureThreshold < 1)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.FailureThreshold must be at least 1.");
|
||||
}
|
||||
|
||||
if (options.Retry.JitterRatio < 0 || options.Retry.JitterRatio > 1)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.JitterRatio must be between 0 and 1.");
|
||||
}
|
||||
|
||||
if (options.Retry.RetryCap < options.Retry.BaseDelay)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.RetryCap must be greater than or equal to BaseDelay.");
|
||||
}
|
||||
|
||||
if (options.Retry.RetryCap < options.Retry.MaxDelay)
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Options;
|
||||
|
||||
public sealed class VexWorkerOptionsValidator : IValidateOptions<VexWorkerOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, VexWorkerOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Retry.RetryCap must be greater than or equal to MaxDelay.");
|
||||
return ValidateOptionsResult.Fail("Excititor.Worker options cannot be null.");
|
||||
}
|
||||
|
||||
if (options.Refresh.ScanInterval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.ScanInterval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Refresh.ConsensusTtl <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.ConsensusTtl must be greater than zero.");
|
||||
}
|
||||
var failures = new List<string>();
|
||||
|
||||
if (options.Refresh.ScanBatchSize <= 0)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.ScanBatchSize must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Refresh.Damper.Minimum < TimeSpan.Zero)
|
||||
if (options.DisableConsensus && options.Refresh.Enabled)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.Damper.Minimum cannot be negative.");
|
||||
failures.Add("Excititor.Worker.DisableConsensus=true requires Refresh.Enabled=false.");
|
||||
}
|
||||
|
||||
if (options.Refresh.Damper.Maximum <= options.Refresh.Damper.Minimum)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.Damper.Maximum must be greater than Minimum.");
|
||||
}
|
||||
|
||||
if (options.Refresh.Damper.DefaultDuration < options.Refresh.Damper.Minimum || options.Refresh.Damper.DefaultDuration > options.Refresh.Damper.Maximum)
|
||||
{
|
||||
failures.Add("Excititor.Worker.Refresh.Damper.DefaultDuration must be within [Minimum, Maximum].");
|
||||
}
|
||||
|
||||
for (var i = 0; i < options.Refresh.Damper.Rules.Count; i++)
|
||||
{
|
||||
var rule = options.Refresh.Damper.Rules[i];
|
||||
if (rule.MinWeight < 0)
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Refresh.Damper.Rules[{i}].MinWeight must be non-negative.");
|
||||
}
|
||||
|
||||
if (rule.Duration <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Refresh.Damper.Rules[{i}].Duration must be greater than zero.");
|
||||
}
|
||||
|
||||
if (rule.Duration < options.Refresh.Damper.Minimum || rule.Duration > options.Refresh.Damper.Maximum)
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Refresh.Damper.Rules[{i}].Duration must be within [Minimum, Maximum].");
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < options.Providers.Count; i++)
|
||||
{
|
||||
var provider = options.Providers[i];
|
||||
if (string.IsNullOrWhiteSpace(provider.ProviderId))
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Providers[{i}].ProviderId must be set.");
|
||||
}
|
||||
|
||||
if (provider.Interval is { } interval && interval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Providers[{i}].Interval must be greater than zero when specified.");
|
||||
}
|
||||
|
||||
if (provider.InitialDelay is { } delay && delay < TimeSpan.Zero)
|
||||
{
|
||||
failures.Add($"Excititor.Worker.Providers[{i}].InitialDelay cannot be negative.");
|
||||
}
|
||||
}
|
||||
|
||||
return failures.Count > 0
|
||||
? ValidateOptionsResult.Fail(failures)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
return failures.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(failures);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,17 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Aoc;
|
||||
using StellaOps.Excititor.Formats.CSAF;
|
||||
using StellaOps.Excititor.Formats.CycloneDX;
|
||||
using StellaOps.Excititor.Formats.OpenVEX;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.Worker.Auth;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
using StellaOps.Excititor.Worker.Scheduling;
|
||||
using StellaOps.Excititor.Worker.Signature;
|
||||
@@ -22,12 +23,20 @@ using StellaOps.IssuerDirectory.Client;
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
var services = builder.Services;
|
||||
var configuration = builder.Configuration;
|
||||
services.AddOptions<VexWorkerOptions>()
|
||||
.Bind(configuration.GetSection("Excititor:Worker"))
|
||||
.ValidateOnStart();
|
||||
|
||||
services.Configure<VexWorkerPluginOptions>(configuration.GetSection("Excititor:Worker:Plugins"));
|
||||
services.AddRedHatCsafConnector();
|
||||
services.AddOptions<VexWorkerOptions>()
|
||||
.Bind(configuration.GetSection("Excititor:Worker"))
|
||||
.ValidateOnStart();
|
||||
|
||||
services.Configure<VexWorkerPluginOptions>(configuration.GetSection("Excititor:Worker:Plugins"));
|
||||
services.Configure<TenantAuthorityOptions>(configuration.GetSection("Excititor:Authority"));
|
||||
services.PostConfigure<VexWorkerOptions>(options =>
|
||||
{
|
||||
if (options.DisableConsensus)
|
||||
{
|
||||
options.Refresh.Enabled = false;
|
||||
}
|
||||
});
|
||||
services.AddRedHatCsafConnector();
|
||||
|
||||
services.AddOptions<VexMongoStorageOptions>()
|
||||
.Bind(configuration.GetSection("Excititor:Storage:Mongo"))
|
||||
@@ -96,6 +105,7 @@ services.AddSingleton<VexConsensusRefreshService>();
|
||||
services.AddSingleton<IVexConsensusRefreshScheduler>(static provider => provider.GetRequiredService<VexConsensusRefreshService>());
|
||||
services.AddHostedService<VexWorkerHostedService>();
|
||||
services.AddHostedService(static provider => provider.GetRequiredService<VexConsensusRefreshService>());
|
||||
services.AddSingleton<ITenantAuthorityClientFactory, TenantAuthorityClientFactory>();
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
|
||||
@@ -22,7 +22,8 @@ internal sealed class VexConsensusRefreshService : BackgroundService, IVexConsen
|
||||
private readonly Channel<RefreshRequest> _refreshRequests;
|
||||
private readonly ConcurrentDictionary<string, byte> _scheduledKeys = new(StringComparer.Ordinal);
|
||||
private readonly IDisposable? _optionsSubscription;
|
||||
private RefreshState _refreshState;
|
||||
private RefreshState _refreshState;
|
||||
private volatile bool _disableConsensus;
|
||||
|
||||
public VexConsensusRefreshService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
@@ -45,19 +46,21 @@ internal sealed class VexConsensusRefreshService : BackgroundService, IVexConsen
|
||||
throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
_refreshState = RefreshState.FromOptions(options.Refresh);
|
||||
_optionsSubscription = optionsMonitor.OnChange(o =>
|
||||
{
|
||||
var state = RefreshState.FromOptions((o?.Refresh) ?? new VexWorkerRefreshOptions());
|
||||
Volatile.Write(ref _refreshState, state);
|
||||
_logger.LogInformation(
|
||||
"Consensus refresh options updated: enabled={Enabled}, interval={Interval}, ttl={Ttl}, batch={Batch}",
|
||||
state.Enabled,
|
||||
state.ScanInterval,
|
||||
state.ConsensusTtl,
|
||||
state.ScanBatchSize);
|
||||
});
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
_disableConsensus = options.DisableConsensus;
|
||||
_refreshState = RefreshState.FromOptions(options.Refresh);
|
||||
_optionsSubscription = optionsMonitor.OnChange(o =>
|
||||
{
|
||||
var state = RefreshState.FromOptions((o?.Refresh) ?? new VexWorkerRefreshOptions());
|
||||
_disableConsensus = o?.DisableConsensus ?? false;
|
||||
Volatile.Write(ref _refreshState, state);
|
||||
_logger.LogInformation(
|
||||
"Consensus refresh options updated: enabled={Enabled}, interval={Interval}, ttl={Ttl}, batch={Batch}",
|
||||
state.Enabled,
|
||||
state.ScanInterval,
|
||||
state.ConsensusTtl,
|
||||
state.ScanBatchSize);
|
||||
});
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
@@ -66,17 +69,23 @@ internal sealed class VexConsensusRefreshService : BackgroundService, IVexConsen
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
public void ScheduleRefresh(string vulnerabilityId, string productKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = BuildKey(vulnerabilityId, productKey);
|
||||
if (!_scheduledKeys.TryAdd(key, 0))
|
||||
{
|
||||
return;
|
||||
public void ScheduleRefresh(string vulnerabilityId, string productKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_disableConsensus)
|
||||
{
|
||||
_logger.LogDebug("Consensus refresh disabled; ignoring schedule request for {VulnerabilityId}/{ProductKey}.", vulnerabilityId, productKey);
|
||||
return;
|
||||
}
|
||||
|
||||
var key = BuildKey(vulnerabilityId, productKey);
|
||||
if (!_scheduledKeys.TryAdd(key, 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new RefreshRequest(vulnerabilityId.Trim(), productKey.Trim());
|
||||
@@ -88,17 +97,23 @@ internal sealed class VexConsensusRefreshService : BackgroundService, IVexConsen
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var queueTask = ProcessQueueAsync(stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var options = CurrentOptions;
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessEligibleHoldsAsync(options, stoppingToken).ConfigureAwait(false);
|
||||
var queueTask = ProcessQueueAsync(stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_disableConsensus)
|
||||
{
|
||||
_logger.LogInformation("Consensus refresh disabled via DisableConsensus flag; exiting refresh loop.");
|
||||
break;
|
||||
}
|
||||
|
||||
var options = CurrentOptions;
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessEligibleHoldsAsync(options, stoppingToken).ConfigureAwait(false);
|
||||
if (options.Enabled)
|
||||
{
|
||||
await ProcessTtlRefreshAsync(options, stoppingToken).ConfigureAwait(false);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Exports;
|
||||
|
||||
public class AttestationQueryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeFiltersHash_IsDeterministic()
|
||||
{
|
||||
var svc = new AttestationQueryService(NullLogger<AttestationQueryService>.Instance);
|
||||
|
||||
var requestA = new AttestationQueryRequest(
|
||||
TenantId: "t1",
|
||||
ArtifactId: "sha256:a",
|
||||
FindingId: null,
|
||||
AttestationId: null,
|
||||
Status: "verified",
|
||||
SinceRecordedAt: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
|
||||
UntilRecordedAt: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
|
||||
Limit: 100,
|
||||
FiltersHash: string.Empty,
|
||||
PagingKey: null);
|
||||
|
||||
var requestB = requestA with { FiltersHash = "anything" };
|
||||
|
||||
var hashA = svc.ComputeFiltersHash(requestA);
|
||||
var hashB = svc.ComputeFiltersHash(requestB);
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageToken_RoundTrips()
|
||||
{
|
||||
var svc = new AttestationQueryService(NullLogger<AttestationQueryService>.Instance);
|
||||
|
||||
var request = new AttestationQueryRequest(
|
||||
TenantId: "t1",
|
||||
ArtifactId: "sha256:a",
|
||||
FindingId: "f1",
|
||||
AttestationId: "att-1",
|
||||
Status: "verified",
|
||||
SinceRecordedAt: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
|
||||
UntilRecordedAt: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
|
||||
Limit: 50,
|
||||
FiltersHash: string.Empty,
|
||||
PagingKey: null);
|
||||
|
||||
var filtersHash = svc.ComputeFiltersHash(request);
|
||||
var key = new AttestationPagingKey(DateTimeOffset.Parse("2024-01-01T12:00:00Z"), "att-9");
|
||||
|
||||
var token = svc.CreatePageToken(key, filtersHash);
|
||||
|
||||
var ok = svc.TryParsePageToken(token, filtersHash, out var parsed, out var error);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Null(error);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(key.RecordedAt, parsed!.RecordedAt);
|
||||
Assert.Equal(key.AttestationId, parsed.AttestationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Hashing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests;
|
||||
|
||||
public sealed class ProjectionHashingTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeCycleHash_IncludesRiskFields()
|
||||
{
|
||||
var projection = CreateProjection(riskScore: 5.5m, riskSeverity: "high");
|
||||
var hashWithRisk = ProjectionHashing.ComputeCycleHash(projection);
|
||||
|
||||
var changedRisk = projection with { RiskScore = 4.0m };
|
||||
var hashChangedRisk = ProjectionHashing.ComputeCycleHash(changedRisk);
|
||||
|
||||
Assert.NotEqual(hashWithRisk, hashChangedRisk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCycleHash_ChangesWhenRiskExplanationChanges()
|
||||
{
|
||||
var projection = CreateProjection(riskExplanationId: Guid.NewGuid());
|
||||
var hashWithExplanation = ProjectionHashing.ComputeCycleHash(projection);
|
||||
|
||||
var projectionDifferent = projection with { RiskExplanationId = Guid.NewGuid() };
|
||||
var hashWithDifferentExplanation = ProjectionHashing.ComputeCycleHash(projectionDifferent);
|
||||
|
||||
Assert.NotEqual(hashWithExplanation, hashWithDifferentExplanation);
|
||||
}
|
||||
|
||||
private static FindingProjection CreateProjection(decimal? riskScore = null, string? riskSeverity = null, Guid? riskExplanationId = null)
|
||||
{
|
||||
return new FindingProjection(
|
||||
TenantId: "t1",
|
||||
FindingId: "f1",
|
||||
PolicyVersion: "v1",
|
||||
Status: "affected",
|
||||
Severity: 7.5m,
|
||||
RiskScore: riskScore,
|
||||
RiskSeverity: riskSeverity,
|
||||
RiskProfileVersion: "profile-1",
|
||||
RiskExplanationId: riskExplanationId,
|
||||
RiskEventSequence: 1,
|
||||
Labels: new JsonObject { ["k"] = "v" },
|
||||
CurrentEventId: Guid.NewGuid(),
|
||||
ExplainRef: "ref",
|
||||
PolicyRationale: new JsonArray("r1"),
|
||||
UpdatedAt: DateTimeOffset.UtcNow,
|
||||
CycleHash: string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.8.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="StellaOps.Findings.Ledger">
|
||||
<HintPath>..\StellaOps.Findings.Ledger\bin\Release\net10.0\StellaOps.Findings.Ledger.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="**/*.cs" />
|
||||
<Compile Include="Exports/ExportPagingTests.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,24 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);**/tools/**/*</DefaultItemExcludes>
|
||||
<DisableTransitiveProjectReferences>true</DisableTransitiveProjectReferences>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Findings.Ledger\\StellaOps.Findings.Ledger.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.8.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<ProjectReference Include="../StellaOps.Findings.Ledger/StellaOps.Findings.Ledger.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="**/*.cs" />
|
||||
<Compile Include="ProjectionHashingTests.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.5.4" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Exports;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic paging helpers and SQL-backed queries for attestation verifications.
|
||||
/// </summary>
|
||||
public sealed class AttestationQueryService
|
||||
{
|
||||
private const int DefaultLimit = 200;
|
||||
private const int MaxLimit = 1000;
|
||||
|
||||
private readonly LedgerDataSource? _dataSource;
|
||||
private readonly ILogger<AttestationQueryService> _logger;
|
||||
|
||||
public AttestationQueryService(ILogger<AttestationQueryService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public AttestationQueryService(LedgerDataSource dataSource, ILogger<AttestationQueryService> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public int ClampLimit(int? requested)
|
||||
{
|
||||
if (!requested.HasValue || requested.Value <= 0)
|
||||
{
|
||||
return DefaultLimit;
|
||||
}
|
||||
|
||||
return Math.Min(requested.Value, MaxLimit);
|
||||
}
|
||||
|
||||
public string ComputeFiltersHash(AttestationQueryRequest request)
|
||||
{
|
||||
var filters = new Dictionary<string, string?>
|
||||
{
|
||||
["artifact_id"] = request.ArtifactId,
|
||||
["finding_id"] = request.FindingId,
|
||||
["attestation_id"] = request.AttestationId,
|
||||
["status"] = request.Status,
|
||||
["since_recorded_at"] = request.SinceRecordedAt?.ToString("O"),
|
||||
["until_recorded_at"] = request.UntilRecordedAt?.ToString("O"),
|
||||
["limit"] = request.Limit.ToString()
|
||||
};
|
||||
|
||||
return ExportPaging.ComputeFiltersHash(filters);
|
||||
}
|
||||
|
||||
public bool TryParsePageToken(string token, string expectedFiltersHash, out AttestationPagingKey? key, out string? error)
|
||||
{
|
||||
key = null;
|
||||
error = null;
|
||||
|
||||
var base64 = token.Replace('-', '+').Replace('_', '/');
|
||||
while (base64.Length % 4 != 0)
|
||||
{
|
||||
base64 += '=';
|
||||
}
|
||||
|
||||
byte[] decodedBytes;
|
||||
try
|
||||
{
|
||||
decodedBytes = Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
error = "invalid_page_token_encoding";
|
||||
return false;
|
||||
}
|
||||
|
||||
AttestationPageToken? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<AttestationPageToken>(decodedBytes);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
error = "invalid_page_token_payload";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload is null || payload.Last is null)
|
||||
{
|
||||
error = "invalid_page_token_payload";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(payload.FiltersHash, expectedFiltersHash, StringComparison.Ordinal))
|
||||
{
|
||||
error = "page_token_filters_mismatch";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTimeOffset.TryParse(payload.Last.RecordedAt, out var recordedAt))
|
||||
{
|
||||
error = "invalid_page_token_payload";
|
||||
return false;
|
||||
}
|
||||
|
||||
key = new AttestationPagingKey(recordedAt, payload.Last.AttestationId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public string CreatePageToken(AttestationPagingKey key, string filtersHash)
|
||||
{
|
||||
var payload = new AttestationPageToken
|
||||
{
|
||||
FiltersHash = filtersHash,
|
||||
Last = new AttestationPageKey
|
||||
{
|
||||
RecordedAt = key.RecordedAt.ToString("O"),
|
||||
AttestationId = key.AttestationId
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json))
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
public async Task<ExportPage<AttestationExportItem>> GetAttestationsAsync(AttestationQueryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (_dataSource is null)
|
||||
{
|
||||
throw new InvalidOperationException("data_source_unavailable");
|
||||
}
|
||||
|
||||
if (!string.Equals(request.FiltersHash, ComputeFiltersHash(request), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("filters_hash_mismatch");
|
||||
}
|
||||
|
||||
const string baseSql = """
|
||||
SELECT attestation_id,
|
||||
artifact_id,
|
||||
finding_id,
|
||||
verification_status,
|
||||
verification_time,
|
||||
dsse_digest,
|
||||
rekor_entry_id,
|
||||
evidence_bundle_ref,
|
||||
ledger_event_id,
|
||||
recorded_at,
|
||||
merkle_leaf_hash,
|
||||
root_hash
|
||||
FROM ledger_attestations
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
var sqlBuilder = new StringBuilder(baseSql);
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenant_id", request.TenantId) { NpgsqlDbType = NpgsqlDbType.Text }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ArtifactId))
|
||||
{
|
||||
sqlBuilder.Append(" AND artifact_id = @artifact_id");
|
||||
parameters.Add(new NpgsqlParameter<string>("artifact_id", request.ArtifactId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.FindingId))
|
||||
{
|
||||
sqlBuilder.Append(" AND finding_id = @finding_id");
|
||||
parameters.Add(new NpgsqlParameter<string>("finding_id", request.FindingId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.AttestationId))
|
||||
{
|
||||
sqlBuilder.Append(" AND attestation_id = @attestation_id");
|
||||
parameters.Add(new NpgsqlParameter<string>("attestation_id", request.AttestationId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
sqlBuilder.Append(" AND verification_status = @status");
|
||||
parameters.Add(new NpgsqlParameter<string>("status", request.Status) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
}
|
||||
|
||||
if (request.SinceRecordedAt.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND recorded_at >= @since_recorded_at");
|
||||
parameters.Add(new NpgsqlParameter<DateTimeOffset>("since_recorded_at", request.SinceRecordedAt.Value) { NpgsqlDbType = NpgsqlDbType.TimestampTz });
|
||||
}
|
||||
|
||||
if (request.UntilRecordedAt.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND recorded_at <= @until_recorded_at");
|
||||
parameters.Add(new NpgsqlParameter<DateTimeOffset>("until_recorded_at", request.UntilRecordedAt.Value) { NpgsqlDbType = NpgsqlDbType.TimestampTz });
|
||||
}
|
||||
|
||||
if (request.PagingKey is not null)
|
||||
{
|
||||
sqlBuilder.Append(" AND (recorded_at > @cursor_recorded_at OR (recorded_at = @cursor_recorded_at AND attestation_id > @cursor_attestation_id))");
|
||||
parameters.Add(new NpgsqlParameter<DateTimeOffset>("cursor_recorded_at", request.PagingKey.RecordedAt) { NpgsqlDbType = NpgsqlDbType.TimestampTz });
|
||||
parameters.Add(new NpgsqlParameter<string>("cursor_attestation_id", request.PagingKey.AttestationId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY recorded_at ASC, attestation_id ASC");
|
||||
sqlBuilder.Append(" LIMIT @take");
|
||||
parameters.Add(new NpgsqlParameter<int>("take", request.Limit + 1) { NpgsqlDbType = NpgsqlDbType.Integer });
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(request.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sqlBuilder.ToString(), connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
command.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var items = new List<AttestationExportItem>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(new AttestationExportItem(
|
||||
AttestationId: reader.GetString(0),
|
||||
ArtifactId: reader.GetString(1),
|
||||
FindingId: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
VerificationStatus: reader.GetString(3),
|
||||
VerificationTime: reader.GetFieldValue<DateTimeOffset>(4),
|
||||
DsseDigest: reader.GetString(5),
|
||||
RekorEntryId: reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
EvidenceBundleRef: reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
LedgerEventId: reader.GetGuid(8).ToString(),
|
||||
RecordedAt: reader.GetFieldValue<DateTimeOffset>(9),
|
||||
MerkleLeafHash: reader.GetString(10),
|
||||
RootHash: reader.GetString(11)));
|
||||
}
|
||||
|
||||
string? nextPageToken = null;
|
||||
if (items.Count > request.Limit)
|
||||
{
|
||||
var last = items[request.Limit];
|
||||
items = items.Take(request.Limit).ToList();
|
||||
var key = new AttestationPagingKey(last.RecordedAt, last.AttestationId);
|
||||
nextPageToken = CreatePageToken(key, request.FiltersHash);
|
||||
}
|
||||
|
||||
return new ExportPage<AttestationExportItem>(items, nextPageToken);
|
||||
}
|
||||
|
||||
private sealed class AttestationPageToken
|
||||
{
|
||||
public string FiltersHash { get; set; } = string.Empty;
|
||||
public AttestationPageKey? Last { get; set; }
|
||||
}
|
||||
|
||||
private sealed class AttestationPageKey
|
||||
{
|
||||
public string RecordedAt { get; set; } = string.Empty;
|
||||
public string AttestationId { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ public static class ProjectionHashing
|
||||
private const string PolicyVersionProperty = nameof(FindingProjection.PolicyVersion);
|
||||
private const string StatusProperty = nameof(FindingProjection.Status);
|
||||
private const string SeverityProperty = nameof(FindingProjection.Severity);
|
||||
private const string RiskScoreProperty = nameof(FindingProjection.RiskScore);
|
||||
private const string RiskSeverityProperty = nameof(FindingProjection.RiskSeverity);
|
||||
private const string RiskProfileVersionProperty = nameof(FindingProjection.RiskProfileVersion);
|
||||
private const string RiskExplanationIdProperty = nameof(FindingProjection.RiskExplanationId);
|
||||
private const string RiskEventSequenceProperty = nameof(FindingProjection.RiskEventSequence);
|
||||
private const string LabelsProperty = nameof(FindingProjection.Labels);
|
||||
private const string CurrentEventIdProperty = nameof(FindingProjection.CurrentEventId);
|
||||
private const string ExplainRefProperty = nameof(FindingProjection.ExplainRef);
|
||||
@@ -27,6 +32,11 @@ public static class ProjectionHashing
|
||||
[PolicyVersionProperty] = projection.PolicyVersion,
|
||||
[StatusProperty] = projection.Status,
|
||||
[SeverityProperty] = projection.Severity,
|
||||
[RiskScoreProperty] = projection.RiskScore,
|
||||
[RiskSeverityProperty] = projection.RiskSeverity,
|
||||
[RiskProfileVersionProperty] = projection.RiskProfileVersion,
|
||||
[RiskExplanationIdProperty] = projection.RiskExplanationId?.ToString(),
|
||||
[RiskEventSequenceProperty] = projection.RiskEventSequence,
|
||||
[LabelsProperty] = projection.Labels.DeepClone(),
|
||||
[CurrentEventIdProperty] = projection.CurrentEventId.ToString(),
|
||||
[ExplainRefProperty] = projection.ExplainRef,
|
||||
|
||||
@@ -14,6 +14,11 @@ public interface IPolicyEvaluationService
|
||||
public sealed record PolicyEvaluationResult(
|
||||
string? Status,
|
||||
decimal? Severity,
|
||||
decimal? RiskScore,
|
||||
string? RiskSeverity,
|
||||
string? RiskProfileVersion,
|
||||
Guid? RiskExplanationId,
|
||||
long? RiskEventSequence,
|
||||
JsonObject Labels,
|
||||
string? ExplainRef,
|
||||
JsonArray Rationale);
|
||||
|
||||
@@ -42,6 +42,11 @@ public sealed class InlinePolicyEvaluationService : IPolicyEvaluationService
|
||||
var result = new PolicyEvaluationResult(
|
||||
status,
|
||||
severity,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
existingProjection?.RiskEventSequence,
|
||||
labels,
|
||||
explainRef,
|
||||
rationale);
|
||||
@@ -62,6 +67,11 @@ public sealed class InlinePolicyEvaluationService : IPolicyEvaluationService
|
||||
return new PolicyEvaluationResult(
|
||||
existingProjection?.Status,
|
||||
existingProjection?.Severity,
|
||||
existingProjection?.RiskScore,
|
||||
existingProjection?.RiskSeverity,
|
||||
existingProjection?.RiskProfileVersion,
|
||||
existingProjection?.RiskExplanationId,
|
||||
existingProjection?.RiskEventSequence,
|
||||
labels,
|
||||
existingProjection?.ExplainRef,
|
||||
rationale);
|
||||
|
||||
@@ -129,6 +129,10 @@ internal sealed class PolicyEngineEvaluationService : IPolicyEvaluationService
|
||||
{
|
||||
["status"] = existingProjection.Status,
|
||||
["severity"] = existingProjection.Severity,
|
||||
["riskScore"] = existingProjection.RiskScore,
|
||||
["riskSeverity"] = existingProjection.RiskSeverity,
|
||||
["riskProfileVersion"] = existingProjection.RiskProfileVersion,
|
||||
["riskExplanationId"] = existingProjection.RiskExplanationId?.ToString(),
|
||||
["labels"] = existingProjection.Labels.DeepClone(),
|
||||
["explainRef"] = existingProjection.ExplainRef,
|
||||
["rationale"] = existingProjection.PolicyRationale.DeepClone()
|
||||
@@ -168,6 +172,22 @@ internal sealed class PolicyEngineEvaluationService : IPolicyEvaluationService
|
||||
severity = decimalSeverity;
|
||||
}
|
||||
|
||||
decimal? riskScore = null;
|
||||
var riskScoreElement = item.GetPropertyOrDefault("riskScore");
|
||||
if (riskScoreElement.HasValue && riskScoreElement.Value.ValueKind == JsonValueKind.Number && riskScoreElement.Value.TryGetDecimal(out var decimalRiskScore))
|
||||
{
|
||||
riskScore = decimalRiskScore;
|
||||
}
|
||||
var riskSeverity = item.GetPropertyOrDefault("riskSeverity")?.GetString();
|
||||
var riskProfileVersion = item.GetPropertyOrDefault("riskProfileVersion")?.GetString();
|
||||
Guid? riskExplanationId = null;
|
||||
var riskExplanationElement = item.GetPropertyOrDefault("riskExplanationId");
|
||||
if (riskExplanationElement.HasValue && riskExplanationElement.Value.ValueKind == JsonValueKind.String &&
|
||||
Guid.TryParse(riskExplanationElement.Value.GetString(), out var parsedExplanation))
|
||||
{
|
||||
riskExplanationId = parsedExplanation;
|
||||
}
|
||||
|
||||
var labelsNode = new JsonObject();
|
||||
var labelsElement = item.GetPropertyOrDefault("labels");
|
||||
if (labelsElement.HasValue && labelsElement.Value.ValueKind == JsonValueKind.Object)
|
||||
@@ -175,6 +195,12 @@ internal sealed class PolicyEngineEvaluationService : IPolicyEvaluationService
|
||||
labelsNode = (JsonObject)labelsElement.Value.ToJsonNode()!;
|
||||
}
|
||||
var explainRef = item.GetPropertyOrDefault("explainRef")?.GetString();
|
||||
long? riskEventSequence = null;
|
||||
var riskEventSequenceElement = item.GetPropertyOrDefault("riskEventSequence");
|
||||
if (riskEventSequenceElement.HasValue && riskEventSequenceElement.Value.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
riskEventSequence = riskEventSequenceElement.Value.GetInt64();
|
||||
}
|
||||
|
||||
JsonArray rationale;
|
||||
var rationaleElement = item.GetPropertyOrDefault("rationale");
|
||||
@@ -191,7 +217,17 @@ internal sealed class PolicyEngineEvaluationService : IPolicyEvaluationService
|
||||
rationale = (JsonArray)rationaleElement.Value.ToJsonNode()!;
|
||||
}
|
||||
|
||||
return new PolicyEvaluationResult(status, severity, labelsNode, explainRef, rationale);
|
||||
return new PolicyEvaluationResult(
|
||||
status,
|
||||
severity,
|
||||
riskScore,
|
||||
riskSeverity,
|
||||
riskProfileVersion,
|
||||
riskExplanationId,
|
||||
riskEventSequence ?? record.SequenceNumber,
|
||||
labelsNode,
|
||||
explainRef,
|
||||
rationale);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Policy engine response did not include evaluation for requested finding.");
|
||||
|
||||
@@ -66,6 +66,11 @@ internal sealed class PolicyEvaluationCache : IDisposable
|
||||
return new PolicyEvaluationResult(
|
||||
result.Status,
|
||||
result.Severity,
|
||||
result.RiskScore,
|
||||
result.RiskSeverity,
|
||||
result.RiskProfileVersion,
|
||||
result.RiskExplanationId,
|
||||
result.RiskEventSequence,
|
||||
labelsClone,
|
||||
result.ExplainRef,
|
||||
rationaleClone);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 004_ledger_attestations.sql
|
||||
-- LEDGER-OBS-54-001: storage for attestation verification exports
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_attestations (
|
||||
tenant_id text NOT NULL,
|
||||
attestation_id uuid NOT NULL,
|
||||
artifact_id text NOT NULL,
|
||||
finding_id text NULL,
|
||||
verification_status text NOT NULL,
|
||||
verification_time timestamptz NOT NULL,
|
||||
dsse_digest text NOT NULL,
|
||||
rekor_entry_id text NULL,
|
||||
evidence_bundle_ref text NULL,
|
||||
ledger_event_id uuid NOT NULL,
|
||||
recorded_at timestamptz NOT NULL,
|
||||
merkle_leaf_hash text NOT NULL,
|
||||
root_hash text NOT NULL,
|
||||
cycle_hash text NOT NULL,
|
||||
projection_version text NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ledger_attestations
|
||||
ADD CONSTRAINT pk_ledger_attestations PRIMARY KEY (tenant_id, attestation_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_recorded
|
||||
ON ledger_attestations (tenant_id, recorded_at, attestation_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_artifact
|
||||
ON ledger_attestations (tenant_id, artifact_id, recorded_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_finding
|
||||
ON ledger_attestations (tenant_id, finding_id, recorded_at DESC)
|
||||
WHERE finding_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_status
|
||||
ON ledger_attestations (tenant_id, verification_status, recorded_at DESC);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 004_risk_fields.sql
|
||||
-- Add risk scoring fields to findings_projection (LEDGER-RISK-66-001/002)
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE findings_projection
|
||||
ADD COLUMN IF NOT EXISTS risk_score NUMERIC(6,3),
|
||||
ADD COLUMN IF NOT EXISTS risk_severity TEXT,
|
||||
ADD COLUMN IF NOT EXISTS risk_profile_version TEXT,
|
||||
ADD COLUMN IF NOT EXISTS risk_explanation_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS risk_event_sequence BIGINT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_projection_risk ON findings_projection (tenant_id, risk_severity, risk_score DESC);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 005_risk_fields.sql
|
||||
-- LEDGER-RISK-66-001: add risk scoring fields to findings projection
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE findings_projection
|
||||
ADD COLUMN IF NOT EXISTS risk_score numeric(6,2) NULL,
|
||||
ADD COLUMN IF NOT EXISTS risk_severity text NULL,
|
||||
ADD COLUMN IF NOT EXISTS risk_profile_version text NULL,
|
||||
ADD COLUMN IF NOT EXISTS risk_explanation_id text NULL,
|
||||
ADD COLUMN IF NOT EXISTS risk_event_sequence bigint NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_findings_projection_risk
|
||||
ON findings_projection (tenant_id, risk_severity, risk_score DESC, recorded_at DESC);
|
||||
|
||||
COMMIT;
|
||||
@@ -460,6 +460,10 @@ internal sealed class NoOpPolicyEvaluationService : IPolicyEvaluationService
|
||||
return Task.FromResult(new PolicyEvaluationResult(
|
||||
Status: current?.Status ?? "new",
|
||||
Severity: current?.Severity,
|
||||
RiskScore: current?.RiskScore,
|
||||
RiskSeverity: current?.RiskSeverity,
|
||||
RiskProfileVersion: current?.RiskProfileVersion,
|
||||
RiskExplanationId: current?.RiskExplanationId,
|
||||
Labels: labels,
|
||||
ExplainRef: null,
|
||||
Rationale: new JsonArray()));
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- Current sprint file (e.g., `docs/implplan/SPRINT_0131_0001_0001_scanner_surface.md`).
|
||||
- `docs/reachability/DELIVERY_GUIDE.md` (sections 5.5–5.9 for native/JS/PHP updates)
|
||||
- `docs/reachability/purl-resolved-edges.md`
|
||||
- `docs/reachability/patch-oracles.md`
|
||||
- Current sprint file (e.g., `docs/implplan/SPRINT_401_reachability_evidence_chain.md`).
|
||||
|
||||
## Working Directory & Boundaries
|
||||
- Primary scope: `src/Scanner/**` (analyzers, worker, web service, plugins, __Libraries, __Tests, __Benchmarks, docs).
|
||||
@@ -23,6 +26,8 @@
|
||||
- Determinism: stable ordering, UTC ISO-8601 timestamps, no `DateTime.Now`/random without seed; normalize path separators.
|
||||
- Logging: structured (`ILogger` message templates); avoid secrets/paths leakage.
|
||||
- Security: no executing untrusted payloads; keep analyzers pure; include redaction guidance for runtime capture adapters.
|
||||
- Native analyzers: capture `.note.gnu.build-id` when present and thread into `SymbolID`/`code_id`; add synthetic roots for `.preinit_array/.init_array/_init`; emit purl+symbol-digest on call edges; emit Unknowns when symbol→purl or edges are unresolved.
|
||||
- Tests: keep patch-oracle fixtures deterministic (strip binaries; stable compilers); add/maintain `tests/reachability/patch-oracles/**` when touching native analyzers.
|
||||
|
||||
## Testing & Verification
|
||||
- Default: `dotnet test src/Scanner/StellaOps.Scanner.sln`.
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
"__Libraries/StellaOps.Scanner.Analyzers.Lang.Rust/StellaOps.Scanner.Analyzers.Lang.Rust.csproj",
|
||||
"__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj",
|
||||
"__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/StellaOps.Scanner.Analyzers.Lang.Tests.csproj",
|
||||
"__Tests/StellaOps.Scanner.Analyzers.Lang.Node.Tests/StellaOps.Scanner.Analyzers.Lang.Node.Tests.csproj",
|
||||
"../Concelier/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj"
|
||||
"__Tests/StellaOps.Scanner.Analyzers.Lang.Node.Tests/StellaOps.Scanner.Analyzers.Lang.Node.Tests.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Esprima;
|
||||
using Esprima.Ast;
|
||||
using EsprimaNode = Esprima.Ast.Node;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
||||
|
||||
@@ -37,7 +38,7 @@ internal static class NodeImportWalker
|
||||
: edges.OrderBy(e => e.ComparisonKey, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static void Walk(Node node, string sourcePath, List<NodeImportEdge> edges)
|
||||
private static void Walk(EsprimaNode node, string sourcePath, List<NodeImportEdge> edges)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
|
||||
@@ -426,12 +426,6 @@ internal static class NodePackageCollector
|
||||
return $"{entry.Source}:{entry.Locator}";
|
||||
}
|
||||
|
||||
private static bool HasYarnPnp(string rootPath)
|
||||
{
|
||||
return File.Exists(Path.Combine(rootPath, ".pnp.cjs"))
|
||||
|| File.Exists(Path.Combine(rootPath, ".pnp.data.cjs"));
|
||||
}
|
||||
|
||||
private static NodePackage? TryCreatePackage(
|
||||
LanguageAnalyzerContext context,
|
||||
string packageJsonPath,
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Core;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal.Phase22;
|
||||
|
||||
|
||||
@@ -8,13 +8,17 @@
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
|
||||
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />
|
||||
<None Include="**\\*" Exclude="**\\*.cs;**\\*.json;bin\\**;obj\\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
|
||||
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />
|
||||
<None Include="**\\*" Exclude="**\\*.cs;**\\*.json;bin\\**;obj\\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Esprima" Version="3.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<ConcelierTestingPath></ConcelierTestingPath>
|
||||
<ConcelierSharedTestsPath></ConcelierSharedTestsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -43,4 +46,4 @@
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<ConcelierTestingPath></ConcelierTestingPath>
|
||||
<ConcelierSharedTestsPath></ConcelierSharedTestsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
<MaxCpuCount>1</MaxCpuCount>
|
||||
<TargetPlatform>x64</TargetPlatform>
|
||||
<TargetFrameworkVersion>net10.0</TargetFrameworkVersion>
|
||||
<ResultsDirectory>./TestResults</ResultsDirectory>
|
||||
<ResultsDirectory>./TestResults/node-isolated</ResultsDirectory>
|
||||
<TestCaseFilter>FullyQualifiedName~Lang.Node.Tests</TestCaseFilter>
|
||||
</RunConfiguration>
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<!-- keep deterministic runs; no code coverage collectors by default -->
|
||||
<DataCollector friendlyName="blame" enabled="false" />
|
||||
</DataCollectors>
|
||||
</DataCollectionRunSettings>
|
||||
</RunSettings>
|
||||
|
||||
@@ -13,10 +13,17 @@ dotnet restore src/Scanner/StellaOps.Scanner.Node.slnf \
|
||||
-p:RestorePackagesPath="$REPO_ROOT/offline/packages" \
|
||||
-p:ContinuousIntegrationBuild=true
|
||||
|
||||
# Run node analyzer tests in isolation
|
||||
# Run node analyzer tests in isolation (minimal logging)
|
||||
if [ "${CLEAN_BEFORE_NODE_TESTS:-0}" = "1" ] && [ -x "$REPO_ROOT/scripts/cleanup-runner-space.sh" ]; then
|
||||
echo "[node-tests-isolated] Running cleanup to reclaim disk space..."
|
||||
"$REPO_ROOT/scripts/cleanup-runner-space.sh"
|
||||
fi
|
||||
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||
NUGET_PACKAGES="$REPO_ROOT/offline/packages" \
|
||||
dotnet test src/Scanner/StellaOps.Scanner.Node.slnf \
|
||||
--no-build \
|
||||
--settings "$REPO_ROOT/__Tests/node-isolated.runsettings" \
|
||||
--no-restore \
|
||||
--settings "$REPO_ROOT/src/Scanner/__Tests/node-isolated.runsettings" \
|
||||
--logger "console;verbosity=minimal" \
|
||||
/m:1
|
||||
|
||||
@@ -13,6 +13,8 @@ Provide language-agnostic collection, normalization, and scoring of reachability
|
||||
## Required Reading
|
||||
- `docs/modules/zastava/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/signals/unknowns-registry.md`
|
||||
- `docs/reachability/DELIVERY_GUIDE.md` (unknowns + runtime ingestion sections)
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
|
||||
@@ -32,13 +32,21 @@ public sealed class CallgraphDocument
|
||||
[BsonElement("nodes")]
|
||||
public List<CallgraphNode> Nodes { get; set; } = new();
|
||||
|
||||
[BsonElement("edges")]
|
||||
public List<CallgraphEdge> Edges { get; set; } = new();
|
||||
|
||||
[BsonElement("edges")]
|
||||
public List<CallgraphEdge> Edges { get; set; } = new();
|
||||
|
||||
[BsonElement("metadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
|
||||
[BsonElement("graphHash")]
|
||||
public string GraphHash { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("roots")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<CallgraphRoot>? Roots { get; set; }
|
||||
|
||||
[BsonElement("schemaVersion")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SchemaVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized callgraph edge.
|
||||
/// </summary>
|
||||
public sealed record CallgraphEdge(
|
||||
string SourceId,
|
||||
string TargetId,
|
||||
string Type);
|
||||
public sealed record CallgraphEdge(
|
||||
string SourceId,
|
||||
string TargetId,
|
||||
string Type,
|
||||
string? Purl = null,
|
||||
string? SymbolDigest = null,
|
||||
IReadOnlyList<string>? Candidates = null,
|
||||
double? Confidence = null,
|
||||
IReadOnlyList<string>? Evidence = null);
|
||||
|
||||
@@ -6,11 +6,13 @@ namespace StellaOps.Signals.Models;
|
||||
/// <summary>
|
||||
/// API request payload for callgraph ingestion.
|
||||
/// </summary>
|
||||
public sealed record CallgraphIngestRequest(
|
||||
[property: Required] string Language,
|
||||
[property: Required] string Component,
|
||||
[property: Required] string Version,
|
||||
[property: Required] string ArtifactContentType,
|
||||
[property: Required] string ArtifactFileName,
|
||||
[property: Required] string ArtifactContentBase64,
|
||||
IReadOnlyDictionary<string, string?>? Metadata);
|
||||
public sealed record CallgraphIngestRequest(
|
||||
[property: Required] string Language,
|
||||
[property: Required] string Component,
|
||||
[property: Required] string Version,
|
||||
[property: Required] string ArtifactContentType,
|
||||
[property: Required] string ArtifactFileName,
|
||||
[property: Required] string ArtifactContentBase64,
|
||||
IReadOnlyDictionary<string, string?>? Metadata,
|
||||
string? SchemaVersion = null,
|
||||
IReadOnlyDictionary<string, string?>? Analyzer = null);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Response returned after callgraph ingestion.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Response returned after callgraph ingestion.
|
||||
/// </summary>
|
||||
public sealed record CallgraphIngestResponse(
|
||||
string CallgraphId,
|
||||
string ArtifactPath,
|
||||
string ArtifactHash,
|
||||
string CasUri,
|
||||
string GraphHash,
|
||||
string ManifestCasUri);
|
||||
string ManifestCasUri,
|
||||
string SchemaVersion,
|
||||
int NodeCount,
|
||||
int EdgeCount,
|
||||
int RootCount);
|
||||
|
||||
@@ -17,6 +17,9 @@ public sealed class CallgraphManifest
|
||||
[JsonPropertyName("graphHash")]
|
||||
public string GraphHash { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactHash")]
|
||||
public string ArtifactHash { get; set; } = string.Empty;
|
||||
|
||||
@@ -26,6 +29,9 @@ public sealed class CallgraphManifest
|
||||
[JsonPropertyName("edgeCount")]
|
||||
public int EdgeCount { get; set; }
|
||||
|
||||
[JsonPropertyName("rootCount")]
|
||||
public int RootCount { get; set; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized callgraph node.
|
||||
/// </summary>
|
||||
public sealed record CallgraphNode(
|
||||
string Id,
|
||||
string Name,
|
||||
string Kind,
|
||||
string? Namespace,
|
||||
string? File,
|
||||
int? Line);
|
||||
public sealed record CallgraphNode(
|
||||
string Id,
|
||||
string Name,
|
||||
string Kind,
|
||||
string? Namespace,
|
||||
string? File,
|
||||
int? Line,
|
||||
string? Purl = null,
|
||||
string? SymbolDigest = null,
|
||||
string? BuildId = null,
|
||||
string? Language = null,
|
||||
IReadOnlyList<string>? Evidence = null,
|
||||
IReadOnlyDictionary<string, string?>? Analyzer = null,
|
||||
string? CodeId = null);
|
||||
|
||||
9
src/Signals/StellaOps.Signals/Models/CallgraphRoot.cs
Normal file
9
src/Signals/StellaOps.Signals/Models/CallgraphRoot.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Synthetic or declared graph root (e.g., main, init_array constructor).
|
||||
/// </summary>
|
||||
public sealed record CallgraphRoot(
|
||||
string Id,
|
||||
string Phase,
|
||||
string? Source = null);
|
||||
@@ -110,6 +110,18 @@ public sealed class RuntimeFactDocument
|
||||
[BsonIgnoreIfNull]
|
||||
public string? CodeId { get; set; }
|
||||
|
||||
[BsonElement("symbolDigest")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SymbolDigest { get; set; }
|
||||
|
||||
[BsonElement("purl")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Purl { get; set; }
|
||||
|
||||
[BsonElement("buildId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? BuildId { get; set; }
|
||||
|
||||
[BsonElement("loaderBase")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? LoaderBase { get; set; }
|
||||
@@ -137,6 +149,10 @@ public sealed class RuntimeFactDocument
|
||||
[BsonElement("hitCount")]
|
||||
public int HitCount { get; set; }
|
||||
|
||||
[BsonElement("observedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ObservedAt { get; set; }
|
||||
|
||||
[BsonElement("metadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
|
||||
@@ -24,6 +24,12 @@ public sealed class RuntimeFactEvent
|
||||
|
||||
public string? CodeId { get; set; }
|
||||
|
||||
public string? SymbolDigest { get; set; }
|
||||
|
||||
public string? Purl { get; set; }
|
||||
|
||||
public string? BuildId { get; set; }
|
||||
|
||||
public string? LoaderBase { get; set; }
|
||||
|
||||
public int? ProcessId { get; set; }
|
||||
@@ -38,6 +44,8 @@ public sealed class RuntimeFactEvent
|
||||
|
||||
public int HitCount { get; set; } = 1;
|
||||
|
||||
public DateTimeOffset? ObservedAt { get; set; }
|
||||
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,15 @@ public sealed class RuntimeFactsStreamMetadata
|
||||
[FromQuery(Name = "version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
[FromQuery(Name = "purl")]
|
||||
public string? Purl { get; set; }
|
||||
|
||||
public ReachabilitySubject ToSubject() => new()
|
||||
{
|
||||
ScanId = ScanId,
|
||||
ImageDigest = ImageDigest,
|
||||
Component = Component,
|
||||
Version = Version
|
||||
Version = Version,
|
||||
// purl is kept at runtime-fact level; subject stays coarse.
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace StellaOps.Signals.Options;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAPI exposure options.
|
||||
/// </summary>
|
||||
public sealed class SignalsOpenApiOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to expose OpenAPI description.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// UI path if enabled (e.g., /signals/swagger).
|
||||
/// </summary>
|
||||
public string UiPath { get; set; } = "/signals/swagger";
|
||||
|
||||
/// <summary>
|
||||
/// JSON path if enabled (e.g., /signals/openapi.json).
|
||||
/// </summary>
|
||||
public string JsonPath { get; set; } = "/signals/openapi.json";
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(UiPath)) UiPath = "/signals/swagger";
|
||||
if (string.IsNullOrWhiteSpace(JsonPath)) JsonPath = "/signals/openapi.json";
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,11 @@ public sealed class SignalsOptions
|
||||
/// Cache configuration.
|
||||
/// </summary>
|
||||
public SignalsCacheOptions Cache { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// OpenAPI exposure (if enabled).
|
||||
/// </summary>
|
||||
public SignalsOpenApiOptions OpenApi { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Validates configured options.
|
||||
@@ -51,5 +56,6 @@ public sealed class SignalsOptions
|
||||
AirGap.Validate();
|
||||
Scoring.Validate();
|
||||
Cache.Validate();
|
||||
OpenApi.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Result produced by a callgraph parser.
|
||||
/// </summary>
|
||||
public sealed record CallgraphParseResult(
|
||||
IReadOnlyList<CallgraphNode> Nodes,
|
||||
IReadOnlyList<CallgraphEdge> Edges,
|
||||
string FormatVersion);
|
||||
/// <summary>
|
||||
/// Result produced by a callgraph parser.
|
||||
/// </summary>
|
||||
public sealed record CallgraphParseResult(
|
||||
IReadOnlyList<CallgraphNode> Nodes,
|
||||
IReadOnlyList<CallgraphEdge> Edges,
|
||||
IReadOnlyList<CallgraphRoot> Roots,
|
||||
string FormatVersion,
|
||||
string SchemaVersion,
|
||||
IReadOnlyDictionary<string, string?>? Analyzer = null);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Parsing;
|
||||
@@ -74,7 +74,14 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
Kind: nodeElement.TryGetProperty("kind", out var kindEl) ? kindEl.GetString() ?? "function" : "function",
|
||||
Namespace: nodeElement.TryGetProperty("namespace", out var nsEl) ? nsEl.GetString() : null,
|
||||
File: nodeElement.TryGetProperty("file", out var fileEl) ? fileEl.GetString() : null,
|
||||
Line: nodeElement.TryGetProperty("line", out var lineEl) && lineEl.ValueKind == JsonValueKind.Number ? lineEl.GetInt32() : null));
|
||||
Line: nodeElement.TryGetProperty("line", out var lineEl) && lineEl.ValueKind == JsonValueKind.Number ? lineEl.GetInt32() : null,
|
||||
Purl: GetString(nodeElement, "purl"),
|
||||
SymbolDigest: GetString(nodeElement, "symbol_digest", "symbolDigest"),
|
||||
BuildId: GetString(nodeElement, "build_id", "buildId"),
|
||||
Language: GetString(nodeElement, "language"),
|
||||
Evidence: GetStringArray(nodeElement, "evidence"),
|
||||
Analyzer: GetStringDictionary(nodeElement, "analyzer"),
|
||||
CodeId: GetString(nodeElement, "code_id", "codeId")));
|
||||
}
|
||||
|
||||
var edges = new List<CallgraphEdge>();
|
||||
@@ -90,7 +97,15 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
}
|
||||
|
||||
var type = edgeElement.TryGetProperty("type", out var typeEl) ? typeEl.GetString() ?? "call" : "call";
|
||||
edges.Add(new CallgraphEdge(source.Trim(), target.Trim(), type));
|
||||
edges.Add(new CallgraphEdge(
|
||||
source.Trim(),
|
||||
target.Trim(),
|
||||
type,
|
||||
Purl: GetString(edgeElement, "purl"),
|
||||
SymbolDigest: GetString(edgeElement, "symbol_digest", "symbolDigest"),
|
||||
Candidates: GetStringArray(edgeElement, "candidates"),
|
||||
Confidence: GetNullableDouble(edgeElement, "confidence"),
|
||||
Evidence: GetStringArray(edgeElement, "evidence")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +113,19 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
? versionEl.GetString()
|
||||
: null;
|
||||
|
||||
result = new CallgraphParseResult(nodes, edges, string.IsNullOrWhiteSpace(formatVersion) ? "1.0" : formatVersion!.Trim());
|
||||
var schemaVersion = root.TryGetProperty("schema_version", out var schemaEl)
|
||||
? schemaEl.GetString()
|
||||
: formatVersion;
|
||||
|
||||
var analyzer = GetStringDictionary(root, "analyzer") ?? GetStringDictionary(root, "toolchain");
|
||||
|
||||
result = new CallgraphParseResult(
|
||||
nodes,
|
||||
edges,
|
||||
Array.Empty<CallgraphRoot>(),
|
||||
string.IsNullOrWhiteSpace(formatVersion) ? "1.0" : formatVersion!.Trim(),
|
||||
string.IsNullOrWhiteSpace(schemaVersion) ? "1.0" : schemaVersion!.Trim(),
|
||||
analyzer);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -128,7 +155,14 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
Kind: nodeElement.TryGetProperty("kind", out var kindEl) ? kindEl.GetString() ?? "function" : "function",
|
||||
Namespace: nodeElement.TryGetProperty("namespace", out var nsEl) ? nsEl.GetString() : null,
|
||||
File: nodeElement.TryGetProperty("file", out var fileEl) ? fileEl.GetString() : null,
|
||||
Line: nodeElement.TryGetProperty("line", out var lineEl) && lineEl.ValueKind == JsonValueKind.Number ? lineEl.GetInt32() : null));
|
||||
Line: nodeElement.TryGetProperty("line", out var lineEl) && lineEl.ValueKind == JsonValueKind.Number ? lineEl.GetInt32() : null,
|
||||
Purl: GetString(nodeElement, "purl"),
|
||||
SymbolDigest: GetString(nodeElement, "symbol_digest", "symbolDigest"),
|
||||
BuildId: GetString(nodeElement, "build_id", "buildId"),
|
||||
Language: GetString(nodeElement, "language"),
|
||||
Evidence: GetStringArray(nodeElement, "evidence"),
|
||||
Analyzer: GetStringDictionary(nodeElement, "analyzer"),
|
||||
CodeId: GetString(nodeElement, "code_id", "codeId")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +189,15 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
? typeEl.GetString() ?? "call"
|
||||
: "call";
|
||||
|
||||
edges.Add(new CallgraphEdge(from.Trim(), to.Trim(), kind));
|
||||
edges.Add(new CallgraphEdge(
|
||||
from.Trim(),
|
||||
to.Trim(),
|
||||
kind,
|
||||
Purl: GetString(edgeElement, "purl"),
|
||||
SymbolDigest: GetString(edgeElement, "symbol_digest", "symbolDigest"),
|
||||
Candidates: GetStringArray(edgeElement, "candidates"),
|
||||
Confidence: GetNullableDouble(edgeElement, "confidence"),
|
||||
Evidence: GetStringArray(edgeElement, "evidence")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +213,7 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
|
||||
foreach (var nodeId in uniqueNodeIds)
|
||||
{
|
||||
nodes.Add(new CallgraphNode(nodeId, nodeId, "function", null, null, null));
|
||||
nodes.Add(new CallgraphNode(nodeId, nodeId, "function", null, null, null, null, null, null, null, null, null, null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,8 +221,105 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
? schemaEl.GetString()
|
||||
: "1.0";
|
||||
|
||||
result = new CallgraphParseResult(nodes, edges, string.IsNullOrWhiteSpace(schemaVersion) ? "1.0" : schemaVersion!.Trim());
|
||||
var roots = ParseRoots(root);
|
||||
|
||||
var analyzer = GetStringDictionary(root, "analyzer") ?? GetStringDictionary(root, "toolchain");
|
||||
|
||||
result = new CallgraphParseResult(
|
||||
nodes,
|
||||
edges,
|
||||
roots,
|
||||
string.IsNullOrWhiteSpace(schemaVersion) ? "1.0" : schemaVersion!.Trim(),
|
||||
string.IsNullOrWhiteSpace(schemaVersion) ? "1.0" : schemaVersion!.Trim(),
|
||||
analyzer);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CallgraphRoot> ParseRoots(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("roots", out var rootsEl) || rootsEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<CallgraphRoot>();
|
||||
}
|
||||
|
||||
var roots = new List<CallgraphRoot>(rootsEl.GetArrayLength());
|
||||
foreach (var r in rootsEl.EnumerateArray())
|
||||
{
|
||||
var id = GetString(r, "id");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var phase = GetString(r, "phase") ?? "runtime";
|
||||
var source = GetString(r, "source");
|
||||
roots.Add(new CallgraphRoot(id.Trim(), phase.Trim(), source));
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string name1, string? name2 = null)
|
||||
{
|
||||
if (element.TryGetProperty(name1, out var v1) && v1.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return v1.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(name2) && element.TryGetProperty(name2!, out var v2) && v2.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return v2.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? GetStringArray(JsonElement element, string name)
|
||||
{
|
||||
if (!element.TryGetProperty(name, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var list = new List<string>(arr.GetArrayLength());
|
||||
foreach (var item in arr.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
list.Add(item.GetString()!);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string?>? GetStringDictionary(JsonElement element, string name)
|
||||
{
|
||||
if (!element.TryGetProperty(name, out var obj) || obj.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
dict[prop.Name] = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() : prop.Value.ToString();
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static double? GetNullableDouble(JsonElement element, string name)
|
||||
{
|
||||
if (!element.TryGetProperty(name, out var val))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return val.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when val.TryGetDouble(out var d) => d,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
var artifactBytes = Convert.FromBase64String(request.ArtifactContentBase64);
|
||||
await using var parseStream = new MemoryStream(artifactBytes, writable: false);
|
||||
var parseResult = await parser.ParseAsync(parseStream, cancellationToken).ConfigureAwait(false);
|
||||
var schemaVersion = !string.IsNullOrWhiteSpace(request.SchemaVersion)
|
||||
? request.SchemaVersion!
|
||||
: parseResult.SchemaVersion;
|
||||
var analyzerMeta = request.Analyzer ?? parseResult.Analyzer;
|
||||
|
||||
parseStream.Position = 0;
|
||||
var artifactHash = ComputeSha256(artifactBytes);
|
||||
@@ -71,8 +75,10 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
Version = request.Version,
|
||||
ArtifactHash = artifactHash,
|
||||
GraphHash = graphHash,
|
||||
SchemaVersion = schemaVersion,
|
||||
NodeCount = parseResult.Nodes.Count,
|
||||
EdgeCount = parseResult.Edges.Count,
|
||||
RootCount = parseResult.Roots.Count,
|
||||
CreatedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
@@ -95,14 +101,15 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Language = parser.Language,
|
||||
Component = request.Component,
|
||||
Version = request.Version,
|
||||
Nodes = new List<CallgraphNode>(parseResult.Nodes),
|
||||
Edges = new List<CallgraphEdge>(parseResult.Edges),
|
||||
Metadata = request.Metadata is null
|
||||
? null
|
||||
: new Dictionary<string, string?>(request.Metadata, StringComparer.OrdinalIgnoreCase),
|
||||
Language = parser.Language,
|
||||
Component = request.Component,
|
||||
Version = request.Version,
|
||||
Nodes = new List<CallgraphNode>(parseResult.Nodes),
|
||||
Edges = new List<CallgraphEdge>(parseResult.Edges),
|
||||
Roots = new List<CallgraphRoot>(parseResult.Roots),
|
||||
Metadata = request.Metadata is null
|
||||
? null
|
||||
: new Dictionary<string, string?>(request.Metadata, StringComparer.OrdinalIgnoreCase),
|
||||
Artifact = new CallgraphArtifactMetadata
|
||||
{
|
||||
Path = artifactMetadata.Path,
|
||||
@@ -119,7 +126,16 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
|
||||
document.Metadata ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
document.Metadata["formatVersion"] = parseResult.FormatVersion;
|
||||
document.Metadata["schemaVersion"] = schemaVersion;
|
||||
if (analyzerMeta is not null)
|
||||
{
|
||||
foreach (var kv in analyzerMeta)
|
||||
{
|
||||
document.Metadata[$"analyzer.{kv.Key}"] = kv.Value;
|
||||
}
|
||||
}
|
||||
document.GraphHash = graphHash;
|
||||
document.SchemaVersion = schemaVersion;
|
||||
|
||||
document = await repository.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -138,7 +154,11 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
document.Artifact.Hash,
|
||||
document.Artifact.CasUri,
|
||||
graphHash,
|
||||
document.Artifact.ManifestCasUri);
|
||||
document.Artifact.ManifestCasUri,
|
||||
schemaVersion,
|
||||
document.Nodes.Count,
|
||||
document.Edges.Count,
|
||||
document.Roots?.Count ?? 0);
|
||||
}
|
||||
|
||||
private static void ValidateRequest(CallgraphIngestRequest request)
|
||||
@@ -186,18 +206,73 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
private static string ComputeGraphHash(CallgraphParseResult result)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.Append("schema|").Append(result.SchemaVersion).AppendLine();
|
||||
|
||||
foreach (var node in result.Nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(node.Id).Append('|').Append(node.Name).AppendLine();
|
||||
builder
|
||||
.Append(node.Id).Append('|')
|
||||
.Append(node.Name).Append('|')
|
||||
.Append(node.Kind).Append('|')
|
||||
.Append(node.Namespace).Append('|')
|
||||
.Append(node.File).Append('|')
|
||||
.Append(node.Line?.ToString() ?? string.Empty).Append('|')
|
||||
.Append(node.Purl).Append('|')
|
||||
.Append(node.SymbolDigest).Append('|')
|
||||
.Append(node.BuildId).Append('|')
|
||||
.Append(node.CodeId).Append('|')
|
||||
.Append(node.Language).Append('|')
|
||||
.Append(Join(node.Evidence)).Append('|')
|
||||
.Append(JoinDict(node.Analyzer))
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
foreach (var edge in result.Edges.OrderBy(e => e.SourceId, StringComparer.Ordinal).ThenBy(e => e.TargetId, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(edge.SourceId).Append("->").Append(edge.TargetId).AppendLine();
|
||||
builder
|
||||
.Append(edge.SourceId).Append("->").Append(edge.TargetId).Append('|')
|
||||
.Append(edge.Type).Append('|')
|
||||
.Append(edge.Purl).Append('|')
|
||||
.Append(edge.SymbolDigest).Append('|')
|
||||
.Append(edge.Confidence?.ToString() ?? string.Empty).Append('|')
|
||||
.Append(Join(edge.Candidates)).Append('|')
|
||||
.Append(Join(edge.Evidence))
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
foreach (var root in result.Roots.OrderBy(r => r.Id, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("root|").Append(root.Id).Append('|').Append(root.Phase).Append('|').Append(root.Source).AppendLine();
|
||||
}
|
||||
|
||||
return ComputeSha256(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
}
|
||||
|
||||
private static string Join(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join(',', values.OrderBy(v => v, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private static string JoinDict(IReadOnlyDictionary<string, string?>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var ordered = new StringBuilder();
|
||||
foreach (var kv in values.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
ordered.Append(kv.Key).Append('=').Append(kv.Value).Append(';');
|
||||
}
|
||||
return ordered.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -125,7 +125,13 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = new RuntimeFactKey(evt.SymbolId.Trim(), evt.CodeId?.Trim(), evt.LoaderBase?.Trim());
|
||||
var key = new RuntimeFactKey(
|
||||
evt.SymbolId.Trim(),
|
||||
evt.CodeId?.Trim(),
|
||||
evt.LoaderBase?.Trim(),
|
||||
evt.Purl?.Trim(),
|
||||
evt.SymbolDigest?.Trim(),
|
||||
evt.BuildId?.Trim());
|
||||
if (!map.TryGetValue(key, out var document))
|
||||
{
|
||||
document = new RuntimeFactDocument
|
||||
@@ -133,11 +139,15 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
SymbolId = key.SymbolId,
|
||||
CodeId = key.CodeId,
|
||||
LoaderBase = key.LoaderBase,
|
||||
Purl = key.Purl,
|
||||
SymbolDigest = key.SymbolDigest,
|
||||
BuildId = key.BuildId,
|
||||
ProcessId = evt.ProcessId,
|
||||
ProcessName = Normalize(evt.ProcessName),
|
||||
SocketAddress = Normalize(evt.SocketAddress),
|
||||
ContainerId = Normalize(evt.ContainerId),
|
||||
EvidenceUri = Normalize(evt.EvidenceUri),
|
||||
ObservedAt = evt.ObservedAt,
|
||||
Metadata = evt.Metadata != null
|
||||
? new Dictionary<string, string?>(evt.Metadata, StringComparer.Ordinal)
|
||||
: null
|
||||
@@ -155,6 +165,10 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
}
|
||||
|
||||
document.HitCount = Math.Clamp(document.HitCount + Math.Max(evt.HitCount, 1), 1, int.MaxValue);
|
||||
document.Purl ??= Normalize(evt.Purl);
|
||||
document.SymbolDigest ??= Normalize(evt.SymbolDigest);
|
||||
document.BuildId ??= Normalize(evt.BuildId);
|
||||
document.ObservedAt ??= evt.ObservedAt;
|
||||
}
|
||||
|
||||
return map.Values.ToList();
|
||||
@@ -194,18 +208,22 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
{
|
||||
foreach (var fact in existing)
|
||||
{
|
||||
var key = new RuntimeFactKey(fact.SymbolId, fact.CodeId, fact.LoaderBase);
|
||||
var key = new RuntimeFactKey(fact.SymbolId, fact.CodeId, fact.LoaderBase, fact.Purl, fact.SymbolDigest, fact.BuildId);
|
||||
map[key] = new RuntimeFactDocument
|
||||
{
|
||||
SymbolId = fact.SymbolId,
|
||||
CodeId = fact.CodeId,
|
||||
LoaderBase = fact.LoaderBase,
|
||||
Purl = fact.Purl,
|
||||
SymbolDigest = fact.SymbolDigest,
|
||||
BuildId = fact.BuildId,
|
||||
ProcessId = fact.ProcessId,
|
||||
ProcessName = fact.ProcessName,
|
||||
SocketAddress = fact.SocketAddress,
|
||||
ContainerId = fact.ContainerId,
|
||||
EvidenceUri = fact.EvidenceUri,
|
||||
HitCount = fact.HitCount,
|
||||
ObservedAt = fact.ObservedAt,
|
||||
Metadata = fact.Metadata is null
|
||||
? null
|
||||
: new Dictionary<string, string?>(fact.Metadata, StringComparer.Ordinal)
|
||||
@@ -217,7 +235,7 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
{
|
||||
foreach (var fact in incoming)
|
||||
{
|
||||
var key = new RuntimeFactKey(fact.SymbolId, fact.CodeId, fact.LoaderBase);
|
||||
var key = new RuntimeFactKey(fact.SymbolId, fact.CodeId, fact.LoaderBase, fact.Purl, fact.SymbolDigest, fact.BuildId);
|
||||
if (!map.TryGetValue(key, out var existingFact))
|
||||
{
|
||||
map[key] = new RuntimeFactDocument
|
||||
@@ -225,12 +243,16 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
SymbolId = fact.SymbolId,
|
||||
CodeId = fact.CodeId,
|
||||
LoaderBase = fact.LoaderBase,
|
||||
Purl = fact.Purl,
|
||||
SymbolDigest = fact.SymbolDigest,
|
||||
BuildId = fact.BuildId,
|
||||
ProcessId = fact.ProcessId,
|
||||
ProcessName = fact.ProcessName,
|
||||
SocketAddress = fact.SocketAddress,
|
||||
ContainerId = fact.ContainerId,
|
||||
EvidenceUri = fact.EvidenceUri,
|
||||
HitCount = fact.HitCount,
|
||||
ObservedAt = fact.ObservedAt,
|
||||
Metadata = fact.Metadata is null
|
||||
? null
|
||||
: new Dictionary<string, string?>(fact.Metadata, StringComparer.Ordinal)
|
||||
@@ -244,6 +266,10 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
existingFact.SocketAddress ??= fact.SocketAddress;
|
||||
existingFact.ContainerId ??= fact.ContainerId;
|
||||
existingFact.EvidenceUri ??= fact.EvidenceUri;
|
||||
existingFact.Purl ??= fact.Purl;
|
||||
existingFact.SymbolDigest ??= fact.SymbolDigest;
|
||||
existingFact.BuildId ??= fact.BuildId;
|
||||
existingFact.ObservedAt ??= fact.ObservedAt;
|
||||
if (fact.Metadata != null && fact.Metadata.Count > 0)
|
||||
{
|
||||
existingFact.Metadata ??= new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
@@ -326,7 +352,7 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private readonly record struct RuntimeFactKey(string SymbolId, string? CodeId, string? LoaderBase);
|
||||
private readonly record struct RuntimeFactKey(string SymbolId, string? CodeId, string? LoaderBase, string? Purl, string? SymbolDigest, string? BuildId);
|
||||
|
||||
private sealed class RuntimeFactKeyComparer : IEqualityComparer<RuntimeFactKey>
|
||||
{
|
||||
@@ -335,7 +361,10 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
public bool Equals(RuntimeFactKey x, RuntimeFactKey y) =>
|
||||
string.Equals(x.SymbolId, y.SymbolId, StringComparison.Ordinal) &&
|
||||
string.Equals(x.CodeId, y.CodeId, StringComparison.Ordinal) &&
|
||||
string.Equals(x.LoaderBase, y.LoaderBase, StringComparison.Ordinal);
|
||||
string.Equals(x.LoaderBase, y.LoaderBase, StringComparison.Ordinal) &&
|
||||
string.Equals(x.Purl, y.Purl, StringComparison.Ordinal) &&
|
||||
string.Equals(x.SymbolDigest, y.SymbolDigest, StringComparison.Ordinal) &&
|
||||
string.Equals(x.BuildId, y.BuildId, StringComparison.Ordinal);
|
||||
|
||||
public int GetHashCode(RuntimeFactKey obj)
|
||||
{
|
||||
@@ -351,6 +380,21 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
hash.Add(obj.LoaderBase, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
if (obj.Purl is not null)
|
||||
{
|
||||
hash.Add(obj.Purl, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
if (obj.SymbolDigest is not null)
|
||||
{
|
||||
hash.Add(obj.SymbolDigest, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
if (obj.BuildId is not null)
|
||||
{
|
||||
hash.Add(obj.BuildId, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ public class CallgraphIngestionTests : IClassFixture<SignalsTestFactory>
|
||||
Assert.False(string.IsNullOrWhiteSpace(body.CasUri));
|
||||
Assert.False(string.IsNullOrWhiteSpace(body.GraphHash));
|
||||
Assert.False(string.IsNullOrWhiteSpace(body.ManifestCasUri));
|
||||
Assert.False(string.IsNullOrWhiteSpace(body.SchemaVersion));
|
||||
Assert.True(body.NodeCount >= 0);
|
||||
Assert.True(body.EdgeCount >= 0);
|
||||
Assert.True(body.RootCount >= 0);
|
||||
Assert.Equal(body.GraphHash, doc.GraphHash);
|
||||
|
||||
var manifestResponse = await client.GetAsync($"/signals/callgraphs/{body.CallgraphId}/manifest");
|
||||
@@ -63,6 +67,7 @@ public class CallgraphIngestionTests : IClassFixture<SignalsTestFactory>
|
||||
var manifest = await manifestResponse.Content.ReadFromJsonAsync<CallgraphManifest>();
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal(body.GraphHash, manifest!.GraphHash);
|
||||
Assert.Equal(body.SchemaVersion, manifest.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,112 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Signals.Tests.TestInfrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
public class SignalsApiTests : IClassFixture<SignalsTestFactory>
|
||||
{
|
||||
private readonly SignalsTestFactory factory;
|
||||
|
||||
public SignalsApiTests(SignalsTestFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Healthz_ReturnsOk()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/healthz");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_ReturnsOk()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/readyz");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("ready", payload!["status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_WithoutScopeHeader_ReturnsUnauthorized()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/signals/ping");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_WithMissingScope_ReturnsForbidden()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
|
||||
var response = await client.GetAsync("/signals/ping");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_WithReadScope_ReturnsNoContent()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "signals:read");
|
||||
var response = await client.GetAsync("/signals/ping");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_WithFallbackDisabled_ReturnsUnauthorized()
|
||||
{
|
||||
using var app = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Signals:Authority:AllowAnonymousFallback"] = "false"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
var response = await client.GetAsync("/signals/ping");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_WithReadScope_ReturnsOk()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "signals:read");
|
||||
var response = await client.GetAsync("/signals/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("signals", payload!["service"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_WithMissingScope_ReturnsForbidden()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
|
||||
var response = await client.GetAsync("/signals/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Tests.TestInfrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
public class SignalsApiTests : IClassFixture<SignalsTestFactory>
|
||||
{
|
||||
private readonly SignalsTestFactory factory;
|
||||
|
||||
public SignalsApiTests(SignalsTestFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Callgraph_Ingest_Response_Includes_Extended_Fields()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write signals:read");
|
||||
|
||||
var req = CallgraphIngestionTests.CreateRequest("java", component: "api-test", version: "1.2.3");
|
||||
var res = await client.PostAsJsonAsync("/signals/callgraphs", req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, res.StatusCode);
|
||||
var body = await res.Content.ReadFromJsonAsync<CallgraphIngestResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(string.IsNullOrWhiteSpace(body!.SchemaVersion));
|
||||
Assert.True(body.NodeCount >= 0);
|
||||
Assert.True(body.EdgeCount >= 0);
|
||||
Assert.True(body.RootCount >= 0);
|
||||
|
||||
// Fetch manifest and ensure schemaVersion matches response
|
||||
var manifestRes = await client.GetAsync($"/signals/callgraphs/{body.CallgraphId}/manifest");
|
||||
Assert.Equal(HttpStatusCode.OK, manifestRes.StatusCode);
|
||||
var manifest = await manifestRes.Content.ReadFromJsonAsync<CallgraphManifest>(new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal(body.SchemaVersion, manifest!.SchemaVersion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,23 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user