audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration

This commit is contained in:
master
2026-01-14 10:48:00 +02:00
parent d7be6ba34b
commit 95d5898650
379 changed files with 40695 additions and 19041 deletions

View File

@@ -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>();

View File

@@ -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 }
}
}
}

View File

@@ -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.

View File

@@ -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++;
}

View File

@@ -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,

View File

@@ -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. |

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -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

View File

@@ -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. |

View File

@@ -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));
}
}