save checkpoint: save features

This commit is contained in:
master
2026-02-12 10:27:23 +02:00
parent dca86e1248
commit 5bca406787
8837 changed files with 1796879 additions and 5294 deletions

View File

@@ -3,11 +3,9 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.SmartDiff.Detection;
using StellaOps.Scanner.SmartDiff.Output;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using System.Collections.Immutable;
using System.Text;
namespace StellaOps.Scanner.WebService.Endpoints;
@@ -41,6 +39,21 @@ internal static class SmartDiffEndpoints
.RequireAuthorization(ScannerPolicies.ScansRead);
// VEX candidate endpoints
group.MapGet("/{scanId}/vex-candidates", HandleGetScanCandidatesAsync)
.WithName("scanner.smartdiff.scan-candidates")
.WithTags("SmartDiff")
.Produces<VexCandidatesResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
group.MapPost("/{scanId}/vex-candidates/review", HandleReviewScanCandidateAsync)
.WithName("scanner.smartdiff.scan-review")
.WithTags("SmartDiff")
.Produces<ReviewResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansWrite);
group.MapGet("/images/{digest}/candidates", HandleGetCandidatesAsync)
.WithName("scanner.smartdiff.candidates")
.WithTags("SmartDiff")
@@ -89,12 +102,19 @@ internal static class SmartDiffEndpoints
var metadata = await metadataRepo.GetScanMetadataAsync(scanId, ct);
if (metadata is not null)
{
baseDigest = metadata.BaseDigest;
targetDigest = metadata.TargetDigest;
baseDigest = NormalizeDigest(metadata.BaseDigest);
targetDigest = NormalizeDigest(metadata.TargetDigest);
scanTime = metadata.ScanTime;
}
}
IReadOnlyList<StellaOps.Scanner.SmartDiff.Output.VexCandidate> vexCandidates = [];
if (!string.IsNullOrWhiteSpace(targetDigest))
{
var candidates = await candidateStore.GetCandidatesAsync(targetDigest, ct).ConfigureAwait(false);
vexCandidates = candidates.Select(ToSarifVexCandidate).ToList();
}
// Convert to SARIF input format
var sarifInput = new SmartDiffSarifInput(
ScannerVersion: GetScannerVersion(),
@@ -112,7 +132,7 @@ internal static class SmartDiffEndpoints
))
.ToList(),
HardeningRegressions: [],
VexCandidates: [],
VexCandidates: vexCandidates,
ReachabilityChanges: []);
// Generate SARIF
@@ -135,6 +155,27 @@ internal static class SmartDiffEndpoints
statusCode: StatusCodes.Status200OK);
}
/// <summary>
/// GET /smart-diff/{scanId}/vex-candidates - Get VEX candidates using scan metadata.
/// </summary>
private static async Task<IResult> HandleGetScanCandidatesAsync(
string scanId,
IScanMetadataRepository metadataRepository,
IVexCandidateStore store,
double? minConfidence = null,
bool? pendingOnly = null,
CancellationToken ct = default)
{
var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false);
var targetDigest = NormalizeDigest(metadata?.TargetDigest);
if (string.IsNullOrWhiteSpace(targetDigest))
{
return Results.NotFound(new { error = "Scan metadata not found", scanId });
}
return await HandleGetCandidatesAsync(targetDigest, store, minConfidence, pendingOnly, ct).ConfigureAwait(false);
}
private static StellaOps.Scanner.SmartDiff.Output.RiskDirection ToSarifRiskDirection(MaterialRiskChangeResult change)
{
if (change.Changes.IsDefaultOrEmpty)
@@ -219,6 +260,11 @@ internal static class SmartDiffEndpoints
CancellationToken ct = default)
{
var normalizedDigest = NormalizeDigest(digest);
if (string.IsNullOrWhiteSpace(normalizedDigest))
{
return Results.BadRequest(new { error = "Invalid image digest" });
}
var candidates = await store.GetCandidatesAsync(normalizedDigest, ct);
if (minConfidence.HasValue)
@@ -303,12 +349,76 @@ internal static class SmartDiffEndpoints
});
}
/// <summary>
/// POST /smart-diff/{scanId}/vex-candidates/review - Review a scan-scoped VEX candidate.
/// </summary>
private static async Task<IResult> HandleReviewScanCandidateAsync(
string scanId,
ScanReviewRequest request,
IScanMetadataRepository metadataRepository,
IVexCandidateStore store,
TimeProvider timeProvider,
HttpContext httpContext,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.CandidateId))
{
return Results.BadRequest(new { error = "CandidateId is required" });
}
var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false);
var targetDigest = NormalizeDigest(metadata?.TargetDigest);
if (string.IsNullOrWhiteSpace(targetDigest))
{
return Results.NotFound(new { error = "Scan metadata not found", scanId });
}
var candidate = await store.GetCandidateAsync(request.CandidateId, ct).ConfigureAwait(false);
if (candidate is null || !string.Equals(candidate.ImageDigest, targetDigest, StringComparison.OrdinalIgnoreCase))
{
return Results.NotFound(new { error = "Candidate not found for scan", scanId, candidateId = request.CandidateId });
}
return await HandleReviewCandidateAsync(
request.CandidateId,
new ReviewRequest
{
Action = request.Action,
Comment = request.Comment
},
store,
timeProvider,
httpContext,
ct).ConfigureAwait(false);
}
#region Helper Methods
private static string NormalizeDigest(string digest)
private static string? NormalizeDigest(string? digest)
{
// Handle URL-encoded colons
return digest.Replace("%3A", ":", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(digest))
{
return null;
}
// Handle URL-encoded colons.
var normalized = digest.Trim().Replace("%3A", ":", StringComparison.OrdinalIgnoreCase);
if (!normalized.Contains(':', StringComparison.Ordinal))
{
normalized = $"sha256:{normalized}";
}
return normalized;
}
private static StellaOps.Scanner.SmartDiff.Output.VexCandidate ToSarifVexCandidate(
StellaOps.Scanner.SmartDiff.Detection.VexCandidate candidate)
{
return new StellaOps.Scanner.SmartDiff.Output.VexCandidate(
VulnId: candidate.FindingKey.VulnId,
ComponentPurl: candidate.FindingKey.ComponentPurl,
Justification: MapJustificationToString(candidate.Justification),
ImpactStatement: candidate.Rationale);
}
private static MaterialChangeDto ToChangeDto(MaterialRiskChangeResult change)
@@ -452,6 +562,14 @@ public sealed class ReviewRequest
public string? Comment { get; init; }
}
/// <summary>Request for POST /{scanId}/vex-candidates/review</summary>
public sealed class ScanReviewRequest
{
public required string CandidateId { get; init; }
public required string Action { get; init; }
public string? Comment { get; init; }
}
/// <summary>Response for POST /candidates/{id}/review</summary>
public sealed class ReviewResponse
{

View File

@@ -30,8 +30,10 @@ using StellaOps.Scanner.Core.TrustAnchors;
using StellaOps.Scanner.Emit.Composition;
using StellaOps.Scanner.Gate;
using StellaOps.Scanner.ReachabilityDrift.DependencyInjection;
using StellaOps.Scanner.SmartDiff.Detection;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Storage.Extensions;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Surface.Secrets;
@@ -180,6 +182,9 @@ builder.Services.AddSingleton<ICounterfactualApiService, CounterfactualApiServic
builder.Services.TryAddSingleton<IVexGateResultsStore, InMemoryVexGateResultsStore>();
builder.Services.TryAddSingleton<IVexGateQueryService, VexGateQueryService>();
builder.Services.TryAddSingleton<IVexReachabilityDecisionFilter, VexReachabilityDecisionFilter>();
builder.Services.TryAddSingleton<IMaterialRiskChangeRepository, PostgresMaterialRiskChangeRepository>();
builder.Services.TryAddSingleton<IVexCandidateStore, PostgresVexCandidateStore>();
builder.Services.TryAddSingleton<IScanMetadataRepository, InMemoryScanMetadataRepository>();
// Secret Detection Settings (Sprint: SPRINT_20260104_006_BE)
builder.Services.AddScoped<ISecretDetectionSettingsService, SecretDetectionSettingsService>();
@@ -607,6 +612,7 @@ apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
apiGroup.MapSbomUploadEndpoints();
apiGroup.MapReachabilityDriftRootEndpoints();
apiGroup.MapDeltaCompareEndpoints();
apiGroup.MapSmartDiffEndpoints();
apiGroup.MapBaselineEndpoints();
apiGroup.MapActionablesEndpoints();
apiGroup.MapCounterfactualEndpoints();

View File

@@ -0,0 +1,54 @@
using StellaOps.Scanner.Core;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Resolves scan metadata from stored scan manifests.
/// </summary>
public sealed class InMemoryScanMetadataRepository : IScanMetadataRepository
{
private readonly IScanManifestRepository _manifestRepository;
public InMemoryScanMetadataRepository(IScanManifestRepository manifestRepository)
{
_manifestRepository = manifestRepository ?? throw new ArgumentNullException(nameof(manifestRepository));
}
public async Task<ScanMetadata?> GetScanMetadataAsync(string scanId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(scanId))
{
return null;
}
var manifest = await _manifestRepository
.GetManifestAsync(scanId.Trim(), cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (manifest is null)
{
return null;
}
return new ScanMetadata(
BaseDigest: null,
TargetDigest: NormalizeDigest(manifest.Manifest.ArtifactDigest),
ScanTime: manifest.Manifest.CreatedAtUtc);
}
private static string? NormalizeDigest(string? digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return null;
}
var normalized = digest.Trim();
if (!normalized.Contains(':', StringComparison.Ordinal))
{
normalized = $"sha256:{normalized}";
}
return normalized.ToLowerInvariant();
}
}

