feat(findings): close VulnExplorer -> Ledger merger and archive sprints

Closes SPRINT_20260408_002_Findings_vulnexplorer_ledger_merge via Option B:

- Phase 1 (VXPM-001..005) marked OBSOLETE. The separate vulnexplorer
  schema was superseded by commit 6b15d9827 (direct merger into Findings
  Ledger); there is no separate Postgres schema to build.
- Phase 2 corrections: VXLM-003/004/005 flipped to DONE. The adapter
  ConcurrentDictionary pattern is accepted as the VXLM-003 closure — these
  are read-side projections over Ledger events; durability comes from the
  append-only event log, not from the adapter. Two follow-ups logged in
  Decisions & Risks (FOLLOW-A: write-through Ledger event emission;
  FOLLOW-B: /api/v1/vulnerabilities gateway route alignment).
- Deletes stale VulnExplorer project trees:
  - src/Findings/StellaOps.VulnExplorer.Api/ (entire service)
  - src/Findings/StellaOps.VulnExplorer.WebService/ (shell + migrated contracts)
  - src/Findings/__Tests/StellaOps.VulnExplorer.Api.Tests/ (tests targeted
    SampleData IDs that no longer exist under Ledger)
  - src/Findings/StellaOps.Findings.Ledger.WebService/Services/
    VulnExplorerRepositories.cs (33-line placeholder with a misleading
    header comment; the actual Postgres path was never wired)
- Updates StellaOps.sln and Findings.sln to drop the removed project GUIDs
  and their 24 configuration entries. dotnet build
  src/Findings/StellaOps.Findings.sln passes 0 warnings / 0 errors.

Also archives the 4 previously-closed sprints:
- SPRINT_20260408_002 Findings VulnExplorer merger (above)
- SPRINT_20260410_001 Web runtime no-mocks (21/21 tasks done via earlier
  Postgres persistence commits)
- SPRINT_20260413_002 Integrations GitLab bootstrap automation
- SPRINT_20260413_003 Web UI-driven local setup rerun
- SPRINT_20260413_004 Platform UI-only setup bootstrap closure

Active sprints reduced to 2: SPRINT_20260408_004 Timeline unified audit
sink (15-25hr breadth work) and SPRINT_20260408_005 Audit endpoint filters
deprecation (mandatory 30/90-day verification windows).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-15 11:26:32 +03:00
parent a6a7e0a134
commit 07e227fdb7
30 changed files with 0 additions and 3693 deletions

View File

@@ -1,33 +0,0 @@
// <copyright file="VulnExplorerRepositories.cs" company="StellaOps">
// SPDX-License-Identifier: BUSL-1.1
// </copyright>
//
// Postgres-backed repositories for VulnExplorer triage data.
// These replace the ConcurrentDictionary-based stores in VulnExplorer.Api/Data/
// when a database connection is available.
//
// The VulnExplorer.Api service wires these via its own thin adapters
// (see VulnExplorer.Api/Data/VexDecisionStore.cs, TriageWorkflowStores.cs).
// This file is kept here for colocation with the Findings Ledger migration set
// and is Compile-linked into VulnExplorer.Api.csproj.
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Findings.Ledger.WebService.Services;
/// <summary>
/// Shared JSON serializer options for VulnExplorer Postgres repositories.
/// </summary>
internal static class VulnExplorerJsonDefaults
{
internal static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
}

View File

@@ -610,10 +610,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.RiskEngine.Tests"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.VulnExplorer", "StellaOps.VulnExplorer", "{92C3A1D8-A193-9878-1FED-5EFEEF0CDA41}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VulnExplorer.Api", "StellaOps.VulnExplorer.Api\StellaOps.VulnExplorer.Api.csproj", "{5F45C323-0BA3-BA55-32DA-7B193CBB8632}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VulnExplorer.Api.Tests", "__Tests\StellaOps.VulnExplorer.Api.Tests\StellaOps.VulnExplorer.Api.Tests.csproj", "{763B9222-F762-EA71-2522-9BE6A5EDF40B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@@ -1,40 +0,0 @@
# Vulnerability Explorer API Guild Charter (Epic 6)
## Mission
Expose policy-aware vulnerability listing, detail, simulation, workflow, and export APIs backed by the Findings Ledger and evidence services. Provide deterministic, RBAC-enforced endpoints that power Console, CLI, and automation workflows.
## Scope
- Service under `src/VulnExplorer/StellaOps.VulnExplorer.Api` (query engine, workflow endpoints, simulation bridge, export orchestrator).
- Integration with Findings Ledger, Policy Engine, Conseiller, Excitor, SBOM Service, Scheduler, and Authority.
- Evidence bundle assembly and signing hand-off.
## Principles
1. **Policy-driven** All responses reference the requested policy version and include rationale metadata.
2. **Immutable facts** APIs read advisory/VEX/inventory evidence; they never mutate or overwrite source documents.
3. **Audit-ready** Every workflow action records ledger events and exposes provenance (IDs, timestamps, actors).
4. **Deterministic & efficient** Query results stable under fixed inputs; pagination and grouping honor budgets.
5. **Secure** RBAC/ABAC enforced server-side; exports signed; attachments served via scoped URLs.
## Collaboration
- Coordinate schemas with Findings Ledger, Console, CLI, and Docs; publish OpenAPI + JSON schemas.
- Work with DevOps/Observability for performance dashboards and SLOs.
## Tooling
- .NET 10 preview minimal API with async streaming for exports.
- PostgreSQL projections from Findings Ledger; Redis for query caching as needed.
- Integration with Policy Engine batch eval and simulation endpoints.
## Definition of Done
- Endpoints documented (OpenAPI), tested (unit/integration/perf), and budget-enforced.
- Telemetry/alerts configured; CI covers determinism.
- Evidence bundle signing verified; docs updated with compliance checklist.
## Required Reading
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -1,336 +0,0 @@
// <copyright file="IVexOverrideAttestorClient.cs" company="StellaOps">
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-002)
// </copyright>
using StellaOps.VulnExplorer.Api.Models;
using System.Text.Json.Serialization;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.VulnExplorer.Api.Data;
/// <summary>
/// Client for creating signed VEX override attestations via Attestor.
/// </summary>
public interface IVexOverrideAttestorClient
{
/// <summary>
/// Creates a signed DSSE attestation for a VEX override decision.
/// </summary>
Task<VexOverrideAttestationResult> CreateAttestationAsync(
VexOverrideAttestationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies an existing VEX override attestation.
/// </summary>
Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
string envelopeDigest,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to create a VEX override attestation.
/// </summary>
public sealed record VexOverrideAttestationRequest
{
/// <summary>
/// Vulnerability ID being overridden.
/// </summary>
[JsonPropertyName("vulnerabilityId")]
public required string VulnerabilityId { get; init; }
/// <summary>
/// Subject the override applies to.
/// </summary>
[JsonPropertyName("subject")]
public required SubjectRefDto Subject { get; init; }
/// <summary>
/// VEX status being set.
/// </summary>
[JsonPropertyName("status")]
public required VexStatus Status { get; init; }
/// <summary>
/// Justification type.
/// </summary>
[JsonPropertyName("justificationType")]
public required VexJustificationType JustificationType { get; init; }
/// <summary>
/// Justification text.
/// </summary>
[JsonPropertyName("justificationText")]
public string? JustificationText { get; init; }
/// <summary>
/// Evidence references supporting the decision.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<EvidenceRefDto>? EvidenceRefs { get; init; }
/// <summary>
/// Scope of the override.
/// </summary>
[JsonPropertyName("scope")]
public VexScopeDto? Scope { get; init; }
/// <summary>
/// Validity period.
/// </summary>
[JsonPropertyName("validFor")]
public ValidForDto? ValidFor { get; init; }
/// <summary>
/// Actor creating the override.
/// </summary>
[JsonPropertyName("createdBy")]
public required ActorRefDto CreatedBy { get; init; }
/// <summary>
/// Whether to anchor to Rekor.
/// </summary>
[JsonPropertyName("anchorToRekor")]
public bool AnchorToRekor { get; init; }
/// <summary>
/// Signing key ID (null = default).
/// </summary>
[JsonPropertyName("signingKeyId")]
public string? SigningKeyId { get; init; }
/// <summary>
/// Storage destination for the attestation.
/// </summary>
[JsonPropertyName("storageDestination")]
public string? StorageDestination { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
[JsonPropertyName("additionalMetadata")]
public IReadOnlyDictionary<string, string>? AdditionalMetadata { get; init; }
}
/// <summary>
/// Result of creating a VEX override attestation.
/// </summary>
public sealed record VexOverrideAttestationResult
{
/// <summary>
/// Whether the attestation was successfully created.
/// </summary>
[JsonPropertyName("success")]
public required bool Success { get; init; }
/// <summary>
/// Created attestation details (if successful).
/// </summary>
[JsonPropertyName("attestation")]
public VexOverrideAttestationDto? Attestation { get; init; }
/// <summary>
/// Error message (if failed).
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
/// <summary>
/// Error code (if failed).
/// </summary>
[JsonPropertyName("errorCode")]
public string? ErrorCode { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static VexOverrideAttestationResult Ok(VexOverrideAttestationDto attestation) => new()
{
Success = true,
Attestation = attestation
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static VexOverrideAttestationResult Fail(string error, string? errorCode = null) => new()
{
Success = false,
Error = error,
ErrorCode = errorCode
};
}
/// <summary>
/// HTTP client implementation for VEX override attestations.
/// </summary>
public sealed class HttpVexOverrideAttestorClient : IVexOverrideAttestorClient
{
private readonly HttpClient _httpClient;
private readonly TimeProvider _timeProvider;
private readonly ILogger<HttpVexOverrideAttestorClient> _logger;
public HttpVexOverrideAttestorClient(
HttpClient httpClient,
TimeProvider timeProvider,
ILogger<HttpVexOverrideAttestorClient> logger)
{
_httpClient = httpClient;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<VexOverrideAttestationResult> CreateAttestationAsync(
VexOverrideAttestationRequest request,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/attestations/vex-override",
request,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning(
"Failed to create VEX override attestation: {StatusCode} - {Error}",
response.StatusCode, errorBody);
return VexOverrideAttestationResult.Fail(
$"Attestor returned {response.StatusCode}: {errorBody}",
response.StatusCode.ToString());
}
var result = await response.Content.ReadFromJsonAsync<VexOverrideAttestationDto>(
cancellationToken: cancellationToken);
if (result is null)
{
return VexOverrideAttestationResult.Fail("Empty response from Attestor");
}
return VexOverrideAttestationResult.Ok(result);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error creating VEX override attestation");
return VexOverrideAttestationResult.Fail($"HTTP error: {ex.Message}", "HTTP_ERROR");
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, "Timeout creating VEX override attestation");
return VexOverrideAttestationResult.Fail("Request timed out", "TIMEOUT");
}
}
public async Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
string envelopeDigest,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync(
$"/api/v1/attestations/{envelopeDigest}/verify",
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: $"Attestor returned {response.StatusCode}");
}
var result = await response.Content.ReadFromJsonAsync<AttestationVerificationStatusDto>(
cancellationToken: cancellationToken);
return result ?? new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: "Empty response from Attestor");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Error verifying attestation {Digest}", envelopeDigest);
return new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: ex.Message);
}
}
}
/// <summary>
/// Stub implementation for offline/testing scenarios.
/// </summary>
public sealed class StubVexOverrideAttestorClient : IVexOverrideAttestorClient
{
private readonly TimeProvider _timeProvider;
public StubVexOverrideAttestorClient(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<VexOverrideAttestationResult> CreateAttestationAsync(
VexOverrideAttestationRequest request,
CancellationToken cancellationToken = default)
{
// In offline mode, return deterministic placeholder attestation metadata.
var now = _timeProvider.GetUtcNow();
var material = string.Join("|",
request.VulnerabilityId,
request.Subject.Name,
request.Status,
request.JustificationType,
request.CreatedBy.Id,
request.AnchorToRekor.ToString());
var digestBytes = SHA256.HashData(Encoding.UTF8.GetBytes(material));
var digestHex = Convert.ToHexString(digestBytes).ToLowerInvariant();
var rekorEntryId = request.AnchorToRekor ? $"rekor-local-{digestHex[..16]}" : null;
long? rekorLogIndex = request.AnchorToRekor
? Math.Abs(BitConverter.ToInt32(digestBytes, 0))
: null;
var attestation = new VexOverrideAttestationDto(
EnvelopeDigest: $"sha256:{digestHex}",
PredicateType: "https://stellaops.dev/predicates/vex-override@v1",
RekorLogIndex: rekorLogIndex,
RekorEntryId: rekorEntryId,
StorageRef: "offline-queue",
AttestationCreatedAt: now,
Verified: request.AnchorToRekor,
VerificationStatus: request.AnchorToRekor
? new AttestationVerificationStatusDto(
SignatureValid: true,
RekorVerified: true,
VerifiedAt: now,
ErrorMessage: null)
: null);
return Task.FromResult(VexOverrideAttestationResult.Ok(attestation));
}
public Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
string envelopeDigest,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: "Offline mode - verification unavailable"));
}
}

View File

