up
This commit is contained in:
136
src/Unknowns/AGENTS.md
Normal file
136
src/Unknowns/AGENTS.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Unknowns Module - Agent Guidelines
|
||||
|
||||
## Module Overview
|
||||
|
||||
The Unknowns module tracks ambiguities discovered during vulnerability scanning that cannot be definitively resolved. It uses **bitemporal semantics** to enable point-in-time compliance queries and full audit trails.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Bitemporal Data Model
|
||||
|
||||
The module uses two time dimensions:
|
||||
- **Valid time** (`valid_from`, `valid_to`): When the unknown was relevant in the real world
|
||||
- **System time** (`sys_from`, `sys_to`): When the system recorded/knew about the unknown
|
||||
|
||||
This enables queries like:
|
||||
- "What unknowns existed on January 1st?" (valid time query)
|
||||
- "What did we know about unknowns on January 1st, as of January 15th?" (bitemporal query)
|
||||
|
||||
### Unknown Types
|
||||
|
||||
| Kind | Description |
|
||||
|------|-------------|
|
||||
| `MissingSbom` | SBOM is missing for a component |
|
||||
| `AmbiguousPackage` | Package reference is ambiguous |
|
||||
| `MissingFeed` | No vulnerability feed covers this component |
|
||||
| `UnresolvedEdge` | Dependency edge cannot be resolved |
|
||||
| `NoVersionInfo` | No version information available |
|
||||
| `UnknownEcosystem` | Ecosystem is not recognized |
|
||||
| `PartialMatch` | Only partial match found |
|
||||
| `VersionRangeUnbounded` | Version range is unbounded |
|
||||
| `UnsupportedFormat` | Format is not supported |
|
||||
| `TransitiveGap` | Gap in transitive dependency chain |
|
||||
|
||||
### Resolution Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `FeedUpdated` | Vulnerability feed was updated to cover the component |
|
||||
| `SbomProvided` | SBOM was provided for the component |
|
||||
| `ManualMapping` | Manual mapping was created |
|
||||
| `Superseded` | Superseded by another unknown or finding |
|
||||
| `FalsePositive` | Determined to be a false positive |
|
||||
| `WontFix` | Acknowledged but not going to be fixed |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/Unknowns/
|
||||
├── __Libraries/
|
||||
│ ├── StellaOps.Unknowns.Core/
|
||||
│ │ ├── Models/
|
||||
│ │ │ └── Unknown.cs # Domain model and enums
|
||||
│ │ └── Repositories/
|
||||
│ │ └── IUnknownRepository.cs # Repository interface
|
||||
│ └── StellaOps.Unknowns.Storage.Postgres/
|
||||
│ ├── Migrations/
|
||||
│ │ └── 001_initial_schema.sql # Bitemporal schema
|
||||
│ └── Repositories/
|
||||
│ └── PostgresUnknownRepository.cs
|
||||
└── __Tests/
|
||||
└── StellaOps.Unknowns.Storage.Postgres.Tests/
|
||||
└── PostgresUnknownRepositoryTests.cs
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Key Tables
|
||||
|
||||
- `unknowns.unknown` - Main bitemporal table with RLS
|
||||
- Views: `unknowns.current`, `unknowns.resolved`
|
||||
- Functions: `unknowns.as_of()`, `unknowns.count_by_kind()`, `unknowns.count_by_severity()`
|
||||
|
||||
### Row-Level Security
|
||||
|
||||
The module uses RLS for tenant isolation:
|
||||
```sql
|
||||
SELECT set_config('app.tenant_id', 'my-tenant', false);
|
||||
SELECT * FROM unknowns.unknown; -- Only returns rows for my-tenant
|
||||
```
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### Creating Unknowns
|
||||
|
||||
```csharp
|
||||
var unknown = await repository.CreateAsync(
|
||||
tenantId: "my-tenant",
|
||||
subjectType: UnknownSubjectType.Package,
|
||||
subjectRef: "pkg:npm/lodash@4.17.21",
|
||||
kind: UnknownKind.MissingFeed,
|
||||
severity: UnknownSeverity.Medium,
|
||||
context: """{"ecosystem": "npm"}""",
|
||||
sourceScanId: scanId,
|
||||
sourceGraphId: null,
|
||||
sourceSbomDigest: null,
|
||||
createdBy: "scanner",
|
||||
cancellationToken);
|
||||
```
|
||||
|
||||
### Resolving Unknowns
|
||||
|
||||
```csharp
|
||||
var resolved = await repository.ResolveAsync(
|
||||
tenantId,
|
||||
unknownId,
|
||||
ResolutionType.FeedUpdated,
|
||||
resolutionRef: "nvd-feed-2025-01",
|
||||
resolutionNotes: "NVD now covers this package",
|
||||
resolvedBy: "feed-sync",
|
||||
cancellationToken);
|
||||
```
|
||||
|
||||
### Point-in-Time Queries
|
||||
|
||||
```csharp
|
||||
// What unknowns existed on a specific date?
|
||||
var historicalUnknowns = await repository.AsOfAsync(
|
||||
tenantId,
|
||||
validAt: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
cancellationToken: ct);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
dotnet test src/Unknowns/__Tests/StellaOps.Unknowns.Storage.Postgres.Tests
|
||||
```
|
||||
|
||||
Tests use Testcontainers for PostgreSQL integration testing.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/operations/postgresql-patterns-runbook.md` - Operational guide
|
||||
- `docs/implplan/SPRINT_3420_0001_0001_bitemporal_unknowns_schema.md` - Sprint spec
|
||||
- `deploy/postgres-validation/001_validate_rls.sql` - RLS validation
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an unknown - an ambiguity discovered during vulnerability scanning
|
||||
/// that cannot be definitively resolved. Uses bitemporal semantics for audit trails.
|
||||
/// </summary>
|
||||
public sealed record Unknown
|
||||
{
|
||||
/// <summary>Unique identifier.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>Tenant that owns this unknown.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the subject (for deduplication).</summary>
|
||||
public required string SubjectHash { get; init; }
|
||||
|
||||
/// <summary>Type of subject (package, ecosystem, version, etc.).</summary>
|
||||
public required UnknownSubjectType SubjectType { get; init; }
|
||||
|
||||
/// <summary>Human-readable reference (purl, name, path).</summary>
|
||||
public required string SubjectRef { get; init; }
|
||||
|
||||
/// <summary>Classification of the unknown.</summary>
|
||||
public required UnknownKind Kind { get; init; }
|
||||
|
||||
/// <summary>Severity assessment of the unknown.</summary>
|
||||
public UnknownSeverity? Severity { get; init; }
|
||||
|
||||
/// <summary>Additional context as flexible JSON.</summary>
|
||||
public JsonDocument? Context { get; init; }
|
||||
|
||||
/// <summary>ID of the scan that discovered this unknown.</summary>
|
||||
public Guid? SourceScanId { get; init; }
|
||||
|
||||
/// <summary>ID of the graph revision context.</summary>
|
||||
public Guid? SourceGraphId { get; init; }
|
||||
|
||||
/// <summary>SBOM digest if applicable.</summary>
|
||||
public string? SourceSbomDigest { get; init; }
|
||||
|
||||
// Bitemporal columns
|
||||
|
||||
/// <summary>When the unknown became valid in the real world.</summary>
|
||||
public required DateTimeOffset ValidFrom { get; init; }
|
||||
|
||||
/// <summary>When the unknown ceased to be valid (null = still valid).</summary>
|
||||
public DateTimeOffset? ValidTo { get; init; }
|
||||
|
||||
/// <summary>When the system recorded this state.</summary>
|
||||
public required DateTimeOffset SysFrom { get; init; }
|
||||
|
||||
/// <summary>When this system state was superseded (null = current).</summary>
|
||||
public DateTimeOffset? SysTo { get; init; }
|
||||
|
||||
// Resolution tracking
|
||||
|
||||
/// <summary>When the unknown was resolved.</summary>
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
|
||||
/// <summary>How the unknown was resolved.</summary>
|
||||
public ResolutionType? ResolutionType { get; init; }
|
||||
|
||||
/// <summary>Reference to the resolving entity.</summary>
|
||||
public string? ResolutionRef { get; init; }
|
||||
|
||||
/// <summary>Notes about the resolution.</summary>
|
||||
public string? ResolutionNotes { get; init; }
|
||||
|
||||
// Scoring (Triage)
|
||||
|
||||
/// <summary>Popularity impact score (P). Range [0,1]. Based on deployment count.</summary>
|
||||
public double PopularityScore { get; init; }
|
||||
|
||||
/// <summary>Number of deployments referencing this unknown's subject.</summary>
|
||||
public int DeploymentCount { get; init; }
|
||||
|
||||
/// <summary>Exploit consequence potential (E). Range [0,1]. Based on CVE severity + KEV.</summary>
|
||||
public double ExploitPotentialScore { get; init; }
|
||||
|
||||
/// <summary>Uncertainty density (U). Range [0,1]. Aggregated from flags.</summary>
|
||||
public double UncertaintyScore { get; init; }
|
||||
|
||||
/// <summary>Uncertainty flags as flexible JSON.</summary>
|
||||
public JsonDocument? UncertaintyFlags { get; init; }
|
||||
|
||||
/// <summary>Graph centrality (C). Range [0,1]. Normalized betweenness centrality.</summary>
|
||||
public double CentralityScore { get; init; }
|
||||
|
||||
/// <summary>Degree centrality (in + out edges).</summary>
|
||||
public int DegreeCentrality { get; init; }
|
||||
|
||||
/// <summary>Betweenness centrality (raw).</summary>
|
||||
public double BetweennessCentrality { get; init; }
|
||||
|
||||
/// <summary>Evidence staleness (S). Range [0,1]. Based on days since analysis.</summary>
|
||||
public double StalenessScore { get; init; }
|
||||
|
||||
/// <summary>Days since last analysis.</summary>
|
||||
public int DaysSinceAnalysis { get; init; }
|
||||
|
||||
/// <summary>Weighted composite score. Range [0,1].</summary>
|
||||
public double CompositeScore { get; init; }
|
||||
|
||||
/// <summary>Triage band assignment: hot, warm, cold.</summary>
|
||||
public TriageBand TriageBand { get; init; }
|
||||
|
||||
/// <summary>Scoring computation trace for audit/debugging.</summary>
|
||||
public JsonDocument? ScoringTrace { get; init; }
|
||||
|
||||
// Rescan Scheduling
|
||||
|
||||
/// <summary>Number of rescan attempts.</summary>
|
||||
public int RescanAttempts { get; init; }
|
||||
|
||||
/// <summary>Result of last rescan attempt.</summary>
|
||||
public string? LastRescanResult { get; init; }
|
||||
|
||||
/// <summary>Next scheduled rescan time.</summary>
|
||||
public DateTimeOffset? NextScheduledRescan { get; init; }
|
||||
|
||||
/// <summary>When this unknown was last analyzed.</summary>
|
||||
public DateTimeOffset? LastAnalyzedAt { get; init; }
|
||||
|
||||
// Evidence Hashes
|
||||
|
||||
/// <summary>SHA-256 hash of the evidence set for deterministic replay.</summary>
|
||||
public byte[]? EvidenceSetHash { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the graph slice for deterministic replay.</summary>
|
||||
public byte[]? GraphSliceHash { get; init; }
|
||||
|
||||
// Audit
|
||||
|
||||
/// <summary>When this record was created.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Who created this record.</summary>
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
/// <summary>When this record was last updated.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
// Computed properties
|
||||
|
||||
/// <summary>Whether this unknown is currently open (valid and not superseded).</summary>
|
||||
public bool IsOpen => ValidTo is null && SysTo is null;
|
||||
|
||||
/// <summary>Whether this unknown has been resolved.</summary>
|
||||
public bool IsResolved => ResolvedAt is not null;
|
||||
|
||||
/// <summary>Whether this unknown is current (not superseded in system time).</summary>
|
||||
public bool IsCurrent => SysTo is null;
|
||||
}
|
||||
|
||||
/// <summary>Type of subject that has an unknown.</summary>
|
||||
public enum UnknownSubjectType
|
||||
{
|
||||
/// <summary>A package (identified by purl or name).</summary>
|
||||
Package,
|
||||
|
||||
/// <summary>An ecosystem (npm, maven, etc.).</summary>
|
||||
Ecosystem,
|
||||
|
||||
/// <summary>A version specification.</summary>
|
||||
Version,
|
||||
|
||||
/// <summary>An edge in the SBOM dependency graph.</summary>
|
||||
SbomEdge,
|
||||
|
||||
/// <summary>A file or binary.</summary>
|
||||
File,
|
||||
|
||||
/// <summary>A runtime component.</summary>
|
||||
Runtime
|
||||
}
|
||||
|
||||
/// <summary>Classification of the unknown.</summary>
|
||||
public enum UnknownKind
|
||||
{
|
||||
/// <summary>SBOM is missing for a component.</summary>
|
||||
MissingSbom,
|
||||
|
||||
/// <summary>Package reference is ambiguous.</summary>
|
||||
AmbiguousPackage,
|
||||
|
||||
/// <summary>No vulnerability feed covers this component.</summary>
|
||||
MissingFeed,
|
||||
|
||||
/// <summary>Dependency edge cannot be resolved.</summary>
|
||||
UnresolvedEdge,
|
||||
|
||||
/// <summary>No version information available.</summary>
|
||||
NoVersionInfo,
|
||||
|
||||
/// <summary>Ecosystem is not recognized.</summary>
|
||||
UnknownEcosystem,
|
||||
|
||||
/// <summary>Only partial match found.</summary>
|
||||
PartialMatch,
|
||||
|
||||
/// <summary>Version range is unbounded.</summary>
|
||||
VersionRangeUnbounded,
|
||||
|
||||
/// <summary>Format is not supported.</summary>
|
||||
UnsupportedFormat,
|
||||
|
||||
/// <summary>Gap in transitive dependency chain.</summary>
|
||||
TransitiveGap
|
||||
}
|
||||
|
||||
/// <summary>Severity of the unknown's impact.</summary>
|
||||
public enum UnknownSeverity
|
||||
{
|
||||
/// <summary>Critical impact - blocks security assessment.</summary>
|
||||
Critical,
|
||||
|
||||
/// <summary>High impact - significant blind spot.</summary>
|
||||
High,
|
||||
|
||||
/// <summary>Medium impact - notable gap.</summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>Low impact - minor gap.</summary>
|
||||
Low,
|
||||
|
||||
/// <summary>Informational only.</summary>
|
||||
Info
|
||||
}
|
||||
|
||||
/// <summary>How the unknown was resolved.</summary>
|
||||
public enum ResolutionType
|
||||
{
|
||||
/// <summary>Vulnerability feed was updated to cover the component.</summary>
|
||||
FeedUpdated,
|
||||
|
||||
/// <summary>SBOM was provided for the component.</summary>
|
||||
SbomProvided,
|
||||
|
||||
/// <summary>Manual mapping was created.</summary>
|
||||
ManualMapping,
|
||||
|
||||
/// <summary>Superseded by another unknown or finding.</summary>
|
||||
Superseded,
|
||||
|
||||
/// <summary>Determined to be a false positive.</summary>
|
||||
FalsePositive,
|
||||
|
||||
/// <summary>Acknowledged but not going to be fixed.</summary>
|
||||
WontFix
|
||||
}
|
||||
|
||||
/// <summary>Triage band for unknown prioritization.</summary>
|
||||
public enum TriageBand
|
||||
{
|
||||
/// <summary>HOT (score >= 0.70): Immediate rescan required.</summary>
|
||||
Hot,
|
||||
|
||||
/// <summary>WARM (score 0.40-0.69): Scheduled rescan within 12-72 hours.</summary>
|
||||
Warm,
|
||||
|
||||
/// <summary>COLD (score < 0.40): Weekly batch processing.</summary>
|
||||
Cold
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for bitemporal unknowns operations.
|
||||
/// </summary>
|
||||
public interface IUnknownRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new unknown record.
|
||||
/// </summary>
|
||||
Task<Unknown> CreateAsync(
|
||||
string tenantId,
|
||||
UnknownSubjectType subjectType,
|
||||
string subjectRef,
|
||||
UnknownKind kind,
|
||||
UnknownSeverity? severity,
|
||||
string? context,
|
||||
Guid? sourceScanId,
|
||||
Guid? sourceGraphId,
|
||||
string? sourceSbomDigest,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an unknown by ID.
|
||||
/// </summary>
|
||||
Task<Unknown?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an unknown by subject hash (current state only).
|
||||
/// </summary>
|
||||
Task<Unknown?> GetBySubjectHashAsync(string tenantId, string subjectHash, UnknownKind kind, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all current open unknowns for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Unknown>> GetOpenUnknownsAsync(
|
||||
string tenantId,
|
||||
int? limit = null,
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets current open unknowns filtered by kind.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Unknown>> GetByKindAsync(
|
||||
string tenantId,
|
||||
UnknownKind kind,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets current open unknowns filtered by severity.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Unknown>> GetBySeverityAsync(
|
||||
string tenantId,
|
||||
UnknownSeverity severity,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets unknowns associated with a specific scan.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Unknown>> GetByScanIdAsync(
|
||||
string tenantId,
|
||||
Guid scanId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Bitemporal query: Gets unknowns as they were at a specific point in time.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Unknown>> AsOfAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset validAt,
|
||||
DateTimeOffset? systemAt = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an unknown with the specified resolution details.
|
||||
/// </summary>
|
||||
Task<Unknown> ResolveAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
ResolutionType resolutionType,
|
||||
string? resolutionRef,
|
||||
string? resolutionNotes,
|
||||
string resolvedBy,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Supersedes an unknown (closes valid time but keeps system record).
|
||||
/// </summary>
|
||||
Task SupersedeAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string supersededBy,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts unknowns grouped by kind for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<UnknownKind, long>> CountByKindAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts unknowns grouped by severity for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<UnknownSeverity, long>> CountBySeverityAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets total count of open unknowns for a tenant.
|
||||
/// </summary>
|
||||
Task<long> CountOpenAsync(string tenantId, CancellationToken cancellationToken);
|
||||
|
||||
// Triage methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets unknowns by triage band, ordered by composite score descending.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Unknown>> GetByTriageBandAsync(
|
||||
string tenantId,
|
||||
TriageBand band,
|
||||
int? limit = null,
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all HOT unknowns for immediate processing.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Unknown>> GetHotQueueAsync(
|
||||
string tenantId,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets unknowns due for rescan based on next_scheduled_rescan.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Unknown>> GetDueForRescanAsync(
|
||||
string tenantId,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates scoring for an unknown.
|
||||
/// </summary>
|
||||
Task<Unknown> UpdateScoresAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
double popularityScore,
|
||||
int deploymentCount,
|
||||
double exploitPotentialScore,
|
||||
double uncertaintyScore,
|
||||
string? uncertaintyFlags,
|
||||
double centralityScore,
|
||||
int degreeCentrality,
|
||||
double betweennessCentrality,
|
||||
double stalenessScore,
|
||||
int daysSinceAnalysis,
|
||||
double compositeScore,
|
||||
TriageBand triageBand,
|
||||
string? scoringTrace,
|
||||
DateTimeOffset? nextScheduledRescan,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records a rescan attempt result.
|
||||
/// </summary>
|
||||
Task<Unknown> RecordRescanAttemptAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string result,
|
||||
DateTimeOffset? nextRescan,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts unknowns grouped by triage band.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<TriageBand, long>> CountByTriageBandAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets triage summary statistics for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TriageSummary>> GetTriageSummaryAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of unknowns by triage band and kind.
|
||||
/// </summary>
|
||||
public sealed record TriageSummary
|
||||
{
|
||||
public required TriageBand Band { get; init; }
|
||||
public required UnknownKind Kind { get; init; }
|
||||
public required long Count { get; init; }
|
||||
public required double AvgScore { get; init; }
|
||||
public required double MaxScore { get; init; }
|
||||
public required double MinScore { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Unknowns.Core</RootNamespace>
|
||||
<Description>Core domain models and abstractions for the StellaOps Unknowns module (bitemporal ambiguity tracking)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,307 @@
|
||||
-- Unknowns Schema Migration 001: Initial Bitemporal Schema
|
||||
-- Sprint: SPRINT_3420_0001_0001 - Bitemporal Unknowns Schema
|
||||
-- Category: A (safe, can run at startup)
|
||||
--
|
||||
-- Purpose: Create the unknowns schema with bitemporal semantics for tracking
|
||||
-- ambiguity in vulnerability scans, enabling point-in-time compliance queries.
|
||||
--
|
||||
-- Bitemporal Dimensions:
|
||||
-- - valid_from/valid_to: When the unknown was relevant in the real world
|
||||
-- - sys_from/sys_to: When the system recorded/knew about the unknown
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 1: Create helper schema for app functions
|
||||
-- ============================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS unknowns_app;
|
||||
|
||||
-- Tenant context helper function
|
||||
CREATE OR REPLACE FUNCTION unknowns_app.require_current_tenant()
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant TEXT;
|
||||
BEGIN
|
||||
v_tenant := current_setting('app.tenant_id', true);
|
||||
IF v_tenant IS NULL OR v_tenant = '' THEN
|
||||
RAISE EXCEPTION 'app.tenant_id session variable not set'
|
||||
USING HINT = 'Set via: SELECT set_config(''app.tenant_id'', ''<tenant>'', false)',
|
||||
ERRCODE = 'P0001';
|
||||
END IF;
|
||||
RETURN v_tenant;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION unknowns_app.require_current_tenant() FROM PUBLIC;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 2: Create enum types
|
||||
-- ============================================================================
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.subject_type AS ENUM (
|
||||
'package',
|
||||
'ecosystem',
|
||||
'version',
|
||||
'sbom_edge',
|
||||
'file',
|
||||
'runtime'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.unknown_kind AS ENUM (
|
||||
'missing_sbom',
|
||||
'ambiguous_package',
|
||||
'missing_feed',
|
||||
'unresolved_edge',
|
||||
'no_version_info',
|
||||
'unknown_ecosystem',
|
||||
'partial_match',
|
||||
'version_range_unbounded',
|
||||
'unsupported_format',
|
||||
'transitive_gap'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.unknown_severity AS ENUM (
|
||||
'critical',
|
||||
'high',
|
||||
'medium',
|
||||
'low',
|
||||
'info'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.resolution_type AS ENUM (
|
||||
'feed_updated',
|
||||
'sbom_provided',
|
||||
'manual_mapping',
|
||||
'superseded',
|
||||
'false_positive',
|
||||
'wont_fix'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 3: Create main bitemporal unknowns table
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS unknowns.unknown (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
|
||||
-- Subject identification
|
||||
subject_hash CHAR(64) NOT NULL, -- SHA-256 hex of subject
|
||||
subject_type unknowns.subject_type NOT NULL,
|
||||
subject_ref TEXT NOT NULL, -- Human-readable reference (purl, name)
|
||||
|
||||
-- Classification
|
||||
kind unknowns.unknown_kind NOT NULL,
|
||||
severity unknowns.unknown_severity,
|
||||
|
||||
-- Context (flexible JSONB for additional details)
|
||||
context JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Source correlation
|
||||
source_scan_id UUID, -- Scan that discovered this unknown
|
||||
source_graph_id UUID, -- Graph revision context
|
||||
source_sbom_digest TEXT, -- SBOM digest if applicable
|
||||
|
||||
-- Bitemporal columns
|
||||
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
valid_to TIMESTAMPTZ, -- NULL = currently valid
|
||||
sys_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
sys_to TIMESTAMPTZ, -- NULL = current system state
|
||||
|
||||
-- Resolution tracking
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolution_type unknowns.resolution_type,
|
||||
resolution_ref TEXT, -- Reference to resolving entity
|
||||
resolution_notes TEXT,
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT ck_unknown_subject_hash_hex CHECK (subject_hash ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT ck_unknown_resolution_consistency CHECK (
|
||||
(resolved_at IS NULL AND resolution_type IS NULL) OR
|
||||
(resolved_at IS NOT NULL AND resolution_type IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT ck_unknown_temporal_order CHECK (
|
||||
(valid_to IS NULL OR valid_from <= valid_to) AND
|
||||
(sys_to IS NULL OR sys_from <= sys_to)
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 4: Create indexes
|
||||
-- ============================================================================
|
||||
|
||||
-- Ensure only one open unknown per subject per tenant (current valid time, current system time)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_unknown_one_open_per_subject
|
||||
ON unknowns.unknown (tenant_id, subject_hash, kind)
|
||||
WHERE valid_to IS NULL AND sys_to IS NULL;
|
||||
|
||||
-- Tenant-scoped queries (most common)
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_tenant
|
||||
ON unknowns.unknown (tenant_id);
|
||||
|
||||
-- Temporal query indexes for valid time
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_tenant_valid
|
||||
ON unknowns.unknown (tenant_id, valid_from, valid_to);
|
||||
|
||||
-- Temporal query indexes for system time
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_tenant_sys
|
||||
ON unknowns.unknown (tenant_id, sys_from, sys_to);
|
||||
|
||||
-- Current open unknowns by kind and severity
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_tenant_kind_severity
|
||||
ON unknowns.unknown (tenant_id, kind, severity)
|
||||
WHERE valid_to IS NULL AND sys_to IS NULL;
|
||||
|
||||
-- Source correlation indexes
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_source_scan
|
||||
ON unknowns.unknown (source_scan_id)
|
||||
WHERE source_scan_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_source_graph
|
||||
ON unknowns.unknown (source_graph_id)
|
||||
WHERE source_graph_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_source_sbom
|
||||
ON unknowns.unknown (source_sbom_digest)
|
||||
WHERE source_sbom_digest IS NOT NULL;
|
||||
|
||||
-- Context GIN index for JSONB queries
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_context_gin
|
||||
ON unknowns.unknown USING GIN (context jsonb_path_ops);
|
||||
|
||||
-- Subject lookup
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_subject_ref
|
||||
ON unknowns.unknown (tenant_id, subject_ref);
|
||||
|
||||
-- Unresolved unknowns
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_unresolved
|
||||
ON unknowns.unknown (tenant_id, kind, created_at DESC)
|
||||
WHERE resolved_at IS NULL AND valid_to IS NULL AND sys_to IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 5: Create views for common query patterns
|
||||
-- ============================================================================
|
||||
|
||||
-- Current unknowns (valid now, known now)
|
||||
CREATE OR REPLACE VIEW unknowns.current AS
|
||||
SELECT * FROM unknowns.unknown
|
||||
WHERE valid_to IS NULL AND sys_to IS NULL;
|
||||
|
||||
-- Resolved unknowns
|
||||
CREATE OR REPLACE VIEW unknowns.resolved AS
|
||||
SELECT * FROM unknowns.unknown
|
||||
WHERE resolved_at IS NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 6: Create temporal query functions
|
||||
-- ============================================================================
|
||||
|
||||
-- Point-in-time query: What unknowns were valid at a given time?
|
||||
CREATE OR REPLACE FUNCTION unknowns.as_of(
|
||||
p_tenant_id TEXT,
|
||||
p_valid_at TIMESTAMPTZ,
|
||||
p_sys_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
RETURNS SETOF unknowns.unknown
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT * FROM unknowns.unknown
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND valid_from <= p_valid_at
|
||||
AND (valid_to IS NULL OR valid_to > p_valid_at)
|
||||
AND sys_from <= p_sys_at
|
||||
AND (sys_to IS NULL OR sys_to > p_sys_at);
|
||||
$$;
|
||||
|
||||
-- Count unknowns by kind for a tenant (current state)
|
||||
CREATE OR REPLACE FUNCTION unknowns.count_by_kind(p_tenant_id TEXT)
|
||||
RETURNS TABLE(kind unknowns.unknown_kind, count BIGINT)
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT kind, count(*)
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
GROUP BY kind;
|
||||
$$;
|
||||
|
||||
-- Count unknowns by severity for a tenant (current state)
|
||||
CREATE OR REPLACE FUNCTION unknowns.count_by_severity(p_tenant_id TEXT)
|
||||
RETURNS TABLE(severity unknowns.unknown_severity, count BIGINT)
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT severity, count(*)
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
AND severity IS NOT NULL
|
||||
GROUP BY severity;
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 7: Enable RLS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE unknowns.unknown ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE unknowns.unknown FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS unknown_tenant_isolation ON unknowns.unknown;
|
||||
CREATE POLICY unknown_tenant_isolation ON unknowns.unknown
|
||||
FOR ALL
|
||||
USING (tenant_id = unknowns_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = unknowns_app.require_current_tenant());
|
||||
|
||||
-- Admin bypass role
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'unknowns_admin') THEN
|
||||
CREATE ROLE unknowns_admin WITH NOLOGIN BYPASSRLS;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 8: Create update trigger for updated_at
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION unknowns.update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_unknown_updated_at ON unknowns.unknown;
|
||||
CREATE TRIGGER trg_unknown_updated_at
|
||||
BEFORE UPDATE ON unknowns.unknown
|
||||
FOR EACH ROW EXECUTE FUNCTION unknowns.update_updated_at();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,233 @@
|
||||
-- Unknowns Schema Migration 002: Scoring Extension for Triage
|
||||
-- Sprint: SPRINT_1102_0001_0001 / SPRINT_3600_0001_0001 - Triage & Unknowns
|
||||
-- Category: A (safe, can run at startup)
|
||||
--
|
||||
-- Purpose: Add scoring columns to enable intelligent triage of scan ambiguities
|
||||
-- with HOT/WARM/COLD band assignment and rescan scheduling.
|
||||
--
|
||||
-- Reference: 14-Dec-2025 - Triage and Unknowns Technical Reference §17-18
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================================
|
||||
-- SCORING ENUM
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TYPE unknowns.triage_band AS ENUM ('hot', 'warm', 'cold');
|
||||
|
||||
-- ============================================================================
|
||||
-- SCORING COLUMNS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE unknowns.unknown
|
||||
-- Popularity score (P): Based on deployment count
|
||||
-- Formula: min(1, log10(1 + deployments) / log10(1 + 100))
|
||||
ADD COLUMN IF NOT EXISTS popularity_score FLOAT DEFAULT 0.0
|
||||
CONSTRAINT chk_popularity_range CHECK (popularity_score >= 0.0 AND popularity_score <= 1.0),
|
||||
ADD COLUMN IF NOT EXISTS deployment_count INT DEFAULT 0,
|
||||
|
||||
-- Exploit potential score (E): Based on CVE severity if known
|
||||
-- Range: 0.0 (no known CVE) to 1.0 (critical CVE + KEV)
|
||||
ADD COLUMN IF NOT EXISTS exploit_potential_score FLOAT DEFAULT 0.0
|
||||
CONSTRAINT chk_exploit_range CHECK (exploit_potential_score >= 0.0 AND exploit_potential_score <= 1.0),
|
||||
|
||||
-- Uncertainty density score (U): Aggregated from flags
|
||||
-- Flags: no_provenance(0.30), version_range(0.25), conflicting_feeds(0.20),
|
||||
-- missing_vector(0.15), unreachable_source(0.10)
|
||||
ADD COLUMN IF NOT EXISTS uncertainty_score FLOAT DEFAULT 0.0
|
||||
CONSTRAINT chk_uncertainty_range CHECK (uncertainty_score >= 0.0 AND uncertainty_score <= 1.0),
|
||||
ADD COLUMN IF NOT EXISTS uncertainty_flags JSONB DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Centrality score (C): Graph position importance
|
||||
-- Based on normalized betweenness centrality
|
||||
ADD COLUMN IF NOT EXISTS centrality_score FLOAT DEFAULT 0.0
|
||||
CONSTRAINT chk_centrality_range CHECK (centrality_score >= 0.0 AND centrality_score <= 1.0),
|
||||
ADD COLUMN IF NOT EXISTS degree_centrality INT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS betweenness_centrality FLOAT DEFAULT 0.0,
|
||||
|
||||
-- Staleness score (S): Evidence age
|
||||
-- Formula: min(1, age_days / 14)
|
||||
ADD COLUMN IF NOT EXISTS staleness_score FLOAT DEFAULT 0.0
|
||||
CONSTRAINT chk_staleness_range CHECK (staleness_score >= 0.0 AND staleness_score <= 1.0),
|
||||
ADD COLUMN IF NOT EXISTS days_since_analysis INT DEFAULT 0,
|
||||
|
||||
-- Composite score: Weighted sum of factors
|
||||
-- Default weights: wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10
|
||||
ADD COLUMN IF NOT EXISTS composite_score FLOAT DEFAULT 0.0
|
||||
CONSTRAINT chk_composite_range CHECK (composite_score >= 0.0 AND composite_score <= 1.0),
|
||||
|
||||
-- Triage band: HOT (>=0.70), WARM (0.40-0.69), COLD (<0.40)
|
||||
ADD COLUMN IF NOT EXISTS triage_band unknowns.triage_band DEFAULT 'cold',
|
||||
|
||||
-- Normalization trace for audit/debugging
|
||||
ADD COLUMN IF NOT EXISTS scoring_trace JSONB,
|
||||
|
||||
-- Rescan scheduling
|
||||
ADD COLUMN IF NOT EXISTS rescan_attempts INT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS last_rescan_result TEXT,
|
||||
ADD COLUMN IF NOT EXISTS next_scheduled_rescan TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS last_analyzed_at TIMESTAMPTZ,
|
||||
|
||||
-- Evidence hashes for deterministic replay
|
||||
ADD COLUMN IF NOT EXISTS evidence_set_hash BYTEA,
|
||||
ADD COLUMN IF NOT EXISTS graph_slice_hash BYTEA;
|
||||
|
||||
-- ============================================================================
|
||||
-- INDEXES FOR TRIAGE QUERIES
|
||||
-- ============================================================================
|
||||
|
||||
-- Band-based queries (most common for triage UI)
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_triage_band
|
||||
ON unknowns.unknown (tenant_id, triage_band, composite_score DESC)
|
||||
WHERE sys_to = 'infinity'::timestamptz;
|
||||
|
||||
-- Hot band priority queue
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_hot_queue
|
||||
ON unknowns.unknown (tenant_id, composite_score DESC)
|
||||
WHERE triage_band = 'hot' AND sys_to = 'infinity'::timestamptz;
|
||||
|
||||
-- Rescan scheduling
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_rescan_schedule
|
||||
ON unknowns.unknown (next_scheduled_rescan)
|
||||
WHERE next_scheduled_rescan IS NOT NULL AND sys_to = 'infinity'::timestamptz;
|
||||
|
||||
-- Kind + band for dashboard aggregations
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_kind_band
|
||||
ON unknowns.unknown (tenant_id, kind, triage_band)
|
||||
WHERE sys_to = 'infinity'::timestamptz;
|
||||
|
||||
-- GIN index for uncertainty flags queries
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_uncertainty_flags
|
||||
ON unknowns.unknown USING GIN (uncertainty_flags)
|
||||
WHERE sys_to = 'infinity'::timestamptz;
|
||||
|
||||
-- ============================================================================
|
||||
-- SCORING HELPER FUNCTIONS
|
||||
-- ============================================================================
|
||||
|
||||
-- Calculate popularity score from deployment count
|
||||
CREATE OR REPLACE FUNCTION unknowns.calc_popularity_score(p_deployment_count INT)
|
||||
RETURNS FLOAT
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$
|
||||
SELECT LEAST(1.0, LOG(1 + p_deployment_count) / LOG(1 + 100))::float;
|
||||
$$;
|
||||
|
||||
-- Calculate staleness score from days since analysis
|
||||
CREATE OR REPLACE FUNCTION unknowns.calc_staleness_score(p_days INT)
|
||||
RETURNS FLOAT
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$
|
||||
SELECT LEAST(1.0, p_days / 14.0)::float;
|
||||
$$;
|
||||
|
||||
-- Assign triage band from composite score
|
||||
CREATE OR REPLACE FUNCTION unknowns.assign_band(p_score FLOAT)
|
||||
RETURNS unknowns.triage_band
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN p_score >= 0.70 THEN 'hot'::unknowns.triage_band
|
||||
WHEN p_score >= 0.40 THEN 'warm'::unknowns.triage_band
|
||||
ELSE 'cold'::unknowns.triage_band
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Calculate composite score with default weights
|
||||
-- wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10
|
||||
CREATE OR REPLACE FUNCTION unknowns.calc_composite_score(
|
||||
p_popularity FLOAT,
|
||||
p_exploit FLOAT,
|
||||
p_uncertainty FLOAT,
|
||||
p_centrality FLOAT,
|
||||
p_staleness FLOAT,
|
||||
p_weights JSONB DEFAULT '{"wP":0.25,"wE":0.25,"wU":0.25,"wC":0.15,"wS":0.10}'::jsonb
|
||||
)
|
||||
RETURNS FLOAT
|
||||
LANGUAGE plpgsql IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_wp FLOAT := COALESCE((p_weights->>'wP')::float, 0.25);
|
||||
v_we FLOAT := COALESCE((p_weights->>'wE')::float, 0.25);
|
||||
v_wu FLOAT := COALESCE((p_weights->>'wU')::float, 0.25);
|
||||
v_wc FLOAT := COALESCE((p_weights->>'wC')::float, 0.15);
|
||||
v_ws FLOAT := COALESCE((p_weights->>'wS')::float, 0.10);
|
||||
v_score FLOAT;
|
||||
BEGIN
|
||||
v_score := v_wp * COALESCE(p_popularity, 0) +
|
||||
v_we * COALESCE(p_exploit, 0) +
|
||||
v_wu * COALESCE(p_uncertainty, 0) +
|
||||
v_wc * COALESCE(p_centrality, 0) +
|
||||
v_ws * COALESCE(p_staleness, 0);
|
||||
RETURN LEAST(1.0, GREATEST(0.0, v_score));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- VIEW: Unknowns by triage band with counts
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE VIEW unknowns.triage_summary AS
|
||||
SELECT
|
||||
tenant_id,
|
||||
triage_band,
|
||||
kind::text AS unknown_kind,
|
||||
COUNT(*) AS count,
|
||||
AVG(composite_score) AS avg_score,
|
||||
MAX(composite_score) AS max_score,
|
||||
MIN(composite_score) AS min_score
|
||||
FROM unknowns.unknown
|
||||
WHERE sys_to = 'infinity'::timestamptz
|
||||
AND resolved_at IS NULL
|
||||
GROUP BY tenant_id, triage_band, kind;
|
||||
|
||||
-- ============================================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.popularity_score IS
|
||||
'Popularity impact (P). Formula: min(1, log10(1+deployments)/log10(101)). Range [0,1].';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.exploit_potential_score IS
|
||||
'Exploit consequence potential (E). Based on CVE severity + KEV status. Range [0,1].';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.uncertainty_score IS
|
||||
'Uncertainty density (U). Aggregated from flags. Range [0,1].';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.centrality_score IS
|
||||
'Graph centrality (C). Normalized betweenness centrality. Range [0,1].';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.staleness_score IS
|
||||
'Evidence staleness (S). Formula: min(1, age_days/14). Range [0,1].';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.composite_score IS
|
||||
'Weighted composite: clamp01(0.25*P + 0.25*E + 0.25*U + 0.15*C + 0.10*S).';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.triage_band IS
|
||||
'HOT (>=0.70): immediate rescan. WARM (0.40-0.69): 12-72h. COLD (<0.40): weekly.';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.scoring_trace IS
|
||||
'JSONB trace of scoring computation for audit/debugging.';
|
||||
|
||||
COMMENT ON FUNCTION unknowns.calc_composite_score IS
|
||||
'Calculate weighted composite score. Default weights: wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10.';
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================================
|
||||
-- VERIFICATION (run manually)
|
||||
-- ============================================================================
|
||||
-- SELECT
|
||||
-- id,
|
||||
-- kind::text,
|
||||
-- triage_band::text,
|
||||
-- composite_score,
|
||||
-- popularity_score,
|
||||
-- exploit_potential_score,
|
||||
-- uncertainty_score,
|
||||
-- centrality_score,
|
||||
-- staleness_score
|
||||
-- FROM unknowns.unknown
|
||||
-- WHERE sys_to = 'infinity'::timestamptz
|
||||
-- ORDER BY composite_score DESC
|
||||
-- LIMIT 10;
|
||||
@@ -0,0 +1,660 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Core.Repositories;
|
||||
|
||||
namespace StellaOps.Unknowns.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the bitemporal unknowns repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresUnknownRepository : IUnknownRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresUnknownRepository> _logger;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
|
||||
private const string SelectColumns = """
|
||||
id, tenant_id, subject_hash, subject_type::text, subject_ref,
|
||||
kind::text, severity::text, context, source_scan_id, source_graph_id, source_sbom_digest,
|
||||
valid_from, valid_to, sys_from, sys_to,
|
||||
resolved_at, resolution_type::text, resolution_ref, resolution_notes,
|
||||
created_at, created_by, updated_at
|
||||
""";
|
||||
|
||||
public PostgresUnknownRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresUnknownRepository> logger,
|
||||
int commandTimeoutSeconds = 30)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_commandTimeoutSeconds = commandTimeoutSeconds;
|
||||
}
|
||||
|
||||
public async Task<Unknown> CreateAsync(
|
||||
string tenantId,
|
||||
UnknownSubjectType subjectType,
|
||||
string subjectRef,
|
||||
UnknownKind kind,
|
||||
UnknownSeverity? severity,
|
||||
string? context,
|
||||
Guid? sourceScanId,
|
||||
Guid? sourceGraphId,
|
||||
string? sourceSbomDigest,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var subjectHash = ComputeSubjectHash(subjectRef);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO unknowns.unknown (
|
||||
id, tenant_id, subject_hash, subject_type, subject_ref,
|
||||
kind, severity, context, source_scan_id, source_graph_id, source_sbom_digest,
|
||||
valid_from, sys_from, created_at, created_by, updated_at
|
||||
) VALUES (
|
||||
@id, @tenant_id, @subject_hash, @subject_type::unknowns.subject_type, @subject_ref,
|
||||
@kind::unknowns.unknown_kind, @severity::unknowns.unknown_severity, @context::jsonb,
|
||||
@source_scan_id, @source_graph_id, @source_sbom_digest,
|
||||
@valid_from, @sys_from, @created_at, @created_by, @updated_at
|
||||
)
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
|
||||
command.Parameters.AddWithValue("id", id);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("subject_hash", subjectHash);
|
||||
command.Parameters.AddWithValue("subject_type", MapSubjectType(subjectType));
|
||||
command.Parameters.AddWithValue("subject_ref", subjectRef);
|
||||
command.Parameters.AddWithValue("kind", MapUnknownKind(kind));
|
||||
command.Parameters.AddWithValue("severity", severity.HasValue ? MapSeverity(severity.Value) : DBNull.Value);
|
||||
command.Parameters.Add(new NpgsqlParameter("context", NpgsqlDbType.Jsonb)
|
||||
{
|
||||
Value = context ?? "{}"
|
||||
});
|
||||
command.Parameters.AddWithValue("source_scan_id", sourceScanId.HasValue ? sourceScanId.Value : DBNull.Value);
|
||||
command.Parameters.AddWithValue("source_graph_id", sourceGraphId.HasValue ? sourceGraphId.Value : DBNull.Value);
|
||||
command.Parameters.AddWithValue("source_sbom_digest", (object?)sourceSbomDigest ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("valid_from", now);
|
||||
command.Parameters.AddWithValue("sys_from", now);
|
||||
command.Parameters.AddWithValue("created_at", now);
|
||||
command.Parameters.AddWithValue("created_by", createdBy);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
_logger.LogDebug("Created unknown {Id} for tenant {TenantId}, kind={Kind}", id, tenantId, kind);
|
||||
|
||||
return new Unknown
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
SubjectHash = subjectHash,
|
||||
SubjectType = subjectType,
|
||||
SubjectRef = subjectRef,
|
||||
Kind = kind,
|
||||
Severity = severity,
|
||||
Context = context is not null ? JsonDocument.Parse(context) : null,
|
||||
SourceScanId = sourceScanId,
|
||||
SourceGraphId = sourceGraphId,
|
||||
SourceSbomDigest = sourceSbomDigest,
|
||||
ValidFrom = now,
|
||||
ValidTo = null,
|
||||
SysFrom = now,
|
||||
SysTo = null,
|
||||
ResolvedAt = null,
|
||||
ResolutionType = null,
|
||||
ResolutionRef = null,
|
||||
ResolutionNotes = null,
|
||||
CreatedAt = now,
|
||||
CreatedBy = createdBy,
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Unknown?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT {SelectColumns}
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND sys_to IS NULL
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapUnknown(reader);
|
||||
}
|
||||
|
||||
public async Task<Unknown?> GetBySubjectHashAsync(
|
||||
string tenantId,
|
||||
string subjectHash,
|
||||
UnknownKind kind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT {SelectColumns}
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND subject_hash = @subject_hash
|
||||
AND kind = @kind::unknowns.unknown_kind
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("subject_hash", subjectHash);
|
||||
command.Parameters.AddWithValue("kind", MapUnknownKind(kind));
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapUnknown(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Unknown>> GetOpenUnknownsAsync(
|
||||
string tenantId,
|
||||
int? limit = null,
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT {SelectColumns}
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
ORDER BY created_at DESC
|
||||
{(limit.HasValue ? "LIMIT @limit" : "")}
|
||||
{(offset.HasValue ? "OFFSET @offset" : "")}
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
if (limit.HasValue)
|
||||
command.Parameters.AddWithValue("limit", limit.Value);
|
||||
if (offset.HasValue)
|
||||
command.Parameters.AddWithValue("offset", offset.Value);
|
||||
|
||||
return await ReadUnknownsAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Unknown>> GetByKindAsync(
|
||||
string tenantId,
|
||||
UnknownKind kind,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT {SelectColumns}
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND kind = @kind::unknowns.unknown_kind
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
ORDER BY created_at DESC
|
||||
{(limit.HasValue ? "LIMIT @limit" : "")}
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("kind", MapUnknownKind(kind));
|
||||
if (limit.HasValue)
|
||||
command.Parameters.AddWithValue("limit", limit.Value);
|
||||
|
||||
return await ReadUnknownsAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Unknown>> GetBySeverityAsync(
|
||||
string tenantId,
|
||||
UnknownSeverity severity,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT {SelectColumns}
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND severity = @severity::unknowns.unknown_severity
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
ORDER BY created_at DESC
|
||||
{(limit.HasValue ? "LIMIT @limit" : "")}
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("severity", MapSeverity(severity));
|
||||
if (limit.HasValue)
|
||||
command.Parameters.AddWithValue("limit", limit.Value);
|
||||
|
||||
return await ReadUnknownsAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Unknown>> GetByScanIdAsync(
|
||||
string tenantId,
|
||||
Guid scanId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT {SelectColumns}
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND source_scan_id = @scan_id
|
||||
AND sys_to IS NULL
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("scan_id", scanId);
|
||||
|
||||
return await ReadUnknownsAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Unknown>> AsOfAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset validAt,
|
||||
DateTimeOffset? systemAt = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sysAt = systemAt ?? DateTimeOffset.UtcNow;
|
||||
|
||||
var sql = $"""
|
||||
SELECT {SelectColumns}
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND valid_from <= @valid_at
|
||||
AND (valid_to IS NULL OR valid_to > @valid_at)
|
||||
AND sys_from <= @sys_at
|
||||
AND (sys_to IS NULL OR sys_to > @sys_at)
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("valid_at", validAt);
|
||||
command.Parameters.AddWithValue("sys_at", sysAt);
|
||||
|
||||
return await ReadUnknownsAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Unknown> ResolveAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
ResolutionType resolutionType,
|
||||
string? resolutionRef,
|
||||
string? resolutionNotes,
|
||||
string resolvedBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
const string sql = """
|
||||
UPDATE unknowns.unknown
|
||||
SET resolved_at = @resolved_at,
|
||||
resolution_type = @resolution_type::unknowns.resolution_type,
|
||||
resolution_ref = @resolution_ref,
|
||||
resolution_notes = @resolution_notes,
|
||||
valid_to = @valid_to,
|
||||
updated_at = @updated_at
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND id = @id
|
||||
AND sys_to IS NULL
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("id", id);
|
||||
command.Parameters.AddWithValue("resolved_at", now);
|
||||
command.Parameters.AddWithValue("resolution_type", MapResolutionType(resolutionType));
|
||||
command.Parameters.AddWithValue("resolution_ref", (object?)resolutionRef ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("resolution_notes", (object?)resolutionNotes ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("valid_to", now);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
if (affected == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown {id} not found or already superseded.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Resolved unknown {Id} with type {ResolutionType}", id, resolutionType);
|
||||
|
||||
var resolved = await GetByIdAsync(tenantId, id, cancellationToken);
|
||||
return resolved ?? throw new InvalidOperationException($"Failed to retrieve resolved unknown {id}.");
|
||||
}
|
||||
|
||||
public async Task SupersedeAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string supersededBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
const string sql = """
|
||||
UPDATE unknowns.unknown
|
||||
SET sys_to = @sys_to,
|
||||
updated_at = @updated_at
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND id = @id
|
||||
AND sys_to IS NULL
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("id", id);
|
||||
command.Parameters.AddWithValue("sys_to", now);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
if (affected == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown {id} not found or already superseded.");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Superseded unknown {Id} by {SupersededBy}", id, supersededBy);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<UnknownKind, long>> CountByKindAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT kind::text, count(*)
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
GROUP BY kind
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = new Dictionary<UnknownKind, long>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var kindStr = reader.GetFieldValue<string>(0);
|
||||
var count = reader.GetInt64(1);
|
||||
result[ParseUnknownKind(kindStr)] = count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<UnknownSeverity, long>> CountBySeverityAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT severity::text, count(*)
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
AND severity IS NOT NULL
|
||||
GROUP BY severity
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = new Dictionary<UnknownSeverity, long>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var severityStr = reader.GetFieldValue<string>(0);
|
||||
var count = reader.GetInt64(1);
|
||||
result[ParseSeverity(severityStr)] = count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<long> CountOpenAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT count(*)
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return result is long count ? count : 0;
|
||||
}
|
||||
|
||||
private static async Task SetTenantContextAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"SELECT set_config('app.tenant_id', @tenant_id, false)",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<Unknown>> ReadUnknownsAsync(
|
||||
NpgsqlCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<Unknown>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
results.Add(MapUnknown(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Unknown MapUnknown(NpgsqlDataReader reader)
|
||||
{
|
||||
var contextJson = reader.IsDBNull(7) ? null : reader.GetFieldValue<string>(7);
|
||||
|
||||
return new Unknown
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
SubjectHash = reader.GetString(2),
|
||||
SubjectType = ParseSubjectType(reader.GetFieldValue<string>(3)),
|
||||
SubjectRef = reader.GetString(4),
|
||||
Kind = ParseUnknownKind(reader.GetFieldValue<string>(5)),
|
||||
Severity = reader.IsDBNull(6) ? null : ParseSeverity(reader.GetFieldValue<string>(6)),
|
||||
Context = contextJson is not null ? JsonDocument.Parse(contextJson) : null,
|
||||
SourceScanId = reader.IsDBNull(8) ? null : reader.GetGuid(8),
|
||||
SourceGraphId = reader.IsDBNull(9) ? null : reader.GetGuid(9),
|
||||
SourceSbomDigest = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
ValidFrom = reader.GetFieldValue<DateTimeOffset>(11),
|
||||
ValidTo = reader.IsDBNull(12) ? null : reader.GetFieldValue<DateTimeOffset>(12),
|
||||
SysFrom = reader.GetFieldValue<DateTimeOffset>(13),
|
||||
SysTo = reader.IsDBNull(14) ? null : reader.GetFieldValue<DateTimeOffset>(14),
|
||||
ResolvedAt = reader.IsDBNull(15) ? null : reader.GetFieldValue<DateTimeOffset>(15),
|
||||
ResolutionType = reader.IsDBNull(16) ? null : ParseResolutionType(reader.GetFieldValue<string>(16)),
|
||||
ResolutionRef = reader.IsDBNull(17) ? null : reader.GetString(17),
|
||||
ResolutionNotes = reader.IsDBNull(18) ? null : reader.GetString(18),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(19),
|
||||
CreatedBy = reader.GetString(20),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(21)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeSubjectHash(string subjectRef)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(subjectRef));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
|
||||
// Enum mapping helpers
|
||||
private static string MapSubjectType(UnknownSubjectType type) => type switch
|
||||
{
|
||||
UnknownSubjectType.Package => "package",
|
||||
UnknownSubjectType.Ecosystem => "ecosystem",
|
||||
UnknownSubjectType.Version => "version",
|
||||
UnknownSubjectType.SbomEdge => "sbom_edge",
|
||||
UnknownSubjectType.File => "file",
|
||||
UnknownSubjectType.Runtime => "runtime",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type))
|
||||
};
|
||||
|
||||
private static UnknownSubjectType ParseSubjectType(string value) => value switch
|
||||
{
|
||||
"package" => UnknownSubjectType.Package,
|
||||
"ecosystem" => UnknownSubjectType.Ecosystem,
|
||||
"version" => UnknownSubjectType.Version,
|
||||
"sbom_edge" => UnknownSubjectType.SbomEdge,
|
||||
"file" => UnknownSubjectType.File,
|
||||
"runtime" => UnknownSubjectType.Runtime,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value))
|
||||
};
|
||||
|
||||
private static string MapUnknownKind(UnknownKind kind) => kind switch
|
||||
{
|
||||
UnknownKind.MissingSbom => "missing_sbom",
|
||||
UnknownKind.AmbiguousPackage => "ambiguous_package",
|
||||
UnknownKind.MissingFeed => "missing_feed",
|
||||
UnknownKind.UnresolvedEdge => "unresolved_edge",
|
||||
UnknownKind.NoVersionInfo => "no_version_info",
|
||||
UnknownKind.UnknownEcosystem => "unknown_ecosystem",
|
||||
UnknownKind.PartialMatch => "partial_match",
|
||||
UnknownKind.VersionRangeUnbounded => "version_range_unbounded",
|
||||
UnknownKind.UnsupportedFormat => "unsupported_format",
|
||||
UnknownKind.TransitiveGap => "transitive_gap",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
|
||||
private static UnknownKind ParseUnknownKind(string value) => value switch
|
||||
{
|
||||
"missing_sbom" => UnknownKind.MissingSbom,
|
||||
"ambiguous_package" => UnknownKind.AmbiguousPackage,
|
||||
"missing_feed" => UnknownKind.MissingFeed,
|
||||
"unresolved_edge" => UnknownKind.UnresolvedEdge,
|
||||
"no_version_info" => UnknownKind.NoVersionInfo,
|
||||
"unknown_ecosystem" => UnknownKind.UnknownEcosystem,
|
||||
"partial_match" => UnknownKind.PartialMatch,
|
||||
"version_range_unbounded" => UnknownKind.VersionRangeUnbounded,
|
||||
"unsupported_format" => UnknownKind.UnsupportedFormat,
|
||||
"transitive_gap" => UnknownKind.TransitiveGap,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value))
|
||||
};
|
||||
|
||||
private static string MapSeverity(UnknownSeverity severity) => severity switch
|
||||
{
|
||||
UnknownSeverity.Critical => "critical",
|
||||
UnknownSeverity.High => "high",
|
||||
UnknownSeverity.Medium => "medium",
|
||||
UnknownSeverity.Low => "low",
|
||||
UnknownSeverity.Info => "info",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(severity))
|
||||
};
|
||||
|
||||
private static UnknownSeverity ParseSeverity(string value) => value switch
|
||||
{
|
||||
"critical" => UnknownSeverity.Critical,
|
||||
"high" => UnknownSeverity.High,
|
||||
"medium" => UnknownSeverity.Medium,
|
||||
"low" => UnknownSeverity.Low,
|
||||
"info" => UnknownSeverity.Info,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value))
|
||||
};
|
||||
|
||||
private static string MapResolutionType(ResolutionType type) => type switch
|
||||
{
|
||||
ResolutionType.FeedUpdated => "feed_updated",
|
||||
ResolutionType.SbomProvided => "sbom_provided",
|
||||
ResolutionType.ManualMapping => "manual_mapping",
|
||||
ResolutionType.Superseded => "superseded",
|
||||
ResolutionType.FalsePositive => "false_positive",
|
||||
ResolutionType.WontFix => "wont_fix",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type))
|
||||
};
|
||||
|
||||
private static ResolutionType ParseResolutionType(string value) => value switch
|
||||
{
|
||||
"feed_updated" => ResolutionType.FeedUpdated,
|
||||
"sbom_provided" => ResolutionType.SbomProvided,
|
||||
"manual_mapping" => ResolutionType.ManualMapping,
|
||||
"superseded" => ResolutionType.Superseded,
|
||||
"false_positive" => ResolutionType.FalsePositive,
|
||||
"wont_fix" => ResolutionType.WontFix,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Unknowns.Storage.Postgres</RootNamespace>
|
||||
<Description>PostgreSQL storage implementation for the StellaOps Unknowns module (bitemporal ambiguity tracking)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-preview.1.25080.5" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,327 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Storage.Postgres.Repositories;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Unknowns.Storage.Postgres.Tests;
|
||||
|
||||
public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16")
|
||||
.Build();
|
||||
|
||||
private NpgsqlDataSource _dataSource = null!;
|
||||
private PostgresUnknownRepository _repository = null!;
|
||||
private const string TestTenantId = "test-tenant";
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _postgres.StartAsync();
|
||||
|
||||
var connectionString = _postgres.GetConnectionString();
|
||||
_dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
|
||||
// Run schema migrations
|
||||
await RunMigrationsAsync();
|
||||
|
||||
_repository = new PostgresUnknownRepository(
|
||||
_dataSource,
|
||||
NullLogger<PostgresUnknownRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
await _postgres.DisposeAsync();
|
||||
}
|
||||
|
||||
private async Task RunMigrationsAsync()
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync();
|
||||
|
||||
// Create schema and types
|
||||
const string schema = """
|
||||
CREATE SCHEMA IF NOT EXISTS unknowns;
|
||||
CREATE SCHEMA IF NOT EXISTS unknowns_app;
|
||||
|
||||
CREATE OR REPLACE FUNCTION unknowns_app.require_current_tenant()
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant TEXT;
|
||||
BEGIN
|
||||
v_tenant := current_setting('app.tenant_id', true);
|
||||
IF v_tenant IS NULL OR v_tenant = '' THEN
|
||||
RAISE EXCEPTION 'app.tenant_id session variable not set';
|
||||
END IF;
|
||||
RETURN v_tenant;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.subject_type AS ENUM (
|
||||
'package', 'ecosystem', 'version', 'sbom_edge', 'file', 'runtime'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.unknown_kind AS ENUM (
|
||||
'missing_sbom', 'ambiguous_package', 'missing_feed', 'unresolved_edge',
|
||||
'no_version_info', 'unknown_ecosystem', 'partial_match',
|
||||
'version_range_unbounded', 'unsupported_format', 'transitive_gap'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.unknown_severity AS ENUM (
|
||||
'critical', 'high', 'medium', 'low', 'info'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.resolution_type AS ENUM (
|
||||
'feed_updated', 'sbom_provided', 'manual_mapping',
|
||||
'superseded', 'false_positive', 'wont_fix'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS unknowns.unknown (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
subject_hash CHAR(64) NOT NULL,
|
||||
subject_type unknowns.subject_type NOT NULL,
|
||||
subject_ref TEXT NOT NULL,
|
||||
kind unknowns.unknown_kind NOT NULL,
|
||||
severity unknowns.unknown_severity,
|
||||
context JSONB NOT NULL DEFAULT '{}',
|
||||
source_scan_id UUID,
|
||||
source_graph_id UUID,
|
||||
source_sbom_digest TEXT,
|
||||
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
valid_to TIMESTAMPTZ,
|
||||
sys_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
sys_to TIMESTAMPTZ,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolution_type unknowns.resolution_type,
|
||||
resolution_ref TEXT,
|
||||
resolution_notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_unknown_one_open_per_subject
|
||||
ON unknowns.unknown (tenant_id, subject_hash, kind)
|
||||
WHERE valid_to IS NULL AND sys_to IS NULL;
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(schema, connection);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ShouldCreateUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var subjectRef = "pkg:npm/lodash@4.17.21";
|
||||
var kind = UnknownKind.MissingFeed;
|
||||
var severity = UnknownSeverity.Medium;
|
||||
|
||||
// Act
|
||||
var result = await _repository.CreateAsync(
|
||||
TestTenantId,
|
||||
UnknownSubjectType.Package,
|
||||
subjectRef,
|
||||
kind,
|
||||
severity,
|
||||
"""{"ecosystem": "npm"}""",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"test-user",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.TenantId.Should().Be(TestTenantId);
|
||||
result.SubjectRef.Should().Be(subjectRef);
|
||||
result.Kind.Should().Be(kind);
|
||||
result.Severity.Should().Be(severity);
|
||||
result.IsOpen.Should().BeTrue();
|
||||
result.IsResolved.Should().BeFalse();
|
||||
result.IsCurrent.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ShouldReturnUnknown_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _repository.CreateAsync(
|
||||
TestTenantId,
|
||||
UnknownSubjectType.Package,
|
||||
"pkg:npm/axios@0.21.0",
|
||||
UnknownKind.AmbiguousPackage,
|
||||
UnknownSeverity.Low,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"test-user",
|
||||
CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var result = await _repository.GetByIdAsync(TestTenantId, created.Id, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(created.Id);
|
||||
result.SubjectRef.Should().Be("pkg:npm/axios@0.21.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ShouldReturnNull_WhenNotExists()
|
||||
{
|
||||
// Act
|
||||
var result = await _repository.GetByIdAsync(TestTenantId, Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOpenUnknownsAsync_ShouldReturnOnlyOpenUnknowns()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.CreateAsync(
|
||||
TestTenantId,
|
||||
UnknownSubjectType.Package,
|
||||
"pkg:npm/open1@1.0.0",
|
||||
UnknownKind.MissingFeed,
|
||||
UnknownSeverity.High,
|
||||
null, null, null, null, "test-user", CancellationToken.None);
|
||||
|
||||
var toResolve = await _repository.CreateAsync(
|
||||
TestTenantId,
|
||||
UnknownSubjectType.Package,
|
||||
"pkg:npm/resolved@1.0.0",
|
||||
UnknownKind.MissingFeed,
|
||||
UnknownSeverity.Medium,
|
||||
null, null, null, null, "test-user", CancellationToken.None);
|
||||
|
||||
await _repository.ResolveAsync(
|
||||
TestTenantId,
|
||||
toResolve.Id,
|
||||
ResolutionType.FeedUpdated,
|
||||
null,
|
||||
"Feed now covers this package",
|
||||
"test-user",
|
||||
CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetOpenUnknownsAsync(TestTenantId, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].SubjectRef.Should().Be("pkg:npm/open1@1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ShouldMarkAsResolved()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _repository.CreateAsync(
|
||||
TestTenantId,
|
||||
UnknownSubjectType.Package,
|
||||
"pkg:npm/resolvable@2.0.0",
|
||||
UnknownKind.MissingSbom,
|
||||
UnknownSeverity.High,
|
||||
null, null, null, null, "test-user", CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var resolved = await _repository.ResolveAsync(
|
||||
TestTenantId,
|
||||
created.Id,
|
||||
ResolutionType.SbomProvided,
|
||||
"sbom://digest/abc123",
|
||||
"SBOM uploaded by vendor",
|
||||
"test-user",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
resolved.IsResolved.Should().BeTrue();
|
||||
resolved.ResolutionType.Should().Be(ResolutionType.SbomProvided);
|
||||
resolved.ResolutionRef.Should().Be("sbom://digest/abc123");
|
||||
resolved.ValidTo.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CountByKindAsync_ShouldReturnCorrectCounts()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = "count-test-tenant";
|
||||
|
||||
await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
|
||||
"pkg:1", UnknownKind.MissingFeed, null, null, null, null, null, "user", CancellationToken.None);
|
||||
await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
|
||||
"pkg:2", UnknownKind.MissingFeed, null, null, null, null, null, "user", CancellationToken.None);
|
||||
await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
|
||||
"pkg:3", UnknownKind.AmbiguousPackage, null, null, null, null, null, "user", CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var counts = await _repository.CountByKindAsync(tenant, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
counts.Should().ContainKey(UnknownKind.MissingFeed);
|
||||
counts[UnknownKind.MissingFeed].Should().Be(2);
|
||||
counts.Should().ContainKey(UnknownKind.AmbiguousPackage);
|
||||
counts[UnknownKind.AmbiguousPackage].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsOfAsync_ShouldReturnHistoricalState()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = "temporal-test-tenant";
|
||||
var beforeCreate = DateTimeOffset.UtcNow.AddSeconds(-1);
|
||||
|
||||
var created = await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
|
||||
"pkg:temporal@1.0.0", UnknownKind.NoVersionInfo, null, null, null, null, null, "user", CancellationToken.None);
|
||||
|
||||
var afterCreate = DateTimeOffset.UtcNow.AddSeconds(1);
|
||||
|
||||
// Act
|
||||
var beforeResults = await _repository.AsOfAsync(tenant, beforeCreate, cancellationToken: CancellationToken.None);
|
||||
var afterResults = await _repository.AsOfAsync(tenant, afterCreate, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
beforeResults.Should().BeEmpty();
|
||||
afterResults.Should().HaveCount(1);
|
||||
afterResults[0].Id.Should().Be(created.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SupersedeAsync_ShouldSetSysTo()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = "supersede-test-tenant";
|
||||
var created = await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
|
||||
"pkg:supersede@1.0.0", UnknownKind.PartialMatch, null, null, null, null, null, "user", CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await _repository.SupersedeAsync(tenant, created.Id, "new-unknown-id", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetByIdAsync(tenant, created.Id, CancellationToken.None);
|
||||
// After supersede, sys_to is set, so GetById (which filters sys_to IS NULL) returns null
|
||||
result.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Unknowns.Storage.Postgres.Tests</RootNamespace>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.4.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Unknowns.Storage.Postgres\StellaOps.Unknowns.Storage.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user