feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictPushDiagnostics.cs
|
||||
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
|
||||
// Task: VERDICT-009
|
||||
// Description: OpenTelemetry instrumentation for verdict push operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry instrumentation for verdict push operations.
|
||||
/// Provides activity tracing and metrics for observability.
|
||||
/// </summary>
|
||||
public static class VerdictPushDiagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// Activity source name for verdict push operations.
|
||||
/// </summary>
|
||||
public const string ActivitySourceName = "StellaOps.Scanner.VerdictPush";
|
||||
|
||||
/// <summary>
|
||||
/// Activity source version.
|
||||
/// </summary>
|
||||
public const string ActivityVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Meter name for verdict push metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "stellaops.scanner.verdict_push";
|
||||
|
||||
/// <summary>
|
||||
/// Meter version.
|
||||
/// </summary>
|
||||
public const string MeterVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Activity source for verdict push tracing.
|
||||
/// </summary>
|
||||
public static ActivitySource ActivitySource { get; } = new(ActivitySourceName, ActivityVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Meter for verdict push metrics.
|
||||
/// </summary>
|
||||
public static Meter Meter { get; } = new(MeterName, MeterVersion);
|
||||
|
||||
// Counters
|
||||
private static readonly Counter<long> _pushAttempts = Meter.CreateCounter<long>(
|
||||
"stellaops.verdict.push.attempts",
|
||||
unit: "{attempts}",
|
||||
description: "Total number of verdict push attempts");
|
||||
|
||||
private static readonly Counter<long> _pushSuccesses = Meter.CreateCounter<long>(
|
||||
"stellaops.verdict.push.successes",
|
||||
unit: "{successes}",
|
||||
description: "Total number of successful verdict pushes");
|
||||
|
||||
private static readonly Counter<long> _pushFailures = Meter.CreateCounter<long>(
|
||||
"stellaops.verdict.push.failures",
|
||||
unit: "{failures}",
|
||||
description: "Total number of failed verdict pushes");
|
||||
|
||||
private static readonly Counter<long> _pushRetries = Meter.CreateCounter<long>(
|
||||
"stellaops.verdict.push.retries",
|
||||
unit: "{retries}",
|
||||
description: "Total number of verdict push retries");
|
||||
|
||||
// Histograms
|
||||
private static readonly Histogram<double> _pushDuration = Meter.CreateHistogram<double>(
|
||||
"stellaops.verdict.push.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of verdict push operations in milliseconds");
|
||||
|
||||
private static readonly Histogram<long> _payloadSize = Meter.CreateHistogram<long>(
|
||||
"stellaops.verdict.push.payload_size",
|
||||
unit: "By",
|
||||
description: "Size of verdict payload in bytes");
|
||||
|
||||
/// <summary>
|
||||
/// Start an activity for a verdict push operation.
|
||||
/// </summary>
|
||||
public static Activity? StartPushActivity(
|
||||
string imageReference,
|
||||
string? imageDigest = null,
|
||||
string? registry = null)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("verdict.push", ActivityKind.Client);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("stellaops.verdict.image_reference", imageReference);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(imageDigest))
|
||||
{
|
||||
activity.SetTag("stellaops.verdict.image_digest", imageDigest);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(registry))
|
||||
{
|
||||
activity.SetTag("stellaops.verdict.registry", registry);
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a push attempt.
|
||||
/// </summary>
|
||||
public static void RecordPushAttempt(string registry, string decision)
|
||||
{
|
||||
_pushAttempts.Add(1,
|
||||
new KeyValuePair<string, object?>("registry", registry),
|
||||
new KeyValuePair<string, object?>("decision", decision));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a successful push.
|
||||
/// </summary>
|
||||
public static void RecordPushSuccess(string registry, string decision, double durationMs, long payloadBytes)
|
||||
{
|
||||
_pushSuccesses.Add(1,
|
||||
new KeyValuePair<string, object?>("registry", registry),
|
||||
new KeyValuePair<string, object?>("decision", decision));
|
||||
|
||||
_pushDuration.Record(durationMs,
|
||||
new KeyValuePair<string, object?>("registry", registry),
|
||||
new KeyValuePair<string, object?>("decision", decision),
|
||||
new KeyValuePair<string, object?>("status", "success"));
|
||||
|
||||
_payloadSize.Record(payloadBytes,
|
||||
new KeyValuePair<string, object?>("registry", registry),
|
||||
new KeyValuePair<string, object?>("decision", decision));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a failed push.
|
||||
/// </summary>
|
||||
public static void RecordPushFailure(string registry, string decision, string errorType, double durationMs)
|
||||
{
|
||||
_pushFailures.Add(1,
|
||||
new KeyValuePair<string, object?>("registry", registry),
|
||||
new KeyValuePair<string, object?>("decision", decision),
|
||||
new KeyValuePair<string, object?>("error_type", errorType));
|
||||
|
||||
_pushDuration.Record(durationMs,
|
||||
new KeyValuePair<string, object?>("registry", registry),
|
||||
new KeyValuePair<string, object?>("decision", decision),
|
||||
new KeyValuePair<string, object?>("status", "failure"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a push retry.
|
||||
/// </summary>
|
||||
public static void RecordPushRetry(string registry, int attemptNumber, string reason)
|
||||
{
|
||||
_pushRetries.Add(1,
|
||||
new KeyValuePair<string, object?>("registry", registry),
|
||||
new KeyValuePair<string, object?>("attempt", attemptNumber),
|
||||
new KeyValuePair<string, object?>("reason", reason));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set activity status to error.
|
||||
/// </summary>
|
||||
public static void SetActivityError(Activity? activity, Exception exception)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetStatus(ActivityStatusCode.Error, exception.Message);
|
||||
activity.SetTag("otel.status_code", "ERROR");
|
||||
activity.SetTag("otel.status_description", exception.Message);
|
||||
activity.SetTag("exception.type", exception.GetType().FullName);
|
||||
activity.SetTag("exception.message", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set activity status to success.
|
||||
/// </summary>
|
||||
public static void SetActivitySuccess(Activity? activity, string? manifestDigest = null)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetStatus(ActivityStatusCode.Ok);
|
||||
activity.SetTag("otel.status_code", "OK");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(manifestDigest))
|
||||
{
|
||||
activity.SetTag("stellaops.verdict.manifest_digest", manifestDigest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,46 @@ public static class OciAnnotations
|
||||
public const string StellaAfterDigest = "org.stellaops.delta.after.digest";
|
||||
public const string StellaSbomDigest = "org.stellaops.sbom.digest";
|
||||
public const string StellaVerdictDigest = "org.stellaops.verdict.digest";
|
||||
|
||||
// Sprint: SPRINT_4300_0001_0001 - OCI Verdict Attestation Push
|
||||
/// <summary>
|
||||
/// The final decision (pass, warn, block) for the verdict.
|
||||
/// </summary>
|
||||
public const string StellaVerdictDecision = "org.stellaops.verdict.decision";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the feeds snapshot used for vulnerability matching.
|
||||
/// </summary>
|
||||
public const string StellaFeedsDigest = "org.stellaops.feeds.digest";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the policy bundle used for evaluation.
|
||||
/// </summary>
|
||||
public const string StellaPolicyDigest = "org.stellaops.policy.digest";
|
||||
|
||||
/// <summary>
|
||||
/// Graph revision identifier for the scan.
|
||||
/// </summary>
|
||||
public const string StellaGraphRevisionId = "org.stellaops.graph.revision.id";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the proof bundle containing the evidence chain.
|
||||
/// </summary>
|
||||
public const string StellaProofBundleDigest = "org.stellaops.proof.bundle.digest";
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the verdict was computed.
|
||||
/// </summary>
|
||||
public const string StellaVerdictTimestamp = "org.stellaops.verdict.timestamp";
|
||||
|
||||
// Sprint: SPRINT_4300_0002_0002 - Unknowns Attestation Predicates
|
||||
/// <summary>
|
||||
/// Digest of the uncertainty state attestation.
|
||||
/// </summary>
|
||||
public const string StellaUncertaintyDigest = "org.stellaops.uncertainty.digest";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the uncertainty budget attestation.
|
||||
/// </summary>
|
||||
public const string StellaUncertaintyBudgetDigest = "org.stellaops.uncertainty.budget.digest";
|
||||
}
|
||||
|
||||
@@ -14,4 +14,16 @@ public static class OciMediaTypes
|
||||
public const string ReachabilitySlice = "application/vnd.stellaops.slice.v1+json";
|
||||
public const string SliceConfig = "application/vnd.stellaops.slice.config.v1+json";
|
||||
public const string SliceArtifact = "application/vnd.stellaops.slice.v1+json";
|
||||
|
||||
// Sprint: SPRINT_4300_0001_0001 - OCI Verdict Attestation Push
|
||||
/// <summary>
|
||||
/// Media type for risk verdict attestation artifacts.
|
||||
/// These are pushed as OCI referrers for container images.
|
||||
/// </summary>
|
||||
public const string VerdictAttestation = "application/vnd.stellaops.verdict.v1+json";
|
||||
|
||||
/// <summary>
|
||||
/// Config media type for verdict attestation artifacts.
|
||||
/// </summary>
|
||||
public const string VerdictConfig = "application/vnd.stellaops.verdict.config.v1+json";
|
||||
}
|
||||
|
||||
@@ -73,4 +73,17 @@ public sealed record OciRegistryAuthorization
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously authorizes a request. This is a convenience method that wraps ApplyTo.
|
||||
/// The OciImageReference parameter is for future token refresh support.
|
||||
/// </summary>
|
||||
public Task AuthorizeRequestAsync(
|
||||
HttpRequestMessage request,
|
||||
OciImageReference reference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ApplyTo(request);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci.Offline;
|
||||
|
||||
@@ -95,15 +94,48 @@ public sealed record BundleImportResult
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data transfer object for slice data in offline bundles.
|
||||
/// Decoupled from ReachabilitySlice to avoid circular dependencies.
|
||||
/// </summary>
|
||||
public sealed record SliceDataDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw JSON bytes of the slice.
|
||||
/// </summary>
|
||||
public required byte[] JsonBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE ID extracted from slice query (for annotations).
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verdict status (for annotations).
|
||||
/// </summary>
|
||||
public string? VerdictStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Referenced call graph digest.
|
||||
/// </summary>
|
||||
public string? GraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Referenced SBOM digest.
|
||||
/// </summary>
|
||||
public string? SbomDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider interface for slice storage operations.
|
||||
/// Uses SliceDataDto to avoid circular dependencies with Reachability project.
|
||||
/// </summary>
|
||||
public interface ISliceStorageProvider
|
||||
{
|
||||
Task<IReadOnlyList<ReachabilitySlice>> GetSlicesForScanAsync(string scanId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<SliceDataDto>> GetSlicesForScanAsync(string scanId, CancellationToken cancellationToken = default);
|
||||
Task<byte[]?> GetGraphAsync(string digest, CancellationToken cancellationToken = default);
|
||||
Task<byte[]?> GetSbomAsync(string digest, CancellationToken cancellationToken = default);
|
||||
Task StoreSliceAsync(ReachabilitySlice slice, CancellationToken cancellationToken = default);
|
||||
Task StoreSliceAsync(byte[] sliceJsonBytes, CancellationToken cancellationToken = default);
|
||||
Task StoreGraphAsync(string digest, byte[] data, CancellationToken cancellationToken = default);
|
||||
Task StoreSbomAsync(string digest, byte[] data, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -183,8 +215,7 @@ public sealed class OfflineBundleService
|
||||
// Export slices
|
||||
foreach (var slice in slices)
|
||||
{
|
||||
var sliceJson = JsonSerializer.Serialize(slice, JsonOptions);
|
||||
var sliceBytes = Encoding.UTF8.GetBytes(sliceJson);
|
||||
var sliceBytes = slice.JsonBytes;
|
||||
var sliceDigest = ComputeDigest(sliceBytes);
|
||||
var slicePath = Path.Combine(blobsDir, sliceDigest);
|
||||
|
||||
@@ -197,8 +228,8 @@ public sealed class OfflineBundleService
|
||||
Size = sliceBytes.Length,
|
||||
Path = $"{BlobsDirectory}/{sliceDigest}",
|
||||
Annotations = ImmutableDictionary<string, string>.Empty
|
||||
.Add("stellaops.slice.cveId", slice.Query?.CveId ?? "unknown")
|
||||
.Add("stellaops.slice.verdict", slice.Verdict?.Status.ToString() ?? "unknown")
|
||||
.Add("stellaops.slice.cveId", slice.CveId ?? "unknown")
|
||||
.Add("stellaops.slice.verdict", slice.VerdictStatus ?? "unknown")
|
||||
});
|
||||
|
||||
// Collect referenced graphs and SBOMs
|
||||
@@ -435,12 +466,9 @@ public sealed class OfflineBundleService
|
||||
|
||||
if (artifact.MediaType == OciMediaTypes.ReachabilitySlice)
|
||||
{
|
||||
var slice = JsonSerializer.Deserialize<ReachabilitySlice>(data, JsonOptions);
|
||||
if (slice != null)
|
||||
{
|
||||
await _storage.StoreSliceAsync(slice, cancellationToken).ConfigureAwait(false);
|
||||
slicesImported++;
|
||||
}
|
||||
// Store raw JSON bytes - consumer deserializes to specific type
|
||||
await _storage.StoreSliceAsync(data, cancellationToken).ConfigureAwait(false);
|
||||
slicesImported++;
|
||||
}
|
||||
else if (artifact.MediaType == OciMediaTypes.ReachabilitySubgraph)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
@@ -39,7 +38,11 @@ public sealed record SlicePullOptions
|
||||
public sealed record SlicePullResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public ReachabilitySlice? Slice { get; init; }
|
||||
/// <summary>
|
||||
/// Raw slice data as JSON element (decoupled from ReachabilitySlice type).
|
||||
/// Consumer should deserialize to appropriate type.
|
||||
/// </summary>
|
||||
public JsonElement? SliceData { get; init; }
|
||||
public string? SliceDigest { get; init; }
|
||||
public byte[]? DsseEnvelope { get; init; }
|
||||
public string? Error { get; init; }
|
||||
@@ -96,7 +99,7 @@ public sealed class SlicePullService : IDisposable
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = true,
|
||||
Slice = cached!.Slice,
|
||||
SliceData = cached!.SliceData,
|
||||
SliceDigest = digest,
|
||||
DsseEnvelope = cached.DsseEnvelope,
|
||||
FromCache = true,
|
||||
@@ -185,9 +188,14 @@ public sealed class SlicePullService : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
// Parse slice
|
||||
var slice = JsonSerializer.Deserialize<ReachabilitySlice>(sliceBytes, JsonOptions);
|
||||
if (slice == null)
|
||||
// Parse slice as raw JSON element (decoupled from ReachabilitySlice type)
|
||||
JsonElement sliceData;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(sliceBytes);
|
||||
sliceData = doc.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new SlicePullResult
|
||||
{
|
||||
@@ -216,7 +224,7 @@ public sealed class SlicePullService : IDisposable
|
||||
{
|
||||
AddToCache(cacheKey, new CachedSlice
|
||||
{
|
||||
Slice = slice,
|
||||
SliceData = sliceData,
|
||||
DsseEnvelope = dsseEnvelope,
|
||||
SignatureVerified = signatureVerified,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(_options.CacheTtl)
|
||||
@@ -230,7 +238,7 @@ public sealed class SlicePullService : IDisposable
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = true,
|
||||
Slice = slice,
|
||||
SliceData = sliceData,
|
||||
SliceDigest = digest,
|
||||
DsseEnvelope = dsseEnvelope,
|
||||
FromCache = false,
|
||||
@@ -346,7 +354,7 @@ public sealed class SlicePullService : IDisposable
|
||||
var index = await response.Content.ReadFromJsonAsync<OciReferrersIndex>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return index?.Manifests ?? Array.Empty<OciReferrer>();
|
||||
return (IReadOnlyList<OciReferrer>?)index?.Manifests ?? Array.Empty<OciReferrer>();
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
@@ -430,7 +438,7 @@ public sealed class SlicePullService : IDisposable
|
||||
|
||||
private sealed record CachedSlice
|
||||
{
|
||||
public required ReachabilitySlice Slice { get; init; }
|
||||
public required JsonElement SliceData { get; init; }
|
||||
public byte[]? DsseEnvelope { get; init; }
|
||||
public bool SignatureVerified { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
<!-- NOTE: Reachability reference intentionally removed to break circular dependency:
|
||||
Reachability -> SmartDiff -> Storage.Oci -> Reachability
|
||||
Use SliceDataDto and JsonElement instead of ReachabilitySlice type. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictOciPublisher.cs
|
||||
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
|
||||
// Task: VERDICT-009 - OpenTelemetry instrumentation integrated.
|
||||
// Description: Pushes risk verdict attestations as OCI referrer artifacts.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using StellaOps.Scanner.Storage.Oci.Diagnostics;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Request to push a verdict attestation to an OCI registry.
|
||||
/// </summary>
|
||||
public sealed record VerdictOciPublishRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// OCI image reference to attach the verdict to.
|
||||
/// Format: registry/repository@sha256:digest
|
||||
/// </summary>
|
||||
public required string Reference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the container image this verdict applies to.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope bytes containing the signed verdict statement.
|
||||
/// </summary>
|
||||
public required byte[] DsseEnvelopeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the SBOM used for vulnerability matching.
|
||||
/// </summary>
|
||||
public required string SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the advisory feeds snapshot used.
|
||||
/// </summary>
|
||||
public required string FeedsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the policy bundle used for evaluation.
|
||||
/// </summary>
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The final verdict decision: pass, warn, or block.
|
||||
/// </summary>
|
||||
public required string Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph revision ID for the scan.
|
||||
/// </summary>
|
||||
public string? GraphRevisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the proof bundle containing the evidence chain.
|
||||
/// </summary>
|
||||
public string? ProofBundleDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the attestation itself (for cross-referencing).
|
||||
/// </summary>
|
||||
public string? AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the verdict was computed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? VerdictTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Digest of the uncertainty state attestation.
|
||||
/// Sprint: SPRINT_4300_0002_0002_unknowns_attestation_predicates
|
||||
/// </summary>
|
||||
public string? UncertaintyStatementDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Digest of the uncertainty budget attestation.
|
||||
/// Sprint: SPRINT_4300_0002_0002_unknowns_attestation_predicates
|
||||
/// </summary>
|
||||
public string? UncertaintyBudgetDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for pushing risk verdict attestations as OCI referrer artifacts.
|
||||
/// This enables verdicts to be portable "ship tokens" attached to container images.
|
||||
/// </summary>
|
||||
public sealed class VerdictOciPublisher
|
||||
{
|
||||
private readonly OciArtifactPusher _pusher;
|
||||
|
||||
public VerdictOciPublisher(OciArtifactPusher pusher)
|
||||
{
|
||||
_pusher = pusher ?? throw new ArgumentNullException(nameof(pusher));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push a verdict attestation as an OCI referrer artifact.
|
||||
/// </summary>
|
||||
/// <param name="request">The verdict push request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the push operation.</returns>
|
||||
public async Task<OciArtifactPushResult> PushAsync(
|
||||
VerdictOciPublishRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Extract registry from reference for telemetry
|
||||
var registry = ExtractRegistry(request.Reference);
|
||||
var payloadSize = request.DsseEnvelopeBytes.Length;
|
||||
|
||||
// Start activity for distributed tracing
|
||||
using var activity = VerdictPushDiagnostics.StartPushActivity(
|
||||
request.Reference,
|
||||
request.ImageDigest,
|
||||
registry);
|
||||
|
||||
// Record push attempt
|
||||
VerdictPushDiagnostics.RecordPushAttempt(registry, request.Decision);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var annotations = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OciAnnotations.StellaPredicateType] = VerdictPredicateTypes.Verdict,
|
||||
[OciAnnotations.StellaSbomDigest] = request.SbomDigest,
|
||||
[OciAnnotations.StellaFeedsDigest] = request.FeedsDigest,
|
||||
[OciAnnotations.StellaPolicyDigest] = request.PolicyDigest,
|
||||
[OciAnnotations.StellaVerdictDecision] = request.Decision
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.GraphRevisionId))
|
||||
{
|
||||
annotations[OciAnnotations.StellaGraphRevisionId] = request.GraphRevisionId!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ProofBundleDigest))
|
||||
{
|
||||
annotations[OciAnnotations.StellaProofBundleDigest] = request.ProofBundleDigest!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.AttestationDigest))
|
||||
{
|
||||
annotations[OciAnnotations.StellaAttestationDigest] = request.AttestationDigest!;
|
||||
}
|
||||
|
||||
if (request.VerdictTimestamp.HasValue)
|
||||
{
|
||||
annotations[OciAnnotations.StellaVerdictTimestamp] = request.VerdictTimestamp.Value.ToString("O");
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_4300_0002_0002 - Unknowns Attestation Predicates
|
||||
if (!string.IsNullOrWhiteSpace(request.UncertaintyStatementDigest))
|
||||
{
|
||||
annotations[OciAnnotations.StellaUncertaintyDigest] = request.UncertaintyStatementDigest!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.UncertaintyBudgetDigest))
|
||||
{
|
||||
annotations[OciAnnotations.StellaUncertaintyBudgetDigest] = request.UncertaintyBudgetDigest!;
|
||||
}
|
||||
|
||||
var pushRequest = new OciArtifactPushRequest
|
||||
{
|
||||
Reference = request.Reference,
|
||||
ArtifactType = OciMediaTypes.VerdictAttestation,
|
||||
SubjectDigest = request.ImageDigest,
|
||||
Layers =
|
||||
[
|
||||
new OciLayerContent
|
||||
{
|
||||
Content = request.DsseEnvelopeBytes,
|
||||
MediaType = OciMediaTypes.DsseEnvelope
|
||||
}
|
||||
],
|
||||
Annotations = annotations
|
||||
};
|
||||
|
||||
var result = await _pusher.PushAsync(pushRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Record success metrics
|
||||
VerdictPushDiagnostics.RecordPushSuccess(
|
||||
registry,
|
||||
request.Decision,
|
||||
stopwatch.Elapsed.TotalMilliseconds,
|
||||
payloadSize);
|
||||
VerdictPushDiagnostics.SetActivitySuccess(activity, result.ManifestDigest);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Record failure metrics
|
||||
VerdictPushDiagnostics.RecordPushFailure(
|
||||
registry,
|
||||
request.Decision,
|
||||
result.Error ?? "unknown",
|
||||
stopwatch.Elapsed.TotalMilliseconds);
|
||||
activity?.SetStatus(ActivityStatusCode.Error, result.Error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
// Record failure metrics
|
||||
VerdictPushDiagnostics.RecordPushFailure(
|
||||
registry,
|
||||
request.Decision,
|
||||
ex.GetType().Name,
|
||||
stopwatch.Elapsed.TotalMilliseconds);
|
||||
VerdictPushDiagnostics.SetActivityError(activity, ex);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract registry hostname from an OCI reference.
|
||||
/// </summary>
|
||||
private static string ExtractRegistry(string reference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Remove tag or digest suffix
|
||||
var refWithoutTag = reference;
|
||||
var atIndex = reference.IndexOf('@');
|
||||
if (atIndex > 0)
|
||||
{
|
||||
refWithoutTag = reference[..atIndex];
|
||||
}
|
||||
else
|
||||
{
|
||||
var colonIndex = reference.LastIndexOf(':');
|
||||
if (colonIndex > 0)
|
||||
{
|
||||
// Check if it's a port number or tag
|
||||
var slashIndex = reference.LastIndexOf('/');
|
||||
if (slashIndex < colonIndex)
|
||||
{
|
||||
refWithoutTag = reference[..colonIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract registry (first path component)
|
||||
var firstSlash = refWithoutTag.IndexOf('/');
|
||||
if (firstSlash > 0)
|
||||
{
|
||||
var potentialRegistry = refWithoutTag[..firstSlash];
|
||||
// Check if it looks like a registry (contains . or : or is localhost)
|
||||
if (potentialRegistry.Contains('.') ||
|
||||
potentialRegistry.Contains(':') ||
|
||||
potentialRegistry.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return potentialRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to docker.io for implicit registry
|
||||
return "docker.io";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type URIs for verdict attestations.
|
||||
/// </summary>
|
||||
public static class VerdictPredicateTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicate type for risk verdict attestations.
|
||||
/// </summary>
|
||||
public const string Verdict = "verdict.stella/v1";
|
||||
}
|
||||
Reference in New Issue
Block a user