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

@@ -118,12 +118,18 @@ public sealed class FacetQuotaGate : IPolicyGate
private static FacetDriftReport? GetDriftReportFromContext(PolicyGateContext context)
{
// Drift report is expected to be in metadata under a well-known key
if (context.Metadata?.TryGetValue("FacetDriftReport", out var value) == true &&
value is string json)
if (context.Metadata?.TryGetValue("FacetDriftReport", out var json) == true &&
!string.IsNullOrWhiteSpace(json))
{
// In a real implementation, deserialize from JSON
// For now, return null to trigger the no-seal path
return null;
try
{
return System.Text.Json.JsonSerializer.Deserialize<FacetDriftReport>(json);
}
catch (System.Text.Json.JsonException)
{
// Malformed JSON - return null to trigger no-seal path
return null;
}
}
return null;

View File

@@ -109,16 +109,13 @@ public sealed class OpaGateAdapter : IPolicyGate
{
MergeResult = new
{
mergeResult.Findings,
mergeResult.TotalFindings,
mergeResult.CriticalCount,
mergeResult.HighCount,
mergeResult.MediumCount,
mergeResult.LowCount,
mergeResult.UnknownCount,
mergeResult.NewFindings,
mergeResult.RemovedFindings,
mergeResult.UnchangedFindings
mergeResult.Status,
mergeResult.Confidence,
mergeResult.HasConflicts,
mergeResult.RequiresReplayProof,
mergeResult.AllClaims,
mergeResult.WinningClaim,
mergeResult.Conflicts
},
Context = new
{

View File

@@ -173,7 +173,7 @@ public sealed record UnknownsGateOptions
/// <summary>
/// Default implementation of unknowns gate checker.
/// </summary>
public sealed class UnknownsGateChecker : IUnknownsGateChecker
public class UnknownsGateChecker : IUnknownsGateChecker
{
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
@@ -299,7 +299,7 @@ public sealed class UnknownsGateChecker : IUnknownsGateChecker
});
}
public async Task<IReadOnlyList<UnknownState>> GetUnknownsAsync(
public virtual async Task<IReadOnlyList<UnknownState>> GetUnknownsAsync(
string bomRef,
CancellationToken ct = default)
{

View File

@@ -0,0 +1,110 @@
using System.Collections.Immutable;
using System.Text;
namespace StellaOps.Policy.Licensing;
public sealed class AttributionGenerator
{
public string Generate(LicenseComplianceReport report, AttributionFormat format)
{
if (report is null)
{
throw new ArgumentNullException(nameof(report));
}
return format switch
{
AttributionFormat.Html => GenerateHtml(report),
AttributionFormat.PlainText => GeneratePlainText(report),
_ => GenerateMarkdown(report)
};
}
private static string GenerateMarkdown(LicenseComplianceReport report)
{
var builder = new StringBuilder();
builder.AppendLine("# Third-Party Attributions");
builder.AppendLine();
foreach (var requirement in report.AttributionRequirements)
{
builder.AppendLine($"## {requirement.ComponentName}");
builder.AppendLine($"- License: {requirement.LicenseId}");
if (!string.IsNullOrWhiteSpace(requirement.ComponentPurl))
{
builder.AppendLine($"- PURL: {requirement.ComponentPurl}");
}
foreach (var notice in requirement.Notices)
{
builder.AppendLine($"- Notice: {notice}");
}
builder.AppendLine();
}
return builder.ToString();
}
private static string GeneratePlainText(LicenseComplianceReport report)
{
var builder = new StringBuilder();
builder.AppendLine("Third-Party Attributions");
builder.AppendLine();
foreach (var requirement in report.AttributionRequirements)
{
builder.AppendLine($"Component: {requirement.ComponentName}");
builder.AppendLine($"License: {requirement.LicenseId}");
if (!string.IsNullOrWhiteSpace(requirement.ComponentPurl))
{
builder.AppendLine($"PURL: {requirement.ComponentPurl}");
}
foreach (var notice in requirement.Notices)
{
builder.AppendLine($"Notice: {notice}");
}
builder.AppendLine();
}
return builder.ToString();
}
private static string GenerateHtml(LicenseComplianceReport report)
{
var builder = new StringBuilder();
builder.AppendLine("<h1>Third-Party Attributions</h1>");
foreach (var requirement in report.AttributionRequirements)
{
builder.AppendLine($"<h2>{Escape(requirement.ComponentName)}</h2>");
builder.AppendLine($"<p><strong>License:</strong> {Escape(requirement.LicenseId)}</p>");
if (!string.IsNullOrWhiteSpace(requirement.ComponentPurl))
{
builder.AppendLine($"<p><strong>PURL:</strong> {Escape(requirement.ComponentPurl)}</p>");
}
if (!requirement.Notices.IsDefaultOrEmpty)
{
builder.AppendLine("<ul>");
foreach (var notice in requirement.Notices)
{
builder.AppendLine($"<li>{Escape(notice)}</li>");
}
builder.AppendLine("</ul>");
}
}
return builder.ToString();
}
private static string Escape(string value)
{
return value
.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,69 @@
namespace StellaOps.Policy.Licensing;
public sealed record LicenseCompatibilityResult(bool IsCompatible, string? Reason);
public sealed class LicenseCompatibilityChecker
{
public LicenseCompatibilityResult Check(
LicenseDescriptor first,
LicenseDescriptor second,
ProjectContext context)
{
if (first is null)
{
throw new ArgumentNullException(nameof(first));
}
if (second is null)
{
throw new ArgumentNullException(nameof(second));
}
if (IsApacheGpl2Conflict(first.Id, second.Id))
{
return new LicenseCompatibilityResult(
false,
"Apache-2.0 is incompatible with GPL-2.0-only due to patent clauses.");
}
if (first.Category == LicenseCategory.Proprietary
&& second.Category == LicenseCategory.StrongCopyleft)
{
return new LicenseCompatibilityResult(
false,
"Strong copyleft is incompatible with proprietary licensing.");
}
if (second.Category == LicenseCategory.Proprietary
&& first.Category == LicenseCategory.StrongCopyleft)
{
return new LicenseCompatibilityResult(
false,
"Strong copyleft is incompatible with proprietary licensing.");
}
if (context.DistributionModel == DistributionModel.Commercial
&& first.Category == LicenseCategory.StrongCopyleft
&& second.Category == LicenseCategory.StrongCopyleft)
{
return new LicenseCompatibilityResult(
true,
"Strong copyleft pairing detected; ensure redistribution obligations are met.");
}
return new LicenseCompatibilityResult(true, null);
}
private static bool IsApacheGpl2Conflict(string first, string second)
{
return (IsApache(first) && IsGpl2Only(second))
|| (IsApache(second) && IsGpl2Only(first));
}
private static bool IsApache(string licenseId)
=> licenseId.Equals("Apache-2.0", StringComparison.OrdinalIgnoreCase);
private static bool IsGpl2Only(string licenseId)
=> licenseId.Equals("GPL-2.0-only", StringComparison.OrdinalIgnoreCase)
|| licenseId.Equals("GPL-2.0+", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,353 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Licensing;
public sealed class LicenseComplianceEvaluator : ILicenseComplianceEvaluator
{
private readonly LicenseKnowledgeBase _knowledgeBase;
private readonly LicenseExpressionEvaluator _expressionEvaluator;
public LicenseComplianceEvaluator(LicenseKnowledgeBase knowledgeBase)
{
_knowledgeBase = knowledgeBase ?? throw new ArgumentNullException(nameof(knowledgeBase));
_expressionEvaluator = new LicenseExpressionEvaluator(
_knowledgeBase,
new LicenseCompatibilityChecker(),
new ProjectContextAnalyzer());
}
public Task<LicenseComplianceReport> EvaluateAsync(
IReadOnlyList<LicenseComponent> components,
LicensePolicy policy,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(components);
ArgumentNullException.ThrowIfNull(policy);
var findings = new List<LicenseFinding>();
var conflicts = new List<LicenseConflict>();
var inventory = new Dictionary<string, LicenseUsage>(StringComparer.OrdinalIgnoreCase);
var categoryCounts = new Dictionary<LicenseCategory, int>();
var attribution = new List<AttributionRequirement>();
var unknownLicenseCount = 0;
var noLicenseCount = 0;
foreach (var component in components)
{
ct.ThrowIfCancellationRequested();
var expressionText = ResolveExpression(component);
if (string.IsNullOrWhiteSpace(expressionText))
{
noLicenseCount++;
findings.Add(new LicenseFinding
{
Type = LicenseFindingType.MissingLicense,
LicenseId = "none",
ComponentName = component.Name,
ComponentPurl = component.Purl,
Category = LicenseCategory.Unknown,
Message = "No license data detected."
});
continue;
}
LicenseExpression expression;
try
{
expression = SpdxLicenseExpressionParser.Parse(expressionText);
}
catch (FormatException ex)
{
unknownLicenseCount++;
findings.Add(new LicenseFinding
{
Type = LicenseFindingType.UnknownLicense,
LicenseId = expressionText,
ComponentName = component.Name,
ComponentPurl = component.Purl,
Category = LicenseCategory.Unknown,
Message = ex.Message
});
continue;
}
var evaluation = _expressionEvaluator.Evaluate(expression, policy);
var exemptedLicenses = GetExemptedLicenses(component, policy);
foreach (var issue in evaluation.Issues.Where(issue => !IsSuppressed(issue, exemptedLicenses)))
{
if (issue.Type == LicenseFindingType.UnknownLicense)
{
unknownLicenseCount++;
}
findings.Add(new LicenseFinding
{
Type = issue.Type,
LicenseId = issue.LicenseId ?? expressionText,
ComponentName = component.Name,
ComponentPurl = component.Purl,
Category = ResolveCategory(issue.LicenseId),
Message = issue.Message
});
}
foreach (var obligation in evaluation.Obligations)
{
var type = obligation.Type switch
{
LicenseObligationType.Attribution => LicenseFindingType.AttributionRequired,
LicenseObligationType.SourceDisclosure => LicenseFindingType.SourceDisclosureRequired,
LicenseObligationType.PatentGrant => LicenseFindingType.PatentClauseRisk,
LicenseObligationType.TrademarkNotice => LicenseFindingType.AttributionRequired,
_ => LicenseFindingType.CommercialRestriction
};
findings.Add(new LicenseFinding
{
Type = type,
LicenseId = string.Join(" AND ", evaluation.SelectedLicenses.Select(l => l.Id)),
ComponentName = component.Name,
ComponentPurl = component.Purl,
Category = evaluation.SelectedLicenses.FirstOrDefault()?.Category ?? LicenseCategory.Unknown,
Message = obligation.Details
});
}
foreach (var license in evaluation.SelectedLicenses)
{
TrackInventory(inventory, categoryCounts, component, expressionText, license);
}
if (evaluation.Issues.Any(issue => issue.Type == LicenseFindingType.LicenseConflict))
{
conflicts.Add(new LicenseConflict
{
ComponentName = component.Name,
ComponentPurl = component.Purl,
LicenseIds = evaluation.SelectedLicenses
.Select(l => l.Id)
.ToImmutableArray(),
Reason = evaluation.Issues.First(issue => issue.Type == LicenseFindingType.LicenseConflict).Message
});
}
if (!evaluation.Obligations.IsDefaultOrEmpty
&& policy.AttributionRequirements.GenerateNoticeFile)
{
foreach (var obligation in evaluation.Obligations)
{
if (obligation.Type != LicenseObligationType.Attribution
&& obligation.Type != LicenseObligationType.SourceDisclosure
&& obligation.Type != LicenseObligationType.TrademarkNotice)
{
continue;
}
attribution.Add(new AttributionRequirement
{
ComponentName = component.Name,
ComponentPurl = component.Purl,
LicenseId = string.Join(" AND ", evaluation.SelectedLicenses.Select(l => l.Id)),
Notices = ImmutableArray.Create(obligation.Details ?? obligation.Type.ToString()),
IncludeLicenseText = policy.AttributionRequirements.IncludeLicenseText
});
}
}
}
var overallStatus = DetermineOverallStatus(findings, policy);
var inventoryReport = new LicenseInventory
{
Licenses = inventory.Values
.OrderBy(item => item.LicenseId, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
ByCategory = categoryCounts.ToImmutableDictionary(),
UnknownLicenseCount = unknownLicenseCount,
NoLicenseCount = noLicenseCount
};
return Task.FromResult(new LicenseComplianceReport
{
Inventory = inventoryReport,
Findings = findings.ToImmutableArray(),
Conflicts = conflicts.ToImmutableArray(),
OverallStatus = overallStatus,
AttributionRequirements = attribution
.OrderBy(item => item.ComponentName, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.LicenseId, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray()
});
}
private static string? ResolveExpression(LicenseComponent component)
{
if (!string.IsNullOrWhiteSpace(component.LicenseExpression))
{
return component.LicenseExpression;
}
if (!component.Licenses.IsDefaultOrEmpty)
{
return component.Licenses.Length == 1
? component.Licenses[0]
: string.Join(" OR ", component.Licenses);
}
return null;
}
private static void TrackInventory(
Dictionary<string, LicenseUsage> inventory,
Dictionary<LicenseCategory, int> categoryCounts,
LicenseComponent component,
string expressionText,
LicenseDescriptor license)
{
if (!inventory.TryGetValue(license.Id, out var usage))
{
usage = new LicenseUsage
{
LicenseId = license.Id,
Expression = expressionText,
Category = license.Category,
Components = ImmutableArray<string>.Empty,
Count = 0
};
}
var components = usage.Components.Add(component.Name);
inventory[license.Id] = usage with
{
Components = components,
Count = components.Length
};
if (!categoryCounts.ContainsKey(license.Category))
{
categoryCounts[license.Category] = 0;
}
categoryCounts[license.Category]++;
}
private LicenseCategory ResolveCategory(string? licenseId)
{
if (licenseId is null)
{
return LicenseCategory.Unknown;
}
return _knowledgeBase.TryGetLicense(licenseId, out var descriptor)
? descriptor.Category
: LicenseCategory.Unknown;
}
private static LicenseComplianceStatus DetermineOverallStatus(
List<LicenseFinding> findings,
LicensePolicy policy)
{
if (findings.Any(finding => finding.Type is LicenseFindingType.ProhibitedLicense
or LicenseFindingType.CopyleftInProprietaryContext
or LicenseFindingType.LicenseConflict
or LicenseFindingType.ConditionalLicenseViolation
or LicenseFindingType.CommercialRestriction))
{
return LicenseComplianceStatus.Fail;
}
if (findings.Any(finding => finding.Type == LicenseFindingType.MissingLicense))
{
return LicenseComplianceStatus.Warn;
}
if (findings.Any(finding => finding.Type == LicenseFindingType.UnknownLicense))
{
return policy.UnknownLicenseHandling == UnknownLicenseHandling.Deny
? LicenseComplianceStatus.Fail
: LicenseComplianceStatus.Warn;
}
if (findings.Any(finding => finding.Type is LicenseFindingType.AttributionRequired
or LicenseFindingType.SourceDisclosureRequired
or LicenseFindingType.PatentClauseRisk))
{
return LicenseComplianceStatus.Warn;
}
return LicenseComplianceStatus.Pass;
}
private static ImmutableHashSet<string> GetExemptedLicenses(
LicenseComponent component,
LicensePolicy policy)
{
if (policy.Exemptions.IsDefaultOrEmpty)
{
return ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
}
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var exemption in policy.Exemptions)
{
if (IsMatch(component.Name, exemption.ComponentPattern))
{
foreach (var license in exemption.AllowedLicenses)
{
if (!string.IsNullOrWhiteSpace(license))
{
allowed.Add(license.Trim());
}
}
}
}
return allowed.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
}
private static bool IsSuppressed(LicenseEvaluationIssue issue, ImmutableHashSet<string> exempted)
{
if (exempted.Count == 0 || string.IsNullOrWhiteSpace(issue.LicenseId))
{
return false;
}
return issue.Type == LicenseFindingType.ProhibitedLicense
&& exempted.Contains(issue.LicenseId!);
}
private static bool IsMatch(string value, string pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return false;
}
if (pattern == "*")
{
return true;
}
var parts = pattern.Split('*', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
return true;
}
var index = 0;
foreach (var part in parts)
{
var found = value.IndexOf(part, index, StringComparison.OrdinalIgnoreCase);
if (found < 0)
{
return false;
}
index = found + part.Length;
}
return !pattern.StartsWith("*", StringComparison.Ordinal)
? value.StartsWith(parts[0], StringComparison.OrdinalIgnoreCase)
: true;
}
}

View File

@@ -0,0 +1,120 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Licensing;
public interface ILicenseComplianceEvaluator
{
Task<LicenseComplianceReport> EvaluateAsync(
IReadOnlyList<LicenseComponent> components,
LicensePolicy policy,
CancellationToken ct = default);
}
public sealed record LicenseComponent
{
public required string Name { get; init; }
public string? Version { get; init; }
public string? Purl { get; init; }
public string? LicenseExpression { get; init; }
public ImmutableArray<string> Licenses { get; init; } = [];
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
}
public sealed record LicenseComplianceReport
{
public LicenseInventory Inventory { get; init; } = new();
public ImmutableArray<LicenseFinding> Findings { get; init; } = [];
public ImmutableArray<LicenseConflict> Conflicts { get; init; } = [];
public LicenseComplianceStatus OverallStatus { get; init; } = LicenseComplianceStatus.Pass;
public ImmutableArray<AttributionRequirement> AttributionRequirements { get; init; } = [];
}
public sealed record LicenseInventory
{
public ImmutableArray<LicenseUsage> Licenses { get; init; } = [];
public ImmutableDictionary<LicenseCategory, int> ByCategory { get; init; } =
ImmutableDictionary<LicenseCategory, int>.Empty;
public int UnknownLicenseCount { get; init; }
public int NoLicenseCount { get; init; }
}
public sealed record LicenseUsage
{
public required string LicenseId { get; init; }
public string? Expression { get; init; }
public LicenseCategory Category { get; init; }
public ImmutableArray<string> Components { get; init; } = [];
public int Count { get; init; }
}
public sealed record LicenseFinding
{
public required LicenseFindingType Type { get; init; }
public required string LicenseId { get; init; }
public required string ComponentName { get; init; }
public string? ComponentPurl { get; init; }
public LicenseCategory Category { get; init; }
public string? Message { get; init; }
}
public sealed record LicenseConflict
{
public required string ComponentName { get; init; }
public string? ComponentPurl { get; init; }
public ImmutableArray<string> LicenseIds { get; init; } = [];
public string? Reason { get; init; }
}
public sealed record AttributionRequirement
{
public required string ComponentName { get; init; }
public string? ComponentPurl { get; init; }
public required string LicenseId { get; init; }
public ImmutableArray<string> Notices { get; init; } = [];
public bool IncludeLicenseText { get; init; }
}
public sealed record LicenseObligation
{
public required LicenseObligationType Type { get; init; }
public string? Details { get; init; }
}
public enum LicenseComplianceStatus
{
Pass = 0,
Warn = 1,
Fail = 2
}
public enum LicenseFindingType
{
ProhibitedLicense = 0,
CopyleftInProprietaryContext = 1,
LicenseConflict = 2,
UnknownLicense = 3,
MissingLicense = 4,
AttributionRequired = 5,
SourceDisclosureRequired = 6,
PatentClauseRisk = 7,
CommercialRestriction = 8,
ConditionalLicenseViolation = 9
}
public enum LicenseCategory
{
Unknown = 0,
Permissive = 1,
WeakCopyleft = 2,
StrongCopyleft = 3,
Proprietary = 4,
PublicDomain = 5
}
public enum LicenseObligationType
{
Attribution = 0,
SourceDisclosure = 1,
PatentGrant = 2,
TrademarkNotice = 3
}

View File

@@ -0,0 +1,612 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
namespace StellaOps.Policy.Licensing;
public sealed class LicenseComplianceReporter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private static readonly StringComparer IdComparer = StringComparer.OrdinalIgnoreCase;
private static readonly Encoding PdfEncoding = Encoding.ASCII;
private static readonly IReadOnlyDictionary<LicenseCategory, string> CategoryColors =
new Dictionary<LicenseCategory, string>
{
[LicenseCategory.PublicDomain] = "#17becf",
[LicenseCategory.Permissive] = "#2ca02c",
[LicenseCategory.WeakCopyleft] = "#ff7f0e",
[LicenseCategory.StrongCopyleft] = "#d62728",
[LicenseCategory.Proprietary] = "#7f7f7f",
[LicenseCategory.Unknown] = "#8c564b"
};
private const int PdfMaxLines = 50;
private const int ChartWidth = 20;
public string ToJson(LicenseComplianceReport report)
{
if (report is null)
{
throw new ArgumentNullException(nameof(report));
}
return JsonSerializer.Serialize(report, JsonOptions);
}
public string ToText(LicenseComplianceReport report)
{
if (report is null)
{
throw new ArgumentNullException(nameof(report));
}
var builder = new StringBuilder();
builder.AppendLine($"License compliance: {report.OverallStatus}");
builder.AppendLine($"Known licenses: {report.Inventory.Licenses.Length}");
builder.AppendLine($"Unknown licenses: {report.Inventory.UnknownLicenseCount}");
builder.AppendLine($"Missing licenses: {report.Inventory.NoLicenseCount}");
builder.AppendLine();
if (!report.Findings.IsDefaultOrEmpty)
{
builder.AppendLine("Findings:");
foreach (var finding in report.Findings
.OrderBy(item => item.ComponentName, IdComparer)
.ThenBy(item => item.LicenseId, IdComparer))
{
builder.AppendLine($"- [{finding.Type}] {finding.ComponentName}: {finding.LicenseId}");
}
builder.AppendLine();
}
if (!report.Conflicts.IsDefaultOrEmpty)
{
builder.AppendLine("Conflicts:");
foreach (var conflict in report.Conflicts
.OrderBy(item => item.ComponentName, IdComparer))
{
builder.AppendLine($"- {conflict.ComponentName}: {string.Join(", ", conflict.LicenseIds)}");
}
builder.AppendLine();
}
AppendCategoryBreakdownText(builder, report);
if (!report.AttributionRequirements.IsDefaultOrEmpty)
{
builder.AppendLine("Attribution Requirements:");
foreach (var requirement in report.AttributionRequirements
.OrderBy(item => item.ComponentName, IdComparer))
{
builder.AppendLine($"- {requirement.ComponentName}: {requirement.LicenseId}");
}
builder.AppendLine();
builder.AppendLine("NOTICE:");
builder.AppendLine(new AttributionGenerator().Generate(report, AttributionFormat.PlainText));
}
return builder.ToString();
}
public string ToMarkdown(LicenseComplianceReport report)
{
if (report is null)
{
throw new ArgumentNullException(nameof(report));
}
var builder = new StringBuilder();
builder.AppendLine("# License Compliance Report");
builder.AppendLine();
builder.AppendLine($"- Status: {report.OverallStatus}");
builder.AppendLine($"- Known licenses: {report.Inventory.Licenses.Length}");
builder.AppendLine($"- Unknown licenses: {report.Inventory.UnknownLicenseCount}");
builder.AppendLine($"- Missing licenses: {report.Inventory.NoLicenseCount}");
builder.AppendLine();
builder.AppendLine("## Inventory");
foreach (var license in report.Inventory.Licenses
.OrderBy(item => item.LicenseId, IdComparer))
{
builder.AppendLine($"- {license.LicenseId} ({license.Category}) x{license.Count}");
}
builder.AppendLine();
AppendCategoryBreakdownMarkdown(builder, report);
if (!report.Findings.IsDefaultOrEmpty)
{
builder.AppendLine("## Findings");
foreach (var finding in report.Findings
.OrderBy(item => item.ComponentName, IdComparer)
.ThenBy(item => item.LicenseId, IdComparer))
{
builder.AppendLine($"- [{finding.Type}] {finding.ComponentName}: {finding.LicenseId}");
}
builder.AppendLine();
}
if (!report.Conflicts.IsDefaultOrEmpty)
{
builder.AppendLine("## Conflicts");
foreach (var conflict in report.Conflicts
.OrderBy(item => item.ComponentName, IdComparer))
{
builder.AppendLine($"- {conflict.ComponentName}: {string.Join(", ", conflict.LicenseIds)}");
}
builder.AppendLine();
}
if (!report.AttributionRequirements.IsDefaultOrEmpty)
{
builder.AppendLine("## Attribution Requirements");
foreach (var requirement in report.AttributionRequirements
.OrderBy(item => item.ComponentName, IdComparer))
{
builder.AppendLine($"- {requirement.ComponentName}: {requirement.LicenseId}");
}
builder.AppendLine();
builder.AppendLine("## NOTICE");
builder.AppendLine(new AttributionGenerator().Generate(report, AttributionFormat.Markdown));
}
return builder.ToString();
}
public string ToHtml(LicenseComplianceReport report)
{
if (report is null)
{
throw new ArgumentNullException(nameof(report));
}
var builder = new StringBuilder();
builder.AppendLine("<h1>License Compliance Report</h1>");
builder.AppendLine("<ul>");
builder.AppendLine($"<li>Status: {Escape(report.OverallStatus.ToString())}</li>");
builder.AppendLine($"<li>Known licenses: {report.Inventory.Licenses.Length}</li>");
builder.AppendLine($"<li>Unknown licenses: {report.Inventory.UnknownLicenseCount}</li>");
builder.AppendLine($"<li>Missing licenses: {report.Inventory.NoLicenseCount}</li>");
builder.AppendLine("</ul>");
builder.AppendLine("<h2>Inventory</h2>");
builder.AppendLine("<ul>");
foreach (var license in report.Inventory.Licenses
.OrderBy(item => item.LicenseId, IdComparer))
{
builder.AppendLine($"<li>{Escape(license.LicenseId)} ({Escape(license.Category.ToString())}) x{license.Count}</li>");
}
builder.AppendLine("</ul>");
AppendCategoryBreakdownHtml(builder, report);
if (!report.Findings.IsDefaultOrEmpty)
{
builder.AppendLine("<h2>Findings</h2>");
builder.AppendLine("<ul>");
foreach (var finding in report.Findings
.OrderBy(item => item.ComponentName, IdComparer)
.ThenBy(item => item.LicenseId, IdComparer))
{
builder.AppendLine($"<li>[{Escape(finding.Type.ToString())}] {Escape(finding.ComponentName)}: {Escape(finding.LicenseId)}</li>");
}
builder.AppendLine("</ul>");
}
if (!report.Conflicts.IsDefaultOrEmpty)
{
builder.AppendLine("<h2>Conflicts</h2>");
builder.AppendLine("<ul>");
foreach (var conflict in report.Conflicts
.OrderBy(item => item.ComponentName, IdComparer))
{
builder.AppendLine($"<li>{Escape(conflict.ComponentName)}: {Escape(string.Join(", ", conflict.LicenseIds))}</li>");
}
builder.AppendLine("</ul>");
}
if (!report.AttributionRequirements.IsDefaultOrEmpty)
{
builder.AppendLine("<h2>Attribution Requirements</h2>");
builder.AppendLine("<ul>");
foreach (var requirement in report.AttributionRequirements
.OrderBy(item => item.ComponentName, IdComparer))
{
builder.AppendLine($"<li>{Escape(requirement.ComponentName)}: {Escape(requirement.LicenseId)}</li>");
}
builder.AppendLine("</ul>");
builder.AppendLine("<h2>NOTICE</h2>");
builder.AppendLine(new AttributionGenerator().Generate(report, AttributionFormat.Html));
}
return builder.ToString();
}
public string ToLegalReview(LicenseComplianceReport report)
{
if (report is null)
{
throw new ArgumentNullException(nameof(report));
}
var builder = new StringBuilder();
builder.AppendLine("License Compliance Report");
builder.AppendLine("=========================");
builder.AppendLine($"Status: {report.OverallStatus}");
builder.AppendLine($"Known licenses: {report.Inventory.Licenses.Length}");
builder.AppendLine($"Unknown licenses: {report.Inventory.UnknownLicenseCount}");
builder.AppendLine($"Missing licenses: {report.Inventory.NoLicenseCount}");
builder.AppendLine();
builder.AppendLine("Inventory");
builder.AppendLine("---------");
foreach (var license in report.Inventory.Licenses
.OrderBy(item => item.LicenseId, IdComparer))
{
builder.AppendLine($"- {license.LicenseId} ({license.Category}) x{license.Count}");
if (!license.Components.IsDefaultOrEmpty)
{
builder.AppendLine($" Components: {string.Join(", ", license.Components)}");
}
}
builder.AppendLine();
if (!report.Findings.IsDefaultOrEmpty)
{
builder.AppendLine("Findings");
builder.AppendLine("--------");
foreach (var finding in report.Findings
.OrderBy(item => item.ComponentName, IdComparer)
.ThenBy(item => item.LicenseId, IdComparer))
{
builder.AppendLine($"- [{finding.Type}] {finding.ComponentName}: {finding.LicenseId}");
if (!string.IsNullOrWhiteSpace(finding.Message))
{
builder.AppendLine($" {finding.Message}");
}
}
builder.AppendLine();
}
if (!report.Conflicts.IsDefaultOrEmpty)
{
builder.AppendLine("Conflicts");
builder.AppendLine("---------");
foreach (var conflict in report.Conflicts
.OrderBy(item => item.ComponentName, IdComparer))
{
builder.AppendLine($"- {conflict.ComponentName}: {string.Join(", ", conflict.LicenseIds)}");
if (!string.IsNullOrWhiteSpace(conflict.Reason))
{
builder.AppendLine($" {conflict.Reason}");
}
}
builder.AppendLine();
}
AppendCategoryBreakdownText(builder, report);
if (!report.AttributionRequirements.IsDefaultOrEmpty)
{
builder.AppendLine("Attribution Requirements");
builder.AppendLine("------------------------");
foreach (var requirement in report.AttributionRequirements
.OrderBy(item => item.ComponentName, IdComparer))
{
builder.AppendLine($"- {requirement.ComponentName}: {requirement.LicenseId}");
if (!string.IsNullOrWhiteSpace(requirement.ComponentPurl))
{
builder.AppendLine($" PURL: {requirement.ComponentPurl}");
}
}
builder.AppendLine();
}
builder.AppendLine("NOTICE");
builder.AppendLine("------");
var attribution = new AttributionGenerator()
.Generate(report, AttributionFormat.PlainText);
builder.AppendLine(attribution);
return builder.ToString();
}
public byte[] ToPdf(LicenseComplianceReport report)
{
if (report is null)
{
throw new ArgumentNullException(nameof(report));
}
var lines = ToText(report)
.Split('\n', StringSplitOptions.None)
.Select(line => line.TrimEnd('\r'))
.Where(line => line.Length > 0)
.Take(PdfMaxLines)
.ToList();
var content = BuildPdfContent(lines);
var contentBytes = PdfEncoding.GetBytes(content);
using var stream = new MemoryStream();
var offsets = new List<long> { 0 };
WritePdf(stream, "%PDF-1.4\n");
offsets.Add(stream.Position);
WritePdf(stream, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
offsets.Add(stream.Position);
WritePdf(stream, "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
offsets.Add(stream.Position);
WritePdf(stream, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ");
WritePdf(stream, "/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n");
offsets.Add(stream.Position);
WritePdf(stream, $"4 0 obj\n<< /Length {contentBytes.Length} >>\nstream\n");
stream.Write(contentBytes, 0, contentBytes.Length);
WritePdf(stream, "\nendstream\nendobj\n");
offsets.Add(stream.Position);
WritePdf(stream, "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
var xrefOffset = stream.Position;
WritePdf(stream, $"xref\n0 {offsets.Count}\n");
WritePdf(stream, "0000000000 65535 f \n");
for (var i = 1; i < offsets.Count; i++)
{
WritePdf(stream, $"{offsets[i]:D10} 00000 n \n");
}
WritePdf(stream, $"trailer\n<< /Size {offsets.Count} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF\n");
return stream.ToArray();
}
private static string Escape(string value)
{
return value
.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
}
private static string BuildPdfContent(IReadOnlyList<string> lines)
{
var builder = new StringBuilder();
builder.AppendLine("BT");
builder.AppendLine("/F1 11 Tf");
builder.AppendLine("72 720 Td");
builder.AppendLine("14 TL");
foreach (var line in lines)
{
builder.Append('(')
.Append(EscapePdfText(line))
.AppendLine(") Tj");
builder.AppendLine("T*");
}
builder.AppendLine("ET");
return builder.ToString();
}
private static void AppendCategoryBreakdownText(StringBuilder builder, LicenseComplianceReport report)
{
var entries = BuildCategoryBreakdown(report);
if (entries.Count == 0)
{
return;
}
builder.AppendLine("Category Breakdown");
builder.AppendLine("------------------");
foreach (var entry in entries)
{
builder.AppendLine($"- {entry.Category}: {entry.Count} ({FormatPercent(entry.Percent)}%)");
}
builder.AppendLine();
builder.AppendLine("Category Chart (approx)");
foreach (var entry in entries)
{
var bar = RenderAsciiBar(entry.Percent, ChartWidth);
builder.AppendLine($"- {entry.Category}: [{bar}] {FormatPercent(entry.Percent)}%");
}
builder.AppendLine();
}
private static void AppendCategoryBreakdownMarkdown(StringBuilder builder, LicenseComplianceReport report)
{
var entries = BuildCategoryBreakdown(report);
if (entries.Count == 0)
{
return;
}
builder.AppendLine("## Category Breakdown");
builder.AppendLine("| Category | Count | Percent |");
builder.AppendLine("| --- | --- | --- |");
foreach (var entry in entries)
{
builder.AppendLine($"| {entry.Category} | {entry.Count} | {FormatPercent(entry.Percent)}% |");
}
builder.AppendLine();
builder.AppendLine("```");
builder.AppendLine("Category Chart (approx)");
foreach (var entry in entries)
{
var bar = RenderAsciiBar(entry.Percent, ChartWidth);
builder.AppendLine($"{entry.Category}: [{bar}] {FormatPercent(entry.Percent)}%");
}
builder.AppendLine("```");
builder.AppendLine();
}
private static void AppendCategoryBreakdownHtml(StringBuilder builder, LicenseComplianceReport report)
{
var entries = BuildCategoryBreakdown(report);
if (entries.Count == 0)
{
return;
}
builder.AppendLine("<h2>Category Breakdown</h2>");
builder.AppendLine("<table>");
builder.AppendLine("<thead><tr><th>Category</th><th>Count</th><th>Percent</th></tr></thead>");
builder.AppendLine("<tbody>");
foreach (var entry in entries)
{
builder.AppendLine(
$"<tr><td>{Escape(entry.Category.ToString())}</td><td>{entry.Count}</td><td>{FormatPercent(entry.Percent)}%</td></tr>");
}
builder.AppendLine("</tbody>");
builder.AppendLine("</table>");
builder.AppendLine(
$"<div style=\"width:180px;height:180px;border-radius:50%;background:{BuildConicGradient(entries)};margin:12px 0;\"></div>");
builder.AppendLine("<ul>");
foreach (var entry in entries)
{
var color = GetCategoryColor(entry.Category);
builder.AppendLine(
$"<li><span style=\"display:inline-block;width:12px;height:12px;background:{color};margin-right:6px;\"></span>{Escape(entry.Category.ToString())} ({entry.Count}, {FormatPercent(entry.Percent)}%)</li>");
}
builder.AppendLine("</ul>");
}
private static IReadOnlyList<CategoryBreakdownEntry> BuildCategoryBreakdown(
LicenseComplianceReport report)
{
if (report.Inventory.ByCategory.Count == 0)
{
return Array.Empty<CategoryBreakdownEntry>();
}
var entries = report.Inventory.ByCategory
.OrderBy(item => item.Key)
.Where(item => item.Value > 0)
.ToList();
if (entries.Count == 0)
{
return Array.Empty<CategoryBreakdownEntry>();
}
var total = entries.Sum(item => item.Value);
if (total <= 0)
{
return Array.Empty<CategoryBreakdownEntry>();
}
return entries
.Select(entry => new CategoryBreakdownEntry(
entry.Key,
entry.Value,
Math.Round(entry.Value * 100.0 / total, 1, MidpointRounding.AwayFromZero)))
.ToList();
}
private static string BuildConicGradient(IReadOnlyList<CategoryBreakdownEntry> entries)
{
var total = entries.Sum(entry => entry.Count);
if (total <= 0)
{
return "conic-gradient(#7f7f7f 0% 100%)";
}
var builder = new StringBuilder("conic-gradient(");
double start = 0;
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
var percent = entry.Count * 100.0 / total;
var end = i == entries.Count - 1 ? 100.0 : start + percent;
var color = GetCategoryColor(entry.Category);
builder.Append(color)
.Append(' ')
.Append(FormatPercent(start))
.Append("% ")
.Append(FormatPercent(end))
.Append('%');
if (i < entries.Count - 1)
{
builder.Append(", ");
}
start = end;
}
builder.Append(')');
return builder.ToString();
}
private static string FormatPercent(double value)
{
return value.ToString("0.0", CultureInfo.InvariantCulture);
}
private static string RenderAsciiBar(double percent, int width)
{
if (width <= 0)
{
return string.Empty;
}
if (percent <= 0)
{
return new string('.', width);
}
var filled = (int)Math.Round(percent / 100.0 * width, MidpointRounding.AwayFromZero);
filled = Math.Clamp(filled, 1, width);
return new string('#', filled).PadRight(width, '.');
}
private static string GetCategoryColor(LicenseCategory category)
{
return CategoryColors.TryGetValue(category, out var color)
? color
: "#7f7f7f";
}
private static string EscapePdfText(string value)
{
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
switch (ch)
{
case '\\':
case '(':
case ')':
builder.Append('\\');
builder.Append(ch);
break;
default:
builder.Append(ch);
break;
}
}
return builder.ToString();
}
private static void WritePdf(Stream stream, string value)
{
var bytes = PdfEncoding.GetBytes(value);
stream.Write(bytes, 0, bytes.Length);
}
private sealed record CategoryBreakdownEntry(
LicenseCategory Category,
int Count,
double Percent);
}

View File

@@ -0,0 +1,329 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Licensing;
public sealed record LicenseExpressionEvaluation
{
public bool IsCompliant { get; init; }
public ImmutableArray<LicenseDescriptor> SelectedLicenses { get; init; } = [];
public ImmutableArray<LicenseDescriptor> AlternativeLicenses { get; init; } = [];
public ImmutableArray<LicenseObligation> Obligations { get; init; } = [];
public ImmutableArray<LicenseEvaluationIssue> Issues { get; init; } = [];
}
public sealed record LicenseEvaluationIssue
{
public required LicenseFindingType Type { get; init; }
public string? LicenseId { get; init; }
public string? Message { get; init; }
}
public sealed class LicenseExpressionEvaluator
{
private readonly LicenseKnowledgeBase _knowledgeBase;
private readonly LicenseCompatibilityChecker _compatibilityChecker;
private readonly ProjectContextAnalyzer _contextAnalyzer;
public LicenseExpressionEvaluator(
LicenseKnowledgeBase knowledgeBase,
LicenseCompatibilityChecker compatibilityChecker,
ProjectContextAnalyzer contextAnalyzer)
{
_knowledgeBase = knowledgeBase ?? throw new ArgumentNullException(nameof(knowledgeBase));
_compatibilityChecker = compatibilityChecker ?? throw new ArgumentNullException(nameof(compatibilityChecker));
_contextAnalyzer = contextAnalyzer ?? throw new ArgumentNullException(nameof(contextAnalyzer));
}
public LicenseExpressionEvaluation Evaluate(LicenseExpression expression, LicensePolicy policy)
{
if (expression is null)
{
throw new ArgumentNullException(nameof(expression));
}
if (policy is null)
{
throw new ArgumentNullException(nameof(policy));
}
return expression switch
{
LicenseIdExpression id => EvaluateIdentifier(id.Id, policy),
OrLaterExpression orLater => EvaluateOrLater(orLater.LicenseId, policy),
WithExceptionExpression with => EvaluateWithException(with, policy),
AndExpression andExpr => EvaluateAnd(andExpr.Terms, policy),
OrExpression orExpr => EvaluateOr(orExpr.Terms, policy),
_ => new LicenseExpressionEvaluation { IsCompliant = false }
};
}
private LicenseExpressionEvaluation EvaluateIdentifier(string licenseId, LicensePolicy policy)
{
var issues = new List<LicenseEvaluationIssue>();
var normalized = licenseId.Trim();
if (!_knowledgeBase.TryGetLicense(normalized, out var descriptor))
{
issues.Add(new LicenseEvaluationIssue
{
Type = LicenseFindingType.UnknownLicense,
LicenseId = normalized,
Message = "Unknown license identifier."
});
var allowed = policy.UnknownLicenseHandling != UnknownLicenseHandling.Deny;
return new LicenseExpressionEvaluation
{
IsCompliant = allowed,
Issues = issues.ToImmutableArray()
};
}
var allowedByPolicy = IsAllowedByPolicy(descriptor, policy, issues);
var obligations = BuildObligations(descriptor);
return new LicenseExpressionEvaluation
{
IsCompliant = allowedByPolicy,
SelectedLicenses = ImmutableArray.Create(descriptor),
Obligations = obligations,
Issues = issues.ToImmutableArray()
};
}
private LicenseExpressionEvaluation EvaluateOrLater(string licenseId, LicensePolicy policy)
{
var candidate = $"{licenseId}-or-later";
if (_knowledgeBase.TryGetLicense(candidate, out _))
{
return EvaluateIdentifier(candidate, policy);
}
return EvaluateIdentifier(licenseId, policy);
}
private LicenseExpressionEvaluation EvaluateWithException(WithExceptionExpression with, LicensePolicy policy)
{
var baseResult = Evaluate(with.License, policy);
if (!_knowledgeBase.IsKnownException(with.ExceptionId))
{
var issues = baseResult.Issues.Add(new LicenseEvaluationIssue
{
Type = LicenseFindingType.UnknownLicense,
LicenseId = with.ExceptionId,
Message = "Unknown license exception."
});
return baseResult with { IsCompliant = false, Issues = issues };
}
return baseResult;
}
private LicenseExpressionEvaluation EvaluateAnd(
ImmutableArray<LicenseExpression> terms,
LicensePolicy policy)
{
var issues = new List<LicenseEvaluationIssue>();
var licenses = new List<LicenseDescriptor>();
var obligations = new List<LicenseObligation>();
var compliant = true;
foreach (var term in terms)
{
var result = Evaluate(term, policy);
issues.AddRange(result.Issues);
obligations.AddRange(result.Obligations);
if (!result.SelectedLicenses.IsDefaultOrEmpty)
{
licenses.AddRange(result.SelectedLicenses);
}
compliant &= result.IsCompliant;
}
var context = policy.ProjectContext;
for (var i = 0; i < licenses.Count; i++)
{
for (var j = i + 1; j < licenses.Count; j++)
{
var conflict = _compatibilityChecker.Check(licenses[i], licenses[j], context);
if (!conflict.IsCompatible)
{
compliant = false;
issues.Add(new LicenseEvaluationIssue
{
Type = LicenseFindingType.LicenseConflict,
LicenseId = $"{licenses[i].Id} + {licenses[j].Id}",
Message = conflict.Reason
});
}
}
}
return new LicenseExpressionEvaluation
{
IsCompliant = compliant,
SelectedLicenses = licenses.DistinctBy(l => l.Id, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
Obligations = obligations.ToImmutableArray(),
Issues = issues.ToImmutableArray()
};
}
private LicenseExpressionEvaluation EvaluateOr(
ImmutableArray<LicenseExpression> terms,
LicensePolicy policy)
{
var evaluations = terms.Select(term => Evaluate(term, policy)).ToList();
var compliant = evaluations.Where(e => e.IsCompliant).ToList();
if (compliant.Count == 0)
{
var combinedIssues = evaluations.SelectMany(e => e.Issues).ToImmutableArray();
return new LicenseExpressionEvaluation
{
IsCompliant = false,
Issues = combinedIssues
};
}
var best = compliant.OrderBy(e => GetRiskScore(e.SelectedLicenses)).First();
var alternatives = compliant
.Where(e => !ReferenceEquals(e, best))
.SelectMany(e => e.SelectedLicenses)
.DistinctBy(l => l.Id, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return best with { AlternativeLicenses = alternatives };
}
private bool IsAllowedByPolicy(
LicenseDescriptor descriptor,
LicensePolicy policy,
List<LicenseEvaluationIssue> issues)
{
if (!policy.AllowedLicenses.IsDefaultOrEmpty
&& !policy.AllowedLicenses.Contains(descriptor.Id, StringComparer.OrdinalIgnoreCase))
{
issues.Add(new LicenseEvaluationIssue
{
Type = LicenseFindingType.ProhibitedLicense,
LicenseId = descriptor.Id,
Message = "License is not in the allow list."
});
return false;
}
if (policy.ProhibitedLicenses.Contains(descriptor.Id, StringComparer.OrdinalIgnoreCase))
{
issues.Add(new LicenseEvaluationIssue
{
Type = LicenseFindingType.ProhibitedLicense,
LicenseId = descriptor.Id,
Message = "License is explicitly prohibited by policy."
});
return false;
}
if (policy.Categories.RequireOsiApproved && !descriptor.IsOsiApproved && descriptor.Category != LicenseCategory.Unknown)
{
issues.Add(new LicenseEvaluationIssue
{
Type = LicenseFindingType.ProhibitedLicense,
LicenseId = descriptor.Id,
Message = "License is not OSI approved."
});
return false;
}
if (!_contextAnalyzer.IsCopyleftAllowed(policy.ProjectContext, policy.Categories, descriptor.Category))
{
issues.Add(new LicenseEvaluationIssue
{
Type = LicenseFindingType.CopyleftInProprietaryContext,
LicenseId = descriptor.Id,
Message = "Copyleft license not allowed in this project context."
});
return false;
}
var conditional = policy.ConditionalLicenses
.FirstOrDefault(rule => rule.License.Equals(descriptor.Id, StringComparison.OrdinalIgnoreCase));
if (conditional is not null && !_contextAnalyzer.IsConditionSatisfied(policy.ProjectContext, conditional.Condition))
{
issues.Add(new LicenseEvaluationIssue
{
Type = LicenseFindingType.ConditionalLicenseViolation,
LicenseId = descriptor.Id,
Message = $"Conditional license requirement not met: {conditional.Condition}."
});
return false;
}
return true;
}
private static ImmutableArray<LicenseObligation> BuildObligations(LicenseDescriptor descriptor)
{
var obligations = new List<LicenseObligation>();
if (descriptor.Attributes.AttributionRequired)
{
obligations.Add(new LicenseObligation
{
Type = LicenseObligationType.Attribution,
Details = "Attribution required."
});
}
if (descriptor.Attributes.SourceDisclosureRequired)
{
obligations.Add(new LicenseObligation
{
Type = LicenseObligationType.SourceDisclosure,
Details = "Source disclosure required."
});
}
if (descriptor.Attributes.PatentGrant)
{
obligations.Add(new LicenseObligation
{
Type = LicenseObligationType.PatentGrant,
Details = "Patent grant obligations apply."
});
}
if (descriptor.Attributes.TrademarkNotice)
{
obligations.Add(new LicenseObligation
{
Type = LicenseObligationType.TrademarkNotice,
Details = "Trademark notice required."
});
}
return obligations.ToImmutableArray();
}
private static int GetRiskScore(ImmutableArray<LicenseDescriptor> licenses)
{
if (licenses.IsDefaultOrEmpty)
{
return int.MaxValue;
}
return licenses.Select(GetRiskScore).Max();
}
private static int GetRiskScore(LicenseDescriptor license)
{
return license.Category switch
{
LicenseCategory.PublicDomain => 0,
LicenseCategory.Permissive => 1,
LicenseCategory.WeakCopyleft => 2,
LicenseCategory.StrongCopyleft => 3,
LicenseCategory.Proprietary => 4,
_ => 5
};
}
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Licensing;
public abstract record LicenseExpression;
public sealed record LicenseIdExpression(string Id) : LicenseExpression;
public sealed record OrLaterExpression(string LicenseId) : LicenseExpression;
public sealed record WithExceptionExpression(LicenseExpression License, string ExceptionId) : LicenseExpression;
public sealed record AndExpression(ImmutableArray<LicenseExpression> Terms) : LicenseExpression;
public sealed record OrExpression(ImmutableArray<LicenseExpression> Terms) : LicenseExpression;

View File

@@ -0,0 +1,226 @@
using System.Collections.Immutable;
using System.Reflection;
using System.Text.Json;
namespace StellaOps.Policy.Licensing;
public sealed record LicenseDescriptor
{
public required string Id { get; init; }
public LicenseCategory Category { get; init; } = LicenseCategory.Unknown;
public bool IsOsiApproved { get; init; }
public LicenseAttributes Attributes { get; init; } = new();
}
public sealed record LicenseAttributes
{
public bool AttributionRequired { get; init; } = true;
public bool SourceDisclosureRequired { get; init; }
public bool PatentGrant { get; init; }
public bool TrademarkNotice { get; init; }
public bool CommercialUseAllowed { get; init; } = true;
public bool ModificationAllowed { get; init; } = true;
public bool DistributionAllowed { get; init; } = true;
}
public sealed class LicenseKnowledgeBase
{
private readonly ImmutableDictionary<string, LicenseDescriptor> _licenses;
private readonly ImmutableHashSet<string> _exceptions;
private LicenseKnowledgeBase(
ImmutableDictionary<string, LicenseDescriptor> licenses,
ImmutableHashSet<string> exceptions)
{
_licenses = licenses;
_exceptions = exceptions;
}
public static LicenseKnowledgeBase LoadDefault()
{
var licenseListJson = ReadEmbeddedResource("spdx-license-list-3.21.json");
var exceptionListJson = ReadEmbeddedResource("spdx-license-exceptions-3.21.json");
return LoadFromJson(licenseListJson, exceptionListJson);
}
public bool TryGetLicense(string licenseId, out LicenseDescriptor descriptor)
{
return _licenses.TryGetValue(NormalizeKey(licenseId), out descriptor!);
}
public bool IsKnownException(string exceptionId)
{
return _exceptions.Contains(NormalizeKey(exceptionId));
}
public ImmutableArray<LicenseDescriptor> AllLicenses =>
_licenses.Values.OrderBy(l => l.Id, StringComparer.OrdinalIgnoreCase).ToImmutableArray();
private static LicenseKnowledgeBase LoadFromJson(string licenseListJson, string exceptionListJson)
{
var licenses = new Dictionary<string, LicenseDescriptor>(StringComparer.OrdinalIgnoreCase);
using (var document = JsonDocument.Parse(licenseListJson))
{
if (document.RootElement.TryGetProperty("licenses", out var licenseArray)
&& licenseArray.ValueKind == JsonValueKind.Array)
{
foreach (var entry in licenseArray.EnumerateArray())
{
var id = entry.GetProperty("licenseId").GetString();
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
var isOsiApproved = entry.TryGetProperty("isOsiApproved", out var osi)
&& osi.ValueKind == JsonValueKind.True;
var descriptor = BuildDescriptor(id!, isOsiApproved);
licenses[NormalizeKey(id!)] = descriptor;
}
}
}
var exceptions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var document = JsonDocument.Parse(exceptionListJson))
{
if (document.RootElement.TryGetProperty("exceptions", out var exceptionArray)
&& exceptionArray.ValueKind == JsonValueKind.Array)
{
foreach (var entry in exceptionArray.EnumerateArray())
{
var id = entry.GetProperty("licenseExceptionId").GetString();
if (!string.IsNullOrWhiteSpace(id))
{
exceptions.Add(NormalizeKey(id!));
}
}
}
}
// Seed common non-SPDX identifiers.
licenses.TryAdd("licenseref-proprietary", BuildDescriptor("LicenseRef-Proprietary", false, LicenseCategory.Proprietary));
licenses.TryAdd("licenseref-commercial", BuildDescriptor("LicenseRef-Commercial", false, LicenseCategory.Proprietary));
return new LicenseKnowledgeBase(
licenses.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
exceptions.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase));
}
private static LicenseDescriptor BuildDescriptor(string id, bool isOsiApproved, LicenseCategory? categoryOverride = null)
{
var category = categoryOverride ?? GetCategory(id);
var attributes = new LicenseAttributes
{
AttributionRequired = category != LicenseCategory.PublicDomain,
SourceDisclosureRequired = category is LicenseCategory.StrongCopyleft or LicenseCategory.WeakCopyleft,
PatentGrant = IsPatentGrantLicense(id),
TrademarkNotice = string.Equals(id, "Apache-2.0", StringComparison.OrdinalIgnoreCase),
CommercialUseAllowed = category != LicenseCategory.Proprietary
};
return new LicenseDescriptor
{
Id = id,
Category = category,
IsOsiApproved = isOsiApproved,
Attributes = attributes
};
}
private static LicenseCategory GetCategory(string id)
{
if (Permissive.Contains(id))
{
return LicenseCategory.Permissive;
}
if (WeakCopyleft.Contains(id))
{
return LicenseCategory.WeakCopyleft;
}
if (StrongCopyleft.Contains(id))
{
return LicenseCategory.StrongCopyleft;
}
if (PublicDomain.Contains(id))
{
return LicenseCategory.PublicDomain;
}
if (id.StartsWith("LicenseRef-", StringComparison.OrdinalIgnoreCase))
{
return LicenseCategory.Proprietary;
}
return LicenseCategory.Unknown;
}
private static bool IsPatentGrantLicense(string id)
{
return id.Equals("Apache-2.0", StringComparison.OrdinalIgnoreCase)
|| id.Equals("MPL-2.0", StringComparison.OrdinalIgnoreCase)
|| id.Equals("EPL-2.0", StringComparison.OrdinalIgnoreCase);
}
private static string ReadEmbeddedResource(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(name => name.EndsWith(fileName, StringComparison.OrdinalIgnoreCase));
if (resourceName is null)
{
throw new InvalidOperationException($"Embedded resource not found: {fileName}");
}
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
throw new InvalidOperationException($"Embedded resource not found: {fileName}");
}
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static string NormalizeKey(string value)
{
return value.Trim().ToLowerInvariant();
}
private static readonly ImmutableHashSet<string> Permissive = ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"CC-BY-4.0");
private static readonly ImmutableHashSet<string> WeakCopyleft = ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
"LGPL-2.1-only",
"LGPL-2.1-or-later",
"LGPL-3.0-only",
"LGPL-3.0-or-later",
"MPL-2.0",
"EPL-2.0");
private static readonly ImmutableHashSet<string> StrongCopyleft = ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
"GPL-2.0-only",
"GPL-2.0-or-later",
"GPL-3.0-only",
"GPL-3.0-or-later",
"AGPL-3.0-only",
"AGPL-3.0-or-later");
private static readonly ImmutableHashSet<string> PublicDomain = ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
"CC0-1.0",
"Unlicense");
}

View File

@@ -0,0 +1,137 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Licensing;
public sealed record LicensePolicy
{
public ProjectContext ProjectContext { get; init; } = new();
public ImmutableArray<string> AllowedLicenses { get; init; } = [];
public ImmutableArray<string> ProhibitedLicenses { get; init; } = [];
public ImmutableArray<ConditionalLicenseRule> ConditionalLicenses { get; init; } = [];
public LicenseCategoryRules Categories { get; init; } = new();
public UnknownLicenseHandling UnknownLicenseHandling { get; init; } = UnknownLicenseHandling.Warn;
public AttributionPolicy AttributionRequirements { get; init; } = new();
public ImmutableArray<LicenseExemption> Exemptions { get; init; } = [];
}
public sealed record ProjectContext
{
public DistributionModel DistributionModel { get; init; } = DistributionModel.Commercial;
public LinkingModel LinkingModel { get; init; } = LinkingModel.Dynamic;
}
public sealed record LicenseCategoryRules
{
public bool AllowCopyleft { get; init; }
public bool AllowWeakCopyleft { get; init; } = true;
public bool RequireOsiApproved { get; init; } = true;
}
public sealed record ConditionalLicenseRule
{
public required string License { get; init; }
public required LicenseCondition Condition { get; init; }
}
public sealed record LicenseExemption
{
public required string ComponentPattern { get; init; }
public required string Reason { get; init; }
public ImmutableArray<string> AllowedLicenses { get; init; } = [];
}
public sealed record AttributionPolicy
{
public bool GenerateNoticeFile { get; init; } = true;
public bool IncludeLicenseText { get; init; } = true;
public AttributionFormat Format { get; init; } = AttributionFormat.Markdown;
}
public enum DistributionModel
{
Internal = 0,
OpenSource = 1,
Commercial = 2,
Saas = 3
}
public enum LinkingModel
{
Static = 0,
Dynamic = 1,
Process = 2
}
public enum UnknownLicenseHandling
{
Allow = 0,
Warn = 1,
Deny = 2
}
public enum LicenseCondition
{
DynamicLinkingOnly = 0,
FileIsolation = 1
}
public enum AttributionFormat
{
Markdown = 0,
PlainText = 1,
Html = 2
}
public static class LicensePolicyDefaults
{
public static LicensePolicy Default { get; } = new()
{
ProjectContext = new ProjectContext
{
DistributionModel = DistributionModel.Commercial,
LinkingModel = LinkingModel.Dynamic
},
AllowedLicenses =
[
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC"
],
ProhibitedLicenses =
[
"GPL-3.0-only",
"GPL-3.0-or-later",
"AGPL-3.0-only",
"AGPL-3.0-or-later"
],
ConditionalLicenses =
[
new ConditionalLicenseRule
{
License = "LGPL-2.1-only",
Condition = LicenseCondition.DynamicLinkingOnly
},
new ConditionalLicenseRule
{
License = "MPL-2.0",
Condition = LicenseCondition.FileIsolation
}
],
Categories = new LicenseCategoryRules
{
AllowCopyleft = false,
AllowWeakCopyleft = true,
RequireOsiApproved = true
},
UnknownLicenseHandling = UnknownLicenseHandling.Warn,
AttributionRequirements = new AttributionPolicy
{
GenerateNoticeFile = true,
IncludeLicenseText = true,
Format = AttributionFormat.Markdown
},
Exemptions = []
};
}

View File

@@ -0,0 +1,147 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Linq;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Policy.Licensing;
public interface ILicensePolicyLoader
{
LicensePolicy Load(string path);
}
public sealed class LicensePolicyLoader : ILicensePolicyLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public LicensePolicy Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("License policy path is required.", nameof(path));
}
var text = File.ReadAllText(path);
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
return LoadJson(text);
}
return LoadYaml(text);
}
private static LicensePolicy LoadJson(string json)
{
var document = JsonSerializer.Deserialize<LicensePolicyDocument>(json, JsonOptions);
if (document?.LicensePolicy is not null)
{
return document.LicensePolicy;
}
var policy = JsonSerializer.Deserialize<LicensePolicy>(json, JsonOptions);
return policy ?? LicensePolicyDefaults.Default;
}
private static LicensePolicy LoadYaml(string yaml)
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var document = deserializer.Deserialize<LicensePolicyYamlDocument>(yaml);
var policyYaml = document?.LicensePolicy ?? deserializer.Deserialize<LicensePolicyYaml>(yaml);
if (policyYaml is null)
{
return LicensePolicyDefaults.Default;
}
return ToLicensePolicy(policyYaml);
}
private sealed record LicensePolicyDocument
{
public LicensePolicy? LicensePolicy { get; init; }
}
private sealed record LicensePolicyYamlDocument
{
public LicensePolicyYaml? LicensePolicy { get; init; }
}
private sealed record LicensePolicyYaml
{
public ProjectContext? ProjectContext { get; init; }
public string[]? AllowedLicenses { get; init; }
public string[]? ProhibitedLicenses { get; init; }
public ConditionalLicenseRuleYaml[]? ConditionalLicenses { get; init; }
public LicenseCategoryRules? Categories { get; init; }
public UnknownLicenseHandling? UnknownLicenseHandling { get; init; }
public AttributionPolicy? AttributionRequirements { get; init; }
public LicenseExemptionYaml[]? Exemptions { get; init; }
}
private sealed record ConditionalLicenseRuleYaml
{
public string? License { get; init; }
public LicenseCondition? Condition { get; init; }
}
private sealed record LicenseExemptionYaml
{
public string? ComponentPattern { get; init; }
public string? Reason { get; init; }
public string[]? AllowedLicenses { get; init; }
}
private static LicensePolicy ToLicensePolicy(LicensePolicyYaml yaml)
{
var defaults = LicensePolicyDefaults.Default;
return new LicensePolicy
{
ProjectContext = yaml.ProjectContext ?? defaults.ProjectContext,
AllowedLicenses = yaml.AllowedLicenses is null
? defaults.AllowedLicenses
: yaml.AllowedLicenses.ToImmutableArray(),
ProhibitedLicenses = yaml.ProhibitedLicenses is null
? defaults.ProhibitedLicenses
: yaml.ProhibitedLicenses.ToImmutableArray(),
ConditionalLicenses = yaml.ConditionalLicenses is null
? defaults.ConditionalLicenses
: yaml.ConditionalLicenses.Select(rule => new ConditionalLicenseRule
{
License = RequireValue(rule.License, "conditionalLicenses.license"),
Condition = rule.Condition ?? LicenseCondition.DynamicLinkingOnly
}).ToImmutableArray(),
Categories = yaml.Categories ?? defaults.Categories,
UnknownLicenseHandling = yaml.UnknownLicenseHandling ?? defaults.UnknownLicenseHandling,
AttributionRequirements = yaml.AttributionRequirements ?? defaults.AttributionRequirements,
Exemptions = yaml.Exemptions is null
? defaults.Exemptions
: yaml.Exemptions.Select(exemption => new LicenseExemption
{
ComponentPattern = RequireValue(exemption.ComponentPattern, "exemptions.componentPattern"),
Reason = RequireValue(exemption.Reason, "exemptions.reason"),
AllowedLicenses = exemption.AllowedLicenses is null
? ImmutableArray<string>.Empty
: exemption.AllowedLicenses.ToImmutableArray()
}).ToImmutableArray()
};
}
private static string RequireValue(string? value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidDataException($"License policy YAML missing required field '{fieldName}'.");
}
return value;
}
}

