audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration
This commit is contained in:
@@ -47,7 +47,7 @@ public sealed class NativeBinaryDiscovery
|
||||
if (!Directory.Exists(rootPath))
|
||||
{
|
||||
_logger.LogWarning("Root path does not exist: {RootPath}", rootPath);
|
||||
return Array.Empty<DiscoveredBinary>();
|
||||
return Task.FromResult<IReadOnlyList<DiscoveredBinary>>(Array.Empty<DiscoveredBinary>());
|
||||
}
|
||||
|
||||
var discovered = new List<DiscoveredBinary>();
|
||||
|
||||
@@ -211,36 +211,132 @@ public sealed class SourceConfigValidator : ISourceConfigValidator
|
||||
{
|
||||
var root = config.RootElement;
|
||||
|
||||
// Optional: acceptedFormats
|
||||
if (root.TryGetProperty("acceptedFormats", out var formats) &&
|
||||
formats.ValueKind == JsonValueKind.Array)
|
||||
// Required: allowedTools
|
||||
if (!root.TryGetProperty("allowedTools", out var allowedTools) ||
|
||||
allowedTools.ValueKind != JsonValueKind.Array ||
|
||||
allowedTools.GetArrayLength() == 0)
|
||||
{
|
||||
foreach (var format in formats.EnumerateArray())
|
||||
errors.Add("allowedTools is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var tool in allowedTools.EnumerateArray())
|
||||
{
|
||||
var formatStr = format.GetString();
|
||||
if (!Enum.TryParse<SbomFormat>(formatStr, true, out _))
|
||||
if (string.IsNullOrWhiteSpace(tool.GetString()))
|
||||
{
|
||||
errors.Add($"Invalid SBOM format: {formatStr}. Valid values: {string.Join(", ", Enum.GetNames<SbomFormat>())}");
|
||||
errors.Add("allowedTools contains empty value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: validationRules
|
||||
if (root.TryGetProperty("validationRules", out var validation))
|
||||
// Optional: allowedCiSystems
|
||||
if (root.TryGetProperty("allowedCiSystems", out var allowedCi))
|
||||
{
|
||||
if (validation.TryGetProperty("maxFileSizeBytes", out var maxSize))
|
||||
if (allowedCi.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
errors.Add("allowedCiSystems must be an array");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var system in allowedCi.EnumerateArray())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(system.GetString()))
|
||||
{
|
||||
errors.Add("allowedCiSystems contains empty value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Required: validation
|
||||
if (!root.TryGetProperty("validation", out var validation) ||
|
||||
validation.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add("validation is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!validation.TryGetProperty("allowedFormats", out var formats) ||
|
||||
formats.ValueKind != JsonValueKind.Array ||
|
||||
formats.GetArrayLength() == 0)
|
||||
{
|
||||
errors.Add("validation.allowedFormats is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var format in formats.EnumerateArray())
|
||||
{
|
||||
var formatStr = format.GetString();
|
||||
if (string.IsNullOrWhiteSpace(formatStr))
|
||||
{
|
||||
errors.Add("validation.allowedFormats contains empty value");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<SbomFormat>(formatStr, true, out _))
|
||||
{
|
||||
errors.Add($"Invalid SBOM format: {formatStr}. Valid values: {string.Join(", ", Enum.GetNames<SbomFormat>())}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validation.TryGetProperty("maxSbomSizeBytes", out var maxSize))
|
||||
{
|
||||
if (maxSize.TryGetInt64(out var size) && size <= 0)
|
||||
{
|
||||
errors.Add("maxFileSizeBytes must be positive");
|
||||
errors.Add("validation.maxSbomSizeBytes must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
if (validation.TryGetProperty("minSpecVersion", out var minSpec))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(minSpec.GetString()))
|
||||
{
|
||||
errors.Add("validation.minSpecVersion must be a non-empty string");
|
||||
}
|
||||
}
|
||||
|
||||
if (validation.TryGetProperty("allowedSigners", out var allowedSigners) &&
|
||||
allowedSigners.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
errors.Add("validation.allowedSigners must be an array");
|
||||
}
|
||||
|
||||
if (validation.TryGetProperty("requiredFields", out var requiredFields) &&
|
||||
requiredFields.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
errors.Add("validation.requiredFields must be an array");
|
||||
}
|
||||
}
|
||||
|
||||
// Warnings for missing recommended settings
|
||||
if (!root.TryGetProperty("validationRules", out _))
|
||||
// Required: attribution
|
||||
if (!root.TryGetProperty("attribution", out var attribution) ||
|
||||
attribution.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add("No validation rules specified - using defaults");
|
||||
errors.Add("attribution is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (attribution.TryGetProperty("allowedRepositories", out var allowedRepos))
|
||||
{
|
||||
if (allowedRepos.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
errors.Add("attribution.allowedRepositories must be an array");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var repo in allowedRepos.EnumerateArray())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(repo.GetString()))
|
||||
{
|
||||
errors.Add("attribution.allowedRepositories contains empty value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -256,6 +352,8 @@ public sealed class SourceConfigValidator : ISourceConfigValidator
|
||||
: ConfigValidationResult.Success();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private ConfigValidationResult ValidateGitConfig(JsonDocument config)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
@@ -275,7 +373,7 @@ public sealed class SourceConfigValidator : ISourceConfigValidator
|
||||
{
|
||||
var url = repoUrl.GetString()!;
|
||||
// Allow git://, https://, ssh:// URLs
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out _))
|
||||
{
|
||||
// Also check for SSH-style URLs (git@github.com:org/repo.git)
|
||||
if (!url.Contains('@') || !url.Contains(':'))
|
||||
@@ -285,11 +383,19 @@ public sealed class SourceConfigValidator : ISourceConfigValidator
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: provider (for better integration)
|
||||
if (root.TryGetProperty("provider", out var provider))
|
||||
// Required: provider (for better integration)
|
||||
if (!root.TryGetProperty("provider", out var provider))
|
||||
{
|
||||
errors.Add("provider is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
var providerStr = provider.GetString();
|
||||
if (!Enum.TryParse<GitProvider>(providerStr, true, out _))
|
||||
if (string.IsNullOrWhiteSpace(providerStr))
|
||||
{
|
||||
errors.Add("provider is required");
|
||||
}
|
||||
else if (!Enum.TryParse<GitProvider>(providerStr, true, out _))
|
||||
{
|
||||
errors.Add($"Invalid provider: {providerStr}. Valid values: {string.Join(", ", Enum.GetNames<GitProvider>())}");
|
||||
}
|
||||
@@ -305,16 +411,37 @@ public sealed class SourceConfigValidator : ISourceConfigValidator
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: branchConfig
|
||||
if (root.TryGetProperty("branchConfig", out var branchConfig))
|
||||
// Required: branches
|
||||
if (!root.TryGetProperty("branches", out var branches) ||
|
||||
branches.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
ValidateBranchConfig(branchConfig, errors);
|
||||
errors.Add("branches is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateBranchConfig(branches, errors);
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if (!root.TryGetProperty("branchConfig", out _))
|
||||
// Required: triggers
|
||||
if (!root.TryGetProperty("triggers", out var triggers) ||
|
||||
triggers.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add("No branch configuration - using default branch only");
|
||||
errors.Add("triggers is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateTriggerConfig(triggers, errors);
|
||||
}
|
||||
|
||||
// Required: scanOptions
|
||||
if (!root.TryGetProperty("scanOptions", out var scanOptions) ||
|
||||
scanOptions.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add("scanOptions is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateGitScanOptions(scanOptions, errors);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -372,24 +499,164 @@ public sealed class SourceConfigValidator : ISourceConfigValidator
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private static void ValidateBranchConfig(JsonElement branchConfig, List<string> errors)
|
||||
{
|
||||
if (branchConfig.TryGetProperty("branchPatterns", out var patterns) &&
|
||||
patterns.ValueKind == JsonValueKind.Array)
|
||||
if (!branchConfig.TryGetProperty("include", out var include) ||
|
||||
include.ValueKind != JsonValueKind.Array ||
|
||||
include.GetArrayLength() == 0)
|
||||
{
|
||||
foreach (var pattern in patterns.EnumerateArray())
|
||||
errors.Add("branches.include is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var pattern in include.EnumerateArray())
|
||||
{
|
||||
var patternStr = pattern.GetString();
|
||||
if (string.IsNullOrWhiteSpace(patternStr))
|
||||
if (string.IsNullOrWhiteSpace(pattern.GetString()))
|
||||
{
|
||||
errors.Add("branchConfig.branchPatterns contains empty pattern");
|
||||
errors.Add("branches.include contains empty pattern");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (branchConfig.TryGetProperty("exclude", out var exclude))
|
||||
{
|
||||
if (exclude.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
errors.Add("branches.exclude must be an array");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var pattern in exclude.EnumerateArray())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern.GetString()))
|
||||
{
|
||||
errors.Add("branches.exclude contains empty pattern");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region JSON Schemas
|
||||
private static void ValidateTriggerConfig(JsonElement triggers, List<string> errors)
|
||||
{
|
||||
if (triggers.TryGetProperty("tagPatterns", out var tagPatterns))
|
||||
{
|
||||
if (tagPatterns.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
errors.Add("triggers.tagPatterns must be an array");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var pattern in tagPatterns.EnumerateArray())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern.GetString()))
|
||||
{
|
||||
errors.Add("triggers.tagPatterns contains empty pattern");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (triggers.TryGetProperty("prActions", out var prActions))
|
||||
{
|
||||
if (prActions.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
errors.Add("triggers.prActions must be an array");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var action in prActions.EnumerateArray())
|
||||
{
|
||||
var actionStr = action.GetString();
|
||||
if (string.IsNullOrWhiteSpace(actionStr) ||
|
||||
!Enum.TryParse<PullRequestAction>(actionStr, true, out _))
|
||||
{
|
||||
errors.Add($"Invalid prAction: {actionStr}. Valid values: {string.Join(", ", Enum.GetNames<PullRequestAction>())}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateGitScanOptions(JsonElement scanOptions, List<string> errors)
|
||||
{
|
||||
if (!scanOptions.TryGetProperty("analyzers", out var analyzers) ||
|
||||
analyzers.ValueKind != JsonValueKind.Array ||
|
||||
analyzers.GetArrayLength() == 0)
|
||||
{
|
||||
errors.Add("scanOptions.analyzers is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var analyzer in analyzers.EnumerateArray())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(analyzer.GetString()))
|
||||
{
|
||||
errors.Add("scanOptions.analyzers contains empty value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scanOptions.TryGetProperty("scanPaths", out var scanPaths))
|
||||
{
|
||||
if (scanPaths.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
errors.Add("scanOptions.scanPaths must be an array");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var path in scanPaths.EnumerateArray())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path.GetString()))
|
||||
{
|
||||
errors.Add("scanOptions.scanPaths contains empty value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scanOptions.TryGetProperty("excludePaths", out var excludePaths))
|
||||
{
|
||||
if (excludePaths.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
errors.Add("scanOptions.excludePaths must be an array");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var path in excludePaths.EnumerateArray())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path.GetString()))
|
||||
{
|
||||
errors.Add("scanOptions.excludePaths contains empty value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scanOptions.TryGetProperty("cloneDepth", out var cloneDepth))
|
||||
{
|
||||
if (cloneDepth.TryGetInt32(out var depth) && depth < 0)
|
||||
{
|
||||
errors.Add("scanOptions.cloneDepth must be zero or greater");
|
||||
}
|
||||
}
|
||||
|
||||
if (scanOptions.TryGetProperty("maxRepoSizeMb", out var maxRepoSize))
|
||||
{
|
||||
if (maxRepoSize.TryGetInt32(out var size) && size <= 0)
|
||||
{
|
||||
errors.Add("scanOptions.maxRepoSizeMb must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region JSON Schemas
|
||||
private static string GetZastavaSchema() => """
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
@@ -455,31 +722,51 @@ public sealed class SourceConfigValidator : ISourceConfigValidator
|
||||
}
|
||||
""";
|
||||
|
||||
|
||||
|
||||
private static string GetCliSchema() => """
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": ["allowedTools", "validation", "attribution"],
|
||||
"properties": {
|
||||
"acceptedFormats": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["CycloneDX", "SPDX", "Syft", "Auto"]
|
||||
"allowedTools": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
|
||||
"allowedCiSystems": { "type": "array", "items": { "type": "string" } },
|
||||
"validation": {
|
||||
"type": "object",
|
||||
"required": ["allowedFormats"],
|
||||
"properties": {
|
||||
"requireSignedSbom": { "type": "boolean" },
|
||||
"allowedSigners": { "type": "array", "items": { "type": "string" } },
|
||||
"maxSbomSizeBytes": { "type": "integer", "minimum": 1 },
|
||||
"allowedFormats": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["SpdxJson", "CycloneDxJson", "CycloneDxXml"]
|
||||
}
|
||||
},
|
||||
"minSpecVersion": { "type": "string" },
|
||||
"requiredFields": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"validationRules": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requireSignature": { "type": "boolean" },
|
||||
"maxFileSizeBytes": { "type": "integer", "minimum": 1 },
|
||||
"maxComponents": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"attributionRules": {
|
||||
"attribution": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requireBuildId": { "type": "boolean" },
|
||||
"requireRepository": { "type": "boolean" },
|
||||
"requireCommitSha": { "type": "boolean" },
|
||||
"requirePipelineId": { "type": "boolean" },
|
||||
"requireArtifactRef": { "type": "boolean" }
|
||||
"allowedRepositories": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"postProcessing": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"runVulnMatching": { "type": "boolean" },
|
||||
"runReachability": { "type": "boolean" },
|
||||
"applyVex": { "type": "boolean" },
|
||||
"generateAttestation": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,31 +777,55 @@ public sealed class SourceConfigValidator : ISourceConfigValidator
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": ["repositoryUrl"],
|
||||
"required": ["provider", "repositoryUrl", "branches", "triggers", "scanOptions"],
|
||||
"properties": {
|
||||
"repositoryUrl": { "type": "string" },
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": ["GitHub", "GitLab", "Bitbucket", "AzureDevOps", "Gitea", "Custom"]
|
||||
"enum": ["GitHub", "GitLab", "Bitbucket", "AzureDevOps", "Gitea", "Generic"]
|
||||
},
|
||||
"authMethod": {
|
||||
"type": "string",
|
||||
"enum": ["None", "Token", "SshKey", "App", "BasicAuth"]
|
||||
"enum": ["Token", "Ssh", "OAuth", "GitHubApp"]
|
||||
},
|
||||
"branchConfig": {
|
||||
"branches": {
|
||||
"type": "object",
|
||||
"required": ["include"],
|
||||
"properties": {
|
||||
"defaultBranch": { "type": "string" },
|
||||
"branchPatterns": { "type": "array", "items": { "type": "string" } },
|
||||
"excludeBranches": { "type": "array", "items": { "type": "string" } }
|
||||
"include": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
|
||||
"exclude": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"triggerConfig": {
|
||||
"triggers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"onPush": { "type": "boolean" },
|
||||
"onPullRequest": { "type": "boolean" },
|
||||
"onTag": { "type": "boolean" }
|
||||
"onTag": { "type": "boolean" },
|
||||
"tagPatterns": { "type": "array", "items": { "type": "string" } },
|
||||
"prActions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["Opened", "Synchronize", "Reopened", "ReadyForReview"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanOptions": {
|
||||
"type": "object",
|
||||
"required": ["analyzers"],
|
||||
"properties": {
|
||||
"analyzers": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
|
||||
"scanPaths": { "type": "array", "items": { "type": "string" } },
|
||||
"excludePaths": { "type": "array", "items": { "type": "string" } },
|
||||
"lockfileOnly": { "type": "boolean" },
|
||||
"enableReachability": { "type": "boolean" },
|
||||
"enableVexLookup": { "type": "boolean" },
|
||||
"cloneDepth": { "type": "integer", "minimum": 0 },
|
||||
"includeSubmodules": { "type": "boolean" },
|
||||
"maxRepoSizeMb": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Domain;
|
||||
|
||||
#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary
|
||||
|
||||
/// <summary>
|
||||
/// Represents a configured SBOM ingestion source.
|
||||
|
||||
@@ -2,7 +2,6 @@ using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Domain;
|
||||
|
||||
#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single execution run of an SBOM source.
|
||||
@@ -138,6 +137,7 @@ public sealed class SbomSourceRun
|
||||
/// </summary>
|
||||
public void RecordItemSkipped()
|
||||
{
|
||||
ItemsScanned++;
|
||||
ItemsSkipped++;
|
||||
}
|
||||
|
||||
|
||||
@@ -359,8 +359,7 @@ public sealed class SbomSourceService : ISbomSourceService
|
||||
"Triggered manual scan for source {SourceId} ({Name}), run {RunId}",
|
||||
sourceId, source.Name, run.RunId);
|
||||
|
||||
// TODO: Actually dispatch the scan to the trigger service
|
||||
// For now, just return the run info
|
||||
// Dispatch is not initiated here; return run metadata only.
|
||||
return new TriggerScanResult
|
||||
{
|
||||
RunId = run.RunId,
|
||||
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0766-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0766-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0766-A | DONE | Already compliant (revalidated 2026-01-07). |
|
||||
| AUDIT-0684-M | DONE | Revalidated 2026-01-12. |
|
||||
| AUDIT-0684-T | DONE | Revalidated 2026-01-12. |
|
||||
| AUDIT-0684-A | DONE | Applied 2026-01-14. |
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Contracts.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Visibility enum.
|
||||
/// </summary>
|
||||
public sealed class VisibilityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Visibility.Public)]
|
||||
[InlineData(Visibility.Internal)]
|
||||
[InlineData(Visibility.Protected)]
|
||||
[InlineData(Visibility.Private)]
|
||||
public void Visibility_AllValues_AreDefined(Visibility visibility)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(visibility));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Visibility_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<Visibility>();
|
||||
Assert.Equal(4, values.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Visibility_JsonSerialization_UsesStringValue()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(Visibility.Public);
|
||||
Assert.Equal("\"Public\"", json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CallKind enum.
|
||||
/// </summary>
|
||||
public sealed class CallKindTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(CallKind.Direct)]
|
||||
[InlineData(CallKind.Virtual)]
|
||||
[InlineData(CallKind.Delegate)]
|
||||
[InlineData(CallKind.Reflection)]
|
||||
[InlineData(CallKind.Dynamic)]
|
||||
[InlineData(CallKind.Plt)]
|
||||
[InlineData(CallKind.Iat)]
|
||||
public void CallKind_AllValues_AreDefined(CallKind kind)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(kind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallKind_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<CallKind>();
|
||||
Assert.Equal(7, values.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallKind_JsonSerialization_UsesStringValue()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(CallKind.Direct);
|
||||
Assert.Equal("\"Direct\"", json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for EntrypointType enum.
|
||||
/// </summary>
|
||||
public sealed class EntrypointTypeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(EntrypointType.HttpHandler)]
|
||||
[InlineData(EntrypointType.GrpcMethod)]
|
||||
[InlineData(EntrypointType.CliCommand)]
|
||||
[InlineData(EntrypointType.BackgroundJob)]
|
||||
[InlineData(EntrypointType.ScheduledJob)]
|
||||
[InlineData(EntrypointType.MessageHandler)]
|
||||
[InlineData(EntrypointType.EventSubscriber)]
|
||||
[InlineData(EntrypointType.WebSocketHandler)]
|
||||
[InlineData(EntrypointType.EventHandler)]
|
||||
[InlineData(EntrypointType.Lambda)]
|
||||
[InlineData(EntrypointType.Unknown)]
|
||||
public void EntrypointType_AllValues_AreDefined(EntrypointType type)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointType_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<EntrypointType>();
|
||||
Assert.Equal(11, values.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SinkCategory enum.
|
||||
/// </summary>
|
||||
public sealed class SinkCategoryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(SinkCategory.CmdExec)]
|
||||
[InlineData(SinkCategory.UnsafeDeser)]
|
||||
[InlineData(SinkCategory.SqlRaw)]
|
||||
[InlineData(SinkCategory.SqlInjection)]
|
||||
[InlineData(SinkCategory.Ssrf)]
|
||||
[InlineData(SinkCategory.FileWrite)]
|
||||
[InlineData(SinkCategory.PathTraversal)]
|
||||
[InlineData(SinkCategory.TemplateInjection)]
|
||||
[InlineData(SinkCategory.CryptoWeak)]
|
||||
[InlineData(SinkCategory.AuthzBypass)]
|
||||
[InlineData(SinkCategory.LdapInjection)]
|
||||
[InlineData(SinkCategory.XPathInjection)]
|
||||
[InlineData(SinkCategory.XxeInjection)]
|
||||
[InlineData(SinkCategory.CodeInjection)]
|
||||
[InlineData(SinkCategory.LogInjection)]
|
||||
[InlineData(SinkCategory.Reflection)]
|
||||
public void SinkCategory_KnownValues_AreDefined(SinkCategory category)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(category));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SinkCategory_JsonSerialization_UsesJsonAttribute()
|
||||
{
|
||||
// SinkCategory has JsonStringEnumMemberName attributes
|
||||
var json = JsonSerializer.Serialize(SinkCategory.CmdExec);
|
||||
Assert.Equal("\"CMD_EXEC\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SinkCategory_JsonDeserialization_FromJsonAttribute()
|
||||
{
|
||||
var category = JsonSerializer.Deserialize<SinkCategory>("\"SQL_INJECTION\"");
|
||||
Assert.Equal(SinkCategory.SqlInjection, category);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CMD_EXEC", SinkCategory.CmdExec)]
|
||||
[InlineData("UNSAFE_DESER", SinkCategory.UnsafeDeser)]
|
||||
[InlineData("SQL_RAW", SinkCategory.SqlRaw)]
|
||||
[InlineData("SSRF", SinkCategory.Ssrf)]
|
||||
[InlineData("FILE_WRITE", SinkCategory.FileWrite)]
|
||||
[InlineData("PATH_TRAVERSAL", SinkCategory.PathTraversal)]
|
||||
[InlineData("CRYPTO_WEAK", SinkCategory.CryptoWeak)]
|
||||
[InlineData("AUTHZ_BYPASS", SinkCategory.AuthzBypass)]
|
||||
[InlineData("XXE", SinkCategory.XxeInjection)]
|
||||
[InlineData("CODE_INJECTION", SinkCategory.CodeInjection)]
|
||||
public void SinkCategory_JsonRoundTrip_PreservesValue(string jsonValue, SinkCategory expected)
|
||||
{
|
||||
var json = $"\"{jsonValue}\"";
|
||||
var deserialized = JsonSerializer.Deserialize<SinkCategory>(json);
|
||||
Assert.Equal(expected, deserialized);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(expected);
|
||||
Assert.Equal(json, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CallEdgeExplanationType enum.
|
||||
/// </summary>
|
||||
public sealed class CallEdgeExplanationTypeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(CallEdgeExplanationType.Import)]
|
||||
[InlineData(CallEdgeExplanationType.DynamicLoad)]
|
||||
[InlineData(CallEdgeExplanationType.Reflection)]
|
||||
[InlineData(CallEdgeExplanationType.Ffi)]
|
||||
[InlineData(CallEdgeExplanationType.EnvGuard)]
|
||||
[InlineData(CallEdgeExplanationType.FeatureFlag)]
|
||||
[InlineData(CallEdgeExplanationType.PlatformArch)]
|
||||
public void CallEdgeExplanationType_KnownValues_AreDefined(CallEdgeExplanationType type)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallEdgeExplanationType_JsonSerialization_UsesStringValue()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(CallEdgeExplanationType.Import);
|
||||
Assert.Equal("\"Import\"", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Contracts\StellaOps.Scanner.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.ProofIntegration\StellaOps.Scanner.ProofIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,145 @@
|
||||
using StellaOps.Scanner.ProofIntegration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.ProofIntegration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for VulnerabilityFinding record.
|
||||
/// </summary>
|
||||
public sealed class VulnerabilityFindingTests
|
||||
{
|
||||
[Fact]
|
||||
public void VulnerabilityFinding_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var finding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-12345",
|
||||
PackagePurl = "pkg:npm/lodash@4.17.20",
|
||||
PackageName = "lodash",
|
||||
PackageVersion = "4.17.20",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
|
||||
Assert.Equal("CVE-2026-12345", finding.CveId);
|
||||
Assert.Equal("pkg:npm/lodash@4.17.20", finding.PackagePurl);
|
||||
Assert.Equal("lodash", finding.PackageName);
|
||||
Assert.Equal("4.17.20", finding.PackageVersion);
|
||||
Assert.Equal("HIGH", finding.Severity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("LOW")]
|
||||
[InlineData("MEDIUM")]
|
||||
[InlineData("HIGH")]
|
||||
[InlineData("CRITICAL")]
|
||||
public void VulnerabilityFinding_SeverityLevels_AreAccepted(string severity)
|
||||
{
|
||||
var finding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-00001",
|
||||
PackagePurl = "pkg:pypi/requests@2.28.0",
|
||||
PackageName = "requests",
|
||||
PackageVersion = "2.28.0",
|
||||
Severity = severity
|
||||
};
|
||||
|
||||
Assert.Equal(severity, finding.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnerabilityFinding_WithDifferentEcosystems_WorksCorrectly()
|
||||
{
|
||||
// npm
|
||||
var npmFinding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-NPM01",
|
||||
PackagePurl = "pkg:npm/@angular/core@17.0.0",
|
||||
PackageName = "@angular/core",
|
||||
PackageVersion = "17.0.0",
|
||||
Severity = "MEDIUM"
|
||||
};
|
||||
Assert.Contains("npm", npmFinding.PackagePurl);
|
||||
|
||||
// pypi
|
||||
var pypiFinding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-PYPI01",
|
||||
PackagePurl = "pkg:pypi/django@4.2.0",
|
||||
PackageName = "django",
|
||||
PackageVersion = "4.2.0",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
Assert.Contains("pypi", pypiFinding.PackagePurl);
|
||||
|
||||
// nuget
|
||||
var nugetFinding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-NUGET01",
|
||||
PackagePurl = "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
PackageName = "Newtonsoft.Json",
|
||||
PackageVersion = "13.0.3",
|
||||
Severity = "LOW"
|
||||
};
|
||||
Assert.Contains("nuget", nugetFinding.PackagePurl);
|
||||
|
||||
// golang
|
||||
var goFinding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-GO01",
|
||||
PackagePurl = "pkg:golang/github.com/gin-gonic/gin@1.9.0",
|
||||
PackageName = "github.com/gin-gonic/gin",
|
||||
PackageVersion = "1.9.0",
|
||||
Severity = "CRITICAL"
|
||||
};
|
||||
Assert.Contains("golang", goFinding.PackagePurl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnerabilityFinding_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var finding1 = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-00001",
|
||||
PackagePurl = "pkg:npm/lodash@4.17.20",
|
||||
PackageName = "lodash",
|
||||
PackageVersion = "4.17.20",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
|
||||
var finding2 = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-00001",
|
||||
PackagePurl = "pkg:npm/lodash@4.17.20",
|
||||
PackageName = "lodash",
|
||||
PackageVersion = "4.17.20",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
|
||||
Assert.Equal(finding1, finding2);
|
||||
Assert.Equal(finding1.GetHashCode(), finding2.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnerabilityFinding_DifferentCve_NotEqual()
|
||||
{
|
||||
var finding1 = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-00001",
|
||||
PackagePurl = "pkg:npm/lodash@4.17.20",
|
||||
PackageName = "lodash",
|
||||
PackageVersion = "4.17.20",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
|
||||
var finding2 = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-00002",
|
||||
PackagePurl = "pkg:npm/lodash@4.17.20",
|
||||
PackageName = "lodash",
|
||||
PackageVersion = "4.17.20",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
|
||||
Assert.NotEqual(finding1, finding2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -207,10 +207,17 @@ public class SourceConfigValidatorTests
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"acceptedFormats": ["CycloneDX", "SPDX"],
|
||||
"validationRules": {
|
||||
"requireSignature": false,
|
||||
"maxFileSizeBytes": 10485760
|
||||
"allowedTools": ["stella-cli"],
|
||||
"validation": {
|
||||
"requireSignedSbom": false,
|
||||
"maxSbomSizeBytes": 10485760,
|
||||
"allowedFormats": ["CycloneDxJson", "SpdxJson"]
|
||||
},
|
||||
"attribution": {
|
||||
"requireBuildId": false,
|
||||
"requireRepository": false,
|
||||
"requireCommitSha": false,
|
||||
"requirePipelineId": false
|
||||
}
|
||||
}
|
||||
""");
|
||||
@@ -228,7 +235,16 @@ public class SourceConfigValidatorTests
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"acceptedFormats": ["InvalidFormat"]
|
||||
"allowedTools": ["stella-cli"],
|
||||
"validation": {
|
||||
"allowedFormats": ["InvalidFormat"]
|
||||
},
|
||||
"attribution": {
|
||||
"requireBuildId": false,
|
||||
"requireRepository": false,
|
||||
"requireCommitSha": false,
|
||||
"requirePipelineId": false
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -241,7 +257,7 @@ public class SourceConfigValidatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CliConfig_Empty_ReturnsWarning()
|
||||
public void Validate_CliConfig_Empty_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("{}");
|
||||
@@ -250,8 +266,8 @@ public class SourceConfigValidatorTests
|
||||
var result = _validator.Validate(SbomSourceType.Cli, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Warnings.Should().Contain(w => w.Contains("validation rules"));
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("allowedTools"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -267,8 +283,16 @@ public class SourceConfigValidatorTests
|
||||
"repositoryUrl": "https://github.com/example/repo",
|
||||
"provider": "GitHub",
|
||||
"authMethod": "Token",
|
||||
"branchConfig": {
|
||||
"defaultBranch": "main"
|
||||
"branches": {
|
||||
"include": ["main"]
|
||||
},
|
||||
"triggers": {
|
||||
"onPush": true,
|
||||
"onPullRequest": false,
|
||||
"onTag": false
|
||||
},
|
||||
"scanOptions": {
|
||||
"analyzers": ["nuget"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
@@ -288,7 +312,18 @@ public class SourceConfigValidatorTests
|
||||
{
|
||||
"repositoryUrl": "git@github.com:example/repo.git",
|
||||
"provider": "GitHub",
|
||||
"authMethod": "SshKey"
|
||||
"authMethod": "Ssh",
|
||||
"branches": {
|
||||
"include": ["main"]
|
||||
},
|
||||
"triggers": {
|
||||
"onPush": false,
|
||||
"onPullRequest": false,
|
||||
"onTag": true
|
||||
},
|
||||
"scanOptions": {
|
||||
"analyzers": ["nuget"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -305,7 +340,16 @@ public class SourceConfigValidatorTests
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"provider": "GitHub"
|
||||
"provider": "GitHub",
|
||||
"branches": {
|
||||
"include": ["main"]
|
||||
},
|
||||
"triggers": {
|
||||
"onPush": true
|
||||
},
|
||||
"scanOptions": {
|
||||
"analyzers": ["nuget"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -324,7 +368,16 @@ public class SourceConfigValidatorTests
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"repositoryUrl": "https://github.com/example/repo",
|
||||
"provider": "InvalidProvider"
|
||||
"provider": "InvalidProvider",
|
||||
"branches": {
|
||||
"include": ["main"]
|
||||
},
|
||||
"triggers": {
|
||||
"onPush": true
|
||||
},
|
||||
"scanOptions": {
|
||||
"analyzers": ["nuget"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -337,13 +390,19 @@ public class SourceConfigValidatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_GitConfig_NoBranchConfig_ReturnsWarning()
|
||||
public void Validate_GitConfig_MissingBranches_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"repositoryUrl": "https://github.com/example/repo",
|
||||
"provider": "GitHub"
|
||||
"provider": "GitHub",
|
||||
"triggers": {
|
||||
"onPush": true
|
||||
},
|
||||
"scanOptions": {
|
||||
"analyzers": ["nuget"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -351,8 +410,8 @@ public class SourceConfigValidatorTests
|
||||
var result = _validator.Validate(SbomSourceType.Git, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Warnings.Should().Contain(w => w.Contains("branch configuration"));
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("branches"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0769-M | DONE | Revalidated 2026-01-07 (test project). |
|
||||
| AUDIT-0769-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0769-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| AUDIT-0738-M | DONE | Revalidated 2026-01-12 (test project). |
|
||||
| AUDIT-0738-T | DONE | Revalidated 2026-01-12. |
|
||||
| AUDIT-0738-A | DONE | Applied 2026-01-14. |
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Handlers;
|
||||
using StellaOps.Scanner.Sources.Persistence;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Tests.Triggers;
|
||||
|
||||
public sealed class SourceTriggerDispatcherTests
|
||||
{
|
||||
private static readonly FakeTimeProvider TimeProvider = new(
|
||||
new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
private static readonly JsonDocument MinimalConfig = JsonDocument.Parse("{}");
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public async Task DispatchAsync_QueuesTargetsAndCompletesRun()
|
||||
{
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var source = CreateSource(guidProvider);
|
||||
source.Activate("tester", TimeProvider);
|
||||
|
||||
var sourceRepo = new InMemorySourceRepository();
|
||||
sourceRepo.Add(source);
|
||||
|
||||
var runRepo = new InMemoryRunRepository();
|
||||
var handler = new InMemoryHandler(new[]
|
||||
{
|
||||
ScanTarget.Image("registry.example.com/app:1.0.0"),
|
||||
ScanTarget.Image("registry.example.com/app:1.1.0")
|
||||
});
|
||||
|
||||
var queue = new InMemoryScanJobQueue(guidProvider);
|
||||
|
||||
var dispatcher = new SourceTriggerDispatcher(
|
||||
sourceRepo,
|
||||
runRepo,
|
||||
new[] { handler },
|
||||
queue,
|
||||
NullLogger<SourceTriggerDispatcher>.Instance,
|
||||
TimeProvider,
|
||||
guidProvider);
|
||||
|
||||
var result = await dispatcher.DispatchAsync(
|
||||
source.SourceId,
|
||||
SbomSourceRunTrigger.Manual,
|
||||
"manual",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.JobsQueued.Should().Be(2);
|
||||
result.Run.ItemsDiscovered.Should().Be(2);
|
||||
result.Run.ItemsSucceeded.Should().Be(2);
|
||||
result.Run.Status.Should().Be(SbomSourceRunStatus.Succeeded);
|
||||
queue.Requests.Should().HaveCount(2);
|
||||
runRepo.Runs.Should().ContainKey(result.Run.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public async Task ProcessScheduledSourcesAsync_DispatchesDueSources()
|
||||
{
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var source = CreateSource(guidProvider);
|
||||
source.Activate("tester", TimeProvider);
|
||||
|
||||
var sourceRepo = new InMemorySourceRepository
|
||||
{
|
||||
DueSources = new List<SbomSource> { source }
|
||||
};
|
||||
sourceRepo.Add(source);
|
||||
|
||||
var runRepo = new InMemoryRunRepository();
|
||||
var handler = new InMemoryHandler(new[] { ScanTarget.Image("registry.example.com/app:1.0.0") });
|
||||
var queue = new InMemoryScanJobQueue(guidProvider);
|
||||
|
||||
var dispatcher = new SourceTriggerDispatcher(
|
||||
sourceRepo,
|
||||
runRepo,
|
||||
new[] { handler },
|
||||
queue,
|
||||
NullLogger<SourceTriggerDispatcher>.Instance,
|
||||
TimeProvider,
|
||||
guidProvider);
|
||||
|
||||
var processed = await dispatcher.ProcessScheduledSourcesAsync(
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
processed.Should().Be(1);
|
||||
runRepo.Runs.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public async Task DispatchAsync_DisabledSource_ReturnsFailedRun()
|
||||
{
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var source = CreateSource(guidProvider);
|
||||
source.Disable("tester", TimeProvider);
|
||||
|
||||
var sourceRepo = new InMemorySourceRepository();
|
||||
sourceRepo.Add(source);
|
||||
|
||||
var runRepo = new InMemoryRunRepository();
|
||||
var handler = new InMemoryHandler(Array.Empty<ScanTarget>());
|
||||
var queue = new InMemoryScanJobQueue(guidProvider);
|
||||
|
||||
var dispatcher = new SourceTriggerDispatcher(
|
||||
sourceRepo,
|
||||
runRepo,
|
||||
new[] { handler },
|
||||
queue,
|
||||
NullLogger<SourceTriggerDispatcher>.Instance,
|
||||
TimeProvider,
|
||||
guidProvider);
|
||||
|
||||
var result = await dispatcher.DispatchAsync(
|
||||
source.SourceId,
|
||||
SbomSourceRunTrigger.Manual,
|
||||
"manual",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("disabled");
|
||||
result.Run.Status.Should().Be(SbomSourceRunStatus.Failed);
|
||||
}
|
||||
|
||||
private static SbomSource CreateSource(IGuidProvider guidProvider)
|
||||
{
|
||||
return SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "source-1",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: MinimalConfig,
|
||||
createdBy: "tester",
|
||||
timeProvider: TimeProvider,
|
||||
guidProvider: guidProvider);
|
||||
}
|
||||
|
||||
private sealed class InMemorySourceRepository : ISbomSourceRepository
|
||||
{
|
||||
public Dictionary<Guid, SbomSource> Sources { get; } = new();
|
||||
public IReadOnlyList<SbomSource> DueSources { get; set; } = Array.Empty<SbomSource>();
|
||||
|
||||
public void Add(SbomSource source) => Sources[source.SourceId] = source;
|
||||
|
||||
public Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||
=> Task.FromResult(Sources.TryGetValue(sourceId, out var source) ? source : null);
|
||||
|
||||
public Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default)
|
||||
=> Task.FromResult(Sources.TryGetValue(sourceId, out var source) ? source : null);
|
||||
|
||||
public Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
|
||||
=> Task.FromResult(Sources.Values.FirstOrDefault(s => s.TenantId == tenantId && s.Name == name));
|
||||
|
||||
public Task<PagedResponse<SbomSource>> ListAsync(
|
||||
string tenantId,
|
||||
ListSourcesRequest request,
|
||||
CancellationToken ct = default)
|
||||
=> throw new NotSupportedException("ListAsync is not used in these tests.");
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> GetDueScheduledSourcesAsync(
|
||||
DateTimeOffset asOf,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<SbomSource>>(DueSources.Take(limit).ToList());
|
||||
|
||||
public Task CreateAsync(SbomSource source, CancellationToken ct = default)
|
||||
{
|
||||
Sources[source.SourceId] = source;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(SbomSource source, CancellationToken ct = default)
|
||||
{
|
||||
Sources[source.SourceId] = source;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||
{
|
||||
Sources.Remove(sourceId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> NameExistsAsync(
|
||||
string tenantId,
|
||||
string name,
|
||||
Guid? excludeSourceId = null,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult(Sources.Values.Any(s =>
|
||||
s.TenantId == tenantId
|
||||
&& s.Name == name
|
||||
&& s.SourceId != excludeSourceId));
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> SearchByNameAsync(string name, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<SbomSource>>(Sources.Values
|
||||
.Where(s => s.Name.Contains(name, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList());
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> GetDueForScheduledRunAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(DueSources);
|
||||
}
|
||||
|
||||
private sealed class InMemoryRunRepository : ISbomSourceRunRepository
|
||||
{
|
||||
public Dictionary<Guid, SbomSourceRun> Runs { get; } = new();
|
||||
|
||||
public Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default)
|
||||
=> Task.FromResult(Runs.TryGetValue(runId, out var run) ? run : null);
|
||||
|
||||
public Task<PagedResponse<SbomSourceRun>> ListForSourceAsync(
|
||||
Guid sourceId,
|
||||
ListSourceRunsRequest request,
|
||||
CancellationToken ct = default)
|
||||
=> throw new NotSupportedException("ListForSourceAsync is not used in these tests.");
|
||||
|
||||
public Task CreateAsync(SbomSourceRun run, CancellationToken ct = default)
|
||||
{
|
||||
Runs[run.RunId] = run;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(SbomSourceRun run, CancellationToken ct = default)
|
||||
{
|
||||
Runs[run.RunId] = run;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SbomSourceRun>> GetStaleRunsAsync(
|
||||
TimeSpan olderThan,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<SbomSourceRun>>(Array.Empty<SbomSourceRun>());
|
||||
|
||||
public Task<SourceRunStats> GetStatsAsync(Guid sourceId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new SourceRunStats());
|
||||
}
|
||||
|
||||
private sealed class InMemoryScanJobQueue : IScanJobQueue
|
||||
{
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
public List<ScanJobRequest> Requests { get; } = new();
|
||||
|
||||
public InMemoryScanJobQueue(IGuidProvider guidProvider)
|
||||
{
|
||||
_guidProvider = guidProvider;
|
||||
}
|
||||
|
||||
public Task<Guid> EnqueueAsync(ScanJobRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(_guidProvider.NewGuid());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryHandler : ISourceTypeHandler
|
||||
{
|
||||
private readonly IReadOnlyList<ScanTarget> _targets;
|
||||
|
||||
public InMemoryHandler(IReadOnlyList<ScanTarget> targets)
|
||||
{
|
||||
_targets = targets;
|
||||
}
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Docker;
|
||||
|
||||
public Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult(_targets);
|
||||
|
||||
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
|
||||
=> ConfigValidationResult.Success();
|
||||
|
||||
public Task<ConnectionTestResult> TestConnectionAsync(SbomSource source, CancellationToken ct = default)
|
||||
=> Task.FromResult(ConnectionTestResult.Succeeded(TimeProvider));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user