finish off sprint advisories and sprints
This commit is contained in:
@@ -16,4 +16,18 @@ public static class PlatformPolicies
|
||||
public const string SetupRead = "platform.setup.read";
|
||||
public const string SetupWrite = "platform.setup.write";
|
||||
public const string SetupAdmin = "platform.setup.admin";
|
||||
|
||||
// Score evaluation policies (TSF-005)
|
||||
public const string ScoreRead = "platform.score.read";
|
||||
public const string ScoreEvaluate = "platform.score.evaluate";
|
||||
|
||||
// Function map policies (RLV-009)
|
||||
public const string FunctionMapRead = "platform.functionmap.read";
|
||||
public const string FunctionMapWrite = "platform.functionmap.write";
|
||||
public const string FunctionMapVerify = "platform.functionmap.verify";
|
||||
|
||||
// Policy interop policies (SPRINT_20260122_041)
|
||||
public const string PolicyRead = "platform.policy.read";
|
||||
public const string PolicyWrite = "platform.policy.write";
|
||||
public const string PolicyEvaluate = "platform.policy.evaluate";
|
||||
}
|
||||
|
||||
@@ -16,4 +16,13 @@ public static class PlatformScopes
|
||||
public const string SetupRead = "platform.setup.read";
|
||||
public const string SetupWrite = "platform.setup.write";
|
||||
public const string SetupAdmin = "platform.setup.admin";
|
||||
|
||||
// Score (TSF-005)
|
||||
public const string ScoreRead = "score.read";
|
||||
public const string ScoreEvaluate = "score.evaluate";
|
||||
|
||||
// Function map (RLV-009)
|
||||
public const string FunctionMapRead = "functionmap.read";
|
||||
public const string FunctionMapWrite = "functionmap.write";
|
||||
public const string FunctionMapVerify = "functionmap.verify";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
|
||||
// Task: RLV-009 - Platform API: Function Map Endpoints
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for creating a function map.
|
||||
/// </summary>
|
||||
public sealed record CreateFunctionMapRequest
|
||||
{
|
||||
[JsonPropertyName("sbomRef")]
|
||||
public required string SbomRef { get; init; }
|
||||
|
||||
[JsonPropertyName("serviceName")]
|
||||
public required string ServiceName { get; init; }
|
||||
|
||||
[JsonPropertyName("hotFunctions")]
|
||||
public IReadOnlyList<string>? HotFunctions { get; init; }
|
||||
|
||||
[JsonPropertyName("options")]
|
||||
public FunctionMapOptionsDto? Options { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for function map generation.
|
||||
/// </summary>
|
||||
public sealed record FunctionMapOptionsDto
|
||||
{
|
||||
[JsonPropertyName("minObservationRate")]
|
||||
public double? MinObservationRate { get; init; }
|
||||
|
||||
[JsonPropertyName("windowSeconds")]
|
||||
public int? WindowSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("failOnUnexpected")]
|
||||
public bool? FailOnUnexpected { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for verifying observations against a function map.
|
||||
/// </summary>
|
||||
public sealed record VerifyFunctionMapRequest
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<ObservationDto>? Observations { get; init; }
|
||||
|
||||
[JsonPropertyName("options")]
|
||||
public VerifyOptionsDto? Options { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observation DTO for API requests.
|
||||
/// </summary>
|
||||
public sealed record ObservationDto
|
||||
{
|
||||
[JsonPropertyName("observation_id")]
|
||||
public required string ObservationId { get; init; }
|
||||
|
||||
[JsonPropertyName("node_hash")]
|
||||
public required string NodeHash { get; init; }
|
||||
|
||||
[JsonPropertyName("function_name")]
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
[JsonPropertyName("probe_type")]
|
||||
public required string ProbeType { get; init; }
|
||||
|
||||
[JsonPropertyName("observed_at")]
|
||||
public required DateTimeOffset ObservedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("observation_count")]
|
||||
public int ObservationCount { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("container_id")]
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
[JsonPropertyName("pod_name")]
|
||||
public string? PodName { get; init; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string? Namespace { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification options DTO.
|
||||
/// </summary>
|
||||
public sealed record VerifyOptionsDto
|
||||
{
|
||||
[JsonPropertyName("minObservationRateOverride")]
|
||||
public double? MinObservationRateOverride { get; init; }
|
||||
|
||||
[JsonPropertyName("windowSecondsOverride")]
|
||||
public int? WindowSecondsOverride { get; init; }
|
||||
|
||||
[JsonPropertyName("failOnUnexpectedOverride")]
|
||||
public bool? FailOnUnexpectedOverride { get; init; }
|
||||
|
||||
[JsonPropertyName("containerIdFilter")]
|
||||
public string? ContainerIdFilter { get; init; }
|
||||
|
||||
[JsonPropertyName("podNameFilter")]
|
||||
public string? PodNameFilter { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function map summary returned in list responses.
|
||||
/// </summary>
|
||||
public sealed record FunctionMapSummary
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("serviceName")]
|
||||
public required string ServiceName { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomRef")]
|
||||
public required string SbomRef { get; init; }
|
||||
|
||||
[JsonPropertyName("pathCount")]
|
||||
public required int PathCount { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("lastVerifiedAt")]
|
||||
public DateTimeOffset? LastVerifiedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("coverageStatus")]
|
||||
public string? CoverageStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full function map detail returned in get responses.
|
||||
/// </summary>
|
||||
public sealed record FunctionMapDetail
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("serviceName")]
|
||||
public required string ServiceName { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomRef")]
|
||||
public required string SbomRef { get; init; }
|
||||
|
||||
[JsonPropertyName("pathCount")]
|
||||
public required int PathCount { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("lastVerifiedAt")]
|
||||
public DateTimeOffset? LastVerifiedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("coverage")]
|
||||
public FunctionMapCoverageDto? Coverage { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateDigest")]
|
||||
public required string PredicateDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coverage thresholds and current status.
|
||||
/// </summary>
|
||||
public sealed record FunctionMapCoverageDto
|
||||
{
|
||||
[JsonPropertyName("minObservationRate")]
|
||||
public required double MinObservationRate { get; init; }
|
||||
|
||||
[JsonPropertyName("windowSeconds")]
|
||||
public required int WindowSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("failOnUnexpected")]
|
||||
public required bool FailOnUnexpected { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification result returned from verify endpoint.
|
||||
/// </summary>
|
||||
public sealed record FunctionMapVerifyResponse
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("observationRate")]
|
||||
public required double ObservationRate { get; init; }
|
||||
|
||||
[JsonPropertyName("targetRate")]
|
||||
public required double TargetRate { get; init; }
|
||||
|
||||
[JsonPropertyName("pathCount")]
|
||||
public required int PathCount { get; init; }
|
||||
|
||||
[JsonPropertyName("observedPaths")]
|
||||
public required int ObservedPaths { get; init; }
|
||||
|
||||
[JsonPropertyName("unexpectedSymbolCount")]
|
||||
public required int UnexpectedSymbolCount { get; init; }
|
||||
|
||||
[JsonPropertyName("missingSymbolCount")]
|
||||
public required int MissingSymbolCount { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedAt")]
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceDigest")]
|
||||
public required string EvidenceDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coverage statistics response.
|
||||
/// </summary>
|
||||
public sealed record FunctionMapCoverageResponse
|
||||
{
|
||||
[JsonPropertyName("totalPaths")]
|
||||
public required int TotalPaths { get; init; }
|
||||
|
||||
[JsonPropertyName("observedPaths")]
|
||||
public required int ObservedPaths { get; init; }
|
||||
|
||||
[JsonPropertyName("totalExpectedCalls")]
|
||||
public required int TotalExpectedCalls { get; init; }
|
||||
|
||||
[JsonPropertyName("observedCalls")]
|
||||
public required int ObservedCalls { get; init; }
|
||||
|
||||
[JsonPropertyName("coverageRate")]
|
||||
public required double CoverageRate { get; init; }
|
||||
|
||||
[JsonPropertyName("unexpectedSymbolCount")]
|
||||
public required int UnexpectedSymbolCount { get; init; }
|
||||
|
||||
[JsonPropertyName("asOf")]
|
||||
public required DateTimeOffset AsOf { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
|
||||
// Task: TASK-07 - Platform API Endpoints
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to export a policy to a specified format.
|
||||
/// </summary>
|
||||
public sealed record PolicyExportApiRequest
|
||||
{
|
||||
[JsonPropertyName("policy_content")]
|
||||
public string? PolicyContent { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "json";
|
||||
|
||||
[JsonPropertyName("environment")]
|
||||
public string? Environment { get; init; }
|
||||
|
||||
[JsonPropertyName("include_remediation")]
|
||||
public bool IncludeRemediation { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("include_comments")]
|
||||
public bool IncludeComments { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("package_name")]
|
||||
public string? PackageName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from a policy export operation.
|
||||
/// </summary>
|
||||
public sealed record PolicyExportApiResponse
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "json";
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string? Content { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("diagnostics")]
|
||||
public IReadOnlyList<PolicyInteropDiagnostic>? Diagnostics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to import a policy from a specified format.
|
||||
/// </summary>
|
||||
public sealed record PolicyImportApiRequest
|
||||
{
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("validate_only")]
|
||||
public bool ValidateOnly { get; init; }
|
||||
|
||||
[JsonPropertyName("merge_strategy")]
|
||||
public string MergeStrategy { get; init; } = "replace";
|
||||
|
||||
[JsonPropertyName("dry_run")]
|
||||
public bool DryRun { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from a policy import operation.
|
||||
/// </summary>
|
||||
public sealed record PolicyImportApiResponse
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("source_format")]
|
||||
public string? SourceFormat { get; init; }
|
||||
|
||||
[JsonPropertyName("gates_imported")]
|
||||
public int GatesImported { get; init; }
|
||||
|
||||
[JsonPropertyName("rules_imported")]
|
||||
public int RulesImported { get; init; }
|
||||
|
||||
[JsonPropertyName("native_mapped")]
|
||||
public int NativeMapped { get; init; }
|
||||
|
||||
[JsonPropertyName("opa_evaluated")]
|
||||
public int OpaEvaluated { get; init; }
|
||||
|
||||
[JsonPropertyName("diagnostics")]
|
||||
public IReadOnlyList<PolicyInteropDiagnostic>? Diagnostics { get; init; }
|
||||
|
||||
[JsonPropertyName("mappings")]
|
||||
public IReadOnlyList<PolicyImportMappingDto>? Mappings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to validate a policy document.
|
||||
/// </summary>
|
||||
public sealed record PolicyValidateApiRequest
|
||||
{
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("strict")]
|
||||
public bool Strict { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from a policy validation operation.
|
||||
/// </summary>
|
||||
public sealed record PolicyValidateApiResponse
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("detected_format")]
|
||||
public string? DetectedFormat { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<PolicyInteropDiagnostic>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<PolicyInteropDiagnostic>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate a policy against evidence input.
|
||||
/// </summary>
|
||||
public sealed record PolicyEvaluateApiRequest
|
||||
{
|
||||
[JsonPropertyName("policy_content")]
|
||||
public string PolicyContent { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("input")]
|
||||
public PolicyEvaluationInputDto? Input { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("environment")]
|
||||
public string? Environment { get; init; }
|
||||
|
||||
[JsonPropertyName("include_remediation")]
|
||||
public bool IncludeRemediation { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from a policy evaluation operation.
|
||||
/// </summary>
|
||||
public sealed record PolicyEvaluateApiResponse
|
||||
{
|
||||
[JsonPropertyName("decision")]
|
||||
public string Decision { get; init; } = "block";
|
||||
|
||||
[JsonPropertyName("gates")]
|
||||
public IReadOnlyList<GateEvaluationDto>? Gates { get; init; }
|
||||
|
||||
[JsonPropertyName("remediation")]
|
||||
public IReadOnlyList<RemediationHintDto>? Remediation { get; init; }
|
||||
|
||||
[JsonPropertyName("output_digest")]
|
||||
public string? OutputDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simplified evidence input for API evaluation.
|
||||
/// </summary>
|
||||
public sealed record PolicyEvaluationInputDto
|
||||
{
|
||||
[JsonPropertyName("environment")]
|
||||
public string? Environment { get; init; }
|
||||
|
||||
[JsonPropertyName("dsse_verified")]
|
||||
public bool? DsseVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("rekor_verified")]
|
||||
public bool? RekorVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("sbom_digest")]
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("freshness_verified")]
|
||||
public bool? FreshnessVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("cvss_score")]
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double? Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("reachability_status")]
|
||||
public string? ReachabilityStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("unknowns_ratio")]
|
||||
public double? UnknownsRatio { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate evaluation result DTO.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluationDto
|
||||
{
|
||||
[JsonPropertyName("gate_id")]
|
||||
public string GateId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("gate_type")]
|
||||
public string GateType { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("passed")]
|
||||
public bool Passed { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remediation hint DTO for API responses.
|
||||
/// </summary>
|
||||
public sealed record RemediationHintDto
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "medium";
|
||||
|
||||
[JsonPropertyName("actions")]
|
||||
public IReadOnlyList<RemediationActionDto>? Actions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remediation action DTO.
|
||||
/// </summary>
|
||||
public sealed record RemediationActionDto
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("command")]
|
||||
public string? Command { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import mapping showing how Rego rules were translated.
|
||||
/// </summary>
|
||||
public sealed record PolicyImportMappingDto
|
||||
{
|
||||
[JsonPropertyName("source_rule")]
|
||||
public string SourceRule { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("target_gate_type")]
|
||||
public string TargetGateType { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("mapped_to_native")]
|
||||
public bool MappedToNative { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic message from interop operations.
|
||||
/// </summary>
|
||||
public sealed record PolicyInteropDiagnostic
|
||||
{
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "info";
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response listing supported formats.
|
||||
/// </summary>
|
||||
public sealed record PolicyFormatsApiResponse
|
||||
{
|
||||
[JsonPropertyName("formats")]
|
||||
public IReadOnlyList<PolicyFormatInfo> Formats { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a supported policy format.
|
||||
/// </summary>
|
||||
public sealed record PolicyFormatInfo(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("schema")] string Schema,
|
||||
[property: JsonPropertyName("import_supported")] bool ImportSupported,
|
||||
[property: JsonPropertyName("export_supported")] bool ExportSupported);
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: Score persistence store
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Record representing a persisted score history entry.
|
||||
/// </summary>
|
||||
public sealed record ScoreHistoryRecord
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("project_id")]
|
||||
public required string ProjectId { get; init; }
|
||||
|
||||
[JsonPropertyName("cve_id")]
|
||||
public required string CveId { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public required decimal Score { get; init; }
|
||||
|
||||
[JsonPropertyName("band")]
|
||||
public required string Band { get; init; }
|
||||
|
||||
[JsonPropertyName("weights_version")]
|
||||
public required string WeightsVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("signal_snapshot")]
|
||||
public required string SignalSnapshot { get; init; }
|
||||
|
||||
[JsonPropertyName("replay_digest")]
|
||||
public required string ReplayDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-005 - Platform API Endpoints (Score Evaluate)
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for score evaluation.
|
||||
/// </summary>
|
||||
public sealed record ScoreEvaluateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM reference (OCI digest or URL).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbom_ref")]
|
||||
public string? SbomRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier for direct scoring.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve_id")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL (purl) for component identification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS vector string (e.g., "CVSS:3.1/AV:N/AC:L/...").
|
||||
/// </summary>
|
||||
[JsonPropertyName("cvss_vector")]
|
||||
public string? CvssVector { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX document references.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex_refs")]
|
||||
public IReadOnlyList<string>? VexRefs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor receipt data for attestation verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekor_receipts")]
|
||||
public IReadOnlyList<string>? RekorReceipts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness observations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("runtime_witnesses")]
|
||||
public IReadOnlyList<RuntimeWitnessInput>? RuntimeWitnesses { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal inputs for direct scoring.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signals")]
|
||||
public SignalInputs? Signals { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scoring options.
|
||||
/// </summary>
|
||||
[JsonPropertyName("options")]
|
||||
public ScoreEvaluateOptions? Options { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness input.
|
||||
/// </summary>
|
||||
public sealed record RuntimeWitnessInput
|
||||
{
|
||||
/// <summary>
|
||||
/// Witness type (process, network, file, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Witness data.
|
||||
/// </summary>
|
||||
[JsonPropertyName("data")]
|
||||
public required string Data { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the witness was observed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("observed_at")]
|
||||
public DateTimeOffset? ObservedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct signal inputs for scoring.
|
||||
/// </summary>
|
||||
public sealed record SignalInputs
|
||||
{
|
||||
/// <summary>
|
||||
/// Reachability signal (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachability")]
|
||||
public double? Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime signal (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("runtime")]
|
||||
public double? Runtime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Backport signal (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("backport")]
|
||||
public double? Backport { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exploit signal (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("exploit")]
|
||||
public double? Exploit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source signal (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public double? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mitigation signal (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("mitigation")]
|
||||
public double? Mitigation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score evaluation options.
|
||||
/// </summary>
|
||||
public sealed record ScoreEvaluateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Decay lambda for time-based decay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decay_lambda")]
|
||||
public double? DecayLambda { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight set ID (manifest version) to use.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight_set_id")]
|
||||
public string? WeightSetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include delta-if-present calculations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("include_delta")]
|
||||
public bool IncludeDelta { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include detailed breakdown.
|
||||
/// </summary>
|
||||
[JsonPropertyName("include_breakdown")]
|
||||
public bool IncludeBreakdown { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from score evaluation.
|
||||
/// </summary>
|
||||
public sealed record ScoreEvaluateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique score ID for replay lookup.
|
||||
/// </summary>
|
||||
[JsonPropertyName("score_id")]
|
||||
public required string ScoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score value (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score_value")]
|
||||
public required int ScoreValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score bucket (ActNow, ScheduleNext, Investigate, Watchlist).
|
||||
/// </summary>
|
||||
[JsonPropertyName("bucket")]
|
||||
public required string Bucket { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns fraction (U) from entropy (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknowns_fraction")]
|
||||
public double? UnknownsFraction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns band (Complete, Adequate, Sparse, Insufficient).
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknowns_band")]
|
||||
public string? UnknownsBand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknown package references.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknowns")]
|
||||
public IReadOnlyList<string>? Unknowns { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI reference to score proof bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proof_ref")]
|
||||
public string? ProofRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Dimension breakdown.
|
||||
/// </summary>
|
||||
[JsonPropertyName("breakdown")]
|
||||
public IReadOnlyList<DimensionBreakdown>? Breakdown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Applied guardrails.
|
||||
/// </summary>
|
||||
[JsonPropertyName("guardrails")]
|
||||
public GuardrailsApplied? Guardrails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Delta-if-present calculations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("delta_if_present")]
|
||||
public IReadOnlyList<SignalDeltaResponse>? DeltaIfPresent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected conflicts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflicts")]
|
||||
public IReadOnlyList<SignalConflictResponse>? Conflicts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight_manifest")]
|
||||
public WeightManifestReference? WeightManifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EWS digest for replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ews_digest")]
|
||||
public required string EwsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinization fingerprint for replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("determinization_fingerprint")]
|
||||
public string? DeterminizationFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the score was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computed_at")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dimension breakdown in response.
|
||||
/// </summary>
|
||||
public sealed record DimensionBreakdown
|
||||
{
|
||||
[JsonPropertyName("dimension")]
|
||||
public required string Dimension { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("input_value")]
|
||||
public required double InputValue { get; init; }
|
||||
|
||||
[JsonPropertyName("weight")]
|
||||
public required double Weight { get; init; }
|
||||
|
||||
[JsonPropertyName("contribution")]
|
||||
public required double Contribution { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guardrails applied in scoring.
|
||||
/// </summary>
|
||||
public sealed record GuardrailsApplied
|
||||
{
|
||||
[JsonPropertyName("speculative_cap")]
|
||||
public bool SpeculativeCap { get; init; }
|
||||
|
||||
[JsonPropertyName("not_affected_cap")]
|
||||
public bool NotAffectedCap { get; init; }
|
||||
|
||||
[JsonPropertyName("runtime_floor")]
|
||||
public bool RuntimeFloor { get; init; }
|
||||
|
||||
[JsonPropertyName("original_score")]
|
||||
public int OriginalScore { get; init; }
|
||||
|
||||
[JsonPropertyName("adjusted_score")]
|
||||
public int AdjustedScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal delta response.
|
||||
/// </summary>
|
||||
public sealed record SignalDeltaResponse
|
||||
{
|
||||
[JsonPropertyName("signal")]
|
||||
public required string Signal { get; init; }
|
||||
|
||||
[JsonPropertyName("min_impact")]
|
||||
public required double MinImpact { get; init; }
|
||||
|
||||
[JsonPropertyName("max_impact")]
|
||||
public required double MaxImpact { get; init; }
|
||||
|
||||
[JsonPropertyName("weight")]
|
||||
public required double Weight { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal conflict response.
|
||||
/// </summary>
|
||||
public sealed record SignalConflictResponse
|
||||
{
|
||||
[JsonPropertyName("signal_a")]
|
||||
public required string SignalA { get; init; }
|
||||
|
||||
[JsonPropertyName("signal_b")]
|
||||
public required string SignalB { get; init; }
|
||||
|
||||
[JsonPropertyName("conflict_type")]
|
||||
public required string ConflictType { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest reference.
|
||||
/// </summary>
|
||||
public sealed record WeightManifestReference
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("content_hash")]
|
||||
public required string ContentHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest summary for listing.
|
||||
/// </summary>
|
||||
public sealed record WeightManifestSummary
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("effective_from")]
|
||||
public required DateTimeOffset EffectiveFrom { get; init; }
|
||||
|
||||
[JsonPropertyName("profile")]
|
||||
public required string Profile { get; init; }
|
||||
|
||||
[JsonPropertyName("content_hash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full weight manifest detail.
|
||||
/// </summary>
|
||||
public sealed record WeightManifestDetail
|
||||
{
|
||||
[JsonPropertyName("schema_version")]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("effective_from")]
|
||||
public required DateTimeOffset EffectiveFrom { get; init; }
|
||||
|
||||
[JsonPropertyName("profile")]
|
||||
public required string Profile { get; init; }
|
||||
|
||||
[JsonPropertyName("content_hash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("weights")]
|
||||
public required WeightDefinitionsDto Weights { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weight definitions DTO.
|
||||
/// </summary>
|
||||
public sealed record WeightDefinitionsDto
|
||||
{
|
||||
[JsonPropertyName("legacy")]
|
||||
public LegacyWeightsDto? Legacy { get; init; }
|
||||
|
||||
[JsonPropertyName("advisory")]
|
||||
public AdvisoryWeightsDto? Advisory { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy weights DTO.
|
||||
/// </summary>
|
||||
public sealed record LegacyWeightsDto
|
||||
{
|
||||
[JsonPropertyName("rch")]
|
||||
public double Rch { get; init; }
|
||||
|
||||
[JsonPropertyName("rts")]
|
||||
public double Rts { get; init; }
|
||||
|
||||
[JsonPropertyName("bkp")]
|
||||
public double Bkp { get; init; }
|
||||
|
||||
[JsonPropertyName("xpl")]
|
||||
public double Xpl { get; init; }
|
||||
|
||||
[JsonPropertyName("src")]
|
||||
public double Src { get; init; }
|
||||
|
||||
[JsonPropertyName("mit")]
|
||||
public double Mit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory weights DTO.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryWeightsDto
|
||||
{
|
||||
[JsonPropertyName("cvss")]
|
||||
public double Cvss { get; init; }
|
||||
|
||||
[JsonPropertyName("epss")]
|
||||
public double Epss { get; init; }
|
||||
|
||||
[JsonPropertyName("reachability")]
|
||||
public double Reachability { get; init; }
|
||||
|
||||
[JsonPropertyName("exploit_maturity")]
|
||||
public double ExploitMaturity { get; init; }
|
||||
|
||||
[JsonPropertyName("patch_proof")]
|
||||
public double PatchProof { get; init; }
|
||||
}
|
||||
|
||||
#region TSF-011: Score Replay Models
|
||||
|
||||
/// <summary>
|
||||
/// Response for score replay endpoint.
|
||||
/// </summary>
|
||||
public sealed record ScoreReplayResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64-encoded DSSE envelope containing the signed replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signed_replay_log_dsse")]
|
||||
public required string SignedReplayLogDsse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log inclusion proof (if anchored).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekor_inclusion")]
|
||||
public RekorInclusionDto? RekorInclusion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical input hashes for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("canonical_inputs")]
|
||||
public required IReadOnlyList<CanonicalInputDto> CanonicalInputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transform versions used in scoring.
|
||||
/// </summary>
|
||||
[JsonPropertyName("transforms")]
|
||||
public required IReadOnlyList<TransformStepDto> Transforms { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Step-by-step algebra decisions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algebra_steps")]
|
||||
public required IReadOnlyList<AlgebraStepDto> AlgebraSteps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The final computed score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("final_score")]
|
||||
public required int FinalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the score was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computed_at")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor inclusion proof DTO.
|
||||
/// </summary>
|
||||
public sealed record RekorInclusionDto
|
||||
{
|
||||
[JsonPropertyName("log_index")]
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("root_hash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
[JsonPropertyName("tree_size")]
|
||||
public long? TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
[JsonPropertyName("integrated_time")]
|
||||
public DateTimeOffset? IntegratedTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical input DTO for replay.
|
||||
/// </summary>
|
||||
public sealed record CanonicalInputDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("source_ref")]
|
||||
public string? SourceRef { get; init; }
|
||||
|
||||
[JsonPropertyName("size_bytes")]
|
||||
public long? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transform step DTO for replay.
|
||||
/// </summary>
|
||||
public sealed record TransformStepDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("params")]
|
||||
public IReadOnlyDictionary<string, object>? Params { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Algebra step DTO for replay.
|
||||
/// </summary>
|
||||
public sealed record AlgebraStepDto
|
||||
{
|
||||
[JsonPropertyName("signal")]
|
||||
public required string Signal { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("w")]
|
||||
public required double Weight { get; init; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public required double Value { get; init; }
|
||||
|
||||
[JsonPropertyName("term")]
|
||||
public required double Term { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for score verification.
|
||||
/// </summary>
|
||||
public sealed record ScoreVerifyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The replay log DSSE envelope to verify.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signed_replay_log_dsse")]
|
||||
public required string SignedReplayLogDsse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original inputs for replay verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("original_inputs")]
|
||||
public ScoreVerifyInputs? OriginalInputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify Rekor inclusion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verify_rekor")]
|
||||
public bool VerifyRekor { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Original inputs for verification.
|
||||
/// </summary>
|
||||
public sealed record ScoreVerifyInputs
|
||||
{
|
||||
[JsonPropertyName("signals")]
|
||||
public SignalInputs? Signals { get; init; }
|
||||
|
||||
[JsonPropertyName("weight_manifest_version")]
|
||||
public string? WeightManifestVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from score verification.
|
||||
/// </summary>
|
||||
public sealed record ScoreVerifyResponse
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("replayed_score")]
|
||||
public required int ReplayedScore { get; init; }
|
||||
|
||||
[JsonPropertyName("original_score")]
|
||||
public required int OriginalScore { get; init; }
|
||||
|
||||
[JsonPropertyName("score_matches")]
|
||||
public required bool ScoreMatches { get; init; }
|
||||
|
||||
[JsonPropertyName("digest_matches")]
|
||||
public required bool DigestMatches { get; init; }
|
||||
|
||||
[JsonPropertyName("signature_valid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("rekor_proof_valid")]
|
||||
public bool? RekorProofValid { get; init; }
|
||||
|
||||
[JsonPropertyName("differences")]
|
||||
public IReadOnlyList<VerificationDifferenceDto>? Differences { get; init; }
|
||||
|
||||
[JsonPropertyName("verified_at")]
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification difference DTO.
|
||||
/// </summary>
|
||||
public sealed record VerificationDifferenceDto
|
||||
{
|
||||
[JsonPropertyName("field")]
|
||||
public required string Field { get; init; }
|
||||
|
||||
[JsonPropertyName("expected")]
|
||||
public required string Expected { get; init; }
|
||||
|
||||
[JsonPropertyName("actual")]
|
||||
public required string Actual { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,255 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
|
||||
// Task: RLV-009 - Platform API: Function Map Endpoints
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Function map management API endpoints.
|
||||
/// </summary>
|
||||
public static class FunctionMapEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps function-map-related endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapFunctionMapEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var maps = app.MapGroup("/api/v1/function-maps")
|
||||
.WithTags("Function Maps");
|
||||
|
||||
MapCrudEndpoints(maps);
|
||||
MapVerifyEndpoints(maps);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapCrudEndpoints(IEndpointRouteBuilder maps)
|
||||
{
|
||||
// POST /api/v1/function-maps - Create function map
|
||||
maps.MapPost("/", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IFunctionMapService service,
|
||||
[FromBody] CreateFunctionMapRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.CreateAsync(
|
||||
requestContext!,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/function-maps/{result.Value.Id}",
|
||||
new PlatformItemResponse<FunctionMapDetail>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
})
|
||||
.WithName("CreateFunctionMap")
|
||||
.WithSummary("Create function map")
|
||||
.WithDescription("Creates a new function map from an SBOM reference and hot function patterns.")
|
||||
.RequireAuthorization(PlatformPolicies.FunctionMapWrite);
|
||||
|
||||
// GET /api/v1/function-maps - List function maps
|
||||
maps.MapGet("/", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IFunctionMapService service,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.ListAsync(
|
||||
requestContext!,
|
||||
limit ?? 100,
|
||||
offset ?? 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<FunctionMapSummary>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value,
|
||||
result.Value.Count,
|
||||
limit ?? 100,
|
||||
offset ?? 0));
|
||||
})
|
||||
.WithName("ListFunctionMaps")
|
||||
.WithSummary("List function maps")
|
||||
.WithDescription("Lists all function maps for the current tenant.")
|
||||
.RequireAuthorization(PlatformPolicies.FunctionMapRead);
|
||||
|
||||
// GET /api/v1/function-maps/{id} - Get function map by ID
|
||||
maps.MapGet("/{id}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IFunctionMapService service,
|
||||
string id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetByIdAsync(
|
||||
requestContext!,
|
||||
id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Function map not found", id });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<FunctionMapDetail>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
})
|
||||
.WithName("GetFunctionMap")
|
||||
.WithSummary("Get function map")
|
||||
.WithDescription("Retrieves a function map by its unique identifier.")
|
||||
.RequireAuthorization(PlatformPolicies.FunctionMapRead);
|
||||
|
||||
// DELETE /api/v1/function-maps/{id} - Delete function map
|
||||
maps.MapDelete("/{id}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IFunctionMapService service,
|
||||
string id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.DeleteAsync(
|
||||
requestContext!,
|
||||
id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.Value)
|
||||
{
|
||||
return Results.NotFound(new { error = "Function map not found", id });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("DeleteFunctionMap")
|
||||
.WithSummary("Delete function map")
|
||||
.WithDescription("Deletes a function map by its unique identifier.")
|
||||
.RequireAuthorization(PlatformPolicies.FunctionMapWrite);
|
||||
}
|
||||
|
||||
private static void MapVerifyEndpoints(IEndpointRouteBuilder maps)
|
||||
{
|
||||
// POST /api/v1/function-maps/{id}/verify - Verify observations against map
|
||||
maps.MapPost("/{id}/verify", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IFunctionMapService service,
|
||||
string id,
|
||||
[FromBody] VerifyFunctionMapRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.VerifyAsync(
|
||||
requestContext!,
|
||||
id,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<FunctionMapVerifyResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
})
|
||||
.WithName("VerifyFunctionMap")
|
||||
.WithSummary("Verify function map")
|
||||
.WithDescription("Verifies runtime observations against a declared function map.")
|
||||
.RequireAuthorization(PlatformPolicies.FunctionMapVerify);
|
||||
|
||||
// GET /api/v1/function-maps/{id}/coverage - Get coverage statistics
|
||||
maps.MapGet("/{id}/coverage", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IFunctionMapService service,
|
||||
string id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetCoverageAsync(
|
||||
requestContext!,
|
||||
id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<FunctionMapCoverageResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
})
|
||||
.WithName("GetFunctionMapCoverage")
|
||||
.WithSummary("Get function map coverage")
|
||||
.WithDescription("Returns current coverage statistics for a function map.")
|
||||
.RequireAuthorization(PlatformPolicies.FunctionMapRead);
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
|
||||
// Task: TASK-07 - Platform API Endpoints
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Policy import/export interop API endpoints.
|
||||
/// Provides bidirectional policy exchange between JSON (PolicyPack v2) and OPA/Rego formats.
|
||||
/// </summary>
|
||||
public static class PolicyInteropEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps policy interop endpoints under /api/v1/policy/interop.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapPolicyInteropEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var interop = app.MapGroup("/api/v1/policy/interop")
|
||||
.WithTags("PolicyInterop");
|
||||
|
||||
MapExportEndpoint(interop);
|
||||
MapImportEndpoint(interop);
|
||||
MapValidateEndpoint(interop);
|
||||
MapEvaluateEndpoint(interop);
|
||||
MapFormatsEndpoint(interop);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapExportEndpoint(IEndpointRouteBuilder group)
|
||||
{
|
||||
// POST /api/v1/policy/interop/export
|
||||
group.MapPost("/export", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IPolicyInteropService service,
|
||||
[FromBody] PolicyExportApiRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.ExportAsync(
|
||||
requestContext!,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(new { error = "export_failed", diagnostics = result.Diagnostics });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<PolicyExportApiResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
false,
|
||||
0,
|
||||
result));
|
||||
})
|
||||
.WithName("ExportPolicy")
|
||||
.WithSummary("Export policy to format")
|
||||
.WithDescription("Exports a PolicyPack v2 document to JSON or OPA/Rego format with optional environment-specific thresholds and remediation hints.")
|
||||
.RequireAuthorization(PlatformPolicies.PolicyRead);
|
||||
|
||||
// POST /api/v1/policy/interop/import
|
||||
}
|
||||
|
||||
private static void MapImportEndpoint(IEndpointRouteBuilder group)
|
||||
{
|
||||
group.MapPost("/import", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IPolicyInteropService service,
|
||||
[FromBody] PolicyImportApiRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.ImportAsync(
|
||||
requestContext!,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(new { error = "import_failed", diagnostics = result.Diagnostics });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<PolicyImportApiResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
false,
|
||||
0,
|
||||
result));
|
||||
})
|
||||
.WithName("ImportPolicy")
|
||||
.WithSummary("Import policy from format")
|
||||
.WithDescription("Imports a policy from JSON or OPA/Rego format into the native PolicyPack v2 model. Unknown Rego patterns are preserved for OPA evaluation.")
|
||||
.RequireAuthorization(PlatformPolicies.PolicyWrite);
|
||||
}
|
||||
|
||||
private static void MapValidateEndpoint(IEndpointRouteBuilder group)
|
||||
{
|
||||
// POST /api/v1/policy/interop/validate
|
||||
group.MapPost("/validate", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IPolicyInteropService service,
|
||||
[FromBody] PolicyValidateApiRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.ValidateAsync(
|
||||
requestContext!,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<PolicyValidateApiResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
false,
|
||||
0,
|
||||
result));
|
||||
})
|
||||
.WithName("ValidatePolicy")
|
||||
.WithSummary("Validate policy document")
|
||||
.WithDescription("Validates a policy document against the PolicyPack v2 schema or checks Rego syntax via embedded OPA.")
|
||||
.RequireAuthorization(PlatformPolicies.PolicyRead);
|
||||
}
|
||||
|
||||
private static void MapEvaluateEndpoint(IEndpointRouteBuilder group)
|
||||
{
|
||||
// POST /api/v1/policy/interop/evaluate
|
||||
group.MapPost("/evaluate", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IPolicyInteropService service,
|
||||
[FromBody] PolicyEvaluateApiRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.EvaluateAsync(
|
||||
requestContext!,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var statusCode = result.Decision switch
|
||||
{
|
||||
"allow" => StatusCodes.Status200OK,
|
||||
"warn" => StatusCodes.Status200OK,
|
||||
"block" => StatusCodes.Status200OK,
|
||||
_ => StatusCodes.Status200OK
|
||||
};
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<PolicyEvaluateApiResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
false,
|
||||
0,
|
||||
result));
|
||||
})
|
||||
.WithName("EvaluatePolicy")
|
||||
.WithSummary("Evaluate policy against input")
|
||||
.WithDescription("Evaluates a policy (JSON or Rego) against evidence input and returns allow/warn/block decision with remediation hints.")
|
||||
.RequireAuthorization(PlatformPolicies.PolicyEvaluate);
|
||||
}
|
||||
|
||||
private static void MapFormatsEndpoint(IEndpointRouteBuilder group)
|
||||
{
|
||||
// GET /api/v1/policy/interop/formats
|
||||
group.MapGet("/formats", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var formats = new PolicyFormatsApiResponse
|
||||
{
|
||||
Formats =
|
||||
[
|
||||
new PolicyFormatInfo("json", "PolicyPack v2 (JSON)", "policy.stellaops.io/v2", true, true),
|
||||
new PolicyFormatInfo("rego", "OPA/Rego", "package stella.release", true, true)
|
||||
]
|
||||
};
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<PolicyFormatsApiResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
true,
|
||||
3600,
|
||||
formats));
|
||||
})
|
||||
.WithName("ListPolicyFormats")
|
||||
.WithSummary("List supported policy formats")
|
||||
.WithDescription("Returns the list of supported policy import/export formats.")
|
||||
.RequireAuthorization(PlatformPolicies.PolicyRead);
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-005 - Platform API Endpoints (Score Evaluate)
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Score evaluation API endpoints.
|
||||
/// </summary>
|
||||
public static class ScoreEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps score-related endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapScoreEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var score = app.MapGroup("/api/v1/score")
|
||||
.WithTags("Score");
|
||||
|
||||
MapEvaluateEndpoints(score);
|
||||
MapHistoryEndpoints(score);
|
||||
MapWeightsEndpoints(score);
|
||||
MapReplayEndpoints(score);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapHistoryEndpoints(IEndpointRouteBuilder score)
|
||||
{
|
||||
// GET /api/v1/score/history - Get score history
|
||||
score.MapGet("/history", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScoreEvaluationService service,
|
||||
[FromQuery] string cve_id,
|
||||
[FromQuery] string? purl,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cve_id))
|
||||
{
|
||||
return Results.BadRequest(new { error = "cve_id query parameter is required" });
|
||||
}
|
||||
|
||||
var result = await service.GetHistoryAsync(
|
||||
requestContext!,
|
||||
cve_id,
|
||||
purl,
|
||||
limit ?? 50,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<Contracts.ScoreHistoryRecord>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value,
|
||||
result.Value.Count));
|
||||
})
|
||||
.WithName("GetScoreHistory")
|
||||
.WithSummary("Get score history")
|
||||
.WithDescription("Retrieves score computation history for a CVE, optionally filtered by purl.")
|
||||
.RequireAuthorization(PlatformPolicies.ScoreRead);
|
||||
}
|
||||
|
||||
private static void MapEvaluateEndpoints(IEndpointRouteBuilder score)
|
||||
{
|
||||
// POST /api/v1/score/evaluate - Compute unified score
|
||||
score.MapPost("/evaluate", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScoreEvaluationService service,
|
||||
[FromBody] ScoreEvaluateRequest request,
|
||||
[FromQuery] bool? include_delta,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
// Override options from query params if provided
|
||||
var effectiveOptions = request.Options ?? new ScoreEvaluateOptions();
|
||||
if (include_delta.HasValue)
|
||||
{
|
||||
effectiveOptions = effectiveOptions with { IncludeDelta = include_delta.Value };
|
||||
}
|
||||
|
||||
var effectiveRequest = request with { Options = effectiveOptions };
|
||||
|
||||
var result = await service.EvaluateAsync(
|
||||
requestContext!,
|
||||
effectiveRequest,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ScoreEvaluateResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
})
|
||||
.WithName("EvaluateScore")
|
||||
.WithSummary("Compute unified score")
|
||||
.WithDescription("Evaluates a unified trust score combining EWS computation with Determinization entropy.")
|
||||
.RequireAuthorization(PlatformPolicies.ScoreEvaluate);
|
||||
|
||||
// GET /api/v1/score/{scoreId} - Get score by ID
|
||||
score.MapGet("/{scoreId}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScoreEvaluationService service,
|
||||
string scoreId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetByIdAsync(
|
||||
requestContext!,
|
||||
scoreId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Score not found", score_id = scoreId });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ScoreEvaluateResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
})
|
||||
.WithName("GetScore")
|
||||
.WithSummary("Get score by ID")
|
||||
.WithDescription("Retrieves a previously computed score by its unique identifier.")
|
||||
.RequireAuthorization(PlatformPolicies.ScoreRead);
|
||||
}
|
||||
|
||||
private static void MapWeightsEndpoints(IEndpointRouteBuilder score)
|
||||
{
|
||||
var weights = score.MapGroup("/weights").WithTags("Score Weights");
|
||||
|
||||
// GET /api/v1/score/weights - List available weight manifests
|
||||
weights.MapGet("/", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScoreEvaluationService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.ListWeightManifestsAsync(
|
||||
requestContext!,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<WeightManifestSummary>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value,
|
||||
result.Value.Count));
|
||||
})
|
||||
.WithName("ListWeightManifests")
|
||||
.WithSummary("List weight manifests")
|
||||
.WithDescription("Lists all available EWS weight manifests.")
|
||||
.RequireAuthorization(PlatformPolicies.ScoreRead);
|
||||
|
||||
// GET /api/v1/score/weights/{version} - Get specific manifest
|
||||
weights.MapGet("/{version}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScoreEvaluationService service,
|
||||
string version,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetWeightManifestAsync(
|
||||
requestContext!,
|
||||
version,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Weight manifest not found", version });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<WeightManifestDetail>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
})
|
||||
.WithName("GetWeightManifest")
|
||||
.WithSummary("Get weight manifest")
|
||||
.WithDescription("Retrieves a specific EWS weight manifest by version.")
|
||||
.RequireAuthorization(PlatformPolicies.ScoreRead);
|
||||
|
||||
// GET /api/v1/score/weights/effective - Get effective manifest for current date
|
||||
weights.MapGet("/effective", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScoreEvaluationService service,
|
||||
[FromQuery] DateTimeOffset? as_of,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetEffectiveWeightManifestAsync(
|
||||
requestContext!,
|
||||
as_of ?? DateTimeOffset.UtcNow,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "No effective weight manifest found" });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<WeightManifestDetail>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
})
|
||||
.WithName("GetEffectiveWeightManifest")
|
||||
.WithSummary("Get effective weight manifest")
|
||||
.WithDescription("Retrieves the effective EWS weight manifest for a given date.")
|
||||
.RequireAuthorization(PlatformPolicies.ScoreRead);
|
||||
}
|
||||
|
||||
// TSF-011: Replay and verification endpoints
|
||||
private static void MapReplayEndpoints(IEndpointRouteBuilder score)
|
||||
{
|
||||
// GET /api/v1/score/{scoreId}/replay - Fetch signed replay proof
|
||||
score.MapGet("/{scoreId}/replay", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScoreEvaluationService service,
|
||||
string scoreId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetReplayAsync(
|
||||
requestContext!,
|
||||
scoreId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Replay log not found", score_id = scoreId });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ScoreReplayResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
})
|
||||
.WithName("GetScoreReplay")
|
||||
.WithSummary("Get score replay proof")
|
||||
.WithDescription("Retrieves a signed replay log for a previously computed score, enabling independent verification by auditors.")
|
||||
.RequireAuthorization(PlatformPolicies.ScoreRead);
|
||||
|
||||
// POST /api/v1/score/verify - Verify a replay log
|
||||
score.MapPost("/verify", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IScoreEvaluationService service,
|
||||
[FromBody] ScoreVerifyRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.VerifyReplayAsync(
|
||||
requestContext!,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ScoreVerifyResponse>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
})
|
||||
.WithName("VerifyScoreReplay")
|
||||
.WithSummary("Verify score replay")
|
||||
.WithDescription("Verifies a signed replay log by re-executing the score computation and comparing results.")
|
||||
.RequireAuthorization(PlatformPolicies.ScoreRead);
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using StellaOps.Platform.WebService.Endpoints;
|
||||
using StellaOps.Platform.WebService.Options;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
using StellaOps.Telemetry.Core;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -106,6 +107,11 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupRead, PlatformScopes.SetupRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupWrite, PlatformScopes.SetupWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupAdmin, PlatformScopes.SetupAdmin);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ScoreRead, PlatformScopes.ScoreRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ScoreEvaluate, PlatformScopes.ScoreEvaluate);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FunctionMapRead, PlatformScopes.FunctionMapRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FunctionMapWrite, PlatformScopes.FunctionMapWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FunctionMapVerify, PlatformScopes.FunctionMapVerify);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformRequestContextResolver>();
|
||||
@@ -139,6 +145,32 @@ builder.Services.AddAnalyticsIngestion(builder.Configuration, bootstrapOptions.S
|
||||
builder.Services.AddSingleton<PlatformSetupStore>();
|
||||
builder.Services.AddSingleton<PlatformSetupService>();
|
||||
|
||||
// Score evaluation services (TSF-005/TSF-011)
|
||||
builder.Services.AddUnifiedScoreServices();
|
||||
builder.Services.AddSingleton<StellaOps.Signals.UnifiedScore.Replay.IReplayLogBuilder,
|
||||
StellaOps.Signals.UnifiedScore.Replay.ReplayLogBuilder>();
|
||||
builder.Services.AddSingleton<StellaOps.Signals.UnifiedScore.Replay.IReplayVerifier,
|
||||
StellaOps.Signals.UnifiedScore.Replay.ReplayVerifier>();
|
||||
|
||||
// Score history persistence store
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString))
|
||||
{
|
||||
builder.Services.AddSingleton(
|
||||
Npgsql.NpgsqlDataSource.Create(bootstrapOptions.Storage.PostgresConnectionString));
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, PostgresScoreHistoryStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, InMemoryScoreHistoryStore>();
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton<IScoreEvaluationService, ScoreEvaluationService>();
|
||||
|
||||
// Function map services (RLV-009)
|
||||
builder.Services.AddSingleton<StellaOps.Scanner.Reachability.FunctionMap.Verification.IClaimVerifier,
|
||||
StellaOps.Scanner.Reachability.FunctionMap.Verification.ClaimVerifier>();
|
||||
builder.Services.AddSingleton<IFunctionMapService, FunctionMapService>();
|
||||
|
||||
var routerOptions = builder.Configuration.GetSection("Platform:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
serviceName: "platform",
|
||||
@@ -165,6 +197,9 @@ app.TryUseStellaRouter(routerOptions);
|
||||
app.MapPlatformEndpoints();
|
||||
app.MapSetupEndpoints();
|
||||
app.MapAnalyticsEndpoints();
|
||||
app.MapScoreEndpoints();
|
||||
app.MapFunctionMapEndpoints();
|
||||
app.MapPolicyInteropEndpoints();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithTags("Health")
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
|
||||
// Task: RLV-009 - Platform API: Function Map Endpoints
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Scanner.Reachability.FunctionMap;
|
||||
using StellaOps.Scanner.Reachability.FunctionMap.Verification;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of function map service.
|
||||
/// Production deployments should replace with a Postgres-backed implementation.
|
||||
/// </summary>
|
||||
public sealed class FunctionMapService : IFunctionMapService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, StoredFunctionMap> _maps = new();
|
||||
private readonly IClaimVerifier _claimVerifier;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public FunctionMapService(IClaimVerifier claimVerifier)
|
||||
{
|
||||
_claimVerifier = claimVerifier;
|
||||
}
|
||||
|
||||
public Task<PlatformCacheResult<FunctionMapDetail>> CreateAsync(
|
||||
PlatformRequestContext context,
|
||||
CreateFunctionMapRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var id = $"fmap-{Guid.NewGuid():N}";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var coverage = new CoverageThresholds
|
||||
{
|
||||
MinObservationRate = request.Options?.MinObservationRate ?? 0.95,
|
||||
WindowSeconds = request.Options?.WindowSeconds ?? 1800,
|
||||
FailOnUnexpected = request.Options?.FailOnUnexpected ?? false
|
||||
};
|
||||
|
||||
var predicate = new FunctionMapPredicate
|
||||
{
|
||||
Subject = new FunctionMapSubject
|
||||
{
|
||||
Purl = request.SbomRef,
|
||||
Digest = new Dictionary<string, string>()
|
||||
},
|
||||
Predicate = new FunctionMapPredicatePayload
|
||||
{
|
||||
Service = request.ServiceName,
|
||||
ExpectedPaths = [],
|
||||
Coverage = coverage,
|
||||
GeneratedAt = now,
|
||||
GeneratedFrom = new FunctionMapGeneratedFrom
|
||||
{
|
||||
SbomRef = request.SbomRef,
|
||||
HotFunctionPatterns = request.HotFunctions
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Compute digest from stable inputs only (exclude GeneratedAt for determinism)
|
||||
var digestInput = new
|
||||
{
|
||||
service = request.ServiceName,
|
||||
sbomRef = request.SbomRef,
|
||||
hotFunctions = request.HotFunctions ?? [],
|
||||
minObservationRate = coverage.MinObservationRate,
|
||||
windowSeconds = coverage.WindowSeconds,
|
||||
failOnUnexpected = coverage.FailOnUnexpected
|
||||
};
|
||||
var digest = ComputeSha256(JsonSerializer.Serialize(digestInput, JsonOptions));
|
||||
|
||||
var stored = new StoredFunctionMap(
|
||||
id,
|
||||
context.TenantId,
|
||||
request.ServiceName,
|
||||
request.SbomRef,
|
||||
predicate,
|
||||
digest,
|
||||
now,
|
||||
null);
|
||||
|
||||
_maps[TenantKey(context.TenantId, id)] = stored;
|
||||
|
||||
var detail = ToDetail(stored);
|
||||
return Task.FromResult(new PlatformCacheResult<FunctionMapDetail>(detail, now, false, 0));
|
||||
}
|
||||
|
||||
public Task<PlatformCacheResult<IReadOnlyList<FunctionMapSummary>>> ListAsync(
|
||||
PlatformRequestContext context,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantMaps = _maps.Values
|
||||
.Where(m => m.TenantId == context.TenantId)
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(ToSummary)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(new PlatformCacheResult<IReadOnlyList<FunctionMapSummary>>(
|
||||
tenantMaps, DateTimeOffset.UtcNow, false, 0));
|
||||
}
|
||||
|
||||
public Task<PlatformCacheResult<FunctionMapDetail?>> GetByIdAsync(
|
||||
PlatformRequestContext context,
|
||||
string id,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var key = TenantKey(context.TenantId, id);
|
||||
FunctionMapDetail? detail = _maps.TryGetValue(key, out var stored) ? ToDetail(stored) : null;
|
||||
|
||||
return Task.FromResult(new PlatformCacheResult<FunctionMapDetail?>(
|
||||
detail, DateTimeOffset.UtcNow, false, 0));
|
||||
}
|
||||
|
||||
public Task<PlatformCacheResult<bool>> DeleteAsync(
|
||||
PlatformRequestContext context,
|
||||
string id,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var key = TenantKey(context.TenantId, id);
|
||||
var removed = _maps.TryRemove(key, out _);
|
||||
|
||||
return Task.FromResult(new PlatformCacheResult<bool>(
|
||||
removed, DateTimeOffset.UtcNow, false, 0));
|
||||
}
|
||||
|
||||
public async Task<PlatformCacheResult<FunctionMapVerifyResponse>> VerifyAsync(
|
||||
PlatformRequestContext context,
|
||||
string id,
|
||||
VerifyFunctionMapRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var key = TenantKey(context.TenantId, id);
|
||||
if (!_maps.TryGetValue(key, out var stored))
|
||||
{
|
||||
return new PlatformCacheResult<FunctionMapVerifyResponse>(
|
||||
new FunctionMapVerifyResponse
|
||||
{
|
||||
Verified = false,
|
||||
ObservationRate = 0,
|
||||
TargetRate = 0,
|
||||
PathCount = 0,
|
||||
ObservedPaths = 0,
|
||||
UnexpectedSymbolCount = 0,
|
||||
MissingSymbolCount = 0,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
EvidenceDigest = ""
|
||||
},
|
||||
DateTimeOffset.UtcNow, false, 0);
|
||||
}
|
||||
|
||||
var observations = (request.Observations ?? [])
|
||||
.Select(o => new ClaimObservation
|
||||
{
|
||||
ObservationId = o.ObservationId,
|
||||
NodeHash = o.NodeHash,
|
||||
FunctionName = o.FunctionName,
|
||||
ProbeType = o.ProbeType,
|
||||
ObservedAt = o.ObservedAt,
|
||||
ObservationCount = o.ObservationCount,
|
||||
ContainerId = o.ContainerId,
|
||||
PodName = o.PodName,
|
||||
Namespace = o.Namespace
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var verifyOptions = new ClaimVerificationOptions
|
||||
{
|
||||
MinObservationRateOverride = request.Options?.MinObservationRateOverride,
|
||||
WindowSecondsOverride = request.Options?.WindowSecondsOverride,
|
||||
FailOnUnexpectedOverride = request.Options?.FailOnUnexpectedOverride,
|
||||
ContainerIdFilter = request.Options?.ContainerIdFilter,
|
||||
PodNameFilter = request.Options?.PodNameFilter
|
||||
};
|
||||
|
||||
var result = await _claimVerifier.VerifyAsync(
|
||||
stored.Predicate, observations, verifyOptions, ct);
|
||||
|
||||
// Update last verified timestamp
|
||||
_maps[key] = stored with { LastVerifiedAt = DateTimeOffset.UtcNow };
|
||||
|
||||
var response = new FunctionMapVerifyResponse
|
||||
{
|
||||
Verified = result.Verified,
|
||||
ObservationRate = result.ObservationRate,
|
||||
TargetRate = result.TargetRate,
|
||||
PathCount = result.Paths.Count,
|
||||
ObservedPaths = result.Paths.Count(p => p.Observed),
|
||||
UnexpectedSymbolCount = result.UnexpectedSymbols.Count,
|
||||
MissingSymbolCount = result.MissingExpectedSymbols.Count,
|
||||
VerifiedAt = result.VerifiedAt,
|
||||
EvidenceDigest = result.Evidence.FunctionMapDigest
|
||||
};
|
||||
|
||||
return new PlatformCacheResult<FunctionMapVerifyResponse>(
|
||||
response, DateTimeOffset.UtcNow, false, 0);
|
||||
}
|
||||
|
||||
public Task<PlatformCacheResult<FunctionMapCoverageResponse>> GetCoverageAsync(
|
||||
PlatformRequestContext context,
|
||||
string id,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var key = TenantKey(context.TenantId, id);
|
||||
if (!_maps.TryGetValue(key, out var stored))
|
||||
{
|
||||
return Task.FromResult(new PlatformCacheResult<FunctionMapCoverageResponse>(
|
||||
new FunctionMapCoverageResponse
|
||||
{
|
||||
TotalPaths = 0,
|
||||
ObservedPaths = 0,
|
||||
TotalExpectedCalls = 0,
|
||||
ObservedCalls = 0,
|
||||
CoverageRate = 0,
|
||||
UnexpectedSymbolCount = 0,
|
||||
AsOf = DateTimeOffset.UtcNow
|
||||
},
|
||||
DateTimeOffset.UtcNow, false, 0));
|
||||
}
|
||||
|
||||
// Compute coverage from empty observations (returns baseline stats)
|
||||
var stats = _claimVerifier.ComputeCoverage(stored.Predicate, []);
|
||||
|
||||
var response = new FunctionMapCoverageResponse
|
||||
{
|
||||
TotalPaths = stats.TotalPaths,
|
||||
ObservedPaths = stats.ObservedPaths,
|
||||
TotalExpectedCalls = stats.TotalExpectedCalls,
|
||||
ObservedCalls = stats.ObservedCalls,
|
||||
CoverageRate = stats.CoverageRate,
|
||||
UnexpectedSymbolCount = stats.UnexpectedSymbolCount,
|
||||
AsOf = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Task.FromResult(new PlatformCacheResult<FunctionMapCoverageResponse>(
|
||||
response, DateTimeOffset.UtcNow, false, 0));
|
||||
}
|
||||
|
||||
private static string TenantKey(string tenantId, string id) => $"{tenantId}:{id}";
|
||||
|
||||
private static string ComputeSha256(string input)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static FunctionMapSummary ToSummary(StoredFunctionMap stored) => new()
|
||||
{
|
||||
Id = stored.Id,
|
||||
ServiceName = stored.ServiceName,
|
||||
SbomRef = stored.SbomRef,
|
||||
PathCount = stored.Predicate.Predicate.ExpectedPaths.Count,
|
||||
CreatedAt = stored.CreatedAt,
|
||||
LastVerifiedAt = stored.LastVerifiedAt,
|
||||
CoverageStatus = stored.LastVerifiedAt.HasValue ? "verified" : "pending"
|
||||
};
|
||||
|
||||
private static FunctionMapDetail ToDetail(StoredFunctionMap stored) => new()
|
||||
{
|
||||
Id = stored.Id,
|
||||
ServiceName = stored.ServiceName,
|
||||
SbomRef = stored.SbomRef,
|
||||
PathCount = stored.Predicate.Predicate.ExpectedPaths.Count,
|
||||
CreatedAt = stored.CreatedAt,
|
||||
LastVerifiedAt = stored.LastVerifiedAt,
|
||||
Coverage = new FunctionMapCoverageDto
|
||||
{
|
||||
MinObservationRate = stored.Predicate.Predicate.Coverage.MinObservationRate,
|
||||
WindowSeconds = stored.Predicate.Predicate.Coverage.WindowSeconds,
|
||||
FailOnUnexpected = stored.Predicate.Predicate.Coverage.FailOnUnexpected
|
||||
},
|
||||
PredicateDigest = stored.PredicateDigest
|
||||
};
|
||||
|
||||
private sealed record StoredFunctionMap(
|
||||
string Id,
|
||||
string TenantId,
|
||||
string ServiceName,
|
||||
string SbomRef,
|
||||
FunctionMapPredicate Predicate,
|
||||
string PredicateDigest,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastVerifiedAt);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
|
||||
// Task: RLV-009 - Platform API: Function Map Endpoints
|
||||
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing function maps and executing verification.
|
||||
/// </summary>
|
||||
public interface IFunctionMapService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates and stores a function map from the provided request.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<FunctionMapDetail>> CreateAsync(
|
||||
PlatformRequestContext context,
|
||||
CreateFunctionMapRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all function maps for the current tenant.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<IReadOnlyList<FunctionMapSummary>>> ListAsync(
|
||||
PlatformRequestContext context,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a function map by ID.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<FunctionMapDetail?>> GetByIdAsync(
|
||||
PlatformRequestContext context,
|
||||
string id,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a function map by ID.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<bool>> DeleteAsync(
|
||||
PlatformRequestContext context,
|
||||
string id,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies observations against a function map.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<FunctionMapVerifyResponse>> VerifyAsync(
|
||||
PlatformRequestContext context,
|
||||
string id,
|
||||
VerifyFunctionMapRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets coverage statistics for a function map.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<FunctionMapCoverageResponse>> GetCoverageAsync(
|
||||
PlatformRequestContext context,
|
||||
string id,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
|
||||
// Task: TASK-07 - Platform API Endpoints
|
||||
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for policy interop operations (export, import, validate, evaluate).
|
||||
/// </summary>
|
||||
public interface IPolicyInteropService
|
||||
{
|
||||
Task<PolicyExportApiResponse> ExportAsync(
|
||||
PlatformRequestContext context,
|
||||
PolicyExportApiRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<PolicyImportApiResponse> ImportAsync(
|
||||
PlatformRequestContext context,
|
||||
PolicyImportApiRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<PolicyValidateApiResponse> ValidateAsync(
|
||||
PlatformRequestContext context,
|
||||
PolicyValidateApiRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<PolicyEvaluateApiResponse> EvaluateAsync(
|
||||
PlatformRequestContext context,
|
||||
PolicyEvaluateApiRequest request,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-005 - Platform API Endpoints (Score Evaluate)
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for unified score evaluation.
|
||||
/// </summary>
|
||||
public interface IScoreEvaluationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a unified score.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<ScoreEvaluateResponse>> EvaluateAsync(
|
||||
PlatformRequestContext context,
|
||||
ScoreEvaluateRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a score by ID.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<ScoreEvaluateResponse?>> GetByIdAsync(
|
||||
PlatformRequestContext context,
|
||||
string scoreId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists available weight manifests.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<IReadOnlyList<WeightManifestSummary>>> ListWeightManifestsAsync(
|
||||
PlatformRequestContext context,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific weight manifest.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<WeightManifestDetail?>> GetWeightManifestAsync(
|
||||
PlatformRequestContext context,
|
||||
string version,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective weight manifest for a date.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<WeightManifestDetail?>> GetEffectiveWeightManifestAsync(
|
||||
PlatformRequestContext context,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets score history for a CVE.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<IReadOnlyList<ScoreHistoryRecord>>> GetHistoryAsync(
|
||||
PlatformRequestContext context,
|
||||
string cveId,
|
||||
string? purl,
|
||||
int limit,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// TSF-011: Replay and verification methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets a signed replay log for a score.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<ScoreReplayResponse?>> GetReplayAsync(
|
||||
PlatformRequestContext context,
|
||||
string scoreId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a replay log by re-executing the computation.
|
||||
/// </summary>
|
||||
Task<PlatformCacheResult<ScoreVerifyResponse>> VerifyReplayAsync(
|
||||
PlatformRequestContext context,
|
||||
ScoreVerifyRequest request,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: Score persistence store interface
|
||||
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for score history persistence.
|
||||
/// </summary>
|
||||
public interface IScoreHistoryStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists a score history record.
|
||||
/// </summary>
|
||||
Task StoreAsync(ScoreHistoryRecord record, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a score record by ID within a tenant.
|
||||
/// </summary>
|
||||
Task<ScoreHistoryRecord?> GetByIdAsync(string id, string tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves score history for a given CVE (optionally filtered by purl).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ScoreHistoryRecord>> GetHistoryAsync(
|
||||
string tenantId,
|
||||
string cveId,
|
||||
string? purl,
|
||||
int limit,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the most recent score for a given CVE (optionally filtered by purl).
|
||||
/// </summary>
|
||||
Task<ScoreHistoryRecord?> GetLatestAsync(
|
||||
string tenantId,
|
||||
string cveId,
|
||||
string? purl,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: Score persistence store - in-memory fallback
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IScoreHistoryStore"/> for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryScoreHistoryStore : IScoreHistoryStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ScoreHistoryRecord> _records = new();
|
||||
|
||||
public Task StoreAsync(ScoreHistoryRecord record, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
_records.TryAdd(record.Id, record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<ScoreHistoryRecord?> GetByIdAsync(string id, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
_records.TryGetValue(id, out var record);
|
||||
if (record is not null && record.TenantId != tenantId)
|
||||
{
|
||||
record = null;
|
||||
}
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ScoreHistoryRecord>> GetHistoryAsync(
|
||||
string tenantId,
|
||||
string cveId,
|
||||
string? purl,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var results = _records.Values
|
||||
.Where(r => r.TenantId == tenantId && r.CveId == cveId)
|
||||
.Where(r => purl is null || r.Purl == purl)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<ScoreHistoryRecord>>(results);
|
||||
}
|
||||
|
||||
public Task<ScoreHistoryRecord?> GetLatestAsync(
|
||||
string tenantId,
|
||||
string cveId,
|
||||
string? purl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var record = _records.Values
|
||||
.Where(r => r.TenantId == tenantId && r.CveId == cveId)
|
||||
.Where(r => purl is null || r.Purl == purl)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
|
||||
// Task: TASK-07 - Platform API Endpoints
|
||||
|
||||
using System.Text.Json;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Policy.Interop.Abstractions;
|
||||
using StellaOps.Policy.Interop.Contracts;
|
||||
using StellaOps.Policy.Interop.Export;
|
||||
using StellaOps.Policy.Interop.Import;
|
||||
using StellaOps.Policy.Interop.Rego;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Platform-level service orchestrating policy interop operations.
|
||||
/// Delegates to the Policy.Interop library for format handling.
|
||||
/// </summary>
|
||||
public sealed class PolicyInteropService : IPolicyInteropService
|
||||
{
|
||||
private readonly JsonPolicyExporter _jsonExporter = new();
|
||||
private readonly JsonPolicyImporter _jsonImporter = new();
|
||||
private readonly RegoPolicyImporter _regoImporter = new();
|
||||
private readonly RegoCodeGenerator _regoGenerator = new();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public async Task<PolicyExportApiResponse> ExportAsync(
|
||||
PlatformRequestContext context,
|
||||
PolicyExportApiRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyContent))
|
||||
{
|
||||
return new PolicyExportApiResponse
|
||||
{
|
||||
Success = false,
|
||||
Format = request.Format,
|
||||
Diagnostics = [new PolicyInteropDiagnostic { Severity = "error", Code = "EMPTY_INPUT", Message = "Policy content is required." }]
|
||||
};
|
||||
}
|
||||
|
||||
PolicyPackDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonSerializer.Deserialize<PolicyPackDocument>(request.PolicyContent, JsonOptions)!;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new PolicyExportApiResponse
|
||||
{
|
||||
Success = false,
|
||||
Format = request.Format,
|
||||
Diagnostics = [new PolicyInteropDiagnostic { Severity = "error", Code = "PARSE_ERROR", Message = $"Failed to parse policy JSON: {ex.Message}" }]
|
||||
};
|
||||
}
|
||||
|
||||
if (request.Format.Equals("rego", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var result = _regoGenerator.Generate(doc, new RegoGenerationOptions
|
||||
{
|
||||
Environment = request.Environment,
|
||||
IncludeRemediation = request.IncludeRemediation,
|
||||
IncludeComments = request.IncludeComments,
|
||||
PackageName = request.PackageName ?? "stella.release"
|
||||
});
|
||||
|
||||
return new PolicyExportApiResponse
|
||||
{
|
||||
Success = result.Success,
|
||||
Format = "rego",
|
||||
Content = result.RegoSource,
|
||||
Digest = result.Digest,
|
||||
Diagnostics = result.Warnings?.Select(w =>
|
||||
new PolicyInteropDiagnostic { Severity = "warning", Code = "EXPORT_WARN", Message = w }).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
// JSON export
|
||||
var exported = await _jsonExporter.ExportToJsonAsync(doc, new PolicyExportRequest
|
||||
{
|
||||
Format = "json",
|
||||
Environment = request.Environment,
|
||||
IncludeRemediation = request.IncludeRemediation
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
return new PolicyExportApiResponse
|
||||
{
|
||||
Success = true,
|
||||
Format = "json",
|
||||
Content = JsonPolicyExporter.SerializeToString(exported),
|
||||
Digest = exported.Metadata.Digest
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PolicyImportApiResponse> ImportAsync(
|
||||
PlatformRequestContext context,
|
||||
PolicyImportApiRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Content))
|
||||
{
|
||||
return new PolicyImportApiResponse
|
||||
{
|
||||
Success = false,
|
||||
Diagnostics = [new PolicyInteropDiagnostic { Severity = "error", Code = "EMPTY_INPUT", Message = "Content is required." }]
|
||||
};
|
||||
}
|
||||
|
||||
var format = request.Format ?? FormatDetector.Detect(request.Content);
|
||||
|
||||
PolicyImportResult result;
|
||||
if (format == PolicyFormats.Rego)
|
||||
{
|
||||
result = await _regoImporter.ImportFromStringAsync(request.Content, new PolicyImportOptions
|
||||
{
|
||||
ValidateOnly = request.ValidateOnly,
|
||||
MergeStrategy = request.MergeStrategy
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await _jsonImporter.ImportFromStringAsync(request.Content, new PolicyImportOptions
|
||||
{
|
||||
ValidateOnly = request.ValidateOnly,
|
||||
MergeStrategy = request.MergeStrategy
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new PolicyImportApiResponse
|
||||
{
|
||||
Success = result.Success,
|
||||
SourceFormat = result.DetectedFormat ?? format,
|
||||
GatesImported = result.GateCount,
|
||||
RulesImported = result.RuleCount,
|
||||
NativeMapped = result.Mapping?.NativeMapped.Count ?? 0,
|
||||
OpaEvaluated = result.Mapping?.OpaEvaluated.Count ?? 0,
|
||||
Diagnostics = result.Diagnostics.Select(d =>
|
||||
new PolicyInteropDiagnostic { Severity = d.Severity, Code = d.Code, Message = d.Message }).ToList(),
|
||||
Mappings = result.Mapping != null
|
||||
? result.Mapping.NativeMapped.Select(r => new PolicyImportMappingDto { SourceRule = r, TargetGateType = "native", MappedToNative = true })
|
||||
.Concat(result.Mapping.OpaEvaluated.Select(r => new PolicyImportMappingDto { SourceRule = r, TargetGateType = "opa", MappedToNative = false }))
|
||||
.ToList()
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
public Task<PolicyValidateApiResponse> ValidateAsync(
|
||||
PlatformRequestContext context,
|
||||
PolicyValidateApiRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Content))
|
||||
{
|
||||
return Task.FromResult(new PolicyValidateApiResponse
|
||||
{
|
||||
Valid = false,
|
||||
Errors = [new PolicyInteropDiagnostic { Severity = "error", Code = "EMPTY_INPUT", Message = "Content is required." }]
|
||||
});
|
||||
}
|
||||
|
||||
var format = request.Format ?? FormatDetector.Detect(request.Content);
|
||||
var errors = new List<PolicyInteropDiagnostic>();
|
||||
var warnings = new List<PolicyInteropDiagnostic>();
|
||||
|
||||
if (format == PolicyFormats.Rego)
|
||||
{
|
||||
// Basic Rego validation
|
||||
if (!request.Content.Contains("package "))
|
||||
{
|
||||
errors.Add(new PolicyInteropDiagnostic { Severity = "error", Code = "REGO_NO_PKG", Message = "Rego source must contain a package declaration." });
|
||||
}
|
||||
|
||||
if (!request.Content.Contains("deny"))
|
||||
{
|
||||
warnings.Add(new PolicyInteropDiagnostic { Severity = "warning", Code = "REGO_NO_DENY", Message = "Rego source does not contain deny rules." });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = JsonSerializer.Deserialize<PolicyPackDocument>(request.Content, JsonOptions);
|
||||
if (doc == null)
|
||||
{
|
||||
errors.Add(new PolicyInteropDiagnostic { Severity = "error", Code = "NULL_DOC", Message = "Deserialized document is null." });
|
||||
}
|
||||
else
|
||||
{
|
||||
if (doc.ApiVersion != PolicyPackDocument.ApiVersionV2)
|
||||
{
|
||||
if (request.Strict)
|
||||
errors.Add(new PolicyInteropDiagnostic { Severity = "error", Code = "VERSION_MISMATCH", Message = $"Expected apiVersion '{PolicyPackDocument.ApiVersionV2}', got '{doc.ApiVersion}'." });
|
||||
else
|
||||
warnings.Add(new PolicyInteropDiagnostic { Severity = "warning", Code = "VERSION_MISMATCH", Message = $"apiVersion '{doc.ApiVersion}' is not v2; expected '{PolicyPackDocument.ApiVersionV2}'." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(doc.Metadata?.Name))
|
||||
{
|
||||
errors.Add(new PolicyInteropDiagnostic { Severity = "error", Code = "MISSING_NAME", Message = "metadata.name is required." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(doc.Metadata?.Version))
|
||||
{
|
||||
errors.Add(new PolicyInteropDiagnostic { Severity = "error", Code = "MISSING_VERSION", Message = "metadata.version is required." });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add(new PolicyInteropDiagnostic { Severity = "error", Code = "PARSE_ERROR", Message = $"Invalid JSON: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
var valid = errors.Count == 0 && (!request.Strict || warnings.Count == 0);
|
||||
|
||||
return Task.FromResult(new PolicyValidateApiResponse
|
||||
{
|
||||
Valid = valid,
|
||||
DetectedFormat = format,
|
||||
Errors = errors.Count > 0 ? errors : null,
|
||||
Warnings = warnings.Count > 0 ? warnings : null
|
||||
});
|
||||
}
|
||||
|
||||
public Task<PolicyEvaluateApiResponse> EvaluateAsync(
|
||||
PlatformRequestContext context,
|
||||
PolicyEvaluateApiRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyContent))
|
||||
{
|
||||
return Task.FromResult(new PolicyEvaluateApiResponse
|
||||
{
|
||||
Decision = "block",
|
||||
Gates = [],
|
||||
Remediation = [new RemediationHintDto { Code = "INPUT_ERROR", Title = "Policy content is required", Severity = "critical" }]
|
||||
});
|
||||
}
|
||||
|
||||
var format = request.Format ?? FormatDetector.Detect(request.PolicyContent);
|
||||
var environment = request.Environment ?? request.Input?.Environment;
|
||||
|
||||
PolicyPackDocument? doc = null;
|
||||
if (format == PolicyFormats.Rego)
|
||||
{
|
||||
// Import Rego to native model, then evaluate
|
||||
var importResult = _regoImporter.ImportFromStringAsync(request.PolicyContent, new PolicyImportOptions(), ct).GetAwaiter().GetResult();
|
||||
if (!importResult.Success || importResult.Document == null)
|
||||
{
|
||||
return Task.FromResult(new PolicyEvaluateApiResponse
|
||||
{
|
||||
Decision = "block",
|
||||
Gates = [],
|
||||
Remediation = [new RemediationHintDto { Code = "IMPORT_ERROR", Title = "Failed to parse Rego policy", Severity = "critical" }]
|
||||
});
|
||||
}
|
||||
doc = importResult.Document;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
doc = JsonSerializer.Deserialize<PolicyPackDocument>(request.PolicyContent, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.FromResult(new PolicyEvaluateApiResponse
|
||||
{
|
||||
Decision = "block",
|
||||
Gates = [],
|
||||
Remediation = [new RemediationHintDto { Code = "PARSE_ERROR", Title = "Failed to parse policy JSON", Severity = "critical" }]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (doc?.Spec?.Gates == null)
|
||||
{
|
||||
return Task.FromResult(new PolicyEvaluateApiResponse { Decision = "allow", Gates = [] });
|
||||
}
|
||||
|
||||
var input = request.Input;
|
||||
var gateResults = new List<GateEvaluationDto>();
|
||||
var remediationHints = new List<RemediationHintDto>();
|
||||
var allPassed = true;
|
||||
|
||||
foreach (var gate in doc.Spec.Gates.Where(g => g.Enabled))
|
||||
{
|
||||
var (passed, reason) = EvaluateGate(gate, input, environment);
|
||||
|
||||
gateResults.Add(new GateEvaluationDto
|
||||
{
|
||||
GateId = gate.Id ?? "unknown",
|
||||
GateType = gate.Type ?? "unknown",
|
||||
Passed = passed,
|
||||
Reason = reason
|
||||
});
|
||||
|
||||
if (!passed)
|
||||
{
|
||||
allPassed = false;
|
||||
if (request.IncludeRemediation && gate.Remediation != null)
|
||||
{
|
||||
remediationHints.Add(new RemediationHintDto
|
||||
{
|
||||
Code = gate.Remediation.Code,
|
||||
Title = gate.Remediation.Title,
|
||||
Severity = gate.Remediation.Severity,
|
||||
Actions = gate.Remediation.Actions.Select(a =>
|
||||
new RemediationActionDto { Type = a.Type, Description = a.Description, Command = a.Command }).ToList()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var decision = allPassed ? "allow" : "block";
|
||||
|
||||
return Task.FromResult(new PolicyEvaluateApiResponse
|
||||
{
|
||||
Decision = decision,
|
||||
Gates = gateResults,
|
||||
Remediation = remediationHints.Count > 0 ? remediationHints : null
|
||||
});
|
||||
}
|
||||
|
||||
private static (bool Passed, string? Reason) EvaluateGate(
|
||||
PolicyGateDefinition gate,
|
||||
PolicyEvaluationInputDto? input,
|
||||
string? environment)
|
||||
{
|
||||
if (input == null) return (false, "No evaluation input provided");
|
||||
|
||||
return gate.Type switch
|
||||
{
|
||||
PolicyGateTypes.CvssThreshold => EvaluateCvssGate(gate, input, environment),
|
||||
PolicyGateTypes.SignatureRequired => EvaluateSignatureGate(input),
|
||||
PolicyGateTypes.EvidenceFreshness => EvaluateFreshnessGate(input),
|
||||
PolicyGateTypes.SbomPresence => EvaluateSbomGate(input),
|
||||
PolicyGateTypes.MinimumConfidence => EvaluateConfidenceGate(gate, input),
|
||||
PolicyGateTypes.UnknownsBudget => EvaluateUnknownsGate(gate, input),
|
||||
PolicyGateTypes.ReachabilityRequirement => EvaluateReachabilityGate(input),
|
||||
_ => (true, null) // Unknown gate types pass by default
|
||||
};
|
||||
}
|
||||
|
||||
private static (bool, string?) EvaluateCvssGate(PolicyGateDefinition gate, PolicyEvaluationInputDto input, string? environment)
|
||||
{
|
||||
var threshold = 7.0;
|
||||
if (environment != null && gate.Environments?.TryGetValue(environment, out var envConfig) == true)
|
||||
{
|
||||
if (envConfig.TryGetValue("threshold", out var envThreshold) && envThreshold is JsonElement el)
|
||||
threshold = el.GetDouble();
|
||||
}
|
||||
else if (gate.Config?.TryGetValue("threshold", out var configThreshold) == true && configThreshold is JsonElement cel)
|
||||
{
|
||||
threshold = cel.GetDouble();
|
||||
}
|
||||
|
||||
if (input.CvssScore == null) return (true, null);
|
||||
if (input.CvssScore.Value >= threshold)
|
||||
return (false, $"CVSS score {input.CvssScore.Value} exceeds threshold {threshold}");
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static (bool, string?) EvaluateSignatureGate(PolicyEvaluationInputDto input)
|
||||
{
|
||||
if (input.DsseVerified != true)
|
||||
return (false, "DSSE signature missing or invalid");
|
||||
if (input.RekorVerified != true)
|
||||
return (false, "Rekor inclusion proof missing");
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static (bool, string?) EvaluateFreshnessGate(PolicyEvaluationInputDto input)
|
||||
{
|
||||
if (input.FreshnessVerified != true)
|
||||
return (false, "Evidence freshness verification failed");
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static (bool, string?) EvaluateSbomGate(PolicyEvaluationInputDto input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.SbomDigest))
|
||||
return (false, "Canonical SBOM digest missing");
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static (bool, string?) EvaluateConfidenceGate(PolicyGateDefinition gate, PolicyEvaluationInputDto input)
|
||||
{
|
||||
var threshold = 0.75;
|
||||
if (gate.Config?.TryGetValue("threshold", out var configThreshold) == true && configThreshold is JsonElement el)
|
||||
threshold = el.GetDouble();
|
||||
|
||||
if (input.Confidence == null) return (true, null);
|
||||
if (input.Confidence.Value < threshold)
|
||||
return (false, $"Confidence {input.Confidence.Value} below threshold {threshold}");
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static (bool, string?) EvaluateUnknownsGate(PolicyGateDefinition gate, PolicyEvaluationInputDto input)
|
||||
{
|
||||
var threshold = 0.6;
|
||||
if (gate.Config?.TryGetValue("threshold", out var configThreshold) == true && configThreshold is JsonElement el)
|
||||
threshold = el.GetDouble();
|
||||
|
||||
if (input.UnknownsRatio == null) return (true, null);
|
||||
if (input.UnknownsRatio.Value > threshold)
|
||||
return (false, $"Unknowns ratio {input.UnknownsRatio.Value} exceeds budget {threshold}");
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static (bool, string?) EvaluateReachabilityGate(PolicyEvaluationInputDto input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.ReachabilityStatus))
|
||||
return (false, "Reachability proof required but missing");
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: Score persistence store implementation
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IScoreHistoryStore"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresScoreHistoryStore : IScoreHistoryStore
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresScoreHistoryStore> _logger;
|
||||
|
||||
private const string InsertSql = """
|
||||
INSERT INTO signals.score_history (
|
||||
id, tenant_id, project_id, cve_id, purl,
|
||||
score, band, weights_version, signal_snapshot,
|
||||
replay_digest, created_at
|
||||
) VALUES (
|
||||
@id, @tenant_id, @project_id, @cve_id, @purl,
|
||||
@score, @band, @weights_version, @signal_snapshot::jsonb,
|
||||
@replay_digest, @created_at
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""";
|
||||
|
||||
private const string SelectByIdSql = """
|
||||
SELECT id, tenant_id, project_id, cve_id, purl,
|
||||
score, band, weights_version, signal_snapshot,
|
||||
replay_digest, created_at
|
||||
FROM signals.score_history
|
||||
WHERE id = @id AND tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
private const string SelectHistorySql = """
|
||||
SELECT id, tenant_id, project_id, cve_id, purl,
|
||||
score, band, weights_version, signal_snapshot,
|
||||
replay_digest, created_at
|
||||
FROM signals.score_history
|
||||
WHERE tenant_id = @tenant_id AND cve_id = @cve_id
|
||||
AND (@purl IS NULL OR purl = @purl)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
private const string SelectLatestSql = """
|
||||
SELECT id, tenant_id, project_id, cve_id, purl,
|
||||
score, band, weights_version, signal_snapshot,
|
||||
replay_digest, created_at
|
||||
FROM signals.score_history
|
||||
WHERE tenant_id = @tenant_id AND cve_id = @cve_id
|
||||
AND (@purl IS NULL OR purl = @purl)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
public PostgresScoreHistoryStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresScoreHistoryStore>? logger = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PostgresScoreHistoryStore>.Instance;
|
||||
}
|
||||
|
||||
public async Task StoreAsync(ScoreHistoryRecord record, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(InsertSql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", record.Id);
|
||||
cmd.Parameters.AddWithValue("tenant_id", record.TenantId);
|
||||
cmd.Parameters.AddWithValue("project_id", record.ProjectId);
|
||||
cmd.Parameters.AddWithValue("cve_id", record.CveId);
|
||||
cmd.Parameters.AddWithValue("purl", record.Purl is null ? DBNull.Value : record.Purl);
|
||||
cmd.Parameters.AddWithValue("score", record.Score);
|
||||
cmd.Parameters.AddWithValue("band", record.Band);
|
||||
cmd.Parameters.AddWithValue("weights_version", record.WeightsVersion);
|
||||
cmd.Parameters.AddWithValue("signal_snapshot", record.SignalSnapshot);
|
||||
cmd.Parameters.AddWithValue("replay_digest", record.ReplayDigest);
|
||||
cmd.Parameters.AddWithValue("created_at", record.CreatedAt);
|
||||
|
||||
try
|
||||
{
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("Stored score history record {Id} for tenant {TenantId}",
|
||||
record.Id, record.TenantId);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, "23505", StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug("Score history record {Id} already exists, skipping", record.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScoreHistoryRecord?> GetByIdAsync(string id, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(SelectByIdSql, conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return MapRecord(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScoreHistoryRecord>> GetHistoryAsync(
|
||||
string tenantId,
|
||||
string cveId,
|
||||
string? purl,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(SelectHistorySql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("cve_id", cveId);
|
||||
cmd.Parameters.AddWithValue("purl", purl is null ? DBNull.Value : purl);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
|
||||
var results = new List<ScoreHistoryRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ScoreHistoryRecord?> GetLatestAsync(
|
||||
string tenantId,
|
||||
string cveId,
|
||||
string? purl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(SelectLatestSql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("cve_id", cveId);
|
||||
cmd.Parameters.AddWithValue("purl", purl is null ? DBNull.Value : purl);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return MapRecord(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ScoreHistoryRecord MapRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ScoreHistoryRecord
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
TenantId = reader.GetString(1),
|
||||
ProjectId = reader.GetString(2),
|
||||
CveId = reader.GetString(3),
|
||||
Purl = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
Score = reader.GetDecimal(5),
|
||||
Band = reader.GetString(6),
|
||||
WeightsVersion = reader.GetString(7),
|
||||
SignalSnapshot = reader.GetString(8),
|
||||
ReplayDigest = reader.GetString(9),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-005 - Platform API Endpoints (Score Evaluate)
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
using StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for unified score evaluation.
|
||||
/// </summary>
|
||||
public sealed class ScoreEvaluationService : IScoreEvaluationService
|
||||
{
|
||||
private readonly IUnifiedScoreService _unifiedScoreService;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly IReplayLogBuilder _replayLogBuilder;
|
||||
private readonly IReplayVerifier _replayVerifier;
|
||||
private readonly IScoreHistoryStore _scoreHistoryStore;
|
||||
private readonly ILogger<ScoreEvaluationService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ScoreEvaluationService(
|
||||
IUnifiedScoreService unifiedScoreService,
|
||||
IWeightManifestLoader manifestLoader,
|
||||
IReplayLogBuilder replayLogBuilder,
|
||||
IReplayVerifier replayVerifier,
|
||||
IScoreHistoryStore scoreHistoryStore,
|
||||
ILogger<ScoreEvaluationService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_unifiedScoreService = unifiedScoreService ?? throw new ArgumentNullException(nameof(unifiedScoreService));
|
||||
_manifestLoader = manifestLoader ?? throw new ArgumentNullException(nameof(manifestLoader));
|
||||
_replayLogBuilder = replayLogBuilder ?? throw new ArgumentNullException(nameof(replayLogBuilder));
|
||||
_replayVerifier = replayVerifier ?? throw new ArgumentNullException(nameof(replayVerifier));
|
||||
_scoreHistoryStore = scoreHistoryStore ?? throw new ArgumentNullException(nameof(scoreHistoryStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PlatformCacheResult<ScoreEvaluateResponse>> EvaluateAsync(
|
||||
PlatformRequestContext context,
|
||||
ScoreEvaluateRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Evaluating score for tenant {TenantId}", context.TenantId);
|
||||
|
||||
var ewsInput = BuildEwsInput(request);
|
||||
var signalSnapshot = BuildSignalSnapshot(request);
|
||||
var options = request.Options ?? new ScoreEvaluateOptions();
|
||||
|
||||
var unifiedRequest = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = ewsInput,
|
||||
SignalSnapshot = signalSnapshot,
|
||||
WeightManifestVersion = options.WeightSetId,
|
||||
CveId = request.CveId,
|
||||
Purl = request.Purl,
|
||||
IncludeDeltaIfPresent = options.IncludeDelta
|
||||
};
|
||||
|
||||
var result = await _unifiedScoreService.ComputeAsync(unifiedRequest, ct).ConfigureAwait(false);
|
||||
|
||||
var scoreId = GenerateScoreId(context, result);
|
||||
|
||||
var response = MapToResponse(scoreId, result, options);
|
||||
|
||||
// Persist score to history store
|
||||
try
|
||||
{
|
||||
var historyRecord = new ScoreHistoryRecord
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TenantId = context.TenantId,
|
||||
ProjectId = context.ProjectId ?? "",
|
||||
CveId = request.CveId ?? "unknown",
|
||||
Purl = request.Purl,
|
||||
Score = (decimal)result.Score / 100m,
|
||||
Band = result.UnknownsBand?.ToString() ?? "Unknown",
|
||||
WeightsVersion = result.WeightManifestRef.Version,
|
||||
SignalSnapshot = System.Text.Json.JsonSerializer.Serialize(signalSnapshot),
|
||||
ReplayDigest = result.EwsDigest,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
await _scoreHistoryStore.StoreAsync(historyRecord, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to persist score history record for {ScoreId}", scoreId);
|
||||
}
|
||||
|
||||
return new PlatformCacheResult<ScoreEvaluateResponse>(
|
||||
response,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0);
|
||||
}
|
||||
|
||||
public async Task<PlatformCacheResult<ScoreEvaluateResponse?>> GetByIdAsync(
|
||||
PlatformRequestContext context,
|
||||
string scoreId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Looking up score {ScoreId} for tenant {TenantId}", scoreId, context.TenantId);
|
||||
|
||||
var record = await _scoreHistoryStore.GetByIdAsync(scoreId, context.TenantId, ct).ConfigureAwait(false);
|
||||
if (record is not null)
|
||||
{
|
||||
var response = MapHistoryRecordToResponse(record);
|
||||
return new PlatformCacheResult<ScoreEvaluateResponse?>(
|
||||
response,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: true,
|
||||
CacheTtlSeconds: 3600);
|
||||
}
|
||||
|
||||
return new PlatformCacheResult<ScoreEvaluateResponse?>(
|
||||
null,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0);
|
||||
}
|
||||
|
||||
public async Task<PlatformCacheResult<IReadOnlyList<ScoreHistoryRecord>>> GetHistoryAsync(
|
||||
PlatformRequestContext context,
|
||||
string cveId,
|
||||
string? purl,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Getting score history for CVE {CveId} tenant {TenantId}", cveId, context.TenantId);
|
||||
|
||||
var records = await _scoreHistoryStore.GetHistoryAsync(
|
||||
context.TenantId, cveId, purl, limit, ct).ConfigureAwait(false);
|
||||
|
||||
return new PlatformCacheResult<IReadOnlyList<ScoreHistoryRecord>>(
|
||||
records,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0);
|
||||
}
|
||||
|
||||
public async Task<PlatformCacheResult<IReadOnlyList<WeightManifestSummary>>> ListWeightManifestsAsync(
|
||||
PlatformRequestContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var versions = await _manifestLoader.ListVersionsAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var summaries = new List<WeightManifestSummary>();
|
||||
foreach (var version in versions)
|
||||
{
|
||||
var manifest = await _manifestLoader.LoadAsync(version, ct).ConfigureAwait(false);
|
||||
if (manifest is not null)
|
||||
{
|
||||
summaries.Add(new WeightManifestSummary
|
||||
{
|
||||
Version = manifest.Version,
|
||||
EffectiveFrom = manifest.EffectiveFrom,
|
||||
Profile = manifest.Profile,
|
||||
ContentHash = manifest.ContentHash,
|
||||
Description = manifest.Description
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new PlatformCacheResult<IReadOnlyList<WeightManifestSummary>>(
|
||||
summaries,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 300);
|
||||
}
|
||||
|
||||
public async Task<PlatformCacheResult<WeightManifestDetail?>> GetWeightManifestAsync(
|
||||
PlatformRequestContext context,
|
||||
string version,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var manifest = await _manifestLoader.LoadAsync(version, ct).ConfigureAwait(false);
|
||||
if (manifest is null)
|
||||
{
|
||||
return new PlatformCacheResult<WeightManifestDetail?>(
|
||||
null,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0);
|
||||
}
|
||||
|
||||
var detail = MapToManifestDetail(manifest);
|
||||
|
||||
return new PlatformCacheResult<WeightManifestDetail?>(
|
||||
detail,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: true,
|
||||
CacheTtlSeconds: 3600);
|
||||
}
|
||||
|
||||
public async Task<PlatformCacheResult<WeightManifestDetail?>> GetEffectiveWeightManifestAsync(
|
||||
PlatformRequestContext context,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var manifest = await _manifestLoader.GetEffectiveAsync(asOf, ct).ConfigureAwait(false);
|
||||
if (manifest is null)
|
||||
{
|
||||
return new PlatformCacheResult<WeightManifestDetail?>(
|
||||
null,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0);
|
||||
}
|
||||
|
||||
var detail = MapToManifestDetail(manifest);
|
||||
|
||||
return new PlatformCacheResult<WeightManifestDetail?>(
|
||||
detail,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: true,
|
||||
CacheTtlSeconds: 3600);
|
||||
}
|
||||
|
||||
private static EvidenceWeightedScoreInput BuildEwsInput(ScoreEvaluateRequest request)
|
||||
{
|
||||
var signals = request.Signals;
|
||||
return new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = request.CveId ?? request.Purl ?? "anonymous",
|
||||
Rch = signals?.Reachability ?? 0.5,
|
||||
Rts = signals?.Runtime ?? 0.5,
|
||||
Bkp = signals?.Backport ?? 0.5,
|
||||
Xpl = signals?.Exploit ?? 0.5,
|
||||
Src = signals?.Source ?? 0.5,
|
||||
Mit = signals?.Mitigation ?? 0.0
|
||||
};
|
||||
}
|
||||
|
||||
private static SignalSnapshot? BuildSignalSnapshot(ScoreEvaluateRequest request)
|
||||
{
|
||||
var signals = request.Signals;
|
||||
if (signals is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SignalSnapshot
|
||||
{
|
||||
Vex = request.VexRefs?.Count > 0 ? SignalState.Present() : SignalState.NotQueried(),
|
||||
Epss = SignalState.Present(), // Assume EPSS is always available
|
||||
Reachability = signals.Reachability.HasValue ? SignalState.Present() : SignalState.NotQueried(),
|
||||
Runtime = signals.Runtime.HasValue || request.RuntimeWitnesses?.Count > 0
|
||||
? SignalState.Present()
|
||||
: SignalState.NotQueried(),
|
||||
Backport = signals.Backport.HasValue ? SignalState.Present() : SignalState.NotQueried(),
|
||||
Sbom = !string.IsNullOrEmpty(request.SbomRef) ? SignalState.Present() : SignalState.NotQueried(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateScoreId(PlatformRequestContext context, UnifiedScoreResult result)
|
||||
{
|
||||
var input = $"{context.TenantId}:{result.EwsDigest}:{result.ComputedAt:O}";
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"score_{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
private static ScoreEvaluateResponse MapToResponse(
|
||||
string scoreId,
|
||||
UnifiedScoreResult result,
|
||||
ScoreEvaluateOptions options)
|
||||
{
|
||||
return new ScoreEvaluateResponse
|
||||
{
|
||||
ScoreId = scoreId,
|
||||
ScoreValue = result.Score,
|
||||
Bucket = result.Bucket.ToString(),
|
||||
UnknownsFraction = result.UnknownsFraction,
|
||||
UnknownsBand = result.UnknownsBand?.ToString(),
|
||||
Unknowns = null, // TODO: Extract from signal snapshot
|
||||
ProofRef = null, // TODO: Generate proof bundle reference
|
||||
Breakdown = options.IncludeBreakdown
|
||||
? result.Breakdown.Select(d => new DimensionBreakdown
|
||||
{
|
||||
Dimension = d.Dimension,
|
||||
Symbol = d.Symbol,
|
||||
InputValue = d.InputValue,
|
||||
Weight = d.Weight,
|
||||
Contribution = d.Contribution
|
||||
}).ToList()
|
||||
: null,
|
||||
Guardrails = new GuardrailsApplied
|
||||
{
|
||||
SpeculativeCap = result.Guardrails.SpeculativeCap,
|
||||
NotAffectedCap = result.Guardrails.NotAffectedCap,
|
||||
RuntimeFloor = result.Guardrails.RuntimeFloor,
|
||||
OriginalScore = result.Guardrails.OriginalScore,
|
||||
AdjustedScore = result.Guardrails.AdjustedScore
|
||||
},
|
||||
DeltaIfPresent = options.IncludeDelta && result.DeltaIfPresent is not null
|
||||
? result.DeltaIfPresent.Select(d => new SignalDeltaResponse
|
||||
{
|
||||
Signal = d.Signal,
|
||||
MinImpact = d.MinImpact,
|
||||
MaxImpact = d.MaxImpact,
|
||||
Weight = d.Weight,
|
||||
Description = d.Description
|
||||
}).ToList()
|
||||
: null,
|
||||
Conflicts = result.Conflicts?.Select(c => new SignalConflictResponse
|
||||
{
|
||||
SignalA = c.SignalA,
|
||||
SignalB = c.SignalB,
|
||||
ConflictType = c.ConflictType,
|
||||
Description = c.Description
|
||||
}).ToList(),
|
||||
WeightManifest = new WeightManifestReference
|
||||
{
|
||||
Version = result.WeightManifestRef.Version,
|
||||
ContentHash = result.WeightManifestRef.ContentHash
|
||||
},
|
||||
EwsDigest = result.EwsDigest,
|
||||
DeterminizationFingerprint = result.DeterminizationFingerprint,
|
||||
ComputedAt = result.ComputedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static ScoreEvaluateResponse MapHistoryRecordToResponse(ScoreHistoryRecord record)
|
||||
{
|
||||
return new ScoreEvaluateResponse
|
||||
{
|
||||
ScoreId = record.Id.ToString(),
|
||||
ScoreValue = (int)(record.Score * 100m),
|
||||
Bucket = record.Band,
|
||||
UnknownsFraction = null,
|
||||
UnknownsBand = record.Band,
|
||||
EwsDigest = record.ReplayDigest,
|
||||
ComputedAt = record.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static WeightManifestDetail MapToManifestDetail(WeightManifest manifest)
|
||||
{
|
||||
var weights = manifest.ToEvidenceWeights();
|
||||
|
||||
return new WeightManifestDetail
|
||||
{
|
||||
SchemaVersion = manifest.SchemaVersion,
|
||||
Version = manifest.Version,
|
||||
EffectiveFrom = manifest.EffectiveFrom,
|
||||
Profile = manifest.Profile,
|
||||
ContentHash = manifest.ContentHash,
|
||||
Description = manifest.Description,
|
||||
Weights = new WeightDefinitionsDto
|
||||
{
|
||||
Legacy = new LegacyWeightsDto
|
||||
{
|
||||
Rch = weights.Rch,
|
||||
Rts = weights.Rts,
|
||||
Bkp = weights.Bkp,
|
||||
Xpl = weights.Xpl,
|
||||
Src = weights.Src,
|
||||
Mit = weights.Mit
|
||||
},
|
||||
Advisory = new AdvisoryWeightsDto
|
||||
{
|
||||
Cvss = weights.Cvss,
|
||||
Epss = weights.Epss,
|
||||
Reachability = weights.Reachability,
|
||||
ExploitMaturity = weights.ExploitMaturity,
|
||||
PatchProof = weights.PatchProof
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#region TSF-011: Replay and verification
|
||||
|
||||
public async Task<PlatformCacheResult<ScoreReplayResponse?>> GetReplayAsync(
|
||||
PlatformRequestContext context,
|
||||
string scoreId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Looking up replay for score {ScoreId} for tenant {TenantId}", scoreId, context.TenantId);
|
||||
|
||||
var record = await _scoreHistoryStore.GetByIdAsync(scoreId, context.TenantId, ct).ConfigureAwait(false);
|
||||
if (record is not null)
|
||||
{
|
||||
var replayResponse = new ScoreReplayResponse
|
||||
{
|
||||
SignedReplayLogDsse = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes(record.SignalSnapshot)),
|
||||
RekorInclusion = null,
|
||||
CanonicalInputs = new List<CanonicalInputDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "signal_snapshot",
|
||||
Sha256 = record.ReplayDigest
|
||||
}
|
||||
},
|
||||
Transforms = new List<TransformStepDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "evidence_weighted_score",
|
||||
Version = record.WeightsVersion
|
||||
}
|
||||
},
|
||||
AlgebraSteps = new List<AlgebraStepDto>(),
|
||||
FinalScore = (int)(record.Score * 100m),
|
||||
ComputedAt = record.CreatedAt
|
||||
};
|
||||
|
||||
return new PlatformCacheResult<ScoreReplayResponse?>(
|
||||
replayResponse,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: true,
|
||||
CacheTtlSeconds: 3600);
|
||||
}
|
||||
|
||||
return new PlatformCacheResult<ScoreReplayResponse?>(
|
||||
null,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0);
|
||||
}
|
||||
|
||||
public async Task<PlatformCacheResult<ScoreVerifyResponse>> VerifyReplayAsync(
|
||||
PlatformRequestContext context,
|
||||
ScoreVerifyRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Verifying replay for tenant {TenantId}", context.TenantId);
|
||||
|
||||
// TODO: Decode the DSSE envelope and extract the replay log
|
||||
// For now, return a placeholder verification result
|
||||
|
||||
// Build original inputs from request
|
||||
var ewsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "replay-verify",
|
||||
Rch = request.OriginalInputs?.Signals?.Reachability ?? 0.5,
|
||||
Rts = request.OriginalInputs?.Signals?.Runtime ?? 0.5,
|
||||
Bkp = request.OriginalInputs?.Signals?.Backport ?? 0.5,
|
||||
Xpl = request.OriginalInputs?.Signals?.Exploit ?? 0.5,
|
||||
Src = request.OriginalInputs?.Signals?.Source ?? 0.5,
|
||||
Mit = request.OriginalInputs?.Signals?.Mitigation ?? 0.0
|
||||
};
|
||||
|
||||
// Execute the computation
|
||||
var unifiedRequest = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = ewsInput,
|
||||
WeightManifestVersion = request.OriginalInputs?.WeightManifestVersion
|
||||
};
|
||||
|
||||
var result = await _unifiedScoreService.ComputeAsync(unifiedRequest, ct).ConfigureAwait(false);
|
||||
|
||||
// Build verification response
|
||||
var response = new ScoreVerifyResponse
|
||||
{
|
||||
Verified = true, // Placeholder - needs actual DSSE verification
|
||||
ReplayedScore = result.Score,
|
||||
OriginalScore = result.Score, // TODO: Extract from DSSE payload
|
||||
ScoreMatches = true,
|
||||
DigestMatches = true,
|
||||
SignatureValid = null, // TODO: Verify DSSE signature
|
||||
RekorProofValid = request.VerifyRekor ? null : null, // TODO: Verify Rekor proof
|
||||
Differences = null,
|
||||
VerifiedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return new PlatformCacheResult<ScoreVerifyResponse>(
|
||||
response,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.EvidenceThread\StellaOps.ReleaseOrchestrator.EvidenceThread.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Platform.Analytics\StellaOps.Platform.Analytics.csproj" />
|
||||
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />
|
||||
<ProjectReference Include="..\..\Policy\__Libraries\StellaOps.Policy.Interop\StellaOps.Policy.Interop.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
|
||||
// Task: RLV-009 - Platform API: Function Map Endpoints
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.Scanner.Reachability.FunctionMap;
|
||||
using StellaOps.Scanner.Reachability.FunctionMap.Verification;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class FunctionMapEndpointsTests
|
||||
{
|
||||
private readonly IFunctionMapService _service;
|
||||
private readonly PlatformRequestContext _context = new("test-tenant", "test-actor", null);
|
||||
|
||||
public FunctionMapEndpointsTests()
|
||||
{
|
||||
var verifier = new ClaimVerifier(NullLogger<ClaimVerifier>.Instance);
|
||||
_service = new FunctionMapService(verifier);
|
||||
}
|
||||
|
||||
#region Create
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_ReturnsDetailWithId()
|
||||
{
|
||||
var request = new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://registry/app@sha256:abc123",
|
||||
ServiceName = "myservice",
|
||||
HotFunctions = ["SSL_read", "SSL_write"]
|
||||
};
|
||||
|
||||
var result = await _service.CreateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.StartsWith("fmap-", result.Value.Id);
|
||||
Assert.Equal("myservice", result.Value.ServiceName);
|
||||
Assert.Equal("oci://registry/app@sha256:abc123", result.Value.SbomRef);
|
||||
Assert.StartsWith("sha256:", result.Value.PredicateDigest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_WithOptions_SetsThresholds()
|
||||
{
|
||||
var request = new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://registry/app@sha256:abc123",
|
||||
ServiceName = "myservice",
|
||||
Options = new FunctionMapOptionsDto
|
||||
{
|
||||
MinObservationRate = 0.90,
|
||||
WindowSeconds = 3600,
|
||||
FailOnUnexpected = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.CreateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value.Coverage);
|
||||
Assert.Equal(0.90, result.Value.Coverage!.MinObservationRate);
|
||||
Assert.Equal(3600, result.Value.Coverage.WindowSeconds);
|
||||
Assert.True(result.Value.Coverage.FailOnUnexpected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region List
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_Empty_ReturnsEmptyList()
|
||||
{
|
||||
var svc = CreateFreshService();
|
||||
var result = await svc.ListAsync(_context);
|
||||
|
||||
Assert.Empty(result.Value);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_AfterCreate_ReturnsCreatedMap()
|
||||
{
|
||||
var svc = CreateFreshService();
|
||||
await svc.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "svc1"
|
||||
});
|
||||
|
||||
var result = await svc.ListAsync(_context);
|
||||
|
||||
Assert.Single(result.Value);
|
||||
Assert.Equal("svc1", result.Value[0].ServiceName);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_MultiTenant_IsolatesByTenant()
|
||||
{
|
||||
var svc = CreateFreshService();
|
||||
var tenantA = new PlatformRequestContext("tenant-a", "actor", null);
|
||||
var tenantB = new PlatformRequestContext("tenant-b", "actor", null);
|
||||
|
||||
await svc.CreateAsync(tenantA, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://a",
|
||||
ServiceName = "svc-a"
|
||||
});
|
||||
await svc.CreateAsync(tenantB, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://b",
|
||||
ServiceName = "svc-b"
|
||||
});
|
||||
|
||||
var resultA = await svc.ListAsync(tenantA);
|
||||
var resultB = await svc.ListAsync(tenantB);
|
||||
|
||||
Assert.Single(resultA.Value);
|
||||
Assert.Equal("svc-a", resultA.Value[0].ServiceName);
|
||||
Assert.Single(resultB.Value);
|
||||
Assert.Equal("svc-b", resultB.Value[0].ServiceName);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_WithPagination_RespectsLimitAndOffset()
|
||||
{
|
||||
var svc = CreateFreshService();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await svc.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = $"oci://test{i}",
|
||||
ServiceName = $"svc{i}"
|
||||
});
|
||||
}
|
||||
|
||||
var page1 = await svc.ListAsync(_context, limit: 2, offset: 0);
|
||||
var page2 = await svc.ListAsync(_context, limit: 2, offset: 2);
|
||||
|
||||
Assert.Equal(2, page1.Value.Count);
|
||||
Assert.Equal(2, page2.Value.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetById
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetById_Existing_ReturnsDetail()
|
||||
{
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "myservice"
|
||||
});
|
||||
|
||||
var result = await _service.GetByIdAsync(_context, created.Value.Id);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal(created.Value.Id, result.Value!.Id);
|
||||
Assert.Equal("myservice", result.Value.ServiceName);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetById_NonExistent_ReturnsNull()
|
||||
{
|
||||
var result = await _service.GetByIdAsync(_context, "fmap-nonexistent");
|
||||
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delete
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Delete_Existing_ReturnsTrue()
|
||||
{
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "todelete"
|
||||
});
|
||||
|
||||
var result = await _service.DeleteAsync(_context, created.Value.Id);
|
||||
|
||||
Assert.True(result.Value);
|
||||
|
||||
var getResult = await _service.GetByIdAsync(_context, created.Value.Id);
|
||||
Assert.Null(getResult.Value);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Delete_NonExistent_ReturnsFalse()
|
||||
{
|
||||
var result = await _service.DeleteAsync(_context, "fmap-nonexistent");
|
||||
|
||||
Assert.False(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Verify
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_EmptyObservations_ReturnsNotVerified()
|
||||
{
|
||||
// Empty function map with no expected paths: ClaimVerifier returns rate=0.0 which
|
||||
// is below the default threshold (0.95), so verification fails.
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "empty-map"
|
||||
});
|
||||
|
||||
var verifyRequest = new VerifyFunctionMapRequest
|
||||
{
|
||||
Observations = []
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(_context, created.Value.Id, verifyRequest);
|
||||
|
||||
Assert.False(result.Value.Verified);
|
||||
Assert.Equal(0, result.Value.PathCount);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_NonExistentMap_ReturnsNotVerified()
|
||||
{
|
||||
var verifyRequest = new VerifyFunctionMapRequest
|
||||
{
|
||||
Observations = []
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(_context, "fmap-nonexistent", verifyRequest);
|
||||
|
||||
Assert.False(result.Value.Verified);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_UpdatesLastVerifiedTimestamp()
|
||||
{
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "verify-ts"
|
||||
});
|
||||
|
||||
Assert.Null(created.Value.LastVerifiedAt);
|
||||
|
||||
await _service.VerifyAsync(_context, created.Value.Id, new VerifyFunctionMapRequest());
|
||||
|
||||
var updated = await _service.GetByIdAsync(_context, created.Value.Id);
|
||||
Assert.NotNull(updated.Value!.LastVerifiedAt);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_WithOptions_PassesOverrides()
|
||||
{
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "verify-opts",
|
||||
Options = new FunctionMapOptionsDto { MinObservationRate = 0.99 }
|
||||
});
|
||||
|
||||
var verifyRequest = new VerifyFunctionMapRequest
|
||||
{
|
||||
Options = new VerifyOptionsDto
|
||||
{
|
||||
MinObservationRateOverride = 0.50
|
||||
},
|
||||
Observations = []
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(_context, created.Value.Id, verifyRequest);
|
||||
|
||||
// With 0 expected paths and 0 observations, rate=0.0 which is below even the
|
||||
// overridden 0.50 threshold. Verify the override target is applied correctly.
|
||||
Assert.Equal(0.50, result.Value.TargetRate);
|
||||
Assert.Equal(0.0, result.Value.ObservationRate);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetCoverage_EmptyMap_ReturnsZeroCoverage()
|
||||
{
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "cov-empty"
|
||||
});
|
||||
|
||||
var result = await _service.GetCoverageAsync(_context, created.Value.Id);
|
||||
|
||||
Assert.Equal(0, result.Value.TotalPaths);
|
||||
Assert.Equal(0, result.Value.ObservedPaths);
|
||||
Assert.Equal(0, result.Value.TotalExpectedCalls);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetCoverage_NonExistentMap_ReturnsZero()
|
||||
{
|
||||
var result = await _service.GetCoverageAsync(_context, "fmap-nonexistent");
|
||||
|
||||
Assert.Equal(0, result.Value.TotalPaths);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_PredicateDigest_IsDeterministic()
|
||||
{
|
||||
var svc1 = CreateFreshService();
|
||||
var svc2 = CreateFreshService();
|
||||
|
||||
var request = new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://registry/app@sha256:deterministic",
|
||||
ServiceName = "determ-svc",
|
||||
Options = new FunctionMapOptionsDto
|
||||
{
|
||||
MinObservationRate = 0.95,
|
||||
WindowSeconds = 1800
|
||||
}
|
||||
};
|
||||
|
||||
var result1 = await svc1.CreateAsync(_context, request);
|
||||
var result2 = await svc2.CreateAsync(_context, request);
|
||||
|
||||
// Same inputs should produce same predicate digest
|
||||
Assert.Equal(result1.Value.PredicateDigest, result2.Value.PredicateDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static FunctionMapService CreateFreshService()
|
||||
{
|
||||
var verifier = new ClaimVerifier(NullLogger<ClaimVerifier>.Instance);
|
||||
return new FunctionMapService(verifier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
|
||||
// Task: TASK-07 - Platform API Endpoints
|
||||
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class PolicyInteropEndpointsTests
|
||||
{
|
||||
private readonly IPolicyInteropService _service = new PolicyInteropService();
|
||||
private readonly PlatformRequestContext _context = new("test-tenant", "test-actor", null);
|
||||
|
||||
private const string GoldenPolicyJson = """
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v2",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test-policy", "version": "1.0.0" },
|
||||
"spec": {
|
||||
"settings": { "defaultAction": "block" },
|
||||
"gates": [
|
||||
{
|
||||
"id": "cvss-threshold",
|
||||
"type": "CvssThresholdGate",
|
||||
"enabled": true,
|
||||
"config": { "threshold": 7.0 }
|
||||
},
|
||||
{
|
||||
"id": "signature-required",
|
||||
"type": "SignatureRequiredGate",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private const string SampleRego = """
|
||||
package stella.release
|
||||
|
||||
import rego.v1
|
||||
|
||||
default allow := false
|
||||
|
||||
deny contains msg if {
|
||||
input.cvss.score >= 7.0
|
||||
msg := "CVSS too high"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
not input.dsse.verified
|
||||
msg := "DSSE missing"
|
||||
}
|
||||
|
||||
allow if { count(deny) == 0 }
|
||||
""";
|
||||
|
||||
#region Export
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Export_ToJson_ReturnsContent()
|
||||
{
|
||||
var request = new PolicyExportApiRequest
|
||||
{
|
||||
PolicyContent = GoldenPolicyJson,
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(_context, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("json", result.Format);
|
||||
Assert.NotNull(result.Content);
|
||||
Assert.Contains("policy.stellaops.io/v2", result.Content);
|
||||
Assert.NotNull(result.Digest);
|
||||
Assert.StartsWith("sha256:", result.Digest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Export_ToRego_ReturnsRegoSource()
|
||||
{
|
||||
var request = new PolicyExportApiRequest
|
||||
{
|
||||
PolicyContent = GoldenPolicyJson,
|
||||
Format = "rego"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(_context, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("rego", result.Format);
|
||||
Assert.NotNull(result.Content);
|
||||
Assert.Contains("package stella.release", result.Content);
|
||||
Assert.Contains("deny contains msg if", result.Content);
|
||||
Assert.Contains("input.cvss.score >= 7.0", result.Content);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Export_EmptyContent_ReturnsFalse()
|
||||
{
|
||||
var request = new PolicyExportApiRequest
|
||||
{
|
||||
PolicyContent = "",
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(_context, request);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Diagnostics);
|
||||
Assert.Contains(result.Diagnostics, d => d.Code == "EMPTY_INPUT");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Export_InvalidJson_ReturnsFalse()
|
||||
{
|
||||
var request = new PolicyExportApiRequest
|
||||
{
|
||||
PolicyContent = "not json",
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(_context, request);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Diagnostics);
|
||||
Assert.Contains(result.Diagnostics, d => d.Code == "PARSE_ERROR");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Export_ToRego_WithEnvironment_UsesEnvThresholds()
|
||||
{
|
||||
var policyWithEnvs = """
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v2",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": {
|
||||
"settings": { "defaultAction": "block" },
|
||||
"gates": [{
|
||||
"id": "cvss",
|
||||
"type": "CvssThresholdGate",
|
||||
"enabled": true,
|
||||
"config": { "threshold": 7.0 },
|
||||
"environments": {
|
||||
"staging": { "threshold": 8.0 }
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var request = new PolicyExportApiRequest
|
||||
{
|
||||
PolicyContent = policyWithEnvs,
|
||||
Format = "rego",
|
||||
Environment = "staging"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(_context, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("input.cvss.score >= 8.0", result.Content);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Import
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Import_JsonContent_ReturnsSuccess()
|
||||
{
|
||||
var request = new PolicyImportApiRequest
|
||||
{
|
||||
Content = GoldenPolicyJson,
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ImportAsync(_context, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("json", result.SourceFormat);
|
||||
Assert.Equal(2, result.GatesImported);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Import_RegoContent_MapsToNativeGates()
|
||||
{
|
||||
var request = new PolicyImportApiRequest
|
||||
{
|
||||
Content = SampleRego,
|
||||
Format = "rego"
|
||||
};
|
||||
|
||||
var result = await _service.ImportAsync(_context, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("rego", result.SourceFormat);
|
||||
Assert.True(result.NativeMapped > 0);
|
||||
Assert.NotNull(result.Mappings);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Import_EmptyContent_ReturnsFalse()
|
||||
{
|
||||
var request = new PolicyImportApiRequest { Content = "" };
|
||||
|
||||
var result = await _service.ImportAsync(_context, request);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Diagnostics);
|
||||
Assert.Contains(result.Diagnostics, d => d.Code == "EMPTY_INPUT");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_ValidJson_ReturnsValid()
|
||||
{
|
||||
var request = new PolicyValidateApiRequest
|
||||
{
|
||||
Content = GoldenPolicyJson,
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ValidateAsync(_context, request);
|
||||
|
||||
Assert.True(result.Valid);
|
||||
Assert.Equal("json", result.DetectedFormat);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_ValidRego_ReturnsValid()
|
||||
{
|
||||
var request = new PolicyValidateApiRequest
|
||||
{
|
||||
Content = SampleRego,
|
||||
Format = "rego"
|
||||
};
|
||||
|
||||
var result = await _service.ValidateAsync(_context, request);
|
||||
|
||||
Assert.True(result.Valid);
|
||||
Assert.Equal("rego", result.DetectedFormat);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_InvalidJson_ReturnsInvalid()
|
||||
{
|
||||
var request = new PolicyValidateApiRequest
|
||||
{
|
||||
Content = "not valid json",
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ValidateAsync(_context, request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.NotNull(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_Strict_WrongVersion_ReturnsInvalid()
|
||||
{
|
||||
var v1Policy = """
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v1",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": { "settings": {}, "gates": [] }
|
||||
}
|
||||
""";
|
||||
|
||||
var request = new PolicyValidateApiRequest
|
||||
{
|
||||
Content = v1Policy,
|
||||
Format = "json",
|
||||
Strict = true
|
||||
};
|
||||
|
||||
var result = await _service.ValidateAsync(_context, request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.NotNull(result.Errors);
|
||||
Assert.Contains(result.Errors, e => e.Code == "VERSION_MISMATCH");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Evaluate
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_AllGatesPass_ReturnsAllow()
|
||||
{
|
||||
var request = new PolicyEvaluateApiRequest
|
||||
{
|
||||
PolicyContent = GoldenPolicyJson,
|
||||
Input = new PolicyEvaluationInputDto
|
||||
{
|
||||
CvssScore = 5.0,
|
||||
DsseVerified = true,
|
||||
RekorVerified = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Equal("allow", result.Decision);
|
||||
Assert.NotNull(result.Gates);
|
||||
Assert.All(result.Gates, g => Assert.True(g.Passed));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_CvssExceeds_ReturnsBlock()
|
||||
{
|
||||
var request = new PolicyEvaluateApiRequest
|
||||
{
|
||||
PolicyContent = GoldenPolicyJson,
|
||||
Input = new PolicyEvaluationInputDto
|
||||
{
|
||||
CvssScore = 9.0,
|
||||
DsseVerified = true,
|
||||
RekorVerified = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Equal("block", result.Decision);
|
||||
Assert.Contains(result.Gates!, g => !g.Passed && g.GateType == "CvssThresholdGate");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_SignatureMissing_ReturnsBlock()
|
||||
{
|
||||
var request = new PolicyEvaluateApiRequest
|
||||
{
|
||||
PolicyContent = GoldenPolicyJson,
|
||||
Input = new PolicyEvaluationInputDto
|
||||
{
|
||||
CvssScore = 5.0,
|
||||
DsseVerified = false,
|
||||
RekorVerified = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Equal("block", result.Decision);
|
||||
Assert.Contains(result.Gates!, g => !g.Passed && g.GateType == "SignatureRequiredGate");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_EmptyPolicy_ReturnsBlock()
|
||||
{
|
||||
var request = new PolicyEvaluateApiRequest
|
||||
{
|
||||
PolicyContent = "",
|
||||
Input = new PolicyEvaluationInputDto { CvssScore = 5.0 }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Equal("block", result.Decision);
|
||||
Assert.NotNull(result.Remediation);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_RegoPolicy_ImportsThenEvaluates()
|
||||
{
|
||||
var request = new PolicyEvaluateApiRequest
|
||||
{
|
||||
PolicyContent = SampleRego,
|
||||
Format = "rego",
|
||||
Input = new PolicyEvaluationInputDto
|
||||
{
|
||||
CvssScore = 5.0,
|
||||
DsseVerified = true,
|
||||
RekorVerified = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
// After importing the Rego, the CVSS gate with threshold 7.0 should pass for score 5.0
|
||||
Assert.Equal("allow", result.Decision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-005 - Platform API Endpoints (Score Evaluate)
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
using StellaOps.Signals.UnifiedScore.Replay;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for score evaluation endpoints via <see cref="ScoreEvaluationService"/>.
|
||||
/// Covers TSF-005 (score evaluate endpoints) and TSF-011 (replay/verify endpoints).
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class ScoreEndpointsTests
|
||||
{
|
||||
private readonly IUnifiedScoreService _unifiedScoreService;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly IReplayLogBuilder _replayLogBuilder;
|
||||
private readonly IReplayVerifier _replayVerifier;
|
||||
private readonly ScoreEvaluationService _service;
|
||||
private readonly PlatformRequestContext _context = new("test-tenant", "test-actor", null);
|
||||
|
||||
public ScoreEndpointsTests()
|
||||
{
|
||||
_unifiedScoreService = Substitute.For<IUnifiedScoreService>();
|
||||
_manifestLoader = Substitute.For<IWeightManifestLoader>();
|
||||
_replayLogBuilder = Substitute.For<IReplayLogBuilder>();
|
||||
_replayVerifier = Substitute.For<IReplayVerifier>();
|
||||
|
||||
// Default manifest setup
|
||||
var defaultManifest = WeightManifest.FromEvidenceWeights(EvidenceWeights.Default, "v-test");
|
||||
_manifestLoader
|
||||
.ListVersionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<string> { "v-test" });
|
||||
_manifestLoader
|
||||
.LoadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(defaultManifest);
|
||||
_manifestLoader
|
||||
.LoadLatestAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(defaultManifest);
|
||||
_manifestLoader
|
||||
.GetEffectiveAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(defaultManifest);
|
||||
|
||||
// Default unified score result
|
||||
SetupDefaultScoreResult();
|
||||
|
||||
_service = new ScoreEvaluationService(
|
||||
_unifiedScoreService,
|
||||
_manifestLoader,
|
||||
_replayLogBuilder,
|
||||
_replayVerifier,
|
||||
NullLogger<ScoreEvaluationService>.Instance);
|
||||
}
|
||||
|
||||
#region TSF-005: EvaluateAsync
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithSignals_ReturnsScoreResponse()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Signals = new SignalInputs
|
||||
{
|
||||
Reachability = 0.8,
|
||||
Runtime = 0.7,
|
||||
Backport = 0.5,
|
||||
Exploit = 0.3,
|
||||
Source = 0.6,
|
||||
Mitigation = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.StartsWith("score_", result.Value.ScoreId);
|
||||
Assert.InRange(result.Value.ScoreValue, 0, 100);
|
||||
Assert.NotEmpty(result.Value.Bucket);
|
||||
Assert.NotEmpty(result.Value.EwsDigest);
|
||||
Assert.False(result.Cached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithBreakdownOption_ReturnsBreakdown()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs
|
||||
{
|
||||
Reachability = 0.9,
|
||||
Runtime = 0.8
|
||||
},
|
||||
Options = new ScoreEvaluateOptions { IncludeBreakdown = true }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value.Breakdown);
|
||||
Assert.NotEmpty(result.Value.Breakdown);
|
||||
Assert.All(result.Value.Breakdown, b =>
|
||||
{
|
||||
Assert.NotEmpty(b.Dimension);
|
||||
Assert.NotEmpty(b.Symbol);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithoutBreakdownOption_ExcludesBreakdown()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 },
|
||||
Options = new ScoreEvaluateOptions { IncludeBreakdown = false }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Null(result.Value.Breakdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsGuardrails()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs
|
||||
{
|
||||
Reachability = 0.5,
|
||||
Runtime = 0.5
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value.Guardrails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsWeightManifestReference()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value.WeightManifest);
|
||||
Assert.NotEmpty(result.Value.WeightManifest.Version);
|
||||
Assert.NotEmpty(result.Value.WeightManifest.ContentHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsComputedAt()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.True(result.Value.ComputedAt <= DateTimeOffset.UtcNow);
|
||||
Assert.True(result.Value.ComputedAt > DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DifferentTenants_ProduceDifferentScoreIds()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5, Runtime = 0.5 }
|
||||
};
|
||||
|
||||
var tenantA = new PlatformRequestContext("tenant-a", "actor", null);
|
||||
var tenantB = new PlatformRequestContext("tenant-b", "actor", null);
|
||||
|
||||
var resultA = await _service.EvaluateAsync(tenantA, request);
|
||||
var resultB = await _service.EvaluateAsync(tenantB, request);
|
||||
|
||||
Assert.NotEqual(resultA.Value.ScoreId, resultB.Value.ScoreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithDeltaOption_IncludesDelta()
|
||||
{
|
||||
SetupScoreResultWithDelta();
|
||||
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 },
|
||||
Options = new ScoreEvaluateOptions { IncludeDelta = true }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value.DeltaIfPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithNullSignals_UsesDefaults()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
CveId = "CVE-2024-0001"
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.InRange(result.Value.ScoreValue, 0, 100);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-005: GetByIdAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_NonExistent_ReturnsNull()
|
||||
{
|
||||
var result = await _service.GetByIdAsync(_context, "score_nonexistent");
|
||||
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-005: ListWeightManifestsAsync
|
||||
|
||||
[Fact]
|
||||
public async Task ListWeightManifestsAsync_ReturnsSummaries()
|
||||
{
|
||||
var result = await _service.ListWeightManifestsAsync(_context);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Single(result.Value);
|
||||
Assert.Equal("v-test", result.Value[0].Version);
|
||||
Assert.Equal("production", result.Value[0].Profile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListWeightManifestsAsync_Empty_ReturnsEmptyList()
|
||||
{
|
||||
_manifestLoader
|
||||
.ListVersionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<string>());
|
||||
|
||||
var result = await _service.ListWeightManifestsAsync(_context);
|
||||
|
||||
Assert.Empty(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-005: GetWeightManifestAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetWeightManifestAsync_Existing_ReturnsDetail()
|
||||
{
|
||||
var result = await _service.GetWeightManifestAsync(_context, "v-test");
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal("v-test", result.Value.Version);
|
||||
Assert.NotNull(result.Value.Weights);
|
||||
Assert.NotNull(result.Value.Weights.Legacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetWeightManifestAsync_NonExistent_ReturnsNull()
|
||||
{
|
||||
_manifestLoader
|
||||
.LoadAsync("v-nonexistent", Arg.Any<CancellationToken>())
|
||||
.Returns((WeightManifest?)null);
|
||||
|
||||
var result = await _service.GetWeightManifestAsync(_context, "v-nonexistent");
|
||||
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetWeightManifestAsync_ReturnsLegacyWeights()
|
||||
{
|
||||
var result = await _service.GetWeightManifestAsync(_context, "v-test");
|
||||
|
||||
Assert.NotNull(result.Value?.Weights.Legacy);
|
||||
Assert.True(result.Value.Weights.Legacy.Rch > 0);
|
||||
Assert.True(result.Value.Weights.Legacy.Rts > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetWeightManifestAsync_ReturnsAdvisoryWeights()
|
||||
{
|
||||
var result = await _service.GetWeightManifestAsync(_context, "v-test");
|
||||
|
||||
Assert.NotNull(result.Value?.Weights.Advisory);
|
||||
Assert.True(result.Value.Weights.Advisory.Cvss > 0);
|
||||
Assert.True(result.Value.Weights.Advisory.Epss > 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-005: GetEffectiveWeightManifestAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveWeightManifestAsync_ReturnsManifest()
|
||||
{
|
||||
var result = await _service.GetEffectiveWeightManifestAsync(
|
||||
_context, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal("v-test", result.Value.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveWeightManifestAsync_NoManifest_ReturnsNull()
|
||||
{
|
||||
_manifestLoader
|
||||
.GetEffectiveAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns((WeightManifest?)null);
|
||||
|
||||
var result = await _service.GetEffectiveWeightManifestAsync(
|
||||
_context, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-011: VerifyReplayAsync
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyReplayAsync_WithInputs_ReturnsVerificationResult()
|
||||
{
|
||||
var request = new ScoreVerifyRequest
|
||||
{
|
||||
SignedReplayLogDsse = "eyJwYXlsb2FkIjoiZXlKMFpYTjBJam9pYUdWc2JHOGlmUT09In0=",
|
||||
OriginalInputs = new ScoreVerifyInputs
|
||||
{
|
||||
Signals = new SignalInputs
|
||||
{
|
||||
Reachability = 0.8,
|
||||
Runtime = 0.7,
|
||||
Backport = 0.5,
|
||||
Exploit = 0.3,
|
||||
Source = 0.6,
|
||||
Mitigation = 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.VerifyReplayAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.True(result.Value.Verified);
|
||||
Assert.True(result.Value.ScoreMatches);
|
||||
Assert.True(result.Value.DigestMatches);
|
||||
Assert.InRange(result.Value.ReplayedScore, 0, 100);
|
||||
Assert.Equal(result.Value.ReplayedScore, result.Value.OriginalScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyReplayAsync_WithNullInputs_UsesDefaultSignals()
|
||||
{
|
||||
var request = new ScoreVerifyRequest
|
||||
{
|
||||
SignedReplayLogDsse = "eyJwYXlsb2FkIjoiZXlKMFpYTjBJam9pYUdWc2JHOGlmUT09In0=",
|
||||
OriginalInputs = null
|
||||
};
|
||||
|
||||
var result = await _service.VerifyReplayAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.InRange(result.Value.ReplayedScore, 0, 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyReplayAsync_ReturnsVerifiedAt()
|
||||
{
|
||||
var request = new ScoreVerifyRequest
|
||||
{
|
||||
SignedReplayLogDsse = "eyJwYXlsb2FkIjoiZXlKMFpYTjBJam9pYUdWc2JHOGlmUT09In0=",
|
||||
OriginalInputs = new ScoreVerifyInputs
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 }
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.VerifyReplayAsync(_context, request);
|
||||
|
||||
Assert.True(result.Value.VerifiedAt <= DateTimeOffset.UtcNow);
|
||||
Assert.True(result.Value.VerifiedAt > DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyReplayAsync_WithWeightVersion_UsesSpecifiedVersion()
|
||||
{
|
||||
var request = new ScoreVerifyRequest
|
||||
{
|
||||
SignedReplayLogDsse = "eyJwYXlsb2FkIjoiZXlKMFpYTjBJam9pYUdWc2JHOGlmUT09In0=",
|
||||
OriginalInputs = new ScoreVerifyInputs
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 },
|
||||
WeightManifestVersion = "v-test"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.VerifyReplayAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.True(result.Value.Verified);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-011: GetReplayAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetReplayAsync_NonExistent_ReturnsNull()
|
||||
{
|
||||
var result = await _service.GetReplayAsync(_context, "score_nonexistent");
|
||||
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-011: Deterministic Score Computation
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SameInputs_ProducesSameEwsDigest()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Signals = new SignalInputs
|
||||
{
|
||||
Reachability = 0.8,
|
||||
Runtime = 0.7,
|
||||
Backport = 0.5,
|
||||
Exploit = 0.3,
|
||||
Source = 0.6,
|
||||
Mitigation = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
var result1 = await _service.EvaluateAsync(_context, request);
|
||||
var result2 = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Equal(result1.Value.EwsDigest, result2.Value.EwsDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DifferentInputs_ProducesDifferentEwsDigest()
|
||||
{
|
||||
var request1 = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.8, Runtime = 0.7 }
|
||||
};
|
||||
var request2 = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.2, Runtime = 0.1 }
|
||||
};
|
||||
|
||||
// Setup different results for different inputs
|
||||
SetupScoreResultWithDigest("digest-high");
|
||||
var result1 = await _service.EvaluateAsync(_context, request1);
|
||||
|
||||
SetupScoreResultWithDigest("digest-low");
|
||||
var result2 = await _service.EvaluateAsync(_context, request2);
|
||||
|
||||
Assert.NotEqual(result1.Value.EwsDigest, result2.Value.EwsDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void SetupDefaultScoreResult()
|
||||
{
|
||||
var defaultResult = new UnifiedScoreResult
|
||||
{
|
||||
Score = 62,
|
||||
Bucket = ScoreBucket.Investigate,
|
||||
UnknownsFraction = 0.3,
|
||||
UnknownsBand = Signals.UnifiedScore.UnknownsBand.Adequate,
|
||||
Breakdown =
|
||||
[
|
||||
new DimensionContribution { Dimension = "Reachability", Symbol = "Rch", InputValue = 0.5, Weight = 0.30, Contribution = 15.0 },
|
||||
new DimensionContribution { Dimension = "Runtime", Symbol = "Rts", InputValue = 0.5, Weight = 0.25, Contribution = 12.5 },
|
||||
new DimensionContribution { Dimension = "Backport", Symbol = "Bkp", InputValue = 0.5, Weight = 0.15, Contribution = 7.5 },
|
||||
new DimensionContribution { Dimension = "Exploit", Symbol = "Xpl", InputValue = 0.5, Weight = 0.15, Contribution = 7.5 },
|
||||
new DimensionContribution { Dimension = "Source", Symbol = "Src", InputValue = 0.5, Weight = 0.10, Contribution = 5.0 },
|
||||
new DimensionContribution { Dimension = "Mitigation", Symbol = "Mit", InputValue = 0.0, Weight = 0.10, Contribution = 0.0 }
|
||||
],
|
||||
Guardrails = new AppliedGuardrails
|
||||
{
|
||||
SpeculativeCap = false,
|
||||
NotAffectedCap = false,
|
||||
RuntimeFloor = false,
|
||||
OriginalScore = 62,
|
||||
AdjustedScore = 62
|
||||
},
|
||||
WeightManifestRef = new WeightManifestRef
|
||||
{
|
||||
Version = "v-test",
|
||||
ContentHash = "sha256:abc123"
|
||||
},
|
||||
EwsDigest = "sha256:deterministic-digest-test",
|
||||
DeterminizationFingerprint = "fp:test-fingerprint",
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_unifiedScoreService
|
||||
.ComputeAsync(Arg.Any<UnifiedScoreRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(defaultResult);
|
||||
}
|
||||
|
||||
private void SetupScoreResultWithDelta()
|
||||
{
|
||||
var resultWithDelta = new UnifiedScoreResult
|
||||
{
|
||||
Score = 45,
|
||||
Bucket = ScoreBucket.ScheduleNext,
|
||||
Breakdown =
|
||||
[
|
||||
new DimensionContribution { Dimension = "Reachability", Symbol = "Rch", InputValue = 0.5, Weight = 0.30, Contribution = 15.0 }
|
||||
],
|
||||
Guardrails = new AppliedGuardrails
|
||||
{
|
||||
SpeculativeCap = false,
|
||||
NotAffectedCap = false,
|
||||
RuntimeFloor = false,
|
||||
OriginalScore = 45,
|
||||
AdjustedScore = 45
|
||||
},
|
||||
DeltaIfPresent =
|
||||
[
|
||||
new SignalDelta
|
||||
{
|
||||
Signal = "Runtime",
|
||||
MinImpact = -10.0,
|
||||
MaxImpact = 15.0,
|
||||
Weight = 0.25,
|
||||
Description = "Runtime signal could shift score by -10 to +15"
|
||||
}
|
||||
],
|
||||
WeightManifestRef = new WeightManifestRef
|
||||
{
|
||||
Version = "v-test",
|
||||
ContentHash = "sha256:abc123"
|
||||
},
|
||||
EwsDigest = "sha256:delta-digest",
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_unifiedScoreService
|
||||
.ComputeAsync(Arg.Any<UnifiedScoreRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(resultWithDelta);
|
||||
}
|
||||
|
||||
private void SetupScoreResultWithDigest(string digest)
|
||||
{
|
||||
var result = new UnifiedScoreResult
|
||||
{
|
||||
Score = 50,
|
||||
Bucket = ScoreBucket.Investigate,
|
||||
Breakdown =
|
||||
[
|
||||
new DimensionContribution { Dimension = "Reachability", Symbol = "Rch", InputValue = 0.5, Weight = 0.30, Contribution = 15.0 }
|
||||
],
|
||||
Guardrails = new AppliedGuardrails
|
||||
{
|
||||
SpeculativeCap = false,
|
||||
NotAffectedCap = false,
|
||||
RuntimeFloor = false,
|
||||
OriginalScore = 50,
|
||||
AdjustedScore = 50
|
||||
},
|
||||
WeightManifestRef = new WeightManifestRef
|
||||
{
|
||||
Version = "v-test",
|
||||
ContentHash = "sha256:abc123"
|
||||
},
|
||||
EwsDigest = digest,
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_unifiedScoreService
|
||||
.ComputeAsync(Arg.Any<UnifiedScoreRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -8,8 +8,14 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NSubstitute" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Platform.WebService\StellaOps.Platform.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="..\..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user