feat: add security sink detection patterns for JavaScript/TypeScript

- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
This commit is contained in:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -0,0 +1,199 @@
// -----------------------------------------------------------------------------
// AuditBundleManifest.cs
// Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI)
// Task: REPLAY-001 - Define audit bundle manifest schema
// Description: Defines the manifest schema for self-contained audit bundles.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.AuditPack.Models;
/// <summary>
/// Manifest for a self-contained audit bundle that enables offline replay.
/// Contains all input hashes required for deterministic verdict reproduction.
/// </summary>
public sealed record AuditBundleManifest
{
/// <summary>
/// Unique identifier for this audit bundle.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// Schema version for forward compatibility.
/// </summary>
public string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Human-readable name for this bundle.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// UTC timestamp when bundle was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Scan identifier this bundle was created from.
/// </summary>
public required string ScanId { get; init; }
/// <summary>
/// Image reference that was scanned.
/// </summary>
public required string ImageRef { get; init; }
/// <summary>
/// Image digest (sha256:...).
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// Merkle root of all bundle contents for integrity verification.
/// </summary>
public required string MerkleRoot { get; init; }
/// <summary>
/// Digest hashes for all inputs used in the scan.
/// </summary>
public required InputDigests Inputs { get; init; }
/// <summary>
/// Digest of the verdict produced by the scan.
/// </summary>
public required string VerdictDigest { get; init; }
/// <summary>
/// Decision from the verdict (pass, warn, block).
/// </summary>
public required string Decision { get; init; }
/// <summary>
/// Inventory of files in the bundle.
/// </summary>
public required ImmutableArray<BundleFileEntry> Files { get; init; }
/// <summary>
/// Total size of all files in bytes.
/// </summary>
public long TotalSizeBytes { get; init; }
/// <summary>
/// Time anchor for replay time context.
/// </summary>
public TimeAnchor? TimeAnchor { get; init; }
/// <summary>
/// Signature algorithm used for signing.
/// </summary>
public string? SignatureAlgorithm { get; init; }
/// <summary>
/// Key ID used for signing.
/// </summary>
public string? SigningKeyId { get; init; }
}
/// <summary>
/// Input digest hashes for deterministic replay.
/// These must match exactly for replay to succeed.
/// </summary>
public sealed record InputDigests
{
/// <summary>
/// SHA-256 digest of the SBOM document.
/// </summary>
public required string SbomDigest { get; init; }
/// <summary>
/// SHA-256 digest of the advisory feeds snapshot.
/// </summary>
public required string FeedsDigest { get; init; }
/// <summary>
/// SHA-256 digest of the policy bundle.
/// </summary>
public required string PolicyDigest { get; init; }
/// <summary>
/// SHA-256 digest of the VEX statements.
/// </summary>
public string? VexDigest { get; init; }
/// <summary>
/// SHA-256 digest of the scoring rules.
/// </summary>
public string? ScoringDigest { get; init; }
/// <summary>
/// SHA-256 digest of the trust roots.
/// </summary>
public string? TrustRootsDigest { get; init; }
}
/// <summary>
/// Entry for a file in the bundle.
/// </summary>
public sealed record BundleFileEntry
{
/// <summary>
/// Relative path within the bundle.
/// </summary>
public required string RelativePath { get; init; }
/// <summary>
/// SHA-256 digest of the file.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Size of the file in bytes.
/// </summary>
public required long SizeBytes { get; init; }
/// <summary>
/// Type of content.
/// </summary>
public required BundleContentType ContentType { get; init; }
}
/// <summary>
/// Type of content in the bundle.
/// </summary>
public enum BundleContentType
{
Manifest,
Signature,
Sbom,
Feeds,
Policy,
Vex,
Verdict,
ProofBundle,
TrustRoot,
TimeAnchor,
Other
}
/// <summary>
/// Time anchor for establishing evaluation time.
/// </summary>
public sealed record TimeAnchor
{
/// <summary>
/// Anchor timestamp.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Source of the time anchor (local, roughtime, rfc3161).
/// </summary>
public required string Source { get; init; }
/// <summary>
/// Digest of the time anchor token.
/// </summary>
public string? TokenDigest { get; init; }
}

View File

@@ -1,4 +1,6 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;

View File

