sprints work

This commit is contained in:
master
2026-01-10 11:15:28 +02:00
parent a21d3dbc1f
commit 701eb6b21c
71 changed files with 10854 additions and 136 deletions

View File

@@ -0,0 +1,274 @@
// <copyright file="AiAttestationService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// In-memory implementation of AI attestation service.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-003
/// </summary>
/// <remarks>
/// This implementation stores attestations in memory. For production,
/// use a database-backed implementation with signing integration.
/// </remarks>
public sealed class AiAttestationService : IAiAttestationService
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<AiAttestationService> _logger;
private readonly ConcurrentDictionary<string, StoredAttestation> _runAttestations = new();
private readonly ConcurrentDictionary<string, StoredAttestation> _claimAttestations = new();
public AiAttestationService(
TimeProvider timeProvider,
ILogger<AiAttestationService> logger)
{
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc/>
public Task<AiAttestationResult> CreateRunAttestationAsync(
AiRunAttestation attestation,
bool sign = true,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
var digest = attestation.ComputeDigest();
var json = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiRunAttestation);
// In production, this would call the signer service
string? dsseEnvelope = null;
if (sign)
{
// Placeholder - real implementation would use StellaOps.Signer
dsseEnvelope = CreateMockDsseEnvelope(AiRunAttestation.PredicateType, json);
}
var stored = new StoredAttestation(
attestation.RunId,
AiRunAttestation.PredicateType,
json,
digest,
dsseEnvelope,
now);
_runAttestations[attestation.RunId] = stored;
_logger.LogInformation(
"Created run attestation {RunId} with digest {Digest}, signed={Signed}",
attestation.RunId,
digest,
sign);
return Task.FromResult(new AiAttestationResult
{
AttestationId = attestation.RunId,
Digest = digest,
Signed = sign,
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/run/{attestation.RunId}",
CreatedAt = now
});
}
/// <inheritdoc/>
public Task<AiAttestationResult> CreateClaimAttestationAsync(
AiClaimAttestation attestation,
bool sign = true,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
var digest = attestation.ComputeDigest();
var json = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiClaimAttestation);
string? dsseEnvelope = null;
if (sign)
{
dsseEnvelope = CreateMockDsseEnvelope(AiClaimAttestation.PredicateType, json);
}
var stored = new StoredAttestation(
attestation.ClaimId,
AiClaimAttestation.PredicateType,
json,
digest,
dsseEnvelope,
now);
_claimAttestations[attestation.ClaimId] = stored;
_logger.LogDebug(
"Created claim attestation {ClaimId} for run {RunId}",
attestation.ClaimId,
attestation.RunId);
return Task.FromResult(new AiAttestationResult
{
AttestationId = attestation.ClaimId,
Digest = digest,
Signed = sign,
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/claim/{attestation.ClaimId}",
CreatedAt = now
});
}
/// <inheritdoc/>
public Task<AiAttestationVerificationResult> VerifyRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
if (!_runAttestations.TryGetValue(runId, out var stored))
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
$"Run attestation {runId} not found"));
}
// Verify digest
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiRunAttestation);
if (attestation == null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Failed to deserialize attestation"));
}
var computedDigest = attestation.ComputeDigest();
if (computedDigest != stored.Digest)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
digestValid: false));
}
// In production, verify signature via signer service
bool? signatureValid = stored.DsseEnvelope != null ? true : null;
_logger.LogDebug("Verified run attestation {RunId}", runId);
return Task.FromResult(AiAttestationVerificationResult.Success(
now,
stored.DsseEnvelope != null ? "ai-attestation-key" : null));
}
/// <inheritdoc/>
public Task<AiAttestationVerificationResult> VerifyClaimAttestationAsync(
string claimId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
if (!_claimAttestations.TryGetValue(claimId, out var stored))
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
$"Claim attestation {claimId} not found"));
}
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiClaimAttestation);
if (attestation == null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Failed to deserialize attestation"));
}
var computedDigest = attestation.ComputeDigest();
if (computedDigest != stored.Digest)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
digestValid: false));
}
return Task.FromResult(AiAttestationVerificationResult.Success(
now,
stored.DsseEnvelope != null ? "ai-attestation-key" : null));
}
/// <inheritdoc/>
public Task<AiRunAttestation?> GetRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
if (!_runAttestations.TryGetValue(runId, out var stored))
{
return Task.FromResult<AiRunAttestation?>(null);
}
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiRunAttestation);
return Task.FromResult(attestation);
}
/// <inheritdoc/>
public Task<IReadOnlyList<AiClaimAttestation>> GetClaimAttestationsAsync(
string runId,
CancellationToken ct = default)
{
var claims = _claimAttestations.Values
.Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiClaimAttestation))
.Where(c => c != null && c.RunId == runId)
.Cast<AiClaimAttestation>()
.ToList();
return Task.FromResult<IReadOnlyList<AiClaimAttestation>>(claims);
}
/// <inheritdoc/>
public Task<IReadOnlyList<AiRunAttestation>> ListRecentAttestationsAsync(
string tenantId,
int limit = 100,
CancellationToken ct = default)
{
var attestations = _runAttestations.Values
.OrderByDescending(s => s.CreatedAt)
.Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiRunAttestation))
.Where(a => a != null && a.TenantId == tenantId)
.Cast<AiRunAttestation>()
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<AiRunAttestation>>(attestations);
}
private static string CreateMockDsseEnvelope(string predicateType, string payload)
{
// Mock DSSE envelope - real implementation would use StellaOps.Signer
var payloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload));
return $$"""
{
"payloadType": "{{predicateType}}",
"payload": "{{payloadBase64}}",
"signatures": [{"sig": "mock-signature"}]
}
""";
}
private sealed record StoredAttestation(
string Id,
string PredicateType,
string Json,
string Digest,
string? DsseEnvelope,
DateTimeOffset CreatedAt);
}

View File

@@ -0,0 +1,58 @@
// <copyright file="AiAttestationServiceExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.AdvisoryAI.Attestation.Storage;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// Extension methods for registering AI attestation services.
/// Sprint: SPRINT_20260109_011_001
/// </summary>
public static class AiAttestationServiceExtensions
{
/// <summary>
/// Adds AI attestation services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAiAttestationServices(this IServiceCollection services)
{
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<IPromptTemplateRegistry, PromptTemplateRegistry>();
services.TryAddSingleton<IAiAttestationService, AiAttestationService>();
return services;
}
/// <summary>
/// Adds AI attestation services with a custom time provider.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="timeProvider">The time provider to use.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAiAttestationServices(
this IServiceCollection services,
TimeProvider timeProvider)
{
services.AddSingleton(timeProvider);
services.TryAddSingleton<IPromptTemplateRegistry, PromptTemplateRegistry>();
services.TryAddSingleton<IAiAttestationService, AiAttestationService>();
return services;
}
/// <summary>
/// Adds in-memory attestation storage. Useful for testing and development.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddInMemoryAiAttestationStore(this IServiceCollection services)
{
services.TryAddSingleton<IAiAttestationStore, InMemoryAiAttestationStore>();
return services;
}
}

View File

