Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
60
src/Attestor/AGENTS.md
Normal file
60
src/Attestor/AGENTS.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Attestor Module — Agent Charter
|
||||
|
||||
## Mission
|
||||
Manage the attestation and proof chain infrastructure for StellaOps:
|
||||
- Accept DSSE-signed attestation bundles from Signer and other modules.
|
||||
- Register attestations with Rekor v2 transparency log for tamper-evident anchoring.
|
||||
- Provide verification APIs for proof chain validation (signature, payload, Rekor inclusion).
|
||||
- Serve deterministic evidence bundles linking artifacts to SBOMs, VEX documents, and verdicts.
|
||||
- Enable "Show Me The Proof" workflows with complete audit trails.
|
||||
|
||||
## Expectations
|
||||
- Coordinate with Signer for cryptographic operations, Scanner/Excititor for attestation generation, and UI for proof visualization.
|
||||
- Maintain deterministic serialization for reproducible verification outcomes.
|
||||
- Support offline verification with bundled Rekor inclusion proofs.
|
||||
- Provide REST APIs for proof chain queries, baseline selection, and trust indicators.
|
||||
- Keep proof chain storage schema current with migrations.
|
||||
|
||||
## Key Components
|
||||
- **StellaOps.Attestor**: Main attestation service and REST API endpoints
|
||||
- **StellaOps.Attestor.Envelope**: DSSE envelope handling and serialization
|
||||
- **StellaOps.Attestor.Types**: Core attestation models and schemas
|
||||
- **StellaOps.Attestor.Verify**: Verification engine for signatures and Rekor proofs
|
||||
- **__Libraries**: Shared attestation utilities and storage abstractions
|
||||
- **__Tests**: Integration tests with Testcontainers for PostgreSQL
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/attestor/README.md`
|
||||
- `docs/modules/attestor/architecture.md`
|
||||
- `docs/modules/attestor/implementation_plan.md`
|
||||
- `docs/product-advisories/20-Dec-2025 - Stella Ops Reference Architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both corresponding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
|
||||
## Attestation Types
|
||||
- **SBOM Attestations**: Link container images to SPDX/CycloneDX SBOMs
|
||||
- **VEX Attestations**: Link OpenVEX statements to products
|
||||
- **Verdict Attestations**: Link policy evaluation results to artifacts
|
||||
- **Provenance Attestations**: SLSA provenance for build reproducibility
|
||||
- **Reachability Attestations**: Link static analysis witness paths to findings
|
||||
|
||||
## Proof Chain Model
|
||||
- **ProofNode**: Individual proof (SBOM, VEX, Verdict, Attestation) with digest and metadata
|
||||
- **ProofEdge**: Relationship between nodes ("attests", "references", "supersedes")
|
||||
- **ProofChain**: Complete directed graph from artifact to all linked evidence
|
||||
- **ProofVerification**: Signature validation, payload hash check, Rekor inclusion proof
|
||||
|
||||
## Guardrails
|
||||
- All attestations must use DSSE envelopes with multiple signature support.
|
||||
- Rekor anchoring must be optional (support air-gapped deployments).
|
||||
- Verification must work offline with bundled inclusion proofs.
|
||||
- Proof chains must be deterministic (stable ordering, canonical serialization).
|
||||
- Preserve determinism: sort outputs, normalize timestamps (UTC ISO-8601).
|
||||
- Keep Offline Kit parity in mind—document air-gapped workflows for any new feature.
|
||||
- Update runbooks/observability assets when operational characteristics change.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
19
src/Attestor/__Libraries/AGENTS.md
Normal file
19
src/Attestor/__Libraries/AGENTS.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Attestor __Libraries AGENTS
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/Attestor/__Libraries/` (shared attestation libraries).
|
||||
- Roles: backend engineer, QA automation.
|
||||
|
||||
## Required Reading (treat as read before DOING)
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/attestor/architecture.md`
|
||||
- Relevant sprint files.
|
||||
|
||||
## Working Agreements
|
||||
- Preserve DSSE/in-toto compatibility and deterministic serialization.
|
||||
- Avoid network dependencies in libraries and tests.
|
||||
- Record schema changes in attestor docs and sprint Decisions & Risks.
|
||||
|
||||
## Testing
|
||||
- Add tests under the corresponding attestor test projects or `src/Attestor/__Tests`.
|
||||
@@ -155,6 +155,12 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
case "verdict.stella/v1":
|
||||
errors.AddRange(ValidateVerdictPredicate(root));
|
||||
break;
|
||||
case "delta-verdict.stella/v1":
|
||||
errors.AddRange(ValidateDeltaVerdictPredicate(root));
|
||||
break;
|
||||
case "reachability-subgraph.stella/v1":
|
||||
errors.AddRange(ValidateReachabilitySubgraphPredicate(root));
|
||||
break;
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
@@ -192,6 +198,8 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
"proofspine.stella/v1" => true,
|
||||
"verdict.stella/v1" => true,
|
||||
"https://stella-ops.org/predicates/sbom-linkage/v1" => true,
|
||||
"delta-verdict.stella/v1" => true,
|
||||
"reachability-subgraph.stella/v1" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -248,4 +256,30 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
if (!root.TryGetProperty("verifiedAt", out _))
|
||||
yield return new() { Path = "/verifiedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateDeltaVerdictPredicate(JsonElement root)
|
||||
{
|
||||
// Required: beforeRevisionId, afterRevisionId, hasMaterialChange, priorityScore, changes, comparedAt
|
||||
if (!root.TryGetProperty("beforeRevisionId", out _))
|
||||
yield return new() { Path = "/beforeRevisionId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("afterRevisionId", out _))
|
||||
yield return new() { Path = "/afterRevisionId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("hasMaterialChange", out _))
|
||||
yield return new() { Path = "/hasMaterialChange", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("priorityScore", out _))
|
||||
yield return new() { Path = "/priorityScore", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("changes", out _))
|
||||
yield return new() { Path = "/changes", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("comparedAt", out _))
|
||||
yield return new() { Path = "/comparedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateReachabilitySubgraphPredicate(JsonElement root)
|
||||
{
|
||||
// Required: graphDigest, analysis
|
||||
if (!root.TryGetProperty("graphDigest", out _))
|
||||
yield return new() { Path = "/graphDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("analysis", out _))
|
||||
yield return new() { Path = "/analysis", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated summary of unknowns for inclusion in attestations.
|
||||
/// Provides verifiable data about unknown risk handled during evaluation.
|
||||
/// </summary>
|
||||
public sealed record UnknownsSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Total count of unknowns encountered.
|
||||
/// </summary>
|
||||
public int Total { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of unknowns by reason code.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, int> ByReasonCode { get; init; }
|
||||
= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Count of unknowns that would block if not excepted.
|
||||
/// </summary>
|
||||
public int BlockingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of unknowns that are covered by approved exceptions.
|
||||
/// </summary>
|
||||
public int ExceptedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy thresholds that were evaluated.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PolicyThresholdsApplied { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Exception IDs that were applied to cover unknowns.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ExceptionsApplied { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the unknowns list for integrity verification.
|
||||
/// </summary>
|
||||
public string? UnknownsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty summary for cases with no unknowns.
|
||||
/// </summary>
|
||||
public static UnknownsSummary Empty { get; } = new()
|
||||
{
|
||||
Total = 0,
|
||||
ByReasonCode = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase),
|
||||
BlockingCount = 0,
|
||||
ExceptedCount = 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DeltaVerdictPredicate.cs
|
||||
// Sprint: SPRINT_4400_0001_0001_signed_delta_verdict
|
||||
// Description: DSSE predicate for Smart-Diff delta verdict attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for Smart-Diff delta verdict attestation.
|
||||
/// predicateType: delta-verdict.stella/v1
|
||||
/// </summary>
|
||||
public sealed record DeltaVerdictPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for delta verdict attestations.
|
||||
/// </summary>
|
||||
public const string PredicateType = "delta-verdict.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// Revision identifier for the baseline scan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("beforeRevisionId")]
|
||||
public required string BeforeRevisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Revision identifier for the current scan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("afterRevisionId")]
|
||||
public required string AfterRevisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether any material change was detected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hasMaterialChange")]
|
||||
public required bool HasMaterialChange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate priority score for the delta.
|
||||
/// </summary>
|
||||
[JsonPropertyName("priorityScore")]
|
||||
public required double PriorityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change details captured by Smart-Diff rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changes")]
|
||||
public ImmutableArray<DeltaVerdictChange> Changes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the baseline verdict attestation (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("beforeVerdictDigest")]
|
||||
public string? BeforeVerdictDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the current verdict attestation (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("afterVerdictDigest")]
|
||||
public string? AfterVerdictDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the baseline proof spine (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("beforeProofSpine")]
|
||||
public AttestationReference? BeforeProofSpine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the current proof spine (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("afterProofSpine")]
|
||||
public AttestationReference? AfterProofSpine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph revision identifier for the baseline analysis (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("beforeGraphRevisionId")]
|
||||
public string? BeforeGraphRevisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph revision identifier for the current analysis (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("afterGraphRevisionId")]
|
||||
public string? AfterGraphRevisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the comparison was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comparedAt")]
|
||||
public required DateTimeOffset ComparedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual change captured in delta verdict.
|
||||
/// </summary>
|
||||
public sealed record DeltaVerdictChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Detection rule identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rule")]
|
||||
public required string Rule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Finding key (vulnerability and component).
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingKey")]
|
||||
public required DeltaFindingKey FindingKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Direction of risk change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("direction")]
|
||||
public required string Direction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change category (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("changeType")]
|
||||
public string? ChangeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reason for the change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous value observed (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousValue")]
|
||||
public string? PreviousValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current value observed (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentValue")]
|
||||
public string? CurrentValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight contribution for this change (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public double? Weight { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding key for delta verdict changes.
|
||||
/// </summary>
|
||||
public sealed record DeltaFindingKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnId")]
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component package URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an attestation or proof spine.
|
||||
/// </summary>
|
||||
public sealed record AttestationReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the attestation (sha256:... or blake3:...).
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional URI where the attestation can be retrieved.
|
||||
/// </summary>
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Models;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type for policy decision attestations.
|
||||
/// Predicate type: https://stella.ops/predicates/policy-decision@v2
|
||||
/// </summary>
|
||||
public sealed record PolicyDecisionPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for policy decisions.
|
||||
/// </summary>
|
||||
public const string PredicateType = "https://stella.ops/predicates/policy-decision@v2";
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy that was evaluated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyRef")]
|
||||
public required string PolicyRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final policy decision outcome.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required PolicyDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the policy was evaluated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary of findings from the evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findings")]
|
||||
public IReadOnlyList<FindingSummary> Findings { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary of unknowns and how they were handled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknowns")]
|
||||
public UnknownsSummary? Unknowns { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether unknowns were a factor in the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknownsAffectedDecision")]
|
||||
public bool UnknownsAffectedDecision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason codes that caused blocking (if any).
|
||||
/// </summary>
|
||||
[JsonPropertyName("blockingReasonCodes")]
|
||||
public IReadOnlyList<string> BlockingReasonCodes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed ID of the knowledge snapshot used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("knowledgeSnapshotId")]
|
||||
public string? KnowledgeSnapshotId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy decision outcome.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum PolicyDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy evaluation passed.
|
||||
/// </summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation failed.
|
||||
/// </summary>
|
||||
Fail,
|
||||
|
||||
/// <summary>
|
||||
/// Policy passed with approved exceptions.
|
||||
/// </summary>
|
||||
PassWithExceptions,
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation could not be completed.
|
||||
/// </summary>
|
||||
Indeterminate
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a finding from policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record FindingSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// The finding identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public required string Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReachabilitySubgraphPredicate.cs
|
||||
// Sprint: SPRINT_4400_0001_0002_reachability_subgraph_attestation
|
||||
// Description: DSSE predicate for reachability subgraph attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for reachability subgraph attestation.
|
||||
/// predicateType: reachability-subgraph.stella/v1
|
||||
/// </summary>
|
||||
public sealed record ReachabilitySubgraphPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for reachability subgraph attestations.
|
||||
/// </summary>
|
||||
public const string PredicateType = "reachability-subgraph.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// Schema version for the predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed digest of the serialized subgraph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("graphDigest")]
|
||||
public required string GraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional CAS URI for the subgraph content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("graphCasUri")]
|
||||
public string? GraphCasUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Finding keys covered by this subgraph (e.g., "CVE-2024-1234@pkg:...").
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingKeys")]
|
||||
public ImmutableArray<string> FindingKeys { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Analysis metadata for the subgraph extraction.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analysis")]
|
||||
public required ReachabilitySubgraphAnalysis Analysis { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about subgraph extraction and analysis.
|
||||
/// </summary>
|
||||
public sealed record ReachabilitySubgraphAnalysis
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyzer name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analyzer")]
|
||||
public required string Analyzer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analyzerVersion")]
|
||||
public required string AnalyzerVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Completeness indicator (full, partial, unknown).
|
||||
/// </summary>
|
||||
[JsonPropertyName("completeness")]
|
||||
public required string Completeness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the subgraph was generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash algorithm used for graph digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hashAlgorithm")]
|
||||
public string HashAlgorithm { get; init; } = "blake3";
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.ProofChain.Models;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates unknowns data into summary format for attestations.
|
||||
/// </summary>
|
||||
public sealed class UnknownsAggregator : IUnknownsAggregator
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an unknowns summary from evaluation results.
|
||||
/// </summary>
|
||||
public UnknownsSummary Aggregate(
|
||||
IReadOnlyList<UnknownItem> unknowns,
|
||||
BudgetCheckResult? budgetResult = null,
|
||||
IReadOnlyList<ExceptionRef>? exceptions = null)
|
||||
{
|
||||
if (unknowns.Count == 0)
|
||||
return UnknownsSummary.Empty;
|
||||
|
||||
// Count by reason code
|
||||
var byReasonCode = unknowns
|
||||
.GroupBy(u => u.ReasonCode)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Calculate blocking count (would block without exceptions)
|
||||
var blockingCount = budgetResult?.Violations.Values.Sum(v => v.Count) ?? 0;
|
||||
|
||||
// Calculate excepted count
|
||||
var exceptedCount = exceptions?.Count ?? 0;
|
||||
|
||||
// Compute digest of unknowns list for integrity
|
||||
var unknownsDigest = ComputeUnknownsDigest(unknowns);
|
||||
|
||||
// Extract policy thresholds that were checked
|
||||
var thresholds = budgetResult?.Violations.Keys
|
||||
.Select(k => $"{k}:{budgetResult.Violations[k].Limit}")
|
||||
.ToList() ?? new List<string>();
|
||||
|
||||
// Extract applied exception IDs
|
||||
var exceptionIds = exceptions?
|
||||
.Select(e => e.ExceptionId)
|
||||
.ToList() ?? new List<string>();
|
||||
|
||||
return new UnknownsSummary
|
||||
{
|
||||
Total = unknowns.Count,
|
||||
ByReasonCode = byReasonCode,
|
||||
BlockingCount = blockingCount,
|
||||
ExceptedCount = exceptedCount,
|
||||
PolicyThresholdsApplied = thresholds,
|
||||
ExceptionsApplied = exceptionIds,
|
||||
UnknownsDigest = unknownsDigest
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic digest of the unknowns list.
|
||||
/// </summary>
|
||||
private static string ComputeUnknownsDigest(IReadOnlyList<UnknownItem> unknowns)
|
||||
{
|
||||
// Sort for determinism
|
||||
var sorted = unknowns
|
||||
.OrderBy(u => u.PackageUrl)
|
||||
.ThenBy(u => u.CveId)
|
||||
.ThenBy(u => u.ReasonCode)
|
||||
.ToList();
|
||||
|
||||
// Serialize to canonical JSON
|
||||
var json = JsonSerializer.Serialize(sorted, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
// Hash the serialized data using SHA256
|
||||
using var sha256 = SHA256.Create();
|
||||
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for unknowns aggregation service.
|
||||
/// </summary>
|
||||
public interface IUnknownsAggregator
|
||||
{
|
||||
/// <summary>
|
||||
/// Aggregates unknowns into a summary.
|
||||
/// </summary>
|
||||
UnknownsSummary Aggregate(
|
||||
IReadOnlyList<UnknownItem> unknowns,
|
||||
BudgetCheckResult? budgetResult = null,
|
||||
IReadOnlyList<ExceptionRef>? exceptions = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input item for unknowns aggregation.
|
||||
/// </summary>
|
||||
public sealed record UnknownItem(
|
||||
string PackageUrl,
|
||||
string? CveId,
|
||||
string ReasonCode,
|
||||
string? RemediationHint);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an applied exception.
|
||||
/// </summary>
|
||||
public sealed record ExceptionRef(
|
||||
string ExceptionId,
|
||||
string Status,
|
||||
IReadOnlyList<string> CoveredReasonCodes);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a budget check operation.
|
||||
/// </summary>
|
||||
public sealed record BudgetCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Budget violations by reason code.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, BudgetViolation> Violations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a budget violation for a specific reason code.
|
||||
/// </summary>
|
||||
public sealed record BudgetViolation(
|
||||
int Count,
|
||||
int Limit);
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for Smart-Diff delta verdicts.
|
||||
/// Predicate type: delta-verdict.stella/v1
|
||||
/// </summary>
|
||||
public sealed record DeltaVerdictStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => DeltaVerdictPredicate.PredicateType;
|
||||
|
||||
/// <summary>
|
||||
/// The delta verdict predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required DeltaVerdictPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for reachability subgraph attestations.
|
||||
/// Predicate type: reachability-subgraph.stella/v1
|
||||
/// </summary>
|
||||
public sealed record ReachabilitySubgraphStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => ReachabilitySubgraphPredicate.PredicateType;
|
||||
|
||||
/// <summary>
|
||||
/// The reachability subgraph predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required ReachabilitySubgraphPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Models;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
@@ -66,6 +67,20 @@ public sealed record VerdictReceiptPayload
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary of unknowns encountered during evaluation.
|
||||
/// Included for transparency about uncertainty in the verdict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknowns")]
|
||||
public UnknownsSummary? Unknowns { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the knowledge snapshot used for evaluation.
|
||||
/// Enables replay and verification of inputs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("knowledgeSnapshotId")]
|
||||
public string? KnowledgeSnapshotId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Models;
|
||||
|
||||
public class UnknownsSummaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Empty_ReturnsZeroCounts()
|
||||
{
|
||||
var summary = UnknownsSummary.Empty;
|
||||
|
||||
summary.Total.Should().Be(0);
|
||||
summary.ByReasonCode.Should().BeEmpty();
|
||||
summary.BlockingCount.Should().Be(0);
|
||||
summary.ExceptedCount.Should().Be(0);
|
||||
summary.PolicyThresholdsApplied.Should().BeEmpty();
|
||||
summary.ExceptionsApplied.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_ProducesValidSummary()
|
||||
{
|
||||
var summary = UnknownsSummary.Empty;
|
||||
|
||||
summary.Should().NotBeNull();
|
||||
summary.ByReasonCode.Should().NotBeNull();
|
||||
summary.PolicyThresholdsApplied.Should().NotBeNull();
|
||||
summary.ExceptionsApplied.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Services;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Services;
|
||||
|
||||
public class UnknownsAggregatorTests
|
||||
{
|
||||
private readonly IUnknownsAggregator _aggregator;
|
||||
|
||||
public UnknownsAggregatorTests()
|
||||
{
|
||||
_aggregator = new UnknownsAggregator();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregate_EmptyList_ReturnsEmptySummary()
|
||||
{
|
||||
var unknowns = new List<UnknownItem>();
|
||||
|
||||
var summary = _aggregator.Aggregate(unknowns);
|
||||
|
||||
summary.Total.Should().Be(0);
|
||||
summary.ByReasonCode.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregate_GroupsByReasonCode()
|
||||
{
|
||||
var unknowns = new List<UnknownItem>
|
||||
{
|
||||
new("pkg:npm/foo@1.0", null, "Reachability", null),
|
||||
new("pkg:npm/bar@1.0", null, "Reachability", null),
|
||||
new("pkg:npm/baz@1.0", null, "Identity", null)
|
||||
};
|
||||
|
||||
var summary = _aggregator.Aggregate(unknowns);
|
||||
|
||||
summary.Total.Should().Be(3);
|
||||
summary.ByReasonCode["Reachability"].Should().Be(2);
|
||||
summary.ByReasonCode["Identity"].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregate_ComputesDeterministicDigest()
|
||||
{
|
||||
var unknowns = CreateUnknowns();
|
||||
|
||||
var summary1 = _aggregator.Aggregate(unknowns);
|
||||
var summary2 = _aggregator.Aggregate(unknowns.Reverse().ToList());
|
||||
|
||||
summary1.UnknownsDigest.Should().Be(summary2.UnknownsDigest);
|
||||
summary1.UnknownsDigest.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregate_IncludesExceptionIds()
|
||||
{
|
||||
var unknowns = CreateUnknowns();
|
||||
var exceptions = new List<ExceptionRef>
|
||||
{
|
||||
new("EXC-001", "Approved", new[] { "Reachability" })
|
||||
};
|
||||
|
||||
var summary = _aggregator.Aggregate(unknowns, null, exceptions);
|
||||
|
||||
summary.ExceptionsApplied.Should().Contain("EXC-001");
|
||||
summary.ExceptedCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregate_IncludesBudgetViolations()
|
||||
{
|
||||
var unknowns = CreateUnknowns();
|
||||
var budgetResult = new BudgetCheckResult
|
||||
{
|
||||
Violations = new Dictionary<string, BudgetViolation>
|
||||
{
|
||||
["Reachability"] = new BudgetViolation(5, 3),
|
||||
["Identity"] = new BudgetViolation(2, 1)
|
||||
}
|
||||
};
|
||||
|
||||
var summary = _aggregator.Aggregate(unknowns, budgetResult);
|
||||
|
||||
summary.BlockingCount.Should().Be(7); // 5 + 2
|
||||
summary.PolicyThresholdsApplied.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<UnknownItem> CreateUnknowns()
|
||||
{
|
||||
return new List<UnknownItem>
|
||||
{
|
||||
new("pkg:npm/foo@1.0", "CVE-2024-001", "Reachability", "Run reachability analysis"),
|
||||
new("pkg:npm/bar@2.0", "CVE-2024-002", "Identity", "Add package digest"),
|
||||
new("pkg:npm/baz@3.0", null, "VexConflict", "Review VEX statements")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps Contributors
|
||||
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Statements;
|
||||
|
||||
public sealed class DeltaVerdictStatementTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2025, 12, 22, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void DeltaVerdictStatement_HasPredicateTypeAndPayload()
|
||||
{
|
||||
var statement = new DeltaVerdictStatement
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
new Subject
|
||||
{
|
||||
Name = "sha256:before",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "before" }
|
||||
},
|
||||
new Subject
|
||||
{
|
||||
Name = "sha256:after",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "after" }
|
||||
}
|
||||
],
|
||||
Predicate = new DeltaVerdictPredicate
|
||||
{
|
||||
BeforeRevisionId = "rev-before",
|
||||
AfterRevisionId = "rev-after",
|
||||
HasMaterialChange = true,
|
||||
PriorityScore = 1750,
|
||||
Changes =
|
||||
[
|
||||
new DeltaVerdictChange
|
||||
{
|
||||
Rule = "R1_ReachabilityFlip",
|
||||
FindingKey = new DeltaFindingKey
|
||||
{
|
||||
VulnId = "CVE-2025-1234",
|
||||
Purl = "pkg:npm/lodash@4.17.20"
|
||||
},
|
||||
Direction = "increased",
|
||||
Reason = "Reachability changed from false to true"
|
||||
}
|
||||
],
|
||||
ComparedAt = FixedTime
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Equal("delta-verdict.stella/v1", statement.PredicateType);
|
||||
Assert.Equal(2, statement.Subject.Count);
|
||||
Assert.Equal("rev-before", statement.Predicate.BeforeRevisionId);
|
||||
Assert.True(statement.Predicate.HasMaterialChange);
|
||||
Assert.Single(statement.Predicate.Changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReachabilitySubgraphStatement_RoundTrips()
|
||||
{
|
||||
var statement = new ReachabilitySubgraphStatement
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
new Subject
|
||||
{
|
||||
Name = "sha256:graph",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "graph" }
|
||||
}
|
||||
],
|
||||
Predicate = new ReachabilitySubgraphPredicate
|
||||
{
|
||||
GraphDigest = "blake3:deadbeef",
|
||||
FindingKeys = ["CVE-2025-9999@pkg:npm/example@1.0.0"],
|
||||
Analysis = new ReachabilitySubgraphAnalysis
|
||||
{
|
||||
Analyzer = "reachability",
|
||||
AnalyzerVersion = "1.0.0",
|
||||
Confidence = 0.9,
|
||||
Completeness = "partial",
|
||||
GeneratedAt = FixedTime
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(statement);
|
||||
var restored = JsonSerializer.Deserialize<ReachabilitySubgraphStatement>(json);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal("reachability-subgraph.stella/v1", restored!.PredicateType);
|
||||
Assert.Equal("blake3:deadbeef", restored.Predicate.GraphDigest);
|
||||
Assert.Single(restored.Predicate.FindingKeys);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user