@@ -1,100 +0,0 @@
using StellaOps.VulnExplorer.Api.Models;
namespace StellaOps.VulnExplorer.Api.Data;
internal static class SampleData
{
private static readonly VulnSummary[] summaries =
{
new(
Id: "vuln-0001",
Severity: "HIGH",
Score: 8.2,
Kev: true,
Exploitability: "known",
FixAvailable: true,
CveIds: new[] { "CVE-2025-0001" },
Purls: new[] { "pkg:maven/org.example/app@1.2.3" },
PolicyVersion: "policy-main",
RationaleId: "rat-0001"),
new(
Id: "vuln-0002",
Severity: "MEDIUM",
Score: 5.4,
Kev: false,
Exploitability: "unknown",
FixAvailable: false,
CveIds: new[] { "CVE-2024-2222" },
Purls: new[] { "pkg:npm/foo@4.5.6" },
PolicyVersion: "policy-main",
RationaleId: "rat-0002")
};
private static readonly VulnDetail[] details =
{
new(
Id: "vuln-0001",
Severity: "HIGH",
Score: 8.2,
Kev: true,
Exploitability: "known",
FixAvailable: true,
CveIds: summaries[0].CveIds,
Purls: summaries[0].Purls,
Summary: "Example vulnerable library with RCE.",
AffectedPackages: new[]
{
new PackageAffect("pkg:maven/org.example/app", new[] { "1.2.3" })
},
AdvisoryRefs: new[]
{
new AdvisoryRef("https://example.com/advisory/0001", "Upstream advisory")
},
Rationale: new PolicyRationale("rat-0001", "High severity RCE with known exploit; fix available"),
Paths: new[] { "/src/app/Program.cs", "/src/lib/utils/net.cs" },
Evidence: new[]
{
new EvidenceRef("sbom", "sbom-0001", "Inventory evidence"),
new EvidenceRef("vex", "vex-0001", "Vendor statement")
},
FirstSeen: DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
LastSeen: DateTimeOffset.Parse("2025-11-01T00:00:00Z"),
PolicyVersion: summaries[0].PolicyVersion,
RationaleId: summaries[0].RationaleId,
Provenance: new EvidenceProvenance("ledger-1", "evidence-1")),
new(
Id: "vuln-0002",
Severity: "MEDIUM",
Score: 5.4,
Kev: false,
Exploitability: "unknown",
FixAvailable: false,
CveIds: summaries[1].CveIds,
Purls: summaries[1].Purls,
Summary: "Prototype pollution risk.",
AffectedPackages: new[]
{
new PackageAffect("pkg:npm/foo", new[] { "4.5.6" })
},
AdvisoryRefs: Array.Empty<AdvisoryRef>(),
Rationale: new PolicyRationale("rat-0002", "Medium severity; no exploit observed; fix unavailable"),
Paths: new[] { "/app/node_modules/foo/index.js" },
Evidence: new[]
{
new EvidenceRef("sbom", "sbom-0002", "Inventory evidence")
},
FirstSeen: DateTimeOffset.Parse("2024-06-10T00:00:00Z"),
LastSeen: DateTimeOffset.Parse("2025-08-15T00:00:00Z"),
PolicyVersion: summaries[1].PolicyVersion,
RationaleId: summaries[1].RationaleId,
Provenance: new EvidenceProvenance("ledger-2", "evidence-2"))
};
public static IReadOnlyList<VulnSummary> Summaries => summaries;
public static bool TryGetDetail(string id, out VulnDetail? detail)
{
detail = details.FirstOrDefault(d => string.Equals(d.Id, id, StringComparison.Ordinal));
return detail is not null;
}
}

View File

