Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Attestor.Core.Validation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests.Validation;
|
||||
|
||||
public sealed class PredicateSchemaValidatorTests
|
||||
{
|
||||
private readonly PredicateSchemaValidator _validator;
|
||||
|
||||
public PredicateSchemaValidatorTests()
|
||||
{
|
||||
_validator = new PredicateSchemaValidator(NullLogger<PredicateSchemaValidator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidSbomPredicate_ReturnsValid()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"format": "spdx-3.0.1",
|
||||
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"componentCount": 42,
|
||||
"uri": "https://example.com/sbom.json",
|
||||
"tooling": "syft",
|
||||
"createdAt": "2025-12-22T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/sbom@v1", predicate);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidVexPredicate_ReturnsValid()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"format": "openvex",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "CVE-2024-12345",
|
||||
"status": "not_affected",
|
||||
"justification": "Component not used",
|
||||
"products": ["pkg:npm/lodash@4.17.21"]
|
||||
}
|
||||
],
|
||||
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"author": "security@example.com",
|
||||
"timestamp": "2025-12-22T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/vex@v1", predicate);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidReachabilityPredicate_ReturnsValid()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"result": "unreachable",
|
||||
"confidence": 0.95,
|
||||
"graphDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"paths": [],
|
||||
"entrypoints": [
|
||||
{
|
||||
"type": "http",
|
||||
"route": "/api/users",
|
||||
"auth": "required"
|
||||
}
|
||||
],
|
||||
"computedAt": "2025-12-22T00:00:00Z",
|
||||
"expiresAt": "2025-12-29T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/reachability@v1", predicate);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidPolicyDecisionPredicate_ReturnsValid()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"finding_id": "CVE-2024-12345@pkg:npm/lodash@4.17.20",
|
||||
"cve": "CVE-2024-12345",
|
||||
"component_purl": "pkg:npm/lodash@4.17.20",
|
||||
"decision": "Block",
|
||||
"reasoning": {
|
||||
"rules_evaluated": 5,
|
||||
"rules_matched": ["high-severity", "reachable"],
|
||||
"final_score": 85.5,
|
||||
"risk_multiplier": 1.2,
|
||||
"reachability_state": "reachable",
|
||||
"vex_status": "affected",
|
||||
"summary": "High severity vulnerability is reachable"
|
||||
},
|
||||
"evidence_refs": [
|
||||
"sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
|
||||
],
|
||||
"evaluated_at": "2025-12-22T00:00:00Z",
|
||||
"expires_at": "2025-12-23T00:00:00Z",
|
||||
"policy_version": "1.0.0",
|
||||
"policy_hash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/policy-decision@v1", predicate);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidHumanApprovalPredicate_ReturnsValid()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schema": "human-approval-v1",
|
||||
"approval_id": "approval-123",
|
||||
"finding_id": "CVE-2024-12345",
|
||||
"decision": "AcceptRisk",
|
||||
"approver": {
|
||||
"user_id": "alice@example.com",
|
||||
"display_name": "Alice Smith",
|
||||
"role": "Security Engineer"
|
||||
},
|
||||
"justification": "Risk accepted for legacy system scheduled for decommission in 30 days",
|
||||
"approved_at": "2025-12-22T00:00:00Z",
|
||||
"expires_at": "2026-01-22T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/human-approval@v1", predicate);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidVexStatus_ReturnsFail()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"format": "openvex",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "CVE-2024-12345",
|
||||
"status": "invalid_status",
|
||||
"products": []
|
||||
}
|
||||
],
|
||||
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/vex@v1", predicate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.NotNull(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingRequiredField_ReturnsFail()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"format": "spdx-3.0.1",
|
||||
"componentCount": 42
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/sbom@v1", predicate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("digest", result.ErrorMessage ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnknownPredicateType_ReturnsSkip()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"someField": "someValue"
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/unknown@v1", predicate);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains("skip", result.ErrorMessage ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidDigestFormat_ReturnsFail()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"format": "spdx-3.0.1",
|
||||
"digest": "invalid-digest-format",
|
||||
"componentCount": 42
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/sbom@v1", predicate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NormalizePredicateType_HandlesWithAndWithoutPrefix()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"format": "spdx-3.0.1",
|
||||
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"componentCount": 42
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
var result1 = _validator.Validate("stella.ops/sbom@v1", predicate);
|
||||
var result2 = _validator.Validate("sbom@v1", predicate);
|
||||
|
||||
Assert.True(result1.IsValid);
|
||||
Assert.True(result2.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidBoundaryPredicate_ReturnsValid()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"surface": "http",
|
||||
"exposure": "public",
|
||||
"observedAt": "2025-12-22T00:00:00Z",
|
||||
"endpoints": [
|
||||
{
|
||||
"route": "/api/users/:id",
|
||||
"method": "GET",
|
||||
"auth": "required"
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"mechanism": "jwt",
|
||||
"required_scopes": ["read:users"]
|
||||
},
|
||||
"controls": ["rate-limit", "WAF"],
|
||||
"expiresAt": "2025-12-25T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/boundary@v1", predicate);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidReachabilityConfidence_ReturnsFail()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"result": "reachable",
|
||||
"confidence": 1.5,
|
||||
"graphDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
""";
|
||||
|
||||
var predicate = JsonDocument.Parse(json).RootElement;
|
||||
var result = _validator.Validate("stella.ops/reachability@v1", predicate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Text.Json;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validation result for predicate schema validation.
|
||||
/// </summary>
|
||||
public sealed record ValidationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public required string? ErrorMessage { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
public static ValidationResult Valid() => new()
|
||||
{
|
||||
IsValid = true,
|
||||
ErrorMessage = null
|
||||
};
|
||||
|
||||
public static ValidationResult Invalid(string message, IReadOnlyList<string>? errors = null) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = message,
|
||||
Errors = errors ?? Array.Empty<string>()
|
||||
};
|
||||
|
||||
public static ValidationResult Skip(string reason) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
ErrorMessage = $"Skipped: {reason}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for validating attestation predicates against JSON schemas.
|
||||
/// </summary>
|
||||
public interface IPredicateSchemaValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a predicate against its JSON schema.
|
||||
/// </summary>
|
||||
/// <param name="predicateType">The predicate type URI (e.g., "stella.ops/sbom@v1").</param>
|
||||
/// <param name="predicate">The predicate JSON element to validate.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
ValidationResult Validate(string predicateType, JsonElement predicate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates attestation predicates against their JSON schemas.
|
||||
/// </summary>
|
||||
public sealed class PredicateSchemaValidator : IPredicateSchemaValidator
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, JsonSchema> _schemas;
|
||||
private readonly ILogger<PredicateSchemaValidator> _logger;
|
||||
|
||||
public PredicateSchemaValidator(ILogger<PredicateSchemaValidator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_schemas = LoadSchemas();
|
||||
}
|
||||
|
||||
public ValidationResult Validate(string predicateType, JsonElement predicate)
|
||||
{
|
||||
// Normalize predicate type (handle both with and without stella.ops/ prefix)
|
||||
var normalizedType = NormalizePredicateType(predicateType);
|
||||
|
||||
if (!_schemas.TryGetValue(normalizedType, out var schema))
|
||||
{
|
||||
_logger.LogDebug("No schema found for predicate type {PredicateType}, skipping validation", predicateType);
|
||||
return ValidationResult.Skip($"No schema for {predicateType}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var results = schema.Evaluate(predicate, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List
|
||||
});
|
||||
|
||||
if (results.IsValid)
|
||||
{
|
||||
_logger.LogDebug("Predicate {PredicateType} validated successfully", predicateType);
|
||||
return ValidationResult.Valid();
|
||||
}
|
||||
|
||||
var errors = CollectErrors(results);
|
||||
_logger.LogWarning("Predicate {PredicateType} validation failed: {ErrorCount} errors",
|
||||
predicateType, errors.Count);
|
||||
|
||||
return ValidationResult.Invalid(
|
||||
$"Schema validation failed for {predicateType}",
|
||||
errors);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error validating predicate {PredicateType}", predicateType);
|
||||
return ValidationResult.Invalid($"Validation error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePredicateType(string predicateType)
|
||||
{
|
||||
// Handle both "stella.ops/sbom@v1" and "sbom@v1" formats
|
||||
if (predicateType.StartsWith("stella.ops/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return predicateType["stella.ops/".Length..];
|
||||
}
|
||||
return predicateType;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> CollectErrors(EvaluationResults results)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (results.HasErrors)
|
||||
{
|
||||
foreach (var detail in results.Details)
|
||||
{
|
||||
if (detail.HasErrors)
|
||||
{
|
||||
var errorMsg = detail.Errors?.FirstOrDefault()?.Value ?? "Unknown error";
|
||||
var location = detail.InstanceLocation.ToString();
|
||||
errors.Add($"{location}: {errorMsg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, JsonSchema> LoadSchemas()
|
||||
{
|
||||
var schemas = new Dictionary<string, JsonSchema>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Load embedded schema resources
|
||||
var assembly = typeof(PredicateSchemaValidator).Assembly;
|
||||
var resourcePrefix = "StellaOps.Attestor.Core.Schemas.";
|
||||
|
||||
var schemaFiles = new[]
|
||||
{
|
||||
("sbom@v1", "sbom.v1.schema.json"),
|
||||
("vex@v1", "vex.v1.schema.json"),
|
||||
("reachability@v1", "reachability.v1.schema.json"),
|
||||
("boundary@v1", "boundary.v1.schema.json"),
|
||||
("policy-decision@v1", "policy-decision.v1.schema.json"),
|
||||
("human-approval@v1", "human-approval.v1.schema.json")
|
||||
};
|
||||
|
||||
foreach (var (key, fileName) in schemaFiles)
|
||||
{
|
||||
var resourceName = resourcePrefix + fileName;
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
|
||||
if (stream is null)
|
||||
{
|
||||
// Schema not embedded, skip gracefully
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var schema = JsonSchema.FromStream(stream);
|
||||
schemas[key] = schema;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log and continue - don't fail on single schema load error
|
||||
Console.WriteLine($"Failed to load schema {fileName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return schemas;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user