sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

@@ -0,0 +1,440 @@
// <copyright file="SarifSchemaValidationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Sarif.Fingerprints;
using StellaOps.Scanner.Sarif.Models;
using StellaOps.Scanner.Sarif.Rules;
using Xunit;
namespace StellaOps.Scanner.Sarif.Tests;
/// <summary>
/// SARIF 2.1.0 schema validation tests.
/// Sprint: SPRINT_20260109_010_001 Task: Write schema validation tests
///
/// These tests validate that generated SARIF conforms to SARIF 2.1.0 specification
/// requirements. Reference: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
/// </summary>
[Trait("Category", "Unit")]
public class SarifSchemaValidationTests
{
private readonly SarifExportService _service;
private readonly FakeTimeProvider _timeProvider;
public SarifSchemaValidationTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
var ruleRegistry = new SarifRuleRegistry();
var fingerprintGenerator = new FingerprintGenerator(ruleRegistry);
_service = new SarifExportService(ruleRegistry, fingerprintGenerator, _timeProvider);
}
/// <summary>
/// Section 3.13: sarifLog object requirements
/// </summary>
[Fact]
public async Task SarifLog_RequiredProperties_ArePresent()
{
// Arrange
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
// Act
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
// Assert - Required properties per SARIF 2.1.0 section 3.13
root["version"].Should().NotBeNull("version is required");
root["$schema"].Should().NotBeNull("$schema is required for SARIF files");
root["runs"].Should().NotBeNull("runs array is required");
}
/// <summary>
/// Section 3.13.2: version property must be "2.1.0"
/// </summary>
[Fact]
public async Task SarifLog_Version_Is2_1_0()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
root["version"]!.GetValue<string>().Should().Be("2.1.0");
}
/// <summary>
/// Section 3.13.3: $schema property format
/// </summary>
[Fact]
public async Task SarifLog_Schema_IsValidUri()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var schema = root["$schema"]!.GetValue<string>();
schema.Should().Contain("sarif");
Uri.TryCreate(schema, UriKind.Absolute, out _).Should().BeTrue("$schema must be a valid URI");
}
/// <summary>
/// Section 3.13.4: runs is an array of run objects
/// </summary>
[Fact]
public async Task SarifLog_Runs_IsArray()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
root["runs"]!.Should().BeOfType<JsonArray>();
root["runs"]!.AsArray().Count.Should().BeGreaterThanOrEqualTo(1);
}
/// <summary>
/// Section 3.14: run object requirements
/// </summary>
[Fact]
public async Task Run_RequiredProperties_ArePresent()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var run = root["runs"]![0]!.AsObject();
// Required property: tool
run["tool"].Should().NotBeNull("tool is required in run object");
// results is optional but we always include it
run["results"].Should().NotBeNull("results should be present");
}
/// <summary>
/// Section 3.18: tool object requirements
/// </summary>
[Fact]
public async Task Tool_RequiredProperties_ArePresent()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var tool = root["runs"]![0]!["tool"]!.AsObject();
// Required property: driver
tool["driver"].Should().NotBeNull("driver is required in tool object");
}
/// <summary>
/// Section 3.19: toolComponent (driver) requirements
/// </summary>
[Fact]
public async Task Driver_RequiredProperties_ArePresent()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var driver = root["runs"]![0]!["tool"]!["driver"]!.AsObject();
// Required property: name
driver["name"].Should().NotBeNull("name is required in driver");
driver["name"]!.GetValue<string>().Should().NotBeEmpty();
}
/// <summary>
/// Section 3.19.2: driver name must match options
/// </summary>
[Fact]
public async Task Driver_Name_MatchesOptions()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions() with { ToolName = "Custom Scanner" };
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var driver = root["runs"]![0]!["tool"]!["driver"]!.AsObject();
driver["name"]!.GetValue<string>().Should().Be("Custom Scanner");
}
/// <summary>
/// Section 3.27: result object requirements
/// </summary>
[Fact]
public async Task Result_RequiredProperties_ArePresent()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var results = root["runs"]![0]!["results"]!.AsArray();
foreach (var result in results)
{
// ruleId is technically optional but we always include it
result!["ruleId"].Should().NotBeNull("ruleId should be present");
// message is required
result["message"].Should().NotBeNull("message is required in result");
}
}
/// <summary>
/// Section 3.27.10: level values must be from enumeration
/// </summary>
[Fact]
public async Task Result_Level_IsValidEnumValue()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var results = root["runs"]![0]!["results"]!.AsArray();
var validLevels = new[] { "none", "note", "warning", "error" };
foreach (var result in results)
{
if (result!["level"] != null)
{
var level = result["level"]!.GetValue<string>();
validLevels.Should().Contain(level, "level must be a valid SARIF enum value");
}
}
}
/// <summary>
/// Section 3.11: message object requirements
/// </summary>
[Fact]
public async Task Message_HasTextOrId()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var results = root["runs"]![0]!["results"]!.AsArray();
foreach (var result in results)
{
var message = result!["message"]!.AsObject();
var hasText = message["text"] != null;
var hasId = message["id"] != null;
(hasText || hasId).Should().BeTrue("message must have either text or id");
}
}
/// <summary>
/// Section 3.28: location object validation
/// </summary>
[Fact]
public async Task Location_PhysicalLocation_HasValidStructure()
{
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
VulnerabilityId = "CVE-2024-12345",
FilePath = "src/test.cs",
StartLine = 10,
EndLine = 15,
StartColumn = 5,
EndColumn = 20
}
};
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var result = root["runs"]![0]!["results"]![0]!.AsObject();
if (result["locations"] != null)
{
var locations = result["locations"]!.AsArray();
foreach (var location in locations)
{
var physicalLocation = location!["physicalLocation"];
if (physicalLocation != null)
{
// artifactLocation should be present
physicalLocation["artifactLocation"].Should().NotBeNull();
}
}
}
}
/// <summary>
/// Section 3.30: region object validation - line numbers are 1-based
/// </summary>
[Fact]
public async Task Region_LineNumbers_AreOneBased()
{
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
VulnerabilityId = "CVE-2024-12345",
FilePath = "src/test.cs",
StartLine = 1, // Minimum valid line
EndLine = 5
}
};
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var result = root["runs"]![0]!["results"]![0]!.AsObject();
if (result["locations"]?[0]?["physicalLocation"]?["region"] is JsonObject region)
{
if (region["startLine"] != null)
{
region["startLine"]!.GetValue<int>().Should().BeGreaterThanOrEqualTo(1, "SARIF line numbers are 1-based");
}
}
}
/// <summary>
/// Section 3.49: reportingDescriptor (rule) requirements
/// </summary>
[Fact]
public async Task Rule_RequiredProperties_ArePresent()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var rules = root["runs"]![0]!["tool"]!["driver"]!["rules"];
if (rules != null)
{
foreach (var rule in rules.AsArray())
{
// id is required
rule!["id"].Should().NotBeNull("rule id is required");
rule["id"]!.GetValue<string>().Should().NotBeEmpty();
}
}
}
/// <summary>
/// SARIF JSON must be valid (parseable)
/// </summary>
[Fact]
public async Task Export_ProducesValidJson()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
// Should not throw
var doc = JsonDocument.Parse(json);
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Object);
}
/// <summary>
/// Empty findings should produce valid SARIF with empty results
/// </summary>
[Fact]
public async Task Export_EmptyFindings_ProducesValidSarif()
{
var findings = Array.Empty<FindingInput>();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
root["version"]!.GetValue<string>().Should().Be("2.1.0");
root["runs"]![0]!["results"]!.AsArray().Count.Should().Be(0);
}
/// <summary>
/// Section 3.27.18: fingerprints must be object with string values
/// </summary>
[Fact]
public async Task Result_Fingerprints_AreStringValues()
{
var findings = CreateSampleFindings();
var options = CreateDefaultOptions();
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var root = JsonNode.Parse(json)!.AsObject();
var results = root["runs"]![0]!["results"]!.AsArray();
foreach (var result in results)
{
var fingerprints = result!["fingerprints"];
if (fingerprints != null)
{
fingerprints.Should().BeOfType<JsonObject>();
foreach (var kvp in fingerprints.AsObject())
{
kvp.Value!.GetValueKind().Should().Be(JsonValueKind.String,
"fingerprint values must be strings");
}
}
}
}
private static FindingInput[] CreateSampleFindings()
{
return new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability CVE-2024-12345",
VulnerabilityId = "CVE-2024-12345",
ComponentPurl = "pkg:npm/test-package@1.0.0",
ComponentName = "test-package",
ComponentVersion = "1.0.0",
Severity = Severity.High,
CvssScore = 8.0
},
new FindingInput
{
Type = FindingType.License,
Title = "GPL-3.0 license detected",
ComponentPurl = "pkg:npm/gpl-lib@2.0.0",
ComponentName = "gpl-lib",
ComponentVersion = "2.0.0",
Severity = Severity.Medium
}
};
}
private static SarifExportOptions CreateDefaultOptions()
{
return new SarifExportOptions
{
ToolName = "StellaOps Scanner",
ToolVersion = "1.0.0-test"
};
}
}