tests fixes and sprints work
This commit is contained in:
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user