sprints work

This commit is contained in:
StellaOps Bot
2025-12-24 21:46:08 +02:00
parent 43e2af88f6
commit b9f71fc7e9
161 changed files with 29566 additions and 527 deletions

View File

@@ -0,0 +1,58 @@
using StellaOps.Canonical.Json;
namespace StellaOps.Evidence.Core.Adapters;
/// <summary>
/// Base adapter functionality for converting module-specific evidence to unified IEvidence.
/// </summary>
public abstract class EvidenceAdapterBase
{
/// <summary>
/// Creates an EvidenceRecord from a payload object.
/// </summary>
/// <typeparam name="T">Payload type.</typeparam>
/// <param name="subjectNodeId">Content-addressed subject identifier.</param>
/// <param name="evidenceType">Type of evidence.</param>
/// <param name="payload">The payload object to serialize.</param>
/// <param name="provenance">Generation provenance.</param>
/// <param name="payloadSchemaVersion">Schema version for the payload.</param>
/// <param name="signatures">Optional signatures.</param>
/// <returns>A new EvidenceRecord.</returns>
protected static EvidenceRecord CreateEvidence<T>(
string subjectNodeId,
EvidenceType evidenceType,
T payload,
EvidenceProvenance provenance,
string payloadSchemaVersion,
IReadOnlyList<EvidenceSignature>? signatures = null)
{
var payloadBytes = CanonJson.Canonicalize(payload);
return EvidenceRecord.Create(
subjectNodeId,
evidenceType,
payloadBytes,
provenance,
payloadSchemaVersion,
signatures);
}
/// <summary>
/// Creates standard provenance from generator info.
/// </summary>
protected static EvidenceProvenance CreateProvenance(
string generatorId,
string generatorVersion,
DateTimeOffset generatedAt,
string? correlationId = null,
Guid? tenantId = null)
{
return new EvidenceProvenance
{
GeneratorId = generatorId,
GeneratorVersion = generatorVersion,
GeneratedAt = generatedAt,
CorrelationId = correlationId,
TenantId = tenantId
};
}
}

View File

@@ -0,0 +1,317 @@
using StellaOps.Evidence.Bundle;
namespace StellaOps.Evidence.Core.Adapters;
/// <summary>
/// Converts Scanner's <see cref="EvidenceBundle"/> to unified <see cref="IEvidence"/> records.
/// An EvidenceBundle may contain multiple evidence types (reachability, VEX, provenance, etc.),
/// each converted to a separate IEvidence record.
/// </summary>
public sealed class EvidenceBundleAdapter : EvidenceAdapterBase, IEvidenceAdapter<EvidenceBundle>
{
/// <summary>
/// Schema version constants for evidence payloads.
/// </summary>
private static class SchemaVersions
{
public const string Reachability = "reachability/v1";
public const string Vex = "vex/v1";
public const string Provenance = "provenance/v1";
public const string CallStack = "callstack/v1";
public const string Diff = "diff/v1";
public const string GraphRevision = "graph-revision/v1";
}
/// <inheritdoc />
public bool CanConvert(EvidenceBundle source)
{
return source is not null;
}
/// <inheritdoc />
public IReadOnlyList<IEvidence> Convert(
EvidenceBundle bundle,
string subjectNodeId,
EvidenceProvenance provenance)
{
ArgumentNullException.ThrowIfNull(bundle);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
ArgumentNullException.ThrowIfNull(provenance);
var results = new List<IEvidence>();
// Convert reachability evidence
if (bundle.Reachability is { Status: EvidenceStatus.Available })
{
results.Add(ConvertReachability(bundle.Reachability, subjectNodeId, provenance));
}
// Convert VEX status evidence
if (bundle.VexStatus is { Status: EvidenceStatus.Available })
{
results.Add(ConvertVexStatus(bundle.VexStatus, subjectNodeId, provenance));
}
// Convert provenance evidence
if (bundle.Provenance is { Status: EvidenceStatus.Available })
{
results.Add(ConvertProvenance(bundle.Provenance, subjectNodeId, provenance));
}
// Convert call stack evidence
if (bundle.CallStack is { Status: EvidenceStatus.Available })
{
results.Add(ConvertCallStack(bundle.CallStack, subjectNodeId, provenance));
}
// Convert diff evidence
if (bundle.Diff is { Status: EvidenceStatus.Available })
{
results.Add(ConvertDiff(bundle.Diff, subjectNodeId, provenance));
}
// Convert graph revision evidence
if (bundle.GraphRevision is { Status: EvidenceStatus.Available })
{
results.Add(ConvertGraphRevision(bundle.GraphRevision, subjectNodeId, provenance));
}
return results;
}
private static IEvidence ConvertReachability(
ReachabilityEvidence reachability,
string subjectNodeId,
EvidenceProvenance provenance)
{
var payload = new ReachabilityPayload
{
Hash = reachability.Hash,
ProofType = reachability.ProofType.ToString(),
FunctionPath = reachability.FunctionPath?.Select(f => new FunctionPathPayload
{
FunctionName = f.FunctionName,
FilePath = f.FilePath,
Line = f.Line,
Column = f.Column,
ModuleName = f.ModuleName
}).ToList(),
ImportChain = reachability.ImportChain?.Select(i => new ImportChainPayload
{
PackageName = i.PackageName,
Version = i.Version,
ImportedBy = i.ImportedBy,
ImportPath = i.ImportPath
}).ToList(),
LatticeState = reachability.LatticeState,
ConfidenceTier = reachability.ConfidenceTier
};
return CreateEvidence(subjectNodeId, EvidenceType.Reachability, payload, provenance, SchemaVersions.Reachability);
}
private static IEvidence ConvertVexStatus(
VexStatusEvidence vexStatus,
string subjectNodeId,
EvidenceProvenance provenance)
{
var payload = new VexStatusPayload
{
Hash = vexStatus.Hash,
VexStatus = vexStatus.Current?.VexStatus,
Justification = vexStatus.Current?.Justification,
ImpactStatement = vexStatus.Current?.ImpactStatement,
ActionStatement = vexStatus.Current?.ActionStatement,
StatementSource = vexStatus.Current?.Source,
StatementTimestamp = vexStatus.Current?.Timestamp
};
return CreateEvidence(subjectNodeId, EvidenceType.Vex, payload, provenance, SchemaVersions.Vex);
}
private static IEvidence ConvertProvenance(
ProvenanceEvidence provenanceEvidence,
string subjectNodeId,
EvidenceProvenance provenance)
{
var payload = new ProvenancePayload
{
Hash = provenanceEvidence.Hash,
BuilderId = provenanceEvidence.Ancestry?.BuildId,
BuildTime = provenanceEvidence.Ancestry?.BuildTime,
ImageDigest = provenanceEvidence.Ancestry?.ImageDigest,
LayerDigest = provenanceEvidence.Ancestry?.LayerDigest,
CommitHash = provenanceEvidence.Ancestry?.CommitHash,
VerificationStatus = provenanceEvidence.VerificationStatus,
RekorLogIndex = provenanceEvidence.RekorEntry?.LogIndex
};
return CreateEvidence(subjectNodeId, EvidenceType.Provenance, payload, provenance, SchemaVersions.Provenance);
}
private static IEvidence ConvertCallStack(
CallStackEvidence callStack,
string subjectNodeId,
EvidenceProvenance provenance)
{
var payload = new CallStackPayload
{
Hash = callStack.Hash,
SinkFrameIndex = callStack.SinkFrameIndex,
SourceFrameIndex = callStack.SourceFrameIndex,
Frames = callStack.Frames?.Select(f => new StackFramePayload
{
FunctionName = f.FunctionName,
FilePath = f.FilePath,
Line = f.Line,
Column = f.Column,
IsSink = f.IsSink,
IsSource = f.IsSource
}).ToList()
};
return CreateEvidence(subjectNodeId, EvidenceType.Runtime, payload, provenance, SchemaVersions.CallStack);
}
private static IEvidence ConvertDiff(
DiffEvidence diff,
string subjectNodeId,
EvidenceProvenance provenance)
{
var payload = new DiffPayload
{
Hash = diff.Hash,
DiffType = diff.DiffType.ToString(),
PreviousScanId = diff.PreviousScanId,
PreviousScanTime = diff.PreviousScanTime,
Entries = diff.Entries?.Select(e => new DiffEntryPayload
{
Operation = e.Operation.ToString(),
Path = e.Path,
OldValue = e.OldValue,
NewValue = e.NewValue,
ComponentPurl = e.ComponentPurl
}).ToList()
};
return CreateEvidence(subjectNodeId, EvidenceType.Artifact, payload, provenance, SchemaVersions.Diff);
}
private static IEvidence ConvertGraphRevision(
GraphRevisionEvidence graphRevision,
string subjectNodeId,
EvidenceProvenance provenance)
{
var payload = new GraphRevisionPayload
{
Hash = graphRevision.Hash,
RevisionId = graphRevision.GraphRevisionId,
VerdictReceipt = graphRevision.VerdictReceipt,
GraphComputedAt = graphRevision.GraphComputedAt,
NodeCount = graphRevision.TotalNodes,
EdgeCount = graphRevision.TotalEdges
};
return CreateEvidence(subjectNodeId, EvidenceType.Dependency, payload, provenance, SchemaVersions.GraphRevision);
}
#region Payload Records
internal sealed record ReachabilityPayload
{
public string? Hash { get; init; }
public string? ProofType { get; init; }
public IReadOnlyList<FunctionPathPayload>? FunctionPath { get; init; }
public IReadOnlyList<ImportChainPayload>? ImportChain { get; init; }
public string? LatticeState { get; init; }
public int? ConfidenceTier { get; init; }
}
internal sealed record FunctionPathPayload
{
public required string FunctionName { get; init; }
public required string FilePath { get; init; }
public required int Line { get; init; }
public int? Column { get; init; }
public string? ModuleName { get; init; }
}
internal sealed record ImportChainPayload
{
public required string PackageName { get; init; }
public string? Version { get; init; }
public string? ImportedBy { get; init; }
public string? ImportPath { get; init; }
}
internal sealed record VexStatusPayload
{
public string? Hash { get; init; }
public string? VexStatus { get; init; }
public string? Justification { get; init; }
public string? ImpactStatement { get; init; }
public string? ActionStatement { get; init; }
public string? StatementSource { get; init; }
public DateTimeOffset? StatementTimestamp { get; init; }
}
internal sealed record ProvenancePayload
{
public string? Hash { get; init; }
public string? BuilderId { get; init; }
public DateTimeOffset? BuildTime { get; init; }
public string? ImageDigest { get; init; }
public string? LayerDigest { get; init; }
public string? CommitHash { get; init; }
public string? VerificationStatus { get; init; }
public long? RekorLogIndex { get; init; }
}
internal sealed record CallStackPayload
{
public string? Hash { get; init; }
public int? SinkFrameIndex { get; init; }
public int? SourceFrameIndex { get; init; }
public IReadOnlyList<StackFramePayload>? Frames { get; init; }
}
internal sealed record StackFramePayload
{
public required string FunctionName { get; init; }
public required string FilePath { get; init; }
public required int Line { get; init; }
public int? Column { get; init; }
public bool IsSink { get; init; }
public bool IsSource { get; init; }
}
internal sealed record DiffPayload
{
public string? Hash { get; init; }
public string? DiffType { get; init; }
public string? PreviousScanId { get; init; }
public DateTimeOffset? PreviousScanTime { get; init; }
public IReadOnlyList<DiffEntryPayload>? Entries { get; init; }
}
internal sealed record DiffEntryPayload
{
public required string Operation { get; init; }
public required string Path { get; init; }
public string? OldValue { get; init; }
public string? NewValue { get; init; }
public string? ComponentPurl { get; init; }
}
internal sealed record GraphRevisionPayload
{
public string? Hash { get; init; }
public string? RevisionId { get; init; }
public string? VerdictReceipt { get; init; }
public DateTimeOffset? GraphComputedAt { get; init; }
public int? NodeCount { get; init; }
public int? EdgeCount { get; init; }
}
#endregion
}

