feat: Initialize Zastava Webhook service with TLS and Authority authentication
- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint. - Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately. - Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly. - Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
@@ -122,6 +122,8 @@ public static class PolicyBinder
|
||||
{
|
||||
case null:
|
||||
return null;
|
||||
case string s when bool.TryParse(s, out var boolValue):
|
||||
return JsonValue.Create(boolValue);
|
||||
case string s:
|
||||
return JsonValue.Create(s);
|
||||
case bool b:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
@@ -25,6 +26,19 @@ public static class PolicyEvaluation
|
||||
var severityWeight = scoringConfig.SeverityWeights.TryGetValue(finding.Severity, out var weight)
|
||||
? weight
|
||||
: scoringConfig.SeverityWeights.GetValueOrDefault(PolicySeverity.Unknown, 0);
|
||||
var trustKey = ResolveTrustKey(finding);
|
||||
var trustWeight = ResolveTrustWeight(scoringConfig, trustKey);
|
||||
var reachabilityKey = ResolveReachabilityKey(finding);
|
||||
var reachabilityWeight = ResolveReachabilityWeight(scoringConfig, reachabilityKey, out var resolvedReachabilityKey);
|
||||
var baseScore = severityWeight * trustWeight * reachabilityWeight;
|
||||
var components = new ScoringComponents(
|
||||
severityWeight,
|
||||
trustWeight,
|
||||
reachabilityWeight,
|
||||
baseScore,
|
||||
trustKey,
|
||||
resolvedReachabilityKey);
|
||||
var unknownConfidence = ComputeUnknownConfidence(scoringConfig.UnknownConfidence, finding);
|
||||
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
@@ -33,73 +47,108 @@ public static class PolicyEvaluation
|
||||
continue;
|
||||
}
|
||||
|
||||
return BuildVerdict(rule, finding, scoringConfig, severityWeight);
|
||||
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence);
|
||||
}
|
||||
|
||||
return PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
|
||||
var baseline = PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
|
||||
return ApplyUnknownConfidence(baseline, unknownConfidence);
|
||||
}
|
||||
|
||||
private static PolicyVerdict BuildVerdict(
|
||||
PolicyRule rule,
|
||||
PolicyFinding finding,
|
||||
PolicyScoringConfig config,
|
||||
double severityWeight)
|
||||
ScoringComponents components,
|
||||
UnknownConfidenceResult? unknownConfidence)
|
||||
{
|
||||
var action = rule.Action;
|
||||
var status = MapAction(action);
|
||||
var notes = BuildNotes(action);
|
||||
var inputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
inputs["severityWeight"] = severityWeight;
|
||||
inputs["severityWeight"] = components.SeverityWeight;
|
||||
inputs["trustWeight"] = components.TrustWeight;
|
||||
inputs["reachabilityWeight"] = components.ReachabilityWeight;
|
||||
inputs["baseScore"] = components.BaseScore;
|
||||
if (!string.IsNullOrWhiteSpace(components.TrustKey))
|
||||
{
|
||||
inputs[$"trustWeight.{components.TrustKey}"] = components.TrustWeight;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(components.ReachabilityKey))
|
||||
{
|
||||
inputs[$"reachability.{components.ReachabilityKey}"] = components.ReachabilityWeight;
|
||||
}
|
||||
if (unknownConfidence is { Band.Description: { Length: > 0 } description })
|
||||
{
|
||||
notes = AppendNote(notes, description);
|
||||
}
|
||||
if (unknownConfidence is { } unknownDetails)
|
||||
{
|
||||
inputs["unknownConfidence"] = unknownDetails.Confidence;
|
||||
inputs["unknownAgeDays"] = unknownDetails.AgeDays;
|
||||
}
|
||||
|
||||
double score = severityWeight;
|
||||
double score = components.BaseScore;
|
||||
string? quietedBy = null;
|
||||
var quiet = false;
|
||||
|
||||
var quietRequested = action.Quiet;
|
||||
var quietAllowed = quietRequested && (action.RequireVex is not null || action.Type == PolicyActionType.RequireVex);
|
||||
|
||||
if (quietRequested && !quietAllowed)
|
||||
{
|
||||
var warnInputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in inputs)
|
||||
{
|
||||
warnInputs[pair.Key] = pair.Value;
|
||||
}
|
||||
if (unknownConfidence is { } unknownInfo)
|
||||
{
|
||||
warnInputs["unknownConfidence"] = unknownInfo.Confidence;
|
||||
warnInputs["unknownAgeDays"] = unknownInfo.AgeDays;
|
||||
}
|
||||
|
||||
var warnPenalty = config.WarnPenalty;
|
||||
warnInputs["warnPenalty"] = warnPenalty;
|
||||
var warnScore = Math.Max(0, components.BaseScore - warnPenalty);
|
||||
var warnNotes = AppendNote(notes, "Quiet flag ignored: rule must specify requireVex justifications.");
|
||||
|
||||
return new PolicyVerdict(
|
||||
finding.FindingId,
|
||||
PolicyVerdictStatus.Warned,
|
||||
rule.Name,
|
||||
action.Type.ToString(),
|
||||
warnNotes,
|
||||
warnScore,
|
||||
config.Version,
|
||||
warnInputs.ToImmutable(),
|
||||
QuietedBy: null,
|
||||
Quiet: false,
|
||||
UnknownConfidence: unknownConfidence?.Confidence,
|
||||
ConfidenceBand: unknownConfidence?.Band.Name,
|
||||
UnknownAgeDays: unknownConfidence?.AgeDays,
|
||||
SourceTrust: components.TrustKey,
|
||||
Reachability: components.ReachabilityKey);
|
||||
}
|
||||
|
||||
switch (status)
|
||||
{
|
||||
case PolicyVerdictStatus.Ignored:
|
||||
score = Math.Max(0, severityWeight - config.IgnorePenalty);
|
||||
inputs["ignorePenalty"] = config.IgnorePenalty;
|
||||
score = ApplyPenalty(score, config.IgnorePenalty, inputs, "ignorePenalty");
|
||||
break;
|
||||
case PolicyVerdictStatus.Warned:
|
||||
score = Math.Max(0, severityWeight - config.WarnPenalty);
|
||||
inputs["warnPenalty"] = config.WarnPenalty;
|
||||
score = ApplyPenalty(score, config.WarnPenalty, inputs, "warnPenalty");
|
||||
break;
|
||||
case PolicyVerdictStatus.Deferred:
|
||||
score = Math.Max(0, severityWeight - (config.WarnPenalty / 2));
|
||||
inputs["deferPenalty"] = config.WarnPenalty / 2;
|
||||
var deferPenalty = config.WarnPenalty / 2;
|
||||
score = ApplyPenalty(score, deferPenalty, inputs, "deferPenalty");
|
||||
break;
|
||||
}
|
||||
|
||||
if (action.Quiet)
|
||||
if (quietAllowed)
|
||||
{
|
||||
var quietAllowed = action.RequireVex is not null || action.Type == PolicyActionType.RequireVex;
|
||||
if (quietAllowed)
|
||||
{
|
||||
score = Math.Max(0, score - config.QuietPenalty);
|
||||
inputs["quietPenalty"] = config.QuietPenalty;
|
||||
quietedBy = rule.Name;
|
||||
quiet = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
inputs.Remove("ignorePenalty");
|
||||
var warnScore = Math.Max(0, severityWeight - config.WarnPenalty);
|
||||
inputs["warnPenalty"] = config.WarnPenalty;
|
||||
var warnNotes = AppendNote(notes, "Quiet flag ignored: rule must specify requireVex justifications.");
|
||||
|
||||
return new PolicyVerdict(
|
||||
finding.FindingId,
|
||||
PolicyVerdictStatus.Warned,
|
||||
rule.Name,
|
||||
action.Type.ToString(),
|
||||
warnNotes,
|
||||
warnScore,
|
||||
config.Version,
|
||||
inputs.ToImmutable(),
|
||||
QuietedBy: null,
|
||||
Quiet: false);
|
||||
}
|
||||
score = ApplyPenalty(score, config.QuietPenalty, inputs, "quietPenalty");
|
||||
quietedBy = rule.Name;
|
||||
quiet = true;
|
||||
}
|
||||
|
||||
return new PolicyVerdict(
|
||||
@@ -112,7 +161,240 @@ public static class PolicyEvaluation
|
||||
config.Version,
|
||||
inputs.ToImmutable(),
|
||||
quietedBy,
|
||||
quiet);
|
||||
quiet,
|
||||
unknownConfidence?.Confidence,
|
||||
unknownConfidence?.Band.Name,
|
||||
unknownConfidence?.AgeDays,
|
||||
components.TrustKey,
|
||||
components.ReachabilityKey);
|
||||
}
|
||||
|
||||
private static double ApplyPenalty(double score, double penalty, ImmutableDictionary<string, double>.Builder inputs, string key)
|
||||
{
|
||||
if (penalty <= 0)
|
||||
{
|
||||
return score;
|
||||
}
|
||||
|
||||
inputs[key] = penalty;
|
||||
return Math.Max(0, score - penalty);
|
||||
}
|
||||
|
||||
private static PolicyVerdict ApplyUnknownConfidence(PolicyVerdict verdict, UnknownConfidenceResult? unknownConfidence)
|
||||
{
|
||||
if (unknownConfidence is null)
|
||||
{
|
||||
return verdict;
|
||||
}
|
||||
|
||||
var inputsBuilder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in verdict.GetInputs())
|
||||
{
|
||||
inputsBuilder[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
inputsBuilder["unknownConfidence"] = unknownConfidence.Value.Confidence;
|
||||
inputsBuilder["unknownAgeDays"] = unknownConfidence.Value.AgeDays;
|
||||
|
||||
return verdict with
|
||||
{
|
||||
Inputs = inputsBuilder.ToImmutable(),
|
||||
UnknownConfidence = unknownConfidence.Value.Confidence,
|
||||
ConfidenceBand = unknownConfidence.Value.Band.Name,
|
||||
UnknownAgeDays = unknownConfidence.Value.AgeDays,
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownConfidenceResult? ComputeUnknownConfidence(PolicyUnknownConfidenceConfig config, PolicyFinding finding)
|
||||
{
|
||||
if (!IsUnknownFinding(finding))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ageDays = ResolveUnknownAgeDays(finding);
|
||||
var rawConfidence = config.Initial - (ageDays * config.DecayPerDay);
|
||||
var confidence = config.Clamp(rawConfidence);
|
||||
var band = config.ResolveBand(confidence);
|
||||
return new UnknownConfidenceResult(ageDays, confidence, band);
|
||||
}
|
||||
|
||||
private static bool IsUnknownFinding(PolicyFinding finding)
|
||||
{
|
||||
if (finding.Severity == PolicySeverity.Unknown)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!finding.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var tag in finding.Tags)
|
||||
{
|
||||
if (string.Equals(tag, "state:unknown", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double ResolveUnknownAgeDays(PolicyFinding finding)
|
||||
{
|
||||
var ageTag = TryGetTagValue(finding.Tags, "unknown-age-days:");
|
||||
if (!string.IsNullOrWhiteSpace(ageTag) &&
|
||||
double.TryParse(ageTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedAge) &&
|
||||
parsedAge >= 0)
|
||||
{
|
||||
return parsedAge;
|
||||
}
|
||||
|
||||
var sinceTag = TryGetTagValue(finding.Tags, "unknown-since:");
|
||||
if (string.IsNullOrWhiteSpace(sinceTag))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!DateTimeOffset.TryParse(sinceTag, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var since))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var observedTag = TryGetTagValue(finding.Tags, "observed-at:");
|
||||
if (!string.IsNullOrWhiteSpace(observedTag) &&
|
||||
DateTimeOffset.TryParse(observedTag, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var observed) &&
|
||||
observed > since)
|
||||
{
|
||||
return Math.Max(0, (observed - since).TotalDays);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string? ResolveTrustKey(PolicyFinding finding)
|
||||
{
|
||||
if (!finding.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
var tagged = TryGetTagValue(finding.Tags, "trust:");
|
||||
if (!string.IsNullOrWhiteSpace(tagged))
|
||||
{
|
||||
return tagged;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(finding.Source))
|
||||
{
|
||||
return finding.Source;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(finding.Vendor))
|
||||
{
|
||||
return finding.Vendor;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double ResolveTrustWeight(PolicyScoringConfig config, string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || config.TrustOverrides.IsEmpty)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return config.TrustOverrides.TryGetValue(key, out var weight) ? weight : 1.0;
|
||||
}
|
||||
|
||||
private static string? ResolveReachabilityKey(PolicyFinding finding)
|
||||
{
|
||||
if (finding.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var reachability = TryGetTagValue(finding.Tags, "reachability:");
|
||||
if (!string.IsNullOrWhiteSpace(reachability))
|
||||
{
|
||||
return reachability;
|
||||
}
|
||||
|
||||
var usage = TryGetTagValue(finding.Tags, "usage:");
|
||||
if (!string.IsNullOrWhiteSpace(usage))
|
||||
{
|
||||
return usage;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double ResolveReachabilityWeight(PolicyScoringConfig config, string? key, out string? resolvedKey)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key) && config.ReachabilityBuckets.TryGetValue(key, out var weight))
|
||||
{
|
||||
resolvedKey = key;
|
||||
return weight;
|
||||
}
|
||||
|
||||
if (config.ReachabilityBuckets.TryGetValue("unknown", out var unknownWeight))
|
||||
{
|
||||
resolvedKey = "unknown";
|
||||
return unknownWeight;
|
||||
}
|
||||
|
||||
resolvedKey = key;
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
private static string? TryGetTagValue(ImmutableArray<string> tags, string prefix)
|
||||
{
|
||||
if (tags.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = tag[prefix.Length..].Trim();
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private readonly record struct ScoringComponents(
|
||||
double SeverityWeight,
|
||||
double TrustWeight,
|
||||
double ReachabilityWeight,
|
||||
double BaseScore,
|
||||
string? TrustKey,
|
||||
string? ReachabilityKey);
|
||||
|
||||
private readonly struct UnknownConfidenceResult
|
||||
{
|
||||
public UnknownConfidenceResult(double ageDays, double confidence, PolicyUnknownConfidenceBand band)
|
||||
{
|
||||
AgeDays = ageDays;
|
||||
Confidence = confidence;
|
||||
Band = band;
|
||||
}
|
||||
|
||||
public double AgeDays { get; }
|
||||
|
||||
public double Confidence { get; }
|
||||
|
||||
public PolicyUnknownConfidenceBand Band { get; }
|
||||
}
|
||||
|
||||
private static bool RuleMatches(PolicyRule rule, PolicyFinding finding)
|
||||
|
||||
@@ -8,7 +8,9 @@ public sealed record PolicyScoringConfig(
|
||||
double QuietPenalty,
|
||||
double WarnPenalty,
|
||||
double IgnorePenalty,
|
||||
ImmutableDictionary<string, double> TrustOverrides)
|
||||
ImmutableDictionary<string, double> TrustOverrides,
|
||||
ImmutableDictionary<string, double> ReachabilityBuckets,
|
||||
PolicyUnknownConfidenceConfig UnknownConfidence)
|
||||
{
|
||||
public static string BaselineVersion => "1.0";
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Json.Schema;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
@@ -23,12 +24,11 @@ public static class PolicyScoringConfigBinder
|
||||
{
|
||||
private const string DefaultResourceName = "StellaOps.Policy.Schemas.policy-scoring-default.json";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
private static readonly JsonSchema ScoringSchema = PolicyScoringSchema.Schema;
|
||||
|
||||
private static readonly ImmutableDictionary<string, double> DefaultReachabilityBuckets = CreateDefaultReachabilityBuckets();
|
||||
|
||||
private static readonly PolicyUnknownConfidenceConfig DefaultUnknownConfidence = CreateDefaultUnknownConfidence();
|
||||
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
@@ -69,6 +69,13 @@ public static class PolicyScoringConfigBinder
|
||||
}
|
||||
|
||||
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
|
||||
var schemaIssues = ValidateAgainstSchema(root);
|
||||
issues.AddRange(schemaIssues);
|
||||
if (schemaIssues.Any(static issue => issue.Severity == PolicyIssueSeverity.Error))
|
||||
{
|
||||
return new PolicyScoringBindingResult(false, null, issues.ToImmutable());
|
||||
}
|
||||
|
||||
var config = BuildConfig(obj, issues);
|
||||
var hasErrors = issues.Any(issue => issue.Severity == PolicyIssueSeverity.Error);
|
||||
return new PolicyScoringBindingResult(!hasErrors, config, issues.ToImmutable());
|
||||
@@ -101,6 +108,127 @@ public static class PolicyScoringConfigBinder
|
||||
return PolicyBinderUtilities.ConvertYamlObject(yamlObject);
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyIssue> ValidateAgainstSchema(JsonNode root)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(root.ToJsonString(new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
}));
|
||||
|
||||
var result = ScoringSchema.Evaluate(document.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List,
|
||||
RequireFormatValidation = true,
|
||||
});
|
||||
|
||||
if (result.IsValid)
|
||||
{
|
||||
return ImmutableArray<PolicyIssue>.Empty;
|
||||
}
|
||||
|
||||
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
CollectSchemaIssues(result, issues, seen);
|
||||
return issues.ToImmutable();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ImmutableArray.Create(PolicyIssue.Error("scoring.schema.normalize", $"Failed to normalize scoring configuration for schema validation: {ex.Message}", "$"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectSchemaIssues(EvaluationResults result, ImmutableArray<PolicyIssue>.Builder issues, HashSet<string> seen)
|
||||
{
|
||||
if (result.Errors is { Count: > 0 })
|
||||
{
|
||||
foreach (var pair in result.Errors)
|
||||
{
|
||||
var keyword = SanitizeKeyword(pair.Key);
|
||||
var path = ConvertPointerToPath(result.InstanceLocation?.ToString() ?? "#");
|
||||
var message = pair.Value ?? "Schema violation.";
|
||||
var key = $"{path}|{keyword}|{message}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error($"scoring.schema.{keyword}", message, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Details is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var detail in result.Details)
|
||||
{
|
||||
CollectSchemaIssues(detail, issues, seen);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConvertPointerToPath(string pointer)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pointer) || pointer == "#")
|
||||
{
|
||||
return "$";
|
||||
}
|
||||
|
||||
if (pointer[0] == '#')
|
||||
{
|
||||
pointer = pointer.Length > 1 ? pointer[1..] : string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(pointer))
|
||||
{
|
||||
return "$";
|
||||
}
|
||||
|
||||
var segments = pointer.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var builder = new StringBuilder("$");
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var unescaped = segment.Replace("~1", "/").Replace("~0", "~");
|
||||
if (int.TryParse(unescaped, out var index))
|
||||
{
|
||||
builder.Append('[').Append(index).Append(']');
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('.').Append(unescaped);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string SanitizeKeyword(string keyword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(keyword.Length);
|
||||
foreach (var ch in keyword)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(char.ToLowerInvariant(ch));
|
||||
}
|
||||
else if (ch is '.' or '_' or '-')
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('_');
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? "unknown" : builder.ToString();
|
||||
}
|
||||
|
||||
private static PolicyScoringConfig BuildConfig(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
var version = ReadString(obj, "version", issues, required: true) ?? PolicyScoringConfig.BaselineVersion;
|
||||
@@ -110,6 +238,8 @@ public static class PolicyScoringConfigBinder
|
||||
var warnPenalty = ReadDouble(obj, "warnPenalty", issues, defaultValue: 15);
|
||||
var ignorePenalty = ReadDouble(obj, "ignorePenalty", issues, defaultValue: 35);
|
||||
var trustOverrides = ReadTrustOverrides(obj, issues);
|
||||
var reachabilityBuckets = ReadReachabilityBuckets(obj, issues);
|
||||
var unknownConfidence = ReadUnknownConfidence(obj, issues);
|
||||
|
||||
return new PolicyScoringConfig(
|
||||
version,
|
||||
@@ -117,7 +247,212 @@ public static class PolicyScoringConfigBinder
|
||||
quietPenalty,
|
||||
warnPenalty,
|
||||
ignorePenalty,
|
||||
trustOverrides);
|
||||
trustOverrides,
|
||||
reachabilityBuckets,
|
||||
unknownConfidence);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> CreateDefaultReachabilityBuckets()
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
builder["entrypoint"] = 1.0;
|
||||
builder["direct"] = 0.85;
|
||||
builder["indirect"] = 0.6;
|
||||
builder["runtime"] = 0.45;
|
||||
builder["unreachable"] = 0.25;
|
||||
builder["unknown"] = 0.5;
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyUnknownConfidenceConfig CreateDefaultUnknownConfidence()
|
||||
{
|
||||
var bands = ImmutableArray.Create(
|
||||
new PolicyUnknownConfidenceBand("high", 0.65, "Fresh unknowns with recent telemetry."),
|
||||
new PolicyUnknownConfidenceBand("medium", 0.35, "Unknowns aging toward action required."),
|
||||
new PolicyUnknownConfidenceBand("low", 0.0, "Stale unknowns that must be triaged."));
|
||||
return new PolicyUnknownConfidenceConfig(0.8, 0.05, 0.2, bands);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> ReadReachabilityBuckets(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue("reachabilityBuckets", out var node))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.reachability.default", "reachabilityBuckets not specified; defaulting to baseline weights.", "$.reachabilityBuckets"));
|
||||
return DefaultReachabilityBuckets;
|
||||
}
|
||||
|
||||
if (node is not JsonObject bucketsObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.reachability.type", "reachabilityBuckets must be an object.", "$.reachabilityBuckets"));
|
||||
return DefaultReachabilityBuckets;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in bucketsObj)
|
||||
{
|
||||
if (pair.Value is null)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.reachability.null", $"Bucket '{pair.Key}' is null; defaulting to 0.", $"$.reachabilityBuckets.{pair.Key}"));
|
||||
builder[pair.Key] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = ExtractDouble(pair.Value, issues, $"$.reachabilityBuckets.{pair.Key}");
|
||||
builder[pair.Key] = value;
|
||||
}
|
||||
|
||||
if (builder.Count == 0)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.reachability.empty", "No reachability buckets defined; using defaults.", "$.reachabilityBuckets"));
|
||||
return DefaultReachabilityBuckets;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyUnknownConfidenceConfig ReadUnknownConfidence(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue("unknownConfidence", out var node))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.default", "unknownConfidence not specified; defaulting to baseline decay settings.", "$.unknownConfidence"));
|
||||
return DefaultUnknownConfidence;
|
||||
}
|
||||
|
||||
if (node is not JsonObject configObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.type", "unknownConfidence must be an object.", "$.unknownConfidence"));
|
||||
return DefaultUnknownConfidence;
|
||||
}
|
||||
|
||||
var initial = DefaultUnknownConfidence.Initial;
|
||||
if (configObj.TryGetPropertyValue("initial", out var initialNode))
|
||||
{
|
||||
initial = ExtractDouble(initialNode, issues, "$.unknownConfidence.initial");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.initial.default", "initial not specified; using baseline value.", "$.unknownConfidence.initial"));
|
||||
}
|
||||
|
||||
var decay = DefaultUnknownConfidence.DecayPerDay;
|
||||
if (configObj.TryGetPropertyValue("decayPerDay", out var decayNode))
|
||||
{
|
||||
decay = ExtractDouble(decayNode, issues, "$.unknownConfidence.decayPerDay");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.decay.default", "decayPerDay not specified; using baseline value.", "$.unknownConfidence.decayPerDay"));
|
||||
}
|
||||
|
||||
var floor = DefaultUnknownConfidence.Floor;
|
||||
if (configObj.TryGetPropertyValue("floor", out var floorNode))
|
||||
{
|
||||
floor = ExtractDouble(floorNode, issues, "$.unknownConfidence.floor");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.floor.default", "floor not specified; using baseline value.", "$.unknownConfidence.floor"));
|
||||
}
|
||||
|
||||
var bands = ReadConfidenceBands(configObj, issues);
|
||||
if (bands.IsDefaultOrEmpty)
|
||||
{
|
||||
bands = DefaultUnknownConfidence.Bands;
|
||||
}
|
||||
|
||||
if (initial < 0 || initial > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.initial.range", "initial confidence should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.initial"));
|
||||
initial = Math.Clamp(initial, 0, 1);
|
||||
}
|
||||
|
||||
if (decay < 0 || decay > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.decay.range", "decayPerDay should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.decayPerDay"));
|
||||
decay = Math.Clamp(decay, 0, 1);
|
||||
}
|
||||
|
||||
if (floor < 0 || floor > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.floor.range", "floor should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.floor"));
|
||||
floor = Math.Clamp(floor, 0, 1);
|
||||
}
|
||||
|
||||
return new PolicyUnknownConfidenceConfig(initial, decay, floor, bands);
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyUnknownConfidenceBand> ReadConfidenceBands(JsonObject configObj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!configObj.TryGetPropertyValue("bands", out var node))
|
||||
{
|
||||
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
|
||||
}
|
||||
|
||||
if (node is not JsonArray array)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.bands.type", "unknownConfidence.bands must be an array.", "$.unknownConfidence.bands"));
|
||||
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyUnknownConfidenceBand>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = 0; index < array.Count; index++)
|
||||
{
|
||||
var element = array[index];
|
||||
if (element is not JsonObject bandObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.band.type", "Band entry must be an object.", $"$.unknownConfidence.bands[{index}]"));
|
||||
continue;
|
||||
}
|
||||
|
||||
string? name = null;
|
||||
if (bandObj.TryGetPropertyValue("name", out var nameNode) && nameNode is JsonValue nameValue && nameValue.TryGetValue(out string? text))
|
||||
{
|
||||
name = text?.Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.band.name", "Band entry requires a non-empty 'name'.", $"$.unknownConfidence.bands[{index}].name"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(name))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.band.duplicate", $"Duplicate band '{name}' encountered.", $"$.unknownConfidence.bands[{index}].name"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bandObj.TryGetPropertyValue("min", out var minNode))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.band.min", $"Band '{name}' is missing 'min'.", $"$.unknownConfidence.bands[{index}].min"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var min = ExtractDouble(minNode, issues, $"$.unknownConfidence.bands[{index}].min");
|
||||
if (min < 0 || min > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.band.range", $"Band '{name}' min should be between 0 and 1. Clamping to valid range.", $"$.unknownConfidence.bands[{index}].min"));
|
||||
min = Math.Clamp(min, 0, 1);
|
||||
}
|
||||
|
||||
string? description = null;
|
||||
if (bandObj.TryGetPropertyValue("description", out var descriptionNode) && descriptionNode is JsonValue descriptionValue && descriptionValue.TryGetValue(out string? descriptionText))
|
||||
{
|
||||
description = descriptionText?.Trim();
|
||||
}
|
||||
|
||||
builder.Add(new PolicyUnknownConfidenceBand(name, min, description));
|
||||
}
|
||||
|
||||
if (builder.Count == 0)
|
||||
{
|
||||
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
|
||||
}
|
||||
|
||||
return builder.ToImmutable()
|
||||
.OrderByDescending(static band => band.Min)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<PolicySeverity, double> ReadSeverityWeights(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
@@ -228,6 +563,8 @@ internal static class PolicyBinderUtilities
|
||||
{
|
||||
case null:
|
||||
return null;
|
||||
case string s when bool.TryParse(s, out var boolValue):
|
||||
return JsonValue.Create(boolValue);
|
||||
case string s:
|
||||
return JsonValue.Create(s);
|
||||
case bool b:
|
||||
|
||||
100
src/StellaOps.Policy/PolicyScoringConfigDigest.cs
Normal file
100
src/StellaOps.Policy/PolicyScoringConfigDigest.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyScoringConfigDigest
|
||||
{
|
||||
public static string Compute(PolicyScoringConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
SkipValidation = true,
|
||||
}))
|
||||
{
|
||||
WriteConfig(writer, config);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.WrittenSpan);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void WriteConfig(Utf8JsonWriter writer, PolicyScoringConfig config)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("version", config.Version);
|
||||
|
||||
writer.WritePropertyName("severityWeights");
|
||||
writer.WriteStartObject();
|
||||
foreach (var severity in Enum.GetValues<PolicySeverity>())
|
||||
{
|
||||
var key = severity.ToString();
|
||||
var value = config.SeverityWeights.TryGetValue(severity, out var weight) ? weight : 0;
|
||||
writer.WriteNumber(key, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
|
||||
writer.WriteNumber("quietPenalty", config.QuietPenalty);
|
||||
writer.WriteNumber("warnPenalty", config.WarnPenalty);
|
||||
writer.WriteNumber("ignorePenalty", config.IgnorePenalty);
|
||||
|
||||
if (!config.TrustOverrides.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("trustOverrides");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in config.TrustOverrides.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteNumber(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
if (!config.ReachabilityBuckets.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("reachabilityBuckets");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in config.ReachabilityBuckets.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteNumber(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WritePropertyName("unknownConfidence");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteNumber("initial", config.UnknownConfidence.Initial);
|
||||
writer.WriteNumber("decayPerDay", config.UnknownConfidence.DecayPerDay);
|
||||
writer.WriteNumber("floor", config.UnknownConfidence.Floor);
|
||||
|
||||
if (!config.UnknownConfidence.Bands.IsDefaultOrEmpty)
|
||||
{
|
||||
writer.WritePropertyName("bands");
|
||||
writer.WriteStartArray();
|
||||
foreach (var band in config.UnknownConfidence.Bands
|
||||
.OrderByDescending(static b => b.Min)
|
||||
.ThenBy(static b => b.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", band.Name);
|
||||
writer.WriteNumber("min", band.Min);
|
||||
if (!string.IsNullOrWhiteSpace(band.Description))
|
||||
{
|
||||
writer.WriteString("description", band.Description);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
27
src/StellaOps.Policy/PolicyScoringSchema.cs
Normal file
27
src/StellaOps.Policy/PolicyScoringSchema.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyScoringSchema
|
||||
{
|
||||
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-scoring-schema@1.json";
|
||||
|
||||
private static readonly Lazy<JsonSchema> CachedSchema = new(LoadSchema, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public static JsonSchema Schema => CachedSchema.Value;
|
||||
|
||||
private static JsonSchema LoadSchema()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using var stream = assembly.GetManifestResourceStream(SchemaResourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{SchemaResourceName}' was not found.");
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var schemaJson = reader.ReadToEnd();
|
||||
return JsonSchema.FromText(schemaJson);
|
||||
}
|
||||
}
|
||||
37
src/StellaOps.Policy/PolicyUnknownConfidenceConfig.cs
Normal file
37
src/StellaOps.Policy/PolicyUnknownConfidenceConfig.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyUnknownConfidenceConfig(
|
||||
double Initial,
|
||||
double DecayPerDay,
|
||||
double Floor,
|
||||
ImmutableArray<PolicyUnknownConfidenceBand> Bands)
|
||||
{
|
||||
public double Clamp(double value)
|
||||
=> Math.Clamp(value, Floor, 1.0);
|
||||
|
||||
public PolicyUnknownConfidenceBand ResolveBand(double value)
|
||||
{
|
||||
if (Bands.IsDefaultOrEmpty)
|
||||
{
|
||||
return PolicyUnknownConfidenceBand.Default;
|
||||
}
|
||||
|
||||
foreach (var band in Bands)
|
||||
{
|
||||
if (value >= band.Min)
|
||||
{
|
||||
return band;
|
||||
}
|
||||
}
|
||||
|
||||
return Bands[Bands.Length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PolicyUnknownConfidenceBand(string Name, double Min, string? Description = null)
|
||||
{
|
||||
public static PolicyUnknownConfidenceBand Default { get; } = new("unspecified", 0, null);
|
||||
}
|
||||
@@ -24,7 +24,12 @@ public sealed record PolicyVerdict(
|
||||
string ConfigVersion = "1.0",
|
||||
ImmutableDictionary<string, double>? Inputs = null,
|
||||
string? QuietedBy = null,
|
||||
bool Quiet = false)
|
||||
bool Quiet = false,
|
||||
double? UnknownConfidence = null,
|
||||
string? ConfidenceBand = null,
|
||||
double? UnknownAgeDays = null,
|
||||
string? SourceTrust = null,
|
||||
string? Reachability = null)
|
||||
{
|
||||
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
|
||||
{
|
||||
@@ -39,7 +44,12 @@ public sealed record PolicyVerdict(
|
||||
ConfigVersion: scoringConfig.Version,
|
||||
Inputs: inputs,
|
||||
QuietedBy: null,
|
||||
Quiet: false);
|
||||
Quiet: false,
|
||||
UnknownConfidence: null,
|
||||
ConfidenceBand: null,
|
||||
UnknownAgeDays: null,
|
||||
SourceTrust: null,
|
||||
Reachability: null);
|
||||
}
|
||||
|
||||
public ImmutableDictionary<string, double> GetInputs()
|
||||
@@ -74,6 +84,28 @@ public sealed record PolicyVerdictDiff(
|
||||
return true;
|
||||
}
|
||||
|
||||
var baselineConfidence = Baseline.UnknownConfidence ?? 0;
|
||||
var projectedConfidence = Projected.UnknownConfidence ?? 0;
|
||||
if (Math.Abs(baselineConfidence - projectedConfidence) > 0.0001)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.ConfidenceBand, Projected.ConfidenceBand, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.SourceTrust, Projected.SourceTrust, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.Reachability, Projected.Reachability, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,35 @@
|
||||
"distro": 0.85,
|
||||
"platform": 0.75,
|
||||
"community": 0.65
|
||||
},
|
||||
"reachabilityBuckets": {
|
||||
"entrypoint": 1.0,
|
||||
"direct": 0.85,
|
||||
"indirect": 0.6,
|
||||
"runtime": 0.45,
|
||||
"unreachable": 0.25,
|
||||
"unknown": 0.5
|
||||
},
|
||||
"unknownConfidence": {
|
||||
"initial": 0.8,
|
||||
"decayPerDay": 0.05,
|
||||
"floor": 0.2,
|
||||
"bands": [
|
||||
{
|
||||
"name": "high",
|
||||
"min": 0.65,
|
||||
"description": "Fresh unknowns with recent telemetry."
|
||||
},
|
||||
{
|
||||
"name": "medium",
|
||||
"min": 0.35,
|
||||
"description": "Unknowns aging toward action required."
|
||||
},
|
||||
{
|
||||
"name": "low",
|
||||
"min": 0.0,
|
||||
"description": "Stale unknowns that must be triaged."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
156
src/StellaOps.Policy/Schemas/policy-scoring-schema@1.json
Normal file
156
src/StellaOps.Policy/Schemas/policy-scoring-schema@1.json
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.stella-ops.org/policy/policy-scoring-schema@1.json",
|
||||
"title": "StellaOps Policy Scoring Configuration v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"version",
|
||||
"severityWeights"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+$"
|
||||
},
|
||||
"severityWeights": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Critical",
|
||||
"High",
|
||||
"Medium",
|
||||
"Low",
|
||||
"Informational",
|
||||
"None",
|
||||
"Unknown"
|
||||
],
|
||||
"properties": {
|
||||
"Critical": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"High": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Medium": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Low": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Informational": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"None": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Unknown": {
|
||||
"$ref": "#/$defs/weight"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quietPenalty": {
|
||||
"$ref": "#/$defs/penalty"
|
||||
},
|
||||
"warnPenalty": {
|
||||
"$ref": "#/$defs/penalty"
|
||||
},
|
||||
"ignorePenalty": {
|
||||
"$ref": "#/$defs/penalty"
|
||||
},
|
||||
"trustOverrides": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z][a-z0-9_.-]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/trustWeight"
|
||||
}
|
||||
},
|
||||
"reachabilityBuckets": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z][a-z0-9_.-]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/reachabilityWeight"
|
||||
}
|
||||
},
|
||||
"unknownConfidence": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"initial",
|
||||
"decayPerDay",
|
||||
"floor",
|
||||
"bands"
|
||||
],
|
||||
"properties": {
|
||||
"initial": {
|
||||
"$ref": "#/$defs/confidence"
|
||||
},
|
||||
"decayPerDay": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"floor": {
|
||||
"$ref": "#/$defs/confidence"
|
||||
},
|
||||
"bands": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"min"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_.-]*$"
|
||||
},
|
||||
"min": {
|
||||
"$ref": "#/$defs/confidence"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"weight": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"penalty": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 200
|
||||
},
|
||||
"trustWeight": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"reachabilityWeight": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1.5
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,12 @@
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
| POLICY-CORE-09-001 | DONE | Policy Guild | SCANNER-WEB-09-101 | Define YAML schema/binder, diagnostics, CLI validation for policy files. | Schema doc published; binder loads sample policy; validation errors actionable. |
|
||||
| POLICY-CORE-09-002 | DONE | Policy Guild | POLICY-CORE-09-001 | Implement policy snapshot store + revision digests + audit logging. | Snapshots persisted with digest; tests compare revisions; audit entries created. |
|
||||
| POLICY-CORE-09-003 | DONE | Policy Guild | POLICY-CORE-09-002 | `/policy/preview` API (image digest → projected verdict delta). | Preview returns diff JSON; integration tests with mocked report; docs updated. |
|
||||
| POLICY-CORE-09-004 | TODO | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config with schema validation, trust table, and golden fixtures. | Scoring config documented; fixtures stored; validation CLI passes. |
|
||||
| POLICY-CORE-09-005 | TODO | Policy Guild | POLICY-CORE-09-004 | Scoring/quiet engine – compute score, enforce VEX-only quiet rules, emit inputs and provenance. | Engine unit tests cover severity weighting; outputs include provenance data. |
|
||||
| POLICY-CORE-09-006 | TODO | Policy Guild | POLICY-CORE-09-005 | Unknown state & confidence decay – deterministic bands surfaced in policy outputs. | Confidence decay tests pass; docs updated; preview endpoint displays banding. |
|
||||
| POLICY-CORE-09-004 | TODO | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config (weights, trust table, reachability buckets) with schema validation, binder, and golden fixtures. | Config serialized with semantic version, binder loads defaults, fixtures assert deterministic hash. |
|
||||
| POLICY-CORE-09-005 | TODO | Policy Guild | POLICY-CORE-09-004, POLICY-CORE-09-002 | Implement scoring/quiet engine: compute score from config, enforce VEX-only quiet rules, emit inputs + `quietedBy` metadata in policy verdicts. | `/reports` policy result includes score, inputs, configVersion, quiet provenance; unit/integration tests prove reproducibility. |
|
||||
| POLICY-CORE-09-006 | TODO | Policy Guild | POLICY-CORE-09-005, FEEDCORE-ENGINE-07-003 | Track unknown states with deterministic confidence bands that decay over time; expose state in policy outputs and docs. | Unknown flags + confidence band persisted, decay job deterministic, preview/report APIs show state with tests covering decay math. |
|
||||
| POLICY-CORE-09-004 | DOING (2025-10-19) | Policy Guild | — | Versioned scoring config with schema validation, trust table, and golden fixtures. | Scoring config documented; fixtures stored; validation CLI passes. |
|
||||
| POLICY-CORE-09-005 | DOING (2025-10-19) | Policy Guild | — | Scoring/quiet engine – compute score, enforce VEX-only quiet rules, emit inputs and provenance. | Engine unit tests cover severity weighting; outputs include provenance data. |
|
||||
| POLICY-CORE-09-006 | DOING (2025-10-19) | Policy Guild | — | Unknown state & confidence decay – deterministic bands surfaced in policy outputs. | Confidence decay tests pass; docs updated; preview endpoint displays banding. |
|
||||
| POLICY-CORE-09-004 | DONE | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config (weights, trust table, reachability buckets) with schema validation, binder, and golden fixtures. | Config serialized with semantic version, binder loads defaults, fixtures assert deterministic hash. |
|
||||
| POLICY-CORE-09-005 | DONE | Policy Guild | POLICY-CORE-09-004, POLICY-CORE-09-002 | Implement scoring/quiet engine: compute score from config, enforce VEX-only quiet rules, emit inputs + `quietedBy` metadata in policy verdicts. | `/reports` policy result includes score, inputs, configVersion, quiet provenance; unit/integration tests prove reproducibility. |
|
||||
| POLICY-CORE-09-006 | DONE | Policy Guild | POLICY-CORE-09-005, FEEDCORE-ENGINE-07-003 | Track unknown states with deterministic confidence bands that decay over time; expose state in policy outputs and docs. | Unknown flags + confidence band persisted, decay job deterministic, preview/report APIs show state with tests covering decay math. |
|
||||
| POLICY-RUNTIME-17-201 | TODO | Policy Guild, Scanner WebService Guild | ZASTAVA-OBS-17-005 | Define runtime reachability feed contract and alignment plan for `SCANNER-RUNTIME-17-401` once Zastava endpoints land; document policy expectations for reachability tags. | Contract note published, sample payload agreed with Scanner team, dependencies captured in scanner/runtime task boards. |
|
||||
|
||||
## Notes
|
||||
- 2025-10-18: POLICY-CORE-09-001 completed. Binder + diagnostics + CLI scaffolding landed with tests; schema embedded at `src/StellaOps.Policy/Schemas/policy-schema@1.json` and referenced by docs/11_DATA_SCHEMAS.md.
|
||||
|
||||
Reference in New Issue
Block a user