@@ -114,11 +114,21 @@ public sealed class DeltaSigningService : IDeltaSigningService
private static string ComputeSignature(byte[] pae, SigningOptions options)
{
return options.Algorithm switch
return ComputeSignatureCore(pae, options.Algorithm, options.SecretBase64);
}
private static string ComputeSignature(byte[] pae, VerificationOptions options)
{
return ComputeSignatureCore(pae, options.Algorithm, options.SecretBase64);
}
private static string ComputeSignatureCore(byte[] pae, SigningAlgorithm algorithm, string? secretBase64)
{
return algorithm switch
{
SigningAlgorithm.HmacSha256 => ComputeHmac(pae, options.SecretBase64),
SigningAlgorithm.HmacSha256 => ComputeHmac(pae, secretBase64),
SigningAlgorithm.Sha256 => Convert.ToBase64String(SHA256.HashData(pae)),
_ => throw new InvalidOperationException($"Unsupported signing algorithm: {options.Algorithm}")
_ => throw new InvalidOperationException($"Unsupported signing algorithm: {algorithm}")
};
}

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Json.Schema.Net" Version="7.2.0" />
<PackageReference Include="JsonSchema.Net" Version="7.2.0" />
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
</ItemGroup>

View File

@@ -0,0 +1,252 @@
using Microsoft.Extensions.Logging;
using StellaOps.Metrics.Kpi.Repositories;
namespace StellaOps.Metrics.Kpi;
/// <summary>
/// Interface for collecting quality KPIs.
/// </summary>
public interface IKpiCollector
{
/// <summary>
/// Collects all quality KPIs for a given period.
/// </summary>
Task<TriageQualityKpis> CollectAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId = null,
CancellationToken ct = default);
/// <summary>
/// Records a reachability result for real-time tracking.
/// </summary>
Task RecordReachabilityResultAsync(Guid findingId, string state, CancellationToken ct);
/// <summary>
/// Records a runtime observation for real-time tracking.
/// </summary>
Task RecordRuntimeObservationAsync(Guid findingId, string posture, CancellationToken ct);
/// <summary>
/// Records a verdict for real-time tracking.
/// </summary>
Task RecordVerdictAsync(Guid verdictId, bool hasReasonSteps, bool hasProofPointer, CancellationToken ct);
/// <summary>
/// Records a replay attempt for real-time tracking.
/// </summary>
Task RecordReplayAttemptAsync(Guid attestationId, bool success, string? failureReason, CancellationToken ct);
}
/// <summary>
/// Collects quality KPIs for explainable triage.
/// </summary>
public sealed class KpiCollector : IKpiCollector
{
private readonly IKpiRepository _repository;
private readonly IFindingRepository _findingRepo;
private readonly IVerdictRepository _verdictRepo;
private readonly IReplayRepository _replayRepo;
private readonly ILogger<KpiCollector> _logger;
public KpiCollector(
IKpiRepository repository,
IFindingRepository findingRepo,
IVerdictRepository verdictRepo,
IReplayRepository replayRepo,
ILogger<KpiCollector> logger)
{
_repository = repository;
_findingRepo = findingRepo;
_verdictRepo = verdictRepo;
_replayRepo = replayRepo;
_logger = logger;
}
/// <inheritdoc />
public async Task<TriageQualityKpis> CollectAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId = null,
CancellationToken ct = default)
{
_logger.LogDebug("Collecting KPIs for period {Start} to {End}, tenant {Tenant}",
start, end, tenantId ?? "global");
var reachability = await CollectReachabilityKpisAsync(start, end, tenantId, ct);
var runtime = await CollectRuntimeKpisAsync(start, end, tenantId, ct);
var explainability = await CollectExplainabilityKpisAsync(start, end, tenantId, ct);
var replay = await CollectReplayKpisAsync(start, end, tenantId, ct);
var unknowns = await CollectUnknownBudgetKpisAsync(start, end, tenantId, ct);
var operational = await CollectOperationalKpisAsync(start, end, tenantId, ct);
return new TriageQualityKpis
{
PeriodStart = start,
PeriodEnd = end,
TenantId = tenantId,
Reachability = reachability,
Runtime = runtime,
Explainability = explainability,
Replay = replay,
Unknowns = unknowns,
Operational = operational
};
}
private async Task<ReachabilityKpis> CollectReachabilityKpisAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct)
{
var findings = await _findingRepo.GetInPeriodAsync(start, end, tenantId, ct);
var byState = findings
.GroupBy(f => f.ReachabilityState ?? "Unknown")
.ToDictionary(g => g.Key, g => g.Count());
var withKnown = findings.Count(f =>
f.ReachabilityState is not null and not "Unknown");
return new ReachabilityKpis
{
TotalFindings = findings.Count,
WithKnownReachability = withKnown,
ByState = byState
};
}
private async Task<RuntimeKpis> CollectRuntimeKpisAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct)
{
var findings = await _findingRepo.GetWithSensorDeployedAsync(start, end, tenantId, ct);
var withRuntime = findings.Count(f => f.HasRuntimeEvidence);
var byPosture = findings
.Where(f => f.RuntimePosture is not null)
.GroupBy(f => f.RuntimePosture!)
.ToDictionary(g => g.Key, g => g.Count());
return new RuntimeKpis
{
TotalWithSensorDeployed = findings.Count,
WithRuntimeCorroboration = withRuntime,
ByPosture = byPosture
};
}
private async Task<ExplainabilityKpis> CollectExplainabilityKpisAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct)
{
var verdicts = await _verdictRepo.GetInPeriodAsync(start, end, tenantId, ct);
var withReasonSteps = verdicts.Count(v => v.ReasonSteps?.Count > 0);
var withProofPointer = verdicts.Count(v => v.ProofPointers?.Count > 0);
var fullyExplainable = verdicts.Count(v =>
v.ReasonSteps?.Count > 0 && v.ProofPointers?.Count > 0);
return new ExplainabilityKpis
{
TotalVerdicts = verdicts.Count,
WithReasonSteps = withReasonSteps,
WithProofPointer = withProofPointer,
FullyExplainable = fullyExplainable
};
}
private async Task<ReplayKpis> CollectReplayKpisAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct)
{
var replays = await _replayRepo.GetInPeriodAsync(start, end, tenantId, ct);
var successful = replays.Count(r => r.Success);
var failureReasons = replays
.Where(r => !r.Success && r.FailureReason is not null)
.GroupBy(r => r.FailureReason!)
.ToDictionary(g => g.Key, g => g.Count());
return new ReplayKpis
{
TotalAttempts = replays.Count,
Successful = successful,
FailureReasons = failureReasons
};
}
private async Task<UnknownBudgetKpis> CollectUnknownBudgetKpisAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct)
{
var breaches = await _repository.GetBudgetBreachesAsync(start, end, tenantId, ct);
var overrides = await _repository.GetOverridesAsync(start, end, tenantId, ct);
return new UnknownBudgetKpis
{
TotalEnvironments = breaches.Count,
BreachesByEnvironment = breaches,
OverridesGranted = overrides.Count,
AvgOverrideAgeDays = overrides.Any()
? (decimal)overrides.Average(o => (DateTimeOffset.UtcNow - o.GrantedAt).TotalDays)
: 0
};
}
private async Task<OperationalKpis> CollectOperationalKpisAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct)
{
var metrics = await _repository.GetOperationalMetricsAsync(start, end, tenantId, ct);
return new OperationalKpis
{
MedianTimeToVerdictSeconds = metrics.MedianVerdictTime.TotalSeconds,
CacheHitRate = metrics.CacheHitRate,
AvgEvidenceSizeBytes = metrics.AvgEvidenceSize,
P95VerdictTimeSeconds = metrics.P95VerdictTime.TotalSeconds
};
}
/// <inheritdoc />
public Task RecordReachabilityResultAsync(Guid findingId, string state, CancellationToken ct) =>
_repository.IncrementCounterAsync("reachability", state, ct);
/// <inheritdoc />
public Task RecordRuntimeObservationAsync(Guid findingId, string posture, CancellationToken ct) =>
_repository.IncrementCounterAsync("runtime", posture, ct);
/// <inheritdoc />
public Task RecordVerdictAsync(Guid verdictId, bool hasReasonSteps, bool hasProofPointer, CancellationToken ct)
{
var label = (hasReasonSteps, hasProofPointer) switch
{
(true, true) => "fully_explainable",
(true, false) => "reasons_only",
(false, true) => "proofs_only",
(false, false) => "unexplained"
};
return _repository.IncrementCounterAsync("explainability", label, ct);
}
/// <inheritdoc />
public Task RecordReplayAttemptAsync(Guid attestationId, bool success, string? failureReason, CancellationToken ct)
{
var label = success ? "success" : (failureReason ?? "unknown_failure");
return _repository.IncrementCounterAsync("replay", label, ct);
}
}