View File

@@ -0,0 +1,148 @@
using StellaOps.Canonical.Json;
namespace StellaOps.Evidence.Core.Adapters;
/// <summary>
/// Converts Attestor's in-toto evidence statements to unified <see cref="IEvidence"/> records.
/// This adapter works with the canonical predicate structure rather than requiring a direct
/// dependency on StellaOps.Attestor.ProofChain.
/// </summary>
/// <remarks>
/// Evidence statements follow the in-toto attestation format with predicateType "evidence.stella/v1".
/// The adapter extracts:
/// - SubjectNodeId from the statement subject (artifact digest)
/// - Payload from the predicate
/// - Provenance from source/sourceVersion/collectionTime
/// </remarks>
public sealed class EvidenceStatementAdapter : EvidenceAdapterBase, IEvidenceAdapter<EvidenceStatementInput>
{
private const string SchemaVersion = "evidence-statement/v1";
/// <inheritdoc />
public bool CanConvert(EvidenceStatementInput source)
{
return source is not null &&
!string.IsNullOrEmpty(source.SubjectDigest) &&
!string.IsNullOrEmpty(source.Source);
}
/// <inheritdoc />
public IReadOnlyList<IEvidence> Convert(
EvidenceStatementInput input,
string subjectNodeId,
EvidenceProvenance provenance)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
ArgumentNullException.ThrowIfNull(provenance);
var payload = new EvidenceStatementPayload
{
Source = input.Source,
SourceVersion = input.SourceVersion,
CollectionTime = input.CollectionTime,
SbomEntryId = input.SbomEntryId,
VulnerabilityId = input.VulnerabilityId,
RawFindingHash = input.RawFindingHash,
OriginalEvidenceId = input.EvidenceId
};
var evidence = CreateEvidence(
subjectNodeId,
EvidenceType.Scan,
payload,
provenance,
SchemaVersion);
return [evidence];
}
/// <summary>
/// Creates an adapter input from Attestor's EvidenceStatement fields.
/// Use this when you have direct access to the statement object.
/// </summary>
public static EvidenceStatementInput FromStatement(
string subjectDigest,
string source,
string sourceVersion,
DateTimeOffset collectionTime,
string sbomEntryId,
string? vulnerabilityId,
string? rawFindingHash,
string? evidenceId)
{
return new EvidenceStatementInput
{
SubjectDigest = subjectDigest,
Source = source,
SourceVersion = sourceVersion,
CollectionTime = collectionTime,
SbomEntryId = sbomEntryId,
VulnerabilityId = vulnerabilityId,
RawFindingHash = rawFindingHash,
EvidenceId = evidenceId
};
}
#region Payload Records
internal sealed record EvidenceStatementPayload
{
public required string Source { get; init; }
public required string SourceVersion { get; init; }
public required DateTimeOffset CollectionTime { get; init; }
public required string SbomEntryId { get; init; }
public string? VulnerabilityId { get; init; }
public string? RawFindingHash { get; init; }
public string? OriginalEvidenceId { get; init; }
}
#endregion
}
/// <summary>
/// Input DTO for EvidenceStatementAdapter.
/// Decouples the adapter from direct dependency on StellaOps.Attestor.ProofChain.
/// </summary>
public sealed record EvidenceStatementInput
{
/// <summary>
/// Subject artifact digest from the in-toto statement.
/// </summary>
public required string SubjectDigest { get; init; }
/// <summary>
/// Scanner or feed name that produced this evidence.
/// </summary>
public required string Source { get; init; }
/// <summary>
/// Version of the source tool.
/// </summary>
public required string SourceVersion { get; init; }
/// <summary>
/// UTC timestamp when evidence was collected.
/// </summary>
public required DateTimeOffset CollectionTime { get; init; }
/// <summary>
/// Reference to the SBOM entry this evidence relates to.
/// </summary>
public required string SbomEntryId { get; init; }
/// <summary>
/// CVE or vulnerability identifier if applicable.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Hash of the raw finding data (to avoid storing large payloads).
/// </summary>
public string? RawFindingHash { get; init; }
/// <summary>
/// Original content-addressed evidence ID from the statement.
/// </summary>
public string? EvidenceId { get; init; }
}