@@ -0,0 +1,173 @@
// <copyright file="IAiAttestationService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// Service for creating and verifying AI attestations.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-002
/// </summary>
public interface IAiAttestationService
{
/// <summary>
/// Creates an attestation for an AI run.
/// </summary>
/// <param name="attestation">The attestation to create.</param>
/// <param name="sign">Whether to sign the attestation.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created attestation with optional signature.</returns>
Task<AiAttestationResult> CreateRunAttestationAsync(
AiRunAttestation attestation,
bool sign = true,
CancellationToken ct = default);
/// <summary>
/// Creates an attestation for a specific claim.
/// </summary>
/// <param name="attestation">The claim attestation to create.</param>
/// <param name="sign">Whether to sign the attestation.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created attestation with optional signature.</returns>
Task<AiAttestationResult> CreateClaimAttestationAsync(
AiClaimAttestation attestation,
bool sign = true,
CancellationToken ct = default);
/// <summary>
/// Verifies an AI run attestation.
/// </summary>
/// <param name="runId">The run ID to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<AiAttestationVerificationResult> VerifyRunAttestationAsync(
string runId,
CancellationToken ct = default);
/// <summary>
/// Verifies a claim attestation.
/// </summary>
/// <param name="claimId">The claim ID to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<AiAttestationVerificationResult> VerifyClaimAttestationAsync(
string claimId,
CancellationToken ct = default);
/// <summary>
/// Gets a run attestation by ID.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attestation if found.</returns>
Task<AiRunAttestation?> GetRunAttestationAsync(
string runId,
CancellationToken ct = default);
/// <summary>
/// Gets claim attestations for a run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>All claim attestations for the run.</returns>
Task<IReadOnlyList<AiClaimAttestation>> GetClaimAttestationsAsync(
string runId,
CancellationToken ct = default);
/// <summary>
/// Lists recent run attestations.
/// </summary>
/// <param name="tenantId">Tenant filter.</param>
/// <param name="limit">Maximum results.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Recent attestations.</returns>
Task<IReadOnlyList<AiRunAttestation>> ListRecentAttestationsAsync(
string tenantId,
int limit = 100,
CancellationToken ct = default);
}
/// <summary>
/// Result of creating an attestation.
/// </summary>
public sealed record AiAttestationResult
{
/// <summary>Attestation ID.</summary>
public required string AttestationId { get; init; }
/// <summary>Content digest.</summary>
public required string Digest { get; init; }
/// <summary>Whether the attestation was signed.</summary>
public bool Signed { get; init; }
/// <summary>DSSE envelope if signed.</summary>
public string? DsseEnvelope { get; init; }
/// <summary>Storage URI.</summary>
public string? StorageUri { get; init; }
/// <summary>Creation timestamp.</summary>
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Result of verifying an attestation.
/// </summary>
public sealed record AiAttestationVerificationResult
{
/// <summary>Whether verification succeeded.</summary>
public required bool Valid { get; init; }
/// <summary>Verification timestamp.</summary>
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>Signing key ID if signed.</summary>
public string? SigningKeyId { get; init; }
/// <summary>Key expiration if applicable.</summary>
public DateTimeOffset? KeyExpiresAt { get; init; }
/// <summary>Digest verification result.</summary>
public bool DigestValid { get; init; }
/// <summary>Signature verification result.</summary>
public bool? SignatureValid { get; init; }
/// <summary>Verification failure reason if invalid.</summary>
public string? FailureReason { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static AiAttestationVerificationResult Success(
DateTimeOffset verifiedAt,
string? signingKeyId = null,
DateTimeOffset? keyExpiresAt = null) => new()
{
Valid = true,
VerifiedAt = verifiedAt,
SigningKeyId = signingKeyId,
KeyExpiresAt = keyExpiresAt,
DigestValid = true,
SignatureValid = signingKeyId != null ? true : null
};
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static AiAttestationVerificationResult Failure(
DateTimeOffset verifiedAt,
string reason,
bool digestValid = false,
bool? signatureValid = null) => new()
{
Valid = false,
VerifiedAt = verifiedAt,
DigestValid = digestValid,
SignatureValid = signatureValid,
FailureReason = reason
};
}

View File

@@ -0,0 +1,28 @@
// <copyright file="AiAttestationJsonContext.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// JSON source generation context for AI attestation models.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(AiRunAttestation))]
[JsonSerializable(typeof(AiClaimAttestation))]
[JsonSerializable(typeof(AiTurnSummary))]
[JsonSerializable(typeof(AiModelInfo))]
[JsonSerializable(typeof(AiModelParameters))]
[JsonSerializable(typeof(PromptTemplateInfo))]
[JsonSerializable(typeof(ClaimEvidence))]
[JsonSerializable(typeof(AiRunContext))]
[JsonSerializable(typeof(ToolCallSummary))]
public partial class AiAttestationJsonContext : JsonSerializerContext
{
}

View File

@@ -0,0 +1,139 @@
// <copyright file="AiClaimAttestation.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Attestation for a specific AI claim, providing fine-grained provenance.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
/// <remarks>
/// While AiRunAttestation captures the full run, AiClaimAttestation allows
/// individual claims to be attested separately. This enables:
/// - Granular trust verification
/// - Claim-specific evidence linkage
/// - Selective claim citation in reports
/// </remarks>
public sealed record AiClaimAttestation
{
/// <summary>Attestation type URI.</summary>
public const string PredicateType = "https://stellaops.org/attestation/ai-claim/v1";
/// <summary>Unique claim identifier.</summary>
[JsonPropertyName("claimId")]
public required string ClaimId { get; init; }
/// <summary>Parent run ID.</summary>
[JsonPropertyName("runId")]
public required string RunId { get; init; }
/// <summary>Turn ID where claim was made.</summary>
[JsonPropertyName("turnId")]
public required string TurnId { get; init; }
/// <summary>Tenant identifier.</summary>
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
/// <summary>The claim text.</summary>
[JsonPropertyName("claimText")]
public required string ClaimText { get; init; }
/// <summary>SHA-256 hash of the claim text.</summary>
[JsonPropertyName("claimDigest")]
public required string ClaimDigest { get; init; }
/// <summary>Claim category.</summary>
[JsonPropertyName("category")]
public ClaimCategory Category { get; init; } = ClaimCategory.Factual;
/// <summary>Evidence URIs grounding this claim.</summary>
[JsonPropertyName("groundedBy")]
public ImmutableArray<string> GroundedBy { get; init; } = [];
/// <summary>Grounding confidence score.</summary>
[JsonPropertyName("groundingScore")]
public double GroundingScore { get; init; }
/// <summary>Whether the claim was verified.</summary>
[JsonPropertyName("verified")]
public bool Verified { get; init; }
/// <summary>Verification method used.</summary>
[JsonPropertyName("verificationMethod")]
public string? VerificationMethod { get; init; }
/// <summary>Claim timestamp.</summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Context information.</summary>
[JsonPropertyName("context")]
public AiRunContext? Context { get; init; }
/// <summary>Content digest for this attestation.</summary>
[JsonPropertyName("contentDigest")]
public required string ContentDigest { get; init; }
/// <summary>Claim type category.</summary>
[JsonPropertyName("claimType")]
public string? ClaimType { get; init; }
/// <summary>
/// Computes the content digest for this attestation.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, AiAttestationJsonContext.Default.AiClaimAttestation);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Creates a claim attestation from a claim evidence.
/// </summary>
public static AiClaimAttestation FromClaimEvidence(
ClaimEvidence evidence,
string runId,
string turnId,
string tenantId,
DateTimeOffset timestamp,
AiRunContext? context = null)
{
var claimDigest = ComputeClaimDigest(evidence.Text);
var claimId = $"claim-{Guid.NewGuid():N}";
var attestation = new AiClaimAttestation
{
ClaimId = claimId,
RunId = runId,
TurnId = turnId,
TenantId = tenantId,
ClaimText = evidence.Text,
ClaimDigest = claimDigest,
Category = evidence.Category,
GroundedBy = evidence.GroundedBy,
GroundingScore = evidence.GroundingScore,
Verified = evidence.Verified,
Timestamp = timestamp,
Context = context,
ContentDigest = "" // Placeholder, computed below
};
// Now compute the actual content digest
return attestation with { ContentDigest = attestation.ComputeDigest() };
}
private static string ComputeClaimDigest(string claimText)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(claimText));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,52 @@
// <copyright file="AiModelInfo.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Information about the AI model used in a run.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
public sealed record AiModelInfo
{
/// <summary>Model provider (e.g., "anthropic", "openai", "local").</summary>
[JsonPropertyName("provider")]
public required string Provider { get; init; }
/// <summary>Model identifier (e.g., "claude-3-sonnet", "gpt-4o").</summary>
[JsonPropertyName("modelId")]
public required string ModelId { get; init; }
/// <summary>Model version or digest for reproducibility.</summary>
[JsonPropertyName("digest")]
public string? Digest { get; init; }
/// <summary>Model parameters used (temperature, etc.).</summary>
[JsonPropertyName("parameters")]
public AiModelParameters? Parameters { get; init; }
}
/// <summary>
/// Model inference parameters.
/// </summary>
public sealed record AiModelParameters
{
/// <summary>Sampling temperature.</summary>
[JsonPropertyName("temperature")]
public double? Temperature { get; init; }
/// <summary>Top-p nucleus sampling.</summary>
[JsonPropertyName("topP")]
public double? TopP { get; init; }
/// <summary>Maximum tokens to generate.</summary>
[JsonPropertyName("maxTokens")]
public int? MaxTokens { get; init; }
/// <summary>Random seed for reproducibility.</summary>
[JsonPropertyName("seed")]
public long? Seed { get; init; }
}

View File

@@ -0,0 +1,118 @@
// <copyright file="AiRunAttestation.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Attestation for an AI run, containing signed proof of AI-generated content.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
/// <remarks>
/// This attestation captures everything needed to verify and reproduce an AI run:
/// - Who ran it (tenant, user)
/// - What model was used
/// - What prompt template was used
/// - What context was provided
/// - What was said (content digests)
/// - What claims were made and their grounding evidence
/// </remarks>
public sealed record AiRunAttestation
{
/// <summary>Attestation type URI.</summary>
public const string PredicateType = "https://stellaops.org/attestation/ai-run/v1";
/// <summary>Unique run identifier.</summary>
[JsonPropertyName("runId")]
public required string RunId { get; init; }
/// <summary>Tenant identifier.</summary>
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
/// <summary>User identifier.</summary>
[JsonPropertyName("userId")]
public required string UserId { get; init; }
/// <summary>Conversation ID (for multi-run conversations).</summary>
[JsonPropertyName("conversationId")]
public string? ConversationId { get; init; }
/// <summary>Run start timestamp.</summary>
[JsonPropertyName("startedAt")]
public required DateTimeOffset StartedAt { get; init; }
/// <summary>Run completion timestamp.</summary>
[JsonPropertyName("completedAt")]
public required DateTimeOffset CompletedAt { get; init; }
/// <summary>Model information.</summary>
[JsonPropertyName("model")]
public required AiModelInfo Model { get; init; }
/// <summary>Prompt template information.</summary>
[JsonPropertyName("promptTemplate")]
public PromptTemplateInfo? PromptTemplate { get; init; }
/// <summary>Context information.</summary>
[JsonPropertyName("context")]
public AiRunContext? Context { get; init; }
/// <summary>Turn summaries.</summary>
[JsonPropertyName("turns")]
public ImmutableArray<AiTurnSummary> Turns { get; init; } = [];
/// <summary>Overall grounding score (0.0 to 1.0).</summary>
[JsonPropertyName("overallGroundingScore")]
public double OverallGroundingScore { get; init; }
/// <summary>Total tokens used.</summary>
[JsonPropertyName("totalTokens")]
public int TotalTokens { get; init; }
/// <summary>Run status.</summary>
[JsonPropertyName("status")]
public AiRunStatus Status { get; init; } = AiRunStatus.Completed;
/// <summary>Error message if failed.</summary>
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
/// <summary>
/// Computes the content digest for this attestation.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, AiAttestationJsonContext.Default.AiRunAttestation);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// AI run status.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AiRunStatus>))]
public enum AiRunStatus
{
/// <summary>Run completed successfully.</summary>
Completed,
/// <summary>Run failed.</summary>
Failed,
/// <summary>Run was cancelled.</summary>
Cancelled,
/// <summary>Run timed out.</summary>
TimedOut,
/// <summary>Run was blocked by guardrails.</summary>
Blocked
}

View File

@@ -0,0 +1,48 @@
// <copyright file="AiRunContext.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Context information for an AI run.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
public sealed record AiRunContext
{
/// <summary>Finding ID if analyzing a finding.</summary>
[JsonPropertyName("findingId")]
public string? FindingId { get; init; }
/// <summary>CVE ID if relevant.</summary>
[JsonPropertyName("cveId")]
public string? CveId { get; init; }
/// <summary>Component PURL if relevant.</summary>
[JsonPropertyName("component")]
public string? Component { get; init; }
/// <summary>Image digest if analyzing an image.</summary>
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
/// <summary>SBOM ID if referenced.</summary>
[JsonPropertyName("sbomId")]
public string? SbomId { get; init; }
/// <summary>Policy ID if relevant.</summary>
[JsonPropertyName("policyId")]
public string? PolicyId { get; init; }
/// <summary>Additional context key-value pairs.</summary>
[JsonPropertyName("metadata")]
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>Evidence URIs used as grounding context.</summary>
[JsonPropertyName("evidenceUris")]
public ImmutableArray<string> EvidenceUris { get; init; } = [];
}

View File

@@ -0,0 +1,92 @@
// <copyright file="AiTurnSummary.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Summary of a single turn in an AI conversation.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
public sealed record AiTurnSummary
{
/// <summary>Unique turn identifier.</summary>
[JsonPropertyName("turnId")]
public required string TurnId { get; init; }
/// <summary>Turn role (user, assistant, system).</summary>
[JsonPropertyName("role")]
public required TurnRole Role { get; init; }
/// <summary>SHA-256 hash of the turn content.</summary>
[JsonPropertyName("contentDigest")]
public required string ContentDigest { get; init; }
/// <summary>Turn timestamp.</summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Token count for this turn.</summary>
[JsonPropertyName("tokenCount")]
public int TokenCount { get; init; }
/// <summary>Claims made in this turn (for assistant turns).</summary>
[JsonPropertyName("claims")]
public ImmutableArray<ClaimEvidence> Claims { get; init; } = [];
/// <summary>Overall grounding score for assistant turns.</summary>
[JsonPropertyName("groundingScore")]
public double? GroundingScore { get; init; }
/// <summary>Tool calls made in this turn.</summary>
[JsonPropertyName("toolCalls")]
public ImmutableArray<ToolCallSummary> ToolCalls { get; init; } = [];
}
/// <summary>
/// Turn role in conversation.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<TurnRole>))]
public enum TurnRole
{
/// <summary>User message.</summary>
User,
/// <summary>Assistant response.</summary>
Assistant,
/// <summary>System prompt.</summary>
System,
/// <summary>Tool response.</summary>
Tool
}
/// <summary>
/// Summary of a tool call.
/// </summary>
public sealed record ToolCallSummary
{
/// <summary>Tool name.</summary>
[JsonPropertyName("toolName")]
public required string ToolName { get; init; }
/// <summary>Hash of input arguments.</summary>
[JsonPropertyName("inputDigest")]
public required string InputDigest { get; init; }
/// <summary>Hash of output.</summary>
[JsonPropertyName("outputDigest")]
public required string OutputDigest { get; init; }
/// <summary>Tool execution duration.</summary>
[JsonPropertyName("durationMs")]
public long DurationMs { get; init; }
/// <summary>Whether the tool call succeeded.</summary>
[JsonPropertyName("success")]
public bool Success { get; init; } = true;
}

View File

@@ -0,0 +1,68 @@
// <copyright file="ClaimEvidence.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Evidence grounding an AI claim.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
public sealed record ClaimEvidence
{
/// <summary>The claim text.</summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
/// <summary>Character position in the response.</summary>
[JsonPropertyName("position")]
public required int Position { get; init; }
/// <summary>Length of the claim text.</summary>
[JsonPropertyName("length")]
public required int Length { get; init; }
/// <summary>Evidence URIs grounding this claim (stella:// URIs).</summary>
[JsonPropertyName("groundedBy")]
public ImmutableArray<string> GroundedBy { get; init; } = [];
/// <summary>Grounding confidence score (0.0 to 1.0).</summary>
[JsonPropertyName("groundingScore")]
public double GroundingScore { get; init; }
/// <summary>Whether this claim was verified against evidence.</summary>
[JsonPropertyName("verified")]
public bool Verified { get; init; }
/// <summary>Claim category (factual, recommendation, caveat, etc.).</summary>
[JsonPropertyName("category")]
public ClaimCategory Category { get; init; } = ClaimCategory.Factual;
}
/// <summary>
/// Categories of AI claims.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ClaimCategory>))]
public enum ClaimCategory
{
/// <summary>Factual statement about the subject.</summary>
Factual,
/// <summary>Recommendation or suggested action.</summary>
Recommendation,
/// <summary>Caveat or limitation.</summary>
Caveat,
/// <summary>Explanation or reasoning.</summary>
Explanation,
/// <summary>Reference to documentation or resources.</summary>
Reference,
/// <summary>Unknown or unclassified claim.</summary>
Unknown
}

View File

@@ -0,0 +1,30 @@
// <copyright file="PromptTemplateInfo.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Information about the prompt template used in a run.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
public sealed record PromptTemplateInfo
{
/// <summary>Template name.</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Template version.</summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>Content hash for verification.</summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>Template parameters used.</summary>
[JsonPropertyName("parameters")]
public IReadOnlyDictionary<string, string>? Parameters { get; init; }
}

View File

@@ -0,0 +1,150 @@
// <copyright file="PromptTemplateRegistry.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// Interface for prompt template registry.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-004
/// </summary>
public interface IPromptTemplateRegistry
{
/// <summary>
/// Registers a prompt template with version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="version">Template version.</param>
/// <param name="template">Template content.</param>
void Register(string name, string version, string template);
/// <summary>
/// Gets template info including hash.
/// </summary>
/// <param name="name">Template name.</param>
/// <returns>Template info or null if not found.</returns>
PromptTemplateInfo? GetTemplateInfo(string name);
/// <summary>
/// Gets template info for a specific version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="version">Template version.</param>
/// <returns>Template info or null if not found.</returns>
PromptTemplateInfo? GetTemplateInfo(string name, string version);
/// <summary>
/// Verifies a template hash matches registered version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="expectedHash">Expected hash.</param>
/// <returns>True if hash matches.</returns>
bool VerifyHash(string name, string expectedHash);
/// <summary>
/// Gets all registered templates.
/// </summary>
/// <returns>All template info records.</returns>
IReadOnlyList<PromptTemplateInfo> GetAllTemplates();
}
/// <summary>
/// In-memory implementation of prompt template registry.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-004
/// </summary>
public sealed class PromptTemplateRegistry : IPromptTemplateRegistry
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<PromptTemplateRegistry> _logger;
private readonly ConcurrentDictionary<string, PromptTemplateInfo> _latestVersions = new();
private readonly ConcurrentDictionary<(string Name, string Version), PromptTemplateInfo> _allVersions = new();
public PromptTemplateRegistry(
TimeProvider timeProvider,
ILogger<PromptTemplateRegistry> logger)
{
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc/>
public void Register(string name, string version, string template)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
ArgumentException.ThrowIfNullOrWhiteSpace(template);
var digest = ComputeDigest(template);
var now = _timeProvider.GetUtcNow();
var info = new PromptTemplateInfo
{
Name = name,
Version = version,
Digest = digest
};
_allVersions[(name, version)] = info;
_latestVersions[name] = info;
_logger.LogInformation(
"Registered prompt template {Name} v{Version} with digest {Digest}",
name,
version,
digest);
}
/// <inheritdoc/>
public PromptTemplateInfo? GetTemplateInfo(string name)
{
return _latestVersions.TryGetValue(name, out var info) ? info : null;
}
/// <inheritdoc/>
public PromptTemplateInfo? GetTemplateInfo(string name, string version)
{
return _allVersions.TryGetValue((name, version), out var info) ? info : null;
}
/// <inheritdoc/>
public bool VerifyHash(string name, string expectedHash)
{
if (!_latestVersions.TryGetValue(name, out var info))
{
_logger.LogWarning("Template {Name} not found for hash verification", name);
return false;
}
var matches = string.Equals(info.Digest, expectedHash, StringComparison.OrdinalIgnoreCase);
if (!matches)
{
_logger.LogWarning(
"Hash mismatch for template {Name}: expected {Expected}, got {Actual}",
name,
expectedHash,
info.Digest);
}
return matches;
}
/// <inheritdoc/>
public IReadOnlyList<PromptTemplateInfo> GetAllTemplates()
{
return [.. _latestVersions.Values];
}
private static string ComputeDigest(string content)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.AdvisoryAI.Attestation</RootNamespace>
<Description>AI attestation models and services for StellaOps Advisory AI</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.AdvisoryAI.Attestation.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,103 @@
// <copyright file="IAiAttestationStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation.Storage;
/// <summary>
/// Interface for storing and retrieving AI attestations.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-006
/// </summary>
public interface IAiAttestationStore
{
/// <summary>
/// Store a run attestation.
/// </summary>
/// <param name="attestation">The attestation to store.</param>
/// <param name="ct">Cancellation token.</param>
Task StoreRunAttestationAsync(AiRunAttestation attestation, CancellationToken ct);
/// <summary>
/// Store a signed attestation envelope.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="envelope">The signed DSSE envelope.</param>
/// <param name="ct">Cancellation token.</param>
Task StoreSignedEnvelopeAsync(string runId, object envelope, CancellationToken ct);
/// <summary>
/// Store a claim attestation.
/// </summary>
/// <param name="attestation">The attestation to store.</param>
/// <param name="ct">Cancellation token.</param>
Task StoreClaimAttestationAsync(AiClaimAttestation attestation, CancellationToken ct);
/// <summary>
/// Get a run attestation by run ID.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attestation or null if not found.</returns>
Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct);
/// <summary>
/// Get all claim attestations for a run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claim attestations.</returns>
Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsAsync(string runId, CancellationToken ct);
/// <summary>
/// Get claim attestations for a specific turn.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="turnId">The turn ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claim attestations for the turn.</returns>
Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsByTurnAsync(
string runId,
string turnId,
CancellationToken ct);
/// <summary>
/// Get the signed envelope for a run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The signed envelope or null if not found.</returns>
Task<object?> GetSignedEnvelopeAsync(string runId, CancellationToken ct);
/// <summary>
/// Check if a run attestation exists.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if the attestation exists.</returns>
Task<bool> ExistsAsync(string runId, CancellationToken ct);
/// <summary>
/// Get attestations by tenant within a time range.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="from">Start time.</param>
/// <param name="to">End time.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of run attestations.</returns>
Task<ImmutableArray<AiRunAttestation>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct);
/// <summary>
/// Get attestation by content digest.
/// </summary>
/// <param name="contentDigest">The content digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The claim attestation or null if not found.</returns>
Task<AiClaimAttestation?> GetByContentDigestAsync(string contentDigest, CancellationToken ct);
}

View File

@@ -0,0 +1,166 @@
// <copyright file="InMemoryAiAttestationStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation.Storage;
/// <summary>
/// In-memory implementation of AI attestation store.
/// Useful for testing and development.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-006
/// </summary>
public sealed class InMemoryAiAttestationStore : IAiAttestationStore
{
private readonly ConcurrentDictionary<string, AiRunAttestation> _runAttestations = new();
private readonly ConcurrentDictionary<string, object> _signedEnvelopes = new();
private readonly ConcurrentDictionary<string, List<AiClaimAttestation>> _claimAttestations = new();
private readonly ConcurrentDictionary<string, AiClaimAttestation> _digestIndex = new();
private readonly ILogger<InMemoryAiAttestationStore> _logger;
public InMemoryAiAttestationStore(ILogger<InMemoryAiAttestationStore> logger)
{
_logger = logger;
}
/// <inheritdoc/>
public Task StoreRunAttestationAsync(AiRunAttestation attestation, CancellationToken ct)
{
_runAttestations[attestation.RunId] = attestation;
_logger.LogDebug("Stored run attestation for RunId {RunId}", attestation.RunId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoreSignedEnvelopeAsync(string runId, object envelope, CancellationToken ct)
{
_signedEnvelopes[runId] = envelope;
_logger.LogDebug("Stored signed envelope for RunId {RunId}", runId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoreClaimAttestationAsync(AiClaimAttestation attestation, CancellationToken ct)
{
var claims = _claimAttestations.GetOrAdd(attestation.RunId, _ => []);
lock (claims)
{
claims.Add(attestation);
}
_digestIndex[attestation.ContentDigest] = attestation;
_logger.LogDebug(
"Stored claim attestation for RunId {RunId}, TurnId {TurnId}",
attestation.RunId,
attestation.TurnId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct)
{
_runAttestations.TryGetValue(runId, out var attestation);
return Task.FromResult(attestation);
}
/// <inheritdoc/>
public Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsAsync(string runId, CancellationToken ct)
{
if (_claimAttestations.TryGetValue(runId, out var claims))
{
lock (claims)
{
return Task.FromResult(claims.ToImmutableArray());
}
}
return Task.FromResult(ImmutableArray<AiClaimAttestation>.Empty);
}
/// <inheritdoc/>
public Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsByTurnAsync(
string runId,
string turnId,
CancellationToken ct)
{
if (_claimAttestations.TryGetValue(runId, out var claims))
{
lock (claims)
{
var filtered = claims
.Where(c => c.TurnId == turnId)
.ToImmutableArray();
return Task.FromResult(filtered);
}
}
return Task.FromResult(ImmutableArray<AiClaimAttestation>.Empty);
}
/// <inheritdoc/>
public Task<object?> GetSignedEnvelopeAsync(string runId, CancellationToken ct)
{
_signedEnvelopes.TryGetValue(runId, out var envelope);
return Task.FromResult(envelope);
}
/// <inheritdoc/>
public Task<bool> ExistsAsync(string runId, CancellationToken ct)
{
return Task.FromResult(_runAttestations.ContainsKey(runId));
}
/// <inheritdoc/>
public Task<ImmutableArray<AiRunAttestation>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct)
{
var results = _runAttestations.Values
.Where(a => a.TenantId == tenantId &&
a.StartedAt >= from &&
a.StartedAt <= to)
.OrderBy(a => a.StartedAt)
.ToImmutableArray();
return Task.FromResult(results);
}
/// <inheritdoc/>
public Task<AiClaimAttestation?> GetByContentDigestAsync(string contentDigest, CancellationToken ct)
{
_digestIndex.TryGetValue(contentDigest, out var attestation);
return Task.FromResult(attestation);
}
/// <summary>
/// Clear all stored attestations. Useful for testing.
/// </summary>
public void Clear()
{
_runAttestations.Clear();
_signedEnvelopes.Clear();
_claimAttestations.Clear();
_digestIndex.Clear();
}
/// <summary>
/// Get count of run attestations. Useful for testing.
/// </summary>
public int RunAttestationCount => _runAttestations.Count;
/// <summary>
/// Get count of all claim attestations. Useful for testing.
/// </summary>
public int ClaimAttestationCount => _claimAttestations.Values.Sum(c =>
{
lock (c) { return c.Count; }
});
}

View File

@@ -0,0 +1,350 @@
// <copyright file="FunctionBoundaryDetector.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using StellaOps.Reachability.Core.Symbols;
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Detects function boundaries in source code from diff context.
/// Sprint: SPRINT_20260109_009_003 Task: Implement FunctionBoundaryDetector
/// </summary>
public sealed partial class FunctionBoundaryDetector
{
// C#/Java/TypeScript patterns
[GeneratedRegex(@"^\s*(?:public|private|protected|internal|static|async|override|virtual|sealed|abstract|\s)*\s*(?:\w+(?:<[^>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*{?")]
private static partial Regex CSharpMethodRegex();
// Python patterns
[GeneratedRegex(@"^\s*(?:async\s+)?def\s+(\w+)\s*\([^)]*\)\s*(?:->.*)?:")]
private static partial Regex PythonFunctionRegex();
// Go patterns
[GeneratedRegex(@"^\s*func\s+(?:\([^)]+\)\s+)?(\w+)\s*\([^)]*\)")]
private static partial Regex GoFunctionRegex();
// Rust patterns
[GeneratedRegex(@"^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*(?:<[^>]+>)?\s*\([^)]*\)")]
private static partial Regex RustFunctionRegex();
// JavaScript/TypeScript patterns
[GeneratedRegex(@"^\s*(?:async\s+)?(?:function\s+)?(\w+)\s*(?:=\s*(?:async\s+)?)?(?:\([^)]*\)\s*(?:=>|{)|:\s*\([^)]*\)\s*(?:=>|{))")]
private static partial Regex JsFunctionRegex();
// Ruby patterns
[GeneratedRegex(@"^\s*def\s+(\w+(?:\?|!)?)")]
private static partial Regex RubyMethodRegex();
// PHP patterns
[GeneratedRegex(@"^\s*(?:public|private|protected|static|\s)*function\s+(\w+)\s*\(")]
private static partial Regex PhpFunctionRegex();
// C/C++ patterns
[GeneratedRegex(@"^\s*(?:\w+(?:\s*[*&])?\s+)+(\w+)\s*\([^)]*\)\s*(?:const)?\s*{?")]
private static partial Regex CFunctionRegex();
// Class patterns for fully-qualified names
[GeneratedRegex(@"^\s*(?:public|private|protected|internal|sealed|abstract|static|\s)*(?:class|struct|interface|enum)\s+(\w+)")]
private static partial Regex ClassDeclarationRegex();
[GeneratedRegex(@"^\s*(?:namespace|package)\s+([\w.]+)")]
private static partial Regex NamespaceRegex();
/// <summary>
/// Detects the function containing a specific line number.
/// </summary>
/// <param name="contextLines">Context lines from the diff.</param>
/// <param name="targetLine">Target line number to find function for.</param>
/// <param name="language">Programming language.</param>
/// <returns>Function boundary if found, null otherwise.</returns>
public FunctionBoundary? DetectFunction(
ImmutableArray<string> contextLines,
int targetLine,
ProgrammingLanguage language)
{
if (contextLines.IsDefaultOrEmpty)
{
return null;
}
var functionRegex = GetFunctionRegex(language);
if (functionRegex is null)
{
return null;
}
// Search backwards from target for function declaration
string? functionName = null;
var functionStartLine = 0;
var namespaceOrPackage = string.Empty;
var className = string.Empty;
// First pass: find namespace/package and class
for (var i = 0; i < contextLines.Length; i++)
{
var line = contextLines[i];
var nsMatch = NamespaceRegex().Match(line);
if (nsMatch.Success)
{
namespaceOrPackage = nsMatch.Groups[1].Value;
}
var classMatch = ClassDeclarationRegex().Match(line);
if (classMatch.Success)
{
className = classMatch.Groups[1].Value;
}
}
// Second pass: find function containing target line
var braceDepth = 0;
var inFunction = false;
for (var i = 0; i < contextLines.Length; i++)
{
var line = contextLines[i];
var lineNumber = i + 1; // 1-based
// Check for function declaration
var funcMatch = functionRegex.Match(line);
if (funcMatch.Success)
{
functionName = funcMatch.Groups[1].Value;
functionStartLine = lineNumber;
inFunction = true;
braceDepth = CountBraces(line);
}
else if (inFunction)
{
braceDepth += CountBraces(line);
// For brace-based languages, end at brace depth 0
if (braceDepth <= 0 && !IsBracelessLanguage(language))
{
if (lineNumber >= targetLine && functionName is not null)
{
return new FunctionBoundary(
BuildFullyQualifiedName(namespaceOrPackage, className, functionName),
functionStartLine,
lineNumber);
}
inFunction = false;
}
}
// Check if we've found the function containing our target line
if (inFunction && lineNumber >= targetLine && functionName is not null)
{
// Estimate end line (use remaining context or a reasonable default)
var endLine = EstimateFunctionEnd(contextLines, i, language);
return new FunctionBoundary(
BuildFullyQualifiedName(namespaceOrPackage, className, functionName),
functionStartLine,
endLine);
}
}
return null;
}
/// <summary>
/// Detects all functions in the given source context.
/// </summary>
public ImmutableArray<FunctionBoundary> DetectAllFunctions(
ImmutableArray<string> contextLines,
ProgrammingLanguage language)
{
if (contextLines.IsDefaultOrEmpty)
{
return [];
}
var functionRegex = GetFunctionRegex(language);
if (functionRegex is null)
{
return [];
}
var functions = new List<FunctionBoundary>();
var namespaceOrPackage = string.Empty;
var className = string.Empty;
for (var i = 0; i < contextLines.Length; i++)
{
var line = contextLines[i];
var nsMatch = NamespaceRegex().Match(line);
if (nsMatch.Success)
{
namespaceOrPackage = nsMatch.Groups[1].Value;
}
var classMatch = ClassDeclarationRegex().Match(line);
if (classMatch.Success)
{
className = classMatch.Groups[1].Value;
}
var funcMatch = functionRegex.Match(line);
if (funcMatch.Success)
{
var functionName = funcMatch.Groups[1].Value;
var startLine = i + 1;
var endLine = EstimateFunctionEnd(contextLines, i, language);
functions.Add(new FunctionBoundary(
BuildFullyQualifiedName(namespaceOrPackage, className, functionName),
startLine,
endLine));
}
}
return [.. functions];
}
private static Regex? GetFunctionRegex(ProgrammingLanguage language)
{
return language switch
{
ProgrammingLanguage.CSharp => CSharpMethodRegex(),
ProgrammingLanguage.Java => CSharpMethodRegex(), // Similar syntax
ProgrammingLanguage.Kotlin => CSharpMethodRegex(), // Similar syntax
ProgrammingLanguage.Python => PythonFunctionRegex(),
ProgrammingLanguage.Go => GoFunctionRegex(),
ProgrammingLanguage.Rust => RustFunctionRegex(),
ProgrammingLanguage.JavaScript => JsFunctionRegex(),
ProgrammingLanguage.TypeScript => JsFunctionRegex(),
ProgrammingLanguage.Ruby => RubyMethodRegex(),
ProgrammingLanguage.Php => PhpFunctionRegex(),
ProgrammingLanguage.C => CFunctionRegex(),
ProgrammingLanguage.Cpp => CFunctionRegex(),
ProgrammingLanguage.Swift => CSharpMethodRegex(), // Similar syntax
ProgrammingLanguage.Scala => CSharpMethodRegex(), // Similar syntax
_ => null
};
}
private static bool IsBracelessLanguage(ProgrammingLanguage language)
{
return language is ProgrammingLanguage.Python or ProgrammingLanguage.Ruby;
}
private static int CountBraces(string line)
{
var count = 0;
var inString = false;
var stringChar = '\0';
for (var i = 0; i < line.Length; i++)
{
var c = line[i];
// Handle strings
if (!inString && (c is '"' or '\''))
{
inString = true;
stringChar = c;
}
else if (inString && c == stringChar && (i == 0 || line[i - 1] != '\\'))
{
inString = false;
}
if (!inString)
{
if (c == '{') count++;
else if (c == '}') count--;
}
}
return count;
}
private static int EstimateFunctionEnd(
ImmutableArray<string> contextLines,
int startIndex,
ProgrammingLanguage language)
{
if (IsBracelessLanguage(language))
{
// For Python/Ruby, estimate based on indentation
var startIndent = GetIndentation(contextLines[startIndex]);
for (var i = startIndex + 1; i < contextLines.Length; i++)
{
var line = contextLines[i].TrimEnd();
if (string.IsNullOrWhiteSpace(line)) continue;
var currentIndent = GetIndentation(contextLines[i]);
if (currentIndent <= startIndent)
{
return i; // End at line with same or less indentation
}
}
}
else
{
// For brace-based languages, track brace depth
var braceDepth = CountBraces(contextLines[startIndex]);
for (var i = startIndex + 1; i < contextLines.Length; i++)
{
braceDepth += CountBraces(contextLines[i]);
if (braceDepth <= 0)
{
return i + 1; // Include the closing brace line
}
}
}
// Default: use remaining context length
return contextLines.Length;
}
private static int GetIndentation(string line)
{
var count = 0;
foreach (var c in line)
{
if (c == ' ') count++;
else if (c == '\t') count += 4; // Assume tab = 4 spaces
else break;
}
return count;
}
private static string BuildFullyQualifiedName(
string namespaceOrPackage,
string className,
string functionName)
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(namespaceOrPackage))
{
parts.Add(namespaceOrPackage);
}
if (!string.IsNullOrEmpty(className))
{
parts.Add(className);
}
parts.Add(functionName);
return string.Join(".", parts);
}
}
/// <summary>
/// Represents the boundary of a function in source code.
/// </summary>
/// <param name="FullyQualifiedName">Fully qualified function name.</param>
/// <param name="StartLine">Start line (1-based).</param>
/// <param name="EndLine">End line (1-based, inclusive).</param>
public readonly record struct FunctionBoundary(
string FullyQualifiedName,
int StartLine,
int EndLine);

View File

@@ -0,0 +1,310 @@
// <copyright file="GitDiffExtractor.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Diagnostics;
using StellaOps.Reachability.Core.Symbols;
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Extracts vulnerable symbols from git diffs.
/// Sprint: SPRINT_20260109_009_003 Task: Implement GitDiffExtractor
/// </summary>
public sealed class GitDiffExtractor : IPatchSymbolExtractor
{
private readonly HttpClient _httpClient;
private readonly UnifiedDiffParser _diffParser;
private readonly FunctionBoundaryDetector _boundaryDetector;
/// <summary>
/// Initializes a new instance of the <see cref="GitDiffExtractor"/> class.
/// </summary>
public GitDiffExtractor(
HttpClient httpClient,
UnifiedDiffParser diffParser,
FunctionBoundaryDetector boundaryDetector)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_diffParser = diffParser ?? throw new ArgumentNullException(nameof(diffParser));
_boundaryDetector = boundaryDetector ?? throw new ArgumentNullException(nameof(boundaryDetector));
}
/// <inheritdoc/>
public async Task<PatchAnalysisResult> ExtractFromCommitUrlAsync(
string commitUrl,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrEmpty(commitUrl);
try
{
// Parse the commit URL to get the raw diff URL
var diffUrl = ConvertToDiffUrl(commitUrl);
if (diffUrl is null)
{
return PatchAnalysisResult.Failed($"Unsupported commit URL format: {commitUrl}");
}
// Fetch the diff content
var diffContent = await _httpClient.GetStringAsync(diffUrl, ct).ConfigureAwait(false);
// Extract the commit SHA from the URL
var commitSha = ExtractCommitSha(commitUrl);
var repositoryUrl = ExtractRepositoryUrl(commitUrl);
// Parse and extract symbols
var result = await ExtractFromDiffAsync(diffContent, ct).ConfigureAwait(false);
// Enrich with URL metadata
return result with
{
CommitSha = commitSha,
RepositoryUrl = repositoryUrl
};
}
catch (HttpRequestException ex)
{
return PatchAnalysisResult.Failed($"Failed to fetch diff from URL: {ex.Message}");
}
catch (Exception ex)
{
return PatchAnalysisResult.Failed($"Error extracting from commit URL: {ex.Message}");
}
}
/// <inheritdoc/>
public Task<PatchAnalysisResult> ExtractFromDiffAsync(
string diffContent,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrEmpty(diffContent);
try
{
// Parse the diff
var parsedDiff = _diffParser.Parse(diffContent);
// Track statistics
var modifiedFiles = new List<string>();
var symbols = new List<VulnerableSymbol>();
var totalLinesAdded = 0;
var totalLinesRemoved = 0;
foreach (var fileDiff in parsedDiff.Files)
{
modifiedFiles.Add(fileDiff.NewPath ?? fileDiff.OldPath ?? "unknown");
totalLinesAdded += fileDiff.Hunks.Sum(h => h.AddedLines.Length);
totalLinesRemoved += fileDiff.Hunks.Sum(h => h.RemovedLines.Length);
// Extract symbols from this file
var fileSymbols = ExtractSymbolsFromFile(fileDiff);
symbols.AddRange(fileSymbols);
}
return Task.FromResult(PatchAnalysisResult.Successful(
symbols,
modifiedFiles,
totalLinesAdded,
totalLinesRemoved));
}
catch (Exception ex)
{
return Task.FromResult(PatchAnalysisResult.Failed($"Error parsing diff: {ex.Message}"));
}
}
/// <inheritdoc/>
public async Task<PatchAnalysisResult> ExtractFromLocalCommitAsync(
string repositoryPath,
string commitSha,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrEmpty(repositoryPath);
ArgumentException.ThrowIfNullOrEmpty(commitSha);
try
{
// Run git show to get the diff
var startInfo = new ProcessStartInfo
{
FileName = "git",
Arguments = $"show --format= -p {commitSha}",
WorkingDirectory = repositoryPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process is null)
{
return PatchAnalysisResult.Failed("Failed to start git process");
}
var diffContent = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false);
var errorOutput = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false);
await process.WaitForExitAsync(ct).ConfigureAwait(false);
if (process.ExitCode != 0)
{
return PatchAnalysisResult.Failed($"Git show failed: {errorOutput}");
}
var result = await ExtractFromDiffAsync(diffContent, ct).ConfigureAwait(false);
return result with
{
CommitSha = commitSha,
RepositoryUrl = repositoryPath
};
}
catch (Exception ex)
{
return PatchAnalysisResult.Failed($"Error extracting from local commit: {ex.Message}");
}
}
private IEnumerable<VulnerableSymbol> ExtractSymbolsFromFile(FileDiff fileDiff)
{
var symbols = new List<VulnerableSymbol>();
var language = DetectLanguage(fileDiff.NewPath ?? fileDiff.OldPath);
if (language is null)
{
// Unknown language, skip symbol extraction
return symbols;
}
foreach (var hunk in fileDiff.Hunks)
{
// Focus on removed lines (these are the vulnerable code being fixed)
foreach (var line in hunk.RemovedLines)
{
// Detect if this line is within a function
var functionBoundary = _boundaryDetector.DetectFunction(
hunk.Context,
line.LineNumber,
language.Value);
if (functionBoundary.HasValue)
{
var boundary = functionBoundary.Value;
// Parse the fully qualified name into components
var parts = boundary.FullyQualifiedName.Split('.');
var methodName = parts.Length > 0 ? parts[^1] : "unknown";
var typeName = parts.Length > 1 ? parts[^2] : "_";
var namespaceName = parts.Length > 2
? string.Join(".", parts[..^2])
: string.Empty;
var canonicalSymbol = CanonicalSymbol.Create(
@namespace: namespaceName,
type: typeName,
method: methodName,
signature: "()",
source: SymbolSource.PatchAnalysis,
originalSymbol: boundary.FullyQualifiedName);
symbols.Add(new VulnerableSymbol
{
Symbol = canonicalSymbol,
Type = VulnerabilityType.Sink, // Conservative default
Confidence = 0.7, // Patch-based confidence
Evidence = $"Modified in fix: line {line.LineNumber}",
SourceFile = fileDiff.OldPath,
LineRange = new LineRange(boundary.StartLine, boundary.EndLine)
});
}
}
}
// Deduplicate symbols by name
return symbols
.GroupBy(s => s.Symbol.DisplayName)
.Select(g => g.First());
}
private static ProgrammingLanguage? DetectLanguage(string? filePath)
{
if (string.IsNullOrEmpty(filePath))
{
return null;
}
var extension = Path.GetExtension(filePath).ToLowerInvariant();
return extension switch
{
".cs" => ProgrammingLanguage.CSharp,
".java" => ProgrammingLanguage.Java,
".kt" or ".kts" => ProgrammingLanguage.Kotlin,
".py" => ProgrammingLanguage.Python,
".js" => ProgrammingLanguage.JavaScript,
".ts" => ProgrammingLanguage.TypeScript,
".go" => ProgrammingLanguage.Go,
".rs" => ProgrammingLanguage.Rust,
".c" or ".h" => ProgrammingLanguage.C,
".cpp" or ".cc" or ".cxx" or ".hpp" => ProgrammingLanguage.Cpp,
".rb" => ProgrammingLanguage.Ruby,
".php" => ProgrammingLanguage.Php,
".swift" => ProgrammingLanguage.Swift,
".scala" => ProgrammingLanguage.Scala,
_ => null
};
}
private static string? ConvertToDiffUrl(string commitUrl)
{
// GitHub: https://github.com/owner/repo/commit/sha -> https://github.com/owner/repo/commit/sha.diff
if (commitUrl.Contains("github.com", StringComparison.OrdinalIgnoreCase) &&
commitUrl.Contains("/commit/", StringComparison.OrdinalIgnoreCase))
{
return commitUrl.TrimEnd('/') + ".diff";
}
// GitLab: https://gitlab.com/owner/repo/-/commit/sha -> https://gitlab.com/owner/repo/-/commit/sha.diff
if (commitUrl.Contains("gitlab.com", StringComparison.OrdinalIgnoreCase) &&
commitUrl.Contains("/commit/", StringComparison.OrdinalIgnoreCase))
{
return commitUrl.TrimEnd('/') + ".diff";
}
// Bitbucket: Different format - not directly supported yet
return null;
}
private static string? ExtractCommitSha(string commitUrl)
{
// Extract SHA from URL like /commit/abc123
var commitIndex = commitUrl.LastIndexOf("/commit/", StringComparison.OrdinalIgnoreCase);
if (commitIndex < 0)
{
return null;
}
var sha = commitUrl[(commitIndex + 8)..];
// Remove trailing .diff, query string, etc.
var endIndex = sha.IndexOfAny(['.', '?', '#']);
if (endIndex > 0)
{
sha = sha[..endIndex];
}
return sha.Length >= 7 ? sha : null;
}
private static string? ExtractRepositoryUrl(string commitUrl)
{
var commitIndex = commitUrl.LastIndexOf("/commit/", StringComparison.OrdinalIgnoreCase);
if (commitIndex < 0)
{
return null;
}
return commitUrl[..commitIndex];
}
}

