sprints work
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
122
src/__Libraries/StellaOps.Evidence.Core/EvidenceRecord.cs
Normal file
122
src/__Libraries/StellaOps.Evidence.Core/EvidenceRecord.cs
Normal 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);
|
||||
49
src/__Libraries/StellaOps.Evidence.Core/EvidenceSignature.cs
Normal file
49
src/__Libraries/StellaOps.Evidence.Core/EvidenceSignature.cs
Normal 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; }
|
||||
}
|
||||
92
src/__Libraries/StellaOps.Evidence.Core/EvidenceType.cs
Normal file
92
src/__Libraries/StellaOps.Evidence.Core/EvidenceType.cs
Normal 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
|
||||
}
|
||||
56
src/__Libraries/StellaOps.Evidence.Core/IEvidence.cs
Normal file
56
src/__Libraries/StellaOps.Evidence.Core/IEvidence.cs
Normal 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; }
|
||||
}
|
||||
82
src/__Libraries/StellaOps.Evidence.Core/IEvidenceStore.cs
Normal file
82
src/__Libraries/StellaOps.Evidence.Core/IEvidenceStore.cs
Normal 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);
|
||||
}
|
||||
167
src/__Libraries/StellaOps.Evidence.Core/InMemoryEvidenceStore.cs
Normal file
167
src/__Libraries/StellaOps.Evidence.Core/InMemoryEvidenceStore.cs
Normal 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;
|
||||
}
|
||||
183
src/__Libraries/StellaOps.Evidence.Core/README.md
Normal file
183
src/__Libraries/StellaOps.Evidence.Core/README.md
Normal 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
|
||||
31
src/__Libraries/StellaOps.Evidence.Core/SignerType.cs
Normal file
31
src/__Libraries/StellaOps.Evidence.Core/SignerType.cs
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user