View File

@@ -0,0 +1,100 @@
namespace StellaOps.Metrics.Kpi;
/// <summary>
/// Interface for KPI trend analysis.
/// </summary>
public interface IKpiTrendService
{
/// <summary>
/// Gets KPI trend over a number of days.
/// </summary>
Task<KpiTrend> GetTrendAsync(int days, string? tenantId, CancellationToken ct);
}
/// <summary>
/// Provides KPI trend analysis.
/// </summary>
public sealed class KpiTrendService : IKpiTrendService
{
private readonly IKpiCollector _collector;
public KpiTrendService(IKpiCollector collector)
{
_collector = collector;
}
/// <inheritdoc />
public async Task<KpiTrend> GetTrendAsync(int days, string? tenantId, CancellationToken ct)
{
var snapshots = new List<KpiSnapshot>();
var end = DateTimeOffset.UtcNow;
var start = end.AddDays(-days);
// Collect daily snapshots
var currentStart = start;
while (currentStart < end)
{
var currentEnd = currentStart.AddDays(1);
if (currentEnd > end) currentEnd = end;
var kpis = await _collector.CollectAsync(currentStart, currentEnd, tenantId, ct);
snapshots.Add(new KpiSnapshot(
currentStart.Date,
kpis.Reachability.PercentKnown,
kpis.Runtime.CoveragePercent,
kpis.Explainability.CompletenessPercent,
kpis.Replay.SuccessRate,
kpis.Reachability.NoiseReductionPercent));
currentStart = currentEnd;
}
// Calculate changes
var firstValid = snapshots.FirstOrDefault(s => s.ReachabilityKnownPercent > 0);
var lastValid = snapshots.LastOrDefault(s => s.ReachabilityKnownPercent > 0);
var changes = new KpiChanges(
ReachabilityDelta: lastValid?.ReachabilityKnownPercent - firstValid?.ReachabilityKnownPercent ?? 0,
RuntimeDelta: lastValid?.RuntimeCoveragePercent - firstValid?.RuntimeCoveragePercent ?? 0,
ExplainabilityDelta: lastValid?.ExplainabilityPercent - firstValid?.ExplainabilityPercent ?? 0,
ReplayDelta: lastValid?.ReplaySuccessRate - firstValid?.ReplaySuccessRate ?? 0);
return new KpiTrend(
Days: days,
TenantId: tenantId,
Snapshots: snapshots,
Changes: changes,
GeneratedAt: DateTimeOffset.UtcNow);
}
}
/// <summary>
/// KPI trend over time.
/// </summary>
public sealed record KpiTrend(
int Days,
string? TenantId,
IReadOnlyList<KpiSnapshot> Snapshots,
KpiChanges Changes,
DateTimeOffset GeneratedAt);
/// <summary>
/// A single day's KPI snapshot.
/// </summary>
public sealed record KpiSnapshot(
DateTimeOffset Date,
decimal ReachabilityKnownPercent,
decimal RuntimeCoveragePercent,
decimal ExplainabilityPercent,
decimal ReplaySuccessRate,
decimal NoiseReductionPercent);
/// <summary>
/// Changes in KPIs over the trend period.
/// </summary>
public sealed record KpiChanges(
decimal ReachabilityDelta,
decimal RuntimeDelta,
decimal ExplainabilityDelta,
decimal ReplayDelta);