View File

@@ -0,0 +1,29 @@
namespace StellaOps.Policy.Licensing;
public sealed class ProjectContextAnalyzer
{
public bool IsConditionSatisfied(ProjectContext context, LicenseCondition condition)
{
return condition switch
{
LicenseCondition.DynamicLinkingOnly => context.LinkingModel == LinkingModel.Dynamic,
LicenseCondition.FileIsolation => context.LinkingModel == LinkingModel.Process,
_ => false
};
}
public bool IsCopyleftAllowed(ProjectContext context, LicenseCategoryRules rules, LicenseCategory category)
{
if (category == LicenseCategory.StrongCopyleft)
{
return rules.AllowCopyleft && context.DistributionModel != DistributionModel.Commercial;
}
if (category == LicenseCategory.WeakCopyleft)
{
return rules.AllowWeakCopyleft;
}
return true;
}
}

View File

@@ -0,0 +1,643 @@
{
"licenseListVersion": "3.21",
"exceptions": [
{
"reference": "./389-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./389-exception.html",
"referenceNumber": 48,
"name": "389 Directory Server Exception",
"licenseExceptionId": "389-exception",
"seeAlso": [
"http://directory.fedoraproject.org/wiki/GPL_Exception_License_Text",
"https://web.archive.org/web/20080828121337/http://directory.fedoraproject.org/wiki/GPL_Exception_License_Text"
]
},
{
"reference": "./Asterisk-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Asterisk-exception.html",
"referenceNumber": 33,
"name": "Asterisk exception",
"licenseExceptionId": "Asterisk-exception",
"seeAlso": [
"https://github.com/asterisk/libpri/blob/7f91151e6bd10957c746c031c1f4a030e8146e9a/pri.c#L22",
"https://github.com/asterisk/libss7/blob/03e81bcd0d28ff25d4c77c78351ddadc82ff5c3f/ss7.c#L24"
]
},
{
"reference": "./Autoconf-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Autoconf-exception-2.0.html",
"referenceNumber": 42,
"name": "Autoconf exception 2.0",
"licenseExceptionId": "Autoconf-exception-2.0",
"seeAlso": [
"http://ac-archive.sourceforge.net/doc/copyright.html",
"http://ftp.gnu.org/gnu/autoconf/autoconf-2.59.tar.gz"
]
},
{
"reference": "./Autoconf-exception-3.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Autoconf-exception-3.0.html",
"referenceNumber": 41,
"name": "Autoconf exception 3.0",
"licenseExceptionId": "Autoconf-exception-3.0",
"seeAlso": [
"http://www.gnu.org/licenses/autoconf-exception-3.0.html"
]
},
{
"reference": "./Autoconf-exception-generic.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Autoconf-exception-generic.html",
"referenceNumber": 4,
"name": "Autoconf generic exception",
"licenseExceptionId": "Autoconf-exception-generic",
"seeAlso": [
"https://launchpad.net/ubuntu/precise/+source/xmltooling/+copyright",
"https://tracker.debian.org/media/packages/s/sipwitch/copyright-1.9.15-3",
"https://opensource.apple.com/source/launchd/launchd-258.1/launchd/compile.auto.html"
]
},
{
"reference": "./Autoconf-exception-macro.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Autoconf-exception-macro.html",
"referenceNumber": 19,
"name": "Autoconf macro exception",
"licenseExceptionId": "Autoconf-exception-macro",
"seeAlso": [
"https://github.com/freedesktop/xorg-macros/blob/39f07f7db58ebbf3dcb64a2bf9098ed5cf3d1223/xorg-macros.m4.in",
"https://www.gnu.org/software/autoconf-archive/ax_pthread.html",
"https://launchpad.net/ubuntu/precise/+source/xmltooling/+copyright"
]
},
{
"reference": "./Bison-exception-2.2.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Bison-exception-2.2.html",
"referenceNumber": 11,
"name": "Bison exception 2.2",
"licenseExceptionId": "Bison-exception-2.2",
"seeAlso": [
"http://git.savannah.gnu.org/cgit/bison.git/tree/data/yacc.c?id\u003d193d7c7054ba7197b0789e14965b739162319b5e#n141"
]
},
{
"reference": "./Bootloader-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Bootloader-exception.html",
"referenceNumber": 50,
"name": "Bootloader Distribution Exception",
"licenseExceptionId": "Bootloader-exception",
"seeAlso": [
"https://github.com/pyinstaller/pyinstaller/blob/develop/COPYING.txt"
]
},
{
"reference": "./Classpath-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Classpath-exception-2.0.html",
"referenceNumber": 36,
"name": "Classpath exception 2.0",
"licenseExceptionId": "Classpath-exception-2.0",
"seeAlso": [
"http://www.gnu.org/software/classpath/license.html",
"https://fedoraproject.org/wiki/Licensing/GPL_Classpath_Exception"
]
},
{
"reference": "./CLISP-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./CLISP-exception-2.0.html",
"referenceNumber": 9,
"name": "CLISP exception 2.0",
"licenseExceptionId": "CLISP-exception-2.0",
"seeAlso": [
"http://sourceforge.net/p/clisp/clisp/ci/default/tree/COPYRIGHT"
]
},
{
"reference": "./cryptsetup-OpenSSL-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./cryptsetup-OpenSSL-exception.html",
"referenceNumber": 39,
"name": "cryptsetup OpenSSL exception",
"licenseExceptionId": "cryptsetup-OpenSSL-exception",
"seeAlso": [
"https://gitlab.com/cryptsetup/cryptsetup/-/blob/main/COPYING",
"https://gitlab.nic.cz/datovka/datovka/-/blob/develop/COPYING",
"https://github.com/nbs-system/naxsi/blob/951123ad456bdf5ac94e8d8819342fe3d49bc002/naxsi_src/naxsi_raw.c",
"http://web.mit.edu/jgross/arch/amd64_deb60/bin/mosh"
]
},
{
"reference": "./DigiRule-FOSS-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./DigiRule-FOSS-exception.html",
"referenceNumber": 20,
"name": "DigiRule FOSS License Exception",
"licenseExceptionId": "DigiRule-FOSS-exception",
"seeAlso": [
"http://www.digirulesolutions.com/drupal/foss"
]
},
{
"reference": "./eCos-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./eCos-exception-2.0.html",
"referenceNumber": 38,
"name": "eCos exception 2.0",
"licenseExceptionId": "eCos-exception-2.0",
"seeAlso": [
"http://ecos.sourceware.org/license-overview.html"
]
},
{
"reference": "./Fawkes-Runtime-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Fawkes-Runtime-exception.html",
"referenceNumber": 8,
"name": "Fawkes Runtime Exception",
"licenseExceptionId": "Fawkes-Runtime-exception",
"seeAlso": [
"http://www.fawkesrobotics.org/about/license/"
]
},
{
"reference": "./FLTK-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./FLTK-exception.html",
"referenceNumber": 18,
"name": "FLTK exception",
"licenseExceptionId": "FLTK-exception",
"seeAlso": [
"http://www.fltk.org/COPYING.php"
]
},
{
"reference": "./Font-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Font-exception-2.0.html",
"referenceNumber": 7,
"name": "Font exception 2.0",
"licenseExceptionId": "Font-exception-2.0",
"seeAlso": [
"http://www.gnu.org/licenses/gpl-faq.html#FontException"
]
},
{
"reference": "./freertos-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./freertos-exception-2.0.html",
"referenceNumber": 47,
"name": "FreeRTOS Exception 2.0",
"licenseExceptionId": "freertos-exception-2.0",
"seeAlso": [
"https://web.archive.org/web/20060809182744/http://www.freertos.org/a00114.html"
]
},
{
"reference": "./GCC-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GCC-exception-2.0.html",
"referenceNumber": 54,
"name": "GCC Runtime Library exception 2.0",
"licenseExceptionId": "GCC-exception-2.0",
"seeAlso": [
"https://gcc.gnu.org/git/?p\u003dgcc.git;a\u003dblob;f\u003dgcc/libgcc1.c;h\u003d762f5143fc6eed57b6797c82710f3538aa52b40b;hb\u003dcb143a3ce4fb417c68f5fa2691a1b1b1053dfba9#l10"
]
},
{
"reference": "./GCC-exception-3.1.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GCC-exception-3.1.html",
"referenceNumber": 27,
"name": "GCC Runtime Library exception 3.1",
"licenseExceptionId": "GCC-exception-3.1",
"seeAlso": [
"http://www.gnu.org/licenses/gcc-exception-3.1.html"
]
},
{
"reference": "./GNAT-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GNAT-exception.html",
"referenceNumber": 13,
"name": "GNAT exception",
"licenseExceptionId": "GNAT-exception",
"seeAlso": [
"https://github.com/AdaCore/florist/blob/master/libsrc/posix-configurable_file_limits.adb"
]
},
{
"reference": "./gnu-javamail-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./gnu-javamail-exception.html",
"referenceNumber": 34,
"name": "GNU JavaMail exception",
"licenseExceptionId": "gnu-javamail-exception",
"seeAlso": [
"http://www.gnu.org/software/classpathx/javamail/javamail.html"
]
},
{
"reference": "./GPL-3.0-interface-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GPL-3.0-interface-exception.html",
"referenceNumber": 21,
"name": "GPL-3.0 Interface Exception",
"licenseExceptionId": "GPL-3.0-interface-exception",
"seeAlso": [
"https://www.gnu.org/licenses/gpl-faq.en.html#LinkingOverControlledInterface"
]
},
{
"reference": "./GPL-3.0-linking-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GPL-3.0-linking-exception.html",
"referenceNumber": 1,
"name": "GPL-3.0 Linking Exception",
"licenseExceptionId": "GPL-3.0-linking-exception",
"seeAlso": [
"https://www.gnu.org/licenses/gpl-faq.en.html#GPLIncompatibleLibs"
]
},
{
"reference": "./GPL-3.0-linking-source-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GPL-3.0-linking-source-exception.html",
"referenceNumber": 37,
"name": "GPL-3.0 Linking Exception (with Corresponding Source)",
"licenseExceptionId": "GPL-3.0-linking-source-exception",
"seeAlso": [
"https://www.gnu.org/licenses/gpl-faq.en.html#GPLIncompatibleLibs",
"https://github.com/mirror/wget/blob/master/src/http.c#L20"
]
},
{
"reference": "./GPL-CC-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GPL-CC-1.0.html",
"referenceNumber": 52,
"name": "GPL Cooperation Commitment 1.0",
"licenseExceptionId": "GPL-CC-1.0",
"seeAlso": [
"https://github.com/gplcc/gplcc/blob/master/Project/COMMITMENT",
"https://gplcc.github.io/gplcc/Project/README-PROJECT.html"
]
},
{
"reference": "./GStreamer-exception-2005.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GStreamer-exception-2005.html",
"referenceNumber": 35,
"name": "GStreamer Exception (2005)",
"licenseExceptionId": "GStreamer-exception-2005",
"seeAlso": [
"https://gstreamer.freedesktop.org/documentation/frequently-asked-questions/licensing.html?gi-language\u003dc#licensing-of-applications-using-gstreamer"
]
},
{
"reference": "./GStreamer-exception-2008.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GStreamer-exception-2008.html",
"referenceNumber": 30,
"name": "GStreamer Exception (2008)",
"licenseExceptionId": "GStreamer-exception-2008",
"seeAlso": [
"https://gstreamer.freedesktop.org/documentation/frequently-asked-questions/licensing.html?gi-language\u003dc#licensing-of-applications-using-gstreamer"
]
},
{
"reference": "./i2p-gpl-java-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./i2p-gpl-java-exception.html",
"referenceNumber": 40,
"name": "i2p GPL+Java Exception",
"licenseExceptionId": "i2p-gpl-java-exception",
"seeAlso": [
"http://geti2p.net/en/get-involved/develop/licenses#java_exception"
]
},
{
"reference": "./KiCad-libraries-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./KiCad-libraries-exception.html",
"referenceNumber": 28,
"name": "KiCad Libraries Exception",
"licenseExceptionId": "KiCad-libraries-exception",
"seeAlso": [
"https://www.kicad.org/libraries/license/"
]
},
{
"reference": "./LGPL-3.0-linking-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./LGPL-3.0-linking-exception.html",
"referenceNumber": 2,
"name": "LGPL-3.0 Linking Exception",
"licenseExceptionId": "LGPL-3.0-linking-exception",
"seeAlso": [
"https://raw.githubusercontent.com/go-xmlpath/xmlpath/v2/LICENSE",
"https://github.com/goamz/goamz/blob/master/LICENSE",
"https://github.com/juju/errors/blob/master/LICENSE"
]
},
{
"reference": "./libpri-OpenH323-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./libpri-OpenH323-exception.html",
"referenceNumber": 32,
"name": "libpri OpenH323 exception",
"licenseExceptionId": "libpri-OpenH323-exception",
"seeAlso": [
"https://github.com/asterisk/libpri/blob/1.6.0/README#L19-L22"
]
},
{
"reference": "./Libtool-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Libtool-exception.html",
"referenceNumber": 17,
"name": "Libtool Exception",
"licenseExceptionId": "Libtool-exception",
"seeAlso": [
"http://git.savannah.gnu.org/cgit/libtool.git/tree/m4/libtool.m4"
]
},
{
"reference": "./Linux-syscall-note.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Linux-syscall-note.html",
"referenceNumber": 49,
"name": "Linux Syscall Note",
"licenseExceptionId": "Linux-syscall-note",
"seeAlso": [
"https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/COPYING"
]
},
{
"reference": "./LLGPL.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./LLGPL.html",
"referenceNumber": 3,
"name": "LLGPL Preamble",
"licenseExceptionId": "LLGPL",
"seeAlso": [
"http://opensource.franz.com/preamble.html"
]
},
{
"reference": "./LLVM-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./LLVM-exception.html",
"referenceNumber": 14,
"name": "LLVM Exception",
"licenseExceptionId": "LLVM-exception",
"seeAlso": [
"http://llvm.org/foundation/relicensing/LICENSE.txt"
]
},
{
"reference": "./LZMA-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./LZMA-exception.html",
"referenceNumber": 55,
"name": "LZMA exception",
"licenseExceptionId": "LZMA-exception",
"seeAlso": [
"http://nsis.sourceforge.net/Docs/AppendixI.html#I.6"
]
},
{
"reference": "./mif-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./mif-exception.html",
"referenceNumber": 53,
"name": "Macros and Inline Functions Exception",
"licenseExceptionId": "mif-exception",
"seeAlso": [
"http://www.scs.stanford.edu/histar/src/lib/cppsup/exception",
"http://dev.bertos.org/doxygen/",
"https://www.threadingbuildingblocks.org/licensing"
]
},
{
"reference": "./Nokia-Qt-exception-1.1.json",
"isDeprecatedLicenseId": true,
"detailsUrl": "./Nokia-Qt-exception-1.1.html",
"referenceNumber": 31,
"name": "Nokia Qt LGPL exception 1.1",
"licenseExceptionId": "Nokia-Qt-exception-1.1",
"seeAlso": [
"https://www.keepassx.org/dev/projects/keepassx/repository/revisions/b8dfb9cc4d5133e0f09cd7533d15a4f1c19a40f2/entry/LICENSE.NOKIA-LGPL-EXCEPTION"
]
},
{
"reference": "./OCaml-LGPL-linking-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./OCaml-LGPL-linking-exception.html",
"referenceNumber": 29,
"name": "OCaml LGPL Linking Exception",
"licenseExceptionId": "OCaml-LGPL-linking-exception",
"seeAlso": [
"https://caml.inria.fr/ocaml/license.en.html"
]
},
{
"reference": "./OCCT-exception-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./OCCT-exception-1.0.html",
"referenceNumber": 15,
"name": "Open CASCADE Exception 1.0",
"licenseExceptionId": "OCCT-exception-1.0",
"seeAlso": [
"http://www.opencascade.com/content/licensing"
]
},
{
"reference": "./OpenJDK-assembly-exception-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./OpenJDK-assembly-exception-1.0.html",
"referenceNumber": 24,
"name": "OpenJDK Assembly exception 1.0",
"licenseExceptionId": "OpenJDK-assembly-exception-1.0",
"seeAlso": [
"http://openjdk.java.net/legal/assembly-exception.html"
]
},
{
"reference": "./openvpn-openssl-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./openvpn-openssl-exception.html",
"referenceNumber": 43,
"name": "OpenVPN OpenSSL Exception",
"licenseExceptionId": "openvpn-openssl-exception",
"seeAlso": [
"http://openvpn.net/index.php/license.html"
]
},
{
"reference": "./PS-or-PDF-font-exception-20170817.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./PS-or-PDF-font-exception-20170817.html",
"referenceNumber": 45,
"name": "PS/PDF font exception (2017-08-17)",
"licenseExceptionId": "PS-or-PDF-font-exception-20170817",
"seeAlso": [
"https://github.com/ArtifexSoftware/urw-base35-fonts/blob/65962e27febc3883a17e651cdb23e783668c996f/LICENSE"
]
},
{
"reference": "./QPL-1.0-INRIA-2004-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./QPL-1.0-INRIA-2004-exception.html",
"referenceNumber": 44,
"name": "INRIA QPL 1.0 2004 variant exception",
"licenseExceptionId": "QPL-1.0-INRIA-2004-exception",
"seeAlso": [
"https://git.frama-c.com/pub/frama-c/-/blob/master/licenses/Q_MODIFIED_LICENSE",
"https://github.com/maranget/hevea/blob/master/LICENSE"
]
},
{
"reference": "./Qt-GPL-exception-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Qt-GPL-exception-1.0.html",
"referenceNumber": 10,
"name": "Qt GPL exception 1.0",
"licenseExceptionId": "Qt-GPL-exception-1.0",
"seeAlso": [
"http://code.qt.io/cgit/qt/qtbase.git/tree/LICENSE.GPL3-EXCEPT"
]
},
{
"reference": "./Qt-LGPL-exception-1.1.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Qt-LGPL-exception-1.1.html",
"referenceNumber": 16,
"name": "Qt LGPL exception 1.1",
"licenseExceptionId": "Qt-LGPL-exception-1.1",
"seeAlso": [
"http://code.qt.io/cgit/qt/qtbase.git/tree/LGPL_EXCEPTION.txt"
]
},
{
"reference": "./Qwt-exception-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Qwt-exception-1.0.html",
"referenceNumber": 51,
"name": "Qwt exception 1.0",
"licenseExceptionId": "Qwt-exception-1.0",
"seeAlso": [
"http://qwt.sourceforge.net/qwtlicense.html"
]
},
{
"reference": "./SHL-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./SHL-2.0.html",
"referenceNumber": 26,
"name": "Solderpad Hardware License v2.0",
"licenseExceptionId": "SHL-2.0",
"seeAlso": [
"https://solderpad.org/licenses/SHL-2.0/"
]
},
{
"reference": "./SHL-2.1.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./SHL-2.1.html",
"referenceNumber": 23,
"name": "Solderpad Hardware License v2.1",
"licenseExceptionId": "SHL-2.1",
"seeAlso": [
"https://solderpad.org/licenses/SHL-2.1/"
]
},
{
"reference": "./SWI-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./SWI-exception.html",
"referenceNumber": 22,
"name": "SWI exception",
"licenseExceptionId": "SWI-exception",
"seeAlso": [
"https://github.com/SWI-Prolog/packages-clpqr/blob/bfa80b9270274f0800120d5b8e6fef42ac2dc6a5/clpqr/class.pl"
]
},
{
"reference": "./Swift-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Swift-exception.html",
"referenceNumber": 46,
"name": "Swift Exception",
"licenseExceptionId": "Swift-exception",
"seeAlso": [
"https://swift.org/LICENSE.txt",
"https://github.com/apple/swift-package-manager/blob/7ab2275f447a5eb37497ed63a9340f8a6d1e488b/LICENSE.txt#L205"
]
},
{
"reference": "./u-boot-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./u-boot-exception-2.0.html",
"referenceNumber": 5,
"name": "U-Boot exception 2.0",
"licenseExceptionId": "u-boot-exception-2.0",
"seeAlso": [
"http://git.denx.de/?p\u003du-boot.git;a\u003dblob;f\u003dLicenses/Exceptions"
]
},
{
"reference": "./Universal-FOSS-exception-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Universal-FOSS-exception-1.0.html",
"referenceNumber": 12,
"name": "Universal FOSS Exception, Version 1.0",
"licenseExceptionId": "Universal-FOSS-exception-1.0",
"seeAlso": [
"https://oss.oracle.com/licenses/universal-foss-exception/"
]
},
{
"reference": "./vsftpd-openssl-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./vsftpd-openssl-exception.html",
"referenceNumber": 56,
"name": "vsftpd OpenSSL exception",
"licenseExceptionId": "vsftpd-openssl-exception",
"seeAlso": [
"https://git.stg.centos.org/source-git/vsftpd/blob/f727873674d9c9cd7afcae6677aa782eb54c8362/f/LICENSE",
"https://launchpad.net/debian/squeeze/+source/vsftpd/+copyright",
"https://github.com/richardcochran/vsftpd/blob/master/COPYING"
]
},
{
"reference": "./WxWindows-exception-3.1.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./WxWindows-exception-3.1.html",
"referenceNumber": 25,
"name": "WxWindows Library Exception 3.1",
"licenseExceptionId": "WxWindows-exception-3.1",
"seeAlso": [
"http://www.opensource.org/licenses/WXwindows"
]
},
{
"reference": "./x11vnc-openssl-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./x11vnc-openssl-exception.html",
"referenceNumber": 6,
"name": "x11vnc OpenSSL Exception",
"licenseExceptionId": "x11vnc-openssl-exception",
"seeAlso": [
"https://github.com/LibVNC/x11vnc/blob/master/src/8to24.c#L22"
]
}
],
"releaseDate": "2023-06-18"
}

