old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CvssThresholdGate.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_cvss_threshold_gate
|
||||
// Tasks: CVSS-GATE-001 to CVSS-GATE-007
|
||||
// Description: Policy gate for CVSS score threshold enforcement.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for CVSS threshold gate.
|
||||
/// </summary>
|
||||
public sealed class CvssThresholdGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:Gates:CvssThreshold";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the gate is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gate priority (lower = earlier evaluation).
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Default CVSS threshold (used when environment-specific not configured).
|
||||
/// </summary>
|
||||
public double DefaultThreshold { get; init; } = 7.0;
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment CVSS thresholds.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, double> Thresholds { get; init; } = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = 7.0,
|
||||
["staging"] = 8.0,
|
||||
["development"] = 9.0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Preferred CVSS version for evaluation: "v3.1", "v4.0", or "highest".
|
||||
/// </summary>
|
||||
public string CvssVersionPreference { get; init; } = "highest";
|
||||
|
||||
/// <summary>
|
||||
/// CVEs to always allow regardless of score.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> Allowlist { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// CVEs to always block regardless of score.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> Denylist { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail findings without CVSS scores.
|
||||
/// </summary>
|
||||
public bool FailOnMissingCvss { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require all CVSS versions to pass (AND) vs any (OR).
|
||||
/// </summary>
|
||||
public bool RequireAllVersionsPass { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS score information for a finding.
|
||||
/// </summary>
|
||||
public sealed record CvssScoreInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// CVSS v3.1 base score (0.0-10.0), null if not available.
|
||||
/// </summary>
|
||||
public double? CvssV31BaseScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 base score (0.0-10.0), null if not available.
|
||||
/// </summary>
|
||||
public double? CvssV40BaseScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v3.1 vector string.
|
||||
/// </summary>
|
||||
public string? CvssV31Vector { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 vector string.
|
||||
/// </summary>
|
||||
public string? CvssV40Vector { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces CVSS score thresholds.
|
||||
/// Blocks findings with CVSS scores exceeding configured thresholds.
|
||||
/// </summary>
|
||||
public sealed class CvssThresholdGate : IPolicyGate
|
||||
{
|
||||
private readonly CvssThresholdGateOptions _options;
|
||||
private readonly Func<string?, CvssScoreInfo?> _cvssLookup;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the gate with options and optional CVSS lookup.
|
||||
/// </summary>
|
||||
/// <param name="options">Gate options.</param>
|
||||
/// <param name="cvssLookup">Function to look up CVSS scores by CVE ID. If null, uses context metadata.</param>
|
||||
public CvssThresholdGate(CvssThresholdGateOptions? options = null, Func<string?, CvssScoreInfo?>? cvssLookup = null)
|
||||
{
|
||||
_options = options ?? new CvssThresholdGateOptions();
|
||||
_cvssLookup = cvssLookup ?? (_ => null);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.FromResult(Pass("disabled"));
|
||||
}
|
||||
|
||||
var cveId = context.CveId;
|
||||
|
||||
// Check denylist first (always block)
|
||||
if (!string.IsNullOrEmpty(cveId) && _options.Denylist.Contains(cveId))
|
||||
{
|
||||
return Task.FromResult(Fail(
|
||||
"denylist",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["cve_id"] = cveId,
|
||||
["reason"] = "CVE is on denylist"
|
||||
}));
|
||||
}
|
||||
|
||||
// Check allowlist (always pass)
|
||||
if (!string.IsNullOrEmpty(cveId) && _options.Allowlist.Contains(cveId))
|
||||
{
|
||||
return Task.FromResult(Pass(
|
||||
"allowlist",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["cve_id"] = cveId,
|
||||
["reason"] = "CVE is on allowlist"
|
||||
}));
|
||||
}
|
||||
|
||||
// Get CVSS scores
|
||||
var cvssInfo = GetCvssScores(cveId, context);
|
||||
if (cvssInfo is null || (!cvssInfo.CvssV31BaseScore.HasValue && !cvssInfo.CvssV40BaseScore.HasValue))
|
||||
{
|
||||
if (_options.FailOnMissingCvss)
|
||||
{
|
||||
return Task.FromResult(Fail(
|
||||
"missing_cvss",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["cve_id"] = cveId ?? "(unknown)",
|
||||
["reason"] = "No CVSS score available"
|
||||
}));
|
||||
}
|
||||
|
||||
return Task.FromResult(Pass(
|
||||
"no_cvss_available",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["cve_id"] = cveId ?? "(unknown)"
|
||||
}));
|
||||
}
|
||||
|
||||
// Get threshold for environment
|
||||
var threshold = GetThreshold(context.Environment);
|
||||
|
||||
// Evaluate based on version preference
|
||||
var (passed, selectedScore, selectedVersion) = EvaluateCvss(cvssInfo, threshold);
|
||||
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["threshold"] = threshold,
|
||||
["environment"] = context.Environment,
|
||||
["cvss_version"] = selectedVersion,
|
||||
["cvss_score"] = selectedScore,
|
||||
["preference"] = _options.CvssVersionPreference
|
||||
};
|
||||
|
||||
if (cvssInfo.CvssV31BaseScore.HasValue)
|
||||
{
|
||||
details["cvss_v31_score"] = cvssInfo.CvssV31BaseScore.Value;
|
||||
}
|
||||
if (cvssInfo.CvssV40BaseScore.HasValue)
|
||||
{
|
||||
details["cvss_v40_score"] = cvssInfo.CvssV40BaseScore.Value;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(cveId))
|
||||
{
|
||||
details["cve_id"] = cveId;
|
||||
}
|
||||
|
||||
if (!passed)
|
||||
{
|
||||
return Task.FromResult(Fail(
|
||||
"cvss_exceeds_threshold",
|
||||
details));
|
||||
}
|
||||
|
||||
return Task.FromResult(Pass("cvss_within_threshold", details));
|
||||
}
|
||||
|
||||
private CvssScoreInfo? GetCvssScores(string? cveId, PolicyGateContext context)
|
||||
{
|
||||
// Try lookup function first
|
||||
var fromLookup = _cvssLookup(cveId);
|
||||
if (fromLookup is not null)
|
||||
{
|
||||
return fromLookup;
|
||||
}
|
||||
|
||||
// Try to extract from context metadata
|
||||
if (context.Metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double? v31Score = null;
|
||||
double? v40Score = null;
|
||||
string? v31Vector = null;
|
||||
string? v40Vector = null;
|
||||
|
||||
if (context.Metadata.TryGetValue("cvss_v31_score", out var v31Str) &&
|
||||
double.TryParse(v31Str, NumberStyles.Float, CultureInfo.InvariantCulture, out var v31))
|
||||
{
|
||||
v31Score = v31;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("cvss_v40_score", out var v40Str) &&
|
||||
double.TryParse(v40Str, NumberStyles.Float, CultureInfo.InvariantCulture, out var v40))
|
||||
{
|
||||
v40Score = v40;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("cvss_v31_vector", out var v31Vec))
|
||||
{
|
||||
v31Vector = v31Vec;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("cvss_v40_vector", out var v40Vec))
|
||||
{
|
||||
v40Vector = v40Vec;
|
||||
}
|
||||
|
||||
if (!v31Score.HasValue && !v40Score.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = v31Score,
|
||||
CvssV40BaseScore = v40Score,
|
||||
CvssV31Vector = v31Vector,
|
||||
CvssV40Vector = v40Vector
|
||||
};
|
||||
}
|
||||
|
||||
private double GetThreshold(string environment)
|
||||
{
|
||||
if (_options.Thresholds.TryGetValue(environment, out var threshold))
|
||||
{
|
||||
return threshold;
|
||||
}
|
||||
|
||||
return _options.DefaultThreshold;
|
||||
}
|
||||
|
||||
private (bool Passed, double Score, string Version) EvaluateCvss(CvssScoreInfo cvssInfo, double threshold)
|
||||
{
|
||||
var v31Score = cvssInfo.CvssV31BaseScore;
|
||||
var v40Score = cvssInfo.CvssV40BaseScore;
|
||||
|
||||
return _options.CvssVersionPreference.ToLowerInvariant() switch
|
||||
{
|
||||
"v3.1" when v31Score.HasValue => (v31Score.Value < threshold, v31Score.Value, "v3.1"),
|
||||
"v4.0" when v40Score.HasValue => (v40Score.Value < threshold, v40Score.Value, "v4.0"),
|
||||
"highest" => EvaluateHighest(v31Score, v40Score, threshold),
|
||||
_ => EvaluateHighest(v31Score, v40Score, threshold)
|
||||
};
|
||||
}
|
||||
|
||||
private (bool Passed, double Score, string Version) EvaluateHighest(double? v31Score, double? v40Score, double threshold)
|
||||
{
|
||||
// Use whichever score is available, preferring the higher one for conservative evaluation
|
||||
if (v31Score.HasValue && v40Score.HasValue)
|
||||
{
|
||||
if (_options.RequireAllVersionsPass)
|
||||
{
|
||||
// Both must pass
|
||||
var passed = v31Score.Value < threshold && v40Score.Value < threshold;
|
||||
var higherScore = Math.Max(v31Score.Value, v40Score.Value);
|
||||
var version = v31Score.Value >= v40Score.Value ? "v3.1" : "v4.0";
|
||||
return (passed, higherScore, $"both ({version} highest)");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use the higher score (more conservative)
|
||||
if (v31Score.Value >= v40Score.Value)
|
||||
{
|
||||
return (v31Score.Value < threshold, v31Score.Value, "v3.1");
|
||||
}
|
||||
return (v40Score.Value < threshold, v40Score.Value, "v4.0");
|
||||
}
|
||||
}
|
||||
|
||||
if (v31Score.HasValue)
|
||||
{
|
||||
return (v31Score.Value < threshold, v31Score.Value, "v3.1");
|
||||
}
|
||||
|
||||
if (v40Score.HasValue)
|
||||
{
|
||||
return (v40Score.Value < threshold, v40Score.Value, "v4.0");
|
||||
}
|
||||
|
||||
// No score available - should not reach here if caller checks first
|
||||
return (true, 0.0, "none");
|
||||
}
|
||||
|
||||
private static GateResult Pass(string reason, IDictionary<string, object>? details = null) => new()
|
||||
{
|
||||
GateName = nameof(CvssThresholdGate),
|
||||
Passed = true,
|
||||
Reason = reason,
|
||||
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
|
||||
private static GateResult Fail(string reason, IDictionary<string, object>? details = null) => new()
|
||||
{
|
||||
GateName = nameof(CvssThresholdGate),
|
||||
Passed = false,
|
||||
Reason = reason,
|
||||
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CvssThresholdGateExtensions.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_cvss_threshold_gate
|
||||
// Tasks: CVSS-GATE-007
|
||||
// Description: Extension methods for CVSS threshold gate registration.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for CVSS threshold gate registration.
|
||||
/// </summary>
|
||||
public static class CvssThresholdGateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds CVSS threshold gate services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration to bind options from.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddCvssThresholdGate(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.Configure<CvssThresholdGateOptions>(
|
||||
configuration.GetSection(CvssThresholdGateOptions.SectionName));
|
||||
|
||||
services.TryAddSingleton<CvssThresholdGate>(sp =>
|
||||
{
|
||||
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<CvssThresholdGateOptions>>()?.Value;
|
||||
return new CvssThresholdGate(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds CVSS threshold gate services with explicit options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration action.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddCvssThresholdGate(
|
||||
this IServiceCollection services,
|
||||
Action<CvssThresholdGateOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.Configure(configureOptions);
|
||||
|
||||
services.TryAddSingleton<CvssThresholdGate>(sp =>
|
||||
{
|
||||
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<CvssThresholdGateOptions>>()?.Value;
|
||||
return new CvssThresholdGate(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the CVSS threshold gate with a policy gate registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Policy gate registry.</param>
|
||||
/// <returns>Registry for chaining.</returns>
|
||||
public static IPolicyGateRegistry RegisterCvssThresholdGate(this IPolicyGateRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
|
||||
registry.Register<CvssThresholdGate>(nameof(CvssThresholdGate));
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomPresenceGate.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_sbom_presence_gate
|
||||
// Tasks: SBOM-GATE-001 to SBOM-GATE-008
|
||||
// Description: Policy gate for SBOM presence and format validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for SBOM presence gate.
|
||||
/// </summary>
|
||||
public sealed class SbomPresenceGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:Gates:SbomPresence";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the gate is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gate priority (lower = earlier evaluation).
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment enforcement levels.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, SbomEnforcementLevel> Enforcement { get; init; } =
|
||||
new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = SbomEnforcementLevel.Required,
|
||||
["staging"] = SbomEnforcementLevel.Required,
|
||||
["development"] = SbomEnforcementLevel.Optional
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Default enforcement level for unknown environments.
|
||||
/// </summary>
|
||||
public SbomEnforcementLevel DefaultEnforcement { get; init; } = SbomEnforcementLevel.Required;
|
||||
|
||||
/// <summary>
|
||||
/// Accepted SBOM formats.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AcceptedFormats { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"spdx-2.2",
|
||||
"spdx-2.3",
|
||||
"spdx-3.0.1",
|
||||
"cyclonedx-1.4",
|
||||
"cyclonedx-1.5",
|
||||
"cyclonedx-1.6",
|
||||
"cyclonedx-1.7"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of components required in SBOM.
|
||||
/// </summary>
|
||||
public int MinimumComponents { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require SBOM signature.
|
||||
/// </summary>
|
||||
public bool RequireSignature { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate SBOM against schema.
|
||||
/// </summary>
|
||||
public bool SchemaValidation { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require primary component/describes field.
|
||||
/// </summary>
|
||||
public bool RequirePrimaryComponent { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM enforcement levels.
|
||||
/// </summary>
|
||||
public enum SbomEnforcementLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM is not required (gate passes regardless).
|
||||
/// </summary>
|
||||
Optional,
|
||||
|
||||
/// <summary>
|
||||
/// SBOM is recommended but not required (warning on missing).
|
||||
/// </summary>
|
||||
Recommended,
|
||||
|
||||
/// <summary>
|
||||
/// SBOM is required (gate fails if missing).
|
||||
/// </summary>
|
||||
Required
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an SBOM for gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record SbomInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether an SBOM is present.
|
||||
/// </summary>
|
||||
public bool Present { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format (e.g., "spdx-2.3", "cyclonedx-1.6").
|
||||
/// </summary>
|
||||
public string? Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format version.
|
||||
/// </summary>
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of components in the SBOM.
|
||||
/// </summary>
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the SBOM has a signature.
|
||||
/// </summary>
|
||||
public bool HasSignature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the SBOM passed schema validation.
|
||||
/// </summary>
|
||||
public bool? SchemaValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Schema validation errors if any.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? SchemaErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a primary component/describes field is present.
|
||||
/// </summary>
|
||||
public bool HasPrimaryComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM document URI or path.
|
||||
/// </summary>
|
||||
public string? DocumentUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM creation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that validates SBOM presence and format.
|
||||
/// </summary>
|
||||
public sealed class SbomPresenceGate : IPolicyGate
|
||||
{
|
||||
private readonly SbomPresenceGateOptions _options;
|
||||
private readonly Func<PolicyGateContext, SbomInfo?> _sbomLookup;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the gate with options and optional SBOM lookup.
|
||||
/// </summary>
|
||||
/// <param name="options">Gate options.</param>
|
||||
/// <param name="sbomLookup">Function to look up SBOM info from context.</param>
|
||||
public SbomPresenceGate(SbomPresenceGateOptions? options = null, Func<PolicyGateContext, SbomInfo?>? sbomLookup = null)
|
||||
{
|
||||
_options = options ?? new SbomPresenceGateOptions();
|
||||
_sbomLookup = sbomLookup ?? GetSbomFromMetadata;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.FromResult(Pass("disabled"));
|
||||
}
|
||||
|
||||
var enforcement = GetEnforcementLevel(context.Environment);
|
||||
|
||||
// If optional, always pass
|
||||
if (enforcement == SbomEnforcementLevel.Optional)
|
||||
{
|
||||
return Task.FromResult(Pass("optional_enforcement", new Dictionary<string, object>
|
||||
{
|
||||
["environment"] = context.Environment,
|
||||
["enforcement"] = enforcement.ToString()
|
||||
}));
|
||||
}
|
||||
|
||||
// Get SBOM info
|
||||
var sbomInfo = _sbomLookup(context);
|
||||
|
||||
// Check presence
|
||||
if (sbomInfo is null || !sbomInfo.Present)
|
||||
{
|
||||
if (enforcement == SbomEnforcementLevel.Recommended)
|
||||
{
|
||||
return Task.FromResult(Pass("sbom_missing_recommended", new Dictionary<string, object>
|
||||
{
|
||||
["environment"] = context.Environment,
|
||||
["enforcement"] = enforcement.ToString(),
|
||||
["warning"] = "SBOM recommended but not present"
|
||||
}));
|
||||
}
|
||||
|
||||
return Task.FromResult(Fail("sbom_missing", new Dictionary<string, object>
|
||||
{
|
||||
["environment"] = context.Environment,
|
||||
["enforcement"] = enforcement.ToString(),
|
||||
["reason"] = "SBOM is required but not present"
|
||||
}));
|
||||
}
|
||||
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["environment"] = context.Environment,
|
||||
["enforcement"] = enforcement.ToString(),
|
||||
["sbom_present"] = true
|
||||
};
|
||||
|
||||
// Validate format
|
||||
if (!string.IsNullOrEmpty(sbomInfo.Format))
|
||||
{
|
||||
details["format"] = sbomInfo.Format;
|
||||
|
||||
var normalizedFormat = NormalizeFormat(sbomInfo.Format, sbomInfo.FormatVersion);
|
||||
if (!_options.AcceptedFormats.Contains(normalizedFormat))
|
||||
{
|
||||
details["normalized_format"] = normalizedFormat;
|
||||
details["accepted_formats"] = string.Join(", ", _options.AcceptedFormats);
|
||||
return Task.FromResult(Fail("invalid_format", details));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate component count
|
||||
details["component_count"] = sbomInfo.ComponentCount;
|
||||
if (sbomInfo.ComponentCount < _options.MinimumComponents)
|
||||
{
|
||||
details["minimum_components"] = _options.MinimumComponents;
|
||||
return Task.FromResult(Fail("insufficient_components", details));
|
||||
}
|
||||
|
||||
// Validate schema
|
||||
if (_options.SchemaValidation && sbomInfo.SchemaValid.HasValue)
|
||||
{
|
||||
details["schema_valid"] = sbomInfo.SchemaValid.Value;
|
||||
if (!sbomInfo.SchemaValid.Value)
|
||||
{
|
||||
if (sbomInfo.SchemaErrors is { Count: > 0 })
|
||||
{
|
||||
details["schema_errors"] = string.Join("; ", sbomInfo.SchemaErrors.Take(5));
|
||||
}
|
||||
return Task.FromResult(Fail("schema_validation_failed", details));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate signature requirement
|
||||
if (_options.RequireSignature)
|
||||
{
|
||||
details["has_signature"] = sbomInfo.HasSignature;
|
||||
if (!sbomInfo.HasSignature)
|
||||
{
|
||||
return Task.FromResult(Fail("signature_missing", details));
|
||||
}
|
||||
|
||||
if (sbomInfo.SignatureValid.HasValue)
|
||||
{
|
||||
details["signature_valid"] = sbomInfo.SignatureValid.Value;
|
||||
if (!sbomInfo.SignatureValid.Value)
|
||||
{
|
||||
return Task.FromResult(Fail("signature_invalid", details));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate primary component
|
||||
if (_options.RequirePrimaryComponent)
|
||||
{
|
||||
details["has_primary_component"] = sbomInfo.HasPrimaryComponent;
|
||||
if (!sbomInfo.HasPrimaryComponent)
|
||||
{
|
||||
return Task.FromResult(Fail("primary_component_missing", details));
|
||||
}
|
||||
}
|
||||
|
||||
// Add optional metadata
|
||||
if (!string.IsNullOrEmpty(sbomInfo.DocumentUri))
|
||||
{
|
||||
details["document_uri"] = sbomInfo.DocumentUri;
|
||||
}
|
||||
if (sbomInfo.CreatedAt.HasValue)
|
||||
{
|
||||
details["created_at"] = sbomInfo.CreatedAt.Value.ToString("o", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return Task.FromResult(Pass("sbom_valid", details));
|
||||
}
|
||||
|
||||
private SbomEnforcementLevel GetEnforcementLevel(string environment)
|
||||
{
|
||||
if (_options.Enforcement.TryGetValue(environment, out var level))
|
||||
{
|
||||
return level;
|
||||
}
|
||||
return _options.DefaultEnforcement;
|
||||
}
|
||||
|
||||
private static string NormalizeFormat(string format, string? version)
|
||||
{
|
||||
// Normalize format string to match accepted formats
|
||||
var normalizedFormat = format.ToLowerInvariant().Trim();
|
||||
|
||||
// Handle various format representations
|
||||
if (normalizedFormat.StartsWith("spdx", StringComparison.Ordinal))
|
||||
{
|
||||
// Extract version from format or use provided version
|
||||
var spdxVersion = ExtractVersion(normalizedFormat, "spdx") ?? version;
|
||||
if (!string.IsNullOrEmpty(spdxVersion))
|
||||
{
|
||||
return $"spdx-{spdxVersion}";
|
||||
}
|
||||
return normalizedFormat;
|
||||
}
|
||||
|
||||
if (normalizedFormat.StartsWith("cyclonedx", StringComparison.Ordinal) ||
|
||||
normalizedFormat.StartsWith("cdx", StringComparison.Ordinal))
|
||||
{
|
||||
var cdxVersion = ExtractVersion(normalizedFormat, "cyclonedx") ??
|
||||
ExtractVersion(normalizedFormat, "cdx") ??
|
||||
version;
|
||||
if (!string.IsNullOrEmpty(cdxVersion))
|
||||
{
|
||||
return $"cyclonedx-{cdxVersion}";
|
||||
}
|
||||
return normalizedFormat.Replace("cdx", "cyclonedx");
|
||||
}
|
||||
|
||||
return normalizedFormat;
|
||||
}
|
||||
|
||||
private static string? ExtractVersion(string format, string prefix)
|
||||
{
|
||||
// Try to extract version from format like "spdx-2.3" or "spdx2.3" or "spdx 2.3"
|
||||
var withoutPrefix = format
|
||||
.Replace(prefix, string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.TrimStart('-', ' ', '_');
|
||||
|
||||
if (string.IsNullOrEmpty(withoutPrefix))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if remaining string looks like a version
|
||||
if (char.IsDigit(withoutPrefix[0]))
|
||||
{
|
||||
// Take until non-version character
|
||||
var versionEnd = 0;
|
||||
while (versionEnd < withoutPrefix.Length &&
|
||||
(char.IsDigit(withoutPrefix[versionEnd]) || withoutPrefix[versionEnd] == '.'))
|
||||
{
|
||||
versionEnd++;
|
||||
}
|
||||
return withoutPrefix[..versionEnd];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SbomInfo? GetSbomFromMetadata(PolicyGateContext context)
|
||||
{
|
||||
if (context.Metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var present = context.Metadata.TryGetValue("sbom_present", out var presentStr) &&
|
||||
bool.TryParse(presentStr, out var p) && p;
|
||||
|
||||
if (!present)
|
||||
{
|
||||
return new SbomInfo { Present = false };
|
||||
}
|
||||
|
||||
context.Metadata.TryGetValue("sbom_format", out var format);
|
||||
context.Metadata.TryGetValue("sbom_format_version", out var formatVersion);
|
||||
|
||||
var componentCount = 0;
|
||||
if (context.Metadata.TryGetValue("sbom_component_count", out var countStr) &&
|
||||
int.TryParse(countStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count))
|
||||
{
|
||||
componentCount = count;
|
||||
}
|
||||
|
||||
var hasSignature = context.Metadata.TryGetValue("sbom_has_signature", out var sigStr) &&
|
||||
bool.TryParse(sigStr, out var sig) && sig;
|
||||
|
||||
bool? signatureValid = null;
|
||||
if (context.Metadata.TryGetValue("sbom_signature_valid", out var sigValidStr) &&
|
||||
bool.TryParse(sigValidStr, out var sv))
|
||||
{
|
||||
signatureValid = sv;
|
||||
}
|
||||
|
||||
bool? schemaValid = null;
|
||||
if (context.Metadata.TryGetValue("sbom_schema_valid", out var schemaValidStr) &&
|
||||
bool.TryParse(schemaValidStr, out var schv))
|
||||
{
|
||||
schemaValid = schv;
|
||||
}
|
||||
|
||||
var hasPrimaryComponent = context.Metadata.TryGetValue("sbom_has_primary_component", out var pcStr) &&
|
||||
bool.TryParse(pcStr, out var pc) && pc;
|
||||
|
||||
context.Metadata.TryGetValue("sbom_document_uri", out var documentUri);
|
||||
|
||||
DateTimeOffset? createdAt = null;
|
||||
if (context.Metadata.TryGetValue("sbom_created_at", out var createdStr) &&
|
||||
DateTimeOffset.TryParse(createdStr, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var created))
|
||||
{
|
||||
createdAt = created;
|
||||
}
|
||||
|
||||
return new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = format,
|
||||
FormatVersion = formatVersion,
|
||||
ComponentCount = componentCount,
|
||||
HasSignature = hasSignature,
|
||||
SignatureValid = signatureValid,
|
||||
SchemaValid = schemaValid,
|
||||
HasPrimaryComponent = hasPrimaryComponent,
|
||||
DocumentUri = documentUri,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
}
|
||||
|
||||
private static GateResult Pass(string reason, IDictionary<string, object>? details = null) => new()
|
||||
{
|
||||
GateName = nameof(SbomPresenceGate),
|
||||
Passed = true,
|
||||
Reason = reason,
|
||||
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
|
||||
private static GateResult Fail(string reason, IDictionary<string, object>? details = null) => new()
|
||||
{
|
||||
GateName = nameof(SbomPresenceGate),
|
||||
Passed = false,
|
||||
Reason = reason,
|
||||
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomPresenceGateExtensions.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_sbom_presence_gate
|
||||
// Tasks: SBOM-GATE-008
|
||||
// Description: Extension methods for SBOM presence gate registration.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for SBOM presence gate registration.
|
||||
/// </summary>
|
||||
public static class SbomPresenceGateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds SBOM presence gate services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration to bind options from.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSbomPresenceGate(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.Configure<SbomPresenceGateOptions>(
|
||||
configuration.GetSection(SbomPresenceGateOptions.SectionName));
|
||||
|
||||
services.TryAddSingleton<SbomPresenceGate>(sp =>
|
||||
{
|
||||
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<SbomPresenceGateOptions>>()?.Value;
|
||||
return new SbomPresenceGate(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds SBOM presence gate services with explicit options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration action.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSbomPresenceGate(
|
||||
this IServiceCollection services,
|
||||
Action<SbomPresenceGateOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.Configure(configureOptions);
|
||||
|
||||
services.TryAddSingleton<SbomPresenceGate>(sp =>
|
||||
{
|
||||
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<SbomPresenceGateOptions>>()?.Value;
|
||||
return new SbomPresenceGate(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the SBOM presence gate with a policy gate registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Policy gate registry.</param>
|
||||
/// <returns>Registry for chaining.</returns>
|
||||
public static IPolicyGateRegistry RegisterSbomPresenceGate(this IPolicyGateRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
|
||||
registry.Register<SbomPresenceGate>(nameof(SbomPresenceGate));
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SignatureRequiredGate.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_signature_required_gate
|
||||
// Tasks: SIG-GATE-001 to SIG-GATE-008
|
||||
// Description: Policy gate for signature verification on evidence artifacts.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for signature required gate.
|
||||
/// </summary>
|
||||
public sealed class SignatureRequiredGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:Gates:SignatureRequired";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the gate is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gate priority (lower = earlier evaluation).
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Per-evidence-type signature requirements.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, EvidenceSignatureConfig> EvidenceTypes { get; init; } =
|
||||
new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = true },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = true }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment override for signature requirements.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, EnvironmentSignatureConfig> Environments { get; init; } =
|
||||
new Dictionary<string, EnvironmentSignatureConfig>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Default behavior for unknown evidence types.
|
||||
/// </summary>
|
||||
public bool RequireUnknownTypes { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to support keyless (Fulcio) verification.
|
||||
/// </summary>
|
||||
public bool EnableKeylessVerification { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Fulcio root certificate paths (bundled).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FulcioRoots { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log URL for keyless verification.
|
||||
/// </summary>
|
||||
public string? RekorUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require transparency log inclusion for keyless signatures.
|
||||
/// </summary>
|
||||
public bool RequireTransparencyLogInclusion { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a specific evidence type.
|
||||
/// </summary>
|
||||
public sealed class EvidenceSignatureConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether signature is required for this evidence type.
|
||||
/// </summary>
|
||||
public bool Required { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Trusted issuers (email identities). Supports wildcards (*@domain.com).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> TrustedIssuers { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Trusted key IDs (for non-keyless verification).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> TrustedKeyIds { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Accepted signature algorithms.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AcceptedAlgorithms { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ES256", "ES384", "ES512", // ECDSA
|
||||
"RS256", "RS384", "RS512", // RSA
|
||||
"EdDSA", "Ed25519" // Edwards curves
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow self-signed certificates.
|
||||
/// </summary>
|
||||
public bool AllowSelfSigned { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment signature configuration override.
|
||||
/// </summary>
|
||||
public sealed class EnvironmentSignatureConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Override required flag for this environment.
|
||||
/// </summary>
|
||||
public bool? RequiredOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional trusted issuers for this environment.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? AdditionalIssuers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence types to skip in this environment.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? SkipEvidenceTypes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a signature for gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record SignatureInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Evidence type (sbom, vex, attestation, etc.).
|
||||
/// </summary>
|
||||
public required string EvidenceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the evidence has a signature.
|
||||
/// </summary>
|
||||
public bool HasSignature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature algorithm used.
|
||||
/// </summary>
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signer identity (email for keyless).
|
||||
/// </summary>
|
||||
public string? SignerIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID for non-keyless signatures.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signature is keyless (Fulcio).
|
||||
/// </summary>
|
||||
public bool IsKeyless { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signature has transparency log inclusion.
|
||||
/// </summary>
|
||||
public bool? HasTransparencyLogInclusion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transparency log entry ID.
|
||||
/// </summary>
|
||||
public string? TransparencyLogEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type.
|
||||
/// </summary>
|
||||
public string? DssePayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate chain validity.
|
||||
/// </summary>
|
||||
public bool? CertificateChainValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate expiration (for keyless).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CertificateExpiry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification errors if any.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? VerificationErrors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces signature requirements on evidence artifacts.
|
||||
/// </summary>
|
||||
public sealed class SignatureRequiredGate : IPolicyGate
|
||||
{
|
||||
private readonly SignatureRequiredGateOptions _options;
|
||||
private readonly Func<PolicyGateContext, IReadOnlyList<SignatureInfo>> _signatureLookup;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the gate with options and optional signature lookup.
|
||||
/// </summary>
|
||||
/// <param name="options">Gate options.</param>
|
||||
/// <param name="signatureLookup">Function to look up signature info from context.</param>
|
||||
public SignatureRequiredGate(
|
||||
SignatureRequiredGateOptions? options = null,
|
||||
Func<PolicyGateContext, IReadOnlyList<SignatureInfo>>? signatureLookup = null)
|
||||
{
|
||||
_options = options ?? new SignatureRequiredGateOptions();
|
||||
_signatureLookup = signatureLookup ?? GetSignaturesFromMetadata;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.FromResult(Pass("disabled"));
|
||||
}
|
||||
|
||||
var signatures = _signatureLookup(context);
|
||||
var envConfig = GetEnvironmentConfig(context.Environment);
|
||||
|
||||
var failures = new List<string>();
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["environment"] = context.Environment,
|
||||
["signatures_evaluated"] = signatures.Count
|
||||
};
|
||||
|
||||
// Check each configured evidence type
|
||||
foreach (var (evidenceType, config) in _options.EvidenceTypes)
|
||||
{
|
||||
// Check if skipped for this environment
|
||||
if (envConfig?.SkipEvidenceTypes?.Contains(evidenceType) == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isRequired = envConfig?.RequiredOverride ?? config.Required;
|
||||
if (!isRequired)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var matchingSignatures = signatures.Where(s =>
|
||||
string.Equals(s.EvidenceType, evidenceType, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (matchingSignatures.Count == 0)
|
||||
{
|
||||
failures.Add($"{evidenceType}: signature missing");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var sig in matchingSignatures)
|
||||
{
|
||||
var validationResult = ValidateSignature(sig, config, envConfig);
|
||||
if (!validationResult.Valid)
|
||||
{
|
||||
failures.Add($"{evidenceType}: {validationResult.Error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for any signatures on unknown types if configured
|
||||
if (_options.RequireUnknownTypes)
|
||||
{
|
||||
var knownTypes = _options.EvidenceTypes.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var unknownSigs = signatures.Where(s => !knownTypes.Contains(s.EvidenceType));
|
||||
foreach (var sig in unknownSigs)
|
||||
{
|
||||
if (!sig.HasSignature || sig.SignatureValid != true)
|
||||
{
|
||||
failures.Add($"{sig.EvidenceType}: unknown type requires valid signature");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
details["failures"] = failures.ToArray();
|
||||
return Task.FromResult(Fail("signature_validation_failed", details));
|
||||
}
|
||||
|
||||
details["all_signatures_valid"] = true;
|
||||
return Task.FromResult(Pass("signatures_verified", details));
|
||||
}
|
||||
|
||||
private (bool Valid, string? Error) ValidateSignature(
|
||||
SignatureInfo sig,
|
||||
EvidenceSignatureConfig config,
|
||||
EnvironmentSignatureConfig? envConfig)
|
||||
{
|
||||
// Check if signature is present
|
||||
if (!sig.HasSignature)
|
||||
{
|
||||
return (false, "signature not present");
|
||||
}
|
||||
|
||||
// Check if signature is valid
|
||||
if (sig.SignatureValid != true)
|
||||
{
|
||||
var errors = sig.VerificationErrors is { Count: > 0 }
|
||||
? string.Join("; ", sig.VerificationErrors.Take(3))
|
||||
: "signature verification failed";
|
||||
return (false, errors);
|
||||
}
|
||||
|
||||
// Check algorithm
|
||||
if (!string.IsNullOrEmpty(sig.Algorithm) && !config.AcceptedAlgorithms.Contains(sig.Algorithm))
|
||||
{
|
||||
return (false, $"algorithm '{sig.Algorithm}' not accepted");
|
||||
}
|
||||
|
||||
// Validate issuer/identity
|
||||
if (!string.IsNullOrEmpty(sig.SignerIdentity))
|
||||
{
|
||||
var trustedIssuers = new HashSet<string>(config.TrustedIssuers, StringComparer.OrdinalIgnoreCase);
|
||||
if (envConfig?.AdditionalIssuers is not null)
|
||||
{
|
||||
trustedIssuers.UnionWith(envConfig.AdditionalIssuers);
|
||||
}
|
||||
|
||||
if (trustedIssuers.Count > 0 && !IsIssuerTrusted(sig.SignerIdentity, trustedIssuers))
|
||||
{
|
||||
return (false, $"issuer '{sig.SignerIdentity}' not trusted");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate key ID for non-keyless
|
||||
if (!sig.IsKeyless && !string.IsNullOrEmpty(sig.KeyId))
|
||||
{
|
||||
if (config.TrustedKeyIds.Count > 0 && !config.TrustedKeyIds.Contains(sig.KeyId))
|
||||
{
|
||||
return (false, $"key '{sig.KeyId}' not trusted");
|
||||
}
|
||||
}
|
||||
|
||||
// Keyless-specific validation
|
||||
if (sig.IsKeyless)
|
||||
{
|
||||
if (!_options.EnableKeylessVerification)
|
||||
{
|
||||
return (false, "keyless verification disabled");
|
||||
}
|
||||
|
||||
if (_options.RequireTransparencyLogInclusion && sig.HasTransparencyLogInclusion != true)
|
||||
{
|
||||
return (false, "transparency log inclusion required");
|
||||
}
|
||||
|
||||
if (sig.CertificateChainValid == false)
|
||||
{
|
||||
return (false, "certificate chain invalid");
|
||||
}
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static bool IsIssuerTrusted(string issuer, ISet<string> trustedIssuers)
|
||||
{
|
||||
// Direct match
|
||||
if (trustedIssuers.Contains(issuer))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard match (*@domain.com)
|
||||
foreach (var trusted in trustedIssuers)
|
||||
{
|
||||
if (trusted.StartsWith("*@", StringComparison.Ordinal))
|
||||
{
|
||||
var domain = trusted[2..];
|
||||
if (issuer.EndsWith($"@{domain}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (trusted.Contains('*'))
|
||||
{
|
||||
// General wildcard pattern
|
||||
var pattern = "^" + Regex.Escape(trusted).Replace("\\*", ".*") + "$";
|
||||
if (Regex.IsMatch(issuer, pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private EnvironmentSignatureConfig? GetEnvironmentConfig(string environment)
|
||||
{
|
||||
if (_options.Environments.TryGetValue(environment, out var config))
|
||||
{
|
||||
return config;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SignatureInfo> GetSignaturesFromMetadata(PolicyGateContext context)
|
||||
{
|
||||
if (context.Metadata is null)
|
||||
{
|
||||
return Array.Empty<SignatureInfo>();
|
||||
}
|
||||
|
||||
var signatures = new List<SignatureInfo>();
|
||||
|
||||
// Parse signature info from metadata
|
||||
// Expected keys: sig_<type>_present, sig_<type>_valid, sig_<type>_identity, etc.
|
||||
var types = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var key in context.Metadata.Keys)
|
||||
{
|
||||
if (key.StartsWith("sig_", StringComparison.OrdinalIgnoreCase) && key.Contains('_'))
|
||||
{
|
||||
var parts = key.Split('_');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
types.Add(parts[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
var prefix = $"sig_{type}_";
|
||||
|
||||
var hasSignature = context.Metadata.TryGetValue($"{prefix}present", out var presentStr) &&
|
||||
bool.TryParse(presentStr, out var present) && present;
|
||||
|
||||
bool? signatureValid = null;
|
||||
if (context.Metadata.TryGetValue($"{prefix}valid", out var validStr) &&
|
||||
bool.TryParse(validStr, out var valid))
|
||||
{
|
||||
signatureValid = valid;
|
||||
}
|
||||
|
||||
context.Metadata.TryGetValue($"{prefix}algorithm", out var algorithm);
|
||||
context.Metadata.TryGetValue($"{prefix}identity", out var identity);
|
||||
context.Metadata.TryGetValue($"{prefix}keyid", out var keyId);
|
||||
|
||||
var isKeyless = context.Metadata.TryGetValue($"{prefix}keyless", out var keylessStr) &&
|
||||
bool.TryParse(keylessStr, out var keyless) && keyless;
|
||||
|
||||
bool? hasLogInclusion = null;
|
||||
if (context.Metadata.TryGetValue($"{prefix}log_inclusion", out var logStr) &&
|
||||
bool.TryParse(logStr, out var log))
|
||||
{
|
||||
hasLogInclusion = log;
|
||||
}
|
||||
|
||||
signatures.Add(new SignatureInfo
|
||||
{
|
||||
EvidenceType = type,
|
||||
HasSignature = hasSignature,
|
||||
SignatureValid = signatureValid,
|
||||
Algorithm = algorithm,
|
||||
SignerIdentity = identity,
|
||||
KeyId = keyId,
|
||||
IsKeyless = isKeyless,
|
||||
HasTransparencyLogInclusion = hasLogInclusion
|
||||
});
|
||||
}
|
||||
|
||||
return signatures;
|
||||
}
|
||||
|
||||
private static GateResult Pass(string reason, IDictionary<string, object>? details = null) => new()
|
||||
{
|
||||
GateName = nameof(SignatureRequiredGate),
|
||||
Passed = true,
|
||||
Reason = reason,
|
||||
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
|
||||
private static GateResult Fail(string reason, IDictionary<string, object>? details = null) => new()
|
||||
{
|
||||
GateName = nameof(SignatureRequiredGate),
|
||||
Passed = false,
|
||||
Reason = reason,
|
||||
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SignatureRequiredGateExtensions.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_signature_required_gate
|
||||
// Tasks: SIG-GATE-008
|
||||
// Description: Extension methods for signature required gate registration.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for signature required gate registration.
|
||||
/// </summary>
|
||||
public static class SignatureRequiredGateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds signature required gate services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration to bind options from.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSignatureRequiredGate(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.Configure<SignatureRequiredGateOptions>(
|
||||
configuration.GetSection(SignatureRequiredGateOptions.SectionName));
|
||||
|
||||
services.TryAddSingleton<SignatureRequiredGate>(sp =>
|
||||
{
|
||||
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<SignatureRequiredGateOptions>>()?.Value;
|
||||
return new SignatureRequiredGate(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds signature required gate services with explicit options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration action.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSignatureRequiredGate(
|
||||
this IServiceCollection services,
|
||||
Action<SignatureRequiredGateOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.Configure(configureOptions);
|
||||
|
||||
services.TryAddSingleton<SignatureRequiredGate>(sp =>
|
||||
{
|
||||
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<SignatureRequiredGateOptions>>()?.Value;
|
||||
return new SignatureRequiredGate(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the signature required gate with a policy gate registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Policy gate registry.</param>
|
||||
/// <returns>Registry for chaining.</returns>
|
||||
public static IPolicyGateRegistry RegisterSignatureRequiredGate(this IPolicyGateRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
|
||||
registry.Register<SignatureRequiredGate>(nameof(SignatureRequiredGate));
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,41 @@ public sealed record VexProofGateOptions
|
||||
["staging"] = "medium",
|
||||
["development"] = "low",
|
||||
};
|
||||
|
||||
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
|
||||
|
||||
/// <summary>
|
||||
/// Whether anchor-aware mode is enabled.
|
||||
/// When enabled, additional validation requirements are enforced.
|
||||
/// </summary>
|
||||
public bool AnchorAwareMode { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When anchor-aware mode is enabled, require VEX statements to have DSSE anchoring.
|
||||
/// </summary>
|
||||
public bool RequireVexAnchoring { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When anchor-aware mode is enabled, require Rekor transparency verification.
|
||||
/// </summary>
|
||||
public bool RequireRekorVerification { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Creates strict anchor-aware options for production use.
|
||||
/// </summary>
|
||||
public static VexProofGateOptions StrictAnchorAware => new()
|
||||
{
|
||||
Enabled = true,
|
||||
MinimumConfidenceTier = "high",
|
||||
RequireProofForNotAffected = true,
|
||||
RequireProofForFixed = true,
|
||||
RequireSignedStatements = true,
|
||||
AnchorAwareMode = true,
|
||||
RequireVexAnchoring = true,
|
||||
RequireRekorVerification = true,
|
||||
MaxAllowedConflicts = 0,
|
||||
MaxProofAgeHours = 72 // 3 days for strict mode
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -96,6 +131,20 @@ public sealed record VexProofGateContext
|
||||
|
||||
/// <summary>Consensus outcome from the proof.</summary>
|
||||
public string? ConsensusOutcome { get; init; }
|
||||
|
||||
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
|
||||
|
||||
/// <summary>Whether the VEX proof is anchored with DSSE attestation.</summary>
|
||||
public bool? IsAnchored { get; init; }
|
||||
|
||||
/// <summary>DSSE envelope digest if anchored.</summary>
|
||||
public string? EnvelopeDigest { get; init; }
|
||||
|
||||
/// <summary>Whether the proof has Rekor transparency.</summary>
|
||||
public bool? HasRekorVerification { get; init; }
|
||||
|
||||
/// <summary>Rekor log index if verified.</summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -225,6 +274,51 @@ public sealed class VexProofGate : IPolicyGate
|
||||
details["consensusOutcome"] = proofContext.ConsensusOutcome;
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
|
||||
// Anchor-aware mode validations
|
||||
if (_options.AnchorAwareMode)
|
||||
{
|
||||
details["anchorAwareMode"] = true;
|
||||
|
||||
// Validate VEX anchoring if required
|
||||
if (_options.RequireVexAnchoring)
|
||||
{
|
||||
details["requireVexAnchoring"] = true;
|
||||
details["isAnchored"] = proofContext.IsAnchored ?? false;
|
||||
|
||||
if (proofContext.IsAnchored != true)
|
||||
{
|
||||
return Task.FromResult(Fail("vex_not_anchored",
|
||||
details.ToImmutableDictionary(),
|
||||
"VEX proof requires DSSE anchoring in anchor-aware mode"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(proofContext.EnvelopeDigest))
|
||||
{
|
||||
details["envelopeDigest"] = proofContext.EnvelopeDigest;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Rekor verification if required
|
||||
if (_options.RequireRekorVerification)
|
||||
{
|
||||
details["requireRekorVerification"] = true;
|
||||
details["hasRekorVerification"] = proofContext.HasRekorVerification ?? false;
|
||||
|
||||
if (proofContext.HasRekorVerification != true)
|
||||
{
|
||||
return Task.FromResult(Fail("rekor_verification_missing",
|
||||
details.ToImmutableDictionary(),
|
||||
"VEX proof requires Rekor transparency verification in anchor-aware mode"));
|
||||
}
|
||||
|
||||
if (proofContext.RekorLogIndex.HasValue)
|
||||
{
|
||||
details["rekorLogIndex"] = proofContext.RekorLogIndex.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new GateResult
|
||||
{
|
||||
GateName = nameof(VexProofGate),
|
||||
@@ -291,6 +385,14 @@ public sealed class VexProofGate : IPolicyGate
|
||||
ProofComputedAt = context.Metadata.TryGetValue("vex_proof_computed_at", out var timeStr) &&
|
||||
DateTimeOffset.TryParse(timeStr, out var time) ? time : null,
|
||||
ConsensusOutcome = context.Metadata.GetValueOrDefault("vex_proof_consensus_outcome"),
|
||||
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
|
||||
IsAnchored = context.Metadata.TryGetValue("vex_proof_anchored", out var anchoredStr) &&
|
||||
bool.TryParse(anchoredStr, out var anchored) ? anchored : null,
|
||||
EnvelopeDigest = context.Metadata.GetValueOrDefault("vex_proof_envelope_digest"),
|
||||
HasRekorVerification = context.Metadata.TryGetValue("vex_proof_rekor_verified", out var rekorStr) &&
|
||||
bool.TryParse(rekorStr, out var rekorVerified) ? rekorVerified : null,
|
||||
RekorLogIndex = context.Metadata.TryGetValue("vex_proof_rekor_log_index", out var rekorIdxStr) &&
|
||||
long.TryParse(rekorIdxStr, out var rekorIdx) ? rekorIdx : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,4 +411,13 @@ public sealed class VexProofGate : IPolicyGate
|
||||
Reason = reason,
|
||||
Details = details ?? ImmutableDictionary<string, object>.Empty,
|
||||
};
|
||||
|
||||
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
|
||||
private static GateResult Fail(string reason, ImmutableDictionary<string, object>? details, string message) => new()
|
||||
{
|
||||
GateName = nameof(VexProofGate),
|
||||
Passed = false,
|
||||
Reason = reason,
|
||||
Details = (details ?? ImmutableDictionary<string, object>.Empty).Add("message", message),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user