Close scratch iteration 009 grouped policy and VEX audit repairs

This commit is contained in:
master
2026-03-13 19:25:48 +02:00
parent 6954ac7967
commit bf4ff5bfd7
41 changed files with 2413 additions and 553 deletions

View File

@@ -10,6 +10,7 @@ public static class GovernanceCompatibilityEndpoints
{
private static readonly ConcurrentDictionary<string, TrustWeightConfigState> TrustWeightStates = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, StalenessConfigState> StalenessStates = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, PolicyConflictState> ConflictStates = new(StringComparer.OrdinalIgnoreCase);
public static void MapGovernanceCompatibilityEndpoints(this WebApplication app)
{
@@ -116,6 +117,56 @@ public static class GovernanceCompatibilityEndpoints
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.BuildStatus());
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
governance.MapGet("/conflicts/dashboard", (
HttpContext context,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.ToDashboard());
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
governance.MapGet("/conflicts", (
HttpContext context,
[FromQuery] string? projectId,
[FromQuery] string? type,
[FromQuery] string? severity,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.List(type, severity));
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
governance.MapPost("/conflicts/{conflictId}/resolve", (
HttpContext context,
string conflictId,
[FromBody] ConflictResolutionWriteModel request,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
var actor = StellaOpsTenantResolver.ResolveActor(context);
var updated = state.Resolve(conflictId, request.Resolution, actor, timeProvider);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
governance.MapPost("/conflicts/{conflictId}/ignore", (
HttpContext context,
string conflictId,
[FromBody] ConflictIgnoreWriteModel request,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
var actor = StellaOpsTenantResolver.ResolveActor(context);
var updated = state.Ignore(conflictId, request.Reason, actor, timeProvider);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
}
private static GovernanceScope ResolveScope(HttpContext context, string? projectId)
@@ -168,6 +219,50 @@ public static class GovernanceCompatibilityEndpoints
]);
}
private static PolicyConflictState CreateDefaultConflictState(string tenantId, string? projectId, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow();
return new PolicyConflictState(
tenantId,
projectId,
now.ToString("O"),
new List<PolicyConflictRecord>
{
new(
"conflict-001",
"rule_overlap",
"warning",
"Overlapping severity rules in profiles",
"The strict and default profiles both escalate exploitable high CVSS findings, creating ambiguous severity outcomes for shared scopes.",
new PolicyConflictSourceRecord("profile-default", "profile", "Default Risk Profile", "1.0.0", "severityOverrides[0]"),
new PolicyConflictSourceRecord("profile-strict", "profile", "Strict Security Profile", "1.1.0", "severityOverrides[0]"),
new[] { "production", "staging" },
"High reachability findings may oscillate between warn and block decisions across identical releases.",
"Consolidate the overlapping rules or assign a strict precedence order.",
now.AddHours(-8).ToString("O"),
"open",
null,
null,
null),
new(
"conflict-002",
"precedence_ambiguity",
"info",
"Ambiguous rule precedence",
"Two release gate rules with the same priority can evaluate the same evidence set in a non-deterministic order.",
new PolicyConflictSourceRecord("gate-cvss-high", "rule", "CVSS High Escalation", null, "rules[2]"),
new PolicyConflictSourceRecord("gate-exploit-available", "rule", "Exploit Available Escalation", null, "rules[5]"),
new[] { "all" },
"Operators may see inconsistent explain traces between runs with identical inputs.",
"Assign distinct priorities so replay and live evaluation remain identical.",
now.AddDays(-1).ToString("O"),
"acknowledged",
now.AddHours(-4).ToString("O"),
"policy-reviewer",
"Captured during route-action scratch verification.")
});
}
private static List<StalenessThresholdRecord> BuildThresholds(int fresh, int aging, int stale, int expired) =>
[
new("fresh", fresh, "low", [new StalenessActionRecord("warn", "Still within freshness SLA.")]),
@@ -308,6 +403,130 @@ public static class GovernanceCompatibilityEndpoints
}).ToArray();
}
private sealed class PolicyConflictState(
string tenantId,
string? projectId,
string lastAnalyzedAt,
List<PolicyConflictRecord> conflicts)
{
public string TenantId { get; private set; } = tenantId;
public string? ProjectId { get; private set; } = projectId;
public string LastAnalyzedAt { get; private set; } = lastAnalyzedAt;
public List<PolicyConflictRecord> Conflicts { get; } = conflicts;
public object ToDashboard()
{
var byType = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["rule_overlap"] = 0,
["precedence_ambiguity"] = 0,
["circular_dependency"] = 0,
["incompatible_actions"] = 0,
["scope_collision"] = 0
};
var bySeverity = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["info"] = 0,
["warning"] = 0,
["error"] = 0,
["critical"] = 0
};
foreach (var conflict in Conflicts)
{
byType[conflict.Type] = byType.GetValueOrDefault(conflict.Type) + 1;
bySeverity[conflict.Severity] = bySeverity.GetValueOrDefault(conflict.Severity) + 1;
}
var trend = Enumerable.Range(0, 7)
.Select(offset => DateTimeOffset.Parse(LastAnalyzedAt).UtcDateTime.Date.AddDays(offset - 6))
.Select(day => new
{
date = day.ToString("yyyy-MM-dd"),
count = Conflicts.Count(conflict =>
DateTimeOffset.Parse(conflict.DetectedAt).UtcDateTime.Date == day)
})
.ToArray();
return new
{
totalConflicts = Conflicts.Count,
openConflicts = Conflicts.Count(conflict => string.Equals(conflict.Status, "open", StringComparison.OrdinalIgnoreCase)),
byType,
bySeverity,
recentConflicts = Conflicts
.OrderByDescending(conflict => conflict.DetectedAt, StringComparer.Ordinal)
.Take(5)
.ToArray(),
trend,
lastAnalyzedAt = LastAnalyzedAt
};
}
public object[] List(string? type, string? severity)
{
IEnumerable<PolicyConflictRecord> query = Conflicts;
if (!string.IsNullOrWhiteSpace(type))
{
query = query.Where(conflict => string.Equals(conflict.Type, type.Trim(), StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(severity))
{
query = query.Where(conflict => string.Equals(conflict.Severity, severity.Trim(), StringComparison.OrdinalIgnoreCase));
}
return query
.OrderByDescending(conflict => conflict.DetectedAt, StringComparer.Ordinal)
.ToArray<object>();
}
public object? Resolve(string conflictId, string? resolution, string actor, TimeProvider timeProvider)
{
var index = Conflicts.FindIndex(conflict => string.Equals(conflict.Id, conflictId, StringComparison.OrdinalIgnoreCase));
if (index < 0)
{
return null;
}
var now = timeProvider.GetUtcNow().ToString("O");
var current = Conflicts[index];
var updated = current with
{
Status = "resolved",
ResolvedAt = now,
ResolvedBy = actor,
ResolutionNotes = string.IsNullOrWhiteSpace(resolution) ? current.SuggestedResolution : resolution.Trim()
};
Conflicts[index] = updated;
LastAnalyzedAt = now;
return updated;
}
public object? Ignore(string conflictId, string? reason, string actor, TimeProvider timeProvider)
{
var index = Conflicts.FindIndex(conflict => string.Equals(conflict.Id, conflictId, StringComparison.OrdinalIgnoreCase));
if (index < 0)
{
return null;
}
var now = timeProvider.GetUtcNow().ToString("O");
var current = Conflicts[index];
var updated = current with
{
Status = "ignored",
ResolvedAt = now,
ResolvedBy = actor,
ResolutionNotes = string.IsNullOrWhiteSpace(reason) ? "Ignored by operator." : reason.Trim()
};
Conflicts[index] = updated;
LastAnalyzedAt = now;
return updated;
}
}
private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "custom" : source.Trim().ToLowerInvariant();
@@ -350,6 +569,34 @@ public static class GovernanceCompatibilityEndpoints
string[]? Channels = null);
}
public sealed record ConflictResolutionWriteModel(string? Resolution);
public sealed record ConflictIgnoreWriteModel(string? Reason);
public sealed record PolicyConflictSourceRecord(
string Id,
string Type,
string Name,
string? Version,
string? Path);
public sealed record PolicyConflictRecord(
string Id,
string Type,
string Severity,
string Summary,
string Description,
PolicyConflictSourceRecord SourceA,
PolicyConflictSourceRecord SourceB,
IReadOnlyList<string> AffectedScope,
string ImpactAssessment,
string? SuggestedResolution,
string DetectedAt,
string Status,
string? ResolvedAt,
string? ResolvedBy,
string? ResolutionNotes);
public sealed record TrustWeightWriteModel
{
public string? Id { get; init; }

View File

@@ -109,4 +109,49 @@ public sealed class GovernanceCompatibilityEndpointsTests : IClassFixture<TestPo
Assert.True(status.ValueKind == JsonValueKind.Array);
Assert.True(status.GetArrayLength() > 0);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ConflictsEndpoints_ReturnDashboardAndFilteredList()
{
var dashboardResponse = await _client.GetAsync("/api/v1/governance/conflicts/dashboard", TestContext.Current.CancellationToken);
dashboardResponse.EnsureSuccessStatusCode();
var dashboard = await dashboardResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(dashboard.GetProperty("totalConflicts").GetInt32() >= 2);
Assert.True(dashboard.GetProperty("byType").TryGetProperty("rule_overlap", out _));
Assert.True(dashboard.GetProperty("trend").GetArrayLength() == 7);
var listResponse = await _client.GetAsync("/api/v1/governance/conflicts?severity=warning", TestContext.Current.CancellationToken);
listResponse.EnsureSuccessStatusCode();
var conflicts = await listResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(conflicts.ValueKind == JsonValueKind.Array);
Assert.All(conflicts.EnumerateArray(), item => Assert.Equal("warning", item.GetProperty("severity").GetString()));
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ConflictResolutionEndpoints_PersistUpdatedStatus()
{
var resolveResponse = await _client.PostAsJsonAsync(
"/api/v1/governance/conflicts/conflict-001/resolve",
new { resolution = "Consolidated precedence ordering" },
TestContext.Current.CancellationToken);
resolveResponse.EnsureSuccessStatusCode();
var resolved = await resolveResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal("resolved", resolved.GetProperty("status").GetString());
Assert.Equal("Consolidated precedence ordering", resolved.GetProperty("resolutionNotes").GetString());
var ignoreResponse = await _client.PostAsJsonAsync(
"/api/v1/governance/conflicts/conflict-002/ignore",
new { reason = "Accepted for lab-only profile" },
TestContext.Current.CancellationToken);
ignoreResponse.EnsureSuccessStatusCode();
var ignored = await ignoreResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal("ignored", ignored.GetProperty("status").GetString());
Assert.Equal("Accepted for lab-only profile", ignored.GetProperty("resolutionNotes").GetString());
}
}