tests fixes and sprints work

This commit is contained in:
master
2026-01-22 19:08:46 +02:00
parent c32fff8f86
commit 726d70dc7f
881 changed files with 134434 additions and 6228 deletions

View File

@@ -0,0 +1,20 @@
# Scanner.AiMlSecurity - Agent Instructions
## Module Overview
This library evaluates AI/ML supply chain metadata (model cards, training data,
provenance, bias/fairness, and safety claims) from parsed SBOMs.
## Key Components
- **AiMlSecurityContext** - Aggregates parsed SBOM data and options.
- **IAiMlSecurityCheck** - Analyzer contract for AI/ML checks.
- **AiMlSecurityReportFormatter** - JSON/text/PDF reporting.
- **AiGovernancePolicyLoader** - Loads AI governance policies (YAML/JSON).
## Required Reading
- `docs/modules/scanner/architecture.md`
- `src/Scanner/docs/ai-ml-security.md`
## Working Agreement
- Keep outputs deterministic (stable ordering, UTC timestamps).
- Avoid new external network calls; use offline fixtures for tests.
- Update sprint status and module docs when contracts change.

View File

@@ -0,0 +1,172 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.ML;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Analyzers;
using StellaOps.Scanner.AiMlSecurity.Models;
using StellaOps.Scanner.AiMlSecurity.Policy;
namespace StellaOps.Scanner.AiMlSecurity;
public interface IAiMlSecurityAnalyzer
{
Task<AiMlSecurityReport> AnalyzeAsync(
IReadOnlyList<ParsedComponent> mlComponents,
AiGovernancePolicy policy,
CancellationToken ct = default);
}
public sealed class AiMlSecurityAnalyzer : IAiMlSecurityAnalyzer
{
private readonly IReadOnlyList<IAiMlSecurityCheck> _checks;
private readonly TimeProvider _timeProvider;
private readonly IEmbeddingService? _embeddingService;
public AiMlSecurityAnalyzer(
IEnumerable<IAiMlSecurityCheck> checks,
TimeProvider? timeProvider = null,
IEmbeddingService? embeddingService = null)
{
_checks = (checks ?? Array.Empty<IAiMlSecurityCheck>()).ToList();
_timeProvider = timeProvider ?? TimeProvider.System;
_embeddingService = embeddingService;
}
public async Task<AiMlSecurityReport> AnalyzeAsync(
IReadOnlyList<ParsedComponent> mlComponents,
AiGovernancePolicy policy,
CancellationToken ct = default)
{
var context = AiMlSecurityContext.Create(mlComponents, policy, _timeProvider, _embeddingService);
var findings = new List<AiSecurityFinding>();
var riskAssessments = new List<AiRiskAssessment>();
AiModelInventory? inventory = null;
foreach (var check in _checks)
{
ct.ThrowIfCancellationRequested();
var result = await check.AnalyzeAsync(context, ct).ConfigureAwait(false);
if (!result.Findings.IsDefaultOrEmpty)
{
findings.AddRange(result.Findings);
}
if (!result.RiskAssessments.IsDefaultOrEmpty)
{
riskAssessments.AddRange(result.RiskAssessments);
}
inventory ??= result.Inventory;
}
var orderedFindings = findings
.OrderByDescending(f => f.Severity)
.ThenBy(f => f.Title, StringComparer.Ordinal)
.ThenBy(f => f.ComponentName ?? f.ComponentBomRef, StringComparer.Ordinal)
.ToImmutableArray();
var orderedAssessments = riskAssessments
.OrderBy(a => a.Category, StringComparer.Ordinal)
.ThenBy(a => a.ModelBomRef, StringComparer.Ordinal)
.ToImmutableArray();
var summary = BuildSummary(orderedFindings, inventory);
var complianceStatus = BuildComplianceStatus(policy, orderedFindings);
return new AiMlSecurityReport
{
Inventory = inventory ?? new AiModelInventory(),
Findings = orderedFindings,
RiskAssessments = orderedAssessments,
ComplianceStatus = complianceStatus,
Summary = summary,
PolicyVersion = policy.Version,
GeneratedAtUtc = _timeProvider.GetUtcNow()
};
}
private static AiMlSummary BuildSummary(
ImmutableArray<AiSecurityFinding> findings,
AiModelInventory? inventory)
{
if (findings.IsDefaultOrEmpty)
{
return new AiMlSummary
{
ModelCount = inventory?.Models.Length ?? 0,
DatasetCount = inventory?.TrainingDatasets.Length ?? 0
};
}
var bySeverity = findings
.GroupBy(f => f.Severity)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var highRiskModels = inventory?.Models.Count(m =>
!string.IsNullOrWhiteSpace(m.RiskCategory)
&& m.RiskCategory!.Equals("high", StringComparison.OrdinalIgnoreCase)) ?? 0;
return new AiMlSummary
{
TotalFindings = findings.Length,
ModelCount = inventory?.Models.Length ?? 0,
DatasetCount = inventory?.TrainingDatasets.Length ?? 0,
HighRiskModelCount = highRiskModels,
FindingsBySeverity = bySeverity
};
}
private static AiComplianceStatus BuildComplianceStatus(
AiGovernancePolicy policy,
ImmutableArray<AiSecurityFinding> findings)
{
var frameworks = GetFrameworks(policy);
var violations = findings.Length;
var isCompliant = violations == 0;
var statuses = frameworks
.Select(framework => new AiComplianceFrameworkStatus
{
Framework = framework,
IsCompliant = isCompliant,
ViolationCount = violations
})
.ToImmutableArray();
return new AiComplianceStatus
{
Frameworks = statuses
};
}
private static ImmutableArray<string> GetFrameworks(AiGovernancePolicy policy)
{
var frameworks = new List<string>();
if (!policy.ComplianceFrameworks.IsDefaultOrEmpty)
{
frameworks.AddRange(policy.ComplianceFrameworks
.Where(f => !string.IsNullOrWhiteSpace(f))
.Select(f => f.Trim()));
}
if (!string.IsNullOrWhiteSpace(policy.ComplianceFramework))
{
var framework = policy.ComplianceFramework!.Trim();
if (!frameworks.Any(existing => existing.Equals(framework, StringComparison.OrdinalIgnoreCase)))
{
frameworks.Add(framework);
}
}
if (frameworks.Count == 0)
{
frameworks.Add("custom");
}
return frameworks
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.AiMlSecurity.Analyzers;
using StellaOps.Scanner.AiMlSecurity.Policy;
namespace StellaOps.Scanner.AiMlSecurity;
public static class AiMlSecurityServiceCollectionExtensions
{
public static IServiceCollection AddAiMlSecurity(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IAiGovernancePolicyLoader, AiGovernancePolicyLoader>();
services.AddSingleton<IAiMlSecurityCheck, AiModelInventoryGenerator>();
services.AddSingleton<IAiMlSecurityCheck, ModelCardCompletenessAnalyzer>();
services.AddSingleton<IAiMlSecurityCheck, TrainingDataProvenanceAnalyzer>();
services.AddSingleton<IAiMlSecurityCheck, BiasFairnessAnalyzer>();
services.AddSingleton<IAiMlSecurityCheck, AiSafetyRiskAnalyzer>();
services.AddSingleton<IAiMlSecurityCheck, ModelProvenanceVerifier>();
services.AddSingleton<IAiMlSecurityCheck, ModelBinaryAnalyzer>();
services.AddSingleton<IAiMlSecurityAnalyzer, AiMlSecurityAnalyzer>();
return services;
}
}

View File

@@ -0,0 +1,176 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.ML;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Policy;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class AiMlSecurityContext
{
private readonly ImmutableArray<AiGovernanceExemption> _exemptions;
private readonly DateOnly _todayUtc;
private AiMlSecurityContext(
ImmutableArray<ParsedComponent> components,
ImmutableArray<ParsedComponent> modelComponents,
ImmutableArray<ParsedComponent> datasetComponents,
AiGovernancePolicy policy,
TimeProvider timeProvider,
IEmbeddingService? embeddingService)
{
Components = components;
ModelComponents = modelComponents;
DatasetComponents = datasetComponents;
Policy = policy;
TimeProvider = timeProvider;
EmbeddingService = embeddingService;
_exemptions = policy.Exemptions.IsDefault ? [] : policy.Exemptions;
_todayUtc = DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime);
}
public ImmutableArray<ParsedComponent> Components { get; }
public ImmutableArray<ParsedComponent> ModelComponents { get; }
public ImmutableArray<ParsedComponent> DatasetComponents { get; }
public AiGovernancePolicy Policy { get; }
public TimeProvider TimeProvider { get; }
public IEmbeddingService? EmbeddingService { get; }
public bool IsExempted(ParsedComponent component)
{
if (_exemptions.IsDefaultOrEmpty)
{
return false;
}
var name = component.Name ?? string.Empty;
var bomRef = component.BomRef ?? string.Empty;
foreach (var exemption in _exemptions)
{
if (exemption.ExpirationDate.HasValue && exemption.ExpirationDate.Value < _todayUtc)
{
continue;
}
var pattern = exemption.ModelPattern;
if (string.IsNullOrWhiteSpace(pattern))
{
continue;
}
if (MatchesPattern(name, pattern) || MatchesPattern(bomRef, pattern))
{
return true;
}
}
return false;
}
public static AiMlSecurityContext Create(
IReadOnlyList<ParsedComponent> components,
AiGovernancePolicy policy,
TimeProvider? timeProvider = null,
IEmbeddingService? embeddingService = null)
{
var allComponents = components?.ToImmutableArray() ?? [];
var models = allComponents.Where(IsModelComponent).ToImmutableArray();
var datasets = allComponents.Where(IsDatasetComponent).ToImmutableArray();
return new AiMlSecurityContext(
allComponents,
models,
datasets,
policy,
timeProvider ?? TimeProvider.System,
embeddingService);
}
private static bool IsModelComponent(ParsedComponent component)
{
if (component.ModelCard is not null)
{
return true;
}
var normalized = NormalizeType(component.Type);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
return normalized.Contains("machinelearning", StringComparison.Ordinal)
|| normalized.Contains("mlmodel", StringComparison.Ordinal)
|| normalized.Contains("aimodel", StringComparison.Ordinal);
}
private static bool IsDatasetComponent(ParsedComponent component)
{
if (component.DatasetMetadata is not null)
{
return true;
}
var normalized = NormalizeType(component.Type);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
return normalized.Contains("dataset", StringComparison.Ordinal);
}
private static string NormalizeType(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value.Trim().Replace("-", string.Empty).Replace("_", string.Empty)
.Replace(" ", string.Empty)
.ToLowerInvariant();
}
private static bool MatchesPattern(string input, string pattern)
{
if (string.IsNullOrEmpty(pattern))
{
return false;
}
var normalizedInput = input ?? string.Empty;
var normalizedPattern = pattern.Trim();
if (normalizedPattern == "*")
{
return true;
}
var wildcardIndex = normalizedPattern.IndexOf('*');
if (wildcardIndex < 0)
{
return normalizedInput.Equals(normalizedPattern, StringComparison.OrdinalIgnoreCase);
}
var parts = normalizedPattern.Split('*', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
return true;
}
var position = 0;
foreach (var part in parts)
{
var matchIndex = normalizedInput.IndexOf(part, position, StringComparison.OrdinalIgnoreCase);
if (matchIndex < 0)
{
return false;
}
position = matchIndex + part.Length;
}
return true;
}
}

View File

@@ -0,0 +1,20 @@
using System.Collections.Immutable;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed record AiMlSecurityResult
{
public static AiMlSecurityResult Empty { get; } = new();
public ImmutableArray<AiSecurityFinding> Findings { get; init; } = [];
public ImmutableArray<AiRiskAssessment> RiskAssessments { get; init; } = [];
public AiModelInventory? Inventory { get; init; }
}
public interface IAiMlSecurityCheck
{
Task<AiMlSecurityResult> AnalyzeAsync(
AiMlSecurityContext context,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,215 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class AiModelInventoryGenerator : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(AiMlSecurityContext context, CancellationToken ct = default)
{
var modelEntries = new List<AiModelEntry>();
var datasetEntries = new Dictionary<string, DatasetEntry>(StringComparer.OrdinalIgnoreCase);
var dependencies = new List<AiModelDependency>();
foreach (var datasetComponent in context.DatasetComponents)
{
var entry = BuildDatasetEntry(datasetComponent, null);
if (!string.IsNullOrWhiteSpace(entry.Name) && !datasetEntries.ContainsKey(entry.Name!))
{
datasetEntries[entry.Name!] = entry;
}
}
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
var card = component.ModelCard;
var datasets = card?.ModelParameters?.Datasets ?? [];
var datasetCount = datasets.IsDefaultOrEmpty ? 0 : datasets.Length;
foreach (var dataset in datasets)
{
var entry = BuildDatasetEntry(null, dataset);
if (!string.IsNullOrWhiteSpace(entry.Name) && !datasetEntries.ContainsKey(entry.Name!))
{
datasetEntries[entry.Name!] = entry;
}
if (!string.IsNullOrWhiteSpace(entry.Name))
{
dependencies.Add(new AiModelDependency
{
ModelBomRef = component.BomRef,
DependencyBomRef = entry.ComponentBomRef ?? entry.Name,
Relation = "dataset",
DependencyType = "training-data"
});
}
}
AppendLineageDependencies(component, dependencies);
var completeness = ModelCardScoring.GetCompleteness(card);
var hasSafety = ModelCardScoring.HasSafetyAssessment(card);
var hasFairness = ModelCardScoring.HasFairnessAssessment(card);
var hasProvenance = !component.Hashes.IsDefaultOrEmpty || component.ExternalReferences.Any();
modelEntries.Add(new AiModelEntry
{
BomRef = component.BomRef,
Name = component.Name,
Version = component.Version,
Type = component.Type,
Source = component.Publisher ?? component.Supplier?.Name,
HasModelCard = card is not null,
Completeness = completeness,
DatasetCount = datasetCount,
RiskCategory = ResolveRiskCategory(component, context.Policy.RiskCategories.HighRisk),
HasSafetyAssessment = hasSafety,
HasFairnessAssessment = hasFairness,
HasProvenanceEvidence = hasProvenance
});
}
return Task.FromResult(new AiMlSecurityResult
{
Inventory = new AiModelInventory
{
Models = modelEntries
.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.Version, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
TrainingDatasets = datasetEntries.Values
.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.Version, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
ModelDependencies = dependencies
.OrderBy(entry => entry.ModelBomRef, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.DependencyBomRef, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray()
}
});
}
private static DatasetEntry BuildDatasetEntry(
ParsedComponent? datasetComponent,
ParsedDatasetRef? datasetRef)
{
if (datasetComponent is not null)
{
var metadata = datasetComponent.DatasetMetadata;
return new DatasetEntry
{
Name = datasetComponent.Name,
Version = datasetComponent.Version,
Url = datasetComponent.ExternalReferences
.Select(reference => reference.Url)
.FirstOrDefault(url => !string.IsNullOrWhiteSpace(url)),
HasProvenance = metadata is not null
&& (!string.IsNullOrWhiteSpace(metadata.DataCollectionProcess)
|| !string.IsNullOrWhiteSpace(metadata.IntendedUse)),
HasSensitiveData = metadata?.HasSensitivePersonalInformation == true
|| (metadata is not null && !metadata.SensitivePersonalInformation.IsDefaultOrEmpty),
ConfidentialityLevel = metadata?.ConfidentialityLevel,
DatasetType = metadata?.DatasetType,
ComponentBomRef = datasetComponent.BomRef
};
}
return new DatasetEntry
{
Name = datasetRef?.Name,
Version = datasetRef?.Version,
Url = datasetRef?.Url,
HasProvenance = datasetRef is not null
&& (!string.IsNullOrWhiteSpace(datasetRef.Url) || !datasetRef.Hashes.IsDefaultOrEmpty),
ComponentBomRef = datasetRef?.Name
};
}
private static void AppendLineageDependencies(
ParsedComponent component,
List<AiModelDependency> dependencies)
{
var pedigree = component.Pedigree;
if (pedigree is null)
{
return;
}
foreach (var ancestor in pedigree.Ancestors)
{
if (string.IsNullOrWhiteSpace(ancestor.BomRef))
{
continue;
}
dependencies.Add(new AiModelDependency
{
ModelBomRef = component.BomRef,
DependencyBomRef = ancestor.BomRef,
Relation = "ancestor",
DependencyType = "model-lineage"
});
}
foreach (var variant in pedigree.Variants)
{
if (string.IsNullOrWhiteSpace(variant.BomRef))
{
continue;
}
dependencies.Add(new AiModelDependency
{
ModelBomRef = component.BomRef,
DependencyBomRef = variant.BomRef,
Relation = "variant",
DependencyType = "model-lineage"
});
}
}
private static string? ResolveRiskCategory(
ParsedComponent component,
ImmutableArray<string> highRiskCategories)
{
if (highRiskCategories.IsDefaultOrEmpty)
{
return null;
}
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(component.Type))
{
candidates.Add(component.Type);
}
var parameters = component.ModelCard?.ModelParameters;
if (!string.IsNullOrWhiteSpace(parameters?.Domain))
{
candidates.Add(parameters.Domain!);
}
if (component.ModelCard?.Considerations?.UseCases is { } useCases && !useCases.IsDefaultOrEmpty)
{
candidates.AddRange(useCases);
}
foreach (var category in highRiskCategories)
{
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate)
&& candidate.Contains(category, StringComparison.OrdinalIgnoreCase))
{
return "high";
}
}
}
return null;
}
}