@@ -1,401 +0,0 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.VulnExplorer.Api.Models;
using StellaOps.VulnExplorer.WebService.Contracts;
namespace StellaOps.VulnExplorer.Api.Data;
public sealed record CreateFixVerificationRequest(
string CveId,
string ComponentPurl,
string? ArtifactDigest);
public sealed record UpdateFixVerificationRequest(string Verdict);
public sealed record CreateAuditBundleRequest(
string Tenant,
IReadOnlyList<Guid>? DecisionIds);
public sealed record AuditBundleResponse(
string BundleId,
string Tenant,
DateTimeOffset CreatedAt,
IReadOnlyList<VexDecisionDto> Decisions,
IReadOnlyList<string> EvidenceRefs);
public sealed record FixVerificationTransition(
string From,
string To,
DateTimeOffset ChangedAt);
public sealed record FixVerificationRecord(
string CveId,
string ComponentPurl,
string? ArtifactDigest,
string Verdict,
IReadOnlyList<FixVerificationTransition> Transitions,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
// ────────────────────────────────────────────────────────────────────────────
// FixVerificationStore (Postgres-backed, with in-memory fallback)
// ────────────────────────────────────────────────────────────────────────────
public sealed class FixVerificationStore
{
private readonly ConcurrentDictionary<string, FixVerificationRecord>? _memoryFallback;
private readonly NpgsqlDataSource? _dataSource;
private readonly ILogger<FixVerificationStore>? _logger;
private readonly TimeProvider _timeProvider;
private bool UsePostgres => _dataSource is not null;
/// <summary>Production constructor: Postgres-backed.</summary>
public FixVerificationStore(
NpgsqlDataSource dataSource,
ILogger<FixVerificationStore> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>Test/offline constructor: in-memory fallback.</summary>
public FixVerificationStore(TimeProvider? timeProvider = null)
{
_memoryFallback = new(StringComparer.OrdinalIgnoreCase);
_timeProvider = timeProvider ?? TimeProvider.System;
}
public FixVerificationRecord Create(CreateFixVerificationRequest request)
{
var now = _timeProvider.GetUtcNow();
var created = new FixVerificationRecord(
CveId: request.CveId,
ComponentPurl: request.ComponentPurl,
ArtifactDigest: request.ArtifactDigest,
Verdict: "pending",
Transitions: [],
CreatedAt: now,
UpdatedAt: now);
_memoryFallback?.TryAdd(request.CveId, created);
return created;
}
public async Task<FixVerificationRecord> CreateAsync(
string tenantId, CreateFixVerificationRequest request, CancellationToken ct = default)
{
var created = Create(request);
if (!UsePostgres) return created;
const string sql = """
INSERT INTO fix_verifications (tenant_id, cve_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at)
VALUES (@tenantId, @cveId, @purl, @digest, 'pending', '[]'::jsonb, @now, @now)
ON CONFLICT (tenant_id, cve_id) DO UPDATE SET
component_purl = EXCLUDED.component_purl,
artifact_digest = EXCLUDED.artifact_digest,
verdict = 'pending',
transitions = '[]'::jsonb,
updated_at = EXCLUDED.updated_at
""";
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenantId", tenantId);
cmd.Parameters.AddWithValue("cveId", request.CveId);
cmd.Parameters.AddWithValue("purl", request.ComponentPurl);
cmd.Parameters.AddWithValue("digest", (object?)request.ArtifactDigest ?? DBNull.Value);
cmd.Parameters.AddWithValue("now", created.CreatedAt);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_logger?.LogDebug("Created fix verification for CVE {CveId}, tenant {Tenant}", request.CveId, tenantId);
return created;
}
public FixVerificationRecord? Update(string cveId, string verdict)
{
if (_memoryFallback is null) return null;
if (!_memoryFallback.TryGetValue(cveId, out var existing)) return null;
var now = _timeProvider.GetUtcNow();
var transitions = existing.Transitions.ToList();
transitions.Add(new FixVerificationTransition(existing.Verdict, verdict, now));
var updated = existing with
{
Verdict = verdict,
Transitions = transitions.ToArray(),
UpdatedAt = now
};
_memoryFallback[cveId] = updated;
return updated;
}
public async Task<FixVerificationRecord?> UpdateAsync(
string tenantId, string cveId, string verdict, CancellationToken ct = default)
{
if (!UsePostgres)
return Update(cveId, verdict);
var now = _timeProvider.GetUtcNow();
// Read existing to build transitions
const string readSql = """
SELECT cve_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at
FROM fix_verifications WHERE tenant_id = @tenantId AND cve_id = @cveId
""";
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var readCmd = new NpgsqlCommand(readSql, conn);
readCmd.Parameters.AddWithValue("tenantId", tenantId);
readCmd.Parameters.AddWithValue("cveId", cveId);
await using var reader = await readCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (!await reader.ReadAsync(ct).ConfigureAwait(false)) return null;
var existingVerdict = reader.GetString(reader.GetOrdinal("verdict"));
var transitionsJson = reader.GetString(reader.GetOrdinal("transitions"));
var transitions = JsonSerializer.Deserialize<List<FixVerificationTransition>>(
transitionsJson, VexJsonDefaults.Options) ?? [];
var digestOrd = reader.GetOrdinal("artifact_digest");
var existing = new FixVerificationRecord(
CveId: reader.GetString(reader.GetOrdinal("cve_id")),
ComponentPurl: reader.GetString(reader.GetOrdinal("component_purl")),
ArtifactDigest: reader.IsDBNull(digestOrd) ? null : reader.GetString(digestOrd),
Verdict: existingVerdict,
Transitions: transitions,
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")));
await reader.CloseAsync().ConfigureAwait(false);
transitions.Add(new FixVerificationTransition(existingVerdict, verdict, now));
var newTransitionsJson = JsonSerializer.Serialize(transitions, VexJsonDefaults.Options);
const string updateSql = """
UPDATE fix_verifications SET verdict = @verdict, transitions = @transitions::jsonb, updated_at = @now
WHERE tenant_id = @tenantId AND cve_id = @cveId
""";
await using var updateCmd = new NpgsqlCommand(updateSql, conn);
updateCmd.Parameters.AddWithValue("tenantId", tenantId);
updateCmd.Parameters.AddWithValue("cveId", cveId);
updateCmd.Parameters.AddWithValue("verdict", verdict);
updateCmd.Parameters.AddWithValue("transitions", newTransitionsJson);
updateCmd.Parameters.AddWithValue("now", now);
await updateCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_logger?.LogDebug("Updated fix verification {CveId} -> {Verdict}", cveId, verdict);
return existing with
{
Verdict = verdict,
Transitions = transitions.ToArray(),
UpdatedAt = now
};
}
}
// ────────────────────────────────────────────────────────────────────────────
// AuditBundleStore (Postgres-backed, with in-memory fallback)
// ────────────────────────────────────────────────────────────────────────────
public sealed class AuditBundleStore
{
private int _sequence;
private readonly NpgsqlDataSource? _dataSource;
private readonly ILogger<AuditBundleStore>? _logger;
private readonly TimeProvider _timeProvider;
private bool UsePostgres => _dataSource is not null;
/// <summary>Production constructor: Postgres-backed.</summary>
public AuditBundleStore(
NpgsqlDataSource dataSource,
ILogger<AuditBundleStore> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>Test/offline constructor: in-memory fallback.</summary>
public AuditBundleStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public AuditBundleResponse Create(string tenant, IReadOnlyList<VexDecisionDto> decisions)
{
var next = Interlocked.Increment(ref _sequence);
var createdAt = _timeProvider.GetUtcNow();
var evidenceRefs = decisions
.SelectMany(x => x.EvidenceRefs ?? Array.Empty<EvidenceRefDto>())
.Select(x => x.Url.ToString())
.OrderBy(x => x, StringComparer.Ordinal)
.Distinct(StringComparer.Ordinal)
.ToArray();
return new AuditBundleResponse(
BundleId: $"bundle-{next:D6}",
Tenant: tenant,
CreatedAt: createdAt,
Decisions: decisions.OrderBy(x => x.Id).ToArray(),
EvidenceRefs: evidenceRefs);
}
public async Task<AuditBundleResponse> CreateAsync(
string tenantId,
IReadOnlyList<VexDecisionDto> decisions,
CancellationToken ct = default)
{
var bundle = Create(tenantId, decisions);
if (!UsePostgres) return bundle;
var decisionIds = decisions.Select(d => d.Id).OrderBy(x => x).ToArray();
const string sql = """
INSERT INTO audit_bundles (tenant_id, bundle_id, decision_ids, evidence_refs, created_at)
VALUES (@tenantId, @bundleId, @decisionIds, @evidenceRefs, @createdAt)
""";
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenantId", tenantId);
cmd.Parameters.AddWithValue("bundleId", bundle.BundleId);
cmd.Parameters.AddWithValue("decisionIds", decisionIds);
cmd.Parameters.AddWithValue("evidenceRefs", bundle.EvidenceRefs.ToArray());
cmd.Parameters.AddWithValue("createdAt", bundle.CreatedAt);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_logger?.LogDebug("Created audit bundle {BundleId} with {Count} decisions", bundle.BundleId, decisions.Count);
return bundle;
}
}
// ────────────────────────────────────────────────────────────────────────────
// EvidenceSubgraphStore (unchanged -- still builds synthetic response)
// ────────────────────────────────────────────────────────────────────────────
public sealed class EvidenceSubgraphStore
{
private readonly TimeProvider timeProvider;
public EvidenceSubgraphStore(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public EvidenceSubgraphResponse Build(string vulnId)
{
var observedAt = timeProvider.GetUtcNow();
return new EvidenceSubgraphResponse
{
FindingId = $"finding-{vulnId}",
VulnId = vulnId,
Root = new EvidenceNode
{
Id = "artifact:registry.example/app@sha256:abc123",
Type = EvidenceNodeType.Artifact,
Label = "registry.example/app:1.2.3",
IsExpandable = true,
Status = EvidenceNodeStatus.Warning
},
Edges = new[]
{
new EvidenceEdge
{
SourceId = "artifact:registry.example/app@sha256:abc123",
TargetId = "reachability:path:main",
Relationship = "has_reachability_path",
IsReachable = true,
Weight = 0.93,
Citation = new EvidenceCitation
{
Source = "reachability-analysis",
SourceUrl = "urn:stellaops:reachability:path:main",
ObservedAt = observedAt,
Confidence = 0.93,
EvidenceHash = "sha256:reachability-main",
IsVerified = true
}
},
new EvidenceEdge
{
SourceId = "artifact:registry.example/app@sha256:abc123",
TargetId = "binary-diff:patch:current",
Relationship = "references_binary_diff",
IsReachable = false,
Weight = 0.81,
Citation = new EvidenceCitation
{
Source = "binary-diff",
SourceUrl = "urn:stellaops:binary-diff:patch:current",
ObservedAt = observedAt,
Confidence = 0.81,
EvidenceHash = "sha256:binary-diff-current",
IsVerified = true
}
},
new EvidenceEdge
{
SourceId = "artifact:registry.example/app@sha256:abc123",
TargetId = "proof-chain:rekor:entry",
Relationship = "anchored_by_proof_chain",
IsReachable = false,
Weight = 1.0,
Citation = new EvidenceCitation
{
Source = "proof-chain",
SourceUrl = "urn:stellaops:proof-chain:rekor:entry",
ObservedAt = observedAt,
Confidence = 1.0,
EvidenceHash = "sha256:proof-chain-entry",
IsVerified = true
}
}
},
Verdict = new VerdictSummary
{
Decision = "review",
Explanation = "Reachability exists but binary diff and proof-chain evidence indicate active remediation progress.",
KeyFactors = new[] { "reachable-path", "binary-diff", "proof-chain" },
ConfidenceScore = 0.84,
AppliedPolicies = new[] { "policy.vuln.reachability", "policy.vuln.proof-chain" },
ComputedAt = observedAt
},
AvailableActions = new[]
{
new TriageAction
{
ActionId = "apply-internal-vex",
Type = TriageActionType.ApplyInternalVex,
Label = "Apply Internal VEX",
RequiresConfirmation = false
},
new TriageAction
{
ActionId = "schedule-patch",
Type = TriageActionType.SchedulePatch,
Label = "Schedule Patch",
RequiresConfirmation = true
}
},
Metadata = new EvidenceMetadata
{
CollectedAt = observedAt,
NodeCount = 4,
EdgeCount = 3,
IsTruncated = false,
MaxDepth = 3,
Sources = new[] { "reachability-analysis", "binary-diff", "proof-chain" }
}
};
}
}

View File

@@ -1,650 +0,0 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Determinism;
using StellaOps.VulnExplorer.Api.Models;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.VulnExplorer.Api.Data;
internal static class VexJsonDefaults
{
internal static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
}
/// <summary>
/// Postgres-backed VEX decision store.
/// Falls back to in-memory ConcurrentDictionary when no NpgsqlDataSource is registered
/// (e.g. in unit tests).
/// </summary>
public sealed class VexDecisionStore
{
// ── fallback in-memory path (tests only) ───────────────────────────
private readonly ConcurrentDictionary<Guid, VexDecisionDto>? _memoryFallback;
// ── postgres path ──────────────────────────────────────────────────
private readonly NpgsqlDataSource? _dataSource;
private readonly ILogger<VexDecisionStore>? _logger;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly IVexOverrideAttestorClient? _attestorClient;
/// <summary>
/// Production constructor: Postgres-backed.
/// </summary>
public VexDecisionStore(
NpgsqlDataSource dataSource,
ILogger<VexDecisionStore> logger,
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null,
IVexOverrideAttestorClient? attestorClient = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
_attestorClient = attestorClient;
}
/// <summary>
/// Test/offline constructor: in-memory fallback.
/// </summary>
public VexDecisionStore(
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null,
IVexOverrideAttestorClient? attestorClient = null)
{
_memoryFallback = new();
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
_attestorClient = attestorClient;
}
private bool UsePostgres => _dataSource is not null;
// ════════════════════════════════════════════════════════════════════
// Synchronous helpers (kept for backwards-compat with existing endpoints)
// ════════════════════════════════════════════════════════════════════
public VexDecisionDto Create(CreateVexDecisionRequest request, string userId, string userDisplayName)
{
var id = _guidProvider.NewGuid();
var now = _timeProvider.GetUtcNow();
var decision = new VexDecisionDto(
Id: id,
VulnerabilityId: request.VulnerabilityId,
Subject: request.Subject,
Status: request.Status,
JustificationType: request.JustificationType,
JustificationText: request.JustificationText,
EvidenceRefs: request.EvidenceRefs,
Scope: request.Scope,
ValidFor: request.ValidFor,
AttestationRef: null,
SignedOverride: null,
SupersedesDecisionId: request.SupersedesDecisionId,
CreatedBy: new ActorRefDto(userId, userDisplayName),
CreatedAt: now,
UpdatedAt: null);
if (_memoryFallback is not null)
{
_memoryFallback[id] = decision;
}
return decision;
}
public VexDecisionDto? Update(Guid id, UpdateVexDecisionRequest request)
{
if (_memoryFallback is null) return null; // sync path not supported in PG mode
if (!_memoryFallback.TryGetValue(id, out var existing)) return null;
var updated = existing with
{
Status = request.Status ?? existing.Status,
JustificationType = request.JustificationType ?? existing.JustificationType,
JustificationText = request.JustificationText ?? existing.JustificationText,
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
Scope = request.Scope ?? existing.Scope,
ValidFor = request.ValidFor ?? existing.ValidFor,
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
UpdatedAt = _timeProvider.GetUtcNow()
};
_memoryFallback[id] = updated;
return updated;
}
public VexDecisionDto? Get(Guid id)
{
if (_memoryFallback is not null)
return _memoryFallback.TryGetValue(id, out var decision) ? decision : null;
return null;
}
public IReadOnlyList<VexDecisionDto> Query(
string? vulnerabilityId = null,
string? subjectName = null,
VexStatus? status = null,
int skip = 0,
int take = 50)
{
if (_memoryFallback is null) return [];
IEnumerable<VexDecisionDto> query = _memoryFallback.Values;
if (vulnerabilityId is not null)
query = query.Where(d => string.Equals(d.VulnerabilityId, vulnerabilityId, StringComparison.OrdinalIgnoreCase));
if (subjectName is not null)
query = query.Where(d => d.Subject.Name.Contains(subjectName, StringComparison.OrdinalIgnoreCase));
if (status is not null)
query = query.Where(d => d.Status == status);
return query
.OrderByDescending(d => d.CreatedAt)
.ThenBy(d => d.Id)
.Skip(skip)
.Take(take)
.ToArray();
}
public int Count() => _memoryFallback?.Count ?? 0;
// ════════════════════════════════════════════════════════════════════
// Async Postgres-backed methods
// ════════════════════════════════════════════════════════════════════
public async Task<VexDecisionDto> CreateAsync(
string tenantId,
CreateVexDecisionRequest request,
string userId,
string userDisplayName,
CancellationToken ct = default)
{
var decision = Create(request, userId, userDisplayName);
if (UsePostgres)
{
await InsertRowAsync(tenantId, decision, ct).ConfigureAwait(false);
}
return decision;
}
public async Task<VexDecisionDto?> UpdateAsync(
string tenantId,
Guid id,
UpdateVexDecisionRequest request,
CancellationToken ct = default)
{
if (!UsePostgres)
return Update(id, request);
var existing = await GetAsync(tenantId, id, ct).ConfigureAwait(false);
if (existing is null) return null;
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Status = request.Status ?? existing.Status,
JustificationType = request.JustificationType ?? existing.JustificationType,
JustificationText = request.JustificationText ?? existing.JustificationText,
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
Scope = request.Scope ?? existing.Scope,
ValidFor = request.ValidFor ?? existing.ValidFor,
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
UpdatedAt = now
};
const string sql = """
UPDATE vex_decisions SET
status = @status,
justification_type = @justificationType,
justification_text = @justificationText,
evidence_refs = @evidenceRefs,
scope_environments = @scopeEnvs,
scope_projects = @scopeProjects,
valid_not_before = @validNotBefore,
valid_not_after = @validNotAfter,
supersedes_decision_id = @supersedesId,
updated_at = @updatedAt
WHERE tenant_id = @tenantId AND id = @id
""";
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenantId", tenantId);
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("status", updated.Status.ToString());
cmd.Parameters.AddWithValue("justificationType", updated.JustificationType.ToString());
cmd.Parameters.AddWithValue("justificationText", (object?)updated.JustificationText ?? DBNull.Value);
cmd.Parameters.AddWithValue("evidenceRefs", NpgsqlDbType.Jsonb,
updated.EvidenceRefs is not null
? JsonSerializer.Serialize(updated.EvidenceRefs, VexJsonDefaults.Options)
: (object)DBNull.Value);
cmd.Parameters.AddWithValue("scopeEnvs",
(object?)updated.Scope?.Environments?.ToArray() ?? DBNull.Value);
cmd.Parameters.AddWithValue("scopeProjects",
(object?)updated.Scope?.Projects?.ToArray() ?? DBNull.Value);
cmd.Parameters.AddWithValue("validNotBefore",
(object?)updated.ValidFor?.NotBefore ?? DBNull.Value);
cmd.Parameters.AddWithValue("validNotAfter",
(object?)updated.ValidFor?.NotAfter ?? DBNull.Value);
cmd.Parameters.AddWithValue("supersedesId",
(object?)updated.SupersedesDecisionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("updatedAt", now);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return updated;
}
public async Task<VexDecisionDto?> GetAsync(
string tenantId, Guid id, CancellationToken ct = default)
{
if (!UsePostgres)
return Get(id);
const string sql = """
SELECT id, vulnerability_id, subject_type, subject_name, subject_digest,
subject_sbom_node_id, status, justification_type, justification_text,
evidence_refs, scope_environments, scope_projects,
valid_not_before, valid_not_after,
attestation_ref_id, attestation_ref_digest, attestation_ref_storage,
signed_override, supersedes_decision_id,
created_by_id, created_by_name, created_at, updated_at
FROM vex_decisions
WHERE tenant_id = @tenantId AND id = @id
""";
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenantId", tenantId);
cmd.Parameters.AddWithValue("id", id);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false) ? MapDecision(reader) : null;
}
public async Task<(IReadOnlyList<VexDecisionDto> Items, int TotalCount)> QueryAsync(
string tenantId,
string? vulnerabilityId = null,
string? subjectName = null,
VexStatus? status = null,
int skip = 0,
int take = 50,
CancellationToken ct = default)
{
if (!UsePostgres)
{
var items = Query(vulnerabilityId, subjectName, status, skip, take);
return (items, Count());
}
var whereClauses = new List<string> { "tenant_id = @tenantId" };
if (vulnerabilityId is not null) whereClauses.Add("vulnerability_id = @vulnId");
if (subjectName is not null) whereClauses.Add("subject_name ILIKE @subjectName");
if (status is not null) whereClauses.Add("status = @status");
var where = string.Join(" AND ", whereClauses);
var countSql = $"SELECT COUNT(*) FROM vex_decisions WHERE {where}";
var querySql = $"""
SELECT id, vulnerability_id, subject_type, subject_name, subject_digest,
subject_sbom_node_id, status, justification_type, justification_text,
evidence_refs, scope_environments, scope_projects,
valid_not_before, valid_not_after,
attestation_ref_id, attestation_ref_digest, attestation_ref_storage,
signed_override, supersedes_decision_id,
created_by_id, created_by_name, created_at, updated_at
FROM vex_decisions
WHERE {where}
ORDER BY created_at DESC, id ASC
OFFSET @skip LIMIT @take
""";
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var countCmd = new NpgsqlCommand(countSql, conn);
AddFilterParams(countCmd, tenantId, vulnerabilityId, subjectName, status);
var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(ct).ConfigureAwait(false));
await using var queryCmd = new NpgsqlCommand(querySql, conn);
AddFilterParams(queryCmd, tenantId, vulnerabilityId, subjectName, status);
queryCmd.Parameters.AddWithValue("skip", skip);
queryCmd.Parameters.AddWithValue("take", take);
var results = new List<VexDecisionDto>();
await using var reader = await queryCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(MapDecision(reader));
}
return (results, totalCount);
}
// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-002)
public async Task<(VexDecisionDto Decision, VexOverrideAttestationResult? AttestationResult)> CreateWithAttestationAsync(
CreateVexDecisionRequest request,
string userId,
string userDisplayName,
CancellationToken cancellationToken = default)
{
return await CreateWithAttestationAsync(null, request, userId, userDisplayName, cancellationToken);
}
public async Task<(VexDecisionDto Decision, VexOverrideAttestationResult? AttestationResult)> CreateWithAttestationAsync(
string? tenantId,
CreateVexDecisionRequest request,
string userId,
string userDisplayName,
CancellationToken cancellationToken = default)
{
var id = _guidProvider.NewGuid();
var now = _timeProvider.GetUtcNow();
VexOverrideAttestationDto? signedOverride = null;
VexOverrideAttestationResult? attestationResult = null;
if (request.AttestationOptions?.CreateAttestation == true && _attestorClient is not null)
{
var attestationRequest = new VexOverrideAttestationRequest
{
VulnerabilityId = request.VulnerabilityId,
Subject = request.Subject,
Status = request.Status,
JustificationType = request.JustificationType,
JustificationText = request.JustificationText,
EvidenceRefs = request.EvidenceRefs,
Scope = request.Scope,
ValidFor = request.ValidFor,
CreatedBy = new ActorRefDto(userId, userDisplayName),
AnchorToRekor = request.AttestationOptions.AnchorToRekor,
SigningKeyId = request.AttestationOptions.SigningKeyId,
StorageDestination = request.AttestationOptions.StorageDestination,
AdditionalMetadata = request.AttestationOptions.AdditionalMetadata
};
attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
if (attestationResult.Success && attestationResult.Attestation is not null)
{
signedOverride = attestationResult.Attestation;
}
}
var decision = new VexDecisionDto(
Id: id,
VulnerabilityId: request.VulnerabilityId,
Subject: request.Subject,
Status: request.Status,
JustificationType: request.JustificationType,
JustificationText: request.JustificationText,
EvidenceRefs: request.EvidenceRefs,
Scope: request.Scope,
ValidFor: request.ValidFor,
AttestationRef: null,
SignedOverride: signedOverride,
SupersedesDecisionId: request.SupersedesDecisionId,
CreatedBy: new ActorRefDto(userId, userDisplayName),
CreatedAt: now,
UpdatedAt: null);
if (_memoryFallback is not null)
_memoryFallback[id] = decision;
if (UsePostgres && tenantId is not null)
await InsertRowAsync(tenantId, decision, cancellationToken).ConfigureAwait(false);
return (decision, attestationResult);
}
public async Task<(VexDecisionDto? Decision, VexOverrideAttestationResult? AttestationResult)> UpdateWithAttestationAsync(
Guid id,
UpdateVexDecisionRequest request,
string userId,
string userDisplayName,
CancellationToken cancellationToken = default)
{
// In-memory fallback path
if (_memoryFallback is not null)
{
if (!_memoryFallback.TryGetValue(id, out var existing))
return (null, null);
VexOverrideAttestationDto? signedOverride = existing.SignedOverride;
VexOverrideAttestationResult? attestationResult = null;
if (request.AttestationOptions?.CreateAttestation == true && _attestorClient is not null)
{
var attestationRequest = new VexOverrideAttestationRequest
{
VulnerabilityId = existing.VulnerabilityId,
Subject = existing.Subject,
Status = request.Status ?? existing.Status,
JustificationType = request.JustificationType ?? existing.JustificationType,
JustificationText = request.JustificationText ?? existing.JustificationText,
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
Scope = request.Scope ?? existing.Scope,
ValidFor = request.ValidFor ?? existing.ValidFor,
CreatedBy = new ActorRefDto(userId, userDisplayName),
AnchorToRekor = request.AttestationOptions.AnchorToRekor,
SigningKeyId = request.AttestationOptions.SigningKeyId,
StorageDestination = request.AttestationOptions.StorageDestination,
AdditionalMetadata = request.AttestationOptions.AdditionalMetadata
};
attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
if (attestationResult.Success && attestationResult.Attestation is not null)
signedOverride = attestationResult.Attestation;
}
var updated = existing with
{
Status = request.Status ?? existing.Status,
JustificationType = request.JustificationType ?? existing.JustificationType,
JustificationText = request.JustificationText ?? existing.JustificationText,
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
Scope = request.Scope ?? existing.Scope,
ValidFor = request.ValidFor ?? existing.ValidFor,
SignedOverride = signedOverride,
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
UpdatedAt = _timeProvider.GetUtcNow()
};
_memoryFallback[id] = updated;
return (updated, attestationResult);
}
return (null, null);
}
// ── Private Postgres helpers ────────────────────────────────────────
private async Task InsertRowAsync(string tenantId, VexDecisionDto d, CancellationToken ct)
{
const string sql = """
INSERT INTO vex_decisions (
id, tenant_id, vulnerability_id,
subject_type, subject_name, subject_digest, subject_sbom_node_id,
status, justification_type, justification_text,
evidence_refs, scope_environments, scope_projects,
valid_not_before, valid_not_after,
attestation_ref_id, attestation_ref_digest, attestation_ref_storage,
signed_override, supersedes_decision_id,
created_by_id, created_by_name, created_at, updated_at
) VALUES (
@id, @tenantId, @vulnId,
@subjectType, @subjectName, @subjectDigest, @subjectSbomNodeId,
@status, @justificationType, @justificationText,
@evidenceRefs, @scopeEnvs, @scopeProjects,
@validNotBefore, @validNotAfter,
@attestRefId, @attestRefDigest, @attestRefStorage,
@signedOverride, @supersedesId,
@createdById, @createdByName, @createdAt, @updatedAt
)
""";
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("id", d.Id);
cmd.Parameters.AddWithValue("tenantId", tenantId);
cmd.Parameters.AddWithValue("vulnId", d.VulnerabilityId);
cmd.Parameters.AddWithValue("subjectType", d.Subject.Type.ToString());
cmd.Parameters.AddWithValue("subjectName", d.Subject.Name);
cmd.Parameters.AddWithValue("subjectDigest", NpgsqlDbType.Jsonb,
JsonSerializer.Serialize(d.Subject.Digest, VexJsonDefaults.Options));
cmd.Parameters.AddWithValue("subjectSbomNodeId", (object?)d.Subject.SbomNodeId ?? DBNull.Value);
cmd.Parameters.AddWithValue("status", d.Status.ToString());
cmd.Parameters.AddWithValue("justificationType", d.JustificationType.ToString());
cmd.Parameters.AddWithValue("justificationText", (object?)d.JustificationText ?? DBNull.Value);
cmd.Parameters.AddWithValue("evidenceRefs", NpgsqlDbType.Jsonb,
d.EvidenceRefs is not null
? JsonSerializer.Serialize(d.EvidenceRefs, VexJsonDefaults.Options)
: (object)DBNull.Value);
cmd.Parameters.AddWithValue("scopeEnvs",
(object?)d.Scope?.Environments?.ToArray() ?? DBNull.Value);
cmd.Parameters.AddWithValue("scopeProjects",
(object?)d.Scope?.Projects?.ToArray() ?? DBNull.Value);
cmd.Parameters.AddWithValue("validNotBefore",
(object?)d.ValidFor?.NotBefore ?? DBNull.Value);
cmd.Parameters.AddWithValue("validNotAfter",
(object?)d.ValidFor?.NotAfter ?? DBNull.Value);
cmd.Parameters.AddWithValue("attestRefId",
(object?)d.AttestationRef?.Id ?? DBNull.Value);
cmd.Parameters.AddWithValue("attestRefDigest", NpgsqlDbType.Jsonb,
d.AttestationRef?.Digest is not null
? JsonSerializer.Serialize(d.AttestationRef.Digest, VexJsonDefaults.Options)
: (object)DBNull.Value);
cmd.Parameters.AddWithValue("attestRefStorage",
(object?)d.AttestationRef?.Storage ?? DBNull.Value);
cmd.Parameters.AddWithValue("signedOverride", NpgsqlDbType.Jsonb,
d.SignedOverride is not null
? JsonSerializer.Serialize(d.SignedOverride, VexJsonDefaults.Options)
: (object)DBNull.Value);
cmd.Parameters.AddWithValue("supersedesId",
(object?)d.SupersedesDecisionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("createdById", d.CreatedBy.Id);
cmd.Parameters.AddWithValue("createdByName", d.CreatedBy.DisplayName);
cmd.Parameters.AddWithValue("createdAt", d.CreatedAt);
cmd.Parameters.AddWithValue("updatedAt", (object?)d.UpdatedAt ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_logger?.LogDebug("Inserted VEX decision {Id} for tenant {Tenant}", d.Id, tenantId);
}
private static void AddFilterParams(
NpgsqlCommand cmd, string tenantId,
string? vulnerabilityId, string? subjectName, VexStatus? status)
{
cmd.Parameters.AddWithValue("tenantId", tenantId);
if (vulnerabilityId is not null)
cmd.Parameters.AddWithValue("vulnId", vulnerabilityId);
if (subjectName is not null)
cmd.Parameters.AddWithValue("subjectName", $"%{subjectName}%");
if (status is not null)
cmd.Parameters.AddWithValue("status", status.Value.ToString());
}
private static VexDecisionDto MapDecision(NpgsqlDataReader r)
{
var subjectDigest = JsonSerializer.Deserialize<IReadOnlyDictionary<string, string>>(
r.GetString(r.GetOrdinal("subject_digest")),
VexJsonDefaults.Options) ?? new Dictionary<string, string>();
var subjectType = Enum.TryParse<SubjectType>(r.GetString(r.GetOrdinal("subject_type")), true, out var st)
? st : SubjectType.Other;
IReadOnlyList<EvidenceRefDto>? evidenceRefs = null;
var evidenceRefsOrd = r.GetOrdinal("evidence_refs");
if (!r.IsDBNull(evidenceRefsOrd))
{
evidenceRefs = JsonSerializer.Deserialize<IReadOnlyList<EvidenceRefDto>>(
r.GetString(evidenceRefsOrd), VexJsonDefaults.Options);
}
VexScopeDto? scope = null;
var scopeEnvsOrd = r.GetOrdinal("scope_environments");
var scopeProjOrd = r.GetOrdinal("scope_projects");
if (!r.IsDBNull(scopeEnvsOrd) || !r.IsDBNull(scopeProjOrd))
{
scope = new VexScopeDto(
Environments: r.IsDBNull(scopeEnvsOrd) ? null : ((string[])r.GetValue(scopeEnvsOrd)).ToList(),
Projects: r.IsDBNull(scopeProjOrd) ? null : ((string[])r.GetValue(scopeProjOrd)).ToList());
}
ValidForDto? validFor = null;
var notBeforeOrd = r.GetOrdinal("valid_not_before");
var notAfterOrd = r.GetOrdinal("valid_not_after");
if (!r.IsDBNull(notBeforeOrd) || !r.IsDBNull(notAfterOrd))
{
validFor = new ValidForDto(
NotBefore: r.IsDBNull(notBeforeOrd) ? null : r.GetFieldValue<DateTimeOffset>(notBeforeOrd),
NotAfter: r.IsDBNull(notAfterOrd) ? null : r.GetFieldValue<DateTimeOffset>(notAfterOrd));
}
AttestationRefDto? attestationRef = null;
var attestRefIdOrd = r.GetOrdinal("attestation_ref_id");
if (!r.IsDBNull(attestRefIdOrd))
{
var attestDigestOrd = r.GetOrdinal("attestation_ref_digest");
var attestStorageOrd = r.GetOrdinal("attestation_ref_storage");
attestationRef = new AttestationRefDto(
Id: r.GetString(attestRefIdOrd),
Digest: r.IsDBNull(attestDigestOrd) ? null
: JsonSerializer.Deserialize<IReadOnlyDictionary<string, string>>(
r.GetString(attestDigestOrd), VexJsonDefaults.Options),
Storage: r.IsDBNull(attestStorageOrd) ? null : r.GetString(attestStorageOrd));
}
VexOverrideAttestationDto? signedOverride = null;
var signedOverrideOrd = r.GetOrdinal("signed_override");
if (!r.IsDBNull(signedOverrideOrd))
{
signedOverride = JsonSerializer.Deserialize<VexOverrideAttestationDto>(
r.GetString(signedOverrideOrd), VexJsonDefaults.Options);
}
var statusStr = r.GetString(r.GetOrdinal("status"));
var vexStatus = Enum.TryParse<VexStatus>(statusStr, true, out var vs) ? vs : VexStatus.AffectedUnmitigated;
var justTypeStr = r.GetString(r.GetOrdinal("justification_type"));
var justType = Enum.TryParse<VexJustificationType>(justTypeStr, true, out var jt) ? jt : VexJustificationType.Other;
var sbomNodeOrd = r.GetOrdinal("subject_sbom_node_id");
var supersedesOrd = r.GetOrdinal("supersedes_decision_id");
var updatedAtOrd = r.GetOrdinal("updated_at");
var justTextOrd = r.GetOrdinal("justification_text");
return new VexDecisionDto(
Id: r.GetGuid(r.GetOrdinal("id")),
VulnerabilityId: r.GetString(r.GetOrdinal("vulnerability_id")),
Subject: new SubjectRefDto(
Type: subjectType,
Name: r.GetString(r.GetOrdinal("subject_name")),
Digest: subjectDigest,
SbomNodeId: r.IsDBNull(sbomNodeOrd) ? null : r.GetString(sbomNodeOrd)),
Status: vexStatus,
JustificationType: justType,
JustificationText: r.IsDBNull(justTextOrd) ? null : r.GetString(justTextOrd),
EvidenceRefs: evidenceRefs,
Scope: scope,
ValidFor: validFor,
AttestationRef: attestationRef,
SignedOverride: signedOverride,
SupersedesDecisionId: r.IsDBNull(supersedesOrd) ? null : r.GetGuid(supersedesOrd),
CreatedBy: new ActorRefDto(
r.GetString(r.GetOrdinal("created_by_id")),
r.GetString(r.GetOrdinal("created_by_name"))),
CreatedAt: r.GetFieldValue<DateTimeOffset>(r.GetOrdinal("created_at")),
UpdatedAt: r.IsDBNull(updatedAtOrd) ? null : r.GetFieldValue<DateTimeOffset>(updatedAtOrd));
}
}

View File

@@ -1,108 +0,0 @@
namespace StellaOps.VulnExplorer.Api.Models;
/// <summary>
/// In-toto style attestation for vulnerability scan results.
/// Based on docs/schemas/attestation-vuln-scan.schema.json
/// </summary>
public sealed record VulnScanAttestationDto(
string Type,
string PredicateType,
IReadOnlyList<AttestationSubjectDto> Subject,
VulnScanPredicateDto Predicate,
AttestationMetaDto AttestationMeta);
/// <summary>
/// Subject of an attestation (artifact that was scanned).
/// </summary>
public sealed record AttestationSubjectDto(
string Name,
IReadOnlyDictionary<string, string> Digest);
/// <summary>
/// Vulnerability scan result predicate.
/// </summary>
public sealed record VulnScanPredicateDto(
ScannerInfoDto Scanner,
ScannerDbInfoDto? ScannerDb,
DateTimeOffset ScanStartedAt,
DateTimeOffset ScanCompletedAt,
SeverityCountsDto SeverityCounts,
FindingReportDto FindingReport);
/// <summary>
/// Scanner information.
/// </summary>
public sealed record ScannerInfoDto(
string Name,
string Version);
/// <summary>
/// Vulnerability database information.
/// </summary>
public sealed record ScannerDbInfoDto(
DateTimeOffset? LastUpdatedAt);
/// <summary>
/// Count of findings by severity.
/// </summary>
public sealed record SeverityCountsDto(
int Critical,
int High,
int Medium,
int Low);
/// <summary>
/// Reference to the full findings report.
/// </summary>
public sealed record FindingReportDto(
string MediaType,
string Location,
IReadOnlyDictionary<string, string> Digest);
/// <summary>
/// Attestation metadata including signer info.
/// </summary>
public sealed record AttestationMetaDto(
string StatementId,
DateTimeOffset CreatedAt,
AttestationSignerDto Signer);
/// <summary>
/// Entity that signed an attestation.
/// </summary>
public sealed record AttestationSignerDto(
string Name,
string KeyId);
/// <summary>
/// Response for listing attestations.
/// </summary>
public sealed record AttestationListResponse(
IReadOnlyList<AttestationSummaryDto> Items,
string? NextPageToken);
/// <summary>
/// Summary view of an attestation for listing.
/// </summary>
public sealed record AttestationSummaryDto(
string Id,
AttestationType Type,
string SubjectName,
IReadOnlyDictionary<string, string> SubjectDigest,
string PredicateType,
DateTimeOffset CreatedAt,
string? SignerName,
string? SignerKeyId,
bool Verified);
/// <summary>
/// Attestation type enumeration.
/// </summary>
public enum AttestationType
{
VulnScan,
Sbom,
Vex,
PolicyEval,
Other
}

View File

@@ -1,209 +0,0 @@
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_009_FE
// Task: FVU-001 - Fix Verification API Models
namespace StellaOps.VulnExplorer.Api.Models;
/// <summary>
/// Fix verification status response for frontend display.
/// </summary>
public sealed record FixVerificationResponse
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Component PURL.</summary>
public required string ComponentPurl { get; init; }
/// <summary>Whether a FixChain attestation exists.</summary>
public required bool HasAttestation { get; init; }
/// <summary>Verdict status: fixed, partial, not_fixed, inconclusive, none.</summary>
public required string Verdict { get; init; }
/// <summary>Confidence score (0.0 - 1.0).</summary>
public required decimal Confidence { get; init; }
/// <summary>Human-readable verdict label.</summary>
public required string VerdictLabel { get; init; }
/// <summary>Golden set reference.</summary>
public FixVerificationGoldenSetRef? GoldenSet { get; init; }
/// <summary>Analysis results summary.</summary>
public FixVerificationAnalysis? Analysis { get; init; }
/// <summary>Risk impact from fix verification.</summary>
public FixVerificationRiskImpact? RiskImpact { get; init; }
/// <summary>Evidence chain references.</summary>
public FixVerificationEvidenceChain? EvidenceChain { get; init; }
/// <summary>When the verification was performed.</summary>
public DateTimeOffset? VerifiedAt { get; init; }
/// <summary>Rationale items.</summary>
public IReadOnlyList<string> Rationale { get; init; } = [];
}
/// <summary>
/// Golden set reference for UI display.
/// </summary>
public sealed record FixVerificationGoldenSetRef
{
/// <summary>Golden set ID (typically CVE ID).</summary>
public required string Id { get; init; }
/// <summary>Content digest.</summary>
public required string Digest { get; init; }
/// <summary>Reviewer/approver.</summary>
public string? ReviewedBy { get; init; }
/// <summary>When reviewed.</summary>
public DateTimeOffset? ReviewedAt { get; init; }
}
/// <summary>
/// Analysis results for UI display.
/// </summary>
public sealed record FixVerificationAnalysis
{
/// <summary>Function-level changes.</summary>
public IReadOnlyList<FunctionChangeResult> Functions { get; init; } = [];
/// <summary>Reachability changes.</summary>
public ReachabilityChangeResult? Reachability { get; init; }
}
/// <summary>
/// Function-level change result.
/// </summary>
public sealed record FunctionChangeResult
{
/// <summary>Function name.</summary>
public required string FunctionName { get; init; }
/// <summary>Change status: modified, removed, unchanged.</summary>
public required string Status { get; init; }
/// <summary>Status icon for UI.</summary>
public required string StatusIcon { get; init; }
/// <summary>Human-readable details.</summary>
public required string Details { get; init; }
/// <summary>Child items (edges, sinks).</summary>
public IReadOnlyList<FunctionChangeChild> Children { get; init; } = [];
}
/// <summary>
/// Child item of a function change (edge or sink).
/// </summary>
public sealed record FunctionChangeChild
{
/// <summary>Name (edge identifier or sink name).</summary>
public required string Name { get; init; }
/// <summary>Change status.</summary>
public required string Status { get; init; }
/// <summary>Status icon.</summary>
public required string StatusIcon { get; init; }
/// <summary>Details.</summary>
public required string Details { get; init; }
}
/// <summary>
/// Reachability change result.
/// </summary>
public sealed record ReachabilityChangeResult
{
/// <summary>Pre-patch path count.</summary>
public required int PrePatchPaths { get; init; }
/// <summary>Post-patch path count.</summary>
public required int PostPatchPaths { get; init; }
/// <summary>Whether all paths were eliminated.</summary>
public required bool AllPathsEliminated { get; init; }
/// <summary>Summary text.</summary>
public required string Summary { get; init; }
}
/// <summary>
/// Risk impact from fix verification.
/// </summary>
public sealed record FixVerificationRiskImpact
{
/// <summary>Base risk score before fix adjustment.</summary>
public required decimal BaseScore { get; init; }
/// <summary>Base severity label.</summary>
public required string BaseSeverity { get; init; }
/// <summary>Fix adjustment percentage (negative = reduction).</summary>
public required decimal AdjustmentPercent { get; init; }
/// <summary>Final risk score after adjustment.</summary>
public required decimal FinalScore { get; init; }
/// <summary>Final severity label.</summary>
public required string FinalSeverity { get; init; }
/// <summary>Progress bar value (0-100).</summary>
public required int ProgressValue { get; init; }
}
/// <summary>
/// Evidence chain for audit trail.
/// </summary>
public sealed record FixVerificationEvidenceChain
{
/// <summary>SBOM reference.</summary>
public EvidenceChainItem? Sbom { get; init; }
/// <summary>Golden set reference.</summary>
public EvidenceChainItem? GoldenSet { get; init; }
/// <summary>Diff report reference.</summary>
public EvidenceChainItem? DiffReport { get; init; }
/// <summary>FixChain attestation reference.</summary>
public EvidenceChainItem? Attestation { get; init; }
}
/// <summary>
/// Individual evidence chain item.
/// </summary>
public sealed record EvidenceChainItem
{
/// <summary>Item label.</summary>
public required string Label { get; init; }
/// <summary>Content digest (truncated for display).</summary>
public required string DigestShort { get; init; }
/// <summary>Full content digest.</summary>
public required string DigestFull { get; init; }
/// <summary>Download URL.</summary>
public string? DownloadUrl { get; init; }
}
/// <summary>
/// Request to verify a fix.
/// </summary>
public sealed record FixVerificationRequest
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Component PURL.</summary>
public required string ComponentPurl { get; init; }
/// <summary>Image or binary digest.</summary>
public string? ArtifactDigest { get; init; }
}

View File

@@ -1,220 +0,0 @@
namespace StellaOps.VulnExplorer.Api.Models;
/// <summary>
/// VEX-style statement attached to a finding + subject, representing a vulnerability exploitability decision.
/// Based on docs/schemas/vex-decision.schema.json
/// </summary>
public sealed record VexDecisionDto(
Guid Id,
string VulnerabilityId,
SubjectRefDto Subject,
VexStatus Status,
VexJustificationType JustificationType,
string? JustificationText,
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
VexScopeDto? Scope,
ValidForDto? ValidFor,
AttestationRefDto? AttestationRef,
VexOverrideAttestationDto? SignedOverride,
Guid? SupersedesDecisionId,
ActorRefDto CreatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
/// <summary>
/// Signed VEX override attestation details.
/// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-001)
/// </summary>
public sealed record VexOverrideAttestationDto(
/// <summary>DSSE envelope digest (sha256:hex).</summary>
string EnvelopeDigest,
/// <summary>Predicate type for the attestation.</summary>
string PredicateType,
/// <summary>Rekor transparency log index (null if not anchored).</summary>
long? RekorLogIndex,
/// <summary>Rekor entry ID (null if not anchored).</summary>
string? RekorEntryId,
/// <summary>Attestation storage location/reference.</summary>
string? StorageRef,
/// <summary>Timestamp when attestation was created.</summary>
DateTimeOffset AttestationCreatedAt,
/// <summary>Whether the attestation has been verified.</summary>
bool Verified,
/// <summary>Verification status details.</summary>
AttestationVerificationStatusDto? VerificationStatus);
/// <summary>
/// Attestation verification status details.
/// </summary>
public sealed record AttestationVerificationStatusDto(
/// <summary>Whether signature was valid.</summary>
bool SignatureValid,
/// <summary>Whether Rekor inclusion was verified.</summary>
bool? RekorVerified,
/// <summary>Timestamp when verification was performed.</summary>
DateTimeOffset? VerifiedAt,
/// <summary>Error message if verification failed.</summary>
string? ErrorMessage);
/// <summary>
/// Reference to an artifact or SBOM component that a VEX decision applies to.
/// </summary>
public sealed record SubjectRefDto(
SubjectType Type,
string Name,
IReadOnlyDictionary<string, string> Digest,
string? SbomNodeId = null);
/// <summary>
/// Reference to evidence supporting a VEX decision (PR, ticket, doc, commit).
/// </summary>
public sealed record EvidenceRefDto(
EvidenceType Type,
Uri Url,
string? Title = null);
/// <summary>
/// Scope definition for VEX decisions (environments and projects where decision applies).
/// </summary>
public sealed record VexScopeDto(
IReadOnlyList<string>? Environments,
IReadOnlyList<string>? Projects);
/// <summary>
/// Validity window for VEX decisions.
/// </summary>
public sealed record ValidForDto(
DateTimeOffset? NotBefore,
DateTimeOffset? NotAfter);
/// <summary>
/// Reference to a signed attestation.
/// </summary>
public sealed record AttestationRefDto(
string? Id,
IReadOnlyDictionary<string, string>? Digest,
string? Storage);
/// <summary>
/// Reference to an actor (user) who created a decision.
/// </summary>
public sealed record ActorRefDto(
string Id,
string DisplayName);
/// <summary>
/// VEX status following OpenVEX semantics.
/// </summary>
public enum VexStatus
{
NotAffected,
AffectedMitigated,
AffectedUnmitigated,
Fixed
}
/// <summary>
/// Subject type enumeration for VEX decisions.
/// </summary>
public enum SubjectType
{
Image,
Repo,
SbomComponent,
Other
}
/// <summary>
/// Evidence type enumeration.
/// </summary>
public enum EvidenceType
{
Pr,
Ticket,
Doc,
Commit,
Other
}
/// <summary>
/// Justification type inspired by CSAF/VEX specifications.
/// </summary>
public enum VexJustificationType
{
CodeNotPresent,
CodeNotReachable,
VulnerableCodeNotInExecutePath,
ConfigurationNotAffected,
OsNotAffected,
RuntimeMitigationPresent,
CompensatingControls,
AcceptedBusinessRisk,
Other
}
/// <summary>
/// Request to create a new VEX decision.
/// </summary>
public sealed record CreateVexDecisionRequest(
string VulnerabilityId,
SubjectRefDto Subject,
VexStatus Status,
VexJustificationType JustificationType,
string? JustificationText,
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
VexScopeDto? Scope,
ValidForDto? ValidFor,
Guid? SupersedesDecisionId,
/// <summary>Attestation options for signed override.</summary>
AttestationRequestOptions? AttestationOptions);
/// <summary>
/// Options for creating a signed attestation with the VEX decision.
/// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-001)
/// </summary>
public sealed record AttestationRequestOptions(
/// <summary>Whether to create a signed attestation (required in strict mode).</summary>
bool CreateAttestation,
/// <summary>Whether to anchor the attestation to Rekor transparency log.</summary>
bool AnchorToRekor = false,
/// <summary>Key ID to use for signing (null = default).</summary>
string? SigningKeyId = null,
/// <summary>Storage destination for the attestation.</summary>
string? StorageDestination = null,
/// <summary>Additional metadata to include in the attestation.</summary>
IReadOnlyDictionary<string, string>? AdditionalMetadata = null);
/// <summary>
/// Request to update an existing VEX decision.
/// </summary>
public sealed record UpdateVexDecisionRequest(
VexStatus? Status,
VexJustificationType? JustificationType,
string? JustificationText,
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
VexScopeDto? Scope,
ValidForDto? ValidFor,
Guid? SupersedesDecisionId,
/// <summary>Attestation options for signed override update.</summary>
AttestationRequestOptions? AttestationOptions);
/// <summary>
/// Response for listing VEX decisions.
/// </summary>
public sealed record VexDecisionListResponse(
IReadOnlyList<VexDecisionDto> Items,
string? NextPageToken);

View File

@@ -1,46 +0,0 @@
namespace StellaOps.VulnExplorer.Api.Models;
public sealed record VulnSummary(
string Id,
string Severity,
double Score,
bool Kev,
string Exploitability,
bool FixAvailable,
IReadOnlyList<string> CveIds,
IReadOnlyList<string> Purls,
string PolicyVersion,
string RationaleId);
public sealed record VulnDetail(
string Id,
string Severity,
double Score,
bool Kev,
string Exploitability,
bool FixAvailable,
IReadOnlyList<string> CveIds,
IReadOnlyList<string> Purls,
string Summary,
IReadOnlyList<PackageAffect> AffectedPackages,
IReadOnlyList<AdvisoryRef> AdvisoryRefs,
PolicyRationale Rationale,
IReadOnlyList<string> Paths,
IReadOnlyList<EvidenceRef> Evidence,
DateTimeOffset FirstSeen,
DateTimeOffset LastSeen,
string PolicyVersion,
string RationaleId,
EvidenceProvenance Provenance);
public sealed record PackageAffect(string Purl, IReadOnlyList<string> Versions);
public sealed record AdvisoryRef(string Url, string Title);
public sealed record EvidenceRef(string Kind, string Reference, string? Title = null);
public sealed record EvidenceProvenance(string LedgerEntryId, string EvidenceBundleId);
public sealed record PolicyRationale(string Id, string Summary);
public sealed record VulnListResponse(IReadOnlyList<VulnSummary> Items, string? NextPageToken);

View File

@@ -1,479 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Npgsql;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.VulnExplorer.Api.Data;
using StellaOps.VulnExplorer.Api.Models;
using StellaOps.VulnExplorer.Api.Security;
using StellaOps.VulnExplorer.WebService.Contracts;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Localization;
using static StellaOps.Localization.T;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Configure JSON serialization with enum string converter
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
// ── Postgres data source (optional -- falls back to in-memory if no connection string) ──
var connectionString = builder.Configuration.GetConnectionString("Default");
if (!string.IsNullOrWhiteSpace(connectionString))
{
var npgsqlBuilder = new NpgsqlDataSourceBuilder(connectionString)
{
Name = "vulnexplorer"
};
var dataSource = npgsqlBuilder.Build();
builder.Services.AddSingleton(dataSource);
}
builder.Services.AddSingleton<IVexOverrideAttestorClient, StubVexOverrideAttestorClient>();
// Wire stores: use Postgres when NpgsqlDataSource is registered, else in-memory
builder.Services.AddSingleton<VexDecisionStore>(sp =>
{
var ds = sp.GetService<NpgsqlDataSource>();
var attestorClient = sp.GetRequiredService<IVexOverrideAttestorClient>();
if (ds is not null)
return new VexDecisionStore(ds, sp.GetRequiredService<ILogger<VexDecisionStore>>(), attestorClient: attestorClient);
return new VexDecisionStore(attestorClient: attestorClient);
});
builder.Services.AddSingleton<FixVerificationStore>(sp =>
{
var ds = sp.GetService<NpgsqlDataSource>();
if (ds is not null)
return new FixVerificationStore(ds, sp.GetRequiredService<ILogger<FixVerificationStore>>());
return new FixVerificationStore();
});
builder.Services.AddSingleton<AuditBundleStore>(sp =>
{
var ds = sp.GetService<NpgsqlDataSource>();
if (ds is not null)
return new AuditBundleStore(ds, sp.GetRequiredService<ILogger<AuditBundleStore>>());
return new AuditBundleStore();
});
builder.Services.AddSingleton<EvidenceSubgraphStore>();
// Authentication and authorization
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(VulnExplorerPolicies.View, StellaOpsScopes.VulnView);
options.AddStellaOpsScopePolicy(VulnExplorerPolicies.Investigate, StellaOpsScopes.VulnInvestigate);
options.AddStellaOpsScopePolicy(VulnExplorerPolicies.Operate, StellaOpsScopes.VulnOperate);
options.AddStellaOpsScopePolicy(VulnExplorerPolicies.Audit, StellaOpsScopes.VulnAudit);
});
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.Services.AddStellaOpsLocalization(builder.Configuration);
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "vulnexplorer",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.TryAddStellaOpsLocalBinding("vulnexplorer");
var app = builder.Build();
app.LogStellaOpsLocalHostname("vulnexplorer");
app.UseSwagger();
app.UseSwaggerUI();
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
app.TryUseStellaRouter(routerEnabled);
app.MapGet("/v1/vulns", ([AsParameters] VulnFilter filter) =>
{
if (string.IsNullOrWhiteSpace(filter.Tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
var data = ApplyFilter(SampleData.Summaries, filter);
var pageSize = Math.Clamp(filter.PageSize ?? 50, 1, 200);
var offset = ParsePageToken(filter.PageToken);
var page = data.Skip(offset).Take(pageSize).ToArray();
var nextOffset = offset + page.Length;
var next = nextOffset < data.Count ? nextOffset.ToString(CultureInfo.InvariantCulture) : null;
var response = new VulnListResponse(page, next);
return Results.Ok(response);
})
.WithName("ListVulns")
.WithDescription(_t("vulnexplorer.vuln.list_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
app.MapGet("/v1/vulns/{id}", ([FromHeader(Name = "x-stella-tenant")] string? tenant, string id) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
return SampleData.TryGetDetail(id, out var detail) && detail is not null
? Results.Ok(detail)
: Results.NotFound();
})
.WithName("GetVuln")
.WithDescription(_t("vulnexplorer.vuln.get_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
// ============================================================================
// VEX Decision Endpoints (API-VEX-06-001, API-VEX-06-002, API-VEX-06-003)
// ============================================================================
app.MapPost("/v1/vex-decisions", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
[FromHeader(Name = "x-stella-user-id")] string? userId,
[FromHeader(Name = "x-stella-user-name")] string? userName,
[FromBody] CreateVexDecisionRequest request,
VexDecisionStore store,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(request.VulnerabilityId))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.vulnerability_id_required") });
}
if (request.Subject is null)
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.subject_required") });
}
var effectiveUserId = userId ?? "anonymous";
var effectiveUserName = userName ?? "Anonymous User";
VexDecisionDto decision;
if (request.AttestationOptions?.CreateAttestation == true)
{
var result = await store.CreateWithAttestationAsync(
tenant,
request,
effectiveUserId,
effectiveUserName,
cancellationToken);
decision = result.Decision;
}
else
{
decision = await store.CreateAsync(tenant, request, effectiveUserId, effectiveUserName, cancellationToken);
}
return Results.Created($"/v1/vex-decisions/{decision.Id}", decision);
})
.WithName("CreateVexDecision")
.WithDescription(_t("vulnexplorer.vex_decision.create_description"))
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
app.MapPatch("/v1/vex-decisions/{id:guid}", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
Guid id,
[FromBody] UpdateVexDecisionRequest request,
VexDecisionStore store,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
var updated = await store.UpdateAsync(tenant, id, request, cancellationToken);
return updated is not null
? Results.Ok(updated)
: Results.NotFound(new { error = _t("vulnexplorer.error.vex_decision_not_found", id) });
})
.WithName("UpdateVexDecision")
.WithDescription(_t("vulnexplorer.vex_decision.update_description"))
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
app.MapGet("/v1/vex-decisions", async (
[AsParameters] VexDecisionFilter filter,
VexDecisionStore store,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(filter.Tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
var pageSize = Math.Clamp(filter.PageSize ?? 50, 1, 200);
var offset = ParsePageToken(filter.PageToken);
var (decisions, totalCount) = await store.QueryAsync(
tenantId: filter.Tenant,
vulnerabilityId: filter.VulnerabilityId,
subjectName: filter.Subject,
status: filter.Status,
skip: offset,
take: pageSize,
ct: cancellationToken);
var nextOffset = offset + decisions.Count;
var next = nextOffset < totalCount ? nextOffset.ToString(CultureInfo.InvariantCulture) : null;
return Results.Ok(new VexDecisionListResponse(decisions, next));
})
.WithName("ListVexDecisions")
.WithDescription(_t("vulnexplorer.vex_decision.list_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
app.MapGet("/v1/vex-decisions/{id:guid}", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
Guid id,
VexDecisionStore store,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
var decision = await store.GetAsync(tenant, id, cancellationToken);
return decision is not null
? Results.Ok(decision)
: Results.NotFound(new { error = _t("vulnexplorer.error.vex_decision_not_found", id) });
})
.WithName("GetVexDecision")
.WithDescription(_t("vulnexplorer.vex_decision.get_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
app.MapGet("/v1/evidence-subgraph/{vulnId}", (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
string vulnId,
EvidenceSubgraphStore store) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(vulnId))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.vuln_id_required") });
}
EvidenceSubgraphResponse response = store.Build(vulnId);
return Results.Ok(response);
})
.WithName("GetEvidenceSubgraph")
.WithDescription(_t("vulnexplorer.evidence_subgraph.get_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
// Route alias: the UI calls /api/vuln-explorer/findings/{vulnId}/evidence-subgraph
// and the gateway forwards it as-is to the service.
app.MapGet("/api/vuln-explorer/findings/{vulnId}/evidence-subgraph", (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
string vulnId,
EvidenceSubgraphStore store) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(vulnId))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.vuln_id_required") });
}
EvidenceSubgraphResponse response = store.Build(vulnId);
return Results.Ok(response);
})
.WithName("GetEvidenceSubgraphAlias")
.WithDescription(_t("vulnexplorer.evidence_subgraph.get_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
app.MapPost("/v1/fix-verifications", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
[FromBody] CreateFixVerificationRequest request,
FixVerificationStore store,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(request.CveId) || string.IsNullOrWhiteSpace(request.ComponentPurl))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.cve_id_and_purl_required") });
}
var created = await store.CreateAsync(tenant, request, cancellationToken);
return Results.Created($"/v1/fix-verifications/{created.CveId}", created);
})
.WithName("CreateFixVerification")
.WithDescription(_t("vulnexplorer.fix_verification.create_description"))
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
app.MapPatch("/v1/fix-verifications/{cveId}", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
string cveId,
[FromBody] UpdateFixVerificationRequest request,
FixVerificationStore store,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(request.Verdict))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.verdict_required") });
}
var updated = await store.UpdateAsync(tenant, cveId, request.Verdict, cancellationToken);
return updated is not null
? Results.Ok(updated)
: Results.NotFound(new { error = _t("vulnexplorer.error.fix_verification_not_found", cveId) });
})
.WithName("UpdateFixVerification")
.WithDescription(_t("vulnexplorer.fix_verification.update_description"))
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
app.MapPost("/v1/audit-bundles", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
[FromBody] CreateAuditBundleRequest request,
VexDecisionStore decisions,
AuditBundleStore bundles,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (request.DecisionIds is null || request.DecisionIds.Count == 0)
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.decision_ids_required") });
}
var selected = new List<VexDecisionDto>();
foreach (var id in request.DecisionIds)
{
var d = await decisions.GetAsync(tenant, id, cancellationToken);
if (d is not null) selected.Add(d);
}
if (selected.Count == 0)
{
return Results.NotFound(new { error = _t("vulnexplorer.error.no_decisions_found") });
}
var bundle = await bundles.CreateAsync(tenant, selected, cancellationToken);
return Results.Created($"/v1/audit-bundles/{bundle.BundleId}", bundle);
})
.WithName("CreateAuditBundle")
.WithDescription(_t("vulnexplorer.audit_bundle.create_description"))
.RequireAuthorization(VulnExplorerPolicies.Audit)
.RequireTenant();
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.LoadTranslationsAsync();
app.Run();
static int ParsePageToken(string? token) =>
int.TryParse(token, out var offset) && offset >= 0 ? offset : 0;
static IReadOnlyList<VulnSummary> ApplyFilter(IReadOnlyList<VulnSummary> source, VulnFilter filter)
{
IEnumerable<VulnSummary> query = source;
if (filter.Cve is { Length: > 0 })
{
var set = filter.Cve.ToHashSet(StringComparer.OrdinalIgnoreCase);
query = query.Where(v => v.CveIds.Any(set.Contains));
}
if (filter.Purl is { Length: > 0 })
{
var set = filter.Purl.ToHashSet(StringComparer.OrdinalIgnoreCase);
query = query.Where(v => v.Purls.Any(set.Contains));
}
if (filter.Severity is { Length: > 0 })
{
var set = filter.Severity.ToHashSet(StringComparer.OrdinalIgnoreCase);
query = query.Where(v => set.Contains(v.Severity));
}
if (filter.Exploitability is not null)
{
query = query.Where(v => string.Equals(v.Exploitability, filter.Exploitability, StringComparison.OrdinalIgnoreCase));
}
if (filter.FixAvailable is not null)
{
query = query.Where(v => v.FixAvailable == filter.FixAvailable);
}
// deterministic ordering: score desc, id asc
query = query
.OrderByDescending(v => v.Score)
.ThenBy(v => v.Id, StringComparer.Ordinal);
return query.ToArray();
}
public record VulnFilter(
[FromHeader(Name = "x-stella-tenant")] string? Tenant,
[FromQuery(Name = "policyVersion")] string? PolicyVersion,
[FromQuery(Name = "pageSize")] int? PageSize,
[FromQuery(Name = "pageToken")] string? PageToken,
[FromQuery(Name = "cve")] string[]? Cve,
[FromQuery(Name = "purl")] string[]? Purl,
[FromQuery(Name = "severity")] string[]? Severity,
[FromQuery(Name = "exploitability")] string? Exploitability,
[FromQuery(Name = "fixAvailable")] bool? FixAvailable);
public record VexDecisionFilter(
[FromHeader(Name = "x-stella-tenant")] string? Tenant,
[FromQuery(Name = "vulnerabilityId")] string? VulnerabilityId,
[FromQuery(Name = "subject")] string? Subject,
[FromQuery(Name = "status")] VexStatus? Status,
[FromQuery(Name = "pageSize")] int? PageSize,
[FromQuery(Name = "pageToken")] string? PageToken);
// Program class public for WebApplicationFactory<Program>
public partial class Program;

