partly or unimplemented features - now implemented
This commit is contained in:
@@ -6,3 +6,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/StellaOps.Scanner.WebService.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-062-VEXREACH-001 | DONE | Added deterministic unit coverage for VEX+reachability filter matrix and controller endpoint (`6` tests passed on filtered run, 2026-02-08). |
|
||||
| SPRINT-20260208-063-TRIAGE-001 | DONE | Add endpoint tests for triage cluster inbox stats and batch triage actions (2026-02-08). |
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Endpoints.Triage;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class TriageClusterEndpointsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetClusterStats_ReturnsSeverityAndReachabilityDistributions()
|
||||
{
|
||||
var findings = BuildFindings();
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IFindingQueryService>();
|
||||
services.AddSingleton<IFindingQueryService>(new StubFindingQueryService(findings));
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/triage/inbox/clusters/stats?artifactDigest=sha256:test");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<TriageClusterStatsResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.TotalClusters.Should().Be(2);
|
||||
payload.TotalFindings.Should().Be(3);
|
||||
payload.SeverityDistribution["critical"].Should().Be(1);
|
||||
payload.ReachabilityDistribution["RuntimeConfirmed"].Should().Be(1);
|
||||
payload.ReachabilityDistribution["Unreachable"].Should().Be(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PostClusterAction_AppliesActionToAllClusterFindings()
|
||||
{
|
||||
var findings = BuildFindings();
|
||||
var triageStatus = new StubTriageStatusService();
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IFindingQueryService>();
|
||||
services.RemoveAll<ITriageStatusService>();
|
||||
services.AddSingleton<IFindingQueryService>(new StubFindingQueryService(findings));
|
||||
services.AddSingleton<ITriageStatusService>(triageStatus);
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var statsResponse = await client.GetAsync("/api/v1/triage/inbox/clusters/stats?artifactDigest=sha256:test");
|
||||
var stats = await statsResponse.Content.ReadFromJsonAsync<TriageClusterStatsResponse>();
|
||||
var cluster = stats!.Clusters.Single(c => c.FindingCount == 2);
|
||||
|
||||
var actionRequest = new BatchTriageClusterActionRequest
|
||||
{
|
||||
ArtifactDigest = "sha256:test",
|
||||
DecisionKind = "MuteReach",
|
||||
Reason = "batch triage test"
|
||||
};
|
||||
|
||||
var actionResponse = await client.PostAsJsonAsync($"/api/v1/triage/inbox/clusters/{cluster.PathId}/actions", actionRequest);
|
||||
actionResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var payload = await actionResponse.Content.ReadFromJsonAsync<BatchTriageClusterActionResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.RequestedFindingCount.Should().Be(2);
|
||||
payload.UpdatedFindingCount.Should().Be(2);
|
||||
payload.Lane.Should().Be("MutedReach");
|
||||
payload.DecisionKind.Should().Be("MuteReach");
|
||||
payload.ActionRecord.ActionRecordId.Should().StartWith("triage-action:");
|
||||
triageStatus.UpdatedFindingIds.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Finding> BuildFindings()
|
||||
{
|
||||
var timestamp = new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero);
|
||||
return
|
||||
[
|
||||
new Finding(
|
||||
"finding-1",
|
||||
"pkg:npm/acme/a@1.0.0",
|
||||
"a",
|
||||
"1.0.0",
|
||||
["CVE-2026-0001"],
|
||||
9.0m,
|
||||
0.6m,
|
||||
Severity.Critical,
|
||||
"sha256:test",
|
||||
timestamp,
|
||||
["entry:http:post:/orders", "OrdersController.Post", "SqlSink.Write"],
|
||||
"entry:http:post:/orders",
|
||||
"SqlSink.Write",
|
||||
ReachabilityStatus.RuntimeConfirmed,
|
||||
0.95m),
|
||||
new Finding(
|
||||
"finding-2",
|
||||
"pkg:npm/acme/a@1.0.0",
|
||||
"a",
|
||||
"1.0.0",
|
||||
["CVE-2026-0002"],
|
||||
7.5m,
|
||||
0.4m,
|
||||
Severity.High,
|
||||
"sha256:test",
|
||||
timestamp,
|
||||
["entry:http:post:/orders", "OrdersController.Post", "KafkaSink.Publish"],
|
||||
"entry:http:post:/orders",
|
||||
"KafkaSink.Publish",
|
||||
ReachabilityStatus.StaticallyReachable,
|
||||
0.75m),
|
||||
new Finding(
|
||||
"finding-3",
|
||||
"pkg:npm/acme/b@2.0.0",
|
||||
"b",
|
||||
"2.0.0",
|
||||
["CVE-2026-0003"],
|
||||
3.0m,
|
||||
0.1m,
|
||||
Severity.Low,
|
||||
"sha256:test",
|
||||
timestamp,
|
||||
["entry:http:get:/health", "HealthController.Get", "LogSink.Write"],
|
||||
"entry:http:get:/health",
|
||||
"LogSink.Write",
|
||||
ReachabilityStatus.Unreachable,
|
||||
0.2m)
|
||||
];
|
||||
}
|
||||
|
||||
private sealed class StubFindingQueryService : IFindingQueryService
|
||||
{
|
||||
private readonly IReadOnlyList<Finding> _findings;
|
||||
|
||||
public StubFindingQueryService(IReadOnlyList<Finding> findings)
|
||||
{
|
||||
_findings = findings;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<Finding>>(
|
||||
_findings.Where(f => string.Equals(f.ArtifactDigest, artifactDigest, StringComparison.Ordinal)).ToArray());
|
||||
}
|
||||
|
||||
private sealed class StubTriageStatusService : ITriageStatusService
|
||||
{
|
||||
public List<string> UpdatedFindingIds { get; } = [];
|
||||
|
||||
public Task<FindingTriageStatusDto?> GetFindingStatusAsync(string findingId, CancellationToken ct = default)
|
||||
=> Task.FromResult<FindingTriageStatusDto?>(null);
|
||||
|
||||
public Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
|
||||
string findingId,
|
||||
UpdateTriageStatusRequestDto request,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
UpdatedFindingIds.Add(findingId);
|
||||
return Task.FromResult<UpdateTriageStatusResponseDto?>(new UpdateTriageStatusResponseDto
|
||||
{
|
||||
FindingId = findingId,
|
||||
PreviousLane = "Active",
|
||||
NewLane = request.Lane ?? "Active",
|
||||
PreviousVerdict = "Block",
|
||||
NewVerdict = "Block",
|
||||
SnapshotId = $"snap-{findingId}",
|
||||
AppliedAt = new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero)
|
||||
});
|
||||
}
|
||||
|
||||
public Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
|
||||
string findingId,
|
||||
SubmitVexStatementRequestDto request,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<SubmitVexStatementResponseDto?>(null);
|
||||
|
||||
public Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
|
||||
BulkTriageQueryRequestDto request,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult(new BulkTriageQueryResponseDto
|
||||
{
|
||||
Findings = [],
|
||||
TotalCount = 0,
|
||||
NextCursor = null,
|
||||
Summary = new TriageSummaryDto
|
||||
{
|
||||
ByLane = new Dictionary<string, int>(),
|
||||
ByVerdict = new Dictionary<string, int>(),
|
||||
CanShipCount = 0,
|
||||
BlockingCount = 0
|
||||
}
|
||||
});
|
||||
|
||||
public Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default)
|
||||
=> Task.FromResult(new TriageSummaryDto
|
||||
{
|
||||
ByLane = new Dictionary<string, int>(),
|
||||
ByVerdict = new Dictionary<string, int>(),
|
||||
CanShipCount = 0,
|
||||
BlockingCount = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexGateControllerFilterTests.cs
|
||||
// Sprint: SPRINT_20260208_062_Scanner_vex_decision_filter_with_reachability
|
||||
// Description: Unit tests for VEX reachability filtering endpoint logic.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Controllers;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class VexGateControllerFilterTests
|
||||
{
|
||||
[Fact]
|
||||
public void FilterByVexReachability_ValidRequest_ReturnsExpectedSummary()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var request = new VexReachabilityFilterRequest
|
||||
{
|
||||
Findings = new List<VexReachabilityFilterFindingDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
FindingId = "f-1",
|
||||
Cve = "CVE-2026-1001",
|
||||
VendorStatus = "not_affected",
|
||||
ReachabilityTier = "unreachable",
|
||||
ExistingDecision = "warn"
|
||||
},
|
||||
new()
|
||||
{
|
||||
FindingId = "f-2",
|
||||
Cve = "CVE-2026-1002",
|
||||
VendorStatus = "affected",
|
||||
ReachabilityTier = "confirmed",
|
||||
ExistingDecision = "warn"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = controller.FilterByVexReachability(request);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsType<VexReachabilityFilterResponse>(ok.Value);
|
||||
Assert.Equal(2, payload.Findings.Count);
|
||||
Assert.Equal(1, payload.Summary.Suppressed);
|
||||
Assert.Equal(1, payload.Summary.Elevated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterByVexReachability_InvalidVendorStatus_ReturnsBadRequest()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var request = new VexReachabilityFilterRequest
|
||||
{
|
||||
Findings = new List<VexReachabilityFilterFindingDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
FindingId = "f-invalid",
|
||||
Cve = "CVE-2026-1999",
|
||||
VendorStatus = "broken_status",
|
||||
ReachabilityTier = "confirmed",
|
||||
ExistingDecision = "warn"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = controller.FilterByVexReachability(request);
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
}
|
||||
|
||||
private static VexGateController CreateController()
|
||||
{
|
||||
var queryService = new Mock<IVexGateQueryService>(MockBehavior.Strict).Object;
|
||||
var filter = new VexReachabilityDecisionFilter();
|
||||
return new VexGateController(
|
||||
queryService,
|
||||
filter,
|
||||
NullLogger<VexGateController>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,104 @@ public sealed class VexGateEndpointsTests
|
||||
Assert.All(findings, f => Assert.Equal("Block", f.Decision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterByVexReachability_WithMatrixCases_ReturnsAnnotatedActions()
|
||||
{
|
||||
await using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new VexReachabilityFilterRequest
|
||||
{
|
||||
Findings = new List<VexReachabilityFilterFindingDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
FindingId = "f-1",
|
||||
Cve = "CVE-2026-0001",
|
||||
Purl = "pkg:npm/a@1.0.0",
|
||||
VendorStatus = "not_affected",
|
||||
ReachabilityTier = "unreachable",
|
||||
ExistingDecision = "warn"
|
||||
},
|
||||
new()
|
||||
{
|
||||
FindingId = "f-2",
|
||||
Cve = "CVE-2026-0002",
|
||||
Purl = "pkg:npm/b@1.0.0",
|
||||
VendorStatus = "affected",
|
||||
ReachabilityTier = "confirmed",
|
||||
ExistingDecision = "warn"
|
||||
},
|
||||
new()
|
||||
{
|
||||
FindingId = "f-3",
|
||||
Cve = "CVE-2026-0003",
|
||||
Purl = "pkg:npm/c@1.0.0",
|
||||
VendorStatus = "not_affected",
|
||||
ReachabilityTier = "confirmed",
|
||||
ExistingDecision = "pass"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync($"{BasePath}/vex-reachability/filter", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<VexReachabilityFilterResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(3, payload!.Findings.Count);
|
||||
Assert.Equal(1, payload.Summary.Suppressed);
|
||||
Assert.Equal(1, payload.Summary.Elevated);
|
||||
Assert.Equal(1, payload.Summary.FlagForReview);
|
||||
|
||||
var byId = payload.Findings.ToDictionary(f => f.FindingId, StringComparer.Ordinal);
|
||||
Assert.Equal("suppress", byId["f-1"].Action);
|
||||
Assert.Equal("pass", byId["f-1"].EffectiveDecision);
|
||||
Assert.Equal("elevate", byId["f-2"].Action);
|
||||
Assert.Equal("block", byId["f-2"].EffectiveDecision);
|
||||
Assert.Equal("flag_for_review", byId["f-3"].Action);
|
||||
Assert.Equal("warn", byId["f-3"].EffectiveDecision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterByVexReachability_WithInvalidTier_ReturnsBadRequest()
|
||||
{
|
||||
await using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new VexReachabilityFilterRequest
|
||||
{
|
||||
Findings = new List<VexReachabilityFilterFindingDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
FindingId = "f-invalid",
|
||||
Cve = "CVE-2026-0999",
|
||||
Purl = "pkg:npm/invalid@1.0.0",
|
||||
VendorStatus = "affected",
|
||||
ReachabilityTier = "tier-9000",
|
||||
ExistingDecision = "warn"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync($"{BasePath}/vex-reachability/filter", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
private static VexGateResultsResponse CreateTestGateResults(
|
||||
string scanId,
|
||||
int blockedCount = 1,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexReachabilityDecisionFilterTests.cs
|
||||
// Sprint: SPRINT_20260208_062_Scanner_vex_decision_filter_with_reachability
|
||||
// Description: Unit tests for VEX + reachability decision matrix filtering.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class VexReachabilityDecisionFilterTests
|
||||
{
|
||||
private readonly VexReachabilityDecisionFilter _filter = new();
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_NotAffectedAndUnreachable_SuppressesToPass()
|
||||
{
|
||||
var input = CreateInput(
|
||||
findingId: "f-1",
|
||||
cve: "CVE-2026-0001",
|
||||
vendorStatus: VexStatus.NotAffected,
|
||||
tier: VexReachabilityTier.Unreachable,
|
||||
existingDecision: VexGateDecision.Warn);
|
||||
|
||||
var result = _filter.Evaluate(input);
|
||||
|
||||
Assert.Equal(VexReachabilityFilterAction.Suppress, result.Action);
|
||||
Assert.Equal(VexGateDecision.Pass, result.EffectiveDecision);
|
||||
Assert.Equal("not_affected+unreachable", result.MatrixRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_AffectedAndConfirmed_ElevatesToBlock()
|
||||
{
|
||||
var input = CreateInput(
|
||||
findingId: "f-2",
|
||||
cve: "CVE-2026-0002",
|
||||
vendorStatus: VexStatus.Affected,
|
||||
tier: VexReachabilityTier.Confirmed,
|
||||
existingDecision: VexGateDecision.Warn);
|
||||
|
||||
var result = _filter.Evaluate(input);
|
||||
|
||||
Assert.Equal(VexReachabilityFilterAction.Elevate, result.Action);
|
||||
Assert.Equal(VexGateDecision.Block, result.EffectiveDecision);
|
||||
Assert.Equal("affected+reachable", result.MatrixRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_NotAffectedAndConfirmed_FlagsForReview()
|
||||
{
|
||||
var input = CreateInput(
|
||||
findingId: "f-3",
|
||||
cve: "CVE-2026-0003",
|
||||
vendorStatus: VexStatus.NotAffected,
|
||||
tier: VexReachabilityTier.Confirmed,
|
||||
existingDecision: VexGateDecision.Pass);
|
||||
|
||||
var result = _filter.Evaluate(input);
|
||||
|
||||
Assert.Equal(VexReachabilityFilterAction.FlagForReview, result.Action);
|
||||
Assert.Equal(VexGateDecision.Warn, result.EffectiveDecision);
|
||||
Assert.Equal("not_affected+reachable", result.MatrixRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateBatch_PreservesInputOrderDeterministically()
|
||||
{
|
||||
var inputs = new[]
|
||||
{
|
||||
CreateInput("f-a", "CVE-A", VexStatus.NotAffected, VexReachabilityTier.Unreachable, VexGateDecision.Warn),
|
||||
CreateInput("f-b", "CVE-B", VexStatus.Affected, VexReachabilityTier.Likely, VexGateDecision.Warn),
|
||||
CreateInput("f-c", "CVE-C", null, VexReachabilityTier.Present, VexGateDecision.Pass)
|
||||
};
|
||||
|
||||
var results = _filter.EvaluateBatch(inputs);
|
||||
|
||||
Assert.Equal(3, results.Length);
|
||||
Assert.Equal("f-a", results[0].FindingId);
|
||||
Assert.Equal("f-b", results[1].FindingId);
|
||||
Assert.Equal("f-c", results[2].FindingId);
|
||||
Assert.Equal(VexReachabilityFilterAction.PassThrough, results[2].Action);
|
||||
Assert.Equal(VexGateDecision.Pass, results[2].EffectiveDecision);
|
||||
}
|
||||
|
||||
private static VexReachabilityDecisionInput CreateInput(
|
||||
string findingId,
|
||||
string cve,
|
||||
VexStatus? vendorStatus,
|
||||
VexReachabilityTier tier,
|
||||
VexGateDecision existingDecision)
|
||||
{
|
||||
return new VexReachabilityDecisionInput
|
||||
{
|
||||
FindingId = findingId,
|
||||
VulnerabilityId = cve,
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
VendorStatus = vendorStatus,
|
||||
ReachabilityTier = tier,
|
||||
ExistingDecision = existingDecision
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user