View File

@@ -0,0 +1,136 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class AiSafetyRiskAnalyzer : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(AiMlSecurityContext context, CancellationToken ct = default)
{
var findings = new List<AiSecurityFinding>();
var assessments = new List<AiRiskAssessment>();
var highRiskCategories = context.Policy.RiskCategories.HighRisk;
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
var riskCategory = ResolveRiskCategory(component, highRiskCategories);
if (!string.IsNullOrWhiteSpace(riskCategory))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.HighRiskAiCategory,
Severity = Severity.High,
Title = "High-risk AI category",
Description = $"Model classified as high-risk category '{riskCategory}'.",
Remediation = "Ensure compliance with high-risk AI requirements and document oversight.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("riskCategory", riskCategory)
});
assessments.Add(new AiRiskAssessment
{
Category = "eu-ai-act",
Level = "high",
Description = $"Model falls into high-risk category '{riskCategory}'.",
ModelBomRef = component.BomRef,
Evidence = [riskCategory]
});
}
if (context.Policy.SafetyRequirements.RequireSafetyAssessment
&& !ModelCardScoring.HasSafetyAssessment(component.ModelCard))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.SafetyAssessmentMissing,
Severity = Severity.High,
Title = "Safety assessment missing",
Description = "Model card does not include safety risk assessment details.",
Remediation = "Provide safety risk assessment and mitigation details.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
}
if (context.Policy.RequireRiskAssessment && string.IsNullOrWhiteSpace(riskCategory))
{
assessments.Add(new AiRiskAssessment
{
Category = "risk-assessment",
Level = "unspecified",
Description = "Policy requires explicit risk assessment; none detected.",
ModelBomRef = component.BomRef,
Evidence = []
});
}
}
return Task.FromResult(new AiMlSecurityResult
{
Findings = findings.ToImmutableArray(),
RiskAssessments = assessments.ToImmutableArray()
});
}
private static string? ResolveRiskCategory(
ParsedComponent component,
ImmutableArray<string> highRiskCategories)
{
if (highRiskCategories.IsDefaultOrEmpty)
{
return null;
}
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(component.Type))
{
candidates.Add(component.Type);
}
var parameters = component.ModelCard?.ModelParameters;
if (!string.IsNullOrWhiteSpace(parameters?.Domain))
{
candidates.Add(parameters.Domain!);
}
if (!string.IsNullOrWhiteSpace(parameters?.InformationAboutApplication))
{
candidates.Add(parameters.InformationAboutApplication!);
}
if (component.ModelCard?.Considerations?.UseCases is { } useCases && !useCases.IsDefaultOrEmpty)
{
candidates.AddRange(useCases);
}
foreach (var category in highRiskCategories)
{
if (string.IsNullOrWhiteSpace(category))
{
continue;
}
foreach (var candidate in candidates)
{
if (candidate.Contains(category, StringComparison.OrdinalIgnoreCase))
{
return category.Trim();
}
}
}
return null;
}
}

