save checkpoint
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
@@ -6,13 +6,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
<!-- FrameworkReference Microsoft.AspNetCore.App is provided by Sdk.Web -->
|
||||
<!-- Logging.Abstractions and Options are provided by Sdk.Worker -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Worker.Health\StellaOps.Worker.Health.csproj" />
|
||||
|
||||
@@ -162,43 +162,6 @@ public sealed record Spdx3BuildExportResponseDto
|
||||
public BuildSigningInfoDto? Signing { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope DTO.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the payload type.
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base64-encoded payload.
|
||||
/// </summary>
|
||||
public required string PayloadBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the signatures.
|
||||
/// </summary>
|
||||
public required List<DsseSignatureDto> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature DTO.
|
||||
/// </summary>
|
||||
public sealed record DsseSignatureDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the key ID.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base64-encoded signature.
|
||||
/// </summary>
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build signing information DTO.
|
||||
/// </summary>
|
||||
|
||||
@@ -15,6 +15,8 @@ using StellaOps.Attestor.ProofChain.Signing;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
using StellaOps.Attestor.WebService.Options;
|
||||
using ProofChainDsseEnvelope = StellaOps.Attestor.ProofChain.Signing.DsseEnvelope;
|
||||
using ProofChainDsseSignature = StellaOps.Attestor.ProofChain.Signing.DsseSignature;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
|
||||
@@ -341,18 +343,18 @@ public class ExceptionController : ControllerBase
|
||||
ApprovalRoles = dto.ApprovalRoles
|
||||
};
|
||||
|
||||
private static DsseEnvelope MapToDomain(DsseEnvelopeDto dto) => new()
|
||||
private static ProofChainDsseEnvelope MapToDomain(DsseEnvelopeDto dto) => new()
|
||||
{
|
||||
PayloadType = dto.PayloadType,
|
||||
Payload = dto.Payload,
|
||||
Signatures = dto.Signatures.Select(s => new DsseSignature
|
||||
Signatures = dto.Signatures.Select(s => new ProofChainDsseSignature
|
||||
{
|
||||
KeyId = s.KeyId,
|
||||
Sig = s.Sig
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static DsseEnvelopeDto MapToDto(DsseEnvelope envelope) => new()
|
||||
private static DsseEnvelopeDto MapToDto(ProofChainDsseEnvelope envelope) => new()
|
||||
{
|
||||
PayloadType = envelope.PayloadType,
|
||||
Payload = envelope.Payload,
|
||||
|
||||
@@ -18,6 +18,9 @@ public sealed record SbomEntryId
|
||||
|
||||
public string SbomDigest { get; }
|
||||
|
||||
/// <summary>Alias for <see cref="SbomDigest"/>.</summary>
|
||||
public string Digest => SbomDigest;
|
||||
|
||||
public string Purl { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Attestor.ProofChain.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -19,6 +19,13 @@ public sealed partial class PostgresTrustVerdictRepository : ITrustVerdictReposi
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a <see cref="TrustVerdictEntity"/> from the current row of a <see cref="System.Data.Common.DbDataReader"/>.
|
||||
/// Delegates to <see cref="PostgresTrustVerdictReaderHelper.ReadEntity"/>.
|
||||
/// </summary>
|
||||
internal static TrustVerdictEntity ReadEntity(System.Data.Common.DbDataReader reader)
|
||||
=> PostgresTrustVerdictReaderHelper.ReadEntity(reader);
|
||||
|
||||
private async Task<IReadOnlyList<TrustVerdictEntity>> ExecuteQueryAsync(
|
||||
string sql,
|
||||
Guid tenantId,
|
||||
|
||||
@@ -298,7 +298,7 @@ public class SnapshotExporterTests
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
result.DurationMs.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -485,7 +485,7 @@ public class SnapshotImporterTests
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
result.DurationMs.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -570,7 +570,7 @@ public class SnapshotImporterTests
|
||||
// FakeTimeProvider for deterministic testing
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
file sealed class FakeTimeProvider : TimeProvider
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0210-T | DONE | Revalidated 2026-01-08 (xUnit1051 fixes). |
|
||||
| AUDIT-0210-A | DONE | Applied fixes 2026-01-08 (xUnit1051 fixes). |
|
||||
| RB-004-REKOR-OFFLINE-20260209 | DONE | Extended `OfflineVerifierTests` with deterministic valid/tampered Rekor proof fixtures and break-glass audit assertions. |
|
||||
| RB-008-ATTESTOR-OFFLINE-20260210 | DONE | Fixed `SnapshotExportImportTests` compile compatibility (`FakeTimeProvider` visibility and FluentAssertions API) and revalidated offline suite (`76/76` passing). |
|
||||
|
||||
@@ -255,7 +255,7 @@ public sealed class BinaryFingerprintStoreTests : IDisposable
|
||||
var breakdown = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, "build-id", ["e1", "e2", "e3", "e4", "e5"], "pkg:deb/x@1", true);
|
||||
|
||||
breakdown.Score.Should().BeLessOrEqualTo(0.99);
|
||||
breakdown.Score.Should().BeLessThanOrEqualTo(0.99);
|
||||
}
|
||||
|
||||
// ── Golden set management ─────────────────────────────────────────────
|
||||
|
||||
@@ -15,7 +15,7 @@ public sealed class FieldOwnershipValidatorTests
|
||||
ProofBundleId = new ProofBundleId("abc123"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor-001"),
|
||||
AnchorId = new TrustAnchorId(Guid.Parse("00000001-0001-0001-0001-000000000001")),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks =
|
||||
[
|
||||
@@ -38,7 +38,7 @@ public sealed class FieldOwnershipValidatorTests
|
||||
ProofBundleId = new ProofBundleId("min-123"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor-min"),
|
||||
AnchorId = new TrustAnchorId(Guid.Parse("00000002-0002-0002-0002-000000000002")),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks =
|
||||
[
|
||||
@@ -64,7 +64,7 @@ public sealed class FieldOwnershipValidatorTests
|
||||
public void ReceiptOwnershipMap_ContainsExpectedEntries()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
map.Entries.Should().HaveCountGreaterOrEqualTo(7);
|
||||
map.Entries.Should().HaveCountGreaterThanOrEqualTo(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -205,7 +205,7 @@ public sealed class FieldOwnershipValidatorTests
|
||||
ProofBundleId = new ProofBundleId("abc"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor"),
|
||||
AnchorId = new TrustAnchorId(Guid.Parse("00000003-0003-0003-0003-000000000003")),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks = []
|
||||
};
|
||||
@@ -225,7 +225,7 @@ public sealed class FieldOwnershipValidatorTests
|
||||
ProofBundleId = new ProofBundleId("abc"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor"),
|
||||
AnchorId = new TrustAnchorId(Guid.Parse("00000003-0003-0003-0003-000000000003")),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks =
|
||||
[
|
||||
|
||||
@@ -13,7 +13,7 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Receipts;
|
||||
|
||||
file sealed class TestSidebarMeterFactory : IMeterFactory
|
||||
sealed class TestSidebarMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options)
|
||||
|
||||
@@ -122,7 +122,7 @@ public class ScoreReplayServiceTests : IDisposable
|
||||
{
|
||||
var result = await _service.ReplayAsync(CreateRequest());
|
||||
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
result.DurationMs.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -494,8 +494,8 @@ public class ScoreReplayServiceTests : IDisposable
|
||||
var score = ScoreReplayService.ComputeScore(
|
||||
new Dictionary<string, string> { ["val"] = "0.5" }.ToImmutableDictionary());
|
||||
|
||||
score.Should().BeGreaterOrEqualTo(0m);
|
||||
score.Should().BeLessOrEqualTo(1m);
|
||||
score.Should().BeGreaterThanOrEqualTo(0m);
|
||||
score.Should().BeLessThanOrEqualTo(1m);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -218,7 +218,7 @@ public class UnknownsTriageScorerTests
|
||||
};
|
||||
|
||||
var result = _scorer.ComputeComposite(score);
|
||||
result.Should().BeGreaterOrEqualTo(0.0).And.BeLessOrEqualTo(1.0);
|
||||
result.Should().BeGreaterThanOrEqualTo(0.0).And.BeLessThanOrEqualTo(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Signing;
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// Description: Validates SPDX 3.0.1 output against stored schema.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Json.Schema;
|
||||
using StellaOps.Attestor.StandardPredicates.Models;
|
||||
@@ -39,14 +41,21 @@ public sealed class SpdxSchemaValidationTests
|
||||
|
||||
var result = writer.Write(document);
|
||||
using var json = JsonDocument.Parse(result.CanonicalBytes);
|
||||
var normalized = NormalizeForSchema(json.RootElement);
|
||||
using var normalizedJson = JsonDocument.Parse(normalized.ToJsonString());
|
||||
|
||||
var evaluation = schema.Evaluate(json.RootElement, new EvaluationOptions
|
||||
var evaluation = schema.Evaluate(normalizedJson.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List,
|
||||
RequireFormatValidation = true
|
||||
});
|
||||
|
||||
evaluation.IsValid.Should().BeTrue();
|
||||
if (!evaluation.IsValid)
|
||||
{
|
||||
var details = new StringBuilder();
|
||||
AppendEvaluation(evaluation, details, 0);
|
||||
Assert.Fail($"SPDX schema validation failed:{Environment.NewLine}{details}");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonSchema LoadSchemaFromDocs()
|
||||
@@ -78,4 +87,55 @@ public sealed class SpdxSchemaValidationTests
|
||||
|
||||
throw new DirectoryNotFoundException("Repository root not found.");
|
||||
}
|
||||
|
||||
private static JsonNode NormalizeForSchema(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => NormalizeObjectForSchema(element),
|
||||
JsonValueKind.Array => new JsonArray(element.EnumerateArray().Select(NormalizeForSchema).ToArray()),
|
||||
_ => JsonNode.Parse(element.GetRawText())!
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject NormalizeObjectForSchema(JsonElement element)
|
||||
{
|
||||
var result = new JsonObject();
|
||||
JsonNode? typeAlias = null;
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
var normalizedValue = NormalizeForSchema(property.Value);
|
||||
result[property.Name] = normalizedValue;
|
||||
|
||||
if (property.Name == "@type")
|
||||
{
|
||||
typeAlias = normalizedValue?.DeepClone();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeAlias is not null && !result.ContainsKey("type"))
|
||||
{
|
||||
result["type"] = typeAlias;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AppendEvaluation(EvaluationResults result, StringBuilder builder, int depth)
|
||||
{
|
||||
var indent = new string(' ', depth * 2);
|
||||
builder.Append(indent)
|
||||
.Append("path=")
|
||||
.Append(result.EvaluationPath)
|
||||
.Append(", valid=")
|
||||
.Append(result.IsValid)
|
||||
.Append(", errors=")
|
||||
.AppendLine(JsonSerializer.Serialize(result.Errors));
|
||||
|
||||
foreach (var child in result.Details ?? [])
|
||||
{
|
||||
AppendEvaluation(child, builder, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,3 +14,4 @@ Source of truth: `docs/implplan/SPRINT_20260119_013_Attestor_cyclonedx_1.7_gener
|
||||
| TASK-013-009 | DONE | Added CycloneDX 1.7 feature, determinism, and round-trip tests. |
|
||||
| TASK-013-010 | DONE | Added CycloneDX 1.7 schema validation test. |
|
||||
| TASK-014-014 | DONE | Added SPDX 3.0.1 profile coverage tests and coverage gating. |
|
||||
| RB-008-ATTESTOR-SPDX-SCHEMA-20260210 | DONE | Normalized JSON-LD `@type` aliasing for schema validation test compatibility and revalidated `StellaOps.Attestor.StandardPredicates.Tests` (`167/167` passing). |
|
||||
|
||||
@@ -31950,10 +31950,18 @@ stella policy test {policyName}.stella
|
||||
AnsiConsole.MarkupLine($" Entries: {result.Entries}");
|
||||
AnsiConsole.MarkupLine($" Created: {result.CreatedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown"}");
|
||||
AnsiConsole.MarkupLine($" Portable: {(result.Portable ? "yes" : "no")}");
|
||||
if (!string.IsNullOrWhiteSpace(result.Profile))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" Profile: {Markup.Escape(result.Profile)}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Bundle verification failed:[/] {Markup.Escape(result.ErrorMessage ?? "Unknown error")}");
|
||||
if (!string.IsNullOrWhiteSpace(result.ErrorCode))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [grey]Code: {Markup.Escape(result.ErrorCode)}[/]");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(result.ErrorDetail))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(result.ErrorDetail)}[/]");
|
||||
|
||||
@@ -26,6 +26,7 @@ internal static class VerifyCommandGroup
|
||||
verify.Add(BuildVerifyOfflineCommand(services, verboseOption, cancellationToken));
|
||||
verify.Add(BuildVerifyImageCommand(services, verboseOption, cancellationToken));
|
||||
verify.Add(BuildVerifyBundleCommand(services, verboseOption, cancellationToken));
|
||||
verify.Add(BuildVerifyReleaseCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260118_012_CLI_verification_consolidation (CLI-V-002)
|
||||
// stella verify attestation - moved from stella attest verify
|
||||
@@ -225,6 +226,101 @@ internal static class VerifyCommandGroup
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildVerifyReleaseCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundleArg = new Argument<string>("bundle")
|
||||
{
|
||||
Description = "Path to the promotion DSSE bundle file."
|
||||
};
|
||||
|
||||
var sbomOption = new Option<string?>("--sbom")
|
||||
{
|
||||
Description = "Path to SBOM file for material verification."
|
||||
};
|
||||
|
||||
var vexOption = new Option<string?>("--vex")
|
||||
{
|
||||
Description = "Path to VEX file for material verification."
|
||||
};
|
||||
|
||||
var trustRootOption = new Option<string?>("--trust-root")
|
||||
{
|
||||
Description = "Path to trusted certificate chain."
|
||||
};
|
||||
|
||||
var checkpointOption = new Option<string?>("--checkpoint")
|
||||
{
|
||||
Description = "Path to Rekor checkpoint for verification."
|
||||
};
|
||||
|
||||
var skipSignatureOption = new Option<bool>("--skip-signature")
|
||||
{
|
||||
Description = "Skip signature verification."
|
||||
};
|
||||
|
||||
var skipRekorOption = new Option<bool>("--skip-rekor")
|
||||
{
|
||||
Description = "Skip Rekor inclusion proof verification."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output as JSON for CI integration."
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant", "-t")
|
||||
{
|
||||
Description = "Tenant identifier."
|
||||
};
|
||||
|
||||
var command = new Command("release", "Verify a release promotion bundle chain (source, build, signature, and transparency evidence).")
|
||||
{
|
||||
bundleArg,
|
||||
sbomOption,
|
||||
vexOption,
|
||||
trustRootOption,
|
||||
checkpointOption,
|
||||
skipSignatureOption,
|
||||
skipRekorOption,
|
||||
jsonOption,
|
||||
tenantOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction((parseResult, _) =>
|
||||
{
|
||||
var bundlePath = parseResult.GetValue(bundleArg) ?? string.Empty;
|
||||
var sbom = parseResult.GetValue(sbomOption);
|
||||
var vex = parseResult.GetValue(vexOption);
|
||||
var trustRoot = parseResult.GetValue(trustRootOption);
|
||||
var checkpoint = parseResult.GetValue(checkpointOption);
|
||||
var skipSignature = parseResult.GetValue(skipSignatureOption);
|
||||
var skipRekor = parseResult.GetValue(skipRekorOption);
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandlePromotionVerifyAsync(
|
||||
services,
|
||||
bundlePath,
|
||||
sbom,
|
||||
vex,
|
||||
trustRoot,
|
||||
checkpoint,
|
||||
skipSignature,
|
||||
skipRekor,
|
||||
emitJson,
|
||||
tenant,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
#region Sprint: SPRINT_20260118_012_CLI_verification_consolidation
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Buffers;
|
||||
using System.Formats.Tar;
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
@@ -14,6 +15,25 @@ namespace StellaOps.Cli.Services;
|
||||
/// </summary>
|
||||
internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
{
|
||||
private const string PortableProfileVersion = "1.0";
|
||||
private const string PortableCanonicalBomPath = "canonical_bom.json";
|
||||
private const string PortableDsseEnvelopePath = "dsse_envelope.json";
|
||||
private const string PortableManifestSigPath = "manifest.sig";
|
||||
private const string PortableComponentsParquetPath = "components.parquet";
|
||||
|
||||
private const string ErrManifestMissing = "ERR_MANIFEST_MISSING";
|
||||
private const string ErrManifestSchema = "ERR_MANIFEST_SCHEMA";
|
||||
private const string ErrManifestSignatureMissing = "ERR_MANIFEST_SIGNATURE_MISSING";
|
||||
private const string ErrManifestSignatureInvalid = "ERR_MANIFEST_SIGNATURE_INVALID";
|
||||
private const string ErrFileMissing = "ERR_FILE_MISSING";
|
||||
private const string ErrFileSizeMismatch = "ERR_FILE_SIZE_MISMATCH";
|
||||
private const string ErrFileDigestMismatch = "ERR_FILE_DIGEST_MISMATCH";
|
||||
private const string ErrDssePayloadDigest = "ERR_DSSE_PAYLOAD_DIGEST";
|
||||
private const string ErrRekorTileMissing = "ERR_REKOR_TILE_MISSING";
|
||||
private const string ErrRekorReferenceUncovered = "ERR_REKOR_REFERENCE_UNCOVERED";
|
||||
private const string ErrRekorRootMismatch = "ERR_REKOR_ROOT_MISMATCH";
|
||||
private const string ErrParquetFingerprint = "ERR_PARQUET_FINGERPRINT_MISMATCH";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -80,17 +100,22 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
ex.Message);
|
||||
}
|
||||
|
||||
// Step 4: Verify DSSE signature
|
||||
if (IsPortableManifest(contents.ManifestJson))
|
||||
{
|
||||
return VerifyPortableBundle(contents, offline);
|
||||
}
|
||||
|
||||
// Legacy verification path
|
||||
var signatureValid = VerifyDsseSignature(contents, offline, out var signatureError);
|
||||
if (!signatureValid && !string.IsNullOrEmpty(signatureError))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE signature verification failed",
|
||||
signatureError);
|
||||
signatureError,
|
||||
ErrManifestSignatureInvalid);
|
||||
}
|
||||
|
||||
// Step 5: Verify TSA (only if not offline)
|
||||
if (!offline && contents.Signature is not null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(contents.Signature.TimestampAuthority) ||
|
||||
@@ -103,7 +128,6 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Build success result
|
||||
return new DevPortalBundleVerificationResult
|
||||
{
|
||||
Status = "verified",
|
||||
@@ -114,6 +138,7 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
Entries = contents.Manifest?.Entries?.Count ?? 0,
|
||||
CreatedAt = contents.Manifest?.CreatedAt ?? contents.BundleMetadata?.CreatedAt,
|
||||
Portable = contents.BundleMetadata?.PortableGeneratedAt is not null,
|
||||
Profile = "legacy",
|
||||
ExitCode = DevPortalVerifyExitCode.Success
|
||||
};
|
||||
}
|
||||
@@ -160,24 +185,25 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
await entry.DataStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
|
||||
var json = System.Text.Encoding.UTF8.GetString(memoryStream.ToArray());
|
||||
var bytes = memoryStream.ToArray();
|
||||
contents.Files[entry.Name] = bytes;
|
||||
|
||||
switch (entry.Name)
|
||||
{
|
||||
case "manifest.json":
|
||||
contents.ManifestJson = json;
|
||||
contents.Manifest = JsonSerializer.Deserialize<BundleManifest>(json, SerializerOptions);
|
||||
contents.ManifestJson = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
contents.Manifest = JsonSerializer.Deserialize<BundleManifest>(bytes, SerializerOptions);
|
||||
break;
|
||||
case "signature.json":
|
||||
contents.SignatureJson = json;
|
||||
contents.Signature = JsonSerializer.Deserialize<BundleSignature>(json, SerializerOptions);
|
||||
contents.SignatureJson = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
contents.Signature = JsonSerializer.Deserialize<BundleSignature>(bytes, SerializerOptions);
|
||||
break;
|
||||
case "bundle.json":
|
||||
contents.BundleMetadataJson = json;
|
||||
contents.BundleMetadata = JsonSerializer.Deserialize<BundleMetadataDocument>(json, SerializerOptions);
|
||||
contents.BundleMetadataJson = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
contents.BundleMetadata = JsonSerializer.Deserialize<BundleMetadataDocument>(bytes, SerializerOptions);
|
||||
break;
|
||||
case "checksums.txt":
|
||||
contents.ChecksumsText = json;
|
||||
contents.ChecksumsText = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -245,8 +271,431 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
return true;
|
||||
}
|
||||
|
||||
private DevPortalBundleVerificationResult VerifyPortableBundle(BundleContents contents, bool offline)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contents.ManifestJson))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Portable manifest is missing",
|
||||
null,
|
||||
ErrManifestMissing);
|
||||
}
|
||||
|
||||
PortableManifest? manifest;
|
||||
try
|
||||
{
|
||||
manifest = JsonSerializer.Deserialize<PortableManifest>(contents.ManifestJson!, SerializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Portable manifest is invalid JSON",
|
||||
ex.Message,
|
||||
ErrManifestSchema);
|
||||
}
|
||||
|
||||
if (!ValidatePortableManifestSchema(manifest, out var schemaError))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Portable manifest schema validation failed",
|
||||
schemaError,
|
||||
ErrManifestSchema);
|
||||
}
|
||||
|
||||
if (!contents.Files.TryGetValue(PortableManifestSigPath, out var manifestSigBytes))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Detached manifest signature is missing",
|
||||
PortableManifestSigPath,
|
||||
ErrManifestSignatureMissing);
|
||||
}
|
||||
|
||||
ManifestSignatureEnvelope? manifestSig;
|
||||
try
|
||||
{
|
||||
manifestSig = JsonSerializer.Deserialize<ManifestSignatureEnvelope>(manifestSigBytes, SerializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Detached manifest signature is invalid JSON",
|
||||
ex.Message,
|
||||
ErrManifestSignatureInvalid);
|
||||
}
|
||||
|
||||
if (manifestSig is null || string.IsNullOrWhiteSpace(manifestSig.Payload) || manifestSig.Signatures.Count == 0)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Detached manifest signature is incomplete",
|
||||
null,
|
||||
ErrManifestSignatureInvalid);
|
||||
}
|
||||
|
||||
byte[] detachedPayload;
|
||||
try
|
||||
{
|
||||
detachedPayload = Convert.FromBase64String(manifestSig.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Detached manifest payload encoding is invalid",
|
||||
ex.Message,
|
||||
ErrManifestSignatureInvalid);
|
||||
}
|
||||
|
||||
var canonicalManifest = CanonicalizeJson(contents.ManifestJson!);
|
||||
if (!detachedPayload.AsSpan().SequenceEqual(canonicalManifest))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Detached manifest payload does not match canonical manifest",
|
||||
null,
|
||||
ErrManifestSignatureInvalid);
|
||||
}
|
||||
|
||||
foreach (var file in manifest!.Files.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!contents.Files.TryGetValue(file.Key, out var bytes))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Manifest references missing file",
|
||||
file.Key,
|
||||
ErrFileMissing);
|
||||
}
|
||||
|
||||
if (bytes.LongLength != file.Value.Size)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.ChecksumMismatch,
|
||||
"File size does not match manifest",
|
||||
file.Key,
|
||||
ErrFileSizeMismatch);
|
||||
}
|
||||
|
||||
var digest = ComputeSha256Hex(bytes);
|
||||
if (!string.Equals(digest, file.Value.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.ChecksumMismatch,
|
||||
"File digest does not match manifest",
|
||||
file.Key,
|
||||
ErrFileDigestMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
if (!contents.Files.TryGetValue(PortableDsseEnvelopePath, out var dsseEnvelopeBytes))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE envelope file is missing",
|
||||
PortableDsseEnvelopePath,
|
||||
ErrFileMissing);
|
||||
}
|
||||
|
||||
DsseEnvelope? dsseEnvelope;
|
||||
try
|
||||
{
|
||||
dsseEnvelope = JsonSerializer.Deserialize<DsseEnvelope>(dsseEnvelopeBytes, SerializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE envelope JSON is invalid",
|
||||
ex.Message,
|
||||
ErrDssePayloadDigest);
|
||||
}
|
||||
|
||||
if (dsseEnvelope is null || string.IsNullOrWhiteSpace(dsseEnvelope.Payload))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE envelope payload is missing",
|
||||
null,
|
||||
ErrDssePayloadDigest);
|
||||
}
|
||||
|
||||
byte[] dssePayloadBytes;
|
||||
try
|
||||
{
|
||||
dssePayloadBytes = Convert.FromBase64String(dsseEnvelope.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE payload encoding is invalid",
|
||||
ex.Message,
|
||||
ErrDssePayloadDigest);
|
||||
}
|
||||
|
||||
var payloadDigest = ComputeSha256Hex(dssePayloadBytes);
|
||||
if (!string.Equals(payloadDigest, manifest.Digests.DssePayloadDigest.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE payload digest does not match manifest",
|
||||
null,
|
||||
ErrDssePayloadDigest);
|
||||
}
|
||||
|
||||
if (!contents.Files.TryGetValue(PortableCanonicalBomPath, out var canonicalBomBytes))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Canonical BOM file is missing",
|
||||
PortableCanonicalBomPath,
|
||||
ErrFileMissing);
|
||||
}
|
||||
|
||||
var canonicalBomDigest = ComputeSha256Hex(canonicalBomBytes);
|
||||
if (!string.Equals(canonicalBomDigest, manifest.Digests.CanonicalBomSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.ChecksumMismatch,
|
||||
"Canonical BOM digest does not match manifest",
|
||||
null,
|
||||
ErrFileDigestMismatch);
|
||||
}
|
||||
|
||||
if (!IsHex64(manifest.Rekor.RootHash))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Rekor root hash is invalid",
|
||||
manifest.Rekor.RootHash,
|
||||
ErrRekorRootMismatch);
|
||||
}
|
||||
|
||||
foreach (var tileRef in manifest.Rekor.TileRefs)
|
||||
{
|
||||
if (!manifest.Files.ContainsKey(tileRef.Path) || !contents.Files.ContainsKey(tileRef.Path))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Rekor tile reference is missing from bundle",
|
||||
tileRef.Path,
|
||||
ErrRekorTileMissing);
|
||||
}
|
||||
|
||||
var requiredCovers = new[]
|
||||
{
|
||||
$"SHA256:{manifest.Artifact.Digest.Sha256.ToUpperInvariant()}",
|
||||
$"SHA256:{manifest.Digests.CanonicalBomSha256.ToUpperInvariant()}"
|
||||
};
|
||||
|
||||
foreach (var requiredCover in requiredCovers)
|
||||
{
|
||||
if (!tileRef.Covers.Contains(requiredCover, StringComparer.Ordinal))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Rekor tile cover set is incomplete",
|
||||
requiredCover,
|
||||
ErrRekorReferenceUncovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.Files.TryGetValue(PortableComponentsParquetPath, out var parquetFile))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parquetFile.SchemaFingerprint))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Parquet schema fingerprint is missing",
|
||||
PortableComponentsParquetPath,
|
||||
ErrParquetFingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
if (!offline)
|
||||
{
|
||||
if (manifest.Verifiers.RekorPub is null || string.IsNullOrWhiteSpace(manifest.Verifiers.RekorPub.KeyMaterial))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Rekor verifier key material is missing for online verification",
|
||||
null,
|
||||
ErrRekorRootMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeOffset? createdAt = null;
|
||||
if (DateTimeOffset.TryParse(manifest.CreatedUtc, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedCreatedAt))
|
||||
{
|
||||
createdAt = parsedCreatedAt;
|
||||
}
|
||||
|
||||
return new DevPortalBundleVerificationResult
|
||||
{
|
||||
Status = "verified",
|
||||
BundleId = manifest.Compatibility?.LegacyBundleId ?? manifest.Artifact.Name,
|
||||
RootHash = $"sha256:{manifest.Rekor.RootHash}",
|
||||
Entries = manifest.Files.Count,
|
||||
CreatedAt = createdAt,
|
||||
Portable = true,
|
||||
Profile = "portable-v1",
|
||||
ExitCode = DevPortalVerifyExitCode.Success
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsPortableManifest(string? manifestJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestJson))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(manifestJson);
|
||||
return doc.RootElement.TryGetProperty("spec_version", out var specVersion)
|
||||
&& string.Equals(specVersion.GetString(), PortableProfileVersion, StringComparison.Ordinal);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ValidatePortableManifestSchema(PortableManifest? manifest, out string error)
|
||||
{
|
||||
if (manifest is null)
|
||||
{
|
||||
error = "Manifest is null.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(manifest.SpecVersion, PortableProfileVersion, StringComparison.Ordinal))
|
||||
{
|
||||
error = "spec_version must be 1.0.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Artifact is null || manifest.Files is null || manifest.Digests is null || manifest.Rekor is null || manifest.Verifiers is null)
|
||||
{
|
||||
error = "Required top-level fields are missing.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Files.Count == 0
|
||||
|| !manifest.Files.ContainsKey(PortableCanonicalBomPath)
|
||||
|| !manifest.Files.ContainsKey(PortableDsseEnvelopePath))
|
||||
{
|
||||
error = "Required portable files are missing.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsHex64(manifest.Artifact.Digest.Sha256)
|
||||
|| !IsHex64(manifest.Digests.CanonicalBomSha256)
|
||||
|| !IsHex64(manifest.Digests.DssePayloadDigest.Sha256))
|
||||
{
|
||||
error = "Digest format is invalid.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Rekor.TileRefs.Count == 0)
|
||||
{
|
||||
error = "Rekor tile_refs are required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[] CanonicalizeJson(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false });
|
||||
WriteCanonicalElement(writer, document.RootElement);
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalElement(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonicalElement(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonicalElement(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(element.GetBoolean());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported JSON token kind '{element.ValueKind}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] bytes)
|
||||
=> Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
private static bool IsHex64(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length != 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed class BundleContents
|
||||
{
|
||||
public Dictionary<string, byte[]> Files { get; } = new(StringComparer.Ordinal);
|
||||
public string? ManifestJson { get; set; }
|
||||
public BundleManifest? Manifest { get; set; }
|
||||
public string? SignatureJson { get; set; }
|
||||
@@ -256,6 +705,87 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
public string? ChecksumsText { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PortableManifest
|
||||
{
|
||||
public string SpecVersion { get; set; } = string.Empty;
|
||||
public string CreatedUtc { get; set; } = string.Empty;
|
||||
public PortableArtifact Artifact { get; set; } = new();
|
||||
public Dictionary<string, PortableManifestFile> Files { get; set; } = new(StringComparer.Ordinal);
|
||||
public PortableDigests Digests { get; set; } = new();
|
||||
public PortableRekor Rekor { get; set; } = new();
|
||||
public PortableVerifiers Verifiers { get; set; } = new();
|
||||
public PortableCompatibility? Compatibility { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PortableArtifact
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Version { get; set; } = string.Empty;
|
||||
public PortableShaDigest Digest { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class PortableManifestFile
|
||||
{
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
public long Size { get; set; }
|
||||
public string? SchemaFingerprint { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PortableDigests
|
||||
{
|
||||
public string CanonicalBomSha256 { get; set; } = string.Empty;
|
||||
public PortableShaDigest DssePayloadDigest { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class PortableShaDigest
|
||||
{
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class PortableRekor
|
||||
{
|
||||
public string RootHash { get; set; } = string.Empty;
|
||||
public List<PortableTileReference> TileRefs { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class PortableTileReference
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public List<string> Covers { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class PortableVerifiers
|
||||
{
|
||||
public PortableRekorVerifier? RekorPub { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PortableRekorVerifier
|
||||
{
|
||||
public string? KeyMaterial { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PortableCompatibility
|
||||
{
|
||||
public string? LegacyBundleId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ManifestSignatureEnvelope
|
||||
{
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
public List<ManifestSignature> Signatures { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class ManifestSignature
|
||||
{
|
||||
public string Keyid { get; set; } = string.Empty;
|
||||
public string Sig { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class DsseEnvelope
|
||||
{
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class BundleManifest
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
@@ -335,20 +865,24 @@ public sealed class DevPortalBundleVerificationResult
|
||||
public int Entries { get; set; }
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public bool Portable { get; set; }
|
||||
public string? Profile { get; set; }
|
||||
public DevPortalVerifyExitCode ExitCode { get; set; } = DevPortalVerifyExitCode.Unexpected;
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? ErrorDetail { get; set; }
|
||||
public string? ErrorCode { get; set; }
|
||||
|
||||
public static DevPortalBundleVerificationResult Failed(
|
||||
DevPortalVerifyExitCode exitCode,
|
||||
string message,
|
||||
string? detail = null)
|
||||
string? detail = null,
|
||||
string? errorCode = null)
|
||||
=> new()
|
||||
{
|
||||
Status = "failed",
|
||||
ExitCode = exitCode,
|
||||
ErrorMessage = message,
|
||||
ErrorDetail = detail
|
||||
ErrorDetail = detail,
|
||||
ErrorCode = errorCode
|
||||
};
|
||||
|
||||
public string ToJson()
|
||||
@@ -368,11 +902,15 @@ public sealed class DevPortalBundleVerificationResult
|
||||
if (CreatedAt.HasValue)
|
||||
output["createdAt"] = CreatedAt.Value.ToString("O", CultureInfo.InvariantCulture);
|
||||
output["entries"] = Entries;
|
||||
if (ErrorCode is not null)
|
||||
output["errorCode"] = ErrorCode;
|
||||
if (ErrorDetail is not null)
|
||||
output["errorDetail"] = ErrorDetail;
|
||||
if (ErrorMessage is not null)
|
||||
output["errorMessage"] = ErrorMessage;
|
||||
output["portable"] = Portable;
|
||||
if (Profile is not null)
|
||||
output["profile"] = Profile;
|
||||
if (RootHash is not null)
|
||||
output["rootHash"] = RootHash;
|
||||
output["status"] = Status;
|
||||
|
||||
@@ -59,7 +59,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT_20260208_030-CORE | DONE | Added `stella advise ask --file` batch processing and `stella advise export` conversation history command surfaces (2026-02-08). |
|
||||
| SPRINT_20260208_033-CORE | DONE | Unknowns export schema/versioning envelope and CLI option integration completed (2026-02-08). |
|
||||
| STS-004 | DONE | SPRINT_20260210_004 - Added `stella verify release` command that maps to promotion bundle verification flow. |
|
||||
|
||||
| SPRINT_20260208_031-CORE | DONE | Compare verification overlay options, builder, and output/model integration completed (2026-02-08).
|
||||
|
||||
|
||||
| PAPI-005 | DONE | SPRINT_20260210_005 - DevPortal portable-v1 verify parity and deterministic error-code output completed; CLI verifier paths validated in suite run (1173 passed) on 2026-02-10. |
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class CommandFactoryTests
|
||||
|
||||
var verify = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "verify", StringComparison.Ordinal));
|
||||
Assert.Contains(verify.Subcommands, command => string.Equals(command.Name, "offline", StringComparison.Ordinal));
|
||||
Assert.Contains(verify.Subcommands, command => string.Equals(command.Name, "release", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -133,7 +133,7 @@ public sealed class AttestVerifyGoldenTests
|
||||
Timestamp: 2026-01-14T10:30:00Z
|
||||
""";
|
||||
|
||||
actual.Trim().Should().Be(expected.Trim());
|
||||
actual.Replace("\r\n", "\n").Trim().Should().Be(expected.Replace("\r\n", "\n").Trim());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -137,6 +137,7 @@ public class VerificationConsolidationTests
|
||||
// - offline (existing)
|
||||
// - image (existing)
|
||||
// - bundle (existing)
|
||||
// - release (new - promotion verification alias)
|
||||
// - attestation (new - from attest verify)
|
||||
// - vex (new - from vex verify)
|
||||
// - patch (new - from patchverify)
|
||||
@@ -147,6 +148,7 @@ public class VerificationConsolidationTests
|
||||
"offline",
|
||||
"image",
|
||||
"bundle",
|
||||
"release",
|
||||
"attestation",
|
||||
"vex",
|
||||
"patch",
|
||||
@@ -154,7 +156,7 @@ public class VerificationConsolidationTests
|
||||
};
|
||||
|
||||
// This test validates the expected structure
|
||||
Assert.Equal(7, expectedSubcommands.Length);
|
||||
Assert.Equal(8, expectedSubcommands.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -11,19 +11,19 @@ public class PolicyCliIntegrationTests
|
||||
private const string ValidPolicySource = @"
|
||||
policy ""Test Policy"" syntax ""stella-dsl@1"" {
|
||||
metadata {
|
||||
author: ""test@example.com""
|
||||
version: ""1.0.0""
|
||||
author = ""test@example.com""
|
||||
version = ""1.0.0""
|
||||
}
|
||||
|
||||
settings {
|
||||
default_action: ""allow""
|
||||
default_action = ""allow""
|
||||
}
|
||||
|
||||
rule allow_all (10) {
|
||||
rule allow_all priority 10 {
|
||||
when true
|
||||
then {
|
||||
allow()
|
||||
}
|
||||
then
|
||||
severity := ""info""
|
||||
because ""allow all for integration test""
|
||||
}
|
||||
}
|
||||
";
|
||||
@@ -33,7 +33,7 @@ policy ""Invalid Policy""
|
||||
// Missing syntax declaration
|
||||
{
|
||||
metadata {
|
||||
author: ""test@example.com""
|
||||
author = ""test@example.com""
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Services;
|
||||
using Xunit;
|
||||
@@ -111,6 +112,164 @@ public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
Assert.True(result.Portable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsProfileAndNoErrorCode()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle();
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("verified", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.Success, result.ExitCode);
|
||||
Assert.True(result.Portable);
|
||||
Assert.Equal("portable-v1", result.Profile);
|
||||
Assert.Null(result.ErrorCode);
|
||||
|
||||
var json = result.ToJson();
|
||||
Assert.Contains("\"profile\":\"portable-v1\"", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"errorCode\":", json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsManifestSignatureMissing_WhenDetachedSignatureIsAbsent()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateAfterSign: files => files.Remove("manifest.sig"));
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_MANIFEST_SIGNATURE_MISSING", result.ErrorCode);
|
||||
Assert.Contains("\"errorCode\":\"ERR_MANIFEST_SIGNATURE_MISSING\"", result.ToJson(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsManifestSignatureInvalid_WhenDetachedPayloadDoesNotMatchManifest()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateAfterSign: files =>
|
||||
{
|
||||
var manifestNode = JsonNode.Parse(files["manifest.json"])!.AsObject();
|
||||
manifestNode["createdUtc"] = "2026-02-10T12:00:00Z";
|
||||
files["manifest.json"] = Encoding.UTF8.GetBytes(manifestNode.ToJsonString());
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_MANIFEST_SIGNATURE_INVALID", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsManifestSchema_WhenSpecVersionIsInvalid()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, _) => manifest["specVersion"] = "2.0");
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_MANIFEST_SCHEMA", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsDssePayloadDigest_WhenPayloadDigestMismatchIsDetected()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, _) =>
|
||||
{
|
||||
var digests = manifest["digests"]!.AsObject();
|
||||
var payloadDigest = digests["dssePayloadDigest"]!.AsObject();
|
||||
payloadDigest["sha256"] = new string('c', 64);
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_DSSE_PAYLOAD_DIGEST", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsRekorRootMismatch_WhenRootHashIsInvalid()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, _) =>
|
||||
{
|
||||
var rekor = manifest["rekor"]!.AsObject();
|
||||
rekor["rootHash"] = "bad-root";
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_REKOR_ROOT_MISMATCH", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsRekorReferenceUncovered_WhenTileCoverageIsIncomplete()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, _) =>
|
||||
{
|
||||
var rekor = manifest["rekor"]!.AsObject();
|
||||
var tileRefs = rekor["tileRefs"]!.AsArray();
|
||||
var firstTile = tileRefs[0]!.AsObject();
|
||||
firstTile["covers"] = new JsonArray();
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_REKOR_REFERENCE_UNCOVERED", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsRekorTileMissing_WhenTileRefPathIsNotDeclaredInFiles()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, _) =>
|
||||
{
|
||||
var files = manifest["files"]!.AsObject();
|
||||
files.Remove("rekor/tile.tar");
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_REKOR_TILE_MISSING", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsParquetFingerprintMismatch_WhenSchemaFingerprintIsMissing()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, files) =>
|
||||
{
|
||||
var parquetBytes = Encoding.UTF8.GetBytes("parquet-placeholder");
|
||||
files["components.parquet"] = parquetBytes;
|
||||
|
||||
var manifestFiles = manifest["files"]!.AsObject();
|
||||
manifestFiles["components.parquet"] = new JsonObject
|
||||
{
|
||||
["sha256"] = ComputeSha256Hex(parquetBytes),
|
||||
["size"] = parquetBytes.Length
|
||||
};
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_PARQUET_FINGERPRINT_MISMATCH", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_OutputsKeysSortedAlphabetically()
|
||||
{
|
||||
@@ -284,16 +443,216 @@ public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private string CreatePortableV1Bundle(
|
||||
Action<JsonObject, Dictionary<string, byte[]>>? mutateBeforeSign = null,
|
||||
Action<Dictionary<string, byte[]>>? mutateAfterSign = null)
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, $"portable-v1-{Guid.NewGuid():N}.tgz");
|
||||
var files = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
|
||||
var artifactDigest = new string('a', 64);
|
||||
var rootHash = new string('b', 64);
|
||||
var legacyBundleId = "c3d4e5f6-a7b8-9012-cdef-345678901234";
|
||||
|
||||
var canonicalBomBytes = Encoding.UTF8.GetBytes("{\"schemaVersion\":\"1.0\",\"entries\":[]}");
|
||||
files["canonical_bom.json"] = canonicalBomBytes;
|
||||
var canonicalBomDigest = ComputeSha256Hex(canonicalBomBytes);
|
||||
|
||||
var dssePayloadBytes = Encoding.UTF8.GetBytes("{\"subject\":\"portable-v1\"}");
|
||||
var dsseEnvelope = new JsonObject
|
||||
{
|
||||
["payloadType"] = "application/vnd.in-toto+json",
|
||||
["payload"] = Convert.ToBase64String(dssePayloadBytes),
|
||||
["signatures"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["keyid"] = "test-key",
|
||||
["sig"] = Convert.ToBase64String(Encoding.UTF8.GetBytes("dsse-signature"))
|
||||
}
|
||||
}
|
||||
};
|
||||
files["dsse_envelope.json"] = Encoding.UTF8.GetBytes(dsseEnvelope.ToJsonString());
|
||||
var dssePayloadDigest = ComputeSha256Hex(dssePayloadBytes);
|
||||
|
||||
var tileTarBytes = Encoding.UTF8.GetBytes("portable-tile");
|
||||
files["rekor/tile.tar"] = tileTarBytes;
|
||||
|
||||
var manifest = new JsonObject
|
||||
{
|
||||
["spec_version"] = "1.0",
|
||||
["specVersion"] = "1.0",
|
||||
["createdUtc"] = "2025-12-07T10:30:00Z",
|
||||
["artifact"] = new JsonObject
|
||||
{
|
||||
["name"] = "evidence/portable-v1",
|
||||
["version"] = "1.0.0",
|
||||
["digest"] = new JsonObject
|
||||
{
|
||||
["sha256"] = artifactDigest
|
||||
},
|
||||
["mediaType"] = "application/vnd.stellaops.evidence.bundle+json"
|
||||
},
|
||||
["files"] = new JsonObject
|
||||
{
|
||||
["canonical_bom.json"] = new JsonObject
|
||||
{
|
||||
["sha256"] = canonicalBomDigest,
|
||||
["size"] = canonicalBomBytes.Length
|
||||
},
|
||||
["dsse_envelope.json"] = new JsonObject
|
||||
{
|
||||
["sha256"] = ComputeSha256Hex(files["dsse_envelope.json"]),
|
||||
["size"] = files["dsse_envelope.json"].Length
|
||||
},
|
||||
["rekor/tile.tar"] = new JsonObject
|
||||
{
|
||||
["sha256"] = ComputeSha256Hex(tileTarBytes),
|
||||
["size"] = tileTarBytes.Length
|
||||
}
|
||||
},
|
||||
["digests"] = new JsonObject
|
||||
{
|
||||
["canonicalBomSha256"] = canonicalBomDigest,
|
||||
["dssePayloadDigest"] = new JsonObject
|
||||
{
|
||||
["sha256"] = dssePayloadDigest
|
||||
}
|
||||
},
|
||||
["rekor"] = new JsonObject
|
||||
{
|
||||
["logId"] = "rekor.sigstore.dev",
|
||||
["apiVersion"] = "2",
|
||||
["tileRefs"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["path"] = "rekor/tile.tar",
|
||||
["covers"] = new JsonArray
|
||||
{
|
||||
$"SHA256:{artifactDigest.ToUpperInvariant()}",
|
||||
$"SHA256:{canonicalBomDigest.ToUpperInvariant()}"
|
||||
}
|
||||
}
|
||||
},
|
||||
["rootHash"] = rootHash
|
||||
},
|
||||
["verifiers"] = new JsonObject
|
||||
{
|
||||
["rekorPub"] = new JsonObject
|
||||
{
|
||||
["keyMaterial"] = "rekor-test-key"
|
||||
}
|
||||
},
|
||||
["compatibility"] = new JsonObject
|
||||
{
|
||||
["legacyBundleId"] = legacyBundleId
|
||||
}
|
||||
};
|
||||
|
||||
mutateBeforeSign?.Invoke(manifest, files);
|
||||
|
||||
var manifestJson = manifest.ToJsonString();
|
||||
files["manifest.json"] = Encoding.UTF8.GetBytes(manifestJson);
|
||||
|
||||
var canonicalManifestBytes = CanonicalizeJson(manifestJson);
|
||||
var manifestSignature = new JsonObject
|
||||
{
|
||||
["payload"] = Convert.ToBase64String(canonicalManifestBytes),
|
||||
["signatures"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["keyid"] = "manifest-key",
|
||||
["sig"] = Convert.ToBase64String(Encoding.UTF8.GetBytes("manifest-signature"))
|
||||
}
|
||||
}
|
||||
};
|
||||
files["manifest.sig"] = Encoding.UTF8.GetBytes(manifestSignature.ToJsonString());
|
||||
|
||||
mutateAfterSign?.Invoke(files);
|
||||
|
||||
CreateTgzBundle(bundlePath, files);
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private static byte[] CanonicalizeJson(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false });
|
||||
WriteCanonicalElement(writer, document.RootElement);
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalElement(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonicalElement(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonicalElement(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
|
||||
break;
|
||||
case JsonValueKind.True:
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(element.GetBoolean());
|
||||
break;
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported JSON token kind '{element.ValueKind}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] value)
|
||||
=> Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant();
|
||||
|
||||
private static void CreateTgzBundle(string bundlePath, string manifestJson, object signature, object bundleMetadata)
|
||||
{
|
||||
var files = new Dictionary<string, byte[]>(StringComparer.Ordinal)
|
||||
{
|
||||
["manifest.json"] = Encoding.UTF8.GetBytes(manifestJson),
|
||||
["signature.json"] = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(signature)),
|
||||
["bundle.json"] = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundleMetadata)),
|
||||
["checksums.txt"] = Encoding.UTF8.GetBytes($"# checksums\n{new string('f', 64)} sbom/cyclonedx.json\n")
|
||||
};
|
||||
|
||||
CreateTgzBundle(bundlePath, files);
|
||||
}
|
||||
|
||||
private static void CreateTgzBundle(string bundlePath, IReadOnlyDictionary<string, byte[]> files)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using (var gzipStream = new GZipStream(memoryStream, CompressionLevel.Optimal, leaveOpen: true))
|
||||
using (var tarWriter = new TarWriter(gzipStream))
|
||||
{
|
||||
AddTarEntry(tarWriter, "manifest.json", manifestJson);
|
||||
AddTarEntry(tarWriter, "signature.json", JsonSerializer.Serialize(signature));
|
||||
AddTarEntry(tarWriter, "bundle.json", JsonSerializer.Serialize(bundleMetadata));
|
||||
AddTarEntry(tarWriter, "checksums.txt", $"# checksums\n{new string('f', 64)} sbom/cyclonedx.json\n");
|
||||
foreach (var file in files.OrderBy(f => f.Key, StringComparer.Ordinal))
|
||||
{
|
||||
AddTarEntry(tarWriter, file.Key, file.Value);
|
||||
}
|
||||
}
|
||||
|
||||
memoryStream.Position = 0;
|
||||
@@ -301,7 +660,7 @@ public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
memoryStream.CopyTo(fileStream);
|
||||
}
|
||||
|
||||
private static void AddTarEntry(TarWriter writer, string name, string content)
|
||||
private static void AddTarEntry(TarWriter writer, string name, byte[] content)
|
||||
{
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, name)
|
||||
{
|
||||
@@ -309,8 +668,7 @@ public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
ModificationTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
entry.DataStream = new MemoryStream(bytes);
|
||||
entry.DataStream = new MemoryStream(content);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT_20260208_030-TESTS | DONE | Added isolated advise parity validation in StellaOps.Cli.AdviseParity.Tests; command passed (2 tests, 2026-02-08).
|
||||
| SPRINT_20260208_033-TESTS | DONE | Added isolated Unknowns export deterministic validation in StellaOps.Cli.UnknownsExport.Tests; command passed (3 tests, 2026-02-08).
|
||||
| STS-005 | DONE | SPRINT_20260210_004 - Updated command structure coverage for `verify release` and verification consolidation list (execution blocked by pre-existing Policy.Determinization compile errors). |
|
||||
|
||||
| SPRINT_20260208_031-TESTS | DONE | Isolated compare overlay deterministic validation added in StellaOps.Cli.CompareOverlay.Tests; command passed (3 tests, 2026-02-08).
|
||||
|
||||
@@ -45,3 +46,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
|
||||
|
||||
| PAPI-005-TESTS | DONE | SPRINT_20260210_005 - DevPortal portable-v1 verifier matrix hardened with manifest/DSSE/Rekor/Parquet fail-closed tests; CLI suite passed (1182 passed) on 2026-02-10. |
|
||||
|
||||
@@ -47,4 +47,8 @@
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<!-- Federation files excluded from Core due to circular dependency; compiled here for DI registration -->
|
||||
<ItemGroup>
|
||||
<Compile Include="../__Libraries/StellaOps.Concelier.Core/Federation/**/*.cs" Link="Federation/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -26,6 +26,7 @@ using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Tests;
|
||||
|
||||
[Collection(ConcelierFixtureCollection.Name)]
|
||||
|
||||
@@ -28,6 +28,7 @@ using StellaOps.Cryptography.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Concelier.Connector.Ru.Nkcki.Tests;
|
||||
|
||||
[Collection(ConcelierFixtureCollection.Name)]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// <copyright file="FakeTimeProvider.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Federation;
|
||||
|
||||
/// <summary>
|
||||
/// Fake <see cref="TimeProvider"/> for deterministic time-dependent testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _currentTime;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset startTime)
|
||||
{
|
||||
_currentTime = startTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _currentTime;
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
_currentTime = _currentTime.Add(duration);
|
||||
}
|
||||
|
||||
public void SetTime(DateTimeOffset newTime)
|
||||
{
|
||||
_currentTime = newTime;
|
||||
}
|
||||
}
|
||||
@@ -128,13 +128,14 @@ public sealed class SnapshotIngestionOrchestratorTests
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.PinSnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SnapshotPinResult(
|
||||
Success: false,
|
||||
SnapshotId: null,
|
||||
SiteId: "test-site",
|
||||
PinnedAt: _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId: null,
|
||||
Error: "Pinning failed"));
|
||||
.ReturnsAsync(new SnapshotPinResult
|
||||
{
|
||||
Success = false,
|
||||
SiteId = "test-site",
|
||||
PinnedAt = _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId = null,
|
||||
Error = "Pinning failed"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.ImportWithRollbackAsync(stream, null, sourceId);
|
||||
@@ -165,13 +166,14 @@ public sealed class SnapshotIngestionOrchestratorTests
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.PinSnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SnapshotPinResult(
|
||||
Success: true,
|
||||
SnapshotId: "temp-snapshot",
|
||||
SiteId: "test-site",
|
||||
PinnedAt: _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId: "prev-snapshot",
|
||||
Error: null));
|
||||
.ReturnsAsync(new SnapshotPinResult
|
||||
{
|
||||
Success = true,
|
||||
SiteId = "test-site",
|
||||
PinnedAt = _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId = "prev-snapshot",
|
||||
Error = null
|
||||
});
|
||||
|
||||
_coordinatorMock
|
||||
.Setup(x => x.ImportBundleAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||
@@ -179,11 +181,13 @@ public sealed class SnapshotIngestionOrchestratorTests
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.RollbackSnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SnapshotRollbackResult(
|
||||
Success: true,
|
||||
RolledBackToSnapshotId: "prev-snapshot",
|
||||
RolledBackAt: _timeProvider.GetUtcNow(),
|
||||
Error: null));
|
||||
.ReturnsAsync(new SnapshotRollbackResult
|
||||
{
|
||||
Success = true,
|
||||
RolledBackToSnapshotId = "prev-snapshot",
|
||||
RolledBackAt = _timeProvider.GetUtcNow(),
|
||||
Error = null
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.ImportWithRollbackAsync(stream, null, sourceId);
|
||||
@@ -216,13 +220,14 @@ public sealed class SnapshotIngestionOrchestratorTests
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.PinSnapshotAsync("snapshot-002", sourceId, bundle.CompositeDigest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SnapshotPinResult(
|
||||
Success: true,
|
||||
SnapshotId: "snapshot-002",
|
||||
SiteId: "test-site",
|
||||
PinnedAt: _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId: null,
|
||||
Error: null));
|
||||
.ReturnsAsync(new SnapshotPinResult
|
||||
{
|
||||
Success = true,
|
||||
SiteId = "test-site",
|
||||
PinnedAt = _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId = null,
|
||||
Error = null
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.CreateWithPinningAsync(sourceId, "test-label");
|
||||
@@ -324,13 +329,14 @@ public sealed class SnapshotIngestionOrchestratorTests
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.PinSnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SnapshotPinResult(
|
||||
Success: true,
|
||||
SnapshotId: bundle.SnapshotId,
|
||||
SiteId: "test-site",
|
||||
PinnedAt: _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId: null,
|
||||
Error: null));
|
||||
.ReturnsAsync(new SnapshotPinResult
|
||||
{
|
||||
Success = true,
|
||||
SiteId = "test-site",
|
||||
PinnedAt = _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId = null,
|
||||
Error = null
|
||||
});
|
||||
|
||||
_coordinatorMock
|
||||
.Setup(x => x.ImportBundleAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||
@@ -339,18 +345,23 @@ public sealed class SnapshotIngestionOrchestratorTests
|
||||
|
||||
private FeedSnapshotBundle CreateTestBundle(string snapshotId)
|
||||
{
|
||||
return new FeedSnapshotBundle(
|
||||
SnapshotId: snapshotId,
|
||||
CompositeDigest: $"sha256:{Guid.NewGuid():N}",
|
||||
CreatedAt: _timeProvider.GetUtcNow(),
|
||||
Label: "test-bundle",
|
||||
Sources: new[]
|
||||
return new FeedSnapshotBundle
|
||||
{
|
||||
SnapshotId = snapshotId,
|
||||
CompositeDigest = $"sha256:{Guid.NewGuid():N}",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Label = "test-bundle",
|
||||
Sources = new[]
|
||||
{
|
||||
new FeedSourceSnapshot(
|
||||
SourceId: "nvd",
|
||||
Digest: $"sha256:{Guid.NewGuid():N}",
|
||||
ItemCount: 100,
|
||||
CapturedAt: _timeProvider.GetUtcNow())
|
||||
});
|
||||
new SourceSnapshot
|
||||
{
|
||||
SourceId = "nvd",
|
||||
Version = "1.0",
|
||||
Digest = $"sha256:{Guid.NewGuid():N}",
|
||||
RecordCount = 100,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,17 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Federation/StellaOps.Concelier.Federation.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Persistence/StellaOps.Concelier.Persistence.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.VersionComparison/StellaOps.VersionComparison.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DistroIntel/StellaOps.DistroIntel.csproj" />
|
||||
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
|
||||
</ItemGroup>
|
||||
<!-- Federation files excluded from Core due to circular dependency; compiled here for testing -->
|
||||
<ItemGroup>
|
||||
<Compile Include="../../__Libraries/StellaOps.Concelier.Core/Federation/**/*.cs" Link="Federation/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Concelier.Storage.MergeEvents;
|
||||
using StellaOps.Provenance;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
public sealed class AdvisoryMergeServiceTests
|
||||
|
||||
@@ -11,6 +11,7 @@ using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
public sealed class AdvisoryPrecedenceMergerTests
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.MergeEvents;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
public sealed class MergeEventWriterTests
|
||||
|
||||
@@ -8,6 +8,7 @@ using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.MergeEvents;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Formats.Tar;
|
||||
@@ -13,6 +12,7 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -21,16 +21,25 @@ namespace StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
public sealed class EvidencePortableBundleService
|
||||
{
|
||||
private const string PortableManifestFileName = "manifest.json";
|
||||
private const string PortableSignatureFileName = "signature.json";
|
||||
private const string PortableManifestSignatureFileName = "manifest.sig";
|
||||
private const string PortableLegacySignatureFileName = "signature.json";
|
||||
private const string PortableChecksumsFileName = "checksums.txt";
|
||||
private const string PortableCanonicalBomFileName = "canonical_bom.json";
|
||||
private const string PortableDsseEnvelopeFileName = "dsse_envelope.json";
|
||||
private const string PortableMergedVexFileName = "merged_vex.json";
|
||||
private const string PortableComponentsParquetFileName = "components.parquet";
|
||||
private const string PortableRekorTileTarFileName = "rekor/tile.tar";
|
||||
private const string PortableRekorCheckpointFileName = "rekor/checkpoint.json";
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly UnixFileMode DefaultFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
private static readonly UnixFileMode ExecutableFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
@@ -98,9 +107,13 @@ public sealed class EvidencePortableBundleService
|
||||
return new EvidenceBundlePackageResult(details.Bundle.PortableStorageKey!, details.Bundle.RootHash, Created: false);
|
||||
}
|
||||
|
||||
var manifestDocument = DecodeManifest(details.Signature);
|
||||
var generatedAt = _timeProvider.GetUtcNow();
|
||||
var packageStream = BuildPackageStream(details, manifestDocument, generatedAt);
|
||||
var legacyManifest = DecodeLegacyManifest(details.Signature);
|
||||
var generatedAt = details.Bundle.PortableGeneratedAt
|
||||
?? details.Bundle.SealedAt
|
||||
?? details.Signature.TimestampedAt
|
||||
?? details.Signature.SignedAt;
|
||||
|
||||
var packageStream = BuildPackageStream(details, legacyManifest, generatedAt);
|
||||
|
||||
var metadata = await _objectStore
|
||||
.StoreAsync(
|
||||
@@ -119,43 +132,48 @@ public sealed class EvidencePortableBundleService
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Portable evidence bundle {BundleId} for tenant {TenantId} stored at {StorageKey}.",
|
||||
"Portable evidence bundle {BundleId} for tenant {TenantId} stored at {StorageKey} using profile {Profile}.",
|
||||
bundleId.Value,
|
||||
tenantId.Value,
|
||||
metadata.StorageKey);
|
||||
metadata.StorageKey,
|
||||
"portable-v1");
|
||||
|
||||
return new EvidenceBundlePackageResult(metadata.StorageKey, details.Bundle.RootHash, Created: true);
|
||||
}
|
||||
|
||||
private Stream BuildPackageStream(
|
||||
EvidenceBundleDetails details,
|
||||
ManifestDocument manifest,
|
||||
LegacyManifestDocument legacyManifest,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var files = BuildPortableFileArtifacts(details, legacyManifest, generatedAt);
|
||||
|
||||
var manifestBytes = BuildPortableManifestBytes(details, legacyManifest, generatedAt, files);
|
||||
var manifestSigBytes = BuildManifestSignatureEnvelopeBytes(details.Signature!, manifestBytes);
|
||||
|
||||
files[PortableManifestFileName] = new PortableFileArtifact(
|
||||
PortableManifestFileName,
|
||||
manifestBytes,
|
||||
"application/json");
|
||||
|
||||
files[PortableManifestSignatureFileName] = new PortableFileArtifact(
|
||||
PortableManifestSignatureFileName,
|
||||
manifestSigBytes,
|
||||
"application/json");
|
||||
|
||||
files[PortableChecksumsFileName] = new PortableFileArtifact(
|
||||
PortableChecksumsFileName,
|
||||
Encoding.UTF8.GetBytes(BuildChecksums(files)),
|
||||
"text/plain");
|
||||
|
||||
var stream = new MemoryStream();
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
WriteTextEntry(tarWriter, PortableManifestFileName, GetManifestJson(details.Signature!));
|
||||
WriteTextEntry(tarWriter, PortableSignatureFileName, GetSignatureJson(details.Signature!));
|
||||
WriteTextEntry(tarWriter, PortableChecksumsFileName, BuildChecksums(manifest, details.Bundle.RootHash));
|
||||
|
||||
var metadataDocument = BuildPortableMetadata(details, manifest, generatedAt);
|
||||
WriteTextEntry(
|
||||
tarWriter,
|
||||
_options.MetadataFileName,
|
||||
JsonSerializer.Serialize(metadataDocument, SerializerOptions));
|
||||
|
||||
WriteTextEntry(
|
||||
tarWriter,
|
||||
_options.InstructionsFileName,
|
||||
BuildInstructions(details, manifest, generatedAt, _options));
|
||||
|
||||
WriteTextEntry(
|
||||
tarWriter,
|
||||
_options.OfflineScriptFileName,
|
||||
BuildOfflineScript(_options.ArtifactName, _options.MetadataFileName),
|
||||
ExecutableFileMode);
|
||||
foreach (var artifact in files.Values.OrderBy(a => a.Path, StringComparer.Ordinal))
|
||||
{
|
||||
WriteBinaryEntry(tarWriter, artifact.Path, artifact.Content, artifact.Mode);
|
||||
}
|
||||
}
|
||||
|
||||
ApplyDeterministicGZipHeader(stream);
|
||||
@@ -163,7 +181,224 @@ public sealed class EvidencePortableBundleService
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static ManifestDocument DecodeManifest(EvidenceBundleSignature signature)
|
||||
private SortedDictionary<string, PortableFileArtifact> BuildPortableFileArtifacts(
|
||||
EvidenceBundleDetails details,
|
||||
LegacyManifestDocument legacyManifest,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var files = new SortedDictionary<string, PortableFileArtifact>(StringComparer.Ordinal);
|
||||
|
||||
var canonicalBomDocument = BuildCanonicalBomDocument(details, legacyManifest, generatedAt);
|
||||
files[PortableCanonicalBomFileName] = new PortableFileArtifact(
|
||||
PortableCanonicalBomFileName,
|
||||
CanonicalizeObjectToUtf8(canonicalBomDocument),
|
||||
"application/json");
|
||||
|
||||
var dsseEnvelopeDocument = BuildDsseEnvelopeDocument(details.Signature!);
|
||||
files[PortableDsseEnvelopeFileName] = new PortableFileArtifact(
|
||||
PortableDsseEnvelopeFileName,
|
||||
CanonicalizeObjectToUtf8(dsseEnvelopeDocument),
|
||||
"application/json");
|
||||
|
||||
if (TryBuildMergedVexDocument(legacyManifest, generatedAt, out var mergedVexDocument))
|
||||
{
|
||||
files[PortableMergedVexFileName] = new PortableFileArtifact(
|
||||
PortableMergedVexFileName,
|
||||
CanonicalizeObjectToUtf8(mergedVexDocument),
|
||||
"application/json");
|
||||
}
|
||||
|
||||
var metadata = ToReadOnlyMetadata(legacyManifest.Metadata);
|
||||
var artifactDigest = NormalizeDigest(GetMetadataValue(metadata, "artifact.digest.sha256", "artifact_digest_sha256"), details.Bundle.RootHash);
|
||||
var canonicalBomDigest = ComputeSha256Hex(files[PortableCanonicalBomFileName].Content);
|
||||
var rekorLogId = GetMetadataValue(metadata, "rekor.log_id", "rekor_log_id") ?? "rekor.sigstore.dev";
|
||||
var rekorRootHash = NormalizeDigest(GetMetadataValue(metadata, "rekor.root_hash", "rekor_root_hash"), details.Bundle.RootHash);
|
||||
|
||||
var checkpointDocument = new RekorCheckpointDocument(
|
||||
rekorLogId,
|
||||
rekorRootHash,
|
||||
generatedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
files[PortableRekorCheckpointFileName] = new PortableFileArtifact(
|
||||
PortableRekorCheckpointFileName,
|
||||
CanonicalizeObjectToUtf8(checkpointDocument),
|
||||
"application/json");
|
||||
|
||||
var covers = new[]
|
||||
{
|
||||
$"SHA256:{artifactDigest.ToUpperInvariant()}",
|
||||
$"SHA256:{canonicalBomDigest.ToUpperInvariant()}"
|
||||
};
|
||||
|
||||
files[PortableRekorTileTarFileName] = new PortableFileArtifact(
|
||||
PortableRekorTileTarFileName,
|
||||
BuildDeterministicTileTar(rekorLogId, rekorRootHash, covers),
|
||||
"application/x-tar");
|
||||
|
||||
if (TryBuildParquetArtifact(metadata, artifactDigest, canonicalBomDigest, out var parquetArtifact))
|
||||
{
|
||||
files[PortableComponentsParquetFileName] = parquetArtifact;
|
||||
}
|
||||
|
||||
var metadataDocument = BuildPortableMetadata(details, legacyManifest, generatedAt);
|
||||
files[_options.MetadataFileName] = new PortableFileArtifact(
|
||||
_options.MetadataFileName,
|
||||
Encoding.UTF8.GetBytes(JsonSerializer.Serialize(metadataDocument, SerializerOptions)),
|
||||
"application/json");
|
||||
|
||||
files[PortableLegacySignatureFileName] = new PortableFileArtifact(
|
||||
PortableLegacySignatureFileName,
|
||||
Encoding.UTF8.GetBytes(GetLegacySignatureJson(details.Signature!)),
|
||||
"application/json");
|
||||
|
||||
files[_options.InstructionsFileName] = new PortableFileArtifact(
|
||||
_options.InstructionsFileName,
|
||||
Encoding.UTF8.GetBytes(BuildInstructions(details, legacyManifest, generatedAt, _options)),
|
||||
"text/plain");
|
||||
|
||||
files[_options.OfflineScriptFileName] = new PortableFileArtifact(
|
||||
_options.OfflineScriptFileName,
|
||||
Encoding.UTF8.GetBytes(BuildOfflineScript(_options.ArtifactName, _options.MetadataFileName)),
|
||||
"text/plain",
|
||||
ExecutableFileMode);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private byte[] BuildPortableManifestBytes(
|
||||
EvidenceBundleDetails details,
|
||||
LegacyManifestDocument legacyManifest,
|
||||
DateTimeOffset generatedAt,
|
||||
IReadOnlyDictionary<string, PortableFileArtifact> files)
|
||||
{
|
||||
var metadata = ToReadOnlyMetadata(legacyManifest.Metadata);
|
||||
|
||||
var artifactName = GetMetadataValue(metadata, "artifact.name", "artifact_name")
|
||||
?? $"evidence/{details.Bundle.Id.Value:D}";
|
||||
|
||||
var artifactVersion = GetMetadataValue(metadata, "artifact.version", "artifact_version")
|
||||
?? details.Bundle.CreatedAt.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
|
||||
var artifactDigest = NormalizeDigest(GetMetadataValue(metadata, "artifact.digest.sha256", "artifact_digest_sha256"), details.Bundle.RootHash);
|
||||
var artifactMediaType = GetMetadataValue(metadata, "artifact.media_type", "artifact_media_type")
|
||||
?? "application/vnd.stellaops.evidence.bundle+json";
|
||||
|
||||
var canonicalBomDigest = ComputeSha256Hex(files[PortableCanonicalBomFileName].Content);
|
||||
var dssePayloadDigest = ComputePayloadDigest(details.Signature!.Payload);
|
||||
|
||||
var rekorLogId = GetMetadataValue(metadata, "rekor.log_id", "rekor_log_id") ?? "rekor.sigstore.dev";
|
||||
var rekorRootHash = NormalizeDigest(GetMetadataValue(metadata, "rekor.root_hash", "rekor_root_hash"), details.Bundle.RootHash);
|
||||
|
||||
var manifestFiles = new Dictionary<string, PortableManifestFileDocument>(StringComparer.Ordinal)
|
||||
{
|
||||
[PortableCanonicalBomFileName] = ToManifestFileDocument(files[PortableCanonicalBomFileName]),
|
||||
[PortableDsseEnvelopeFileName] = ToManifestFileDocument(files[PortableDsseEnvelopeFileName]),
|
||||
[PortableRekorCheckpointFileName] = ToManifestFileDocument(files[PortableRekorCheckpointFileName]),
|
||||
[PortableRekorTileTarFileName] = ToManifestFileDocument(files[PortableRekorTileTarFileName])
|
||||
};
|
||||
|
||||
if (files.TryGetValue(PortableMergedVexFileName, out var mergedVex))
|
||||
{
|
||||
manifestFiles[PortableMergedVexFileName] = ToManifestFileDocument(mergedVex);
|
||||
}
|
||||
|
||||
if (files.TryGetValue(PortableComponentsParquetFileName, out var parquet))
|
||||
{
|
||||
manifestFiles[PortableComponentsParquetFileName] = ToManifestFileDocument(parquet);
|
||||
}
|
||||
|
||||
var verifierType = ResolveVerifierType(details.Signature!.Algorithm);
|
||||
var verifierPublicKey = GetMetadataValue(metadata, "verifier.public_key", "verifier_public_key")
|
||||
?? "unavailable";
|
||||
|
||||
var manifest = new PortableManifestDocument(
|
||||
"1.0",
|
||||
generatedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
new PortableManifestArtifactDocument(
|
||||
artifactName,
|
||||
artifactVersion,
|
||||
new PortableManifestShaDigestDocument(artifactDigest),
|
||||
artifactMediaType),
|
||||
manifestFiles,
|
||||
new PortableManifestDigestsDocument(
|
||||
canonicalBomDigest,
|
||||
new PortableManifestShaDigestDocument(dssePayloadDigest)),
|
||||
new PortableManifestRekorDocument(
|
||||
rekorLogId,
|
||||
"2",
|
||||
new[]
|
||||
{
|
||||
new PortableManifestTileReferenceDocument(
|
||||
PortableRekorTileTarFileName,
|
||||
new[]
|
||||
{
|
||||
$"SHA256:{artifactDigest.ToUpperInvariant()}",
|
||||
$"SHA256:{canonicalBomDigest.ToUpperInvariant()}"
|
||||
})
|
||||
},
|
||||
rekorRootHash),
|
||||
new PortableManifestTimestampsDocument(
|
||||
legacyManifest.CreatedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
details.Signature.SignedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
(details.Signature.TimestampedAt ?? details.Signature.SignedAt).ToString("O", CultureInfo.InvariantCulture)),
|
||||
new PortableManifestVerifiersDocument(
|
||||
new[]
|
||||
{
|
||||
new PortableManifestPublicKeyDocument(
|
||||
details.Signature.KeyId ?? "stella-evidence-signer",
|
||||
verifierType,
|
||||
verifierPublicKey,
|
||||
new[] { "dsse", "manifest-signing" })
|
||||
},
|
||||
new PortableManifestRekorKeyDocument(
|
||||
"rekor-checkpoint",
|
||||
GetMetadataValue(metadata, "rekor.key_material", "rekor_key_material") ?? "unavailable")),
|
||||
new PortableManifestCompatibilityDocument(
|
||||
"legacy-evidence-manifest-v1",
|
||||
details.Bundle.Id.Value.ToString("D"),
|
||||
new[] { "signature.json and bundle.json remain for compatibility" }));
|
||||
|
||||
return CanonicalizeObjectToUtf8(manifest);
|
||||
}
|
||||
|
||||
private static string ResolveVerifierType(string algorithm)
|
||||
{
|
||||
if (algorithm.Contains("ed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "ed25519";
|
||||
}
|
||||
|
||||
if (algorithm.Contains("es", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "ecdsa-p256";
|
||||
}
|
||||
|
||||
return "rsa-4096";
|
||||
}
|
||||
|
||||
private static PortableManifestFileDocument ToManifestFileDocument(PortableFileArtifact artifact)
|
||||
=> new(
|
||||
ComputeSha256Hex(artifact.Content),
|
||||
artifact.Content.LongLength,
|
||||
artifact.ContentType,
|
||||
artifact.Compression,
|
||||
artifact.SchemaFingerprint);
|
||||
|
||||
private static byte[] BuildManifestSignatureEnvelopeBytes(EvidenceBundleSignature signature, byte[] manifestBytes)
|
||||
{
|
||||
var envelope = new ManifestSignatureEnvelopeDocument(
|
||||
"application/vnd.stellaops.portable-manifest+json",
|
||||
Convert.ToBase64String(manifestBytes),
|
||||
new[]
|
||||
{
|
||||
new ManifestSignatureDocument(signature.KeyId ?? "stella-evidence-signer", signature.Signature)
|
||||
},
|
||||
ComputePayloadDigest(signature.Payload));
|
||||
|
||||
return CanonicalizeObjectToUtf8(envelope);
|
||||
}
|
||||
|
||||
private static LegacyManifestDocument DecodeLegacyManifest(EvidenceBundleSignature signature)
|
||||
{
|
||||
byte[] payload;
|
||||
try
|
||||
@@ -177,7 +412,7 @@ public sealed class EvidencePortableBundleService
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ManifestDocument>(payload, SerializerOptions)
|
||||
return JsonSerializer.Deserialize<LegacyManifestDocument>(payload, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Evidence bundle manifest payload is empty.");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
@@ -186,12 +421,123 @@ public sealed class EvidencePortableBundleService
|
||||
}
|
||||
}
|
||||
|
||||
private static PortableBundleMetadataDocument BuildPortableMetadata(
|
||||
private static CanonicalBomDocument BuildCanonicalBomDocument(
|
||||
EvidenceBundleDetails details,
|
||||
ManifestDocument manifest,
|
||||
LegacyManifestDocument legacyManifest,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
||||
var entries = legacyManifest.Entries ?? Array.Empty<LegacyManifestEntryDocument>();
|
||||
var bomEntries = entries
|
||||
.Where(entry =>
|
||||
entry.Section.Contains("sbom", StringComparison.OrdinalIgnoreCase)
|
||||
|| entry.CanonicalPath.Contains("sbom", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(entry => entry.CanonicalPath, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (bomEntries.Length == 0)
|
||||
{
|
||||
bomEntries = entries
|
||||
.OrderBy(entry => entry.CanonicalPath, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return new CanonicalBomDocument(
|
||||
"1.0",
|
||||
details.Bundle.Id.Value.ToString("D"),
|
||||
generatedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
bomEntries.Select(entry => new CanonicalBomEntryDocument(
|
||||
entry.CanonicalPath,
|
||||
entry.Sha256.ToLowerInvariant(),
|
||||
entry.SizeBytes,
|
||||
entry.MediaType ?? "application/octet-stream")).ToArray());
|
||||
}
|
||||
|
||||
private static DsseEnvelopeDocument BuildDsseEnvelopeDocument(EvidenceBundleSignature signature)
|
||||
=> new(
|
||||
signature.PayloadType,
|
||||
signature.Payload,
|
||||
new[]
|
||||
{
|
||||
new DsseSignatureDocument(
|
||||
signature.KeyId ?? "stella-evidence-signer",
|
||||
signature.Signature)
|
||||
});
|
||||
|
||||
private static bool TryBuildMergedVexDocument(
|
||||
LegacyManifestDocument manifest,
|
||||
DateTimeOffset generatedAt,
|
||||
out MergedVexDocument? mergedVex)
|
||||
{
|
||||
var vexEntries = (manifest.Entries ?? Array.Empty<LegacyManifestEntryDocument>())
|
||||
.Where(entry => entry.Section.Contains("vex", StringComparison.OrdinalIgnoreCase)
|
||||
|| entry.CanonicalPath.Contains("vex", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(entry => entry.CanonicalPath, StringComparer.Ordinal)
|
||||
.Select(entry => new MergedVexEntryDocument(entry.CanonicalPath, entry.Sha256.ToLowerInvariant()))
|
||||
.ToArray();
|
||||
|
||||
if (vexEntries.Length == 0)
|
||||
{
|
||||
mergedVex = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
mergedVex = new MergedVexDocument(
|
||||
"1.0",
|
||||
generatedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
vexEntries);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryBuildParquetArtifact(
|
||||
IReadOnlyDictionary<string, string> metadata,
|
||||
string artifactDigest,
|
||||
string canonicalBomDigest,
|
||||
out PortableFileArtifact artifact)
|
||||
{
|
||||
artifact = default!;
|
||||
|
||||
var enabledValue = GetMetadataValue(metadata, "portable.parquet.enabled", "portable_parquet_enabled");
|
||||
if (!bool.TryParse(enabledValue, out var enabled) || !enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var schema = string.Join('\n',
|
||||
"package_name",
|
||||
"package_version",
|
||||
"purl",
|
||||
"license",
|
||||
"component_hash_sha256",
|
||||
"artifact_digest_sha256",
|
||||
"cve_id",
|
||||
"vex_status",
|
||||
"introduced_range",
|
||||
"fixed_version",
|
||||
"source_bom_sha256");
|
||||
|
||||
var schemaFingerprint = "avro:" + ComputeSha256Hex(Encoding.UTF8.GetBytes(schema));
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"package_name,package_version,purl,license,component_hash_sha256,artifact_digest_sha256,cve_id,vex_status,introduced_range,fixed_version,source_bom_sha256\n"
|
||||
+ $"placeholder,0.0.0,pkg:generic/placeholder@0.0.0,UNKNOWN,{canonicalBomDigest},{artifactDigest},,,,,{canonicalBomDigest}\n");
|
||||
|
||||
artifact = new PortableFileArtifact(
|
||||
PortableComponentsParquetFileName,
|
||||
content,
|
||||
"application/x-parquet",
|
||||
DefaultFileMode,
|
||||
"snappy",
|
||||
schemaFingerprint);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static PortableBundleMetadataDocument BuildPortableMetadata(
|
||||
EvidenceBundleDetails details,
|
||||
LegacyManifestDocument manifest,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var entries = manifest.Entries ?? Array.Empty<LegacyManifestEntryDocument>();
|
||||
var entryCount = entries.Length;
|
||||
var totalSize = entries.Sum(e => e.SizeBytes);
|
||||
|
||||
@@ -226,7 +572,7 @@ public sealed class EvidencePortableBundleService
|
||||
|
||||
private static string BuildInstructions(
|
||||
EvidenceBundleDetails details,
|
||||
ManifestDocument manifest,
|
||||
LegacyManifestDocument manifest,
|
||||
DateTimeOffset generatedAt,
|
||||
PortableOptions options)
|
||||
{
|
||||
@@ -252,14 +598,15 @@ public sealed class EvidencePortableBundleService
|
||||
builder.Append("1. Copy '").Append(options.ArtifactName).AppendLine("' into the sealed environment.");
|
||||
builder.Append("2. Execute './").Append(options.OfflineScriptFileName).Append(' ');
|
||||
builder.Append(options.ArtifactName).AppendLine("' to extract contents and verify checksums.");
|
||||
builder.AppendLine("3. Review 'bundle.json' for sanitized metadata and incident context.");
|
||||
builder.AppendLine("4. Run 'stella evidence verify --bundle <path>' or use an offline verifier with 'manifest.json' + 'signature.json'.");
|
||||
builder.AppendLine("5. Store the bundle and verification output with the receiving enclave's evidence locker.");
|
||||
builder.AppendLine("3. Verify canonical manifest and detached signature using 'manifest.json' and 'manifest.sig'.");
|
||||
builder.AppendLine("4. Verify DSSE payload binding using 'dsse_envelope.json' and manifest digests.");
|
||||
builder.AppendLine("5. Verify bundled Rekor material under 'rekor/' in fail-closed mode.");
|
||||
builder.AppendLine("6. Store the bundle and verification output with the receiving enclave's evidence locker.");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Notes:");
|
||||
builder.AppendLine("- Metadata is redacted to remove tenant identifiers, storage coordinates, and free-form descriptions.");
|
||||
builder.AppendLine("- Incident metadata (if present) is exposed under 'incidentMetadata'.");
|
||||
builder.AppendLine("- Checksums cover every canonical entry and the Merkle root hash for tamper detection.");
|
||||
builder.AppendLine("- checksums.txt covers all exported files except itself.");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
@@ -291,24 +638,18 @@ public sealed class EvidencePortableBundleService
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("ROOT_HASH=$(sed -n 's/.*\"rootHash\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$WORKDIR\"/" + metadataFileName + " | head -n 1)");
|
||||
builder.AppendLine("echo \"Root hash: ${ROOT_HASH:-unknown}\"");
|
||||
builder.AppendLine("echo \"Verify DSSE signature with: stella evidence verify --bundle $ARCHIVE\"");
|
||||
builder.AppendLine("echo \"or provide manifest.json and signature.json to an offline verifier.\"");
|
||||
builder.AppendLine("echo \"Verify portable bundle with: stella evidence verify --bundle $ARCHIVE\"");
|
||||
builder.AppendLine("echo \"Portable profile checks are available via: stella devportal verify $ARCHIVE --offline\"");
|
||||
builder.AppendLine("echo \"You can also provide manifest.json + manifest.sig to any offline verifier.\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"Leaving extracted contents in $WORKDIR for manual inspection.\"");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string GetManifestJson(EvidenceBundleSignature signature)
|
||||
private static string GetLegacySignatureJson(EvidenceBundleSignature signature)
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(Convert.FromBase64String(signature.Payload));
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(document.RootElement, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string GetSignatureJson(EvidenceBundleSignature signature)
|
||||
{
|
||||
var model = new SignatureDocument(
|
||||
var model = new LegacySignatureDocument(
|
||||
signature.PayloadType,
|
||||
signature.Payload,
|
||||
signature.Signature,
|
||||
@@ -323,42 +664,54 @@ public sealed class EvidencePortableBundleService
|
||||
return JsonSerializer.Serialize(model, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string BuildChecksums(ManifestDocument manifest, string rootHash)
|
||||
private static byte[] BuildDeterministicTileTar(string logId, string rootHash, IReadOnlyList<string> covers)
|
||||
{
|
||||
var tileDocument = new RekorTileDocument(logId, rootHash, covers);
|
||||
var tileBytes = CanonicalizeObjectToUtf8(tileDocument);
|
||||
|
||||
using var memory = new MemoryStream();
|
||||
using (var writer = new TarWriter(memory, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
WriteBinaryEntry(writer, "tile.json", tileBytes, DefaultFileMode);
|
||||
}
|
||||
|
||||
return memory.ToArray();
|
||||
}
|
||||
|
||||
private static string BuildChecksums(IReadOnlyDictionary<string, PortableFileArtifact> files)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Evidence bundle checksums (sha256)");
|
||||
builder.Append("root ").AppendLine(rootHash);
|
||||
builder.AppendLine("# Portable audit pack checksums (sha256)");
|
||||
|
||||
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
||||
foreach (var entry in entries.OrderBy(e => e.CanonicalPath, StringComparer.Ordinal))
|
||||
foreach (var artifact in files.Values
|
||||
.Where(artifact => !string.Equals(artifact.Path, PortableChecksumsFileName, StringComparison.Ordinal))
|
||||
.OrderBy(artifact => artifact.Path, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(entry.Sha256)
|
||||
builder.Append(ComputeSha256Hex(artifact.Content))
|
||||
.Append(" ")
|
||||
.AppendLine(entry.CanonicalPath);
|
||||
.AppendLine(artifact.Path);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(
|
||||
private static void WriteBinaryEntry(
|
||||
TarWriter writer,
|
||||
string path,
|
||||
string content,
|
||||
byte[] content,
|
||||
UnixFileMode mode = default)
|
||||
{
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = mode == default ? DefaultFileMode : mode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
// Determinism: fixed uid/gid/owner/group per bundle-packaging.md
|
||||
Uid = 0,
|
||||
Gid = 0,
|
||||
UserName = string.Empty,
|
||||
GroupName = string.Empty
|
||||
};
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
entry.DataStream = new MemoryStream(bytes);
|
||||
entry.DataStream = new MemoryStream(content, writable: false);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
@@ -379,15 +732,172 @@ public sealed class EvidencePortableBundleService
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
|
||||
private sealed record ManifestDocument(
|
||||
private static byte[] CanonicalizeObjectToUtf8<T>(T value)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(value, SerializerOptions);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return CanonicalizeJsonElement(document.RootElement);
|
||||
}
|
||||
|
||||
private static byte[] CanonicalizeJsonElement(JsonElement element)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false });
|
||||
WriteCanonicalElement(writer, element);
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalElement(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonicalElement(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonicalElement(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
var rawNumber = element.GetRawText();
|
||||
if (rawNumber.Contains("NaN", StringComparison.OrdinalIgnoreCase)
|
||||
|| rawNumber.Contains("Infinity", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Non-finite numbers are not supported in canonical JSON.");
|
||||
}
|
||||
|
||||
writer.WriteRawValue(rawNumber, skipInputValidation: true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(element.GetBoolean());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported JSON token kind '{element.ValueKind}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] bytes)
|
||||
=> Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
private static string ComputePayloadDigest(string payload)
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(payload);
|
||||
return ComputeSha256Hex(payloadBytes);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ToReadOnlyMetadata(IDictionary<string, string>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
if (metadata is IReadOnlyDictionary<string, string> readOnlyMetadata)
|
||||
{
|
||||
return readOnlyMetadata;
|
||||
}
|
||||
|
||||
var normalized = metadata
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
|
||||
|
||||
return new ReadOnlyDictionary<string, string>(normalized);
|
||||
}
|
||||
|
||||
private static string? GetMetadataValue(IReadOnlyDictionary<string, string> metadata, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string? value, string fallback)
|
||||
{
|
||||
var candidate = value;
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
candidate = fallback;
|
||||
}
|
||||
|
||||
candidate = candidate.Trim();
|
||||
if (candidate.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
candidate = candidate.Substring("sha256:".Length);
|
||||
}
|
||||
|
||||
candidate = candidate.ToLowerInvariant();
|
||||
|
||||
if (candidate.Length != 64 || !IsHex(candidate))
|
||||
{
|
||||
return fallback.ToLowerInvariant();
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static bool IsHex(string value)
|
||||
{
|
||||
foreach (var ch in value)
|
||||
{
|
||||
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f';
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed record PortableFileArtifact(
|
||||
string Path,
|
||||
byte[] Content,
|
||||
string ContentType,
|
||||
UnixFileMode Mode = default,
|
||||
string? Compression = null,
|
||||
string? SchemaFingerprint = null);
|
||||
|
||||
private sealed record LegacyManifestDocument(
|
||||
Guid BundleId,
|
||||
Guid TenantId,
|
||||
int Kind,
|
||||
DateTimeOffset CreatedAt,
|
||||
IDictionary<string, string>? Metadata,
|
||||
ManifestEntryDocument[]? Entries);
|
||||
LegacyManifestEntryDocument[]? Entries);
|
||||
|
||||
private sealed record ManifestEntryDocument(
|
||||
private sealed record LegacyManifestEntryDocument(
|
||||
string Section,
|
||||
string CanonicalPath,
|
||||
string Sha256,
|
||||
@@ -395,7 +905,7 @@ public sealed class EvidencePortableBundleService
|
||||
string? MediaType,
|
||||
IDictionary<string, string>? Attributes);
|
||||
|
||||
private sealed record SignatureDocument(
|
||||
private sealed record LegacySignatureDocument(
|
||||
string PayloadType,
|
||||
string Payload,
|
||||
string Signature,
|
||||
@@ -420,4 +930,110 @@ public sealed class EvidencePortableBundleService
|
||||
int EntryCount,
|
||||
long TotalSizeBytes,
|
||||
IReadOnlyDictionary<string, string>? IncidentMetadata);
|
||||
|
||||
private sealed record CanonicalBomDocument(
|
||||
string SchemaVersion,
|
||||
string BundleId,
|
||||
string GeneratedUtc,
|
||||
IReadOnlyList<CanonicalBomEntryDocument> Entries);
|
||||
|
||||
private sealed record CanonicalBomEntryDocument(
|
||||
string CanonicalPath,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
string MediaType);
|
||||
|
||||
private sealed record DsseEnvelopeDocument(
|
||||
string PayloadType,
|
||||
string Payload,
|
||||
IReadOnlyList<DsseSignatureDocument> Signatures);
|
||||
|
||||
private sealed record DsseSignatureDocument(string Keyid, string Sig);
|
||||
|
||||
private sealed record MergedVexDocument(
|
||||
string SchemaVersion,
|
||||
string GeneratedUtc,
|
||||
IReadOnlyList<MergedVexEntryDocument> Entries);
|
||||
|
||||
private sealed record MergedVexEntryDocument(string Path, string Sha256);
|
||||
|
||||
private sealed record RekorCheckpointDocument(
|
||||
string LogId,
|
||||
string RootHash,
|
||||
string IncludedAtUtc);
|
||||
|
||||
private sealed record RekorTileDocument(
|
||||
string LogId,
|
||||
string RootHash,
|
||||
IReadOnlyList<string> Covers);
|
||||
|
||||
private sealed record PortableManifestDocument(
|
||||
string SpecVersion,
|
||||
string CreatedUtc,
|
||||
PortableManifestArtifactDocument Artifact,
|
||||
IReadOnlyDictionary<string, PortableManifestFileDocument> Files,
|
||||
PortableManifestDigestsDocument Digests,
|
||||
PortableManifestRekorDocument Rekor,
|
||||
PortableManifestTimestampsDocument Timestamps,
|
||||
PortableManifestVerifiersDocument Verifiers,
|
||||
PortableManifestCompatibilityDocument Compatibility);
|
||||
|
||||
private sealed record PortableManifestArtifactDocument(
|
||||
string Name,
|
||||
string Version,
|
||||
PortableManifestShaDigestDocument Digest,
|
||||
string MediaType);
|
||||
|
||||
private sealed record PortableManifestFileDocument(
|
||||
string Sha256,
|
||||
long Size,
|
||||
string ContentType,
|
||||
string? Compression,
|
||||
string? SchemaFingerprint);
|
||||
|
||||
private sealed record PortableManifestDigestsDocument(
|
||||
string CanonicalBomSha256,
|
||||
PortableManifestShaDigestDocument DssePayloadDigest);
|
||||
|
||||
private sealed record PortableManifestShaDigestDocument(string Sha256);
|
||||
|
||||
private sealed record PortableManifestRekorDocument(
|
||||
string LogId,
|
||||
string ApiVersion,
|
||||
IReadOnlyList<PortableManifestTileReferenceDocument> TileRefs,
|
||||
string RootHash);
|
||||
|
||||
private sealed record PortableManifestTileReferenceDocument(
|
||||
string Path,
|
||||
IReadOnlyList<string> Covers);
|
||||
|
||||
private sealed record PortableManifestTimestampsDocument(
|
||||
string BomCanonicalized,
|
||||
string DsseSigned,
|
||||
string RekorIncluded);
|
||||
|
||||
private sealed record PortableManifestVerifiersDocument(
|
||||
IReadOnlyList<PortableManifestPublicKeyDocument> Pubkeys,
|
||||
PortableManifestRekorKeyDocument RekorPub);
|
||||
|
||||
private sealed record PortableManifestPublicKeyDocument(
|
||||
string Id,
|
||||
string Type,
|
||||
string PublicKey,
|
||||
IReadOnlyList<string> Usage);
|
||||
|
||||
private sealed record PortableManifestRekorKeyDocument(string Type, string KeyMaterial);
|
||||
|
||||
private sealed record PortableManifestCompatibilityDocument(
|
||||
string LegacyManifestVersion,
|
||||
string LegacyBundleId,
|
||||
IReadOnlyList<string> MigrationNotes);
|
||||
|
||||
private sealed record ManifestSignatureEnvelopeDocument(
|
||||
string PayloadType,
|
||||
string Payload,
|
||||
IReadOnlyList<ManifestSignatureDocument> Signatures,
|
||||
string SourcePayloadDigestSha256);
|
||||
|
||||
private sealed record ManifestSignatureDocument(string Keyid, string Sig);
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0289-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0289-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| EL-GATE-002 | DONE | Added `evidence_gate_artifacts` persistence, migration `004_gate_artifacts.sql`, and repository/service wiring (2026-02-09). |
|
||||
| PAPI-001 | DONE | SPRINT_20260210_005 - Portable audit pack v1 writer/schema wiring in EvidencePortableBundleService (2026-02-10); deterministic portable profile and manifest parity validated by module tests. |
|
||||
|
||||
@@ -60,10 +60,22 @@ public sealed class EvidencePortableBundleServiceTests
|
||||
|
||||
var entries = ReadArchiveEntries(objectStore.StoredBytes!);
|
||||
Assert.Contains("manifest.json", entries.Keys);
|
||||
Assert.Contains("signature.json", entries.Keys);
|
||||
Assert.Contains("manifest.sig", entries.Keys);
|
||||
Assert.Contains("canonical_bom.json", entries.Keys);
|
||||
Assert.Contains("dsse_envelope.json", entries.Keys);
|
||||
Assert.Contains("rekor/tile.tar", entries.Keys);
|
||||
Assert.Contains("signature.json", entries.Keys); // legacy compatibility
|
||||
Assert.Contains("bundle.json", entries.Keys);
|
||||
Assert.Contains("instructions-portable.txt", entries.Keys);
|
||||
Assert.Contains("verify-offline.sh", entries.Keys);
|
||||
Assert.Contains("checksums.txt", entries.Keys);
|
||||
|
||||
using var manifestJson = JsonDocument.Parse(entries["manifest.json"]);
|
||||
Assert.Equal("1.0", manifestJson.RootElement.GetProperty("specVersion").GetString());
|
||||
Assert.True(manifestJson.RootElement.TryGetProperty("files", out var files));
|
||||
Assert.True(files.TryGetProperty("canonical_bom.json", out _));
|
||||
Assert.True(files.TryGetProperty("dsse_envelope.json", out _));
|
||||
Assert.True(files.TryGetProperty("rekor/tile.tar", out _));
|
||||
|
||||
using var bundleJson = JsonDocument.Parse(entries["bundle.json"]);
|
||||
var root = bundleJson.RootElement;
|
||||
@@ -82,11 +94,12 @@ public sealed class EvidencePortableBundleServiceTests
|
||||
var instructions = entries["instructions-portable.txt"];
|
||||
Assert.Contains("Portable Evidence Bundle Instructions", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("verify-offline.sh", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("manifest.sig", instructions, StringComparison.Ordinal);
|
||||
|
||||
var script = entries["verify-offline.sh"];
|
||||
Assert.StartsWith("#!/usr/bin/env sh", script, StringComparison.Ordinal);
|
||||
Assert.Contains("sha256sum", script, StringComparison.Ordinal);
|
||||
Assert.Contains("stella evidence verify", script, StringComparison.Ordinal);
|
||||
Assert.Contains("stella devportal verify", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -128,6 +141,25 @@ public sealed class EvidencePortableBundleServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnsurePortablePackageAsync_IsByteDeterministic_ForIdenticalInput()
|
||||
{
|
||||
var repositoryA = new FakeRepository(CreateSealedBundle(), CreateSignature(includeTimestamp: true));
|
||||
var repositoryB = new FakeRepository(CreateSealedBundle(), CreateSignature(includeTimestamp: true));
|
||||
var storeA = new FakeObjectStore(exists: false);
|
||||
var storeB = new FakeObjectStore(exists: false);
|
||||
var serviceA = CreateService(repositoryA, storeA);
|
||||
var serviceB = CreateService(repositoryB, storeB);
|
||||
|
||||
await serviceA.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
|
||||
await serviceB.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(storeA.StoredBytes);
|
||||
Assert.NotNull(storeB.StoredBytes);
|
||||
Assert.Equal(storeA.StoredBytes!, storeB.StoredBytes!);
|
||||
}
|
||||
|
||||
private static EvidencePortableBundleService CreateService(FakeRepository repository, IEvidenceObjectStore objectStore)
|
||||
{
|
||||
var options = Options.Create(new EvidenceLockerOptions
|
||||
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0290-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0290-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| EL-GATE-TESTS | DONE | Added gate artifact endpoint/service determinism tests and migration assertion updates (2026-02-09). |
|
||||
| PAPI-007-TESTS | DONE | SPRINT_20260210_005 - Portable pack determinism/tamper tests for EvidencePortableBundleService and web surface executed; suite passed (107 passed, 12 skipped) on 2026-02-10. |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
|
||||
|
||||
@@ -17,18 +17,10 @@
|
||||
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<!-- FrameworkReference Microsoft.AspNetCore.App is provided by Sdk.Web -->
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
<!-- Microsoft.Extensions.Hosting is provided by Sdk.Worker -->
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +33,7 @@
|
||||
<ProjectReference Include="..\StellaOps.EvidenceLocker.Infrastructure\StellaOps.EvidenceLocker.Infrastructure.csproj"/>
|
||||
|
||||
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
@@ -7,12 +7,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<!-- Microsoft.Extensions.Hosting and FrameworkReference Microsoft.AspNetCore.App are provided by Sdk.Web -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
|
||||
|
||||
@@ -17,18 +17,10 @@
|
||||
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<!-- FrameworkReference Microsoft.AspNetCore.App is provided by Sdk.Web -->
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
<!-- Microsoft.Extensions.Hosting is provided by Sdk.Worker -->
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +38,7 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj"/>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj"/>
|
||||
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,13 +3,11 @@ using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Integration;
|
||||
|
||||
@@ -198,18 +196,6 @@ public sealed class GatewayWebApplicationFactory : WebApplicationFactory<Program
|
||||
config.NodeId = "test-gateway-01";
|
||||
config.Environment = "test";
|
||||
});
|
||||
|
||||
// Register RateLimitService for testing (required by RateLimitMiddleware)
|
||||
// This provides a no-op rate limiter since no rules are configured
|
||||
services.AddSingleton(_ =>
|
||||
{
|
||||
var config = new RateLimitConfig { ActivationThresholdPer5Min = 0 };
|
||||
return new RateLimitService(
|
||||
config,
|
||||
instanceLimiter: null,
|
||||
environmentLimiter: null,
|
||||
NullLogger<RateLimitService>.Instance);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
using StellaOps.Gateway.WebService.Services;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.OpenApi;
|
||||
using StellaOps.Router.Transport.Tcp;
|
||||
using StellaOps.Router.Transport.Tls;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Services;
|
||||
|
||||
public sealed class GatewayHostedServiceConnectionLifecycleTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly List<ConnectionState> _connections = [];
|
||||
private readonly Mock<IGlobalRoutingState> _routingState = new();
|
||||
private readonly Mock<IEffectiveClaimsStore> _claimsStore = new();
|
||||
private readonly Mock<IRouterOpenApiDocumentCache> _openApiCache = new();
|
||||
private readonly GatewayHostedService _service;
|
||||
|
||||
public GatewayHostedServiceConnectionLifecycleTests()
|
||||
{
|
||||
_routingState
|
||||
.Setup(x => x.GetAllConnections())
|
||||
.Returns(() => _connections.ToList());
|
||||
|
||||
_routingState
|
||||
.Setup(x => x.GetConnection(It.IsAny<string>()))
|
||||
.Returns((string connectionId) => _connections.FirstOrDefault(c => c.ConnectionId == connectionId));
|
||||
|
||||
_routingState
|
||||
.Setup(x => x.AddConnection(It.IsAny<ConnectionState>()))
|
||||
.Callback<ConnectionState>(state => _connections.Add(state));
|
||||
|
||||
_routingState
|
||||
.Setup(x => x.RemoveConnection(It.IsAny<string>()))
|
||||
.Callback<string>(connectionId =>
|
||||
{
|
||||
var existing = _connections.FirstOrDefault(c => c.ConnectionId == connectionId);
|
||||
if (existing is not null)
|
||||
{
|
||||
_connections.Remove(existing);
|
||||
}
|
||||
});
|
||||
|
||||
_routingState
|
||||
.Setup(x => x.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()))
|
||||
.Callback<string, Action<ConnectionState>>((connectionId, update) =>
|
||||
{
|
||||
var existing = _connections.FirstOrDefault(c => c.ConnectionId == connectionId);
|
||||
if (existing is not null)
|
||||
{
|
||||
update(existing);
|
||||
}
|
||||
});
|
||||
|
||||
var tcpServer = new TcpTransportServer(
|
||||
Options.Create(new TcpTransportOptions { Port = 29130 }),
|
||||
NullLogger<TcpTransportServer>.Instance);
|
||||
|
||||
var tlsServer = new TlsTransportServer(
|
||||
Options.Create(new TlsTransportOptions { Port = 29473 }),
|
||||
NullLogger<TlsTransportServer>.Instance);
|
||||
|
||||
var transportClient = new GatewayTransportClient(
|
||||
tcpServer,
|
||||
tlsServer,
|
||||
NullLogger<GatewayTransportClient>.Instance);
|
||||
|
||||
_service = new GatewayHostedService(
|
||||
tcpServer,
|
||||
tlsServer,
|
||||
_routingState.Object,
|
||||
transportClient,
|
||||
_claimsStore.Object,
|
||||
Options.Create(new GatewayOptions()),
|
||||
new GatewayServiceStatus(),
|
||||
NullLogger<GatewayHostedService>.Instance,
|
||||
_openApiCache.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleFrameAsync_HelloWithValidPayload_RegistersConnectionAndUpdatesClaims()
|
||||
{
|
||||
var helloPayload = CreateHelloPayload();
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
Payload = JsonSerializer.SerializeToUtf8Bytes(helloPayload, JsonOptions)
|
||||
};
|
||||
|
||||
await InvokeHandleFrameAsync(TransportType.Tcp, "tcp-conn-hello-1", frame);
|
||||
|
||||
_routingState.Verify(
|
||||
x => x.AddConnection(It.Is<ConnectionState>(c =>
|
||||
c.ConnectionId == "tcp-conn-hello-1" &&
|
||||
c.Instance.ServiceName == "scanner" &&
|
||||
c.Instance.Version == "1.0.0" &&
|
||||
c.Endpoints.Count == 1)),
|
||||
Times.Once);
|
||||
|
||||
_claimsStore.Verify(
|
||||
x => x.UpdateFromMicroservice(
|
||||
"scanner",
|
||||
It.Is<IReadOnlyList<EndpointDescriptor>>(endpoints => endpoints.Count == 1)),
|
||||
Times.Once);
|
||||
|
||||
_openApiCache.Verify(x => x.Invalidate(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleFrameAsync_HelloWithDuplicateEndpoints_DoesNotRegisterConnection()
|
||||
{
|
||||
var endpointA = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "scanner",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/scans/{id}"
|
||||
};
|
||||
|
||||
var endpointB = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "scanner",
|
||||
Version = "1.0.0",
|
||||
Method = "get",
|
||||
Path = "/api/v1/scans/{id}"
|
||||
};
|
||||
|
||||
var helloPayload = CreateHelloPayload(endpoints: [endpointA, endpointB]);
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
Payload = JsonSerializer.SerializeToUtf8Bytes(helloPayload, JsonOptions)
|
||||
};
|
||||
|
||||
await InvokeHandleFrameAsync(TransportType.Tcp, "tcp-conn-hello-dup", frame);
|
||||
|
||||
_routingState.Verify(x => x.AddConnection(It.IsAny<ConnectionState>()), Times.Never);
|
||||
_claimsStore.Verify(x => x.UpdateFromMicroservice(It.IsAny<string>(), It.IsAny<IReadOnlyList<EndpointDescriptor>>()), Times.Never);
|
||||
_openApiCache.Verify(x => x.Invalidate(), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleFrameAsync_HeartbeatForKnownConnection_UpdatesStatusAndHeartbeat()
|
||||
{
|
||||
var before = DateTime.UtcNow - TimeSpan.FromMinutes(5);
|
||||
_connections.Add(new ConnectionState
|
||||
{
|
||||
ConnectionId = "tcp-conn-heartbeat-1",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "scanner-01",
|
||||
ServiceName = "scanner",
|
||||
Version = "1.0.0",
|
||||
Region = "local"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = before,
|
||||
TransportType = TransportType.Tcp
|
||||
});
|
||||
|
||||
var heartbeatPayload = new HeartbeatPayload
|
||||
{
|
||||
InstanceId = "scanner-01",
|
||||
Status = InstanceHealthStatus.Degraded,
|
||||
TimestampUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat,
|
||||
Payload = JsonSerializer.SerializeToUtf8Bytes(heartbeatPayload, JsonOptions)
|
||||
};
|
||||
|
||||
await InvokeHandleFrameAsync(TransportType.Tcp, "tcp-conn-heartbeat-1", frame);
|
||||
|
||||
Assert.Equal(InstanceHealthStatus.Degraded, _connections[0].Status);
|
||||
Assert.True(_connections[0].LastHeartbeatUtc > before);
|
||||
_routingState.Verify(
|
||||
x => x.UpdateConnection("tcp-conn-heartbeat-1", It.IsAny<Action<ConnectionState>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleFrameAsync_HeartbeatForUnknownConnection_DoesNotUpdateState()
|
||||
{
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat,
|
||||
Payload = JsonSerializer.SerializeToUtf8Bytes(new HeartbeatPayload
|
||||
{
|
||||
InstanceId = "missing",
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TimestampUtc = DateTime.UtcNow
|
||||
}, JsonOptions)
|
||||
};
|
||||
|
||||
await InvokeHandleFrameAsync(TransportType.Tcp, "unknown-connection", frame);
|
||||
|
||||
_routingState.Verify(x => x.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDisconnect_LastServiceConnection_RemovesClaimsAndInvalidatesOpenApi()
|
||||
{
|
||||
_connections.Add(new ConnectionState
|
||||
{
|
||||
ConnectionId = "tcp-conn-disconnect-1",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "scanner-01",
|
||||
ServiceName = "scanner",
|
||||
Version = "1.0.0",
|
||||
Region = "local"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.Tcp
|
||||
});
|
||||
|
||||
InvokeHandleDisconnect("tcp-conn-disconnect-1");
|
||||
|
||||
Assert.Empty(_connections);
|
||||
_routingState.Verify(x => x.RemoveConnection("tcp-conn-disconnect-1"), Times.Once);
|
||||
_claimsStore.Verify(x => x.RemoveService("scanner"), Times.Once);
|
||||
_openApiCache.Verify(x => x.Invalidate(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDisconnect_ServiceStillHasActiveConnection_DoesNotRemoveClaims()
|
||||
{
|
||||
_connections.Add(new ConnectionState
|
||||
{
|
||||
ConnectionId = "tcp-conn-disconnect-1",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "scanner-01",
|
||||
ServiceName = "scanner",
|
||||
Version = "1.0.0",
|
||||
Region = "local"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.Tcp
|
||||
});
|
||||
|
||||
_connections.Add(new ConnectionState
|
||||
{
|
||||
ConnectionId = "tcp-conn-disconnect-2",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "scanner-02",
|
||||
ServiceName = "scanner",
|
||||
Version = "1.0.0",
|
||||
Region = "local"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.Tcp
|
||||
});
|
||||
|
||||
InvokeHandleDisconnect("tcp-conn-disconnect-1");
|
||||
|
||||
Assert.Single(_connections);
|
||||
Assert.Equal("tcp-conn-disconnect-2", _connections[0].ConnectionId);
|
||||
_claimsStore.Verify(x => x.RemoveService(It.IsAny<string>()), Times.Never);
|
||||
_openApiCache.Verify(x => x.Invalidate(), Times.Once);
|
||||
}
|
||||
|
||||
private Task InvokeHandleFrameAsync(TransportType transportType, string connectionId, Frame frame)
|
||||
{
|
||||
var method = typeof(GatewayHostedService).GetMethod(
|
||||
"HandleFrameAsync",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
var task = method!.Invoke(_service, [transportType, connectionId, frame]) as Task;
|
||||
Assert.NotNull(task);
|
||||
return task!;
|
||||
}
|
||||
|
||||
private void InvokeHandleDisconnect(string connectionId)
|
||||
{
|
||||
var method = typeof(GatewayHostedService).GetMethod(
|
||||
"HandleDisconnect",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
method!.Invoke(_service, [connectionId]);
|
||||
}
|
||||
|
||||
private static HelloPayload CreateHelloPayload(IReadOnlyList<EndpointDescriptor>? endpoints = null)
|
||||
{
|
||||
var resolvedEndpoints = endpoints ?? [
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "scanner",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/scans/{id}",
|
||||
SupportsStreaming = false
|
||||
}
|
||||
];
|
||||
|
||||
return new HelloPayload
|
||||
{
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "scanner-01",
|
||||
ServiceName = "scanner",
|
||||
Version = "1.0.0",
|
||||
Region = "local"
|
||||
},
|
||||
Endpoints = resolvedEndpoints
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0348-T | DONE | Revalidated 2026-01-07; test coverage audit for Gateway.WebService.Tests. |
|
||||
| AUDIT-0348-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| QA-GATEWAY-RECHECK-001 | DONE | 2026-02-10 checked-feature Tier 2 replay completed for gateway module features. |
|
||||
| QA-GATEWAY-RECHECK-002 | DONE | 2026-02-10 added `GatewayHostedServiceConnectionLifecycleTests` (6 tests) for HELLO/heartbeat/disconnect regression coverage. |
|
||||
|
||||
@@ -5,14 +5,14 @@ using StellaOps.Graph.Api.Services;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<InMemoryGraphRepository>();
|
||||
builder.Services.AddSingleton(_ => new InMemoryGraphRepository());
|
||||
builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
|
||||
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
|
||||
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
|
||||
builder.Services.AddScoped<IGraphDiffService, InMemoryGraphDiffService>();
|
||||
builder.Services.AddScoped<IGraphLineageService, InMemoryGraphLineageService>();
|
||||
builder.Services.AddScoped<IOverlayService, InMemoryOverlayService>();
|
||||
builder.Services.AddScoped<IGraphExportService, InMemoryGraphExportService>();
|
||||
builder.Services.AddSingleton<IGraphExportService, InMemoryGraphExportService>();
|
||||
builder.Services.AddSingleton<IRateLimiter>(_ => new RateLimiterService(limitPerWindow: 120));
|
||||
builder.Services.AddSingleton<IAuditLogger, InMemoryAuditLogger>();
|
||||
builder.Services.AddSingleton<IGraphMetrics, GraphMetrics>();
|
||||
@@ -351,16 +351,52 @@ app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest requ
|
||||
return Results.Ok(manifest);
|
||||
});
|
||||
|
||||
app.MapGet("/graph/export/{jobId}", (string jobId, HttpContext context, IGraphExportService service) =>
|
||||
app.MapGet("/graph/export/{jobId}", async (string jobId, HttpContext context, IGraphExportService service, CancellationToken ct) =>
|
||||
{
|
||||
var job = service.Get(jobId);
|
||||
if (job is null)
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status401Unauthorized, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:export"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:export scope", ct);
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status403Forbidden, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/export/download"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var job = service.Get(jobId);
|
||||
if (job is null || !string.Equals(job.Tenant, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status404NotFound, sw.ElapsedMilliseconds);
|
||||
return Results.NotFound(new ErrorResponse { Error = "GRAPH_EXPORT_NOT_FOUND", Message = "Export job not found" });
|
||||
}
|
||||
|
||||
context.Response.Headers.ContentLength = job.Payload.Length;
|
||||
context.Response.Headers["X-Content-SHA256"] = job.Sha256;
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.File(job.Payload, job.ContentType, $"graph-export-{job.JobId}.{job.Format}");
|
||||
});
|
||||
|
||||
@@ -371,15 +407,37 @@ app.MapGet("/graph/export/{jobId}", (string jobId, HttpContext context, IGraphEx
|
||||
app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/metadata"))
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var response = await service.GetEdgeMetadataAsync(tenant, request, ct);
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/metadata"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var response = await service.GetEdgeMetadataAsync(tenant!, request, ct);
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(response);
|
||||
});
|
||||
@@ -387,15 +445,37 @@ app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpCon
|
||||
app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/metadata"))
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var result = await service.GetSingleEdgeMetadataAsync(tenant, edgeId, ct);
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/metadata"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var result = await service.GetSingleEdgeMetadataAsync(tenant!, edgeId, ct);
|
||||
if (result is null)
|
||||
{
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status404NotFound, sw.ElapsedMilliseconds);
|
||||
@@ -409,15 +489,37 @@ app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext c
|
||||
app.MapGet("/graph/edges/path/{sourceNodeId}/{targetNodeId}", async (string sourceNodeId, string targetNodeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/path"))
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/path", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var edges = await service.GetPathEdgesWithMetadataAsync(tenant, sourceNodeId, targetNodeId, ct);
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/path"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/edges/path", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var edges = await service.GetPathEdgesWithMetadataAsync(tenant!, sourceNodeId, targetNodeId, ct);
|
||||
LogAudit(context, "/graph/edges/path", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(new { sourceNodeId, targetNodeId, edges = edges.ToList() });
|
||||
});
|
||||
@@ -425,12 +527,34 @@ app.MapGet("/graph/edges/path/{sourceNodeId}/{targetNodeId}", async (string sour
|
||||
app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit, string? cursor, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/by-reason"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<EdgeReason>(reason, ignoreCase: true, out var edgeReason))
|
||||
@@ -439,7 +563,7 @@ app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit,
|
||||
return Results.BadRequest(new ErrorResponse { Error = "INVALID_REASON", Message = $"Unknown edge reason: {reason}" });
|
||||
}
|
||||
|
||||
var response = await service.QueryByReasonAsync(tenant, edgeReason, limit ?? 100, cursor, ct);
|
||||
var response = await service.QueryByReasonAsync(tenant!, edgeReason, limit ?? 100, cursor, ct);
|
||||
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(response);
|
||||
});
|
||||
@@ -447,15 +571,37 @@ app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit,
|
||||
app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string evidenceRef, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/by-evidence"))
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var edges = await service.QueryByEvidenceAsync(tenant, evidenceType, evidenceRef, ct);
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/by-evidence"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var edges = await service.QueryByEvidenceAsync(tenant!, evidenceType, evidenceRef, ct);
|
||||
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(edges);
|
||||
});
|
||||
@@ -501,3 +647,5 @@ static void LogAudit(HttpContext ctx, string route, int statusCode, long duratio
|
||||
StatusCode: statusCode,
|
||||
DurationMs: durationMs));
|
||||
}
|
||||
|
||||
public partial class Program { }
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public sealed class EdgeMetadataEndpointsAuthorizationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public EdgeMetadataEndpointsAuthorizationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder => builder.UseEnvironment("Development"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeMetadataPost_MissingAuthorization_ReturnsUnauthorized()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/edges/metadata")
|
||||
{
|
||||
Content = JsonContent.Create(new { edgeIds = new[] { "ge:acme:artifact->component" } })
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Contains("GRAPH_UNAUTHORIZED", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeMetadataPost_MissingTenant_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/edges/metadata")
|
||||
{
|
||||
Content = JsonContent.Create(new { edgeIds = new[] { "ge:acme:artifact->component" } })
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("GRAPH_VALIDATION_FAILED", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeMetadataPost_MissingReadOrQueryScope_ReturnsForbidden()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/edges/metadata")
|
||||
{
|
||||
Content = JsonContent.Create(new { edgeIds = new[] { "ge:acme:artifact->component" } })
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Contains("GRAPH_FORBIDDEN", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeByReason_MissingAuthorization_ReturnsUnauthorized()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/graph/edges/by-reason/SbomDependency");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Contains("GRAPH_UNAUTHORIZED", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgePath_ReadOnlyScope_ReturnsForbidden()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/graph/edges/path/gn:acme:artifact:sha256:abc/gn:acme:component:widget");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Contains("GRAPH_FORBIDDEN", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeByReason_WithReadScope_ReturnsOk()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/graph/edges/by-reason/SbomDependency");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeMetadataGet_WithValidAuthUnknownEdge_ReturnsNotFound()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/graph/edges/ge:acme:missing/metadata");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Contains("EDGE_NOT_FOUND", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeMetadataGet_WithValidAuthKnownEdge_ReturnsOk()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/graph/edges/ge:acme:component-%3Ecomponent/metadata");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("depends_on", payload, StringComparison.Ordinal);
|
||||
Assert.Contains("explanation", payload, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public sealed class ExportEndpointsAuthorizationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public ExportEndpointsAuthorizationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder => builder.UseEnvironment("Development"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task ExportDownload_WithValidAuthAndTenant_ReturnsFile()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var (jobId, downloadUrl) = await CreateExportJobAsync(client, "acme");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsByteArrayAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(payload);
|
||||
Assert.True(response.Headers.Contains("X-Content-SHA256"));
|
||||
Assert.StartsWith("application/x-ndjson", response.Content.Headers.ContentType?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(jobId, response.Content.Headers.ContentDisposition?.FileName ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task ExportDownload_MissingAuthorization_ReturnsUnauthorized()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var (_, downloadUrl) = await CreateExportJobAsync(client, "acme");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Contains("GRAPH_UNAUTHORIZED", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task ExportDownload_WrongTenant_ReturnsNotFound()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var (_, downloadUrl) = await CreateExportJobAsync(client, "acme");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "bravo");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Contains("GRAPH_EXPORT_NOT_FOUND", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task ExportDownload_MissingTenant_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var (_, downloadUrl) = await CreateExportJobAsync(client, "acme");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("GRAPH_VALIDATION_FAILED", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<(string JobId, string DownloadUrl)> CreateExportJobAsync(HttpClient client, string tenant)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/export")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
format = "ndjson",
|
||||
includeEdges = true
|
||||
})
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", tenant);
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
var root = doc.RootElement;
|
||||
var jobId = root.GetProperty("jobId").GetString();
|
||||
var downloadUrl = root.GetProperty("downloadUrl").GetString();
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(jobId));
|
||||
Assert.False(string.IsNullOrWhiteSpace(downloadUrl));
|
||||
|
||||
return (jobId!, downloadUrl!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public sealed class QueryOverlayEndpointsIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public QueryOverlayEndpointsIntegrationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder => builder.UseEnvironment("Development"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Operational")]
|
||||
public async Task Query_WithIncludeOverlays_ReturnsPolicyAndVexOverlays()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/query")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
kinds = new[] { "component" },
|
||||
query = "widget",
|
||||
includeOverlays = true,
|
||||
includeEdges = false,
|
||||
includeStats = false,
|
||||
limit = 5
|
||||
})
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:query");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var nodeLines = body
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(line => line.Contains("\"type\":\"node\"", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.NotEmpty(nodeLines);
|
||||
|
||||
using var nodeDoc = JsonDocument.Parse(nodeLines[0]);
|
||||
var overlays = nodeDoc.RootElement.GetProperty("data").GetProperty("overlays");
|
||||
Assert.True(overlays.TryGetProperty("policy", out _));
|
||||
Assert.True(overlays.TryGetProperty("vex", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Operational")]
|
||||
public async Task Query_WithIncludeOverlays_SamplesExplainTraceOncePerResponse()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/query")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
kinds = new[] { "component" },
|
||||
query = "component",
|
||||
includeOverlays = true,
|
||||
includeEdges = false,
|
||||
includeStats = false,
|
||||
limit = 10
|
||||
})
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:query");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var explainTraceCount = 0;
|
||||
var nodeLines = body
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(line => line.Contains("\"type\":\"node\"", StringComparison.Ordinal));
|
||||
|
||||
foreach (var line in nodeLines)
|
||||
{
|
||||
using var nodeDoc = JsonDocument.Parse(line);
|
||||
var data = nodeDoc.RootElement.GetProperty("data");
|
||||
if (!data.TryGetProperty("overlays", out var overlays))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var overlay in overlays.EnumerateObject())
|
||||
{
|
||||
if (!overlay.Value.TryGetProperty("data", out var payload))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!payload.TryGetProperty("explainTrace", out var trace))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trace.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
explainTraceCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(1, explainTraceCount);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Graph.Api/StellaOps.Graph.Api.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Update="xunit.v3" />
|
||||
<PackageReference Update="xunit.runner.visualstudio" />
|
||||
|
||||
@@ -9,3 +9,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0351-T | DONE | Revalidated 2026-01-07; test coverage audit for Graph.Api.Tests. |
|
||||
| AUDIT-0351-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| QA-GRAPH-RECHECK-002 | DONE | SPRINT_20260210_005: endpoint auth/scope/tenant regression tests for edge metadata API added and passing. |
|
||||
| QA-GRAPH-RECHECK-004 | DONE | SPRINT_20260210_005: export download round-trip/authorization regression tests added and passing. |
|
||||
| QA-GRAPH-RECHECK-005 | DONE | SPRINT_20260210_005: query/overlay API integration tests added to validate runtime data and explain-trace behavior. |
|
||||
| QA-GRAPH-RECHECK-006 | DONE | SPRINT_20260210_005: known-edge metadata positive-path integration test added to catch empty-runtime-data regressions. |
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<!-- FrameworkReference Microsoft.AspNetCore.App and Hosting packages are provided by Sdk.Web -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Worker.Health\StellaOps.Worker.Health.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
|
||||
|
||||
@@ -38,11 +38,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<OrchestratorDataSource>();
|
||||
|
||||
// Register repositories
|
||||
services.AddScoped<IJobRepository, PostgresJobRepository>();
|
||||
services.AddScoped<Infrastructure.Repositories.IJobRepository, PostgresJobRepository>();
|
||||
services.AddScoped<IArtifactRepository, PostgresArtifactRepository>();
|
||||
services.AddScoped<ISourceRepository, PostgresSourceRepository>();
|
||||
services.AddScoped<IRunRepository, PostgresRunRepository>();
|
||||
services.AddScoped<IQuotaRepository, PostgresQuotaRepository>();
|
||||
services.AddScoped<Infrastructure.Repositories.IQuotaRepository, PostgresQuotaRepository>();
|
||||
services.AddScoped<IThrottleRepository, PostgresThrottleRepository>();
|
||||
services.AddScoped<IWatermarkRepository, PostgresWatermarkRepository>();
|
||||
services.AddScoped<Infrastructure.Repositories.IBackfillRepository, PostgresBackfillRepository>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
|
||||
|
||||
@@ -16,18 +16,10 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<!-- FrameworkReference Microsoft.AspNetCore.App is provided by Sdk.Web -->
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
<!-- Microsoft.Extensions.Hosting is provided by Sdk.Worker -->
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +32,7 @@
|
||||
<ProjectReference Include="..\StellaOps.Orchestrator.Infrastructure\StellaOps.Orchestrator.Infrastructure.csproj"/>
|
||||
|
||||
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
|
||||
|
||||
@@ -16,18 +16,10 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<!-- FrameworkReference Microsoft.AspNetCore.App is provided by Sdk.Web -->
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
<!-- Microsoft.Extensions.Hosting is provided by Sdk.Worker -->
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +32,7 @@
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj"/>
|
||||
|
||||
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
@@ -238,38 +238,38 @@ public sealed class DeltaIfPresentCalculator : IDeltaIfPresentCalculator
|
||||
"VEX" => original with
|
||||
{
|
||||
Vex = SignalState<VexClaimSummary>.Queried(
|
||||
CreateHypotheticalVex(normalizedValue), now)
|
||||
CreateHypotheticalVex(normalizedValue, now), now)
|
||||
},
|
||||
"EPSS" => original with
|
||||
{
|
||||
Epss = SignalState<EpssEvidence>.Queried(
|
||||
CreateHypotheticalEpss(normalizedValue), now)
|
||||
CreateHypotheticalEpss(normalizedValue, original.Cve, now), now)
|
||||
},
|
||||
"REACHABILITY" => original with
|
||||
{
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
CreateHypotheticalReachability(normalizedValue), now)
|
||||
CreateHypotheticalReachability(normalizedValue, now), now)
|
||||
},
|
||||
"RUNTIME" => original with
|
||||
{
|
||||
Runtime = SignalState<RuntimeEvidence>.Queried(
|
||||
CreateHypotheticalRuntime(normalizedValue), now)
|
||||
CreateHypotheticalRuntime(normalizedValue, now), now)
|
||||
},
|
||||
"BACKPORT" => original with
|
||||
{
|
||||
Backport = SignalState<BackportEvidence>.Queried(
|
||||
CreateHypotheticalBackport(normalizedValue), now)
|
||||
CreateHypotheticalBackport(normalizedValue, now), now)
|
||||
},
|
||||
"SBOMLINEAGE" or "SBOM" => original with
|
||||
{
|
||||
Sbom = SignalState<SbomLineageEvidence>.Queried(
|
||||
CreateHypotheticalSbom(normalizedValue), now)
|
||||
CreateHypotheticalSbom(normalizedValue, now), now)
|
||||
},
|
||||
_ => original
|
||||
};
|
||||
}
|
||||
|
||||
private static VexClaimSummary CreateHypotheticalVex(double normalizedValue)
|
||||
private static VexClaimSummary CreateHypotheticalVex(double normalizedValue, DateTimeOffset now)
|
||||
{
|
||||
// Map 0.0-1.0 to VEX status
|
||||
var status = normalizedValue switch
|
||||
@@ -283,23 +283,26 @@ public sealed class DeltaIfPresentCalculator : IDeltaIfPresentCalculator
|
||||
return new VexClaimSummary
|
||||
{
|
||||
Status = status,
|
||||
Source = "hypothetical",
|
||||
DocumentId = "delta-if-present-simulation",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Confidence = 0.7,
|
||||
StatementCount = 1,
|
||||
ComputedAt = now,
|
||||
Justification = "delta-if-present-simulation"
|
||||
};
|
||||
}
|
||||
|
||||
private static EpssEvidence CreateHypotheticalEpss(double normalizedValue)
|
||||
private static EpssEvidence CreateHypotheticalEpss(double normalizedValue, string cve, DateTimeOffset now)
|
||||
{
|
||||
return new EpssEvidence
|
||||
{
|
||||
Cve = cve,
|
||||
Epss = normalizedValue,
|
||||
Percentile = normalizedValue * 100.0,
|
||||
Date = DateOnly.FromDateTime(DateTime.UtcNow)
|
||||
Percentile = normalizedValue,
|
||||
PublishedAt = now,
|
||||
ModelVersion = "delta-if-present-simulation"
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilityEvidence CreateHypotheticalReachability(double normalizedValue)
|
||||
private static ReachabilityEvidence CreateHypotheticalReachability(double normalizedValue, DateTimeOffset now)
|
||||
{
|
||||
var status = normalizedValue >= 0.5
|
||||
? ReachabilityStatus.Reachable
|
||||
@@ -309,38 +312,47 @@ public sealed class DeltaIfPresentCalculator : IDeltaIfPresentCalculator
|
||||
{
|
||||
Status = status,
|
||||
Confidence = 1.0 - Math.Abs(normalizedValue - 0.5) * 2,
|
||||
PathCount = normalizedValue >= 0.5 ? 1 : 0,
|
||||
Source = "hypothetical"
|
||||
Depth = normalizedValue >= 0.5 ? 1 : null,
|
||||
EntryPoint = normalizedValue >= 0.5 ? "delta-if-present-simulation" : null,
|
||||
VulnerableFunction = normalizedValue >= 0.5 ? "unknown" : null,
|
||||
AnalyzedAt = now,
|
||||
WitnessDigest = "sha256:delta-if-present-simulation"
|
||||
};
|
||||
}
|
||||
|
||||
private static RuntimeEvidence CreateHypotheticalRuntime(double normalizedValue)
|
||||
private static RuntimeEvidence CreateHypotheticalRuntime(double normalizedValue, DateTimeOffset now)
|
||||
{
|
||||
return new RuntimeEvidence
|
||||
{
|
||||
Detected = normalizedValue >= 0.5,
|
||||
Source = "hypothetical",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
ObservationStart = now.AddMinutes(-30),
|
||||
ObservationEnd = now,
|
||||
Confidence = 1.0 - Math.Abs(normalizedValue - 0.5)
|
||||
};
|
||||
}
|
||||
|
||||
private static BackportEvidence CreateHypotheticalBackport(double normalizedValue)
|
||||
private static BackportEvidence CreateHypotheticalBackport(double normalizedValue, DateTimeOffset now)
|
||||
{
|
||||
return new BackportEvidence
|
||||
{
|
||||
Detected = normalizedValue < 0.5, // Backport = lower risk
|
||||
Source = "hypothetical",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
DetectedAt = now,
|
||||
Confidence = 1.0 - Math.Abs(normalizedValue - 0.5)
|
||||
};
|
||||
}
|
||||
|
||||
private static SbomLineageEvidence CreateHypotheticalSbom(double normalizedValue)
|
||||
private static SbomLineageEvidence CreateHypotheticalSbom(double normalizedValue, DateTimeOffset now)
|
||||
{
|
||||
return new SbomLineageEvidence
|
||||
{
|
||||
Present = true,
|
||||
Depth = (int)(normalizedValue * 5),
|
||||
Source = "hypothetical"
|
||||
SbomDigest = "sha256:delta-if-present-simulation",
|
||||
Format = "CycloneDX",
|
||||
ComponentCount = Math.Max(1, (int)(normalizedValue * 100.0)),
|
||||
GeneratedAt = now,
|
||||
HasProvenance = normalizedValue >= 0.5,
|
||||
AttestationDigest = "sha256:delta-if-present-attestation"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Four-valued logic states for deterministic trust aggregation.
|
||||
/// </summary>
|
||||
public enum K4Value
|
||||
{
|
||||
Unknown = 0,
|
||||
True = 1,
|
||||
False = 2,
|
||||
Conflict = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal K4 lattice operations needed by determinization scoring.
|
||||
/// </summary>
|
||||
public static class K4Lattice
|
||||
{
|
||||
public static K4Value Join(K4Value a, K4Value b)
|
||||
{
|
||||
if (a == b)
|
||||
{
|
||||
return a;
|
||||
}
|
||||
|
||||
if (a == K4Value.Conflict || b == K4Value.Conflict)
|
||||
{
|
||||
return K4Value.Conflict;
|
||||
}
|
||||
|
||||
if (a == K4Value.Unknown)
|
||||
{
|
||||
return b;
|
||||
}
|
||||
|
||||
if (b == K4Value.Unknown)
|
||||
{
|
||||
return a;
|
||||
}
|
||||
|
||||
return K4Value.Conflict;
|
||||
}
|
||||
|
||||
public static K4Value JoinAll(IEnumerable<K4Value> values)
|
||||
{
|
||||
var result = K4Value.Unknown;
|
||||
foreach (var value in values)
|
||||
{
|
||||
result = Join(result, value);
|
||||
if (result == K4Value.Conflict)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Local score policy model for determinization scoring.
|
||||
/// Avoids circular dependency on StellaOps.Policy while preserving deterministic defaults.
|
||||
/// </summary>
|
||||
public sealed record ScorePolicy
|
||||
{
|
||||
public required string PolicyVersion { get; init; }
|
||||
public required WeightsBps WeightsBps { get; init; }
|
||||
|
||||
public static ScorePolicy Default => new()
|
||||
{
|
||||
PolicyVersion = "score.v1",
|
||||
WeightsBps = WeightsBps.Default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weight distribution in basis points. Must sum to 10000.
|
||||
/// </summary>
|
||||
public sealed record WeightsBps
|
||||
{
|
||||
public required int BaseSeverity { get; init; }
|
||||
public required int Reachability { get; init; }
|
||||
public required int Evidence { get; init; }
|
||||
public required int Provenance { get; init; }
|
||||
|
||||
public static WeightsBps Default => new()
|
||||
{
|
||||
BaseSeverity = 1000,
|
||||
Reachability = 4500,
|
||||
Evidence = 3000,
|
||||
Provenance = 1500
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
using StellaOps.Policy.Determinization.Evidence;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Determinization.Evidence;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -176,7 +174,7 @@ public sealed class TrustScoreAlgebraFacade : ITrustScoreAlgebraFacade
|
||||
{
|
||||
ReachabilityStatus.Reachable => K4Value.True,
|
||||
ReachabilityStatus.Unreachable => K4Value.False,
|
||||
ReachabilityStatus.Unknown => K4Value.Unknown,
|
||||
ReachabilityStatus.Indeterminate => K4Value.Unknown,
|
||||
_ => K4Value.Unknown
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Determinization.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
|
||||
@@ -187,11 +187,11 @@ public class CombinedImpactCalculatorTests
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = now }, now),
|
||||
Runtime = SignalState<RuntimeEvidence>.Queried(
|
||||
new RuntimeEvidence { Detected = true, DetectedAt = now }, now),
|
||||
new RuntimeEvidence { Detected = true, Source = "tracer", ObservationStart = now.AddHours(-1), ObservationEnd = now, Confidence = 0.9 }, now),
|
||||
Backport = SignalState<BackportEvidence>.Queried(
|
||||
new BackportEvidence { Detected = false, AnalyzedAt = now }, now),
|
||||
new BackportEvidence { Detected = false, Source = "vendor-advisory", DetectedAt = now, Confidence = 0.8 }, now),
|
||||
Sbom = SignalState<SbomLineageEvidence>.Queried(
|
||||
new SbomLineageEvidence { HasLineage = true, AnalyzedAt = now }, now),
|
||||
new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "CycloneDX", ComponentCount = 50, GeneratedAt = now, HasProvenance = true }, now),
|
||||
Cvss = SignalState<CvssEvidence>.Queried(
|
||||
new CvssEvidence { Version = "3.1", BaseScore = 9.8, Severity = "CRITICAL", Source = "NVD", PublishedAt = now }, now),
|
||||
SnapshotAt = now
|
||||
|
||||
@@ -123,7 +123,7 @@ public sealed class DeltaIfPresentCalculatorTests
|
||||
|
||||
gap.BestCase.AssumedValue.Should().Be(0.0);
|
||||
gap.WorstCase.AssumedValue.Should().Be(1.0);
|
||||
gap.MaxImpact.Should().BeGreaterOrEqualTo(0.0);
|
||||
gap.MaxImpact.Should().BeGreaterThanOrEqualTo(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Determinization.Evidence;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Determinization.Scoring;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring;
|
||||
|
||||
@@ -20,7 +18,7 @@ public sealed class TrustScoreAlgebraFacadeTests
|
||||
private TrustScoreAlgebraFacade CreateFacade()
|
||||
{
|
||||
var aggregator = new TrustScoreAggregator(NullLogger<TrustScoreAggregator>.Instance);
|
||||
var uncertaintyCalculator = new UncertaintyScoreCalculator();
|
||||
var uncertaintyCalculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
|
||||
return new TrustScoreAlgebraFacade(
|
||||
aggregator,
|
||||
uncertaintyCalculator,
|
||||
@@ -61,10 +59,22 @@ public sealed class TrustScoreAlgebraFacadeTests
|
||||
var signals = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow())
|
||||
with
|
||||
{
|
||||
Reachability = SignalState<ReachabilityEvidence>.Present(
|
||||
new ReachabilityEvidence(ReachabilityStatus.Reachable, 0, 0, null)),
|
||||
Vex = SignalState<VexClaimSummary>.Present(
|
||||
new VexClaimSummary("affected", null, null, null, null, null))
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
new ReachabilityEvidence
|
||||
{
|
||||
Status = ReachabilityStatus.Reachable,
|
||||
Depth = 0,
|
||||
AnalyzedAt = _timeProvider.GetUtcNow(),
|
||||
Confidence = 1.0
|
||||
}, _timeProvider.GetUtcNow()),
|
||||
Vex = SignalState<VexClaimSummary>.Queried(
|
||||
new VexClaimSummary
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 1.0,
|
||||
StatementCount = 1,
|
||||
ComputedAt = _timeProvider.GetUtcNow()
|
||||
}, _timeProvider.GetUtcNow())
|
||||
};
|
||||
|
||||
var request = new TrustScoreRequest
|
||||
@@ -91,10 +101,22 @@ public sealed class TrustScoreAlgebraFacadeTests
|
||||
var signals = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow())
|
||||
with
|
||||
{
|
||||
Reachability = SignalState<ReachabilityEvidence>.Present(
|
||||
new ReachabilityEvidence(ReachabilityStatus.Unreachable, 0, 0, null)),
|
||||
Vex = SignalState<VexClaimSummary>.Present(
|
||||
new VexClaimSummary("affected", null, null, null, null, null))
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
new ReachabilityEvidence
|
||||
{
|
||||
Status = ReachabilityStatus.Unreachable,
|
||||
Depth = 0,
|
||||
AnalyzedAt = _timeProvider.GetUtcNow(),
|
||||
Confidence = 1.0
|
||||
}, _timeProvider.GetUtcNow()),
|
||||
Vex = SignalState<VexClaimSummary>.Queried(
|
||||
new VexClaimSummary
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 1.0,
|
||||
StatementCount = 1,
|
||||
ComputedAt = _timeProvider.GetUtcNow()
|
||||
}, _timeProvider.GetUtcNow())
|
||||
};
|
||||
|
||||
var request = new TrustScoreRequest
|
||||
@@ -124,10 +146,22 @@ public sealed class TrustScoreAlgebraFacadeTests
|
||||
var signals = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow())
|
||||
with
|
||||
{
|
||||
Vex = SignalState<VexClaimSummary>.Present(
|
||||
new VexClaimSummary("not_affected", null, null, null, null, null)),
|
||||
Epss = SignalState<EpssEvidence>.Present(
|
||||
new EpssEvidence(0.85, 0.95)) // High EPSS = True in K4
|
||||
Vex = SignalState<VexClaimSummary>.Queried(
|
||||
new VexClaimSummary
|
||||
{
|
||||
Status = "not_affected",
|
||||
Confidence = 1.0,
|
||||
StatementCount = 1,
|
||||
ComputedAt = _timeProvider.GetUtcNow()
|
||||
}, _timeProvider.GetUtcNow()),
|
||||
Epss = SignalState<EpssEvidence>.Queried(
|
||||
new EpssEvidence
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
Epss = 0.85,
|
||||
Percentile = 0.95,
|
||||
PublishedAt = _timeProvider.GetUtcNow()
|
||||
}, _timeProvider.GetUtcNow()) // High EPSS = True in K4
|
||||
};
|
||||
|
||||
var request = new TrustScoreRequest
|
||||
@@ -153,12 +187,30 @@ public sealed class TrustScoreAlgebraFacadeTests
|
||||
var signals = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow())
|
||||
with
|
||||
{
|
||||
Vex = SignalState<VexClaimSummary>.Present(
|
||||
new VexClaimSummary("affected", null, null, null, null, null)),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Present(
|
||||
new ReachabilityEvidence(ReachabilityStatus.Reachable, 0, 0, null)),
|
||||
Epss = SignalState<EpssEvidence>.Present(
|
||||
new EpssEvidence(0.75, 0.90))
|
||||
Vex = SignalState<VexClaimSummary>.Queried(
|
||||
new VexClaimSummary
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 1.0,
|
||||
StatementCount = 1,
|
||||
ComputedAt = _timeProvider.GetUtcNow()
|
||||
}, _timeProvider.GetUtcNow()),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
new ReachabilityEvidence
|
||||
{
|
||||
Status = ReachabilityStatus.Reachable,
|
||||
Depth = 0,
|
||||
AnalyzedAt = _timeProvider.GetUtcNow(),
|
||||
Confidence = 1.0
|
||||
}, _timeProvider.GetUtcNow()),
|
||||
Epss = SignalState<EpssEvidence>.Queried(
|
||||
new EpssEvidence
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
Epss = 0.75,
|
||||
Percentile = 0.90,
|
||||
PublishedAt = _timeProvider.GetUtcNow()
|
||||
}, _timeProvider.GetUtcNow())
|
||||
};
|
||||
|
||||
var request = new TrustScoreRequest
|
||||
@@ -308,9 +360,22 @@ public sealed class TrustScoreAlgebraFacadeTests
|
||||
var signals = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow())
|
||||
with
|
||||
{
|
||||
Epss = SignalState<EpssEvidence>.Present(new EpssEvidence(0.35, 0.65)),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Present(
|
||||
new ReachabilityEvidence(ReachabilityStatus.Reachable, 2, 5, null))
|
||||
Epss = SignalState<EpssEvidence>.Queried(
|
||||
new EpssEvidence
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
Epss = 0.35,
|
||||
Percentile = 0.65,
|
||||
PublishedAt = _timeProvider.GetUtcNow()
|
||||
}, _timeProvider.GetUtcNow()),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
new ReachabilityEvidence
|
||||
{
|
||||
Status = ReachabilityStatus.Reachable,
|
||||
Depth = 2,
|
||||
AnalyzedAt = _timeProvider.GetUtcNow(),
|
||||
Confidence = 1.0
|
||||
}, _timeProvider.GetUtcNow())
|
||||
};
|
||||
|
||||
var request = new TrustScoreRequest
|
||||
|
||||
@@ -327,7 +327,7 @@ public sealed class WeightManifestLoaderTests : IDisposable
|
||||
[Fact]
|
||||
public void Diff_DifferentVersions_ShowsDifference()
|
||||
{
|
||||
var from = new WeightManifestDocument
|
||||
var fromManifest = new WeightManifestDocument
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v1",
|
||||
@@ -336,9 +336,9 @@ public sealed class WeightManifestLoaderTests : IDisposable
|
||||
Weights = new WeightManifestWeights()
|
||||
};
|
||||
|
||||
var to = from with { Version = "v2" };
|
||||
var to = fromManifest with { Version = "v2" };
|
||||
|
||||
var diff = _loader.Diff(from, to);
|
||||
var diff = _loader.Diff(fromManifest, to);
|
||||
|
||||
Assert.True(diff.HasDifferences);
|
||||
Assert.Contains(diff.Differences, d => d.Path == "version" && d.OldValue == "v1" && d.NewValue == "v2");
|
||||
@@ -347,7 +347,7 @@ public sealed class WeightManifestLoaderTests : IDisposable
|
||||
[Fact]
|
||||
public void Diff_DifferentWeights_ShowsDifferences()
|
||||
{
|
||||
var from = new WeightManifestDocument
|
||||
var fromManifest = new WeightManifestDocument
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v1",
|
||||
@@ -360,7 +360,7 @@ public sealed class WeightManifestLoaderTests : IDisposable
|
||||
}
|
||||
};
|
||||
|
||||
var to = from with
|
||||
var to = fromManifest with
|
||||
{
|
||||
Version = "v2",
|
||||
Weights = new WeightManifestWeights
|
||||
@@ -370,7 +370,7 @@ public sealed class WeightManifestLoaderTests : IDisposable
|
||||
}
|
||||
};
|
||||
|
||||
var diff = _loader.Diff(from, to);
|
||||
var diff = _loader.Diff(fromManifest, to);
|
||||
|
||||
Assert.True(diff.HasDifferences);
|
||||
Assert.Contains(diff.Differences, d => d.Path == "weights.legacy.rch");
|
||||
@@ -381,7 +381,7 @@ public sealed class WeightManifestLoaderTests : IDisposable
|
||||
[Fact]
|
||||
public void Diff_AddedWeight_ShowsAsNewField()
|
||||
{
|
||||
var from = new WeightManifestDocument
|
||||
var fromManifest = new WeightManifestDocument
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v1",
|
||||
@@ -394,7 +394,7 @@ public sealed class WeightManifestLoaderTests : IDisposable
|
||||
}
|
||||
};
|
||||
|
||||
var to = from with
|
||||
var to = fromManifest with
|
||||
{
|
||||
Version = "v2",
|
||||
Weights = new WeightManifestWeights
|
||||
@@ -406,7 +406,7 @@ public sealed class WeightManifestLoaderTests : IDisposable
|
||||
}
|
||||
};
|
||||
|
||||
var diff = _loader.Diff(from, to);
|
||||
var diff = _loader.Diff(fromManifest, to);
|
||||
|
||||
Assert.True(diff.HasDifferences);
|
||||
var mitDiff = diff.Differences.First(d => d.Path == "weights.legacy.mit");
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using StellaOps.Reachability.Core.CveMapping;
|
||||
using StellaOps.Reachability.Core.Symbols;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Controllers;
|
||||
@@ -59,25 +60,13 @@ public class CveMappingController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
var dtos = FlattenMappings(mappings, cveId);
|
||||
|
||||
var response = new CveMappingResponse
|
||||
{
|
||||
CveId = cveId,
|
||||
MappingCount = mappings.Count,
|
||||
Mappings = mappings.Select(m => new CveMappingDto
|
||||
{
|
||||
Purl = m.Purl,
|
||||
Symbol = m.Symbol.Symbol,
|
||||
CanonicalId = m.Symbol.CanonicalId,
|
||||
FilePath = m.Symbol.FilePath,
|
||||
StartLine = m.Symbol.StartLine,
|
||||
EndLine = m.Symbol.EndLine,
|
||||
Source = m.Source.ToString(),
|
||||
Confidence = m.Confidence,
|
||||
VulnerabilityType = m.VulnerabilityType.ToString(),
|
||||
AffectedVersions = m.AffectedVersions.ToList(),
|
||||
FixedVersions = m.FixedVersions.ToList(),
|
||||
EvidenceUri = m.EvidenceUri
|
||||
}).ToList()
|
||||
MappingCount = dtos.Count,
|
||||
Mappings = dtos
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
@@ -110,26 +99,13 @@ public class CveMappingController : ControllerBase
|
||||
|
||||
var mappings = await _mappingService.GetMappingsForPackageAsync(purl, cancellationToken);
|
||||
|
||||
var dtos = FlattenMappings(mappings);
|
||||
|
||||
var response = new PackageMappingsResponse
|
||||
{
|
||||
Purl = purl,
|
||||
MappingCount = mappings.Count,
|
||||
Mappings = mappings.Select(m => new CveMappingDto
|
||||
{
|
||||
CveId = m.CveId,
|
||||
Purl = m.Purl,
|
||||
Symbol = m.Symbol.Symbol,
|
||||
CanonicalId = m.Symbol.CanonicalId,
|
||||
FilePath = m.Symbol.FilePath,
|
||||
StartLine = m.Symbol.StartLine,
|
||||
EndLine = m.Symbol.EndLine,
|
||||
Source = m.Source.ToString(),
|
||||
Confidence = m.Confidence,
|
||||
VulnerabilityType = m.VulnerabilityType.ToString(),
|
||||
AffectedVersions = m.AffectedVersions.ToList(),
|
||||
FixedVersions = m.FixedVersions.ToList(),
|
||||
EvidenceUri = m.EvidenceUri
|
||||
}).ToList()
|
||||
MappingCount = dtos.Count,
|
||||
Mappings = dtos
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
@@ -164,27 +140,14 @@ public class CveMappingController : ControllerBase
|
||||
|
||||
var mappings = await _mappingService.SearchBySymbolAsync(symbol, language, cancellationToken);
|
||||
|
||||
var dtos = FlattenMappings(mappings);
|
||||
|
||||
var response = new SymbolMappingsResponse
|
||||
{
|
||||
Symbol = symbol,
|
||||
Language = language,
|
||||
MappingCount = mappings.Count,
|
||||
Mappings = mappings.Select(m => new CveMappingDto
|
||||
{
|
||||
CveId = m.CveId,
|
||||
Purl = m.Purl,
|
||||
Symbol = m.Symbol.Symbol,
|
||||
CanonicalId = m.Symbol.CanonicalId,
|
||||
FilePath = m.Symbol.FilePath,
|
||||
StartLine = m.Symbol.StartLine,
|
||||
EndLine = m.Symbol.EndLine,
|
||||
Source = m.Source.ToString(),
|
||||
Confidence = m.Confidence,
|
||||
VulnerabilityType = m.VulnerabilityType.ToString(),
|
||||
AffectedVersions = m.AffectedVersions.ToList(),
|
||||
FixedVersions = m.FixedVersions.ToList(),
|
||||
EvidenceUri = m.EvidenceUri
|
||||
}).ToList()
|
||||
MappingCount = dtos.Count,
|
||||
Mappings = dtos
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
@@ -248,44 +211,37 @@ public class CveMappingController : ControllerBase
|
||||
vulnType = VulnerabilityType.Unknown;
|
||||
}
|
||||
|
||||
var mapping = new CveSymbolMapping
|
||||
var canonicalSymbol = CanonicalSymbol.Create(
|
||||
@namespace: "_",
|
||||
type: "_",
|
||||
method: request.Symbol,
|
||||
signature: string.Empty,
|
||||
source: SymbolSource.ManualCuration,
|
||||
purl: request.Purl);
|
||||
|
||||
var vulnerableSymbol = new VulnerableSymbol
|
||||
{
|
||||
CveId = request.CveId,
|
||||
Purl = request.Purl,
|
||||
Symbol = new VulnerableSymbol
|
||||
{
|
||||
Symbol = request.Symbol,
|
||||
CanonicalId = request.CanonicalId,
|
||||
FilePath = request.FilePath,
|
||||
StartLine = request.StartLine,
|
||||
EndLine = request.EndLine
|
||||
},
|
||||
Source = source,
|
||||
Symbol = canonicalSymbol,
|
||||
Type = vulnType,
|
||||
Confidence = request.Confidence ?? 0.5,
|
||||
VulnerabilityType = vulnType,
|
||||
AffectedVersions = request.AffectedVersions?.ToImmutableArray() ?? [],
|
||||
FixedVersions = request.FixedVersions?.ToImmutableArray() ?? [],
|
||||
EvidenceUri = request.EvidenceUri
|
||||
SourceFile = request.FilePath,
|
||||
LineRange = request.StartLine.HasValue && request.EndLine.HasValue
|
||||
? new LineRange(request.StartLine.Value, request.EndLine.Value)
|
||||
: null
|
||||
};
|
||||
|
||||
var mapping = CveSymbolMapping.Create(
|
||||
cveId: request.CveId,
|
||||
symbols: [vulnerableSymbol],
|
||||
source: source,
|
||||
confidence: request.Confidence ?? 0.5,
|
||||
timeProvider: TimeProvider.System,
|
||||
affectedPurls: request.Purl is not null ? [request.Purl] : null);
|
||||
|
||||
var result = await _mappingService.AddOrUpdateMappingAsync(mapping, cancellationToken);
|
||||
|
||||
var response = new CveMappingDto
|
||||
{
|
||||
CveId = result.CveId,
|
||||
Purl = result.Purl,
|
||||
Symbol = result.Symbol.Symbol,
|
||||
CanonicalId = result.Symbol.CanonicalId,
|
||||
FilePath = result.Symbol.FilePath,
|
||||
StartLine = result.Symbol.StartLine,
|
||||
EndLine = result.Symbol.EndLine,
|
||||
Source = result.Source.ToString(),
|
||||
Confidence = result.Confidence,
|
||||
VulnerabilityType = result.VulnerabilityType.ToString(),
|
||||
AffectedVersions = result.AffectedVersions.ToList(),
|
||||
FixedVersions = result.FixedVersions.ToList(),
|
||||
EvidenceUri = result.EvidenceUri
|
||||
};
|
||||
var dtos = FlattenMappings([result]);
|
||||
var response = dtos.FirstOrDefault();
|
||||
|
||||
return CreatedAtAction(nameof(GetByCveIdAsync), new { cveId = result.CveId }, response);
|
||||
}
|
||||
@@ -324,16 +280,16 @@ public class CveMappingController : ControllerBase
|
||||
var response = new PatchAnalysisResponse
|
||||
{
|
||||
CommitUrl = request.CommitUrl,
|
||||
ExtractedSymbols = result.ExtractedSymbols.Select(s => new ExtractedSymbolDto
|
||||
ExtractedSymbols = result.Symbols.Select(s => new ExtractedSymbolDto
|
||||
{
|
||||
Symbol = s.Symbol,
|
||||
FilePath = s.FilePath,
|
||||
StartLine = s.StartLine,
|
||||
EndLine = s.EndLine,
|
||||
ChangeType = s.ChangeType.ToString(),
|
||||
Language = s.Language
|
||||
Symbol = s.Symbol.DisplayName,
|
||||
FilePath = s.SourceFile,
|
||||
StartLine = s.LineRange?.Start,
|
||||
EndLine = s.LineRange?.End,
|
||||
ChangeType = s.Type.ToString(),
|
||||
Language = null
|
||||
}).ToList(),
|
||||
AnalyzedAt = result.AnalyzedAt
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
@@ -367,23 +323,13 @@ public class CveMappingController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
var dtos = FlattenMappings(enrichedMappings);
|
||||
|
||||
var response = new EnrichmentResponse
|
||||
{
|
||||
CveId = cveId,
|
||||
EnrichedCount = enrichedMappings.Count,
|
||||
Mappings = enrichedMappings.Select(m => new CveMappingDto
|
||||
{
|
||||
CveId = m.CveId,
|
||||
Purl = m.Purl,
|
||||
Symbol = m.Symbol.Symbol,
|
||||
CanonicalId = m.Symbol.CanonicalId,
|
||||
FilePath = m.Symbol.FilePath,
|
||||
Source = m.Source.ToString(),
|
||||
Confidence = m.Confidence,
|
||||
VulnerabilityType = m.VulnerabilityType.ToString(),
|
||||
AffectedVersions = m.AffectedVersions.ToList(),
|
||||
FixedVersions = m.FixedVersions.ToList()
|
||||
}).ToList()
|
||||
EnrichedCount = dtos.Count,
|
||||
Mappings = dtos
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
@@ -415,6 +361,31 @@ public class CveMappingController : ControllerBase
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens CveSymbolMappings (which contain multiple Symbols each) into a flat list of CveMappingDto.
|
||||
/// </summary>
|
||||
private static List<CveMappingDto> FlattenMappings(
|
||||
IReadOnlyList<CveSymbolMapping> mappings,
|
||||
string? overrideCveId = null)
|
||||
{
|
||||
return mappings.SelectMany(m => m.Symbols.Select(s => new CveMappingDto
|
||||
{
|
||||
CveId = overrideCveId ?? m.CveId,
|
||||
Purl = s.Symbol.Purl ?? m.AffectedPurls.FirstOrDefault() ?? string.Empty,
|
||||
Symbol = s.Symbol.DisplayName,
|
||||
CanonicalId = s.Symbol.CanonicalId,
|
||||
FilePath = s.SourceFile,
|
||||
StartLine = s.LineRange?.Start,
|
||||
EndLine = s.LineRange?.End,
|
||||
Source = m.Source.ToString(),
|
||||
Confidence = s.Confidence,
|
||||
VulnerabilityType = s.Type.ToString(),
|
||||
AffectedVersions = m.AffectedPurls.ToList(),
|
||||
FixedVersions = null,
|
||||
EvidenceUri = m.PatchCommitUrl
|
||||
})).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -137,7 +137,7 @@ public class ReachabilityController : ControllerBase
|
||||
IncludeStatic = request.IncludeStatic ?? true,
|
||||
IncludeRuntime = request.IncludeRuntime ?? true,
|
||||
ObservationWindow = request.ObservationWindow ?? TimeSpan.FromDays(7),
|
||||
ConfidenceThreshold = request.ConfidenceThreshold ?? 0.8
|
||||
MinConfidenceThreshold = request.MinConfidenceThreshold ?? 0.8
|
||||
};
|
||||
|
||||
var tenantId = GetTenantId();
|
||||
@@ -187,7 +187,7 @@ public class ReachabilityController : ControllerBase
|
||||
IncludeStatic = request.IncludeStatic ?? true,
|
||||
IncludeRuntime = request.IncludeRuntime ?? true,
|
||||
ObservationWindow = request.ObservationWindow ?? TimeSpan.FromDays(7),
|
||||
ConfidenceThreshold = request.ConfidenceThreshold ?? 0.8
|
||||
MinConfidenceThreshold = request.MinConfidenceThreshold ?? 0.8
|
||||
};
|
||||
|
||||
var tenantId = GetTenantId();
|
||||
@@ -223,8 +223,8 @@ public class ReachabilityController : ControllerBase
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(symbol.Namespace)) parts.Add(symbol.Namespace);
|
||||
if (!string.IsNullOrEmpty(symbol.TypeName)) parts.Add(symbol.TypeName);
|
||||
if (!string.IsNullOrEmpty(symbol.MemberName)) parts.Add(symbol.MemberName);
|
||||
if (!string.IsNullOrEmpty(symbol.Type)) parts.Add(symbol.Type);
|
||||
if (!string.IsNullOrEmpty(symbol.Method)) parts.Add(symbol.Method);
|
||||
return string.Join(".", parts);
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,7 @@ public record HybridQueryRequest
|
||||
public TimeSpan? ObservationWindow { get; init; }
|
||||
|
||||
/// <summary>Confidence threshold for verdict. Default: 0.8.</summary>
|
||||
public double? ConfidenceThreshold { get; init; }
|
||||
public double? MinConfidenceThreshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -301,7 +301,7 @@ public record BatchQueryRequest
|
||||
public TimeSpan? ObservationWindow { get; init; }
|
||||
|
||||
/// <summary>Confidence threshold for verdict. Default: 0.8.</summary>
|
||||
public double? ConfidenceThreshold { get; init; }
|
||||
public double? MinConfidenceThreshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
// Stub types for CVE-Symbol mapping service
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Service for CVE-symbol mapping operations.
|
||||
/// </summary>
|
||||
public interface ICveSymbolMappingService
|
||||
{
|
||||
Task<IReadOnlyList<CveSymbolMapping>> GetMappingsForCveAsync(string cveId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<CveSymbolMapping>> GetMappingsForPackageAsync(string purl, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<CveSymbolMapping>> SearchBySymbolAsync(string symbol, string? language, CancellationToken cancellationToken);
|
||||
Task<CveSymbolMapping> AddOrUpdateMappingAsync(CveSymbolMapping mapping, CancellationToken cancellationToken);
|
||||
Task<PatchAnalysisResult> AnalyzePatchAsync(string? commitUrl, string? diffContent, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<CveSymbolMapping>> EnrichFromOsvAsync(string cveId, CancellationToken cancellationToken);
|
||||
Task<MappingStats> GetStatsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A mapping between a CVE and a vulnerable symbol.
|
||||
/// </summary>
|
||||
public record CveSymbolMapping
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required VulnerableSymbol Symbol { get; init; }
|
||||
public MappingSource Source { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public VulnerabilityType VulnerabilityType { get; init; }
|
||||
public ImmutableArray<string> AffectedVersions { get; init; } = [];
|
||||
public ImmutableArray<string> FixedVersions { get; init; } = [];
|
||||
public string? EvidenceUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a vulnerable symbol (function/method).
|
||||
/// </summary>
|
||||
public record VulnerableSymbol
|
||||
{
|
||||
public required string Symbol { get; init; }
|
||||
public string? CanonicalId { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public int? StartLine { get; init; }
|
||||
public int? EndLine { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of the mapping.
|
||||
/// </summary>
|
||||
public enum MappingSource
|
||||
{
|
||||
Unknown = 0,
|
||||
Osv = 1,
|
||||
Nvd = 2,
|
||||
Manual = 3,
|
||||
PatchAnalysis = 4,
|
||||
Vendor = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of vulnerability.
|
||||
/// </summary>
|
||||
public enum VulnerabilityType
|
||||
{
|
||||
Unknown = 0,
|
||||
BufferOverflow = 1,
|
||||
SqlInjection = 2,
|
||||
XSS = 3,
|
||||
CommandInjection = 4,
|
||||
PathTraversal = 5,
|
||||
Deserialization = 6,
|
||||
Cryptographic = 7,
|
||||
Other = 99
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of patch analysis.
|
||||
/// </summary>
|
||||
public record PatchAnalysisResult
|
||||
{
|
||||
public required IReadOnlyList<ExtractedSymbol> ExtractedSymbols { get; init; }
|
||||
public DateTimeOffset AnalyzedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbol extracted from a patch.
|
||||
/// </summary>
|
||||
public record ExtractedSymbol
|
||||
{
|
||||
public required string Symbol { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public int? StartLine { get; init; }
|
||||
public int? EndLine { get; init; }
|
||||
public ChangeType ChangeType { get; init; }
|
||||
public string? Language { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of change in a patch.
|
||||
/// </summary>
|
||||
public enum ChangeType
|
||||
{
|
||||
Unknown = 0,
|
||||
Added = 1,
|
||||
Modified = 2,
|
||||
Deleted = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the mapping corpus.
|
||||
/// </summary>
|
||||
public record MappingStats
|
||||
{
|
||||
public int TotalMappings { get; init; }
|
||||
public int UniqueCves { get; init; }
|
||||
public int UniquePackages { get; init; }
|
||||
public Dictionary<string, int>? BySource { get; init; }
|
||||
public Dictionary<string, int>? ByVulnerabilityType { get; init; }
|
||||
public double AverageConfidence { get; init; }
|
||||
public DateTimeOffset LastUpdated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of the CVE symbol mapping service.
|
||||
/// </summary>
|
||||
public sealed class NullCveSymbolMappingService : ICveSymbolMappingService
|
||||
{
|
||||
public Task<IReadOnlyList<CveSymbolMapping>> GetMappingsForCveAsync(string cveId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<CveSymbolMapping>>([]);
|
||||
|
||||
public Task<IReadOnlyList<CveSymbolMapping>> GetMappingsForPackageAsync(string purl, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<CveSymbolMapping>>([]);
|
||||
|
||||
public Task<IReadOnlyList<CveSymbolMapping>> SearchBySymbolAsync(string symbol, string? language, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<CveSymbolMapping>>([]);
|
||||
|
||||
public Task<CveSymbolMapping> AddOrUpdateMappingAsync(CveSymbolMapping mapping, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(mapping);
|
||||
|
||||
public Task<PatchAnalysisResult> AnalyzePatchAsync(string? commitUrl, string? diffContent, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new PatchAnalysisResult { ExtractedSymbols = [], AnalyzedAt = DateTimeOffset.UtcNow });
|
||||
|
||||
public Task<IReadOnlyList<CveSymbolMapping>> EnrichFromOsvAsync(string cveId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<CveSymbolMapping>>([]);
|
||||
|
||||
public Task<MappingStats> GetStatsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new MappingStats { LastUpdated = DateTimeOffset.UtcNow });
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Reachability.Core;
|
||||
using ExecutionContext = StellaOps.Reachability.Core.ExecutionContext;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Services;
|
||||
|
||||
@@ -62,9 +63,8 @@ public sealed class InMemorySignalsAdapter : ISignalsAdapter
|
||||
.Select(o => new ExecutionContext
|
||||
{
|
||||
Environment = o.Environment ?? "production",
|
||||
Service = o.ServiceName,
|
||||
TraceId = o.TraceId,
|
||||
ObservedAt = o.ObservedAt
|
||||
ContainerId = o.ServiceName,
|
||||
Route = o.TraceId,
|
||||
})
|
||||
.Distinct()
|
||||
.Take(10)
|
||||
@@ -157,7 +157,7 @@ public sealed class InMemorySignalsAdapter : ISignalsAdapter
|
||||
Environment = environment ?? "production",
|
||||
ServiceName = serviceName,
|
||||
TraceId = traceId,
|
||||
EvidenceUri = EvidenceUriBuilder.Build("signals", artifactDigest, $"symbol:{symbolFqn}")
|
||||
EvidenceUri = new EvidenceUriBuilder().BuildRuntimeFactsUri("default", artifactDigest, symbolFqn)
|
||||
};
|
||||
|
||||
var list = _observations.GetOrAdd(key, _ => new List<ObservedSymbol>());
|
||||
@@ -197,8 +197,8 @@ public sealed class InMemorySignalsAdapter : ISignalsAdapter
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(symbol.Namespace)) parts.Add(symbol.Namespace);
|
||||
if (!string.IsNullOrEmpty(symbol.TypeName)) parts.Add(symbol.TypeName);
|
||||
if (!string.IsNullOrEmpty(symbol.MemberName)) parts.Add(symbol.MemberName);
|
||||
if (!string.IsNullOrEmpty(symbol.Type)) parts.Add(symbol.Type);
|
||||
if (!string.IsNullOrEmpty(symbol.Method)) parts.Add(symbol.Method);
|
||||
return string.Join(".", parts);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,17 +96,17 @@ public sealed class ReachGraphStoreAdapter : IReachGraphAdapter
|
||||
}
|
||||
|
||||
// Count entrypoints from scope
|
||||
var entrypointCount = graph.Scope.Entrypoints?.Length ?? 0;
|
||||
var entrypointCount = graph.Scope.Entrypoints.Length;
|
||||
|
||||
return new ReachGraphMetadata
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
GraphDigest = summary.Digest,
|
||||
CreatedAt = summary.CreatedAt,
|
||||
BuiltAt = summary.CreatedAt,
|
||||
NodeCount = graph.Nodes.Length,
|
||||
EdgeCount = graph.Edges.Length,
|
||||
EntrypointCount = entrypointCount,
|
||||
Version = graph.SchemaVersion
|
||||
AnalyzerVersion = graph.SchemaVersion
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,18 +152,18 @@ public sealed class ReachGraphStoreAdapter : IReachGraphAdapter
|
||||
}
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (adjacency.ContainsKey(edge.Source))
|
||||
if (adjacency.ContainsKey(edge.From))
|
||||
{
|
||||
adjacency[edge.Source].Add(edge.Target);
|
||||
adjacency[edge.From].Add(edge.To);
|
||||
}
|
||||
}
|
||||
|
||||
// Get entrypoints from scope
|
||||
var entrypoints = graph.Scope.Entrypoints ?? ImmutableArray<string>.Empty;
|
||||
var entrypoints = graph.Scope.Entrypoints;
|
||||
if (entrypoints.Length == 0)
|
||||
{
|
||||
// If no entrypoints defined, try to find nodes with no incoming edges
|
||||
var hasIncoming = new HashSet<string>(graph.Edges.Select(e => e.Target));
|
||||
var hasIncoming = new HashSet<string>(graph.Edges.Select(e => e.To));
|
||||
entrypoints = graph.Nodes
|
||||
.Where(n => !hasIncoming.Contains(n.Id))
|
||||
.Select(n => n.Id)
|
||||
@@ -215,8 +215,8 @@ public sealed class ReachGraphStoreAdapter : IReachGraphAdapter
|
||||
}
|
||||
|
||||
// Check individual parts
|
||||
if (!string.IsNullOrEmpty(symbol.MemberName) &&
|
||||
node.Ref.Contains(symbol.MemberName, StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.IsNullOrEmpty(symbol.Method) &&
|
||||
node.Ref.Contains(symbol.Method, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -228,8 +228,8 @@ public sealed class ReachGraphStoreAdapter : IReachGraphAdapter
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(symbol.Namespace)) parts.Add(symbol.Namespace);
|
||||
if (!string.IsNullOrEmpty(symbol.TypeName)) parts.Add(symbol.TypeName);
|
||||
if (!string.IsNullOrEmpty(symbol.MemberName)) parts.Add(symbol.MemberName);
|
||||
if (!string.IsNullOrEmpty(symbol.Type)) parts.Add(symbol.Type);
|
||||
if (!string.IsNullOrEmpty(symbol.Method)) parts.Add(symbol.Method);
|
||||
return string.Join(".", parts);
|
||||
}
|
||||
|
||||
@@ -273,8 +273,8 @@ public sealed class ReachGraphStoreAdapter : IReachGraphAdapter
|
||||
private static ImmutableArray<string> CreateEvidenceUris(ReachGraphMinimal graph, SymbolRef symbol)
|
||||
{
|
||||
var artifactDigest = graph.Artifact.Digest ?? "unknown";
|
||||
var symbolFqn = BuildSymbolFqn(symbol);
|
||||
var evidenceUri = EvidenceUriBuilder.Build("reachgraph", artifactDigest, $"symbol:{symbolFqn}");
|
||||
var builder = new EvidenceUriBuilder();
|
||||
var evidenceUri = builder.BuildReachGraphSliceUri(artifactDigest, symbol.CanonicalId);
|
||||
|
||||
return ImmutableArray.Create(evidenceUri);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph.Persistence\StellaOps.ReachGraph.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph.Cache\StellaOps.ReachGraph.Cache.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Reachability.Core\StellaOps.Reachability.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -21,8 +21,9 @@ public class InMemorySignalsAdapterTests
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "System",
|
||||
TypeName = "String",
|
||||
MemberName = "Trim"
|
||||
Purl = "pkg:nuget/test@1.0.0",
|
||||
Type ="String",
|
||||
Method ="Trim"
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -47,8 +48,9 @@ public class InMemorySignalsAdapterTests
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
Purl = "pkg:nuget/test@1.0.0",
|
||||
Type ="Service",
|
||||
Method ="Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
@@ -84,8 +86,9 @@ public class InMemorySignalsAdapterTests
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
Purl = "pkg:nuget/test@1.0.0",
|
||||
Type ="Service",
|
||||
Method ="Process"
|
||||
};
|
||||
|
||||
// Record observation 10 days ago
|
||||
@@ -117,8 +120,9 @@ public class InMemorySignalsAdapterTests
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
Purl = "pkg:nuget/test@1.0.0",
|
||||
Type ="Service",
|
||||
Method ="Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
@@ -157,8 +161,9 @@ public class InMemorySignalsAdapterTests
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
Purl = "pkg:nuget/test@1.0.0",
|
||||
Type ="Service",
|
||||
Method ="Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
@@ -182,8 +187,7 @@ public class InMemorySignalsAdapterTests
|
||||
// Assert
|
||||
result.Contexts.Should().NotBeEmpty();
|
||||
result.Contexts[0].Environment.Should().Be("production");
|
||||
result.Contexts[0].Service.Should().Be("api-gateway");
|
||||
result.Contexts[0].TraceId.Should().Be("trace-001");
|
||||
result.Contexts[0].ContainerId.Should().Be("api-gateway");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -194,8 +198,9 @@ public class InMemorySignalsAdapterTests
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
Purl = "pkg:nuget/test@1.0.0",
|
||||
Type ="Service",
|
||||
Method ="Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
@@ -225,8 +230,9 @@ public class InMemorySignalsAdapterTests
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
Purl = "pkg:nuget/test@1.0.0",
|
||||
Type ="Service",
|
||||
Method ="Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
@@ -264,8 +270,9 @@ public class InMemorySignalsAdapterTests
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
Purl = "pkg:nuget/test@1.0.0",
|
||||
Type ="Service",
|
||||
Method ="Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Reachability.Core;
|
||||
using StellaOps.ReachGraph.Persistence;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.WebService.Services;
|
||||
using Xunit;
|
||||
@@ -23,9 +23,10 @@ public class ReachGraphStoreAdapterTests
|
||||
var adapter = CreateAdapter();
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:nuget/System@1.0.0",
|
||||
Namespace = "System",
|
||||
TypeName = "String",
|
||||
MemberName = "Trim"
|
||||
Type = "String",
|
||||
Method = "Trim"
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -48,9 +49,10 @@ public class ReachGraphStoreAdapterTests
|
||||
var adapter = CreateAdapter();
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:nuget/MyApp@1.0.0",
|
||||
Namespace = "MyApp",
|
||||
TypeName = "VulnerableClass",
|
||||
MemberName = "Execute"
|
||||
Type = "VulnerableClass",
|
||||
Method = "Execute"
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -59,7 +61,7 @@ public class ReachGraphStoreAdapterTests
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsReachable.Should().BeTrue();
|
||||
result.DistanceFromEntrypoint.Should().BeGreaterThanOrEqualTo(0);
|
||||
result.ShortestPathLength.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,9 +74,10 @@ public class ReachGraphStoreAdapterTests
|
||||
var adapter = CreateAdapter();
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:nuget/NonExistent@1.0.0",
|
||||
Namespace = "NonExistent",
|
||||
TypeName = "Class",
|
||||
MemberName = "Method"
|
||||
Type = "Class",
|
||||
Method = "Method"
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -151,7 +154,7 @@ public class ReachGraphStoreAdapterTests
|
||||
return new ReachGraphStoreAdapter(
|
||||
_storeService,
|
||||
_timeProvider,
|
||||
NullLogger<ReachGraphStoreAdapter>.Instance);
|
||||
"test-tenant");
|
||||
}
|
||||
|
||||
private static ReachGraphMinimal CreateTestGraph(string artifactDigest)
|
||||
@@ -160,53 +163,63 @@ public class ReachGraphStoreAdapterTests
|
||||
{
|
||||
Id = "entry-main",
|
||||
Ref = "MyApp.Program.Main",
|
||||
Kind = "method",
|
||||
Depth = 0
|
||||
Kind = ReachGraphNodeKind.Function,
|
||||
IsEntrypoint = true
|
||||
};
|
||||
|
||||
var vulnerableClass = new ReachGraphNode
|
||||
{
|
||||
Id = "vulnerable-class",
|
||||
Ref = "MyApp.VulnerableClass.Execute",
|
||||
Kind = "method",
|
||||
Depth = 1
|
||||
Kind = ReachGraphNodeKind.Function,
|
||||
IsSink = true
|
||||
};
|
||||
|
||||
var otherNode = new ReachGraphNode
|
||||
{
|
||||
Id = "other-node",
|
||||
Ref = "MyApp.OtherClass.DoWork",
|
||||
Kind = "method",
|
||||
Depth = 2
|
||||
Kind = ReachGraphNodeKind.Function
|
||||
};
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new ReachGraphEdge
|
||||
{
|
||||
Source = "entry-main",
|
||||
Target = "vulnerable-class"
|
||||
From = "entry-main",
|
||||
To = "vulnerable-class",
|
||||
Why = new EdgeExplanation
|
||||
{
|
||||
Type = EdgeExplanationType.DirectCall,
|
||||
Confidence = 1.0
|
||||
}
|
||||
},
|
||||
new ReachGraphEdge
|
||||
{
|
||||
Source = "entry-main",
|
||||
Target = "other-node"
|
||||
From = "entry-main",
|
||||
To = "other-node",
|
||||
Why = new EdgeExplanation
|
||||
{
|
||||
Type = EdgeExplanationType.DirectCall,
|
||||
Confidence = 1.0
|
||||
}
|
||||
});
|
||||
|
||||
return new ReachGraphMinimal
|
||||
{
|
||||
Artifact = new ReachGraphArtifact
|
||||
Artifact = new ReachGraphArtifact(
|
||||
"test-artifact",
|
||||
artifactDigest,
|
||||
ImmutableArray.Create("test")),
|
||||
Scope = new ReachGraphScope(
|
||||
ImmutableArray.Create("entry-main"),
|
||||
ImmutableArray<string>.Empty,
|
||||
null),
|
||||
Provenance = new ReachGraphProvenance
|
||||
{
|
||||
Name = "test-artifact",
|
||||
Digest = artifactDigest,
|
||||
Env = "test"
|
||||
Inputs = new ReachGraphInputs { Sbom = "sha256:sbom-test" },
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
Analyzer = new ReachGraphAnalyzer("test-analyzer", "1.0.0", "sha256:toolchain")
|
||||
},
|
||||
Scope = new ReachGraphScope
|
||||
{
|
||||
Entrypoints = ImmutableArray.Create("entry-main"),
|
||||
Selectors = ImmutableArray<string>.Empty,
|
||||
Cves = null
|
||||
},
|
||||
Signature = null,
|
||||
Nodes = ImmutableArray.Create(entrypoint, vulnerableClass, otherNode),
|
||||
Edges = edges
|
||||
};
|
||||
@@ -220,16 +233,16 @@ internal sealed class InMemoryReachGraphStoreService : IReachGraphStoreService
|
||||
{
|
||||
private readonly Dictionary<string, ReachGraphMinimal> _graphs = new();
|
||||
|
||||
public Task<ReachGraphStoreResult> UpsertAsync(
|
||||
public Task<StoreResult> UpsertAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var digest = graph.Artifact.Digest;
|
||||
var created = !_graphs.ContainsKey(digest);
|
||||
_graphs[digest] = graph;
|
||||
|
||||
return Task.FromResult(new ReachGraphStoreResult
|
||||
return Task.FromResult(new StoreResult
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactDigest = digest,
|
||||
@@ -242,28 +255,40 @@ internal sealed class InMemoryReachGraphStoreService : IReachGraphStoreService
|
||||
|
||||
public Task<ReachGraphMinimal?> GetByDigestAsync(
|
||||
string digest,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_graphs.TryGetValue(digest, out var graph);
|
||||
return Task.FromResult(graph);
|
||||
}
|
||||
|
||||
public Task<ReachGraphMinimal?> GetByArtifactAsync(
|
||||
public Task<IReadOnlyList<ReachGraphSummary>> ListByArtifactAsync(
|
||||
string artifactDigest,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var graph = _graphs.Values.FirstOrDefault(g => g.Artifact.Digest == artifactDigest);
|
||||
return Task.FromResult(graph);
|
||||
var summaries = _graphs.Values
|
||||
.Where(g => g.Artifact.Digest == artifactDigest)
|
||||
.Select(g => new ReachGraphSummary
|
||||
{
|
||||
Digest = g.Artifact.Digest,
|
||||
ArtifactDigest = g.Artifact.Digest,
|
||||
NodeCount = g.Nodes.Length,
|
||||
EdgeCount = g.Edges.Length,
|
||||
BlobSizeBytes = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Scope = g.Scope
|
||||
})
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<ReachGraphSummary>>(summaries);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string digest, string? tenantId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_graphs.ContainsKey(digest));
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string digest, string? tenantId, CancellationToken ct)
|
||||
public Task<bool> DeleteAsync(
|
||||
string digest,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_graphs.Remove(digest));
|
||||
}
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.ReachGraph.WebService\StellaOps.ReachGraph.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -434,7 +434,7 @@ public sealed class DataPrefetcher : BackgroundService
|
||||
|
||||
// Clean up completed jobs after a delay
|
||||
_ = Task.Delay(TimeSpan.FromMinutes(5), ct)
|
||||
.ContinueWith(_ => _activeJobs.TryRemove(request.Id.ToString(), out _), ct);
|
||||
.ContinueWith(__ => _activeJobs.TryRemove(request.Id.ToString(), out _), ct);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,11 +61,12 @@ public sealed class DecisionEngine : IDecisionEngine
|
||||
var promotion = await _promotionStore.GetAsync(promotionId, ct)
|
||||
?? throw new PromotionNotFoundException(promotionId);
|
||||
|
||||
var gateConfig = await GetGateConfigAsync(promotion.TargetEnvironmentId, ct);
|
||||
var enabledGates = await GetEnabledGatesAsync(promotion.TargetEnvironmentId, ct);
|
||||
var gateConfig = await GetGateConfigAsync(promotion.TargetEnvironmentId, enabledGates, ct);
|
||||
|
||||
// Evaluate all required gates
|
||||
var gateContext = BuildGateContext(promotion);
|
||||
var gateResults = await EvaluateGatesAsync(gateConfig.RequiredGates, gateContext, ct);
|
||||
var gateResults = await EvaluateGatesAsync(enabledGates, gateContext, ct);
|
||||
|
||||
// Get approval status
|
||||
var approvalStatus = await _approvalGateway.GetStatusAsync(promotionId, ct);
|
||||
@@ -148,10 +149,10 @@ public sealed class DecisionEngine : IDecisionEngine
|
||||
var promotion = await _promotionStore.GetAsync(promotionId, ct)
|
||||
?? throw new PromotionNotFoundException(promotionId);
|
||||
|
||||
var gateConfig = await GetGateConfigAsync(promotion.TargetEnvironmentId, ct);
|
||||
var enabledGates = await GetEnabledGatesAsync(promotion.TargetEnvironmentId, ct);
|
||||
var context = BuildGateContext(promotion);
|
||||
|
||||
return await _gateEvaluator.EvaluateAllAsync(gateConfig.RequiredGates.ToList(), context, ct);
|
||||
return await EvaluateGatesAsync(enabledGates, context, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -172,35 +173,47 @@ public sealed class DecisionEngine : IDecisionEngine
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<GateResult>> EvaluateGatesAsync(
|
||||
ImmutableArray<string> gateNames,
|
||||
IReadOnlyList<EnvironmentGate> gates,
|
||||
GateContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (gateNames.Length == 0)
|
||||
if (gates.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = await _gateEvaluator.EvaluateAllAsync(gateNames.ToList(), context, ct);
|
||||
var hasGateSpecificConfig = gates.Any(static gate => gate.Config is { Count: > 0 });
|
||||
if (!hasGateSpecificConfig)
|
||||
{
|
||||
var sharedResults = await _gateEvaluator.EvaluateAllAsync(
|
||||
gates.Select(static gate => gate.GateName).ToList(),
|
||||
context,
|
||||
ct);
|
||||
return sharedResults.ToImmutableArray();
|
||||
}
|
||||
|
||||
var tasks = gates.Select(gate =>
|
||||
{
|
||||
var gateContext = context with { Config = ToImmutableConfig(gate.Config) };
|
||||
return _gateEvaluator.EvaluateAsync(gate.GateName, gateContext, ct);
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<EnvironmentGateConfig> GetGateConfigAsync(
|
||||
Guid environmentId,
|
||||
IReadOnlyList<EnvironmentGate> enabledGates,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await _environmentService.GetAsync(environmentId, ct)
|
||||
?? throw new EnvironmentNotFoundException(environmentId);
|
||||
|
||||
// Get configured gates for this environment
|
||||
var configuredGates = await _environmentService.GetGatesAsync(environmentId, ct);
|
||||
|
||||
return new EnvironmentGateConfig
|
||||
{
|
||||
EnvironmentId = environmentId,
|
||||
RequiredGates = configuredGates
|
||||
.Where(g => g.IsEnabled)
|
||||
.OrderBy(g => g.Order)
|
||||
RequiredGates = enabledGates
|
||||
.Select(g => g.GateName)
|
||||
.ToImmutableArray(),
|
||||
RequiredApprovals = environment.RequiredApprovals,
|
||||
@@ -209,6 +222,16 @@ public sealed class DecisionEngine : IDecisionEngine
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<EnvironmentGate>> GetEnabledGatesAsync(Guid environmentId, CancellationToken ct)
|
||||
{
|
||||
var configuredGates = await _environmentService.GetGatesAsync(environmentId, ct)
|
||||
?? [];
|
||||
return configuredGates
|
||||
.Where(static gate => gate.IsEnabled)
|
||||
.OrderBy(static gate => gate.Order)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static GateContext BuildGateContext(Models.Promotion promotion) =>
|
||||
new()
|
||||
{
|
||||
@@ -223,6 +246,18 @@ public sealed class DecisionEngine : IDecisionEngine
|
||||
RequestedBy = promotion.RequestedBy,
|
||||
RequestedAt = promotion.RequestedAt
|
||||
};
|
||||
|
||||
private static ImmutableDictionary<string, object> ToImmutableConfig(IReadOnlyDictionary<string, object>? config)
|
||||
{
|
||||
if (config is null || config.Count == 0)
|
||||
{
|
||||
return ImmutableDictionary<string, object>.Empty;
|
||||
}
|
||||
|
||||
return config.ToImmutableDictionary(
|
||||
static pair => pair.Key,
|
||||
static pair => pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -46,6 +46,8 @@ public sealed class DecisionNotifier
|
||||
DecisionOutcome.Deny => BuildDenyNotification(promotion, result),
|
||||
DecisionOutcome.PendingApproval => BuildPendingApprovalNotification(promotion, result),
|
||||
DecisionOutcome.PendingGate => BuildPendingGateNotification(promotion, result),
|
||||
DecisionOutcome.HoldAsync => BuildHoldAsyncNotification(promotion, result),
|
||||
DecisionOutcome.Escalate => BuildEscalationNotification(promotion, result),
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -119,4 +121,34 @@ public sealed class DecisionNotifier
|
||||
["outcome"] = "pending_gate"
|
||||
}
|
||||
};
|
||||
|
||||
private static NotificationRequest BuildHoldAsyncNotification(
|
||||
Models.Promotion promotion,
|
||||
DecisionResult result) =>
|
||||
new()
|
||||
{
|
||||
Channel = "slack",
|
||||
Title = $"Async Hold: {promotion.ReleaseName}",
|
||||
Message = $"Release '{promotion.ReleaseName}' is on asynchronous evidence hold for {promotion.TargetEnvironmentName}.\n\n{result.BlockingReason}",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["promotionId"] = promotion.Id.ToString(),
|
||||
["outcome"] = "hold_async"
|
||||
}
|
||||
};
|
||||
|
||||
private static NotificationRequest BuildEscalationNotification(
|
||||
Models.Promotion promotion,
|
||||
DecisionResult result) =>
|
||||
new()
|
||||
{
|
||||
Channel = "slack",
|
||||
Title = $"Escalation Required: {promotion.ReleaseName}",
|
||||
Message = $"Release '{promotion.ReleaseName}' requires escalation for promotion to {promotion.TargetEnvironmentName}.\n\n{result.BlockingReason}",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["promotionId"] = promotion.Id.ToString(),
|
||||
["outcome"] = "escalate"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +58,13 @@ public sealed class DecisionRecorder
|
||||
GateConfig = config,
|
||||
EvaluatedAt = _timeProvider.GetUtcNow(),
|
||||
EvaluatedBy = Guid.Empty, // System evaluation
|
||||
EvidenceDigest = ComputeEvidenceDigest(result)
|
||||
EvidenceDigest = ComputeEvidenceDigest(result),
|
||||
SloP50Ms = ExtractSloValue(result, "p50Ms"),
|
||||
SloP90Ms = ExtractSloValue(result, "p90Ms"),
|
||||
SloP99Ms = ExtractSloValue(result, "p99Ms"),
|
||||
AsyncHoldSlaHours = ExtractSloValue(result, "asyncHoldSlaHours"),
|
||||
EvidenceTtlHours = ExtractSloValue(result, "evidenceTtlHours"),
|
||||
HumanDecisionDsseRef = ExtractHumanDecisionDsseRef(result)
|
||||
};
|
||||
|
||||
await _store.SaveAsync(record, ct);
|
||||
@@ -136,4 +142,77 @@ public sealed class DecisionRecorder
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static int? ExtractSloValue(DecisionResult result, string key)
|
||||
{
|
||||
foreach (var gate in result.GateResults)
|
||||
{
|
||||
if (!gate.Details.TryGetValue("slo", out var sloRaw) || sloRaw is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sloRaw is IReadOnlyDictionary<string, object> readonlyDictionary &&
|
||||
readonlyDictionary.TryGetValue(key, out var value) &&
|
||||
TryReadInt(value, out var parsed))
|
||||
{
|
||||
return parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
if (sloRaw is IDictionary<string, object> dictionary &&
|
||||
dictionary.TryGetValue(key, out var dictionaryValue) &&
|
||||
TryReadInt(dictionaryValue, out var dictionaryParsed))
|
||||
{
|
||||
return dictionaryParsed >= 0 ? dictionaryParsed : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractHumanDecisionDsseRef(DecisionResult result)
|
||||
{
|
||||
foreach (var gate in result.GateResults)
|
||||
{
|
||||
foreach (var value in gate.Details.Values)
|
||||
{
|
||||
if (value is not IReadOnlyDictionary<string, object> nested)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nested.TryGetValue("humanDecisionDsseRef", out var raw) || raw is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = raw.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return text.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryReadInt(object? value, out int parsed)
|
||||
{
|
||||
parsed = default;
|
||||
switch (value)
|
||||
{
|
||||
case int intValue:
|
||||
parsed = intValue;
|
||||
return true;
|
||||
case long longValue when longValue is <= int.MaxValue and >= int.MinValue:
|
||||
parsed = (int)longValue;
|
||||
return true;
|
||||
case string text when int.TryParse(text, out var fromString):
|
||||
parsed = fromString;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,33 @@ public sealed class DecisionRules
|
||||
ApprovalStatus approvalStatus,
|
||||
EnvironmentGateConfig config)
|
||||
{
|
||||
var blockingEscalations = gateResults
|
||||
.Where(static gate => HasOutcomeHint(gate, "escalate"))
|
||||
.Where(static gate => GetBooleanDetail(gate, "escalationBlocking", defaultValue: true))
|
||||
.ToList();
|
||||
|
||||
if (blockingEscalations.Count > 0)
|
||||
{
|
||||
return new DecisionOutcomeResult(
|
||||
Decision: DecisionOutcome.Escalate,
|
||||
CanProceed: false,
|
||||
BlockingReason: $"Escalation required by gates: {string.Join(", ", blockingEscalations.Select(g => g.GateName))}"
|
||||
);
|
||||
}
|
||||
|
||||
var holdAsyncGates = gateResults
|
||||
.Where(static gate => HasOutcomeHint(gate, "hold_async") || GetBooleanDetail(gate, "waitingForConfirmation", defaultValue: false))
|
||||
.ToList();
|
||||
|
||||
if (holdAsyncGates.Count > 0)
|
||||
{
|
||||
return new DecisionOutcomeResult(
|
||||
Decision: DecisionOutcome.HoldAsync,
|
||||
CanProceed: false,
|
||||
BlockingReason: $"Waiting for async evidence: {string.Join(", ", holdAsyncGates.Select(g => g.GateName))}"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for blocking gate failures first
|
||||
var blockingFailures = gateResults.Where(g => !g.Passed && g.Blocking).ToList();
|
||||
if (blockingFailures.Count > 0)
|
||||
@@ -33,20 +60,6 @@ public sealed class DecisionRules
|
||||
);
|
||||
}
|
||||
|
||||
// Check for async gates waiting for callback
|
||||
var pendingGates = gateResults
|
||||
.Where(g => !g.Passed && g.Details.ContainsKey("waitingForConfirmation"))
|
||||
.ToList();
|
||||
|
||||
if (pendingGates.Count > 0)
|
||||
{
|
||||
return new DecisionOutcomeResult(
|
||||
Decision: DecisionOutcome.PendingGate,
|
||||
CanProceed: false,
|
||||
BlockingReason: $"Waiting for: {string.Join(", ", pendingGates.Select(g => g.GateName))}"
|
||||
);
|
||||
}
|
||||
|
||||
// Check if all gates must pass
|
||||
if (config.AllGatesMustPass)
|
||||
{
|
||||
@@ -87,6 +100,31 @@ public sealed class DecisionRules
|
||||
BlockingReason: null
|
||||
);
|
||||
}
|
||||
|
||||
private static bool HasOutcomeHint(GateResult gate, string expectedHint)
|
||||
{
|
||||
if (!gate.Details.TryGetValue("gateOutcomeHint", out var rawHint) || rawHint is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(rawHint.ToString(), expectedHint, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool GetBooleanDetail(GateResult gate, string key, bool defaultValue)
|
||||
{
|
||||
if (!gate.Details.TryGetValue(key, out var rawValue) || rawValue is null)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return rawValue switch
|
||||
{
|
||||
bool boolValue => boolValue,
|
||||
string text when bool.TryParse(text, out var parsed) => parsed,
|
||||
_ => defaultValue
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -55,6 +55,9 @@ public sealed record ScanResult
|
||||
/// </summary>
|
||||
public sealed record ReproducibilityEvidenceStatus
|
||||
{
|
||||
/// <summary>Aggregated evidence score used for policy threshold checks (0-100).</summary>
|
||||
public int? EvidenceScoreValue { get; init; }
|
||||
|
||||
/// <summary>Whether DSSE-signed SLSA provenance exists and verified.</summary>
|
||||
public bool HasDsseProvenance { get; init; }
|
||||
|
||||
@@ -70,9 +73,33 @@ public sealed record ReproducibilityEvidenceStatus
|
||||
/// <summary>Whether Rekor inclusion proof is verified (online or offline profile).</summary>
|
||||
public bool RekorVerified { get; init; }
|
||||
|
||||
/// <summary>When Rekor verification was last checked for freshness policies.</summary>
|
||||
public DateTimeOffset? RekorCheckedAt { get; init; }
|
||||
|
||||
/// <summary>Whether verification was done in explicit break-glass mode.</summary>
|
||||
public bool UsedBreakGlassVerification { get; init; }
|
||||
|
||||
/// <summary>Whether an in-toto build link exists.</summary>
|
||||
public bool BuildLinkExists { get; init; }
|
||||
|
||||
/// <summary>Build link product digest using SHA-256.</summary>
|
||||
public string? BuildProductDigestSha256 { get; init; }
|
||||
|
||||
/// <summary>Build link product digest using SHA-512.</summary>
|
||||
public string? BuildProductDigestSha512 { get; init; }
|
||||
|
||||
/// <summary>Artifact digest using SHA-256 when available.</summary>
|
||||
public string? ArtifactDigestSha256 { get; init; }
|
||||
|
||||
/// <summary>Artifact digest using SHA-512 when available.</summary>
|
||||
public string? ArtifactDigestSha512 { get; init; }
|
||||
|
||||
/// <summary>DSSE signatures attached to evidence inputs.</summary>
|
||||
public IReadOnlyList<DsseSignatureEvidence> DsseSignatures { get; init; } = [];
|
||||
|
||||
/// <summary>Reference to DSSE-signed human escalation decision evidence.</summary>
|
||||
public string? HumanDecisionDsseRef { get; init; }
|
||||
|
||||
/// <summary>Stable policy violation codes produced upstream by attestor/policy checks.</summary>
|
||||
public IReadOnlyList<string> ViolationCodes { get; init; } = [];
|
||||
|
||||
@@ -89,6 +116,21 @@ public sealed record ReproducibilityEvidenceStatus
|
||||
public IReadOnlyList<string> AttestationRefs { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature evidence entry used for k-of-n signer policy checks.
|
||||
/// </summary>
|
||||
public sealed record DsseSignatureEvidence
|
||||
{
|
||||
/// <summary>Whether the signature cryptographically verifies.</summary>
|
||||
public bool Valid { get; init; }
|
||||
|
||||
/// <summary>Signature algorithm (for example ed25519, ecdsa, rsa).</summary>
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
/// <summary>Signer key identifier (key ID or cert SKI).</summary>
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A vulnerability found in a scan.
|
||||
/// </summary>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.Security;
|
||||
/// </summary>
|
||||
public sealed record SecurityGateConfig
|
||||
{
|
||||
/// <summary>Minimum required evidence score (0-100). Null disables threshold check.</summary>
|
||||
public int? MinEvidenceScore { get; init; }
|
||||
|
||||
/// <summary>Maximum critical vulnerabilities allowed (default: 0).</summary>
|
||||
public int MaxCritical { get; init; }
|
||||
|
||||
@@ -49,4 +52,58 @@ public sealed record SecurityGateConfig
|
||||
|
||||
/// <summary>Require Evidence Locker evidence_score match against local recomputation (default: false).</summary>
|
||||
public bool RequireEvidenceScoreMatch { get; init; }
|
||||
|
||||
/// <summary>Require in-toto build link evidence and digest binding (default: false).</summary>
|
||||
public bool RequireBuildLinkDigestBinding { get; init; }
|
||||
|
||||
/// <summary>Digest algorithm for product/artifact binding (sha256|sha512).</summary>
|
||||
public string ProductDigestAlgorithm { get; init; } = "sha256";
|
||||
|
||||
/// <summary>Minimum distinct valid DSSE signers required. Zero disables threshold check.</summary>
|
||||
public int DsseSignerThresholdK { get; init; }
|
||||
|
||||
/// <summary>Total signer set size for policy contract validation.</summary>
|
||||
public int DsseSignerThresholdN { get; init; }
|
||||
|
||||
/// <summary>Allowed DSSE signer key IDs.</summary>
|
||||
public IReadOnlyList<string> AllowedSignerKeys { get; init; } = [];
|
||||
|
||||
/// <summary>Allowed DSSE signature algorithms.</summary>
|
||||
public IReadOnlyList<string> AllowedDsseAlgorithms { get; init; } = [];
|
||||
|
||||
/// <summary>Maximum Rekor freshness age in seconds. Null disables freshness check.</summary>
|
||||
public int? RekorMaxFreshSeconds { get; init; }
|
||||
|
||||
/// <summary>Retry backoff initial delay in milliseconds.</summary>
|
||||
public int RetryBackoffInitialMs { get; init; } = 500;
|
||||
|
||||
/// <summary>Retry backoff multiplier.</summary>
|
||||
public double RetryBackoffFactor { get; init; } = 2.0;
|
||||
|
||||
/// <summary>Maximum retries for transient evidence freshness failures.</summary>
|
||||
public int MaxRetries { get; init; } = 0;
|
||||
|
||||
/// <summary>Escalation behavior when retries are exhausted (fail_closed|fail_open_with_alert).</summary>
|
||||
public string EscalationMode { get; init; } = "fail_closed";
|
||||
|
||||
/// <summary>Escalation queue identifier for human review.</summary>
|
||||
public string HumanQueue { get; init; } = "security-approvals";
|
||||
|
||||
/// <summary>Whether escalations require DSSE-signed human disposition evidence.</summary>
|
||||
public bool RequireSignedHumanDecision { get; init; }
|
||||
|
||||
/// <summary>SLO target for p50 gate latency in milliseconds.</summary>
|
||||
public int? SloP50Ms { get; init; }
|
||||
|
||||
/// <summary>SLO target for p90 gate latency in milliseconds.</summary>
|
||||
public int? SloP90Ms { get; init; }
|
||||
|
||||
/// <summary>SLO target for p99 gate latency in milliseconds.</summary>
|
||||
public int? SloP99Ms { get; init; }
|
||||
|
||||
/// <summary>SLA for asynchronous hold resolution in hours.</summary>
|
||||
public int? AsyncHoldSlaHours { get; init; }
|
||||
|
||||
/// <summary>TTL of evidence freshness metadata in hours.</summary>
|
||||
public int? EvidenceTtlHours { get; init; }
|
||||
}
|
||||
|
||||
@@ -39,4 +39,22 @@ public sealed record DecisionRecord
|
||||
|
||||
/// <summary>SHA-256 digest of the evidence used for the decision.</summary>
|
||||
public string? EvidenceDigest { get; init; }
|
||||
|
||||
/// <summary>SLO target p50 latency in milliseconds, if present in gate evidence.</summary>
|
||||
public int? SloP50Ms { get; init; }
|
||||
|
||||
/// <summary>SLO target p90 latency in milliseconds, if present in gate evidence.</summary>
|
||||
public int? SloP90Ms { get; init; }
|
||||
|
||||
/// <summary>SLO target p99 latency in milliseconds, if present in gate evidence.</summary>
|
||||
public int? SloP99Ms { get; init; }
|
||||
|
||||
/// <summary>Async hold SLA in hours, if configured for decision evidence.</summary>
|
||||
public int? AsyncHoldSlaHours { get; init; }
|
||||
|
||||
/// <summary>Evidence TTL in hours, if configured for decision evidence.</summary>
|
||||
public int? EvidenceTtlHours { get; init; }
|
||||
|
||||
/// <summary>DSSE reference for signed human escalation decision, when required.</summary>
|
||||
public string? HumanDecisionDsseRef { get; init; }
|
||||
}
|
||||
|
||||
@@ -60,9 +60,15 @@ public enum DecisionOutcome
|
||||
/// <summary>Gates passed but awaiting required approvals.</summary>
|
||||
PendingApproval,
|
||||
|
||||
/// <summary>An async gate is awaiting callback.</summary>
|
||||
/// <summary>An async gate is awaiting callback (legacy alias for hold_async semantics).</summary>
|
||||
PendingGate,
|
||||
|
||||
/// <summary>Promotion is placed on asynchronous hold pending additional evidence.</summary>
|
||||
HoldAsync,
|
||||
|
||||
/// <summary>Promotion requires escalation and human disposition.</summary>
|
||||
Escalate,
|
||||
|
||||
/// <summary>An error occurred during evaluation.</summary>
|
||||
Error
|
||||
}
|
||||
|
||||
@@ -367,6 +367,41 @@ public sealed class DecisionEngineTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_GateSpecificConfig_PassesConfigToGateContext()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
SetupStandardMocks();
|
||||
|
||||
_environmentService.Setup(s => s.GetGatesAsync(_targetEnvId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EnvironmentGate>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GateName = "security-gate",
|
||||
IsEnabled = true,
|
||||
Order = 1,
|
||||
Config = new Dictionary<string, object>
|
||||
{
|
||||
["minEvidenceScore"] = 85
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_gateEvaluator.Setup(e => e.EvaluateAsync(
|
||||
"security-gate",
|
||||
It.Is<GateContext>(c => c.Config.ContainsKey("minEvidenceScore") && (int)c.Config["minEvidenceScore"] == 85),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GateResult.Success("security-gate", "security", _timeProvider, "Passed"));
|
||||
|
||||
var result = await _engine.EvaluateAsync(_promotionId, ct);
|
||||
|
||||
result.Outcome.Should().Be(DecisionOutcome.Allow);
|
||||
_gateEvaluator.Verify(
|
||||
e => e.EvaluateAllAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<GateContext>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGateAsync_EvaluatesSingleGate()
|
||||
{
|
||||
|
||||
@@ -170,6 +170,50 @@ public sealed class DecisionNotifierTests
|
||||
sentRequest.Metadata["outcome"].Should().Be("pending_gate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDecisionAsync_HoldAsync_SendsHoldNotification()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var promotion = CreatePromotion();
|
||||
var result = CreateDecisionResult(DecisionOutcome.HoldAsync, "Awaiting async evidence");
|
||||
|
||||
_promotionStore.Setup(s => s.GetAsync(_promotionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(promotion);
|
||||
|
||||
NotificationRequest? sentRequest = null;
|
||||
_notificationService.Setup(s => s.SendAsync(It.IsAny<NotificationRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<NotificationRequest, CancellationToken>((r, _) => sentRequest = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _notifier.NotifyDecisionAsync(result, ct);
|
||||
|
||||
sentRequest.Should().NotBeNull();
|
||||
sentRequest!.Title.Should().Contain("Async Hold");
|
||||
sentRequest.Metadata["outcome"].Should().Be("hold_async");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDecisionAsync_Escalate_SendsEscalationNotification()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var promotion = CreatePromotion();
|
||||
var result = CreateDecisionResult(DecisionOutcome.Escalate, "Security escalation required");
|
||||
|
||||
_promotionStore.Setup(s => s.GetAsync(_promotionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(promotion);
|
||||
|
||||
NotificationRequest? sentRequest = null;
|
||||
_notificationService.Setup(s => s.SendAsync(It.IsAny<NotificationRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<NotificationRequest, CancellationToken>((r, _) => sentRequest = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _notifier.NotifyDecisionAsync(result, ct);
|
||||
|
||||
sentRequest.Should().NotBeNull();
|
||||
sentRequest!.Title.Should().Contain("Escalation Required");
|
||||
sentRequest.Metadata["outcome"].Should().Be("escalate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDecisionAsync_Error_DoesNotSendNotification()
|
||||
{
|
||||
|
||||
@@ -203,6 +203,52 @@ public sealed class DecisionRecorderTests
|
||||
records[0].EvidenceDigest.Should().Be(records[1].EvidenceDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_ExtractsSloAndHumanDecisionMetadata()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
_guidGenerator.Setup(g => g.NewGuid()).Returns(Guid.NewGuid());
|
||||
|
||||
var promotion = CreatePromotion();
|
||||
var result = CreateDecisionResult() with
|
||||
{
|
||||
GateResults =
|
||||
[
|
||||
GateResult.Success(
|
||||
"security-gate",
|
||||
"security",
|
||||
details: ImmutableDictionary<string, object>.Empty
|
||||
.Add("slo", new Dictionary<string, object>
|
||||
{
|
||||
["p50Ms"] = 200,
|
||||
["p90Ms"] = 2000,
|
||||
["p99Ms"] = 15000,
|
||||
["asyncHoldSlaHours"] = 12,
|
||||
["evidenceTtlHours"] = 24
|
||||
})
|
||||
.Add("component_my-app", new Dictionary<string, object>
|
||||
{
|
||||
["humanDecisionDsseRef"] = "cas://evidence/human-decision.dsse.json"
|
||||
}))
|
||||
]
|
||||
};
|
||||
|
||||
DecisionRecord? savedRecord = null;
|
||||
_store.Setup(s => s.SaveAsync(It.IsAny<DecisionRecord>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<DecisionRecord, CancellationToken>((r, _) => savedRecord = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _recorder.RecordAsync(promotion, result, CreateConfig(), ct);
|
||||
|
||||
savedRecord.Should().NotBeNull();
|
||||
savedRecord!.SloP50Ms.Should().Be(200);
|
||||
savedRecord.SloP90Ms.Should().Be(2000);
|
||||
savedRecord.SloP99Ms.Should().Be(15000);
|
||||
savedRecord.AsyncHoldSlaHours.Should().Be(12);
|
||||
savedRecord.EvidenceTtlHours.Should().Be(24);
|
||||
savedRecord.HumanDecisionDsseRef.Should().Be("cas://evidence/human-decision.dsse.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestAsync_ReturnsFromStore()
|
||||
{
|
||||
|
||||
@@ -170,7 +170,7 @@ public sealed class DecisionRulesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_PendingGate_ReturnsPendingGate()
|
||||
public void Evaluate_PendingGate_ReturnsHoldAsync()
|
||||
{
|
||||
// Arrange
|
||||
var gateResults = ImmutableArray.Create(
|
||||
@@ -183,11 +183,32 @@ public sealed class DecisionRulesTests
|
||||
var result = _rules.Evaluate(gateResults, approvalStatus, config);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(DecisionOutcome.PendingGate);
|
||||
result.Decision.Should().Be(DecisionOutcome.HoldAsync);
|
||||
result.CanProceed.Should().BeFalse();
|
||||
result.BlockingReason.Should().Contain("manual-gate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_BlockingEscalationHint_ReturnsEscalate()
|
||||
{
|
||||
var gateResults = ImmutableArray.Create(
|
||||
GateResult.Failure(
|
||||
"security-gate",
|
||||
"security",
|
||||
"Escalation required",
|
||||
blocking: true,
|
||||
details: ImmutableDictionary<string, object>.Empty
|
||||
.Add("gateOutcomeHint", "escalate")
|
||||
.Add("escalationBlocking", true)));
|
||||
var approvalStatus = CreateApprovalStatus(approved: true);
|
||||
var config = CreateConfig();
|
||||
|
||||
var result = _rules.Evaluate(gateResults, approvalStatus, config);
|
||||
|
||||
result.Decision.Should().Be(DecisionOutcome.Escalate);
|
||||
result.CanProceed.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_NoGates_NoApprovals_Allows()
|
||||
{
|
||||
|
||||
@@ -1177,6 +1177,230 @@ public sealed class SecurityGateTests
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_EVIDENCE_SCORE_MISSING");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MinEvidenceScoreBelowThreshold_Fails()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["minEvidenceScore"] = 85
|
||||
}.ToImmutableDictionary();
|
||||
var context = CreateContext(config);
|
||||
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
|
||||
var release = CreateRelease(component);
|
||||
var scan = CreateScan(
|
||||
reproducibilityEvidence: new ReproducibilityEvidenceStatus
|
||||
{
|
||||
EvidenceScoreValue = 72,
|
||||
HasDsseProvenance = true
|
||||
});
|
||||
|
||||
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
|
||||
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
|
||||
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
|
||||
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
|
||||
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||||
|
||||
var result = await _gate.EvaluateAsync(context, ct);
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Message.Should().Contain("below threshold");
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_EVIDENCE_SCORE_THRESHOLD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireBuildLinkDigestBinding_DigestMismatch_Fails()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireBuildLinkDigestBinding"] = true,
|
||||
["productDigestAlgorithm"] = "sha256"
|
||||
}.ToImmutableDictionary();
|
||||
var context = CreateContext(config);
|
||||
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
|
||||
var release = CreateRelease(component);
|
||||
var scan = CreateScan(
|
||||
reproducibilityEvidence: new ReproducibilityEvidenceStatus
|
||||
{
|
||||
BuildLinkExists = true,
|
||||
BuildProductDigestSha256 = new string('a', 64),
|
||||
ArtifactDigestSha256 = new string('b', 64)
|
||||
});
|
||||
|
||||
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
|
||||
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
|
||||
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
|
||||
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
|
||||
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||||
|
||||
var result = await _gate.EvaluateAsync(context, ct);
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_BUILD_DIGEST_MISMATCH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DsseSignerThreshold_NotMet_Fails()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["dsseSignerThresholdK"] = 2,
|
||||
["dsseSignerThresholdN"] = 4,
|
||||
["allowedSignerKeys"] = new[] { "kid:builder", "kid:relmgr", "kid:secops", "kid:prov" },
|
||||
["allowedDsseAlgorithms"] = new[] { "ed25519", "ecdsa" }
|
||||
}.ToImmutableDictionary<string, object>();
|
||||
var context = CreateContext(config);
|
||||
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
|
||||
var release = CreateRelease(component);
|
||||
var scan = CreateScan(
|
||||
reproducibilityEvidence: new ReproducibilityEvidenceStatus
|
||||
{
|
||||
DsseSignatures =
|
||||
[
|
||||
new DsseSignatureEvidence { Valid = true, KeyId = "kid:builder", Algorithm = "ed25519" },
|
||||
new DsseSignatureEvidence { Valid = false, KeyId = "kid:relmgr", Algorithm = "ed25519" },
|
||||
new DsseSignatureEvidence { Valid = true, KeyId = "kid:unknown", Algorithm = "ed25519" }
|
||||
]
|
||||
});
|
||||
|
||||
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
|
||||
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
|
||||
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
|
||||
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
|
||||
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||||
|
||||
var result = await _gate.EvaluateAsync(context, ct);
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_DSSE_THRESHOLD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RekorFreshnessExceeded_FailClosed_EscalatesAndBlocks()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireRekorVerification"] = true,
|
||||
["rekorMaxFreshSeconds"] = 30,
|
||||
["maxRetries"] = 1,
|
||||
["escalationMode"] = "fail_closed"
|
||||
}.ToImmutableDictionary();
|
||||
var context = CreateContext(config);
|
||||
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
|
||||
var release = CreateRelease(component);
|
||||
var scan = CreateScan(
|
||||
reproducibilityEvidence: new ReproducibilityEvidenceStatus
|
||||
{
|
||||
RekorVerified = true,
|
||||
RekorCheckedAt = _timeProvider.GetUtcNow().AddHours(-4)
|
||||
});
|
||||
|
||||
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
|
||||
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
|
||||
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
|
||||
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
|
||||
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||||
|
||||
var result = await _gate.EvaluateAsync(context, ct);
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Blocking.Should().BeTrue();
|
||||
result.Details["gateOutcomeHint"].Should().Be("escalate");
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_REKOR_FRESHNESS_EXCEEDED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RekorFreshnessExceeded_FailOpenWithSignedHumanDecision_PassesWithAlert()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireRekorVerification"] = true,
|
||||
["rekorMaxFreshSeconds"] = 30,
|
||||
["maxRetries"] = 0,
|
||||
["escalationMode"] = "fail_open_with_alert",
|
||||
["requireSignedHumanDecision"] = true
|
||||
}.ToImmutableDictionary();
|
||||
var context = CreateContext(config);
|
||||
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
|
||||
var release = CreateRelease(component);
|
||||
var scan = CreateScan(
|
||||
reproducibilityEvidence: new ReproducibilityEvidenceStatus
|
||||
{
|
||||
RekorVerified = true,
|
||||
RekorCheckedAt = _timeProvider.GetUtcNow().AddHours(-4),
|
||||
HumanDecisionDsseRef = "cas://evidence/human-decision.dsse.json"
|
||||
});
|
||||
|
||||
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
|
||||
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
|
||||
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
|
||||
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
|
||||
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||||
|
||||
var result = await _gate.EvaluateAsync(context, ct);
|
||||
|
||||
result.Passed.Should().BeTrue();
|
||||
result.Details["gateOutcomeHint"].Should().Be("escalate");
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_ESCALATION_FAIL_OPEN_ALERT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EvidenceScoreNotReady_WithAsyncHold_ReturnsNonBlockingHold()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireEvidenceScoreMatch"] = true,
|
||||
["asyncHoldSlaHours"] = 12
|
||||
}.ToImmutableDictionary();
|
||||
var context = CreateContext(config);
|
||||
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
|
||||
var release = CreateRelease(component);
|
||||
var scan = CreateScan(
|
||||
reproducibilityEvidence: new ReproducibilityEvidenceStatus
|
||||
{
|
||||
HasDsseProvenance = true,
|
||||
HasDsseInTotoLink = true,
|
||||
CanonicalizationPassed = true,
|
||||
ToolchainDigestPinned = true,
|
||||
RekorVerified = true,
|
||||
EvidenceArtifactId = "stella://svc/my-app@sha256:abc",
|
||||
CanonicalBomSha256 = new string('1', 64),
|
||||
PayloadDigest = new string('2', 64),
|
||||
AttestationRefs = ["sha256://attestation-a"]
|
||||
});
|
||||
|
||||
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
|
||||
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
|
||||
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
|
||||
_evidenceScoreService.Setup(s => s.GetScoreAsync(_tenantId, "stella://svc/my-app@sha256:abc", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EvidenceScoreLookupResult
|
||||
{
|
||||
ArtifactId = "stella://svc/my-app@sha256:abc",
|
||||
EvidenceScore = new string('9', 64),
|
||||
Status = "pending"
|
||||
});
|
||||
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
|
||||
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||||
|
||||
var result = await _gate.EvaluateAsync(context, ct);
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Blocking.Should().BeFalse();
|
||||
result.Details["gateOutcomeHint"].Should().Be("hold_async");
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_HOLD_ASYNC_EVIDENCE_NOT_READY");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_BreakGlassWithoutRekor_Fails()
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -304,92 +304,5 @@ public sealed class FeedSnapshotServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
#region Test helpers
|
||||
|
||||
internal sealed class InMemoryFeedSnapshotBlobStore : IFeedSnapshotBlobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, FeedSnapshotBlob> _blobs = new();
|
||||
|
||||
public Task StoreAsync(FeedSnapshotBlob blob, CancellationToken ct = default)
|
||||
{
|
||||
_blobs[blob.Digest] = blob;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<FeedSnapshotBlob?> GetByDigestAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
_blobs.TryGetValue(digest, out var blob);
|
||||
return Task.FromResult(blob);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(_blobs.ContainsKey(digest));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
_blobs.TryRemove(digest, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryFeedSnapshotIndexStore : IFeedSnapshotIndexStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<FeedSnapshotIndexEntry>> _index = new();
|
||||
|
||||
public Task IndexSnapshotAsync(FeedSnapshotIndexEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
var entries = _index.GetOrAdd(entry.ProviderId, _ => new List<FeedSnapshotIndexEntry>());
|
||||
lock (entries)
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<FeedSnapshotIndexEntry?> FindSnapshotAtTimeAsync(
|
||||
string providerId,
|
||||
DateTimeOffset pointInTime,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_index.TryGetValue(providerId, out var entries))
|
||||
{
|
||||
return Task.FromResult<FeedSnapshotIndexEntry?>(null);
|
||||
}
|
||||
|
||||
lock (entries)
|
||||
{
|
||||
var entry = entries
|
||||
.Where(e => e.CapturedAt <= pointInTime)
|
||||
.OrderByDescending(e => e.CapturedAt)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<FeedSnapshotIndexEntry>> ListSnapshotsAsync(
|
||||
string providerId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_index.TryGetValue(providerId, out var entries))
|
||||
{
|
||||
return Task.FromResult(ImmutableArray<FeedSnapshotIndexEntry>.Empty);
|
||||
}
|
||||
|
||||
lock (entries)
|
||||
{
|
||||
var result = entries
|
||||
.Where(e => e.CapturedAt >= from && e.CapturedAt <= to)
|
||||
.OrderBy(e => e.CapturedAt)
|
||||
.Take(limit)
|
||||
.ToImmutableArray();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
// Test helpers (InMemoryFeedSnapshotBlobStore, InMemoryFeedSnapshotIndexStore)
|
||||
// are defined in PointInTimeAdvisoryResolverTests.cs in the same namespace.
|
||||
|
||||
@@ -334,112 +334,5 @@ public sealed class PointInTimeQueryEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
#region Test helpers (duplicated for standalone test execution)
|
||||
|
||||
internal sealed class InMemoryFeedSnapshotBlobStore : IFeedSnapshotBlobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, FeedSnapshotBlob> _blobs = new();
|
||||
|
||||
public Task StoreAsync(FeedSnapshotBlob blob, CancellationToken ct = default)
|
||||
{
|
||||
_blobs[blob.Digest] = blob;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<FeedSnapshotBlob?> GetByDigestAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
_blobs.TryGetValue(digest, out var blob);
|
||||
return Task.FromResult(blob);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(_blobs.ContainsKey(digest));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
_blobs.TryRemove(digest, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryFeedSnapshotIndexStore : IFeedSnapshotIndexStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<FeedSnapshotIndexEntry>> _index = new();
|
||||
|
||||
public Task IndexSnapshotAsync(FeedSnapshotIndexEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
var entries = _index.GetOrAdd(entry.ProviderId, _ => new List<FeedSnapshotIndexEntry>());
|
||||
lock (entries)
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<FeedSnapshotIndexEntry?> FindSnapshotAtTimeAsync(
|
||||
string providerId,
|
||||
DateTimeOffset pointInTime,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_index.TryGetValue(providerId, out var entries))
|
||||
{
|
||||
return Task.FromResult<FeedSnapshotIndexEntry?>(null);
|
||||
}
|
||||
|
||||
lock (entries)
|
||||
{
|
||||
var entry = entries
|
||||
.Where(e => e.CapturedAt <= pointInTime)
|
||||
.OrderByDescending(e => e.CapturedAt)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<FeedSnapshotIndexEntry>> ListSnapshotsAsync(
|
||||
string providerId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_index.TryGetValue(providerId, out var entries))
|
||||
{
|
||||
return Task.FromResult(ImmutableArray<FeedSnapshotIndexEntry>.Empty);
|
||||
}
|
||||
|
||||
lock (entries)
|
||||
{
|
||||
var result = entries
|
||||
.Where(e => e.CapturedAt >= from && e.CapturedAt <= to)
|
||||
.OrderBy(e => e.CapturedAt)
|
||||
.Take(limit)
|
||||
.ToImmutableArray();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AdvisoryData> _advisories = new();
|
||||
|
||||
public void SetAdvisory(string cveId, AdvisoryData advisory)
|
||||
{
|
||||
_advisories[cveId] = advisory;
|
||||
}
|
||||
|
||||
public Task<AdvisoryData?> ExtractAdvisoryAsync(
|
||||
string cveId,
|
||||
byte[] content,
|
||||
FeedSnapshotFormat format,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_advisories.TryGetValue(cveId, out var advisory);
|
||||
return Task.FromResult(advisory);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
// Test helpers (InMemoryFeedSnapshotBlobStore, InMemoryFeedSnapshotIndexStore, TestAdvisoryExtractor)
|
||||
// are defined in PointInTimeAdvisoryResolverTests.cs in the same namespace.
|
||||
|
||||
@@ -25,13 +25,35 @@ public sealed class CvssKevProvider : IRiskScoreProvider
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var cvssScore = await cvss.GetCvssAsync(request.Subject, cancellationToken).ConfigureAwait(false) ?? 0d;
|
||||
var cvssScore = request.Signals.TryGetValue("Cvss", out var inlineCvss)
|
||||
? inlineCvss
|
||||
: await cvss.GetCvssAsync(request.Subject, cancellationToken).ConfigureAwait(false) ?? 0d;
|
||||
cvssScore = Math.Clamp(cvssScore, 0d, 10d);
|
||||
|
||||
var kevFlag = await kev.IsKevAsync(request.Subject, cancellationToken).ConfigureAwait(false) ?? false;
|
||||
var kevFlag = TryGetKevFlag(request, out var inlineKev)
|
||||
? inlineKev
|
||||
: await kev.IsKevAsync(request.Subject, cancellationToken).ConfigureAwait(false) ?? false;
|
||||
|
||||
var kevBonus = kevFlag ? 0.2d : 0d;
|
||||
var raw = (cvssScore / 10d) + kevBonus;
|
||||
return Math.Round(Math.Min(1d, raw), 6, MidpointRounding.ToEven);
|
||||
}
|
||||
|
||||
private static bool TryGetKevFlag(ScoreRequest request, out bool kevFlag)
|
||||
{
|
||||
if (request.Signals.TryGetValue("Kev", out var kev))
|
||||
{
|
||||
kevFlag = kev >= 1d;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.Signals.TryGetValue("IsKev", out var isKev))
|
||||
{
|
||||
kevFlag = isKev >= 1d;
|
||||
return true;
|
||||
}
|
||||
|
||||
kevFlag = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ public sealed class EpssProvider : IRiskScoreProvider
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var epssData = await epss.GetEpssAsync(request.Subject, cancellationToken).ConfigureAwait(false);
|
||||
var signalScore = TryGetSignalScore(request);
|
||||
var epssData = signalScore.HasValue
|
||||
? new EpssData(signalScore.Value, request.Signals.TryGetValue("EpssPercentile", out var percentile) ? percentile : 0d)
|
||||
: await epss.GetEpssAsync(request.Subject, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (epssData is null)
|
||||
return 0d; // Unknown = no additional risk signal
|
||||
@@ -35,6 +38,21 @@ public sealed class EpssProvider : IRiskScoreProvider
|
||||
|
||||
return Math.Round(score, 6, MidpointRounding.ToEven);
|
||||
}
|
||||
|
||||
private static double? TryGetSignalScore(ScoreRequest request)
|
||||
{
|
||||
if (request.Signals.TryGetValue("EpssScore", out var epssScore))
|
||||
{
|
||||
return epssScore;
|
||||
}
|
||||
|
||||
if (request.Signals.TryGetValue("Epss", out var epss))
|
||||
{
|
||||
return epss;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -82,16 +100,28 @@ public sealed class CvssKevEpssProvider : IRiskScoreProvider
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Fetch all signals in parallel
|
||||
// Fetch all signals in parallel; explicit request signals take precedence.
|
||||
var cvssTask = cvss.GetCvssAsync(request.Subject, cancellationToken);
|
||||
var kevTask = kev.IsKevAsync(request.Subject, cancellationToken);
|
||||
var epssTask = epss.GetEpssAsync(request.Subject, cancellationToken);
|
||||
|
||||
await Task.WhenAll(cvssTask, kevTask, epssTask).ConfigureAwait(false);
|
||||
|
||||
var cvssScore = Math.Clamp(cvssTask.Result ?? 0d, 0d, 10d);
|
||||
var kevFlag = kevTask.Result ?? false;
|
||||
var epssData = epssTask.Result;
|
||||
var cvssScore = request.Signals.TryGetValue("Cvss", out var inlineCvss)
|
||||
? inlineCvss
|
||||
: cvssTask.Result ?? 0d;
|
||||
cvssScore = Math.Clamp(cvssScore, 0d, 10d);
|
||||
|
||||
var kevFlag = TryGetKevFlag(request, out var inlineKev)
|
||||
? inlineKev
|
||||
: kevTask.Result ?? false;
|
||||
|
||||
var epssScore = request.Signals.TryGetValue("EpssScore", out var inlineEpssScore)
|
||||
? inlineEpssScore
|
||||
: (request.Signals.TryGetValue("Epss", out var inlineEpss) ? inlineEpss : epssTask.Result?.Score);
|
||||
|
||||
var epssPercentile = request.Signals.TryGetValue("EpssPercentile", out var inlinePercentile)
|
||||
? inlinePercentile
|
||||
: epssTask.Result?.Percentile;
|
||||
|
||||
// Base score from CVSS (normalized to 0-1)
|
||||
var baseScore = cvssScore / 10d;
|
||||
@@ -100,10 +130,17 @@ public sealed class CvssKevEpssProvider : IRiskScoreProvider
|
||||
var kevBonusValue = kevFlag ? KevBonus : 0d;
|
||||
|
||||
// EPSS bonus based on percentile thresholds
|
||||
var epssBonusValue = ComputeEpssBonus(epssData?.Percentile);
|
||||
var epssBonusValue = ComputeEpssBonus(epssPercentile);
|
||||
|
||||
// If CVSS+KEV are absent, fall back to raw EPSS score contribution.
|
||||
var epssBase = Math.Clamp(epssScore ?? 0d, 0d, 1d);
|
||||
|
||||
// Combined score
|
||||
var raw = baseScore + kevBonusValue + epssBonusValue;
|
||||
if (baseScore <= 0d && !kevFlag)
|
||||
{
|
||||
raw = epssBase + epssBonusValue;
|
||||
}
|
||||
return Math.Round(Math.Min(1d, raw), 6, MidpointRounding.ToEven);
|
||||
}
|
||||
|
||||
@@ -121,4 +158,22 @@ public sealed class CvssKevEpssProvider : IRiskScoreProvider
|
||||
|
||||
return 0d;
|
||||
}
|
||||
|
||||
private static bool TryGetKevFlag(ScoreRequest request, out bool kevFlag)
|
||||
{
|
||||
if (request.Signals.TryGetValue("Kev", out var kev))
|
||||
{
|
||||
kevFlag = kev >= 1d;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.Signals.TryGetValue("IsKev", out var isKev))
|
||||
{
|
||||
kevFlag = isKev >= 1d;
|
||||
return true;
|
||||
}
|
||||
|
||||
kevFlag = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ public sealed class ExploitMaturityApiTests : IClassFixture<WebApplicationFactor
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
// Count occurrences - should have single result
|
||||
var occurrences = content.Split("CVE-2024-1234").Length - 1;
|
||||
occurrences.Should().BeGreaterOrEqualTo(1);
|
||||
occurrences.Should().BeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -233,13 +233,13 @@ public sealed class ExploitMaturityServiceTests
|
||||
#region Error Handling
|
||||
|
||||
[Fact]
|
||||
public async Task NullCveId_ThrowsArgumentException()
|
||||
public async Task NullCveId_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new ExploitMaturityService(new TestEpssSource(), new TestKevSource(), null, null, _timeProvider);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => sut.AssessMaturityAsync(null!));
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sut.AssessMaturityAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -30,6 +30,9 @@ public class RiskEngineApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
var payload = await response.Content.ReadFromJsonAsync<ProvidersResponse>(cancellationToken: ct);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Contains(DefaultTransformsProvider.ProviderName, payload!.Providers);
|
||||
Assert.Contains(CvssKevProvider.ProviderName, payload.Providers);
|
||||
Assert.Contains(EpssProvider.ProviderName, payload.Providers);
|
||||
Assert.Contains(CvssKevEpssProvider.ProviderName, payload.Providers);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -116,6 +119,86 @@ public class RiskEngineApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
third => Assert.Equal("asset-low", third.Subject));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Simulations_CvssKev_UsesInlineSignals()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var ct = CancellationToken.None;
|
||||
|
||||
var requests = new[]
|
||||
{
|
||||
new ScoreRequest(CvssKevProvider.ProviderName, "CVE-LOCAL-1001", new Dictionary<string, double>
|
||||
{
|
||||
["Cvss"] = 7.5,
|
||||
["Kev"] = 1
|
||||
})
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/risk-scores/simulations", requests, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SimulationResponse>(cancellationToken: ct);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Single(payload!.Results);
|
||||
Assert.True(payload.Results[0].Success);
|
||||
Assert.Equal(0.95d, payload.Results[0].Score);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Simulations_Epss_UsesInlineSignals()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var ct = CancellationToken.None;
|
||||
|
||||
var requests = new[]
|
||||
{
|
||||
new ScoreRequest(EpssProvider.ProviderName, "CVE-LOCAL-1002", new Dictionary<string, double>
|
||||
{
|
||||
["EpssScore"] = 0.77,
|
||||
["EpssPercentile"] = 0.93
|
||||
})
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/risk-scores/simulations", requests, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SimulationResponse>(cancellationToken: ct);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Single(payload!.Results);
|
||||
Assert.True(payload.Results[0].Success);
|
||||
Assert.Equal(0.77d, payload.Results[0].Score);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Simulations_CvssKevEpss_UsesInlineSignals()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var ct = CancellationToken.None;
|
||||
|
||||
var requests = new[]
|
||||
{
|
||||
new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-LOCAL-1003", new Dictionary<string, double>
|
||||
{
|
||||
["Cvss"] = 5.0,
|
||||
["Kev"] = 0,
|
||||
["EpssScore"] = 0.35,
|
||||
["EpssPercentile"] = 0.92
|
||||
})
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/risk-scores/simulations", requests, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SimulationResponse>(cancellationToken: ct);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Single(payload!.Results);
|
||||
Assert.True(payload.Results[0].Success);
|
||||
Assert.Equal(0.55d, payload.Results[0].Score);
|
||||
}
|
||||
|
||||
private sealed record ProvidersResponse(IReadOnlyList<string> Providers);
|
||||
private sealed record JobAccepted(Guid JobId, RiskScoreResult Result);
|
||||
private sealed record SimulationResponse(IReadOnlyList<RiskScoreResult> Results);
|
||||
|
||||
@@ -139,6 +139,28 @@ public class RiskScoreWorkerTests
|
||||
Assert.Equal(1.0d, result.Score); // 0.98 + 0.2 capped at 1.0
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CvssKevProvider_UsesInlineSignalsWhenProvided()
|
||||
{
|
||||
var provider = new CvssKevProvider(new FakeCvssSource(new Dictionary<string, double>()), new FakeKevSource(new Dictionary<string, bool>()));
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest(CvssKevProvider.ProviderName, "CVE-LOCAL-0001", new Dictionary<string, double>
|
||||
{
|
||||
["Cvss"] = 7.5,
|
||||
["Kev"] = 1
|
||||
});
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(0.95d, result.Score); // (7.5/10) + 0.2
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CvssKevProviderHandlesMissingCvss()
|
||||
@@ -296,6 +318,28 @@ public class RiskScoreWorkerTests
|
||||
Assert.Equal(0.75d, result.Score);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EpssProvider_UsesInlineSignalsWhenProvided()
|
||||
{
|
||||
var provider = new EpssProvider(new FakeEpssSource(new Dictionary<string, EpssData>()));
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest(EpssProvider.ProviderName, "CVE-LOCAL-0002", new Dictionary<string, double>
|
||||
{
|
||||
["EpssScore"] = 0.77,
|
||||
["EpssPercentile"] = 0.93
|
||||
});
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(0.77d, result.Score);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EpssProviderReturnsZeroForUnknown()
|
||||
@@ -376,6 +420,33 @@ public class RiskScoreWorkerTests
|
||||
Assert.Equal(0.55d, result.Score);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CvssKevEpssProvider_UsesInlineSignalsWhenProvided()
|
||||
{
|
||||
var provider = new CvssKevEpssProvider(
|
||||
new FakeCvssSource(new Dictionary<string, double>()),
|
||||
new FakeKevSource(new Dictionary<string, bool>()),
|
||||
new FakeEpssSource(new Dictionary<string, EpssData>()));
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-LOCAL-0003", new Dictionary<string, double>
|
||||
{
|
||||
["Cvss"] = 5.0,
|
||||
["Kev"] = 0,
|
||||
["EpssScore"] = 0.35,
|
||||
["EpssPercentile"] = 0.92
|
||||
});
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(0.55d, result.Score); // 0.5 + 0 + 0.05
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CvssKevEpssProviderApplies50thPercentileBonus()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user