save progress
This commit is contained in:
@@ -34,6 +34,7 @@ using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
using StellaOps.Scanner.Triage;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
using StellaOps.Scanner.WebService.Determinism;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -155,7 +156,16 @@ builder.Services.AddSingleton<IBaselineService, BaselineService>();
|
||||
builder.Services.AddSingleton<IActionablesService, ActionablesService>();
|
||||
builder.Services.AddSingleton<ICounterfactualApiService, CounterfactualApiService>();
|
||||
builder.Services.AddDbContext<TriageDbContext>(options =>
|
||||
options.UseNpgsql(bootstrapOptions.Storage.Dsn));
|
||||
options.UseNpgsql(bootstrapOptions.Storage.Dsn, npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.MapEnum<TriageLane>();
|
||||
npgsqlOptions.MapEnum<TriageVerdict>();
|
||||
npgsqlOptions.MapEnum<TriageReachability>();
|
||||
npgsqlOptions.MapEnum<TriageVexStatus>();
|
||||
npgsqlOptions.MapEnum<TriageDecisionKind>();
|
||||
npgsqlOptions.MapEnum<TriageSnapshotTrigger>();
|
||||
npgsqlOptions.MapEnum<TriageEvidenceType>();
|
||||
}));
|
||||
builder.Services.AddScoped<ITriageQueryService, TriageQueryService>();
|
||||
builder.Services.AddScoped<ITriageStatusService, TriageStatusService>();
|
||||
|
||||
@@ -503,6 +513,10 @@ app.UseExceptionHandler(errorApp =>
|
||||
context.Response.ContentType = "application/problem+json";
|
||||
var feature = context.Features.Get<IExceptionHandlerFeature>();
|
||||
var error = feature?.Error;
|
||||
if (error is not null)
|
||||
{
|
||||
app.Logger.LogError(error, "Unhandled exception.");
|
||||
}
|
||||
|
||||
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
|
||||
@@ -49,7 +49,8 @@ CREATE TABLE IF NOT EXISTS links (
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ix_links_from_artifact ON links (from_type, from_digest, artifact_id);
|
||||
|
||||
CREATE TYPE job_state AS ENUM ('Pending','Running','Succeeded','Failed','Cancelled');
|
||||
DO $$ BEGIN CREATE TYPE job_state AS ENUM ('Pending','Running','Succeeded','Failed','Cancelled');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
@@ -6,21 +8,27 @@ namespace StellaOps.Scanner.Triage.Entities;
|
||||
public enum TriageLane
|
||||
{
|
||||
/// <summary>Finding is actively being evaluated.</summary>
|
||||
[PgName("ACTIVE")]
|
||||
Active,
|
||||
|
||||
/// <summary>Finding is blocking shipment.</summary>
|
||||
[PgName("BLOCKED")]
|
||||
Blocked,
|
||||
|
||||
/// <summary>Finding requires a security exception to proceed.</summary>
|
||||
[PgName("NEEDS_EXCEPTION")]
|
||||
NeedsException,
|
||||
|
||||
/// <summary>Finding is muted due to reachability analysis (not reachable).</summary>
|
||||
[PgName("MUTED_REACH")]
|
||||
MutedReach,
|
||||
|
||||
/// <summary>Finding is muted due to VEX status (not affected).</summary>
|
||||
[PgName("MUTED_VEX")]
|
||||
MutedVex,
|
||||
|
||||
/// <summary>Finding is mitigated by compensating controls.</summary>
|
||||
[PgName("COMPENSATED")]
|
||||
Compensated
|
||||
}
|
||||
|
||||
@@ -30,12 +38,15 @@ public enum TriageLane
|
||||
public enum TriageVerdict
|
||||
{
|
||||
/// <summary>Can ship - no blocking issues.</summary>
|
||||
[PgName("SHIP")]
|
||||
Ship,
|
||||
|
||||
/// <summary>Cannot ship - blocking issues present.</summary>
|
||||
[PgName("BLOCK")]
|
||||
Block,
|
||||
|
||||
/// <summary>Exception granted - can ship with documented exception.</summary>
|
||||
[PgName("EXCEPTION")]
|
||||
Exception
|
||||
}
|
||||
|
||||
@@ -45,12 +56,15 @@ public enum TriageVerdict
|
||||
public enum TriageReachability
|
||||
{
|
||||
/// <summary>Vulnerable code is reachable.</summary>
|
||||
[PgName("YES")]
|
||||
Yes,
|
||||
|
||||
/// <summary>Vulnerable code is not reachable.</summary>
|
||||
[PgName("NO")]
|
||||
No,
|
||||
|
||||
/// <summary>Reachability cannot be determined.</summary>
|
||||
[PgName("UNKNOWN")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
@@ -60,15 +74,19 @@ public enum TriageReachability
|
||||
public enum TriageVexStatus
|
||||
{
|
||||
/// <summary>Product is affected by the vulnerability.</summary>
|
||||
[PgName("affected")]
|
||||
Affected,
|
||||
|
||||
/// <summary>Product is not affected by the vulnerability.</summary>
|
||||
[PgName("not_affected")]
|
||||
NotAffected,
|
||||
|
||||
/// <summary>Investigation is ongoing.</summary>
|
||||
[PgName("under_investigation")]
|
||||
UnderInvestigation,
|
||||
|
||||
/// <summary>Status is unknown.</summary>
|
||||
[PgName("unknown")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
@@ -78,15 +96,19 @@ public enum TriageVexStatus
|
||||
public enum TriageDecisionKind
|
||||
{
|
||||
/// <summary>Mute based on reachability analysis.</summary>
|
||||
[PgName("MUTE_REACH")]
|
||||
MuteReach,
|
||||
|
||||
/// <summary>Mute based on VEX status.</summary>
|
||||
[PgName("MUTE_VEX")]
|
||||
MuteVex,
|
||||
|
||||
/// <summary>Acknowledge the finding without action.</summary>
|
||||
[PgName("ACK")]
|
||||
Ack,
|
||||
|
||||
/// <summary>Grant a security exception.</summary>
|
||||
[PgName("EXCEPTION")]
|
||||
Exception
|
||||
}
|
||||
|
||||
@@ -96,24 +118,31 @@ public enum TriageDecisionKind
|
||||
public enum TriageSnapshotTrigger
|
||||
{
|
||||
/// <summary>Vulnerability feed was updated.</summary>
|
||||
[PgName("FEED_UPDATE")]
|
||||
FeedUpdate,
|
||||
|
||||
/// <summary>VEX document was updated.</summary>
|
||||
[PgName("VEX_UPDATE")]
|
||||
VexUpdate,
|
||||
|
||||
/// <summary>SBOM was updated.</summary>
|
||||
[PgName("SBOM_UPDATE")]
|
||||
SbomUpdate,
|
||||
|
||||
/// <summary>Runtime trace was received.</summary>
|
||||
[PgName("RUNTIME_TRACE")]
|
||||
RuntimeTrace,
|
||||
|
||||
/// <summary>Policy was updated.</summary>
|
||||
[PgName("POLICY_UPDATE")]
|
||||
PolicyUpdate,
|
||||
|
||||
/// <summary>A triage decision was made.</summary>
|
||||
[PgName("DECISION")]
|
||||
Decision,
|
||||
|
||||
/// <summary>Manual rescan was triggered.</summary>
|
||||
[PgName("RESCAN")]
|
||||
Rescan
|
||||
}
|
||||
|
||||
@@ -123,29 +152,38 @@ public enum TriageSnapshotTrigger
|
||||
public enum TriageEvidenceType
|
||||
{
|
||||
/// <summary>Slice of the SBOM relevant to the finding.</summary>
|
||||
[PgName("SBOM_SLICE")]
|
||||
SbomSlice,
|
||||
|
||||
/// <summary>VEX document.</summary>
|
||||
[PgName("VEX_DOC")]
|
||||
VexDoc,
|
||||
|
||||
/// <summary>Build provenance attestation.</summary>
|
||||
[PgName("PROVENANCE")]
|
||||
Provenance,
|
||||
|
||||
/// <summary>Callstack or callgraph slice.</summary>
|
||||
[PgName("CALLSTACK_SLICE")]
|
||||
CallstackSlice,
|
||||
|
||||
/// <summary>Reachability proof document.</summary>
|
||||
[PgName("REACHABILITY_PROOF")]
|
||||
ReachabilityProof,
|
||||
|
||||
/// <summary>Replay manifest for deterministic reproduction.</summary>
|
||||
[PgName("REPLAY_MANIFEST")]
|
||||
ReplayManifest,
|
||||
|
||||
/// <summary>Policy document that was applied.</summary>
|
||||
[PgName("POLICY")]
|
||||
Policy,
|
||||
|
||||
/// <summary>Scan log output.</summary>
|
||||
[PgName("SCAN_LOG")]
|
||||
ScanLog,
|
||||
|
||||
/// <summary>Other evidence type.</summary>
|
||||
[PgName("OTHER")]
|
||||
Other
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
-- Generated from docs/db/triage_schema.sql
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
@@ -64,6 +62,27 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Scan metadata
|
||||
CREATE TABLE IF NOT EXISTS triage_scan (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
image_reference text NOT NULL,
|
||||
image_digest text NULL,
|
||||
target_digest text NULL,
|
||||
target_reference text NULL,
|
||||
knowledge_snapshot_id text NULL,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz NULL,
|
||||
status text NOT NULL,
|
||||
policy_hash text NULL,
|
||||
feed_snapshot_hash text NULL,
|
||||
snapshot_created_at timestamptz NULL,
|
||||
feed_versions jsonb NULL,
|
||||
snapshot_content_hash text NULL,
|
||||
final_digest text NULL,
|
||||
feed_snapshot_at timestamptz NULL,
|
||||
offline_bundle_id text NULL
|
||||
);
|
||||
|
||||
-- Core: finding (caseId == findingId)
|
||||
CREATE TABLE IF NOT EXISTS triage_finding (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -73,8 +92,18 @@ CREATE TABLE IF NOT EXISTS triage_finding (
|
||||
purl text NOT NULL,
|
||||
cve_id text NULL,
|
||||
rule_id text NULL,
|
||||
artifact_digest text NULL,
|
||||
scan_id uuid NULL,
|
||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
status text NULL,
|
||||
is_muted boolean NOT NULL DEFAULT false,
|
||||
is_backport_fixed boolean NOT NULL DEFAULT false,
|
||||
fixed_in_version text NULL,
|
||||
superseded_by text NULL,
|
||||
delta_comparison_id uuid NULL,
|
||||
knowledge_snapshot_id text NULL,
|
||||
UNIQUE (asset_id, environment_id, purl, cve_id, rule_id)
|
||||
);
|
||||
|
||||
@@ -83,6 +112,29 @@ CREATE INDEX IF NOT EXISTS ix_triage_finding_asset_label ON triage_finding (asse
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_purl ON triage_finding (purl);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_cve ON triage_finding (cve_id);
|
||||
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS artifact_digest text NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS scan_id uuid NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS status text NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS is_muted boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS is_backport_fixed boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS fixed_in_version text NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS superseded_by text NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS delta_comparison_id uuid NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS knowledge_snapshot_id text NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_triage_finding_scan'
|
||||
) THEN
|
||||
ALTER TABLE triage_finding
|
||||
ADD CONSTRAINT fk_triage_finding_scan
|
||||
FOREIGN KEY (scan_id) REFERENCES triage_scan(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Effective VEX (post-merge)
|
||||
CREATE TABLE IF NOT EXISTS triage_effective_vex (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -196,6 +248,32 @@ CREATE TABLE IF NOT EXISTS triage_snapshot (
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_finding ON triage_snapshot (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_trigger ON triage_snapshot (trigger, created_at DESC);
|
||||
|
||||
-- Policy decisions
|
||||
CREATE TABLE IF NOT EXISTS triage_policy_decision (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
policy_id text NOT NULL,
|
||||
action text NOT NULL,
|
||||
reason text NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_policy_decision_finding ON triage_policy_decision (finding_id, applied_at DESC);
|
||||
|
||||
-- Attestations
|
||||
CREATE TABLE IF NOT EXISTS triage_attestation (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
type text NOT NULL,
|
||||
issuer text NULL,
|
||||
envelope_hash text NULL,
|
||||
content_ref text NULL,
|
||||
ledger_ref text NULL,
|
||||
collected_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_attestation_finding ON triage_attestation (finding_id, collected_at DESC);
|
||||
|
||||
-- Current-case view
|
||||
CREATE OR REPLACE VIEW v_triage_case_current AS
|
||||
WITH latest_risk AS (
|
||||
@@ -246,4 +324,3 @@ LEFT JOIN latest_risk r ON r.finding_id = f.id
|
||||
LEFT JOIN latest_reach re ON re.finding_id = f.id
|
||||
LEFT JOIN latest_vex v ON v.finding_id = f.id;
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -21,8 +21,7 @@ public sealed class ReportSamplesTests
|
||||
[Fact]
|
||||
public async Task ReportSampleEnvelope_RemainsCanonical()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", ".."));
|
||||
var repoRoot = ResolveRepoRoot();
|
||||
var path = Path.Combine(repoRoot, "samples", "api", "reports", "report-sample.dsse.json");
|
||||
Assert.True(File.Exists(path), $"Sample file not found at {path}.");
|
||||
await using var stream = File.OpenRead(path);
|
||||
@@ -35,4 +34,18 @@ public sealed class ReportSamplesTests
|
||||
var expectedPayload = Convert.ToBase64String(reportBytes);
|
||||
Assert.Equal(expectedPayload, response.Dsse!.Payload);
|
||||
}
|
||||
|
||||
private static string ResolveRepoRoot()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
return Path.GetFullPath(Path.Combine(
|
||||
baseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
".."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +117,7 @@ public sealed class SbomUploadEndpointsTests
|
||||
|
||||
private static string LoadFixtureBase64(string fileName)
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", ".."));
|
||||
var repoRoot = ResolveRepoRoot();
|
||||
var path = Path.Combine(
|
||||
repoRoot,
|
||||
"src",
|
||||
@@ -134,6 +133,20 @@ public sealed class SbomUploadEndpointsTests
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private static string ResolveRepoRoot()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
return Path.GetFullPath(Path.Combine(
|
||||
baseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
".."));
|
||||
}
|
||||
|
||||
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
@@ -47,7 +48,11 @@ public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceSta
|
||||
postgresFixture = new ScannerWebServicePostgresFixture();
|
||||
postgresFixture.InitializeAsync().GetAwaiter().GetResult();
|
||||
|
||||
configuration["scanner:storage:dsn"] = postgresFixture.ConnectionString;
|
||||
var connectionBuilder = new NpgsqlConnectionStringBuilder(postgresFixture.ConnectionString)
|
||||
{
|
||||
SearchPath = $"{postgresFixture.SchemaName},public"
|
||||
};
|
||||
configuration["scanner:storage:dsn"] = connectionBuilder.ToString();
|
||||
configuration["scanner:storage:database"] = postgresFixture.SchemaName;
|
||||
}
|
||||
|
||||
@@ -173,7 +178,34 @@ public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceSta
|
||||
public override async ValueTask InitializeAsync()
|
||||
{
|
||||
await base.InitializeAsync();
|
||||
await Fixture.RunMigrationsFromAssemblyAsync<TriageDbContext>("Scanner.Triage.WebService.Tests");
|
||||
var migrationsPath = Path.Combine(
|
||||
ResolveRepoRoot(),
|
||||
"src",
|
||||
"Scanner",
|
||||
"__Libraries",
|
||||
"StellaOps.Scanner.Triage",
|
||||
"Migrations");
|
||||
|
||||
if (!Directory.Exists(migrationsPath))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Triage migrations not found at {migrationsPath}");
|
||||
}
|
||||
|
||||
await Fixture.RunMigrationsAsync(migrationsPath, "Scanner.Triage.WebService.Tests");
|
||||
}
|
||||
|
||||
private static string ResolveRepoRoot()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
return Path.GetFullPath(Path.Combine(
|
||||
baseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
".."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user