View File

@@ -0,0 +1,48 @@
using System.Collections.Immutable;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class BiasFairnessAnalyzer : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(AiMlSecurityContext context, CancellationToken ct = default)
{
if (!context.Policy.TrainingDataRequirements.RequireBiasAssessment)
{
return Task.FromResult(AiMlSecurityResult.Empty);
}
var findings = new List<AiSecurityFinding>();
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
if (ModelCardScoring.HasFairnessAssessment(component.ModelCard))
{
continue;
}
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.BiasAssessmentMissing,
Severity = Severity.High,
Title = "Bias assessment missing",
Description = "Model card lacks fairness or bias assessment details.",
Remediation = "Document bias evaluation and mitigation strategies in modelCard.considerations.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
}
return Task.FromResult(new AiMlSecurityResult
{
Findings = findings.ToImmutableArray()
});
}
}

View File

@@ -0,0 +1,111 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.ML;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class ModelBinaryAnalyzer : IAiMlSecurityCheck
{
private static readonly string[] BinaryPathKeys =
{
"model:binaryPath",
"model:artifactPath",
"modelBinaryPath",
"modelFilePath"
};
private const long MaxBinaryBytes = 2 * 1024 * 1024;
public async Task<AiMlSecurityResult> AnalyzeAsync(AiMlSecurityContext context, CancellationToken ct = default)
{
if (context.EmbeddingService is null)
{
return AiMlSecurityResult.Empty;
}
var assessments = new List<AiRiskAssessment>();
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
var path = ResolveBinaryPath(component);
if (string.IsNullOrWhiteSpace(path))
{
continue;
}
if (!File.Exists(path))
{
assessments.Add(new AiRiskAssessment
{
Category = "binary-analysis",
Level = "missing",
Description = $"Model binary path not found: {path}.",
ModelBomRef = component.BomRef,
Evidence = []
});
continue;
}
var bytes = await ReadBinaryAsync(path, ct).ConfigureAwait(false);
var embedding = await context.EmbeddingService.GenerateEmbeddingAsync(
new EmbeddingInput(null, null, bytes, EmbeddingInputType.Instructions),
null,
ct).ConfigureAwait(false);
var matches = await context.EmbeddingService.FindSimilarAsync(embedding, ct: ct).ConfigureAwait(false);
var evidence = matches
.Select(match => $"{match.FunctionName}:{match.Similarity:F2}")
.ToImmutableArray();
assessments.Add(new AiRiskAssessment
{
Category = "binary-analysis",
Level = "completed",
Description = $"Computed embedding for model binary {Path.GetFileName(path)}.",
ModelBomRef = component.BomRef,
Evidence = evidence
});
}
return new AiMlSecurityResult
{
RiskAssessments = assessments.ToImmutableArray()
};
}
private static string? ResolveBinaryPath(ParsedComponent component)
{
foreach (var key in BinaryPathKeys)
{
if (component.Properties.TryGetValue(key, out var value)
&& !string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
private static async Task<byte[]> ReadBinaryAsync(string path, CancellationToken ct)
{
var info = new FileInfo(path);
if (info.Length <= MaxBinaryBytes)
{
return await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
}
var buffer = new byte[MaxBinaryBytes];
await using var stream = File.OpenRead(path);
var read = await stream.ReadAsync(buffer, ct).ConfigureAwait(false);
return read == buffer.Length ? buffer : buffer[..read];
}
}

View File

@@ -0,0 +1,145 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class ModelCardCompletenessAnalyzer : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(
AiMlSecurityContext context,
CancellationToken ct = default)
{
var findings = new List<AiSecurityFinding>();
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
var card = component.ModelCard;
if (card is null)
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.MissingModelCard,
Severity = Severity.High,
Title = "Missing model card",
Description = "Model component does not provide a model card.",
Remediation = "Attach a modelCard section with required metadata.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
continue;
}
var completeness = ModelCardScoring.GetCompleteness(card);
var minimum = context.Policy.ModelCardRequirements.MinimumCompleteness;
if (minimum != AiModelCardCompleteness.None && completeness < minimum)
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.IncompleteModelCard,
Severity = completeness <= AiModelCardCompleteness.Minimal ? Severity.High : Severity.Medium,
Title = "Incomplete model card",
Description = $"Model card completeness is {completeness} but policy requires {minimum}.",
Remediation = "Populate missing model card sections to meet policy requirements.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("completeness", completeness.ToString())
.Add("required", minimum.ToString())
});
}
var missingSections = GetMissingSections(card, context.Policy.ModelCardRequirements.RequiredSections);
if (!missingSections.IsDefaultOrEmpty)
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.IncompleteModelCard,
Severity = Severity.Medium,
Title = "Model card missing required sections",
Description = "Missing required model card sections: " + string.Join(", ", missingSections),
Remediation = "Provide the required sections in modelCard.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("missingSections", string.Join(",", missingSections))
});
}
if (!ModelCardScoring.HasPerformanceMetrics(card))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.MissingPerformanceMetrics,
Severity = Severity.Medium,
Title = "Missing performance metrics",
Description = "Model card does not include performance metrics in quantitative analysis.",
Remediation = "Add performance metrics and evaluation results.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
}
}
return Task.FromResult(new AiMlSecurityResult
{
Findings = findings.ToImmutableArray()
});
}
private static ImmutableArray<string> GetMissingSections(
ParsedModelCard card,
ImmutableArray<string> requiredSections)
{
if (requiredSections.IsDefaultOrEmpty)
{
return [];
}
var missing = new List<string>();
foreach (var section in requiredSections)
{
if (string.IsNullOrWhiteSpace(section))
{
continue;
}
if (!HasSection(card, section.Trim()))
{
missing.Add(section.Trim());
}
}
return missing
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static bool HasSection(ParsedModelCard card, string section)
{
var normalized = section.Replace(" ", string.Empty).ToLowerInvariant();
return normalized switch
{
"modelparameters" => card.ModelParameters is not null,
"quantitativeanalysis" => card.QuantitativeAnalysis is not null,
"considerations" => card.Considerations is not null,
"considerations.ethicalconsiderations" =>
card.Considerations is not null && !card.Considerations.EthicalConsiderations.IsDefaultOrEmpty,
"considerations.fairnessassessments" =>
card.Considerations is not null && !card.Considerations.FairnessAssessments.IsDefaultOrEmpty,
_ => false
};
}
}