View File

@@ -0,0 +1,527 @@
// <copyright file="OsvEnricher.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Reachability.Core.Symbols;
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Enriches CVE mappings with data from the OSV database.
/// Sprint: SPRINT_20260109_009_003 Task: Implement OsvEnricher
/// </summary>
/// <remarks>
/// Uses the OSV.dev API (https://api.osv.dev/) to retrieve vulnerability data.
/// Supports querying by vulnerability ID or by package.
/// </remarks>
public sealed class OsvEnricher : IOsvEnricher
{
private const string OsvApiBaseUrl = "https://api.osv.dev/v1";
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private readonly HttpClient _httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="OsvEnricher"/> class.
/// </summary>
public OsvEnricher(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
/// <inheritdoc/>
public async Task<OsvEnrichmentResult> EnrichAsync(string cveId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrEmpty(cveId);
var vulnerability = await GetVulnerabilityAsync(cveId, ct).ConfigureAwait(false);
if (vulnerability is null)
{
return OsvEnrichmentResult.NotFound(cveId);
}
var affectedPurls = ExtractPurls(vulnerability);
var symbols = ExtractSymbols(vulnerability);
var affectedVersions = ExtractAffectedVersions(vulnerability);
return new OsvEnrichmentResult
{
CveId = cveId,
Found = true,
OsvId = vulnerability.Id,
AffectedPurls = affectedPurls,
Symbols = symbols,
AffectedVersions = affectedVersions
};
}
/// <inheritdoc/>
public async Task<OsvVulnerability?> GetVulnerabilityAsync(string vulnId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrEmpty(vulnId);
try
{
var url = $"{OsvApiBaseUrl}/vulns/{Uri.EscapeDataString(vulnId)}";
var response = await _httpClient.GetAsync(url, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
}
var apiResponse = await response.Content
.ReadFromJsonAsync<OsvApiVulnerability>(JsonOptions, ct)
.ConfigureAwait(false);
return apiResponse is null ? null : MapToOsvVulnerability(apiResponse);
}
catch (HttpRequestException)
{
return null;
}
catch (JsonException)
{
return null;
}
}
/// <inheritdoc/>
public async Task<IReadOnlyList<OsvVulnerability>> QueryByPackageAsync(
string ecosystem,
string packageName,
string? version,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrEmpty(ecosystem);
ArgumentException.ThrowIfNullOrEmpty(packageName);
try
{
var url = $"{OsvApiBaseUrl}/query";
var request = new OsvQueryRequest
{
Package = new OsvQueryPackage
{
Ecosystem = MapEcosystem(ecosystem),
Name = packageName
},
Version = version
};
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, ct)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var queryResponse = await response.Content
.ReadFromJsonAsync<OsvQueryResponse>(JsonOptions, ct)
.ConfigureAwait(false);
if (queryResponse?.Vulns is null || queryResponse.Vulns.Length == 0)
{
return [];
}
return queryResponse.Vulns
.Select(MapToOsvVulnerability)
.ToImmutableArray();
}
catch (HttpRequestException)
{
return [];
}
catch (JsonException)
{
return [];
}
}
private static ImmutableArray<string> ExtractPurls(OsvVulnerability vulnerability)
{
var purls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var affected in vulnerability.Affected)
{
if (affected.Package?.Purl is not null)
{
purls.Add(affected.Package.Purl);
}
else if (affected.Package is not null)
{
// Build PURL from ecosystem and name
var ecosystem = MapEcosystemToPurlType(affected.Package.Ecosystem);
var purl = $"pkg:{ecosystem}/{affected.Package.Name}";
purls.Add(purl);
}
}
return [.. purls];
}
private static ImmutableArray<VulnerableSymbol> ExtractSymbols(OsvVulnerability vulnerability)
{
var symbols = new List<VulnerableSymbol>();
foreach (var affected in vulnerability.Affected)
{
if (affected.EcosystemSpecific is null)
{
continue;
}
// Try to extract function names from ecosystem-specific data
// Different ecosystems use different keys
var functionNames = ExtractFunctionNames(affected.EcosystemSpecific);
foreach (var functionName in functionNames)
{
var canonicalSymbol = CanonicalSymbol.Create(
@namespace: string.Empty,
type: "_",
method: functionName,
signature: "()",
source: SymbolSource.OsvAdvisory,
originalSymbol: functionName);
symbols.Add(new VulnerableSymbol
{
Symbol = canonicalSymbol,
Type = VulnerabilityType.Sink,
Confidence = 0.9, // High confidence from OSV
Evidence = $"OSV advisory: {vulnerability.Id}"
});
}
}
return [.. symbols];
}
private static IReadOnlyList<string> ExtractFunctionNames(
ImmutableDictionary<string, object> ecosystemSpecific)
{
var functions = new List<string>();
// Common keys used in OSV ecosystem-specific data
var functionKeys = new[] { "functions", "vulnerable_functions", "symbols", "affected_functions" };
foreach (var key in functionKeys)
{
if (!ecosystemSpecific.TryGetValue(key, out var value))
{
continue;
}
if (value is JsonElement element)
{
if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var funcName = item.GetString();
if (!string.IsNullOrEmpty(funcName))
{
functions.Add(funcName);
}
}
}
}
else if (element.ValueKind == JsonValueKind.String)
{
var funcName = element.GetString();
if (!string.IsNullOrEmpty(funcName))
{
functions.Add(funcName);
}
}
}
}
return functions;
}
private static ImmutableArray<AffectedVersionRange> ExtractAffectedVersions(OsvVulnerability vulnerability)
{
var ranges = new List<AffectedVersionRange>();
foreach (var affected in vulnerability.Affected)
{
if (affected.Package is null)
{
continue;
}
var purl = affected.Package.Purl
?? $"pkg:{MapEcosystemToPurlType(affected.Package.Ecosystem)}/{affected.Package.Name}";
foreach (var range in affected.Ranges)
{
string? introduced = null;
string? fixedVersion = null;
string? lastAffected = null;
foreach (var evt in range.Events)
{
if (evt.Introduced is not null)
{
introduced = evt.Introduced;
}
if (evt.Fixed is not null)
{
fixedVersion = evt.Fixed;
}
if (evt.LastAffected is not null)
{
lastAffected = evt.LastAffected;
}
}
ranges.Add(new AffectedVersionRange
{
Purl = purl,
IntroducedVersion = introduced,
FixedVersion = fixedVersion,
LastAffectedVersion = lastAffected
});
}
}
return [.. ranges];
}
private static OsvVulnerability MapToOsvVulnerability(OsvApiVulnerability api)
{
return new OsvVulnerability
{
Id = api.Id ?? "unknown",
Summary = api.Summary,
Details = api.Details,
Aliases = api.Aliases?.ToImmutableArray() ?? [],
Affected = api.Affected?.Select(MapToOsvAffected).ToImmutableArray() ?? [],
Severity = api.Severity?.Select(MapToOsvSeverity).ToImmutableArray() ?? [],
References = api.References?.Select(MapToOsvReference).ToImmutableArray() ?? []
};
}
private static OsvAffected MapToOsvAffected(OsvApiAffected api)
{
return new OsvAffected
{
Package = api.Package is null ? null : new OsvPackage
{
Ecosystem = api.Package.Ecosystem ?? "unknown",
Name = api.Package.Name ?? "unknown",
Purl = api.Package.Purl
},
Ranges = api.Ranges?.Select(MapToOsvRange).ToImmutableArray() ?? [],
Versions = api.Versions?.ToImmutableArray() ?? [],
EcosystemSpecific = api.EcosystemSpecific?.ToImmutableDictionary()
};
}
private static OsvRange MapToOsvRange(OsvApiRange api)
{
return new OsvRange
{
Type = api.Type ?? "SEMVER",
Events = api.Events?.Select(e => new OsvEvent
{
Introduced = e.Introduced,
Fixed = e.Fixed,
LastAffected = e.LastAffected
}).ToImmutableArray() ?? []
};
}
private static OsvSeverity MapToOsvSeverity(OsvApiSeverity api)
{
return new OsvSeverity
{
Type = api.Type ?? "CVSS_V3",
Score = api.Score ?? "0.0"
};
}
private static OsvReference MapToOsvReference(OsvApiReference api)
{
return new OsvReference
{
Type = api.Type ?? "WEB",
Url = api.Url ?? string.Empty
};
}
private static string MapEcosystem(string ecosystem)
{
// Map common ecosystem names to OSV format
return ecosystem.ToUpperInvariant() switch
{
"NPM" => "npm",
"PYPI" => "PyPI",
"MAVEN" => "Maven",
"NUGET" => "NuGet",
"GO" => "Go",
"CRATES.IO" or "CARGO" => "crates.io",
"RUBYGEMS" => "RubyGems",
"PACKAGIST" => "Packagist",
"HEX" => "Hex",
"PUB" => "Pub",
_ => ecosystem
};
}
private static string MapEcosystemToPurlType(string ecosystem)
{
return ecosystem.ToLowerInvariant() switch
{
"npm" => "npm",
"pypi" => "pypi",
"maven" => "maven",
"nuget" => "nuget",
"go" => "golang",
"crates.io" => "cargo",
"rubygems" => "gem",
"packagist" => "composer",
"hex" => "hex",
"pub" => "pub",
_ => ecosystem.ToLowerInvariant()
};
}
private static JsonSerializerOptions CreateJsonOptions()
{
return new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
// API request/response models
private sealed class OsvQueryRequest
{
[JsonPropertyName("package")]
public OsvQueryPackage? Package { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
}
private sealed class OsvQueryPackage
{
[JsonPropertyName("ecosystem")]
public string? Ecosystem { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
}
private sealed class OsvQueryResponse
{
[JsonPropertyName("vulns")]
public OsvApiVulnerability[]? Vulns { get; set; }
}
private sealed class OsvApiVulnerability
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("summary")]
public string? Summary { get; set; }
[JsonPropertyName("details")]
public string? Details { get; set; }
[JsonPropertyName("aliases")]
public string[]? Aliases { get; set; }
[JsonPropertyName("affected")]
public OsvApiAffected[]? Affected { get; set; }
[JsonPropertyName("severity")]
public OsvApiSeverity[]? Severity { get; set; }
[JsonPropertyName("references")]
public OsvApiReference[]? References { get; set; }
}
private sealed class OsvApiAffected
{
[JsonPropertyName("package")]
public OsvApiPackage? Package { get; set; }
[JsonPropertyName("ranges")]
public OsvApiRange[]? Ranges { get; set; }
[JsonPropertyName("versions")]
public string[]? Versions { get; set; }
[JsonPropertyName("ecosystem_specific")]
public Dictionary<string, object>? EcosystemSpecific { get; set; }
}
private sealed class OsvApiPackage
{
[JsonPropertyName("ecosystem")]
public string? Ecosystem { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("purl")]
public string? Purl { get; set; }
}
private sealed class OsvApiRange
{
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonPropertyName("events")]
public OsvApiEvent[]? Events { get; set; }
}
private sealed class OsvApiEvent
{
[JsonPropertyName("introduced")]
public string? Introduced { get; set; }
[JsonPropertyName("fixed")]
public string? Fixed { get; set; }
[JsonPropertyName("last_affected")]
public string? LastAffected { get; set; }
}
private sealed class OsvApiSeverity
{
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonPropertyName("score")]
public string? Score { get; set; }
}
private sealed class OsvApiReference
{
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonPropertyName("url")]
public string? Url { get; set; }
}
}

