Add unit tests for PackRunAttestation and SealedInstallEnforcer
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
release-manifest-verify / verify (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
release-manifest-verify / verify (push) Has been cancelled
- Implement comprehensive tests for PackRunAttestationService, covering attestation generation, verification, and event emission. - Add tests for SealedInstallEnforcer to validate sealed install requirements and enforcement logic. - Introduce a MonacoLoaderService stub for testing purposes to prevent Monaco workers/styles from loading during Karma runs.
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Authority.Storage.Postgres;
|
||||
using StellaOps.Concelier.Storage.Postgres;
|
||||
using StellaOps.Excititor.Storage.Postgres;
|
||||
using StellaOps.Notify.Storage.Postgres;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
@@ -35,11 +34,6 @@ public static class MigrationModuleRegistry
|
||||
SchemaName: "scheduler",
|
||||
MigrationsAssembly: typeof(SchedulerDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Scheduler.Storage.Postgres.Migrations"),
|
||||
new(
|
||||
Name: "Concelier",
|
||||
SchemaName: "vuln",
|
||||
MigrationsAssembly: typeof(ConcelierDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Concelier.Storage.Postgres.Migrations"),
|
||||
new(
|
||||
Name: "Policy",
|
||||
SchemaName: "policy",
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Scheduler/__Libraries/StellaOps.Scheduler.Storage.Postgres/StellaOps.Scheduler.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Storage.Postgres/StellaOps.Concelier.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
|
||||
|
||||
@@ -10,14 +10,13 @@ public class MigrationModuleRegistryTests
|
||||
public void Modules_Populated_With_All_Postgres_Modules()
|
||||
{
|
||||
var modules = MigrationModuleRegistry.Modules;
|
||||
Assert.Equal(6, modules.Count);
|
||||
Assert.Equal(5, modules.Count);
|
||||
Assert.Contains(modules, m => m.Name == "Authority" && m.SchemaName == "authority");
|
||||
Assert.Contains(modules, m => m.Name == "Scheduler" && m.SchemaName == "scheduler");
|
||||
Assert.Contains(modules, m => m.Name == "Concelier" && m.SchemaName == "vuln");
|
||||
Assert.Contains(modules, m => m.Name == "Policy" && m.SchemaName == "policy");
|
||||
Assert.Contains(modules, m => m.Name == "Notify" && m.SchemaName == "notify");
|
||||
Assert.Contains(modules, m => m.Name == "Excititor" && m.SchemaName == "vex");
|
||||
Assert.Equal(6, MigrationModuleRegistry.ModuleNames.Count());
|
||||
Assert.Equal(5, MigrationModuleRegistry.ModuleNames.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -25,7 +25,6 @@ public class SystemCommandBuilderTests
|
||||
{
|
||||
Assert.Contains("Authority", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Scheduler", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Concelier", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Policy", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Notify", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Excititor", MigrationModuleRegistry.ModuleNames);
|
||||
|
||||
@@ -14,9 +14,15 @@
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Exclude legacy Mongo-based import/conversion helpers until Postgres-native pipeline is ready -->
|
||||
<Compile Remove="Converters\**\*.cs" />
|
||||
<Compile Remove="Conversion\**\*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.RiskFeed;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Risk feed API endpoints (EXCITITOR-RISK-66-001).
|
||||
/// Publishes risk-engine ready feeds with status, justification, and provenance
|
||||
/// without derived severity (aggregation-only per AOC baseline).
|
||||
/// </summary>
|
||||
public static class RiskFeedEndpoints
|
||||
{
|
||||
public static void MapRiskFeedEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/risk/v1");
|
||||
|
||||
// POST /risk/v1/feed - Generate risk feed
|
||||
group.MapPost("/feed", async (
|
||||
HttpContext context,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IRiskFeedService riskFeedService,
|
||||
[FromBody] RiskFeedRequestDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_RISK_PARAMS", message = "Request body is required" }
|
||||
});
|
||||
}
|
||||
|
||||
var domainRequest = new RiskFeedRequest(
|
||||
tenantId: tenant,
|
||||
advisoryKeys: request.AdvisoryKeys,
|
||||
artifacts: request.Artifacts,
|
||||
since: request.Since,
|
||||
limit: request.Limit ?? 1000);
|
||||
|
||||
var feedResponse = await riskFeedService
|
||||
.GenerateFeedAsync(domainRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var responseDto = MapToResponse(feedResponse);
|
||||
return Results.Ok(responseDto);
|
||||
}).WithName("GenerateRiskFeed");
|
||||
|
||||
// GET /risk/v1/feed/item - Get single risk feed item
|
||||
group.MapGet("/feed/item", async (
|
||||
HttpContext context,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IRiskFeedService riskFeedService,
|
||||
[FromQuery] string? advisoryKey,
|
||||
[FromQuery] string? artifact,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey) || string.IsNullOrWhiteSpace(artifact))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_RISK_PARAMS", message = "advisoryKey and artifact query parameters are required" }
|
||||
});
|
||||
}
|
||||
|
||||
var item = await riskFeedService
|
||||
.GetItemAsync(tenant, advisoryKey, artifact, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
error = new { code = "ERR_RISK_NOT_FOUND", message = "No risk feed item found for the specified advisory and artifact" }
|
||||
});
|
||||
}
|
||||
|
||||
var dto = MapToItemDto(item);
|
||||
return Results.Ok(dto);
|
||||
}).WithName("GetRiskFeedItem");
|
||||
|
||||
// GET /risk/v1/feed/by-advisory - Get risk feed items by advisory key
|
||||
group.MapGet("/feed/by-advisory/{advisoryKey}", async (
|
||||
HttpContext context,
|
||||
string advisoryKey,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IRiskFeedService riskFeedService,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_RISK_PARAMS", message = "advisoryKey is required" }
|
||||
});
|
||||
}
|
||||
|
||||
var request = new RiskFeedRequest(
|
||||
tenantId: tenant,
|
||||
advisoryKeys: [advisoryKey],
|
||||
limit: limit ?? 100);
|
||||
|
||||
var feedResponse = await riskFeedService
|
||||
.GenerateFeedAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var responseDto = MapToResponse(feedResponse);
|
||||
return Results.Ok(responseDto);
|
||||
}).WithName("GetRiskFeedByAdvisory");
|
||||
|
||||
// GET /risk/v1/feed/by-artifact/{artifact} - Get risk feed items by artifact
|
||||
group.MapGet("/feed/by-artifact/{**artifact}", async (
|
||||
HttpContext context,
|
||||
string artifact,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IRiskFeedService riskFeedService,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifact))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_RISK_PARAMS", message = "artifact is required" }
|
||||
});
|
||||
}
|
||||
|
||||
var request = new RiskFeedRequest(
|
||||
tenantId: tenant,
|
||||
artifacts: [artifact],
|
||||
limit: limit ?? 100);
|
||||
|
||||
var feedResponse = await riskFeedService
|
||||
.GenerateFeedAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var responseDto = MapToResponse(feedResponse);
|
||||
return Results.Ok(responseDto);
|
||||
}).WithName("GetRiskFeedByArtifact");
|
||||
}
|
||||
|
||||
private static RiskFeedResponseDto MapToResponse(RiskFeedResponse response)
|
||||
{
|
||||
var items = response.Items
|
||||
.Select(MapToItemDto)
|
||||
.ToList();
|
||||
|
||||
return new RiskFeedResponseDto(
|
||||
Items: items,
|
||||
GeneratedAt: response.GeneratedAt,
|
||||
NextPageToken: response.NextPageToken);
|
||||
}
|
||||
|
||||
private static RiskFeedItemDto MapToItemDto(RiskFeedItem item)
|
||||
{
|
||||
var provenance = new RiskFeedProvenanceDto(
|
||||
TenantId: item.Provenance.TenantId,
|
||||
LinksetId: item.Provenance.LinksetId,
|
||||
ContentHash: item.Provenance.ContentHash,
|
||||
Confidence: item.Provenance.Confidence.ToString().ToLowerInvariant(),
|
||||
HasConflicts: item.Provenance.HasConflicts,
|
||||
GeneratedAt: item.Provenance.GeneratedAt,
|
||||
AttestationId: item.Provenance.AttestationId);
|
||||
|
||||
var sources = item.Sources
|
||||
.Select(s => new RiskFeedSourceDto(
|
||||
ObservationId: s.ObservationId,
|
||||
ProviderId: s.ProviderId,
|
||||
Status: s.Status,
|
||||
Justification: s.Justification,
|
||||
Confidence: s.Confidence))
|
||||
.ToList();
|
||||
|
||||
return new RiskFeedItemDto(
|
||||
AdvisoryKey: item.AdvisoryKey,
|
||||
Artifact: item.Artifact,
|
||||
Status: item.Status.ToString().ToLowerInvariant(),
|
||||
Justification: item.Justification?.ToString().ToLowerInvariant(),
|
||||
Provenance: provenance,
|
||||
ObservedAt: item.ObservedAt,
|
||||
Sources: sources);
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(
|
||||
HttpContext context,
|
||||
VexMongoStorageOptions options,
|
||||
out string tenant,
|
||||
out IResult? problem)
|
||||
{
|
||||
problem = null;
|
||||
tenant = string.Empty;
|
||||
|
||||
var headerTenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant))
|
||||
{
|
||||
tenant = headerTenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(options.DefaultTenant))
|
||||
{
|
||||
tenant = options.DefaultTenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
else
|
||||
{
|
||||
problem = Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header is required" }
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTO
|
||||
public sealed record RiskFeedRequestDto(
|
||||
[property: JsonPropertyName("advisoryKeys")] IEnumerable<string>? AdvisoryKeys,
|
||||
[property: JsonPropertyName("artifacts")] IEnumerable<string>? Artifacts,
|
||||
[property: JsonPropertyName("since")] DateTimeOffset? Since,
|
||||
[property: JsonPropertyName("limit")] int? Limit);
|
||||
|
||||
// Response DTOs
|
||||
public sealed record RiskFeedResponseDto(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<RiskFeedItemDto> Items,
|
||||
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
|
||||
[property: JsonPropertyName("nextPageToken")] string? NextPageToken);
|
||||
|
||||
public sealed record RiskFeedItemDto(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("artifact")] string Artifact,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("justification")] string? Justification,
|
||||
[property: JsonPropertyName("provenance")] RiskFeedProvenanceDto Provenance,
|
||||
[property: JsonPropertyName("observedAt")] DateTimeOffset ObservedAt,
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<RiskFeedSourceDto> Sources);
|
||||
|
||||
public sealed record RiskFeedProvenanceDto(
|
||||
[property: JsonPropertyName("tenantId")] string TenantId,
|
||||
[property: JsonPropertyName("linksetId")] string LinksetId,
|
||||
[property: JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: JsonPropertyName("confidence")] string Confidence,
|
||||
[property: JsonPropertyName("hasConflicts")] bool HasConflicts,
|
||||
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
|
||||
[property: JsonPropertyName("attestationId")] string? AttestationId);
|
||||
|
||||
public sealed record RiskFeedSourceDto(
|
||||
[property: JsonPropertyName("observationId")] string ObservationId,
|
||||
[property: JsonPropertyName("providerId")] string ProviderId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("justification")] string? Justification,
|
||||
[property: JsonPropertyName("confidence")] double? Confidence);
|
||||
@@ -90,6 +90,9 @@ services.AddSingleton<IVexHashingService>(sp =>
|
||||
services.AddSingleton<IVexObservationProjectionService, VexObservationProjectionService>();
|
||||
services.AddScoped<IVexObservationQueryService, VexObservationQueryService>();
|
||||
|
||||
// EXCITITOR-RISK-66-001: Risk feed service for Risk Engine integration
|
||||
services.AddScoped<StellaOps.Excititor.Core.RiskFeed.IRiskFeedService, StellaOps.Excititor.Core.RiskFeed.RiskFeedService>();
|
||||
|
||||
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
|
||||
if (rekorSection.Exists())
|
||||
{
|
||||
@@ -2323,6 +2326,9 @@ PolicyEndpoints.MapPolicyEndpoints(app);
|
||||
ObservationEndpoints.MapObservationEndpoints(app);
|
||||
LinksetEndpoints.MapLinksetEndpoints(app);
|
||||
|
||||
// Risk Feed APIs (EXCITITOR-RISK-66-001)
|
||||
RiskFeedEndpoints.MapRiskFeedEndpoints(app);
|
||||
|
||||
app.Run();
|
||||
|
||||
internal sealed record ExcititorTimelineEvent(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace StellaOps.Excititor.Core.RiskFeed;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating risk-engine ready feeds from VEX linksets.
|
||||
/// Produces status/justification/provenance without derived severity (aggregation-only).
|
||||
/// </summary>
|
||||
public interface IRiskFeedService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates risk feed items from linksets matching the request criteria.
|
||||
/// </summary>
|
||||
/// <param name="request">Filter criteria for the feed.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Risk feed response with items and pagination info.</returns>
|
||||
Task<RiskFeedResponse> GenerateFeedAsync(RiskFeedRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single risk feed item for a specific advisory/artifact pair.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="advisoryKey">Advisory/CVE identifier.</param>
|
||||
/// <param name="artifact">Package URL or product key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Risk feed item if found.</returns>
|
||||
Task<RiskFeedItem?> GetItemAsync(
|
||||
string tenantId,
|
||||
string advisoryKey,
|
||||
string artifact,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of <see cref="IRiskFeedService"/> for testing and fallback.
|
||||
/// </summary>
|
||||
public sealed class NullRiskFeedService : IRiskFeedService
|
||||
{
|
||||
public static readonly NullRiskFeedService Instance = new();
|
||||
|
||||
public Task<RiskFeedResponse> GenerateFeedAsync(RiskFeedRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new RiskFeedResponse(
|
||||
Enumerable.Empty<RiskFeedItem>(),
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<RiskFeedItem?> GetItemAsync(
|
||||
string tenantId,
|
||||
string advisoryKey,
|
||||
string artifact,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<RiskFeedItem?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
|
||||
namespace StellaOps.Excititor.Core.RiskFeed;
|
||||
|
||||
/// <summary>
|
||||
/// Generates risk-engine ready feeds from VEX linksets.
|
||||
/// Produces status/justification/provenance without derived severity (aggregation-only per AOC baseline).
|
||||
/// </summary>
|
||||
public sealed class RiskFeedService : IRiskFeedService
|
||||
{
|
||||
private readonly IVexLinksetStore _linksetStore;
|
||||
|
||||
public RiskFeedService(IVexLinksetStore linksetStore)
|
||||
{
|
||||
_linksetStore = linksetStore ?? throw new ArgumentNullException(nameof(linksetStore));
|
||||
}
|
||||
|
||||
public async Task<RiskFeedResponse> GenerateFeedAsync(
|
||||
RiskFeedRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var feedItems = new List<RiskFeedItem>();
|
||||
var generatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// If specific advisory keys are requested, query by vulnerability
|
||||
if (!request.AdvisoryKeys.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var advisoryKey in request.AdvisoryKeys)
|
||||
{
|
||||
if (feedItems.Count >= request.Limit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var linksets = await _linksetStore.FindByVulnerabilityAsync(
|
||||
request.TenantId,
|
||||
advisoryKey,
|
||||
request.Limit - feedItems.Count,
|
||||
cancellationToken);
|
||||
|
||||
var items = linksets
|
||||
.Where(ls => PassesSinceFilter(ls, request.Since))
|
||||
.Select(ls => BuildFeedItem(ls, generatedAt))
|
||||
.Where(item => item is not null)
|
||||
.Cast<RiskFeedItem>();
|
||||
|
||||
feedItems.AddRange(items);
|
||||
}
|
||||
}
|
||||
// If specific artifacts are requested, query by product key
|
||||
else if (!request.Artifacts.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var artifact in request.Artifacts)
|
||||
{
|
||||
if (feedItems.Count >= request.Limit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var linksets = await _linksetStore.FindByProductKeyAsync(
|
||||
request.TenantId,
|
||||
artifact,
|
||||
request.Limit - feedItems.Count,
|
||||
cancellationToken);
|
||||
|
||||
var items = linksets
|
||||
.Where(ls => PassesSinceFilter(ls, request.Since))
|
||||
.Select(ls => BuildFeedItem(ls, generatedAt))
|
||||
.Where(item => item is not null)
|
||||
.Cast<RiskFeedItem>();
|
||||
|
||||
feedItems.AddRange(items);
|
||||
}
|
||||
}
|
||||
// Otherwise query linksets with conflicts (high-value for risk assessment)
|
||||
else
|
||||
{
|
||||
var linksets = await _linksetStore.FindWithConflictsAsync(
|
||||
request.TenantId,
|
||||
request.Limit,
|
||||
cancellationToken);
|
||||
|
||||
var items = linksets
|
||||
.Where(ls => PassesSinceFilter(ls, request.Since))
|
||||
.Select(ls => BuildFeedItem(ls, generatedAt))
|
||||
.Where(item => item is not null)
|
||||
.Cast<RiskFeedItem>();
|
||||
|
||||
feedItems.AddRange(items);
|
||||
}
|
||||
|
||||
// Sort for deterministic output
|
||||
var sortedItems = feedItems
|
||||
.OrderBy(item => item.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(item => item.Artifact, StringComparer.Ordinal)
|
||||
.Take(request.Limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new RiskFeedResponse(sortedItems, generatedAt);
|
||||
}
|
||||
|
||||
public async Task<RiskFeedItem?> GetItemAsync(
|
||||
string tenantId,
|
||||
string advisoryKey,
|
||||
string artifact,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new ArgumentException("Tenant ID must be provided.", nameof(tenantId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey))
|
||||
{
|
||||
throw new ArgumentException("Advisory key must be provided.", nameof(advisoryKey));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifact))
|
||||
{
|
||||
throw new ArgumentException("Artifact must be provided.", nameof(artifact));
|
||||
}
|
||||
|
||||
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
|
||||
var linksetId = VexLinkset.CreateLinksetId(normalizedTenant, advisoryKey.Trim(), artifact.Trim());
|
||||
|
||||
var linkset = await _linksetStore.GetByIdAsync(
|
||||
normalizedTenant,
|
||||
linksetId,
|
||||
cancellationToken);
|
||||
|
||||
if (linkset is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildFeedItem(linkset, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static bool PassesSinceFilter(VexLinkset linkset, DateTimeOffset? since)
|
||||
{
|
||||
if (!since.HasValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return linkset.UpdatedAt >= since.Value;
|
||||
}
|
||||
|
||||
private RiskFeedItem? BuildFeedItem(VexLinkset linkset, DateTimeOffset generatedAt)
|
||||
{
|
||||
if (linkset.Observations.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the dominant status from observations (most common status)
|
||||
var statusGroups = linkset.Observations
|
||||
.GroupBy(obs => obs.Status, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (statusGroups.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dominantStatusStr = statusGroups[0].Key;
|
||||
if (!TryParseStatus(dominantStatusStr, out var status))
|
||||
{
|
||||
// Unknown status - skip this linkset
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to get justification from disagreements or observation references
|
||||
VexJustification? justification = null;
|
||||
foreach (var disagreement in linkset.Disagreements)
|
||||
{
|
||||
if (TryParseJustification(disagreement.Justification, out var parsed))
|
||||
{
|
||||
justification = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build provenance
|
||||
var contentHash = ComputeContentHash(linkset);
|
||||
var provenance = new RiskFeedProvenance(
|
||||
tenantId: linkset.Tenant,
|
||||
linksetId: linkset.LinksetId,
|
||||
contentHash: contentHash,
|
||||
confidence: linkset.Confidence,
|
||||
hasConflicts: linkset.HasConflicts,
|
||||
generatedAt: generatedAt);
|
||||
|
||||
// Build source references
|
||||
var sources = linkset.Observations
|
||||
.Select(obs => new RiskFeedObservationSource(
|
||||
observationId: obs.ObservationId,
|
||||
providerId: obs.ProviderId,
|
||||
status: obs.Status,
|
||||
justification: null,
|
||||
confidence: obs.Confidence))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new RiskFeedItem(
|
||||
advisoryKey: linkset.VulnerabilityId,
|
||||
artifact: linkset.ProductKey,
|
||||
status: status,
|
||||
justification: justification,
|
||||
provenance: provenance,
|
||||
observedAt: linkset.UpdatedAt,
|
||||
sources: sources);
|
||||
}
|
||||
|
||||
private static string ComputeContentHash(VexLinkset linkset)
|
||||
{
|
||||
var canonical = new
|
||||
{
|
||||
linkset.LinksetId,
|
||||
linkset.Tenant,
|
||||
linkset.VulnerabilityId,
|
||||
linkset.ProductKey,
|
||||
Observations = linkset.Observations
|
||||
.Select(o => new { o.ObservationId, o.ProviderId, o.Status, o.Confidence })
|
||||
.OrderBy(o => o.ProviderId)
|
||||
.ThenBy(o => o.ObservationId)
|
||||
.ToArray(),
|
||||
linkset.UpdatedAt
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(canonical, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static bool TryParseStatus(string? statusStr, out VexClaimStatus status)
|
||||
{
|
||||
status = VexClaimStatus.Affected;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(statusStr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = statusStr.Trim().ToLowerInvariant().Replace("_", "");
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"affected" => AssignStatus(VexClaimStatus.Affected, out status),
|
||||
"notaffected" => AssignStatus(VexClaimStatus.NotAffected, out status),
|
||||
"fixed" => AssignStatus(VexClaimStatus.Fixed, out status),
|
||||
"underinvestigation" => AssignStatus(VexClaimStatus.UnderInvestigation, out status),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool AssignStatus(VexClaimStatus value, out VexClaimStatus status)
|
||||
{
|
||||
status = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseJustification(string? justificationStr, out VexJustification justification)
|
||||
{
|
||||
justification = VexJustification.ComponentNotPresent;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(justificationStr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = justificationStr.Trim().ToLowerInvariant().Replace("_", "");
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"componentnotpresent" => AssignJustification(VexJustification.ComponentNotPresent, out justification),
|
||||
"componentnotconfigured" => AssignJustification(VexJustification.ComponentNotConfigured, out justification),
|
||||
"vulnerablecodenotpresent" => AssignJustification(VexJustification.VulnerableCodeNotPresent, out justification),
|
||||
"vulnerablecodenotinexecutepath" => AssignJustification(VexJustification.VulnerableCodeNotInExecutePath, out justification),
|
||||
"vulnerablecodecannotbecontrolledbyadversary" => AssignJustification(VexJustification.VulnerableCodeCannotBeControlledByAdversary, out justification),
|
||||
"inlinemitigationsalreadyexist" => AssignJustification(VexJustification.InlineMitigationsAlreadyExist, out justification),
|
||||
"protectedbymitigatingcontrol" => AssignJustification(VexJustification.ProtectedByMitigatingControl, out justification),
|
||||
"codenotpresent" => AssignJustification(VexJustification.CodeNotPresent, out justification),
|
||||
"codenotreachable" => AssignJustification(VexJustification.CodeNotReachable, out justification),
|
||||
"requiresconfiguration" => AssignJustification(VexJustification.RequiresConfiguration, out justification),
|
||||
"requiresdependency" => AssignJustification(VexJustification.RequiresDependency, out justification),
|
||||
"requiresenvironment" => AssignJustification(VexJustification.RequiresEnvironment, out justification),
|
||||
"protectedbycompensatingcontrol" => AssignJustification(VexJustification.ProtectedByCompensatingControl, out justification),
|
||||
"protectedatperimeter" => AssignJustification(VexJustification.ProtectedAtPerimeter, out justification),
|
||||
"protectedatruntime" => AssignJustification(VexJustification.ProtectedAtRuntime, out justification),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool AssignJustification(VexJustification value, out VexJustification justification)
|
||||
{
|
||||
justification = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Severity level.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<Severity>))]
|
||||
public enum Severity
|
||||
{
|
||||
[JsonPropertyName("critical")]
|
||||
Critical,
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
High,
|
||||
|
||||
[JsonPropertyName("medium")]
|
||||
Medium,
|
||||
|
||||
[JsonPropertyName("low")]
|
||||
Low,
|
||||
|
||||
[JsonPropertyName("info")]
|
||||
Info
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFC 7807 Problem Details for HTTP APIs.
|
||||
/// </summary>
|
||||
public sealed record ProblemDetails
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public required string Title { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required int Status { get; init; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
|
||||
[JsonPropertyName("instance")]
|
||||
public string? Instance { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<ValidationError>? Errors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation error.
|
||||
/// </summary>
|
||||
public sealed record ValidationError
|
||||
{
|
||||
[JsonPropertyName("field")]
|
||||
public string? Field { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common pagination parameters.
|
||||
/// </summary>
|
||||
public sealed record PaginationParams
|
||||
{
|
||||
public int PageSize { get; init; } = 20;
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Policy override.
|
||||
/// </summary>
|
||||
public sealed record Override
|
||||
{
|
||||
[JsonPropertyName("override_id")]
|
||||
public required Guid OverrideId { get; init; }
|
||||
|
||||
[JsonPropertyName("profile_id")]
|
||||
public Guid? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("rule_id")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required OverrideStatus Status { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public OverrideScope? Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("approved_by")]
|
||||
public string? ApprovedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("approved_at")]
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("created_by")]
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override status.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<OverrideStatus>))]
|
||||
public enum OverrideStatus
|
||||
{
|
||||
[JsonPropertyName("pending")]
|
||||
Pending,
|
||||
|
||||
[JsonPropertyName("approved")]
|
||||
Approved,
|
||||
|
||||
[JsonPropertyName("disabled")]
|
||||
Disabled,
|
||||
|
||||
[JsonPropertyName("expired")]
|
||||
Expired
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override scope.
|
||||
/// </summary>
|
||||
public sealed record OverrideScope
|
||||
{
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("cve_id")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
[JsonPropertyName("component")]
|
||||
public string? Component { get; init; }
|
||||
|
||||
[JsonPropertyName("environment")]
|
||||
public string? Environment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an override.
|
||||
/// </summary>
|
||||
public sealed record CreateOverrideRequest
|
||||
{
|
||||
[JsonPropertyName("profile_id")]
|
||||
public Guid? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("rule_id")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public OverrideScope? Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to approve an override.
|
||||
/// </summary>
|
||||
public sealed record ApproveOverrideRequest
|
||||
{
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack workspace entity.
|
||||
/// </summary>
|
||||
public sealed record PolicyPack
|
||||
{
|
||||
[JsonPropertyName("pack_id")]
|
||||
public required Guid PackId { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required PolicyPackStatus Status { get; init; }
|
||||
|
||||
[JsonPropertyName("rules")]
|
||||
public IReadOnlyList<PolicyRule>? Rules { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updated_at")]
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("published_at")]
|
||||
public DateTimeOffset? PublishedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack status.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PolicyPackStatus>))]
|
||||
public enum PolicyPackStatus
|
||||
{
|
||||
[JsonPropertyName("draft")]
|
||||
Draft,
|
||||
|
||||
[JsonPropertyName("pending_review")]
|
||||
PendingReview,
|
||||
|
||||
[JsonPropertyName("published")]
|
||||
Published,
|
||||
|
||||
[JsonPropertyName("archived")]
|
||||
Archived
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual policy rule within a pack.
|
||||
/// </summary>
|
||||
public sealed record PolicyRule
|
||||
{
|
||||
[JsonPropertyName("rule_id")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required Severity Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("rego")]
|
||||
public string? Rego { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a policy pack.
|
||||
/// </summary>
|
||||
public sealed record CreatePolicyPackRequest
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("rules")]
|
||||
public IReadOnlyList<PolicyRule>? Rules { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update a policy pack.
|
||||
/// </summary>
|
||||
public sealed record UpdatePolicyPackRequest
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("rules")]
|
||||
public IReadOnlyList<PolicyRule>? Rules { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated list of policy packs.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackList
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public required IReadOnlyList<PolicyPack> Items { get; init; }
|
||||
|
||||
[JsonPropertyName("next_page_token")]
|
||||
public string? NextPageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compilation result for a policy pack.
|
||||
/// </summary>
|
||||
public sealed record CompilationResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public required bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<CompilationError>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<CompilationWarning>? Warnings { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compilation error.
|
||||
/// </summary>
|
||||
public sealed record CompilationError
|
||||
{
|
||||
[JsonPropertyName("rule_id")]
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
[JsonPropertyName("column")]
|
||||
public int? Column { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compilation warning.
|
||||
/// </summary>
|
||||
public sealed record CompilationWarning
|
||||
{
|
||||
[JsonPropertyName("rule_id")]
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to simulate a policy pack.
|
||||
/// </summary>
|
||||
public sealed record SimulationRequest
|
||||
{
|
||||
[JsonPropertyName("input")]
|
||||
public required IReadOnlyDictionary<string, object> Input { get; init; }
|
||||
|
||||
[JsonPropertyName("options")]
|
||||
public SimulationOptions? Options { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulation options.
|
||||
/// </summary>
|
||||
public sealed record SimulationOptions
|
||||
{
|
||||
[JsonPropertyName("trace")]
|
||||
public bool Trace { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public bool Explain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulation result.
|
||||
/// </summary>
|
||||
public sealed record SimulationResult
|
||||
{
|
||||
[JsonPropertyName("result")]
|
||||
public required IReadOnlyDictionary<string, object> Result { get; init; }
|
||||
|
||||
[JsonPropertyName("violations")]
|
||||
public IReadOnlyList<SimulatedViolation>? Violations { get; init; }
|
||||
|
||||
[JsonPropertyName("trace")]
|
||||
public IReadOnlyList<string>? Trace { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public PolicyExplainTrace? Explain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulated violation.
|
||||
/// </summary>
|
||||
public sealed record SimulatedViolation
|
||||
{
|
||||
[JsonPropertyName("rule_id")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required string Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
[JsonPropertyName("context")]
|
||||
public IReadOnlyDictionary<string, object>? Context { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy explain trace.
|
||||
/// </summary>
|
||||
public sealed record PolicyExplainTrace
|
||||
{
|
||||
[JsonPropertyName("steps")]
|
||||
public IReadOnlyList<object>? Steps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to publish a policy pack.
|
||||
/// </summary>
|
||||
public sealed record PublishRequest
|
||||
{
|
||||
[JsonPropertyName("approval_id")]
|
||||
public string? ApprovalId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to promote a policy pack.
|
||||
/// </summary>
|
||||
public sealed record PromoteRequest
|
||||
{
|
||||
[JsonPropertyName("target_environment")]
|
||||
public TargetEnvironment? TargetEnvironment { get; init; }
|
||||
|
||||
[JsonPropertyName("approval_id")]
|
||||
public string? ApprovalId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target environment for promotion.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<TargetEnvironment>))]
|
||||
public enum TargetEnvironment
|
||||
{
|
||||
[JsonPropertyName("staging")]
|
||||
Staging,
|
||||
|
||||
[JsonPropertyName("production")]
|
||||
Production
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Sealed mode status (air-gap operation).
|
||||
/// </summary>
|
||||
public sealed record SealedModeStatus
|
||||
{
|
||||
[JsonPropertyName("sealed")]
|
||||
public required bool Sealed { get; init; }
|
||||
|
||||
[JsonPropertyName("mode")]
|
||||
public required SealedMode Mode { get; init; }
|
||||
|
||||
[JsonPropertyName("sealed_at")]
|
||||
public DateTimeOffset? SealedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("sealed_by")]
|
||||
public string? SealedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("bundle_version")]
|
||||
public string? BundleVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("last_advisory_update")]
|
||||
public DateTimeOffset? LastAdvisoryUpdate { get; init; }
|
||||
|
||||
[JsonPropertyName("time_anchor")]
|
||||
public TimeAnchor? TimeAnchor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sealed mode state.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<SealedMode>))]
|
||||
public enum SealedMode
|
||||
{
|
||||
[JsonPropertyName("online")]
|
||||
Online,
|
||||
|
||||
[JsonPropertyName("sealed")]
|
||||
Sealed,
|
||||
|
||||
[JsonPropertyName("transitioning")]
|
||||
Transitioning
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time anchor for sealed mode operations.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchor
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to seal the environment.
|
||||
/// </summary>
|
||||
public sealed record SealRequest
|
||||
{
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("time_anchor")]
|
||||
public DateTimeOffset? TimeAnchor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to unseal the environment.
|
||||
/// </summary>
|
||||
public sealed record UnsealRequest
|
||||
{
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("audit_note")]
|
||||
public string? AuditNote { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify an air-gap bundle.
|
||||
/// </summary>
|
||||
public sealed record VerifyBundleRequest
|
||||
{
|
||||
[JsonPropertyName("bundle_digest")]
|
||||
public required string BundleDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("public_key")]
|
||||
public string? PublicKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
public sealed record BundleVerificationResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("bundle_digest")]
|
||||
public string? BundleDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("signed_at")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("signer_fingerprint")]
|
||||
public string? SignerFingerprint { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Policy snapshot.
|
||||
/// </summary>
|
||||
public sealed record Snapshot
|
||||
{
|
||||
[JsonPropertyName("snapshot_id")]
|
||||
public required Guid SnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("pack_ids")]
|
||||
public IReadOnlyList<Guid>? PackIds { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("created_by")]
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotRequest
|
||||
{
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("pack_ids")]
|
||||
public required IReadOnlyList<Guid> PackIds { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated list of snapshots.
|
||||
/// </summary>
|
||||
public sealed record SnapshotList
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public required IReadOnlyList<Snapshot> Items { get; init; }
|
||||
|
||||
[JsonPropertyName("next_page_token")]
|
||||
public string? NextPageToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Overall staleness status.
|
||||
/// </summary>
|
||||
public sealed record StalenessStatus
|
||||
{
|
||||
[JsonPropertyName("overall_status")]
|
||||
public required StalenessLevel OverallStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public required IReadOnlyList<SourceStaleness> Sources { get; init; }
|
||||
|
||||
[JsonPropertyName("last_check")]
|
||||
public DateTimeOffset? LastCheck { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Staleness level.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<StalenessLevel>))]
|
||||
public enum StalenessLevel
|
||||
{
|
||||
[JsonPropertyName("fresh")]
|
||||
Fresh,
|
||||
|
||||
[JsonPropertyName("stale")]
|
||||
Stale,
|
||||
|
||||
[JsonPropertyName("critical")]
|
||||
Critical,
|
||||
|
||||
[JsonPropertyName("unknown")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Staleness status for an individual source.
|
||||
/// </summary>
|
||||
public sealed record SourceStaleness
|
||||
{
|
||||
[JsonPropertyName("source_id")]
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("source_name")]
|
||||
public string? SourceName { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required StalenessLevel Status { get; init; }
|
||||
|
||||
[JsonPropertyName("last_update")]
|
||||
public required DateTimeOffset LastUpdate { get; init; }
|
||||
|
||||
[JsonPropertyName("max_age_hours")]
|
||||
public int? MaxAgeHours { get; init; }
|
||||
|
||||
[JsonPropertyName("age_hours")]
|
||||
public double? AgeHours { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate staleness.
|
||||
/// </summary>
|
||||
public sealed record EvaluateStalenessRequest
|
||||
{
|
||||
[JsonPropertyName("source_id")]
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("threshold_hours")]
|
||||
public int? ThresholdHours { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of staleness evaluation.
|
||||
/// </summary>
|
||||
public sealed record StalenessEvaluation
|
||||
{
|
||||
[JsonPropertyName("source_id")]
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("is_stale")]
|
||||
public required bool IsStale { get; init; }
|
||||
|
||||
[JsonPropertyName("age_hours")]
|
||||
public double? AgeHours { get; init; }
|
||||
|
||||
[JsonPropertyName("threshold_hours")]
|
||||
public int? ThresholdHours { get; init; }
|
||||
|
||||
[JsonPropertyName("recommendation")]
|
||||
public string? Recommendation { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Verification policy for attestation validation.
|
||||
/// Based on OpenAPI: docs/schemas/policy-registry-api.openapi.yaml
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicy
|
||||
{
|
||||
[JsonPropertyName("policy_id")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant_scope")]
|
||||
public required string TenantScope { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate_types")]
|
||||
public required IReadOnlyList<string> PredicateTypes { get; init; }
|
||||
|
||||
[JsonPropertyName("signer_requirements")]
|
||||
public required SignerRequirements SignerRequirements { get; init; }
|
||||
|
||||
[JsonPropertyName("validity_window")]
|
||||
public ValidityWindow? ValidityWindow { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updated_at")]
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requirements for attestation signers.
|
||||
/// </summary>
|
||||
public sealed record SignerRequirements
|
||||
{
|
||||
[JsonPropertyName("minimum_signatures")]
|
||||
public int MinimumSignatures { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("trusted_key_fingerprints")]
|
||||
public required IReadOnlyList<string> TrustedKeyFingerprints { get; init; }
|
||||
|
||||
[JsonPropertyName("trusted_issuers")]
|
||||
public IReadOnlyList<string>? TrustedIssuers { get; init; }
|
||||
|
||||
[JsonPropertyName("require_rekor")]
|
||||
public bool RequireRekor { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithms")]
|
||||
public IReadOnlyList<string>? Algorithms { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validity window for attestations.
|
||||
/// </summary>
|
||||
public sealed record ValidityWindow
|
||||
{
|
||||
[JsonPropertyName("not_before")]
|
||||
public DateTimeOffset? NotBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("not_after")]
|
||||
public DateTimeOffset? NotAfter { get; init; }
|
||||
|
||||
[JsonPropertyName("max_attestation_age")]
|
||||
public int? MaxAttestationAge { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a verification policy.
|
||||
/// </summary>
|
||||
public sealed record CreateVerificationPolicyRequest
|
||||
{
|
||||
[JsonPropertyName("policy_id")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant_scope")]
|
||||
public string? TenantScope { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate_types")]
|
||||
public required IReadOnlyList<string> PredicateTypes { get; init; }
|
||||
|
||||
[JsonPropertyName("signer_requirements")]
|
||||
public SignerRequirements? SignerRequirements { get; init; }
|
||||
|
||||
[JsonPropertyName("validity_window")]
|
||||
public ValidityWindow? ValidityWindow { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update a verification policy.
|
||||
/// </summary>
|
||||
public sealed record UpdateVerificationPolicyRequest
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate_types")]
|
||||
public IReadOnlyList<string>? PredicateTypes { get; init; }
|
||||
|
||||
[JsonPropertyName("signer_requirements")]
|
||||
public SignerRequirements? SignerRequirements { get; init; }
|
||||
|
||||
[JsonPropertyName("validity_window")]
|
||||
public ValidityWindow? ValidityWindow { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated list of verification policies.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyList
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public required IReadOnlyList<VerificationPolicy> Items { get; init; }
|
||||
|
||||
[JsonPropertyName("next_page_token")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("total_count")]
|
||||
public int? TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Policy violation.
|
||||
/// </summary>
|
||||
public sealed record Violation
|
||||
{
|
||||
[JsonPropertyName("violation_id")]
|
||||
public required Guid ViolationId { get; init; }
|
||||
|
||||
[JsonPropertyName("policy_id")]
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
[JsonPropertyName("rule_id")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required Severity Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("cve_id")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
[JsonPropertyName("context")]
|
||||
public IReadOnlyDictionary<string, object>? Context { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a violation.
|
||||
/// </summary>
|
||||
public sealed record CreateViolationRequest
|
||||
{
|
||||
[JsonPropertyName("policy_id")]
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
[JsonPropertyName("rule_id")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required Severity Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("cve_id")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
[JsonPropertyName("context")]
|
||||
public IReadOnlyDictionary<string, object>? Context { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch request to create violations.
|
||||
/// </summary>
|
||||
public sealed record ViolationBatchRequest
|
||||
{
|
||||
[JsonPropertyName("violations")]
|
||||
public required IReadOnlyList<CreateViolationRequest> Violations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of batch violation creation.
|
||||
/// </summary>
|
||||
public sealed record ViolationBatchResult
|
||||
{
|
||||
[JsonPropertyName("created")]
|
||||
public required int Created { get; init; }
|
||||
|
||||
[JsonPropertyName("failed")]
|
||||
public required int Failed { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<BatchError>? Errors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error from batch operation.
|
||||
/// </summary>
|
||||
public sealed record BatchError
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public int? Index { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated list of violations.
|
||||
/// </summary>
|
||||
public sealed record ViolationList
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public required IReadOnlyList<Violation> Items { get; init; }
|
||||
|
||||
[JsonPropertyName("next_page_token")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("total_count")]
|
||||
public int? TotalCount { get; init; }
|
||||
}
|
||||
214
src/Policy/StellaOps.Policy.Registry/IPolicyRegistryClient.cs
Normal file
214
src/Policy/StellaOps.Policy.Registry/IPolicyRegistryClient.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// Typed HTTP client for Policy Registry API.
|
||||
/// Based on OpenAPI: docs/schemas/policy-registry-api.openapi.yaml
|
||||
/// </summary>
|
||||
public interface IPolicyRegistryClient
|
||||
{
|
||||
// ============================================================
|
||||
// VERIFICATION POLICY OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
Task<VerificationPolicyList> ListVerificationPoliciesAsync(
|
||||
Guid tenantId,
|
||||
PaginationParams? pagination = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VerificationPolicy> CreateVerificationPolicyAsync(
|
||||
Guid tenantId,
|
||||
CreateVerificationPolicyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VerificationPolicy> GetVerificationPolicyAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VerificationPolicy> UpdateVerificationPolicyAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
UpdateVerificationPolicyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteVerificationPolicyAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// ============================================================
|
||||
// POLICY PACK OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
Task<PolicyPackList> ListPolicyPacksAsync(
|
||||
Guid tenantId,
|
||||
PolicyPackStatus? status = null,
|
||||
PaginationParams? pagination = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicyPack> CreatePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
CreatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicyPack> GetPolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicyPack> UpdatePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
UpdatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeletePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CompilationResult> CompilePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SimulationResult> SimulatePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
SimulationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicyPack> PublishPolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PublishRequest? request = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicyPack> PromotePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PromoteRequest? request = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// ============================================================
|
||||
// SNAPSHOT OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
Task<SnapshotList> ListSnapshotsAsync(
|
||||
Guid tenantId,
|
||||
PaginationParams? pagination = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Snapshot> CreateSnapshotAsync(
|
||||
Guid tenantId,
|
||||
CreateSnapshotRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Snapshot> GetSnapshotAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteSnapshotAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Snapshot> GetSnapshotByDigestAsync(
|
||||
Guid tenantId,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// ============================================================
|
||||
// VIOLATION OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
Task<ViolationList> ListViolationsAsync(
|
||||
Guid tenantId,
|
||||
Severity? severity = null,
|
||||
PaginationParams? pagination = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Violation> AppendViolationAsync(
|
||||
Guid tenantId,
|
||||
CreateViolationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ViolationBatchResult> AppendViolationBatchAsync(
|
||||
Guid tenantId,
|
||||
ViolationBatchRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Violation> GetViolationAsync(
|
||||
Guid tenantId,
|
||||
Guid violationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// ============================================================
|
||||
// OVERRIDE OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
Task<Override> CreateOverrideAsync(
|
||||
Guid tenantId,
|
||||
CreateOverrideRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Override> GetOverrideAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteOverrideAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Override> ApproveOverrideAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
ApproveOverrideRequest? request = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Override> DisableOverrideAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// ============================================================
|
||||
// SEALED MODE OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
Task<SealedModeStatus> GetSealedModeStatusAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SealedModeStatus> SealAsync(
|
||||
Guid tenantId,
|
||||
SealRequest? request = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SealedModeStatus> UnsealAsync(
|
||||
Guid tenantId,
|
||||
UnsealRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BundleVerificationResult> VerifyBundleAsync(
|
||||
Guid tenantId,
|
||||
VerifyBundleRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// ============================================================
|
||||
// STALENESS OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
Task<StalenessStatus> GetStalenessStatusAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<StalenessEvaluation> EvaluateStalenessAsync(
|
||||
Guid tenantId,
|
||||
EvaluateStalenessRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
634
src/Policy/StellaOps.Policy.Registry/PolicyRegistryClient.cs
Normal file
634
src/Policy/StellaOps.Policy.Registry/PolicyRegistryClient.cs
Normal file
@@ -0,0 +1,634 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation for Policy Registry API.
|
||||
/// </summary>
|
||||
public sealed class PolicyRegistryClient : IPolicyRegistryClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PolicyRegistryClient(HttpClient httpClient, IOptions<PolicyRegistryClientOptions>? options = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
if (options?.Value?.BaseUrl is not null && _httpClient.BaseAddress is null)
|
||||
{
|
||||
_httpClient.BaseAddress = new Uri(options.Value.BaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddTenantHeader(HttpRequestMessage request, Guid tenantId)
|
||||
{
|
||||
request.Headers.Add("X-Tenant-Id", tenantId.ToString());
|
||||
}
|
||||
|
||||
private static string BuildQueryString(PaginationParams? pagination, params (string name, string? value)[] additional)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (pagination is not null)
|
||||
{
|
||||
if (pagination.PageSize != 20)
|
||||
{
|
||||
parts.Add($"page_size={pagination.PageSize}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pagination.PageToken))
|
||||
{
|
||||
parts.Add($"page_token={Uri.EscapeDataString(pagination.PageToken)}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (name, value) in additional)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
parts.Add($"{name}={Uri.EscapeDataString(value)}");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VERIFICATION POLICY OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
public async Task<VerificationPolicyList> ListVerificationPoliciesAsync(
|
||||
Guid tenantId,
|
||||
PaginationParams? pagination = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = BuildQueryString(pagination);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/verification-policies{query}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<VerificationPolicyList>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<VerificationPolicy> CreateVerificationPolicyAsync(
|
||||
Guid tenantId,
|
||||
CreateVerificationPolicyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/verification-policies");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<VerificationPolicy>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<VerificationPolicy> GetVerificationPolicyAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<VerificationPolicy>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<VerificationPolicy> UpdateVerificationPolicyAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
UpdateVerificationPolicyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<VerificationPolicy>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task DeleteVerificationPolicyAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POLICY PACK OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
public async Task<PolicyPackList> ListPolicyPacksAsync(
|
||||
Guid tenantId,
|
||||
PolicyPackStatus? status = null,
|
||||
PaginationParams? pagination = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = BuildQueryString(pagination, ("status", status?.ToString().ToLowerInvariant()));
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/packs{query}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PolicyPackList>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<PolicyPack> CreatePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
CreatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/packs");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PolicyPack>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<PolicyPack> GetPolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/packs/{packId}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PolicyPack>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<PolicyPack> UpdatePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
UpdatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/policy/packs/{packId}");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PolicyPack>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task DeletePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/packs/{packId}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<CompilationResult> CompilePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/compile");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
// Note: 422 also returns CompilationResult, so we read regardless of status
|
||||
return await response.Content.ReadFromJsonAsync<CompilationResult>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<SimulationResult> SimulatePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
SimulationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/simulate");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SimulationResult>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<PolicyPack> PublishPolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PublishRequest? request = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/publish");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
if (request is not null)
|
||||
{
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PolicyPack>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<PolicyPack> PromotePolicyPackAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PromoteRequest? request = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/promote");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
if (request is not null)
|
||||
{
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PolicyPack>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SNAPSHOT OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
public async Task<SnapshotList> ListSnapshotsAsync(
|
||||
Guid tenantId,
|
||||
PaginationParams? pagination = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = BuildQueryString(pagination);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots{query}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SnapshotList>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<Snapshot> CreateSnapshotAsync(
|
||||
Guid tenantId,
|
||||
CreateSnapshotRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/snapshots");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<Snapshot>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<Snapshot> GetSnapshotAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots/{snapshotId}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<Snapshot>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task DeleteSnapshotAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/snapshots/{snapshotId}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<Snapshot> GetSnapshotByDigestAsync(
|
||||
Guid tenantId,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots/by-digest/{Uri.EscapeDataString(digest)}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<Snapshot>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VIOLATION OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
public async Task<ViolationList> ListViolationsAsync(
|
||||
Guid tenantId,
|
||||
Severity? severity = null,
|
||||
PaginationParams? pagination = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = BuildQueryString(pagination, ("severity", severity?.ToString().ToLowerInvariant()));
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/violations{query}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ViolationList>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<Violation> AppendViolationAsync(
|
||||
Guid tenantId,
|
||||
CreateViolationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/violations");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<Violation>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<ViolationBatchResult> AppendViolationBatchAsync(
|
||||
Guid tenantId,
|
||||
ViolationBatchRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/violations/batch");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ViolationBatchResult>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<Violation> GetViolationAsync(
|
||||
Guid tenantId,
|
||||
Guid violationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/violations/{violationId}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<Violation>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OVERRIDE OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
public async Task<Override> CreateOverrideAsync(
|
||||
Guid tenantId,
|
||||
CreateOverrideRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/overrides");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<Override>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<Override> GetOverrideAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/overrides/{overrideId}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<Override>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task DeleteOverrideAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/overrides/{overrideId}");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<Override> ApproveOverrideAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
ApproveOverrideRequest? request = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/overrides/{overrideId}:approve");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
if (request is not null)
|
||||
{
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<Override>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<Override> DisableOverrideAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/overrides/{overrideId}:disable");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<Override>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SEALED MODE OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
public async Task<SealedModeStatus> GetSealedModeStatusAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/policy/sealed-mode/status");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SealedModeStatus>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<SealedModeStatus> SealAsync(
|
||||
Guid tenantId,
|
||||
SealRequest? request = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/seal");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
if (request is not null)
|
||||
{
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SealedModeStatus>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<SealedModeStatus> UnsealAsync(
|
||||
Guid tenantId,
|
||||
UnsealRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/unseal");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SealedModeStatus>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<BundleVerificationResult> VerifyBundleAsync(
|
||||
Guid tenantId,
|
||||
VerifyBundleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/verify");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<BundleVerificationResult>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// STALENESS OPERATIONS
|
||||
// ============================================================
|
||||
|
||||
public async Task<StalenessStatus> GetStalenessStatusAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/policy/staleness/status");
|
||||
AddTenantHeader(request, tenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<StalenessStatus>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<StalenessEvaluation> EvaluateStalenessAsync(
|
||||
Guid tenantId,
|
||||
EvaluateStalenessRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/staleness/evaluate");
|
||||
AddTenantHeader(httpRequest, tenantId);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<StalenessEvaluation>(_jsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize response");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Policy Registry client.
|
||||
/// </summary>
|
||||
public sealed class PolicyRegistryClientOptions
|
||||
{
|
||||
public string? BaseUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Policy.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Policy Registry services.
|
||||
/// </summary>
|
||||
public static class PolicyRegistryServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Policy Registry typed HTTP client to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPolicyRegistryClient(
|
||||
this IServiceCollection services,
|
||||
Action<PolicyRegistryClientOptions>? configureOptions = null)
|
||||
{
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
}
|
||||
|
||||
services.AddHttpClient<IPolicyRegistryClient, PolicyRegistryClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the Policy Registry typed HTTP client with a custom base address.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPolicyRegistryClient(
|
||||
this IServiceCollection services,
|
||||
string baseUrl)
|
||||
{
|
||||
services.Configure<PolicyRegistryClientOptions>(options =>
|
||||
{
|
||||
options.BaseUrl = baseUrl;
|
||||
});
|
||||
|
||||
services.AddHttpClient<IPolicyRegistryClient, PolicyRegistryClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Policy.Registry</RootNamespace>
|
||||
<AssemblyName>StellaOps.Policy.Registry</AssemblyName>
|
||||
<Description>Policy Registry typed clients and contracts for StellaOps Policy Engine</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,12 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Discovery;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
|
||||
|
||||
internal static class JavaLockFileCollector
|
||||
{
|
||||
private static readonly string[] GradleLockPatterns = { "gradle.lockfile" };
|
||||
private static readonly string[] GradleLockPatterns = ["gradle.lockfile"];
|
||||
|
||||
public static async Task<JavaLockData> LoadAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -15,6 +20,10 @@ internal static class JavaLockFileCollector
|
||||
var entries = new Dictionary<string, JavaLockEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
var root = context.RootPath;
|
||||
|
||||
// Discover all build files
|
||||
var buildFiles = JavaBuildFileDiscovery.Discover(root);
|
||||
|
||||
// Priority 1: Gradle lockfiles (most reliable)
|
||||
foreach (var pattern in GradleLockPatterns)
|
||||
{
|
||||
var lockPath = Path.Combine(root, pattern);
|
||||
@@ -33,15 +42,35 @@ internal static class JavaLockFileCollector
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: If no lockfiles, parse Gradle build files with version catalog
|
||||
if (entries.Count == 0 && buildFiles.UsesGradle && !buildFiles.HasGradleLockFiles)
|
||||
{
|
||||
await ParseGradleBuildFilesAsync(context, buildFiles, entries, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Priority 3: Parse Maven POMs with property resolution
|
||||
foreach (var pomFile in buildFiles.MavenPoms)
|
||||
{
|
||||
await ParsePomWithResolutionAsync(context, pomFile.AbsolutePath, entries, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Fallback: original pom.xml scanning for any POMs not caught by discovery
|
||||
foreach (var pomPath in Directory.EnumerateFiles(root, "pom.xml", SearchOption.AllDirectories))
|
||||
{
|
||||
await ParsePomAsync(context, pomPath, entries, cancellationToken).ConfigureAwait(false);
|
||||
if (!buildFiles.MavenPoms.Any(p => p.AbsolutePath.Equals(pomPath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await ParsePomWithResolutionAsync(context, pomPath, entries, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.Count == 0 ? JavaLockData.Empty : new JavaLockData(entries);
|
||||
}
|
||||
|
||||
private static async Task ParseGradleLockFileAsync(LanguageAnalyzerContext context, string path, IDictionary<string, JavaLockEntry> entries, CancellationToken cancellationToken)
|
||||
private static async Task ParseGradleLockFileAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string path,
|
||||
IDictionary<string, JavaLockEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream);
|
||||
@@ -52,7 +81,7 @@ internal static class JavaLockFileCollector
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
line = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#", StringComparison.Ordinal))
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -76,6 +105,9 @@ internal static class JavaLockFileCollector
|
||||
continue;
|
||||
}
|
||||
|
||||
var scope = MapGradleConfigurationToScope(configuration);
|
||||
var riskLevel = JavaScopeClassifier.GetRiskLevel(scope);
|
||||
|
||||
var entry = new JavaLockEntry(
|
||||
groupId.Trim(),
|
||||
artifactId.Trim(),
|
||||
@@ -84,13 +116,193 @@ internal static class JavaLockFileCollector
|
||||
NormalizeLocator(context, path),
|
||||
configuration,
|
||||
null,
|
||||
null,
|
||||
scope,
|
||||
riskLevel,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
entries[entry.Key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ParsePomAsync(LanguageAnalyzerContext context, string path, IDictionary<string, JavaLockEntry> entries, CancellationToken cancellationToken)
|
||||
private static async Task ParseGradleBuildFilesAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
JavaBuildFiles buildFiles,
|
||||
IDictionary<string, JavaLockEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Load version catalog if present
|
||||
GradleVersionCatalog? versionCatalog = null;
|
||||
if (buildFiles.HasVersionCatalog)
|
||||
{
|
||||
var catalogFile = buildFiles.VersionCatalogFiles.FirstOrDefault();
|
||||
if (catalogFile is not null)
|
||||
{
|
||||
versionCatalog = await GradleVersionCatalogParser.ParseAsync(
|
||||
catalogFile.AbsolutePath,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load gradle.properties
|
||||
GradleProperties? gradleProperties = null;
|
||||
var propsFile = buildFiles.GradlePropertiesFiles.FirstOrDefault();
|
||||
if (propsFile is not null)
|
||||
{
|
||||
gradleProperties = await GradlePropertiesParser.ParseAsync(
|
||||
propsFile.AbsolutePath,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Parse Kotlin DSL files
|
||||
foreach (var ktsFile in buildFiles.GradleKotlinFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var buildFile = await GradleKotlinParser.ParseAsync(
|
||||
ktsFile.AbsolutePath,
|
||||
gradleProperties,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AddGradleDependencies(context, buildFile, versionCatalog, entries);
|
||||
}
|
||||
|
||||
// Parse Groovy DSL files
|
||||
foreach (var groovyFile in buildFiles.GradleGroovyFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var buildFile = await GradleGroovyParser.ParseAsync(
|
||||
groovyFile.AbsolutePath,
|
||||
gradleProperties,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AddGradleDependencies(context, buildFile, versionCatalog, entries);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddGradleDependencies(
|
||||
LanguageAnalyzerContext context,
|
||||
GradleBuildFile buildFile,
|
||||
GradleVersionCatalog? versionCatalog,
|
||||
IDictionary<string, JavaLockEntry> entries)
|
||||
{
|
||||
foreach (var dep in buildFile.Dependencies)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dep.GroupId) || string.IsNullOrWhiteSpace(dep.ArtifactId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var version = dep.Version;
|
||||
|
||||
// Try to resolve from version catalog if version is missing
|
||||
if (string.IsNullOrWhiteSpace(version) && versionCatalog is not null)
|
||||
{
|
||||
// Check if this dependency matches a catalog library
|
||||
var catalogLib = versionCatalog.Libraries.Values
|
||||
.FirstOrDefault(l =>
|
||||
l.GroupId.Equals(dep.GroupId, StringComparison.OrdinalIgnoreCase) &&
|
||||
l.ArtifactId.Equals(dep.ArtifactId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
version = catalogLib?.Version;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scope = dep.Scope ?? "compile";
|
||||
var riskLevel = JavaScopeClassifier.GetRiskLevel(scope);
|
||||
|
||||
var entry = new JavaLockEntry(
|
||||
dep.GroupId,
|
||||
dep.ArtifactId,
|
||||
version,
|
||||
Path.GetFileName(buildFile.SourcePath),
|
||||
NormalizeLocator(context, buildFile.SourcePath),
|
||||
scope,
|
||||
null,
|
||||
null,
|
||||
scope,
|
||||
riskLevel,
|
||||
dep.VersionSource.ToString().ToLowerInvariant(),
|
||||
dep.VersionProperty,
|
||||
null);
|
||||
|
||||
entries.TryAdd(entry.Key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ParsePomWithResolutionAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string path,
|
||||
IDictionary<string, JavaLockEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pom = await MavenPomParser.ParseAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (pom == MavenPom.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Build effective POM with property resolution
|
||||
var effectivePomBuilder = new MavenEffectivePomBuilder(context.RootPath);
|
||||
var effectivePom = await effectivePomBuilder.BuildAsync(pom, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var dep in effectivePom.ResolvedDependencies)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dep.GroupId) ||
|
||||
string.IsNullOrWhiteSpace(dep.ArtifactId) ||
|
||||
string.IsNullOrWhiteSpace(dep.Version) ||
|
||||
!dep.IsVersionResolved)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scope = dep.Scope ?? "compile";
|
||||
var riskLevel = JavaScopeClassifier.GetRiskLevel(scope);
|
||||
|
||||
// Get license info if available
|
||||
var license = effectivePom.Licenses.FirstOrDefault();
|
||||
|
||||
var entry = new JavaLockEntry(
|
||||
dep.GroupId,
|
||||
dep.ArtifactId,
|
||||
dep.Version,
|
||||
"pom.xml",
|
||||
NormalizeLocator(context, path),
|
||||
scope,
|
||||
null,
|
||||
null,
|
||||
scope,
|
||||
riskLevel,
|
||||
dep.VersionSource.ToString().ToLowerInvariant(),
|
||||
dep.VersionProperty,
|
||||
license?.SpdxId);
|
||||
|
||||
entries.TryAdd(entry.Key, entry);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall back to simple parsing if resolution fails
|
||||
await ParsePomSimpleAsync(context, path, entries, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ParsePomSimpleAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string path,
|
||||
IDictionary<string, JavaLockEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var document = await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false);
|
||||
@@ -117,6 +329,9 @@ internal static class JavaLockFileCollector
|
||||
continue;
|
||||
}
|
||||
|
||||
scope ??= "compile";
|
||||
var riskLevel = JavaScopeClassifier.GetRiskLevel(scope);
|
||||
|
||||
var entry = new JavaLockEntry(
|
||||
groupId,
|
||||
artifactId,
|
||||
@@ -125,12 +340,49 @@ internal static class JavaLockFileCollector
|
||||
NormalizeLocator(context, path),
|
||||
scope,
|
||||
repository,
|
||||
null,
|
||||
scope,
|
||||
riskLevel,
|
||||
"direct",
|
||||
null,
|
||||
null);
|
||||
|
||||
entries[entry.Key] = entry;
|
||||
entries.TryAdd(entry.Key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? MapGradleConfigurationToScope(string? configuration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuration))
|
||||
{
|
||||
return "compile";
|
||||
}
|
||||
|
||||
// Parse configuration like "compileClasspath,runtimeClasspath"
|
||||
var configs = configuration.Split(',', StringSplitOptions.TrimEntries);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var scope = config.ToLowerInvariant() switch
|
||||
{
|
||||
"compileclasspath" or "implementation" or "api" => "compile",
|
||||
"runtimeclasspath" or "runtimeonly" => "runtime",
|
||||
"testcompileclasspath" or "testimplementation" => "test",
|
||||
"testruntimeclasspath" or "testruntimeonly" => "test",
|
||||
"compileonly" => "provided",
|
||||
"annotationprocessor" => "compile",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (scope is not null)
|
||||
{
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
|
||||
return "compile";
|
||||
}
|
||||
|
||||
private static string NormalizeLocator(LanguageAnalyzerContext context, string path)
|
||||
=> context.GetRelativePath(path).Replace('\\', '/');
|
||||
}
|
||||
@@ -143,7 +395,12 @@ internal sealed record JavaLockEntry(
|
||||
string Locator,
|
||||
string? Configuration,
|
||||
string? Repository,
|
||||
string? ResolvedUrl)
|
||||
string? ResolvedUrl,
|
||||
string? Scope,
|
||||
string? RiskLevel,
|
||||
string? VersionSource,
|
||||
string? VersionProperty,
|
||||
string? License)
|
||||
{
|
||||
public string Key => BuildKey(GroupId, ArtifactId, Version);
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers and accesses the local Maven repository (~/.m2/repository).
|
||||
/// </summary>
|
||||
internal sealed class MavenLocalRepository
|
||||
{
|
||||
private readonly string? _repositoryPath;
|
||||
|
||||
public MavenLocalRepository()
|
||||
{
|
||||
_repositoryPath = DiscoverRepositoryPath();
|
||||
}
|
||||
|
||||
public MavenLocalRepository(string repositoryPath)
|
||||
{
|
||||
_repositoryPath = repositoryPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the repository path, or null if not found.
|
||||
/// </summary>
|
||||
public string? RepositoryPath => _repositoryPath;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the local repository exists.
|
||||
/// </summary>
|
||||
public bool Exists => _repositoryPath is not null && Directory.Exists(_repositoryPath);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to a POM file in the local repository.
|
||||
/// </summary>
|
||||
public string? GetPomPath(string groupId, string artifactId, string version)
|
||||
{
|
||||
if (_repositoryPath is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relativePath = GetRelativePath(groupId, artifactId, version, $"{artifactId}-{version}.pom");
|
||||
return Path.Combine(_repositoryPath, relativePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to a JAR file in the local repository.
|
||||
/// </summary>
|
||||
public string? GetJarPath(string groupId, string artifactId, string version, string? classifier = null)
|
||||
{
|
||||
if (_repositoryPath is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileName = classifier is null
|
||||
? $"{artifactId}-{version}.jar"
|
||||
: $"{artifactId}-{version}-{classifier}.jar";
|
||||
|
||||
var relativePath = GetRelativePath(groupId, artifactId, version, fileName);
|
||||
return Path.Combine(_repositoryPath, relativePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path for an artifact version in the local repository.
|
||||
/// </summary>
|
||||
public string? GetArtifactDirectory(string groupId, string artifactId, string version)
|
||||
{
|
||||
if (_repositoryPath is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var groupPath = groupId.Replace('.', Path.DirectorySeparatorChar);
|
||||
return Path.Combine(_repositoryPath, groupPath, artifactId, version);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a POM exists in the local repository.
|
||||
/// </summary>
|
||||
public bool HasPom(string groupId, string artifactId, string version)
|
||||
{
|
||||
var path = GetPomPath(groupId, artifactId, version);
|
||||
return path is not null && File.Exists(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a JAR exists in the local repository.
|
||||
/// </summary>
|
||||
public bool HasJar(string groupId, string artifactId, string version, string? classifier = null)
|
||||
{
|
||||
var path = GetJarPath(groupId, artifactId, version, classifier);
|
||||
return path is not null && File.Exists(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all versions of an artifact in the local repository.
|
||||
/// </summary>
|
||||
public IEnumerable<string> GetAvailableVersions(string groupId, string artifactId)
|
||||
{
|
||||
if (_repositoryPath is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var groupPath = groupId.Replace('.', Path.DirectorySeparatorChar);
|
||||
var artifactDir = Path.Combine(_repositoryPath, groupPath, artifactId);
|
||||
|
||||
if (!Directory.Exists(artifactDir))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var versionDir in Directory.EnumerateDirectories(artifactDir))
|
||||
{
|
||||
var version = Path.GetFileName(versionDir);
|
||||
var pomPath = Path.Combine(versionDir, $"{artifactId}-{version}.pom");
|
||||
|
||||
if (File.Exists(pomPath))
|
||||
{
|
||||
yield return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a POM from the local repository.
|
||||
/// </summary>
|
||||
public async Task<MavenPom?> ReadPomAsync(
|
||||
string groupId,
|
||||
string artifactId,
|
||||
string version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = GetPomPath(groupId, artifactId, version);
|
||||
if (path is null || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await MavenPomParser.ParseAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string GetRelativePath(string groupId, string artifactId, string version, string fileName)
|
||||
{
|
||||
var groupPath = groupId.Replace('.', Path.DirectorySeparatorChar);
|
||||
return Path.Combine(groupPath, artifactId, version, fileName);
|
||||
}
|
||||
|
||||
private static string? DiscoverRepositoryPath()
|
||||
{
|
||||
// Check M2_REPO environment variable
|
||||
var m2Repo = Environment.GetEnvironmentVariable("M2_REPO");
|
||||
if (!string.IsNullOrEmpty(m2Repo) && Directory.Exists(m2Repo))
|
||||
{
|
||||
return m2Repo;
|
||||
}
|
||||
|
||||
// Check MAVEN_REPOSITORY environment variable
|
||||
var mavenRepo = Environment.GetEnvironmentVariable("MAVEN_REPOSITORY");
|
||||
if (!string.IsNullOrEmpty(mavenRepo) && Directory.Exists(mavenRepo))
|
||||
{
|
||||
return mavenRepo;
|
||||
}
|
||||
|
||||
// Check for custom settings in ~/.m2/settings.xml
|
||||
var settingsPath = GetSettingsPath();
|
||||
if (settingsPath is not null)
|
||||
{
|
||||
var customPath = TryParseLocalRepositoryFromSettings(settingsPath);
|
||||
if (!string.IsNullOrEmpty(customPath) && Directory.Exists(customPath))
|
||||
{
|
||||
return customPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: ~/.m2/repository
|
||||
var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var defaultPath = Path.Combine(userHome, ".m2", "repository");
|
||||
|
||||
return Directory.Exists(defaultPath) ? defaultPath : null;
|
||||
}
|
||||
|
||||
private static string? GetSettingsPath()
|
||||
{
|
||||
var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var settingsPath = Path.Combine(userHome, ".m2", "settings.xml");
|
||||
|
||||
return File.Exists(settingsPath) ? settingsPath : null;
|
||||
}
|
||||
|
||||
private static string? TryParseLocalRepositoryFromSettings(string settingsPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(settingsPath);
|
||||
var startTag = "<localRepository>";
|
||||
var endTag = "</localRepository>";
|
||||
|
||||
var startIndex = content.IndexOf(startTag, StringComparison.OrdinalIgnoreCase);
|
||||
if (startIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
startIndex += startTag.Length;
|
||||
var endIndex = content.IndexOf(endTag, startIndex, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (endIndex > startIndex)
|
||||
{
|
||||
var path = content[startIndex..endIndex].Trim();
|
||||
|
||||
// Expand environment variables
|
||||
path = Environment.ExpandEnvironmentVariables(path);
|
||||
|
||||
// Handle ${user.home}
|
||||
var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
path = path.Replace("${user.home}", userHome, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal stub for shaded JAR analysis results pending full Postgres migration cleanup.
|
||||
/// </summary>
|
||||
internal sealed record ShadedJarAnalysisResult(
|
||||
bool IsShaded,
|
||||
double Confidence,
|
||||
IReadOnlyList<string> Markers,
|
||||
IReadOnlyList<string> EmbeddedArtifacts,
|
||||
IReadOnlyList<string> RelocatedPrefixes)
|
||||
{
|
||||
public static ShadedJarAnalysisResult None { get; } =
|
||||
new(false, 0, Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
|
||||
|
||||
public sealed class GradleGroovyParserTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ParsesStringNotationDependenciesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var content = """
|
||||
dependencies {
|
||||
implementation 'org.slf4j:slf4j-api:1.7.36'
|
||||
api "com.google.guava:guava:31.1-jre"
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
""";
|
||||
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempFile, content, cancellationToken);
|
||||
var result = await GradleGroovyParser.ParseAsync(tempFile, null, cancellationToken);
|
||||
|
||||
Assert.Equal(3, result.Dependencies.Length);
|
||||
|
||||
var slf4j = result.Dependencies.First(d => d.ArtifactId == "slf4j-api");
|
||||
Assert.Equal("org.slf4j", slf4j.GroupId);
|
||||
Assert.Equal("1.7.36", slf4j.Version);
|
||||
Assert.Equal("implementation", slf4j.Scope);
|
||||
|
||||
var guava = result.Dependencies.First(d => d.ArtifactId == "guava");
|
||||
Assert.Equal("com.google.guava", guava.GroupId);
|
||||
Assert.Equal("31.1-jre", guava.Version);
|
||||
Assert.Equal("api", guava.Scope);
|
||||
|
||||
var junit = result.Dependencies.First(d => d.ArtifactId == "junit");
|
||||
Assert.Equal("junit", junit.GroupId);
|
||||
Assert.Equal("4.13.2", junit.Version);
|
||||
Assert.Equal("testImplementation", junit.Scope);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParsesMapNotationDependenciesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var content = """
|
||||
dependencies {
|
||||
implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0'
|
||||
compileOnly(group: "javax.servlet", name: "servlet-api", version: "2.5")
|
||||
}
|
||||
""";
|
||||
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempFile, content, cancellationToken);
|
||||
var result = await GradleGroovyParser.ParseAsync(tempFile, null, cancellationToken);
|
||||
|
||||
Assert.Equal(2, result.Dependencies.Length);
|
||||
|
||||
var commons = result.Dependencies.First(d => d.ArtifactId == "commons-lang3");
|
||||
Assert.Equal("org.apache.commons", commons.GroupId);
|
||||
Assert.Equal("3.12.0", commons.Version);
|
||||
Assert.Equal("implementation", commons.Scope);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvesPropertyPlaceholdersAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var content = """
|
||||
dependencies {
|
||||
implementation "org.slf4j:slf4j-api:${slf4jVersion}"
|
||||
}
|
||||
""";
|
||||
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempFile, content, cancellationToken);
|
||||
var properties = new GradleProperties(
|
||||
new Dictionary<string, string> { ["slf4jVersion"] = "2.0.7" }.ToImmutableDictionary(),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var result = await GradleGroovyParser.ParseAsync(tempFile, properties, cancellationToken);
|
||||
|
||||
Assert.Single(result.Dependencies);
|
||||
var dep = result.Dependencies[0];
|
||||
Assert.Equal("2.0.7", dep.Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
|
||||
|
||||
public sealed class MavenPomParserTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ParsesDependenciesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var content = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>1.7.36</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>4.13.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""";
|
||||
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempFile, content, cancellationToken);
|
||||
var result = await MavenPomParser.ParseAsync(tempFile, cancellationToken);
|
||||
|
||||
Assert.NotEqual(MavenPom.Empty, result);
|
||||
Assert.Equal("com.example", result.GroupId);
|
||||
Assert.Equal("demo", result.ArtifactId);
|
||||
Assert.Equal("1.0.0", result.Version);
|
||||
Assert.Equal(2, result.Dependencies.Length);
|
||||
|
||||
var slf4j = result.Dependencies.First(d => d.ArtifactId == "slf4j-api");
|
||||
Assert.Equal("org.slf4j", slf4j.GroupId);
|
||||
Assert.Equal("1.7.36", slf4j.Version);
|
||||
Assert.Null(slf4j.Scope);
|
||||
|
||||
var junit = result.Dependencies.First(d => d.ArtifactId == "junit");
|
||||
Assert.Equal("test", junit.Scope);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParsesPropertiesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var content = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<properties>
|
||||
<slf4j.version>2.0.7</slf4j.version>
|
||||
<java.version>17</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>${slf4j.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""";
|
||||
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempFile, content, cancellationToken);
|
||||
var result = await MavenPomParser.ParseAsync(tempFile, cancellationToken);
|
||||
|
||||
Assert.Equal(2, result.Properties.Count);
|
||||
Assert.Equal("2.0.7", result.Properties["slf4j.version"]);
|
||||
Assert.Equal("17", result.Properties["java.version"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParsesLicensesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var content = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
</license>
|
||||
</licenses>
|
||||
</project>
|
||||
""";
|
||||
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempFile, content, cancellationToken);
|
||||
var result = await MavenPomParser.ParseAsync(tempFile, cancellationToken);
|
||||
|
||||
Assert.Single(result.Licenses);
|
||||
var license = result.Licenses[0];
|
||||
Assert.Equal("Apache License, Version 2.0", license.Name);
|
||||
Assert.Equal("https://www.apache.org/licenses/LICENSE-2.0", license.Url);
|
||||
Assert.Equal("Apache-2.0", license.SpdxId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParsesParentReferenceAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var content = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.1.0</version>
|
||||
</parent>
|
||||
<artifactId>demo</artifactId>
|
||||
</project>
|
||||
""";
|
||||
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempFile, content, cancellationToken);
|
||||
var result = await MavenPomParser.ParseAsync(tempFile, cancellationToken);
|
||||
|
||||
Assert.NotNull(result.Parent);
|
||||
Assert.Equal("org.springframework.boot", result.Parent.GroupId);
|
||||
Assert.Equal("spring-boot-starter-parent", result.Parent.ArtifactId);
|
||||
Assert.Equal("3.1.0", result.Parent.Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParsesDependencyManagementAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var content = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>
|
||||
""";
|
||||
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempFile, content, cancellationToken);
|
||||
var result = await MavenPomParser.ParseAsync(tempFile, cancellationToken);
|
||||
|
||||
Assert.Single(result.DependencyManagement);
|
||||
var bom = result.DependencyManagement[0];
|
||||
Assert.Equal("org.springframework.boot", bom.GroupId);
|
||||
Assert.Equal("spring-boot-dependencies", bom.ArtifactId);
|
||||
Assert.Equal("pom", bom.Type);
|
||||
Assert.Equal("import", bom.Scope);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Osgi;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
|
||||
|
||||
public sealed class OsgiBundleParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesBasicBundleManifest()
|
||||
{
|
||||
var manifest = """
|
||||
Manifest-Version: 1.0
|
||||
Bundle-SymbolicName: com.example.bundle
|
||||
Bundle-Version: 1.0.0.SNAPSHOT
|
||||
Bundle-Name: Example Bundle
|
||||
Bundle-Vendor: Example Corp
|
||||
""";
|
||||
|
||||
var dict = OsgiBundleParser.ParseManifest(manifest);
|
||||
var result = OsgiBundleParser.Parse(dict);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("com.example.bundle", result.SymbolicName);
|
||||
Assert.Equal("1.0.0.SNAPSHOT", result.Version);
|
||||
Assert.Equal("Example Bundle", result.Name);
|
||||
Assert.Equal("Example Corp", result.Vendor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesImportPackage()
|
||||
{
|
||||
var manifest = """
|
||||
Manifest-Version: 1.0
|
||||
Bundle-SymbolicName: com.example.bundle
|
||||
Bundle-Version: 1.0.0
|
||||
Import-Package: org.osgi.framework;version="[1.8,2)",
|
||||
org.slf4j;version="[1.7,2)",
|
||||
javax.servlet;resolution:=optional
|
||||
""";
|
||||
|
||||
var dict = OsgiBundleParser.ParseManifest(manifest);
|
||||
var result = OsgiBundleParser.Parse(dict);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.ImportPackage.Length);
|
||||
|
||||
var osgi = result.ImportPackage.First(p => p.PackageName == "org.osgi.framework");
|
||||
Assert.Equal("[1.8,2)", osgi.Version);
|
||||
Assert.False(osgi.IsOptional);
|
||||
|
||||
var servlet = result.ImportPackage.First(p => p.PackageName == "javax.servlet");
|
||||
Assert.True(servlet.IsOptional);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesExportPackage()
|
||||
{
|
||||
var manifest = """
|
||||
Manifest-Version: 1.0
|
||||
Bundle-SymbolicName: com.example.bundle
|
||||
Bundle-Version: 1.0.0
|
||||
Export-Package: com.example.api;version="1.0.0",
|
||||
com.example.impl;version="1.0.0"
|
||||
""";
|
||||
|
||||
var dict = OsgiBundleParser.ParseManifest(manifest);
|
||||
var result = OsgiBundleParser.Parse(dict);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.ExportPackage.Length);
|
||||
|
||||
var api = result.ExportPackage.First(p => p.PackageName == "com.example.api");
|
||||
Assert.Equal("1.0.0", api.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesSingletonBundle()
|
||||
{
|
||||
var manifest = """
|
||||
Manifest-Version: 1.0
|
||||
Bundle-SymbolicName: com.example.singleton;singleton:=true
|
||||
Bundle-Version: 1.0.0
|
||||
""";
|
||||
|
||||
var dict = OsgiBundleParser.ParseManifest(manifest);
|
||||
var result = OsgiBundleParser.Parse(dict);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("com.example.singleton", result.SymbolicName);
|
||||
Assert.True(result.IsSingleton);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesFragmentHost()
|
||||
{
|
||||
var manifest = """
|
||||
Manifest-Version: 1.0
|
||||
Bundle-SymbolicName: com.example.fragment
|
||||
Bundle-Version: 1.0.0
|
||||
Fragment-Host: com.example.host;bundle-version="[1.0,2.0)"
|
||||
""";
|
||||
|
||||
var dict = OsgiBundleParser.ParseManifest(manifest);
|
||||
var result = OsgiBundleParser.Parse(dict);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsFragment);
|
||||
Assert.Contains("com.example.host", result.FragmentHost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsNullForNonOsgiManifest()
|
||||
{
|
||||
var manifest = """
|
||||
Manifest-Version: 1.0
|
||||
Implementation-Title: Regular JAR
|
||||
Implementation-Version: 1.0.0
|
||||
""";
|
||||
|
||||
var dict = OsgiBundleParser.ParseManifest(manifest);
|
||||
var result = OsgiBundleParser.Parse(dict);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesManifestContinuationLines()
|
||||
{
|
||||
var manifest = "Manifest-Version: 1.0\r\n" +
|
||||
"Bundle-SymbolicName: com.example.bundle\r\n" +
|
||||
"Import-Package: org.osgi.framework;version=\"[1.8,2)\",org.slf4j;v\r\n" +
|
||||
" ersion=\"[1.7,2)\"\r\n" +
|
||||
"Bundle-Version: 1.0.0\r\n";
|
||||
|
||||
var dict = OsgiBundleParser.ParseManifest(manifest);
|
||||
var result = OsgiBundleParser.Parse(dict);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.ImportPackage.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Shading;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
|
||||
|
||||
public sealed class ShadedJarDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DetectsMultiplePomPropertiesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var jarPath = Path.Combine(Path.GetTempPath(), $"shaded-{Guid.NewGuid()}.jar");
|
||||
|
||||
try
|
||||
{
|
||||
using (var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create))
|
||||
{
|
||||
WritePomProperties(archive, "com.example", "shaded", "1.0.0");
|
||||
WritePomProperties(archive, "org.slf4j", "slf4j-api", "1.7.36");
|
||||
WritePomProperties(archive, "com.google.guava", "guava", "31.1-jre");
|
||||
}
|
||||
|
||||
var result = await ShadedJarDetector.AnalyzeAsync(jarPath, cancellationToken);
|
||||
|
||||
Assert.True(result.IsShaded);
|
||||
Assert.Contains("multiple-pom-properties", result.Markers);
|
||||
Assert.Equal(3, result.EmbeddedArtifacts.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(jarPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectsDependencyReducedPomAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var jarPath = Path.Combine(Path.GetTempPath(), $"shade-plugin-{Guid.NewGuid()}.jar");
|
||||
|
||||
try
|
||||
{
|
||||
using (var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create))
|
||||
{
|
||||
WritePomProperties(archive, "com.example", "shaded", "1.0.0");
|
||||
var entry = archive.CreateEntry("META-INF/maven/com.example/shaded/dependency-reduced-pom.xml");
|
||||
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
|
||||
writer.Write("<project></project>");
|
||||
}
|
||||
|
||||
var result = await ShadedJarDetector.AnalyzeAsync(jarPath, cancellationToken);
|
||||
|
||||
Assert.True(result.IsShaded);
|
||||
Assert.Contains("dependency-reduced-pom.xml", result.Markers);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(jarPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectsRelocatedPackagesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var jarPath = Path.Combine(Path.GetTempPath(), $"relocated-{Guid.NewGuid()}.jar");
|
||||
|
||||
try
|
||||
{
|
||||
using (var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create))
|
||||
{
|
||||
WritePomProperties(archive, "com.example", "shaded", "1.0.0");
|
||||
// Create relocated class files
|
||||
CreateEmptyClass(archive, "shaded/com/google/common/collect/ImmutableList.class");
|
||||
CreateEmptyClass(archive, "shaded/com/google/common/base/Preconditions.class");
|
||||
}
|
||||
|
||||
var result = await ShadedJarDetector.AnalyzeAsync(jarPath, cancellationToken);
|
||||
|
||||
Assert.True(result.IsShaded);
|
||||
Assert.Contains("relocated-packages", result.Markers);
|
||||
Assert.NotEmpty(result.RelocatedPrefixes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(jarPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsNotShadedForRegularJarAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var jarPath = Path.Combine(Path.GetTempPath(), $"regular-{Guid.NewGuid()}.jar");
|
||||
|
||||
try
|
||||
{
|
||||
using (var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create))
|
||||
{
|
||||
WritePomProperties(archive, "com.example", "regular", "1.0.0");
|
||||
CreateEmptyClass(archive, "com/example/Main.class");
|
||||
}
|
||||
|
||||
var result = await ShadedJarDetector.AnalyzeAsync(jarPath, cancellationToken);
|
||||
|
||||
Assert.False(result.IsShaded);
|
||||
Assert.Empty(result.Markers);
|
||||
Assert.Equal(ShadingConfidence.None, result.Confidence);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(jarPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WritePomProperties(ZipArchive archive, string groupId, string artifactId, string version)
|
||||
{
|
||||
var path = $"META-INF/maven/{groupId}/{artifactId}/pom.properties";
|
||||
var entry = archive.CreateEntry(path);
|
||||
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
|
||||
writer.WriteLine($"groupId={groupId}");
|
||||
writer.WriteLine($"artifactId={artifactId}");
|
||||
writer.WriteLine($"version={version}");
|
||||
}
|
||||
|
||||
private static void CreateEmptyClass(ZipArchive archive, string path)
|
||||
{
|
||||
var entry = archive.CreateEntry(path);
|
||||
using var stream = entry.Open();
|
||||
// Minimal class file header
|
||||
stream.Write([0xCA, 0xFE, 0xBA, 0xBE]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Conflicts;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
|
||||
|
||||
public sealed class VersionConflictDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void DetectsMajorVersionConflicts()
|
||||
{
|
||||
var dependencies = new[]
|
||||
{
|
||||
CreateDependency("org.slf4j", "slf4j-api", "1.7.36", "pom.xml"),
|
||||
CreateDependency("org.slf4j", "slf4j-api", "2.0.7", "gradle.lockfile")
|
||||
};
|
||||
|
||||
var result = VersionConflictDetector.Analyze(dependencies);
|
||||
|
||||
Assert.True(result.HasConflicts);
|
||||
Assert.Equal(1, result.TotalConflicts);
|
||||
Assert.Equal(ConflictSeverity.High, result.MaxSeverity);
|
||||
|
||||
var conflict = result.Conflicts[0];
|
||||
Assert.Equal("org.slf4j", conflict.GroupId);
|
||||
Assert.Equal("slf4j-api", conflict.ArtifactId);
|
||||
Assert.Contains("1.7.36", conflict.UniqueVersions);
|
||||
Assert.Contains("2.0.7", conflict.UniqueVersions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectsMinorVersionConflicts()
|
||||
{
|
||||
var dependencies = new[]
|
||||
{
|
||||
CreateDependency("com.google.guava", "guava", "31.0-jre", "pom.xml"),
|
||||
CreateDependency("com.google.guava", "guava", "31.1-jre", "gradle.lockfile")
|
||||
};
|
||||
|
||||
var result = VersionConflictDetector.Analyze(dependencies);
|
||||
|
||||
Assert.True(result.HasConflicts);
|
||||
Assert.Equal(ConflictSeverity.Medium, result.MaxSeverity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoresIdenticalVersions()
|
||||
{
|
||||
var dependencies = new[]
|
||||
{
|
||||
CreateDependency("org.slf4j", "slf4j-api", "1.7.36", "pom.xml"),
|
||||
CreateDependency("org.slf4j", "slf4j-api", "1.7.36", "gradle.lockfile")
|
||||
};
|
||||
|
||||
var result = VersionConflictDetector.Analyze(dependencies);
|
||||
|
||||
Assert.False(result.HasConflicts);
|
||||
Assert.Equal(0, result.TotalConflicts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmptyForNoDependencies()
|
||||
{
|
||||
var result = VersionConflictDetector.Analyze(Array.Empty<JavaDependencyDeclaration>());
|
||||
|
||||
Assert.False(result.HasConflicts);
|
||||
Assert.Equal(VersionConflictAnalysis.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConflictReturnsNullForNonConflicting()
|
||||
{
|
||||
var dependencies = new[]
|
||||
{
|
||||
CreateDependency("org.slf4j", "slf4j-api", "1.7.36", "pom.xml"),
|
||||
CreateDependency("com.google.guava", "guava", "31.1-jre", "pom.xml")
|
||||
};
|
||||
|
||||
var result = VersionConflictDetector.Analyze(dependencies);
|
||||
|
||||
Assert.Null(result.GetConflict("org.slf4j", "slf4j-api"));
|
||||
Assert.Null(result.GetConflict("com.google.guava", "guava"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConflictFindsConflictingArtifact()
|
||||
{
|
||||
var dependencies = new[]
|
||||
{
|
||||
CreateDependency("org.slf4j", "slf4j-api", "1.7.36", "pom.xml"),
|
||||
CreateDependency("org.slf4j", "slf4j-api", "2.0.7", "gradle.lockfile"),
|
||||
CreateDependency("com.google.guava", "guava", "31.1-jre", "pom.xml")
|
||||
};
|
||||
|
||||
var result = VersionConflictDetector.Analyze(dependencies);
|
||||
|
||||
var conflict = result.GetConflict("org.slf4j", "slf4j-api");
|
||||
Assert.NotNull(conflict);
|
||||
Assert.Equal(2, conflict.UniqueVersions.Count());
|
||||
|
||||
Assert.Null(result.GetConflict("com.google.guava", "guava"));
|
||||
}
|
||||
|
||||
private static JavaDependencyDeclaration CreateDependency(
|
||||
string groupId,
|
||||
string artifactId,
|
||||
string version,
|
||||
string source)
|
||||
{
|
||||
return new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = groupId,
|
||||
ArtifactId = artifactId,
|
||||
Version = version,
|
||||
Source = source,
|
||||
Locator = source
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scheduler.Queue\StellaOps.Scheduler.Queue.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scheduler.Storage.Postgres\StellaOps.Scheduler.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scheduler.Worker\StellaOps.Scheduler.Worker.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Scheduler.Queue\StellaOps.Scheduler.Queue.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Scheduler.Storage.Postgres\StellaOps.Scheduler.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Scheduler.Worker\StellaOps.Scheduler.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -99,18 +99,6 @@ Global
|
||||
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|x86.Build.0 = Release|Any CPU
|
||||
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -363,18 +351,6 @@ Global
|
||||
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Release|x86.Build.0 = Release|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -441,7 +417,6 @@ Global
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{33770BC5-6802-45AD-A866-10027DD360E2} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{167198F1-43CF-42F4-BEF2-5ABC87116A37} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
@@ -451,7 +426,6 @@ Global
|
||||
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{2F097B4B-8F38-45C3-8A42-90250E912F0C} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{7C22F6B7-095E-459B-BCCF-87098EA9F192} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{972CEB4D-510B-4701-B4A2-F14A85F11CC7} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{7B4C9EAC-316E-4890-A715-7BB9C1577F96} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{B13D1DF0-1B9E-4557-919C-0A4E0FC9A8C7} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{D640DBB2-4251-44B3-B949-75FC6BF02B71} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using Scheduler.Backfill;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Postgres;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
var parsed = ParseArgs(args);
|
||||
var options = BackfillOptions.From(parsed.PostgresConnection, parsed.BatchSize, parsed.DryRun);
|
||||
@@ -91,7 +94,7 @@ internal sealed class BackfillRunner
|
||||
SchemaName = "scheduler",
|
||||
CommandTimeoutSeconds = 30,
|
||||
AutoMigrate = false
|
||||
}));
|
||||
}), NullLogger<SchedulerDataSource>.Instance);
|
||||
_graphJobRepository = new GraphJobRepository(_dataSource);
|
||||
}
|
||||
|
||||
@@ -106,7 +109,7 @@ internal sealed class BackfillRunner
|
||||
return;
|
||||
}
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync();
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(CancellationToken.None);
|
||||
await using var tx = await conn.BeginTransactionAsync();
|
||||
|
||||
// Example: seed an empty job to validate wiring
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Planning;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
@@ -156,9 +155,9 @@ public sealed class RunnerSegmentQueueMessage
|
||||
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
|
||||
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct SchedulerQueueEnqueueResult(string MessageId, bool Deduplicated);
|
||||
}
|
||||
|
||||
public readonly record struct SchedulerQueueEnqueueResult(string MessageId, bool Deduplicated);
|
||||
|
||||
public sealed class SchedulerQueueLeaseRequest
|
||||
{
|
||||
@@ -215,12 +214,32 @@ public sealed class SchedulerQueueClaimOptions
|
||||
MinIdleTime = minIdleTime;
|
||||
}
|
||||
|
||||
public string ClaimantConsumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
|
||||
public TimeSpan MinIdleTime { get; }
|
||||
}
|
||||
public string ClaimantConsumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
|
||||
public TimeSpan MinIdleTime { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal pointer to a Surface.FS manifest associated with an image digest.
|
||||
/// Kept local to avoid coupling queue contracts to worker assemblies.
|
||||
/// </summary>
|
||||
public sealed record SurfaceManifestPointer
|
||||
{
|
||||
public SurfaceManifestPointer(string manifestDigest, string? tenant)
|
||||
{
|
||||
ManifestDigest = manifestDigest ?? throw new ArgumentNullException(nameof(manifestDigest));
|
||||
Tenant = tenant;
|
||||
}
|
||||
|
||||
[JsonPropertyName("manifestDigest")]
|
||||
public string ManifestDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
public enum SchedulerQueueReleaseDisposition
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
@@ -10,12 +9,10 @@ namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
public sealed class GraphJobRepository : IGraphJobRepository
|
||||
{
|
||||
private readonly SchedulerDataSource _dataSource;
|
||||
private readonly JsonSerializerOptions _json;
|
||||
|
||||
public GraphJobRepository(SchedulerDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_json = CanonicalJsonSerializer.Options;
|
||||
}
|
||||
|
||||
public async ValueTask InsertAsync(GraphBuildJob job, CancellationToken cancellationToken)
|
||||
@@ -24,16 +21,16 @@ public sealed class GraphJobRepository : IGraphJobRepository
|
||||
(id, tenant_id, type, status, payload, created_at, updated_at, correlation_id)
|
||||
VALUES (@Id, @TenantId, @Type, @Status, @Payload, @CreatedAt, @UpdatedAt, @CorrelationId);";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(job.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
job.Id,
|
||||
job.TenantId,
|
||||
Type = (short)GraphJobQueryType.Build,
|
||||
Status = (short)job.Status,
|
||||
Payload = JsonSerializer.Serialize(job, _json),
|
||||
Payload = CanonicalJsonSerializer.Serialize(job),
|
||||
job.CreatedAt,
|
||||
UpdatedAt = job.UpdatedAt ?? job.CreatedAt,
|
||||
UpdatedAt = job.CompletedAt ?? job.CreatedAt,
|
||||
job.CorrelationId
|
||||
});
|
||||
}
|
||||
@@ -44,16 +41,16 @@ public sealed class GraphJobRepository : IGraphJobRepository
|
||||
(id, tenant_id, type, status, payload, created_at, updated_at, correlation_id)
|
||||
VALUES (@Id, @TenantId, @Type, @Status, @Payload, @CreatedAt, @UpdatedAt, @CorrelationId);";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(job.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
job.Id,
|
||||
job.TenantId,
|
||||
Type = (short)GraphJobQueryType.Overlay,
|
||||
Status = (short)job.Status,
|
||||
Payload = JsonSerializer.Serialize(job, _json),
|
||||
Payload = CanonicalJsonSerializer.Serialize(job),
|
||||
job.CreatedAt,
|
||||
UpdatedAt = job.UpdatedAt ?? job.CreatedAt,
|
||||
UpdatedAt = job.CompletedAt ?? job.CreatedAt,
|
||||
job.CorrelationId
|
||||
});
|
||||
}
|
||||
@@ -61,17 +58,17 @@ public sealed class GraphJobRepository : IGraphJobRepository
|
||||
public async ValueTask<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = "SELECT payload FROM scheduler.graph_jobs WHERE tenant_id=@TenantId AND id=@Id AND type=@Type LIMIT 1";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var payload = await conn.ExecuteScalarAsync<string?>(sql, new { TenantId = tenantId, Id = jobId, Type = (short)GraphJobQueryType.Build });
|
||||
return payload is null ? null : JsonSerializer.Deserialize<GraphBuildJob>(payload, _json);
|
||||
return payload is null ? null : CanonicalJsonSerializer.Deserialize<GraphBuildJob>(payload);
|
||||
}
|
||||
|
||||
public async ValueTask<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = "SELECT payload FROM scheduler.graph_jobs WHERE tenant_id=@TenantId AND id=@Id AND type=@Type LIMIT 1";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var payload = await conn.ExecuteScalarAsync<string?>(sql, new { TenantId = tenantId, Id = jobId, Type = (short)GraphJobQueryType.Overlay });
|
||||
return payload is null ? null : JsonSerializer.Deserialize<GraphOverlayJob>(payload, _json);
|
||||
return payload is null ? null : CanonicalJsonSerializer.Deserialize<GraphOverlayJob>(payload);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyCollection<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken)
|
||||
@@ -83,15 +80,15 @@ public sealed class GraphJobRepository : IGraphJobRepository
|
||||
}
|
||||
sql += " ORDER BY created_at DESC LIMIT @Limit";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var rows = await conn.QueryAsync<string>(sql, new
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Type = (short)GraphJobQueryType.Build,
|
||||
Status = status is null ? null : (short)status,
|
||||
Status = (short?)status,
|
||||
Limit = limit
|
||||
});
|
||||
return rows.Select(r => JsonSerializer.Deserialize<GraphBuildJob>(r, _json)!).ToArray();
|
||||
return rows.Select(r => CanonicalJsonSerializer.Deserialize<GraphBuildJob>(r)).ToArray();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken)
|
||||
@@ -103,15 +100,15 @@ public sealed class GraphJobRepository : IGraphJobRepository
|
||||
}
|
||||
sql += " ORDER BY created_at DESC LIMIT @Limit";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var rows = await conn.QueryAsync<string>(sql, new
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Type = (short)GraphJobQueryType.Overlay,
|
||||
Status = status is null ? null : (short)status,
|
||||
Status = (short?)status,
|
||||
Limit = limit
|
||||
});
|
||||
return rows.Select(r => JsonSerializer.Deserialize<GraphOverlayJob>(r, _json)!).ToArray();
|
||||
return rows.Select(r => CanonicalJsonSerializer.Deserialize<GraphOverlayJob>(r)).ToArray();
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken)
|
||||
@@ -123,7 +120,7 @@ public sealed class GraphJobRepository : IGraphJobRepository
|
||||
SET status=@NewStatus, payload=@Payload, updated_at=NOW()
|
||||
WHERE tenant_id=@TenantId AND id=@Id AND status=@ExpectedStatus AND type=@Type";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(job.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
var rows = await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
job.TenantId,
|
||||
@@ -131,7 +128,7 @@ public sealed class GraphJobRepository : IGraphJobRepository
|
||||
ExpectedStatus = (short)expectedStatus,
|
||||
NewStatus = (short)job.Status,
|
||||
Type = (short)GraphJobQueryType.Build,
|
||||
Payload = JsonSerializer.Serialize(job, _json)
|
||||
Payload = CanonicalJsonSerializer.Serialize(job)
|
||||
});
|
||||
return rows == 1;
|
||||
}
|
||||
@@ -142,7 +139,7 @@ public sealed class GraphJobRepository : IGraphJobRepository
|
||||
SET status=@NewStatus, payload=@Payload, updated_at=NOW()
|
||||
WHERE tenant_id=@TenantId AND id=@Id AND status=@ExpectedStatus AND type=@Type";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(job.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
var rows = await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
job.TenantId,
|
||||
@@ -150,8 +147,14 @@ public sealed class GraphJobRepository : IGraphJobRepository
|
||||
ExpectedStatus = (short)expectedStatus,
|
||||
NewStatus = (short)job.Status,
|
||||
Type = (short)GraphJobQueryType.Overlay,
|
||||
Payload = JsonSerializer.Serialize(job, _json)
|
||||
Payload = CanonicalJsonSerializer.Serialize(job)
|
||||
});
|
||||
return rows == 1;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum GraphJobQueryType : short
|
||||
{
|
||||
Build = 0,
|
||||
Overlay = 1
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.6.3" />
|
||||
<PackageReference Update="xunit" Version="2.9.2" />
|
||||
<PackageReference Update="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Update="xunit" Version="2.9.2" />
|
||||
<PackageReference Update="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PackageReference Update="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating and verifying pack run attestations.
|
||||
/// Per TASKRUN-OBS-54-001.
|
||||
/// </summary>
|
||||
public interface IPackRunAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates an attestation for a pack run.
|
||||
/// </summary>
|
||||
Task<PackRunAttestationResult> GenerateAsync(
|
||||
PackRunAttestationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a pack run attestation.
|
||||
/// </summary>
|
||||
Task<PackRunAttestationVerificationResult> VerifyAsync(
|
||||
PackRunAttestationVerificationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attestation by ID.
|
||||
/// </summary>
|
||||
Task<PackRunAttestation?> GetAsync(
|
||||
Guid attestationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists attestations for a run.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the DSSE envelope for an attestation.
|
||||
/// </summary>
|
||||
Task<PackRunDsseEnvelope?> GetEnvelopeAsync(
|
||||
Guid attestationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store for pack run attestations.
|
||||
/// </summary>
|
||||
public interface IPackRunAttestationStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores an attestation.
|
||||
/// </summary>
|
||||
Task StoreAsync(
|
||||
PackRunAttestation attestation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attestation by ID.
|
||||
/// </summary>
|
||||
Task<PackRunAttestation?> GetAsync(
|
||||
Guid attestationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists attestations for a run.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates attestation status.
|
||||
/// </summary>
|
||||
Task UpdateStatusAsync(
|
||||
Guid attestationId,
|
||||
PackRunAttestationStatus status,
|
||||
string? error = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing provider for pack run attestations.
|
||||
/// </summary>
|
||||
public interface IPackRunAttestationSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs an in-toto statement.
|
||||
/// </summary>
|
||||
Task<PackRunDsseEnvelope> SignAsync(
|
||||
byte[] statementBytes,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE envelope signature.
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(
|
||||
PackRunDsseEnvelope envelope,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current signing key ID.
|
||||
/// </summary>
|
||||
string GetKeyId();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of pack run attestation service.
|
||||
/// </summary>
|
||||
public sealed class PackRunAttestationService : IPackRunAttestationService
|
||||
{
|
||||
private readonly IPackRunAttestationStore _store;
|
||||
private readonly IPackRunAttestationSigner? _signer;
|
||||
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
|
||||
private readonly ILogger<PackRunAttestationService> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public PackRunAttestationService(
|
||||
IPackRunAttestationStore store,
|
||||
ILogger<PackRunAttestationService> logger,
|
||||
IPackRunAttestationSigner? signer = null,
|
||||
IPackRunTimelineEventEmitter? timelineEmitter = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_signer = signer;
|
||||
_timelineEmitter = timelineEmitter;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackRunAttestationResult> GenerateAsync(
|
||||
PackRunAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
// Build provenance predicate
|
||||
var buildDefinition = new PackRunBuildDefinition(
|
||||
BuildType: "https://stellaops.io/pack-run/v1",
|
||||
ExternalParameters: request.ExternalParameters,
|
||||
InternalParameters: new Dictionary<string, object>
|
||||
{
|
||||
["planHash"] = request.PlanHash
|
||||
},
|
||||
ResolvedDependencies: request.ResolvedDependencies);
|
||||
|
||||
var runDetails = new PackRunDetails(
|
||||
Builder: new PackRunBuilder(
|
||||
Id: request.BuilderId ?? "https://stellaops.io/task-runner",
|
||||
Version: new Dictionary<string, string>
|
||||
{
|
||||
["stellaops.task-runner"] = GetVersion()
|
||||
},
|
||||
BuilderDependencies: null),
|
||||
Metadata: new PackRunProvMetadata(
|
||||
InvocationId: request.RunId,
|
||||
StartedOn: request.StartedAt,
|
||||
FinishedOn: request.CompletedAt),
|
||||
Byproducts: null);
|
||||
|
||||
var predicate = new PackRunProvenancePredicate(
|
||||
BuildDefinition: buildDefinition,
|
||||
RunDetails: runDetails);
|
||||
|
||||
var predicateJson = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
|
||||
// Build in-toto statement
|
||||
var statement = new PackRunInTotoStatement(
|
||||
Type: InTotoStatementTypes.V1,
|
||||
Subject: request.Subjects,
|
||||
PredicateType: PredicateTypes.PackRunProvenance,
|
||||
Predicate: predicate);
|
||||
|
||||
var statementJson = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var statementBytes = Encoding.UTF8.GetBytes(statementJson);
|
||||
|
||||
// Sign if signer is available
|
||||
PackRunDsseEnvelope? envelope = null;
|
||||
PackRunAttestationStatus status = PackRunAttestationStatus.Pending;
|
||||
string? error = null;
|
||||
|
||||
if (_signer is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
envelope = await _signer.SignAsync(statementBytes, cancellationToken);
|
||||
status = PackRunAttestationStatus.Signed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to sign attestation for run {RunId}", request.RunId);
|
||||
error = ex.Message;
|
||||
status = PackRunAttestationStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
// Create attestation record
|
||||
var attestation = new PackRunAttestation(
|
||||
AttestationId: Guid.NewGuid(),
|
||||
TenantId: request.TenantId,
|
||||
RunId: request.RunId,
|
||||
PlanHash: request.PlanHash,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
Subjects: request.Subjects,
|
||||
PredicateType: PredicateTypes.PackRunProvenance,
|
||||
PredicateJson: predicateJson,
|
||||
Envelope: envelope,
|
||||
Status: status,
|
||||
Error: error,
|
||||
EvidenceSnapshotId: request.EvidenceSnapshotId,
|
||||
Metadata: request.Metadata);
|
||||
|
||||
// Store attestation
|
||||
await _store.StoreAsync(attestation, cancellationToken);
|
||||
|
||||
// Emit timeline event
|
||||
if (_timelineEmitter is not null)
|
||||
{
|
||||
var eventType = status == PackRunAttestationStatus.Signed
|
||||
? PackRunAttestationEventTypes.AttestationCreated
|
||||
: PackRunAttestationEventTypes.AttestationFailed;
|
||||
|
||||
await _timelineEmitter.EmitAsync(
|
||||
PackRunTimelineEvent.Create(
|
||||
tenantId: request.TenantId,
|
||||
eventType: eventType,
|
||||
source: "taskrunner-attestation",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: request.RunId,
|
||||
planHash: request.PlanHash,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["attestationId"] = attestation.AttestationId.ToString(),
|
||||
["predicateType"] = attestation.PredicateType,
|
||||
["subjectCount"] = request.Subjects.Count.ToString(),
|
||||
["status"] = status.ToString()
|
||||
},
|
||||
evidencePointer: envelope is not null
|
||||
? PackRunEvidencePointer.Attestation(
|
||||
request.RunId,
|
||||
envelope.ComputeDigest())
|
||||
: null),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated attestation {AttestationId} for run {RunId} with {SubjectCount} subjects, status {Status}",
|
||||
attestation.AttestationId,
|
||||
request.RunId,
|
||||
request.Subjects.Count,
|
||||
status);
|
||||
|
||||
return new PackRunAttestationResult(
|
||||
Success: status != PackRunAttestationStatus.Failed,
|
||||
Attestation: attestation,
|
||||
Error: error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate attestation for run {RunId}", request.RunId);
|
||||
|
||||
return new PackRunAttestationResult(
|
||||
Success: false,
|
||||
Attestation: null,
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackRunAttestationVerificationResult> VerifyAsync(
|
||||
PackRunAttestationVerificationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var signatureStatus = PackRunSignatureVerificationStatus.NotVerified;
|
||||
var subjectStatus = PackRunSubjectVerificationStatus.NotVerified;
|
||||
var revocationStatus = PackRunRevocationStatus.NotChecked;
|
||||
|
||||
var attestation = await _store.GetAsync(request.AttestationId, cancellationToken);
|
||||
if (attestation is null)
|
||||
{
|
||||
return new PackRunAttestationVerificationResult(
|
||||
Valid: false,
|
||||
AttestationId: request.AttestationId,
|
||||
SignatureStatus: PackRunSignatureVerificationStatus.NotVerified,
|
||||
SubjectStatus: PackRunSubjectVerificationStatus.NotVerified,
|
||||
RevocationStatus: PackRunRevocationStatus.NotChecked,
|
||||
Errors: ["Attestation not found"],
|
||||
VerifiedAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if (request.VerifySignature && attestation.Envelope is not null && _signer is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var signatureValid = await _signer.VerifyAsync(attestation.Envelope, cancellationToken);
|
||||
signatureStatus = signatureValid
|
||||
? PackRunSignatureVerificationStatus.Valid
|
||||
: PackRunSignatureVerificationStatus.Invalid;
|
||||
|
||||
if (!signatureValid)
|
||||
{
|
||||
errors.Add("Signature verification failed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
signatureStatus = PackRunSignatureVerificationStatus.Invalid;
|
||||
errors.Add($"Signature verification error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else if (request.VerifySignature && attestation.Envelope is null)
|
||||
{
|
||||
signatureStatus = PackRunSignatureVerificationStatus.Invalid;
|
||||
errors.Add("No envelope available for signature verification");
|
||||
}
|
||||
|
||||
// Verify subjects
|
||||
if (request.VerifySubjects && request.ExpectedSubjects is not null)
|
||||
{
|
||||
var expectedSet = request.ExpectedSubjects
|
||||
.Select(s => $"{s.Name}:{string.Join(",", s.Digest.OrderBy(d => d.Key).Select(d => $"{d.Key}={d.Value}"))}")
|
||||
.ToHashSet();
|
||||
|
||||
var actualSet = attestation.Subjects
|
||||
.Select(s => $"{s.Name}:{string.Join(",", s.Digest.OrderBy(d => d.Key).Select(d => $"{d.Key}={d.Value}"))}")
|
||||
.ToHashSet();
|
||||
|
||||
if (expectedSet.SetEquals(actualSet))
|
||||
{
|
||||
subjectStatus = PackRunSubjectVerificationStatus.Match;
|
||||
}
|
||||
else if (expectedSet.IsSubsetOf(actualSet))
|
||||
{
|
||||
subjectStatus = PackRunSubjectVerificationStatus.Match;
|
||||
}
|
||||
else
|
||||
{
|
||||
var missing = expectedSet.Except(actualSet).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
subjectStatus = PackRunSubjectVerificationStatus.Missing;
|
||||
errors.Add($"Missing subjects: {string.Join(", ", missing)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
subjectStatus = PackRunSubjectVerificationStatus.Mismatch;
|
||||
errors.Add("Subject digest mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check revocation
|
||||
if (request.CheckRevocation)
|
||||
{
|
||||
revocationStatus = attestation.Status == PackRunAttestationStatus.Revoked
|
||||
? PackRunRevocationStatus.Revoked
|
||||
: PackRunRevocationStatus.NotRevoked;
|
||||
|
||||
if (attestation.Status == PackRunAttestationStatus.Revoked)
|
||||
{
|
||||
errors.Add("Attestation has been revoked");
|
||||
}
|
||||
}
|
||||
|
||||
var valid = errors.Count == 0 &&
|
||||
(signatureStatus is PackRunSignatureVerificationStatus.Valid or PackRunSignatureVerificationStatus.NotVerified) &&
|
||||
(subjectStatus is PackRunSubjectVerificationStatus.Match or PackRunSubjectVerificationStatus.NotVerified) &&
|
||||
(revocationStatus is PackRunRevocationStatus.NotRevoked or PackRunRevocationStatus.NotChecked);
|
||||
|
||||
return new PackRunAttestationVerificationResult(
|
||||
Valid: valid,
|
||||
AttestationId: request.AttestationId,
|
||||
SignatureStatus: signatureStatus,
|
||||
SubjectStatus: subjectStatus,
|
||||
RevocationStatus: revocationStatus,
|
||||
Errors: errors.Count > 0 ? errors : null,
|
||||
VerifiedAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PackRunAttestation?> GetAsync(
|
||||
Guid attestationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _store.GetAsync(attestationId, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _store.ListByRunAsync(tenantId, runId, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackRunDsseEnvelope?> GetEnvelopeAsync(
|
||||
Guid attestationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attestation = await _store.GetAsync(attestationId, cancellationToken);
|
||||
return attestation?.Envelope;
|
||||
}
|
||||
|
||||
private static string GetVersion()
|
||||
{
|
||||
var assembly = typeof(PackRunAttestationService).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation event types for timeline.
|
||||
/// </summary>
|
||||
public static class PackRunAttestationEventTypes
|
||||
{
|
||||
/// <summary>Attestation created successfully.</summary>
|
||||
public const string AttestationCreated = "pack.attestation.created";
|
||||
|
||||
/// <summary>Attestation creation failed.</summary>
|
||||
public const string AttestationFailed = "pack.attestation.failed";
|
||||
|
||||
/// <summary>Attestation verified.</summary>
|
||||
public const string AttestationVerified = "pack.attestation.verified";
|
||||
|
||||
/// <summary>Attestation verification failed.</summary>
|
||||
public const string AttestationVerificationFailed = "pack.attestation.verification_failed";
|
||||
|
||||
/// <summary>Attestation revoked.</summary>
|
||||
public const string AttestationRevoked = "pack.attestation.revoked";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory attestation store for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPackRunAttestationStore : IPackRunAttestationStore
|
||||
{
|
||||
private readonly Dictionary<Guid, PackRunAttestation> _attestations = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StoreAsync(
|
||||
PackRunAttestation attestation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_attestations[attestation.AttestationId] = attestation;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PackRunAttestation?> GetAsync(
|
||||
Guid attestationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_attestations.TryGetValue(attestationId, out var attestation);
|
||||
return Task.FromResult(attestation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _attestations.Values
|
||||
.Where(a => a.TenantId == tenantId && a.RunId == runId)
|
||||
.OrderBy(a => a.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PackRunAttestation>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateStatusAsync(
|
||||
Guid attestationId,
|
||||
PackRunAttestationStatus status,
|
||||
string? error = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_attestations.TryGetValue(attestationId, out var attestation))
|
||||
{
|
||||
_attestations[attestationId] = attestation with
|
||||
{
|
||||
Status = status,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Gets all attestations (for testing).</summary>
|
||||
public IReadOnlyList<PackRunAttestation> GetAll()
|
||||
{
|
||||
lock (_lock) { return _attestations.Values.ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>Clears all attestations (for testing).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock) { _attestations.Clear(); }
|
||||
}
|
||||
|
||||
/// <summary>Gets attestation count.</summary>
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) { return _attestations.Count; } }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub signer for testing (does not perform real cryptographic signing).
|
||||
/// </summary>
|
||||
public sealed class StubPackRunAttestationSigner : IPackRunAttestationSigner
|
||||
{
|
||||
private readonly string _keyId;
|
||||
|
||||
public StubPackRunAttestationSigner(string keyId = "test-key-001")
|
||||
{
|
||||
_keyId = keyId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PackRunDsseEnvelope> SignAsync(
|
||||
byte[] statementBytes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = Convert.ToBase64String(statementBytes);
|
||||
|
||||
// Create stub signature (not cryptographically valid)
|
||||
var sigBytes = System.Security.Cryptography.SHA256.HashData(statementBytes);
|
||||
var sig = Convert.ToBase64String(sigBytes);
|
||||
|
||||
var envelope = new PackRunDsseEnvelope(
|
||||
PayloadType: PackRunDsseEnvelope.InTotoPayloadType,
|
||||
Payload: payload,
|
||||
Signatures: [new PackRunDsseSignature(_keyId, sig)]);
|
||||
|
||||
return Task.FromResult(envelope);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> VerifyAsync(
|
||||
PackRunDsseEnvelope envelope,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Stub always returns true for testing
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetKeyId() => _keyId;
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.TaskRunner.Core.Evidence;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE attestation for pack run execution.
|
||||
/// Per TASKRUN-OBS-54-001.
|
||||
/// </summary>
|
||||
public sealed record PackRunAttestation(
|
||||
/// <summary>Unique attestation identifier.</summary>
|
||||
Guid AttestationId,
|
||||
|
||||
/// <summary>Tenant scope.</summary>
|
||||
string TenantId,
|
||||
|
||||
/// <summary>Run ID this attestation covers.</summary>
|
||||
string RunId,
|
||||
|
||||
/// <summary>Plan hash that was executed.</summary>
|
||||
string PlanHash,
|
||||
|
||||
/// <summary>When the attestation was created.</summary>
|
||||
DateTimeOffset CreatedAt,
|
||||
|
||||
/// <summary>Subjects covered by this attestation (produced artifacts).</summary>
|
||||
IReadOnlyList<PackRunAttestationSubject> Subjects,
|
||||
|
||||
/// <summary>Predicate type URI.</summary>
|
||||
string PredicateType,
|
||||
|
||||
/// <summary>Predicate content as JSON.</summary>
|
||||
string PredicateJson,
|
||||
|
||||
/// <summary>DSSE envelope containing signature.</summary>
|
||||
PackRunDsseEnvelope? Envelope,
|
||||
|
||||
/// <summary>Attestation status.</summary>
|
||||
PackRunAttestationStatus Status,
|
||||
|
||||
/// <summary>Error message if signing failed.</summary>
|
||||
string? Error,
|
||||
|
||||
/// <summary>Reference to evidence snapshot.</summary>
|
||||
Guid? EvidenceSnapshotId,
|
||||
|
||||
/// <summary>Attestation metadata.</summary>
|
||||
IReadOnlyDictionary<string, string>? Metadata)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the canonical statement digest.
|
||||
/// </summary>
|
||||
public string ComputeStatementDigest()
|
||||
{
|
||||
var statement = new PackRunInTotoStatement(
|
||||
Type: InTotoStatementTypes.V01,
|
||||
Subject: Subjects,
|
||||
PredicateType: PredicateType,
|
||||
Predicate: JsonSerializer.Deserialize<JsonElement>(PredicateJson, JsonOptions));
|
||||
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes to JSON.
|
||||
/// </summary>
|
||||
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes from JSON.
|
||||
/// </summary>
|
||||
public static PackRunAttestation? FromJson(string json)
|
||||
=> JsonSerializer.Deserialize<PackRunAttestation>(json, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation status.
|
||||
/// </summary>
|
||||
public enum PackRunAttestationStatus
|
||||
{
|
||||
/// <summary>Attestation is pending signing.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Attestation is signed and valid.</summary>
|
||||
Signed,
|
||||
|
||||
/// <summary>Attestation signing failed.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Attestation signature was revoked.</summary>
|
||||
Revoked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject covered by attestation (an artifact).
|
||||
/// </summary>
|
||||
public sealed record PackRunAttestationSubject(
|
||||
/// <summary>Subject name (artifact path or identifier).</summary>
|
||||
[property: JsonPropertyName("name")]
|
||||
string Name,
|
||||
|
||||
/// <summary>Subject digest (sha256 -> hash).</summary>
|
||||
[property: JsonPropertyName("digest")]
|
||||
IReadOnlyDictionary<string, string> Digest)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a subject from an artifact reference.
|
||||
/// </summary>
|
||||
public static PackRunAttestationSubject FromArtifact(PackRunArtifactReference artifact)
|
||||
{
|
||||
var digest = new Dictionary<string, string>();
|
||||
|
||||
// Parse sha256:abcdef format and extract just the hash
|
||||
if (artifact.Sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
digest["sha256"] = artifact.Sha256[7..];
|
||||
}
|
||||
else
|
||||
{
|
||||
digest["sha256"] = artifact.Sha256;
|
||||
}
|
||||
|
||||
return new PackRunAttestationSubject(artifact.Name, digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a subject from a material.
|
||||
/// </summary>
|
||||
public static PackRunAttestationSubject FromMaterial(PackRunEvidenceMaterial material)
|
||||
{
|
||||
var digest = new Dictionary<string, string>();
|
||||
|
||||
if (material.Sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
digest["sha256"] = material.Sha256[7..];
|
||||
}
|
||||
else
|
||||
{
|
||||
digest["sha256"] = material.Sha256;
|
||||
}
|
||||
|
||||
return new PackRunAttestationSubject(material.CanonicalPath, digest);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement wrapper for pack runs.
|
||||
/// </summary>
|
||||
public sealed record PackRunInTotoStatement(
|
||||
/// <summary>Statement type (always _type).</summary>
|
||||
[property: JsonPropertyName("_type")]
|
||||
string Type,
|
||||
|
||||
/// <summary>Subjects covered.</summary>
|
||||
[property: JsonPropertyName("subject")]
|
||||
IReadOnlyList<PackRunAttestationSubject> Subject,
|
||||
|
||||
/// <summary>Predicate type URI.</summary>
|
||||
[property: JsonPropertyName("predicateType")]
|
||||
string PredicateType,
|
||||
|
||||
/// <summary>Predicate content.</summary>
|
||||
[property: JsonPropertyName("predicate")]
|
||||
object Predicate);
|
||||
|
||||
/// <summary>
|
||||
/// Standard in-toto statement type URIs.
|
||||
/// </summary>
|
||||
public static class InTotoStatementTypes
|
||||
{
|
||||
/// <summary>In-toto statement v0.1.</summary>
|
||||
public const string V01 = "https://in-toto.io/Statement/v0.1";
|
||||
|
||||
/// <summary>In-toto statement v1.0.</summary>
|
||||
public const string V1 = "https://in-toto.io/Statement/v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard predicate type URIs.
|
||||
/// </summary>
|
||||
public static class PredicateTypes
|
||||
{
|
||||
/// <summary>SLSA Provenance v0.2.</summary>
|
||||
public const string SlsaProvenanceV02 = "https://slsa.dev/provenance/v0.2";
|
||||
|
||||
/// <summary>SLSA Provenance v1.0.</summary>
|
||||
public const string SlsaProvenanceV1 = "https://slsa.dev/provenance/v1";
|
||||
|
||||
/// <summary>StellaOps Pack Run provenance.</summary>
|
||||
public const string PackRunProvenance = "https://stellaops.io/attestation/pack-run/v1";
|
||||
|
||||
/// <summary>StellaOps Pack Run completion.</summary>
|
||||
public const string PackRunCompletion = "https://stellaops.io/attestation/pack-run-completion/v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for pack run attestation.
|
||||
/// </summary>
|
||||
public sealed record PackRunDsseEnvelope(
|
||||
/// <summary>Payload type (usually application/vnd.in-toto+json).</summary>
|
||||
[property: JsonPropertyName("payloadType")]
|
||||
string PayloadType,
|
||||
|
||||
/// <summary>Base64-encoded payload.</summary>
|
||||
[property: JsonPropertyName("payload")]
|
||||
string Payload,
|
||||
|
||||
/// <summary>Signatures on the envelope.</summary>
|
||||
[property: JsonPropertyName("signatures")]
|
||||
IReadOnlyList<PackRunDsseSignature> Signatures)
|
||||
{
|
||||
/// <summary>Standard payload type for in-toto attestations.</summary>
|
||||
public const string InTotoPayloadType = "application/vnd.in-toto+json";
|
||||
|
||||
/// <summary>
|
||||
/// Computes the envelope digest.
|
||||
/// </summary>
|
||||
public string ComputeDigest()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature in a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record PackRunDsseSignature(
|
||||
/// <summary>Key identifier.</summary>
|
||||
[property: JsonPropertyName("keyid")]
|
||||
string? KeyId,
|
||||
|
||||
/// <summary>Base64-encoded signature.</summary>
|
||||
[property: JsonPropertyName("sig")]
|
||||
string Sig);
|
||||
|
||||
/// <summary>
|
||||
/// Pack run provenance predicate per SLSA Provenance v1.
|
||||
/// </summary>
|
||||
public sealed record PackRunProvenancePredicate(
|
||||
/// <summary>Build definition describing what was run.</summary>
|
||||
[property: JsonPropertyName("buildDefinition")]
|
||||
PackRunBuildDefinition BuildDefinition,
|
||||
|
||||
/// <summary>Run details describing the actual execution.</summary>
|
||||
[property: JsonPropertyName("runDetails")]
|
||||
PackRunDetails RunDetails);
|
||||
|
||||
/// <summary>
|
||||
/// Build definition for pack run provenance.
|
||||
/// </summary>
|
||||
public sealed record PackRunBuildDefinition(
|
||||
/// <summary>Build type identifier.</summary>
|
||||
[property: JsonPropertyName("buildType")]
|
||||
string BuildType,
|
||||
|
||||
/// <summary>External parameters (e.g., pack manifest URL).</summary>
|
||||
[property: JsonPropertyName("externalParameters")]
|
||||
IReadOnlyDictionary<string, object>? ExternalParameters,
|
||||
|
||||
/// <summary>Internal parameters resolved during build.</summary>
|
||||
[property: JsonPropertyName("internalParameters")]
|
||||
IReadOnlyDictionary<string, object>? InternalParameters,
|
||||
|
||||
/// <summary>Dependencies resolved during build.</summary>
|
||||
[property: JsonPropertyName("resolvedDependencies")]
|
||||
IReadOnlyList<PackRunDependency>? ResolvedDependencies);
|
||||
|
||||
/// <summary>
|
||||
/// Resolved dependency in provenance.
|
||||
/// </summary>
|
||||
public sealed record PackRunDependency(
|
||||
/// <summary>Dependency URI.</summary>
|
||||
[property: JsonPropertyName("uri")]
|
||||
string Uri,
|
||||
|
||||
/// <summary>Dependency digest.</summary>
|
||||
[property: JsonPropertyName("digest")]
|
||||
IReadOnlyDictionary<string, string>? Digest,
|
||||
|
||||
/// <summary>Dependency name.</summary>
|
||||
[property: JsonPropertyName("name")]
|
||||
string? Name,
|
||||
|
||||
/// <summary>Media type.</summary>
|
||||
[property: JsonPropertyName("mediaType")]
|
||||
string? MediaType);
|
||||
|
||||
/// <summary>
|
||||
/// Run details for pack run provenance.
|
||||
/// </summary>
|
||||
public sealed record PackRunDetails(
|
||||
/// <summary>Builder information.</summary>
|
||||
[property: JsonPropertyName("builder")]
|
||||
PackRunBuilder Builder,
|
||||
|
||||
/// <summary>Run metadata.</summary>
|
||||
[property: JsonPropertyName("metadata")]
|
||||
PackRunProvMetadata Metadata,
|
||||
|
||||
/// <summary>By-products of the run.</summary>
|
||||
[property: JsonPropertyName("byproducts")]
|
||||
IReadOnlyList<PackRunByproduct>? Byproducts);
|
||||
|
||||
/// <summary>
|
||||
/// Builder information.
|
||||
/// </summary>
|
||||
public sealed record PackRunBuilder(
|
||||
/// <summary>Builder ID (URI).</summary>
|
||||
[property: JsonPropertyName("id")]
|
||||
string Id,
|
||||
|
||||
/// <summary>Builder version.</summary>
|
||||
[property: JsonPropertyName("version")]
|
||||
IReadOnlyDictionary<string, string>? Version,
|
||||
|
||||
/// <summary>Builder dependencies.</summary>
|
||||
[property: JsonPropertyName("builderDependencies")]
|
||||
IReadOnlyList<PackRunDependency>? BuilderDependencies);
|
||||
|
||||
/// <summary>
|
||||
/// Provenance metadata.
|
||||
/// </summary>
|
||||
public sealed record PackRunProvMetadata(
|
||||
/// <summary>Invocation ID.</summary>
|
||||
[property: JsonPropertyName("invocationId")]
|
||||
string? InvocationId,
|
||||
|
||||
/// <summary>When the build started.</summary>
|
||||
[property: JsonPropertyName("startedOn")]
|
||||
DateTimeOffset? StartedOn,
|
||||
|
||||
/// <summary>When the build finished.</summary>
|
||||
[property: JsonPropertyName("finishedOn")]
|
||||
DateTimeOffset? FinishedOn);
|
||||
|
||||
/// <summary>
|
||||
/// By-product of the build.
|
||||
/// </summary>
|
||||
public sealed record PackRunByproduct(
|
||||
/// <summary>By-product URI.</summary>
|
||||
[property: JsonPropertyName("uri")]
|
||||
string? Uri,
|
||||
|
||||
/// <summary>By-product digest.</summary>
|
||||
[property: JsonPropertyName("digest")]
|
||||
IReadOnlyDictionary<string, string>? Digest,
|
||||
|
||||
/// <summary>By-product name.</summary>
|
||||
[property: JsonPropertyName("name")]
|
||||
string? Name,
|
||||
|
||||
/// <summary>By-product media type.</summary>
|
||||
[property: JsonPropertyName("mediaType")]
|
||||
string? MediaType);
|
||||
|
||||
/// <summary>
|
||||
/// Request to generate an attestation for a pack run.
|
||||
/// </summary>
|
||||
public sealed record PackRunAttestationRequest(
|
||||
/// <summary>Run ID to attest.</summary>
|
||||
string RunId,
|
||||
|
||||
/// <summary>Tenant ID.</summary>
|
||||
string TenantId,
|
||||
|
||||
/// <summary>Plan hash.</summary>
|
||||
string PlanHash,
|
||||
|
||||
/// <summary>Subjects (artifacts) to attest.</summary>
|
||||
IReadOnlyList<PackRunAttestationSubject> Subjects,
|
||||
|
||||
/// <summary>Evidence snapshot ID to link.</summary>
|
||||
Guid? EvidenceSnapshotId,
|
||||
|
||||
/// <summary>Run started at.</summary>
|
||||
DateTimeOffset StartedAt,
|
||||
|
||||
/// <summary>Run completed at.</summary>
|
||||
DateTimeOffset? CompletedAt,
|
||||
|
||||
/// <summary>Builder ID.</summary>
|
||||
string? BuilderId,
|
||||
|
||||
/// <summary>External parameters.</summary>
|
||||
IReadOnlyDictionary<string, object>? ExternalParameters,
|
||||
|
||||
/// <summary>Resolved dependencies.</summary>
|
||||
IReadOnlyList<PackRunDependency>? ResolvedDependencies,
|
||||
|
||||
/// <summary>Additional metadata.</summary>
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation generation.
|
||||
/// </summary>
|
||||
public sealed record PackRunAttestationResult(
|
||||
/// <summary>Whether attestation generation succeeded.</summary>
|
||||
bool Success,
|
||||
|
||||
/// <summary>Generated attestation.</summary>
|
||||
PackRunAttestation? Attestation,
|
||||
|
||||
/// <summary>Error message if failed.</summary>
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a pack run attestation.
|
||||
/// </summary>
|
||||
public sealed record PackRunAttestationVerificationRequest(
|
||||
/// <summary>Attestation ID to verify.</summary>
|
||||
Guid AttestationId,
|
||||
|
||||
/// <summary>Expected subjects to verify against.</summary>
|
||||
IReadOnlyList<PackRunAttestationSubject>? ExpectedSubjects,
|
||||
|
||||
/// <summary>Whether to verify signature.</summary>
|
||||
bool VerifySignature,
|
||||
|
||||
/// <summary>Whether to verify subjects match.</summary>
|
||||
bool VerifySubjects,
|
||||
|
||||
/// <summary>Whether to check revocation status.</summary>
|
||||
bool CheckRevocation);
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation verification.
|
||||
/// </summary>
|
||||
public sealed record PackRunAttestationVerificationResult(
|
||||
/// <summary>Whether verification passed.</summary>
|
||||
bool Valid,
|
||||
|
||||
/// <summary>Attestation that was verified.</summary>
|
||||
Guid AttestationId,
|
||||
|
||||
/// <summary>Signature verification status.</summary>
|
||||
PackRunSignatureVerificationStatus SignatureStatus,
|
||||
|
||||
/// <summary>Subject verification status.</summary>
|
||||
PackRunSubjectVerificationStatus SubjectStatus,
|
||||
|
||||
/// <summary>Revocation status.</summary>
|
||||
PackRunRevocationStatus RevocationStatus,
|
||||
|
||||
/// <summary>Verification errors.</summary>
|
||||
IReadOnlyList<string>? Errors,
|
||||
|
||||
/// <summary>When verification was performed.</summary>
|
||||
DateTimeOffset VerifiedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification status.
|
||||
/// </summary>
|
||||
public enum PackRunSignatureVerificationStatus
|
||||
{
|
||||
/// <summary>Not verified.</summary>
|
||||
NotVerified,
|
||||
|
||||
/// <summary>Signature is valid.</summary>
|
||||
Valid,
|
||||
|
||||
/// <summary>Signature is invalid.</summary>
|
||||
Invalid,
|
||||
|
||||
/// <summary>Key not found.</summary>
|
||||
KeyNotFound,
|
||||
|
||||
/// <summary>Key expired.</summary>
|
||||
KeyExpired
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject verification status.
|
||||
/// </summary>
|
||||
public enum PackRunSubjectVerificationStatus
|
||||
{
|
||||
/// <summary>Not verified.</summary>
|
||||
NotVerified,
|
||||
|
||||
/// <summary>All subjects match.</summary>
|
||||
Match,
|
||||
|
||||
/// <summary>Subjects do not match.</summary>
|
||||
Mismatch,
|
||||
|
||||
/// <summary>Missing expected subjects.</summary>
|
||||
Missing
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revocation status.
|
||||
/// </summary>
|
||||
public enum PackRunRevocationStatus
|
||||
{
|
||||
/// <summary>Not checked.</summary>
|
||||
NotChecked,
|
||||
|
||||
/// <summary>Not revoked.</summary>
|
||||
NotRevoked,
|
||||
|
||||
/// <summary>Revoked.</summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>Revocation check failed.</summary>
|
||||
CheckFailed
|
||||
}
|
||||
@@ -313,6 +313,21 @@ public static class PackRunEventTypes
|
||||
/// <summary>Sealed install requirements warning.</summary>
|
||||
public const string SealedInstallWarning = "pack.sealed_install.warning";
|
||||
|
||||
/// <summary>Attestation created successfully (per TASKRUN-OBS-54-001).</summary>
|
||||
public const string AttestationCreated = "pack.attestation.created";
|
||||
|
||||
/// <summary>Attestation creation failed.</summary>
|
||||
public const string AttestationFailed = "pack.attestation.failed";
|
||||
|
||||
/// <summary>Attestation verified successfully.</summary>
|
||||
public const string AttestationVerified = "pack.attestation.verified";
|
||||
|
||||
/// <summary>Attestation verification failed.</summary>
|
||||
public const string AttestationVerificationFailed = "pack.attestation.verification_failed";
|
||||
|
||||
/// <summary>Attestation was revoked.</summary>
|
||||
public const string AttestationRevoked = "pack.attestation.revoked";
|
||||
|
||||
/// <summary>Checks if the event type is a pack run event.</summary>
|
||||
public static bool IsPackRunEvent(string eventType) =>
|
||||
eventType.StartsWith(Prefix, StringComparison.Ordinal);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence for bundle import operations.
|
||||
/// Per TASKRUN-AIRGAP-58-001.
|
||||
/// </summary>
|
||||
public sealed record BundleImportEvidence(
|
||||
/// <summary>Unique import job identifier.</summary>
|
||||
string JobId,
|
||||
|
||||
/// <summary>Tenant that initiated the import.</summary>
|
||||
string TenantId,
|
||||
|
||||
/// <summary>Bundle source path or URL.</summary>
|
||||
string SourcePath,
|
||||
|
||||
/// <summary>When the import started.</summary>
|
||||
DateTimeOffset StartedAt,
|
||||
|
||||
/// <summary>When the import completed.</summary>
|
||||
DateTimeOffset? CompletedAt,
|
||||
|
||||
/// <summary>Final status of the import.</summary>
|
||||
BundleImportStatus Status,
|
||||
|
||||
/// <summary>Error message if failed.</summary>
|
||||
string? ErrorMessage,
|
||||
|
||||
/// <summary>Actor who initiated the import.</summary>
|
||||
string? InitiatedBy,
|
||||
|
||||
/// <summary>Input bundle manifest.</summary>
|
||||
BundleImportInputManifest? InputManifest,
|
||||
|
||||
/// <summary>Output files with hashes.</summary>
|
||||
IReadOnlyList<BundleImportOutputFile> OutputFiles,
|
||||
|
||||
/// <summary>Import transcript log entries.</summary>
|
||||
IReadOnlyList<BundleImportTranscriptEntry> Transcript,
|
||||
|
||||
/// <summary>Validation results.</summary>
|
||||
BundleImportValidationResult? ValidationResult,
|
||||
|
||||
/// <summary>Computed hashes for evidence chain.</summary>
|
||||
BundleImportHashChain HashChain);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle import status.
|
||||
/// </summary>
|
||||
public enum BundleImportStatus
|
||||
{
|
||||
/// <summary>Import is pending.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Import is in progress.</summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>Import completed successfully.</summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>Import failed.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Import was cancelled.</summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>Import is partially complete.</summary>
|
||||
PartiallyComplete
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input bundle manifest from the import source.
|
||||
/// </summary>
|
||||
public sealed record BundleImportInputManifest(
|
||||
/// <summary>Bundle format version.</summary>
|
||||
string FormatVersion,
|
||||
|
||||
/// <summary>Bundle identifier.</summary>
|
||||
string BundleId,
|
||||
|
||||
/// <summary>Bundle version.</summary>
|
||||
string BundleVersion,
|
||||
|
||||
/// <summary>When the bundle was created.</summary>
|
||||
DateTimeOffset CreatedAt,
|
||||
|
||||
/// <summary>Who created the bundle.</summary>
|
||||
string? CreatedBy,
|
||||
|
||||
/// <summary>Total size in bytes.</summary>
|
||||
long TotalSizeBytes,
|
||||
|
||||
/// <summary>Number of items in the bundle.</summary>
|
||||
int ItemCount,
|
||||
|
||||
/// <summary>SHA-256 of the manifest.</summary>
|
||||
string ManifestSha256,
|
||||
|
||||
/// <summary>Bundle signature if present.</summary>
|
||||
string? Signature,
|
||||
|
||||
/// <summary>Signature verification status.</summary>
|
||||
bool? SignatureValid);
|
||||
|
||||
/// <summary>
|
||||
/// Output file from bundle import.
|
||||
/// </summary>
|
||||
public sealed record BundleImportOutputFile(
|
||||
/// <summary>Relative path within staging directory.</summary>
|
||||
string RelativePath,
|
||||
|
||||
/// <summary>SHA-256 hash of the file.</summary>
|
||||
string Sha256,
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
long SizeBytes,
|
||||
|
||||
/// <summary>Media type.</summary>
|
||||
string MediaType,
|
||||
|
||||
/// <summary>When the file was staged.</summary>
|
||||
DateTimeOffset StagedAt,
|
||||
|
||||
/// <summary>Source item identifier in the bundle.</summary>
|
||||
string? SourceItemId);
|
||||
|
||||
/// <summary>
|
||||
/// Transcript entry for bundle import.
|
||||
/// </summary>
|
||||
public sealed record BundleImportTranscriptEntry(
|
||||
/// <summary>When the entry was recorded.</summary>
|
||||
DateTimeOffset Timestamp,
|
||||
|
||||
/// <summary>Log level.</summary>
|
||||
string Level,
|
||||
|
||||
/// <summary>Event type.</summary>
|
||||
string EventType,
|
||||
|
||||
/// <summary>Message.</summary>
|
||||
string Message,
|
||||
|
||||
/// <summary>Additional data.</summary>
|
||||
IReadOnlyDictionary<string, string>? Data);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle import validation result.
|
||||
/// </summary>
|
||||
public sealed record BundleImportValidationResult(
|
||||
/// <summary>Whether validation passed.</summary>
|
||||
bool Valid,
|
||||
|
||||
/// <summary>Checksum verification passed.</summary>
|
||||
bool ChecksumValid,
|
||||
|
||||
/// <summary>Signature verification passed.</summary>
|
||||
bool? SignatureValid,
|
||||
|
||||
/// <summary>Format validation passed.</summary>
|
||||
bool FormatValid,
|
||||
|
||||
/// <summary>Validation errors.</summary>
|
||||
IReadOnlyList<string>? Errors,
|
||||
|
||||
/// <summary>Validation warnings.</summary>
|
||||
IReadOnlyList<string>? Warnings);
|
||||
|
||||
/// <summary>
|
||||
/// Hash chain for bundle import evidence.
|
||||
/// </summary>
|
||||
public sealed record BundleImportHashChain(
|
||||
/// <summary>Hash of all input files.</summary>
|
||||
string InputsHash,
|
||||
|
||||
/// <summary>Hash of all output files.</summary>
|
||||
string OutputsHash,
|
||||
|
||||
/// <summary>Hash of the transcript.</summary>
|
||||
string TranscriptHash,
|
||||
|
||||
/// <summary>Combined root hash.</summary>
|
||||
string RootHash,
|
||||
|
||||
/// <summary>Algorithm used.</summary>
|
||||
string Algorithm)
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes hash chain from import evidence data.
|
||||
/// </summary>
|
||||
public static BundleImportHashChain Compute(
|
||||
BundleImportInputManifest? input,
|
||||
IReadOnlyList<BundleImportOutputFile> outputs,
|
||||
IReadOnlyList<BundleImportTranscriptEntry> transcript)
|
||||
{
|
||||
// Compute input hash
|
||||
var inputJson = input is not null
|
||||
? JsonSerializer.Serialize(input, JsonOptions)
|
||||
: "null";
|
||||
var inputsHash = ComputeSha256(inputJson);
|
||||
|
||||
// Compute outputs hash (sorted for determinism)
|
||||
var sortedOutputs = outputs
|
||||
.OrderBy(o => o.RelativePath, StringComparer.Ordinal)
|
||||
.Select(o => o.Sha256)
|
||||
.ToList();
|
||||
var outputsJson = JsonSerializer.Serialize(sortedOutputs, JsonOptions);
|
||||
var outputsHash = ComputeSha256(outputsJson);
|
||||
|
||||
// Compute transcript hash
|
||||
var transcriptJson = JsonSerializer.Serialize(transcript, JsonOptions);
|
||||
var transcriptHash = ComputeSha256(transcriptJson);
|
||||
|
||||
// Compute root hash
|
||||
var combined = $"{inputsHash}|{outputsHash}|{transcriptHash}";
|
||||
var rootHash = ComputeSha256(combined);
|
||||
|
||||
return new BundleImportHashChain(
|
||||
InputsHash: inputsHash,
|
||||
OutputsHash: outputsHash,
|
||||
TranscriptHash: transcriptHash,
|
||||
RootHash: rootHash,
|
||||
Algorithm: "sha256");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Service for capturing bundle import evidence.
|
||||
/// Per TASKRUN-AIRGAP-58-001.
|
||||
/// </summary>
|
||||
public interface IBundleImportEvidenceService
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures evidence for a bundle import operation.
|
||||
/// </summary>
|
||||
Task<BundleImportEvidenceResult> CaptureAsync(
|
||||
BundleImportEvidence evidence,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Exports evidence to a portable bundle format.
|
||||
/// </summary>
|
||||
Task<PortableEvidenceBundleResult> ExportToPortableBundleAsync(
|
||||
string jobId,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence for a bundle import job.
|
||||
/// </summary>
|
||||
Task<BundleImportEvidence?> GetAsync(
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of capturing bundle import evidence.
|
||||
/// </summary>
|
||||
public sealed record BundleImportEvidenceResult(
|
||||
/// <summary>Whether capture was successful.</summary>
|
||||
bool Success,
|
||||
|
||||
/// <summary>The captured snapshot.</summary>
|
||||
PackRunEvidenceSnapshot? Snapshot,
|
||||
|
||||
/// <summary>Evidence pointer for linking.</summary>
|
||||
PackRunEvidencePointer? EvidencePointer,
|
||||
|
||||
/// <summary>Error message if capture failed.</summary>
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Result of exporting to portable bundle.
|
||||
/// </summary>
|
||||
public sealed record PortableEvidenceBundleResult(
|
||||
/// <summary>Whether export was successful.</summary>
|
||||
bool Success,
|
||||
|
||||
/// <summary>Path to the exported bundle.</summary>
|
||||
string? OutputPath,
|
||||
|
||||
/// <summary>SHA-256 of the bundle.</summary>
|
||||
string? BundleSha256,
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
long SizeBytes,
|
||||
|
||||
/// <summary>Error message if export failed.</summary>
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of bundle import evidence service.
|
||||
/// </summary>
|
||||
public sealed class BundleImportEvidenceService : IBundleImportEvidenceService
|
||||
{
|
||||
private readonly IPackRunEvidenceStore _store;
|
||||
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
|
||||
private readonly ILogger<BundleImportEvidenceService> _logger;
|
||||
|
||||
public BundleImportEvidenceService(
|
||||
IPackRunEvidenceStore store,
|
||||
ILogger<BundleImportEvidenceService> logger,
|
||||
IPackRunTimelineEventEmitter? timelineEmitter = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timelineEmitter = timelineEmitter;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleImportEvidenceResult> CaptureAsync(
|
||||
BundleImportEvidence evidence,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidence);
|
||||
|
||||
try
|
||||
{
|
||||
var materials = new List<PackRunEvidenceMaterial>();
|
||||
|
||||
// Add input manifest
|
||||
if (evidence.InputManifest is not null)
|
||||
{
|
||||
materials.Add(PackRunEvidenceMaterial.FromJson(
|
||||
"input",
|
||||
"manifest.json",
|
||||
evidence.InputManifest,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["bundleId"] = evidence.InputManifest.BundleId,
|
||||
["bundleVersion"] = evidence.InputManifest.BundleVersion
|
||||
}));
|
||||
}
|
||||
|
||||
// Add output files as materials
|
||||
foreach (var output in evidence.OutputFiles)
|
||||
{
|
||||
materials.Add(new PackRunEvidenceMaterial(
|
||||
Section: "output",
|
||||
Path: output.RelativePath,
|
||||
Sha256: output.Sha256,
|
||||
SizeBytes: output.SizeBytes,
|
||||
MediaType: output.MediaType,
|
||||
Attributes: new Dictionary<string, string>
|
||||
{
|
||||
["stagedAt"] = output.StagedAt.ToString("O")
|
||||
}));
|
||||
}
|
||||
|
||||
// Add transcript
|
||||
materials.Add(PackRunEvidenceMaterial.FromJson(
|
||||
"transcript",
|
||||
"import-log.json",
|
||||
evidence.Transcript));
|
||||
|
||||
// Add validation result
|
||||
if (evidence.ValidationResult is not null)
|
||||
{
|
||||
materials.Add(PackRunEvidenceMaterial.FromJson(
|
||||
"validation",
|
||||
"result.json",
|
||||
evidence.ValidationResult));
|
||||
}
|
||||
|
||||
// Add hash chain
|
||||
materials.Add(PackRunEvidenceMaterial.FromJson(
|
||||
"hashchain",
|
||||
"chain.json",
|
||||
evidence.HashChain));
|
||||
|
||||
// Create metadata
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["jobId"] = evidence.JobId,
|
||||
["status"] = evidence.Status.ToString(),
|
||||
["sourcePath"] = evidence.SourcePath,
|
||||
["startedAt"] = evidence.StartedAt.ToString("O"),
|
||||
["outputCount"] = evidence.OutputFiles.Count.ToString(),
|
||||
["rootHash"] = evidence.HashChain.RootHash
|
||||
};
|
||||
|
||||
if (evidence.CompletedAt.HasValue)
|
||||
{
|
||||
metadata["completedAt"] = evidence.CompletedAt.Value.ToString("O");
|
||||
metadata["durationMs"] = ((evidence.CompletedAt.Value - evidence.StartedAt).TotalMilliseconds).ToString("F0");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(evidence.InitiatedBy))
|
||||
{
|
||||
metadata["initiatedBy"] = evidence.InitiatedBy;
|
||||
}
|
||||
|
||||
// Create snapshot
|
||||
var snapshot = PackRunEvidenceSnapshot.Create(
|
||||
tenantId: evidence.TenantId,
|
||||
runId: evidence.JobId,
|
||||
planHash: evidence.HashChain.RootHash,
|
||||
kind: PackRunEvidenceSnapshotKind.BundleImport,
|
||||
materials: materials,
|
||||
metadata: metadata);
|
||||
|
||||
// Store snapshot
|
||||
await _store.StoreAsync(snapshot, cancellationToken);
|
||||
|
||||
var evidencePointer = PackRunEvidencePointer.Bundle(
|
||||
snapshot.SnapshotId,
|
||||
snapshot.RootHash);
|
||||
|
||||
// Emit timeline event
|
||||
if (_timelineEmitter is not null)
|
||||
{
|
||||
await _timelineEmitter.EmitAsync(
|
||||
PackRunTimelineEvent.Create(
|
||||
tenantId: evidence.TenantId,
|
||||
eventType: "bundle.import.evidence_captured",
|
||||
source: "taskrunner-bundle-import",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
runId: evidence.JobId,
|
||||
planHash: evidence.HashChain.RootHash,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["snapshotId"] = snapshot.SnapshotId.ToString(),
|
||||
["rootHash"] = snapshot.RootHash,
|
||||
["status"] = evidence.Status.ToString(),
|
||||
["outputCount"] = evidence.OutputFiles.Count.ToString()
|
||||
},
|
||||
evidencePointer: evidencePointer),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Captured bundle import evidence for job {JobId} with {OutputCount} outputs, root hash {RootHash}",
|
||||
evidence.JobId,
|
||||
evidence.OutputFiles.Count,
|
||||
evidence.HashChain.RootHash);
|
||||
|
||||
return new BundleImportEvidenceResult(
|
||||
Success: true,
|
||||
Snapshot: snapshot,
|
||||
EvidencePointer: evidencePointer,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to capture bundle import evidence for job {JobId}", evidence.JobId);
|
||||
|
||||
return new BundleImportEvidenceResult(
|
||||
Success: false,
|
||||
Snapshot: null,
|
||||
EvidencePointer: null,
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PortableEvidenceBundleResult> ExportToPortableBundleAsync(
|
||||
string jobId,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get all snapshots for this job
|
||||
var snapshots = await _store.GetByRunIdAsync(jobId, cancellationToken);
|
||||
if (snapshots.Count == 0)
|
||||
{
|
||||
return new PortableEvidenceBundleResult(
|
||||
Success: false,
|
||||
OutputPath: null,
|
||||
BundleSha256: null,
|
||||
SizeBytes: 0,
|
||||
Error: $"No evidence found for job {jobId}");
|
||||
}
|
||||
|
||||
// Create portable bundle structure
|
||||
var bundleManifest = new PortableEvidenceBundleManifest
|
||||
{
|
||||
Version = "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
JobId = jobId,
|
||||
SnapshotCount = snapshots.Count,
|
||||
Snapshots = snapshots.Select(s => new PortableSnapshotReference
|
||||
{
|
||||
SnapshotId = s.SnapshotId,
|
||||
Kind = s.Kind.ToString(),
|
||||
RootHash = s.RootHash,
|
||||
CreatedAt = s.CreatedAt,
|
||||
MaterialCount = s.Materials.Count
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Serialize bundle
|
||||
var bundleJson = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
manifest = bundleManifest,
|
||||
snapshots = snapshots
|
||||
}, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
// Write to file
|
||||
await File.WriteAllTextAsync(outputPath, bundleJson, cancellationToken);
|
||||
var fileInfo = new FileInfo(outputPath);
|
||||
|
||||
// Compute bundle hash
|
||||
var bundleBytes = await File.ReadAllBytesAsync(outputPath, cancellationToken);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bundleBytes);
|
||||
var bundleSha256 = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exported portable evidence bundle for job {JobId} to {OutputPath}, size {SizeBytes} bytes",
|
||||
jobId,
|
||||
outputPath,
|
||||
fileInfo.Length);
|
||||
|
||||
return new PortableEvidenceBundleResult(
|
||||
Success: true,
|
||||
OutputPath: outputPath,
|
||||
BundleSha256: bundleSha256,
|
||||
SizeBytes: fileInfo.Length,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export portable evidence bundle for job {JobId}", jobId);
|
||||
|
||||
return new PortableEvidenceBundleResult(
|
||||
Success: false,
|
||||
OutputPath: null,
|
||||
BundleSha256: null,
|
||||
SizeBytes: 0,
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleImportEvidence?> GetAsync(
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshots = await _store.GetByRunIdAsync(jobId, cancellationToken);
|
||||
var importSnapshot = snapshots.FirstOrDefault(s => s.Kind == PackRunEvidenceSnapshotKind.BundleImport);
|
||||
|
||||
if (importSnapshot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reconstruct evidence from snapshot
|
||||
return ReconstructEvidence(importSnapshot);
|
||||
}
|
||||
|
||||
private static BundleImportEvidence? ReconstructEvidence(PackRunEvidenceSnapshot snapshot)
|
||||
{
|
||||
// This would deserialize the stored materials back into the evidence structure
|
||||
// For now, return a minimal reconstruction from metadata
|
||||
var metadata = snapshot.Metadata ?? new Dictionary<string, string>();
|
||||
|
||||
return new BundleImportEvidence(
|
||||
JobId: metadata.GetValueOrDefault("jobId", snapshot.RunId),
|
||||
TenantId: snapshot.TenantId,
|
||||
SourcePath: metadata.GetValueOrDefault("sourcePath", "unknown"),
|
||||
StartedAt: DateTimeOffset.TryParse(metadata.GetValueOrDefault("startedAt"), out var started)
|
||||
? started : snapshot.CreatedAt,
|
||||
CompletedAt: DateTimeOffset.TryParse(metadata.GetValueOrDefault("completedAt"), out var completed)
|
||||
? completed : null,
|
||||
Status: Enum.TryParse<BundleImportStatus>(metadata.GetValueOrDefault("status"), out var status)
|
||||
? status : BundleImportStatus.Completed,
|
||||
ErrorMessage: null,
|
||||
InitiatedBy: metadata.GetValueOrDefault("initiatedBy"),
|
||||
InputManifest: null,
|
||||
OutputFiles: [],
|
||||
Transcript: [],
|
||||
ValidationResult: null,
|
||||
HashChain: new BundleImportHashChain(
|
||||
InputsHash: "sha256:reconstructed",
|
||||
OutputsHash: "sha256:reconstructed",
|
||||
TranscriptHash: "sha256:reconstructed",
|
||||
RootHash: metadata.GetValueOrDefault("rootHash", snapshot.RootHash),
|
||||
Algorithm: "sha256"));
|
||||
}
|
||||
|
||||
private sealed class PortableEvidenceBundleManifest
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required string JobId { get; init; }
|
||||
public required int SnapshotCount { get; init; }
|
||||
public required IReadOnlyList<PortableSnapshotReference> Snapshots { get; init; }
|
||||
}
|
||||
|
||||
private sealed class PortableSnapshotReference
|
||||
{
|
||||
public required Guid SnapshotId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string RootHash { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required int MaterialCount { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,14 @@ public interface IPackRunEvidenceStore
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence snapshots by run ID only (across all tenants).
|
||||
/// For bundle import evidence lookups.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PackRunEvidenceSnapshot>> GetByRunIdAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists evidence snapshots by kind for a run.
|
||||
/// </summary>
|
||||
@@ -109,6 +117,20 @@ public sealed class InMemoryPackRunEvidenceStore : IPackRunEvidenceStore
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> GetByRunIdAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _snapshots.Values
|
||||
.Where(s => s.RunId == runId)
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PackRunEvidenceSnapshot>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByKindAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
|
||||
@@ -151,7 +151,10 @@ public enum PackRunEvidenceSnapshotKind
|
||||
ArtifactManifest,
|
||||
|
||||
/// <summary>Environment digest snapshot.</summary>
|
||||
EnvironmentDigest
|
||||
EnvironmentDigest,
|
||||
|
||||
/// <summary>Bundle import snapshot (TASKRUN-AIRGAP-58-001).</summary>
|
||||
BundleImport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TaskRunner.Core.Evidence;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class BundleImportEvidenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void BundleImportHashChain_Compute_CreatesDeterministicHash()
|
||||
{
|
||||
var input = new BundleImportInputManifest(
|
||||
FormatVersion: "1.0.0",
|
||||
BundleId: "test-bundle",
|
||||
BundleVersion: "2025.10.0",
|
||||
CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"),
|
||||
CreatedBy: "test@example.com",
|
||||
TotalSizeBytes: 1024,
|
||||
ItemCount: 5,
|
||||
ManifestSha256: "sha256:abc123",
|
||||
Signature: null,
|
||||
SignatureValid: null);
|
||||
|
||||
var outputs = new List<BundleImportOutputFile>
|
||||
{
|
||||
new("file1.json", "sha256:aaa", 100, "application/json", DateTimeOffset.UtcNow, "item1"),
|
||||
new("file2.json", "sha256:bbb", 200, "application/json", DateTimeOffset.UtcNow, "item2")
|
||||
};
|
||||
|
||||
var transcript = new List<BundleImportTranscriptEntry>
|
||||
{
|
||||
new(DateTimeOffset.UtcNow, "info", "import.started", "Import started", null)
|
||||
};
|
||||
|
||||
var chain1 = BundleImportHashChain.Compute(input, outputs, transcript);
|
||||
var chain2 = BundleImportHashChain.Compute(input, outputs, transcript);
|
||||
|
||||
Assert.Equal(chain1.RootHash, chain2.RootHash);
|
||||
Assert.Equal(chain1.InputsHash, chain2.InputsHash);
|
||||
Assert.Equal(chain1.OutputsHash, chain2.OutputsHash);
|
||||
Assert.StartsWith("sha256:", chain1.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleImportHashChain_Compute_DifferentInputsProduceDifferentHashes()
|
||||
{
|
||||
var input1 = new BundleImportInputManifest(
|
||||
FormatVersion: "1.0.0",
|
||||
BundleId: "bundle-1",
|
||||
BundleVersion: "2025.10.0",
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
CreatedBy: null,
|
||||
TotalSizeBytes: 1024,
|
||||
ItemCount: 5,
|
||||
ManifestSha256: "sha256:abc123",
|
||||
Signature: null,
|
||||
SignatureValid: null);
|
||||
|
||||
var input2 = new BundleImportInputManifest(
|
||||
FormatVersion: "1.0.0",
|
||||
BundleId: "bundle-2",
|
||||
BundleVersion: "2025.10.0",
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
CreatedBy: null,
|
||||
TotalSizeBytes: 1024,
|
||||
ItemCount: 5,
|
||||
ManifestSha256: "sha256:def456",
|
||||
Signature: null,
|
||||
SignatureValid: null);
|
||||
|
||||
var outputs = new List<BundleImportOutputFile>();
|
||||
var transcript = new List<BundleImportTranscriptEntry>();
|
||||
|
||||
var chain1 = BundleImportHashChain.Compute(input1, outputs, transcript);
|
||||
var chain2 = BundleImportHashChain.Compute(input2, outputs, transcript);
|
||||
|
||||
Assert.NotEqual(chain1.RootHash, chain2.RootHash);
|
||||
Assert.NotEqual(chain1.InputsHash, chain2.InputsHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BundleImportEvidenceService_CaptureAsync_StoresEvidence()
|
||||
{
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new BundleImportEvidenceService(
|
||||
store,
|
||||
NullLogger<BundleImportEvidenceService>.Instance);
|
||||
|
||||
var evidence = CreateTestEvidence();
|
||||
|
||||
var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.NotNull(result.EvidencePointer);
|
||||
Assert.Equal(1, store.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BundleImportEvidenceService_CaptureAsync_CreatesCorrectMaterials()
|
||||
{
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new BundleImportEvidenceService(
|
||||
store,
|
||||
NullLogger<BundleImportEvidenceService>.Instance);
|
||||
|
||||
var evidence = CreateTestEvidence();
|
||||
|
||||
var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var snapshot = result.Snapshot!;
|
||||
|
||||
// Should have: input manifest, 2 outputs, transcript, validation, hashchain = 6 materials
|
||||
Assert.Equal(6, snapshot.Materials.Count);
|
||||
Assert.Contains(snapshot.Materials, m => m.Section == "input");
|
||||
Assert.Contains(snapshot.Materials, m => m.Section == "output");
|
||||
Assert.Contains(snapshot.Materials, m => m.Section == "transcript");
|
||||
Assert.Contains(snapshot.Materials, m => m.Section == "validation");
|
||||
Assert.Contains(snapshot.Materials, m => m.Section == "hashchain");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BundleImportEvidenceService_CaptureAsync_SetsCorrectMetadata()
|
||||
{
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new BundleImportEvidenceService(
|
||||
store,
|
||||
NullLogger<BundleImportEvidenceService>.Instance);
|
||||
|
||||
var evidence = CreateTestEvidence();
|
||||
|
||||
var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var snapshot = result.Snapshot!;
|
||||
|
||||
Assert.Equal(evidence.JobId, snapshot.Metadata!["jobId"]);
|
||||
Assert.Equal(evidence.Status.ToString(), snapshot.Metadata["status"]);
|
||||
Assert.Equal(evidence.SourcePath, snapshot.Metadata["sourcePath"]);
|
||||
Assert.Equal("2", snapshot.Metadata["outputCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BundleImportEvidenceService_CaptureAsync_EmitsTimelineEvent()
|
||||
{
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var timelineSink = new InMemoryPackRunTimelineEventSink();
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
timelineSink,
|
||||
TimeProvider.System,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
var service = new BundleImportEvidenceService(
|
||||
store,
|
||||
NullLogger<BundleImportEvidenceService>.Instance,
|
||||
emitter);
|
||||
|
||||
var evidence = CreateTestEvidence();
|
||||
|
||||
var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, timelineSink.Count);
|
||||
var evt = timelineSink.GetEvents()[0];
|
||||
Assert.Equal("bundle.import.evidence_captured", evt.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BundleImportEvidenceService_GetAsync_ReturnsEvidence()
|
||||
{
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new BundleImportEvidenceService(
|
||||
store,
|
||||
NullLogger<BundleImportEvidenceService>.Instance);
|
||||
|
||||
var evidence = CreateTestEvidence();
|
||||
await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
|
||||
|
||||
var retrieved = await service.GetAsync(evidence.JobId, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(evidence.JobId, retrieved.JobId);
|
||||
Assert.Equal(evidence.TenantId, retrieved.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BundleImportEvidenceService_GetAsync_ReturnsNullForMissingJob()
|
||||
{
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new BundleImportEvidenceService(
|
||||
store,
|
||||
NullLogger<BundleImportEvidenceService>.Instance);
|
||||
|
||||
var retrieved = await service.GetAsync("non-existent-job", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_CreatesFile()
|
||||
{
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new BundleImportEvidenceService(
|
||||
store,
|
||||
NullLogger<BundleImportEvidenceService>.Instance);
|
||||
|
||||
var evidence = CreateTestEvidence();
|
||||
await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
|
||||
|
||||
var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var result = await service.ExportToPortableBundleAsync(
|
||||
evidence.JobId,
|
||||
outputPath,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(outputPath, result.OutputPath);
|
||||
Assert.True(File.Exists(outputPath));
|
||||
Assert.True(result.SizeBytes > 0);
|
||||
Assert.StartsWith("sha256:", result.BundleSha256);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_FailsForMissingJob()
|
||||
{
|
||||
var store = new InMemoryPackRunEvidenceStore();
|
||||
var service = new BundleImportEvidenceService(
|
||||
store,
|
||||
NullLogger<BundleImportEvidenceService>.Instance);
|
||||
|
||||
var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json");
|
||||
|
||||
var result = await service.ExportToPortableBundleAsync(
|
||||
"non-existent-job",
|
||||
outputPath,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("No evidence found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleImportEvidence_RecordProperties_AreAccessible()
|
||||
{
|
||||
var evidence = CreateTestEvidence();
|
||||
|
||||
Assert.Equal("test-job-123", evidence.JobId);
|
||||
Assert.Equal("tenant-1", evidence.TenantId);
|
||||
Assert.Equal("/path/to/bundle.tar.gz", evidence.SourcePath);
|
||||
Assert.Equal(BundleImportStatus.Completed, evidence.Status);
|
||||
Assert.NotNull(evidence.InputManifest);
|
||||
Assert.Equal(2, evidence.OutputFiles.Count);
|
||||
Assert.Equal(2, evidence.Transcript.Count);
|
||||
Assert.NotNull(evidence.ValidationResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleImportValidationResult_RecordProperties_AreAccessible()
|
||||
{
|
||||
var result = new BundleImportValidationResult(
|
||||
Valid: true,
|
||||
ChecksumValid: true,
|
||||
SignatureValid: true,
|
||||
FormatValid: true,
|
||||
Errors: null,
|
||||
Warnings: ["Advisory data may be stale"]);
|
||||
|
||||
Assert.True(result.Valid);
|
||||
Assert.True(result.ChecksumValid);
|
||||
Assert.True(result.SignatureValid);
|
||||
Assert.True(result.FormatValid);
|
||||
Assert.Null(result.Errors);
|
||||
Assert.Single(result.Warnings!);
|
||||
}
|
||||
|
||||
private static BundleImportEvidence CreateTestEvidence()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var input = new BundleImportInputManifest(
|
||||
FormatVersion: "1.0.0",
|
||||
BundleId: "test-bundle-001",
|
||||
BundleVersion: "2025.10.0",
|
||||
CreatedAt: now.AddHours(-1),
|
||||
CreatedBy: "bundle-builder@example.com",
|
||||
TotalSizeBytes: 10240,
|
||||
ItemCount: 5,
|
||||
ManifestSha256: "sha256:abcdef1234567890",
|
||||
Signature: "base64sig...",
|
||||
SignatureValid: true);
|
||||
|
||||
var outputs = new List<BundleImportOutputFile>
|
||||
{
|
||||
new("advisories/CVE-2025-0001.json", "sha256:output1hash", 512, "application/json", now, "item1"),
|
||||
new("advisories/CVE-2025-0002.json", "sha256:output2hash", 1024, "application/json", now, "item2")
|
||||
};
|
||||
|
||||
var transcript = new List<BundleImportTranscriptEntry>
|
||||
{
|
||||
new(now.AddMinutes(-5), "info", "import.started", "Bundle import started", new Dictionary<string, string>
|
||||
{
|
||||
["sourcePath"] = "/path/to/bundle.tar.gz"
|
||||
}),
|
||||
new(now, "info", "import.completed", "Bundle import completed successfully", new Dictionary<string, string>
|
||||
{
|
||||
["itemsImported"] = "5"
|
||||
})
|
||||
};
|
||||
|
||||
var validation = new BundleImportValidationResult(
|
||||
Valid: true,
|
||||
ChecksumValid: true,
|
||||
SignatureValid: true,
|
||||
FormatValid: true,
|
||||
Errors: null,
|
||||
Warnings: null);
|
||||
|
||||
var hashChain = BundleImportHashChain.Compute(input, outputs, transcript);
|
||||
|
||||
return new BundleImportEvidence(
|
||||
JobId: "test-job-123",
|
||||
TenantId: "tenant-1",
|
||||
SourcePath: "/path/to/bundle.tar.gz",
|
||||
StartedAt: now.AddMinutes(-5),
|
||||
CompletedAt: now,
|
||||
Status: BundleImportStatus.Completed,
|
||||
ErrorMessage: null,
|
||||
InitiatedBy: "admin@example.com",
|
||||
InputManifest: input,
|
||||
OutputFiles: outputs,
|
||||
Transcript: transcript,
|
||||
ValidationResult: validation,
|
||||
HashChain: hashChain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TaskRunner.Core.Attestation;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
using StellaOps.TaskRunner.Core.Evidence;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunAttestationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GenerateAsync_CreatesAttestationWithSubjects()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var signer = new StubPackRunAttestationSigner();
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance,
|
||||
signer);
|
||||
|
||||
var subjects = new List<PackRunAttestationSubject>
|
||||
{
|
||||
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" }),
|
||||
new("artifact/sbom.json", new Dictionary<string, string> { ["sha256"] = "def456" })
|
||||
};
|
||||
|
||||
var request = new PackRunAttestationRequest(
|
||||
RunId: "run-001",
|
||||
TenantId: "tenant-1",
|
||||
PlanHash: "sha256:plan123",
|
||||
Subjects: subjects,
|
||||
EvidenceSnapshotId: Guid.NewGuid(),
|
||||
StartedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BuilderId: null,
|
||||
ExternalParameters: null,
|
||||
ResolvedDependencies: null,
|
||||
Metadata: null);
|
||||
|
||||
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Attestation);
|
||||
Assert.Equal(PackRunAttestationStatus.Signed, result.Attestation.Status);
|
||||
Assert.Equal(2, result.Attestation.Subjects.Count);
|
||||
Assert.NotNull(result.Attestation.Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_WithoutSigner_CreatesPendingAttestation()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance);
|
||||
|
||||
var subjects = new List<PackRunAttestationSubject>
|
||||
{
|
||||
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
|
||||
};
|
||||
|
||||
var request = new PackRunAttestationRequest(
|
||||
RunId: "run-002",
|
||||
TenantId: "tenant-1",
|
||||
PlanHash: "sha256:plan123",
|
||||
Subjects: subjects,
|
||||
EvidenceSnapshotId: null,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: null,
|
||||
BuilderId: null,
|
||||
ExternalParameters: null,
|
||||
ResolvedDependencies: null,
|
||||
Metadata: null);
|
||||
|
||||
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Attestation);
|
||||
Assert.Equal(PackRunAttestationStatus.Pending, result.Attestation.Status);
|
||||
Assert.Null(result.Attestation.Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_EmitsTimelineEvent()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var signer = new StubPackRunAttestationSigner();
|
||||
var timelineSink = new InMemoryPackRunTimelineEventSink();
|
||||
var emitter = new PackRunTimelineEventEmitter(
|
||||
timelineSink,
|
||||
TimeProvider.System,
|
||||
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance,
|
||||
signer,
|
||||
emitter);
|
||||
|
||||
var request = new PackRunAttestationRequest(
|
||||
RunId: "run-003",
|
||||
TenantId: "tenant-1",
|
||||
PlanHash: "sha256:plan123",
|
||||
Subjects: [new("artifact/test.json", new Dictionary<string, string> { ["sha256"] = "abc" })],
|
||||
EvidenceSnapshotId: null,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BuilderId: null,
|
||||
ExternalParameters: null,
|
||||
ResolvedDependencies: null,
|
||||
Metadata: null);
|
||||
|
||||
await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, timelineSink.Count);
|
||||
var evt = timelineSink.GetEvents()[0];
|
||||
Assert.Equal(PackRunAttestationEventTypes.AttestationCreated, evt.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidatesSubjectsMatch()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var signer = new StubPackRunAttestationSigner();
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance,
|
||||
signer);
|
||||
|
||||
var subjects = new List<PackRunAttestationSubject>
|
||||
{
|
||||
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
|
||||
};
|
||||
|
||||
var request = new PackRunAttestationRequest(
|
||||
RunId: "run-004",
|
||||
TenantId: "tenant-1",
|
||||
PlanHash: "sha256:plan123",
|
||||
Subjects: subjects,
|
||||
EvidenceSnapshotId: null,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BuilderId: null,
|
||||
ExternalParameters: null,
|
||||
ResolvedDependencies: null,
|
||||
Metadata: null);
|
||||
|
||||
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(genResult.Attestation);
|
||||
|
||||
var verifyResult = await service.VerifyAsync(
|
||||
new PackRunAttestationVerificationRequest(
|
||||
AttestationId: genResult.Attestation.AttestationId,
|
||||
ExpectedSubjects: subjects,
|
||||
VerifySignature: true,
|
||||
VerifySubjects: true,
|
||||
CheckRevocation: true),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(verifyResult.Valid);
|
||||
Assert.Equal(PackRunSignatureVerificationStatus.Valid, verifyResult.SignatureStatus);
|
||||
Assert.Equal(PackRunSubjectVerificationStatus.Match, verifyResult.SubjectStatus);
|
||||
Assert.Equal(PackRunRevocationStatus.NotRevoked, verifyResult.RevocationStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_DetectsMismatchedSubjects()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var signer = new StubPackRunAttestationSigner();
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance,
|
||||
signer);
|
||||
|
||||
var subjects = new List<PackRunAttestationSubject>
|
||||
{
|
||||
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
|
||||
};
|
||||
|
||||
var request = new PackRunAttestationRequest(
|
||||
RunId: "run-005",
|
||||
TenantId: "tenant-1",
|
||||
PlanHash: "sha256:plan123",
|
||||
Subjects: subjects,
|
||||
EvidenceSnapshotId: null,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BuilderId: null,
|
||||
ExternalParameters: null,
|
||||
ResolvedDependencies: null,
|
||||
Metadata: null);
|
||||
|
||||
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(genResult.Attestation);
|
||||
|
||||
// Verify with different expected subjects
|
||||
var differentSubjects = new List<PackRunAttestationSubject>
|
||||
{
|
||||
new("artifact/different.tar.gz", new Dictionary<string, string> { ["sha256"] = "xyz789" })
|
||||
};
|
||||
|
||||
var verifyResult = await service.VerifyAsync(
|
||||
new PackRunAttestationVerificationRequest(
|
||||
AttestationId: genResult.Attestation.AttestationId,
|
||||
ExpectedSubjects: differentSubjects,
|
||||
VerifySignature: false,
|
||||
VerifySubjects: true,
|
||||
CheckRevocation: false),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(verifyResult.Valid);
|
||||
Assert.Equal(PackRunSubjectVerificationStatus.Missing, verifyResult.SubjectStatus);
|
||||
Assert.NotNull(verifyResult.Errors);
|
||||
Assert.Contains(verifyResult.Errors, e => e.Contains("Missing subjects"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_DetectsRevokedAttestation()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var signer = new StubPackRunAttestationSigner();
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance,
|
||||
signer);
|
||||
|
||||
var subjects = new List<PackRunAttestationSubject>
|
||||
{
|
||||
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
|
||||
};
|
||||
|
||||
var request = new PackRunAttestationRequest(
|
||||
RunId: "run-006",
|
||||
TenantId: "tenant-1",
|
||||
PlanHash: "sha256:plan123",
|
||||
Subjects: subjects,
|
||||
EvidenceSnapshotId: null,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BuilderId: null,
|
||||
ExternalParameters: null,
|
||||
ResolvedDependencies: null,
|
||||
Metadata: null);
|
||||
|
||||
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(genResult.Attestation);
|
||||
|
||||
// Revoke the attestation
|
||||
await store.UpdateStatusAsync(
|
||||
genResult.Attestation.AttestationId,
|
||||
PackRunAttestationStatus.Revoked,
|
||||
"Compromised key",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var verifyResult = await service.VerifyAsync(
|
||||
new PackRunAttestationVerificationRequest(
|
||||
AttestationId: genResult.Attestation.AttestationId,
|
||||
ExpectedSubjects: null,
|
||||
VerifySignature: false,
|
||||
VerifySubjects: false,
|
||||
CheckRevocation: true),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(verifyResult.Valid);
|
||||
Assert.Equal(PackRunRevocationStatus.Revoked, verifyResult.RevocationStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsErrorForNonExistentAttestation()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance);
|
||||
|
||||
var verifyResult = await service.VerifyAsync(
|
||||
new PackRunAttestationVerificationRequest(
|
||||
AttestationId: Guid.NewGuid(),
|
||||
ExpectedSubjects: null,
|
||||
VerifySignature: false,
|
||||
VerifySubjects: false,
|
||||
CheckRevocation: false),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(verifyResult.Valid);
|
||||
Assert.NotNull(verifyResult.Errors);
|
||||
Assert.Contains(verifyResult.Errors, e => e.Contains("not found"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByRunAsync_ReturnsAttestationsForRun()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var signer = new StubPackRunAttestationSigner();
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance,
|
||||
signer);
|
||||
|
||||
// Create two attestations for the same run
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var request = new PackRunAttestationRequest(
|
||||
RunId: "run-007",
|
||||
TenantId: "tenant-1",
|
||||
PlanHash: "sha256:plan123",
|
||||
Subjects: [new($"artifact/output{i}.tar.gz", new Dictionary<string, string> { ["sha256"] = $"hash{i}" })],
|
||||
EvidenceSnapshotId: null,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BuilderId: null,
|
||||
ExternalParameters: null,
|
||||
ResolvedDependencies: null,
|
||||
Metadata: null);
|
||||
|
||||
await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
var attestations = await service.ListByRunAsync("tenant-1", "run-007", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(2, attestations.Count);
|
||||
Assert.All(attestations, a => Assert.Equal("run-007", a.RunId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEnvelopeAsync_ReturnsEnvelopeForSignedAttestation()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var signer = new StubPackRunAttestationSigner();
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance,
|
||||
signer);
|
||||
|
||||
var request = new PackRunAttestationRequest(
|
||||
RunId: "run-008",
|
||||
TenantId: "tenant-1",
|
||||
PlanHash: "sha256:plan123",
|
||||
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })],
|
||||
EvidenceSnapshotId: null,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BuilderId: null,
|
||||
ExternalParameters: null,
|
||||
ResolvedDependencies: null,
|
||||
Metadata: null);
|
||||
|
||||
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(genResult.Attestation);
|
||||
|
||||
var envelope = await service.GetEnvelopeAsync(genResult.Attestation.AttestationId, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(envelope);
|
||||
Assert.Equal(PackRunDsseEnvelope.InTotoPayloadType, envelope.PayloadType);
|
||||
Assert.Single(envelope.Signatures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackRunAttestationSubject_FromArtifact_ParsesSha256Prefix()
|
||||
{
|
||||
var artifact = new PackRunArtifactReference(
|
||||
Name: "output.tar.gz",
|
||||
Sha256: "sha256:abcdef123456",
|
||||
SizeBytes: 1024,
|
||||
MediaType: "application/gzip");
|
||||
|
||||
var subject = PackRunAttestationSubject.FromArtifact(artifact);
|
||||
|
||||
Assert.Equal("output.tar.gz", subject.Name);
|
||||
Assert.Equal("abcdef123456", subject.Digest["sha256"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackRunAttestation_ComputeStatementDigest_IsDeterministic()
|
||||
{
|
||||
var subjects = new List<PackRunAttestationSubject>
|
||||
{
|
||||
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
|
||||
};
|
||||
|
||||
var attestation = new PackRunAttestation(
|
||||
AttestationId: Guid.NewGuid(),
|
||||
TenantId: "tenant-1",
|
||||
RunId: "run-001",
|
||||
PlanHash: "sha256:plan123",
|
||||
CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"),
|
||||
Subjects: subjects,
|
||||
PredicateType: PredicateTypes.PackRunProvenance,
|
||||
PredicateJson: "{\"test\":true}",
|
||||
Envelope: null,
|
||||
Status: PackRunAttestationStatus.Pending,
|
||||
Error: null,
|
||||
EvidenceSnapshotId: null,
|
||||
Metadata: null);
|
||||
|
||||
var digest1 = attestation.ComputeStatementDigest();
|
||||
var digest2 = attestation.ComputeStatementDigest();
|
||||
|
||||
Assert.Equal(digest1, digest2);
|
||||
Assert.StartsWith("sha256:", digest1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackRunDsseEnvelope_ComputeDigest_IsDeterministic()
|
||||
{
|
||||
var envelope = new PackRunDsseEnvelope(
|
||||
PayloadType: PackRunDsseEnvelope.InTotoPayloadType,
|
||||
Payload: Convert.ToBase64String([1, 2, 3]),
|
||||
Signatures: [new PackRunDsseSignature("key-001", "sig123")]);
|
||||
|
||||
var digest1 = envelope.ComputeDigest();
|
||||
var digest2 = envelope.ComputeDigest();
|
||||
|
||||
Assert.Equal(digest1, digest2);
|
||||
Assert.StartsWith("sha256:", digest1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_WithExternalParameters_IncludesInPredicate()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var signer = new StubPackRunAttestationSigner();
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance,
|
||||
signer);
|
||||
|
||||
var externalParams = new Dictionary<string, object>
|
||||
{
|
||||
["manifestUrl"] = "https://registry.example.com/pack/v1",
|
||||
["version"] = "1.0.0"
|
||||
};
|
||||
|
||||
var request = new PackRunAttestationRequest(
|
||||
RunId: "run-009",
|
||||
TenantId: "tenant-1",
|
||||
PlanHash: "sha256:plan123",
|
||||
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc" })],
|
||||
EvidenceSnapshotId: null,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BuilderId: "https://stellaops.io/task-runner/custom",
|
||||
ExternalParameters: externalParams,
|
||||
ResolvedDependencies: null,
|
||||
Metadata: null);
|
||||
|
||||
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Attestation);
|
||||
Assert.Contains("manifestUrl", result.Attestation.PredicateJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_WithResolvedDependencies_IncludesInPredicate()
|
||||
{
|
||||
var store = new InMemoryPackRunAttestationStore();
|
||||
var signer = new StubPackRunAttestationSigner();
|
||||
var service = new PackRunAttestationService(
|
||||
store,
|
||||
NullLogger<PackRunAttestationService>.Instance,
|
||||
signer);
|
||||
|
||||
var dependencies = new List<PackRunDependency>
|
||||
{
|
||||
new("https://registry.example.com/tool/scanner:v1",
|
||||
new Dictionary<string, string> { ["sha256"] = "scanner123" },
|
||||
"scanner",
|
||||
"application/vnd.oci.image.index.v1+json")
|
||||
};
|
||||
|
||||
var request = new PackRunAttestationRequest(
|
||||
RunId: "run-010",
|
||||
TenantId: "tenant-1",
|
||||
PlanHash: "sha256:plan123",
|
||||
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc" })],
|
||||
EvidenceSnapshotId: null,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BuilderId: null,
|
||||
ExternalParameters: null,
|
||||
ResolvedDependencies: dependencies,
|
||||
Metadata: null);
|
||||
|
||||
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Attestation);
|
||||
Assert.Contains("resolvedDependencies", result.Attestation.PredicateJson);
|
||||
Assert.Contains("scanner", result.Attestation.PredicateJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.TaskRunner.Core.AirGap;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class SealedInstallEnforcerTests
|
||||
{
|
||||
private static TaskPackManifest CreateManifest(bool sealedInstall, SealedRequirements? requirements = null)
|
||||
{
|
||||
return new TaskPackManifest
|
||||
{
|
||||
ApiVersion = "taskrunner/v1",
|
||||
Kind = "TaskPack",
|
||||
Metadata = new TaskPackMetadata
|
||||
{
|
||||
Name = "test-pack",
|
||||
Version = "1.0.0"
|
||||
},
|
||||
Spec = new TaskPackSpec
|
||||
{
|
||||
SealedInstall = sealedInstall,
|
||||
SealedRequirements = requirements
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WhenPackDoesNotRequireSealedInstall_ReturnsAllowed()
|
||||
{
|
||||
var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed());
|
||||
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
|
||||
var enforcer = new SealedInstallEnforcer(
|
||||
statusProvider,
|
||||
options,
|
||||
NullLogger<SealedInstallEnforcer>.Instance);
|
||||
|
||||
var manifest = CreateManifest(sealedInstall: false);
|
||||
|
||||
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal("Pack does not require sealed install", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WhenEnforcementDisabled_ReturnsAllowed()
|
||||
{
|
||||
var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed());
|
||||
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = false });
|
||||
var enforcer = new SealedInstallEnforcer(
|
||||
statusProvider,
|
||||
options,
|
||||
NullLogger<SealedInstallEnforcer>.Instance);
|
||||
|
||||
var manifest = CreateManifest(sealedInstall: true);
|
||||
|
||||
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal("Enforcement disabled", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WhenSealedRequiredButEnvironmentNotSealed_ReturnsDenied()
|
||||
{
|
||||
var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed());
|
||||
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
|
||||
var enforcer = new SealedInstallEnforcer(
|
||||
statusProvider,
|
||||
options,
|
||||
NullLogger<SealedInstallEnforcer>.Instance);
|
||||
|
||||
var manifest = CreateManifest(sealedInstall: true);
|
||||
|
||||
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode);
|
||||
Assert.NotNull(result.Violation);
|
||||
Assert.True(result.Violation.RequiredSealed);
|
||||
Assert.False(result.Violation.ActualSealed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WhenSealedRequiredAndEnvironmentSealed_ReturnsAllowed()
|
||||
{
|
||||
var status = new SealedModeStatus(
|
||||
Sealed: true,
|
||||
Mode: "sealed",
|
||||
SealedAt: DateTimeOffset.UtcNow.AddDays(-1),
|
||||
SealedBy: "admin@test.com",
|
||||
BundleVersion: "2025.10.0",
|
||||
BundleDigest: "sha256:abc123",
|
||||
LastAdvisoryUpdate: DateTimeOffset.UtcNow.AddHours(-12),
|
||||
AdvisoryStalenessHours: 12,
|
||||
TimeAnchor: new TimeAnchorInfo(
|
||||
DateTimeOffset.UtcNow.AddHours(-1),
|
||||
"base64signature",
|
||||
Valid: true,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddDays(30)),
|
||||
EgressBlocked: true,
|
||||
NetworkPolicy: "deny-all");
|
||||
|
||||
var statusProvider = new MockAirGapStatusProvider(status);
|
||||
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
|
||||
var enforcer = new SealedInstallEnforcer(
|
||||
statusProvider,
|
||||
options,
|
||||
NullLogger<SealedInstallEnforcer>.Instance);
|
||||
|
||||
var manifest = CreateManifest(sealedInstall: true);
|
||||
|
||||
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal("Sealed install requirements satisfied", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WhenBundleVersionBelowMinimum_ReturnsDenied()
|
||||
{
|
||||
var status = new SealedModeStatus(
|
||||
Sealed: true,
|
||||
Mode: "sealed",
|
||||
SealedAt: DateTimeOffset.UtcNow,
|
||||
SealedBy: null,
|
||||
BundleVersion: "2024.5.0",
|
||||
BundleDigest: null,
|
||||
LastAdvisoryUpdate: DateTimeOffset.UtcNow,
|
||||
AdvisoryStalenessHours: 0,
|
||||
TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, true, DateTimeOffset.UtcNow.AddDays(30)),
|
||||
EgressBlocked: true,
|
||||
NetworkPolicy: null);
|
||||
|
||||
var statusProvider = new MockAirGapStatusProvider(status);
|
||||
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
|
||||
var enforcer = new SealedInstallEnforcer(
|
||||
statusProvider,
|
||||
options,
|
||||
NullLogger<SealedInstallEnforcer>.Instance);
|
||||
|
||||
var requirements = new SealedRequirements(
|
||||
MinBundleVersion: "2025.10.0",
|
||||
MaxAdvisoryStalenessHours: 168,
|
||||
RequireTimeAnchor: true,
|
||||
AllowedOfflineDurationHours: 720,
|
||||
RequireSignatureVerification: true);
|
||||
|
||||
var manifest = CreateManifest(sealedInstall: true, requirements);
|
||||
|
||||
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
|
||||
Assert.NotNull(result.RequirementViolations);
|
||||
Assert.Single(result.RequirementViolations);
|
||||
Assert.Equal("min_bundle_version", result.RequirementViolations[0].Requirement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WhenAdvisoryTooStale_ReturnsDenied()
|
||||
{
|
||||
var status = new SealedModeStatus(
|
||||
Sealed: true,
|
||||
Mode: "sealed",
|
||||
SealedAt: DateTimeOffset.UtcNow,
|
||||
SealedBy: null,
|
||||
BundleVersion: "2025.10.0",
|
||||
BundleDigest: null,
|
||||
LastAdvisoryUpdate: DateTimeOffset.UtcNow.AddHours(-200),
|
||||
AdvisoryStalenessHours: 200,
|
||||
TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, true, DateTimeOffset.UtcNow.AddDays(30)),
|
||||
EgressBlocked: true,
|
||||
NetworkPolicy: null);
|
||||
|
||||
var statusProvider = new MockAirGapStatusProvider(status);
|
||||
var options = Options.Create(new SealedInstallEnforcementOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DenyOnStaleness = true,
|
||||
StalenessGracePeriodHours = 0
|
||||
});
|
||||
var enforcer = new SealedInstallEnforcer(
|
||||
statusProvider,
|
||||
options,
|
||||
NullLogger<SealedInstallEnforcer>.Instance);
|
||||
|
||||
var requirements = new SealedRequirements(
|
||||
MinBundleVersion: null,
|
||||
MaxAdvisoryStalenessHours: 168,
|
||||
RequireTimeAnchor: false,
|
||||
AllowedOfflineDurationHours: 720,
|
||||
RequireSignatureVerification: false);
|
||||
|
||||
var manifest = CreateManifest(sealedInstall: true, requirements);
|
||||
|
||||
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
|
||||
Assert.NotNull(result.RequirementViolations);
|
||||
Assert.Single(result.RequirementViolations);
|
||||
Assert.Equal("max_advisory_staleness_hours", result.RequirementViolations[0].Requirement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WhenTimeAnchorMissing_ReturnsDenied()
|
||||
{
|
||||
var status = new SealedModeStatus(
|
||||
Sealed: true,
|
||||
Mode: "sealed",
|
||||
SealedAt: DateTimeOffset.UtcNow,
|
||||
SealedBy: null,
|
||||
BundleVersion: "2025.10.0",
|
||||
BundleDigest: null,
|
||||
LastAdvisoryUpdate: DateTimeOffset.UtcNow,
|
||||
AdvisoryStalenessHours: 0,
|
||||
TimeAnchor: null, // No time anchor
|
||||
EgressBlocked: true,
|
||||
NetworkPolicy: null);
|
||||
|
||||
var statusProvider = new MockAirGapStatusProvider(status);
|
||||
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
|
||||
var enforcer = new SealedInstallEnforcer(
|
||||
statusProvider,
|
||||
options,
|
||||
NullLogger<SealedInstallEnforcer>.Instance);
|
||||
|
||||
var requirements = new SealedRequirements(
|
||||
MinBundleVersion: null,
|
||||
MaxAdvisoryStalenessHours: 168,
|
||||
RequireTimeAnchor: true,
|
||||
AllowedOfflineDurationHours: 720,
|
||||
RequireSignatureVerification: false);
|
||||
|
||||
var manifest = CreateManifest(sealedInstall: true, requirements);
|
||||
|
||||
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
|
||||
Assert.NotNull(result.RequirementViolations);
|
||||
Assert.Single(result.RequirementViolations);
|
||||
Assert.Equal("require_time_anchor", result.RequirementViolations[0].Requirement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WhenTimeAnchorInvalid_ReturnsDenied()
|
||||
{
|
||||
var status = new SealedModeStatus(
|
||||
Sealed: true,
|
||||
Mode: "sealed",
|
||||
SealedAt: DateTimeOffset.UtcNow,
|
||||
SealedBy: null,
|
||||
BundleVersion: "2025.10.0",
|
||||
BundleDigest: null,
|
||||
LastAdvisoryUpdate: DateTimeOffset.UtcNow,
|
||||
AdvisoryStalenessHours: 0,
|
||||
TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, Valid: false, null),
|
||||
EgressBlocked: true,
|
||||
NetworkPolicy: null);
|
||||
|
||||
var statusProvider = new MockAirGapStatusProvider(status);
|
||||
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
|
||||
var enforcer = new SealedInstallEnforcer(
|
||||
statusProvider,
|
||||
options,
|
||||
NullLogger<SealedInstallEnforcer>.Instance);
|
||||
|
||||
var requirements = new SealedRequirements(
|
||||
MinBundleVersion: null,
|
||||
MaxAdvisoryStalenessHours: 168,
|
||||
RequireTimeAnchor: true,
|
||||
AllowedOfflineDurationHours: 720,
|
||||
RequireSignatureVerification: false);
|
||||
|
||||
var manifest = CreateManifest(sealedInstall: true, requirements);
|
||||
|
||||
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
|
||||
Assert.NotNull(result.RequirementViolations);
|
||||
Assert.Contains(result.RequirementViolations, v => v.Requirement == "require_time_anchor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WhenStatusProviderFails_ReturnsDenied()
|
||||
{
|
||||
var statusProvider = new FailingAirGapStatusProvider();
|
||||
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
|
||||
var enforcer = new SealedInstallEnforcer(
|
||||
statusProvider,
|
||||
options,
|
||||
NullLogger<SealedInstallEnforcer>.Instance);
|
||||
|
||||
var manifest = CreateManifest(sealedInstall: true);
|
||||
|
||||
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode);
|
||||
Assert.Contains("Failed to verify", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeStatus_Unsealed_ReturnsCorrectDefaults()
|
||||
{
|
||||
var status = SealedModeStatus.Unsealed();
|
||||
|
||||
Assert.False(status.Sealed);
|
||||
Assert.Equal("unsealed", status.Mode);
|
||||
Assert.Null(status.SealedAt);
|
||||
Assert.Null(status.BundleVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeStatus_Unavailable_ReturnsCorrectDefaults()
|
||||
{
|
||||
var status = SealedModeStatus.Unavailable();
|
||||
|
||||
Assert.False(status.Sealed);
|
||||
Assert.Equal("unavailable", status.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedRequirements_Default_HasExpectedValues()
|
||||
{
|
||||
var defaults = SealedRequirements.Default;
|
||||
|
||||
Assert.Null(defaults.MinBundleVersion);
|
||||
Assert.Equal(168, defaults.MaxAdvisoryStalenessHours);
|
||||
Assert.True(defaults.RequireTimeAnchor);
|
||||
Assert.Equal(720, defaults.AllowedOfflineDurationHours);
|
||||
Assert.True(defaults.RequireSignatureVerification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforcementResult_CreateAllowed_SetsProperties()
|
||||
{
|
||||
var result = SealedInstallEnforcementResult.CreateAllowed("Test message");
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Null(result.ErrorCode);
|
||||
Assert.Equal("Test message", result.Message);
|
||||
Assert.Null(result.Violation);
|
||||
Assert.Null(result.RequirementViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforcementResult_CreateDenied_SetsProperties()
|
||||
{
|
||||
var violation = new SealedInstallViolation("pack-1", "1.0.0", true, false, "Seal the environment");
|
||||
var result = SealedInstallEnforcementResult.CreateDenied(
|
||||
SealedInstallErrorCodes.SealedInstallViolation,
|
||||
"Denied message",
|
||||
violation);
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode);
|
||||
Assert.Equal("Denied message", result.Message);
|
||||
Assert.NotNull(result.Violation);
|
||||
Assert.Equal("pack-1", result.Violation.PackId);
|
||||
}
|
||||
|
||||
private sealed class MockAirGapStatusProvider : IAirGapStatusProvider
|
||||
{
|
||||
private readonly SealedModeStatus _status;
|
||||
|
||||
public MockAirGapStatusProvider(SealedModeStatus status)
|
||||
{
|
||||
_status = status;
|
||||
}
|
||||
|
||||
public Task<SealedModeStatus> GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_status);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingAirGapStatusProvider : IAirGapStatusProvider
|
||||
{
|
||||
public Task<SealedModeStatus> GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new HttpRequestException("Connection refused");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,15 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TaskRunner.Core.AirGap;
|
||||
using StellaOps.TaskRunner.Core.Attestation;
|
||||
using StellaOps.TaskRunner.Core.Configuration;
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Execution.Simulation;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
using StellaOps.TaskRunner.Infrastructure.AirGap;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
using StellaOps.TaskRunner.WebService;
|
||||
using StellaOps.TaskRunner.WebService.Deprecation;
|
||||
@@ -101,6 +105,28 @@ builder.Services.AddSingleton<IPackRunJobScheduler>(sp => sp.GetRequiredService<
|
||||
builder.Services.AddSingleton<PackRunApprovalDecisionService>();
|
||||
builder.Services.AddApiDeprecation(builder.Configuration);
|
||||
builder.Services.AddSingleton<IDeprecationNotificationService, LoggingDeprecationNotificationService>();
|
||||
|
||||
// Sealed install enforcement (TASKRUN-AIRGAP-57-001)
|
||||
builder.Services.Configure<SealedInstallEnforcementOptions>(
|
||||
builder.Configuration.GetSection("TaskRunner:Enforcement:SealedInstall"));
|
||||
builder.Services.Configure<AirGapStatusProviderOptions>(
|
||||
builder.Configuration.GetSection("TaskRunner:AirGap"));
|
||||
builder.Services.AddHttpClient<IAirGapStatusProvider, HttpAirGapStatusProvider>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AirGapStatusProviderOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
builder.Services.AddSingleton<ISealedInstallEnforcer, SealedInstallEnforcer>();
|
||||
builder.Services.AddSingleton<IPackRunTimelineEventSink, InMemoryPackRunTimelineEventSink>();
|
||||
builder.Services.AddSingleton<IPackRunTimelineEventEmitter, PackRunTimelineEventEmitter>();
|
||||
builder.Services.AddSingleton<ISealedInstallAuditLogger, SealedInstallAuditLogger>();
|
||||
|
||||
// Pack run attestations (TASKRUN-OBS-54-001)
|
||||
builder.Services.AddSingleton<IPackRunAttestationStore, InMemoryPackRunAttestationStore>();
|
||||
builder.Services.AddSingleton<IPackRunAttestationSigner, StubPackRunAttestationSigner>();
|
||||
builder.Services.AddSingleton<IPackRunAttestationService, PackRunAttestationService>();
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -191,6 +217,19 @@ app.MapPost("/api/runs/{runId}/approvals/{approvalId}", HandleApplyApprovalDecis
|
||||
app.MapPost("/v1/task-runner/runs/{runId}/cancel", HandleCancelRun).WithName("CancelRun");
|
||||
app.MapPost("/api/runs/{runId}/cancel", HandleCancelRun).WithName("CancelRunApi");
|
||||
|
||||
// Attestation endpoints (TASKRUN-OBS-54-001)
|
||||
app.MapGet("/v1/task-runner/runs/{runId}/attestations", HandleListAttestations).WithName("ListRunAttestations");
|
||||
app.MapGet("/api/runs/{runId}/attestations", HandleListAttestations).WithName("ListRunAttestationsApi");
|
||||
|
||||
app.MapGet("/v1/task-runner/attestations/{attestationId}", HandleGetAttestation).WithName("GetAttestation");
|
||||
app.MapGet("/api/attestations/{attestationId}", HandleGetAttestation).WithName("GetAttestationApi");
|
||||
|
||||
app.MapGet("/v1/task-runner/attestations/{attestationId}/envelope", HandleGetAttestationEnvelope).WithName("GetAttestationEnvelope");
|
||||
app.MapGet("/api/attestations/{attestationId}/envelope", HandleGetAttestationEnvelope).WithName("GetAttestationEnvelopeApi");
|
||||
|
||||
app.MapPost("/v1/task-runner/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestation");
|
||||
app.MapPost("/api/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestationApi");
|
||||
|
||||
app.MapGet("/.well-known/openapi", (HttpResponse response) =>
|
||||
{
|
||||
var metadata = OpenApiMetadataFactory.Create("/openapi");
|
||||
@@ -212,6 +251,8 @@ async Task<IResult> HandleCreateRun(
|
||||
IPackRunStateStore stateStore,
|
||||
IPackRunLogStore logStore,
|
||||
IPackRunJobScheduler scheduler,
|
||||
ISealedInstallEnforcer sealedInstallEnforcer,
|
||||
ISealedInstallAuditLogger auditLogger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Manifest))
|
||||
@@ -229,6 +270,49 @@ async Task<IResult> HandleCreateRun(
|
||||
return Results.BadRequest(new { error = "Invalid manifest", detail = ex.Message });
|
||||
}
|
||||
|
||||
// TASKRUN-AIRGAP-57-001: Sealed install enforcement
|
||||
var enforcementResult = await sealedInstallEnforcer.EnforceAsync(
|
||||
manifest,
|
||||
request.TenantId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Log the enforcement decision
|
||||
await auditLogger.LogEnforcementAsync(
|
||||
manifest,
|
||||
enforcementResult,
|
||||
request.TenantId,
|
||||
request.RunId,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!enforcementResult.Allowed)
|
||||
{
|
||||
return Results.Json(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = enforcementResult.ErrorCode,
|
||||
message = enforcementResult.Message,
|
||||
details = new
|
||||
{
|
||||
pack_id = manifest.Metadata.Name,
|
||||
pack_version = manifest.Metadata.Version,
|
||||
sealed_install_required = manifest.Spec.SealedInstall,
|
||||
environment_sealed = enforcementResult.Violation?.ActualSealed ?? false,
|
||||
violations = enforcementResult.RequirementViolations?.Select(v => new
|
||||
{
|
||||
requirement = v.Requirement,
|
||||
expected = v.Expected,
|
||||
actual = v.Actual,
|
||||
message = v.Message
|
||||
}),
|
||||
recommendation = enforcementResult.Violation?.Recommendation
|
||||
}
|
||||
},
|
||||
status = "rejected",
|
||||
rejected_at = DateTimeOffset.UtcNow.ToString("O")
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var inputs = ConvertInputs(request.Inputs);
|
||||
var planResult = planner.Plan(manifest, inputs);
|
||||
if (!planResult.Success || planResult.Plan is null)
|
||||
@@ -465,6 +549,138 @@ async Task<IResult> HandleCancelRun(
|
||||
return Results.Accepted($"/v1/task-runner/runs/{runId}", new { status = "cancelled" });
|
||||
}
|
||||
|
||||
// Attestation handlers (TASKRUN-OBS-54-001)
|
||||
async Task<IResult> HandleListAttestations(
|
||||
string runId,
|
||||
[FromHeader(Name = "X-Tenant-ID")] string? tenantId,
|
||||
IPackRunAttestationService attestationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "runId is required." });
|
||||
}
|
||||
|
||||
var effectiveTenantId = tenantId ?? "default";
|
||||
var attestations = await attestationService.ListByRunAsync(effectiveTenantId, runId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
runId,
|
||||
count = attestations.Count,
|
||||
attestations = attestations.Select(a => new
|
||||
{
|
||||
attestationId = a.AttestationId,
|
||||
status = a.Status.ToString().ToLowerInvariant(),
|
||||
predicateType = a.PredicateType,
|
||||
subjectCount = a.Subjects.Count,
|
||||
createdAt = a.CreatedAt.ToString("O"),
|
||||
hasEnvelope = a.Envelope is not null
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async Task<IResult> HandleGetAttestation(
|
||||
string attestationId,
|
||||
IPackRunAttestationService attestationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(attestationId, out var id))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid attestationId format." });
|
||||
}
|
||||
|
||||
var attestation = await attestationService.GetAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
if (attestation is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
attestationId = attestation.AttestationId,
|
||||
tenantId = attestation.TenantId,
|
||||
runId = attestation.RunId,
|
||||
planHash = attestation.PlanHash,
|
||||
status = attestation.Status.ToString().ToLowerInvariant(),
|
||||
predicateType = attestation.PredicateType,
|
||||
subjects = attestation.Subjects.Select(s => new
|
||||
{
|
||||
name = s.Name,
|
||||
digest = s.Digest
|
||||
}),
|
||||
createdAt = attestation.CreatedAt.ToString("O"),
|
||||
evidenceSnapshotId = attestation.EvidenceSnapshotId,
|
||||
error = attestation.Error,
|
||||
metadata = attestation.Metadata
|
||||
});
|
||||
}
|
||||
|
||||
async Task<IResult> HandleGetAttestationEnvelope(
|
||||
string attestationId,
|
||||
IPackRunAttestationService attestationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(attestationId, out var id))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid attestationId format." });
|
||||
}
|
||||
|
||||
var envelope = await attestationService.GetEnvelopeAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
if (envelope is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
payloadType = envelope.PayloadType,
|
||||
payload = envelope.Payload,
|
||||
signatures = envelope.Signatures.Select(s => new
|
||||
{
|
||||
keyid = s.KeyId,
|
||||
sig = s.Sig
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async Task<IResult> HandleVerifyAttestation(
|
||||
string attestationId,
|
||||
[FromBody] VerifyAttestationRequest? request,
|
||||
IPackRunAttestationService attestationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(attestationId, out var id))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid attestationId format." });
|
||||
}
|
||||
|
||||
var expectedSubjects = request?.ExpectedSubjects?.Select(s =>
|
||||
new PackRunAttestationSubject(s.Name, s.Digest ?? new Dictionary<string, string>())).ToList();
|
||||
|
||||
var verifyRequest = new PackRunAttestationVerificationRequest(
|
||||
AttestationId: id,
|
||||
ExpectedSubjects: expectedSubjects,
|
||||
VerifySignature: request?.VerifySignature ?? true,
|
||||
VerifySubjects: request?.VerifySubjects ?? (expectedSubjects is not null),
|
||||
CheckRevocation: request?.CheckRevocation ?? true);
|
||||
|
||||
var result = await attestationService.VerifyAsync(verifyRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var statusCode = result.Valid ? 200 : 400;
|
||||
return Results.Json(new
|
||||
{
|
||||
valid = result.Valid,
|
||||
attestationId = result.AttestationId,
|
||||
signatureStatus = result.SignatureStatus.ToString().ToLowerInvariant(),
|
||||
subjectStatus = result.SubjectStatus.ToString().ToLowerInvariant(),
|
||||
revocationStatus = result.RevocationStatus.ToString().ToLowerInvariant(),
|
||||
errors = result.Errors,
|
||||
verifiedAt = result.VerifiedAt.ToString("O")
|
||||
}, statusCode: statusCode);
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
static IDictionary<string, JsonNode?>? ConvertInputs(JsonObject? node)
|
||||
@@ -487,6 +703,15 @@ internal sealed record CreateRunRequest(string? RunId, string Manifest, JsonObje
|
||||
|
||||
internal sealed record SimulationRequest(string Manifest, JsonObject? Inputs);
|
||||
|
||||
// Attestation API request models (TASKRUN-OBS-54-001)
|
||||
internal sealed record VerifyAttestationRequest(
|
||||
IReadOnlyList<VerifyAttestationSubject>? ExpectedSubjects,
|
||||
bool VerifySignature = true,
|
||||
bool VerifySubjects = false,
|
||||
bool CheckRevocation = true);
|
||||
|
||||
internal sealed record VerifyAttestationSubject(string Name, IReadOnlyDictionary<string, string>? Digest);
|
||||
|
||||
internal sealed record SimulationResponse(
|
||||
string PlanHash,
|
||||
FailurePolicyResponse FailurePolicy,
|
||||
|
||||
@@ -91,6 +91,12 @@
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.cjs",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/app/features/policy-studio/editor/monaco-loader.service.ts",
|
||||
"with": "src/app/features/policy-studio/editor/monaco-loader.service.stub.ts"
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Test-only stub to prevent Monaco workers/styles from loading during Karma runs.
|
||||
// Keeps the same public contract as the real MonacoLoaderService but returns a
|
||||
// lightweight in-memory implementation.
|
||||
export class MonacoLoaderService {
|
||||
private readonly monaco = {
|
||||
editor: {
|
||||
createModel: (value: string) => {
|
||||
let current = value;
|
||||
return {
|
||||
getValue: () => current,
|
||||
setValue: (v: string) => (current = v),
|
||||
dispose: () => undefined,
|
||||
};
|
||||
},
|
||||
create: () => ({
|
||||
onDidChangeModelContent: (_cb: () => void) => ({ dispose: () => undefined }),
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
setModelMarkers: (_model: unknown, _owner: string, _markers: any[]) => undefined,
|
||||
setTheme: () => undefined,
|
||||
},
|
||||
languages: {
|
||||
register: () => undefined,
|
||||
setMonarchTokensProvider: () => undefined,
|
||||
setLanguageConfiguration: () => undefined,
|
||||
},
|
||||
MarkerSeverity: { Error: 8, Warning: 4, Info: 2 },
|
||||
};
|
||||
|
||||
load(): Promise<any> {
|
||||
return Promise.resolve(this.monaco);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user