View File

@@ -0,0 +1,110 @@
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
internal static class ModelCardScoring
{
public static AiModelCardCompleteness GetCompleteness(ParsedModelCard? card)
{
if (card is null)
{
return AiModelCardCompleteness.None;
}
var hasParameters = HasModelParameters(card.ModelParameters);
var hasQuantitative = HasQuantitativeAnalysis(card.QuantitativeAnalysis);
var hasConsiderations = HasConsiderations(card.Considerations);
if (!hasParameters && !hasQuantitative && !hasConsiderations)
{
return AiModelCardCompleteness.Minimal;
}
if (hasParameters && !hasQuantitative && !hasConsiderations)
{
return AiModelCardCompleteness.Basic;
}
if (hasParameters && hasQuantitative && !hasConsiderations)
{
return AiModelCardCompleteness.Standard;
}
if (hasParameters && hasQuantitative && hasConsiderations)
{
return AiModelCardCompleteness.Complete;
}
if (hasParameters && hasConsiderations && !hasQuantitative)
{
return AiModelCardCompleteness.Basic;
}
if (hasQuantitative && hasConsiderations && !hasParameters)
{
return AiModelCardCompleteness.Standard;
}
return AiModelCardCompleteness.Basic;
}
public static bool HasPerformanceMetrics(ParsedModelCard? card)
{
var metrics = card?.QuantitativeAnalysis?.PerformanceMetrics;
return metrics is not null && !metrics.Value.IsDefaultOrEmpty;
}
public static bool HasFairnessAssessment(ParsedModelCard? card)
{
var fairness = card?.Considerations?.FairnessAssessments;
return fairness is not null && !fairness.Value.IsDefaultOrEmpty;
}
public static bool HasSafetyAssessment(ParsedModelCard? card)
{
return !string.IsNullOrWhiteSpace(card?.ModelParameters?.SafetyRiskAssessment);
}
private static bool HasModelParameters(ParsedModelParameters? parameters)
{
if (parameters is null)
{
return false;
}
return !string.IsNullOrWhiteSpace(parameters.Task)
|| !string.IsNullOrWhiteSpace(parameters.ArchitectureFamily)
|| !string.IsNullOrWhiteSpace(parameters.ModelArchitecture)
|| !parameters.Datasets.IsDefaultOrEmpty
|| !parameters.Inputs.IsDefaultOrEmpty
|| !parameters.Outputs.IsDefaultOrEmpty
|| !string.IsNullOrWhiteSpace(parameters.TypeOfModel)
|| !string.IsNullOrWhiteSpace(parameters.Domain);
}
private static bool HasQuantitativeAnalysis(ParsedQuantitativeAnalysis? analysis)
{
if (analysis is null)
{
return false;
}
return !analysis.PerformanceMetrics.IsDefaultOrEmpty
|| !analysis.Graphics.IsDefaultOrEmpty;
}
private static bool HasConsiderations(ParsedConsiderations? considerations)
{
if (considerations is null)
{
return false;
}
return !considerations.Users.IsDefaultOrEmpty
|| !considerations.UseCases.IsDefaultOrEmpty
|| !considerations.TechnicalLimitations.IsDefaultOrEmpty
|| !considerations.EthicalConsiderations.IsDefaultOrEmpty
|| !considerations.FairnessAssessments.IsDefaultOrEmpty;
}
}

