more audit work
This commit is contained in:
@@ -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
|
||||
49
src/OpsMemory/StellaOps.OpsMemory.WebService/Program.cs
Normal file
49
src/OpsMemory/StellaOps.OpsMemory.WebService/Program.cs
Normal 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();
|
||||
@@ -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>
|
||||
300
src/OpsMemory/StellaOps.OpsMemory/Models/OpsMemoryRecord.cs
Normal file
300
src/OpsMemory/StellaOps.OpsMemory/Models/OpsMemoryRecord.cs
Normal file
@@ -0,0 +1,300 @@
|
||||
// <copyright file="OpsMemoryRecord.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A structured record of a security decision and its outcome for playbook learning.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-001
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique memory record identifier.
|
||||
/// </summary>
|
||||
public required string MemoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier for multi-tenancy isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when this record was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset RecordedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the situation context at the time of decision.
|
||||
/// </summary>
|
||||
public required SituationContext Situation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the decision that was made.
|
||||
/// </summary>
|
||||
public required DecisionRecord Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the outcome of the decision (if recorded).
|
||||
/// </summary>
|
||||
public OutcomeRecord? Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the similarity vector for finding related decisions.
|
||||
/// </summary>
|
||||
public ImmutableArray<float> SimilarityVector { get; init; } = ImmutableArray<float>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this record has an outcome recorded.
|
||||
/// </summary>
|
||||
public bool HasOutcome => Outcome is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this decision was successful.
|
||||
/// </summary>
|
||||
public bool WasSuccessful => Outcome?.Status == OutcomeStatus.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The security context at the time a decision was made.
|
||||
/// </summary>
|
||||
public sealed record SituationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the CVE identifier (if applicable).
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component PURL.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component name (for display).
|
||||
/// </summary>
|
||||
public string? ComponentName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component version.
|
||||
/// </summary>
|
||||
public string? ComponentVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the severity level.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVSS score (0-10).
|
||||
/// </summary>
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reachability status.
|
||||
/// </summary>
|
||||
public ReachabilityStatus Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the EPSS score (0-1).
|
||||
/// </summary>
|
||||
public double? EpssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the KEV (Known Exploited Vulnerability) status.
|
||||
/// </summary>
|
||||
public bool IsKev { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the context tags (environment, service, team, etc.).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ContextTags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional context fields.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> AdditionalContext { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status for a vulnerability.
|
||||
/// </summary>
|
||||
public enum ReachabilityStatus
|
||||
{
|
||||
/// <summary>Not analyzed.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Confirmed reachable from entry point.</summary>
|
||||
Reachable,
|
||||
|
||||
/// <summary>Analyzed and determined not reachable.</summary>
|
||||
NotReachable,
|
||||
|
||||
/// <summary>Potentially reachable (inconclusive analysis).</summary>
|
||||
Potential
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A security decision that was made.
|
||||
/// </summary>
|
||||
public sealed record DecisionRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action taken.
|
||||
/// </summary>
|
||||
public required DecisionAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable rationale.
|
||||
/// </summary>
|
||||
public required string Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the identity of the decision maker.
|
||||
/// </summary>
|
||||
public required string DecidedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the decision.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy reference that guided this decision.
|
||||
/// </summary>
|
||||
public string? PolicyReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VEX statement ID (if one was created).
|
||||
/// </summary>
|
||||
public string? VexStatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any mitigation details.
|
||||
/// </summary>
|
||||
public MitigationDetails? Mitigation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actions that can be taken for a security finding.
|
||||
/// </summary>
|
||||
public enum DecisionAction
|
||||
{
|
||||
/// <summary>Accept the risk (no action).</summary>
|
||||
Accept,
|
||||
|
||||
/// <summary>Remediate (upgrade/patch).</summary>
|
||||
Remediate,
|
||||
|
||||
/// <summary>Quarantine/isolate the component.</summary>
|
||||
Quarantine,
|
||||
|
||||
/// <summary>Apply mitigation (WAF, config, etc.).</summary>
|
||||
Mitigate,
|
||||
|
||||
/// <summary>Defer for later review.</summary>
|
||||
Defer,
|
||||
|
||||
/// <summary>Escalate to security team.</summary>
|
||||
Escalate,
|
||||
|
||||
/// <summary>False positive - not applicable.</summary>
|
||||
FalsePositive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details about a mitigation applied.
|
||||
/// </summary>
|
||||
public sealed record MitigationDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the type of mitigation.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the description of the mitigation.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mitigation effectiveness (0-1).
|
||||
/// </summary>
|
||||
public double? Effectiveness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the expiration date for the mitigation.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of a security decision.
|
||||
/// </summary>
|
||||
public sealed record OutcomeRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the outcome status.
|
||||
/// </summary>
|
||||
public required OutcomeStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time to resolution.
|
||||
/// </summary>
|
||||
public TimeSpan? ResolutionTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actual impact experienced.
|
||||
/// </summary>
|
||||
public string? ActualImpact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets lessons learned from this decision.
|
||||
/// </summary>
|
||||
public string? LessonsLearned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets who recorded the outcome.
|
||||
/// </summary>
|
||||
public required string RecordedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the outcome was recorded.
|
||||
/// </summary>
|
||||
public required DateTimeOffset RecordedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the original decision would be made again.
|
||||
/// </summary>
|
||||
public bool? WouldRepeat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets alternative actions that might have been better.
|
||||
/// </summary>
|
||||
public string? AlternativeActions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome status for a decision.
|
||||
/// </summary>
|
||||
public enum OutcomeStatus
|
||||
{
|
||||
/// <summary>Decision led to successful resolution.</summary>
|
||||
Success,
|
||||
|
||||
/// <summary>Decision led to partial resolution.</summary>
|
||||
PartialSuccess,
|
||||
|
||||
/// <summary>Decision was ineffective.</summary>
|
||||
Ineffective,
|
||||
|
||||
/// <summary>Decision led to negative consequences.</summary>
|
||||
NegativeOutcome,
|
||||
|
||||
/// <summary>Outcome is still pending.</summary>
|
||||
Pending
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
// <copyright file="PlaybookSuggestionService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
|
||||
namespace StellaOps.OpsMemory.Playbook;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating playbook suggestions based on past decisions.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-005
|
||||
/// </summary>
|
||||
public sealed class PlaybookSuggestionService
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly SimilarityVectorGenerator _vectorGenerator;
|
||||
private readonly ILogger<PlaybookSuggestionService> _logger;
|
||||
|
||||
private const int DefaultTopK = 10;
|
||||
private const int MaxSuggestions = 3;
|
||||
private const double MinConfidence = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlaybookSuggestionService"/> class.
|
||||
/// </summary>
|
||||
public PlaybookSuggestionService(
|
||||
IOpsMemoryStore store,
|
||||
SimilarityVectorGenerator vectorGenerator,
|
||||
ILogger<PlaybookSuggestionService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_vectorGenerator = vectorGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets playbook suggestions for a given situation.
|
||||
/// </summary>
|
||||
/// <param name="request">The suggestion request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Playbook suggestions ordered by confidence.</returns>
|
||||
public async Task<PlaybookSuggestionResult> GetSuggestionsAsync(
|
||||
PlaybookSuggestionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Generating playbook suggestions for CVE {CveId}, component {Component}",
|
||||
request.Situation.CveId,
|
||||
request.Situation.Component);
|
||||
|
||||
// Generate similarity vector for current situation
|
||||
var situationVector = _vectorGenerator.Generate(request.Situation);
|
||||
|
||||
// Query similar situations with successful outcomes
|
||||
var query = new SimilarityQuery
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
SimilarityVector = situationVector,
|
||||
Limit = request.TopK ?? DefaultTopK,
|
||||
MinSimilarity = request.MinSimilarity ?? 0.6,
|
||||
OnlyWithOutcome = true,
|
||||
OnlySuccessful = true,
|
||||
Since = request.Since
|
||||
};
|
||||
|
||||
var similarRecords = await _store.FindSimilarAsync(query, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (similarRecords.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No similar successful decisions found");
|
||||
return new PlaybookSuggestionResult
|
||||
{
|
||||
Suggestions = ImmutableArray<PlaybookSuggestion>.Empty,
|
||||
AnalyzedRecords = 0
|
||||
};
|
||||
}
|
||||
|
||||
// Group by action and rank by success rate and similarity
|
||||
var suggestions = GroupAndRankSuggestions(
|
||||
request.Situation,
|
||||
similarRecords,
|
||||
request.MaxSuggestions ?? MaxSuggestions);
|
||||
|
||||
return new PlaybookSuggestionResult
|
||||
{
|
||||
Suggestions = suggestions,
|
||||
AnalyzedRecords = similarRecords.Count,
|
||||
TopSimilarity = similarRecords.Max(r => r.SimilarityScore)
|
||||
};
|
||||
}
|
||||
|
||||
private ImmutableArray<PlaybookSuggestion> GroupAndRankSuggestions(
|
||||
SituationContext currentSituation,
|
||||
IReadOnlyList<SimilarityMatch> similarRecords,
|
||||
int maxSuggestions)
|
||||
{
|
||||
// Group by action
|
||||
var byAction = similarRecords
|
||||
.GroupBy(r => r.Record.Decision.Action)
|
||||
.Select(g => new
|
||||
{
|
||||
Action = g.Key,
|
||||
Records = g.ToList(),
|
||||
AvgSimilarity = g.Average(r => r.SimilarityScore),
|
||||
SuccessCount = g.Count(r => r.Record.Outcome?.Status == OutcomeStatus.Success),
|
||||
TotalCount = g.Count()
|
||||
})
|
||||
.OrderByDescending(g => g.SuccessCount)
|
||||
.ThenByDescending(g => g.AvgSimilarity)
|
||||
.Take(maxSuggestions)
|
||||
.ToList();
|
||||
|
||||
var suggestions = new List<PlaybookSuggestion>();
|
||||
|
||||
foreach (var group in byAction)
|
||||
{
|
||||
var confidence = CalculateConfidence(
|
||||
group.SuccessCount,
|
||||
group.TotalCount,
|
||||
group.AvgSimilarity);
|
||||
|
||||
if (confidence < MinConfidence)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the best example for this action
|
||||
var bestExample = group.Records
|
||||
.OrderByDescending(r => r.SimilarityScore)
|
||||
.First();
|
||||
|
||||
// Get matching factors
|
||||
var factors = _vectorGenerator.GetMatchingFactors(
|
||||
currentSituation,
|
||||
bestExample.Record.Situation);
|
||||
|
||||
// Build rationale from past decisions
|
||||
var rationales = group.Records
|
||||
.Select(r => r.Record.Decision.Rationale)
|
||||
.Distinct()
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
suggestions.Add(new PlaybookSuggestion
|
||||
{
|
||||
Action = group.Action,
|
||||
Confidence = confidence,
|
||||
Rationale = BuildRationale(group.Action, rationales, factors),
|
||||
Evidence = BuildEvidence(group.Records),
|
||||
MatchingFactors = factors,
|
||||
SimilarDecisionCount = group.TotalCount,
|
||||
SuccessRate = (double)group.SuccessCount / group.TotalCount,
|
||||
BestMatchMemoryId = bestExample.Record.MemoryId,
|
||||
AverageResolutionTime = CalculateAverageResolutionTime(group.Records)
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions
|
||||
.OrderByDescending(s => s.Confidence)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static double CalculateConfidence(int successCount, int totalCount, double avgSimilarity)
|
||||
{
|
||||
// Confidence formula:
|
||||
// - Base: success rate * similarity
|
||||
// - Boost for more data points (log scale)
|
||||
var successRate = (double)successCount / totalCount;
|
||||
var dataBoost = Math.Min(1.0, Math.Log10(totalCount + 1) / 2);
|
||||
|
||||
return (successRate * 0.5 + avgSimilarity * 0.3 + dataBoost * 0.2);
|
||||
}
|
||||
|
||||
private static string BuildRationale(
|
||||
DecisionAction action,
|
||||
List<string> pastRationales,
|
||||
ImmutableArray<string> matchingFactors)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
// Start with the recommendation
|
||||
parts.Add($"Recommended action: **{action}**");
|
||||
|
||||
// Add matching factors
|
||||
if (matchingFactors.Length > 0)
|
||||
{
|
||||
parts.Add(string.Format(CultureInfo.InvariantCulture,
|
||||
"This situation matches previous cases with: {0}.",
|
||||
string.Join(", ", matchingFactors)));
|
||||
}
|
||||
|
||||
// Add sample past rationales
|
||||
if (pastRationales.Count > 0)
|
||||
{
|
||||
parts.Add(string.Format(CultureInfo.InvariantCulture,
|
||||
"Past decisions used similar reasoning: \"{0}\"",
|
||||
pastRationales[0]));
|
||||
}
|
||||
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
|
||||
private static ImmutableArray<PlaybookEvidence> BuildEvidence(List<SimilarityMatch> records)
|
||||
{
|
||||
return records
|
||||
.OrderByDescending(r => r.SimilarityScore)
|
||||
.Take(5)
|
||||
.Select(r => new PlaybookEvidence
|
||||
{
|
||||
MemoryId = r.Record.MemoryId,
|
||||
Similarity = r.SimilarityScore,
|
||||
Action = r.Record.Decision.Action,
|
||||
Outcome = r.Record.Outcome?.Status ?? OutcomeStatus.Pending,
|
||||
DecidedAt = r.Record.Decision.DecidedAt,
|
||||
Cve = r.Record.Situation.CveId,
|
||||
Component = r.Record.Situation.ComponentName
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static TimeSpan? CalculateAverageResolutionTime(List<SimilarityMatch> records)
|
||||
{
|
||||
var resolutionTimes = records
|
||||
.Where(r => r.Record.Outcome?.ResolutionTime.HasValue == true)
|
||||
.Select(r => r.Record.Outcome!.ResolutionTime!.Value)
|
||||
.ToList();
|
||||
|
||||
if (resolutionTimes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var avgTicks = resolutionTimes.Average(t => t.Ticks);
|
||||
return TimeSpan.FromTicks((long)avgTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for playbook suggestions.
|
||||
/// </summary>
|
||||
public sealed record PlaybookSuggestionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current situation to get suggestions for.
|
||||
/// </summary>
|
||||
public required SituationContext Situation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of similar records to analyze.
|
||||
/// </summary>
|
||||
public int? TopK { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum similarity threshold.
|
||||
/// </summary>
|
||||
public double? MinSimilarity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of suggestions to return.
|
||||
/// </summary>
|
||||
public int? MaxSuggestions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the earliest date for historical records.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of playbook suggestion generation.
|
||||
/// </summary>
|
||||
public sealed record PlaybookSuggestionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the suggestions ordered by confidence.
|
||||
/// </summary>
|
||||
public ImmutableArray<PlaybookSuggestion> Suggestions { get; init; } =
|
||||
ImmutableArray<PlaybookSuggestion>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of historical records analyzed.
|
||||
/// </summary>
|
||||
public int AnalyzedRecords { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the highest similarity score found.
|
||||
/// </summary>
|
||||
public double? TopSimilarity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any suggestions were found.
|
||||
/// </summary>
|
||||
public bool HasSuggestions => Suggestions.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A playbook suggestion based on historical data.
|
||||
/// </summary>
|
||||
public sealed record PlaybookSuggestion
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the recommended action.
|
||||
/// </summary>
|
||||
public required DecisionAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confidence level (0-1).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable rationale.
|
||||
/// </summary>
|
||||
public required string Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence records supporting this suggestion.
|
||||
/// </summary>
|
||||
public ImmutableArray<PlaybookEvidence> Evidence { get; init; } =
|
||||
ImmutableArray<PlaybookEvidence>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the factors that matched the current situation.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> MatchingFactors { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of similar past decisions.
|
||||
/// </summary>
|
||||
public int SimilarDecisionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the success rate for this action in similar situations.
|
||||
/// </summary>
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the memory ID of the best matching past decision.
|
||||
/// </summary>
|
||||
public string? BestMatchMemoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average resolution time for this action.
|
||||
/// </summary>
|
||||
public TimeSpan? AverageResolutionTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence record for a playbook suggestion.
|
||||
/// </summary>
|
||||
public sealed record PlaybookEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the memory record ID.
|
||||
/// </summary>
|
||||
public required string MemoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the similarity score to current situation.
|
||||
/// </summary>
|
||||
public required double Similarity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action that was taken.
|
||||
/// </summary>
|
||||
public required DecisionAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the outcome of the decision.
|
||||
/// </summary>
|
||||
public required OutcomeStatus Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the decision was made.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE (if any).
|
||||
/// </summary>
|
||||
public string? Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component name (if any).
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// <copyright file="SimilarityVectorGenerator.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
namespace StellaOps.OpsMemory.Similarity;
|
||||
|
||||
/// <summary>
|
||||
/// Generates similarity vectors for finding related security decisions.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-004
|
||||
/// </summary>
|
||||
public sealed class SimilarityVectorGenerator
|
||||
{
|
||||
// Vector dimensions:
|
||||
// [0-9] : CVE category one-hot (10 categories)
|
||||
// [10-14] : Severity one-hot (none, low, medium, high, critical)
|
||||
// [15-18] : Reachability one-hot (unknown, reachable, not-reachable, potential)
|
||||
// [19-23] : EPSS band one-hot (0-0.2, 0.2-0.4, 0.4-0.6, 0.6-0.8, 0.8-1.0)
|
||||
// [24-28] : CVSS band one-hot (0-2, 2-4, 4-6, 6-8, 8-10)
|
||||
// [29] : KEV flag
|
||||
// [30-39] : Component type one-hot (10 types)
|
||||
// [40-49] : Context tag presence (10 common tags)
|
||||
private const int VectorDimension = 50;
|
||||
|
||||
private static readonly ImmutableArray<string> CveCategories = ImmutableArray.Create(
|
||||
"memory", "injection", "auth", "crypto", "dos",
|
||||
"info-disclosure", "privilege-escalation", "xss", "path-traversal", "other");
|
||||
|
||||
private static readonly ImmutableArray<string> ComponentTypes = ImmutableArray.Create(
|
||||
"npm", "maven", "pypi", "nuget", "go", "cargo", "deb", "rpm", "apk", "other");
|
||||
|
||||
private static readonly ImmutableArray<string> CommonContextTags = ImmutableArray.Create(
|
||||
"production", "development", "staging", "external-facing", "internal",
|
||||
"payment", "auth", "data", "api", "frontend");
|
||||
|
||||
/// <summary>
|
||||
/// Generates a similarity vector from a situation context.
|
||||
/// </summary>
|
||||
/// <param name="situation">The situation to vectorize.</param>
|
||||
/// <returns>A normalized similarity vector.</returns>
|
||||
public ImmutableArray<float> Generate(SituationContext situation)
|
||||
{
|
||||
var vector = new float[VectorDimension];
|
||||
|
||||
// CVE category (dimensions 0-9)
|
||||
var category = ClassifyCve(situation.CveId);
|
||||
var categoryIndex = CveCategories.IndexOf(category);
|
||||
if (categoryIndex >= 0 && categoryIndex < 10)
|
||||
{
|
||||
vector[categoryIndex] = 1.0f;
|
||||
}
|
||||
|
||||
// Severity (dimensions 10-14)
|
||||
var severityIndex = GetSeverityIndex(situation.Severity);
|
||||
if (severityIndex >= 0)
|
||||
{
|
||||
vector[10 + severityIndex] = 1.0f;
|
||||
}
|
||||
|
||||
// Reachability (dimensions 15-18)
|
||||
vector[15 + (int)situation.Reachability] = 1.0f;
|
||||
|
||||
// EPSS band (dimensions 19-23)
|
||||
if (situation.EpssScore.HasValue)
|
||||
{
|
||||
var epssBand = GetBandIndex(situation.EpssScore.Value, 0, 1, 5);
|
||||
vector[19 + epssBand] = 1.0f;
|
||||
}
|
||||
|
||||
// CVSS band (dimensions 24-28)
|
||||
if (situation.CvssScore.HasValue)
|
||||
{
|
||||
var cvssBand = GetBandIndex(situation.CvssScore.Value, 0, 10, 5);
|
||||
vector[24 + cvssBand] = 1.0f;
|
||||
}
|
||||
|
||||
// KEV flag (dimension 29)
|
||||
vector[29] = situation.IsKev ? 1.0f : 0.0f;
|
||||
|
||||
// Component type (dimensions 30-39)
|
||||
var componentType = ClassifyComponent(situation.Component);
|
||||
var typeIndex = ComponentTypes.IndexOf(componentType);
|
||||
if (typeIndex >= 0 && typeIndex < 10)
|
||||
{
|
||||
vector[30 + typeIndex] = 1.0f;
|
||||
}
|
||||
|
||||
// Context tags (dimensions 40-49)
|
||||
foreach (var tag in situation.ContextTags)
|
||||
{
|
||||
var tagLower = tag.ToLowerInvariant();
|
||||
var tagIndex = CommonContextTags.IndexOf(tagLower);
|
||||
if (tagIndex >= 0 && tagIndex < 10)
|
||||
{
|
||||
vector[40 + tagIndex] = 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to unit vector
|
||||
return Normalize(vector);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes cosine similarity between two vectors.
|
||||
/// </summary>
|
||||
public static double CosineSimilarity(ImmutableArray<float> a, ImmutableArray<float> b)
|
||||
{
|
||||
if (a.Length != b.Length)
|
||||
{
|
||||
throw new ArgumentException("Vectors must have the same dimension");
|
||||
}
|
||||
|
||||
double dotProduct = 0;
|
||||
double normA = 0;
|
||||
double normB = 0;
|
||||
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
if (normA == 0 || normB == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dotProduct / (Math.Sqrt(normA) * Math.Sqrt(normB));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the factors that contributed to similarity.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> GetMatchingFactors(
|
||||
SituationContext a,
|
||||
SituationContext b)
|
||||
{
|
||||
var factors = new List<string>();
|
||||
|
||||
// Check CVE category match
|
||||
var catA = ClassifyCve(a.CveId);
|
||||
var catB = ClassifyCve(b.CveId);
|
||||
if (catA == catB && catA != "other")
|
||||
{
|
||||
factors.Add($"Same vulnerability category: {catA}");
|
||||
}
|
||||
|
||||
// Check severity match
|
||||
if (a.Severity?.ToLowerInvariant() == b.Severity?.ToLowerInvariant() &&
|
||||
!string.IsNullOrEmpty(a.Severity))
|
||||
{
|
||||
factors.Add($"Same severity: {a.Severity}");
|
||||
}
|
||||
|
||||
// Check reachability match
|
||||
if (a.Reachability == b.Reachability && a.Reachability != ReachabilityStatus.Unknown)
|
||||
{
|
||||
factors.Add($"Same reachability: {a.Reachability}");
|
||||
}
|
||||
|
||||
// Check similar EPSS
|
||||
if (a.EpssScore.HasValue && b.EpssScore.HasValue)
|
||||
{
|
||||
if (Math.Abs(a.EpssScore.Value - b.EpssScore.Value) < 0.2)
|
||||
{
|
||||
factors.Add(string.Format(CultureInfo.InvariantCulture,
|
||||
"Similar EPSS: {0:P0} vs {1:P0}",
|
||||
a.EpssScore.Value, b.EpssScore.Value));
|
||||
}
|
||||
}
|
||||
|
||||
// Check KEV match
|
||||
if (a.IsKev && b.IsKev)
|
||||
{
|
||||
factors.Add("Both are KEV");
|
||||
}
|
||||
|
||||
// Check component type match
|
||||
var typeA = ClassifyComponent(a.Component);
|
||||
var typeB = ClassifyComponent(b.Component);
|
||||
if (typeA == typeB && typeA != "other")
|
||||
{
|
||||
factors.Add($"Same component type: {typeA}");
|
||||
}
|
||||
|
||||
// Check overlapping context tags
|
||||
var commonTags = a.ContextTags.Intersect(b.ContextTags, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (commonTags.Count > 0)
|
||||
{
|
||||
factors.Add($"Shared context: {string.Join(", ", commonTags)}");
|
||||
}
|
||||
|
||||
return factors.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string ClassifyCve(string? cveId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(cveId))
|
||||
{
|
||||
return "other";
|
||||
}
|
||||
|
||||
// Simple heuristic classification based on CVE patterns
|
||||
// In production, this would query a CVE database or use CWE mapping
|
||||
var cveLower = cveId.ToLowerInvariant();
|
||||
|
||||
// These are placeholder classifications - real implementation would use CWE data
|
||||
return "other";
|
||||
}
|
||||
|
||||
private static string ClassifyComponent(string? purl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(purl))
|
||||
{
|
||||
return "other";
|
||||
}
|
||||
|
||||
// Parse PURL type
|
||||
if (purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase))
|
||||
return "npm";
|
||||
if (purl.StartsWith("pkg:maven/", StringComparison.OrdinalIgnoreCase))
|
||||
return "maven";
|
||||
if (purl.StartsWith("pkg:pypi/", StringComparison.OrdinalIgnoreCase))
|
||||
return "pypi";
|
||||
if (purl.StartsWith("pkg:nuget/", StringComparison.OrdinalIgnoreCase))
|
||||
return "nuget";
|
||||
if (purl.StartsWith("pkg:golang/", StringComparison.OrdinalIgnoreCase))
|
||||
return "go";
|
||||
if (purl.StartsWith("pkg:cargo/", StringComparison.OrdinalIgnoreCase))
|
||||
return "cargo";
|
||||
if (purl.StartsWith("pkg:deb/", StringComparison.OrdinalIgnoreCase))
|
||||
return "deb";
|
||||
if (purl.StartsWith("pkg:rpm/", StringComparison.OrdinalIgnoreCase))
|
||||
return "rpm";
|
||||
if (purl.StartsWith("pkg:apk/", StringComparison.OrdinalIgnoreCase))
|
||||
return "apk";
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
private static int GetSeverityIndex(string? severity)
|
||||
{
|
||||
if (string.IsNullOrEmpty(severity))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return severity.ToLowerInvariant() switch
|
||||
{
|
||||
"none" => 0,
|
||||
"low" => 1,
|
||||
"medium" => 2,
|
||||
"high" => 3,
|
||||
"critical" => 4,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetBandIndex(double value, double min, double max, int bands)
|
||||
{
|
||||
var normalized = (value - min) / (max - min);
|
||||
var band = (int)(normalized * bands);
|
||||
return Math.Clamp(band, 0, bands - 1);
|
||||
}
|
||||
|
||||
private static ImmutableArray<float> Normalize(float[] vector)
|
||||
{
|
||||
double norm = 0;
|
||||
foreach (var v in vector)
|
||||
{
|
||||
norm += v * v;
|
||||
}
|
||||
|
||||
if (norm == 0)
|
||||
{
|
||||
return vector.ToImmutableArray();
|
||||
}
|
||||
|
||||
var normSqrt = (float)Math.Sqrt(norm);
|
||||
for (int i = 0; i < vector.Length; i++)
|
||||
{
|
||||
vector[i] /= normSqrt;
|
||||
}
|
||||
|
||||
return vector.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
16
src/OpsMemory/StellaOps.OpsMemory/StellaOps.OpsMemory.csproj
Normal file
16
src/OpsMemory/StellaOps.OpsMemory/StellaOps.OpsMemory.csproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>OpsMemory - Decision ledger for security playbook learning</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
305
src/OpsMemory/StellaOps.OpsMemory/Storage/IOpsMemoryStore.cs
Normal file
305
src/OpsMemory/StellaOps.OpsMemory/Storage/IOpsMemoryStore.cs
Normal file
@@ -0,0 +1,305 @@
|
||||
// <copyright file="IOpsMemoryStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
namespace StellaOps.OpsMemory.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for OpsMemory storage operations.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-002
|
||||
/// </summary>
|
||||
public interface IOpsMemoryStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a new security decision.
|
||||
/// </summary>
|
||||
/// <param name="record">The decision record to store.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created record with assigned ID.</returns>
|
||||
Task<OpsMemoryRecord> RecordDecisionAsync(
|
||||
OpsMemoryRecord record,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records the outcome of a previously made decision.
|
||||
/// </summary>
|
||||
/// <param name="memoryId">The memory record ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="outcome">The outcome details.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated record.</returns>
|
||||
Task<OpsMemoryRecord?> RecordOutcomeAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
OutcomeRecord outcome,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds decisions similar to the given situation.
|
||||
/// </summary>
|
||||
/// <param name="query">The similarity query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Similar records ordered by similarity score.</returns>
|
||||
Task<IReadOnlyList<SimilarityMatch>> FindSimilarAsync(
|
||||
SimilarityQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific memory record by ID.
|
||||
/// </summary>
|
||||
/// <param name="memoryId">The memory record ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The record or null if not found.</returns>
|
||||
Task<OpsMemoryRecord?> GetByIdAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries memory records with filters.
|
||||
/// </summary>
|
||||
/// <param name="query">The query parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching records with pagination info.</returns>
|
||||
Task<PagedResult<OpsMemoryRecord>> QueryAsync(
|
||||
OpsMemoryQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics for playbook analysis.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="since">Optional start date for statistics.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Aggregated statistics.</returns>
|
||||
Task<OpsMemoryStats> GetStatsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? since = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for finding similar situations.
|
||||
/// </summary>
|
||||
public sealed record SimilarityQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID for isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the similarity vector to match against.
|
||||
/// </summary>
|
||||
public ImmutableArray<float> SimilarityVector { get; init; } = ImmutableArray<float>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the situation to match (used if vector not provided).
|
||||
/// </summary>
|
||||
public SituationContext? Situation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of results (default: 10).
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum similarity threshold (0-1, default: 0.7).
|
||||
/// </summary>
|
||||
public double MinSimilarity { get; init; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to include only records with outcomes.
|
||||
/// </summary>
|
||||
public bool OnlyWithOutcome { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to include only successful outcomes.
|
||||
/// </summary>
|
||||
public bool OnlySuccessful { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the earliest record date to consider.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A similarity match result.
|
||||
/// </summary>
|
||||
public sealed record SimilarityMatch
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matching memory record.
|
||||
/// </summary>
|
||||
public required OpsMemoryRecord Record { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the similarity score (0-1).
|
||||
/// </summary>
|
||||
public required double SimilarityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the matching factors that contributed to similarity.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> MatchingFactors { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for listing memory records.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE filter (exact match).
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component PURL filter (prefix match).
|
||||
/// </summary>
|
||||
public string? ComponentPrefix { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action filter.
|
||||
/// </summary>
|
||||
public DecisionAction? Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the outcome status filter.
|
||||
/// </summary>
|
||||
public OutcomeStatus? OutcomeStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the start date filter.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the end date filter.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the context tag filter (any match).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ContextTags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the page size (default: 20).
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cursor for pagination.
|
||||
/// </summary>
|
||||
public string? Cursor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sort field.
|
||||
/// </summary>
|
||||
public OpsMemorySortField SortBy { get; init; } = OpsMemorySortField.RecordedAt;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to sort descending.
|
||||
/// </summary>
|
||||
public bool Descending { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort fields for memory queries.
|
||||
/// </summary>
|
||||
public enum OpsMemorySortField
|
||||
{
|
||||
/// <summary>Sort by record creation time.</summary>
|
||||
RecordedAt,
|
||||
|
||||
/// <summary>Sort by decision time.</summary>
|
||||
DecidedAt,
|
||||
|
||||
/// <summary>Sort by CVSS score.</summary>
|
||||
CvssScore,
|
||||
|
||||
/// <summary>Sort by EPSS score.</summary>
|
||||
EpssScore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated result.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The item type.</typeparam>
|
||||
public sealed record PagedResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the items in this page.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<T> Items { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count (if available).
|
||||
/// </summary>
|
||||
public int? TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cursor for the next page.
|
||||
/// </summary>
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are more results.
|
||||
/// </summary>
|
||||
public bool HasMore => NextCursor is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated statistics for OpsMemory.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the total number of decisions.
|
||||
/// </summary>
|
||||
public int TotalDecisions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of decisions with outcomes recorded.
|
||||
/// </summary>
|
||||
public int DecisionsWithOutcomes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the success rate (0-1).
|
||||
/// </summary>
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the breakdown by action.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<DecisionAction, int> ByAction { get; init; } =
|
||||
ImmutableDictionary<DecisionAction, int>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the breakdown by outcome status.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<OutcomeStatus, int> ByOutcome { get; init; } =
|
||||
ImmutableDictionary<OutcomeStatus, int>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average resolution time.
|
||||
/// </summary>
|
||||
public TimeSpan? AverageResolutionTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most common context tags.
|
||||
/// </summary>
|
||||
public ImmutableArray<(string Tag, int Count)> TopContextTags { get; init; } =
|
||||
ImmutableArray<(string, int)>.Empty;
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
// <copyright file="PostgresOpsMemoryStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
namespace StellaOps.OpsMemory.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of IOpsMemoryStore.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-003
|
||||
/// </summary>
|
||||
public sealed class PostgresOpsMemoryStore : IOpsMemoryStore, IAsyncDisposable
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresOpsMemoryStore> _logger;
|
||||
private readonly OpsMemoryStoreOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgresOpsMemoryStore"/> class.
|
||||
/// </summary>
|
||||
public PostgresOpsMemoryStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresOpsMemoryStore> logger,
|
||||
OpsMemoryStoreOptions? options = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_options = options ?? new OpsMemoryStoreOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord> RecordDecisionAsync(
|
||||
OpsMemoryRecord record,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO opsmemory.decisions (
|
||||
memory_id, tenant_id, recorded_at,
|
||||
cve_id, component, component_name, component_version,
|
||||
severity, cvss_score, reachability, epss_score, is_kev,
|
||||
context_tags, additional_context,
|
||||
action, rationale, decided_by, decided_at,
|
||||
policy_reference, vex_statement_id, mitigation,
|
||||
similarity_vector
|
||||
) VALUES (
|
||||
@memoryId, @tenantId, @recordedAt,
|
||||
@cveId, @component, @componentName, @componentVersion,
|
||||
@severity, @cvssScore, @reachability, @epssScore, @isKev,
|
||||
@contextTags, @additionalContext,
|
||||
@action, @rationale, @decidedBy, @decidedAt,
|
||||
@policyReference, @vexStatementId, @mitigation,
|
||||
@similarityVector
|
||||
)
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
AddRecordParameters(cmd, record);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded decision {MemoryId} for tenant {TenantId}: {Action}",
|
||||
record.MemoryId, record.TenantId, record.Decision.Action);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord?> RecordOutcomeAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
OutcomeRecord outcome,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE opsmemory.decisions
|
||||
SET outcome_status = @status,
|
||||
outcome_resolution_time = @resolutionTime,
|
||||
outcome_actual_impact = @actualImpact,
|
||||
outcome_lessons_learned = @lessonsLearned,
|
||||
outcome_recorded_by = @recordedBy,
|
||||
outcome_recorded_at = @recordedAt,
|
||||
outcome_would_repeat = @wouldRepeat,
|
||||
outcome_alternative_actions = @alternativeActions
|
||||
WHERE memory_id = @memoryId AND tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("memoryId", memoryId);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("status", outcome.Status.ToString());
|
||||
cmd.Parameters.AddWithValue("resolutionTime",
|
||||
(object?)outcome.ResolutionTime?.TotalSeconds ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("actualImpact", (object?)outcome.ActualImpact ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("lessonsLearned", (object?)outcome.LessonsLearned ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("recordedBy", outcome.RecordedBy);
|
||||
cmd.Parameters.AddWithValue("recordedAt", outcome.RecordedAt);
|
||||
cmd.Parameters.AddWithValue("wouldRepeat", (object?)outcome.WouldRepeat ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("alternativeActions", (object?)outcome.AlternativeActions ?? DBNull.Value);
|
||||
|
||||
var affected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
_logger.LogWarning("Decision {MemoryId} not found for tenant {TenantId}", memoryId, tenantId);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded outcome for decision {MemoryId}: {Status}",
|
||||
memoryId, outcome.Status);
|
||||
|
||||
return await GetByIdAsync(memoryId, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord?> GetByIdAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM opsmemory.decisions
|
||||
WHERE memory_id = @memoryId AND tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("memoryId", memoryId);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapRecord(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<OpsMemoryRecord>> QueryAsync(
|
||||
OpsMemoryQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var whereClauses = new List<string> { "tenant_id = @tenantId" };
|
||||
var parameters = new List<NpgsqlParameter> { new("tenantId", query.TenantId) };
|
||||
|
||||
if (!string.IsNullOrEmpty(query.CveId))
|
||||
{
|
||||
whereClauses.Add("cve_id = @cveId");
|
||||
parameters.Add(new NpgsqlParameter("cveId", query.CveId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ComponentPrefix))
|
||||
{
|
||||
whereClauses.Add("component LIKE @componentPrefix");
|
||||
parameters.Add(new NpgsqlParameter("componentPrefix", query.ComponentPrefix + "%"));
|
||||
}
|
||||
|
||||
if (query.Action.HasValue)
|
||||
{
|
||||
whereClauses.Add("action = @action");
|
||||
parameters.Add(new NpgsqlParameter("action", query.Action.Value.ToString()));
|
||||
}
|
||||
|
||||
if (query.OutcomeStatus.HasValue)
|
||||
{
|
||||
whereClauses.Add("outcome_status = @outcomeStatus");
|
||||
parameters.Add(new NpgsqlParameter("outcomeStatus", query.OutcomeStatus.Value.ToString()));
|
||||
}
|
||||
|
||||
if (query.Since.HasValue)
|
||||
{
|
||||
whereClauses.Add("recorded_at >= @since");
|
||||
parameters.Add(new NpgsqlParameter("since", query.Since.Value));
|
||||
}
|
||||
|
||||
if (query.Until.HasValue)
|
||||
{
|
||||
whereClauses.Add("recorded_at <= @until");
|
||||
parameters.Add(new NpgsqlParameter("until", query.Until.Value));
|
||||
}
|
||||
|
||||
if (!query.ContextTags.IsDefaultOrEmpty)
|
||||
{
|
||||
whereClauses.Add("context_tags && @contextTags");
|
||||
parameters.Add(new NpgsqlParameter("contextTags", query.ContextTags.ToArray()));
|
||||
}
|
||||
|
||||
var whereClause = string.Join(" AND ", whereClauses);
|
||||
var sortColumn = query.SortBy switch
|
||||
{
|
||||
OpsMemorySortField.DecidedAt => "decided_at",
|
||||
OpsMemorySortField.CvssScore => "cvss_score",
|
||||
OpsMemorySortField.EpssScore => "epss_score",
|
||||
_ => "recorded_at"
|
||||
};
|
||||
var sortDir = query.Descending ? "DESC" : "ASC";
|
||||
|
||||
// Count query
|
||||
var countSql = $"SELECT COUNT(*) FROM opsmemory.decisions WHERE {whereClause}";
|
||||
|
||||
await using var countCmd = _dataSource.CreateCommand(countSql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
countCmd.Parameters.Add(CloneParameter(p));
|
||||
}
|
||||
var totalCount = Convert.ToInt32(
|
||||
await countCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false),
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
// Select query with pagination
|
||||
var selectSql = $"""
|
||||
SELECT * FROM opsmemory.decisions
|
||||
WHERE {whereClause}
|
||||
ORDER BY {sortColumn} {sortDir}
|
||||
LIMIT {query.PageSize + 1}
|
||||
""";
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Cursor))
|
||||
{
|
||||
selectSql = $"""
|
||||
SELECT * FROM opsmemory.decisions
|
||||
WHERE {whereClause} AND recorded_at < @cursor
|
||||
ORDER BY {sortColumn} {sortDir}
|
||||
LIMIT {query.PageSize + 1}
|
||||
""";
|
||||
parameters.Add(new NpgsqlParameter("cursor", DateTimeOffset.Parse(query.Cursor, CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
await using var selectCmd = _dataSource.CreateCommand(selectSql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
selectCmd.Parameters.Add(CloneParameter(p));
|
||||
}
|
||||
|
||||
var records = new List<OpsMemoryRecord>();
|
||||
await using var reader = await selectCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
records.Add(MapRecord(reader));
|
||||
}
|
||||
|
||||
string? nextCursor = null;
|
||||
if (records.Count > query.PageSize)
|
||||
{
|
||||
records.RemoveAt(records.Count - 1);
|
||||
nextCursor = records[^1].RecordedAt.ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return new PagedResult<OpsMemoryRecord>
|
||||
{
|
||||
Items = records,
|
||||
TotalCount = totalCount,
|
||||
NextCursor = nextCursor
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SimilarityMatch>> FindSimilarAsync(
|
||||
SimilarityQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Without pgvector, we fetch candidates and compute similarity in-memory
|
||||
var whereClauses = new List<string> { "tenant_id = @tenantId" };
|
||||
var parameters = new List<NpgsqlParameter> { new("tenantId", query.TenantId) };
|
||||
|
||||
if (query.OnlyWithOutcome)
|
||||
{
|
||||
whereClauses.Add("outcome_status IS NOT NULL");
|
||||
}
|
||||
|
||||
if (query.OnlySuccessful)
|
||||
{
|
||||
whereClauses.Add("outcome_status = 'Success'");
|
||||
}
|
||||
|
||||
if (query.Since.HasValue)
|
||||
{
|
||||
whereClauses.Add("recorded_at >= @since");
|
||||
parameters.Add(new NpgsqlParameter("since", query.Since.Value));
|
||||
}
|
||||
|
||||
var whereClause = string.Join(" AND ", whereClauses);
|
||||
|
||||
var sql = $"""
|
||||
SELECT * FROM opsmemory.decisions
|
||||
WHERE {whereClause}
|
||||
AND similarity_vector IS NOT NULL
|
||||
AND array_length(similarity_vector, 1) > 0
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT 100
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
cmd.Parameters.Add(p);
|
||||
}
|
||||
|
||||
var candidates = new List<(OpsMemoryRecord Record, float[] Vector)>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var record = MapRecord(reader);
|
||||
var vectorOrdinal = reader.GetOrdinal("similarity_vector");
|
||||
if (!reader.IsDBNull(vectorOrdinal))
|
||||
{
|
||||
var vector = (float[])reader.GetValue(vectorOrdinal);
|
||||
if (vector.Length > 0)
|
||||
{
|
||||
candidates.Add((record, vector));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute cosine similarity
|
||||
var queryVector = query.SimilarityVector.ToArray();
|
||||
var matches = candidates
|
||||
.Select(c => new
|
||||
{
|
||||
c.Record,
|
||||
Similarity = CosineSimilarity(queryVector, c.Vector)
|
||||
})
|
||||
.Where(m => m.Similarity >= query.MinSimilarity)
|
||||
.OrderByDescending(m => m.Similarity)
|
||||
.Take(query.Limit)
|
||||
.Select(m => new SimilarityMatch
|
||||
{
|
||||
Record = m.Record,
|
||||
SimilarityScore = m.Similarity,
|
||||
MatchingFactors = DetermineMatchingFactors(m.Record, query.Situation)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Found {Count} similar records for tenant {TenantId} (checked {Candidates} candidates)",
|
||||
matches.Count, query.TenantId, candidates.Count);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryStats> GetStatsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? since = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var whereClause = "tenant_id = @tenantId";
|
||||
var parameters = new List<NpgsqlParameter> { new("tenantId", tenantId) };
|
||||
|
||||
if (since.HasValue)
|
||||
{
|
||||
whereClause += " AND recorded_at >= @since";
|
||||
parameters.Add(new NpgsqlParameter("since", since.Value));
|
||||
}
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
COUNT(*) as total_decisions,
|
||||
COUNT(outcome_status) as decisions_with_outcomes,
|
||||
COUNT(*) FILTER (WHERE outcome_status = 'Success') as successful_outcomes,
|
||||
AVG(outcome_resolution_time) FILTER (WHERE outcome_resolution_time IS NOT NULL) as avg_resolution_time
|
||||
FROM opsmemory.decisions
|
||||
WHERE {whereClause}
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
cmd.Parameters.Add(p);
|
||||
}
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var totalDecisions = reader.GetInt32(0);
|
||||
var decisionsWithOutcomes = reader.GetInt32(1);
|
||||
var successfulOutcomes = reader.GetInt32(2);
|
||||
var avgResolutionSeconds = reader.IsDBNull(3) ? (double?)null : reader.GetDouble(3);
|
||||
|
||||
// Get breakdown by action
|
||||
var actionSql = $"""
|
||||
SELECT action, COUNT(*) as count
|
||||
FROM opsmemory.decisions
|
||||
WHERE {whereClause}
|
||||
GROUP BY action
|
||||
""";
|
||||
|
||||
await using var actionCmd = _dataSource.CreateCommand(actionSql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
actionCmd.Parameters.Add(CloneParameter(p));
|
||||
}
|
||||
|
||||
var byAction = ImmutableDictionary.CreateBuilder<DecisionAction, int>();
|
||||
await using var actionReader = await actionCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await actionReader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (Enum.TryParse<DecisionAction>(actionReader.GetString(0), out var action))
|
||||
{
|
||||
byAction[action] = actionReader.GetInt32(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Get breakdown by outcome
|
||||
var outcomeSql = $"""
|
||||
SELECT outcome_status, COUNT(*) as count
|
||||
FROM opsmemory.decisions
|
||||
WHERE {whereClause} AND outcome_status IS NOT NULL
|
||||
GROUP BY outcome_status
|
||||
""";
|
||||
|
||||
await using var outcomeCmd = _dataSource.CreateCommand(outcomeSql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
outcomeCmd.Parameters.Add(CloneParameter(p));
|
||||
}
|
||||
|
||||
var byOutcome = ImmutableDictionary.CreateBuilder<OutcomeStatus, int>();
|
||||
await using var outcomeReader = await outcomeCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await outcomeReader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (Enum.TryParse<OutcomeStatus>(outcomeReader.GetString(0), out var status))
|
||||
{
|
||||
byOutcome[status] = outcomeReader.GetInt32(1);
|
||||
}
|
||||
}
|
||||
|
||||
return new OpsMemoryStats
|
||||
{
|
||||
TotalDecisions = totalDecisions,
|
||||
DecisionsWithOutcomes = decisionsWithOutcomes,
|
||||
SuccessRate = decisionsWithOutcomes > 0
|
||||
? (double)successfulOutcomes / decisionsWithOutcomes
|
||||
: 0,
|
||||
ByAction = byAction.ToImmutable(),
|
||||
ByOutcome = byOutcome.ToImmutable(),
|
||||
AverageResolutionTime = avgResolutionSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(avgResolutionSeconds.Value)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// DataSource is managed externally
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void AddRecordParameters(NpgsqlCommand cmd, OpsMemoryRecord record)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("memoryId", record.MemoryId);
|
||||
cmd.Parameters.AddWithValue("tenantId", record.TenantId);
|
||||
cmd.Parameters.AddWithValue("recordedAt", record.RecordedAt);
|
||||
|
||||
// Situation
|
||||
cmd.Parameters.AddWithValue("cveId", (object?)record.Situation.CveId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("component", (object?)record.Situation.Component ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("componentName", (object?)record.Situation.ComponentName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("componentVersion", (object?)record.Situation.ComponentVersion ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("severity", (object?)record.Situation.Severity ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("cvssScore", (object?)record.Situation.CvssScore ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("reachability", record.Situation.Reachability.ToString());
|
||||
cmd.Parameters.AddWithValue("epssScore", (object?)record.Situation.EpssScore ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("isKev", record.Situation.IsKev);
|
||||
cmd.Parameters.AddWithValue("contextTags", record.Situation.ContextTags.IsDefaultOrEmpty
|
||||
? Array.Empty<string>()
|
||||
: record.Situation.ContextTags.ToArray());
|
||||
|
||||
var additionalContext = record.Situation.AdditionalContext.IsEmpty
|
||||
? null
|
||||
: JsonSerializer.Serialize(record.Situation.AdditionalContext, JsonOptions);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("additionalContext", NpgsqlDbType.Jsonb)
|
||||
{
|
||||
Value = (object?)additionalContext ?? DBNull.Value
|
||||
});
|
||||
|
||||
// Decision
|
||||
cmd.Parameters.AddWithValue("action", record.Decision.Action.ToString());
|
||||
cmd.Parameters.AddWithValue("rationale", record.Decision.Rationale);
|
||||
cmd.Parameters.AddWithValue("decidedBy", record.Decision.DecidedBy);
|
||||
cmd.Parameters.AddWithValue("decidedAt", record.Decision.DecidedAt);
|
||||
cmd.Parameters.AddWithValue("policyReference", (object?)record.Decision.PolicyReference ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("vexStatementId", (object?)record.Decision.VexStatementId ?? DBNull.Value);
|
||||
|
||||
var mitigation = record.Decision.Mitigation is null
|
||||
? null
|
||||
: JsonSerializer.Serialize(record.Decision.Mitigation, JsonOptions);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("mitigation", NpgsqlDbType.Jsonb)
|
||||
{
|
||||
Value = (object?)mitigation ?? DBNull.Value
|
||||
});
|
||||
|
||||
// Similarity vector
|
||||
cmd.Parameters.AddWithValue("similarityVector", record.SimilarityVector.IsDefaultOrEmpty
|
||||
? Array.Empty<float>()
|
||||
: record.SimilarityVector.ToArray());
|
||||
}
|
||||
|
||||
private static OpsMemoryRecord MapRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
OutcomeRecord? outcome = null;
|
||||
var outcomeStatusOrdinal = reader.GetOrdinal("outcome_status");
|
||||
if (!reader.IsDBNull(outcomeStatusOrdinal))
|
||||
{
|
||||
var resolutionTimeOrdinal = reader.GetOrdinal("outcome_resolution_time");
|
||||
TimeSpan? resolutionTime = reader.IsDBNull(resolutionTimeOrdinal)
|
||||
? null
|
||||
: TimeSpan.FromSeconds(reader.GetDouble(resolutionTimeOrdinal));
|
||||
|
||||
outcome = new OutcomeRecord
|
||||
{
|
||||
Status = Enum.Parse<OutcomeStatus>(reader.GetString(outcomeStatusOrdinal)),
|
||||
ResolutionTime = resolutionTime,
|
||||
ActualImpact = GetNullableString(reader, "outcome_actual_impact"),
|
||||
LessonsLearned = GetNullableString(reader, "outcome_lessons_learned"),
|
||||
RecordedBy = reader.GetString(reader.GetOrdinal("outcome_recorded_by")),
|
||||
RecordedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("outcome_recorded_at")),
|
||||
WouldRepeat = GetNullableBool(reader, "outcome_would_repeat"),
|
||||
AlternativeActions = GetNullableString(reader, "outcome_alternative_actions")
|
||||
};
|
||||
}
|
||||
|
||||
var contextTagsOrdinal = reader.GetOrdinal("context_tags");
|
||||
var contextTags = reader.IsDBNull(contextTagsOrdinal)
|
||||
? ImmutableArray<string>.Empty
|
||||
: ((string[])reader.GetValue(contextTagsOrdinal)).ToImmutableArray();
|
||||
|
||||
var additionalContextOrdinal = reader.GetOrdinal("additional_context");
|
||||
var additionalContext = reader.IsDBNull(additionalContextOrdinal)
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(
|
||||
reader.GetString(additionalContextOrdinal), JsonOptions)
|
||||
?? ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var mitigationOrdinal = reader.GetOrdinal("mitigation");
|
||||
MitigationDetails? mitigation = reader.IsDBNull(mitigationOrdinal)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<MitigationDetails>(reader.GetString(mitigationOrdinal), JsonOptions);
|
||||
|
||||
var vectorOrdinal = reader.GetOrdinal("similarity_vector");
|
||||
var similarityVector = reader.IsDBNull(vectorOrdinal)
|
||||
? ImmutableArray<float>.Empty
|
||||
: ((float[])reader.GetValue(vectorOrdinal)).ToImmutableArray();
|
||||
|
||||
return new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = reader.GetString(reader.GetOrdinal("memory_id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
RecordedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("recorded_at")),
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = GetNullableString(reader, "cve_id"),
|
||||
Component = GetNullableString(reader, "component"),
|
||||
ComponentName = GetNullableString(reader, "component_name"),
|
||||
ComponentVersion = GetNullableString(reader, "component_version"),
|
||||
Severity = GetNullableString(reader, "severity"),
|
||||
CvssScore = GetNullableDouble(reader, "cvss_score"),
|
||||
Reachability = Enum.Parse<ReachabilityStatus>(
|
||||
reader.GetString(reader.GetOrdinal("reachability"))),
|
||||
EpssScore = GetNullableDouble(reader, "epss_score"),
|
||||
IsKev = reader.GetBoolean(reader.GetOrdinal("is_kev")),
|
||||
ContextTags = contextTags,
|
||||
AdditionalContext = additionalContext
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = Enum.Parse<DecisionAction>(reader.GetString(reader.GetOrdinal("action"))),
|
||||
Rationale = reader.GetString(reader.GetOrdinal("rationale")),
|
||||
DecidedBy = reader.GetString(reader.GetOrdinal("decided_by")),
|
||||
DecidedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("decided_at")),
|
||||
PolicyReference = GetNullableString(reader, "policy_reference"),
|
||||
VexStatementId = GetNullableString(reader, "vex_statement_id"),
|
||||
Mitigation = mitigation
|
||||
},
|
||||
Outcome = outcome,
|
||||
SimilarityVector = similarityVector
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetNullableString(NpgsqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||
}
|
||||
|
||||
private static double? GetNullableDouble(NpgsqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetDouble(ordinal);
|
||||
}
|
||||
|
||||
private static bool? GetNullableBool(NpgsqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetBoolean(ordinal);
|
||||
}
|
||||
|
||||
private static NpgsqlParameter CloneParameter(NpgsqlParameter p) =>
|
||||
new(p.ParameterName, p.Value);
|
||||
|
||||
private static double CosineSimilarity(float[] a, float[] b)
|
||||
{
|
||||
if (a.Length != b.Length || a.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
double dotProduct = 0;
|
||||
double normA = 0;
|
||||
double normB = 0;
|
||||
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
if (normA == 0 || normB == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dotProduct / (Math.Sqrt(normA) * Math.Sqrt(normB));
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> DetermineMatchingFactors(OpsMemoryRecord record, SituationContext? query)
|
||||
{
|
||||
if (query is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var factors = new List<string>();
|
||||
|
||||
if (query.CveId == record.Situation.CveId)
|
||||
{
|
||||
factors.Add("same_cve");
|
||||
}
|
||||
|
||||
if (query.Severity == record.Situation.Severity)
|
||||
{
|
||||
factors.Add("same_severity");
|
||||
}
|
||||
|
||||
if (query.Reachability == record.Situation.Reachability)
|
||||
{
|
||||
factors.Add("same_reachability");
|
||||
}
|
||||
|
||||
if (query.IsKev == record.Situation.IsKev)
|
||||
{
|
||||
factors.Add("same_kev_status");
|
||||
}
|
||||
|
||||
if (!query.ContextTags.IsDefaultOrEmpty && !record.Situation.ContextTags.IsDefaultOrEmpty)
|
||||
{
|
||||
var overlap = query.ContextTags.Intersect(record.Situation.ContextTags).ToList();
|
||||
if (overlap.Count > 0)
|
||||
{
|
||||
factors.Add($"shared_tags:{string.Join(",", overlap)}");
|
||||
}
|
||||
}
|
||||
|
||||
return factors.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for OpsMemory store.
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the default page size.
|
||||
/// </summary>
|
||||
public int DefaultPageSize { get; set; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// <copyright file="OutcomeTrackingService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tracking;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks outcomes of decisions and links them back to OpsMemory.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-008
|
||||
/// </summary>
|
||||
public sealed class OutcomeTrackingService : IOutcomeTrackingService
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<OutcomeTrackingService> _logger;
|
||||
private readonly OutcomeTrackingOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OutcomeTrackingService"/> class.
|
||||
/// </summary>
|
||||
public OutcomeTrackingService(
|
||||
IOpsMemoryStore store,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<OutcomeTrackingService> logger,
|
||||
OutcomeTrackingOptions? options = null)
|
||||
{
|
||||
_store = store;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
_options = options ?? new OutcomeTrackingOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects if a resolution event corresponds to a tracked decision.
|
||||
/// </summary>
|
||||
public async Task<OutcomePrompt?> DetectResolutionAsync(
|
||||
ResolutionEvent resolutionEvent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Query for decisions matching the CVE and component
|
||||
var query = new OpsMemoryQuery
|
||||
{
|
||||
TenantId = resolutionEvent.TenantId,
|
||||
CveId = resolutionEvent.CveId,
|
||||
ComponentPrefix = resolutionEvent.ComponentPurl,
|
||||
PageSize = 10
|
||||
};
|
||||
|
||||
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Find decisions without outcomes
|
||||
var pendingDecisions = result.Items
|
||||
.Where(r => !r.HasOutcome)
|
||||
.OrderByDescending(r => r.RecordedAt)
|
||||
.ToList();
|
||||
|
||||
if (pendingDecisions.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No pending decisions found for CVE {CveId} on {Component}",
|
||||
resolutionEvent.CveId,
|
||||
resolutionEvent.ComponentPurl);
|
||||
return null;
|
||||
}
|
||||
|
||||
var decision = pendingDecisions[0];
|
||||
var elapsed = resolutionEvent.ResolvedAt - decision.Decision.DecidedAt;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Resolution detected for decision {MemoryId}: {ResolutionType} after {Elapsed}",
|
||||
decision.MemoryId,
|
||||
resolutionEvent.ResolutionType,
|
||||
elapsed);
|
||||
|
||||
return new OutcomePrompt
|
||||
{
|
||||
MemoryId = decision.MemoryId,
|
||||
TenantId = decision.TenantId,
|
||||
CveId = resolutionEvent.CveId,
|
||||
Component = resolutionEvent.ComponentPurl,
|
||||
DecisionAction = decision.Decision.Action.ToString(),
|
||||
DecidedAt = decision.Decision.DecidedAt,
|
||||
SuggestedOutcome = MapResolutionToOutcome(resolutionEvent.ResolutionType, decision.Decision.Action),
|
||||
ResolutionDetails = resolutionEvent.Details,
|
||||
ElapsedSinceDecision = elapsed
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an outcome for a decision based on user classification.
|
||||
/// </summary>
|
||||
public async Task<RecordedOutcome> RecordOutcomeAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
OutcomeStatus status,
|
||||
string? impact = null,
|
||||
string? lessons = null,
|
||||
string recordedBy = "system",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var outcome = new OutcomeRecord
|
||||
{
|
||||
Status = status,
|
||||
ActualImpact = impact,
|
||||
LessonsLearned = lessons,
|
||||
RecordedBy = recordedBy,
|
||||
RecordedAt = now
|
||||
};
|
||||
|
||||
var updated = await _store.RecordOutcomeAsync(
|
||||
memoryId,
|
||||
tenantId,
|
||||
outcome,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Outcome recorded for decision {MemoryId}: {Status}",
|
||||
memoryId,
|
||||
status);
|
||||
|
||||
return new RecordedOutcome
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = tenantId,
|
||||
Status = status,
|
||||
RecordedAt = now,
|
||||
Success = updated is not null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets pending outcome prompts for a tenant.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<OutcomePrompt>> GetPendingPromptsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cutoff = _timeProvider.GetUtcNow().AddDays(-_options.OutcomeWindowDays);
|
||||
var minAge = _timeProvider.GetUtcNow().AddHours(-_options.MinHoursBeforePrompt);
|
||||
|
||||
var query = new OpsMemoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Since = cutoff,
|
||||
Until = minAge,
|
||||
OutcomeStatus = null, // Looking for records without outcomes
|
||||
PageSize = _options.MaxPendingPrompts
|
||||
};
|
||||
|
||||
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter to only those without outcomes
|
||||
var pendingDecisions = result.Items
|
||||
.Where(r => !r.HasOutcome)
|
||||
.ToList();
|
||||
|
||||
var prompts = pendingDecisions.Select(d => new OutcomePrompt
|
||||
{
|
||||
MemoryId = d.MemoryId,
|
||||
TenantId = d.TenantId,
|
||||
CveId = d.Situation.CveId,
|
||||
Component = d.Situation.Component,
|
||||
DecisionAction = d.Decision.Action.ToString(),
|
||||
DecidedAt = d.Decision.DecidedAt,
|
||||
ElapsedSinceDecision = _timeProvider.GetUtcNow() - d.Decision.DecidedAt
|
||||
}).ToList();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Found {Count} pending prompts for tenant {TenantId}",
|
||||
prompts.Count,
|
||||
tenantId);
|
||||
|
||||
return prompts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates success metrics for a tenant over a time period.
|
||||
/// </summary>
|
||||
public async Task<OutcomeMetrics> CalculateMetricsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = new OpsMemoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Since = from,
|
||||
Until = to,
|
||||
PageSize = 1000 // Get all for metrics calculation
|
||||
};
|
||||
|
||||
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
var records = result.Items;
|
||||
|
||||
var totalDecisions = records.Count;
|
||||
var withOutcomes = records.Where(r => r.HasOutcome).ToList();
|
||||
var successful = withOutcomes.Count(r => r.WasSuccessful);
|
||||
|
||||
var resolutionTimes = withOutcomes
|
||||
.Where(r => r.Outcome?.ResolutionTime.HasValue == true)
|
||||
.Select(r => r.Outcome!.ResolutionTime!.Value)
|
||||
.ToList();
|
||||
|
||||
var avgResolutionTime = resolutionTimes.Count > 0
|
||||
? TimeSpan.FromTicks((long)resolutionTimes.Average(t => t.Ticks))
|
||||
: TimeSpan.Zero;
|
||||
|
||||
var byAction = records
|
||||
.GroupBy(r => r.Decision.Action)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var byOutcome = withOutcomes
|
||||
.Where(r => r.Outcome is not null)
|
||||
.GroupBy(r => r.Outcome!.Status)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return new OutcomeMetrics
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PeriodStart = from,
|
||||
PeriodEnd = to,
|
||||
TotalDecisions = totalDecisions,
|
||||
DecisionsWithOutcome = withOutcomes.Count,
|
||||
SuccessfulOutcomes = successful,
|
||||
SuccessRate = withOutcomes.Count > 0 ? (double)successful / withOutcomes.Count : 0,
|
||||
AverageResolutionTime = avgResolutionTime,
|
||||
ByAction = byAction,
|
||||
ByOutcome = byOutcome
|
||||
};
|
||||
}
|
||||
|
||||
private static OutcomeStatus MapResolutionToOutcome(string resolutionType, DecisionAction action)
|
||||
{
|
||||
return resolutionType.ToUpperInvariant() switch
|
||||
{
|
||||
"UPGRADED" => OutcomeStatus.Success,
|
||||
"PATCHED" => OutcomeStatus.Success,
|
||||
"REMOVED" => OutcomeStatus.Success,
|
||||
"MITIGATED" => OutcomeStatus.PartialSuccess,
|
||||
"FALSE_POSITIVE" => OutcomeStatus.Success,
|
||||
"WONT_FIX" => action == DecisionAction.Accept ? OutcomeStatus.Success : OutcomeStatus.PartialSuccess,
|
||||
"EXPLOITED" => OutcomeStatus.NegativeOutcome,
|
||||
"INCIDENT" => OutcomeStatus.NegativeOutcome,
|
||||
_ => OutcomeStatus.Pending
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for outcome tracking.
|
||||
/// </summary>
|
||||
public interface IOutcomeTrackingService
|
||||
{
|
||||
/// <summary>Detects resolution and creates outcome prompt.</summary>
|
||||
Task<OutcomePrompt?> DetectResolutionAsync(ResolutionEvent resolutionEvent, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Records an outcome for a decision.</summary>
|
||||
Task<RecordedOutcome> RecordOutcomeAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
OutcomeStatus status,
|
||||
string? impact = null,
|
||||
string? lessons = null,
|
||||
string recordedBy = "system",
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets pending outcome prompts.</summary>
|
||||
Task<IReadOnlyList<OutcomePrompt>> GetPendingPromptsAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Calculates success metrics.</summary>
|
||||
Task<OutcomeMetrics> CalculateMetricsAsync(string tenantId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A resolution event indicating a finding was resolved.
|
||||
/// </summary>
|
||||
public sealed record ResolutionEvent
|
||||
{
|
||||
/// <summary>Gets the tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Gets the CVE ID.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Gets the component PURL.</summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>Gets when the resolution occurred.</summary>
|
||||
public required DateTimeOffset ResolvedAt { get; init; }
|
||||
|
||||
/// <summary>Gets the resolution type.</summary>
|
||||
public required string ResolutionType { get; init; }
|
||||
|
||||
/// <summary>Gets additional details.</summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompt for recording an outcome.
|
||||
/// </summary>
|
||||
public sealed record OutcomePrompt
|
||||
{
|
||||
/// <summary>Gets the memory ID.</summary>
|
||||
public required string MemoryId { get; init; }
|
||||
|
||||
/// <summary>Gets the tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Gets the CVE ID.</summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>Gets the component.</summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>Gets the decision action taken.</summary>
|
||||
public required string DecisionAction { get; init; }
|
||||
|
||||
/// <summary>Gets when the decision was made.</summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>Gets the suggested outcome status.</summary>
|
||||
public OutcomeStatus? SuggestedOutcome { get; init; }
|
||||
|
||||
/// <summary>Gets resolution details.</summary>
|
||||
public string? ResolutionDetails { get; init; }
|
||||
|
||||
/// <summary>Gets elapsed time since decision.</summary>
|
||||
public TimeSpan? ElapsedSinceDecision { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recorded outcome result.
|
||||
/// </summary>
|
||||
public sealed record RecordedOutcome
|
||||
{
|
||||
/// <summary>Gets the memory ID.</summary>
|
||||
public required string MemoryId { get; init; }
|
||||
|
||||
/// <summary>Gets the tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Gets the outcome status.</summary>
|
||||
public required OutcomeStatus Status { get; init; }
|
||||
|
||||
/// <summary>Gets when recorded.</summary>
|
||||
public required DateTimeOffset RecordedAt { get; init; }
|
||||
|
||||
/// <summary>Gets whether recording succeeded.</summary>
|
||||
public bool Success { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Success metrics for outcomes.
|
||||
/// </summary>
|
||||
public sealed record OutcomeMetrics
|
||||
{
|
||||
/// <summary>Gets the tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Gets the period start.</summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>Gets the period end.</summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>Gets total decisions.</summary>
|
||||
public int TotalDecisions { get; init; }
|
||||
|
||||
/// <summary>Gets decisions with outcomes.</summary>
|
||||
public int DecisionsWithOutcome { get; init; }
|
||||
|
||||
/// <summary>Gets successful outcomes.</summary>
|
||||
public int SuccessfulOutcomes { get; init; }
|
||||
|
||||
/// <summary>Gets success rate (0-1).</summary>
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
/// <summary>Gets average resolution time.</summary>
|
||||
public TimeSpan AverageResolutionTime { get; init; }
|
||||
|
||||
/// <summary>Gets counts by action.</summary>
|
||||
public ImmutableDictionary<DecisionAction, int> ByAction { get; init; } =
|
||||
ImmutableDictionary<DecisionAction, int>.Empty;
|
||||
|
||||
/// <summary>Gets counts by outcome status.</summary>
|
||||
public ImmutableDictionary<OutcomeStatus, int> ByOutcome { get; init; } =
|
||||
ImmutableDictionary<OutcomeStatus, int>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for outcome tracking.
|
||||
/// </summary>
|
||||
public sealed class OutcomeTrackingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the window in days to look back for outcomes.
|
||||
/// Default: 30 days.
|
||||
/// </summary>
|
||||
public int OutcomeWindowDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets minimum hours before prompting for outcome.
|
||||
/// Default: 24 hours.
|
||||
/// </summary>
|
||||
public int MinHoursBeforePrompt { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets maximum pending prompts to return.
|
||||
/// Default: 50.
|
||||
/// </summary>
|
||||
public int MaxPendingPrompts { get; set; } = 50;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// <copyright file="PostgresOpsMemoryStoreTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PostgresOpsMemoryStore.
|
||||
/// Uses the CI PostgreSQL instance on port 5433.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class PostgresOpsMemoryStoreTests : IAsyncLifetime
|
||||
{
|
||||
private const string ConnectionString = "Host=localhost;Port=5433;Database=stellaops_test;Username=stellaops_ci;Password=ci_test_password";
|
||||
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgresOpsMemoryStore? _store;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_dataSource = NpgsqlDataSource.Create(ConnectionString);
|
||||
_store = new PostgresOpsMemoryStore(
|
||||
_dataSource,
|
||||
NullLogger<PostgresOpsMemoryStore>.Instance);
|
||||
|
||||
// Clean up any existing test data
|
||||
await using var cmd = _dataSource.CreateCommand("DELETE FROM opsmemory.decisions WHERE tenant_id LIKE 'test-%'");
|
||||
await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_store != null)
|
||||
{
|
||||
await _store.DisposeAsync();
|
||||
}
|
||||
|
||||
if (_dataSource != null)
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordDecision_ShouldPersistAndRetrieve()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var tenantId = $"test-{Guid.NewGuid()}";
|
||||
var memoryId = Guid.NewGuid().ToString();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var contextTags = ImmutableArray.Create("production", "critical-path");
|
||||
var similarityVector = ImmutableArray.CreateRange(Enumerable.Range(0, 50).Select(i => (float)i / 100));
|
||||
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = tenantId,
|
||||
RecordedAt = now,
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Component = "pkg:npm/lodash@4.17.20",
|
||||
ComponentName = "lodash",
|
||||
ComponentVersion = "4.17.20",
|
||||
Severity = "HIGH",
|
||||
CvssScore = 7.5,
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
EpssScore = 0.15,
|
||||
IsKev = false,
|
||||
ContextTags = contextTags
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = DecisionAction.Defer,
|
||||
Rationale = "Waiting for upstream patch",
|
||||
DecidedBy = "security-team",
|
||||
DecidedAt = now
|
||||
},
|
||||
SimilarityVector = similarityVector
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store!.RecordDecisionAsync(record, ct);
|
||||
var retrieved = await _store.GetByIdAsync(memoryId, tenantId, ct);
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.MemoryId.Should().Be(memoryId);
|
||||
retrieved.TenantId.Should().Be(tenantId);
|
||||
retrieved.Situation.CveId.Should().Be("CVE-2024-1234");
|
||||
retrieved.Situation.Component.Should().Be("pkg:npm/lodash@4.17.20");
|
||||
retrieved.Situation.Severity.Should().Be("HIGH");
|
||||
retrieved.Situation.CvssScore.Should().Be(7.5);
|
||||
retrieved.Situation.Reachability.Should().Be(ReachabilityStatus.Reachable);
|
||||
retrieved.Decision.Action.Should().Be(DecisionAction.Defer);
|
||||
retrieved.Decision.Rationale.Should().Be("Waiting for upstream patch");
|
||||
retrieved.Situation.ContextTags.Should().Contain("production");
|
||||
retrieved.SimilarityVector.Should().HaveCount(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordOutcome_ShouldUpdateDecision()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var tenantId = $"test-{Guid.NewGuid()}";
|
||||
var memoryId = Guid.NewGuid().ToString();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = tenantId,
|
||||
RecordedAt = now,
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2024-5678",
|
||||
Reachability = ReachabilityStatus.Unknown
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = DecisionAction.Remediate,
|
||||
Rationale = "Critical finding requires fix",
|
||||
DecidedBy = "auto-policy",
|
||||
DecidedAt = now
|
||||
}
|
||||
};
|
||||
|
||||
await _store!.RecordDecisionAsync(record, ct);
|
||||
|
||||
var outcome = new OutcomeRecord
|
||||
{
|
||||
Status = OutcomeStatus.Success,
|
||||
ResolutionTime = TimeSpan.FromDays(3),
|
||||
ActualImpact = "None - patched before exploitation",
|
||||
LessonsLearned = "Auto-remediation worked well",
|
||||
RecordedBy = "security-team",
|
||||
RecordedAt = now.AddDays(3),
|
||||
WouldRepeat = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = await _store.RecordOutcomeAsync(memoryId, tenantId, outcome, ct);
|
||||
|
||||
// Assert
|
||||
updated.Should().NotBeNull();
|
||||
updated!.HasOutcome.Should().BeTrue();
|
||||
updated.Outcome.Should().NotBeNull();
|
||||
updated.Outcome!.Status.Should().Be(OutcomeStatus.Success);
|
||||
updated.Outcome.ResolutionTime.Should().Be(TimeSpan.FromDays(3));
|
||||
updated.Outcome.ActualImpact.Should().Be("None - patched before exploitation");
|
||||
updated.WasSuccessful.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_ShouldFilterByTenant()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var tenant1 = $"test-{Guid.NewGuid()}";
|
||||
var tenant2 = $"test-{Guid.NewGuid()}";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var record1 = CreateRecord(tenant1, "CVE-T1-001", DecisionAction.Accept, now);
|
||||
var record2 = CreateRecord(tenant2, "CVE-T2-001", DecisionAction.Defer, now);
|
||||
|
||||
await _store!.RecordDecisionAsync(record1, ct);
|
||||
await _store.RecordDecisionAsync(record2, ct);
|
||||
|
||||
// Act
|
||||
var result = await _store.QueryAsync(new OpsMemoryQuery { TenantId = tenant1 }, ct);
|
||||
|
||||
// Assert
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.Items[0].Situation.CveId.Should().Be("CVE-T1-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_ShouldFilterByCve()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var tenantId = $"test-{Guid.NewGuid()}";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var record1 = CreateRecord(tenantId, "CVE-2024-1111", DecisionAction.Accept, now);
|
||||
var record2 = CreateRecord(tenantId, "CVE-2024-2222", DecisionAction.Defer, now);
|
||||
|
||||
await _store!.RecordDecisionAsync(record1, ct);
|
||||
await _store.RecordDecisionAsync(record2, ct);
|
||||
|
||||
// Act
|
||||
var result = await _store.QueryAsync(new OpsMemoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CveId = "CVE-2024-1111"
|
||||
}, ct);
|
||||
|
||||
// Assert
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.Items[0].Decision.Action.Should().Be(DecisionAction.Accept);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_ShouldReturnCorrectCounts()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var tenantId = $"test-{Guid.NewGuid()}";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var record1 = CreateRecord(tenantId, "CVE-STAT-001", DecisionAction.Remediate, now);
|
||||
var record2 = CreateRecord(tenantId, "CVE-STAT-002", DecisionAction.Defer, now);
|
||||
|
||||
await _store!.RecordDecisionAsync(record1, ct);
|
||||
await _store.RecordDecisionAsync(record2, ct);
|
||||
|
||||
// Record outcome for one
|
||||
await _store.RecordOutcomeAsync(record1.MemoryId, tenantId, new OutcomeRecord
|
||||
{
|
||||
Status = OutcomeStatus.Success,
|
||||
RecordedBy = "test",
|
||||
RecordedAt = now
|
||||
}, ct);
|
||||
|
||||
// Act
|
||||
var stats = await _store.GetStatsAsync(tenantId, null, ct);
|
||||
|
||||
// Assert
|
||||
stats.TotalDecisions.Should().Be(2);
|
||||
stats.DecisionsWithOutcomes.Should().Be(1);
|
||||
stats.SuccessRate.Should().Be(1.0);
|
||||
stats.ByAction.Should().ContainKey(DecisionAction.Remediate);
|
||||
stats.ByAction.Should().ContainKey(DecisionAction.Defer);
|
||||
}
|
||||
|
||||
private static OpsMemoryRecord CreateRecord(string tenantId, string cveId, DecisionAction action, DateTimeOffset at)
|
||||
{
|
||||
return new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = Guid.NewGuid().ToString(),
|
||||
TenantId = tenantId,
|
||||
RecordedAt = at,
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = cveId,
|
||||
Reachability = ReachabilityStatus.Unknown
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = action,
|
||||
Rationale = $"Test decision for {cveId}",
|
||||
DecidedBy = "test",
|
||||
DecidedAt = at
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,345 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SimilarityVectorGenerator.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-010
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class SimilarityVectorGeneratorTests
|
||||
{
|
||||
private readonly SimilarityVectorGenerator _generator = new();
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithEmptySituation_SetsReachabilityUnknownDimension()
|
||||
{
|
||||
// Arrange
|
||||
var situation = new SituationContext();
|
||||
|
||||
// Act
|
||||
var vector = _generator.Generate(situation);
|
||||
|
||||
// Assert
|
||||
vector.Should().HaveCount(50);
|
||||
// Reachability defaults to Unknown (dimension 15)
|
||||
vector[15].Should().BeGreaterThan(0, "reachability Unknown should be set");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithSeverity_SetsCorrectDimension()
|
||||
{
|
||||
// Arrange
|
||||
var lowSeverity = new SituationContext { Severity = "low" };
|
||||
var criticalSeverity = new SituationContext { Severity = "critical" };
|
||||
|
||||
// Act
|
||||
var lowVector = _generator.Generate(lowSeverity);
|
||||
var criticalVector = _generator.Generate(criticalSeverity);
|
||||
|
||||
// Assert
|
||||
// Severity dimensions are 10-14 (none, low, medium, high, critical)
|
||||
lowVector[11].Should().BeGreaterThan(0, "low severity should set dimension 11");
|
||||
criticalVector[14].Should().BeGreaterThan(0, "critical severity should set dimension 14");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithReachability_SetsCorrectDimension()
|
||||
{
|
||||
// Arrange
|
||||
var reachable = new SituationContext { Reachability = ReachabilityStatus.Reachable };
|
||||
var notReachable = new SituationContext { Reachability = ReachabilityStatus.NotReachable };
|
||||
|
||||
// Act
|
||||
var reachableVector = _generator.Generate(reachable);
|
||||
var notReachableVector = _generator.Generate(notReachable);
|
||||
|
||||
// Assert
|
||||
// Reachability dimensions are 15-18 (unknown=0, reachable=1, notReachable=2, potential=3)
|
||||
reachableVector[16].Should().BeGreaterThan(0, "reachable should set dimension 16");
|
||||
notReachableVector[17].Should().BeGreaterThan(0, "not reachable should set dimension 17");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithEpssScore_SetsBandDimension()
|
||||
{
|
||||
// Arrange
|
||||
var lowEpss = new SituationContext { EpssScore = 0.1 }; // Band 0-0.2 -> dim 19
|
||||
var highEpss = new SituationContext { EpssScore = 0.9 }; // Band 0.8-1.0 -> dim 23
|
||||
|
||||
// Act
|
||||
var lowVector = _generator.Generate(lowEpss);
|
||||
var highVector = _generator.Generate(highEpss);
|
||||
|
||||
// Assert
|
||||
lowVector[19].Should().BeGreaterThan(0, "low EPSS should set dimension 19");
|
||||
highVector[23].Should().BeGreaterThan(0, "high EPSS should set dimension 23");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithCvssScore_SetsBandDimension()
|
||||
{
|
||||
// Arrange
|
||||
var lowCvss = new SituationContext { CvssScore = 1.5 }; // Band 0-2 -> dim 24
|
||||
var highCvss = new SituationContext { CvssScore = 9.5 }; // Band 8-10 -> dim 28
|
||||
|
||||
// Act
|
||||
var lowVector = _generator.Generate(lowCvss);
|
||||
var highVector = _generator.Generate(highCvss);
|
||||
|
||||
// Assert
|
||||
lowVector[24].Should().BeGreaterThan(0, "low CVSS should set dimension 24");
|
||||
highVector[28].Should().BeGreaterThan(0, "high CVSS should set dimension 28");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithKev_SetsKevDimension()
|
||||
{
|
||||
// Arrange
|
||||
var kev = new SituationContext { IsKev = true };
|
||||
var notKev = new SituationContext { IsKev = false };
|
||||
|
||||
// Act
|
||||
var kevVector = _generator.Generate(kev);
|
||||
var notKevVector = _generator.Generate(notKev);
|
||||
|
||||
// Assert
|
||||
kevVector[29].Should().BeGreaterThan(0, "KEV should set dimension 29");
|
||||
notKevVector[29].Should().Be(0, "non-KEV should not set dimension 29");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithNpmComponent_SetsComponentTypeDimension()
|
||||
{
|
||||
// Arrange
|
||||
var npm = new SituationContext { Component = "pkg:npm/lodash@4.17.21" };
|
||||
|
||||
// Act
|
||||
var vector = _generator.Generate(npm);
|
||||
|
||||
// Assert
|
||||
// Component type dimensions are 30-39, npm is index 0
|
||||
vector[30].Should().BeGreaterThan(0, "npm component should set dimension 30");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithContextTags_SetsTagDimensions()
|
||||
{
|
||||
// Arrange
|
||||
var situation = new SituationContext
|
||||
{
|
||||
ContextTags = ImmutableArray.Create("production", "external-facing", "payment")
|
||||
};
|
||||
|
||||
// Act
|
||||
var vector = _generator.Generate(situation);
|
||||
|
||||
// Assert
|
||||
// Context tag dimensions are 40-49
|
||||
// production=0, external-facing=3, payment=5
|
||||
vector[40].Should().BeGreaterThan(0, "production tag should set dimension 40");
|
||||
vector[43].Should().BeGreaterThan(0, "external-facing tag should set dimension 43");
|
||||
vector[45].Should().BeGreaterThan(0, "payment tag should set dimension 45");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_VectorIsNormalized()
|
||||
{
|
||||
// Arrange
|
||||
var situation = new SituationContext
|
||||
{
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
EpssScore = 0.5,
|
||||
CvssScore = 7.5,
|
||||
IsKev = true,
|
||||
ContextTags = ImmutableArray.Create("production", "payment")
|
||||
};
|
||||
|
||||
// Act
|
||||
var vector = _generator.Generate(situation);
|
||||
|
||||
// Assert - L2 norm should be approximately 1 (unit vector)
|
||||
var norm = Math.Sqrt(vector.Sum(v => v * v));
|
||||
norm.Should().BeApproximately(1.0, 0.001, "vector should be normalized to unit length");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CosineSimilarity_IdenticalVectors_ReturnsOne()
|
||||
{
|
||||
// Arrange
|
||||
var situation = new SituationContext
|
||||
{
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
};
|
||||
var vector = _generator.Generate(situation);
|
||||
|
||||
// Act
|
||||
var similarity = SimilarityVectorGenerator.CosineSimilarity(vector, vector);
|
||||
|
||||
// Assert
|
||||
similarity.Should().BeApproximately(1.0, 0.001, "identical vectors should have similarity 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CosineSimilarity_DifferentSituations_ReturnsLowerThanIdentical()
|
||||
{
|
||||
// Arrange - create two different vectors
|
||||
var situation1 = new SituationContext
|
||||
{
|
||||
Severity = "low",
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
EpssScore = 0.1
|
||||
};
|
||||
var situation2 = new SituationContext
|
||||
{
|
||||
Severity = "critical",
|
||||
Reachability = ReachabilityStatus.NotReachable,
|
||||
EpssScore = 0.9
|
||||
};
|
||||
|
||||
var vector1 = _generator.Generate(situation1);
|
||||
var vector2 = _generator.Generate(situation2);
|
||||
|
||||
// Act
|
||||
var sameSimilarity = SimilarityVectorGenerator.CosineSimilarity(vector1, vector1);
|
||||
var diffSimilarity = SimilarityVectorGenerator.CosineSimilarity(vector1, vector2);
|
||||
|
||||
// Assert - different situations should have lower similarity than identical
|
||||
diffSimilarity.Should().BeLessThan(sameSimilarity, "different situations should have lower similarity than identical");
|
||||
diffSimilarity.Should().BeGreaterThanOrEqualTo(0, "similarity should be non-negative");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CosineSimilarity_SimilarSituations_ReturnsHighValue()
|
||||
{
|
||||
// Arrange
|
||||
var situation1 = new SituationContext
|
||||
{
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
EpssScore = 0.75,
|
||||
IsKev = true
|
||||
};
|
||||
var situation2 = new SituationContext
|
||||
{
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
EpssScore = 0.8, // Same band as 0.75
|
||||
IsKev = true
|
||||
};
|
||||
|
||||
var vector1 = _generator.Generate(situation1);
|
||||
var vector2 = _generator.Generate(situation2);
|
||||
|
||||
// Act
|
||||
var similarity = SimilarityVectorGenerator.CosineSimilarity(vector1, vector2);
|
||||
|
||||
// Assert - 0.8+ is still quite similar for real-world situations
|
||||
similarity.Should().BeGreaterThan(0.8, "similar situations should have high similarity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CosineSimilarity_DifferentLengthVectors_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var vector1 = ImmutableArray.Create(1.0f, 0.0f, 0.0f);
|
||||
var vector2 = ImmutableArray.Create(1.0f, 0.0f);
|
||||
|
||||
// Act & Assert
|
||||
Action act = () => SimilarityVectorGenerator.CosineSimilarity(vector1, vector2);
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*same dimension*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_SameSeverity_ReportsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { Severity = "high" };
|
||||
var b = new SituationContext { Severity = "high" };
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().Contain(f => f.Contains("severity", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_SameReachability_ReportsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { Reachability = ReachabilityStatus.Reachable };
|
||||
var b = new SituationContext { Reachability = ReachabilityStatus.Reachable };
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().Contain(f => f.Contains("reachability", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_BothKev_ReportsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { IsKev = true };
|
||||
var b = new SituationContext { IsKev = true };
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().Contain(f => f.Contains("KEV", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_OverlappingTags_ReportsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { ContextTags = ImmutableArray.Create("production", "api") };
|
||||
var b = new SituationContext { ContextTags = ImmutableArray.Create("production", "frontend") };
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().Contain(f => f.Contains("production", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_SimilarEpss_ReportsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { EpssScore = 0.75 };
|
||||
var b = new SituationContext { EpssScore = 0.80 }; // Within 0.2 threshold
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().Contain(f => f.Contains("EPSS", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_DifferentEpss_DoesNotReportMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { EpssScore = 0.2 };
|
||||
var b = new SituationContext { EpssScore = 0.8 }; // More than 0.2 difference
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().NotContain(f => f.Contains("EPSS", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user