View File

@@ -0,0 +1,205 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Licensing;
public sealed class SpdxLicenseExpressionParser
{
private readonly Tokenizer _tokenizer;
public SpdxLicenseExpressionParser(string expression)
{
_tokenizer = new Tokenizer(expression);
}
public static LicenseExpression Parse(string expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
throw new ArgumentException("License expression is required.", nameof(expression));
}
var parser = new SpdxLicenseExpressionParser(expression);
var result = parser.ParseOr();
parser.Expect(TokenKind.End);
return result;
}
private LicenseExpression ParseOr()
{
var left = ParseAnd();
var terms = new List<LicenseExpression> { left };
while (_tokenizer.Peek().Kind == TokenKind.Or)
{
_tokenizer.Next();
terms.Add(ParseAnd());
}
return terms.Count == 1 ? left : new OrExpression(terms.ToImmutableArray());
}
private LicenseExpression ParseAnd()
{
var left = ParseWith();
var terms = new List<LicenseExpression> { left };
while (_tokenizer.Peek().Kind == TokenKind.And)
{
_tokenizer.Next();
terms.Add(ParseWith());
}
return terms.Count == 1 ? left : new AndExpression(terms.ToImmutableArray());
}
private LicenseExpression ParseWith()
{
var left = ParsePrimary();
if (_tokenizer.Peek().Kind != TokenKind.With)
{
return left;
}
_tokenizer.Next();
var exceptionToken = Expect(TokenKind.Identifier);
return new WithExceptionExpression(left, exceptionToken.Value ?? string.Empty);
}
private LicenseExpression ParsePrimary()
{
var token = _tokenizer.Peek();
if (token.Kind == TokenKind.LeftParen)
{
_tokenizer.Next();
var expression = ParseOr();
Expect(TokenKind.RightParen);
return expression;
}
if (token.Kind == TokenKind.Identifier)
{
_tokenizer.Next();
return BuildLicenseExpression(token.Value ?? string.Empty);
}
throw new FormatException($"Unexpected token '{token.Kind}'.");
}
private static LicenseExpression BuildLicenseExpression(string value)
{
var trimmed = value.Trim();
if (trimmed.EndsWith("+", StringComparison.Ordinal))
{
return new OrLaterExpression(trimmed.TrimEnd('+'));
}
return new LicenseIdExpression(trimmed);
}
private Token Expect(TokenKind kind)
{
var token = _tokenizer.Next();
if (token.Kind != kind)
{
throw new FormatException($"Expected {kind} but found {token.Kind}.");
}
return token;
}
private sealed class Tokenizer
{
private readonly string _input;
private int _index;
private Token? _buffer;
public Tokenizer(string input)
{
_input = input ?? string.Empty;
}
public Token Peek()
{
_buffer ??= ReadNext();
return _buffer.Value;
}
public Token Next()
{
var token = Peek();
_buffer = null;
return token;
}
private Token ReadNext()
{
SkipWhitespace();
if (_index >= _input.Length)
{
return new Token(TokenKind.End, null);
}
var ch = _input[_index];
if (ch == '(')
{
_index++;
return new Token(TokenKind.LeftParen, "(");
}
if (ch == ')')
{
_index++;
return new Token(TokenKind.RightParen, ")");
}
var start = _index;
while (_index < _input.Length)
{
ch = _input[_index];
if (char.IsWhiteSpace(ch) || ch == '(' || ch == ')')
{
break;
}
_index++;
}
var value = _input.Substring(start, _index - start);
if (value.Equals("AND", StringComparison.OrdinalIgnoreCase))
{
return new Token(TokenKind.And, value);
}
if (value.Equals("OR", StringComparison.OrdinalIgnoreCase))
{
return new Token(TokenKind.Or, value);
}
if (value.Equals("WITH", StringComparison.OrdinalIgnoreCase))
{
return new Token(TokenKind.With, value);
}
return new Token(TokenKind.Identifier, value);
}
private void SkipWhitespace()
{
while (_index < _input.Length && char.IsWhiteSpace(_input[_index]))
{
_index++;
}
}
}
private readonly record struct Token(TokenKind Kind, string? Value);
private enum TokenKind
{
Identifier,
And,
Or,
With,
LeftParen,
RightParen,
End
}
}