View File

@@ -1,14 +0,0 @@
{
"profiles": {
"StellaOps.VulnExplorer.Api": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"STELLAOPS_WEBSERVICES_CORS": "true",
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
},
"applicationUrl": "https://localhost:10130;http://localhost:10131"
}
}
}

View File

@@ -1,22 +0,0 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.VulnExplorer.Api.Security;
/// <summary>
/// Named authorization policy constants for the VulnExplorer service.
/// Policies are registered via AddStellaOpsScopePolicy in Program.cs.
/// </summary>
internal static class VulnExplorerPolicies
{
/// <summary>Policy for viewing vulnerability findings, reports, and dashboards. Requires vuln:view scope.</summary>
public const string View = "VulnExplorer.View";
/// <summary>Policy for triage actions (assign, comment, annotate). Requires vuln:investigate scope.</summary>
public const string Investigate = "VulnExplorer.Investigate";
/// <summary>Policy for state-changing operations (VEX decisions, fix verifications). Requires vuln:operate scope.</summary>
public const string Operate = "VulnExplorer.Operate";
/// <summary>Policy for audit export and immutable ledger access. Requires vuln:audit scope.</summary>
public const string Audit = "VulnExplorer.Audit";
}

View File

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.VulnExplorer.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Translations\*.json" />
</ItemGroup>
<ItemGroup>
<Compile Include="../StellaOps.VulnExplorer.WebService/Contracts/EvidenceSubgraphContracts.cs" Link="Contracts/EvidenceSubgraphContracts.cs" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -1,8 +0,0 @@
# StellaOps.VulnExplorer.Api Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/VulnExplorer/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,29 +0,0 @@
{
"_meta": { "locale": "en-US", "namespace": "vulnexplorer", "version": "1.0" },
"vulnexplorer.vuln.list_description": "Returns a paginated list of vulnerability summaries for the tenant, optionally filtered by CVE IDs, PURLs, severity levels, exploitability, and fix availability. Results are ordered by score descending then ID ascending. Requires x-stella-tenant header.",
"vulnexplorer.vuln.get_description": "Returns the full vulnerability detail record for a specific vulnerability ID including CVE IDs, affected components, severity score, exploitability assessment, and fix availability. Returns 404 if not found. Requires x-stella-tenant header.",
"vulnexplorer.vex_decision.create_description": "Creates a new VEX decision record for a vulnerability and subject artifact, recording the analyst verdict, justification, and optional attestation options. Optionally creates a signed VEX attestation if attestationOptions.createAttestation is true. Returns 201 Created with the VEX decision. Requires x-stella-tenant, x-stella-user-id, and x-stella-user-name headers.",
"vulnexplorer.vex_decision.update_description": "Partially updates an existing VEX decision record by ID, allowing the analyst to revise the status, justification, or other mutable fields. Returns 200 with the updated decision or 404 if the decision is not found. Requires x-stella-tenant header.",
"vulnexplorer.vex_decision.list_description": "Returns a paginated list of VEX decisions for the tenant, optionally filtered by vulnerability ID, subject artifact name, and decision status. Results are returned in stable order with a page token for continuation. Requires x-stella-tenant header.",
"vulnexplorer.vex_decision.get_description": "Returns the full VEX decision record for a specific decision ID including vulnerability reference, subject artifact, analyst verdict, justification, timestamps, and attestation reference if present. Returns 404 if the decision is not found. Requires x-stella-tenant header.",
"vulnexplorer.evidence_subgraph.get_description": "Returns the evidence subgraph for a specific vulnerability ID, linking together all related VEX decisions, fix verifications, audit bundles, and attestations that form the traceability chain for the vulnerability disposition. Requires x-stella-tenant header.",
"vulnexplorer.fix_verification.create_description": "Creates a new fix verification record linking a CVE ID to a component PURL to track the verification status of an applied fix. Returns 201 Created with the verification record. Requires x-stella-tenant header and both cveId and componentPurl in the request body.",
"vulnexplorer.fix_verification.update_description": "Updates the verdict for an existing fix verification record, recording the confirmed verification outcome for a CVE fix. Returns 200 with the updated record or 404 if the fix verification is not found. Requires x-stella-tenant header and verdict in the request body.",
"vulnexplorer.audit_bundle.create_description": "Creates an immutable audit bundle aggregating a set of VEX decisions by their IDs into a single exportable evidence record for compliance and audit purposes. Returns 201 Created with the bundle ID and included decisions. Returns 404 if none of the requested decision IDs are found. Requires x-stella-tenant header.",
"vulnexplorer.error.tenant_required": "x-stella-tenant required",
"vulnexplorer.error.vulnerability_id_required": "vulnerabilityId is required",
"vulnexplorer.error.subject_required": "subject is required",
"vulnexplorer.error.vuln_id_required": "vulnId is required",
"vulnexplorer.error.cve_id_and_purl_required": "cveId and componentPurl are required",
"vulnexplorer.error.verdict_required": "verdict is required",
"vulnexplorer.error.decision_ids_required": "decisionIds is required",
"vulnexplorer.error.no_decisions_found": "No decisions found for requested decisionIds",
"vulnexplorer.error.vex_decision_not_found": "VEX decision {0} not found",
"vulnexplorer.error.fix_verification_not_found": "Fix verification {0} not found"
}

