507 lines
20 KiB
C#
507 lines
20 KiB
C#
using System;
|
|
using Xunit;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Linq;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Determinism;
|
|
using StellaOps.Policy;
|
|
using StellaOps.Scanner.Storage.Models;
|
|
using StellaOps.Scanner.Storage.Services;
|
|
using StellaOps.Scanner.WebService.Contracts;
|
|
using StellaOps.Scanner.WebService.Options;
|
|
using StellaOps.Scanner.WebService.Services;
|
|
|
|
using StellaOps.TestKit;
|
|
namespace StellaOps.Scanner.WebService.Tests;
|
|
|
|
public sealed class ReportEventDispatcherTests
|
|
{
|
|
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task PublishAsync_EmitsReportReadyAndScanCompleted()
|
|
{
|
|
var publisher = new RecordingEventPublisher();
|
|
var tracker = new RecordingClassificationChangeTracker();
|
|
var dispatcher = new ReportEventDispatcher(
|
|
publisher,
|
|
tracker,
|
|
Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()),
|
|
new SequentialGuidProvider(),
|
|
TimeProvider.System,
|
|
NullLogger<ReportEventDispatcher>.Instance);
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
|
|
var request = new ReportRequestDto
|
|
{
|
|
ImageDigest = "sha256:feedface",
|
|
Findings = new[]
|
|
{
|
|
new PolicyPreviewFindingDto
|
|
{
|
|
Id = "finding-1",
|
|
Severity = "Critical",
|
|
Repository = "acme/edge/api",
|
|
Cve = "CVE-2024-9999",
|
|
Tags = new[] { "reachability:runtime", "kev:CVE-2024-9999" }
|
|
}
|
|
}
|
|
};
|
|
|
|
var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0");
|
|
var projected = new PolicyVerdict(
|
|
"finding-1",
|
|
PolicyVerdictStatus.Blocked,
|
|
Score: 47.5,
|
|
ConfigVersion: "1.0",
|
|
SourceTrust: "NVD",
|
|
Reachability: "runtime");
|
|
|
|
var preview = new PolicyPreviewResponse(
|
|
Success: true,
|
|
PolicyDigest: "digest-123",
|
|
RevisionId: "rev-42",
|
|
Issues: ImmutableArray<PolicyIssue>.Empty,
|
|
Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)),
|
|
ChangedCount: 1);
|
|
|
|
var document = new ReportDocumentDto
|
|
{
|
|
ReportId = "report-abc",
|
|
ImageDigest = "sha256:feedface",
|
|
GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
|
|
Verdict = "blocked",
|
|
Policy = new ReportPolicyDto
|
|
{
|
|
RevisionId = "rev-42",
|
|
Digest = "digest-123"
|
|
},
|
|
Summary = new ReportSummaryDto
|
|
{
|
|
Total = 1,
|
|
Blocked = 1,
|
|
Warned = 0,
|
|
Ignored = 0,
|
|
Quieted = 0
|
|
},
|
|
Verdicts = new[]
|
|
{
|
|
new PolicyPreviewVerdictDto
|
|
{
|
|
FindingId = "finding-1",
|
|
Status = "Blocked",
|
|
Score = 47.5,
|
|
SourceTrust = "NVD",
|
|
Reachability = "runtime"
|
|
}
|
|
}
|
|
};
|
|
|
|
var envelope = new DsseEnvelopeDto
|
|
{
|
|
PayloadType = "application/vnd.stellaops.report+json",
|
|
Payload = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions)),
|
|
Signatures = new[]
|
|
{
|
|
new DsseSignatureDto { KeyId = "test-key", Algorithm = "hs256", Signature = "signature-value" }
|
|
}
|
|
};
|
|
|
|
var context = new DefaultHttpContext();
|
|
context.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha")
|
|
}));
|
|
context.Request.Scheme = "https";
|
|
context.Request.Host = new HostString("scanner.example");
|
|
|
|
await dispatcher.PublishAsync(request, preview, document, envelope, context, cancellationToken);
|
|
|
|
Assert.Equal(2, publisher.Events.Count);
|
|
|
|
var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady);
|
|
Assert.Equal("tenant-alpha", readyEvent.Tenant);
|
|
Assert.Equal("scanner.event.report.ready:tenant-alpha:report-abc", readyEvent.IdempotencyKey);
|
|
Assert.Equal("api", readyEvent.Scope?.Repo);
|
|
Assert.Equal("acme/edge", readyEvent.Scope?.Namespace);
|
|
Assert.Equal("sha256:feedface", readyEvent.Scope?.Digest);
|
|
var readyPayload = Assert.IsType<ReportReadyEventPayload>(readyEvent.Payload);
|
|
Assert.Equal("report-abc", readyPayload.ReportId);
|
|
Assert.Equal("report-abc", readyPayload.ScanId);
|
|
Assert.Equal("fail", readyPayload.Verdict);
|
|
Assert.Equal(0, readyPayload.QuietedFindingCount);
|
|
Assert.NotNull(readyPayload.Delta);
|
|
Assert.Equal(1, readyPayload.Delta?.NewCritical);
|
|
Assert.Contains("CVE-2024-9999", readyPayload.Delta?.Kev ?? Array.Empty<string>());
|
|
Assert.Equal("https://scanner.example/ui/reports/report-abc", readyPayload.Links.Report?.Ui);
|
|
Assert.Equal("https://scanner.example/api/v1/reports/report-abc", readyPayload.Links.Report?.Api);
|
|
Assert.Equal("https://scanner.example/ui/policy/revisions/rev-42", readyPayload.Links.Policy?.Ui);
|
|
Assert.Equal("https://scanner.example/api/v1/policy/revisions/rev-42", readyPayload.Links.Policy?.Api);
|
|
Assert.Equal("https://scanner.example/ui/attestations/report-abc", readyPayload.Links.Attestation?.Ui);
|
|
Assert.Equal("https://scanner.example/api/v1/reports/report-abc/attestation", readyPayload.Links.Attestation?.Api);
|
|
Assert.Equal(envelope.Payload, readyPayload.Dsse?.Payload);
|
|
Assert.Equal("blocked", readyPayload.Report.Verdict);
|
|
|
|
var scanEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerScanCompleted);
|
|
Assert.Equal("tenant-alpha", scanEvent.Tenant);
|
|
Assert.Equal("scanner.event.scan.completed:tenant-alpha:report-abc", scanEvent.IdempotencyKey);
|
|
Assert.Equal("sha256:feedface", scanEvent.Scope?.Digest);
|
|
var scanPayload = Assert.IsType<ScanCompletedEventPayload>(scanEvent.Payload);
|
|
Assert.Equal("report-abc", scanPayload.ReportId);
|
|
Assert.Equal("report-abc", scanPayload.ScanId);
|
|
Assert.Equal("fail", scanPayload.Verdict);
|
|
var finding = Assert.Single(scanPayload.Findings);
|
|
Assert.Equal("finding-1", finding.Id);
|
|
Assert.Equal("runtime", finding.Reachability);
|
|
Assert.Equal("CVE-2024-9999", finding.Cve);
|
|
Assert.Equal("https://scanner.example/api/v1/reports/report-abc", scanPayload.Links.Report?.Api);
|
|
Assert.Equal("https://scanner.example/ui/reports/report-abc", scanPayload.Links.Report?.Ui);
|
|
Assert.Equal("https://scanner.example/ui/policy/revisions/rev-42", scanPayload.Links.Policy?.Ui);
|
|
Assert.Equal("https://scanner.example/api/v1/policy/revisions/rev-42", scanPayload.Links.Policy?.Api);
|
|
Assert.Equal("https://scanner.example/ui/attestations/report-abc", scanPayload.Links.Attestation?.Ui);
|
|
Assert.Equal("https://scanner.example/api/v1/reports/report-abc/attestation", scanPayload.Links.Attestation?.Api);
|
|
Assert.Equal(envelope.Payload, scanPayload.Dsse?.Payload);
|
|
Assert.Equal("blocked", scanPayload.Report.Verdict);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task PublishAsync_RecordsFnDriftClassificationChanges()
|
|
{
|
|
var publisher = new RecordingEventPublisher();
|
|
var tracker = new RecordingClassificationChangeTracker();
|
|
var dispatcher = new ReportEventDispatcher(
|
|
publisher,
|
|
tracker,
|
|
Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()),
|
|
new SequentialGuidProvider(),
|
|
TimeProvider.System,
|
|
NullLogger<ReportEventDispatcher>.Instance);
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
|
|
var request = new ReportRequestDto
|
|
{
|
|
ImageDigest = "sha256:feedface",
|
|
Findings = new[]
|
|
{
|
|
new PolicyPreviewFindingDto
|
|
{
|
|
Id = "finding-1",
|
|
Severity = "Critical",
|
|
Repository = "acme/edge/api",
|
|
Cve = "CVE-2024-9999",
|
|
Purl = "pkg:nuget/Acme.Edge.Api@1.2.3",
|
|
Tags = new[] { "reachability:runtime" }
|
|
}
|
|
}
|
|
};
|
|
|
|
var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0");
|
|
var projected = new PolicyVerdict(
|
|
"finding-1",
|
|
PolicyVerdictStatus.Blocked,
|
|
Score: 47.5,
|
|
ConfigVersion: "1.0",
|
|
SourceTrust: "NVD",
|
|
Reachability: "runtime");
|
|
|
|
var preview = new PolicyPreviewResponse(
|
|
Success: true,
|
|
PolicyDigest: "digest-123",
|
|
RevisionId: "rev-42",
|
|
Issues: ImmutableArray<PolicyIssue>.Empty,
|
|
Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)),
|
|
ChangedCount: 1);
|
|
|
|
var document = new ReportDocumentDto
|
|
{
|
|
ReportId = "report-abc",
|
|
ImageDigest = "sha256:feedface",
|
|
GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
|
|
Verdict = "blocked",
|
|
Policy = new ReportPolicyDto
|
|
{
|
|
RevisionId = "rev-42",
|
|
Digest = "digest-123"
|
|
},
|
|
Summary = new ReportSummaryDto
|
|
{
|
|
Total = 1,
|
|
Blocked = 1,
|
|
Warned = 0,
|
|
Ignored = 0,
|
|
Quieted = 0
|
|
}
|
|
};
|
|
|
|
var context = new DefaultHttpContext();
|
|
context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") }));
|
|
|
|
await dispatcher.PublishAsync(request, preview, document, envelope: null, context, cancellationToken);
|
|
|
|
var change = Assert.Single(tracker.Changes);
|
|
Assert.Equal("sha256:feedface", change.ArtifactDigest);
|
|
Assert.Equal("CVE-2024-9999", change.VulnId);
|
|
Assert.Equal("pkg:nuget/Acme.Edge.Api@1.2.3", change.PackagePurl);
|
|
Assert.Equal(ClassificationStatus.Unaffected, change.PreviousStatus);
|
|
Assert.Equal(ClassificationStatus.Affected, change.NewStatus);
|
|
Assert.Equal(DriftCause.ReachabilityDelta, change.Cause);
|
|
Assert.Equal(document.GeneratedAt, change.ChangedAt);
|
|
Assert.NotEqual(Guid.Empty, change.TenantId);
|
|
Assert.NotEqual(Guid.Empty, change.ExecutionId);
|
|
Assert.NotEqual(Guid.Empty, change.ManifestId);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task PublishAsync_DoesNotFailWhenFnDriftTrackingThrows()
|
|
{
|
|
var publisher = new RecordingEventPublisher();
|
|
var tracker = new RecordingClassificationChangeTracker
|
|
{
|
|
ThrowOnTrack = true
|
|
};
|
|
var dispatcher = new ReportEventDispatcher(
|
|
publisher,
|
|
tracker,
|
|
Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()),
|
|
new SequentialGuidProvider(),
|
|
TimeProvider.System,
|
|
NullLogger<ReportEventDispatcher>.Instance);
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
|
|
var request = new ReportRequestDto
|
|
{
|
|
ImageDigest = "sha256:feedface",
|
|
Findings = new[]
|
|
{
|
|
new PolicyPreviewFindingDto
|
|
{
|
|
Id = "finding-1",
|
|
Severity = "Critical",
|
|
Repository = "acme/edge/api",
|
|
Cve = "CVE-2024-9999",
|
|
Purl = "pkg:nuget/Acme.Edge.Api@1.2.3"
|
|
}
|
|
}
|
|
};
|
|
|
|
var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0");
|
|
var projected = new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked, ConfigVersion: "1.0");
|
|
|
|
var preview = new PolicyPreviewResponse(
|
|
Success: true,
|
|
PolicyDigest: "digest-123",
|
|
RevisionId: "rev-42",
|
|
Issues: ImmutableArray<PolicyIssue>.Empty,
|
|
Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)),
|
|
ChangedCount: 1);
|
|
|
|
var document = new ReportDocumentDto
|
|
{
|
|
ReportId = "report-abc",
|
|
ImageDigest = "sha256:feedface",
|
|
GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
|
|
Verdict = "blocked",
|
|
Policy = new ReportPolicyDto(),
|
|
Summary = new ReportSummaryDto()
|
|
};
|
|
|
|
var context = new DefaultHttpContext();
|
|
context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") }));
|
|
|
|
await dispatcher.PublishAsync(request, preview, document, envelope: null, context, cancellationToken);
|
|
|
|
Assert.Equal(2, publisher.Events.Count);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task PublishAsync_HonoursConfiguredConsoleAndApiSegments()
|
|
{
|
|
var options = Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions
|
|
{
|
|
Api = new ScannerWebServiceOptions.ApiOptions
|
|
{
|
|
BasePath = "/custom-api",
|
|
ReportsSegment = "reports-view",
|
|
PolicySegment = "policy-hub"
|
|
},
|
|
Console = new ScannerWebServiceOptions.ConsoleOptions
|
|
{
|
|
BasePath = "/console",
|
|
ReportsSegment = "insights",
|
|
PolicySegment = "policy-center",
|
|
AttestationsSegment = "evidence"
|
|
}
|
|
});
|
|
|
|
var publisher = new RecordingEventPublisher();
|
|
var tracker = new RecordingClassificationChangeTracker();
|
|
var dispatcher = new ReportEventDispatcher(
|
|
publisher,
|
|
tracker,
|
|
options,
|
|
new SequentialGuidProvider(),
|
|
TimeProvider.System,
|
|
NullLogger<ReportEventDispatcher>.Instance);
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
|
|
var request = new ReportRequestDto
|
|
{
|
|
ImageDigest = "sha256:feedface",
|
|
Findings = new[]
|
|
{
|
|
new PolicyPreviewFindingDto
|
|
{
|
|
Id = "finding-1",
|
|
Severity = "Critical",
|
|
Repository = "acme/edge/api",
|
|
Cve = "CVE-2024-9999",
|
|
Tags = new[] { "reachability:runtime", "kev:CVE-2024-9999" }
|
|
}
|
|
}
|
|
};
|
|
|
|
var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0");
|
|
var projected = new PolicyVerdict(
|
|
"finding-1",
|
|
PolicyVerdictStatus.Blocked,
|
|
Score: 47.5,
|
|
ConfigVersion: "1.0",
|
|
SourceTrust: "NVD",
|
|
Reachability: "runtime");
|
|
|
|
var preview = new PolicyPreviewResponse(
|
|
Success: true,
|
|
PolicyDigest: "digest-123",
|
|
RevisionId: "rev-42",
|
|
Issues: ImmutableArray<PolicyIssue>.Empty,
|
|
Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)),
|
|
ChangedCount: 1);
|
|
|
|
var document = new ReportDocumentDto
|
|
{
|
|
ReportId = "report-abc",
|
|
ImageDigest = "sha256:feedface",
|
|
GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
|
|
Verdict = "blocked",
|
|
Policy = new ReportPolicyDto
|
|
{
|
|
RevisionId = "rev-42",
|
|
Digest = "digest-123"
|
|
},
|
|
Summary = new ReportSummaryDto
|
|
{
|
|
Total = 1,
|
|
Blocked = 1,
|
|
Warned = 0,
|
|
Ignored = 0,
|
|
Quieted = 0
|
|
},
|
|
Verdicts = new[]
|
|
{
|
|
new PolicyPreviewVerdictDto
|
|
{
|
|
FindingId = "finding-1",
|
|
Status = "Blocked",
|
|
Score = 47.5,
|
|
SourceTrust = "NVD",
|
|
Reachability = "runtime"
|
|
}
|
|
}
|
|
};
|
|
|
|
var envelope = new DsseEnvelopeDto
|
|
{
|
|
PayloadType = "application/vnd.stellaops.report+json",
|
|
Payload = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions)),
|
|
Signatures = new[]
|
|
{
|
|
new DsseSignatureDto { KeyId = "test-key", Algorithm = "hs256", Signature = "signature-value" }
|
|
}
|
|
};
|
|
|
|
var context = new DefaultHttpContext();
|
|
context.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha")
|
|
}));
|
|
context.Request.Scheme = "https";
|
|
context.Request.Host = new HostString("scanner.example");
|
|
|
|
await dispatcher.PublishAsync(request, preview, document, envelope, context, cancellationToken);
|
|
|
|
var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady);
|
|
var links = Assert.IsType<ReportReadyEventPayload>(readyEvent.Payload).Links;
|
|
|
|
Assert.Equal("https://scanner.example/console/insights/report-abc", links.Report?.Ui);
|
|
Assert.Equal("https://scanner.example/custom-api/reports-view/report-abc", links.Report?.Api);
|
|
Assert.Equal("https://scanner.example/console/policy-center/revisions/rev-42", links.Policy?.Ui);
|
|
Assert.Equal("https://scanner.example/custom-api/policy-hub/revisions/rev-42", links.Policy?.Api);
|
|
Assert.Equal("https://scanner.example/console/evidence/report-abc", links.Attestation?.Ui);
|
|
Assert.Equal("https://scanner.example/custom-api/reports-view/report-abc/attestation", links.Attestation?.Api);
|
|
}
|
|
|
|
private sealed class RecordingEventPublisher : IPlatformEventPublisher
|
|
{
|
|
public List<OrchestratorEvent> Events { get; } = new();
|
|
|
|
public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
|
|
{
|
|
Events.Add(@event);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingClassificationChangeTracker : IClassificationChangeTracker
|
|
{
|
|
public List<ClassificationChange> Changes { get; } = new();
|
|
public bool ThrowOnTrack { get; init; }
|
|
|
|
public Task TrackChangeAsync(ClassificationChange change, CancellationToken cancellationToken = default)
|
|
{
|
|
if (ThrowOnTrack)
|
|
{
|
|
throw new InvalidOperationException("Tracking failure");
|
|
}
|
|
|
|
Changes.Add(change);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task TrackChangesAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default)
|
|
{
|
|
if (ThrowOnTrack)
|
|
{
|
|
throw new InvalidOperationException("Tracking failure");
|
|
}
|
|
|
|
Changes.AddRange(changes);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<IReadOnlyList<ClassificationChange>> ComputeDeltaAsync(
|
|
Guid tenantId,
|
|
string artifactDigest,
|
|
Guid previousExecutionId,
|
|
Guid currentExecutionId,
|
|
CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<IReadOnlyList<ClassificationChange>>(Array.Empty<ClassificationChange>());
|
|
}
|
|
}
|