View File

@@ -0,0 +1,188 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
using StellaOps.Scanner.AiMlSecurity.Policy;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class ModelProvenanceVerifier : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(AiMlSecurityContext context, CancellationToken ct = default)
{
var findings = new List<AiSecurityFinding>();
var provenancePolicy = context.Policy.ProvenanceRequirements;
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
var hasHash = !component.Hashes.IsDefaultOrEmpty;
var hasSignature = HasSignature(component);
var source = ResolveSource(component);
var hasTrustedSource = HasTrustedSource(source, provenancePolicy);
if ((provenancePolicy.RequireHash && !hasHash)
|| (provenancePolicy.RequireSignature && !hasSignature)
|| (!provenancePolicy.TrustedSources.IsDefaultOrEmpty && !hasTrustedSource))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.UnverifiedModelProvenance,
Severity = provenancePolicy.RequireSignature ? Severity.High : Severity.Medium,
Title = "Unverified model provenance",
Description = "Model provenance does not meet policy requirements.",
Remediation = "Provide hashes/signatures and trusted source references.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("hasHash", hasHash.ToString())
.Add("hasSignature", hasSignature.ToString())
.Add("source", source ?? string.Empty)
});
}
if (component.Modified || HasLineage(component))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.ModelDriftRisk,
Severity = Severity.Medium,
Title = "Model drift risk",
Description = "Model indicates modifications or fine-tuning lineage.",
Remediation = "Review fine-tuning lineage and validate drift monitoring.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
}
if (IsAdversarialVulnerable(component))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.AdversarialVulnerability,
Severity = Severity.High,
Title = "Adversarial vulnerability flagged",
Description = "Model indicates adversarial robustness concerns.",
Remediation = "Perform adversarial testing and mitigation.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
}
}
return Task.FromResult(new AiMlSecurityResult
{
Findings = findings.ToImmutableArray()
});
}
private static bool HasSignature(ParsedComponent component)
{
if (component.ExternalReferences.Any(reference =>
(reference.Type ?? string.Empty).Contains("signature", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
foreach (var pair in component.Properties)
{
if (pair.Key.Contains("signature", StringComparison.OrdinalIgnoreCase)
&& IsTruthy(pair.Value))
{
return true;
}
}
return false;
}
private static string? ResolveSource(ParsedComponent component)
{
if (!string.IsNullOrWhiteSpace(component.Publisher))
{
return component.Publisher;
}
if (!string.IsNullOrWhiteSpace(component.Supplier?.Name))
{
return component.Supplier?.Name;
}
var external = component.ExternalReferences
.Select(reference => reference.Url)
.FirstOrDefault(url => !string.IsNullOrWhiteSpace(url));
return external;
}
private static bool HasTrustedSource(string? source, AiProvenanceRequirements policy)
{
if (string.IsNullOrWhiteSpace(source))
{
return false;
}
var normalized = source.ToLowerInvariant();
return policy.TrustedSources.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase))
|| policy.KnownModelHubs.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
private static bool HasLineage(ParsedComponent component)
{
var pedigree = component.Pedigree;
if (pedigree is null)
{
return false;
}
return !pedigree.Ancestors.IsDefaultOrEmpty || !pedigree.Variants.IsDefaultOrEmpty;
}
private static bool IsAdversarialVulnerable(ParsedComponent component)
{
if (component.Properties.TryGetValue("ai:adversarialVulnerability", out var value)
&& IsTruthy(value))
{
return true;
}
if (component.Properties.TryGetValue("ai:adversarial", out var shorthand)
&& IsTruthy(shorthand))
{
return true;
}
if (component.ModelCard?.Considerations?.TechnicalLimitations is { } limitations)
{
foreach (var limitation in limitations)
{
if (limitation.Contains("adversarial", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
private static bool IsTruthy(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
return value.Equals("true", StringComparison.OrdinalIgnoreCase)
|| value.Equals("yes", StringComparison.OrdinalIgnoreCase)
|| value.Equals("1", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,165 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class TrainingDataProvenanceAnalyzer : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(
AiMlSecurityContext context,
CancellationToken ct = default)
{
var findings = new List<AiSecurityFinding>();
var datasetIndex = BuildDatasetIndex(context.DatasetComponents);
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
var card = component.ModelCard;
var datasets = card?.ModelParameters?.Datasets ?? [];
if (context.Policy.TrainingDataRequirements.RequireProvenance
&& datasets.IsDefaultOrEmpty)
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.UnknownTrainingData,
Severity = Severity.High,
Title = "Unknown training data",
Description = "Model card does not list any training datasets.",
Remediation = "Provide dataset provenance in modelCard.modelParameters.datasets.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
continue;
}
foreach (var dataset in datasets)
{
var name = dataset.Name ?? string.Empty;
var hasProvenance = HasDatasetProvenance(dataset, datasetIndex, name);
if (context.Policy.TrainingDataRequirements.RequireProvenance && !hasProvenance)
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.UnknownTrainingData,
Severity = Severity.Medium,
Title = "Incomplete training data provenance",
Description = $"Dataset '{name}' is missing provenance details.",
Remediation = "Add dataset source, collection process, or hashes.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
DatasetName = name
});
}
if (!context.Policy.TrainingDataRequirements.SensitiveDataAllowed
&& HasSensitiveData(datasetIndex, name, card))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.SensitiveDataInTraining,
Severity = Severity.High,
Title = "Sensitive data in training set",
Description = $"Dataset '{name}' indicates sensitive personal information.",
Remediation = "Remove sensitive data or document allowed processing.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
DatasetName = name
});
}
}
}
return Task.FromResult(new AiMlSecurityResult
{
Findings = findings.ToImmutableArray()
});
}
private static Dictionary<string, ParsedComponent> BuildDatasetIndex(
ImmutableArray<ParsedComponent> datasets)
{
var index = new Dictionary<string, ParsedComponent>(StringComparer.OrdinalIgnoreCase);
foreach (var dataset in datasets)
{
var name = dataset.Name;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
if (!index.ContainsKey(name))
{
index[name] = dataset;
}
}
return index;
}
private static bool HasDatasetProvenance(
ParsedDatasetRef dataset,
Dictionary<string, ParsedComponent> datasetIndex,
string name)
{
if (!string.IsNullOrWhiteSpace(dataset.Url) || !dataset.Hashes.IsDefaultOrEmpty)
{
return true;
}
if (datasetIndex.TryGetValue(name, out var component)
&& component.DatasetMetadata is { } metadata)
{
return !string.IsNullOrWhiteSpace(metadata.DataCollectionProcess)
|| !string.IsNullOrWhiteSpace(metadata.DatasetType)
|| !string.IsNullOrWhiteSpace(metadata.DataPreprocessing)
|| !string.IsNullOrWhiteSpace(metadata.IntendedUse);
}
return false;
}
private static bool HasSensitiveData(
Dictionary<string, ParsedComponent> datasetIndex,
string name,
ParsedModelCard? card)
{
if (card?.ModelParameters?.UseSensitivePersonalInformation == true)
{
return true;
}
if (card?.ModelParameters?.SensitivePersonalInformation is { } modelSensitive
&& !modelSensitive.IsDefaultOrEmpty)
{
return true;
}
if (datasetIndex.TryGetValue(name, out var dataset)
&& dataset.DatasetMetadata is { } metadata)
{
if (metadata.HasSensitivePersonalInformation == true)
{
return true;
}
if (!metadata.SensitivePersonalInformation.IsDefaultOrEmpty)
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,137 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.AiMlSecurity.Models;
public enum Severity
{
Critical,
High,
Medium,
Low,
Info,
Unknown
}
public enum AiSecurityFindingType
{
MissingModelCard,
IncompleteModelCard,
UnknownTrainingData,
BiasAssessmentMissing,
SafetyAssessmentMissing,
UnverifiedModelProvenance,
SensitiveDataInTraining,
HighRiskAiCategory,
MissingPerformanceMetrics,
ModelDriftRisk,
AdversarialVulnerability
}
public enum AiModelCardCompleteness
{
None,
Minimal,
Basic,
Standard,
Complete
}
public sealed record AiMlSecurityReport
{
public string? PolicyVersion { get; init; }
public DateTimeOffset GeneratedAtUtc { get; init; }
public AiModelInventory Inventory { get; init; } = new();
public ImmutableArray<AiSecurityFinding> Findings { get; init; } = [];
public ImmutableArray<AiRiskAssessment> RiskAssessments { get; init; } = [];
public AiComplianceStatus ComplianceStatus { get; init; } = new();
public AiMlSummary Summary { get; init; } = new();
}
public sealed record AiModelInventory
{
public ImmutableArray<AiModelEntry> Models { get; init; } = [];
public ImmutableArray<DatasetEntry> TrainingDatasets { get; init; } = [];
public ImmutableArray<AiModelDependency> ModelDependencies { get; init; } = [];
}
public sealed record AiModelEntry
{
public string? BomRef { get; init; }
public string? Name { get; init; }
public string? Version { get; init; }
public string? Type { get; init; }
public string? Source { get; init; }
public bool HasModelCard { get; init; }
public AiModelCardCompleteness Completeness { get; init; } = AiModelCardCompleteness.None;
public int DatasetCount { get; init; }
public string? RiskCategory { get; init; }
public bool HasSafetyAssessment { get; init; }
public bool HasFairnessAssessment { get; init; }
public bool HasProvenanceEvidence { get; init; }
}
public sealed record DatasetEntry
{
public string? Name { get; init; }
public string? Version { get; init; }
public string? Url { get; init; }
public bool HasProvenance { get; init; }
public bool HasSensitiveData { get; init; }
public string? ConfidentialityLevel { get; init; }
public string? DatasetType { get; init; }
public string? ComponentBomRef { get; init; }
}
public sealed record AiModelDependency
{
public string? ModelBomRef { get; init; }
public string? DependencyBomRef { get; init; }
public string? Relation { get; init; }
public string? DependencyType { get; init; }
}
public sealed record AiSecurityFinding
{
public AiSecurityFindingType Type { get; init; }
public Severity Severity { get; init; } = Severity.Unknown;
public string Title { get; init; } = string.Empty;
public string? Description { get; init; }
public string? Remediation { get; init; }
public string? ComponentName { get; init; }
public string? ComponentBomRef { get; init; }
public string? ModelName { get; init; }
public string? DatasetName { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
public sealed record AiRiskAssessment
{
public string Category { get; init; } = string.Empty;
public string Level { get; init; } = string.Empty;
public string? Description { get; init; }
public string? ModelBomRef { get; init; }
public ImmutableArray<string> Evidence { get; init; } = [];
}
public sealed record AiComplianceStatus
{
public ImmutableArray<AiComplianceFrameworkStatus> Frameworks { get; init; } = [];
}
public sealed record AiComplianceFrameworkStatus
{
public string Framework { get; init; } = string.Empty;
public bool IsCompliant { get; init; }
public int ViolationCount { get; init; }
}
public sealed record AiMlSummary
{
public int TotalFindings { get; init; }
public int ModelCount { get; init; }
public int DatasetCount { get; init; }
public int HighRiskModelCount { get; init; }
public ImmutableDictionary<Severity, int> FindingsBySeverity { get; init; } =
ImmutableDictionary<Severity, int>.Empty;
}

View File

@@ -0,0 +1,63 @@
using System.Collections.Immutable;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Policy;
public sealed record AiGovernancePolicy
{
public string? Version { get; init; }
public string? ComplianceFramework { get; init; }
public ImmutableArray<string> ComplianceFrameworks { get; init; } = [];
public AiModelCardRequirements ModelCardRequirements { get; init; } = new();
public AiTrainingDataRequirements TrainingDataRequirements { get; init; } = new();
public AiRiskCategories RiskCategories { get; init; } = new();
public AiSafetyRequirements SafetyRequirements { get; init; } = new();
public AiProvenanceRequirements ProvenanceRequirements { get; init; } = new();
public bool RequireRiskAssessment { get; init; }
public ImmutableArray<AiGovernanceExemption> Exemptions { get; init; } = [];
}
public sealed record AiModelCardRequirements
{
public AiModelCardCompleteness MinimumCompleteness { get; init; } = AiModelCardCompleteness.Basic;
public ImmutableArray<string> RequiredSections { get; init; } = [];
}
public sealed record AiTrainingDataRequirements
{
public bool RequireProvenance { get; init; } = true;
public bool SensitiveDataAllowed { get; init; }
public bool RequireBiasAssessment { get; init; } = true;
}
public sealed record AiRiskCategories
{
public ImmutableArray<string> HighRisk { get; init; } = [];
}
public sealed record AiSafetyRequirements
{
public bool RequireSafetyAssessment { get; init; } = true;
public AiHumanOversightRequirements HumanOversightRequired { get; init; } = new();
}
public sealed record AiHumanOversightRequirements
{
public bool ForHighRisk { get; init; } = true;
}
public sealed record AiProvenanceRequirements
{
public bool RequireHash { get; init; }
public bool RequireSignature { get; init; }
public ImmutableArray<string> TrustedSources { get; init; } = [];
public ImmutableArray<string> KnownModelHubs { get; init; } = [];
}
public sealed record AiGovernanceExemption
{
public string? ModelPattern { get; init; }
public string? Reason { get; init; }
public bool RiskAccepted { get; init; }
public DateOnly? ExpirationDate { get; init; }
}

View File

@@ -0,0 +1,152 @@
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.AiMlSecurity.Models;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Scanner.AiMlSecurity.Policy;
public interface IAiGovernancePolicyLoader
{
Task<AiGovernancePolicy> LoadAsync(string? path, CancellationToken ct = default);
}
public static class AiGovernancePolicyDefaults
{
public static AiGovernancePolicy Default { get; } = new()
{
ComplianceFrameworks = ["EU-AI-Act", "NIST-AI-RMF"],
ModelCardRequirements = new AiModelCardRequirements
{
MinimumCompleteness = AiModelCardCompleteness.Standard,
RequiredSections = [
"modelParameters",
"quantitativeAnalysis",
"considerations"
]
},
TrainingDataRequirements = new AiTrainingDataRequirements
{
RequireProvenance = true,
SensitiveDataAllowed = false,
RequireBiasAssessment = true
},
RiskCategories = new AiRiskCategories
{
HighRisk = [
"biometricIdentification",
"criticalInfrastructure",
"employmentDecisions",
"creditScoring",
"lawEnforcement"
]
},
SafetyRequirements = new AiSafetyRequirements
{
RequireSafetyAssessment = true,
HumanOversightRequired = new AiHumanOversightRequirements
{
ForHighRisk = true
}
},
ProvenanceRequirements = new AiProvenanceRequirements
{
RequireHash = false,
RequireSignature = false,
TrustedSources = ["huggingface", "modelzoo"],
KnownModelHubs = ["huggingface", "tensorflowhub", "pytorchhub"]
}
};
}
public sealed class AiGovernancePolicyLoader : IAiGovernancePolicyLoader
{
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private readonly IDeserializer _yamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
public async Task<AiGovernancePolicy> LoadAsync(string? path, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return AiGovernancePolicyDefaults.Default;
}
var extension = Path.GetExtension(path).ToLowerInvariant();
await using var stream = File.OpenRead(path);
return extension switch
{
".yaml" or ".yml" => LoadFromYaml(stream),
_ => await LoadFromJsonAsync(stream, ct).ConfigureAwait(false)
};
}
private AiGovernancePolicy LoadFromYaml(Stream stream)
{
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var yamlObject = _yamlDeserializer.Deserialize(reader);
if (yamlObject is null)
{
return AiGovernancePolicyDefaults.Default;
}
var payload = JsonSerializer.Serialize(yamlObject);
using var document = JsonDocument.Parse(payload);
return ExtractPolicy(document.RootElement);
}
private static async Task<AiGovernancePolicy> LoadFromJsonAsync(Stream stream, CancellationToken ct)
{
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct)
.ConfigureAwait(false);
return ExtractPolicy(document.RootElement);
}
private static AiGovernancePolicy ExtractPolicy(JsonElement root)
{
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("aiGovernancePolicy", out var policyElement))
{
return JsonSerializer.Deserialize<AiGovernancePolicy>(policyElement, JsonOptions)
?? AiGovernancePolicyDefaults.Default;
}
return JsonSerializer.Deserialize<AiGovernancePolicy>(root, JsonOptions)
?? AiGovernancePolicyDefaults.Default;
}
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
options.Converters.Add(new FlexibleBooleanConverter());
return options;
}
private sealed class FlexibleBooleanConverter : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.String when bool.TryParse(reader.GetString(), out var value) => value,
_ => throw new JsonException($"Expected boolean value or boolean string, got {reader.TokenType}.")
};
}
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteBooleanValue(value);
}
}
}

