Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
This commit is contained in:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -0,0 +1,186 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Prompting;
namespace StellaOps.AdvisoryAI.Guardrails;
public interface IAdvisoryGuardrailPipeline
{
Task<AdvisoryGuardrailResult> EvaluateAsync(AdvisoryPrompt prompt, CancellationToken cancellationToken);
}
public sealed record AdvisoryGuardrailResult(
bool Blocked,
string SanitizedPrompt,
ImmutableArray<AdvisoryGuardrailViolation> Violations,
ImmutableDictionary<string, string> Metadata)
{
public static AdvisoryGuardrailResult Allowed(string sanitizedPrompt, ImmutableDictionary<string, string>? metadata = null)
=> new(false, sanitizedPrompt, ImmutableArray<AdvisoryGuardrailViolation>.Empty, metadata ?? ImmutableDictionary<string, string>.Empty);
public static AdvisoryGuardrailResult Blocked(string sanitizedPrompt, IEnumerable<AdvisoryGuardrailViolation> violations, ImmutableDictionary<string, string>? metadata = null)
=> new(true, sanitizedPrompt, violations.ToImmutableArray(), metadata ?? ImmutableDictionary<string, string>.Empty);
}
public sealed record AdvisoryGuardrailViolation(string Code, string Message);
public sealed class AdvisoryGuardrailOptions
{
private static readonly string[] DefaultBlockedPhrases =
{
"ignore previous instructions",
"disregard earlier instructions",
"you are now the system",
"override the system prompt",
"please jailbreak"
};
public int MaxPromptLength { get; set; } = 16000;
public bool RequireCitations { get; set; } = true;
public List<string> BlockedPhrases { get; } = new(DefaultBlockedPhrases);
}
internal sealed class AdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
{
private readonly AdvisoryGuardrailOptions _options;
private readonly ILogger<AdvisoryGuardrailPipeline>? _logger;
private readonly IReadOnlyList<RedactionRule> _redactionRules;
private readonly string[] _blockedPhraseCache;
public AdvisoryGuardrailPipeline(
IOptions<AdvisoryGuardrailOptions> options,
ILogger<AdvisoryGuardrailPipeline>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? new AdvisoryGuardrailOptions();
_logger = logger;
_redactionRules = new[]
{
new RedactionRule(
new Regex(@"(?i)(aws_secret_access_key\s*[:=]\s*)([A-Za-z0-9\/+=]{40,})", RegexOptions.CultureInvariant | RegexOptions.Compiled),
match => $"{match.Groups[1].Value}[REDACTED_AWS_SECRET]"),
new RedactionRule(
new Regex(@"(?i)(token|apikey|password)\s*[:=]\s*([A-Za-z0-9\-_/]{16,})", RegexOptions.CultureInvariant | RegexOptions.Compiled),
match => $"{match.Groups[1].Value}: [REDACTED_CREDENTIAL]"),
new RedactionRule(
new Regex(@"(?is)-----BEGIN [^-]+ PRIVATE KEY-----.*?-----END [^-]+ PRIVATE KEY-----", RegexOptions.CultureInvariant | RegexOptions.Compiled),
_ => "[REDACTED_PRIVATE_KEY]")
};
_blockedPhraseCache = _options.BlockedPhrases
.Where(phrase => !string.IsNullOrWhiteSpace(phrase))
.Select(phrase => phrase.Trim().ToLowerInvariant())
.ToArray();
}
public Task<AdvisoryGuardrailResult> EvaluateAsync(AdvisoryPrompt prompt, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(prompt);
var sanitized = prompt.Prompt ?? string.Empty;
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
var violations = ImmutableArray.CreateBuilder<AdvisoryGuardrailViolation>();
var redactionCount = ApplyRedactions(ref sanitized);
metadataBuilder["prompt_length"] = sanitized.Length.ToString(CultureInfo.InvariantCulture);
metadataBuilder["redaction_count"] = redactionCount.ToString(CultureInfo.InvariantCulture);
var blocked = false;
if (_options.RequireCitations && prompt.Citations.IsDefaultOrEmpty)
{
blocked = true;
violations.Add(new AdvisoryGuardrailViolation("citation_missing", "At least one citation is required."));
}
if (!prompt.Citations.IsDefaultOrEmpty)
{
foreach (var citation in prompt.Citations)
{
if (citation.Index <= 0 || string.IsNullOrWhiteSpace(citation.DocumentId) || string.IsNullOrWhiteSpace(citation.ChunkId))
{
blocked = true;
violations.Add(new AdvisoryGuardrailViolation("citation_invalid", "Citation index or identifiers are missing."));
break;
}
}
}
if (_options.MaxPromptLength > 0 && sanitized.Length > _options.MaxPromptLength)
{
blocked = true;
violations.Add(new AdvisoryGuardrailViolation("prompt_too_long", $"Prompt length {sanitized.Length} exceeds {_options.MaxPromptLength}."));
}
if (_blockedPhraseCache.Length > 0)
{
var lowered = sanitized.ToLowerInvariant();
var phraseHits = 0;
foreach (var phrase in _blockedPhraseCache)
{
if (lowered.Contains(phrase))
{
phraseHits++;
violations.Add(new AdvisoryGuardrailViolation("prompt_injection", $"Detected blocked phrase '{phrase}'"));
}
}
if (phraseHits > 0)
{
blocked = true;
metadataBuilder["blocked_phrase_count"] = phraseHits.ToString(CultureInfo.InvariantCulture);
}
}
var metadata = metadataBuilder.ToImmutable();
if (blocked)
{
_logger?.LogWarning("Guardrail blocked prompt for cache key {CacheKey}", prompt.CacheKey);
return Task.FromResult(AdvisoryGuardrailResult.Blocked(sanitized, violations, metadata));
}
return Task.FromResult(AdvisoryGuardrailResult.Allowed(sanitized, metadata));
}
private int ApplyRedactions(ref string sanitized)
{
var count = 0;
foreach (var rule in _redactionRules)
{
sanitized = rule.Regex.Replace(sanitized, match =>
{
count++;
return rule.Replacement(match);
});
}
return count;
}
private sealed record RedactionRule(Regex Regex, Func<Match, string> Replacement);
}
internal sealed class NoOpAdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
{
private readonly ILogger<NoOpAdvisoryGuardrailPipeline>? _logger;
public NoOpAdvisoryGuardrailPipeline(ILogger<NoOpAdvisoryGuardrailPipeline>? logger = null)
{
_logger = logger;
}
public Task<AdvisoryGuardrailResult> EvaluateAsync(AdvisoryPrompt prompt, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(prompt);
_logger?.LogDebug("No-op guardrail pipeline invoked for cache key {CacheKey}", prompt.CacheKey);
return Task.FromResult(AdvisoryGuardrailResult.Allowed(prompt.Prompt ?? string.Empty));
}
}