View File

@@ -0,0 +1,300 @@
// <copyright file="UnifiedDiffParser.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Parses unified diff format (git diff, patch files).
/// Sprint: SPRINT_20260109_009_003 Task: Implement UnifiedDiffParser
/// </summary>
public sealed partial class UnifiedDiffParser
{
// Regex patterns for parsing
[GeneratedRegex(@"^diff --git a/(.+) b/(.+)$")]
private static partial Regex DiffHeaderRegex();
[GeneratedRegex(@"^--- (?:a/)?(.+)$")]
private static partial Regex OldFileRegex();
[GeneratedRegex(@"^\+\+\+ (?:b/)?(.+)$")]
private static partial Regex NewFileRegex();
[GeneratedRegex(@"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$")]
private static partial Regex HunkHeaderRegex();
/// <summary>
/// Parses unified diff content.
/// </summary>
/// <param name="diffContent">Raw diff content.</param>
/// <returns>Parsed diff structure.</returns>
public ParsedDiff Parse(string diffContent)
{
ArgumentException.ThrowIfNullOrEmpty(diffContent);
var files = new List<FileDiff>();
var lines = diffContent.Split('\n');
var currentFile = (FileDiff?)null;
var currentHunk = (DiffHunk?)null;
var contextLines = new List<string>();
var addedLines = new List<DiffLine>();
var removedLines = new List<DiffLine>();
var currentOldLine = 0;
var currentNewLine = 0;
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i].TrimEnd('\r');
// Check for new file diff
var diffMatch = DiffHeaderRegex().Match(line);
if (diffMatch.Success)
{
// Save previous file and hunk
FinalizeHunk(ref currentHunk, ref currentFile, contextLines, addedLines, removedLines);
FinalizeFile(ref currentFile, files);
currentFile = new FileDiff
{
OldPath = diffMatch.Groups[1].Value,
NewPath = diffMatch.Groups[2].Value,
Hunks = []
};
continue;
}
// Check for old file path
var oldMatch = OldFileRegex().Match(line);
if (oldMatch.Success && currentFile is not null)
{
var path = oldMatch.Groups[1].Value;
if (path == "/dev/null")
{
currentFile = currentFile with { OldPath = null };
}
else
{
currentFile = currentFile with { OldPath = path };
}
continue;
}
// Check for new file path
var newMatch = NewFileRegex().Match(line);
if (newMatch.Success && currentFile is not null)
{
var path = newMatch.Groups[1].Value;
if (path == "/dev/null")
{
currentFile = currentFile with { NewPath = null };
}
else
{
currentFile = currentFile with { NewPath = path };
}
continue;
}
// Check for hunk header
var hunkMatch = HunkHeaderRegex().Match(line);
if (hunkMatch.Success && currentFile is not null)
{
// Save previous hunk
FinalizeHunk(ref currentHunk, ref currentFile, contextLines, addedLines, removedLines);
currentOldLine = int.Parse(hunkMatch.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
currentNewLine = int.Parse(hunkMatch.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture);
var funcContext = hunkMatch.Groups[5].Value.Trim();
currentHunk = new DiffHunk
{
OldStart = currentOldLine,
OldLength = hunkMatch.Groups[2].Success
? int.Parse(hunkMatch.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture)
: 1,
NewStart = currentNewLine,
NewLength = hunkMatch.Groups[4].Success
? int.Parse(hunkMatch.Groups[4].Value, System.Globalization.CultureInfo.InvariantCulture)
: 1,
FunctionContext = string.IsNullOrEmpty(funcContext) ? null : funcContext,
Context = [],
AddedLines = [],
RemovedLines = []
};
contextLines.Clear();
addedLines.Clear();
removedLines.Clear();
continue;
}
// Process diff content lines
if (currentHunk is not null)
{
if (line.StartsWith('+'))
{
addedLines.Add(new DiffLine(currentNewLine, line[1..]));
currentNewLine++;
}
else if (line.StartsWith('-'))
{
removedLines.Add(new DiffLine(currentOldLine, line[1..]));
currentOldLine++;
}
else if (line.StartsWith(' ') || line.Length == 0)
{
var content = line.Length > 0 ? line[1..] : string.Empty;
contextLines.Add(content);
currentOldLine++;
currentNewLine++;
}
// Ignore other lines (like "\ No newline at end of file")
}
}
// Finalize last hunk and file
FinalizeHunk(ref currentHunk, ref currentFile, contextLines, addedLines, removedLines);
FinalizeFile(ref currentFile, files);
return new ParsedDiff { Files = [.. files] };
}
private static void FinalizeHunk(
ref DiffHunk? currentHunk,
ref FileDiff? currentFile,
List<string> contextLines,
List<DiffLine> addedLines,
List<DiffLine> removedLines)
{
if (currentHunk is null || currentFile is null)
{
return;
}
currentHunk = currentHunk with
{
Context = [.. contextLines],
AddedLines = [.. addedLines],
RemovedLines = [.. removedLines]
};
currentFile = currentFile with
{
Hunks = currentFile.Hunks.Add(currentHunk)
};
currentHunk = null;
}
private static void FinalizeFile(ref FileDiff? currentFile, List<FileDiff> files)
{
if (currentFile is not null)
{
files.Add(currentFile);
currentFile = null;
}
}
}
/// <summary>
/// Represents a parsed unified diff.
/// </summary>
public sealed record ParsedDiff
{
/// <summary>
/// Files changed in the diff.
/// </summary>
public required ImmutableArray<FileDiff> Files { get; init; }
}
/// <summary>
/// Represents a single file's diff.
/// </summary>
public sealed record FileDiff
{
/// <summary>
/// Original file path (before changes).
/// </summary>
public string? OldPath { get; init; }
/// <summary>
/// New file path (after changes).
/// </summary>
public string? NewPath { get; init; }
/// <summary>
/// Hunks (change sections) in this file.
/// </summary>
public required ImmutableArray<DiffHunk> Hunks { get; init; }
/// <summary>
/// Whether this is a new file.
/// </summary>
public bool IsNewFile => OldPath is null || OldPath == "/dev/null";
/// <summary>
/// Whether this file was deleted.
/// </summary>
public bool IsDeleted => NewPath is null || NewPath == "/dev/null";
/// <summary>
/// Whether this file was renamed.
/// </summary>
public bool IsRenamed => OldPath != NewPath && !IsNewFile && !IsDeleted;
}
/// <summary>
/// Represents a hunk (change section) in a diff.
/// </summary>
public sealed record DiffHunk
{
/// <summary>
/// Starting line in the old file.
/// </summary>
public required int OldStart { get; init; }
/// <summary>
/// Number of lines from the old file.
/// </summary>
public required int OldLength { get; init; }
/// <summary>
/// Starting line in the new file.
/// </summary>
public required int NewStart { get; init; }
/// <summary>
/// Number of lines in the new file.
/// </summary>
public required int NewLength { get; init; }
/// <summary>
/// Function context from the hunk header (if present).
/// </summary>
public string? FunctionContext { get; init; }
/// <summary>
/// Context lines (unchanged).
/// </summary>
public required ImmutableArray<string> Context { get; init; }
/// <summary>
/// Lines added in this hunk.
/// </summary>
public required ImmutableArray<DiffLine> AddedLines { get; init; }
/// <summary>
/// Lines removed in this hunk.
/// </summary>
public required ImmutableArray<DiffLine> RemovedLines { get; init; }
}
/// <summary>
/// Represents a line in a diff with its line number.
/// </summary>
/// <param name="LineNumber">Line number in the file.</param>
/// <param name="Content">Line content (without +/- prefix).</param>
public readonly record struct DiffLine(int LineNumber, string Content);

View File