View File

@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -1,514 +0,0 @@
// <copyright file="EvidenceSubgraphContracts.cs" company="StellaOps">
// SPDX-License-Identifier: BUSL-1.1
// </copyright>
namespace StellaOps.VulnExplorer.WebService.Contracts;
using System.Text.Json.Serialization;
/// <summary>
/// Response containing the evidence subgraph for a finding.
/// </summary>
public sealed record EvidenceSubgraphResponse
{
/// <summary>
/// Finding identifier this subgraph explains.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Vulnerability identifier (CVE ID or similar).
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Root node of the evidence graph (typically the artifact).
/// </summary>
public required EvidenceNode Root { get; init; }
/// <summary>
/// All edges in the evidence graph.
/// </summary>
public required IReadOnlyList<EvidenceEdge> Edges { get; init; }
/// <summary>
/// Summary verdict for this finding.
/// </summary>
public required VerdictSummary Verdict { get; init; }
/// <summary>
/// Available triage actions for this finding.
/// </summary>
public required IReadOnlyList<TriageAction> AvailableActions { get; init; }
/// <summary>
/// Optional metadata about the evidence collection.
/// </summary>
public EvidenceMetadata? Metadata { get; init; }
}
/// <summary>
/// Node in the evidence graph.
/// </summary>
public sealed record EvidenceNode
{
/// <summary>
/// Unique identifier for this node.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Type of evidence this node represents.
/// </summary>
public required EvidenceNodeType Type { get; init; }
/// <summary>
/// Human-readable label for display.
/// </summary>
public required string Label { get; init; }
/// <summary>
/// Optional longer description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Type-specific metadata.
/// </summary>
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
/// <summary>
/// Child nodes (for expandable tree view).
/// </summary>
public IReadOnlyList<EvidenceNode>? Children { get; init; }
/// <summary>
/// Whether this node is expandable (has or can load children).
/// </summary>
public bool IsExpandable { get; init; }
/// <summary>
/// Status indicator for this node (pass/fail/info).
/// </summary>
public EvidenceNodeStatus Status { get; init; } = EvidenceNodeStatus.Info;
}
/// <summary>
/// Type of evidence node.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EvidenceNodeType
{
/// <summary>Container image or artifact.</summary>
Artifact,
/// <summary>Software package (PURL).</summary>
Package,
/// <summary>Code symbol (function, class, method).</summary>
Symbol,
/// <summary>Call path from entry point to vulnerable code.</summary>
CallPath,
/// <summary>VEX claim from vendor or internal team.</summary>
VexClaim,
/// <summary>Policy rule that affected the verdict.</summary>
PolicyRule,
/// <summary>External advisory source (NVD, vendor, etc.).</summary>
AdvisorySource,
/// <summary>Scanner evidence (binary analysis, SBOM, etc.).</summary>
ScannerEvidence,
/// <summary>Runtime observation (eBPF, traces).</summary>
RuntimeObservation,
/// <summary>Configuration or environment context.</summary>
Configuration,
}
/// <summary>
/// Status indicator for evidence nodes.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EvidenceNodeStatus
{
/// <summary>Informational, no pass/fail.</summary>
Info,
/// <summary>Positive indicator (mitigating factor).</summary>
Pass,
/// <summary>Negative indicator (risk factor).</summary>
Fail,
/// <summary>Needs review or additional information.</summary>
Warning,
/// <summary>Unknown or incomplete data.</summary>
Unknown,
}
/// <summary>
/// Edge connecting two evidence nodes.
/// </summary>
public sealed record EvidenceEdge
{
/// <summary>
/// Source node identifier.
/// </summary>
public required string SourceId { get; init; }
/// <summary>
/// Target node identifier.
/// </summary>
public required string TargetId { get; init; }
/// <summary>
/// Type of relationship (contains, calls, claims, references, etc.).
/// </summary>
public required string Relationship { get; init; }
/// <summary>
/// Citation linking to source evidence.
/// </summary>
public required EvidenceCitation Citation { get; init; }
/// <summary>
/// Whether this edge represents a reachable path.
/// </summary>
public bool IsReachable { get; init; }
/// <summary>
/// Strength of the relationship (for visualization).
/// </summary>
public double? Weight { get; init; }
}
/// <summary>
/// Citation linking to source evidence.
/// </summary>
public sealed record EvidenceCitation
{
/// <summary>
/// Source type (scanner, vex:vendor, advisory:nvd, etc.).
/// </summary>
public required string Source { get; init; }
/// <summary>
/// URL to the source evidence (if available).
/// </summary>
public required string SourceUrl { get; init; }
/// <summary>
/// When this evidence was observed/collected.
/// </summary>
public required DateTimeOffset ObservedAt { get; init; }
/// <summary>
/// Confidence score (0.0-1.0) if applicable.
/// </summary>
public double? Confidence { get; init; }
/// <summary>
/// Hash of the evidence (for verification).
/// </summary>
public string? EvidenceHash { get; init; }
/// <summary>
/// Whether this citation is verified/signed.
/// </summary>
public bool IsVerified { get; init; }
}
/// <summary>
/// Summary verdict for a finding.
/// </summary>
public sealed record VerdictSummary
{
/// <summary>
/// Decision outcome (allow, deny, review).
/// </summary>
public required string Decision { get; init; }
/// <summary>
/// Human-readable explanation paragraph.
/// </summary>
public required string Explanation { get; init; }
/// <summary>
/// Key factors that influenced the decision.
/// </summary>
public required IReadOnlyList<string> KeyFactors { get; init; }
/// <summary>
/// Overall confidence score (0.0-1.0).
/// </summary>
public required double ConfidenceScore { get; init; }
/// <summary>
/// Policy rule IDs that affected this verdict.
/// </summary>
public IReadOnlyList<string>? AppliedPolicies { get; init; }
/// <summary>
/// Timestamp when this verdict was computed.
/// </summary>
public DateTimeOffset? ComputedAt { get; init; }
}
/// <summary>
/// Available triage action.
/// </summary>
public sealed record TriageAction
{
/// <summary>
/// Unique action identifier.
/// </summary>
public required string ActionId { get; init; }
/// <summary>
/// Type of action.
/// </summary>
public required TriageActionType Type { get; init; }
/// <summary>
/// Display label.
/// </summary>
public required string Label { get; init; }
/// <summary>
/// Optional description of what this action does.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Whether this action requires confirmation dialog.
/// </summary>
public bool RequiresConfirmation { get; init; }
/// <summary>
/// Whether this action is currently available.
/// </summary>
public bool IsEnabled { get; init; } = true;
/// <summary>
/// Reason if the action is disabled.
/// </summary>
public string? DisabledReason { get; init; }
/// <summary>
/// Additional parameters for the action.
/// </summary>
public IReadOnlyDictionary<string, object>? Parameters { get; init; }
}
/// <summary>
/// Type of triage action.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TriageActionType
{
/// <summary>Accept vendor's VEX claim.</summary>
AcceptVendorVex,
/// <summary>Request additional evidence.</summary>
RequestEvidence,
/// <summary>Open diff view to previous version.</summary>
OpenDiff,
/// <summary>Create time-boxed policy exception.</summary>
CreateException,
/// <summary>Mark as false positive.</summary>
MarkFalsePositive,
/// <summary>Escalate to security team.</summary>
EscalateToSecurityTeam,
/// <summary>Apply internal VEX claim.</summary>
ApplyInternalVex,
/// <summary>Schedule for patching.</summary>
SchedulePatch,
/// <summary>Suppress finding.</summary>
Suppress,
}
/// <summary>
/// Metadata about evidence collection.
/// </summary>
public sealed record EvidenceMetadata
{
/// <summary>
/// When the evidence was collected.
/// </summary>
public DateTimeOffset CollectedAt { get; init; }
/// <summary>
/// Number of nodes in the graph.
/// </summary>
public int NodeCount { get; init; }
/// <summary>
/// Number of edges in the graph.
/// </summary>
public int EdgeCount { get; init; }
/// <summary>
/// Whether the graph is complete or truncated.
/// </summary>
public bool IsTruncated { get; init; }
/// <summary>
/// Maximum depth of the tree.
/// </summary>
public int MaxDepth { get; init; }
/// <summary>
/// Sources of evidence included.
/// </summary>
public IReadOnlyList<string>? Sources { get; init; }
}
/// <summary>
/// Request to execute a triage action.
/// </summary>
public sealed record ExecuteTriageActionRequest
{
/// <summary>
/// Finding to apply action to.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Action to execute.
/// </summary>
public required string ActionId { get; init; }
/// <summary>
/// Optional parameters for the action.
/// </summary>
public IReadOnlyDictionary<string, object>? Parameters { get; init; }
/// <summary>
/// User comment/justification.
/// </summary>
public string? Comment { get; init; }
}
/// <summary>
/// Response from triage action execution.
/// </summary>
public sealed record ExecuteTriageActionResponse
{
/// <summary>
/// Whether the action succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Result message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Updated verdict after action (if changed).
/// </summary>
public VerdictSummary? UpdatedVerdict { get; init; }
/// <summary>
/// Next recommended action (if any).
/// </summary>
public TriageAction? NextAction { get; init; }
}
/// <summary>
/// Filters for finding triage.
/// </summary>
public sealed record TriageFilters
{
/// <summary>
/// Reachability filter.
/// </summary>
public ReachabilityFilter Reachability { get; init; } = ReachabilityFilter.Reachable;
/// <summary>
/// Patch status filter.
/// </summary>
public PatchStatusFilter PatchStatus { get; init; } = PatchStatusFilter.Unpatched;
/// <summary>
/// VEX status filter.
/// </summary>
public VexStatusFilter VexStatus { get; init; } = VexStatusFilter.Unvexed;
/// <summary>
/// Severity levels to include.
/// </summary>
public IReadOnlyList<string> Severity { get; init; } = new[] { "critical", "high" };
/// <summary>
/// Whether to show suppressed findings.
/// </summary>
public bool ShowSuppressed { get; init; }
}
/// <summary>
/// Reachability filter options.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ReachabilityFilter
{
/// <summary>Show all findings.</summary>
All,
/// <summary>Show only reachable findings.</summary>
Reachable,
/// <summary>Show only unreachable findings.</summary>
Unreachable,
/// <summary>Show findings with unknown reachability.</summary>
Unknown,
}
/// <summary>
/// Patch status filter options.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PatchStatusFilter
{
/// <summary>Show all findings.</summary>
All,
/// <summary>Show only findings with available patches.</summary>
Patched,
/// <summary>Show only findings without available patches.</summary>
Unpatched,
}
/// <summary>
/// VEX status filter options.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum VexStatusFilter
{
/// <summary>Show all findings.</summary>
All,
/// <summary>Show only findings with VEX claims.</summary>
Vexed,
/// <summary>Show only findings without VEX claims.</summary>
Unvexed,
/// <summary>Show findings with conflicting VEX claims.</summary>
Conflicting,
}

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<!-- Test packages provided by Directory.Build.props -->
<ItemGroup>
<ProjectReference Include="../../StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,8 +0,0 @@
# StellaOps.VulnExplorer.Api.Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Tests/StellaOps.VulnExplorer.Api.Tests/StellaOps.VulnExplorer.Api.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,83 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using StellaOps.VulnExplorer.Api.Models;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.VulnExplorer.Api.Tests;
public class VulnApiTests : IClassFixture<VulnExplorerApiWebApplicationFactory>
{
private readonly VulnExplorerApiWebApplicationFactory factory;
public VulnApiTests(VulnExplorerApiWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task List_ReturnsDeterministicOrder()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/vulns", cancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<VulnListResponse>(cancellationToken);
Assert.NotNull(payload);
Assert.Equal(new[] { "vuln-0001", "vuln-0002" }, payload!.Items.Select(v => v.Id));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task List_FiltersByCve()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/vulns?cve=CVE-2024-2222", cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<VulnListResponse>(cancellationToken);
Assert.Single(payload!.Items);
Assert.Equal("vuln-0002", payload.Items[0].Id);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Detail_ReturnsNotFoundWhenMissing()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/vulns/missing", cancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Detail_ReturnsRationaleAndPaths()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/vulns/vuln-0001", cancellationToken);
response.EnsureSuccessStatusCode();
var detail = await response.Content.ReadFromJsonAsync<VulnDetail>(cancellationToken);
Assert.NotNull(detail);
Assert.Equal("rat-0001", detail!.Rationale.Id);
Assert.Contains("/src/app/Program.cs", detail.Paths);
Assert.NotEmpty(detail.Evidence);
}
private HttpClient CreateClient()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add(VulnExplorerTestAuthHandler.HeaderName, VulnExplorerTestAuthHandler.HeaderValue);
client.DefaultRequestHeaders.Add("x-stella-tenant", "tenant-a");
return client;
}
}