View File

@@ -0,0 +1,35 @@
namespace StellaOps.Metrics.Kpi.Repositories;
/// <summary>
/// Repository for querying findings for KPI calculations.
/// </summary>
public interface IFindingRepository
{
/// <summary>
/// Gets all findings in a period for KPI calculation.
/// </summary>
Task<IReadOnlyList<FindingKpiData>> GetInPeriodAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct);
/// <summary>
/// Gets findings where a runtime sensor was deployed.
/// </summary>
Task<IReadOnlyList<FindingKpiData>> GetWithSensorDeployedAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct);
}
/// <summary>
/// Finding data needed for KPI calculations.
/// </summary>
public sealed record FindingKpiData(
Guid Id,
string? ReachabilityState,
bool HasRuntimeEvidence,
string? RuntimePosture,
DateTimeOffset CreatedAt);

View File

@@ -0,0 +1,57 @@
namespace StellaOps.Metrics.Kpi.Repositories;
/// <summary>
/// Repository for KPI counter operations and operational metrics.
/// </summary>
public interface IKpiRepository
{
/// <summary>
/// Increments a counter for a specific category and label.
/// </summary>
Task IncrementCounterAsync(string category, string label, CancellationToken ct);
/// <summary>
/// Gets budget breaches by environment for a given period.
/// </summary>
Task<IReadOnlyDictionary<string, int>> GetBudgetBreachesAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct);
/// <summary>
/// Gets overrides granted in a given period.
/// </summary>
Task<IReadOnlyList<OverrideRecord>> GetOverridesAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct);
/// <summary>
/// Gets operational metrics for a given period.
/// </summary>
Task<OperationalMetrics> GetOperationalMetricsAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct);
}
/// <summary>
/// Represents an override record.
/// </summary>
public sealed record OverrideRecord(
Guid Id,
string EnvironmentId,
DateTimeOffset GrantedAt,
string Reason);
/// <summary>
/// Operational metrics from the repository.
/// </summary>
public sealed record OperationalMetrics(
TimeSpan MedianVerdictTime,
TimeSpan P95VerdictTime,
decimal CacheHitRate,
long AvgEvidenceSize);

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Metrics.Kpi.Repositories;
/// <summary>
/// Repository for querying replay attempts for KPI calculations.
/// </summary>
public interface IReplayRepository
{
/// <summary>
/// Gets all replay attempts in a period for KPI calculation.
/// </summary>
Task<IReadOnlyList<ReplayKpiData>> GetInPeriodAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct);
}
/// <summary>
/// Replay attempt data needed for KPI calculations.
/// </summary>
public sealed record ReplayKpiData(
Guid AttestationId,
bool Success,
string? FailureReason,
DateTimeOffset AttemptedAt);

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Metrics.Kpi.Repositories;
/// <summary>
/// Repository for querying verdicts for KPI calculations.
/// </summary>
public interface IVerdictRepository
{
/// <summary>
/// Gets all verdicts in a period for KPI calculation.
/// </summary>
Task<IReadOnlyList<VerdictKpiData>> GetInPeriodAsync(
DateTimeOffset start,
DateTimeOffset end,
string? tenantId,
CancellationToken ct);
}
/// <summary>
/// Verdict data needed for KPI calculations.
/// </summary>
public sealed record VerdictKpiData(
Guid Id,
IReadOnlyList<string>? ReasonSteps,
IReadOnlyList<string>? ProofPointers,
DateTimeOffset CreatedAt);

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -3,7 +3,7 @@ namespace StellaOps.Router.Common.Models;
/// <summary>
/// Configuration for payload and memory limits.
/// </summary>
public sealed record PayloadLimits
public sealed class PayloadLimits
{
/// <summary>
/// Default payload limits.
@@ -11,20 +11,20 @@ public sealed record PayloadLimits
public static readonly PayloadLimits Default = new();
/// <summary>
/// Gets the maximum request bytes per call.
/// Gets or sets the maximum request bytes per call.
/// Default: 10 MB.
/// </summary>
public long MaxRequestBytesPerCall { get; init; } = 10 * 1024 * 1024;
public long MaxRequestBytesPerCall { get; set; } = 10 * 1024 * 1024;
/// <summary>
/// Gets the maximum request bytes per connection.
/// Gets or sets the maximum request bytes per connection.
/// Default: 100 MB.
/// </summary>
public long MaxRequestBytesPerConnection { get; init; } = 100 * 1024 * 1024;
public long MaxRequestBytesPerConnection { get; set; } = 100 * 1024 * 1024;
/// <summary>
/// Gets the maximum aggregate in-flight bytes across all requests.
/// Gets or sets the maximum aggregate in-flight bytes across all requests.
/// Default: 1 GB.
/// </summary>
public long MaxAggregateInflightBytes { get; init; } = 1024 * 1024 * 1024;
public long MaxAggregateInflightBytes { get; set; } = 1024 * 1024 * 1024;
}

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Router.Gateway.OpenApi;
/// <summary>
/// Generates OpenAPI 3.1.0 documents from aggregated microservice schemas.
/// </summary>
internal sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
{
private readonly IGlobalRoutingState _routingState;
private readonly OpenApiAggregationOptions _options;

View File

@@ -7,7 +7,7 @@ namespace StellaOps.Router.Gateway.OpenApi;
/// <summary>
/// Caches the generated OpenAPI document with TTL-based expiration.
/// </summary>
internal sealed class RouterOpenApiDocumentCache : IRouterOpenApiDocumentCache
public sealed class RouterOpenApiDocumentCache : IRouterOpenApiDocumentCache
{
private readonly IOpenApiDocumentGenerator _generator;
private readonly OpenApiAggregationOptions _options;

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Json.Schema.Net" Version="7.2.0" />
<PackageReference Include="JsonSchema.Net" Version="7.2.0" />
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
</ItemGroup>

View File

@@ -24,11 +24,11 @@ public sealed class RunManifestValidator : IRunManifestValidator
var json = RunManifestSerializer.Serialize(manifest);
var schemaResult = _schema.Evaluate(JsonDocument.Parse(json));
if (!schemaResult.IsValid)
if (!schemaResult.IsValid && schemaResult.Errors is not null)
{
foreach (var error in schemaResult.Errors)
{
errors.Add(new ValidationError("Schema", error.Message));
errors.Add(new ValidationError("Schema", error.Value ?? "Unknown error"));
}
}

View File

@@ -0,0 +1,313 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using StellaOps.Metrics.Kpi;
using StellaOps.Metrics.Kpi.Repositories;
using Xunit;
namespace StellaOps.Metrics.Tests.Kpi;
public class KpiCollectorTests
{
private readonly Mock<IKpiRepository> _kpiRepoMock;
private readonly Mock<IFindingRepository> _findingRepoMock;
private readonly Mock<IVerdictRepository> _verdictRepoMock;
private readonly Mock<IReplayRepository> _replayRepoMock;
private readonly Mock<ILogger<KpiCollector>> _loggerMock;
private readonly KpiCollector _collector;
public KpiCollectorTests()
{
_kpiRepoMock = new Mock<IKpiRepository>();
_findingRepoMock = new Mock<IFindingRepository>();
_verdictRepoMock = new Mock<IVerdictRepository>();
_replayRepoMock = new Mock<IReplayRepository>();
_loggerMock = new Mock<ILogger<KpiCollector>>();
_collector = new KpiCollector(
_kpiRepoMock.Object,
_findingRepoMock.Object,
_verdictRepoMock.Object,
_replayRepoMock.Object,
_loggerMock.Object);
}
[Fact]
public async Task CollectAsync_ReturnsAllCategories()
{
// Arrange
SetupDefaultMocks();
// Act
var result = await _collector.CollectAsync(
DateTimeOffset.UtcNow.AddDays(-7),
DateTimeOffset.UtcNow,
ct: CancellationToken.None);
// Assert
result.Reachability.Should().NotBeNull();
result.Runtime.Should().NotBeNull();
result.Explainability.Should().NotBeNull();
result.Replay.Should().NotBeNull();
result.Unknowns.Should().NotBeNull();
result.Operational.Should().NotBeNull();
}
[Fact]
public async Task CollectAsync_CalculatesReachabilityPercentagesCorrectly()
{
// Arrange
var findings = new List<FindingKpiData>
{
new(Guid.NewGuid(), "Reachable", false, null, DateTimeOffset.UtcNow),
new(Guid.NewGuid(), "Reachable", false, null, DateTimeOffset.UtcNow),
new(Guid.NewGuid(), "ConfirmedUnreachable", false, null, DateTimeOffset.UtcNow),
new(Guid.NewGuid(), "Unknown", false, null, DateTimeOffset.UtcNow)
};
_findingRepoMock
.Setup(x => x.GetInPeriodAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(findings);
SetupOtherMocks();
// Act
var result = await _collector.CollectAsync(
DateTimeOffset.UtcNow.AddDays(-7),
DateTimeOffset.UtcNow,
ct: CancellationToken.None);
// Assert
result.Reachability.TotalFindings.Should().Be(4);
result.Reachability.WithKnownReachability.Should().Be(3); // 2 Reachable + 1 ConfirmedUnreachable
result.Reachability.PercentKnown.Should().Be(75m);
}
[Fact]
public async Task CollectAsync_CalculatesExplainabilityCorrectly()
{
// Arrange
var verdicts = new List<VerdictKpiData>
{
new(Guid.NewGuid(), new[] { "step1" }, new[] { "proof1" }, DateTimeOffset.UtcNow),
new(Guid.NewGuid(), new[] { "step1", "step2" }, null, DateTimeOffset.UtcNow),
new(Guid.NewGuid(), null, new[] { "proof1" }, DateTimeOffset.UtcNow),
new(Guid.NewGuid(), null, null, DateTimeOffset.UtcNow)
};
_verdictRepoMock
.Setup(x => x.GetInPeriodAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(verdicts);
SetupOtherMocksExceptVerdicts();
// Act
var result = await _collector.CollectAsync(
DateTimeOffset.UtcNow.AddDays(-7),
DateTimeOffset.UtcNow,
ct: CancellationToken.None);
// Assert
result.Explainability.TotalVerdicts.Should().Be(4);
result.Explainability.WithReasonSteps.Should().Be(2);
result.Explainability.WithProofPointer.Should().Be(2);
result.Explainability.FullyExplainable.Should().Be(1);
result.Explainability.CompletenessPercent.Should().Be(25m);
}
[Fact]
public async Task RecordVerdictAsync_FullyExplainable_IncrementsCorrectCounter()
{
// Act
await _collector.RecordVerdictAsync(
Guid.NewGuid(),
hasReasonSteps: true,
hasProofPointer: true,
CancellationToken.None);
// Assert
_kpiRepoMock.Verify(
r => r.IncrementCounterAsync("explainability", "fully_explainable", It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RecordVerdictAsync_ReasonsOnly_IncrementsCorrectCounter()
{
// Act
await _collector.RecordVerdictAsync(
Guid.NewGuid(),
hasReasonSteps: true,
hasProofPointer: false,
CancellationToken.None);
// Assert
_kpiRepoMock.Verify(
r => r.IncrementCounterAsync("explainability", "reasons_only", It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RecordVerdictAsync_ProofsOnly_IncrementsCorrectCounter()
{
// Act
await _collector.RecordVerdictAsync(
Guid.NewGuid(),
hasReasonSteps: false,
hasProofPointer: true,
CancellationToken.None);
// Assert
_kpiRepoMock.Verify(
r => r.IncrementCounterAsync("explainability", "proofs_only", It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RecordVerdictAsync_Unexplained_IncrementsCorrectCounter()
{
// Act
await _collector.RecordVerdictAsync(
Guid.NewGuid(),
hasReasonSteps: false,
hasProofPointer: false,
CancellationToken.None);
// Assert
_kpiRepoMock.Verify(
r => r.IncrementCounterAsync("explainability", "unexplained", It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RecordReplayAttemptAsync_Success_IncrementsSuccessCounter()
{
// Act
await _collector.RecordReplayAttemptAsync(
Guid.NewGuid(),
success: true,
failureReason: null,
CancellationToken.None);
// Assert
_kpiRepoMock.Verify(
r => r.IncrementCounterAsync("replay", "success", It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RecordReplayAttemptAsync_Failure_IncrementsFailureReasonCounter()
{
// Act
await _collector.RecordReplayAttemptAsync(
Guid.NewGuid(),
success: false,
failureReason: "FeedDrift",
CancellationToken.None);
// Assert
_kpiRepoMock.Verify(
r => r.IncrementCounterAsync("replay", "FeedDrift", It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RecordReachabilityResultAsync_IncrementsCorrectCounter()
{
// Act
await _collector.RecordReachabilityResultAsync(
Guid.NewGuid(),
"ConfirmedUnreachable",
CancellationToken.None);
// Assert
_kpiRepoMock.Verify(
r => r.IncrementCounterAsync("reachability", "ConfirmedUnreachable", It.IsAny<CancellationToken>()),
Times.Once);
}
private void SetupDefaultMocks()
{
_findingRepoMock
.Setup(x => x.GetInPeriodAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<FindingKpiData>());
_findingRepoMock
.Setup(x => x.GetWithSensorDeployedAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<FindingKpiData>());
_verdictRepoMock
.Setup(x => x.GetInPeriodAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VerdictKpiData>());
_replayRepoMock
.Setup(x => x.GetInPeriodAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ReplayKpiData>());
_kpiRepoMock
.Setup(x => x.GetBudgetBreachesAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, int>());
_kpiRepoMock
.Setup(x => x.GetOverridesAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<OverrideRecord>());
_kpiRepoMock
.Setup(x => x.GetOperationalMetricsAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OperationalMetrics(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), 0.85m, 1024));
}
private void SetupOtherMocks()
{
_findingRepoMock
.Setup(x => x.GetWithSensorDeployedAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<FindingKpiData>());
_verdictRepoMock
.Setup(x => x.GetInPeriodAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VerdictKpiData>());
_replayRepoMock
.Setup(x => x.GetInPeriodAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ReplayKpiData>());
_kpiRepoMock
.Setup(x => x.GetBudgetBreachesAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, int>());
_kpiRepoMock
.Setup(x => x.GetOverridesAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<OverrideRecord>());
_kpiRepoMock
.Setup(x => x.GetOperationalMetricsAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OperationalMetrics(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), 0.85m, 1024));
}
private void SetupOtherMocksExceptVerdicts()
{
_findingRepoMock
.Setup(x => x.GetInPeriodAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<FindingKpiData>());
_findingRepoMock
.Setup(x => x.GetWithSensorDeployedAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<FindingKpiData>());
_replayRepoMock
.Setup(x => x.GetInPeriodAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ReplayKpiData>());
_kpiRepoMock
.Setup(x => x.GetBudgetBreachesAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, int>());
_kpiRepoMock
.Setup(x => x.GetOverridesAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<OverrideRecord>());
_kpiRepoMock
.Setup(x => x.GetOperationalMetricsAsync(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OperationalMetrics(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), 0.85m, 1024));
}
}

View File

@@ -0,0 +1,180 @@
using FluentAssertions;
using StellaOps.Metrics.Kpi;
using Xunit;
namespace StellaOps.Metrics.Tests.Kpi;
public class KpiModelsTests
{
[Fact]
public void ReachabilityKpis_PercentKnown_CalculatesCorrectly()
{
// Arrange
var kpis = new ReachabilityKpis
{
TotalFindings = 100,
WithKnownReachability = 75,
ByState = new Dictionary<string, int>
{
["Reachable"] = 50,
["ConfirmedUnreachable"] = 25,
["Unknown"] = 25
}
};
// Assert
kpis.PercentKnown.Should().Be(75m);
}
[Fact]
public void ReachabilityKpis_NoiseReductionPercent_CalculatesCorrectly()
{
// Arrange
var kpis = new ReachabilityKpis
{
TotalFindings = 100,
WithKnownReachability = 75,
ByState = new Dictionary<string, int>
{
["Reachable"] = 50,
["ConfirmedUnreachable"] = 25,
["Unknown"] = 25
}
};
// Assert
kpis.NoiseReductionPercent.Should().Be(25m);
}
[Fact]
public void ReachabilityKpis_WithZeroTotal_ReturnsZeroPercent()
{
// Arrange
var kpis = new ReachabilityKpis
{
TotalFindings = 0,
WithKnownReachability = 0,
ByState = new Dictionary<string, int>()
};
// Assert
kpis.PercentKnown.Should().Be(0m);
kpis.NoiseReductionPercent.Should().Be(0m);
}
[Fact]
public void RuntimeKpis_CoveragePercent_CalculatesCorrectly()
{
// Arrange
var kpis = new RuntimeKpis
{
TotalWithSensorDeployed = 200,
WithRuntimeCorroboration = 100,
ByPosture = new Dictionary<string, int>
{
["Supports"] = 60,
["Contradicts"] = 30,
["Unknown"] = 10
}
};
// Assert
kpis.CoveragePercent.Should().Be(50m);
}
[Fact]
public void ExplainabilityKpis_CompletenessPercent_CalculatesCorrectly()
{
// Arrange
var kpis = new ExplainabilityKpis
{
TotalVerdicts = 100,
WithReasonSteps = 90,
WithProofPointer = 85,
FullyExplainable = 80
};
// Assert
kpis.CompletenessPercent.Should().Be(80m);
}
[Fact]
public void ReplayKpis_SuccessRate_CalculatesCorrectly()
{
// Arrange
var kpis = new ReplayKpis
{
TotalAttempts = 50,
Successful = 45,
FailureReasons = new Dictionary<string, int>
{
["FeedDrift"] = 3,
["PolicyChange"] = 2
}
};
// Assert
kpis.SuccessRate.Should().Be(90m);
}
[Fact]
public void TriageQualityKpis_ContainsAllCategories()
{
// Arrange
var kpis = CreateSampleKpis();
// Assert
kpis.Reachability.Should().NotBeNull();
kpis.Runtime.Should().NotBeNull();
kpis.Explainability.Should().NotBeNull();
kpis.Replay.Should().NotBeNull();
kpis.Unknowns.Should().NotBeNull();
kpis.Operational.Should().NotBeNull();
}
private static TriageQualityKpis CreateSampleKpis() => new()
{
PeriodStart = DateTimeOffset.UtcNow.AddDays(-7),
PeriodEnd = DateTimeOffset.UtcNow,
TenantId = null,
Reachability = new ReachabilityKpis
{
TotalFindings = 100,
WithKnownReachability = 80,
ByState = new Dictionary<string, int>()
},
Runtime = new RuntimeKpis
{
TotalWithSensorDeployed = 50,
WithRuntimeCorroboration = 30,
ByPosture = new Dictionary<string, int>()
},
Explainability = new ExplainabilityKpis
{
TotalVerdicts = 100,
WithReasonSteps = 95,
WithProofPointer = 90,
FullyExplainable = 88
},
Replay = new ReplayKpis
{
TotalAttempts = 20,
Successful = 19,
FailureReasons = new Dictionary<string, int>()
},
Unknowns = new UnknownBudgetKpis
{
TotalEnvironments = 5,
BreachesByEnvironment = new Dictionary<string, int>(),
OverridesGranted = 2,
AvgOverrideAgeDays = 3.5m
},
Operational = new OperationalKpis
{
MedianTimeToVerdictSeconds = 1.5,
CacheHitRate = 0.85m,
AvgEvidenceSizeBytes = 1024000,
P95VerdictTimeSeconds = 5.2
}
};
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Metrics\StellaOps.Metrics.csproj" />
</ItemGroup>
</Project>