View File

@@ -14,3 +14,4 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
| SPRINT-20260208-063-TRIAGE-001 | DONE | Implement triage cluster batch action and cluster statistics endpoints for sprint 063 (2026-02-08). |
| HOT-003 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: wired SBOM ingestion projection writes into Scanner WebService pipeline. |
| HOT-004 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: added SBOM hot-lookup read endpoints with bounded pagination. |
| SPRINT-20260212-002-SMARTDIFF-001 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: wired SmartDiff endpoints into Program, added scan-scoped VEX candidate/review API compatibility, and embedded VEX candidates in SARIF output (2026-02-12). |

View File

@@ -5,6 +5,7 @@ Source of truth: `docs/implplan/SPRINT_20260113_001_001_SCANNER_elf_section_hash
| Task ID | Status | Notes |
| --- | --- | --- |
| QA-SCANNER-VERIFY-004 | DONE | SPRINT_20260212_002 run-001: validated AI/ML worker stage integration path for `ai-ml-supply-chain-security-analysis-module` during Tier 0/1/2 verification. |
| ELF-SECTION-EVIDENCE-0001 | DONE | Populate section hashes into native metadata for SBOM emission. |
| ELF-SECTION-DI-0001 | DONE | Register section hash extractor options and services. |
| AUDIT-HOTLIST-SCANNER-WORKER-0001 | DONE | Apply audit hotlist findings for Scanner.Worker. |

View File

@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| QA-SCANNER-VERIFY-004 | DONE | SPRINT_20260212_002 run-001: `ai-ml-supply-chain-security-analysis-module` passed Tier 0/1/2; feature moved to `docs/features/checked/scanner/`. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Libraries/StellaOps.Scanner.AiMlSecurity/StellaOps.Scanner.AiMlSecurity.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -9,3 +9,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0765-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0765-A | DONE | Already compliant (revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| QA-SCANNER-VERIFY-002 | BLOCKED | SPRINT_20260212_002 run-001: verify `secret-detection-and-credential-leak-guard` across Tier 0/1/2 evidence and terminalize dossier state. |

View File

@@ -0,0 +1,108 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v10.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v10.0": {
"StellaOps.Scanner.ChangeTrace/1.0.0": {
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.1",
"Microsoft.Extensions.Options": "10.0.1",
"StellaOps.Canonical.Json": "1.0.0"
},
"runtime": {
"StellaOps.Scanner.ChangeTrace.dll": {}
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions/10.0.1": {
"runtime": {
"lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.125.57005"
}
}
},
"Microsoft.Extensions.Logging.Abstractions/10.0.1": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
},
"runtime": {
"lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.125.57005"
}
}
},
"Microsoft.Extensions.Options/10.0.1": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1",
"Microsoft.Extensions.Primitives": "10.0.1"
},
"runtime": {
"lib/net10.0/Microsoft.Extensions.Options.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.125.57005"
}
}
},
"Microsoft.Extensions.Primitives/10.0.1": {
"runtime": {
"lib/net10.0/Microsoft.Extensions.Primitives.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.125.57005"
}
}
},
"StellaOps.Canonical.Json/1.0.0": {
"runtime": {
"StellaOps.Canonical.Json.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
}
}
},
"libraries": {
"StellaOps.Scanner.ChangeTrace/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Microsoft.Extensions.DependencyInjection.Abstractions/10.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==",
"path": "microsoft.extensions.dependencyinjection.abstractions/10.0.1",
"hashPath": "microsoft.extensions.dependencyinjection.abstractions.10.0.1.nupkg.sha512"
},
"Microsoft.Extensions.Logging.Abstractions/10.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==",
"path": "microsoft.extensions.logging.abstractions/10.0.1",
"hashPath": "microsoft.extensions.logging.abstractions.10.0.1.nupkg.sha512"
},
"Microsoft.Extensions.Options/10.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==",
"path": "microsoft.extensions.options/10.0.1",
"hashPath": "microsoft.extensions.options.10.0.1.nupkg.sha512"
},
"Microsoft.Extensions.Primitives/10.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==",
"path": "microsoft.extensions.primitives/10.0.1",
"hashPath": "microsoft.extensions.primitives.10.0.1.nupkg.sha512"
},
"StellaOps.Canonical.Json/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
}
}
}

View File

@@ -5,9 +5,8 @@
// Description: Service for matching secret findings against exception patterns.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.FileSystemGlobbing;
using System.Text.RegularExpressions;
using System.Text;
namespace StellaOps.Scanner.Core.Secrets.Configuration;
@@ -115,17 +114,14 @@ public sealed class SecretExceptionMatcher
{
try
{
var matcher = new Matcher();
matcher.AddInclude(globPattern);
// Normalize path separators to forward slashes
var normalizedPath = filePath.Replace('\\', '/').TrimStart('/');
// For patterns like **/test/**, we need to match against the path
// Matcher.Match needs both a directory base and files, but we can
// work around this by matching the path itself
var result = matcher.Match(normalizedPath);
return result.HasMatches;
var normalizedPattern = globPattern.Replace('\\', '/').TrimStart('/');
var regexPattern = ConvertGlobToRegex(normalizedPattern);
return Regex.IsMatch(
normalizedPath,
regexPattern,
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
}
catch
{
@@ -134,6 +130,52 @@ public sealed class SecretExceptionMatcher
}
}
private static string ConvertGlobToRegex(string pattern)
{
var sb = new StringBuilder(pattern.Length * 2);
sb.Append('^');
for (var i = 0; i < pattern.Length; i++)
{
var current = pattern[i];
var next = i + 1 < pattern.Length ? pattern[i + 1] : '\0';
if (current == '*' && next == '*')
{
var following = i + 2 < pattern.Length ? pattern[i + 2] : '\0';
if (following == '/')
{
sb.Append("(?:.*/)?");
i += 2;
continue;
}
sb.Append(".*");
i++;
continue;
}
switch (current)
{
case '*':
sb.Append("[^/]*");
break;
case '?':
sb.Append("[^/]");
break;
case '/':
sb.Append('/');
break;
default:
sb.Append(Regex.Escape(current.ToString()));
break;
}
}
sb.Append('$');
return sb.ToString();
}
private sealed record CompiledExceptionPattern(
SecretExceptionPattern Pattern,
Regex ValueRegex);

View File

@@ -0,0 +1,17 @@
# AGENTS - Scanner Manifest Library
## Scope
- Owns OCI manifest snapshot, layer diffID resolution, base-image attribution, and layer-reuse logic.
- Keep behavior deterministic and offline-friendly.
## Required Reading
- `src/Scanner/AGENTS.md`
- `docs/modules/scanner/architecture.md`
- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
- `docs/code-of-conduct/TESTING_PRACTICES.md`
## Working Agreement
1. Keep base-image detection deterministic (stable ordering, no random tie-breakers).
2. Do not add external network dependencies beyond existing registry clients.
3. When changing contracts or matching semantics, add/adjust focused behavioral tests.
4. Keep logs structured and free of sensitive payload data.

View File

@@ -1,7 +1,5 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using System.Collections.Concurrent;
using System.Collections.Immutable;
@@ -41,15 +39,25 @@ public sealed class BaseImageDetector : IBaseImageDetector
if (_diffIdIndex.TryGetValue(diffId, out var matches))
{
// Prefer exact layer index match
var exactMatch = matches.FirstOrDefault(m => m.LayerIndex == layerIndex);
(string BaseImage, int LayerIndex)[] snapshot;
lock (matches)
{
snapshot = [.. matches];
}
// Prefer exact layer index match.
var exactMatch = snapshot.FirstOrDefault(m => m.LayerIndex == layerIndex);
if (!string.IsNullOrEmpty(exactMatch.BaseImage))
{
return exactMatch.BaseImage;
}
// Return any matching base image
return matches.FirstOrDefault().BaseImage;
// Fuzzy fallback: closest index first, then lexical for deterministic tie-break.
return snapshot
.OrderBy(m => Math.Abs(m.LayerIndex - layerIndex))
.ThenBy(m => m.BaseImage, StringComparer.OrdinalIgnoreCase)
.Select(m => m.BaseImage)
.FirstOrDefault();
}
return null;
@@ -139,9 +147,12 @@ public sealed class BaseImageDetector : IBaseImageDetector
_diffIdIndex[diffId] = list;
}
// Remove existing entry for this base image and add new one
list.RemoveAll(e => e.BaseImage.Equals(baseImageRef, StringComparison.OrdinalIgnoreCase));
list.Add((baseImageRef, i));
// Remove existing entry for this base image and add new one.
lock (list)
{
list.RemoveAll(e => e.BaseImage.Equals(baseImageRef, StringComparison.OrdinalIgnoreCase));
list.Add((baseImageRef, i));
}
}
_logger.LogInformation("Registered base image {BaseImage}", baseImageRef);
@@ -160,6 +171,38 @@ public sealed class BaseImageDetector : IBaseImageDetector
return [.. _knownBaseImages.Keys];
}
public async Task<IReadOnlyList<BaseImageMatch>> GetRecommendationsAsync(
IEnumerable<string> layerDiffIds,
int maxRecommendations = 3,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(layerDiffIds);
await EnsureIndexLoadedAsync(cancellationToken).ConfigureAwait(false);
return BaseImageMatchEngine.RankMatches(layerDiffIds, BuildKnownBaseImageInfos(), maxRecommendations);
}
public async Task<IReadOnlyDictionary<string, IReadOnlyList<BaseImageMatch>>> GetRecommendationsBulkAsync(
IReadOnlyDictionary<string, IReadOnlyList<string>> imagesByReference,
int maxRecommendations = 3,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(imagesByReference);
await EnsureIndexLoadedAsync(cancellationToken).ConfigureAwait(false);
var known = BuildKnownBaseImageInfos();
var results = new SortedDictionary<string, IReadOnlyList<BaseImageMatch>>(StringComparer.OrdinalIgnoreCase);
foreach (var (imageReference, layers) in imagesByReference
.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
results[imageReference] = BaseImageMatchEngine.RankMatches(layers, known, maxRecommendations);
}
return results;
}
private async Task EnsureIndexLoadedAsync(CancellationToken cancellationToken)
{
if (_indexLoaded)
@@ -228,7 +271,10 @@ public sealed class BaseImageDetector : IBaseImageDetector
_diffIdIndex[diffId] = list;
}
list.Add((imageRef, layerIndex));
lock (list)
{
list.Add((imageRef, layerIndex));
}
}
// Don't forget the last image
@@ -255,4 +301,17 @@ public sealed class BaseImageDetector : IBaseImageDetector
// This is a placeholder to show the pattern.
_logger.LogDebug("Built-in base image index ready for population");
}
private IReadOnlyList<BaseImageInfo> BuildKnownBaseImageInfos()
{
return [.. _knownBaseImages
.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)
.Select(static kvp => new BaseImageInfo
{
ImageReference = kvp.Key,
LayerDiffIds = [.. kvp.Value],
RegisteredAt = DateTimeOffset.MinValue,
DetectionCount = 0
})];
}
}

View File

@@ -0,0 +1,143 @@
using System.Collections.Immutable;
using System.Globalization;
namespace StellaOps.Scanner.Manifest.Resolution;
/// <summary>
/// Deterministic base-image match scoring for exact and fuzzy recommendations.
/// </summary>
public static class BaseImageMatchEngine
{
/// <summary>
/// Ranks known base-image fingerprints against the candidate image layers.
/// </summary>
public static IReadOnlyList<BaseImageMatch> RankMatches(
IEnumerable<string> candidateLayerDiffIds,
IEnumerable<BaseImageInfo> knownBaseImages,
int maxRecommendations = 3)
{
ArgumentNullException.ThrowIfNull(candidateLayerDiffIds);
ArgumentNullException.ThrowIfNull(knownBaseImages);
if (maxRecommendations <= 0)
{
return [];
}
var candidate = Normalize(candidateLayerDiffIds);
if (candidate.Length == 0)
{
return [];
}
var candidateLayerSet = new HashSet<string>(candidate, StringComparer.OrdinalIgnoreCase);
var matches = new List<BaseImageMatch>();
foreach (var baseImage in knownBaseImages.OrderBy(static x => x.ImageReference, StringComparer.OrdinalIgnoreCase))
{
var baseLayers = Normalize(baseImage.LayerDiffIds);
if (baseLayers.Length == 0)
{
continue;
}
var prefixMatches = CountPrefixMatches(candidate, baseLayers);
var overlap = baseLayers.Count(candidateLayerSet.Contains);
if (overlap == 0)
{
continue;
}
var exact = prefixMatches == baseLayers.Length && baseLayers.Length <= candidate.Length;
var confidence = exact
? 1.0
: ComputeFuzzyConfidence(prefixMatches, overlap, candidate.Length, baseLayers.Length);
// Keep only meaningful fuzzy recommendations.
if (!exact && confidence < 0.55)
{
continue;
}
var rationale = exact
? "Exact ordered prefix match for base-image layers."
: FormattableString.Invariant(
$"Fuzzy overlap: prefix={prefixMatches}/{baseLayers.Length}, overlap={overlap}/{baseLayers.Length}.");
matches.Add(new BaseImageMatch
{
ImageReference = baseImage.ImageReference,
MatchType = exact ? BaseImageMatchType.Exact : BaseImageMatchType.Fuzzy,
Confidence = exact ? 1.0 : Math.Round(confidence, 4),
MatchedLayerCount = overlap,
CandidateLayerCount = candidate.Length,
BaseLayerCount = baseLayers.Length,
Rationale = rationale
});
}
return [.. matches
.OrderByDescending(static m => m.Confidence)
.ThenByDescending(static m => m.MatchedLayerCount)
.ThenBy(static m => m.ImageReference, StringComparer.OrdinalIgnoreCase)
.Take(maxRecommendations)];
}
private static double ComputeFuzzyConfidence(
int prefixMatches,
int overlap,
int candidateCount,
int baseCount)
{
var prefixCoverage = baseCount == 0 ? 0 : (double)prefixMatches / baseCount;
var baseCoverage = baseCount == 0 ? 0 : (double)overlap / baseCount;
var candidateCoverage = candidateCount == 0 ? 0 : (double)overlap / candidateCount;
var sizePenalty = 0.0;
var maxCount = Math.Max(candidateCount, baseCount);
if (maxCount > 0)
{
sizePenalty = Math.Abs(candidateCount - baseCount) / (double)maxCount;
}
var score = (0.55 * prefixCoverage) + (0.30 * baseCoverage) + (0.15 * candidateCoverage) - (0.10 * sizePenalty);
return Math.Clamp(score, 0.0, 0.99);
}
private static int CountPrefixMatches(IReadOnlyList<string> candidate, IReadOnlyList<string> baseLayers)
{
var max = Math.Min(candidate.Count, baseLayers.Count);
var matches = 0;
for (var i = 0; i < max; i++)
{
if (!string.Equals(candidate[i], baseLayers[i], StringComparison.OrdinalIgnoreCase))
{
break;
}
matches++;
}
return matches;
}
private static ImmutableArray<string> Normalize(IEnumerable<string> layerDiffIds)
{
return [.. layerDiffIds
.Where(static x => !string.IsNullOrWhiteSpace(x))
.Select(static x => NormalizeDiffId(x))
.Distinct(StringComparer.OrdinalIgnoreCase)];
}
private static string NormalizeDiffId(string diffId)
{
var value = diffId.Trim();
if (value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return "sha256:" + value["sha256:".Length..].ToLower(CultureInfo.InvariantCulture);
}
return value.ToLower(CultureInfo.InvariantCulture);
}
}

View File

@@ -39,6 +39,32 @@ public interface IBaseImageDetector
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of registered base image references.</returns>
Task<IReadOnlyList<string>> GetRegisteredBaseImagesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Produces ranked base-image recommendations for ordered layer diffIDs.
/// </summary>
/// <param name="layerDiffIds">Ordered layer diffIDs for the image being evaluated.</param>
/// <param name="maxRecommendations">Maximum number of recommendations to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Ranked recommendations ordered by confidence.</returns>
Task<IReadOnlyList<BaseImageMatch>> GetRecommendationsAsync(
IEnumerable<string> layerDiffIds,
int maxRecommendations = 3,
CancellationToken cancellationToken = default);
/// <summary>
/// Produces ranked base-image recommendations for multiple images in one call.
/// </summary>
/// <param name="imagesByReference">
/// Image references mapped to ordered layer diffIDs for each image.
/// </param>
/// <param name="maxRecommendations">Maximum recommendations per image.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Recommendation sets per image reference.</returns>
Task<IReadOnlyDictionary<string, IReadOnlyList<BaseImageMatch>>> GetRecommendationsBulkAsync(
IReadOnlyDictionary<string, IReadOnlyList<string>> imagesByReference,
int maxRecommendations = 3,
CancellationToken cancellationToken = default);
}
/// <summary>
@@ -66,3 +92,53 @@ public sealed record BaseImageInfo
/// </summary>
public long DetectionCount { get; init; }
}
/// <summary>
/// Match semantics for a base-image recommendation.
/// </summary>
public enum BaseImageMatchType
{
Exact = 0,
Fuzzy = 1
}
/// <summary>
/// Ranked base-image recommendation for a candidate image.
/// </summary>
public sealed record BaseImageMatch
{
/// <summary>
/// Base image reference (e.g., "alpine:3.19").
/// </summary>
public required string ImageReference { get; init; }
/// <summary>
/// Exact or fuzzy match type.
/// </summary>
public BaseImageMatchType MatchType { get; init; }
/// <summary>
/// Confidence score in [0,1].
/// </summary>
public double Confidence { get; init; }
/// <summary>
/// Number of overlapping layers between candidate and base image.
/// </summary>
public int MatchedLayerCount { get; init; }
/// <summary>
/// Number of layers in the candidate image.
/// </summary>
public int CandidateLayerCount { get; init; }
/// <summary>
/// Number of layers in the matched base image fingerprint.
/// </summary>
public int BaseLayerCount { get; init; }
/// <summary>
/// Deterministic rationale for why this recommendation was returned.
/// </summary>
public required string Rationale { get; init; }
}

View File

@@ -4,5 +4,9 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| QA-SCANNER-VERIFY-005 | DONE | SPRINT_20260212_002 run-001: `api-gateway-boundary-extractor` passed Tier 0/1/2 and moved to `docs/features/checked/scanner/`. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| QA-SCANNER-VERIFY-001 | BLOCKED | SPRINT_20260212_002 run-001: blocked verification for `3-bit-reachability-gate` due missing AGENTS in required Scanner reachability/smart-diff test modules. |

View File

@@ -6,3 +6,6 @@ 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/__Libraries/StellaOps.Scanner.SmartDiff/StellaOps.Scanner.SmartDiff.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| QA-SCANNER-VERIFY-001 | BLOCKED | SPRINT_20260212_002 run-001: blocked verification for `3-bit-reachability-gate` due missing AGENTS in required Scanner reachability/smart-diff test modules. |

View File

@@ -0,0 +1,16 @@
# AGENTS - Scanner AI/ML Security Tests
## Scope
- This directory owns behavioral and contract tests for `StellaOps.Scanner.AiMlSecurity`.
- Validate policy loading, model card checks, provenance thresholds, and risk classification with deterministic fixtures.
## Required Reading
- `src/Scanner/AGENTS.md`
- `src/Scanner/__Libraries/StellaOps.Scanner.AiMlSecurity/AGENTS.md`
- `docs/code-of-conduct/TESTING_PRACTICES.md`
## Working Agreement
1. Keep tests deterministic (fixed clocks, stable ordering, no unseeded randomness).
2. Avoid network dependencies; use local fixtures/stubs for policy and scan inputs.
3. Keep evidence and logs free of secrets or sensitive model metadata.
4. Record new behavioral prerequisites and coverage changes in sprint/run notes.

View File

@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| QA-SCANNER-VERIFY-004 | DONE | SPRINT_20260212_002 run-001: Tier 1 contract tests and Tier 2 integration tests passed for AI/ML supply-chain module behavior. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.AiMlSecurity.Tests/StellaOps.Scanner.AiMlSecurity.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -9,3 +9,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0768-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0768-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| QA-SCANNER-VERIFY-002 | BLOCKED | SPRINT_20260212_002 run-001: execute secrets analyzer behavioral checks for `secret-detection-and-credential-leak-guard`. |

View File

@@ -0,0 +1,16 @@
# AGENTS - Scanner Core Tests
## Scope
- This directory owns behavioral and contract tests for `StellaOps.Scanner.Core`.
- Keep test fixtures deterministic and offline-friendly.
## Required Reading
- `src/Scanner/AGENTS.md`
- `docs/modules/scanner/architecture.md`
- `docs/code-of-conduct/TESTING_PRACTICES.md`
## Working Agreement
1. Update sprint/task state when tests are added or behavior expectations change.
2. Prefer deterministic assertions and fixed-time providers over wall-clock time.
3. Do not log secret payloads in test output.
4. Keep test data minimal and stable to avoid flaky outcomes.

View File

@@ -0,0 +1,189 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Reflection;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.Scanner.Manifest.Resolution;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Core.Tests;
public sealed class BaseImageRecommendationTests
{
private static readonly FieldInfo KnownBaseImagesField =
typeof(BaseImageDetector).GetField("_knownBaseImages", BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Unable to locate _knownBaseImages field.");
private static readonly FieldInfo DiffIdIndexField =
typeof(BaseImageDetector).GetField("_diffIdIndex", BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Unable to locate _diffIdIndex field.");
private static readonly FieldInfo IndexLoadedField =
typeof(BaseImageDetector).GetField("_indexLoaded", BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Unable to locate _indexLoaded field.");
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RankMatches_ReturnsExactRecommendationWithConfidenceOne()
{
var known = new[]
{
new BaseImageInfo
{
ImageReference = "debian:bookworm",
LayerDiffIds = ["sha256:a1", "sha256:b1", "sha256:c1"],
RegisteredAt = DateTimeOffset.UtcNow,
DetectionCount = 0
},
new BaseImageInfo
{
ImageReference = "ubuntu:24.04",
LayerDiffIds = ["sha256:x1", "sha256:y1", "sha256:z1"],
RegisteredAt = DateTimeOffset.UtcNow,
DetectionCount = 0
}
};
var matches = BaseImageMatchEngine.RankMatches(
["sha256:a1", "sha256:b1", "sha256:c1", "sha256:app1"],
known,
maxRecommendations: 3);
Assert.NotEmpty(matches);
Assert.Equal("debian:bookworm", matches[0].ImageReference);
Assert.Equal(BaseImageMatchType.Exact, matches[0].MatchType);
Assert.Equal(1.0, matches[0].Confidence);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RankMatches_ReturnsFuzzyRecommendationWhenMajorityOfLayersOverlap()
{
var known = new[]
{
new BaseImageInfo
{
ImageReference = "alpine:3.19",
LayerDiffIds = ["sha256:a2", "sha256:b2", "sha256:c2"],
RegisteredAt = DateTimeOffset.UtcNow,
DetectionCount = 0
}
};
var matches = BaseImageMatchEngine.RankMatches(
["sha256:a2", "sha256:b2", "sha256:custom-layer"],
known,
maxRecommendations: 3);
Assert.Single(matches);
Assert.Equal(BaseImageMatchType.Fuzzy, matches[0].MatchType);
Assert.Equal("alpine:3.19", matches[0].ImageReference);
Assert.True(matches[0].Confidence >= 0.55);
Assert.True(matches[0].Confidence < 1.0);
Assert.Equal(2, matches[0].MatchedLayerCount);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RankMatches_ReturnsEmptyWhenNoKnownLayersOverlap()
{
var known = new[]
{
new BaseImageInfo
{
ImageReference = "alpine:3.19",
LayerDiffIds = ["sha256:a9", "sha256:b9", "sha256:c9"],
RegisteredAt = DateTimeOffset.UtcNow,
DetectionCount = 0
}
};
var matches = BaseImageMatchEngine.RankMatches(
["sha256:x9", "sha256:y9", "sha256:z9"],
known,
maxRecommendations: 3);
Assert.Empty(matches);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetRecommendationsBulkAsync_ReturnsDeterministicRecommendationsForEachImage()
{
await using var dataSource = NpgsqlDataSource.Create("Host=127.0.0.1;Port=65535;Database=unused;Username=unused;Password=unused");
var detector = new BaseImageDetector(dataSource, NullLogger<BaseImageDetector>.Instance);
SeedKnownBaseImages(
detector,
new BaseImageInfo
{
ImageReference = "debian:bookworm",
LayerDiffIds = ["sha256:a3", "sha256:b3", "sha256:c3"],
RegisteredAt = DateTimeOffset.UtcNow,
DetectionCount = 0
},
new BaseImageInfo
{
ImageReference = "ubi:9",
LayerDiffIds = ["sha256:m3", "sha256:n3", "sha256:o3"],
RegisteredAt = DateTimeOffset.UtcNow,
DetectionCount = 0
});
var bulk = await detector.GetRecommendationsBulkAsync(
new Dictionary<string, IReadOnlyList<string>>
{
["img-two"] = ["sha256:m3", "sha256:n3", "sha256:o3", "sha256:extra"],
["img-one"] = ["sha256:a3", "sha256:b3", "sha256:patched"]
},
maxRecommendations: 2,
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(["img-one", "img-two"], bulk.Keys.ToArray());
Assert.True(bulk["img-one"].Count <= 2);
Assert.True(bulk["img-two"].Count <= 2);
Assert.Equal("ubi:9", bulk["img-two"][0].ImageReference);
Assert.Equal(BaseImageMatchType.Exact, bulk["img-two"][0].MatchType);
Assert.Equal(BaseImageMatchType.Fuzzy, bulk["img-one"][0].MatchType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DetectBaseImageAsync_UsesClosestLayerIndexAsFuzzyFallback()
{
await using var dataSource = NpgsqlDataSource.Create("Host=127.0.0.1;Port=65535;Database=unused;Username=unused;Password=unused");
var detector = new BaseImageDetector(dataSource, NullLogger<BaseImageDetector>.Instance);
SeedDiffIndex(
detector,
"sha256:shared",
("debian:bookworm", 0),
("alpine:3.19", 3));
var detected = await detector.DetectBaseImageAsync(
"sha256:shared",
layerIndex: 2,
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("alpine:3.19", detected);
}
private static void SeedKnownBaseImages(BaseImageDetector detector, params BaseImageInfo[] images)
{
var map = (ConcurrentDictionary<string, ImmutableArray<string>>)KnownBaseImagesField.GetValue(detector)!;
foreach (var image in images)
{
map[image.ImageReference] = [.. image.LayerDiffIds];
}
IndexLoadedField.SetValue(detector, true);
}
private static void SeedDiffIndex(BaseImageDetector detector, string diffId, params (string BaseImage, int LayerIndex)[] entries)
{
var map = (ConcurrentDictionary<string, List<(string BaseImage, int LayerIndex)>>)DiffIdIndexField.GetValue(detector)!;
map[diffId] = [.. entries];
IndexLoadedField.SetValue(detector, true);
}
}

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Manifest/StellaOps.Scanner.Manifest.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
@@ -22,4 +23,4 @@
<ItemGroup>
<None Update="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,16 @@
# AGENTS - Scanner Reachability Tests
## Scope
- This directory owns behavioral and contract tests for Scanner reachability services.
- Validate gate computation, path evidence, and deterministic reachability outcomes.
## Required Reading
- `src/Scanner/AGENTS.md`
- `docs/modules/scanner/architecture.md`
- `docs/code-of-conduct/TESTING_PRACTICES.md`
## Working Agreement
1. Keep fixtures deterministic and offline-friendly.
2. Use stable graph/node ordering in assertions to avoid flaky results.
3. Do not emit sensitive path payloads in test logs.
4. Note any runtime prerequisites in sprint execution logs.

View File

@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| QA-SCANNER-VERIFY-005 | DONE | SPRINT_20260212_002 run-001: Tier 1 boundary-scope tests and Tier 2 boundary behavior tests passed for API gateway boundary extraction. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/StellaOps.Scanner.Reachability.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-059-REACHCORPUS-001 | DONE | Built deterministic toy-service reachability corpus (`labels.yaml`) and per-tier precision/recall harness for sprint 059 (2026-02-08). |

View File

@@ -0,0 +1,16 @@
# AGENTS - Scanner SmartDiff Tests
## Scope
- This directory owns behavioral and schema tests for Scanner SmartDiff features.
- Validate predicate generation, deterministic serialization, and policy-facing diff semantics.
## Required Reading
- `src/Scanner/AGENTS.md`
- `docs/modules/scanner/architecture.md`
- `docs/code-of-conduct/TESTING_PRACTICES.md`
## Working Agreement
1. Ensure deterministic outputs (stable sort order and fixed timestamp providers).
2. Keep schema/predicate fixtures versioned and minimal.
3. Avoid network calls; use local deterministic fixtures.
4. Reflect contract changes in sprint/run notes and linked docs.

View File

@@ -0,0 +1,172 @@
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.SmartDiff.Detection;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
[Trait("Category", TestCategories.Integration)]
public sealed class SmartDiffEndpointsTests
{
private const string BasePath = "/api/v1/smart-diff";
private static readonly DateTimeOffset FixedNow = new(2026, 2, 12, 8, 0, 0, TimeSpan.Zero);
[Fact]
public async Task GetScanScopedVexCandidates_ReturnsCandidatesForResolvedTargetDigest()
{
var imageDigest = "sha256:target-123";
var candidate = CreateCandidate("candidate-1", imageDigest, requiresReview: true);
var candidateStore = new InMemoryVexCandidateStore();
await candidateStore.StoreCandidatesAsync([candidate], TestContext.Current.CancellationToken);
var metadataRepository = new StubScanMetadataRepository();
metadataRepository.Set("scan-1", new ScanMetadata(null, imageDigest, FixedNow));
await using var factory = CreateFactory(candidateStore, metadataRepository);
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-1/vex-candidates", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken), cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(imageDigest, payload.RootElement.GetProperty("imageDigest").GetString());
Assert.Equal(1, payload.RootElement.GetProperty("totalCandidates").GetInt32());
var first = payload.RootElement.GetProperty("candidates")[0];
Assert.Equal("candidate-1", first.GetProperty("candidateId").GetString());
Assert.Equal("CVE-2026-0001", first.GetProperty("vulnId").GetString());
Assert.Equal("vulnerable_code_not_in_execute_path", first.GetProperty("justification").GetString());
Assert.True(first.GetProperty("requiresReview").GetBoolean());
Assert.Equal("delta", first.GetProperty("evidenceLinks")[0].GetProperty("type").GetString());
}
[Fact]
public async Task ReviewScanScopedCandidate_UpdatesCandidateReviewState()
{
var imageDigest = "sha256:target-456";
var candidateStore = new InMemoryVexCandidateStore();
await candidateStore.StoreCandidatesAsync(
[CreateCandidate("candidate-2", imageDigest, requiresReview: true)],
TestContext.Current.CancellationToken);
var metadataRepository = new StubScanMetadataRepository();
metadataRepository.Set("scan-2", new ScanMetadata(null, imageDigest, FixedNow));
await using var factory = CreateFactory(candidateStore, metadataRepository);
await factory.InitializeAsync();
using var client = factory.CreateClient();
var reviewResponse = await client.PostAsJsonAsync(
$"{BasePath}/scan-2/vex-candidates/review",
new ScanReviewRequestDto("candidate-2", "accept", "verified"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, reviewResponse.StatusCode);
var candidateResponse = await client.GetAsync($"{BasePath}/candidates/candidate-2", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, candidateResponse.StatusCode);
using var payload = await JsonDocument.ParseAsync(await candidateResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken), cancellationToken: TestContext.Current.CancellationToken);
var candidate = payload.RootElement.GetProperty("candidate");
Assert.False(candidate.GetProperty("requiresReview").GetBoolean());
}
[Fact]
public async Task GetScanSarif_EmbedsVexCandidateResults()
{
var imageDigest = "sha256:target-789";
var candidateStore = new InMemoryVexCandidateStore();
await candidateStore.StoreCandidatesAsync(
[CreateCandidate("candidate-3", imageDigest, requiresReview: false)],
TestContext.Current.CancellationToken);
var metadataRepository = new StubScanMetadataRepository();
metadataRepository.Set("scan-3", new ScanMetadata("sha256:base-111", imageDigest, FixedNow));
await using var factory = CreateFactory(candidateStore, metadataRepository);
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scans/scan-3/sarif", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains("\"ruleId\":\"SDIFF003\"", body, StringComparison.Ordinal);
Assert.Contains("CVE-2026-0001", body, StringComparison.Ordinal);
Assert.Contains("pkg:npm/test-component@1.0.0", body, StringComparison.Ordinal);
}
private static ScannerApplicationFactory CreateFactory(
IVexCandidateStore candidateStore,
IScanMetadataRepository metadataRepository)
{
return ScannerApplicationFactory.CreateLightweight()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexCandidateStore>();
services.RemoveAll<IMaterialRiskChangeRepository>();
services.RemoveAll<IScanMetadataRepository>();
services.AddSingleton(candidateStore);
services.AddSingleton<IMaterialRiskChangeRepository, StubMaterialRiskChangeRepository>();
services.AddSingleton(metadataRepository);
});
}
private static VexCandidate CreateCandidate(string candidateId, string imageDigest, bool requiresReview)
{
return new VexCandidate(
CandidateId: candidateId,
FindingKey: new FindingKey("CVE-2026-0001", "pkg:npm/test-component@1.0.0"),
SuggestedStatus: VexStatusType.NotAffected,
Justification: VexJustification.VulnerableCodeNotInExecutePath,
Rationale: "Reachability evidence shows vulnerable APIs are not reachable.",
EvidenceLinks: ImmutableArray.Create(new EvidenceLink("delta", "oci://scanner/delta/scan-1", "sha256:evidence")),
Confidence: 0.97,
ImageDigest: imageDigest,
GeneratedAt: FixedNow,
ExpiresAt: FixedNow.AddDays(30),
RequiresReview: requiresReview);
}
private sealed record ScanReviewRequestDto(string CandidateId, string Action, string? Comment);
private sealed class StubScanMetadataRepository : IScanMetadataRepository
{
private readonly Dictionary<string, ScanMetadata> _metadataByScanId = new(StringComparer.OrdinalIgnoreCase);
public void Set(string scanId, ScanMetadata metadata)
{
_metadataByScanId[scanId] = metadata;
}
public Task<ScanMetadata?> GetScanMetadataAsync(string scanId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_metadataByScanId.TryGetValue(scanId, out var metadata) ? metadata : null);
}
}
private sealed class StubMaterialRiskChangeRepository : IMaterialRiskChangeRepository
{
public Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default) => Task.CompletedTask;
public Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default) => Task.CompletedTask;
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<MaterialRiskChangeResult>>(Array.Empty<MaterialRiskChangeResult>());
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(FindingKey findingKey, int limit = 10, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<MaterialRiskChangeResult>>(Array.Empty<MaterialRiskChangeResult>());
public Task<MaterialRiskChangeQueryResult> QueryChangesAsync(MaterialRiskChangeQuery query, CancellationToken ct = default) =>
Task.FromResult(new MaterialRiskChangeQueryResult(ImmutableArray<MaterialRiskChangeResult>.Empty, 0, query.Offset, query.Limit));
}
}

View File

@@ -0,0 +1,16 @@
# AGENTS - Scanner Worker Tests
## Scope
- This directory owns behavioral and integration tests for `StellaOps.Scanner.Worker`.
- Validate queue/stage execution semantics with deterministic, local-only fixtures.
## Required Reading
- `src/Scanner/AGENTS.md`
- `src/Scanner/StellaOps.Scanner.Worker/AGENTS.md`
- `docs/code-of-conduct/TESTING_PRACTICES.md`
## Working Agreement
1. Keep tests deterministic (stable ordering, fixed clocks, no random data without seeds).
2. Avoid external network dependencies; use local stubs/fakes.
3. Redact or avoid sensitive data in assertions and logs.
4. Record any new runtime prerequisites in sprint/run notes.

View File

@@ -0,0 +1,203 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Concelier.SbomIntegration.Parsing;
using StellaOps.Scanner.AiMlSecurity;
using StellaOps.Scanner.AiMlSecurity.Models;
using StellaOps.Scanner.AiMlSecurity.Policy;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Processing.AiMlSecurity;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class AiMlSecurityStageExecutorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_StoresReportAndPolicyVersion_WhenModelCardExists()
{
var analyzer = new Mock<IAiMlSecurityAnalyzer>(MockBehavior.Strict);
var policyLoader = new Mock<IAiGovernancePolicyLoader>(MockBehavior.Strict);
var parsedSbomParser = new Mock<IParsedSbomParser>(MockBehavior.Strict);
var sbomParser = new Mock<ISbomParser>(MockBehavior.Strict);
var policy = AiGovernancePolicyDefaults.Default with { Version = "policy-v1" };
policyLoader.Setup(x => x.LoadAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
parsedSbomParser.Setup(x => x.ParseAsync(It.IsAny<Stream>(), SbomFormat.CycloneDX, It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateParsedSbom(withModelCard: true));
var report = new AiMlSecurityReport
{
PolicyVersion = "policy-v1",
Summary = new AiMlSummary { TotalFindings = 2 }
};
analyzer.Setup(x => x.AnalyzeAsync(It.IsAny<IReadOnlyList<ParsedComponent>>(), policy, It.IsAny<CancellationToken>()))
.ReturnsAsync(report);
var executor = CreateExecutor(analyzer.Object, policyLoader.Object, parsedSbomParser.Object, sbomParser.Object);
var sbomPath = CreateTempSbomFile();
try
{
var context = CreateContext(sbomPath);
await executor.ExecuteAsync(context, TestContext.Current.CancellationToken);
Assert.True(context.Analysis.TryGet<AiMlSecurityReport>(ScanAnalysisKeys.AiMlSecurityReport, out var storedReport));
Assert.NotNull(storedReport);
Assert.Equal(2, storedReport!.Summary.TotalFindings);
Assert.True(context.Analysis.TryGet<string>(ScanAnalysisKeys.AiMlPolicyVersion, out var policyVersion));
Assert.Equal("policy-v1", policyVersion);
}
finally
{
File.Delete(sbomPath);
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_LoadsPolicyForEachRun_WithoutRestart()
{
var analyzer = new Mock<IAiMlSecurityAnalyzer>(MockBehavior.Strict);
var policyLoader = new Mock<IAiGovernancePolicyLoader>(MockBehavior.Strict);
var parsedSbomParser = new Mock<IParsedSbomParser>(MockBehavior.Strict);
var sbomParser = new Mock<ISbomParser>(MockBehavior.Strict);
var policyV1 = AiGovernancePolicyDefaults.Default with { Version = "policy-v1" };
var policyV2 = AiGovernancePolicyDefaults.Default with { Version = "policy-v2", RequireRiskAssessment = true };
policyLoader.SetupSequence(x => x.LoadAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(policyV1)
.ReturnsAsync(policyV2);
parsedSbomParser.Setup(x => x.ParseAsync(It.IsAny<Stream>(), SbomFormat.CycloneDX, It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateParsedSbom(withModelCard: true));
var capturedPolicies = new List<AiGovernancePolicy>();
analyzer.Setup(x => x.AnalyzeAsync(It.IsAny<IReadOnlyList<ParsedComponent>>(), It.IsAny<AiGovernancePolicy>(), It.IsAny<CancellationToken>()))
.Callback<IReadOnlyList<ParsedComponent>, AiGovernancePolicy, CancellationToken>((_, p, _) => capturedPolicies.Add(p))
.ReturnsAsync((IReadOnlyList<ParsedComponent> _, AiGovernancePolicy p, CancellationToken _) =>
new AiMlSecurityReport { PolicyVersion = p.Version, Summary = new AiMlSummary() });
var executor = CreateExecutor(analyzer.Object, policyLoader.Object, parsedSbomParser.Object, sbomParser.Object);
var sbomPath = CreateTempSbomFile();
try
{
var firstContext = CreateContext(sbomPath);
var secondContext = CreateContext(sbomPath);
await executor.ExecuteAsync(firstContext, TestContext.Current.CancellationToken);
await executor.ExecuteAsync(secondContext, TestContext.Current.CancellationToken);
Assert.Collection(
capturedPolicies,
p => Assert.Equal("policy-v1", p.Version),
p => Assert.Equal("policy-v2", p.Version));
Assert.True(firstContext.Analysis.TryGet<string>(ScanAnalysisKeys.AiMlPolicyVersion, out var firstVersion));
Assert.True(secondContext.Analysis.TryGet<string>(ScanAnalysisKeys.AiMlPolicyVersion, out var secondVersion));
Assert.Equal("policy-v1", firstVersion);
Assert.Equal("policy-v2", secondVersion);
}
finally
{
File.Delete(sbomPath);
}
}
private static AiMlSecurityStageExecutor CreateExecutor(
IAiMlSecurityAnalyzer analyzer,
IAiGovernancePolicyLoader policyLoader,
IParsedSbomParser parsedSbomParser,
ISbomParser sbomParser)
{
var options = new ScannerWorkerOptions();
options.AiMlSecurity.Enabled = true;
return new AiMlSecurityStageExecutor(
analyzer,
policyLoader,
parsedSbomParser,
sbomParser,
Microsoft.Extensions.Options.Options.Create(options),
NullLogger<AiMlSecurityStageExecutor>.Instance);
}
private static ScanJobContext CreateContext(string sbomPath)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
[ScanMetadataKeys.SbomPath] = sbomPath,
[ScanMetadataKeys.SbomFormat] = "cyclonedx"
};
var lease = new TestLease(metadata);
return new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
}
private static ParsedSbom CreateParsedSbom(bool withModelCard)
{
var modelCard = withModelCard
? new ParsedModelCard { ModelParameters = new ParsedModelParameters { Task = "classification" } }
: null;
return new ParsedSbom
{
Format = "CycloneDX",
SpecVersion = "1.6",
SerialNumber = "urn:uuid:test",
Metadata = new ParsedSbomMetadata { Name = "scanner-test" },
Components =
[
new ParsedComponent
{
BomRef = "model-1",
Name = "ml-model",
Type = "machine-learning-model",
ModelCard = modelCard
}
]
};
}
private static string CreateTempSbomFile()
{
var path = Path.Combine(Path.GetTempPath(), "stellaops-tests", "scanner", $"aiml-stage-{Guid.NewGuid():N}.json");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, "{}");
return path;
}
private sealed class TestLease : IScanJobLease
{
public TestLease(IReadOnlyDictionary<string, string> metadata)
{
Metadata = metadata;
var now = DateTimeOffset.UtcNow;
EnqueuedAtUtc = now;
LeasedAtUtc = now;
}
public string JobId => "job-aiml";
public string ScanId => "scan-aiml";
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata { get; }
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}