feat: Implement IsolatedReplayContext for deterministic audit replay
- Added IsolatedReplayContext class to provide an isolated environment for replaying audit bundles without external calls. - Introduced methods for initializing the context, verifying input digests, and extracting inputs for policy evaluation. - Created supporting interfaces and options for context configuration. feat: Create ReplayExecutor for executing policy re-evaluation and verdict comparison - Developed ReplayExecutor class to handle the execution of replay processes, including input verification and verdict comparison. - Implemented detailed drift detection and error handling during replay execution. - Added interfaces for policy evaluation and replay execution options. feat: Add ScanSnapshotFetcher for fetching scan data and snapshots - Introduced ScanSnapshotFetcher class to retrieve necessary scan data and snapshots for audit bundle creation. - Implemented methods to fetch scan metadata, advisory feeds, policy snapshots, and VEX statements. - Created supporting interfaces for scan data, feed snapshots, and policy snapshots.
This commit is contained in:
292
src/Policy/StellaOps.Policy.Gateway/Contracts/DeltaContracts.cs
Normal file
292
src/Policy/StellaOps.Policy.Gateway/Contracts/DeltaContracts.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_4100_0004_0001 - Security State Delta & Verdict
|
||||
// Task: T6 - Add Delta API endpoints
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Policy.Deltas;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to compute a security state delta.
|
||||
/// </summary>
|
||||
public sealed record ComputeDeltaRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact digest (required).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact name (optional).
|
||||
/// </summary>
|
||||
public string? ArtifactName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact tag (optional).
|
||||
/// </summary>
|
||||
public string? ArtifactTag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target snapshot ID (required).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string TargetSnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Explicit baseline snapshot ID (optional).
|
||||
/// If not provided, baseline selection strategy is used.
|
||||
/// </summary>
|
||||
public string? BaselineSnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Baseline selection strategy (optional, defaults to LastApproved).
|
||||
/// Values: PreviousBuild, LastApproved, ProductionDeployed, BranchBase
|
||||
/// </summary>
|
||||
public string? BaselineStrategy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from computing a security state delta.
|
||||
/// </summary>
|
||||
public sealed record ComputeDeltaResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The computed delta ID.
|
||||
/// </summary>
|
||||
public required string DeltaId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Baseline snapshot ID used.
|
||||
/// </summary>
|
||||
public required string BaselineSnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target snapshot ID.
|
||||
/// </summary>
|
||||
public required string TargetSnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the delta was computed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics.
|
||||
/// </summary>
|
||||
public required DeltaSummaryDto Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of drivers identified.
|
||||
/// </summary>
|
||||
public int DriverCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics DTO.
|
||||
/// </summary>
|
||||
public sealed record DeltaSummaryDto
|
||||
{
|
||||
public int TotalChanges { get; init; }
|
||||
public int RiskIncreasing { get; init; }
|
||||
public int RiskDecreasing { get; init; }
|
||||
public int Neutral { get; init; }
|
||||
public decimal RiskScore { get; init; }
|
||||
public required string RiskDirection { get; init; }
|
||||
|
||||
public static DeltaSummaryDto FromModel(DeltaSummary summary) => new()
|
||||
{
|
||||
TotalChanges = summary.TotalChanges,
|
||||
RiskIncreasing = summary.RiskIncreasing,
|
||||
RiskDecreasing = summary.RiskDecreasing,
|
||||
Neutral = summary.Neutral,
|
||||
RiskScore = summary.RiskScore,
|
||||
RiskDirection = summary.RiskDirection
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full delta response DTO.
|
||||
/// </summary>
|
||||
public sealed record DeltaResponse
|
||||
{
|
||||
public required string DeltaId { get; init; }
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
public required string BaselineSnapshotId { get; init; }
|
||||
public required string TargetSnapshotId { get; init; }
|
||||
public required ArtifactRefDto Artifact { get; init; }
|
||||
public required SbomDeltaDto Sbom { get; init; }
|
||||
public required ReachabilityDeltaDto Reachability { get; init; }
|
||||
public required VexDeltaDto Vex { get; init; }
|
||||
public required PolicyDeltaDto Policy { get; init; }
|
||||
public required UnknownsDeltaDto Unknowns { get; init; }
|
||||
public required IReadOnlyList<DeltaDriverDto> Drivers { get; init; }
|
||||
public required DeltaSummaryDto Summary { get; init; }
|
||||
|
||||
public static DeltaResponse FromModel(SecurityStateDelta delta) => new()
|
||||
{
|
||||
DeltaId = delta.DeltaId,
|
||||
ComputedAt = delta.ComputedAt,
|
||||
BaselineSnapshotId = delta.BaselineSnapshotId,
|
||||
TargetSnapshotId = delta.TargetSnapshotId,
|
||||
Artifact = ArtifactRefDto.FromModel(delta.Artifact),
|
||||
Sbom = SbomDeltaDto.FromModel(delta.Sbom),
|
||||
Reachability = ReachabilityDeltaDto.FromModel(delta.Reachability),
|
||||
Vex = VexDeltaDto.FromModel(delta.Vex),
|
||||
Policy = PolicyDeltaDto.FromModel(delta.Policy),
|
||||
Unknowns = UnknownsDeltaDto.FromModel(delta.Unknowns),
|
||||
Drivers = delta.Drivers.Select(DeltaDriverDto.FromModel).ToList(),
|
||||
Summary = DeltaSummaryDto.FromModel(delta.Summary)
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record ArtifactRefDto
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
|
||||
public static ArtifactRefDto FromModel(ArtifactRef artifact) => new()
|
||||
{
|
||||
Digest = artifact.Digest,
|
||||
Name = artifact.Name,
|
||||
Tag = artifact.Tag
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record SbomDeltaDto
|
||||
{
|
||||
public int PackagesAdded { get; init; }
|
||||
public int PackagesRemoved { get; init; }
|
||||
public int PackagesModified { get; init; }
|
||||
|
||||
public static SbomDeltaDto FromModel(SbomDelta sbom) => new()
|
||||
{
|
||||
PackagesAdded = sbom.PackagesAdded,
|
||||
PackagesRemoved = sbom.PackagesRemoved,
|
||||
PackagesModified = sbom.PackagesModified
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record ReachabilityDeltaDto
|
||||
{
|
||||
public int NewReachable { get; init; }
|
||||
public int NewUnreachable { get; init; }
|
||||
public int ChangedReachability { get; init; }
|
||||
|
||||
public static ReachabilityDeltaDto FromModel(ReachabilityDelta reach) => new()
|
||||
{
|
||||
NewReachable = reach.NewReachable,
|
||||
NewUnreachable = reach.NewUnreachable,
|
||||
ChangedReachability = reach.ChangedReachability
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record VexDeltaDto
|
||||
{
|
||||
public int NewVexStatements { get; init; }
|
||||
public int RevokedVexStatements { get; init; }
|
||||
public int CoverageIncrease { get; init; }
|
||||
public int CoverageDecrease { get; init; }
|
||||
|
||||
public static VexDeltaDto FromModel(VexDelta vex) => new()
|
||||
{
|
||||
NewVexStatements = vex.NewVexStatements,
|
||||
RevokedVexStatements = vex.RevokedVexStatements,
|
||||
CoverageIncrease = vex.CoverageIncrease,
|
||||
CoverageDecrease = vex.CoverageDecrease
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record PolicyDeltaDto
|
||||
{
|
||||
public int NewViolations { get; init; }
|
||||
public int ResolvedViolations { get; init; }
|
||||
public int PolicyVersionChanged { get; init; }
|
||||
|
||||
public static PolicyDeltaDto FromModel(PolicyDelta policy) => new()
|
||||
{
|
||||
NewViolations = policy.NewViolations,
|
||||
ResolvedViolations = policy.ResolvedViolations,
|
||||
PolicyVersionChanged = policy.PolicyVersionChanged
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record UnknownsDeltaDto
|
||||
{
|
||||
public int NewUnknowns { get; init; }
|
||||
public int ResolvedUnknowns { get; init; }
|
||||
public int TotalBaselineUnknowns { get; init; }
|
||||
public int TotalTargetUnknowns { get; init; }
|
||||
|
||||
public static UnknownsDeltaDto FromModel(UnknownsDelta unknowns) => new()
|
||||
{
|
||||
NewUnknowns = unknowns.NewUnknowns,
|
||||
ResolvedUnknowns = unknowns.ResolvedUnknowns,
|
||||
TotalBaselineUnknowns = unknowns.TotalBaselineUnknowns,
|
||||
TotalTargetUnknowns = unknowns.TotalTargetUnknowns
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record DeltaDriverDto
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
|
||||
public static DeltaDriverDto FromModel(DeltaDriver driver) => new()
|
||||
{
|
||||
Type = driver.Type,
|
||||
Severity = driver.Severity.ToString().ToLowerInvariant(),
|
||||
Description = driver.Description,
|
||||
CveId = driver.CveId,
|
||||
Purl = driver.Purl
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate a delta verdict.
|
||||
/// </summary>
|
||||
public sealed record EvaluateDeltaRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Exception IDs to apply.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Exceptions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delta verdict response DTO.
|
||||
/// </summary>
|
||||
public sealed record DeltaVerdictResponse
|
||||
{
|
||||
public required string VerdictId { get; init; }
|
||||
public required string DeltaId { get; init; }
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string RecommendedGate { get; init; }
|
||||
public int RiskPoints { get; init; }
|
||||
public required IReadOnlyList<DeltaDriverDto> BlockingDrivers { get; init; }
|
||||
public required IReadOnlyList<DeltaDriverDto> WarningDrivers { get; init; }
|
||||
public required IReadOnlyList<string> AppliedExceptions { get; init; }
|
||||
public string? Explanation { get; init; }
|
||||
public required IReadOnlyList<string> Recommendations { get; init; }
|
||||
|
||||
public static DeltaVerdictResponse FromModel(DeltaVerdict verdict) => new()
|
||||
{
|
||||
VerdictId = verdict.VerdictId,
|
||||
DeltaId = verdict.DeltaId,
|
||||
EvaluatedAt = verdict.EvaluatedAt,
|
||||
Status = verdict.Status.ToString().ToLowerInvariant(),
|
||||
RecommendedGate = verdict.RecommendedGate.ToString(),
|
||||
RiskPoints = verdict.RiskPoints,
|
||||
BlockingDrivers = verdict.BlockingDrivers.Select(DeltaDriverDto.FromModel).ToList(),
|
||||
WarningDrivers = verdict.WarningDrivers.Select(DeltaDriverDto.FromModel).ToList(),
|
||||
AppliedExceptions = verdict.AppliedExceptions.ToList(),
|
||||
Explanation = verdict.Explanation,
|
||||
Recommendations = verdict.Recommendations.ToList()
|
||||
};
|
||||
}
|
||||
373
src/Policy/StellaOps.Policy.Gateway/Endpoints/DeltasEndpoints.cs
Normal file
373
src/Policy/StellaOps.Policy.Gateway/Endpoints/DeltasEndpoints.cs
Normal file
@@ -0,0 +1,373 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_4100_0004_0001 - Security State Delta & Verdict
|
||||
// Task: T6 - Add Delta API endpoints
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Policy.Deltas;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Delta API endpoints for Policy Gateway.
|
||||
/// </summary>
|
||||
public static class DeltasEndpoints
|
||||
{
|
||||
private const string DeltaCachePrefix = "delta:";
|
||||
private static readonly TimeSpan DeltaCacheDuration = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Maps delta endpoints to the application.
|
||||
/// </summary>
|
||||
public static void MapDeltasEndpoints(this WebApplication app)
|
||||
{
|
||||
var deltas = app.MapGroup("/api/policy/deltas")
|
||||
.WithTags("Deltas");
|
||||
|
||||
// POST /api/policy/deltas/compute - Compute a security state delta
|
||||
deltas.MapPost("/compute", async Task<IResult>(
|
||||
ComputeDeltaRequest request,
|
||||
IDeltaComputer deltaComputer,
|
||||
IBaselineSelector baselineSelector,
|
||||
IMemoryCache cache,
|
||||
ILogger<DeltaComputer> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required",
|
||||
Status = 400
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Artifact digest required",
|
||||
Status = 400
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TargetSnapshotId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Target snapshot ID required",
|
||||
Status = 400
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Select baseline
|
||||
BaselineSelectionResult baselineResult;
|
||||
if (!string.IsNullOrWhiteSpace(request.BaselineSnapshotId))
|
||||
{
|
||||
baselineResult = await baselineSelector.SelectExplicitAsync(
|
||||
request.BaselineSnapshotId,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
var strategy = ParseStrategy(request.BaselineStrategy);
|
||||
baselineResult = await baselineSelector.SelectBaselineAsync(
|
||||
request.ArtifactDigest,
|
||||
strategy,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (!baselineResult.IsFound)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Baseline not found",
|
||||
Status = 404,
|
||||
Detail = baselineResult.Error
|
||||
});
|
||||
}
|
||||
|
||||
// Compute delta
|
||||
var delta = await deltaComputer.ComputeDeltaAsync(
|
||||
baselineResult.Snapshot!.SnapshotId,
|
||||
request.TargetSnapshotId,
|
||||
new ArtifactRef(
|
||||
request.ArtifactDigest,
|
||||
request.ArtifactName,
|
||||
request.ArtifactTag),
|
||||
cancellationToken);
|
||||
|
||||
// Cache the delta for subsequent retrieval
|
||||
cache.Set(
|
||||
DeltaCachePrefix + delta.DeltaId,
|
||||
delta,
|
||||
DeltaCacheDuration);
|
||||
|
||||
logger.LogInformation(
|
||||
"Computed delta {DeltaId} between {Baseline} and {Target}",
|
||||
delta.DeltaId, delta.BaselineSnapshotId, delta.TargetSnapshotId);
|
||||
|
||||
return Results.Ok(new ComputeDeltaResponse
|
||||
{
|
||||
DeltaId = delta.DeltaId,
|
||||
BaselineSnapshotId = delta.BaselineSnapshotId,
|
||||
TargetSnapshotId = delta.TargetSnapshotId,
|
||||
ComputedAt = delta.ComputedAt,
|
||||
Summary = DeltaSummaryDto.FromModel(delta.Summary),
|
||||
DriverCount = delta.Drivers.Count
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Snapshot not found",
|
||||
Status = 404,
|
||||
Detail = ex.Message
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
|
||||
|
||||
// GET /api/policy/deltas/{deltaId} - Get a delta by ID
|
||||
deltas.MapGet("/{deltaId}", async Task<IResult>(
|
||||
string deltaId,
|
||||
IMemoryCache cache,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deltaId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Delta ID required",
|
||||
Status = 400
|
||||
});
|
||||
}
|
||||
|
||||
// Try to retrieve from cache
|
||||
if (!cache.TryGetValue(DeltaCachePrefix + deltaId, out SecurityStateDelta? delta) || delta is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Delta not found",
|
||||
Status = 404,
|
||||
Detail = $"No delta found with ID: {deltaId}. Deltas are cached for {DeltaCacheDuration.TotalMinutes} minutes after computation."
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(DeltaResponse.FromModel(delta));
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
||||
|
||||
// POST /api/policy/deltas/{deltaId}/evaluate - Evaluate delta and get verdict
|
||||
deltas.MapPost("/{deltaId}/evaluate", async Task<IResult>(
|
||||
string deltaId,
|
||||
EvaluateDeltaRequest? request,
|
||||
IMemoryCache cache,
|
||||
ILogger<DeltaComputer> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deltaId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Delta ID required",
|
||||
Status = 400
|
||||
});
|
||||
}
|
||||
|
||||
// Try to retrieve delta from cache
|
||||
if (!cache.TryGetValue(DeltaCachePrefix + deltaId, out SecurityStateDelta? delta) || delta is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Delta not found",
|
||||
Status = 404,
|
||||
Detail = $"No delta found with ID: {deltaId}"
|
||||
});
|
||||
}
|
||||
|
||||
// Build verdict from delta drivers
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
|
||||
// Apply risk points based on summary
|
||||
builder.WithRiskPoints((int)delta.Summary.RiskScore);
|
||||
|
||||
// Categorize drivers as blocking or warning
|
||||
foreach (var driver in delta.Drivers)
|
||||
{
|
||||
if (IsBlockingDriver(driver))
|
||||
{
|
||||
builder.AddBlockingDriver(driver);
|
||||
}
|
||||
else if (driver.Severity >= DeltaDriverSeverity.Medium)
|
||||
{
|
||||
builder.AddWarningDriver(driver);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply exceptions if provided
|
||||
if (request?.Exceptions is not null)
|
||||
{
|
||||
foreach (var exceptionId in request.Exceptions)
|
||||
{
|
||||
builder.AddException(exceptionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Add recommendations based on drivers
|
||||
AddRecommendations(builder, delta.Drivers);
|
||||
|
||||
var verdict = builder.Build(deltaId);
|
||||
|
||||
// Cache the verdict
|
||||
cache.Set(
|
||||
DeltaCachePrefix + deltaId + ":verdict",
|
||||
verdict,
|
||||
DeltaCacheDuration);
|
||||
|
||||
logger.LogInformation(
|
||||
"Evaluated delta {DeltaId}: status={Status}, gate={Gate}",
|
||||
deltaId, verdict.Status, verdict.RecommendedGate);
|
||||
|
||||
return Results.Ok(DeltaVerdictResponse.FromModel(verdict));
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
|
||||
|
||||
// GET /api/policy/deltas/{deltaId}/attestation - Get signed attestation
|
||||
deltas.MapGet("/{deltaId}/attestation", async Task<IResult>(
|
||||
string deltaId,
|
||||
IMemoryCache cache,
|
||||
IDeltaVerdictAttestor? attestor,
|
||||
ILogger<DeltaComputer> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deltaId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Delta ID required",
|
||||
Status = 400
|
||||
});
|
||||
}
|
||||
|
||||
// Try to retrieve delta from cache
|
||||
if (!cache.TryGetValue(DeltaCachePrefix + deltaId, out SecurityStateDelta? delta) || delta is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Delta not found",
|
||||
Status = 404,
|
||||
Detail = $"No delta found with ID: {deltaId}"
|
||||
});
|
||||
}
|
||||
|
||||
// Try to retrieve verdict from cache
|
||||
if (!cache.TryGetValue(DeltaCachePrefix + deltaId + ":verdict", out DeltaVerdict? verdict) || verdict is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Verdict not found",
|
||||
Status = 404,
|
||||
Detail = "Delta must be evaluated before attestation can be generated. Call POST /evaluate first."
|
||||
});
|
||||
}
|
||||
|
||||
if (attestor is null)
|
||||
{
|
||||
return Results.Problem(new ProblemDetails
|
||||
{
|
||||
Title = "Attestor not configured",
|
||||
Status = 501,
|
||||
Detail = "Delta verdict attestation requires a signer to be configured"
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var envelope = await attestor.AttestAsync(delta, verdict, cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Created attestation for delta {DeltaId} verdict {VerdictId}",
|
||||
deltaId, verdict.VerdictId);
|
||||
|
||||
return Results.Ok(envelope);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create attestation for delta {DeltaId}", deltaId);
|
||||
return Results.Problem(new ProblemDetails
|
||||
{
|
||||
Title = "Attestation failed",
|
||||
Status = 500,
|
||||
Detail = "Failed to create signed attestation"
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
||||
}
|
||||
|
||||
private static BaselineSelectionStrategy ParseStrategy(string? strategy)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(strategy))
|
||||
return BaselineSelectionStrategy.LastApproved;
|
||||
|
||||
return strategy.ToLowerInvariant() switch
|
||||
{
|
||||
"previousbuild" or "previous_build" or "previous-build" => BaselineSelectionStrategy.PreviousBuild,
|
||||
"lastapproved" or "last_approved" or "last-approved" => BaselineSelectionStrategy.LastApproved,
|
||||
"productiondeployed" or "production_deployed" or "production-deployed" or "production" => BaselineSelectionStrategy.ProductionDeployed,
|
||||
"branchbase" or "branch_base" or "branch-base" => BaselineSelectionStrategy.BranchBase,
|
||||
_ => BaselineSelectionStrategy.LastApproved
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsBlockingDriver(DeltaDriver driver)
|
||||
{
|
||||
// Block on critical/high severity negative drivers
|
||||
if (driver.Severity is DeltaDriverSeverity.Critical or DeltaDriverSeverity.High)
|
||||
{
|
||||
// These types indicate risk increase
|
||||
return driver.Type is
|
||||
"new-reachable-cve" or
|
||||
"lost-vex-coverage" or
|
||||
"vex-status-downgrade" or
|
||||
"new-policy-violation";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AddRecommendations(DeltaVerdictBuilder builder, IReadOnlyList<DeltaDriver> drivers)
|
||||
{
|
||||
var hasReachableCve = drivers.Any(d => d.Type == "new-reachable-cve");
|
||||
var hasLostVex = drivers.Any(d => d.Type == "lost-vex-coverage");
|
||||
var hasNewViolation = drivers.Any(d => d.Type == "new-policy-violation");
|
||||
var hasNewUnknowns = drivers.Any(d => d.Type == "new-unknowns");
|
||||
|
||||
if (hasReachableCve)
|
||||
{
|
||||
builder.AddRecommendation("Review new reachable CVEs and apply VEX statements or patches");
|
||||
}
|
||||
|
||||
if (hasLostVex)
|
||||
{
|
||||
builder.AddRecommendation("Investigate lost VEX coverage - statements may have expired or been revoked");
|
||||
}
|
||||
|
||||
if (hasNewViolation)
|
||||
{
|
||||
builder.AddRecommendation("Address policy violations or request exceptions");
|
||||
}
|
||||
|
||||
if (hasNewUnknowns)
|
||||
{
|
||||
builder.AddRecommendation("Investigate new unknown packages - consider adding SBOM metadata");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ using StellaOps.Policy.Gateway.Endpoints;
|
||||
using StellaOps.Policy.Gateway.Infrastructure;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using StellaOps.Policy.Deltas;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
@@ -119,6 +121,12 @@ builder.Services.AddScoped<IApprovalWorkflowService, ApprovalWorkflowService>();
|
||||
builder.Services.AddSingleton<IExceptionNotificationService, NoOpExceptionNotificationService>();
|
||||
builder.Services.AddHostedService<ExceptionExpiryWorker>();
|
||||
|
||||
// Delta services
|
||||
builder.Services.AddScoped<IDeltaComputer, DeltaComputer>();
|
||||
builder.Services.AddScoped<IBaselineSelector, BaselineSelector>();
|
||||
builder.Services.AddScoped<ISnapshotStore, InMemorySnapshotStore>();
|
||||
builder.Services.AddScoped<StellaOps.Policy.Deltas.ISnapshotService, DeltaSnapshotServiceAdapter>();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
|
||||
@@ -486,6 +494,9 @@ cvss.MapGet("/policies", async Task<IResult>(
|
||||
// Exception management endpoints
|
||||
app.MapExceptionEndpoints();
|
||||
|
||||
// Delta management endpoints
|
||||
app.MapDeltasEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_4100_0004_0001 - Security State Delta & Verdict
|
||||
// Task: T6 - Add Delta API endpoints
|
||||
|
||||
using StellaOps.Policy.Deltas;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter that bridges between the KnowledgeSnapshotManifest-based snapshot store
|
||||
/// and the SnapshotData interface required by the DeltaComputer.
|
||||
/// </summary>
|
||||
public sealed class DeltaSnapshotServiceAdapter : StellaOps.Policy.Deltas.ISnapshotService
|
||||
{
|
||||
private readonly ISnapshotStore _snapshotStore;
|
||||
private readonly ILogger<DeltaSnapshotServiceAdapter> _logger;
|
||||
|
||||
public DeltaSnapshotServiceAdapter(
|
||||
ISnapshotStore snapshotStore,
|
||||
ILogger<DeltaSnapshotServiceAdapter> logger)
|
||||
{
|
||||
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets snapshot data by ID, converting from KnowledgeSnapshotManifest.
|
||||
/// </summary>
|
||||
public async Task<SnapshotData?> GetSnapshotAsync(string snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshotId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var manifest = await _snapshotStore.GetAsync(snapshotId, ct).ConfigureAwait(false);
|
||||
if (manifest is null)
|
||||
{
|
||||
_logger.LogDebug("Snapshot {SnapshotId} not found in store", snapshotId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return ConvertToSnapshotData(manifest);
|
||||
}
|
||||
|
||||
private static SnapshotData ConvertToSnapshotData(KnowledgeSnapshotManifest manifest)
|
||||
{
|
||||
// Get policy version from manifest sources
|
||||
var policySource = manifest.Sources.FirstOrDefault(s => s.Type == KnowledgeSourceTypes.Policy);
|
||||
var policyVersion = policySource?.Digest;
|
||||
|
||||
// Note: In a full implementation, we would fetch and parse the bundled content
|
||||
// from each source to extract packages, reachability, VEX statements, etc.
|
||||
// For now, we return the manifest metadata only.
|
||||
return new SnapshotData
|
||||
{
|
||||
SnapshotId = manifest.SnapshotId,
|
||||
Packages = [],
|
||||
Reachability = [],
|
||||
VexStatements = [],
|
||||
PolicyViolations = [],
|
||||
Unknowns = [],
|
||||
PolicyVersion = policyVersion
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
<ProjectReference Include="../StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_5200_0001_0001 - Starter Policy Template
|
||||
// Task: T7 - Policy Pack Distribution
|
||||
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Distribution;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for publishing policy packs to OCI registries.
|
||||
/// </summary>
|
||||
public interface IPolicyPackOciPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a policy pack to an OCI registry.
|
||||
/// </summary>
|
||||
Task<PolicyPackPushResult> PushAsync(
|
||||
PolicyPackPushRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pulls a policy pack from an OCI registry.
|
||||
/// </summary>
|
||||
Task<PolicyPackPullResult> PullAsync(
|
||||
string reference,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists available policy pack versions in a repository.
|
||||
/// </summary>
|
||||
Task<PolicyPackTagList> ListTagsAsync(
|
||||
string repository,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to push a policy pack to OCI registry.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackPushRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// OCI reference (e.g., registry.example.com/policies/starter-day1:1.0.0).
|
||||
/// </summary>
|
||||
public required string Reference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack content as YAML.
|
||||
/// </summary>
|
||||
public required byte[] PackContent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack name.
|
||||
/// </summary>
|
||||
public required string PackName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack version.
|
||||
/// </summary>
|
||||
public required string PackVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional environment overrides to include.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, byte[]>? Overrides { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional DSSE attestation envelope to include.
|
||||
/// </summary>
|
||||
public byte[]? Attestation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional annotations to include in the manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pushing a policy pack to OCI registry.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackPushResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? ManifestDigest { get; init; }
|
||||
public string? ManifestReference { get; init; }
|
||||
public IReadOnlyList<string>? LayerDigests { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static PolicyPackPushResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pulling a policy pack from OCI registry.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackPullResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? ManifestDigest { get; init; }
|
||||
public byte[]? PackContent { get; init; }
|
||||
public string? PackName { get; init; }
|
||||
public string? PackVersion { get; init; }
|
||||
public IReadOnlyDictionary<string, byte[]>? Overrides { get; init; }
|
||||
public byte[]? Attestation { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static PolicyPackPullResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of available policy pack tags in a repository.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackTagList
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required string Repository { get; init; }
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
541
src/Policy/__Libraries/StellaOps.Policy/Deltas/DeltaComputer.cs
Normal file
541
src/Policy/__Libraries/StellaOps.Policy/Deltas/DeltaComputer.cs
Normal file
@@ -0,0 +1,541 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_4100_0004_0001 - Security State Delta & Verdict
|
||||
// Task: T3 - Implement DeltaComputer
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Deltas;
|
||||
|
||||
/// <summary>
|
||||
/// Computes security state deltas between baseline and target snapshots.
|
||||
/// </summary>
|
||||
public sealed class DeltaComputer : IDeltaComputer
|
||||
{
|
||||
private readonly ISnapshotService _snapshotService;
|
||||
private readonly ILogger<DeltaComputer> _logger;
|
||||
|
||||
public DeltaComputer(
|
||||
ISnapshotService snapshotService,
|
||||
ILogger<DeltaComputer> logger)
|
||||
{
|
||||
_snapshotService = snapshotService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SecurityStateDelta> ComputeDeltaAsync(
|
||||
string baselineSnapshotId,
|
||||
string targetSnapshotId,
|
||||
ArtifactRef artifact,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Computing delta between {Baseline} and {Target} for artifact {Artifact}",
|
||||
baselineSnapshotId, targetSnapshotId, artifact.Digest);
|
||||
|
||||
// Load snapshots
|
||||
var baseline = await _snapshotService.GetSnapshotAsync(baselineSnapshotId, ct)
|
||||
?? throw new InvalidOperationException($"Baseline snapshot {baselineSnapshotId} not found");
|
||||
var target = await _snapshotService.GetSnapshotAsync(targetSnapshotId, ct)
|
||||
?? throw new InvalidOperationException($"Target snapshot {targetSnapshotId} not found");
|
||||
|
||||
// Compute component deltas
|
||||
var sbomDelta = ComputeSbomDelta(baseline, target);
|
||||
var reachabilityDelta = ComputeReachabilityDelta(baseline, target);
|
||||
var vexDelta = ComputeVexDelta(baseline, target);
|
||||
var policyDelta = ComputePolicyDelta(baseline, target);
|
||||
var unknownsDelta = ComputeUnknownsDelta(baseline, target);
|
||||
|
||||
// Identify drivers
|
||||
var drivers = IdentifyDrivers(sbomDelta, reachabilityDelta, vexDelta, policyDelta, unknownsDelta);
|
||||
|
||||
// Compute summary
|
||||
var summary = ComputeSummary(sbomDelta, reachabilityDelta, vexDelta, policyDelta, drivers);
|
||||
|
||||
var delta = new SecurityStateDelta
|
||||
{
|
||||
DeltaId = "", // Computed below
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
BaselineSnapshotId = baselineSnapshotId,
|
||||
TargetSnapshotId = targetSnapshotId,
|
||||
Artifact = artifact,
|
||||
Sbom = sbomDelta,
|
||||
Reachability = reachabilityDelta,
|
||||
Vex = vexDelta,
|
||||
Policy = policyDelta,
|
||||
Unknowns = unknownsDelta,
|
||||
Drivers = drivers,
|
||||
Summary = summary
|
||||
};
|
||||
|
||||
// Compute content-addressed ID
|
||||
var deltaId = ComputeDeltaId(delta);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Computed delta {DeltaId} with {DriverCount} drivers, risk direction: {RiskDirection}",
|
||||
deltaId, drivers.Count, summary.RiskDirection);
|
||||
|
||||
return delta with { DeltaId = deltaId };
|
||||
}
|
||||
|
||||
private SbomDelta ComputeSbomDelta(SnapshotData baseline, SnapshotData target)
|
||||
{
|
||||
var baselinePackages = baseline.Packages.ToDictionary(p => p.Purl);
|
||||
var targetPackages = target.Packages.ToDictionary(p => p.Purl);
|
||||
|
||||
var addedPackages = new List<PackageChange>();
|
||||
var removedPackages = new List<PackageChange>();
|
||||
var versionChanges = new List<PackageVersionChange>();
|
||||
|
||||
// Find added packages
|
||||
foreach (var (purl, pkg) in targetPackages)
|
||||
{
|
||||
if (!baselinePackages.ContainsKey(purl))
|
||||
{
|
||||
addedPackages.Add(new PackageChange(purl, pkg.License));
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed packages
|
||||
foreach (var (purl, pkg) in baselinePackages)
|
||||
{
|
||||
if (!targetPackages.ContainsKey(purl))
|
||||
{
|
||||
removedPackages.Add(new PackageChange(purl, pkg.License));
|
||||
}
|
||||
}
|
||||
|
||||
// Find version changes (same package name, different version in PURL)
|
||||
foreach (var (purl, targetPkg) in targetPackages)
|
||||
{
|
||||
if (baselinePackages.TryGetValue(purl, out var baselinePkg))
|
||||
{
|
||||
if (targetPkg.Version != baselinePkg.Version)
|
||||
{
|
||||
versionChanges.Add(new PackageVersionChange(
|
||||
purl,
|
||||
baselinePkg.Version ?? "unknown",
|
||||
targetPkg.Version ?? "unknown"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SbomDelta
|
||||
{
|
||||
PackagesAdded = addedPackages.Count,
|
||||
PackagesRemoved = removedPackages.Count,
|
||||
PackagesModified = versionChanges.Count,
|
||||
AddedPackages = addedPackages,
|
||||
RemovedPackages = removedPackages,
|
||||
VersionChanges = versionChanges
|
||||
};
|
||||
}
|
||||
|
||||
private ReachabilityDelta ComputeReachabilityDelta(SnapshotData baseline, SnapshotData target)
|
||||
{
|
||||
var baselineReach = baseline.Reachability.ToDictionary(r => (r.CveId, r.Purl));
|
||||
var targetReach = target.Reachability.ToDictionary(r => (r.CveId, r.Purl));
|
||||
|
||||
var changes = new List<ReachabilityChange>();
|
||||
int newReachable = 0, newUnreachable = 0, changedReachability = 0;
|
||||
|
||||
// Find changes in reachability
|
||||
foreach (var (key, targetState) in targetReach)
|
||||
{
|
||||
if (baselineReach.TryGetValue(key, out var baselineState))
|
||||
{
|
||||
if (baselineState.IsReachable != targetState.IsReachable)
|
||||
{
|
||||
changes.Add(new ReachabilityChange(
|
||||
key.CveId,
|
||||
key.Purl,
|
||||
baselineState.IsReachable,
|
||||
targetState.IsReachable));
|
||||
changedReachability++;
|
||||
|
||||
if (targetState.IsReachable && !baselineState.IsReachable)
|
||||
newReachable++;
|
||||
else if (!targetState.IsReachable && baselineState.IsReachable)
|
||||
newUnreachable++;
|
||||
}
|
||||
}
|
||||
else if (targetState.IsReachable)
|
||||
{
|
||||
// New reachable CVE
|
||||
changes.Add(new ReachabilityChange(key.CveId, key.Purl, false, true));
|
||||
newReachable++;
|
||||
}
|
||||
}
|
||||
|
||||
return new ReachabilityDelta
|
||||
{
|
||||
NewReachable = newReachable,
|
||||
NewUnreachable = newUnreachable,
|
||||
ChangedReachability = changedReachability,
|
||||
Changes = changes
|
||||
};
|
||||
}
|
||||
|
||||
private VexDelta ComputeVexDelta(SnapshotData baseline, SnapshotData target)
|
||||
{
|
||||
var baselineVex = baseline.VexStatements.ToDictionary(v => v.CveId);
|
||||
var targetVex = target.VexStatements.ToDictionary(v => v.CveId);
|
||||
|
||||
var changes = new List<VexChange>();
|
||||
int newStatements = 0, revokedStatements = 0;
|
||||
int coverageIncrease = 0, coverageDecrease = 0;
|
||||
|
||||
// Find new VEX statements
|
||||
foreach (var (cveId, targetStatement) in targetVex)
|
||||
{
|
||||
if (!baselineVex.TryGetValue(cveId, out var baselineStatement))
|
||||
{
|
||||
changes.Add(new VexChange(cveId, null, targetStatement.Status));
|
||||
newStatements++;
|
||||
if (targetStatement.Status == "not_affected")
|
||||
coverageIncrease++;
|
||||
}
|
||||
else if (baselineStatement.Status != targetStatement.Status)
|
||||
{
|
||||
changes.Add(new VexChange(cveId, baselineStatement.Status, targetStatement.Status));
|
||||
|
||||
if (baselineStatement.Status == "not_affected" && targetStatement.Status != "not_affected")
|
||||
coverageDecrease++;
|
||||
else if (baselineStatement.Status != "not_affected" && targetStatement.Status == "not_affected")
|
||||
coverageIncrease++;
|
||||
}
|
||||
}
|
||||
|
||||
// Find revoked VEX statements
|
||||
foreach (var (cveId, baselineStatement) in baselineVex)
|
||||
{
|
||||
if (!targetVex.ContainsKey(cveId))
|
||||
{
|
||||
changes.Add(new VexChange(cveId, baselineStatement.Status, null));
|
||||
revokedStatements++;
|
||||
if (baselineStatement.Status == "not_affected")
|
||||
coverageDecrease++;
|
||||
}
|
||||
}
|
||||
|
||||
return new VexDelta
|
||||
{
|
||||
NewVexStatements = newStatements,
|
||||
RevokedVexStatements = revokedStatements,
|
||||
CoverageIncrease = coverageIncrease,
|
||||
CoverageDecrease = coverageDecrease,
|
||||
Changes = changes
|
||||
};
|
||||
}
|
||||
|
||||
private PolicyDelta ComputePolicyDelta(SnapshotData baseline, SnapshotData target)
|
||||
{
|
||||
var baselineViolations = baseline.PolicyViolations.ToDictionary(v => v.RuleId);
|
||||
var targetViolations = target.PolicyViolations.ToDictionary(v => v.RuleId);
|
||||
|
||||
var changes = new List<PolicyChange>();
|
||||
int newViolations = 0, resolvedViolations = 0;
|
||||
|
||||
// Find new violations
|
||||
foreach (var (ruleId, violation) in targetViolations)
|
||||
{
|
||||
if (!baselineViolations.ContainsKey(ruleId))
|
||||
{
|
||||
changes.Add(new PolicyChange(ruleId, "new-violation", violation.Message));
|
||||
newViolations++;
|
||||
}
|
||||
}
|
||||
|
||||
// Find resolved violations
|
||||
foreach (var (ruleId, violation) in baselineViolations)
|
||||
{
|
||||
if (!targetViolations.ContainsKey(ruleId))
|
||||
{
|
||||
changes.Add(new PolicyChange(ruleId, "resolved-violation", violation.Message));
|
||||
resolvedViolations++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check policy version change
|
||||
int policyVersionChanged = baseline.PolicyVersion != target.PolicyVersion ? 1 : 0;
|
||||
|
||||
return new PolicyDelta
|
||||
{
|
||||
NewViolations = newViolations,
|
||||
ResolvedViolations = resolvedViolations,
|
||||
PolicyVersionChanged = policyVersionChanged,
|
||||
Changes = changes
|
||||
};
|
||||
}
|
||||
|
||||
private UnknownsDelta ComputeUnknownsDelta(SnapshotData baseline, SnapshotData target)
|
||||
{
|
||||
var baselineUnknowns = baseline.Unknowns.ToDictionary(u => u.Id);
|
||||
var targetUnknowns = target.Unknowns.ToDictionary(u => u.Id);
|
||||
|
||||
var newUnknowns = targetUnknowns.Keys.Except(baselineUnknowns.Keys).Count();
|
||||
var resolvedUnknowns = baselineUnknowns.Keys.Except(targetUnknowns.Keys).Count();
|
||||
|
||||
// Count by reason code
|
||||
var byReasonCode = targetUnknowns.Values
|
||||
.Where(u => !baselineUnknowns.ContainsKey(u.Id))
|
||||
.GroupBy(u => u.ReasonCode)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return new UnknownsDelta
|
||||
{
|
||||
NewUnknowns = newUnknowns,
|
||||
ResolvedUnknowns = resolvedUnknowns,
|
||||
TotalBaselineUnknowns = baselineUnknowns.Count,
|
||||
TotalTargetUnknowns = targetUnknowns.Count,
|
||||
ByReasonCode = byReasonCode
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<DeltaDriver> IdentifyDrivers(
|
||||
SbomDelta sbom,
|
||||
ReachabilityDelta reach,
|
||||
VexDelta vex,
|
||||
PolicyDelta policy,
|
||||
UnknownsDelta unknowns)
|
||||
{
|
||||
var drivers = new List<DeltaDriver>();
|
||||
|
||||
// New reachable CVEs are critical drivers
|
||||
foreach (var change in reach.Changes.Where(c => !c.WasReachable && c.IsReachable))
|
||||
{
|
||||
drivers.Add(new DeltaDriver
|
||||
{
|
||||
Type = "new-reachable-cve",
|
||||
Severity = DeltaDriverSeverity.Critical,
|
||||
Description = $"CVE {change.CveId} is now reachable",
|
||||
CveId = change.CveId,
|
||||
Purl = change.Purl
|
||||
});
|
||||
}
|
||||
|
||||
// Lost VEX coverage
|
||||
foreach (var change in vex.Changes.Where(c => c.OldStatus == "not_affected" && c.NewStatus is null))
|
||||
{
|
||||
drivers.Add(new DeltaDriver
|
||||
{
|
||||
Type = "lost-vex-coverage",
|
||||
Severity = DeltaDriverSeverity.High,
|
||||
Description = $"VEX coverage lost for {change.CveId}",
|
||||
CveId = change.CveId
|
||||
});
|
||||
}
|
||||
|
||||
// VEX status downgrade (not_affected -> affected or other)
|
||||
foreach (var change in vex.Changes.Where(c =>
|
||||
c.OldStatus == "not_affected" && c.NewStatus is not null && c.NewStatus != "not_affected"))
|
||||
{
|
||||
drivers.Add(new DeltaDriver
|
||||
{
|
||||
Type = "vex-status-downgrade",
|
||||
Severity = DeltaDriverSeverity.High,
|
||||
Description = $"VEX status changed from not_affected to {change.NewStatus} for {change.CveId}",
|
||||
CveId = change.CveId
|
||||
});
|
||||
}
|
||||
|
||||
// New policy violations
|
||||
foreach (var change in policy.Changes.Where(c => c.ChangeType == "new-violation"))
|
||||
{
|
||||
drivers.Add(new DeltaDriver
|
||||
{
|
||||
Type = "new-policy-violation",
|
||||
Severity = DeltaDriverSeverity.High,
|
||||
Description = change.Description ?? $"New violation of rule {change.RuleId}"
|
||||
});
|
||||
}
|
||||
|
||||
// High-risk packages added
|
||||
foreach (var pkg in sbom.AddedPackages.Where(IsHighRiskPackage))
|
||||
{
|
||||
drivers.Add(new DeltaDriver
|
||||
{
|
||||
Type = "high-risk-package-added",
|
||||
Severity = DeltaDriverSeverity.Medium,
|
||||
Description = $"New high-risk package: {pkg.Purl}",
|
||||
Purl = pkg.Purl
|
||||
});
|
||||
}
|
||||
|
||||
// Increased unknowns
|
||||
if (unknowns.NewUnknowns > 0)
|
||||
{
|
||||
var severity = unknowns.NewUnknowns > 10
|
||||
? DeltaDriverSeverity.High
|
||||
: DeltaDriverSeverity.Medium;
|
||||
|
||||
drivers.Add(new DeltaDriver
|
||||
{
|
||||
Type = "new-unknowns",
|
||||
Severity = severity,
|
||||
Description = $"{unknowns.NewUnknowns} new unknown(s) introduced",
|
||||
Details = unknowns.ByReasonCode.ToDictionary(kv => kv.Key, kv => kv.Value.ToString())
|
||||
});
|
||||
}
|
||||
|
||||
// CVEs becoming unreachable (positive)
|
||||
foreach (var change in reach.Changes.Where(c => c.WasReachable && !c.IsReachable))
|
||||
{
|
||||
drivers.Add(new DeltaDriver
|
||||
{
|
||||
Type = "cve-now-unreachable",
|
||||
Severity = DeltaDriverSeverity.Low,
|
||||
Description = $"CVE {change.CveId} is now unreachable (risk reduced)",
|
||||
CveId = change.CveId,
|
||||
Purl = change.Purl
|
||||
});
|
||||
}
|
||||
|
||||
// New VEX coverage (positive)
|
||||
foreach (var change in vex.Changes.Where(c =>
|
||||
c.OldStatus is null && c.NewStatus == "not_affected"))
|
||||
{
|
||||
drivers.Add(new DeltaDriver
|
||||
{
|
||||
Type = "new-vex-coverage",
|
||||
Severity = DeltaDriverSeverity.Low,
|
||||
Description = $"New VEX coverage for {change.CveId}: not_affected",
|
||||
CveId = change.CveId
|
||||
});
|
||||
}
|
||||
|
||||
return drivers.OrderByDescending(d => d.Severity).ToList();
|
||||
}
|
||||
|
||||
private DeltaSummary ComputeSummary(
|
||||
SbomDelta sbom,
|
||||
ReachabilityDelta reach,
|
||||
VexDelta vex,
|
||||
PolicyDelta policy,
|
||||
IReadOnlyList<DeltaDriver> drivers)
|
||||
{
|
||||
var totalChanges = sbom.PackagesAdded + sbom.PackagesRemoved + sbom.PackagesModified +
|
||||
reach.NewReachable + reach.NewUnreachable + reach.ChangedReachability +
|
||||
vex.NewVexStatements + vex.RevokedVexStatements +
|
||||
policy.NewViolations + policy.ResolvedViolations;
|
||||
|
||||
var riskIncreasing = drivers.Count(d =>
|
||||
d.Severity is DeltaDriverSeverity.Critical or DeltaDriverSeverity.High &&
|
||||
!IsPositiveDriver(d.Type));
|
||||
|
||||
var riskDecreasing = drivers.Count(d => IsPositiveDriver(d.Type));
|
||||
|
||||
var neutral = Math.Max(0, totalChanges - riskIncreasing - riskDecreasing);
|
||||
|
||||
var riskScore = ComputeRiskScore(drivers);
|
||||
var riskDirection = riskIncreasing > riskDecreasing ? "increasing" :
|
||||
riskIncreasing < riskDecreasing ? "decreasing" : "stable";
|
||||
|
||||
return new DeltaSummary
|
||||
{
|
||||
TotalChanges = totalChanges,
|
||||
RiskIncreasing = riskIncreasing,
|
||||
RiskDecreasing = riskDecreasing,
|
||||
Neutral = neutral,
|
||||
RiskScore = riskScore,
|
||||
RiskDirection = riskDirection
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsPositiveDriver(string driverType) =>
|
||||
driverType is "cve-now-unreachable" or "new-vex-coverage" or "resolved-violation";
|
||||
|
||||
private static decimal ComputeRiskScore(IReadOnlyList<DeltaDriver> drivers)
|
||||
{
|
||||
return drivers.Sum(d => d.Severity switch
|
||||
{
|
||||
DeltaDriverSeverity.Critical => 20m,
|
||||
DeltaDriverSeverity.High => 10m,
|
||||
DeltaDriverSeverity.Medium => 5m,
|
||||
DeltaDriverSeverity.Low => 1m,
|
||||
_ => 0m
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsHighRiskPackage(PackageChange pkg)
|
||||
{
|
||||
// Check for known high-risk characteristics
|
||||
var purl = pkg.Purl.ToLowerInvariant();
|
||||
return purl.Contains("native") ||
|
||||
purl.Contains("crypto") ||
|
||||
purl.Contains("ssl") ||
|
||||
purl.Contains("auth") ||
|
||||
purl.Contains("shell") ||
|
||||
purl.Contains("exec");
|
||||
}
|
||||
|
||||
private static string ComputeDeltaId(SecurityStateDelta delta)
|
||||
{
|
||||
// Create a deterministic representation for hashing
|
||||
var deterministicDelta = delta with
|
||||
{
|
||||
DeltaId = "",
|
||||
ComputedAt = default // Exclude timestamp for determinism
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(deterministicDelta, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
var hashHex = Convert.ToHexStringLower(hash);
|
||||
|
||||
return $"delta:sha256:{hashHex}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for computing security state deltas.
|
||||
/// </summary>
|
||||
public interface IDeltaComputer
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the delta between two knowledge snapshots for an artifact.
|
||||
/// </summary>
|
||||
Task<SecurityStateDelta> ComputeDeltaAsync(
|
||||
string baselineSnapshotId,
|
||||
string targetSnapshotId,
|
||||
ArtifactRef artifact,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for accessing snapshot data.
|
||||
/// </summary>
|
||||
public interface ISnapshotService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets snapshot data by ID.
|
||||
/// </summary>
|
||||
Task<SnapshotData?> GetSnapshotAsync(string snapshotId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot data for delta computation.
|
||||
/// </summary>
|
||||
public sealed record SnapshotData
|
||||
{
|
||||
public required string SnapshotId { get; init; }
|
||||
public IReadOnlyList<PackageData> Packages { get; init; } = [];
|
||||
public IReadOnlyList<ReachabilityData> Reachability { get; init; } = [];
|
||||
public IReadOnlyList<VexStatementData> VexStatements { get; init; } = [];
|
||||
public IReadOnlyList<PolicyViolationData> PolicyViolations { get; init; } = [];
|
||||
public IReadOnlyList<UnknownData> Unknowns { get; init; } = [];
|
||||
public string? PolicyVersion { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PackageData(string Purl, string? Version, string? License);
|
||||
public sealed record ReachabilityData(string CveId, string Purl, bool IsReachable);
|
||||
public sealed record VexStatementData(string CveId, string Status, string? Justification);
|
||||
public sealed record PolicyViolationData(string RuleId, string Severity, string? Message);
|
||||
public sealed record UnknownData(string Id, string ReasonCode, string? Description);
|
||||
@@ -0,0 +1,374 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_4100_0004_0001 - Security State Delta & Verdict
|
||||
// Task: T5 - Create DeltaVerdictStatement
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Deltas;
|
||||
|
||||
/// <summary>
|
||||
/// Creates in-toto statements for delta verdicts.
|
||||
/// </summary>
|
||||
public static class DeltaVerdictStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicate type for delta verdict attestations.
|
||||
/// </summary>
|
||||
public const string PredicateType = "https://stellaops.io/predicates/delta-verdict@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Creates an in-toto statement from a delta verdict.
|
||||
/// </summary>
|
||||
public static InTotoStatement CreateStatement(
|
||||
SecurityStateDelta delta,
|
||||
DeltaVerdict verdict)
|
||||
{
|
||||
return new InTotoStatement
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
Subject = new[]
|
||||
{
|
||||
new InTotoSubject
|
||||
{
|
||||
Name = delta.Artifact.Name ?? delta.Artifact.Digest,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = delta.Artifact.Digest.Replace("sha256:", "")
|
||||
}
|
||||
}
|
||||
},
|
||||
PredicateType = PredicateType,
|
||||
Predicate = new DeltaVerdictPredicate
|
||||
{
|
||||
DeltaId = delta.DeltaId,
|
||||
VerdictId = verdict.VerdictId,
|
||||
Status = verdict.Status.ToString(),
|
||||
BaselineSnapshotId = delta.BaselineSnapshotId,
|
||||
TargetSnapshotId = delta.TargetSnapshotId,
|
||||
RecommendedGate = verdict.RecommendedGate.ToString(),
|
||||
RiskPoints = verdict.RiskPoints,
|
||||
Summary = new DeltaSummaryPredicate
|
||||
{
|
||||
TotalChanges = delta.Summary.TotalChanges,
|
||||
RiskIncreasing = delta.Summary.RiskIncreasing,
|
||||
RiskDecreasing = delta.Summary.RiskDecreasing,
|
||||
RiskDirection = delta.Summary.RiskDirection,
|
||||
RiskScore = delta.Summary.RiskScore
|
||||
},
|
||||
BlockingDrivers = verdict.BlockingDrivers
|
||||
.Select(d => new DriverPredicate
|
||||
{
|
||||
Type = d.Type,
|
||||
Severity = d.Severity.ToString(),
|
||||
Description = d.Description,
|
||||
CveId = d.CveId,
|
||||
Purl = d.Purl
|
||||
})
|
||||
.ToList(),
|
||||
WarningDrivers = verdict.WarningDrivers
|
||||
.Select(d => new DriverPredicate
|
||||
{
|
||||
Type = d.Type,
|
||||
Severity = d.Severity.ToString(),
|
||||
Description = d.Description,
|
||||
CveId = d.CveId,
|
||||
Purl = d.Purl
|
||||
})
|
||||
.ToList(),
|
||||
AppliedExceptions = verdict.AppliedExceptions.ToList(),
|
||||
Explanation = verdict.Explanation,
|
||||
Recommendations = verdict.Recommendations.ToList(),
|
||||
EvaluatedAt = verdict.EvaluatedAt.ToString("o")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the statement to JSON.
|
||||
/// </summary>
|
||||
public static string ToJson(InTotoStatement statement)
|
||||
{
|
||||
return JsonSerializer.Serialize(statement, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the statement to bytes for signing.
|
||||
/// </summary>
|
||||
public static byte[] ToBytes(InTotoStatement statement)
|
||||
{
|
||||
return JsonSerializer.SerializeToUtf8Bytes(statement, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// in-toto statement structure.
|
||||
/// </summary>
|
||||
public sealed record InTotoStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<InTotoSubject> Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required DeltaVerdictPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// in-toto subject (artifact reference).
|
||||
/// </summary>
|
||||
public sealed record InTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delta verdict predicate for attestation.
|
||||
/// </summary>
|
||||
public sealed record DeltaVerdictPredicate
|
||||
{
|
||||
[JsonPropertyName("deltaId")]
|
||||
public required string DeltaId { get; init; }
|
||||
|
||||
[JsonPropertyName("verdictId")]
|
||||
public required string VerdictId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineSnapshotId")]
|
||||
public required string BaselineSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("targetSnapshotId")]
|
||||
public required string TargetSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("recommendedGate")]
|
||||
public required string RecommendedGate { get; init; }
|
||||
|
||||
[JsonPropertyName("riskPoints")]
|
||||
public int RiskPoints { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public required DeltaSummaryPredicate Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("blockingDrivers")]
|
||||
public required IReadOnlyList<DriverPredicate> BlockingDrivers { get; init; }
|
||||
|
||||
[JsonPropertyName("warningDrivers")]
|
||||
public required IReadOnlyList<DriverPredicate> WarningDrivers { get; init; }
|
||||
|
||||
[JsonPropertyName("appliedExceptions")]
|
||||
public required IReadOnlyList<string> AppliedExceptions { get; init; }
|
||||
|
||||
[JsonPropertyName("explanation")]
|
||||
public string? Explanation { get; init; }
|
||||
|
||||
[JsonPropertyName("recommendations")]
|
||||
public required IReadOnlyList<string> Recommendations { get; init; }
|
||||
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public required string EvaluatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary section of the predicate.
|
||||
/// </summary>
|
||||
public sealed record DeltaSummaryPredicate
|
||||
{
|
||||
[JsonPropertyName("totalChanges")]
|
||||
public int TotalChanges { get; init; }
|
||||
|
||||
[JsonPropertyName("riskIncreasing")]
|
||||
public int RiskIncreasing { get; init; }
|
||||
|
||||
[JsonPropertyName("riskDecreasing")]
|
||||
public int RiskDecreasing { get; init; }
|
||||
|
||||
[JsonPropertyName("riskDirection")]
|
||||
public required string RiskDirection { get; init; }
|
||||
|
||||
[JsonPropertyName("riskScore")]
|
||||
public decimal RiskScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver details in the predicate.
|
||||
/// </summary>
|
||||
public sealed record DriverPredicate
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required string Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
[JsonPropertyName("cveId")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE (Dead Simple Signing Envelope) structure.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<DsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature structure.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and signing delta verdict attestations.
|
||||
/// </summary>
|
||||
public sealed class DeltaVerdictAttestor : IDeltaVerdictAttestor
|
||||
{
|
||||
private readonly ISigner _signer;
|
||||
private readonly ILogger<DeltaVerdictAttestor> _logger;
|
||||
|
||||
public DeltaVerdictAttestor(ISigner signer, ILogger<DeltaVerdictAttestor> logger)
|
||||
{
|
||||
_signer = signer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DsseEnvelope> AttestAsync(
|
||||
SecurityStateDelta delta,
|
||||
DeltaVerdict verdict,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var statement = DeltaVerdictStatement.CreateStatement(delta, verdict);
|
||||
var payload = DeltaVerdictStatement.ToBytes(statement);
|
||||
|
||||
var signature = await _signer.SignAsync(payload, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created delta verdict attestation for {DeltaId} with status {Status}",
|
||||
delta.DeltaId, verdict.Status);
|
||||
|
||||
return new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String(payload),
|
||||
Signatures = new[]
|
||||
{
|
||||
new DsseSignature
|
||||
{
|
||||
KeyId = _signer.KeyId,
|
||||
Sig = Convert.ToBase64String(signature)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> VerifyAsync(
|
||||
DsseEnvelope envelope,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (envelope.Signatures.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No signatures found in envelope");
|
||||
return false;
|
||||
}
|
||||
|
||||
var payload = Convert.FromBase64String(envelope.Payload);
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
var signature = Convert.FromBase64String(sig.Sig);
|
||||
var isValid = await _signer.VerifyAsync(payload, signature, sig.KeyId, ct);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
_logger.LogWarning("Invalid signature for key {KeyId}", sig.KeyId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signing and verifying attestations.
|
||||
/// </summary>
|
||||
public interface ISigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the key ID for the current signing key.
|
||||
/// </summary>
|
||||
string KeyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Signs the payload.
|
||||
/// </summary>
|
||||
Task<byte[]> SignAsync(byte[] payload, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signature.
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(byte[] payload, byte[] signature, string keyId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for creating and verifying delta verdict attestations.
|
||||
/// </summary>
|
||||
public interface IDeltaVerdictAttestor
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a signed attestation for a delta verdict.
|
||||
/// </summary>
|
||||
Task<DsseEnvelope> AttestAsync(
|
||||
SecurityStateDelta delta,
|
||||
DeltaVerdict verdict,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a delta verdict attestation.
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(
|
||||
DsseEnvelope envelope,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
Reference in New Issue
Block a user