save checkpoint: save features
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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). |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
17
src/Scanner/__Libraries/StellaOps.Scanner.Manifest/AGENTS.md
Normal file
17
src/Scanner/__Libraries/StellaOps.Scanner.Manifest/AGENTS.md
Normal 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.
|
||||
@@ -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
|
||||
})];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
|
||||
16
src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/AGENTS.md
Normal file
16
src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/AGENTS.md
Normal 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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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). |
|
||||
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
16
src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/AGENTS.md
Normal file
16
src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/AGENTS.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user