View File

@@ -0,0 +1,165 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Reporting;
public static class AiMlSecurityReportFormatter
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public static byte[] ToJsonBytes(AiMlSecurityReport report)
{
return JsonSerializer.SerializeToUtf8Bytes(report, JsonOptions);
}
public static string ToText(AiMlSecurityReport report)
{
var builder = new StringBuilder();
builder.AppendLine("AI/ML Security Report");
builder.AppendLine($"Findings: {report.Summary.TotalFindings}");
builder.AppendLine($"Models: {report.Summary.ModelCount}");
builder.AppendLine($"Datasets: {report.Summary.DatasetCount}");
if (report.Summary.FindingsBySeverity.Count > 0)
{
builder.AppendLine();
builder.AppendLine("Findings by Severity:");
foreach (var severityGroup in report.Summary.FindingsBySeverity.OrderByDescending(kvp => kvp.Key))
{
builder.AppendLine($" {severityGroup.Key}: {severityGroup.Value}");
}
}
if (!report.ComplianceStatus.Frameworks.IsDefaultOrEmpty)
{
builder.AppendLine();
builder.AppendLine("Compliance:");
foreach (var framework in report.ComplianceStatus.Frameworks)
{
builder.AppendLine($" {framework.Framework}: {(framework.IsCompliant ? "Compliant" : "Non-compliant")} ({framework.ViolationCount} violations)");
}
}
if (!report.RiskAssessments.IsDefaultOrEmpty)
{
builder.AppendLine();
builder.AppendLine("Risk Assessments:");
foreach (var assessment in report.RiskAssessments)
{
builder.AppendLine($" {assessment.Category}: {assessment.Level}");
}
}
if (!report.Findings.IsDefaultOrEmpty)
{
builder.AppendLine();
foreach (var finding in report.Findings)
{
builder.AppendLine($"- [{finding.Severity}] {finding.Title} ({finding.ComponentName ?? finding.ComponentBomRef})");
if (!string.IsNullOrWhiteSpace(finding.Description))
{
builder.AppendLine($" {finding.Description}");
}
if (!string.IsNullOrWhiteSpace(finding.Remediation))
{
builder.AppendLine($" Remediation: {finding.Remediation}");
}
}
}
return builder.ToString();
}
public static byte[] ToPdfBytes(AiMlSecurityReport report)
{
return SimplePdfBuilder.Build(ToText(report));
}
}
internal static class SimplePdfBuilder
{
public static byte[] Build(string text)
{
var lines = text.Replace("\r", string.Empty).Split('\n');
var contentStream = BuildContentStream(lines);
var objects = new List<string>
{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
$"<< /Length {contentStream.Length} >>\nstream\n{contentStream}\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
};
using var stream = new MemoryStream();
WriteLine(stream, "%PDF-1.4");
var offsets = new List<long> { 0 };
for (var i = 0; i < objects.Count; i++)
{
offsets.Add(stream.Position);
WriteLine(stream, $"{i + 1} 0 obj");
WriteLine(stream, objects[i]);
WriteLine(stream, "endobj");
}
var xrefStart = stream.Position;
WriteLine(stream, "xref");
WriteLine(stream, $"0 {objects.Count + 1}");
WriteLine(stream, "0000000000 65535 f ");
for (var i = 1; i < offsets.Count; i++)
{
WriteLine(stream, $"{offsets[i]:0000000000} 00000 n ");
}
WriteLine(stream, "trailer");
WriteLine(stream, $"<< /Size {objects.Count + 1} /Root 1 0 R >>");
WriteLine(stream, "startxref");
WriteLine(stream, xrefStart.ToString());
WriteLine(stream, "%%EOF");
return stream.ToArray();
}
private static string BuildContentStream(IEnumerable<string> lines)
{
var builder = new StringBuilder();
builder.AppendLine("BT");
builder.AppendLine("/F1 10 Tf");
var y = 760;
foreach (var line in lines)
{
var escaped = EscapeText(line);
builder.AppendLine($"72 {y} Td ({escaped}) Tj");
y -= 14;
if (y < 60)
{
break;
}
}
builder.AppendLine("ET");
return builder.ToString();
}
private static string EscapeText(string value)
{
return value.Replace("\\", "\\\\")
.Replace("(", "\\(")
.Replace(")", "\\)");
}
private static void WriteLine(Stream stream, string line)
{
var bytes = Encoding.ASCII.GetBytes(line + "\n");
stream.Write(bytes, 0, bytes.Length);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.ML/StellaOps.BinaryIndex.ML.csproj" />
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.SbomIntegration/StellaOps.Concelier.SbomIntegration.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
</Project>