tests fixes and sprints work
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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("&", "&", StringComparison.Ordinal)
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("&", "&", StringComparison.Ordinal)
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", 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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 = []
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&", StringComparison.Ordinal)
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user