View File

@@ -0,0 +1,78 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Policy.NtiaCompliance;
public sealed class DependencyCompletenessChecker
{
public DependencyCompletenessReport Evaluate(ParsedSbom sbom, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(sbom);
var components = sbom.Components;
if (components.IsDefaultOrEmpty)
{
return new DependencyCompletenessReport
{
TotalComponents = 0,
ComponentsWithDependencies = 0,
CompletenessScore = 0.0
};
}
var componentRefs = components
.Where(component => !string.IsNullOrWhiteSpace(component.BomRef))
.Select(component => component.BomRef)
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
var dependencyParticipants = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var missingDependencyRefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var dependency in sbom.Dependencies)
{
ct.ThrowIfCancellationRequested();
if (!string.IsNullOrWhiteSpace(dependency.SourceRef))
{
dependencyParticipants.Add(dependency.SourceRef);
}
foreach (var target in dependency.DependsOn)
{
if (string.IsNullOrWhiteSpace(target))
{
continue;
}
dependencyParticipants.Add(target);
if (!componentRefs.Contains(target))
{
missingDependencyRefs.Add(target);
}
}
}
var orphaned = components
.Where(component => !dependencyParticipants.Contains(component.BomRef))
.Select(component => component.BomRef)
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var totalComponents = components.Length;
var withDependencies = totalComponents - orphaned.Length;
var completenessScore = totalComponents == 0
? 0.0
: Math.Round(withDependencies * 100.0 / totalComponents, 2, MidpointRounding.AwayFromZero);
return new DependencyCompletenessReport
{
TotalComponents = totalComponents,
ComponentsWithDependencies = withDependencies,
OrphanedComponents = orphaned,
MissingDependencyRefs = missingDependencyRefs
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
CompletenessScore = completenessScore
};
}
}

