Close scratch iteration 009 grouped policy and VEX audit repairs
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user