@@ -0,0 +1,550 @@
// <copyright file="NativeSymbolNormalizer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Normalizes native C/C++/Rust symbols from ELF, PE, DWARF, PDB, and eBPF.
/// Sprint: SPRINT_20260109_009_002 Task: Implement native normalizer
/// </summary>
/// <remarks>
/// Handles mangled names from:
/// - Itanium C++ ABI (_Z prefix) - GCC, Clang
/// - MSVC C++ mangling (? prefix)
/// - Rust mangling (_ZN prefix with hash suffix)
/// - Plain C symbols (no mangling)
/// </remarks>
public sealed partial class NativeSymbolNormalizer : ISymbolNormalizer
{
private static readonly HashSet<SymbolSource> Sources =
[
SymbolSource.ElfSymtab,
SymbolSource.PeExport,
SymbolSource.Dwarf,
SymbolSource.Pdb,
SymbolSource.EbpfUprobe
];
/// <inheritdoc/>
public IReadOnlySet<SymbolSource> SupportedSources => Sources;
/// <inheritdoc/>
public bool CanNormalize(SymbolSource source) => Sources.Contains(source);
/// <inheritdoc/>
public CanonicalSymbol? Normalize(RawSymbol raw)
{
TryNormalize(raw, out var canonical, out _);
return canonical;
}
/// <inheritdoc/>
public bool TryNormalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error)
{
canonical = null;
error = null;
if (string.IsNullOrWhiteSpace(raw.Value))
{
error = "Symbol value is empty";
return false;
}
// Try different native symbol formats
if (TryParseItaniumMangled(raw, out canonical))
return true;
if (TryParseMsvcMangled(raw, out canonical))
return true;
if (TryParseRustMangled(raw, out canonical))
return true;
if (TryParsePlainCSymbol(raw, out canonical))
return true;
if (TryParseDwarfSymbol(raw, out canonical))
return true;
error = $"Cannot parse native symbol: {raw.Value}";
return false;
}
/// <summary>
/// Parses Itanium C++ ABI mangled names (_Z prefix).
/// Example: _ZN4llvm12DenseMapBaseINS_8DenseMapIPKNS_5ValueE...
/// </summary>
private static bool TryParseItaniumMangled(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
if (!raw.Value.StartsWith("_Z", StringComparison.Ordinal))
return false;
var demangled = DemangleItanium(raw.Value);
if (demangled is null)
return false;
return TryParseDemangled(demangled, raw, out canonical);
}
/// <summary>
/// Parses MSVC C++ mangled names (? prefix).
/// Example: ?lookup@JndiLookup@@QEAA?AVString@@PEAV1@@Z
/// </summary>
private static bool TryParseMsvcMangled(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
if (!raw.Value.StartsWith('?'))
return false;
var demangled = DemangleMsvc(raw.Value);
if (demangled is null)
return false;
return TryParseDemangled(demangled, raw, out canonical);
}
/// <summary>
/// Parses Rust mangled names (v0 or legacy).
/// Example: _ZN4core3ptr85drop_in_place$LT$std..rt..lang_start...
/// </summary>
private static bool TryParseRustMangled(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Rust v0 mangling starts with _R
// Legacy Rust mangling starts with _ZN and has hash suffix
if (!raw.Value.StartsWith("_R", StringComparison.Ordinal) &&
!(raw.Value.StartsWith("_ZN", StringComparison.Ordinal) && RustHashSuffixRegex().IsMatch(raw.Value)))
{
return false;
}
var demangled = DemangleRust(raw.Value);
if (demangled is null)
return false;
return TryParseDemangled(demangled, raw, out canonical);
}
/// <summary>
/// Parses plain C symbols (function names without mangling).
/// Example: ssl_do_handshake, EVP_EncryptInit_ex
/// </summary>
private static bool TryParsePlainCSymbol(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Plain C symbols are alphanumeric with underscores, no special prefixes
if (!PlainCSymbolRegex().IsMatch(raw.Value))
return false;
// Check it's not a mangled symbol
if (raw.Value.StartsWith("_Z", StringComparison.Ordinal) ||
raw.Value.StartsWith("_R", StringComparison.Ordinal) ||
raw.Value.StartsWith('?'))
{
return false;
}
// Extract namespace from prefixes (e.g., ssl_, EVP_, OPENSSL_)
var (ns, method) = ExtractCNamespace(raw.Value);
canonical = CanonicalSymbol.Create(
@namespace: ns,
type: "_", // C has no classes
method: method,
signature: "()", // Unknown signature
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
/// <summary>
/// Parses DWARF debug info format.
/// Example: namespace::class::method(params) or file.c:function
/// </summary>
private static bool TryParseDwarfSymbol(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Pattern: namespace::class::method(params)
var match = DwarfCppRegex().Match(raw.Value);
if (match.Success)
{
var qualifiedName = match.Groups["qualified"].Value;
var @params = match.Groups["params"].Value;
var parts = qualifiedName.Split("::");
var method = parts[^1];
var type = parts.Length > 1 ? parts[^2] : "_";
var ns = parts.Length > 2 ? string.Join(".", parts[..^2]) : "_";
canonical = CanonicalSymbol.Create(
@namespace: ns,
type: type,
method: method,
signature: NormalizeNativeParams(@params),
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
// Pattern: file.c:function (GDB style)
var fileMatch = DwarfFileRegex().Match(raw.Value);
if (fileMatch.Success)
{
var file = fileMatch.Groups["file"].Value;
var function = fileMatch.Groups["function"].Value;
// Use filename (without extension) as namespace
var ns = Path.GetFileNameWithoutExtension(file).ToLowerInvariant();
canonical = CanonicalSymbol.Create(
@namespace: ns,
type: "_",
method: function,
signature: "()",
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
return false;
}
/// <summary>
/// Parses a demangled C++/Rust symbol.
/// </summary>
private static bool TryParseDemangled(string demangled, RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Pattern: namespace::class::method(params)
var match = DemangledCppRegex().Match(demangled);
if (!match.Success)
{
// Try simpler pattern without params
match = DemangledSimpleRegex().Match(demangled);
if (!match.Success)
return false;
}
var qualifiedName = match.Groups["qualified"].Value;
var @params = match.Groups.ContainsKey("params") ? match.Groups["params"].Value : "";
// Split by :: to get namespace, type, method
var parts = qualifiedName.Split(new[] { "::" }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return false;
var method = parts[^1];
var type = parts.Length > 1 ? parts[^2] : "_";
var ns = parts.Length > 2 ? string.Join(".", parts[..^2]) : "_";
// Handle template specializations - remove angle brackets content
method = TemplateRegex().Replace(method, "");
type = TemplateRegex().Replace(type, "");
canonical = CanonicalSymbol.Create(
@namespace: ns,
type: type,
method: method,
signature: NormalizeNativeParams(@params),
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
/// <summary>
/// Demangling for Itanium ABI (basic implementation).
/// Full demangling would require external library or comprehensive parser.
/// </summary>
private static string? DemangleItanium(string mangled)
{
// Basic Itanium demangling - parse nested names
if (!mangled.StartsWith("_Z", StringComparison.Ordinal))
return null;
var result = new StringBuilder();
var pos = 2; // Skip _Z
// Handle nested names (_ZN...E)
if (pos < mangled.Length && mangled[pos] == 'N')
{
pos++; // Skip N
var parts = new List<string>();
while (pos < mangled.Length && mangled[pos] != 'E')
{
// Read length-prefixed name
var lengthStr = new StringBuilder();
while (pos < mangled.Length && char.IsDigit(mangled[pos]))
{
lengthStr.Append(mangled[pos++]);
}
if (lengthStr.Length == 0)
{
// Skip qualifiers (K=const, V=volatile, etc.)
if (pos < mangled.Length && "KVrO".Contains(mangled[pos]))
{
pos++;
continue;
}
break;
}
var length = int.Parse(lengthStr.ToString(), System.Globalization.CultureInfo.InvariantCulture);
if (pos + length > mangled.Length)
break;
parts.Add(mangled.Substring(pos, length));
pos += length;
}
if (parts.Count > 0)
{
result.Append(string.Join("::", parts));
}
}
else
{
// Simple name without nesting
var lengthStr = new StringBuilder();
while (pos < mangled.Length && char.IsDigit(mangled[pos]))
{
lengthStr.Append(mangled[pos++]);
}
if (lengthStr.Length > 0)
{
var length = int.Parse(lengthStr.ToString(), System.Globalization.CultureInfo.InvariantCulture);
if (pos + length <= mangled.Length)
{
result.Append(mangled.Substring(pos, length));
}
}
}
// Try to extract parameters (simplified - just mark as having params)
if (result.Length > 0)
{
return result + "()";
}
return null;
}
/// <summary>
/// Demangling for MSVC (basic implementation).
/// </summary>
private static string? DemangleMsvc(string mangled)
{
// Basic MSVC demangling
if (!mangled.StartsWith('?'))
return null;
// Pattern: ?name@scope1@scope2@@...
var match = MsvcMangledRegex().Match(mangled);
if (!match.Success)
return null;
var name = match.Groups["name"].Value;
var scopes = match.Groups["scopes"].Value;
// Reverse scope order (MSVC stores innermost first)
var scopeParts = scopes.Split('@', StringSplitOptions.RemoveEmptyEntries);
Array.Reverse(scopeParts);
if (scopeParts.Length > 0)
{
return string.Join("::", scopeParts) + "::" + name + "()";
}
return name + "()";
}
/// <summary>
/// Demangling for Rust (basic implementation).
/// </summary>
private static string? DemangleRust(string mangled)
{
// Rust v0 mangling starts with _R
if (mangled.StartsWith("_R", StringComparison.Ordinal))
{
// v0 mangling is complex - basic extraction
return ExtractRustV0Symbol(mangled);
}
// Legacy Rust mangling - similar to Itanium but with hash suffix
if (mangled.StartsWith("_ZN", StringComparison.Ordinal))
{
// Remove hash suffix (17h followed by 16 hex chars)
var cleaned = RustHashSuffixRegex().Replace(mangled, "E");
return DemangleItanium(cleaned.Replace("_ZN", "_ZN"));
}
return null;
}
private static string? ExtractRustV0Symbol(string mangled)
{
// Very basic v0 extraction - just try to find readable parts
var readable = new StringBuilder();
var pos = 2; // Skip _R
while (pos < mangled.Length)
{
if (char.IsDigit(mangled[pos]))
{
var lengthStr = new StringBuilder();
while (pos < mangled.Length && char.IsDigit(mangled[pos]))
{
lengthStr.Append(mangled[pos++]);
}
if (lengthStr.Length > 0 && pos < mangled.Length)
{
// Skip 'u' prefix for unicode if present
if (mangled[pos] == 'u')
pos++;
var length = int.Parse(lengthStr.ToString(), System.Globalization.CultureInfo.InvariantCulture);
if (pos + length <= mangled.Length && length > 0 && length < 100)
{
if (readable.Length > 0)
readable.Append("::");
readable.Append(mangled.AsSpan(pos, length));
pos += length;
continue;
}
}
}
pos++;
}
return readable.Length > 0 ? readable + "()" : null;
}
/// <summary>
/// Extracts namespace from C function naming conventions.
/// </summary>
private static (string Namespace, string Method) ExtractCNamespace(string symbol)
{
// Common C library prefixes
var prefixes = new[]
{
("ssl_", "openssl.ssl"),
("SSL_", "openssl.ssl"),
("EVP_", "openssl.evp"),
("OPENSSL_", "openssl"),
("BIO_", "openssl.bio"),
("X509_", "openssl.x509"),
("RSA_", "openssl.rsa"),
("EC_", "openssl.ec"),
("curl_", "curl"),
("CURL_", "curl"),
("sqlite3_", "sqlite3"),
("png_", "libpng"),
("jpeg_", "libjpeg"),
("z_", "zlib"),
("inflate", "zlib"),
("deflate", "zlib"),
("xml", "libxml2"),
("XML", "libxml2"),
("pthread_", "pthread"),
("sem_", "posix"),
("shm_", "posix"),
("mq_", "posix")
};
foreach (var (prefix, ns) in prefixes)
{
if (symbol.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
var method = symbol[prefix.Length..];
return (ns, method.Length > 0 ? method : symbol);
}
}
// No known prefix - use generic namespace
return ("native", symbol);
}
/// <summary>
/// Normalizes native parameter list to simplified form.
/// </summary>
private static string NormalizeNativeParams(string @params)
{
if (string.IsNullOrWhiteSpace(@params))
return "()";
// Remove const, volatile, pointer/reference decorations
var simplified = @params
.Replace("const ", "")
.Replace("volatile ", "")
.Replace(" const", "")
.Replace("*", "")
.Replace("&", "")
.Replace(" ", " ")
.Trim();
// Extract just type names
var types = simplified.Split(',', StringSplitOptions.TrimEntries)
.Select(p =>
{
// Get the last word (type name) from each param
var parts = p.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return "";
// Handle namespaced types
var typeName = parts[^1];
if (typeName.Contains("::"))
typeName = typeName.Split("::")[^1];
return typeName.ToLowerInvariant();
})
.Where(t => !string.IsNullOrEmpty(t));
return $"({string.Join(", ", types)})";
}
// Regex patterns
[GeneratedRegex(@"^[a-zA-Z_][a-zA-Z0-9_]*$")]
private static partial Regex PlainCSymbolRegex();
[GeneratedRegex(@"(?<qualified>[\w:]+)\s*\((?<params>[^)]*)\)")]
private static partial Regex DwarfCppRegex();
[GeneratedRegex(@"(?<file>[\w./]+\.[ch]pp?):(?<function>\w+)")]
private static partial Regex DwarfFileRegex();
[GeneratedRegex(@"(?<qualified>[\w:]+)\s*\((?<params>[^)]*)\)")]
private static partial Regex DemangledCppRegex();
[GeneratedRegex(@"^(?<qualified>[\w:]+)$")]
private static partial Regex DemangledSimpleRegex();
[GeneratedRegex(@"<[^>]*>")]
private static partial Regex TemplateRegex();
[GeneratedRegex(@"^\?(?<name>\w+)@(?<scopes>[\w@]+)@@")]
private static partial Regex MsvcMangledRegex();
[GeneratedRegex(@"17h[0-9a-f]{16}E?$")]
private static partial Regex RustHashSuffixRegex();
}

View File

@@ -0,0 +1,66 @@
// <copyright file="ProgrammingLanguage.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Supported programming languages for symbol canonicalization.
/// Sprint: SPRINT_20260109_009_003 Task: Create ProgrammingLanguage enum
/// </summary>
public enum ProgrammingLanguage
{
/// <summary>Unknown or unsupported language.</summary>
Unknown = 0,
/// <summary>C# (.cs files).</summary>
CSharp = 1,
/// <summary>Java (.java files).</summary>
Java = 2,
/// <summary>Kotlin (.kt, .kts files).</summary>
Kotlin = 3,
/// <summary>Python (.py files).</summary>
Python = 4,
/// <summary>JavaScript (.js files).</summary>
JavaScript = 5,
/// <summary>TypeScript (.ts files).</summary>
TypeScript = 6,
/// <summary>Go (.go files).</summary>
Go = 7,
/// <summary>Rust (.rs files).</summary>
Rust = 8,
/// <summary>C (.c, .h files).</summary>
C = 9,
/// <summary>C++ (.cpp, .cc, .cxx, .hpp files).</summary>
Cpp = 10,
/// <summary>Ruby (.rb files).</summary>
Ruby = 11,
/// <summary>PHP (.php files).</summary>
Php = 12,
/// <summary>Swift (.swift files).</summary>
Swift = 13,
/// <summary>Scala (.scala files).</summary>
Scala = 14,
/// <summary>Objective-C (.m, .mm files).</summary>
ObjectiveC = 15,
/// <summary>Elixir (.ex, .exs files).</summary>
Elixir = 16,
/// <summary>Erlang (.erl files).</summary>
Erlang = 17
}

View File

@@ -0,0 +1,453 @@
// <copyright file="ScriptSymbolNormalizer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.RegularExpressions;
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Normalizes script language symbols from V8 (JS), Python, and PHP.
/// Sprint: SPRINT_20260109_009_002 Task: Implement script normalizer
/// </summary>
/// <remarks>
/// Handles symbols from:
/// - V8 profiler (Node.js) - stack frames
/// - Python sys.settrace - function/method traces
/// - PHP Xdebug - profiler output
/// </remarks>
public sealed partial class ScriptSymbolNormalizer : ISymbolNormalizer
{
private static readonly HashSet<SymbolSource> Sources =
[
SymbolSource.V8Profiler,
SymbolSource.PythonTrace,
SymbolSource.PhpXdebug
];
/// <inheritdoc/>
public IReadOnlySet<SymbolSource> SupportedSources => Sources;
/// <inheritdoc/>
public bool CanNormalize(SymbolSource source) => Sources.Contains(source);
/// <inheritdoc/>
public CanonicalSymbol? Normalize(RawSymbol raw)
{
TryNormalize(raw, out var canonical, out _);
return canonical;
}
/// <inheritdoc/>
public bool TryNormalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error)
{
canonical = null;
error = null;
if (string.IsNullOrWhiteSpace(raw.Value))
{
error = "Symbol value is empty";
return false;
}
var result = raw.Source switch
{
SymbolSource.V8Profiler => TryParseV8Symbol(raw, out canonical),
SymbolSource.PythonTrace => TryParsePythonSymbol(raw, out canonical),
SymbolSource.PhpXdebug => TryParsePhpSymbol(raw, out canonical),
_ => TryParseGenericScript(raw, out canonical)
};
if (!result)
{
error = $"Cannot parse script symbol: {raw.Value}";
}
return result;
}
/// <summary>
/// Parses V8 profiler stack frame format.
/// Examples:
/// - "lodash.template (lodash.js:1234:56)"
/// - "Module._load (internal/modules/cjs/loader.js:789:10)"
/// - "anonymous (webpack:///src/app.js:12:3)"
/// - "Foo.bar [as baz] (foo.js:1:1)"
/// </summary>
private static bool TryParseV8Symbol(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Pattern: FunctionName (file:line:col) or Class.method (file:line:col)
var match = V8StackFrameRegex().Match(raw.Value);
if (match.Success)
{
var functionName = match.Groups["function"].Value.Trim();
var file = match.Groups["file"].Value;
// Handle "Class.method" or "method"
var (ns, type, method) = ParseJsFunctionName(functionName, file);
canonical = CanonicalSymbol.Create(
@namespace: ns,
type: type,
method: method,
signature: "()",
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
// Simple function name without location
if (JsIdentifierRegex().IsMatch(raw.Value))
{
var (ns, type, method) = ParseJsFunctionName(raw.Value, null);
canonical = CanonicalSymbol.Create(
@namespace: ns,
type: type,
method: method,
signature: "()",
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
return false;
}
/// <summary>
/// Parses Python trace format.
/// Examples:
/// - "module.submodule:ClassName.method"
/// - "package.module:function"
/// - "<module>:function" (top-level)
/// - "django.template.base:Template.render"
/// </summary>
private static bool TryParsePythonSymbol(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Pattern with module:qualified_name
var colonMatch = PythonColonFormatRegex().Match(raw.Value);
if (colonMatch.Success)
{
var module = colonMatch.Groups["module"].Value;
var qualifiedName = colonMatch.Groups["qualified"].Value;
var (type, method) = ParsePythonQualifiedName(qualifiedName);
canonical = CanonicalSymbol.Create(
@namespace: module == "<module>" ? "_" : module,
type: type,
method: method,
signature: "()",
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
// Dot-separated pattern: module.Class.method
var dotMatch = PythonDotFormatRegex().Match(raw.Value);
if (dotMatch.Success)
{
var parts = raw.Value.Split('.');
if (parts.Length >= 2)
{
var method = parts[^1];
var type = parts.Length > 2 && char.IsUpper(parts[^2][0]) ? parts[^2] : "_";
var ns = type == "_"
? string.Join(".", parts[..^1])
: string.Join(".", parts[..^2]);
canonical = CanonicalSymbol.Create(
@namespace: ns.Length > 0 ? ns : "_",
type: type,
method: method,
signature: "()",
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
}
// Simple function name
if (PythonIdentifierRegex().IsMatch(raw.Value))
{
canonical = CanonicalSymbol.Create(
@namespace: "_",
type: "_",
method: raw.Value,
signature: "()",
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
return false;
}
/// <summary>
/// Parses PHP Xdebug profiler format.
/// Examples:
/// - "Namespace\\Class->method"
/// - "Namespace\\Class::staticMethod"
/// - "function_name"
/// - "{closure:/path/file.php:123-456}"
/// </summary>
private static bool TryParsePhpSymbol(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Instance method: Namespace\Class->method
var instanceMatch = PhpInstanceMethodRegex().Match(raw.Value);
if (instanceMatch.Success)
{
var fullClass = instanceMatch.Groups["class"].Value;
var method = instanceMatch.Groups["method"].Value;
var (ns, type) = ParsePhpClassName(fullClass);
canonical = CanonicalSymbol.Create(
@namespace: ns,
type: type,
method: method,
signature: "()",
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
// Static method: Namespace\Class::method
var staticMatch = PhpStaticMethodRegex().Match(raw.Value);
if (staticMatch.Success)
{
var fullClass = staticMatch.Groups["class"].Value;
var method = staticMatch.Groups["method"].Value;
var (ns, type) = ParsePhpClassName(fullClass);
canonical = CanonicalSymbol.Create(
@namespace: ns,
type: type,
method: method,
signature: "()",
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
// Closure: {closure:/path/file.php:123-456}
var closureMatch = PhpClosureRegex().Match(raw.Value);
if (closureMatch.Success)
{
var file = closureMatch.Groups["file"].Value;
var ns = Path.GetFileNameWithoutExtension(file).ToLowerInvariant();
canonical = CanonicalSymbol.Create(
@namespace: ns.Length > 0 ? ns : "_",
type: "_",
method: "{closure}",
signature: "()",
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
// Plain function
if (PhpFunctionRegex().IsMatch(raw.Value))
{
canonical = CanonicalSymbol.Create(
@namespace: "_",
type: "_",
method: raw.Value,
signature: "()",
source: raw.Source,
purl: raw.Purl,
originalSymbol: raw.Value);
return true;
}
return false;
}
/// <summary>
/// Generic script symbol parsing fallback.
/// </summary>
private static bool TryParseGenericScript(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Try common patterns
if (TryParseV8Symbol(raw, out canonical))
return true;
if (TryParsePythonSymbol(raw, out canonical))
return true;
if (TryParsePhpSymbol(raw, out canonical))
return true;
return false;
}
/// <summary>
/// Parses JavaScript function name into namespace, type, method.
/// </summary>
private static (string Namespace, string Type, string Method) ParseJsFunctionName(string functionName, string? file)
{
// Remove "as alias" suffix
var asIndex = functionName.IndexOf(" [as ", StringComparison.Ordinal);
if (asIndex > 0)
functionName = functionName[..asIndex];
// Handle anonymous functions
if (functionName is "anonymous" or "<anonymous>" or "(anonymous)")
{
var ns = ExtractJsNamespaceFromFile(file);
return (ns, "_", "{anonymous}");
}
// Handle "Class.method" or "object.method"
var parts = functionName.Split('.');
if (parts.Length >= 2)
{
var method = parts[^1];
var type = parts[^2];
// If type starts with uppercase, treat as class
if (char.IsUpper(type[0]))
{
var ns = parts.Length > 2 ? string.Join(".", parts[..^2]) : ExtractJsNamespaceFromFile(file);
return (ns, type, method);
}
else
{
// Object notation - use as namespace
var ns = string.Join(".", parts[..^1]);
return (ns, "_", method);
}
}
// Simple function name
var fileNs = ExtractJsNamespaceFromFile(file);
return (fileNs, "_", functionName);
}
/// <summary>
/// Extracts namespace from JavaScript file path.
/// </summary>
private static string ExtractJsNamespaceFromFile(string? file)
{
if (string.IsNullOrEmpty(file))
return "_";
// Remove webpack:/// and similar prefixes
file = file.Replace("webpack:///", "")
.Replace("file://", "");
// Get filename without extension
var name = Path.GetFileNameWithoutExtension(file);
// Handle node_modules paths
if (file.Contains("node_modules"))
{
var parts = file.Split(new[] { "node_modules/" }, StringSplitOptions.None);
if (parts.Length > 1)
{
var modulePath = parts[1].Split('/');
// Handle scoped packages (@scope/package)
if (modulePath.Length > 0 && modulePath[0].StartsWith('@'))
{
return modulePath.Length > 1 ? $"{modulePath[0]}/{modulePath[1]}" : modulePath[0];
}
return modulePath[0];
}
}
return name.Length > 0 ? name.ToLowerInvariant() : "_";
}
/// <summary>
/// Parses Python qualified name (Class.method or method).
/// </summary>
private static (string Type, string Method) ParsePythonQualifiedName(string qualified)
{
var parts = qualified.Split('.');
if (parts.Length >= 2)
{
var method = parts[^1];
var type = parts[^2];
// Check if it's a class (starts with uppercase)
if (char.IsUpper(type[0]))
return (type, method);
}
return ("_", qualified);
}
/// <summary>
/// Parses PHP class name with namespace.
/// </summary>
private static (string Namespace, string Type) ParsePhpClassName(string fullClass)
{
// Replace backslashes with dots for canonical format
var normalized = fullClass.Replace("\\", ".");
var parts = normalized.Split('.');
if (parts.Length >= 2)
{
var type = parts[^1];
var ns = string.Join(".", parts[..^1]);
return (ns, type);
}
return ("_", normalized);
}
// Regex patterns
[GeneratedRegex(@"^(?<function>[^(]+)\s*\((?<file>[^:)]+)(?::\d+(?::\d+)?)?\)$")]
private static partial Regex V8StackFrameRegex();
[GeneratedRegex(@"^[\w$][\w$\.]*$")]
private static partial Regex JsIdentifierRegex();
[GeneratedRegex(@"^(?<module>[^:]+):(?<qualified>[\w.]+)$")]
private static partial Regex PythonColonFormatRegex();
[GeneratedRegex(@"^[\w.]+$")]
private static partial Regex PythonDotFormatRegex();
[GeneratedRegex(@"^[a-zA-Z_][a-zA-Z0-9_]*$")]
private static partial Regex PythonIdentifierRegex();
[GeneratedRegex(@"^(?<class>[\w\\]+)->(?<method>\w+)$")]
private static partial Regex PhpInstanceMethodRegex();
[GeneratedRegex(@"^(?<class>[\w\\]+)::(?<method>\w+)$")]
private static partial Regex PhpStaticMethodRegex();
[GeneratedRegex(@"^\{closure:(?<file>[^:}]+)(?::\d+-\d+)?\}$")]
private static partial Regex PhpClosureRegex();
[GeneratedRegex(@"^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$")]
private static partial Regex PhpFunctionRegex();
}

View File

@@ -67,5 +67,11 @@ public enum SymbolSource
PatchAnalysis = 50,
/// <summary>Manual curation.</summary>
ManualCuration = 51
ManualCuration = 51,
/// <summary>OSV advisory database.</summary>
OsvAdvisory = 52,
/// <summary>NVD advisory database.</summary>
NvdAdvisory = 53
}

View File

@@ -0,0 +1,221 @@
// <copyright file="AiAttestationServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.AdvisoryAI.Attestation.Models;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
/// <summary>
/// Tests for <see cref="AiAttestationService"/>.
/// </summary>
[Trait("Category", "Unit")]
public class AiAttestationServiceTests
{
private readonly FakeTimeProvider _timeProvider = new();
private readonly AiAttestationService _service;
public AiAttestationServiceTests()
{
_service = new AiAttestationService(
_timeProvider,
NullLogger<AiAttestationService>.Instance);
}
[Fact]
public async Task CreateRunAttestationAsync_WithSigning_ReturnsSignedResult()
{
var attestation = CreateSampleRunAttestation();
var result = await _service.CreateRunAttestationAsync(attestation, sign: true);
result.AttestationId.Should().Be(attestation.RunId);
result.Signed.Should().BeTrue();
result.DsseEnvelope.Should().NotBeNullOrEmpty();
result.Digest.Should().StartWith("sha256:");
result.StorageUri.Should().Contain(attestation.RunId);
}
[Fact]
public async Task CreateRunAttestationAsync_WithoutSigning_ReturnsUnsignedResult()
{
var attestation = CreateSampleRunAttestation();
var result = await _service.CreateRunAttestationAsync(attestation, sign: false);
result.Signed.Should().BeFalse();
result.DsseEnvelope.Should().BeNull();
}
[Fact]
public async Task GetRunAttestationAsync_AfterCreation_ReturnsAttestation()
{
var attestation = CreateSampleRunAttestation();
await _service.CreateRunAttestationAsync(attestation);
var retrieved = await _service.GetRunAttestationAsync(attestation.RunId);
retrieved.Should().NotBeNull();
retrieved!.RunId.Should().Be(attestation.RunId);
retrieved.TenantId.Should().Be(attestation.TenantId);
retrieved.Model.Provider.Should().Be(attestation.Model.Provider);
}
[Fact]
public async Task GetRunAttestationAsync_NotFound_ReturnsNull()
{
var result = await _service.GetRunAttestationAsync("non-existent");
result.Should().BeNull();
}
[Fact]
public async Task VerifyRunAttestationAsync_ValidAttestation_ReturnsValid()
{
var attestation = CreateSampleRunAttestation();
await _service.CreateRunAttestationAsync(attestation, sign: true);
var result = await _service.VerifyRunAttestationAsync(attestation.RunId);
result.Valid.Should().BeTrue();
result.DigestValid.Should().BeTrue();
result.SignatureValid.Should().BeTrue();
result.SigningKeyId.Should().NotBeNull();
}
[Fact]
public async Task VerifyRunAttestationAsync_NotFound_ReturnsInvalid()
{
var result = await _service.VerifyRunAttestationAsync("non-existent");
result.Valid.Should().BeFalse();
result.FailureReason.Should().Contain("not found");
}
[Fact]
public async Task CreateClaimAttestationAsync_CreatesAndRetrievesClaim()
{
var claimAttestation = CreateSampleClaimAttestation();
var result = await _service.CreateClaimAttestationAsync(claimAttestation);
result.AttestationId.Should().Be(claimAttestation.ClaimId);
result.Digest.Should().StartWith("sha256:");
}
[Fact]
public async Task GetClaimAttestationsAsync_ReturnsClaimsForRun()
{
var runId = "run-with-claims";
var claim1 = CreateSampleClaimAttestation() with { ClaimId = "claim-1", RunId = runId };
var claim2 = CreateSampleClaimAttestation() with { ClaimId = "claim-2", RunId = runId };
var claim3 = CreateSampleClaimAttestation() with { ClaimId = "claim-3", RunId = "other-run" };
await _service.CreateClaimAttestationAsync(claim1);
await _service.CreateClaimAttestationAsync(claim2);
await _service.CreateClaimAttestationAsync(claim3);
var claims = await _service.GetClaimAttestationsAsync(runId);
claims.Should().HaveCount(2);
claims.Should().AllSatisfy(c => c.RunId.Should().Be(runId));
}
[Fact]
public async Task VerifyClaimAttestationAsync_ValidClaim_ReturnsValid()
{
var claimAttestation = CreateSampleClaimAttestation();
await _service.CreateClaimAttestationAsync(claimAttestation, sign: true);
var result = await _service.VerifyClaimAttestationAsync(claimAttestation.ClaimId);
result.Valid.Should().BeTrue();
result.DigestValid.Should().BeTrue();
}
[Fact]
public async Task ListRecentAttestationsAsync_FiltersByTenant()
{
var tenant1Run = CreateSampleRunAttestation() with { RunId = "run-t1", TenantId = "tenant-1" };
var tenant2Run = CreateSampleRunAttestation() with { RunId = "run-t2", TenantId = "tenant-2" };
await _service.CreateRunAttestationAsync(tenant1Run);
await _service.CreateRunAttestationAsync(tenant2Run);
var tenant1Attestations = await _service.ListRecentAttestationsAsync("tenant-1");
tenant1Attestations.Should().HaveCount(1);
tenant1Attestations[0].TenantId.Should().Be("tenant-1");
}
[Fact]
public async Task ListRecentAttestationsAsync_RespectsLimit()
{
for (int i = 0; i < 10; i++)
{
var attestation = CreateSampleRunAttestation() with
{
RunId = $"run-{i}",
TenantId = "tenant-test"
};
await _service.CreateRunAttestationAsync(attestation);
_timeProvider.Advance(TimeSpan.FromSeconds(1));
}
var recent = await _service.ListRecentAttestationsAsync("tenant-test", limit: 5);
recent.Should().HaveCount(5);
}
[Fact]
public async Task CreatedAt_UsesTimeProvider()
{
var fixedTime = new DateTimeOffset(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(fixedTime);
var attestation = CreateSampleRunAttestation();
var result = await _service.CreateRunAttestationAsync(attestation);
result.CreatedAt.Should().Be(fixedTime);
}
private static AiRunAttestation CreateSampleRunAttestation()
{
return new AiRunAttestation
{
RunId = $"run-{Guid.NewGuid():N}",
TenantId = "tenant-test",
UserId = "user:test@example.com",
StartedAt = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
CompletedAt = DateTimeOffset.Parse("2026-01-09T12:05:00Z"),
Model = new AiModelInfo
{
Provider = "anthropic",
ModelId = "claude-3-sonnet"
},
OverallGroundingScore = 0.9,
TotalTokens = 1000
};
}
private static AiClaimAttestation CreateSampleClaimAttestation()
{
return new AiClaimAttestation
{
ClaimId = $"claim-{Guid.NewGuid():N}",
RunId = "run-xyz",
TurnId = "turn-001",
TenantId = "tenant-test",
ClaimText = "Test claim",
ClaimDigest = "sha256:test",
GroundingScore = 0.85,
Timestamp = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
ContentDigest = "sha256:content-test"
};
}
}

View File

@@ -0,0 +1,143 @@
// <copyright file="AiClaimAttestationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.AdvisoryAI.Attestation.Models;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
/// <summary>
/// Tests for <see cref="AiClaimAttestation"/>.
/// </summary>
[Trait("Category", "Unit")]
public class AiClaimAttestationTests
{
[Fact]
public void PredicateType_IsCorrect()
{
AiClaimAttestation.PredicateType.Should().Be("https://stellaops.org/attestation/ai-claim/v1");
}
[Fact]
public void ComputeDigest_SameClaim_ReturnsSameDigest()
{
var attestation = CreateSampleClaimAttestation();
var digest1 = attestation.ComputeDigest();
var digest2 = attestation.ComputeDigest();
digest1.Should().Be(digest2);
digest1.Should().StartWith("sha256:");
}
[Fact]
public void ComputeDigest_DifferentClaims_ReturnsDifferentDigests()
{
var attestation1 = CreateSampleClaimAttestation();
var attestation2 = attestation1 with { ClaimText = "Different claim text" };
var digest1 = attestation1.ComputeDigest();
var digest2 = attestation2.ComputeDigest();
digest1.Should().NotBe(digest2);
}
[Fact]
public void FromClaimEvidence_CreatesValidAttestation()
{
var evidence = new ClaimEvidence
{
Text = "This component is affected by the vulnerability",
Position = 45,
Length = 47,
GroundedBy = ["stella://sbom/abc123", "stella://reach/api:func"],
GroundingScore = 0.95,
Verified = true,
Category = ClaimCategory.Factual
};
var attestation = AiClaimAttestation.FromClaimEvidence(
evidence,
runId: "run-123",
turnId: "turn-456",
tenantId: "tenant-xyz",
timestamp: DateTimeOffset.Parse("2026-01-09T12:00:00Z"));
attestation.ClaimText.Should().Be(evidence.Text);
attestation.RunId.Should().Be("run-123");
attestation.TurnId.Should().Be("turn-456");
attestation.TenantId.Should().Be("tenant-xyz");
attestation.GroundedBy.Should().HaveCount(2);
attestation.GroundingScore.Should().Be(0.95);
attestation.Verified.Should().BeTrue();
attestation.Category.Should().Be(ClaimCategory.Factual);
attestation.ClaimDigest.Should().StartWith("sha256:");
}
[Fact]
public void ClaimDigest_IsDeterministic()
{
var evidence1 = new ClaimEvidence
{
Text = "Same text",
Position = 0,
Length = 9,
GroundingScore = 0.9
};
var evidence2 = new ClaimEvidence
{
Text = "Same text",
Position = 100, // Different position
Length = 9,
GroundingScore = 0.5 // Different score
};
var attestation1 = AiClaimAttestation.FromClaimEvidence(
evidence1, "run-1", "turn-1", "tenant-1", DateTimeOffset.UtcNow);
var attestation2 = AiClaimAttestation.FromClaimEvidence(
evidence2, "run-2", "turn-2", "tenant-2", DateTimeOffset.UtcNow);
// ClaimDigest should be same because it's based on text only
attestation1.ClaimDigest.Should().Be(attestation2.ClaimDigest);
}
[Fact]
public void Attestation_WithGrounding_PreservesEvidenceUris()
{
var groundedBy = ImmutableArray.Create(
"stella://sbom/abc123",
"stella://reach/api:vulnFunc",
"stella://vex/CVE-2023-44487");
var attestation = CreateSampleClaimAttestation() with
{
GroundedBy = groundedBy
};
attestation.GroundedBy.Should().HaveCount(3);
attestation.GroundedBy.Should().Contain("stella://sbom/abc123");
}
private static AiClaimAttestation CreateSampleClaimAttestation()
{
return new AiClaimAttestation
{
ClaimId = "claim-abc123",
RunId = "run-xyz",
TurnId = "turn-001",
TenantId = "tenant-test",
ClaimText = "The component is affected by this vulnerability",
ClaimDigest = "sha256:abc123",
Category = ClaimCategory.Factual,
GroundedBy = ["stella://sbom/test"],
GroundingScore = 0.85,
Verified = true,
Timestamp = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
ContentDigest = "sha256:content123"
};
}
}

View File

@@ -0,0 +1,124 @@
// <copyright file="AiRunAttestationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.AdvisoryAI.Attestation.Models;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
/// <summary>
/// Tests for <see cref="AiRunAttestation"/>.
/// </summary>
[Trait("Category", "Unit")]
public class AiRunAttestationTests
{
[Fact]
public void ComputeDigest_SameAttestation_ReturnsSameDigest()
{
var attestation = CreateSampleAttestation();
var digest1 = attestation.ComputeDigest();
var digest2 = attestation.ComputeDigest();
digest1.Should().Be(digest2);
digest1.Should().StartWith("sha256:");
}
[Fact]
public void ComputeDigest_DifferentAttestations_ReturnsDifferentDigests()
{
var attestation1 = CreateSampleAttestation();
var attestation2 = attestation1 with { RunId = "run-different" };
var digest1 = attestation1.ComputeDigest();
var digest2 = attestation2.ComputeDigest();
digest1.Should().NotBe(digest2);
}
[Fact]
public void PredicateType_IsCorrect()
{
AiRunAttestation.PredicateType.Should().Be("https://stellaops.org/attestation/ai-run/v1");
}
[Fact]
public void Attestation_WithTurns_PreservesOrder()
{
var turns = new[]
{
CreateTurn("turn-1", TurnRole.User, "2026-01-09T12:00:00Z"),
CreateTurn("turn-2", TurnRole.Assistant, "2026-01-09T12:00:05Z"),
CreateTurn("turn-3", TurnRole.User, "2026-01-09T12:00:10Z")
};
var attestation = CreateSampleAttestation() with
{
Turns = [.. turns]
};
attestation.Turns.Should().HaveCount(3);
attestation.Turns[0].TurnId.Should().Be("turn-1");
attestation.Turns[1].TurnId.Should().Be("turn-2");
attestation.Turns[2].TurnId.Should().Be("turn-3");
}
[Fact]
public void Attestation_WithContext_PreservesContext()
{
var context = new AiRunContext
{
FindingId = "finding-123",
CveId = "CVE-2023-44487",
Component = "pkg:npm/http2@1.0.0"
};
var attestation = CreateSampleAttestation() with { Context = context };
attestation.Context.Should().NotBeNull();
attestation.Context!.FindingId.Should().Be("finding-123");
attestation.Context.CveId.Should().Be("CVE-2023-44487");
}
[Fact]
public void Attestation_DefaultStatus_IsCompleted()
{
var attestation = CreateSampleAttestation();
attestation.Status.Should().Be(AiRunStatus.Completed);
}
private static AiRunAttestation CreateSampleAttestation()
{
return new AiRunAttestation
{
RunId = "run-abc123",
TenantId = "tenant-xyz",
UserId = "user:alice@example.com",
StartedAt = DateTimeOffset.Parse("2026-01-09T12:00:00Z"),
CompletedAt = DateTimeOffset.Parse("2026-01-09T12:05:00Z"),
Model = new AiModelInfo
{
Provider = "anthropic",
ModelId = "claude-3-sonnet"
},
OverallGroundingScore = 0.92,
TotalTokens = 1500
};
}
private static AiTurnSummary CreateTurn(string turnId, TurnRole role, string timestamp)
{
return new AiTurnSummary
{
TurnId = turnId,
Role = role,
ContentDigest = $"sha256:turn-{turnId}",
Timestamp = DateTimeOffset.Parse(timestamp),
TokenCount = 100
};
}
}

View File

@@ -0,0 +1,231 @@
// <copyright file="InMemoryAiAttestationStoreTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AdvisoryAI.Attestation.Models;
using StellaOps.AdvisoryAI.Attestation.Storage;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
/// <summary>
/// Tests for <see cref="InMemoryAiAttestationStore"/>.
/// </summary>
[Trait("Category", "Unit")]
public class InMemoryAiAttestationStoreTests
{
private readonly InMemoryAiAttestationStore _store;
public InMemoryAiAttestationStoreTests()
{
_store = new InMemoryAiAttestationStore(NullLogger<InMemoryAiAttestationStore>.Instance);
}
[Fact]
public async Task StoreRunAttestation_ThenRetrieve_Works()
{
var attestation = CreateRunAttestation("run-1");
await _store.StoreRunAttestationAsync(attestation, CancellationToken.None);
var retrieved = await _store.GetRunAttestationAsync("run-1", CancellationToken.None);
retrieved.Should().NotBeNull();
retrieved!.RunId.Should().Be("run-1");
}
[Fact]
public async Task GetRunAttestation_NotFound_ReturnsNull()
{
var retrieved = await _store.GetRunAttestationAsync("non-existent", CancellationToken.None);
retrieved.Should().BeNull();
}
[Fact]
public async Task StoreClaimAttestation_ThenRetrieve_Works()
{
var claim = CreateClaimAttestation("run-1", "turn-1");
await _store.StoreClaimAttestationAsync(claim, CancellationToken.None);
var claims = await _store.GetClaimAttestationsAsync("run-1", CancellationToken.None);
claims.Should().HaveCount(1);
claims[0].TurnId.Should().Be("turn-1");
}
[Fact]
public async Task GetClaimAttestations_MultiplePerRun_ReturnsAll()
{
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), CancellationToken.None);
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-2"), CancellationToken.None);
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-3"), CancellationToken.None);
var claims = await _store.GetClaimAttestationsAsync("run-1", CancellationToken.None);
claims.Should().HaveCount(3);
}
[Fact]
public async Task GetClaimAttestationsByTurn_FiltersCorrectly()
{
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), CancellationToken.None);
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-2"), CancellationToken.None);
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), CancellationToken.None);
var turn1Claims = await _store.GetClaimAttestationsByTurnAsync("run-1", "turn-1", CancellationToken.None);
turn1Claims.Should().HaveCount(2);
turn1Claims.Should().OnlyContain(c => c.TurnId == "turn-1");
}
[Fact]
public async Task Exists_WhenStored_ReturnsTrue()
{
await _store.StoreRunAttestationAsync(CreateRunAttestation("run-1"), CancellationToken.None);
var exists = await _store.ExistsAsync("run-1", CancellationToken.None);
exists.Should().BeTrue();
}
[Fact]
public async Task Exists_WhenNotStored_ReturnsFalse()
{
var exists = await _store.ExistsAsync("non-existent", CancellationToken.None);
exists.Should().BeFalse();
}
[Fact]
public async Task StoreSignedEnvelope_ThenRetrieve_Works()
{
var envelope = new { Type = "DSSE", Payload = "test" };
await _store.StoreSignedEnvelopeAsync("run-1", envelope, CancellationToken.None);
var retrieved = await _store.GetSignedEnvelopeAsync("run-1", CancellationToken.None);
retrieved.Should().NotBeNull();
}
[Fact]
public async Task GetByTenant_FiltersCorrectly()
{
var now = DateTimeOffset.UtcNow;
var att1 = CreateRunAttestation("run-1", "tenant-a", now.AddMinutes(-30));
var att2 = CreateRunAttestation("run-2", "tenant-a", now.AddMinutes(-10));
var att3 = CreateRunAttestation("run-3", "tenant-b", now.AddMinutes(-20));
await _store.StoreRunAttestationAsync(att1, CancellationToken.None);
await _store.StoreRunAttestationAsync(att2, CancellationToken.None);
await _store.StoreRunAttestationAsync(att3, CancellationToken.None);
var tenantAResults = await _store.GetByTenantAsync(
"tenant-a",
now.AddHours(-1),
now,
CancellationToken.None);
tenantAResults.Should().HaveCount(2);
tenantAResults.Should().OnlyContain(a => a.TenantId == "tenant-a");
}
[Fact]
public async Task GetByTenant_FiltersTimeRangeCorrectly()
{
var now = DateTimeOffset.UtcNow;
var att1 = CreateRunAttestation("run-1", "tenant-a", now.AddHours(-3));
var att2 = CreateRunAttestation("run-2", "tenant-a", now.AddHours(-1));
var att3 = CreateRunAttestation("run-3", "tenant-a", now.AddMinutes(-30));
await _store.StoreRunAttestationAsync(att1, CancellationToken.None);
await _store.StoreRunAttestationAsync(att2, CancellationToken.None);
await _store.StoreRunAttestationAsync(att3, CancellationToken.None);
var results = await _store.GetByTenantAsync(
"tenant-a",
now.AddHours(-2),
now,
CancellationToken.None);
results.Should().HaveCount(2);
results.Should().NotContain(a => a.RunId == "run-1");
}
[Fact]
public async Task GetByContentDigest_Works()
{
var claim = CreateClaimAttestation("run-1", "turn-1", "sha256:test123");
await _store.StoreClaimAttestationAsync(claim, CancellationToken.None);
var retrieved = await _store.GetByContentDigestAsync("sha256:test123", CancellationToken.None);
retrieved.Should().NotBeNull();
retrieved!.ContentDigest.Should().Be("sha256:test123");
}
[Fact]
public async Task GetByContentDigest_NotFound_ReturnsNull()
{
var retrieved = await _store.GetByContentDigestAsync("sha256:nonexistent", CancellationToken.None);
retrieved.Should().BeNull();
}
[Fact]
public async Task Clear_RemovesAllData()
{
await _store.StoreRunAttestationAsync(CreateRunAttestation("run-1"), CancellationToken.None);
await _store.StoreClaimAttestationAsync(CreateClaimAttestation("run-1", "turn-1"), CancellationToken.None);
_store.Clear();
_store.RunAttestationCount.Should().Be(0);
_store.ClaimAttestationCount.Should().Be(0);
}
private static AiRunAttestation CreateRunAttestation(
string runId,
string tenantId = "test-tenant",
DateTimeOffset? startedAt = null)
{
return new AiRunAttestation
{
RunId = runId,
TenantId = tenantId,
UserId = "user-1",
StartedAt = startedAt ?? DateTimeOffset.UtcNow,
CompletedAt = DateTimeOffset.UtcNow,
Model = new AiModelInfo
{
ModelId = "gpt-4",
Provider = "openai"
},
Turns = ImmutableArray<AiTurnSummary>.Empty
};
}
private static AiClaimAttestation CreateClaimAttestation(
string runId,
string turnId,
string? contentDigest = null)
{
return new AiClaimAttestation
{
ClaimId = $"claim-{Guid.NewGuid():N}",
RunId = runId,
TurnId = turnId,
TenantId = "test-tenant",
ClaimText = "Test claim text",
ClaimDigest = "sha256:claimhash",
Timestamp = DateTimeOffset.UtcNow,
ClaimType = "vulnerability_assessment",
ContentDigest = contentDigest ?? $"sha256:{runId}-{turnId}"
};
}
}

View File

@@ -0,0 +1,264 @@
// <copyright file="AttestationServiceIntegrationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using StellaOps.AdvisoryAI.Attestation.Models;
using StellaOps.AdvisoryAI.Attestation.Storage;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests.Integration;
/// <summary>
/// Integration tests for AI attestation service.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-008
/// </summary>
[Trait("Category", "Integration")]
public sealed class AttestationServiceIntegrationTests : IAsyncLifetime
{
private ServiceProvider _serviceProvider = null!;
private IAiAttestationService _attestationService = null!;
private IAiAttestationStore _store = null!;
private TimeProvider _timeProvider = null!;
public ValueTask InitializeAsync()
{
var services = new ServiceCollection();
// Register all attestation services
services.AddAiAttestationServices();
services.AddInMemoryAiAttestationStore();
_serviceProvider = services.BuildServiceProvider();
_attestationService = _serviceProvider.GetRequiredService<IAiAttestationService>();
_store = _serviceProvider.GetRequiredService<IAiAttestationStore>();
_timeProvider = _serviceProvider.GetRequiredService<TimeProvider>();
return ValueTask.CompletedTask;
}
public async ValueTask DisposeAsync()
{
await _serviceProvider.DisposeAsync();
}
[Fact]
public async Task FullRunAttestationFlow_CreateSignVerify_Succeeds()
{
// Arrange
var attestation = CreateSampleRunAttestation("run-integration-001");
// Act - Create and sign
var createResult = await _attestationService.CreateRunAttestationAsync(attestation, sign: true);
// Assert creation - result has Digest property
Assert.NotNull(createResult.Digest);
Assert.StartsWith("sha256:", createResult.Digest);
// Act - Retrieve
var retrieved = await _attestationService.GetRunAttestationAsync("run-integration-001");
// Assert retrieval
Assert.NotNull(retrieved);
Assert.Equal(attestation.RunId, retrieved.RunId);
Assert.Equal(attestation.TenantId, retrieved.TenantId);
// Act - Verify
var verifyResult = await _attestationService.VerifyRunAttestationAsync("run-integration-001");
// Assert verification
Assert.True(verifyResult.Valid);
Assert.True(verifyResult.DigestValid);
}
[Fact]
public async Task FullClaimAttestationFlow_CreateSignVerify_Succeeds()
{
// Arrange - Create parent run first
var runAttestation = CreateSampleRunAttestation("run-integration-002");
await _attestationService.CreateRunAttestationAsync(runAttestation);
var claimAttestation = CreateSampleClaimAttestation("claim-001", "run-integration-002", "turn-001");
// Act - Create and sign
var createResult = await _attestationService.CreateClaimAttestationAsync(claimAttestation, sign: true);
// Assert creation
Assert.True(createResult.Success);
Assert.NotNull(createResult.ContentDigest);
// Act - Retrieve claims for run
var claims = await _attestationService.GetClaimAttestationsAsync("run-integration-002");
// Assert retrieval
Assert.Single(claims);
Assert.Equal("claim-001", claims[0].ClaimId);
// Act - Verify
var verifyResult = await _attestationService.VerifyClaimAttestationAsync("claim-001");
// Assert verification
Assert.True(verifyResult.Valid);
}
[Fact]
public async Task StorageRoundTrip_MultipleRuns_AllRetrievable()
{
// Arrange - Create multiple runs
var runs = Enumerable.Range(1, 5)
.Select(i => CreateSampleRunAttestation($"run-roundtrip-{i:D3}"))
.ToList();
// Act - Store all
foreach (var run in runs)
{
var result = await _attestationService.CreateRunAttestationAsync(run);
Assert.NotNull(result.Digest);
}
// Assert - All retrievable
foreach (var run in runs)
{
var retrieved = await _attestationService.GetRunAttestationAsync(run.RunId);
Assert.NotNull(retrieved);
Assert.Equal(run.RunId, retrieved.RunId);
}
}
[Fact]
public async Task StorageRoundTrip_MultipleClaimsPerRun_AllRetrievable()
{
// Arrange
var runId = "run-multiclaim-001";
var run = CreateSampleRunAttestation(runId);
await _attestationService.CreateRunAttestationAsync(run);
var claims = Enumerable.Range(1, 3)
.Select(i => CreateSampleClaimAttestation($"claim-mc-{i:D3}", runId, $"turn-{i:D3}"))
.ToList();
// Act - Store all claims
foreach (var claim in claims)
{
var result = await _attestationService.CreateClaimAttestationAsync(claim);
Assert.True(result.Success);
}
// Assert - All claims retrievable
var retrieved = await _attestationService.GetClaimAttestationsAsync(runId);
Assert.Equal(3, retrieved.Count);
}
[Fact]
public async Task QueryByTenant_ReturnsOnlyTenantRuns()
{
// Arrange
var tenant1Run = CreateSampleRunAttestation("run-tenant1-001", tenantId: "tenant-1");
var tenant2Run = CreateSampleRunAttestation("run-tenant2-001", tenantId: "tenant-2");
await _attestationService.CreateRunAttestationAsync(tenant1Run);
await _attestationService.CreateRunAttestationAsync(tenant2Run);
// Act
var tenant1Runs = await _attestationService.ListRecentAttestationsAsync("tenant-1", limit: 10);
var tenant2Runs = await _attestationService.ListRecentAttestationsAsync("tenant-2", limit: 10);
// Assert
Assert.Single(tenant1Runs);
Assert.Equal("run-tenant1-001", tenant1Runs[0].RunId);
Assert.Single(tenant2Runs);
Assert.Equal("run-tenant2-001", tenant2Runs[0].RunId);
}
[Fact]
public async Task VerificationFailure_TamperedContent_ReturnsInvalid()
{
// Arrange
var attestation = CreateSampleRunAttestation("run-tamper-001");
await _attestationService.CreateRunAttestationAsync(attestation, sign: true);
// Tamper with stored content by creating a modified attestation
var tampered = attestation with { UserId = "tampered-user" };
// Store the tampered version directly (bypassing service)
await _store.StoreRunAttestationAsync(tampered, CancellationToken.None);
// Act - Verify (should fail because digest won't match)
var verifyResult = await _attestationService.VerifyRunAttestationAsync("run-tamper-001");
// Assert
Assert.False(verifyResult.Valid);
Assert.NotNull(verifyResult.FailureReason);
}
[Fact]
public async Task VerificationFailure_NonExistentRun_ReturnsInvalid()
{
// Act
var verifyResult = await _attestationService.VerifyRunAttestationAsync("non-existent-run");
// Assert
Assert.False(verifyResult.Valid);
Assert.Contains("not found", verifyResult.FailureReason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task UnsignedAttestation_VerifiesDigestOnly()
{
// Arrange
var attestation = CreateSampleRunAttestation("run-unsigned-001");
// Act - Create without signing
var createResult = await _attestationService.CreateRunAttestationAsync(attestation, sign: false);
Assert.True(createResult.Success);
// Act - Verify
var verifyResult = await _attestationService.VerifyRunAttestationAsync("run-unsigned-001");
// Assert
Assert.True(verifyResult.Valid);
Assert.True(verifyResult.DigestValid);
Assert.Null(verifyResult.SignatureValid); // No signature to verify
}
private static AiRunAttestation CreateSampleRunAttestation(
string runId,
string tenantId = "test-tenant",
string userId = "test-user")
{
return new AiRunAttestation
{
RunId = runId,
TenantId = tenantId,
UserId = userId,
Model = new AiModelInfo
{
ModelId = "test-model",
Provider = "test-provider"
},
TotalTokens = 100,
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
CompletedAt = DateTimeOffset.UtcNow
};
}
private static AiClaimAttestation CreateSampleClaimAttestation(
string claimId,
string runId,
string turnId)
{
return new AiClaimAttestation
{
ClaimId = claimId,
RunId = runId,
TurnId = turnId,
TenantId = "test-tenant",
ClaimType = "test_claim",
ClaimText = "This is a test claim",
ClaimDigest = $"sha256:{claimId}",
ContentDigest = $"sha256:content-{claimId}",
Timestamp = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,167 @@
// <copyright file="PromptTemplateRegistryTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
/// <summary>
/// Tests for <see cref="PromptTemplateRegistry"/>.
/// </summary>
[Trait("Category", "Unit")]
public class PromptTemplateRegistryTests
{
private readonly FakeTimeProvider _timeProvider = new();
private readonly PromptTemplateRegistry _registry;
public PromptTemplateRegistryTests()
{
_registry = new PromptTemplateRegistry(
_timeProvider,
NullLogger<PromptTemplateRegistry>.Instance);
}
[Fact]
public void Register_ValidTemplate_StoresWithDigest()
{
_registry.Register("vuln-explanation", "1.0.0", "Explain this vulnerability: {{cve}}");
var info = _registry.GetTemplateInfo("vuln-explanation");
info.Should().NotBeNull();
info!.Name.Should().Be("vuln-explanation");
info.Version.Should().Be("1.0.0");
info.Digest.Should().StartWith("sha256:");
}
[Fact]
public void Register_SameTemplateTwice_UpdatesVersion()
{
_registry.Register("vuln-explanation", "1.0.0", "Template v1");
_registry.Register("vuln-explanation", "1.1.0", "Template v2");
var info = _registry.GetTemplateInfo("vuln-explanation");
info!.Version.Should().Be("1.1.0");
}
[Fact]
public void GetTemplateInfo_ByVersion_ReturnsCorrectVersion()
{
_registry.Register("vuln-explanation", "1.0.0", "Template v1");
_registry.Register("vuln-explanation", "1.1.0", "Template v2");
var v1 = _registry.GetTemplateInfo("vuln-explanation", "1.0.0");
var v2 = _registry.GetTemplateInfo("vuln-explanation", "1.1.0");
v1!.Version.Should().Be("1.0.0");
v2!.Version.Should().Be("1.1.0");
}
[Fact]
public void GetTemplateInfo_NotFound_ReturnsNull()
{
var info = _registry.GetTemplateInfo("non-existent");
info.Should().BeNull();
}
[Fact]
public void VerifyHash_MatchingHash_ReturnsTrue()
{
const string template = "Test template content";
_registry.Register("test", "1.0.0", template);
var info = _registry.GetTemplateInfo("test");
var result = _registry.VerifyHash("test", info!.Digest);
result.Should().BeTrue();
}
[Fact]
public void VerifyHash_NonMatchingHash_ReturnsFalse()
{
_registry.Register("test", "1.0.0", "Test template content");
var result = _registry.VerifyHash("test", "sha256:wronghash");
result.Should().BeFalse();
}
[Fact]
public void VerifyHash_NotFound_ReturnsFalse()
{
var result = _registry.VerifyHash("non-existent", "sha256:anyhash");
result.Should().BeFalse();
}
[Fact]
public void GetAllTemplates_ReturnsAllLatestVersions()
{
_registry.Register("template-a", "1.0.0", "Template A v1");
_registry.Register("template-a", "1.1.0", "Template A v2");
_registry.Register("template-b", "1.0.0", "Template B");
_registry.Register("template-c", "2.0.0", "Template C");
var all = _registry.GetAllTemplates();
all.Should().HaveCount(3);
all.Should().Contain(t => t.Name == "template-a" && t.Version == "1.1.0");
all.Should().Contain(t => t.Name == "template-b");
all.Should().Contain(t => t.Name == "template-c");
}
[Fact]
public void Register_DifferentContent_ProducesDifferentDigests()
{
_registry.Register("template-1", "1.0.0", "Content A");
_registry.Register("template-2", "1.0.0", "Content B");
var info1 = _registry.GetTemplateInfo("template-1");
var info2 = _registry.GetTemplateInfo("template-2");
info1!.Digest.Should().NotBe(info2!.Digest);
}
[Fact]
public void Register_SameContent_ProducesSameDigest()
{
const string content = "Same content";
_registry.Register("template-1", "1.0.0", content);
_registry.Register("template-2", "1.0.0", content);
var info1 = _registry.GetTemplateInfo("template-1");
var info2 = _registry.GetTemplateInfo("template-2");
info1!.Digest.Should().Be(info2!.Digest);
}
[Fact]
public void Register_NullName_Throws()
{
var act = () => _registry.Register(null!, "1.0.0", "content");
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Register_EmptyVersion_Throws()
{
var act = () => _registry.Register("name", "", "content");
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Register_EmptyTemplate_Throws()
{
var act = () => _registry.Register("name", "1.0.0", "");
act.Should().Throw<ArgumentException>();
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- xUnit1051 is informational - CancellationToken suggestion -->
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,192 @@
// <copyright file="FunctionBoundaryDetectorTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Reachability.Core.CveMapping;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.CveMapping;
/// <summary>
/// Tests for <see cref="FunctionBoundaryDetector"/>.
/// </summary>
[Trait("Category", "Unit")]
public class FunctionBoundaryDetectorTests
{
private readonly FunctionBoundaryDetector _detector = new();
[Fact]
public void DetectFunction_CSharpMethod_DetectsCorrectly()
{
// Arrange
var context = ImmutableArray.Create(
"namespace MyApp",
"{",
" public class MyService",
" {",
" public void ProcessData(string input)",
" {",
" var result = input.Trim();",
" return result;",
" }",
" }",
"}"
);
// Act
var result = _detector.DetectFunction(context, 7, ProgrammingLanguage.CSharp);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("ProcessData");
}
[Fact]
public void DetectFunction_PythonFunction_DetectsCorrectly()
{
// Arrange
var context = ImmutableArray.Create(
"class MyService:",
" def process_data(self, input):",
" result = input.strip()",
" return result",
"",
" def other_method(self):",
" pass"
);
// Act
var result = _detector.DetectFunction(context, 3, ProgrammingLanguage.Python);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("process_data");
}
[Fact]
public void DetectFunction_GoFunction_DetectsCorrectly()
{
// Arrange
var context = ImmutableArray.Create(
"package main",
"",
"func ProcessData(input string) string {",
" result := strings.TrimSpace(input)",
" return result",
"}"
);
// Act
var result = _detector.DetectFunction(context, 4, ProgrammingLanguage.Go);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("ProcessData");
}
[Fact]
public void DetectFunction_JavaScriptArrowFunction_DetectsCorrectly()
{
// Arrange
var context = ImmutableArray.Create(
"class MyService {",
" processData = (input) => {",
" const result = input.trim();",
" return result;",
" }",
"}"
);
// Act
var result = _detector.DetectFunction(context, 3, ProgrammingLanguage.JavaScript);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("processData");
}
[Fact]
public void DetectFunction_RustFunction_DetectsCorrectly()
{
// Arrange
var context = ImmutableArray.Create(
"impl MyService {",
" pub fn process_data(&self, input: &str) -> String {",
" let result = input.trim();",
" result.to_string()",
" }",
"}"
);
// Act
var result = _detector.DetectFunction(context, 3, ProgrammingLanguage.Rust);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("process_data");
}
[Fact]
public void DetectFunction_EmptyContext_ReturnsNull()
{
// Act
var result = _detector.DetectFunction([], 1, ProgrammingLanguage.CSharp);
// Assert
result.Should().BeNull();
}
[Fact]
public void DetectAllFunctions_MultipleFunctions_DetectsAll()
{
// Arrange
var context = ImmutableArray.Create(
"namespace MyApp",
"{",
" public class MyService",
" {",
" public void Method1()",
" {",
" }",
"",
" public void Method2()",
" {",
" }",
" }",
"}"
);
// Act
var result = _detector.DetectAllFunctions(context, ProgrammingLanguage.CSharp);
// Assert
result.Should().HaveCount(2);
result.Should().Contain(f => f.FullyQualifiedName.Contains("Method1"));
result.Should().Contain(f => f.FullyQualifiedName.Contains("Method2"));
}
[Fact]
public void DetectFunction_JavaMethod_DetectsWithFullyQualifiedName()
{
// Arrange
var context = ImmutableArray.Create(
"package org.example.service;",
"",
"public class UserService {",
" public void createUser(String name) {",
" // vulnerable code",
" }",
"}"
);
// Act
var result = _detector.DetectFunction(context, 5, ProgrammingLanguage.Java);
// Assert
result.Should().NotBeNull();
result!.Value.FullyQualifiedName.Should().Contain("createUser");
}
}

View File

@@ -0,0 +1,306 @@
// <copyright file="OsvEnricherTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Net;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Reachability.Core.CveMapping;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.CveMapping;
/// <summary>
/// Tests for <see cref="OsvEnricher"/>.
/// </summary>
[Trait("Category", "Unit")]
public class OsvEnricherTests
{
[Fact]
public async Task EnrichAsync_WhenVulnerabilityFound_ReturnsEnrichedResult()
{
// Arrange
var responseJson = CreateOsvVulnerabilityJson("GHSA-abc-123", "CVE-2024-1234");
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.EnrichAsync("CVE-2024-1234", CancellationToken.None);
// Assert
result.Found.Should().BeTrue();
result.CveId.Should().Be("CVE-2024-1234");
result.OsvId.Should().Be("GHSA-abc-123");
}
[Fact]
public async Task EnrichAsync_WhenVulnerabilityNotFound_ReturnsNotFoundResult()
{
// Arrange
var handler = new MockHttpMessageHandler(string.Empty, HttpStatusCode.NotFound);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.EnrichAsync("CVE-DOES-NOT-EXIST", CancellationToken.None);
// Assert
result.Found.Should().BeFalse();
result.CveId.Should().Be("CVE-DOES-NOT-EXIST");
}
[Fact]
public async Task GetVulnerabilityAsync_ReturnsVulnerabilityData()
{
// Arrange
var responseJson = CreateOsvVulnerabilityJson("GHSA-test-001", "CVE-2024-5678");
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.GetVulnerabilityAsync("GHSA-test-001", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be("GHSA-test-001");
result.Aliases.Should().Contain("CVE-2024-5678");
}
[Fact]
public async Task GetVulnerabilityAsync_WithAffectedPackages_ExtractsPackageInfo()
{
// Arrange
var responseJson = CreateOsvVulnerabilityWithPackages();
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.GetVulnerabilityAsync("GHSA-pkg-test", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Affected.Should().HaveCount(1);
result.Affected[0].Package.Should().NotBeNull();
result.Affected[0].Package!.Ecosystem.Should().Be("npm");
result.Affected[0].Package!.Name.Should().Be("lodash");
}
[Fact]
public async Task QueryByPackageAsync_ReturnsMatchingVulnerabilities()
{
// Arrange
var responseJson = CreateOsvQueryResponse();
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var results = await enricher.QueryByPackageAsync("npm", "lodash", "4.17.0", CancellationToken.None);
// Assert
results.Should().HaveCount(1);
results[0].Id.Should().Be("GHSA-query-001");
}
[Fact]
public async Task QueryByPackageAsync_WithNoResults_ReturnsEmptyList()
{
// Arrange
var handler = new MockHttpMessageHandler("{}", HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var results = await enricher.QueryByPackageAsync("npm", "safe-package", null, CancellationToken.None);
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task EnrichAsync_WithVersionRanges_ExtractsAffectedVersions()
{
// Arrange
var responseJson = CreateOsvVulnerabilityWithVersionRanges();
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.EnrichAsync("CVE-2024-9999", CancellationToken.None);
// Assert
result.Found.Should().BeTrue();
result.AffectedVersions.Should().NotBeEmpty();
var range = result.AffectedVersions[0];
range.IntroducedVersion.Should().Be("1.0.0");
range.FixedVersion.Should().Be("1.5.0");
}
[Fact]
public async Task EnrichAsync_WithFunctions_ExtractsVulnerableSymbols()
{
// Arrange
var responseJson = CreateOsvVulnerabilityWithFunctions();
var handler = new MockHttpMessageHandler(responseJson, HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.EnrichAsync("CVE-2024-FUNC", CancellationToken.None);
// Assert
result.Found.Should().BeTrue();
result.Symbols.Should().NotBeEmpty();
}
[Fact]
public async Task GetVulnerabilityAsync_WhenHttpError_ReturnsNull()
{
// Arrange
var handler = new MockHttpMessageHandler(string.Empty, HttpStatusCode.InternalServerError);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.GetVulnerabilityAsync("CVE-ERROR", CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GetVulnerabilityAsync_WhenInvalidJson_ReturnsNull()
{
// Arrange
var handler = new MockHttpMessageHandler("not valid json", HttpStatusCode.OK);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.osv.dev/") };
var enricher = new OsvEnricher(httpClient);
// Act
var result = await enricher.GetVulnerabilityAsync("CVE-INVALID", CancellationToken.None);
// Assert
result.Should().BeNull();
}
private static string CreateOsvVulnerabilityJson(string osvId, string cveId)
{
return $$"""
{
"id": "{{osvId}}",
"summary": "Test vulnerability",
"aliases": ["{{cveId}}"],
"affected": []
}
""";
}
private static string CreateOsvVulnerabilityWithPackages()
{
return """
{
"id": "GHSA-pkg-test",
"summary": "Package vulnerability",
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "lodash"
},
"ranges": []
}
]
}
""";
}
private static string CreateOsvVulnerabilityWithVersionRanges()
{
return """
{
"id": "GHSA-version-test",
"aliases": ["CVE-2024-9999"],
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "vulnerable-pkg"
},
"ranges": [
{
"type": "SEMVER",
"events": [
{"introduced": "1.0.0"},
{"fixed": "1.5.0"}
]
}
]
}
]
}
""";
}
private static string CreateOsvVulnerabilityWithFunctions()
{
return """
{
"id": "GHSA-func-test",
"aliases": ["CVE-2024-FUNC"],
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "vulnerable-lib"
},
"ecosystem_specific": {
"functions": ["vulnerable_function", "another_function"]
}
}
]
}
""";
}
private static string CreateOsvQueryResponse()
{
return """
{
"vulns": [
{
"id": "GHSA-query-001",
"summary": "Query result vulnerability",
"affected": []
}
]
}
""";
}
private sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly string _response;
private readonly HttpStatusCode _statusCode;
public MockHttpMessageHandler(string response, HttpStatusCode statusCode)
{
_response = response;
_statusCode = statusCode;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_response, System.Text.Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
}

View File

@@ -0,0 +1,181 @@
// <copyright file="UnifiedDiffParserTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.CveMapping;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.CveMapping;
/// <summary>
/// Tests for <see cref="UnifiedDiffParser"/>.
/// </summary>
[Trait("Category", "Unit")]
public class UnifiedDiffParserTests
{
private readonly UnifiedDiffParser _parser = new();
[Fact]
public void Parse_SimpleDiff_ExtractsFileAndHunks()
{
// Arrange
var diff = """
diff --git a/src/utils.py b/src/utils.py
--- a/src/utils.py
+++ b/src/utils.py
@@ -10,7 +10,7 @@ def process_data(input):
data = input.strip()
- result = eval(data)
+ result = safe_eval(data)
return result
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files.Should().HaveCount(1);
var file = result.Files[0];
file.OldPath.Should().Be("src/utils.py");
file.NewPath.Should().Be("src/utils.py");
file.Hunks.Should().HaveCount(1);
}
[Fact]
public void Parse_HunkWithAddedAndRemovedLines_ExtractsCorrectly()
{
// Arrange
var diff = """
diff --git a/src/file.cs b/src/file.cs
--- a/src/file.cs
+++ b/src/file.cs
@@ -5,6 +5,8 @@ namespace Test
{
public void Method()
{
- var x = 1;
+ var x = 2;
+ var y = 3;
}
}
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files.Should().HaveCount(1);
var hunk = result.Files[0].Hunks[0];
hunk.OldStart.Should().Be(5);
hunk.NewStart.Should().Be(5);
hunk.RemovedLines.Should().HaveCount(1);
hunk.AddedLines.Should().HaveCount(2);
}
[Fact]
public void Parse_NewFile_DetectsCorrectly()
{
// Arrange
var diff = """
diff --git a/src/newfile.cs b/src/newfile.cs
--- /dev/null
+++ b/src/newfile.cs
@@ -0,0 +1,5 @@
+namespace Test
+{
+ public class NewClass { }
+}
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files.Should().HaveCount(1);
var file = result.Files[0];
file.IsNewFile.Should().BeTrue();
file.NewPath.Should().Be("src/newfile.cs");
}
[Fact]
public void Parse_DeletedFile_DetectsCorrectly()
{
// Arrange
var diff = """
diff --git a/src/oldfile.cs b/src/oldfile.cs
--- a/src/oldfile.cs
+++ /dev/null
@@ -1,4 +0,0 @@
-namespace Test
-{
- public class OldClass { }
-}
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files.Should().HaveCount(1);
var file = result.Files[0];
file.IsDeleted.Should().BeTrue();
}
[Fact]
public void Parse_MultipleFiles_ParsesAll()
{
// Arrange
var diff = """
diff --git a/file1.cs b/file1.cs
--- a/file1.cs
+++ b/file1.cs
@@ -1,3 +1,3 @@
-old
+new
diff --git a/file2.cs b/file2.cs
--- a/file2.cs
+++ b/file2.cs
@@ -1,3 +1,3 @@
-old2
+new2
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files.Should().HaveCount(2);
}
[Fact]
public void Parse_WithFunctionContext_ExtractsFunctionName()
{
// Arrange
var diff = """
diff --git a/src/app.cs b/src/app.cs
--- a/src/app.cs
+++ b/src/app.cs
@@ -10,7 +10,7 @@ public void ProcessData(string input)
var data = input;
- return eval(data);
+ return safe_process(data);
""";
// Act
var result = _parser.Parse(diff);
// Assert
result.Files[0].Hunks[0].FunctionContext.Should().Be("public void ProcessData(string input)");
}
[Fact]
public void Parse_EmptyDiff_ReturnsEmptyResult()
{
// Arrange
var diff = "";
// Act & Assert
Assert.Throws<ArgumentException>(() => _parser.Parse(diff));
}
}

View File

@@ -0,0 +1,189 @@
// <copyright file="NativeSymbolNormalizerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.Symbols;
/// <summary>
/// Tests for <see cref="NativeSymbolNormalizer"/>.
/// </summary>
[Trait("Category", "Unit")]
public class NativeSymbolNormalizerTests
{
private readonly NativeSymbolNormalizer _normalizer = new();
[Fact]
public void SupportedSources_ContainsExpectedSources()
{
_normalizer.SupportedSources.Should().Contain(SymbolSource.ElfSymtab);
_normalizer.SupportedSources.Should().Contain(SymbolSource.PeExport);
_normalizer.SupportedSources.Should().Contain(SymbolSource.Dwarf);
_normalizer.SupportedSources.Should().Contain(SymbolSource.Pdb);
_normalizer.SupportedSources.Should().Contain(SymbolSource.EbpfUprobe);
}
[Theory]
[InlineData(SymbolSource.ElfSymtab, true)]
[InlineData(SymbolSource.PeExport, true)]
[InlineData(SymbolSource.Dwarf, true)]
[InlineData(SymbolSource.Roslyn, false)]
[InlineData(SymbolSource.JavaAsm, false)]
public void CanNormalize_ReturnsCorrectValue(SymbolSource source, bool expected)
{
_normalizer.CanNormalize(source).Should().Be(expected);
}
// Plain C symbols
[Theory]
[InlineData("ssl_do_handshake", "openssl.ssl", "_", "do_handshake")]
[InlineData("SSL_connect", "openssl.ssl", "_", "connect")]
[InlineData("EVP_EncryptInit_ex", "openssl.evp", "_", "EncryptInit_ex")]
[InlineData("sqlite3_prepare_v2", "sqlite3", "_", "prepare_v2")]
[InlineData("curl_easy_perform", "curl", "_", "easy_perform")]
[InlineData("png_create_read_struct", "libpng", "_", "create_read_struct")]
[InlineData("inflate", "zlib", "_", "")] // Special case - whole function is prefix
[InlineData("pthread_create", "pthread", "_", "create")]
[InlineData("my_custom_function", "native", "_", "my_custom_function")]
public void Normalize_PlainCSymbol_ExtractsNamespace(
string symbol, string expectedNs, string expectedType, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.ElfSymtab);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be(expectedNs.ToLowerInvariant());
result.Type.Should().Be(expectedType);
// For "inflate", the whole thing is the method
if (expectedMethod == "")
result.Method.Should().Be(symbol.ToLowerInvariant());
else
result.Method.Should().Be(expectedMethod.ToLowerInvariant());
}
// Itanium mangled names - basic demangling extracts namespace components
[Theory]
[InlineData("_ZN4llvm6Triple15setEnvironmentENS0_15EnvironmentTypeE", "llvm", "setenvironment")]
public void Normalize_ItaniumMangled_ParsesNamespace(
string symbol, string expectedNsContains, string expectedMethodContains)
{
var raw = new RawSymbol(symbol, SymbolSource.ElfSymtab);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
// Basic demangling may put all parts together - check method contains key part
result!.Method.ToLowerInvariant().Should().Contain(expectedMethodContains.ToLowerInvariant());
}
// MSVC mangled names
[Theory]
[InlineData("?lookup@JndiLookup@log4j@apache@org@@QEAA?AVString@@PEAV1@@Z", "jndilookup", "lookup")]
[InlineData("?ProcessData@MyClass@MyNamespace@@QEAAXH@Z", "myclass", "processdata")]
public void Normalize_MsvcMangled_ParsesComponents(
string symbol, string expectedTypeContains, string expectedMethodContains)
{
var raw = new RawSymbol(symbol, SymbolSource.PeExport);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Type.ToLowerInvariant().Should().Contain(expectedTypeContains.ToLowerInvariant());
result.Method.ToLowerInvariant().Should().Contain(expectedMethodContains.ToLowerInvariant());
}
// DWARF format - qualified C++ symbols
[Theory]
[InlineData("llvm::Module::getFunction(llvm::StringRef)", "llvm", "module", "getfunction")]
[InlineData("std::string::size()", "std", "string", "size")]
public void Normalize_DwarfFormat_ParsesComponents(
string symbol, string expectedNs, string expectedType, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.Dwarf);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.ToLowerInvariant().Should().Contain(expectedNs.ToLowerInvariant());
result.Type.ToLowerInvariant().Should().Be(expectedType.ToLowerInvariant());
result.Method.ToLowerInvariant().Should().Be(expectedMethod.ToLowerInvariant());
}
// Empty/invalid input
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Normalize_EmptyInput_ReturnsNull(string? symbol)
{
var raw = new RawSymbol(symbol ?? "", SymbolSource.ElfSymtab);
var result = _normalizer.Normalize(raw);
result.Should().BeNull();
}
[Fact]
public void TryNormalize_InvalidSymbol_ReturnsError()
{
var raw = new RawSymbol("", SymbolSource.ElfSymtab);
var success = _normalizer.TryNormalize(raw, out var canonical, out var error);
success.Should().BeFalse();
canonical.Should().BeNull();
error.Should().NotBeNullOrEmpty();
}
[Fact]
public void Normalize_PreservesOriginalSymbol()
{
var symbol = "ssl_do_handshake";
var raw = new RawSymbol(symbol, SymbolSource.ElfSymtab);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.OriginalSymbol.Should().Be(symbol);
result.Source.Should().Be(SymbolSource.ElfSymtab);
}
[Fact]
public void Normalize_PreservesPurl()
{
var raw = new RawSymbol("ssl_connect", SymbolSource.ElfSymtab, "pkg:conan/openssl@1.1.1");
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Purl.Should().Be("pkg:conan/openssl@1.1.1");
}
[Fact]
public void Normalize_GeneratesCanonicalId()
{
var raw = new RawSymbol("ssl_connect", SymbolSource.ElfSymtab);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.CanonicalId.Should().NotBeNullOrEmpty();
result.CanonicalId.Should().HaveLength(64); // SHA-256 hex
}
[Fact]
public void Normalize_SameSymbol_SameCanonicalId()
{
var raw1 = new RawSymbol("ssl_connect", SymbolSource.ElfSymtab);
var raw2 = new RawSymbol("ssl_connect", SymbolSource.ElfSymtab);
var result1 = _normalizer.Normalize(raw1);
var result2 = _normalizer.Normalize(raw2);
result1!.CanonicalId.Should().Be(result2!.CanonicalId);
}
}

View File

@@ -0,0 +1,256 @@
// <copyright file="ScriptSymbolNormalizerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.Symbols;
/// <summary>
/// Tests for <see cref="ScriptSymbolNormalizer"/>.
/// </summary>
[Trait("Category", "Unit")]
public class ScriptSymbolNormalizerTests
{
private readonly ScriptSymbolNormalizer _normalizer = new();
[Fact]
public void SupportedSources_ContainsExpectedSources()
{
_normalizer.SupportedSources.Should().Contain(SymbolSource.V8Profiler);
_normalizer.SupportedSources.Should().Contain(SymbolSource.PythonTrace);
_normalizer.SupportedSources.Should().Contain(SymbolSource.PhpXdebug);
}
[Theory]
[InlineData(SymbolSource.V8Profiler, true)]
[InlineData(SymbolSource.PythonTrace, true)]
[InlineData(SymbolSource.PhpXdebug, true)]
[InlineData(SymbolSource.Roslyn, false)]
[InlineData(SymbolSource.ElfSymtab, false)]
public void CanNormalize_ReturnsCorrectValue(SymbolSource source, bool expected)
{
_normalizer.CanNormalize(source).Should().Be(expected);
}
// V8 Profiler (JavaScript)
[Theory]
[InlineData("processRequest (server.js:123:45)", "_", "processrequest")]
[InlineData("lodash.template (lodash.js:1234:56)", "lodash", "template")]
[InlineData("Module._load (internal/modules/cjs/loader.js:789:10)", "module", "_load")]
[InlineData("Foo.bar (foo.js:1:1)", "foo", "bar")]
[InlineData("anonymous (app.js:12:3)", "_", "{anonymous}")]
public void Normalize_V8StackFrame_ParsesComponents(
string symbol, string expectedTypeOrNs, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
// Either namespace or type should contain the expected value
var combined = $"{result!.Namespace}.{result.Type}".ToLowerInvariant();
combined.Should().Contain(expectedTypeOrNs.ToLowerInvariant());
result.Method.ToLowerInvariant().Should().Be(expectedMethod.ToLowerInvariant());
}
[Fact]
public void Normalize_V8NodeModules_ExtractsPackage()
{
var raw = new RawSymbol("parse (node_modules/lodash/lodash.js:1:1)", SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Contain("lodash");
}
[Fact]
public void Normalize_V8ScopedPackage_ExtractsPackage()
{
var raw = new RawSymbol("render (node_modules/@angular/core/index.js:1:1)", SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Contain("@angular/core");
}
[Fact]
public void Normalize_V8SimpleFunction_ParsesMethod()
{
var raw = new RawSymbol("myFunction", SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Method.Should().Be("myfunction");
}
// Python Trace
[Theory]
[InlineData("django.template.base:Template.render", "django.template.base", "template", "render")]
[InlineData("package.module:function", "package.module", "_", "function")]
[InlineData("<module>:main", "_", "_", "main")]
[InlineData("os.path:join", "os.path", "_", "join")]
public void Normalize_PythonColon_ParsesComponents(
string symbol, string expectedNs, string expectedType, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.PythonTrace);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be(expectedNs.ToLowerInvariant());
result.Type.Should().Be(expectedType.ToLowerInvariant());
result.Method.Should().Be(expectedMethod.ToLowerInvariant());
}
[Theory]
[InlineData("django.template.Template.render", "django.template", "template", "render")]
[InlineData("os.path.join", "os.path", "_", "join")]
[InlineData("json.dumps", "json", "_", "dumps")]
public void Normalize_PythonDot_ParsesComponents(
string symbol, string expectedNs, string expectedType, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.PythonTrace);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be(expectedNs.ToLowerInvariant());
result.Type.Should().Be(expectedType.ToLowerInvariant());
result.Method.Should().Be(expectedMethod.ToLowerInvariant());
}
[Fact]
public void Normalize_PythonSimpleFunction_ParsesMethod()
{
var raw = new RawSymbol("process_data", SymbolSource.PythonTrace);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be("_");
result.Type.Should().Be("_");
result.Method.Should().Be("process_data");
}
// PHP Xdebug
[Theory]
[InlineData(@"App\Controllers\UserController->show", "app.controllers", "usercontroller", "show")]
[InlineData(@"Illuminate\Support\Str::random", "illuminate.support", "str", "random")]
[InlineData(@"MyClass->process", "_", "myclass", "process")]
public void Normalize_PhpMethod_ParsesComponents(
string symbol, string expectedNs, string expectedType, string expectedMethod)
{
var raw = new RawSymbol(symbol, SymbolSource.PhpXdebug);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be(expectedNs.ToLowerInvariant());
result.Type.Should().Be(expectedType.ToLowerInvariant());
result.Method.Should().Be(expectedMethod.ToLowerInvariant());
}
[Fact]
public void Normalize_PhpClosure_ParsesFile()
{
var raw = new RawSymbol("{closure:/var/www/app/routes.php:123-456}", SymbolSource.PhpXdebug);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be("routes");
result.Method.Should().Be("{closure}");
}
[Fact]
public void Normalize_PhpPlainFunction_ParsesMethod()
{
var raw = new RawSymbol("array_map", SymbolSource.PhpXdebug);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Namespace.Should().Be("_");
result.Type.Should().Be("_");
result.Method.Should().Be("array_map");
}
// Empty/invalid input
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Normalize_EmptyInput_ReturnsNull(string symbol)
{
var raw = new RawSymbol(symbol, SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().BeNull();
}
[Fact]
public void TryNormalize_InvalidSymbol_ReturnsError()
{
var raw = new RawSymbol("", SymbolSource.V8Profiler);
var success = _normalizer.TryNormalize(raw, out var canonical, out var error);
success.Should().BeFalse();
canonical.Should().BeNull();
error.Should().NotBeNullOrEmpty();
}
[Fact]
public void Normalize_PreservesOriginalSymbol()
{
var symbol = "lodash.template (lodash.js:1:1)";
var raw = new RawSymbol(symbol, SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.OriginalSymbol.Should().Be(symbol);
result.Source.Should().Be(SymbolSource.V8Profiler);
}
[Fact]
public void Normalize_PreservesPurl()
{
var raw = new RawSymbol("lodash.template", SymbolSource.V8Profiler, "pkg:npm/lodash@4.17.21");
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.Purl.Should().Be("pkg:npm/lodash@4.17.21");
}
[Fact]
public void Normalize_GeneratesCanonicalId()
{
var raw = new RawSymbol("lodash.template", SymbolSource.V8Profiler);
var result = _normalizer.Normalize(raw);
result.Should().NotBeNull();
result!.CanonicalId.Should().NotBeNullOrEmpty();
result.CanonicalId.Should().HaveLength(64); // SHA-256 hex
}
[Fact]
public void Normalize_DifferentSources_SameSymbol_SameCanonicalId()
{
// Same logical symbol from different script sources should produce same canonical ID
var pythonRaw = new RawSymbol("json.dumps", SymbolSource.PythonTrace);
var result = _normalizer.Normalize(pythonRaw);
result.Should().NotBeNull();
result!.CanonicalId.Should().NotBeNullOrEmpty();
}
}