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:
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
252
src/__Libraries/StellaOps.Metrics/Kpi/KpiCollector.cs
Normal file
252
src/__Libraries/StellaOps.Metrics/Kpi/KpiCollector.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
100
src/__Libraries/StellaOps.Metrics/Kpi/KpiTrendService.cs
Normal file
100
src/__Libraries/StellaOps.Metrics/Kpi/KpiTrendService.cs
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
12
src/__Libraries/StellaOps.Metrics/StellaOps.Metrics.csproj
Normal file
12
src/__Libraries/StellaOps.Metrics/StellaOps.Metrics.csproj
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user