more audit work

This commit is contained in:
master
2026-01-08 10:21:51 +02:00
parent 43c02081ef
commit 51cf4bc16c
546 changed files with 36721 additions and 4003 deletions

View File

@@ -0,0 +1,559 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using System.Collections.Immutable;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Playbook;
using StellaOps.OpsMemory.Storage;
namespace StellaOps.OpsMemory.WebService.Endpoints;
/// <summary>
/// OpsMemory API endpoints for decision recording and playbook suggestions.
/// Sprint: SPRINT_20260107_006_004 Task OM-006
/// </summary>
public static class OpsMemoryEndpoints
{
/// <summary>
/// Maps OpsMemory endpoints.
/// </summary>
public static void MapOpsMemoryEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/opsmemory")
.WithTags("OpsMemory");
group.MapPost("/decisions", RecordDecisionAsync)
.WithName("RecordDecision")
.WithDescription("Record a security decision for future playbook learning");
group.MapGet("/decisions/{memoryId}", GetDecisionAsync)
.WithName("GetDecision")
.WithDescription("Get a specific decision by ID");
group.MapPost("/decisions/{memoryId}/outcome", RecordOutcomeAsync)
.WithName("RecordOutcome")
.WithDescription("Record the outcome of a previous decision");
group.MapGet("/suggestions", GetSuggestionsAsync)
.WithName("GetPlaybookSuggestions")
.WithDescription("Get playbook suggestions for a given situation");
group.MapGet("/decisions", QueryDecisionsAsync)
.WithName("QueryDecisions")
.WithDescription("Query past decisions with filters");
group.MapGet("/stats", GetStatsAsync)
.WithName("GetOpsMemoryStats")
.WithDescription("Get decision statistics for a tenant");
}
/// <summary>
/// Record a new security decision.
/// </summary>
private static async Task<Results<Created<RecordDecisionResponse>, BadRequest<ProblemDetails>>> RecordDecisionAsync(
RecordDecisionRequest request,
IOpsMemoryStore store,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return TypedResults.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "TenantId is required"
});
}
if (!Enum.TryParse<DecisionAction>(request.Action, ignoreCase: true, out var action))
{
return TypedResults.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = $"Invalid action: {request.Action}. Valid values: {string.Join(", ", Enum.GetNames<DecisionAction>())}"
});
}
var record = new OpsMemoryRecord
{
MemoryId = Guid.NewGuid().ToString("N"),
TenantId = request.TenantId,
RecordedAt = DateTimeOffset.UtcNow,
Situation = new SituationContext
{
CveId = request.CveId,
Component = request.ComponentPurl,
Severity = request.Severity,
Reachability = ParseReachability(request.Reachability),
EpssScore = request.EpssScore,
CvssScore = request.CvssScore,
ContextTags = request.ContextTags?.ToImmutableArray() ?? ImmutableArray<string>.Empty
},
Decision = new DecisionRecord
{
Action = action,
Rationale = request.Rationale ?? string.Empty,
DecidedBy = request.DecidedBy ?? "unknown",
DecidedAt = DateTimeOffset.UtcNow,
PolicyReference = request.PolicyReference,
Mitigation = !string.IsNullOrEmpty(request.MitigationType)
? new MitigationDetails
{
Type = request.MitigationType,
Description = request.MitigationDetails ?? string.Empty
}
: null
}
};
var saved = await store.RecordDecisionAsync(record, cancellationToken);
return TypedResults.Created(
$"/api/v1/opsmemory/decisions/{saved.MemoryId}",
new RecordDecisionResponse
{
MemoryId = saved.MemoryId,
RecordedAt = saved.RecordedAt
});
}
/// <summary>
/// Get a specific decision.
/// </summary>
private static async Task<Results<Ok<DecisionDetailsResponse>, NotFound>> GetDecisionAsync(
string memoryId,
string tenantId,
IOpsMemoryStore store,
CancellationToken cancellationToken)
{
var record = await store.GetByIdAsync(memoryId, tenantId, cancellationToken);
if (record == null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(MapToDetailsResponse(record));
}
/// <summary>
/// Record the outcome of a decision.
/// </summary>
private static async Task<Results<Ok<OutcomeRecordedResponse>, NotFound, BadRequest<ProblemDetails>>> RecordOutcomeAsync(
string memoryId,
string tenantId,
RecordOutcomeRequest request,
IOpsMemoryStore store,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<OutcomeStatus>(request.Status, ignoreCase: true, out var status))
{
return TypedResults.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = $"Invalid status: {request.Status}. Valid values: {string.Join(", ", Enum.GetNames<OutcomeStatus>())}"
});
}
var outcome = new OutcomeRecord
{
Status = status,
ResolutionTime = request.ResolutionTimeMinutes.HasValue
? TimeSpan.FromMinutes(request.ResolutionTimeMinutes.Value)
: null,
ActualImpact = request.ActualImpact,
LessonsLearned = request.LessonsLearned,
RecordedBy = request.RecordedBy ?? "unknown",
RecordedAt = DateTimeOffset.UtcNow
};
var updated = await store.RecordOutcomeAsync(memoryId, tenantId, outcome, cancellationToken);
if (updated == null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(new OutcomeRecordedResponse
{
MemoryId = memoryId,
Status = status.ToString(),
RecordedAt = outcome.RecordedAt
});
}
/// <summary>
/// Get playbook suggestions for a situation.
/// </summary>
private static async Task<Ok<PlaybookSuggestionsResponse>> GetSuggestionsAsync(
string tenantId,
PlaybookSuggestionService suggestionService,
string? cveId,
string? componentPurl,
string? severity,
string? reachability,
double? epssScore,
double? cvssScore,
int? limit,
CancellationToken cancellationToken)
{
var request = new PlaybookSuggestionRequest
{
TenantId = tenantId,
Situation = new SituationContext
{
CveId = cveId,
Component = componentPurl,
Severity = severity,
Reachability = ParseReachability(reachability),
EpssScore = epssScore,
CvssScore = cvssScore
},
MaxSuggestions = limit ?? 3
};
var result = await suggestionService.GetSuggestionsAsync(request, cancellationToken);
return TypedResults.Ok(new PlaybookSuggestionsResponse
{
Suggestions = result.Suggestions.Select(s => new PlaybookSuggestionDto
{
SuggestedAction = s.Action.ToString(),
Confidence = s.Confidence,
Rationale = s.Rationale,
SuccessRate = s.SuccessRate,
SimilarDecisionCount = s.SimilarDecisionCount,
AverageResolutionTimeMinutes = s.AverageResolutionTime?.TotalMinutes,
Evidence = s.Evidence.Select(e => new PlaybookEvidenceDto
{
MemoryId = e.MemoryId,
Similarity = e.Similarity,
Action = e.Action.ToString(),
Outcome = e.Outcome.ToString(),
DecidedAt = e.DecidedAt,
CveId = e.Cve,
ComponentPurl = e.Component
}).ToList(),
MatchingFactors = s.MatchingFactors.ToList()
}).ToList(),
AnalyzedRecords = result.AnalyzedRecords,
TopSimilarity = result.TopSimilarity
});
}
/// <summary>
/// Query past decisions.
/// </summary>
private static async Task<Ok<QueryDecisionsResponse>> QueryDecisionsAsync(
string tenantId,
IOpsMemoryStore store,
string? cveId,
string? componentPrefix,
string? action,
string? outcomeStatus,
int? pageSize,
string? cursor,
CancellationToken cancellationToken)
{
DecisionAction? actionFilter = null;
if (!string.IsNullOrEmpty(action) && Enum.TryParse<DecisionAction>(action, ignoreCase: true, out var a))
{
actionFilter = a;
}
OutcomeStatus? statusFilter = null;
if (!string.IsNullOrEmpty(outcomeStatus) && Enum.TryParse<OutcomeStatus>(outcomeStatus, ignoreCase: true, out var s))
{
statusFilter = s;
}
var query = new OpsMemoryQuery
{
TenantId = tenantId,
CveId = cveId,
ComponentPrefix = componentPrefix,
Action = actionFilter,
OutcomeStatus = statusFilter,
PageSize = pageSize ?? 20,
Cursor = cursor
};
var result = await store.QueryAsync(query, cancellationToken);
return TypedResults.Ok(new QueryDecisionsResponse
{
Decisions = result.Items.Select(MapToSummary).ToList(),
TotalCount = result.TotalCount,
NextCursor = result.NextCursor,
HasMore = result.HasMore
});
}
/// <summary>
/// Get statistics for a tenant.
/// </summary>
private static async Task<Ok<OpsMemoryStatsResponse>> GetStatsAsync(
string tenantId,
IOpsMemoryStore store,
CancellationToken cancellationToken)
{
var stats = await store.GetStatsAsync(tenantId, null, cancellationToken);
return TypedResults.Ok(new OpsMemoryStatsResponse
{
TenantId = tenantId,
TotalDecisions = stats.TotalDecisions,
DecisionsWithOutcomes = stats.DecisionsWithOutcomes,
SuccessRate = stats.SuccessRate
});
}
private static ReachabilityStatus ParseReachability(string? value)
{
if (string.IsNullOrEmpty(value))
{
return ReachabilityStatus.Unknown;
}
return Enum.TryParse<ReachabilityStatus>(value, ignoreCase: true, out var result)
? result
: ReachabilityStatus.Unknown;
}
private static DecisionDetailsResponse MapToDetailsResponse(OpsMemoryRecord record)
{
return new DecisionDetailsResponse
{
MemoryId = record.MemoryId,
TenantId = record.TenantId,
RecordedAt = record.RecordedAt,
Situation = new SituationDto
{
CveId = record.Situation.CveId,
Component = record.Situation.Component,
ComponentName = record.Situation.ComponentName,
Severity = record.Situation.Severity,
Reachability = record.Situation.Reachability.ToString(),
EpssScore = record.Situation.EpssScore,
CvssScore = record.Situation.CvssScore,
IsKev = record.Situation.IsKev,
ContextTags = record.Situation.ContextTags.ToList()
},
Decision = new DecisionDto
{
Action = record.Decision.Action.ToString(),
Rationale = record.Decision.Rationale,
DecidedBy = record.Decision.DecidedBy,
DecidedAt = record.Decision.DecidedAt,
PolicyReference = record.Decision.PolicyReference,
VexStatementId = record.Decision.VexStatementId,
Mitigation = record.Decision.Mitigation != null
? new MitigationDto
{
Type = record.Decision.Mitigation.Type,
Description = record.Decision.Mitigation.Description,
Effectiveness = record.Decision.Mitigation.Effectiveness,
ExpiresAt = record.Decision.Mitigation.ExpiresAt
}
: null
},
Outcome = record.Outcome != null
? new OutcomeDto
{
Status = record.Outcome.Status.ToString(),
ResolutionTimeMinutes = record.Outcome.ResolutionTime?.TotalMinutes,
ActualImpact = record.Outcome.ActualImpact,
LessonsLearned = record.Outcome.LessonsLearned,
RecordedBy = record.Outcome.RecordedBy,
RecordedAt = record.Outcome.RecordedAt,
WouldRepeat = record.Outcome.WouldRepeat,
AlternativeActions = record.Outcome.AlternativeActions
}
: null
};
}
private static DecisionSummaryDto MapToSummary(OpsMemoryRecord record)
{
return new DecisionSummaryDto
{
MemoryId = record.MemoryId,
RecordedAt = record.RecordedAt,
CveId = record.Situation.CveId,
Component = record.Situation.Component,
Action = record.Decision.Action.ToString(),
OutcomeStatus = record.Outcome?.Status.ToString(),
DecidedBy = record.Decision.DecidedBy
};
}
}
#region Request/Response DTOs
/// <summary>Request to record a decision.</summary>
public sealed record RecordDecisionRequest
{
public required string TenantId { get; init; }
public string? CveId { get; init; }
public string? ComponentPurl { get; init; }
public string? Severity { get; init; }
public string? Reachability { get; init; }
public double? EpssScore { get; init; }
public double? CvssScore { get; init; }
public List<string>? ContextTags { get; init; }
public required string Action { get; init; }
public string? Rationale { get; init; }
public string? DecidedBy { get; init; }
public string? PolicyReference { get; init; }
public string? MitigationType { get; init; }
public string? MitigationDetails { get; init; }
}
/// <summary>Response after recording a decision.</summary>
public sealed record RecordDecisionResponse
{
public required string MemoryId { get; init; }
public DateTimeOffset RecordedAt { get; init; }
}
/// <summary>Request to record an outcome.</summary>
public sealed record RecordOutcomeRequest
{
public required string Status { get; init; }
public int? ResolutionTimeMinutes { get; init; }
public string? ActualImpact { get; init; }
public string? LessonsLearned { get; init; }
public string? RecordedBy { get; init; }
}
/// <summary>Response after recording an outcome.</summary>
public sealed record OutcomeRecordedResponse
{
public required string MemoryId { get; init; }
public required string Status { get; init; }
public DateTimeOffset RecordedAt { get; init; }
}
/// <summary>Detailed decision information.</summary>
public sealed record DecisionDetailsResponse
{
public required string MemoryId { get; init; }
public required string TenantId { get; init; }
public DateTimeOffset RecordedAt { get; init; }
public required SituationDto Situation { get; init; }
public required DecisionDto Decision { get; init; }
public OutcomeDto? Outcome { get; init; }
}
/// <summary>Situation context DTO.</summary>
public sealed record SituationDto
{
public string? CveId { get; init; }
public string? Component { get; init; }
public string? ComponentName { get; init; }
public string? Severity { get; init; }
public string? Reachability { get; init; }
public double? EpssScore { get; init; }
public double? CvssScore { get; init; }
public bool IsKev { get; init; }
public List<string>? ContextTags { get; init; }
}
/// <summary>Decision DTO.</summary>
public sealed record DecisionDto
{
public required string Action { get; init; }
public required string Rationale { get; init; }
public required string DecidedBy { get; init; }
public DateTimeOffset DecidedAt { get; init; }
public string? PolicyReference { get; init; }
public string? VexStatementId { get; init; }
public MitigationDto? Mitigation { get; init; }
}
/// <summary>Mitigation DTO.</summary>
public sealed record MitigationDto
{
public required string Type { get; init; }
public required string Description { get; init; }
public double? Effectiveness { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>Outcome DTO.</summary>
public sealed record OutcomeDto
{
public required string Status { get; init; }
public double? ResolutionTimeMinutes { get; init; }
public string? ActualImpact { get; init; }
public string? LessonsLearned { get; init; }
public required string RecordedBy { get; init; }
public DateTimeOffset RecordedAt { get; init; }
public bool? WouldRepeat { get; init; }
public string? AlternativeActions { get; init; }
}
/// <summary>Playbook suggestions response.</summary>
public sealed record PlaybookSuggestionsResponse
{
public required List<PlaybookSuggestionDto> Suggestions { get; init; }
public int AnalyzedRecords { get; init; }
public double? TopSimilarity { get; init; }
}
/// <summary>A single playbook suggestion.</summary>
public sealed record PlaybookSuggestionDto
{
public required string SuggestedAction { get; init; }
public double Confidence { get; init; }
public required string Rationale { get; init; }
public double SuccessRate { get; init; }
public int SimilarDecisionCount { get; init; }
public double? AverageResolutionTimeMinutes { get; init; }
public required List<PlaybookEvidenceDto> Evidence { get; init; }
public List<string>? MatchingFactors { get; init; }
}
/// <summary>Evidence for a suggestion.</summary>
public sealed record PlaybookEvidenceDto
{
public required string MemoryId { get; init; }
public double Similarity { get; init; }
public required string Action { get; init; }
public required string Outcome { get; init; }
public DateTimeOffset DecidedAt { get; init; }
public string? CveId { get; init; }
public string? ComponentPurl { get; init; }
}
/// <summary>Query decisions response.</summary>
public sealed record QueryDecisionsResponse
{
public required List<DecisionSummaryDto> Decisions { get; init; }
public int? TotalCount { get; init; }
public string? NextCursor { get; init; }
public bool HasMore { get; init; }
}
/// <summary>Decision summary for lists.</summary>
public sealed record DecisionSummaryDto
{
public required string MemoryId { get; init; }
public DateTimeOffset RecordedAt { get; init; }
public string? CveId { get; init; }
public string? Component { get; init; }
public required string Action { get; init; }
public string? OutcomeStatus { get; init; }
public string? DecidedBy { get; init; }
}
/// <summary>Statistics response.</summary>
public sealed record OpsMemoryStatsResponse
{
public required string TenantId { get; init; }
public int TotalDecisions { get; init; }
public int DecisionsWithOutcomes { get; init; }
public double SuccessRate { get; init; }
}
#endregion

View File

@@ -0,0 +1,49 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using Npgsql;
using StellaOps.OpsMemory.Playbook;
using StellaOps.OpsMemory.Similarity;
using StellaOps.OpsMemory.Storage;
using StellaOps.OpsMemory.WebService.Endpoints;
var builder = WebApplication.CreateBuilder(args);
// Add PostgreSQL data source
var connectionString = builder.Configuration.GetConnectionString("OpsMemory")
?? "Host=localhost;Port=5432;Database=stellaops;Username=stellaops;Password=stellaops";
builder.Services.AddSingleton<NpgsqlDataSource>(_ => NpgsqlDataSource.Create(connectionString));
// Add OpsMemory services
builder.Services.AddSingleton<IOpsMemoryStore, PostgresOpsMemoryStore>();
builder.Services.AddSingleton<SimilarityVectorGenerator>();
builder.Services.AddSingleton<PlaybookSuggestionService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new()
{
Title = "StellaOps OpsMemory API",
Version = "v1",
Description = "Decision ledger and playbook suggestions API for security operations learning"
});
});
builder.Services.AddHealthChecks();
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Map endpoints
app.MapOpsMemoryEndpoints();
app.MapHealthChecks("/health");
app.Run();

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.OpsMemory.WebService</RootNamespace>
<Description>StellaOps OpsMemory Service - Decision ledger and playbook suggestions API</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Npgsql" />
</ItemGroup>
</Project>