View File

@@ -0,0 +1,450 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Policy.NtiaCompliance;
public sealed class NtiaBaselineValidator : INtiaComplianceValidator
{
private readonly SupplierValidator _supplierValidator;
private readonly SupplierTrustVerifier _supplierTrustVerifier;
private readonly DependencyCompletenessChecker _dependencyChecker;
private readonly RegulatoryFrameworkMapper _frameworkMapper;
private readonly SupplyChainTransparencyReporter _transparencyReporter;
public NtiaBaselineValidator(
SupplierValidator? supplierValidator = null,
SupplierTrustVerifier? supplierTrustVerifier = null,
DependencyCompletenessChecker? dependencyChecker = null,
RegulatoryFrameworkMapper? frameworkMapper = null,
SupplyChainTransparencyReporter? transparencyReporter = null)
{
_supplierValidator = supplierValidator ?? new SupplierValidator();
_supplierTrustVerifier = supplierTrustVerifier ?? new SupplierTrustVerifier();
_dependencyChecker = dependencyChecker ?? new DependencyCompletenessChecker();
_frameworkMapper = frameworkMapper ?? new RegulatoryFrameworkMapper();
_transparencyReporter = transparencyReporter ?? new SupplyChainTransparencyReporter();
}
public Task<NtiaComplianceReport> ValidateAsync(
ParsedSbom sbom,
NtiaCompliancePolicy policy,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(sbom);
ArgumentNullException.ThrowIfNull(policy);
var components = sbom.Components;
var requiredElements = policy.MinimumElements.Elements.IsDefaultOrEmpty
? NtiaCompliancePolicyDefaults.MinimumElements.Elements
: policy.MinimumElements.Elements;
var supplierReport = _supplierValidator.Validate(sbom, policy.SupplierValidation, ct);
var supplierTrust = _supplierTrustVerifier.Verify(supplierReport, policy.SupplierValidation, ct);
var dependencyReport = _dependencyChecker.Evaluate(sbom, ct);
var elementStatuses = BuildElementStatuses(
sbom,
components,
requiredElements,
supplierReport,
dependencyReport,
policy,
ct);
var findings = BuildFindings(
elementStatuses,
supplierReport,
supplierTrust,
dependencyReport,
policy);
var complianceScore = ComputeComplianceScore(elementStatuses);
var frameworks = _frameworkMapper.Map(sbom, policy, elementStatuses, ct);
var supplyChain = _transparencyReporter.Build(supplierReport, supplierTrust, policy.SupplierValidation);
var status = ResolveOverallStatus(policy, elementStatuses, complianceScore, supplierReport, supplierTrust);
return Task.FromResult(new NtiaComplianceReport
{
OverallStatus = status,
ElementStatuses = elementStatuses,
Findings = findings,
ComplianceScore = complianceScore,
SupplierStatus = supplierReport.Status,
SupplierReport = supplierReport,
SupplierTrust = supplierTrust,
DependencyCompleteness = dependencyReport,
Frameworks = frameworks,
SupplyChain = supplyChain
});
}
private static ImmutableArray<NtiaElementStatus> BuildElementStatuses(
ParsedSbom sbom,
ImmutableArray<ParsedComponent> components,
ImmutableArray<NtiaElement> requiredElements,
SupplierValidationReport supplierReport,
DependencyCompletenessReport dependencyReport,
NtiaCompliancePolicy policy,
CancellationToken ct)
{
var builder = ImmutableArray.CreateBuilder<NtiaElementStatus>();
var totalComponents = components.Length;
foreach (var element in requiredElements)
{
ct.ThrowIfCancellationRequested();
switch (element)
{
case NtiaElement.SupplierName:
builder.Add(BuildSupplierStatus(supplierReport));
break;
case NtiaElement.ComponentName:
builder.Add(BuildComponentStatus(
element,
components,
policy,
component => !string.IsNullOrWhiteSpace(component.Name)));
break;
case NtiaElement.ComponentVersion:
builder.Add(BuildComponentStatus(
element,
components,
policy,
component => !string.IsNullOrWhiteSpace(component.Version)));
break;
case NtiaElement.OtherUniqueIdentifiers:
builder.Add(BuildComponentStatus(
element,
components,
policy,
HasUniqueIdentifier));
break;
case NtiaElement.DependencyRelationship:
builder.Add(BuildDependencyStatus(dependencyReport));
break;
case NtiaElement.AuthorOfSbomData:
builder.Add(new NtiaElementStatus
{
Element = element,
Present = !sbom.Metadata.Authors.IsDefaultOrEmpty,
Valid = !sbom.Metadata.Authors.IsDefaultOrEmpty,
ComponentsCovered = !sbom.Metadata.Authors.IsDefaultOrEmpty ? totalComponents : 0,
ComponentsMissing = !sbom.Metadata.Authors.IsDefaultOrEmpty ? 0 : totalComponents,
Notes = sbom.Metadata.Authors.IsDefaultOrEmpty
? "SBOM author list missing."
: null
});
break;
case NtiaElement.Timestamp:
builder.Add(new NtiaElementStatus
{
Element = element,
Present = sbom.Metadata.Timestamp.HasValue,
Valid = sbom.Metadata.Timestamp.HasValue,
ComponentsCovered = sbom.Metadata.Timestamp.HasValue ? totalComponents : 0,
ComponentsMissing = sbom.Metadata.Timestamp.HasValue ? 0 : totalComponents,
Notes = sbom.Metadata.Timestamp.HasValue
? null
: "SBOM timestamp missing."
});
break;
default:
builder.Add(new NtiaElementStatus
{
Element = element,
Present = false,
Valid = false,
ComponentsCovered = 0,
ComponentsMissing = totalComponents,
Notes = "Unsupported element."
});
break;
}
}
return builder.ToImmutable();
}
private static NtiaElementStatus BuildSupplierStatus(SupplierValidationReport report)
{
var missing = report.ComponentsMissingSupplier;
var covered = report.ComponentsWithSupplier;
var present = covered > 0;
var valid = report.Status == SupplierValidationStatus.Pass;
var notes = report.Status == SupplierValidationStatus.Pass
? null
: "Supplier coverage or validation warnings detected.";
return new NtiaElementStatus
{
Element = NtiaElement.SupplierName,
Present = present,
Valid = valid,
ComponentsCovered = covered,
ComponentsMissing = missing,
Notes = notes
};
}
private static NtiaElementStatus BuildDependencyStatus(DependencyCompletenessReport report)
{
var present = report.ComponentsWithDependencies > 0;
var valid = report.OrphanedComponents.IsDefaultOrEmpty;
var notes = report.OrphanedComponents.IsDefaultOrEmpty
? null
: "Orphaned components detected without dependencies.";
return new NtiaElementStatus
{
Element = NtiaElement.DependencyRelationship,
Present = present,
Valid = valid,
ComponentsCovered = report.ComponentsWithDependencies,
ComponentsMissing = report.OrphanedComponents.Length,
Notes = notes
};
}
private static NtiaElementStatus BuildComponentStatus(
NtiaElement element,
ImmutableArray<ParsedComponent> components,
NtiaCompliancePolicy policy,
Func<ParsedComponent, bool> selector)
{
var applicable = 0;
var covered = 0;
foreach (var component in components)
{
if (IsExempt(component.Name, element, policy.Exemptions))
{
continue;
}
applicable++;
if (selector(component))
{
covered++;
}
}
var missing = Math.Max(0, applicable - covered);
var present = covered > 0;
var valid = missing == 0 && applicable > 0;
var notes = valid ? null : "Coverage gap for component element.";
return new NtiaElementStatus
{
Element = element,
Present = present,
Valid = valid,
ComponentsCovered = covered,
ComponentsMissing = missing,
Notes = notes
};
}
private static bool HasUniqueIdentifier(ParsedComponent component)
{
if (!string.IsNullOrWhiteSpace(component.Purl))
{
return true;
}
if (!string.IsNullOrWhiteSpace(component.Cpe))
{
return true;
}
if (component.Swid is not null
&& (!string.IsNullOrWhiteSpace(component.Swid.TagId)
|| !string.IsNullOrWhiteSpace(component.Swid.Name)))
{
return true;
}
return !component.Hashes.IsDefaultOrEmpty;
}
private static ImmutableArray<NtiaFinding> BuildFindings(
ImmutableArray<NtiaElementStatus> elementStatuses,
SupplierValidationReport supplierReport,
SupplierTrustReport supplierTrustReport,
DependencyCompletenessReport dependencyReport,
NtiaCompliancePolicy policy)
{
var builder = ImmutableArray.CreateBuilder<NtiaFinding>();
foreach (var status in elementStatuses)
{
if (status.ComponentsMissing <= 0)
{
continue;
}
builder.Add(new NtiaFinding
{
Type = NtiaFindingType.MissingElement,
Element = status.Element,
Count = status.ComponentsMissing,
Message = string.Format(
CultureInfo.InvariantCulture,
"{0} components missing {1}.",
status.ComponentsMissing,
status.Element)
});
}
if (!supplierReport.Findings.IsDefaultOrEmpty)
{
builder.AddRange(supplierReport.Findings);
}
if (!dependencyReport.OrphanedComponents.IsDefaultOrEmpty)
{
builder.Add(new NtiaFinding
{
Type = NtiaFindingType.MissingDependency,
Element = NtiaElement.DependencyRelationship,
Count = dependencyReport.OrphanedComponents.Length,
Message = string.Format(
CultureInfo.InvariantCulture,
"{0} components missing dependency relationships.",
dependencyReport.OrphanedComponents.Length)
});
}
if (policy.Thresholds.EnforceSupplierTrust && supplierTrustReport.BlockedSuppliers > 0)
{
builder.Add(new NtiaFinding
{
Type = NtiaFindingType.BlockedSupplier,
Count = supplierTrustReport.BlockedSuppliers,
Message = "Blocked suppliers detected in inventory."
});
}
if (supplierTrustReport.UnknownSuppliers > 0)
{
builder.Add(new NtiaFinding
{
Type = NtiaFindingType.UnknownSupplier,
Count = supplierTrustReport.UnknownSuppliers,
Message = "Unknown suppliers detected in inventory."
});
}
return builder
.OrderBy(finding => finding.Type)
.ThenBy(finding => finding.Element?.ToString(), StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static double ComputeComplianceScore(ImmutableArray<NtiaElementStatus> elementStatuses)
{
if (elementStatuses.IsDefaultOrEmpty)
{
return 0.0;
}
var total = elementStatuses.Length;
var score = 0.0;
foreach (var status in elementStatuses)
{
var applicable = status.ComponentsCovered + status.ComponentsMissing;
var coverage = applicable == 0
? (status.Present ? 1.0 : 0.0)
: status.ComponentsCovered * 1.0 / applicable;
score += coverage;
}
var percent = score / total * 100.0;
return Math.Round(percent, 2, MidpointRounding.AwayFromZero);
}
private static NtiaComplianceStatus ResolveOverallStatus(
NtiaCompliancePolicy policy,
ImmutableArray<NtiaElementStatus> elementStatuses,
double complianceScore,
SupplierValidationReport supplierReport,
SupplierTrustReport supplierTrustReport)
{
var hasMissingElements = elementStatuses.Any(status => !status.Valid);
var supplierFailed = supplierReport.Status == SupplierValidationStatus.Fail;
var supplierWarn = supplierReport.Status == SupplierValidationStatus.Warn;
var blockedSuppliers = policy.Thresholds.EnforceSupplierTrust && supplierTrustReport.BlockedSuppliers > 0;
var belowThreshold = complianceScore < policy.Thresholds.MinimumCompliancePercent;
if (belowThreshold || hasMissingElements || supplierFailed || blockedSuppliers)
{
return policy.Thresholds.AllowPartialCompliance
? NtiaComplianceStatus.Warn
: NtiaComplianceStatus.Fail;
}
if (supplierWarn)
{
return NtiaComplianceStatus.Warn;
}
return NtiaComplianceStatus.Pass;
}
private static bool IsExempt(string componentName, NtiaElement element, ImmutableArray<NtiaExemption> exemptions)
{
if (exemptions.IsDefaultOrEmpty)
{
return false;
}
foreach (var exemption in exemptions)
{
if (exemption.ExemptElements.Contains(element)
&& IsMatch(componentName, exemption.ComponentPattern))
{
return true;
}
}
return false;
}
private static bool IsMatch(string value, string pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return false;
}
if (pattern == "*")
{
return true;
}
var parts = pattern.Split('*', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
return true;
}
var index = 0;
foreach (var part in parts)
{
var found = value.IndexOf(part, index, StringComparison.OrdinalIgnoreCase);
if (found < 0)
{
return false;
}
index = found + part.Length;
}
return !pattern.StartsWith("*", StringComparison.Ordinal)
? value.StartsWith(parts[0], StringComparison.OrdinalIgnoreCase)
: true;
}
}

View File

@@ -0,0 +1,183 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Policy.NtiaCompliance;
public interface INtiaComplianceValidator
{
Task<NtiaComplianceReport> ValidateAsync(
ParsedSbom sbom,
NtiaCompliancePolicy policy,
CancellationToken ct = default);
}
public sealed record NtiaComplianceReport
{
public NtiaComplianceStatus OverallStatus { get; init; } = NtiaComplianceStatus.Unknown;
public ImmutableArray<NtiaElementStatus> ElementStatuses { get; init; } = [];
public ImmutableArray<NtiaFinding> Findings { get; init; } = [];
public double ComplianceScore { get; init; }
public SupplierValidationStatus SupplierStatus { get; init; } = SupplierValidationStatus.Unknown;
public SupplierValidationReport? SupplierReport { get; init; }
public SupplierTrustReport? SupplierTrust { get; init; }
public DependencyCompletenessReport? DependencyCompleteness { get; init; }
public FrameworkComplianceReport? Frameworks { get; init; }
public SupplyChainTransparencyReport? SupplyChain { get; init; }
}
public sealed record NtiaElementStatus
{
public NtiaElement Element { get; init; }
public bool Present { get; init; }
public bool Valid { get; init; }
public int ComponentsCovered { get; init; }
public int ComponentsMissing { get; init; }
public string? Notes { get; init; }
}
public sealed record NtiaFinding
{
public NtiaFindingType Type { get; init; }
public NtiaElement? Element { get; init; }
public string? Component { get; init; }
public string? Supplier { get; init; }
public int? Count { get; init; }
public string? Message { get; init; }
}
public sealed record SupplierValidationReport
{
public ImmutableArray<SupplierInventoryEntry> Suppliers { get; init; } = [];
public ImmutableArray<ComponentSupplierEntry> Components { get; init; } = [];
public int ComponentsMissingSupplier { get; init; }
public int ComponentsWithSupplier { get; init; }
public double CoveragePercent { get; init; }
public SupplierValidationStatus Status { get; init; } = SupplierValidationStatus.Unknown;
public ImmutableArray<NtiaFinding> Findings { get; init; } = [];
}
public sealed record SupplierInventoryEntry
{
public required string Name { get; init; }
public string? Url { get; init; }
public int ComponentCount { get; init; }
public bool PlaceholderDetected { get; init; }
}
public sealed record ComponentSupplierEntry
{
public required string ComponentName { get; init; }
public string? SupplierName { get; init; }
public string? SupplierUrl { get; init; }
public bool IsPlaceholder { get; init; }
public bool UrlValid { get; init; }
}
public sealed record SupplierTrustReport
{
public ImmutableArray<SupplierTrustEntry> Suppliers { get; init; } = [];
public int VerifiedSuppliers { get; init; }
public int KnownSuppliers { get; init; }
public int UnknownSuppliers { get; init; }
public int BlockedSuppliers { get; init; }
}
public sealed record SupplierTrustEntry
{
public required string Supplier { get; init; }
public SupplierTrustLevel TrustLevel { get; init; }
public ImmutableArray<string> Components { get; init; } = [];
}
public sealed record DependencyCompletenessReport
{
public int TotalComponents { get; init; }
public int ComponentsWithDependencies { get; init; }
public ImmutableArray<string> OrphanedComponents { get; init; } = [];
public ImmutableArray<string> MissingDependencyRefs { get; init; } = [];
public double CompletenessScore { get; init; }
}
public sealed record FrameworkComplianceReport
{
public ImmutableArray<FrameworkComplianceEntry> Frameworks { get; init; } = [];
}
public sealed record FrameworkComplianceEntry
{
public required RegulatoryFramework Framework { get; init; }
public NtiaComplianceStatus Status { get; init; } = NtiaComplianceStatus.Unknown;
public ImmutableArray<NtiaElement> MissingElements { get; init; } = [];
public ImmutableArray<string> MissingFields { get; init; } = [];
public double ComplianceScore { get; init; }
}
public sealed record SupplyChainTransparencyReport
{
public int TotalSuppliers { get; init; }
public int TotalComponents { get; init; }
public string? TopSupplier { get; init; }
public double TopSupplierShare { get; init; }
public double ConcentrationIndex { get; init; }
public int UnknownSuppliers { get; init; }
public int BlockedSuppliers { get; init; }
public ImmutableArray<SupplierInventoryEntry> Suppliers { get; init; } = [];
public ImmutableArray<string> RiskFlags { get; init; } = [];
}
public enum NtiaComplianceStatus
{
Unknown = 0,
Pass = 1,
Warn = 2,
Fail = 3
}
public enum SupplierValidationStatus
{
Unknown = 0,
Pass = 1,
Warn = 2,
Fail = 3
}
public enum SupplierTrustLevel
{
Verified = 0,
Known = 1,
Unknown = 2,
Blocked = 3
}
public enum NtiaElement
{
SupplierName = 0,
ComponentName = 1,
ComponentVersion = 2,
OtherUniqueIdentifiers = 3,
DependencyRelationship = 4,
AuthorOfSbomData = 5,
Timestamp = 6
}
public enum NtiaFindingType
{
MissingElement = 0,
InvalidElement = 1,
PlaceholderSupplier = 2,
InvalidSupplierUrl = 3,
MissingSupplier = 4,
BlockedSupplier = 5,
UnknownSupplier = 6,
MissingDependency = 7,
MissingIdentifier = 8
}
public enum RegulatoryFramework
{
Ntia = 0,
Fda = 1,
Cisa = 2,
EuCra = 3,
Nist = 4
}

View File

@@ -0,0 +1,104 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.NtiaCompliance;
public sealed record NtiaCompliancePolicy
{
public MinimumElementsPolicy MinimumElements { get; init; } =
NtiaCompliancePolicyDefaults.MinimumElements;
public SupplierValidationPolicy SupplierValidation { get; init; } =
NtiaCompliancePolicyDefaults.SupplierValidation;
public ImmutableArray<string> UniqueIdentifierPriority { get; init; } =
NtiaCompliancePolicyDefaults.UniqueIdentifierPriority;
public ImmutableArray<RegulatoryFramework> Frameworks { get; init; } =
NtiaCompliancePolicyDefaults.Frameworks;
public NtiaComplianceThresholds Thresholds { get; init; } =
NtiaCompliancePolicyDefaults.Thresholds;
public ImmutableArray<NtiaExemption> Exemptions { get; init; } = [];
public ImmutableDictionary<RegulatoryFramework, ImmutableArray<string>> FrameworkRequirements { get; init; } =
ImmutableDictionary<RegulatoryFramework, ImmutableArray<string>>.Empty;
}
public sealed record MinimumElementsPolicy
{
public bool RequireAll { get; init; } = true;
public ImmutableArray<NtiaElement> Elements { get; init; } =
NtiaCompliancePolicyDefaults.DefaultElements;
}
public sealed record SupplierValidationPolicy
{
public bool RejectPlaceholders { get; init; } = true;
public ImmutableArray<string> PlaceholderPatterns { get; init; } =
NtiaCompliancePolicyDefaults.PlaceholderPatterns;
public bool RequireUrl { get; init; }
public ImmutableArray<string> TrustedSuppliers { get; init; } = [];
public ImmutableArray<string> BlockedSuppliers { get; init; } = [];
public double MinimumCoveragePercent { get; init; } = 80.0;
}
public sealed record NtiaComplianceThresholds
{
public double MinimumCompliancePercent { get; init; } = 95.0;
public bool AllowPartialCompliance { get; init; }
public bool EnforceSupplierTrust { get; init; }
}
public sealed record NtiaExemption
{
public required string ComponentPattern { get; init; }
public ImmutableArray<NtiaElement> ExemptElements { get; init; } = [];
public string? Reason { get; init; }
}
public static class NtiaCompliancePolicyDefaults
{
public static readonly ImmutableArray<NtiaElement> DefaultElements =
[
NtiaElement.SupplierName,
NtiaElement.ComponentName,
NtiaElement.ComponentVersion,
NtiaElement.OtherUniqueIdentifiers,
NtiaElement.DependencyRelationship,
NtiaElement.AuthorOfSbomData,
NtiaElement.Timestamp
];
public static readonly MinimumElementsPolicy MinimumElements = new()
{
RequireAll = true,
Elements = DefaultElements
};
public static readonly SupplierValidationPolicy SupplierValidation = new()
{
RejectPlaceholders = true,
PlaceholderPatterns = PlaceholderPatterns,
RequireUrl = false,
TrustedSuppliers = [],
BlockedSuppliers = [],
MinimumCoveragePercent = 80.0
};
public static readonly ImmutableArray<string> UniqueIdentifierPriority =
["purl", "cpe", "swid", "hash"];
public static readonly ImmutableArray<RegulatoryFramework> Frameworks =
[RegulatoryFramework.Ntia];
public static readonly NtiaComplianceThresholds Thresholds = new()
{
MinimumCompliancePercent = 95.0,
AllowPartialCompliance = false,
EnforceSupplierTrust = false
};
public static readonly ImmutableArray<string> PlaceholderPatterns =
["unknown", "n/a", "tbd", "todo", "unspecified", "none"];
}

View File

@@ -0,0 +1,229 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Policy.NtiaCompliance;
public interface INtiaCompliancePolicyLoader
{
NtiaCompliancePolicy Load(string path);
}
public sealed class NtiaCompliancePolicyLoader : INtiaCompliancePolicyLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public NtiaCompliancePolicy Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("NTIA policy path is required.", nameof(path));
}
var text = File.ReadAllText(path);
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
return LoadJson(text);
}
return LoadYaml(text);
}
private static NtiaCompliancePolicy LoadJson(string json)
{
var document = JsonSerializer.Deserialize<NtiaCompliancePolicyDocument>(json, JsonOptions);
if (document?.NtiaCompliancePolicy is not null)
{
return document.NtiaCompliancePolicy;
}
var policy = JsonSerializer.Deserialize<NtiaCompliancePolicy>(json, JsonOptions);
return policy ?? new NtiaCompliancePolicy();
}
private static NtiaCompliancePolicy LoadYaml(string yaml)
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var document = deserializer.Deserialize<NtiaCompliancePolicyYamlDocument>(yaml);
var policyYaml = document?.NtiaCompliancePolicy ?? deserializer.Deserialize<NtiaCompliancePolicyYaml>(yaml);
if (policyYaml is null)
{
return new NtiaCompliancePolicy();
}
return ToPolicy(policyYaml);
}
private static NtiaCompliancePolicy ToPolicy(NtiaCompliancePolicyYaml yaml)
{
var minimum = new MinimumElementsPolicy
{
RequireAll = yaml.MinimumElements?.RequireAll ?? NtiaCompliancePolicyDefaults.MinimumElements.RequireAll,
Elements = yaml.MinimumElements?.Elements is null
? NtiaCompliancePolicyDefaults.MinimumElements.Elements
: ParseEnumList<NtiaElement>(yaml.MinimumElements.Elements, NtiaCompliancePolicyDefaults.MinimumElements.Elements)
};
var supplier = new SupplierValidationPolicy
{
RejectPlaceholders = yaml.SupplierValidation?.RejectPlaceholders
?? NtiaCompliancePolicyDefaults.SupplierValidation.RejectPlaceholders,
PlaceholderPatterns = yaml.SupplierValidation?.PlaceholderPatterns is null
? NtiaCompliancePolicyDefaults.PlaceholderPatterns
: yaml.SupplierValidation.PlaceholderPatterns.ToImmutableArray(),
RequireUrl = yaml.SupplierValidation?.RequireUrl ?? NtiaCompliancePolicyDefaults.SupplierValidation.RequireUrl,
TrustedSuppliers = yaml.SupplierValidation?.TrustedSuppliers is null
? ImmutableArray<string>.Empty
: yaml.SupplierValidation.TrustedSuppliers.ToImmutableArray(),
BlockedSuppliers = yaml.SupplierValidation?.BlockedSuppliers is null
? ImmutableArray<string>.Empty
: yaml.SupplierValidation.BlockedSuppliers.ToImmutableArray(),
MinimumCoveragePercent = yaml.SupplierValidation?.MinimumCoveragePercent
?? NtiaCompliancePolicyDefaults.SupplierValidation.MinimumCoveragePercent
};
var thresholds = new NtiaComplianceThresholds
{
MinimumCompliancePercent = yaml.Thresholds?.MinimumCompliancePercent
?? NtiaCompliancePolicyDefaults.Thresholds.MinimumCompliancePercent,
AllowPartialCompliance = yaml.Thresholds?.AllowPartialCompliance
?? NtiaCompliancePolicyDefaults.Thresholds.AllowPartialCompliance,
EnforceSupplierTrust = yaml.Thresholds?.EnforceSupplierTrust
?? NtiaCompliancePolicyDefaults.Thresholds.EnforceSupplierTrust
};
var frameworks = yaml.Frameworks is null
? NtiaCompliancePolicyDefaults.Frameworks
: ParseEnumList<RegulatoryFramework>(yaml.Frameworks, NtiaCompliancePolicyDefaults.Frameworks);
var exemptions = yaml.Exemptions is null
? ImmutableArray<NtiaExemption>.Empty
: yaml.Exemptions.Select(exemption => new NtiaExemption
{
ComponentPattern = RequireValue(exemption.ComponentPattern, "exemptions.componentPattern"),
ExemptElements = exemption.ExemptElements is null
? ImmutableArray<NtiaElement>.Empty
: ParseEnumList<NtiaElement>(exemption.ExemptElements, ImmutableArray<NtiaElement>.Empty),
Reason = exemption.Reason
}).ToImmutableArray();
var frameworkRequirements = yaml.FrameworkRequirements is null
? ImmutableDictionary<RegulatoryFramework, ImmutableArray<string>>.Empty
: yaml.FrameworkRequirements
.Where(entry => entry.Value is not null)
.ToImmutableDictionary(
entry => ParseEnum<RegulatoryFramework>(entry.Key, RegulatoryFramework.Ntia),
entry => entry.Value!.ToImmutableArray());
return new NtiaCompliancePolicy
{
MinimumElements = minimum,
SupplierValidation = supplier,
UniqueIdentifierPriority = yaml.UniqueIdentifierPriority is null
? NtiaCompliancePolicyDefaults.UniqueIdentifierPriority
: yaml.UniqueIdentifierPriority.ToImmutableArray(),
Frameworks = frameworks,
Thresholds = thresholds,
Exemptions = exemptions,
FrameworkRequirements = frameworkRequirements
};
}
private static ImmutableArray<T> ParseEnumList<T>(IEnumerable<string> values, ImmutableArray<T> fallback)
where T : struct
{
if (values is null)
{
return fallback;
}
var builder = ImmutableArray.CreateBuilder<T>();
foreach (var value in values)
{
if (Enum.TryParse(value, true, out T parsed))
{
builder.Add(parsed);
}
}
return builder.Count == 0 ? fallback : builder.ToImmutable();
}
private static T ParseEnum<T>(string value, T fallback)
where T : struct
{
return Enum.TryParse(value, true, out T parsed) ? parsed : fallback;
}
private static string RequireValue(string? value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidDataException($"NTIA policy YAML missing required field '{fieldName}'.");
}
return value;
}
private sealed record NtiaCompliancePolicyDocument
{
public NtiaCompliancePolicy? NtiaCompliancePolicy { get; init; }
}
private sealed record NtiaCompliancePolicyYamlDocument
{
public NtiaCompliancePolicyYaml? NtiaCompliancePolicy { get; init; }
}
private sealed record NtiaCompliancePolicyYaml
{
public MinimumElementsYaml? MinimumElements { get; init; }
public SupplierValidationYaml? SupplierValidation { get; init; }
public string[]? UniqueIdentifierPriority { get; init; }
public string[]? Frameworks { get; init; }
public NtiaComplianceThresholdsYaml? Thresholds { get; init; }
public NtiaExemptionYaml[]? Exemptions { get; init; }
public Dictionary<string, string[]?>? FrameworkRequirements { get; init; }
}
private sealed record MinimumElementsYaml
{
public bool? RequireAll { get; init; }
public string[]? Elements { get; init; }
}
private sealed record SupplierValidationYaml
{
public bool? RejectPlaceholders { get; init; }
public string[]? PlaceholderPatterns { get; init; }
public bool? RequireUrl { get; init; }
public string[]? TrustedSuppliers { get; init; }
public string[]? BlockedSuppliers { get; init; }
public double? MinimumCoveragePercent { get; init; }
}
private sealed record NtiaComplianceThresholdsYaml
{
public double? MinimumCompliancePercent { get; init; }
public bool? AllowPartialCompliance { get; init; }
public bool? EnforceSupplierTrust { get; init; }
}
private sealed record NtiaExemptionYaml
{
public string? ComponentPattern { get; init; }
public string[]? ExemptElements { get; init; }
public string? Reason { get; init; }
}
}

