sprints work
This commit is contained in:
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user