Add call graph fixtures for various languages and scenarios
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET. - Added `all-visibility-levels.json` to validate method visibility levels in .NET. - Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application. - Included `go-gin-api.json` for a Go Gin API application structure. - Added `java-spring-boot.json` for the Spring PetClinic application in Java. - Introduced `legacy-no-schema.json` for legacy application structure without schema. - Created `node-express-api.json` for an Express.js API application structure.
This commit is contained in:
179
src/Policy/StellaOps.Policy.Engine/Scoring/ScorePolicyService.cs
Normal file
179
src/Policy/StellaOps.Policy.Engine/Scoring/ScorePolicyService.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Scoring;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Provides score policies with caching and digest computation.
|
||||
/// </summary>
|
||||
public interface IScorePolicyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the active score policy for a tenant.
|
||||
/// </summary>
|
||||
ScorePolicy GetPolicy(string tenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the canonical digest of a score policy for determinism tracking.
|
||||
/// </summary>
|
||||
string ComputePolicyDigest(ScorePolicy policy);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached digest for a tenant's policy.
|
||||
/// </summary>
|
||||
string? GetCachedDigest(string tenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Reloads policies from disk (cache invalidation).
|
||||
/// </summary>
|
||||
void Reload();
|
||||
}
|
||||
|
||||
public sealed class ScorePolicyService : IScorePolicyService
|
||||
{
|
||||
private readonly IScorePolicyProvider _provider;
|
||||
private readonly ConcurrentDictionary<string, (ScorePolicy Policy, string Digest)> _cache = new();
|
||||
private readonly ILogger<ScorePolicyService> _logger;
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public ScorePolicyService(
|
||||
IScorePolicyProvider provider,
|
||||
ILogger<ScorePolicyService> logger)
|
||||
{
|
||||
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ScorePolicy GetPolicy(string tenantId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
return _cache.GetOrAdd(tenantId, tid =>
|
||||
{
|
||||
var policy = _provider.GetPolicy(tid);
|
||||
var digest = ComputePolicyDigest(policy);
|
||||
_logger.LogInformation(
|
||||
"Loaded score policy for tenant {TenantId}, digest: {Digest}",
|
||||
tid, digest);
|
||||
return (policy, digest);
|
||||
}).Policy;
|
||||
}
|
||||
|
||||
public string? GetCachedDigest(string tenantId)
|
||||
{
|
||||
return _cache.TryGetValue(tenantId, out var entry) ? entry.Digest : null;
|
||||
}
|
||||
|
||||
public string ComputePolicyDigest(ScorePolicy policy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
// Canonical JSON serialization for deterministic digest
|
||||
var json = JsonSerializer.Serialize(policy, CanonicalJsonOptions);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
var count = _cache.Count;
|
||||
_cache.Clear();
|
||||
_logger.LogInformation("Score policy cache cleared ({Count} entries removed)", count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides score policies from a configured source.
|
||||
/// </summary>
|
||||
public interface IScorePolicyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the score policy for a tenant.
|
||||
/// </summary>
|
||||
ScorePolicy GetPolicy(string tenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File-based score policy provider.
|
||||
/// </summary>
|
||||
public sealed class FileScorePolicyProvider : IScorePolicyProvider
|
||||
{
|
||||
private readonly ScorePolicyLoader _loader;
|
||||
private readonly string _basePath;
|
||||
private readonly ILogger<FileScorePolicyProvider> _logger;
|
||||
|
||||
public FileScorePolicyProvider(
|
||||
ScorePolicyLoader loader,
|
||||
string basePath,
|
||||
ILogger<FileScorePolicyProvider> logger)
|
||||
{
|
||||
_loader = loader ?? throw new ArgumentNullException(nameof(loader));
|
||||
_basePath = basePath ?? throw new ArgumentNullException(nameof(basePath));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ScorePolicy GetPolicy(string tenantId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
// Try tenant-specific policy first
|
||||
var tenantPath = Path.Combine(_basePath, $"score-policy.{tenantId}.yaml");
|
||||
if (File.Exists(tenantPath))
|
||||
{
|
||||
_logger.LogDebug("Loading tenant-specific score policy from {Path}", tenantPath);
|
||||
return _loader.LoadFromFile(tenantPath);
|
||||
}
|
||||
|
||||
// Fall back to default policy
|
||||
var defaultPath = Path.Combine(_basePath, "score-policy.yaml");
|
||||
if (File.Exists(defaultPath))
|
||||
{
|
||||
_logger.LogDebug("Loading default score policy from {Path}", defaultPath);
|
||||
return _loader.LoadFromFile(defaultPath);
|
||||
}
|
||||
|
||||
// Use built-in default
|
||||
_logger.LogDebug("Using built-in default score policy for tenant {TenantId}", tenantId);
|
||||
return ScorePolicy.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory score policy provider for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryScorePolicyProvider : IScorePolicyProvider
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ScorePolicy> _policies = new();
|
||||
private ScorePolicy _defaultPolicy = ScorePolicy.Default;
|
||||
|
||||
public ScorePolicy GetPolicy(string tenantId)
|
||||
{
|
||||
return _policies.TryGetValue(tenantId, out var policy) ? policy : _defaultPolicy;
|
||||
}
|
||||
|
||||
public void SetPolicy(string tenantId, ScorePolicy policy)
|
||||
{
|
||||
_policies[tenantId] = policy;
|
||||
}
|
||||
|
||||
public void SetDefaultPolicy(ScorePolicy policy)
|
||||
{
|
||||
_defaultPolicy = policy;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_policies.Clear();
|
||||
_defaultPolicy = ScorePolicy.Default;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
|
||||
|
||||
207
src/Policy/StellaOps.Policy.Engine/Vex/VexProofSpineService.cs
Normal file
207
src/Policy/StellaOps.Policy.Engine/Vex/VexProofSpineService.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating proof spines from VEX decisions.
|
||||
/// </summary>
|
||||
public interface IVexProofSpineService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a proof spine for a VEX decision.
|
||||
/// </summary>
|
||||
Task<ProofSpineResult> CreateSpineAsync(
|
||||
VexStatement statement,
|
||||
VexProofSpineContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates proof spines for all statements in a VEX document.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProofSpineResult>> CreateSpinesForDocumentAsync(
|
||||
VexDecisionDocument document,
|
||||
VexProofSpineContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context information for proof spine creation.
|
||||
/// </summary>
|
||||
public sealed record VexProofSpineContext
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public string? ScanId { get; init; }
|
||||
public string? PolicyProfileId { get; init; }
|
||||
public string? SbomDigest { get; init; }
|
||||
public string? GraphDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of proof spine creation.
|
||||
/// </summary>
|
||||
public sealed record ProofSpineResult
|
||||
{
|
||||
public required string SpineId { get; init; }
|
||||
public required string ArtifactId { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string Verdict { get; init; }
|
||||
public required int SegmentCount { get; init; }
|
||||
public string? RootHash { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IVexProofSpineService"/>.
|
||||
/// </summary>
|
||||
public sealed class VexProofSpineService : IVexProofSpineService
|
||||
{
|
||||
private readonly IDsseSigningService _signer;
|
||||
private readonly ICryptoProfile _cryptoProfile;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<VexProofSpineService> _logger;
|
||||
|
||||
public VexProofSpineService(
|
||||
IDsseSigningService signer,
|
||||
ICryptoProfile cryptoProfile,
|
||||
ICryptoHash cryptoHash,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VexProofSpineService> logger)
|
||||
{
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
_cryptoProfile = cryptoProfile ?? throw new ArgumentNullException(nameof(cryptoProfile));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
private const string ToolId = "stellaops/policy-engine";
|
||||
private const string ToolVersion = "1.0.0";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ProofSpineResult> CreateSpineAsync(
|
||||
VexStatement statement,
|
||||
VexProofSpineContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var artifactId = statement.Products.FirstOrDefault()?.Id ?? "unknown";
|
||||
var vulnId = statement.Vulnerability.Id;
|
||||
|
||||
var builder = new ProofSpineBuilder(_signer, _cryptoProfile, _cryptoHash, _timeProvider)
|
||||
.ForArtifact(artifactId)
|
||||
.ForVulnerability(vulnId);
|
||||
|
||||
if (!string.IsNullOrEmpty(context.ScanId))
|
||||
{
|
||||
builder.WithScanRun(context.ScanId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.PolicyProfileId))
|
||||
{
|
||||
builder.WithPolicyProfile(context.PolicyProfileId);
|
||||
}
|
||||
|
||||
// Add SBOM slice segment if available
|
||||
if (!string.IsNullOrEmpty(context.SbomDigest))
|
||||
{
|
||||
builder.AddSbomSlice(
|
||||
context.SbomDigest,
|
||||
new[] { artifactId },
|
||||
ToolId,
|
||||
ToolVersion);
|
||||
}
|
||||
|
||||
// Add reachability analysis segment if evidence is present
|
||||
if (statement.Evidence is not null)
|
||||
{
|
||||
var graphHash = statement.Evidence.GraphHash ?? context.GraphDigest;
|
||||
if (!string.IsNullOrEmpty(graphHash))
|
||||
{
|
||||
builder.AddReachability(
|
||||
graphHash,
|
||||
statement.Evidence.LatticeState ?? "U",
|
||||
statement.Evidence.Confidence,
|
||||
statement.Evidence.CallPath?.ToList(),
|
||||
ToolId,
|
||||
ToolVersion);
|
||||
}
|
||||
}
|
||||
|
||||
// Add policy evaluation segment with final verdict
|
||||
var factors = new Dictionary<string, string>
|
||||
{
|
||||
["lattice_state"] = statement.Evidence?.LatticeState ?? "U",
|
||||
["confidence"] = statement.Evidence?.Confidence.ToString("F2") ?? "0.00"
|
||||
};
|
||||
|
||||
builder.AddPolicyEval(
|
||||
context.PolicyProfileId ?? "default",
|
||||
factors,
|
||||
statement.Status,
|
||||
statement.Justification ?? "VEX decision based on reachability analysis",
|
||||
ToolId,
|
||||
ToolVersion);
|
||||
|
||||
// Build the spine
|
||||
var spine = await builder.BuildAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created proof spine {SpineId} for {VulnId}:{ArtifactId} with verdict {Verdict}",
|
||||
spine.SpineId,
|
||||
vulnId,
|
||||
artifactId,
|
||||
statement.Status);
|
||||
|
||||
return new ProofSpineResult
|
||||
{
|
||||
SpineId = spine.SpineId,
|
||||
ArtifactId = artifactId,
|
||||
VulnerabilityId = vulnId,
|
||||
Verdict = statement.Status,
|
||||
SegmentCount = spine.Segments.Count,
|
||||
RootHash = spine.RootHash,
|
||||
CreatedAt = spine.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<ProofSpineResult>> CreateSpinesForDocumentAsync(
|
||||
VexDecisionDocument document,
|
||||
VexProofSpineContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var results = new List<ProofSpineResult>();
|
||||
|
||||
foreach (var statement in document.Statements)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CreateSpineAsync(statement, context, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to create proof spine for {VulnId}",
|
||||
statement.Vulnerability.Id);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created {Count} proof spines for VEX document {DocumentId}",
|
||||
results.Count,
|
||||
document.Id);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stellaops.org/schemas/score-policy.v1.json",
|
||||
"title": "StellaOps Score Policy v1",
|
||||
"description": "Defines deterministic vulnerability scoring weights, buckets, and overrides",
|
||||
"type": "object",
|
||||
"required": ["policyVersion", "weightsBps"],
|
||||
"properties": {
|
||||
"policyVersion": {
|
||||
"const": "score.v1",
|
||||
"description": "Policy schema version"
|
||||
},
|
||||
"weightsBps": {
|
||||
"type": "object",
|
||||
"description": "Weight distribution in basis points (must sum to 10000)",
|
||||
"required": ["baseSeverity", "reachability", "evidence", "provenance"],
|
||||
"properties": {
|
||||
"baseSeverity": { "type": "integer", "minimum": 0, "maximum": 10000 },
|
||||
"reachability": { "type": "integer", "minimum": 0, "maximum": 10000 },
|
||||
"evidence": { "type": "integer", "minimum": 0, "maximum": 10000 },
|
||||
"provenance": { "type": "integer", "minimum": 0, "maximum": 10000 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reachability": {
|
||||
"$ref": "#/$defs/reachabilityConfig"
|
||||
},
|
||||
"evidence": {
|
||||
"$ref": "#/$defs/evidenceConfig"
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/$defs/provenanceConfig"
|
||||
},
|
||||
"overrides": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/scoreOverride" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"reachabilityConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hopBuckets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["maxHops", "score"],
|
||||
"properties": {
|
||||
"maxHops": { "type": "integer", "minimum": 0 },
|
||||
"score": { "type": "integer", "minimum": 0, "maximum": 100 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"unreachableScore": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"gateMultipliersBps": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"featureFlag": { "type": "integer", "minimum": 0, "maximum": 10000 },
|
||||
"authRequired": { "type": "integer", "minimum": 0, "maximum": 10000 },
|
||||
"adminOnly": { "type": "integer", "minimum": 0, "maximum": 10000 },
|
||||
"nonDefaultConfig": { "type": "integer", "minimum": 0, "maximum": 10000 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidenceConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"points": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"runtime": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"dast": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"sast": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"sca": { "type": "integer", "minimum": 0, "maximum": 100 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"freshnessBuckets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["maxAgeDays", "multiplierBps"],
|
||||
"properties": {
|
||||
"maxAgeDays": { "type": "integer", "minimum": 0 },
|
||||
"multiplierBps": { "type": "integer", "minimum": 0, "maximum": 10000 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"provenanceConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"levels": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"unsigned": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"signed": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"signedWithSbom": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"signedWithSbomAndAttestations": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"reproducible": { "type": "integer", "minimum": 0, "maximum": 100 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"scoreOverride": {
|
||||
"type": "object",
|
||||
"required": ["name", "when"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"when": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"flags": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "boolean" }
|
||||
},
|
||||
"minReachability": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"maxReachability": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"minEvidence": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"maxEvidence": { "type": "integer", "minimum": 0, "maximum": 100 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"setScore": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"clampMaxScore": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"clampMinScore": { "type": "integer", "minimum": 0, "maximum": 100 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Loads score policies from YAML files.
|
||||
/// </summary>
|
||||
public sealed class ScorePolicyLoader
|
||||
{
|
||||
private static readonly IDeserializer Deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Loads a score policy from a YAML file.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to the YAML file</param>
|
||||
/// <returns>Parsed score policy</returns>
|
||||
/// <exception cref="ScorePolicyLoadException">If parsing fails</exception>
|
||||
public ScorePolicy LoadFromFile(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("Path cannot be null or empty", nameof(path));
|
||||
|
||||
if (!File.Exists(path))
|
||||
throw new ScorePolicyLoadException($"Score policy file not found: {path}");
|
||||
|
||||
var yaml = File.ReadAllText(path, Encoding.UTF8);
|
||||
return LoadFromYaml(yaml, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a score policy from YAML content.
|
||||
/// </summary>
|
||||
/// <param name="yaml">YAML content</param>
|
||||
/// <param name="source">Source identifier for error messages</param>
|
||||
/// <returns>Parsed score policy</returns>
|
||||
public ScorePolicy LoadFromYaml(string yaml, string source = "<inline>")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(yaml))
|
||||
throw new ScorePolicyLoadException($"Empty YAML content from {source}");
|
||||
|
||||
try
|
||||
{
|
||||
var policy = Deserializer.Deserialize<ScorePolicy>(yaml);
|
||||
|
||||
if (policy is null)
|
||||
throw new ScorePolicyLoadException($"Failed to parse score policy from {source}: empty document");
|
||||
|
||||
// Validate policy version
|
||||
if (policy.PolicyVersion != "score.v1")
|
||||
throw new ScorePolicyLoadException(
|
||||
$"Unsupported policy version '{policy.PolicyVersion}' in {source}. Expected 'score.v1'");
|
||||
|
||||
// Validate weight sum
|
||||
if (!policy.ValidateWeights())
|
||||
{
|
||||
var sum = policy.WeightsBps.BaseSeverity + policy.WeightsBps.Reachability +
|
||||
policy.WeightsBps.Evidence + policy.WeightsBps.Provenance;
|
||||
throw new ScorePolicyLoadException(
|
||||
$"Weight basis points must sum to 10000 in {source}. Got: {sum}");
|
||||
}
|
||||
|
||||
return policy;
|
||||
}
|
||||
catch (YamlException ex)
|
||||
{
|
||||
throw new ScorePolicyLoadException($"YAML parse error in {source}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to load a score policy, returning null on failure.
|
||||
/// </summary>
|
||||
public ScorePolicy? TryLoadFromFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return LoadFromFile(path);
|
||||
}
|
||||
catch (ScorePolicyLoadException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when score policy loading fails.
|
||||
/// </summary>
|
||||
public sealed class ScorePolicyLoadException : Exception
|
||||
{
|
||||
public ScorePolicyLoadException(string message) : base(message) { }
|
||||
public ScorePolicyLoadException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Root score policy configuration loaded from YAML.
|
||||
/// </summary>
|
||||
public sealed record ScorePolicy
|
||||
{
|
||||
public required string PolicyVersion { get; init; }
|
||||
public required WeightsBps WeightsBps { get; init; }
|
||||
public ReachabilityPolicyConfig? Reachability { get; init; }
|
||||
public EvidencePolicyConfig? Evidence { get; init; }
|
||||
public ProvenancePolicyConfig? Provenance { get; init; }
|
||||
public IReadOnlyList<ScoreOverride>? Overrides { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates that weight basis points sum to 10000.
|
||||
/// </summary>
|
||||
public bool ValidateWeights()
|
||||
{
|
||||
var sum = WeightsBps.BaseSeverity + WeightsBps.Reachability +
|
||||
WeightsBps.Evidence + WeightsBps.Provenance;
|
||||
return sum == 10000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default score policy.
|
||||
/// </summary>
|
||||
public static ScorePolicy Default => new()
|
||||
{
|
||||
PolicyVersion = "score.v1",
|
||||
WeightsBps = WeightsBps.Default,
|
||||
Reachability = ReachabilityPolicyConfig.Default,
|
||||
Evidence = EvidencePolicyConfig.Default,
|
||||
Provenance = ProvenancePolicyConfig.Default,
|
||||
Overrides = []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weight distribution in basis points. Must sum to 10000.
|
||||
/// </summary>
|
||||
public sealed record WeightsBps
|
||||
{
|
||||
public required int BaseSeverity { get; init; }
|
||||
public required int Reachability { get; init; }
|
||||
public required int Evidence { get; init; }
|
||||
public required int Provenance { get; init; }
|
||||
|
||||
public static WeightsBps Default => new()
|
||||
{
|
||||
BaseSeverity = 1000, // 10%
|
||||
Reachability = 4500, // 45%
|
||||
Evidence = 3000, // 30%
|
||||
Provenance = 1500 // 15%
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability scoring configuration.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityPolicyConfig
|
||||
{
|
||||
public IReadOnlyList<HopBucket>? HopBuckets { get; init; }
|
||||
public int UnreachableScore { get; init; } = 0;
|
||||
public GateMultipliersBps? GateMultipliersBps { get; init; }
|
||||
|
||||
public static ReachabilityPolicyConfig Default => new()
|
||||
{
|
||||
HopBuckets =
|
||||
[
|
||||
new HopBucket(0, 100), // Direct call
|
||||
new HopBucket(1, 90), // 1 hop
|
||||
new HopBucket(3, 70), // 2-3 hops
|
||||
new HopBucket(5, 50), // 4-5 hops
|
||||
new HopBucket(10, 30), // 6-10 hops
|
||||
new HopBucket(int.MaxValue, 10) // > 10 hops
|
||||
],
|
||||
UnreachableScore = 0,
|
||||
GateMultipliersBps = Scoring.GateMultipliersBps.Default
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record HopBucket(int MaxHops, int Score);
|
||||
|
||||
public sealed record GateMultipliersBps
|
||||
{
|
||||
public int FeatureFlag { get; init; } = 7000;
|
||||
public int AuthRequired { get; init; } = 8000;
|
||||
public int AdminOnly { get; init; } = 8500;
|
||||
public int NonDefaultConfig { get; init; } = 7500;
|
||||
|
||||
public static GateMultipliersBps Default => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence scoring configuration.
|
||||
/// </summary>
|
||||
public sealed record EvidencePolicyConfig
|
||||
{
|
||||
public EvidencePoints? Points { get; init; }
|
||||
public IReadOnlyList<FreshnessBucket>? FreshnessBuckets { get; init; }
|
||||
|
||||
public static EvidencePolicyConfig Default => new()
|
||||
{
|
||||
Points = EvidencePoints.Default,
|
||||
FreshnessBuckets =
|
||||
[
|
||||
new FreshnessBucket(7, 10000), // 0-7 days: 100%
|
||||
new FreshnessBucket(30, 9000), // 8-30 days: 90%
|
||||
new FreshnessBucket(90, 7000), // 31-90 days: 70%
|
||||
new FreshnessBucket(180, 5000), // 91-180 days: 50%
|
||||
new FreshnessBucket(365, 3000), // 181-365 days: 30%
|
||||
new FreshnessBucket(int.MaxValue, 1000) // > 1 year: 10%
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record EvidencePoints
|
||||
{
|
||||
public int Runtime { get; init; } = 60;
|
||||
public int Dast { get; init; } = 30;
|
||||
public int Sast { get; init; } = 20;
|
||||
public int Sca { get; init; } = 10;
|
||||
|
||||
public static EvidencePoints Default => new();
|
||||
}
|
||||
|
||||
public sealed record FreshnessBucket(int MaxAgeDays, int MultiplierBps);
|
||||
|
||||
/// <summary>
|
||||
/// Provenance scoring configuration.
|
||||
/// </summary>
|
||||
public sealed record ProvenancePolicyConfig
|
||||
{
|
||||
public ProvenanceLevels? Levels { get; init; }
|
||||
|
||||
public static ProvenancePolicyConfig Default => new()
|
||||
{
|
||||
Levels = ProvenanceLevels.Default
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record ProvenanceLevels
|
||||
{
|
||||
public int Unsigned { get; init; } = 0;
|
||||
public int Signed { get; init; } = 30;
|
||||
public int SignedWithSbom { get; init; } = 60;
|
||||
public int SignedWithSbomAndAttestations { get; init; } = 80;
|
||||
public int Reproducible { get; init; } = 100;
|
||||
|
||||
public static ProvenanceLevels Default => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score override rule for special conditions.
|
||||
/// </summary>
|
||||
public sealed record ScoreOverride
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required ScoreOverrideCondition When { get; init; }
|
||||
public int? SetScore { get; init; }
|
||||
public int? ClampMaxScore { get; init; }
|
||||
public int? ClampMinScore { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ScoreOverrideCondition
|
||||
{
|
||||
public IReadOnlyDictionary<string, bool>? Flags { get; init; }
|
||||
public int? MinReachability { get; init; }
|
||||
public int? MaxReachability { get; init; }
|
||||
public int? MinEvidence { get; init; }
|
||||
public int? MaxEvidence { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user