View File

@@ -0,0 +1,358 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
namespace StellaOps.Policy.NtiaCompliance;
public sealed class NtiaComplianceReporter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private static readonly Encoding PdfEncoding = Encoding.ASCII;
private const int PdfMaxLines = 60;
public string ToJson(NtiaComplianceReport report)
{
ArgumentNullException.ThrowIfNull(report);
return JsonSerializer.Serialize(report, JsonOptions);
}
public string ToRegulatoryJson(NtiaComplianceReport report)
{
ArgumentNullException.ThrowIfNull(report);
var payload = new
{
status = report.OverallStatus.ToString().ToLowerInvariant(),
complianceScore = report.ComplianceScore,
elements = report.ElementStatuses
.OrderBy(item => item.Element)
.Select(item => new
{
element = item.Element.ToString(),
present = item.Present,
valid = item.Valid,
componentsCovered = item.ComponentsCovered,
componentsMissing = item.ComponentsMissing,
notes = item.Notes
}),
supplierStatus = report.SupplierStatus.ToString().ToLowerInvariant(),
supplierCoverage = report.SupplierReport?.CoveragePercent,
frameworks = report.Frameworks?.Frameworks
.OrderBy(item => item.Framework)
.Select(item => new
{
framework = item.Framework.ToString(),
status = item.Status.ToString().ToLowerInvariant(),
complianceScore = item.ComplianceScore,
missingElements = item.MissingElements.Select(e => e.ToString()).ToArray(),
missingFields = item.MissingFields
})
};
return JsonSerializer.Serialize(payload, JsonOptions);
}
public string ToText(NtiaComplianceReport report)
{
ArgumentNullException.ThrowIfNull(report);
var builder = new StringBuilder();
builder.AppendLine($"NTIA compliance: {report.OverallStatus}");
builder.AppendLine($"Compliance score: {report.ComplianceScore:0.00}%");
builder.AppendLine($"Supplier status: {report.SupplierStatus}");
builder.AppendLine();
AppendElementStatusesText(builder, report);
AppendFindingsText(builder, report);
AppendSupplyChainText(builder, report);
return builder.ToString();
}
public string ToMarkdown(NtiaComplianceReport report)
{
ArgumentNullException.ThrowIfNull(report);
var builder = new StringBuilder();
builder.AppendLine("# NTIA Compliance Report");
builder.AppendLine();
builder.AppendLine($"- Status: {report.OverallStatus}");
builder.AppendLine($"- Compliance score: {report.ComplianceScore:0.00}%");
builder.AppendLine($"- Supplier status: {report.SupplierStatus}");
builder.AppendLine();
builder.AppendLine("## Elements");
builder.AppendLine("| Element | Present | Valid | Covered | Missing | Notes |");
builder.AppendLine("| --- | --- | --- | --- | --- | --- |");
foreach (var status in report.ElementStatuses.OrderBy(item => item.Element))
{
builder.AppendLine($"| {status.Element} | {status.Present} | {status.Valid} | {status.ComponentsCovered} | {status.ComponentsMissing} | {status.Notes ?? string.Empty} |");
}
builder.AppendLine();
AppendFindingsMarkdown(builder, report);
AppendSupplyChainMarkdown(builder, report);
return builder.ToString();
}
public string ToHtml(NtiaComplianceReport report)
{
ArgumentNullException.ThrowIfNull(report);
var builder = new StringBuilder();
builder.AppendLine("<h1>NTIA Compliance Report</h1>");
builder.AppendLine("<ul>");
builder.AppendLine($"<li>Status: {Escape(report.OverallStatus.ToString())}</li>");
builder.AppendLine($"<li>Compliance score: {report.ComplianceScore.ToString("0.00", CultureInfo.InvariantCulture)}%</li>");
builder.AppendLine($"<li>Supplier status: {Escape(report.SupplierStatus.ToString())}</li>");
builder.AppendLine("</ul>");
builder.AppendLine("<h2>Elements</h2>");
builder.AppendLine("<table>");
builder.AppendLine("<thead><tr><th>Element</th><th>Present</th><th>Valid</th><th>Covered</th><th>Missing</th><th>Notes</th></tr></thead>");
builder.AppendLine("<tbody>");
foreach (var status in report.ElementStatuses.OrderBy(item => item.Element))
{
builder.AppendLine(
$"<tr><td>{Escape(status.Element.ToString())}</td><td>{status.Present}</td><td>{status.Valid}</td><td>{status.ComponentsCovered}</td><td>{status.ComponentsMissing}</td><td>{Escape(status.Notes ?? string.Empty)}</td></tr>");
}
builder.AppendLine("</tbody></table>");
AppendFindingsHtml(builder, report);
AppendSupplyChainHtml(builder, report);
return builder.ToString();
}
public byte[] ToPdf(NtiaComplianceReport report)
{
ArgumentNullException.ThrowIfNull(report);
var lines = ToText(report)
.Split('\n', StringSplitOptions.None)
.Select(line => line.TrimEnd('\r'))
.Where(line => line.Length > 0)
.Take(PdfMaxLines)
.ToList();
var content = BuildPdfContent(lines);
var contentBytes = PdfEncoding.GetBytes(content);
using var stream = new MemoryStream();
var offsets = new List<long> { 0 };
WritePdf(stream, "%PDF-1.4\n");
offsets.Add(stream.Position);
WritePdf(stream, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
offsets.Add(stream.Position);
WritePdf(stream, "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
offsets.Add(stream.Position);
WritePdf(stream, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ");
WritePdf(stream, "/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n");
offsets.Add(stream.Position);
WritePdf(stream, $"4 0 obj\n<< /Length {contentBytes.Length} >>\nstream\n");
stream.Write(contentBytes, 0, contentBytes.Length);
WritePdf(stream, "\nendstream\nendobj\n");
offsets.Add(stream.Position);
WritePdf(stream, "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
var xrefOffset = stream.Position;
WritePdf(stream, $"xref\n0 {offsets.Count}\n");
WritePdf(stream, "0000000000 65535 f \n");
for (var i = 1; i < offsets.Count; i++)
{
WritePdf(stream, $"{offsets[i]:D10} 00000 n \n");
}
WritePdf(stream, $"trailer\n<< /Size {offsets.Count} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF\n");
return stream.ToArray();
}
private static void AppendElementStatusesText(StringBuilder builder, NtiaComplianceReport report)
{
builder.AppendLine("Elements:");
foreach (var status in report.ElementStatuses.OrderBy(item => item.Element))
{
builder.AppendLine($"- {status.Element}: present={status.Present}, valid={status.Valid}, covered={status.ComponentsCovered}, missing={status.ComponentsMissing}");
}
builder.AppendLine();
}
private static void AppendFindingsText(StringBuilder builder, NtiaComplianceReport report)
{
if (report.Findings.IsDefaultOrEmpty)
{
return;
}
builder.AppendLine("Findings:");
foreach (var finding in report.Findings)
{
builder.AppendLine($"- [{finding.Type}] {finding.Message ?? string.Empty}".Trim());
}
builder.AppendLine();
}
private static void AppendFindingsMarkdown(StringBuilder builder, NtiaComplianceReport report)
{
if (report.Findings.IsDefaultOrEmpty)
{
return;
}
builder.AppendLine("## Findings");
foreach (var finding in report.Findings)
{
builder.AppendLine($"- [{finding.Type}] {finding.Message ?? string.Empty}".Trim());
}
builder.AppendLine();
}
private static void AppendFindingsHtml(StringBuilder builder, NtiaComplianceReport report)
{
if (report.Findings.IsDefaultOrEmpty)
{
return;
}
builder.AppendLine("<h2>Findings</h2>");
builder.AppendLine("<ul>");
foreach (var finding in report.Findings)
{
builder.AppendLine($"<li>[{Escape(finding.Type.ToString())}] {Escape(finding.Message ?? string.Empty)}</li>");
}
builder.AppendLine("</ul>");
}
private static void AppendSupplyChainText(StringBuilder builder, NtiaComplianceReport report)
{
if (report.SupplyChain is null)
{
return;
}
builder.AppendLine("Supply Chain:");
builder.AppendLine($"- Suppliers: {report.SupplyChain.TotalSuppliers}");
builder.AppendLine($"- Top supplier: {report.SupplyChain.TopSupplier}");
builder.AppendLine(string.Format(
CultureInfo.InvariantCulture,
"- Top supplier share: {0:P1}",
report.SupplyChain.TopSupplierShare));
if (!report.SupplyChain.RiskFlags.IsDefaultOrEmpty)
{
builder.AppendLine($"- Risk flags: {string.Join(", ", report.SupplyChain.RiskFlags)}");
}
builder.AppendLine();
}
private static void AppendSupplyChainMarkdown(StringBuilder builder, NtiaComplianceReport report)
{
if (report.SupplyChain is null)
{
return;
}
builder.AppendLine("## Supply Chain");
builder.AppendLine($"- Suppliers: {report.SupplyChain.TotalSuppliers}");
builder.AppendLine($"- Top supplier: {report.SupplyChain.TopSupplier}");
builder.AppendLine(string.Format(
CultureInfo.InvariantCulture,
"- Top supplier share: {0:P1}",
report.SupplyChain.TopSupplierShare));
if (!report.SupplyChain.RiskFlags.IsDefaultOrEmpty)
{
builder.AppendLine($"- Risk flags: {string.Join(", ", report.SupplyChain.RiskFlags)}");
}
builder.AppendLine();
}
private static void AppendSupplyChainHtml(StringBuilder builder, NtiaComplianceReport report)
{
if (report.SupplyChain is null)
{
return;
}
builder.AppendLine("<h2>Supply Chain</h2>");
builder.AppendLine("<ul>");
builder.AppendLine($"<li>Suppliers: {report.SupplyChain.TotalSuppliers}</li>");
builder.AppendLine($"<li>Top supplier: {Escape(report.SupplyChain.TopSupplier ?? string.Empty)}</li>");
builder.AppendLine($"<li>Top supplier share: {report.SupplyChain.TopSupplierShare.ToString("P1", CultureInfo.InvariantCulture)}</li>");
if (!report.SupplyChain.RiskFlags.IsDefaultOrEmpty)
{
builder.AppendLine($"<li>Risk flags: {Escape(string.Join(", ", report.SupplyChain.RiskFlags))}</li>");
}
builder.AppendLine("</ul>");
}
private static string Escape(string value)
{
return value
.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
}
private static string BuildPdfContent(IReadOnlyList<string> lines)
{
var builder = new StringBuilder();
builder.AppendLine("BT");
builder.AppendLine("/F1 11 Tf");
builder.AppendLine("72 720 Td");
builder.AppendLine("14 TL");
foreach (var line in lines)
{
builder.Append('(')
.Append(EscapePdfText(line))
.AppendLine(") Tj");
builder.AppendLine("T*");
}
builder.AppendLine("ET");
return builder.ToString();
}
private static string EscapePdfText(string value)
{
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
switch (ch)
{
case '\\':
case '(':
case ')':
builder.Append('\\');
builder.Append(ch);
break;
default:
builder.Append(ch);
break;
}
}
return builder.ToString();
}
private static void WritePdf(Stream stream, string value)
{
var bytes = PdfEncoding.GetBytes(value);
stream.Write(bytes, 0, bytes.Length);
}
}

View File

@@ -0,0 +1,158 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Policy.NtiaCompliance;
public sealed class RegulatoryFrameworkMapper
{
public FrameworkComplianceReport Map(
ParsedSbom sbom,
NtiaCompliancePolicy policy,
ImmutableArray<NtiaElementStatus> elementStatuses,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(sbom);
ArgumentNullException.ThrowIfNull(policy);
if (policy.Frameworks.IsDefaultOrEmpty)
{
return new FrameworkComplianceReport();
}
var requiredElements = policy.MinimumElements.Elements.IsDefaultOrEmpty
? NtiaCompliancePolicyDefaults.MinimumElements.Elements
: policy.MinimumElements.Elements;
var elementLookup = elementStatuses.ToDictionary(status => status.Element, status => status);
var entries = new List<FrameworkComplianceEntry>(policy.Frameworks.Length);
foreach (var framework in policy.Frameworks)
{
ct.ThrowIfCancellationRequested();
var missingElements = requiredElements
.Where(element => elementLookup.TryGetValue(element, out var status) && !status.Valid)
.ToImmutableArray();
var missingFields = ResolveMissingFields(sbom, policy.FrameworkRequirements, framework);
var complianceScore = ComputeScore(requiredElements, elementLookup);
var status = ResolveFrameworkStatus(policy, missingElements, missingFields);
entries.Add(new FrameworkComplianceEntry
{
Framework = framework,
MissingElements = missingElements,
MissingFields = missingFields,
ComplianceScore = complianceScore,
Status = status
});
}
return new FrameworkComplianceReport
{
Frameworks = entries.ToImmutableArray()
};
}
private static ImmutableArray<string> ResolveMissingFields(
ParsedSbom sbom,
ImmutableDictionary<RegulatoryFramework, ImmutableArray<string>> requirements,
RegulatoryFramework framework)
{
if (!requirements.TryGetValue(framework, out var fields) || fields.IsDefaultOrEmpty)
{
return ImmutableArray<string>.Empty;
}
var missing = ImmutableArray.CreateBuilder<string>();
foreach (var field in fields)
{
if (string.IsNullOrWhiteSpace(field))
{
continue;
}
if (!HasField(sbom, field))
{
missing.Add(field);
}
}
return missing
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static bool HasField(ParsedSbom sbom, string field)
{
var key = field.Trim().ToLowerInvariant();
var metadata = sbom.Metadata;
switch (key)
{
case "name":
return !string.IsNullOrWhiteSpace(metadata.Name);
case "version":
return !string.IsNullOrWhiteSpace(metadata.Version);
case "supplier":
return !string.IsNullOrWhiteSpace(metadata.Supplier);
case "manufacturer":
return !string.IsNullOrWhiteSpace(metadata.Manufacturer);
case "timestamp":
return metadata.Timestamp.HasValue;
case "author":
case "authors":
return !metadata.Authors.IsDefaultOrEmpty;
}
foreach (var component in sbom.Components)
{
if (component.Properties.ContainsKey(field))
{
return true;
}
}
return false;
}
private static double ComputeScore(
ImmutableArray<NtiaElement> requiredElements,
Dictionary<NtiaElement, NtiaElementStatus> elementLookup)
{
if (requiredElements.IsDefaultOrEmpty)
{
return 0.0;
}
var total = requiredElements.Length;
var score = 0.0;
foreach (var element in requiredElements)
{
if (elementLookup.TryGetValue(element, out var status) && status.Valid)
{
score += 100.0 / total;
}
}
return Math.Round(score, 2, MidpointRounding.AwayFromZero);
}
private static NtiaComplianceStatus ResolveFrameworkStatus(
NtiaCompliancePolicy policy,
ImmutableArray<NtiaElement> missingElements,
ImmutableArray<string> missingFields)
{
if (missingElements.IsDefaultOrEmpty && missingFields.IsDefaultOrEmpty)
{
return NtiaComplianceStatus.Pass;
}
if (!policy.Thresholds.AllowPartialCompliance)
{
return NtiaComplianceStatus.Fail;
}
return NtiaComplianceStatus.Warn;
}
}

View File

@@ -0,0 +1,103 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.NtiaCompliance;
public sealed class SupplierTrustVerifier
{
public SupplierTrustReport Verify(
SupplierValidationReport validationReport,
SupplierValidationPolicy policy,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(validationReport);
ArgumentNullException.ThrowIfNull(policy);
if (validationReport.Suppliers.IsDefaultOrEmpty)
{
return new SupplierTrustReport();
}
var trusted = new HashSet<string>(policy.TrustedSuppliers, StringComparer.OrdinalIgnoreCase);
var blocked = new HashSet<string>(policy.BlockedSuppliers, StringComparer.OrdinalIgnoreCase);
var componentLookup = validationReport.Components
.Where(entry => !string.IsNullOrWhiteSpace(entry.SupplierName))
.GroupBy(entry => entry.SupplierName!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.Select(entry => entry.ComponentName)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
StringComparer.OrdinalIgnoreCase);
var entries = new List<SupplierTrustEntry>(validationReport.Suppliers.Length);
var verified = 0;
var known = 0;
var unknown = 0;
var blockedCount = 0;
foreach (var supplier in validationReport.Suppliers
.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase))
{
ct.ThrowIfCancellationRequested();
var trustLevel = ResolveTrustLevel(supplier, trusted, blocked);
switch (trustLevel)
{
case SupplierTrustLevel.Verified:
verified++;
break;
case SupplierTrustLevel.Known:
known++;
break;
case SupplierTrustLevel.Blocked:
blockedCount++;
break;
default:
unknown++;
break;
}
var components = componentLookup.TryGetValue(supplier.Name, out var mappedComponents)
? mappedComponents
: ImmutableArray<string>.Empty;
entries.Add(new SupplierTrustEntry
{
Supplier = supplier.Name,
TrustLevel = trustLevel,
Components = components
});
}
return new SupplierTrustReport
{
Suppliers = entries.ToImmutableArray(),
VerifiedSuppliers = verified,
KnownSuppliers = known,
UnknownSuppliers = unknown,
BlockedSuppliers = blockedCount
};
}
private static SupplierTrustLevel ResolveTrustLevel(
SupplierInventoryEntry supplier,
HashSet<string> trusted,
HashSet<string> blocked)
{
if (blocked.Contains(supplier.Name))
{
return SupplierTrustLevel.Blocked;
}
if (trusted.Contains(supplier.Name))
{
return SupplierTrustLevel.Verified;
}
if (supplier.PlaceholderDetected)
{
return SupplierTrustLevel.Unknown;
}
return SupplierTrustLevel.Known;
}
}

View File

@@ -0,0 +1,281 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.RegularExpressions;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Policy.NtiaCompliance;
public sealed class SupplierValidator
{
public SupplierValidationReport Validate(
ParsedSbom sbom,
SupplierValidationPolicy policy,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(sbom);
ArgumentNullException.ThrowIfNull(policy);
var components = sbom.Components;
if (components.IsDefaultOrEmpty)
{
return new SupplierValidationReport
{
Status = SupplierValidationStatus.Unknown
};
}
var placeholderPatterns = BuildPlaceholderPatterns(policy.PlaceholderPatterns);
var componentEntries = new List<ComponentSupplierEntry>(components.Length);
var inventory = new Dictionary<string, SupplierInventoryEntry>(StringComparer.OrdinalIgnoreCase);
var missingCount = 0;
var placeholderCount = 0;
var invalidUrlCount = 0;
foreach (var component in components)
{
ct.ThrowIfCancellationRequested();
var supplier = ResolveSupplier(component, sbom.Metadata);
var supplierName = supplier.Name;
var supplierUrl = supplier.Url;
var hasSupplier = !string.IsNullOrWhiteSpace(supplierName);
var isPlaceholder = hasSupplier && IsPlaceholder(supplierName!, placeholderPatterns);
var urlValid = string.IsNullOrWhiteSpace(supplierUrl) || IsValidUrl(supplierUrl);
if (!hasSupplier)
{
missingCount++;
}
if (isPlaceholder)
{
placeholderCount++;
}
if (policy.RequireUrl && hasSupplier && !urlValid)
{
invalidUrlCount++;
}
componentEntries.Add(new ComponentSupplierEntry
{
ComponentName = component.Name,
SupplierName = supplierName,
SupplierUrl = supplierUrl,
IsPlaceholder = isPlaceholder,
UrlValid = urlValid
});
if (hasSupplier)
{
TrackInventory(inventory, supplierName!, supplierUrl, isPlaceholder);
}
}
var totalComponents = components.Length;
var withSupplier = totalComponents - missingCount;
var coveragePercent = totalComponents == 0
? 0.0
: Math.Round(withSupplier * 100.0 / totalComponents, 2, MidpointRounding.AwayFromZero);
var findings = BuildFindings(missingCount, placeholderCount, invalidUrlCount, totalComponents);
var status = ResolveStatus(policy, missingCount, placeholderCount, invalidUrlCount, coveragePercent);
return new SupplierValidationReport
{
Suppliers = inventory.Values
.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
Components = componentEntries
.OrderBy(entry => entry.ComponentName, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
ComponentsMissingSupplier = missingCount,
ComponentsWithSupplier = withSupplier,
CoveragePercent = coveragePercent,
Status = status,
Findings = findings
};
}
private static SupplierIdentity ResolveSupplier(ParsedComponent component, ParsedSbomMetadata metadata)
{
var componentSupplier = component.Supplier ?? component.Manufacturer;
var name = componentSupplier?.Name;
var url = componentSupplier?.Url;
if (string.IsNullOrWhiteSpace(name))
{
name = component.Publisher;
}
if (string.IsNullOrWhiteSpace(name))
{
name = metadata.Supplier ?? metadata.Manufacturer;
}
if (string.IsNullOrWhiteSpace(url))
{
url = componentSupplier?.Url;
}
return new SupplierIdentity(name?.Trim(), url?.Trim());
}
private static void TrackInventory(
Dictionary<string, SupplierInventoryEntry> inventory,
string supplierName,
string? supplierUrl,
bool placeholderDetected)
{
if (!inventory.TryGetValue(supplierName, out var entry))
{
entry = new SupplierInventoryEntry
{
Name = supplierName,
Url = supplierUrl,
ComponentCount = 0,
PlaceholderDetected = placeholderDetected
};
}
inventory[supplierName] = entry with
{
ComponentCount = entry.ComponentCount + 1,
Url = entry.Url ?? supplierUrl,
PlaceholderDetected = entry.PlaceholderDetected || placeholderDetected
};
}
private static SupplierValidationStatus ResolveStatus(
SupplierValidationPolicy policy,
int missingCount,
int placeholderCount,
int invalidUrlCount,
double coveragePercent)
{
if (missingCount == 0 && placeholderCount == 0 && invalidUrlCount == 0)
{
return SupplierValidationStatus.Pass;
}
if (policy.RejectPlaceholders && placeholderCount > 0)
{
return SupplierValidationStatus.Fail;
}
if (policy.RequireUrl && invalidUrlCount > 0)
{
return SupplierValidationStatus.Fail;
}
if (coveragePercent < policy.MinimumCoveragePercent)
{
return SupplierValidationStatus.Warn;
}
return SupplierValidationStatus.Warn;
}
private static ImmutableArray<NtiaFinding> BuildFindings(
int missingCount,
int placeholderCount,
int invalidUrlCount,
int totalComponents)
{
var findings = ImmutableArray.CreateBuilder<NtiaFinding>();
if (missingCount > 0)
{
findings.Add(new NtiaFinding
{
Type = NtiaFindingType.MissingSupplier,
Element = NtiaElement.SupplierName,
Count = missingCount,
Message = string.Format(
CultureInfo.InvariantCulture,
"{0} of {1} components missing supplier.",
missingCount,
totalComponents)
});
}
if (placeholderCount > 0)
{
findings.Add(new NtiaFinding
{
Type = NtiaFindingType.PlaceholderSupplier,
Element = NtiaElement.SupplierName,
Count = placeholderCount,
Message = string.Format(
CultureInfo.InvariantCulture,
"{0} components use placeholder supplier names.",
placeholderCount)
});
}
if (invalidUrlCount > 0)
{
findings.Add(new NtiaFinding
{
Type = NtiaFindingType.InvalidSupplierUrl,
Element = NtiaElement.SupplierName,
Count = invalidUrlCount,
Message = string.Format(
CultureInfo.InvariantCulture,
"{0} components have invalid supplier URLs.",
invalidUrlCount)
});
}
return findings.ToImmutable();
}
private static ImmutableArray<Regex> BuildPlaceholderPatterns(ImmutableArray<string> patterns)
{
if (patterns.IsDefaultOrEmpty)
{
return ImmutableArray<Regex>.Empty;
}
var builder = ImmutableArray.CreateBuilder<Regex>();
foreach (var pattern in patterns)
{
if (string.IsNullOrWhiteSpace(pattern))
{
continue;
}
builder.Add(new Regex(pattern.Trim(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
}
return builder.ToImmutable();
}
private static bool IsPlaceholder(string value, ImmutableArray<Regex> patterns)
{
if (patterns.IsDefaultOrEmpty)
{
return false;
}
var candidate = value.Trim();
foreach (var regex in patterns)
{
if (regex.IsMatch(candidate))
{
return true;
}
}
return false;
}
private static bool IsValidUrl(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase));
}
private sealed record SupplierIdentity(string? Name, string? Url);
}

View File

@@ -0,0 +1,108 @@
using System.Collections.Immutable;
using System.Globalization;
namespace StellaOps.Policy.NtiaCompliance;
public sealed class SupplyChainTransparencyReporter
{
private const double ConcentrationWarningThreshold = 0.8;
public SupplyChainTransparencyReport Build(
SupplierValidationReport validationReport,
SupplierTrustReport? trustReport,
SupplierValidationPolicy policy)
{
ArgumentNullException.ThrowIfNull(validationReport);
ArgumentNullException.ThrowIfNull(policy);
var suppliers = validationReport.Suppliers;
if (suppliers.IsDefaultOrEmpty)
{
return new SupplyChainTransparencyReport();
}
var totalComponents = validationReport.Components.Length;
var totalSuppliers = suppliers.Length;
var topSupplier = suppliers.OrderByDescending(entry => entry.ComponentCount)
.ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.First();
var topShare = totalComponents == 0
? 0.0
: Math.Round(topSupplier.ComponentCount * 1.0 / totalComponents, 4, MidpointRounding.AwayFromZero);
var concentration = ComputeConcentrationIndex(suppliers, totalComponents);
var unknownSuppliers = trustReport?.UnknownSuppliers ?? 0;
var blockedSuppliers = trustReport?.BlockedSuppliers ?? 0;
var riskFlags = BuildRiskFlags(validationReport, topShare, unknownSuppliers, blockedSuppliers, policy);
return new SupplyChainTransparencyReport
{
TotalSuppliers = totalSuppliers,
TotalComponents = totalComponents,
TopSupplier = topSupplier.Name,
TopSupplierShare = topShare,
ConcentrationIndex = concentration,
UnknownSuppliers = unknownSuppliers,
BlockedSuppliers = blockedSuppliers,
Suppliers = suppliers
.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
RiskFlags = riskFlags
};
}
private static double ComputeConcentrationIndex(
ImmutableArray<SupplierInventoryEntry> suppliers,
int totalComponents)
{
if (totalComponents == 0)
{
return 0.0;
}
var sum = 0.0;
foreach (var supplier in suppliers)
{
var share = supplier.ComponentCount * 1.0 / totalComponents;
sum += share * share;
}
return Math.Round(sum, 4, MidpointRounding.AwayFromZero);
}
private static ImmutableArray<string> BuildRiskFlags(
SupplierValidationReport validationReport,
double topShare,
int unknownSuppliers,
int blockedSuppliers,
SupplierValidationPolicy policy)
{
var flags = ImmutableArray.CreateBuilder<string>();
if (topShare >= ConcentrationWarningThreshold)
{
flags.Add("supplier_concentration_high");
}
if (unknownSuppliers > 0)
{
flags.Add("unknown_supplier_detected");
}
if (blockedSuppliers > 0)
{
flags.Add("blocked_supplier_detected");
}
if (validationReport.CoveragePercent < policy.MinimumCoveragePercent)
{
flags.Add(string.Format(
CultureInfo.InvariantCulture,
"supplier_coverage_below_{0:0.##}",
policy.MinimumCoveragePercent));
}
return flags.ToImmutable();
}
}

View File

@@ -24,6 +24,8 @@
<EmbeddedResource Include="Schemas\spl-sample@1.json" />
<EmbeddedResource Include="Schemas\spl-secret-block@1.json" />
<EmbeddedResource Include="Schemas\spl-secret-warn@1.json" />
<EmbeddedResource Include="Licensing\Resources\spdx-license-list-3.21.json" />
<EmbeddedResource Include="Licensing\Resources\spdx-license-exceptions-3.21.json" />
</ItemGroup>
<ItemGroup>
@@ -34,11 +36,11 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Facet/StellaOps.Facet.csproj" />
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.SbomIntegration/StellaOps.Concelier.SbomIntegration.csproj" />
</ItemGroup>
<!-- Temporarily exclude incomplete gate files until proper contracts are defined -->
<ItemGroup>
<Compile Remove="Gates\Opa\OpaGateAdapter.cs" />
<Compile Remove="Gates\Attestation\VexStatusPromotionGate.cs" />
<Compile Remove="Gates\Attestation\AttestationVerificationGate.cs" />
<Compile Remove="Gates\Attestation\RekorFreshnessGate.cs" />

View File

@@ -1,10 +1,21 @@
# StellaOps.Policy Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
Source of truth: `docs/implplan/SPRINT_20260119_021_Policy_license_compliance.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0438-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy. |
| AUDIT-0438-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy. |
| AUDIT-0438-A | TODO | Revalidated 2026-01-07 (open findings). |
| TASK-021-001 | DONE | License compliance interfaces and models defined. |
| TASK-021-002 | DONE | SPDX license expression parser implemented. |
| TASK-021-003 | DONE | License expression evaluator implemented. |
| TASK-021-004 | DONE | SPDX knowledge base loading and categorization added. |
| TASK-021-005 | DONE | Compatibility checker implemented. |
| TASK-021-006 | DONE | Project context analyzer implemented. |
| TASK-021-007 | DONE | Attribution generator and requirements tracking added. |
| TASK-021-008 | DONE | License policy schema and loader implemented. |
| TASK-021-010 | DOING | License compliance reporter expanded with category breakdown, ASCII/HTML chart rendering, attribution/NOTICE sections, and PDF output; remaining gap is policy report integration. |
| TASK-021-011 | DONE | License compliance unit tests expanded (expression evaluator, compatibility, policy loader, compliance evaluator); coverage validated at 93.69% for Licensing namespace. |
| TASK-021-012 | DONE | Real SBOM integration tests added (npm-monorepo, alpine-busybox, python-venv, java-multi-license); filtered integration runs passed. |