View File

@@ -0,0 +1,99 @@
// <copyright file="ExceptionApplicationAdapter.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Evidence.Core.Adapters;
/// <summary>
/// Input DTO for ExceptionApplication data, decoupling from Policy.Exceptions dependency.
/// </summary>
public sealed record ExceptionApplicationInput
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required string ExceptionId { get; init; }
public required string FindingId { get; init; }
public string? VulnerabilityId { get; init; }
public required string OriginalStatus { get; init; }
public required string AppliedStatus { get; init; }
public required string EffectName { get; init; }
public required string EffectType { get; init; }
public Guid? EvaluationRunId { get; init; }
public string? PolicyBundleDigest { get; init; }
public required DateTimeOffset AppliedAt { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Adapter that converts Policy's ExceptionApplication into unified IEvidence records.
/// Uses <see cref="ExceptionApplicationInput"/> DTO to avoid circular dependencies.
/// </summary>
/// <remarks>
/// Each ExceptionApplication represents a policy exception that was applied to a finding,
/// tracking the status transition from original to applied state.
/// </remarks>
public sealed class ExceptionApplicationAdapter : EvidenceAdapterBase, IEvidenceAdapter<ExceptionApplicationInput>
{
private const string PayloadSchemaVersion = "1.0.0";
/// <inheritdoc />
public bool CanConvert(ExceptionApplicationInput source)
{
return source is not null &&
!string.IsNullOrEmpty(source.ExceptionId) &&
!string.IsNullOrEmpty(source.FindingId);
}
/// <inheritdoc />
public IReadOnlyList<IEvidence> Convert(
ExceptionApplicationInput application,
string subjectNodeId,
EvidenceProvenance provenance)
{
ArgumentNullException.ThrowIfNull(application);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
ArgumentNullException.ThrowIfNull(provenance);
var payload = new ExceptionApplicationPayload(
ApplicationId: application.Id.ToString("D"),
TenantId: application.TenantId.ToString("D"),
ExceptionId: application.ExceptionId,
FindingId: application.FindingId,
VulnerabilityId: application.VulnerabilityId,
OriginalStatus: application.OriginalStatus,
AppliedStatus: application.AppliedStatus,
EffectName: application.EffectName,
EffectType: application.EffectType,
EvaluationRunId: application.EvaluationRunId?.ToString("D"),
PolicyBundleDigest: application.PolicyBundleDigest,
AppliedAt: application.AppliedAt);
var record = CreateEvidence(
subjectNodeId: subjectNodeId,
evidenceType: EvidenceType.Exception,
payload: payload,
provenance: provenance,
payloadSchemaVersion: PayloadSchemaVersion);
return [record];
}
/// <summary>
/// Payload for exception application evidence record.
/// </summary>
private sealed record ExceptionApplicationPayload(
string ApplicationId,
string TenantId,
string ExceptionId,
string FindingId,
string? VulnerabilityId,
string OriginalStatus,
string AppliedStatus,
string EffectName,
string EffectType,
string? EvaluationRunId,
string? PolicyBundleDigest,
DateTimeOffset AppliedAt);
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Evidence.Core.Adapters;
/// <summary>
/// Interface for adapters that convert module-specific evidence types to unified IEvidence.
/// </summary>
/// <typeparam name="TSource">The source evidence type from the module.</typeparam>
public interface IEvidenceAdapter<TSource>
{
/// <summary>
/// Converts a module-specific evidence object to unified IEvidence record(s).
/// A single source object may produce multiple evidence records (e.g., EvidenceBundle
/// contains reachability, VEX, etc.).
/// </summary>
/// <param name="source">The source evidence to convert.</param>
/// <param name="subjectNodeId">Content-addressed subject identifier.</param>
/// <param name="provenance">Generation provenance for the converted records.</param>
/// <returns>One or more unified evidence records.</returns>
IReadOnlyList<IEvidence> Convert(TSource source, string subjectNodeId, EvidenceProvenance provenance);
/// <summary>
/// Checks if the adapter can handle the given source object.
/// </summary>
/// <param name="source">The source evidence to check.</param>
/// <returns>True if this adapter can convert the source.</returns>
bool CanConvert(TSource source);
}

View File

@@ -0,0 +1,144 @@
namespace StellaOps.Evidence.Core.Adapters;
/// <summary>
/// Converts Scanner's ProofSegment to unified <see cref="IEvidence"/> records.
/// Each segment represents a step in the proof chain from SBOM to VEX verdict.
/// </summary>
public sealed class ProofSegmentAdapter : EvidenceAdapterBase, IEvidenceAdapter<ProofSegmentInput>
{
private const string SchemaVersion = "proof-segment/v1";
/// <inheritdoc />
public bool CanConvert(ProofSegmentInput source)
{
return source is not null &&
!string.IsNullOrEmpty(source.SegmentId) &&
!string.IsNullOrEmpty(source.InputHash);
}
/// <inheritdoc />
public IReadOnlyList<IEvidence> Convert(
ProofSegmentInput input,
string subjectNodeId,
EvidenceProvenance provenance)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
ArgumentNullException.ThrowIfNull(provenance);
var evidenceType = MapSegmentTypeToEvidenceType(input.SegmentType);
var payload = new ProofSegmentPayload
{
SegmentId = input.SegmentId,
SegmentType = input.SegmentType,
Index = input.Index,
InputHash = input.InputHash,
ResultHash = input.ResultHash,
PrevSegmentHash = input.PrevSegmentHash,
ToolId = input.ToolId,
ToolVersion = input.ToolVersion,
Status = input.Status,
SpineId = input.SpineId
};
var evidence = CreateEvidence(
subjectNodeId,
evidenceType,
payload,
provenance,
SchemaVersion);
return [evidence];
}
/// <summary>
/// Maps proof segment types to unified evidence types.
/// </summary>
private static EvidenceType MapSegmentTypeToEvidenceType(string segmentType) =>
segmentType?.ToUpperInvariant() switch
{
"SBOMSLICE" => EvidenceType.Artifact,
"MATCH" => EvidenceType.Scan,
"REACHABILITY" => EvidenceType.Reachability,
"GUARDANALYSIS" => EvidenceType.Guard,
"RUNTIMEOBSERVATION" => EvidenceType.Runtime,
"POLICYEVAL" => EvidenceType.Policy,
_ => EvidenceType.Custom
};
#region Payload Records
internal sealed record ProofSegmentPayload
{
public required string SegmentId { get; init; }
public required string SegmentType { get; init; }
public required int Index { get; init; }
public required string InputHash { get; init; }
public required string ResultHash { get; init; }
public string? PrevSegmentHash { get; init; }
public required string ToolId { get; init; }
public required string ToolVersion { get; init; }
public required string Status { get; init; }
public string? SpineId { get; init; }
}
#endregion
}
/// <summary>
/// Input DTO for ProofSegmentAdapter.
/// Decouples the adapter from direct dependency on StellaOps.Scanner.ProofSpine.
/// </summary>
public sealed record ProofSegmentInput
{
/// <summary>
/// Unique segment identifier.
/// </summary>
public required string SegmentId { get; init; }
/// <summary>
/// Segment type (e.g., "SbomSlice", "Match", "Reachability", "GuardAnalysis", "RuntimeObservation", "PolicyEval").
/// </summary>
public required string SegmentType { get; init; }
/// <summary>
/// Position in the proof chain (0-based).
/// </summary>
public required int Index { get; init; }
/// <summary>
/// Hash of input data to this segment.
/// </summary>
public required string InputHash { get; init; }
/// <summary>
/// Hash of output/result from this segment.
/// </summary>
public required string ResultHash { get; init; }
/// <summary>
/// Hash of the previous segment (for chaining verification).
/// </summary>
public string? PrevSegmentHash { get; init; }
/// <summary>
/// Tool that produced this segment.
/// </summary>
public required string ToolId { get; init; }
/// <summary>
/// Version of the tool.
/// </summary>
public required string ToolVersion { get; init; }
/// <summary>
/// Verification status (e.g., "Pending", "Verified", "Invalid", "Untrusted").
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Parent spine ID for correlation.
/// </summary>
public string? SpineId { get; init; }
}

View File

@@ -0,0 +1,248 @@
// <copyright file="VexObservationAdapter.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Nodes;
namespace StellaOps.Evidence.Core.Adapters;
/// <summary>
/// Input DTO for VexObservation data, decoupling from Excititor.Core dependency.
/// </summary>
public sealed record VexObservationInput
{
public required string ObservationId { get; init; }
public required string Tenant { get; init; }
public required string ProviderId { get; init; }
public required string StreamId { get; init; }
public required VexObservationUpstreamInput Upstream { get; init; }
public required ImmutableArray<VexObservationStatementInput> Statements { get; init; }
public required VexObservationContentInput Content { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public ImmutableArray<string> Supersedes { get; init; } = [];
public ImmutableDictionary<string, string> Attributes { get; init; } = ImmutableDictionary<string, string>.Empty;
}
public sealed record VexObservationUpstreamInput
{
public required string UpstreamId { get; init; }
public string? DocumentVersion { get; init; }
public required DateTimeOffset FetchedAt { get; init; }
public required DateTimeOffset ReceivedAt { get; init; }
public required string ContentHash { get; init; }
public required VexObservationSignatureInput Signature { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
}
public sealed record VexObservationSignatureInput
{
public bool Present { get; init; }
public string? Format { get; init; }
public string? KeyId { get; init; }
public string? Signature { get; init; }
}
public sealed record VexObservationContentInput
{
public required string Format { get; init; }
public string? SpecVersion { get; init; }
public JsonNode? Raw { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
}
public sealed record VexObservationStatementInput
{
public required string VulnerabilityId { get; init; }
public required string ProductKey { get; init; }
public required string Status { get; init; }
public DateTimeOffset? LastObserved { get; init; }
public string? Locator { get; init; }
public string? Justification { get; init; }
public string? IntroducedVersion { get; init; }
public string? FixedVersion { get; init; }
public string? Purl { get; init; }
public string? Cpe { get; init; }
public ImmutableArray<JsonNode> Evidence { get; init; } = [];
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Adapter that converts Excititor's VexObservation into unified IEvidence records.
/// Uses <see cref="VexObservationInput"/> DTO to avoid circular dependencies.
/// </summary>
/// <remarks>
/// VexObservations contain multiple statements; each statement becomes a separate evidence record.
/// An additional observation-level evidence record captures the overall document provenance.
/// </remarks>
public sealed class VexObservationAdapter : EvidenceAdapterBase, IEvidenceAdapter<VexObservationInput>
{
private const string PayloadSchemaVersion = "1.0.0";
private const string AdapterSource = "VexObservationAdapter";
/// <inheritdoc />
public bool CanConvert(VexObservationInput source)
{
return source is not null &&
!string.IsNullOrEmpty(source.ObservationId) &&
!string.IsNullOrEmpty(source.ProviderId);
}
/// <inheritdoc />
public IReadOnlyList<IEvidence> Convert(
VexObservationInput observation,
string subjectNodeId,
EvidenceProvenance provenance)
{
ArgumentNullException.ThrowIfNull(observation);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
ArgumentNullException.ThrowIfNull(provenance);
var records = new List<IEvidence>();
// Create observation-level evidence record (provenance for the VEX document)
var observationRecord = CreateObservationRecord(observation, subjectNodeId, provenance);
records.Add(observationRecord);
// Create per-statement evidence records
for (int i = 0; i < observation.Statements.Length; i++)
{
var statement = observation.Statements[i];
var statementRecord = CreateStatementRecord(
observation,
statement,
subjectNodeId,
provenance,
i);
records.Add(statementRecord);
}
return records;
}
private EvidenceRecord CreateObservationRecord(
VexObservationInput observation,
string subjectNodeId,
EvidenceProvenance provenance)
{
var payload = new VexObservationPayload(
ObservationId: observation.ObservationId,
Tenant: observation.Tenant,
ProviderId: observation.ProviderId,
StreamId: observation.StreamId,
UpstreamId: observation.Upstream.UpstreamId,
DocumentVersion: observation.Upstream.DocumentVersion,
ContentHash: observation.Upstream.ContentHash,
Format: observation.Content.Format,
SpecVersion: observation.Content.SpecVersion,
StatementCount: observation.Statements.Length,
Supersedes: observation.Supersedes,
FetchedAt: observation.Upstream.FetchedAt,
ReceivedAt: observation.Upstream.ReceivedAt,
CreatedAt: observation.CreatedAt);
var signatures = BuildObservationSignatures(observation.Upstream.Signature);
return CreateEvidence(
subjectNodeId: subjectNodeId,
evidenceType: EvidenceType.Provenance,
payload: payload,
provenance: provenance,
payloadSchemaVersion: PayloadSchemaVersion,
signatures: signatures);
}
private EvidenceRecord CreateStatementRecord(
VexObservationInput observation,
VexObservationStatementInput statement,
string subjectNodeId,
EvidenceProvenance provenance,
int statementIndex)
{
var payload = new VexStatementPayload(
ObservationId: observation.ObservationId,
StatementIndex: statementIndex,
VulnerabilityId: statement.VulnerabilityId,
ProductKey: statement.ProductKey,
Status: statement.Status,
Justification: statement.Justification,
LastObserved: statement.LastObserved,
Locator: statement.Locator,
IntroducedVersion: statement.IntroducedVersion,
FixedVersion: statement.FixedVersion,
Purl: statement.Purl,
Cpe: statement.Cpe,
EvidenceCount: statement.Evidence.Length,
ProviderId: observation.ProviderId,
StreamId: observation.StreamId);
var signatures = BuildObservationSignatures(observation.Upstream.Signature);
return CreateEvidence(
subjectNodeId: subjectNodeId,
evidenceType: EvidenceType.Vex,
payload: payload,
provenance: provenance,
payloadSchemaVersion: PayloadSchemaVersion,
signatures: signatures);
}
private static ImmutableArray<EvidenceSignature> BuildObservationSignatures(
VexObservationSignatureInput signature)
{
if (!signature.Present || string.IsNullOrWhiteSpace(signature.Signature))
{
return [];
}
var sig = new EvidenceSignature
{
SignerId = signature.KeyId ?? "unknown",
Algorithm = signature.Format ?? "unknown",
SignatureBase64 = signature.Signature,
SignedAt = DateTimeOffset.UtcNow,
SignerType = SignerType.Vendor
};
return [sig];
}
/// <summary>
/// Payload for observation-level (provenance) evidence record.
/// </summary>
private sealed record VexObservationPayload(
string ObservationId,
string Tenant,
string ProviderId,
string StreamId,
string UpstreamId,
string? DocumentVersion,
string ContentHash,
string Format,
string? SpecVersion,
int StatementCount,
ImmutableArray<string> Supersedes,
DateTimeOffset FetchedAt,
DateTimeOffset ReceivedAt,
DateTimeOffset CreatedAt);
/// <summary>
/// Payload for statement-level VEX evidence record.
/// </summary>
private sealed record VexStatementPayload(
string ObservationId,
int StatementIndex,
string VulnerabilityId,
string ProductKey,
string Status,
string? Justification,
DateTimeOffset? LastObserved,
string? Locator,
string? IntroducedVersion,
string? FixedVersion,
string? Purl,
string? Cpe,
int EvidenceCount,
string ProviderId,
string StreamId);
}

View File

@@ -0,0 +1,66 @@
namespace StellaOps.Evidence.Core;
/// <summary>
/// Provenance information for evidence generation.
/// Captures who generated the evidence, when, and with what inputs.
/// </summary>
public sealed record EvidenceProvenance
{
/// <summary>
/// Tool or service that generated this evidence.
/// Format: "stellaops/{module}/{component}" or vendor identifier.
/// Examples: "stellaops/scanner/trivy", "stellaops/policy/opa", "vendor/snyk".
/// </summary>
public required string GeneratorId { get; init; }
/// <summary>
/// Version of the generator tool.
/// </summary>
public required string GeneratorVersion { get; init; }
/// <summary>
/// When the evidence was generated (UTC).
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Content-addressed hash of inputs used to generate this evidence.
/// Enables replay verification.
/// Format: "sha256:{hex}" or similar.
/// </summary>
public string? InputsDigest { get; init; }
/// <summary>
/// Environment/region where evidence was generated.
/// Examples: "production", "staging", "eu-west-1".
/// </summary>
public string? Environment { get; init; }
/// <summary>
/// Scan run or evaluation ID for correlation across multiple evidence records.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Optional tenant identifier for multi-tenant deployments.
/// </summary>
public Guid? TenantId { get; init; }
/// <summary>
/// Additional metadata for organization-specific tracking.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Creates a minimal provenance record for testing or internal use.
/// </summary>
public static EvidenceProvenance CreateMinimal(string generatorId, string generatorVersion)
{
return new EvidenceProvenance
{
GeneratorId = generatorId,
GeneratorVersion = generatorVersion,
GeneratedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,122 @@
using StellaOps.Canonical.Json;
namespace StellaOps.Evidence.Core;
/// <summary>
/// Concrete implementation of unified evidence record.
/// EvidenceRecord is immutable and content-addressed: the EvidenceId is computed
/// from the canonicalized contents of the record.
/// </summary>
public sealed record EvidenceRecord : IEvidence
{
/// <inheritdoc />
public required string SubjectNodeId { get; init; }
/// <inheritdoc />
public required EvidenceType EvidenceType { get; init; }
/// <inheritdoc />
public required string EvidenceId { get; init; }
/// <inheritdoc />
public required ReadOnlyMemory<byte> Payload { get; init; }
/// <inheritdoc />
public IReadOnlyList<EvidenceSignature> Signatures { get; init; } = [];
/// <inheritdoc />
public required EvidenceProvenance Provenance { get; init; }
/// <inheritdoc />
public string? ExternalPayloadCid { get; init; }
/// <inheritdoc />
public required string PayloadSchemaVersion { get; init; }
/// <summary>
/// Computes EvidenceId from record contents using versioned canonicalization.
/// The hash input includes SubjectNodeId, EvidenceType, Payload (Base64), and Provenance
/// to ensure unique, deterministic identifiers.
/// </summary>
/// <param name="subjectNodeId">Content-addressed subject identifier.</param>
/// <param name="evidenceType">Type of evidence.</param>
/// <param name="payload">Canonical JSON payload bytes.</param>
/// <param name="provenance">Generation provenance.</param>
/// <returns>Content-addressed evidence ID in format "sha256:{hex}".</returns>
public static string ComputeEvidenceId(
string subjectNodeId,
EvidenceType evidenceType,
ReadOnlySpan<byte> payload,
EvidenceProvenance provenance)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
ArgumentNullException.ThrowIfNull(provenance);
var hashInput = new EvidenceHashInput(
SubjectNodeId: subjectNodeId,
EvidenceType: evidenceType.ToString(),
PayloadBase64: Convert.ToBase64String(payload),
GeneratorId: provenance.GeneratorId,
GeneratorVersion: provenance.GeneratorVersion,
GeneratedAt: provenance.GeneratedAt.ToUniversalTime().ToString("O"));
return CanonJson.HashVersionedPrefixed(hashInput, CanonVersion.Current);
}
/// <summary>
/// Creates an EvidenceRecord with auto-computed EvidenceId.
/// </summary>
/// <param name="subjectNodeId">Content-addressed subject identifier.</param>
/// <param name="evidenceType">Type of evidence.</param>
/// <param name="payload">Canonical JSON payload bytes.</param>
/// <param name="provenance">Generation provenance.</param>
/// <param name="payloadSchemaVersion">Schema version for the payload.</param>
/// <param name="signatures">Optional signatures.</param>
/// <param name="externalPayloadCid">Optional CID for external storage.</param>
/// <returns>A new EvidenceRecord with computed EvidenceId.</returns>
public static EvidenceRecord Create(
string subjectNodeId,
EvidenceType evidenceType,
ReadOnlyMemory<byte> payload,
EvidenceProvenance provenance,
string payloadSchemaVersion,
IReadOnlyList<EvidenceSignature>? signatures = null,
string? externalPayloadCid = null)
{
var evidenceId = ComputeEvidenceId(subjectNodeId, evidenceType, payload.Span, provenance);
return new EvidenceRecord
{
SubjectNodeId = subjectNodeId,
EvidenceType = evidenceType,
EvidenceId = evidenceId,
Payload = payload,
Provenance = provenance,
PayloadSchemaVersion = payloadSchemaVersion,
Signatures = signatures ?? [],
ExternalPayloadCid = externalPayloadCid
};
}
/// <summary>
/// Verifies that the EvidenceId matches the computed hash of the record contents.
/// </summary>
/// <returns>True if the EvidenceId is valid; false if tampered.</returns>
public bool VerifyIntegrity()
{
var computed = ComputeEvidenceId(SubjectNodeId, EvidenceType, Payload.Span, Provenance);
return string.Equals(EvidenceId, computed, StringComparison.Ordinal);
}
}
/// <summary>
/// Internal record for evidence ID hash computation.
/// Fields are sorted alphabetically for deterministic canonicalization.
/// </summary>
internal sealed record EvidenceHashInput(
string GeneratedAt,
string GeneratorId,
string GeneratorVersion,
string EvidenceType,
string PayloadBase64,
string SubjectNodeId);

View File

@@ -0,0 +1,49 @@
namespace StellaOps.Evidence.Core;
/// <summary>
/// Cryptographic signature on evidence.
/// Signatures attest that a signer (human, service, or system) vouches for the evidence.
/// </summary>
public sealed record EvidenceSignature
{
/// <summary>
/// Signer identity (key ID, certificate subject, or service account).
/// </summary>
public required string SignerId { get; init; }
/// <summary>
/// Signature algorithm (e.g., "ES256", "RS256", "EdDSA", "GOST3411-2012").
/// </summary>
public required string Algorithm { get; init; }
/// <summary>
/// Base64-encoded signature bytes.
/// </summary>
public required string SignatureBase64 { get; init; }
/// <summary>
/// Timestamp when signature was created (UTC).
/// </summary>
public required DateTimeOffset SignedAt { get; init; }
/// <summary>
/// Signer type for categorization and filtering.
/// </summary>
public SignerType SignerType { get; init; } = SignerType.Internal;
/// <summary>
/// Optional key certificate chain for verification (PEM or Base64 DER).
/// First element is the signing certificate, followed by intermediates.
/// </summary>
public IReadOnlyList<string>? CertificateChain { get; init; }
/// <summary>
/// Optional transparency log entry ID (e.g., Rekor log index).
/// </summary>
public string? TransparencyLogEntryId { get; init; }
/// <summary>
/// Optional timestamp authority response (RFC 3161 TST, Base64).
/// </summary>
public string? TimestampToken { get; init; }
}

View File

@@ -0,0 +1,92 @@
namespace StellaOps.Evidence.Core;
/// <summary>
/// Known evidence types in StellaOps.
/// Evidence types categorize the kind of proof or observation attached to a subject node.
/// </summary>
public enum EvidenceType
{
/// <summary>
/// Call graph reachability analysis result.
/// Payload: ReachabilityEvidence (paths, confidence, graph digest).
/// </summary>
Reachability = 1,
/// <summary>
/// Vulnerability scan finding.
/// Payload: ScanEvidence (CVE, severity, affected package, advisory source).
/// </summary>
Scan = 2,
/// <summary>
/// Policy evaluation result.
/// Payload: PolicyEvidence (rule ID, verdict, inputs, config version).
/// </summary>
Policy = 3,
/// <summary>
/// Artifact metadata (SBOM entry, layer info, provenance).
/// Payload: ArtifactEvidence (PURL, digest, build info).
/// </summary>
Artifact = 4,
/// <summary>
/// VEX statement (vendor exploitability assessment).
/// Payload: VexEvidence (status, justification, impact, action).
/// </summary>
Vex = 5,
/// <summary>
/// EPSS score snapshot.
/// Payload: EpssEvidence (score, percentile, model date).
/// </summary>
Epss = 6,
/// <summary>
/// Runtime observation (eBPF, dyld, ETW).
/// Payload: RuntimeEvidence (observation type, call frames, timestamp).
/// </summary>
Runtime = 7,
/// <summary>
/// Build provenance (SLSA, reproducibility).
/// Payload: ProvenanceEvidence (build ID, builder, inputs, outputs).
/// </summary>
Provenance = 8,
/// <summary>
/// Exception/waiver applied.
/// Payload: ExceptionEvidence (exception ID, reason, expiry).
/// </summary>
Exception = 9,
/// <summary>
/// Guard/gate analysis (feature flags, auth gates).
/// Payload: GuardEvidence (gate type, condition, bypass confidence).
/// </summary>
Guard = 10,
/// <summary>
/// KEV (Known Exploited Vulnerabilities) status.
/// Payload: KevEvidence (in_kev flag, date_added, due_date).
/// </summary>
Kev = 11,
/// <summary>
/// License compliance evidence.
/// Payload: LicenseEvidence (SPDX ID, obligations, conflicts).
/// </summary>
License = 12,
/// <summary>
/// Dependency relationship evidence.
/// Payload: DependencyEvidence (parent, child, scope, is_dev).
/// </summary>
Dependency = 13,
/// <summary>
/// Unknown or custom evidence type.
/// Payload schema determined by PayloadSchemaVersion.
/// </summary>
Custom = 255
}

View File

@@ -0,0 +1,56 @@
namespace StellaOps.Evidence.Core;
/// <summary>
/// Unified evidence contract for content-addressed proof records.
/// All evidence types in StellaOps implement this interface to enable
/// cross-module evidence linking, verification, and storage.
/// </summary>
public interface IEvidence
{
/// <summary>
/// Content-addressed identifier for the subject this evidence applies to.
/// Format: "sha256:{hex}" or algorithm-prefixed hash.
/// </summary>
string SubjectNodeId { get; }
/// <summary>
/// Type discriminator for the evidence payload.
/// </summary>
EvidenceType EvidenceType { get; }
/// <summary>
/// Content-addressed identifier for this evidence record.
/// Computed from versioned canonicalized (SubjectNodeId, EvidenceType, Payload, Provenance).
/// Format: "sha256:{hex}"
/// </summary>
string EvidenceId { get; }
/// <summary>
/// Type-specific evidence payload as canonical JSON bytes.
/// The payload format is determined by <see cref="PayloadSchemaVersion"/>.
/// </summary>
ReadOnlyMemory<byte> Payload { get; }
/// <summary>
/// Cryptographic signatures attesting to this evidence.
/// May be empty for unsigned evidence.
/// </summary>
IReadOnlyList<EvidenceSignature> Signatures { get; }
/// <summary>
/// Provenance information: who generated, when, how.
/// </summary>
EvidenceProvenance Provenance { get; }
/// <summary>
/// Optional CID (Content Identifier) for large payloads stored externally.
/// When set, <see cref="Payload"/> may be empty or contain a summary.
/// </summary>
string? ExternalPayloadCid { get; }
/// <summary>
/// Schema version for the payload format.
/// Format: "{type}/{version}" (e.g., "reachability/v1", "vex/v2").
/// </summary>
string PayloadSchemaVersion { get; }
}

View File

@@ -0,0 +1,82 @@
namespace StellaOps.Evidence.Core;
/// <summary>
/// Storage and retrieval interface for evidence records.
/// Implementations may be in-memory (testing), PostgreSQL (production), or external stores.
/// </summary>
public interface IEvidenceStore
{
/// <summary>
/// Stores an evidence record.
/// If evidence with the same EvidenceId already exists, the operation is idempotent.
/// </summary>
/// <param name="evidence">The evidence record to store.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The evidence ID (for confirmation or chaining).</returns>
Task<string> StoreAsync(IEvidence evidence, CancellationToken ct = default);
/// <summary>
/// Stores multiple evidence records in a single transaction.
/// </summary>
/// <param name="evidenceRecords">The evidence records to store.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of records stored (excluding duplicates).</returns>
Task<int> StoreBatchAsync(IEnumerable<IEvidence> evidenceRecords, CancellationToken ct = default);
/// <summary>
/// Retrieves evidence by its content-addressed ID.
/// </summary>
/// <param name="evidenceId">The evidence ID (sha256:...).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The evidence record, or null if not found.</returns>
Task<IEvidence?> GetByIdAsync(string evidenceId, CancellationToken ct = default);
/// <summary>
/// Retrieves all evidence for a subject node.
/// </summary>
/// <param name="subjectNodeId">Content-addressed subject identifier.</param>
/// <param name="typeFilter">Optional: filter by evidence type.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of evidence records for the subject.</returns>
Task<IReadOnlyList<IEvidence>> GetBySubjectAsync(
string subjectNodeId,
EvidenceType? typeFilter = null,
CancellationToken ct = default);
/// <summary>
/// Retrieves evidence by type across all subjects.
/// </summary>
/// <param name="evidenceType">The evidence type to filter by.</param>
/// <param name="limit">Maximum number of records to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of evidence records of the specified type.</returns>
Task<IReadOnlyList<IEvidence>> GetByTypeAsync(
EvidenceType evidenceType,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// Checks if evidence exists for a subject.
/// </summary>
/// <param name="subjectNodeId">Content-addressed subject identifier.</param>
/// <param name="type">The evidence type to check for.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if matching evidence exists.</returns>
Task<bool> ExistsAsync(string subjectNodeId, EvidenceType type, CancellationToken ct = default);
/// <summary>
/// Deletes evidence by ID (for expiration/cleanup).
/// </summary>
/// <param name="evidenceId">The evidence ID to delete.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if evidence was deleted; false if not found.</returns>
Task<bool> DeleteAsync(string evidenceId, CancellationToken ct = default);
/// <summary>
/// Gets the count of evidence records for a subject.
/// </summary>
/// <param name="subjectNodeId">Content-addressed subject identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of evidence records for the subject.</returns>
Task<int> CountBySubjectAsync(string subjectNodeId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,167 @@
using System.Collections.Concurrent;
namespace StellaOps.Evidence.Core;
/// <summary>
/// Thread-safe in-memory implementation of <see cref="IEvidenceStore"/>.
/// Intended for testing, development, and ephemeral processing.
/// </summary>
public sealed class InMemoryEvidenceStore : IEvidenceStore
{
private readonly ConcurrentDictionary<string, IEvidence> _byId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentBag<string>> _bySubject = new(StringComparer.Ordinal);
/// <inheritdoc />
public Task<string> StoreAsync(IEvidence evidence, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(evidence);
ct.ThrowIfCancellationRequested();
_byId.TryAdd(evidence.EvidenceId, evidence);
var subjectBag = _bySubject.GetOrAdd(evidence.SubjectNodeId, _ => []);
if (!subjectBag.Contains(evidence.EvidenceId))
{
subjectBag.Add(evidence.EvidenceId);
}
return Task.FromResult(evidence.EvidenceId);
}
/// <inheritdoc />
public Task<int> StoreBatchAsync(IEnumerable<IEvidence> evidenceRecords, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(evidenceRecords);
ct.ThrowIfCancellationRequested();
var count = 0;
foreach (var evidence in evidenceRecords)
{
if (_byId.TryAdd(evidence.EvidenceId, evidence))
{
var subjectBag = _bySubject.GetOrAdd(evidence.SubjectNodeId, _ => []);
subjectBag.Add(evidence.EvidenceId);
count++;
}
}
return Task.FromResult(count);
}
/// <inheritdoc />
public Task<IEvidence?> GetByIdAsync(string evidenceId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId);
ct.ThrowIfCancellationRequested();
_byId.TryGetValue(evidenceId, out var evidence);
return Task.FromResult(evidence);
}
/// <inheritdoc />
public Task<IReadOnlyList<IEvidence>> GetBySubjectAsync(
string subjectNodeId,
EvidenceType? typeFilter = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
ct.ThrowIfCancellationRequested();
if (!_bySubject.TryGetValue(subjectNodeId, out var evidenceIds))
{
return Task.FromResult<IReadOnlyList<IEvidence>>([]);
}
var results = evidenceIds
.Distinct()
.Select(id => _byId.TryGetValue(id, out var e) ? e : null)
.Where(e => e is not null)
.Where(e => typeFilter is null || e!.EvidenceType == typeFilter)
.Cast<IEvidence>()
.ToList();
return Task.FromResult<IReadOnlyList<IEvidence>>(results);
}
/// <inheritdoc />
public Task<IReadOnlyList<IEvidence>> GetByTypeAsync(
EvidenceType evidenceType,
int limit = 100,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
var results = _byId.Values
.Where(e => e.EvidenceType == evidenceType)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<IEvidence>>(results);
}
/// <inheritdoc />
public Task<bool> ExistsAsync(string subjectNodeId, EvidenceType type, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
ct.ThrowIfCancellationRequested();
if (!_bySubject.TryGetValue(subjectNodeId, out var evidenceIds))
{
return Task.FromResult(false);
}
var exists = evidenceIds
.Distinct()
.Any(id => _byId.TryGetValue(id, out var e) && e.EvidenceType == type);
return Task.FromResult(exists);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string evidenceId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId);
ct.ThrowIfCancellationRequested();
if (!_byId.TryRemove(evidenceId, out var evidence))
{
return Task.FromResult(false);
}
// Note: We don't remove from _bySubject index (ConcurrentBag doesn't support removal).
// The GetBySubject method filters out null entries.
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<int> CountBySubjectAsync(string subjectNodeId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
ct.ThrowIfCancellationRequested();
if (!_bySubject.TryGetValue(subjectNodeId, out var evidenceIds))
{
return Task.FromResult(0);
}
var count = evidenceIds
.Distinct()
.Count(id => _byId.ContainsKey(id));
return Task.FromResult(count);
}
/// <summary>
/// Clears all stored evidence. For testing only.
/// </summary>
public void Clear()
{
_byId.Clear();
_bySubject.Clear();
}
/// <summary>
/// Gets the total number of evidence records stored.
/// </summary>
public int Count => _byId.Count;
}

View File

@@ -0,0 +1,183 @@
# StellaOps.Evidence.Core
Unified evidence model library providing content-addressed, cryptographically verifiable evidence records for the StellaOps platform.
## Overview
This library defines the core evidence model that unifies all evidence types across StellaOps modules. Evidence records are:
- **Content-addressed**: Each record has a deterministic ID derived from its content
- **Cryptographically verifiable**: Records can carry signatures from their producers
- **Linked**: Records reference their sources (subjects) and can form chains
- **Typed**: Each record has a well-defined type for semantic clarity
## Key Types
### IEvidence
The core evidence interface that all evidence records implement:
```csharp
public interface IEvidence
{
string EvidenceId { get; } // Content-addressed ID
EvidenceType Type { get; } // Evidence type enum
string SubjectNodeId { get; } // What this evidence is about
DateTimeOffset CreatedAt { get; } // UTC timestamp
IReadOnlyList<EvidenceSignature> Signatures { get; } // Cryptographic signatures
EvidenceProvenance? Provenance { get; } // Origin information
IReadOnlyDictionary<string, string> Properties { get; } // Type-specific data
}
```
### EvidenceType
Enumeration of all supported evidence types:
| Type | Description |
|------|-------------|
| `Unknown` | Unspecified evidence type |
| `Sbom` | Software Bill of Materials |
| `Vulnerability` | Vulnerability finding |
| `Vex` | VEX statement (exploitability) |
| `Attestation` | DSSE/in-toto attestation |
| `PolicyDecision` | Policy evaluation result |
| `ScanResult` | Scanner output |
| `Provenance` | SLSA provenance |
| `Signature` | Cryptographic signature |
| `ProofSegment` | Proof chain segment |
| `Exception` | Policy exception/waiver |
| `Advisory` | Security advisory |
| `CveMatch` | CVE to component match |
| `ReachabilityResult` | Code reachability analysis |
### EvidenceRecord
The standard implementation of `IEvidence`:
```csharp
public sealed record EvidenceRecord : IEvidence
{
public required string EvidenceId { get; init; }
public required EvidenceType Type { get; init; }
public required string SubjectNodeId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public IReadOnlyList<EvidenceSignature> Signatures { get; init; } = [];
public EvidenceProvenance? Provenance { get; init; }
public IReadOnlyDictionary<string, string> Properties { get; init; } =
new Dictionary<string, string>();
}
```
## Adapters
The library provides adapters to convert module-specific types to unified evidence records:
| Adapter | Source Module | Source Type |
|---------|--------------|-------------|
| `EvidenceStatementAdapter` | Attestor | `EvidenceStatement` |
| `ProofSegmentAdapter` | Scanner | `ProofSegment` |
| `VexObservationAdapter` | Excititor | `VexObservation` |
| `ExceptionApplicationAdapter` | Policy | `ExceptionApplication` |
### Using Adapters
```csharp
// Convert a VEX observation to evidence records
var adapter = new VexObservationAdapter();
var input = new VexObservationInput
{
SubjectDigest = imageDigest,
Upstream = new VexObservationUpstreamInput { ... },
Statements = new[] { ... }
};
var records = adapter.ToEvidence(input);
```
## Storage
### IEvidenceStore
Interface for evidence persistence:
```csharp
public interface IEvidenceStore
{
Task<IEvidence?> GetAsync(string evidenceId, CancellationToken ct = default);
Task<IReadOnlyList<IEvidence>> GetBySubjectAsync(string subjectNodeId, CancellationToken ct = default);
Task<IReadOnlyList<IEvidence>> GetByTypeAsync(EvidenceType type, CancellationToken ct = default);
Task StoreAsync(IEvidence evidence, CancellationToken ct = default);
Task<bool> ExistsAsync(string evidenceId, CancellationToken ct = default);
}
```
### InMemoryEvidenceStore
Thread-safe in-memory implementation for testing and caching:
```csharp
var store = new InMemoryEvidenceStore();
await store.StoreAsync(evidenceRecord);
var retrieved = await store.GetAsync(evidenceRecord.EvidenceId);
```
## Usage Examples
### Creating Evidence Records
```csharp
var evidence = new EvidenceRecord
{
EvidenceId = "sha256:abc123...",
Type = EvidenceType.Vulnerability,
SubjectNodeId = componentId,
CreatedAt = DateTimeOffset.UtcNow,
Signatures = new[]
{
new EvidenceSignature
{
SignerId = "scanner/grype",
Algorithm = "Ed25519",
SignatureBase64 = "...",
SignedAt = DateTimeOffset.UtcNow,
SignerType = SignerType.Tool
}
},
Properties = new Dictionary<string, string>
{
["cve"] = "CVE-2024-1234",
["severity"] = "HIGH",
["cvss"] = "8.5"
}
};
```
### Querying Evidence
```csharp
var store = serviceProvider.GetRequiredService<IEvidenceStore>();
// Get all evidence for a specific subject
var subjectEvidence = await store.GetBySubjectAsync(componentId);
// Get all VEX statements
var vexRecords = await store.GetByTypeAsync(EvidenceType.Vex);
// Check if evidence exists
var exists = await store.ExistsAsync(evidenceId);
```
## Integration
### Dependency Injection
```csharp
services.AddSingleton<IEvidenceStore, InMemoryEvidenceStore>();
// Or for PostgreSQL:
// services.AddScoped<IEvidenceStore, PostgresEvidenceStore>();
```
## Related Documentation
- [Unified Evidence Model](../../docs/modules/evidence/unified-model.md) - Architecture overview
- [Graph Root Attestation](../../docs/modules/attestor/graph-root-attestation.md) - Evidence in attestations

View File

@@ -0,0 +1,31 @@
namespace StellaOps.Evidence.Core;
/// <summary>
/// Signer type categorization for evidence signatures.
/// </summary>
public enum SignerType
{
/// <summary>Internal StellaOps service.</summary>
Internal = 0,
/// <summary>External vendor/supplier.</summary>
Vendor = 1,
/// <summary>CI/CD pipeline.</summary>
CI = 2,
/// <summary>Human operator.</summary>
Operator = 3,
/// <summary>Third-party attestation service (e.g., Rekor).</summary>
TransparencyLog = 4,
/// <summary>Automated security scanner.</summary>
Scanner = 5,
/// <summary>Policy engine or decision service.</summary>
PolicyEngine = 6,
/// <summary>Unknown or unclassified signer.</summary>
Unknown = 255
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Evidence.Core</RootNamespace>
<Description>Unified evidence model interface and core types for StellaOps content-addressed proof records.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="..\StellaOps.Evidence.Bundle\StellaOps.Evidence.Bundle.csproj" />
</ItemGroup>
</Project>