View File

@@ -1,97 +0,0 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
namespace StellaOps.VulnExplorer.Api.Tests;
public sealed class VulnExplorerApiWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:ResourceServer:Authority"] = "https://authority.test.stella-ops.local",
["Authority:ResourceServer:RequireHttpsMetadata"] = "false"
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IConfigureOptions<JwtBearerOptions>>();
services.RemoveAll<IPostConfigureOptions<JwtBearerOptions>>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = VulnExplorerTestAuthHandler.SchemeName;
options.DefaultChallengeScheme = VulnExplorerTestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, VulnExplorerTestAuthHandler>(
VulnExplorerTestAuthHandler.SchemeName,
_ => { })
.AddScheme<AuthenticationSchemeOptions, VulnExplorerTestAuthHandler>(
StellaOpsAuthenticationDefaults.AuthenticationScheme,
_ => { });
});
}
}
internal sealed class VulnExplorerTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
internal const string SchemeName = "VulnExplorerTest";
internal const string HeaderName = "X-VulnExplorer-Test-Auth";
internal const string HeaderValue = "true";
public VulnExplorerTestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(HeaderName, out var authHeader) ||
!string.Equals(authHeader.ToString(), HeaderValue, StringComparison.Ordinal))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var tenant = Request.Headers.TryGetValue(StellaOpsHttpHeaderNames.Tenant, out var tenantHeader) &&
!string.IsNullOrWhiteSpace(tenantHeader.ToString())
? tenantHeader.ToString().Trim()
: "tenant-a";
var claims = new List<Claim>
{
new(StellaOpsClaimTypes.Subject, "vulnexplorer-test-user"),
new(StellaOpsClaimTypes.Tenant, tenant),
new(StellaOpsClaimTypes.Scope, string.Join(' ', new[]
{
StellaOpsScopes.VulnView,
StellaOpsScopes.VulnInvestigate,
StellaOpsScopes.VulnOperate,
StellaOpsScopes.VulnAudit
}))
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -1,208 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.VulnExplorer.Api.Tests;
public sealed class VulnExplorerTriageApiE2ETests : IClassFixture<VulnExplorerApiWebApplicationFactory>
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly VulnExplorerApiWebApplicationFactory factory;
public VulnExplorerTriageApiE2ETests(VulnExplorerApiWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreateAndGetVexDecision_WorksEndToEnd()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var createPayload = CreateDecisionPayload("CVE-2025-E2E-001", "notAffected", withAttestation: false);
var createResponse = await client.PostAsJsonAsync("/v1/vex-decisions", createPayload, JsonOptions, cancellationToken);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<JsonObject>(cancellationToken);
var decisionId = created?["id"]?.GetValue<string>();
Assert.False(string.IsNullOrWhiteSpace(decisionId));
var getResponse = await client.GetAsync($"/v1/vex-decisions/{decisionId}", cancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<JsonObject>(cancellationToken);
Assert.Equal("CVE-2025-E2E-001", fetched?["vulnerabilityId"]?.GetValue<string>());
Assert.Equal("notAffected", fetched?["status"]?.GetValue<string>());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreateWithAttestation_ReturnsSignedOverrideAndRekorReference()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var payload = CreateDecisionPayload("CVE-2025-E2E-002", "affectedMitigated", withAttestation: true);
var response = await client.PostAsJsonAsync("/v1/vex-decisions", payload, JsonOptions, cancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonObject>(cancellationToken);
var signedOverride = body?["signedOverride"]?.AsObject();
Assert.NotNull(signedOverride);
Assert.False(string.IsNullOrWhiteSpace(signedOverride?["envelopeDigest"]?.GetValue<string>()));
Assert.NotNull(signedOverride?["rekorLogIndex"]);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task EvidenceSubgraphEndpoint_ReturnsReachabilityAndProofReferences()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/evidence-subgraph/CVE-2025-0001", cancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonObject>(cancellationToken);
Assert.NotNull(body?["root"]);
Assert.NotNull(body?["edges"]);
Assert.NotNull(body?["verdict"]);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task FixVerificationWorkflow_TracksStateTransitions()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var createResponse = await client.PostAsJsonAsync(
"/v1/fix-verifications",
new
{
cveId = "CVE-2025-E2E-003",
componentPurl = "pkg:maven/org.example/app@1.2.3",
artifactDigest = "sha256:abc123"
},
cancellationToken);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var patchResponse = await client.PatchAsync(
"/v1/fix-verifications/CVE-2025-E2E-003",
JsonContent.Create(new { verdict = "verified_by_scanner" }),
cancellationToken);
Assert.Equal(HttpStatusCode.OK, patchResponse.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreateAuditBundle_ReturnsBundleForDecisionSet()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var createPayload = CreateDecisionPayload("CVE-2025-E2E-004", "notAffected", withAttestation: false);
var decisionResponse = await client.PostAsJsonAsync("/v1/vex-decisions", createPayload, JsonOptions, cancellationToken);
Assert.Equal(HttpStatusCode.Created, decisionResponse.StatusCode);
var decision = await decisionResponse.Content.ReadFromJsonAsync<JsonObject>(cancellationToken);
var decisionId = decision?["id"]?.GetValue<string>();
Assert.False(string.IsNullOrWhiteSpace(decisionId));
var bundleResponse = await client.PostAsJsonAsync(
"/v1/audit-bundles",
new
{
tenant = "tenant-a",
decisionIds = new[] { decisionId }
},
cancellationToken);
Assert.Equal(HttpStatusCode.Created, bundleResponse.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreateDecision_WithInvalidStatus_ReturnsBadRequest()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
const string invalidJson = """
{
"vulnerabilityId": "CVE-2025-E2E-005",
"subject": {
"type": "image",
"name": "registry.example/app:9.9.9",
"digest": {
"sha256": "zzz999"
}
},
"status": "invalidStatusLiteral",
"justificationType": "other"
}
""";
using var content = new StringContent(invalidJson, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/v1/vex-decisions", content, cancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private HttpClient CreateClient()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add(VulnExplorerTestAuthHandler.HeaderName, VulnExplorerTestAuthHandler.HeaderValue);
client.DefaultRequestHeaders.Add("x-stella-tenant", "tenant-a");
client.DefaultRequestHeaders.Add("x-stella-user-id", "e2e-analyst");
client.DefaultRequestHeaders.Add("x-stella-user-name", "E2E Analyst");
return client;
}
private static object CreateDecisionPayload(string vulnerabilityId, string status, bool withAttestation)
{
if (withAttestation)
{
return new
{
vulnerabilityId,
subject = new
{
type = "image",
name = "registry.example/app:2.0.0",
digest = new Dictionary<string, string> { ["sha256"] = "def456" }
},
status,
justificationType = "runtimeMitigationPresent",
justificationText = "Runtime guard active.",
attestationOptions = new
{
createAttestation = true,
anchorToRekor = true,
signingKeyId = "test-key"
}
};
}
return new
{
vulnerabilityId,
subject = new
{
type = "image",
name = "registry.example/app:1.2.3",
digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
},
status,
justificationType = "codeNotReachable",
justificationText = "Guarded by deployment policy."
};
}
}

View File

@@ -1347,10 +1347,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexLens.Core.Test
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexLens.Persistence", "VexLens\StellaOps.VexLens.Persistence\StellaOps.VexLens.Persistence.csproj", "{0F9CBD78-C279-951B-A38F-A0AA57B62517}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VulnExplorer.Api", "Findings\StellaOps.VulnExplorer.Api\StellaOps.VulnExplorer.Api.csproj", "{5F45C323-0BA3-BA55-32DA-7B193CBB8632}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VulnExplorer.Api.Tests", "Findings\__Tests\StellaOps.VulnExplorer.Api.Tests\StellaOps.VulnExplorer.Api.Tests.csproj", "{763B9222-F762-EA71-2522-9BE6A5EDF40B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Agent", "Zastava\StellaOps.Zastava.Agent\StellaOps.Zastava.Agent.csproj", "{AF5F6865-50BE-8D89-4AC6-D5EAF6EBD558}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Core", "Zastava\__Libraries\StellaOps.Zastava.Core\StellaOps.Zastava.Core.csproj", "{DA7634C2-9156-9B79-7A1D-90D8E605DC8A}"
@@ -10167,30 +10163,6 @@ Global
{0F9CBD78-C279-951B-A38F-A0AA57B62517}.Release|x64.Build.0 = Release|Any CPU
{0F9CBD78-C279-951B-A38F-A0AA57B62517}.Release|x86.ActiveCfg = Release|Any CPU
{0F9CBD78-C279-951B-A38F-A0AA57B62517}.Release|x86.Build.0 = Release|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Debug|x64.ActiveCfg = Debug|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Debug|x64.Build.0 = Debug|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Debug|x86.ActiveCfg = Debug|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Debug|x86.Build.0 = Debug|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Release|Any CPU.Build.0 = Release|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Release|x64.ActiveCfg = Release|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Release|x64.Build.0 = Release|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Release|x86.ActiveCfg = Release|Any CPU
{5F45C323-0BA3-BA55-32DA-7B193CBB8632}.Release|x86.Build.0 = Release|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Debug|x64.ActiveCfg = Debug|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Debug|x64.Build.0 = Debug|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Debug|x86.ActiveCfg = Debug|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Debug|x86.Build.0 = Debug|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Release|Any CPU.Build.0 = Release|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Release|x64.ActiveCfg = Release|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Release|x64.Build.0 = Release|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Release|x86.ActiveCfg = Release|Any CPU
{763B9222-F762-EA71-2522-9BE6A5EDF40B}.Release|x86.Build.0 = Release|Any CPU
{AF5F6865-50BE-8D89-4AC6-D5EAF6EBD558}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AF5F6865-50BE-8D89-4AC6-D5EAF6EBD558}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AF5F6865-50BE-8D89-4AC6-D5EAF6EBD558}.Debug|x64.ActiveCfg = Debug|Any CPU