more audit work

This commit is contained in:
master
2026-01-08 10:21:51 +02:00
parent 43c02081ef
commit 51cf4bc16c
546 changed files with 36721 additions and 4003 deletions

View File

@@ -13,6 +13,7 @@ using JsonSerializer = CycloneDX.Json.Serializer;
using ProtoSerializer = CycloneDX.Protobuf.Serializer;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Evidence;
namespace StellaOps.Scanner.Emit.Composition;
@@ -319,6 +320,7 @@ public sealed class CycloneDxComposer
private static List<Component> BuildComponents(ImmutableArray<AggregatedComponent> components)
{
var evidenceMapper = new CycloneDxEvidenceMapper();
var result = new List<Component>(components.Length);
foreach (var component in components)
{
@@ -332,6 +334,7 @@ public sealed class CycloneDxComposer
Type = MapClassification(component.Identity.ComponentType),
Scope = MapScope(component.Metadata?.Scope),
Properties = BuildProperties(component),
Evidence = evidenceMapper.Map(component),
};
result.Add(model);

View File

@@ -55,6 +55,13 @@ public sealed record LayerSbomRef
/// </summary>
[JsonPropertyName("componentCount")]
public required int ComponentCount { get; init; }
/// <summary>
/// Component PURLs in this layer.
/// Sprint: SPRINT_20260107_004_002 Task SG-010
/// </summary>
[JsonPropertyName("componentPurls")]
public IReadOnlyList<string>? ComponentPurls { get; init; }
}
/// <summary>

View File

@@ -0,0 +1,259 @@
// <copyright file="CallstackEvidenceBuilder.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Evidence;
/// <summary>
/// Builds CycloneDX 1.7 callstack evidence from reachability call graph data.
/// Sprint: SPRINT_20260107_005_001 Task EV-006
/// </summary>
public sealed class CallstackEvidenceBuilder
{
/// <summary>
/// Builds callstack evidence from aggregated component reachability data.
/// </summary>
/// <param name="component">The aggregated component with reachability evidence.</param>
/// <returns>The callstack evidence, or null if no reachability data exists.</returns>
public ComponentCallstackEvidence? Build(AggregatedComponent component)
{
ArgumentNullException.ThrowIfNull(component);
// Extract reachability evidence from component
var reachabilityEvidence = component.Evidence
.Where(e => e.Kind.Equals("reachability", StringComparison.OrdinalIgnoreCase) ||
e.Kind.Equals("callgraph", StringComparison.OrdinalIgnoreCase) ||
e.Kind.Equals("call-path", StringComparison.OrdinalIgnoreCase))
.ToList();
if (reachabilityEvidence.Count == 0)
{
return null;
}
var frames = BuildCallstackFrames(reachabilityEvidence);
if (frames.IsDefaultOrEmpty)
{
return null;
}
return new ComponentCallstackEvidence
{
Frames = frames,
};
}
/// <summary>
/// Builds callstack evidence from vulnerability-specific reachability data.
/// </summary>
/// <param name="component">The aggregated component.</param>
/// <param name="vulnerabilityId">The vulnerability ID to link to.</param>
/// <returns>The callstack evidence, or null if no linked reachability data exists.</returns>
public ComponentCallstackEvidence? BuildForVulnerability(
AggregatedComponent component,
string vulnerabilityId)
{
ArgumentNullException.ThrowIfNull(component);
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
// Find reachability evidence linked to this vulnerability
var linkedEvidence = component.Evidence
.Where(e => (e.Kind.Equals("reachability", StringComparison.OrdinalIgnoreCase) ||
e.Kind.Equals("callgraph", StringComparison.OrdinalIgnoreCase)) &&
(e.Value?.Contains(vulnerabilityId, StringComparison.OrdinalIgnoreCase) == true ||
e.Source?.Contains(vulnerabilityId, StringComparison.OrdinalIgnoreCase) == true))
.ToList();
if (linkedEvidence.Count == 0)
{
return null;
}
var frames = BuildCallstackFrames(linkedEvidence);
if (frames.IsDefaultOrEmpty)
{
return null;
}
return new ComponentCallstackEvidence
{
Frames = frames,
};
}
private static ImmutableArray<CallstackFrame> BuildCallstackFrames(List<ComponentEvidence> evidence)
{
var frames = ImmutableArray.CreateBuilder<CallstackFrame>();
foreach (var e in evidence)
{
// Parse evidence value for call path information
// Format: "func1@file1:line1 -> func2@file2:line2 -> ..."
var paths = ParseCallPath(e.Value);
foreach (var path in paths)
{
frames.Add(path);
}
// If evidence source contains structured frame data
if (!string.IsNullOrWhiteSpace(e.Source))
{
var sourceFrame = ParseSourceFrame(e.Source);
if (sourceFrame is not null && !ContainsEquivalentFrame(frames, sourceFrame))
{
frames.Add(sourceFrame);
}
}
}
// Sort frames by sequence (first call first)
return frames
.OrderBy(f => f.Sequence)
.ThenBy(f => f.Function, StringComparer.Ordinal)
.ToImmutableArray();
}
private static List<CallstackFrame> ParseCallPath(string? value)
{
var frames = new List<CallstackFrame>();
if (string.IsNullOrWhiteSpace(value))
{
return frames;
}
// Split by common call path separators
var segments = value.Split(new[] { " -> ", "->", " → ", "→", "|" }, StringSplitOptions.RemoveEmptyEntries);
var sequence = 0;
foreach (var segment in segments)
{
var frame = ParseFrameSegment(segment.Trim(), sequence++);
if (frame is not null)
{
frames.Add(frame);
}
}
return frames;
}
private static CallstackFrame? ParseFrameSegment(string segment, int sequence)
{
if (string.IsNullOrWhiteSpace(segment))
{
return null;
}
// Format: "function@file:line" or "function@file" or "function"
var atIndex = segment.IndexOf('@');
var function = atIndex > 0 ? segment[..atIndex] : segment;
var fileAndLine = atIndex > 0 ? segment[(atIndex + 1)..] : null;
string? file = null;
int? line = null;
if (!string.IsNullOrWhiteSpace(fileAndLine))
{
var colonIndex = fileAndLine.LastIndexOf(':');
if (colonIndex > 0 && int.TryParse(fileAndLine[(colonIndex + 1)..], out var parsedLine))
{
file = fileAndLine[..colonIndex];
line = parsedLine;
}
else
{
file = fileAndLine;
}
}
return new CallstackFrame
{
Function = function,
File = file,
Line = line,
Sequence = sequence,
};
}
private static CallstackFrame? ParseSourceFrame(string source)
{
// Source might be a file path with optional line number
// Format: "/path/to/file.cs:123" or "/path/to/file.cs"
var colonIndex = source.LastIndexOf(':');
// Avoid matching drive letters on Windows (C:)
if (colonIndex > 1 && int.TryParse(source[(colonIndex + 1)..], out var line))
{
return new CallstackFrame
{
File = source[..colonIndex],
Line = line,
Sequence = int.MaxValue, // Unknown sequence
};
}
return new CallstackFrame
{
File = source,
Sequence = int.MaxValue,
};
}
private static bool ContainsEquivalentFrame(ImmutableArray<CallstackFrame>.Builder frames, CallstackFrame frame)
{
return frames.Any(f =>
string.Equals(f.File, frame.File, StringComparison.OrdinalIgnoreCase) &&
f.Line == frame.Line &&
string.Equals(f.Function, frame.Function, StringComparison.Ordinal));
}
}
/// <summary>
/// Represents callstack evidence for a component.
/// </summary>
public sealed record ComponentCallstackEvidence
{
/// <summary>
/// Gets the callstack frames.
/// </summary>
public ImmutableArray<CallstackFrame> Frames { get; init; }
}
/// <summary>
/// Represents a single frame in a callstack.
/// </summary>
public sealed record CallstackFrame
{
/// <summary>
/// Gets the function or method name.
/// </summary>
public string? Function { get; init; }
/// <summary>
/// Gets the source file path.
/// </summary>
public string? File { get; init; }
/// <summary>
/// Gets the line number.
/// </summary>
public int? Line { get; init; }
/// <summary>
/// Gets the byte offset for binary analysis.
/// </summary>
public int? Offset { get; init; }
/// <summary>
/// Gets the symbol name.
/// </summary>
public string? Symbol { get; init; }
/// <summary>
/// Gets the frame sequence in the call path (0 = entry point).
/// </summary>
public int Sequence { get; init; }
}

View File

@@ -0,0 +1,217 @@
// <copyright file="CycloneDxEvidenceMapper.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using CycloneDX.Models;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Evidence;
/// <summary>
/// Maps StellaOps evidence data to CycloneDX 1.7 native evidence fields.
/// Sprint: SPRINT_20260107_005_001 Task EV-001
/// </summary>
/// <remarks>
/// This mapper replaces the legacy property-based evidence storage with
/// native CycloneDX 1.7 evidence structures for spec compliance.
/// </remarks>
public sealed class CycloneDxEvidenceMapper
{
private readonly IdentityEvidenceBuilder _identityBuilder;
private readonly OccurrenceEvidenceBuilder _occurrenceBuilder;
private readonly LicenseEvidenceBuilder _licenseBuilder;
/// <summary>
/// Initializes a new instance of the <see cref="CycloneDxEvidenceMapper"/> class.
/// </summary>
public CycloneDxEvidenceMapper()
{
_identityBuilder = new IdentityEvidenceBuilder();
_occurrenceBuilder = new OccurrenceEvidenceBuilder();
_licenseBuilder = new LicenseEvidenceBuilder();
}
/// <summary>
/// Maps component evidence to CycloneDX 1.7 Evidence structure.
/// </summary>
/// <param name="component">The aggregated component with evidence data.</param>
/// <returns>The mapped CycloneDX Evidence, or null if no evidence available.</returns>
public CycloneDX.Models.Evidence? Map(AggregatedComponent component)
{
ArgumentNullException.ThrowIfNull(component);
var identity = _identityBuilder.Build(component);
var occurrences = _occurrenceBuilder.Build(component);
var licenses = _licenseBuilder.Build(component);
var copyrights = BuildCopyrightEvidence(component);
if (identity is null && occurrences.IsDefaultOrEmpty && licenses.IsDefaultOrEmpty && copyrights.IsDefaultOrEmpty)
{
return null;
}
return new CycloneDX.Models.Evidence
{
Identity = identity is not null ? [ConvertToEvidenceIdentity(identity)] : null,
Occurrences = occurrences.IsDefaultOrEmpty ? null : ConvertToEvidenceOccurrences(occurrences),
Licenses = licenses.IsDefaultOrEmpty ? null : ConvertToLicenseChoices(licenses),
Copyright = copyrights.IsDefaultOrEmpty ? null : ConvertToEvidenceCopyrights(copyrights),
};
}
private static EvidenceIdentity ConvertToEvidenceIdentity(ComponentIdentityEvidence identity)
{
return new EvidenceIdentity
{
// EvidenceIdentity.Field is a string in some CycloneDX versions
Confidence = (float?)identity.Confidence,
ConcludedValue = identity.Field,
Methods = identity.Methods?.Select(m => new EvidenceMethods
{
Confidence = (float?)m.Confidence ?? 0f,
Value = m.Value,
}).ToList(),
};
}
private static List<EvidenceOccurrence> ConvertToEvidenceOccurrences(ImmutableArray<OccurrenceEvidence> occurrences)
{
return occurrences.Select(o => new EvidenceOccurrence
{
Location = o.Location,
}).ToList();
}
private static List<LicenseChoice> ConvertToLicenseChoices(ImmutableArray<LicenseEvidence> licenses)
{
return licenses.Select(l => l.License).ToList();
}
private static List<EvidenceCopyright> ConvertToEvidenceCopyrights(ImmutableArray<CopyrightEvidence> copyrights)
{
return copyrights.Select(c => new EvidenceCopyright
{
Text = c.Text,
}).ToList();
}
/// <summary>
/// Maps legacy property-based evidence to component evidence data.
/// </summary>
/// <param name="properties">Legacy properties containing evidence data.</param>
/// <returns>Parsed evidence records.</returns>
public static ImmutableArray<ComponentEvidenceRecord> ParseLegacyProperties(
IReadOnlyList<Property>? properties)
{
if (properties is null || properties.Count == 0)
{
return ImmutableArray<ComponentEvidenceRecord>.Empty;
}
var results = ImmutableArray.CreateBuilder<ComponentEvidenceRecord>();
foreach (var prop in properties)
{
if (prop.Name?.StartsWith("stellaops:evidence[", StringComparison.OrdinalIgnoreCase) == true &&
!string.IsNullOrWhiteSpace(prop.Value))
{
var parsed = ParseLegacyEvidenceValue(prop.Value);
if (parsed is not null)
{
results.Add(parsed);
}
}
}
return results.ToImmutable();
}
private static ComponentEvidenceRecord? ParseLegacyEvidenceValue(string value)
{
// Format: kind:value@source (e.g., "crypto:aes-256@/src/crypto.c")
var atIndex = value.LastIndexOf('@');
if (atIndex <= 0)
{
return null;
}
var kindValue = value[..atIndex];
var source = value[(atIndex + 1)..];
var colonIndex = kindValue.IndexOf(':');
if (colonIndex <= 0)
{
return null;
}
var kind = kindValue[..colonIndex];
var evidenceValue = kindValue[(colonIndex + 1)..];
return new ComponentEvidenceRecord
{
Kind = kind,
Value = evidenceValue,
Source = source,
};
}
private static ImmutableArray<CopyrightEvidence> BuildCopyrightEvidence(AggregatedComponent component)
{
if (component.Evidence.IsDefaultOrEmpty)
{
return ImmutableArray<CopyrightEvidence>.Empty;
}
var copyrightEvidence = component.Evidence
.Where(e => string.Equals(e.Kind, "copyright", StringComparison.OrdinalIgnoreCase))
.Select(e => new CopyrightEvidence { Text = e.Value })
.ToImmutableArray();
return copyrightEvidence;
}
}
/// <summary>
/// Represents a parsed component evidence record from legacy or native formats.
/// Sprint: SPRINT_20260107_005_001 Task EV-001
/// </summary>
public sealed record ComponentEvidenceRecord
{
/// <summary>
/// Gets or sets the kind of evidence (e.g., "crypto", "license", "copyright").
/// </summary>
public required string Kind { get; init; }
/// <summary>
/// Gets or sets the evidence value (e.g., algorithm name, license ID).
/// </summary>
public required string Value { get; init; }
/// <summary>
/// Gets or sets the source location of the evidence.
/// </summary>
public required string Source { get; init; }
/// <summary>
/// Gets or sets the confidence score (0.0-1.0).
/// </summary>
public double? Confidence { get; init; }
/// <summary>
/// Gets or sets the detection technique used.
/// </summary>
public string? Technique { get; init; }
}
/// <summary>
/// StellaOps internal Copyright Evidence model.
/// Sprint: SPRINT_20260107_005_001 Task EV-001
/// </summary>
public sealed class CopyrightEvidence
{
/// <summary>
/// Gets or sets the copyright text.
/// </summary>
public string? Text { get; set; }
}

View File

@@ -0,0 +1,175 @@
// <copyright file="EvidenceConfidenceNormalizer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Globalization;
namespace StellaOps.Scanner.Emit.Evidence;
/// <summary>
/// Normalizes confidence scores from various analyzers to CycloneDX 1.7 scale (0.0-1.0).
/// Sprint: SPRINT_20260107_005_001 Task EV-008
/// </summary>
/// <remarks>
/// <para>Confidence Scoring Methodology:</para>
/// <list type="bullet">
/// <item>CycloneDX 1.7 uses a 0.0-1.0 normalized scale for evidence confidence</item>
/// <item>Different analyzers use different scoring systems (percentage, 1-5, 1-10, etc.)</item>
/// <item>This class normalizes all scores to the CycloneDX scale consistently</item>
/// <item>Scores are clamped to valid range to prevent invalid output</item>
/// </list>
/// </remarks>
public static class EvidenceConfidenceNormalizer
{
/// <summary>
/// Normalizes a percentage-based confidence score (0-100) to CycloneDX scale (0.0-1.0).
/// </summary>
/// <param name="percentageConfidence">Confidence score as percentage (0-100).</param>
/// <returns>Normalized confidence score (0.0-1.0).</returns>
public static double NormalizeFromPercentage(double percentageConfidence)
{
var normalized = percentageConfidence / 100.0;
return Clamp(normalized);
}
/// <summary>
/// Normalizes a 1-5 scale confidence score to CycloneDX scale (0.0-1.0).
/// </summary>
/// <param name="scaleConfidence">Confidence score on 1-5 scale.</param>
/// <returns>Normalized confidence score (0.0-1.0).</returns>
public static double NormalizeFromScale5(int scaleConfidence)
{
// Map 1-5 scale: 1=0.2, 2=0.4, 3=0.6, 4=0.8, 5=1.0
var normalized = scaleConfidence / 5.0;
return Clamp(normalized);
}
/// <summary>
/// Normalizes a 1-10 scale confidence score to CycloneDX scale (0.0-1.0).
/// </summary>
/// <param name="scaleConfidence">Confidence score on 1-10 scale.</param>
/// <returns>Normalized confidence score (0.0-1.0).</returns>
public static double NormalizeFromScale10(int scaleConfidence)
{
var normalized = scaleConfidence / 10.0;
return Clamp(normalized);
}
/// <summary>
/// Normalizes an analyzer-specific confidence string to CycloneDX scale.
/// </summary>
/// <param name="confidenceValue">Raw confidence value from analyzer.</param>
/// <param name="analyzerType">Type of analyzer (e.g., "syft", "trivy", "grype").</param>
/// <returns>Normalized confidence score (0.0-1.0), or null if parsing fails.</returns>
public static double? NormalizeFromAnalyzer(string? confidenceValue, string analyzerType)
{
if (string.IsNullOrWhiteSpace(confidenceValue))
{
return null;
}
// Try to parse as double using invariant culture (Rule 8.5)
if (!double.TryParse(confidenceValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var rawValue))
{
return TryParseTextualConfidence(confidenceValue);
}
return analyzerType?.ToLowerInvariant() switch
{
// Syft uses 0.0-1.0 scale directly
"syft" => Clamp(rawValue),
// Trivy uses percentage (0-100)
"trivy" => NormalizeFromPercentage(rawValue),
// Grype uses 0.0-1.0 scale
"grype" => Clamp(rawValue),
// Dependency-Track uses 0.0-10.0 scale
"dependency-track" or "dtrack" => NormalizeFromScale10((int)Math.Round(rawValue)),
// ORT uses 0.0-100.0 percentage
"ort" or "oss-review-toolkit" => NormalizeFromPercentage(rawValue),
// Snyk uses 0.0-100.0 percentage
"snyk" => NormalizeFromPercentage(rawValue),
// Default: assume 0.0-1.0 scale if value <= 1.0, else assume percentage
_ => rawValue <= 1.0 ? Clamp(rawValue) : NormalizeFromPercentage(rawValue),
};
}
/// <summary>
/// Combines multiple confidence scores using weighted average.
/// </summary>
/// <param name="confidenceScores">Collection of confidence scores (already normalized).</param>
/// <param name="weights">Optional weights for each score. If null, equal weights are used.</param>
/// <returns>Combined confidence score (0.0-1.0).</returns>
public static double CombineConfidenceScores(
IReadOnlyList<double> confidenceScores,
IReadOnlyList<double>? weights = null)
{
ArgumentNullException.ThrowIfNull(confidenceScores);
if (confidenceScores.Count == 0)
{
return 0.0;
}
if (weights != null && weights.Count != confidenceScores.Count)
{
throw new ArgumentException(
"Weights count must match confidence scores count",
nameof(weights));
}
double totalWeight = 0;
double weightedSum = 0;
for (int i = 0; i < confidenceScores.Count; i++)
{
var weight = weights?[i] ?? 1.0;
weightedSum += confidenceScores[i] * weight;
totalWeight += weight;
}
if (totalWeight == 0)
{
return 0.0;
}
return Clamp(weightedSum / totalWeight);
}
/// <summary>
/// Formats a normalized confidence score as a string for output.
/// </summary>
/// <param name="normalizedConfidence">The normalized confidence score (0.0-1.0).</param>
/// <returns>Formatted string representation using invariant culture.</returns>
public static string FormatConfidence(double normalizedConfidence)
{
return Clamp(normalizedConfidence).ToString("F2", CultureInfo.InvariantCulture);
}
private static double Clamp(double value)
{
return Math.Clamp(value, 0.0, 1.0);
}
private static double? TryParseTextualConfidence(string value)
{
// Handle textual confidence values from some analyzers
return value.ToLowerInvariant() switch
{
"highest" or "very high" or "certain" => 1.0,
"high" => 0.9,
"medium-high" => 0.75,
"medium" or "moderate" => 0.6,
"medium-low" => 0.45,
"low" => 0.3,
"very low" or "uncertain" => 0.1,
"none" or "unknown" => null,
_ => null,
};
}
}

View File

@@ -0,0 +1,203 @@
// <copyright file="IdentityEvidenceBuilder.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Evidence;
/// <summary>
/// Builds CycloneDX 1.7 component identity evidence from package detection data.
/// Sprint: SPRINT_20260107_005_001 Task EV-002
/// </summary>
public sealed class IdentityEvidenceBuilder
{
/// <summary>
/// Builds identity evidence from aggregated component data.
/// </summary>
/// <param name="component">The aggregated component.</param>
/// <returns>The identity evidence, or null if insufficient data.</returns>
public ComponentIdentityEvidence? Build(AggregatedComponent component)
{
ArgumentNullException.ThrowIfNull(component);
// Determine the primary identification field
var field = DetermineIdentityField(component);
if (field is null)
{
return null;
}
var methods = BuildDetectionMethods(component);
var confidence = CalculateOverallConfidence(methods);
return new ComponentIdentityEvidence
{
Field = field,
Confidence = confidence,
Methods = methods.IsDefaultOrEmpty ? null : [.. methods],
};
}
private static string? DetermineIdentityField(AggregatedComponent component)
{
// Priority: PURL > Name
if (!string.IsNullOrWhiteSpace(component.Identity.Purl))
{
return "purl";
}
if (!string.IsNullOrWhiteSpace(component.Identity.Name) &&
component.Identity.Name != "unknown")
{
return "name";
}
return null;
}
private static ImmutableArray<IdentityEvidenceMethod> BuildDetectionMethods(AggregatedComponent component)
{
var methods = ImmutableArray.CreateBuilder<IdentityEvidenceMethod>();
// Build methods based on available evidence
var hasManifestEvidence = component.Evidence.Any(e =>
e.Kind.Equals("manifest", StringComparison.OrdinalIgnoreCase) ||
e.Source?.Contains("package.json", StringComparison.OrdinalIgnoreCase) == true ||
e.Source?.Contains(".csproj", StringComparison.OrdinalIgnoreCase) == true ||
e.Source?.Contains("pom.xml", StringComparison.OrdinalIgnoreCase) == true);
var hasBinaryEvidence = component.Evidence.Any(e =>
e.Kind.Equals("binary", StringComparison.OrdinalIgnoreCase));
var hasHashEvidence = component.Evidence.Any(e =>
e.Kind.Equals("hash", StringComparison.OrdinalIgnoreCase) ||
e.Kind.Equals("digest", StringComparison.OrdinalIgnoreCase));
if (hasManifestEvidence)
{
methods.Add(new IdentityEvidenceMethod
{
Technique = IdentityEvidenceTechnique.ManifestAnalysis,
Confidence = 0.95, // High confidence for manifest
});
}
if (hasBinaryEvidence)
{
methods.Add(new IdentityEvidenceMethod
{
Technique = IdentityEvidenceTechnique.BinaryAnalysis,
Confidence = 0.80,
});
}
if (hasHashEvidence)
{
methods.Add(new IdentityEvidenceMethod
{
Technique = IdentityEvidenceTechnique.HashComparison,
Confidence = 0.99, // Very high confidence for hash match
});
}
// Default: attestation if component has PURL but no other methods
if (methods.Count == 0 && !string.IsNullOrWhiteSpace(component.Identity.Purl))
{
methods.Add(new IdentityEvidenceMethod
{
Technique = IdentityEvidenceTechnique.Attestation,
Confidence = 0.70,
});
}
return methods.ToImmutable();
}
private static double CalculateOverallConfidence(ImmutableArray<IdentityEvidenceMethod> methods)
{
if (methods.IsDefaultOrEmpty)
{
return 0.50;
}
// Use highest confidence from methods
return methods.Max(m => m.Confidence ?? 0.50);
}
}
/// <summary>
/// CycloneDX 1.7 Component Identity Evidence.
/// Sprint: SPRINT_20260107_005_001 Task EV-002
/// </summary>
public sealed class ComponentIdentityEvidence
{
/// <summary>
/// Gets or sets the field used for identity (purl, cpe, name, etc.).
/// </summary>
public string? Field { get; set; }
/// <summary>
/// Gets or sets the overall confidence score (0.0-1.0).
/// </summary>
public double? Confidence { get; set; }
/// <summary>
/// Gets or sets the detection methods used.
/// </summary>
public List<IdentityEvidenceMethod>? Methods { get; set; }
}
/// <summary>
/// CycloneDX 1.7 Identity Evidence Method.
/// Sprint: SPRINT_20260107_005_001 Task EV-002
/// </summary>
public sealed class IdentityEvidenceMethod
{
/// <summary>
/// Gets or sets the detection technique.
/// </summary>
public IdentityEvidenceTechnique Technique { get; set; }
/// <summary>
/// Gets or sets the confidence score for this method (0.0-1.0).
/// </summary>
public double? Confidence { get; set; }
/// <summary>
/// Gets or sets additional value/detail for the method.
/// </summary>
public string? Value { get; set; }
}
/// <summary>
/// CycloneDX 1.7 Identity Evidence Techniques.
/// Sprint: SPRINT_20260107_005_001 Task EV-002
/// </summary>
public enum IdentityEvidenceTechnique
{
/// <summary>Binary analysis technique.</summary>
BinaryAnalysis,
/// <summary>Manifest analysis technique.</summary>
ManifestAnalysis,
/// <summary>Source code analysis technique.</summary>
SourceCodeAnalysis,
/// <summary>Hash comparison technique.</summary>
HashComparison,
/// <summary>Filename analysis technique.</summary>
FilenameAnalysis,
/// <summary>Attestation-based technique.</summary>
Attestation,
/// <summary>Dynamic analysis technique.</summary>
DynamicAnalysis,
/// <summary>Other unspecified technique.</summary>
Other,
}

View File

@@ -0,0 +1,163 @@
// <copyright file="LegacyEvidencePropertyWriter.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Globalization;
using CycloneDX.Models;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Evidence;
/// <summary>
/// Writes evidence data to legacy CycloneDX property format for backward compatibility.
/// Sprint: SPRINT_20260107_005_001 Task EV-009
/// </summary>
/// <remarks>
/// <para>Migration Support:</para>
/// <para>
/// During the migration period from property-based evidence to native CycloneDX 1.7 evidence fields,
/// this class provides dual-output capability - writing evidence both to native fields and legacy properties.
/// </para>
/// <para>Legacy Property Format:</para>
/// <list type="bullet">
/// <item>stellaops:evidence[n]:kind - Evidence kind (identity, occurrence, license, callstack)</item>
/// <item>stellaops:evidence[n]:source - Evidence source/analyzer</item>
/// <item>stellaops:evidence[n]:value - Evidence value</item>
/// <item>stellaops:evidence[n]:confidence - Confidence score (0.0-1.0)</item>
/// <item>stellaops:evidence[n]:methods - Reference to evidence.methods[] (CycloneDX 1.7)</item>
/// </list>
/// </remarks>
public sealed class LegacyEvidencePropertyWriter
{
private const string PropertyPrefix = "stellaops:evidence";
/// <summary>
/// Writes component evidence to legacy property format.
/// </summary>
/// <param name="component">The CycloneDX component to add properties to.</param>
/// <param name="evidence">The evidence collection from Scanner core.</param>
/// <param name="options">Options controlling evidence output.</param>
public void WriteEvidenceProperties(
Component component,
ImmutableArray<ComponentEvidence> evidence,
LegacyEvidenceOptions options)
{
ArgumentNullException.ThrowIfNull(component);
if (evidence.IsDefaultOrEmpty)
{
return;
}
component.Properties ??= [];
int evidenceIndex = 0;
foreach (var item in evidence)
{
WriteEvidenceItem(component.Properties, item, evidenceIndex, options);
evidenceIndex++;
}
}
/// <summary>
/// Removes legacy evidence properties from a component.
/// </summary>
/// <param name="component">The CycloneDX component to clean.</param>
public void RemoveLegacyProperties(Component component)
{
ArgumentNullException.ThrowIfNull(component);
if (component.Properties == null)
{
return;
}
component.Properties.RemoveAll(p => p.Name?.StartsWith(PropertyPrefix, StringComparison.Ordinal) == true);
}
private void WriteEvidenceItem(
List<Property> properties,
ComponentEvidence evidence,
int index,
LegacyEvidenceOptions options)
{
var prefix = $"{PropertyPrefix}[{index.ToString(CultureInfo.InvariantCulture)}]";
// Kind
properties.Add(new Property
{
Name = $"{prefix}:kind",
Value = evidence.Kind,
});
// Source
if (!string.IsNullOrWhiteSpace(evidence.Source))
{
properties.Add(new Property
{
Name = $"{prefix}:source",
Value = evidence.Source,
});
}
// Value
if (!string.IsNullOrWhiteSpace(evidence.Value))
{
properties.Add(new Property
{
Name = $"{prefix}:value",
Value = evidence.Value,
});
}
// Methods reference (CycloneDX 1.7 interop)
if (options.IncludeMethodsReference)
{
var methodsReference = MapKindToMethodsReference(evidence.Kind);
if (!string.IsNullOrWhiteSpace(methodsReference))
{
properties.Add(new Property
{
Name = $"{prefix}:methods",
Value = methodsReference,
});
}
}
}
private static string? MapKindToMethodsReference(string kind)
{
return kind.ToLowerInvariant() switch
{
"identity" => "evidence.identity",
"occurrence" => "evidence.occurrences",
"license" => "evidence.licenses",
"callstack" => "evidence.callstack",
"copyright" => "evidence.copyright",
"hash" => "evidence.identity",
"manifest" => "evidence.occurrences",
"signature" => "evidence.identity",
_ => null,
};
}
}
/// <summary>
/// Options for legacy evidence property output.
/// </summary>
public sealed class LegacyEvidenceOptions
{
/// <summary>
/// Gets or sets a value indicating whether to include references to CycloneDX 1.7 evidence.methods[].
/// Default is true.
/// </summary>
public bool IncludeMethodsReference { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether legacy properties should be written at all.
/// When false, only native CycloneDX 1.7 evidence fields are used.
/// Default is true during migration period.
/// </summary>
public bool EnableLegacyProperties { get; set; } = true;
}

View File

@@ -0,0 +1,172 @@
// <copyright file="LicenseEvidenceBuilder.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using CycloneDX.Models;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Evidence;
/// <summary>
/// Builds CycloneDX 1.7 license evidence from component license detection.
/// Sprint: SPRINT_20260107_005_001 Task EV-004
/// </summary>
public sealed class LicenseEvidenceBuilder
{
/// <summary>
/// Builds license evidence from aggregated component data.
/// </summary>
/// <param name="component">The aggregated component.</param>
/// <returns>Array of license evidence records.</returns>
public ImmutableArray<LicenseEvidence> Build(AggregatedComponent component)
{
ArgumentNullException.ThrowIfNull(component);
var results = ImmutableArray.CreateBuilder<LicenseEvidence>();
// Extract license evidence from component evidence
var licenseEvidence = component.Evidence
.Where(e => string.Equals(e.Kind, "license", StringComparison.OrdinalIgnoreCase))
.Select(e => new LicenseEvidence
{
License = CreateLicenseChoiceFromValue(e.Value),
Acknowledgement = LicenseAcknowledgement.Concluded,
Comment = !string.IsNullOrWhiteSpace(e.Source) ? $"Detected at {e.Source}" : null,
});
results.AddRange(licenseEvidence);
return results
.Distinct(LicenseEvidenceComparer.Instance)
.ToImmutableArray();
}
private static LicenseChoice CreateLicenseChoiceFromValue(string value)
{
// Check for SPDX expression operators first (AND, OR, WITH)
if (value.Contains(" AND ", StringComparison.OrdinalIgnoreCase) ||
value.Contains(" OR ", StringComparison.OrdinalIgnoreCase) ||
value.Contains(" WITH ", StringComparison.OrdinalIgnoreCase))
{
return new LicenseChoice { Expression = value };
}
// Try to parse as SPDX ID
if (IsSpdxLicenseId(value))
{
return new LicenseChoice
{
License = new License { Id = value },
};
}
// Otherwise treat as license name
return new LicenseChoice
{
License = new License { Name = value },
};
}
private static bool IsSpdxLicenseId(string value)
{
// Basic SPDX license ID detection (common patterns)
return !string.IsNullOrWhiteSpace(value) &&
(value.StartsWith("MIT", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("Apache-", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("GPL-", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("LGPL-", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("BSD-", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("ISC", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("MPL-", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("AGPL-", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("CC-", StringComparison.OrdinalIgnoreCase) ||
value.Equals("Unlicense", StringComparison.OrdinalIgnoreCase) ||
value.Equals("0BSD", StringComparison.OrdinalIgnoreCase));
}
}
/// <summary>
/// CycloneDX 1.7 License Evidence.
/// Sprint: SPRINT_20260107_005_001 Task EV-004
/// </summary>
public sealed record LicenseEvidence
{
/// <summary>
/// Gets or sets the license choice (license or expression).
/// </summary>
public required LicenseChoice License { get; init; }
/// <summary>
/// Gets or sets how the license was acknowledged.
/// </summary>
public LicenseAcknowledgement Acknowledgement { get; init; } = LicenseAcknowledgement.Declared;
/// <summary>
/// Gets or sets optional comment about the license evidence.
/// </summary>
public string? Comment { get; init; }
}
/// <summary>
/// CycloneDX 1.7 License Acknowledgement types.
/// Sprint: SPRINT_20260107_005_001 Task EV-004
/// </summary>
public enum LicenseAcknowledgement
{
/// <summary>License declared by package author.</summary>
Declared,
/// <summary>License concluded by analysis.</summary>
Concluded,
}
/// <summary>
/// Comparer for license evidence to eliminate duplicates.
/// Sprint: SPRINT_20260107_005_001 Task EV-004
/// </summary>
internal sealed class LicenseEvidenceComparer : IEqualityComparer<LicenseEvidence>
{
public static readonly LicenseEvidenceComparer Instance = new();
public bool Equals(LicenseEvidence? x, LicenseEvidence? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
var xId = GetLicenseIdentifier(x.License);
var yId = GetLicenseIdentifier(y.License);
return string.Equals(xId, yId, StringComparison.OrdinalIgnoreCase) &&
x.Acknowledgement == y.Acknowledgement;
}
public int GetHashCode(LicenseEvidence obj)
{
return HashCode.Combine(
GetLicenseIdentifier(obj.License)?.ToLowerInvariant(),
obj.Acknowledgement);
}
private static string? GetLicenseIdentifier(LicenseChoice choice)
{
if (!string.IsNullOrWhiteSpace(choice.Expression))
{
return choice.Expression;
}
if (choice.License is not null)
{
return choice.License.Id ?? choice.License.Name;
}
return null;
}
}

View File

@@ -0,0 +1,134 @@
// <copyright file="OccurrenceEvidenceBuilder.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Evidence;
/// <summary>
/// Builds CycloneDX 1.7 occurrence evidence from component file locations.
/// Sprint: SPRINT_20260107_005_001 Task EV-003
/// </summary>
public sealed class OccurrenceEvidenceBuilder
{
/// <summary>
/// Builds occurrence evidence from aggregated component data.
/// </summary>
/// <param name="component">The aggregated component.</param>
/// <returns>Array of occurrence evidence records.</returns>
public ImmutableArray<OccurrenceEvidence> Build(AggregatedComponent component)
{
ArgumentNullException.ThrowIfNull(component);
if (component.Evidence.IsDefaultOrEmpty)
{
return ImmutableArray<OccurrenceEvidence>.Empty;
}
return component.Evidence
.Where(e => !string.IsNullOrWhiteSpace(e.Source))
.Select(e => new OccurrenceEvidence
{
Location = NormalizePath(e.Source!),
AdditionalContext = $"{e.Kind}:{e.Value}",
})
.Distinct(OccurrenceEvidenceComparer.Instance)
.ToImmutableArray();
}
/// <summary>
/// Builds occurrence evidence from evidence records with location info.
/// </summary>
/// <param name="evidenceRecords">Evidence records.</param>
/// <returns>Array of occurrence evidence records.</returns>
public static ImmutableArray<OccurrenceEvidence> BuildFromRecords(
IReadOnlyList<ComponentEvidence>? evidenceRecords)
{
if (evidenceRecords is null || evidenceRecords.Count == 0)
{
return ImmutableArray<OccurrenceEvidence>.Empty;
}
return evidenceRecords
.Where(e => !string.IsNullOrWhiteSpace(e.Source))
.Select(e => new OccurrenceEvidence
{
Location = NormalizePath(e.Source!),
AdditionalContext = $"{e.Kind}:{e.Value}",
})
.Distinct(OccurrenceEvidenceComparer.Instance)
.ToImmutableArray();
}
private static string NormalizePath(string path)
{
// Ensure consistent forward slashes and remove leading/trailing whitespace
return path.Trim().Replace('\\', '/');
}
}
/// <summary>
/// CycloneDX 1.7 Occurrence Evidence.
/// Sprint: SPRINT_20260107_005_001 Task EV-003
/// </summary>
public sealed record OccurrenceEvidence
{
/// <summary>
/// Gets or sets the file location path.
/// </summary>
public required string Location { get; init; }
/// <summary>
/// Gets or sets the line number (1-based).
/// </summary>
public int? Line { get; init; }
/// <summary>
/// Gets or sets the byte offset in the file.
/// </summary>
public int? Offset { get; init; }
/// <summary>
/// Gets or sets the symbol name at this location.
/// </summary>
public string? Symbol { get; init; }
/// <summary>
/// Gets or sets additional context about the occurrence.
/// </summary>
public string? AdditionalContext { get; init; }
}
/// <summary>
/// Comparer for occurrence evidence to eliminate duplicates.
/// Sprint: SPRINT_20260107_005_001 Task EV-003
/// </summary>
internal sealed class OccurrenceEvidenceComparer : IEqualityComparer<OccurrenceEvidence>
{
public static readonly OccurrenceEvidenceComparer Instance = new();
public bool Equals(OccurrenceEvidence? x, OccurrenceEvidence? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return string.Equals(x.Location, y.Location, StringComparison.OrdinalIgnoreCase) &&
x.Line == y.Line;
}
public int GetHashCode(OccurrenceEvidence obj)
{
return HashCode.Combine(
obj.Location?.ToLowerInvariant(),
obj.Line);
}
}

View File

@@ -0,0 +1,149 @@
// <copyright file="AncestorComponentBuilder.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Builds ancestor component entries from upstream package information.
/// Sprint: SPRINT_20260107_005_002 Task PD-004
/// </summary>
public sealed class AncestorComponentBuilder
{
private readonly List<AncestorComponent> _ancestors = new();
/// <summary>
/// Adds an ancestor component at the specified level.
/// </summary>
/// <param name="name">Component name.</param>
/// <param name="version">Upstream version.</param>
/// <param name="level">Ancestry level (1 = direct parent).</param>
/// <returns>This builder for fluent chaining.</returns>
public AncestorComponentBuilder AddAncestor(string name, string version, int level = 1)
{
_ancestors.Add(new AncestorComponent
{
Name = name,
Version = version,
Level = level
});
return this;
}
/// <summary>
/// Adds an ancestor with full details.
/// </summary>
/// <param name="name">Component name.</param>
/// <param name="version">Upstream version.</param>
/// <param name="purl">Package URL for the ancestor.</param>
/// <param name="projectUrl">URL to the upstream project.</param>
/// <param name="componentType">Type of component.</param>
/// <param name="level">Ancestry level.</param>
/// <returns>This builder for fluent chaining.</returns>
public AncestorComponentBuilder AddAncestor(
string name,
string version,
string? purl,
string? projectUrl = null,
string componentType = "library",
int level = 1)
{
_ancestors.Add(new AncestorComponent
{
Type = componentType,
Name = name,
Version = version,
Purl = purl,
ProjectUrl = projectUrl,
Level = level
});
return this;
}
/// <summary>
/// Adds a generic upstream source (e.g., openssl).
/// </summary>
/// <param name="packageName">Generic package name.</param>
/// <param name="upstreamVersion">Upstream version without distro suffix.</param>
/// <param name="upstreamProjectUrl">URL to the upstream project.</param>
/// <returns>This builder for fluent chaining.</returns>
public AncestorComponentBuilder AddGenericUpstream(
string packageName,
string upstreamVersion,
string? upstreamProjectUrl = null)
{
var purl = $"pkg:generic/{Uri.EscapeDataString(packageName)}@{Uri.EscapeDataString(upstreamVersion)}";
return AddAncestor(
packageName,
upstreamVersion,
purl,
upstreamProjectUrl,
"library",
level: 1);
}
/// <summary>
/// Adds a GitHub upstream source.
/// </summary>
/// <param name="owner">Repository owner.</param>
/// <param name="repo">Repository name.</param>
/// <param name="version">Version or tag.</param>
/// <returns>This builder for fluent chaining.</returns>
public AncestorComponentBuilder AddGitHubUpstream(
string owner,
string repo,
string version)
{
var purl = $"pkg:github/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}@{Uri.EscapeDataString(version)}";
var projectUrl = $"https://github.com/{owner}/{repo}";
return AddAncestor(
repo,
version,
purl,
projectUrl,
"library",
level: 1);
}
/// <summary>
/// Adds multi-level ancestry (for complex derivation chains).
/// </summary>
/// <param name="ancestors">Ancestors in order from closest to most distant.</param>
/// <returns>This builder for fluent chaining.</returns>
public AncestorComponentBuilder AddAncestryChain(
params (string Name, string Version, string? Purl)[] ancestors)
{
for (var i = 0; i < ancestors.Length; i++)
{
var (name, version, purl) = ancestors[i];
AddAncestor(name, version, purl, level: i + 1);
}
return this;
}
/// <summary>
/// Builds the immutable array of ancestors.
/// </summary>
/// <returns>Immutable array of ancestor components.</returns>
public ImmutableArray<AncestorComponent> Build()
{
return _ancestors
.OrderBy(a => a.Level)
.ThenBy(a => a.Name, StringComparer.Ordinal)
.ToImmutableArray();
}
/// <summary>
/// Clears the builder for reuse.
/// </summary>
/// <returns>This builder for fluent chaining.</returns>
public AncestorComponentBuilder Clear()
{
_ancestors.Clear();
return this;
}
}

View File

@@ -0,0 +1,215 @@
// <copyright file="CachedPedigreeDataProvider.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Cached decorator for <see cref="IPedigreeDataProvider"/> using bounded MemoryCache.
/// Follows CLAUDE.md Rule 8.17 (bounded caches with eviction).
/// Sprint: SPRINT_20260107_005_002 Task PD-010
/// </summary>
public sealed class CachedPedigreeDataProvider : IPedigreeDataProvider, IDisposable
{
private readonly IPedigreeDataProvider _inner;
private readonly MemoryCache _cache;
private readonly PedigreeCacheOptions _options;
private readonly ILogger<CachedPedigreeDataProvider> _logger;
private const string CacheKeyPrefix = "pedigree:";
/// <summary>
/// Initializes a new instance of the <see cref="CachedPedigreeDataProvider"/> class.
/// </summary>
/// <param name="inner">The underlying pedigree data provider.</param>
/// <param name="options">Cache configuration options.</param>
/// <param name="logger">Logger instance.</param>
public CachedPedigreeDataProvider(
IPedigreeDataProvider inner,
IOptions<PedigreeCacheOptions> options,
ILogger<CachedPedigreeDataProvider> logger)
{
_inner = inner;
_options = options.Value;
_logger = logger;
// Create bounded cache per CLAUDE.md Rule 8.17
_cache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = _options.MaxCacheEntries
});
}
/// <inheritdoc/>
public async Task<PedigreeData?> GetPedigreeAsync(
string purl,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(purl))
{
return null;
}
var cacheKey = CacheKeyPrefix + purl;
// Check cache first
if (_cache.TryGetValue(cacheKey, out PedigreeData? cached))
{
_logger.LogDebug("Cache hit for pedigree: {Purl}", purl);
return cached;
}
// Fetch from inner provider
var result = await _inner.GetPedigreeAsync(purl, cancellationToken)
.ConfigureAwait(false);
// Cache the result (even if null, to avoid repeated lookups)
CacheResult(cacheKey, result);
return result;
}
/// <inheritdoc/>
public async Task<IReadOnlyDictionary<string, PedigreeData>> GetPedigreesBatchAsync(
IEnumerable<string> purls,
CancellationToken cancellationToken = default)
{
var purlList = purls.Where(p => !string.IsNullOrEmpty(p)).Distinct().ToList();
if (purlList.Count == 0)
{
return new Dictionary<string, PedigreeData>();
}
var results = new Dictionary<string, PedigreeData>(StringComparer.Ordinal);
var uncachedPurls = new List<string>();
// Check cache for each PURL
foreach (var purl in purlList)
{
var cacheKey = CacheKeyPrefix + purl;
if (_cache.TryGetValue(cacheKey, out PedigreeData? cached) && cached is not null)
{
results[purl] = cached;
}
else
{
uncachedPurls.Add(purl);
}
}
_logger.LogDebug(
"Pedigree cache: {CacheHits} hits, {CacheMisses} misses",
results.Count,
uncachedPurls.Count);
// Fetch uncached items
if (uncachedPurls.Count > 0)
{
var fetched = await _inner.GetPedigreesBatchAsync(uncachedPurls, cancellationToken)
.ConfigureAwait(false);
foreach (var (purl, pedigree) in fetched)
{
var cacheKey = CacheKeyPrefix + purl;
CacheResult(cacheKey, pedigree);
results[purl] = pedigree;
}
// Cache negative results for uncached PURLs that weren't found
foreach (var purl in uncachedPurls)
{
if (!fetched.ContainsKey(purl))
{
var cacheKey = CacheKeyPrefix + purl;
CacheNegativeResult(cacheKey);
}
}
}
return results;
}
/// <summary>
/// Invalidates cached pedigree data for a specific PURL.
/// </summary>
/// <param name="purl">The PURL to invalidate.</param>
public void Invalidate(string purl)
{
var cacheKey = CacheKeyPrefix + purl;
_cache.Remove(cacheKey);
_logger.LogDebug("Invalidated pedigree cache for: {Purl}", purl);
}
/// <summary>
/// Invalidates all cached pedigree data.
/// </summary>
public void InvalidateAll()
{
_cache.Compact(1.0);
_logger.LogInformation("Invalidated all pedigree cache entries");
}
/// <inheritdoc/>
public void Dispose()
{
_cache.Dispose();
}
private void CacheResult(string cacheKey, PedigreeData? result)
{
var entryOptions = new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = _options.SlidingExpiration,
AbsoluteExpirationRelativeToNow = _options.AbsoluteExpiration
};
_cache.Set(cacheKey, result, entryOptions);
}
private void CacheNegativeResult(string cacheKey)
{
// Cache negative results with shorter TTL
var entryOptions = new MemoryCacheEntryOptions
{
Size = 1,
AbsoluteExpirationRelativeToNow = _options.NegativeCacheTtl
};
_cache.Set<PedigreeData?>(cacheKey, null, entryOptions);
}
}
/// <summary>
/// Configuration options for pedigree caching.
/// </summary>
public sealed class PedigreeCacheOptions
{
/// <summary>
/// Gets or sets the maximum number of cache entries.
/// Default: 10,000 entries.
/// </summary>
public int MaxCacheEntries { get; set; } = 10_000;
/// <summary>
/// Gets or sets the sliding expiration for cache entries.
/// Default: 30 minutes.
/// </summary>
public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Gets or sets the absolute expiration for cache entries.
/// Default: 4 hours (aligned with advisory freshness).
/// </summary>
public TimeSpan AbsoluteExpiration { get; set; } = TimeSpan.FromHours(4);
/// <summary>
/// Gets or sets the TTL for negative cache results (not found).
/// Default: 15 minutes.
/// </summary>
public TimeSpan NegativeCacheTtl { get; set; } = TimeSpan.FromMinutes(15);
}

View File

@@ -0,0 +1,255 @@
// <copyright file="CommitInfoBuilder.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Builds commit info entries from patch signatures and changelog data.
/// Sprint: SPRINT_20260107_005_002 Task PD-006
/// </summary>
public sealed partial class CommitInfoBuilder
{
private readonly List<CommitInfo> _commits = new();
/// <summary>
/// Adds a commit with basic information.
/// </summary>
/// <param name="sha">Commit SHA (full or abbreviated).</param>
/// <param name="url">Optional URL to view the commit.</param>
/// <param name="message">Optional commit message.</param>
/// <returns>This builder for fluent chaining.</returns>
public CommitInfoBuilder AddCommit(
string sha,
string? url = null,
string? message = null)
{
_commits.Add(new CommitInfo
{
Uid = NormalizeSha(sha),
Url = url,
Message = TruncateMessage(message)
});
return this;
}
/// <summary>
/// Adds a commit with full actor information.
/// </summary>
/// <param name="sha">Commit SHA.</param>
/// <param name="url">URL to view the commit.</param>
/// <param name="message">Commit message.</param>
/// <param name="authorName">Author name.</param>
/// <param name="authorEmail">Author email.</param>
/// <param name="authorTime">Author timestamp.</param>
/// <param name="committerName">Committer name (if different).</param>
/// <param name="committerEmail">Committer email.</param>
/// <param name="committerTime">Committer timestamp.</param>
/// <param name="resolvesCves">CVE IDs resolved by this commit.</param>
/// <returns>This builder for fluent chaining.</returns>
public CommitInfoBuilder AddCommit(
string sha,
string? url,
string? message,
string? authorName,
string? authorEmail = null,
DateTimeOffset? authorTime = null,
string? committerName = null,
string? committerEmail = null,
DateTimeOffset? committerTime = null,
IEnumerable<string>? resolvesCves = null)
{
CommitActor? author = authorName is not null || authorEmail is not null
? new CommitActor
{
Name = authorName,
Email = authorEmail,
Timestamp = authorTime
}
: null;
CommitActor? committer = committerName is not null || committerEmail is not null
? new CommitActor
{
Name = committerName,
Email = committerEmail,
Timestamp = committerTime
}
: null;
_commits.Add(new CommitInfo
{
Uid = NormalizeSha(sha),
Url = url,
Message = TruncateMessage(message),
Author = author,
Committer = committer,
ResolvesCves = resolvesCves?.ToImmutableArray() ?? ImmutableArray<string>.Empty
});
return this;
}
/// <summary>
/// Adds a GitHub commit with auto-generated URL.
/// </summary>
/// <param name="owner">Repository owner.</param>
/// <param name="repo">Repository name.</param>
/// <param name="sha">Commit SHA.</param>
/// <param name="message">Optional commit message.</param>
/// <param name="resolvesCves">CVE IDs resolved by this commit.</param>
/// <returns>This builder for fluent chaining.</returns>
public CommitInfoBuilder AddGitHubCommit(
string owner,
string repo,
string sha,
string? message = null,
IEnumerable<string>? resolvesCves = null)
{
var normalizedSha = NormalizeSha(sha);
var url = $"https://github.com/{owner}/{repo}/commit/{normalizedSha}";
_commits.Add(new CommitInfo
{
Uid = normalizedSha,
Url = url,
Message = TruncateMessage(message),
ResolvesCves = resolvesCves?.ToImmutableArray() ?? ImmutableArray<string>.Empty
});
return this;
}
/// <summary>
/// Adds a GitLab commit with auto-generated URL.
/// </summary>
/// <param name="projectPath">Full project path (e.g., "group/project").</param>
/// <param name="sha">Commit SHA.</param>
/// <param name="message">Optional commit message.</param>
/// <param name="gitlabHost">GitLab host (default: gitlab.com).</param>
/// <returns>This builder for fluent chaining.</returns>
public CommitInfoBuilder AddGitLabCommit(
string projectPath,
string sha,
string? message = null,
string gitlabHost = "gitlab.com")
{
var normalizedSha = NormalizeSha(sha);
var url = $"https://{gitlabHost}/{projectPath}/-/commit/{normalizedSha}";
_commits.Add(new CommitInfo
{
Uid = normalizedSha,
Url = url,
Message = TruncateMessage(message)
});
return this;
}
/// <summary>
/// Extracts CVE references from a commit message and adds them to resolves list.
/// </summary>
/// <param name="sha">Commit SHA.</param>
/// <param name="url">Commit URL.</param>
/// <param name="message">Commit message to scan for CVE IDs.</param>
/// <returns>This builder for fluent chaining.</returns>
public CommitInfoBuilder AddCommitWithCveExtraction(
string sha,
string? url,
string message)
{
var cves = ExtractCveIds(message);
_commits.Add(new CommitInfo
{
Uid = NormalizeSha(sha),
Url = url,
Message = TruncateMessage(message),
ResolvesCves = cves
});
return this;
}
/// <summary>
/// Builds the immutable array of commits.
/// </summary>
/// <returns>Immutable array of commit info.</returns>
public ImmutableArray<CommitInfo> Build()
{
return _commits
.OrderBy(c => c.Author?.Timestamp ?? DateTimeOffset.MaxValue)
.ThenBy(c => c.Uid, StringComparer.Ordinal)
.ToImmutableArray();
}
/// <summary>
/// Clears the builder for reuse.
/// </summary>
/// <returns>This builder for fluent chaining.</returns>
public CommitInfoBuilder Clear()
{
_commits.Clear();
return this;
}
private static string NormalizeSha(string sha)
{
// Ensure lowercase for consistency
var normalized = sha.Trim().ToLowerInvariant();
// Validate hex characters
if (!HexShaRegex().IsMatch(normalized))
{
return normalized; // Return as-is if not valid hex (could be a ref)
}
return normalized;
}
private static string? TruncateMessage(string? message, int maxLength = 500)
{
if (string.IsNullOrEmpty(message))
{
return null;
}
// Take first line for summary
var firstLine = message.Split('\n', 2)[0].Trim();
if (firstLine.Length <= maxLength)
{
return firstLine;
}
return string.Concat(firstLine.AsSpan(0, maxLength - 3), "...");
}
private static ImmutableArray<string> ExtractCveIds(string text)
{
if (string.IsNullOrEmpty(text))
{
return ImmutableArray<string>.Empty;
}
var matches = CveIdRegex().Matches(text);
return matches
.Cast<Match>()
.Select(m => m.Value.ToUpperInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.Ordinal)
.ToImmutableArray();
}
[GeneratedRegex(@"^[0-9a-f]+$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex HexShaRegex();
[GeneratedRegex(@"CVE-\d{4}-\d{4,}", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex CveIdRegex();
}

View File

@@ -0,0 +1,244 @@
// <copyright file="CycloneDxPedigreeMapper.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using CycloneDX.Models;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Maps <see cref="PedigreeData"/> to CycloneDX <see cref="Pedigree"/> model.
/// Sprint: SPRINT_20260107_005_002 Task PD-003
/// </summary>
public sealed class CycloneDxPedigreeMapper
{
/// <summary>
/// Maps pedigree data to CycloneDX pedigree model.
/// </summary>
/// <param name="data">The pedigree data to map.</param>
/// <returns>CycloneDX pedigree model, or null if no data.</returns>
public Pedigree? Map(PedigreeData? data)
{
if (data is null || !data.HasData)
{
return null;
}
return new Pedigree
{
Ancestors = MapAncestors(data.Ancestors),
Variants = MapVariants(data.Variants),
Commits = MapCommits(data.Commits),
Patches = MapPatches(data.Patches),
Notes = data.Notes
};
}
private static List<Component>? MapAncestors(
IReadOnlyList<AncestorComponent> ancestors)
{
if (ancestors.Count == 0)
{
return null;
}
return ancestors
.OrderBy(a => a.Level)
.ThenBy(a => a.Name, StringComparer.Ordinal)
.Select(ancestor => new Component
{
Type = MapComponentType(ancestor.Type),
Name = ancestor.Name,
Version = ancestor.Version,
Purl = ancestor.Purl,
ExternalReferences = BuildProjectReferences(ancestor.ProjectUrl)
})
.ToList();
}
private static List<Component>? MapVariants(
IReadOnlyList<VariantComponent> variants)
{
if (variants.Count == 0)
{
return null;
}
return variants
.OrderBy(v => v.Distribution, StringComparer.OrdinalIgnoreCase)
.ThenBy(v => v.Name, StringComparer.Ordinal)
.Select(variant => new Component
{
Type = MapComponentType(variant.Type),
Name = variant.Name,
Version = variant.Version,
Purl = variant.Purl,
Properties = BuildVariantProperties(variant)
})
.ToList();
}
private static List<Commit>? MapCommits(
IReadOnlyList<CommitInfo> commits)
{
if (commits.Count == 0)
{
return null;
}
return commits
.OrderBy(c => c.Author?.Timestamp ?? DateTimeOffset.MaxValue)
.Select(commit => new Commit
{
Uid = commit.Uid,
Url = commit.Url,
Message = commit.Message,
Author = MapCommitActor(commit.Author),
Committer = MapCommitActor(commit.Committer)
})
.ToList();
}
private static List<Patch>? MapPatches(
IReadOnlyList<PatchInfo> patches)
{
if (patches.Count == 0)
{
return null;
}
return patches
.OrderBy(p => p.Type)
.ThenBy(p => p.DiffUrl, StringComparer.Ordinal)
.Select(patch => new Patch
{
Type = MapPatchType(patch.Type),
Diff = BuildDiff(patch),
Resolves = MapResolutions(patch.Resolves)
})
.ToList();
}
private static Component.Classification MapComponentType(string type) =>
type.ToLowerInvariant() switch
{
"application" => Component.Classification.Application,
"framework" => Component.Classification.Framework,
"library" => Component.Classification.Library,
"container" => Component.Classification.Container,
"platform" => Component.Classification.Platform,
"operating-system" => Component.Classification.Operating_System,
"device" => Component.Classification.Device,
"device-driver" => Component.Classification.Device_Driver,
"firmware" => Component.Classification.Firmware,
"file" => Component.Classification.File,
"machine-learning-model" => Component.Classification.Machine_Learning_Model,
"data" => Component.Classification.Data,
_ => Component.Classification.Library
};
private static Patch.PatchClassification MapPatchType(PatchType type) =>
type switch
{
PatchType.Unofficial => Patch.PatchClassification.Unofficial,
PatchType.Monkey => Patch.PatchClassification.Monkey,
PatchType.Backport => Patch.PatchClassification.Backport,
PatchType.CherryPick => Patch.PatchClassification.Cherry_Pick,
_ => Patch.PatchClassification.Backport
};
private static IdentifiableAction? MapCommitActor(CommitActor? actor)
{
if (actor is null)
{
return null;
}
return new IdentifiableAction
{
Name = actor.Name,
Email = actor.Email,
Timestamp = actor.Timestamp
};
}
private static Diff? BuildDiff(PatchInfo patch)
{
if (string.IsNullOrEmpty(patch.DiffUrl) && string.IsNullOrEmpty(patch.DiffText))
{
return null;
}
return new Diff
{
Url = patch.DiffUrl,
Text = new AttachedText
{
Content = patch.DiffText
}
};
}
private static List<Issue>? MapResolutions(
IReadOnlyList<PatchResolution> resolutions)
{
if (resolutions.Count == 0)
{
return null;
}
return resolutions
.OrderBy(r => r.Id, StringComparer.Ordinal)
.Select(resolution => new Issue
{
Type = Issue.IssueClassification.Security,
Id = resolution.Id,
Source = resolution.SourceName is not null
? new Source { Name = resolution.SourceName, Url = resolution.SourceUrl }
: null
})
.ToList();
}
private static List<ExternalReference>? BuildProjectReferences(string? projectUrl)
{
if (string.IsNullOrEmpty(projectUrl))
{
return null;
}
return new List<ExternalReference>
{
new ExternalReference
{
Type = ExternalReference.ExternalReferenceType.Website,
Url = projectUrl
}
};
}
private static List<Property>? BuildVariantProperties(VariantComponent variant)
{
var properties = new List<Property>();
if (!string.IsNullOrEmpty(variant.Distribution))
{
properties.Add(new Property
{
Name = "stellaops:pedigree:distribution",
Value = variant.Distribution
});
}
if (!string.IsNullOrEmpty(variant.Release))
{
properties.Add(new Property
{
Name = "stellaops:pedigree:release",
Value = variant.Release
});
}
return properties.Count > 0 ? properties : null;
}
}

View File

@@ -0,0 +1,377 @@
// <copyright file="FeedserPedigreeDataProvider.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Provides pedigree data by querying Feedser patch signature and backport proof services.
/// Sprint: SPRINT_20260107_005_002 Task PD-002
/// </summary>
public sealed class FeedserPedigreeDataProvider : IPedigreeDataProvider
{
private readonly IFeedserPatchSignatureClient _patchClient;
private readonly IFeedserBackportProofClient _backportClient;
private readonly PedigreeNotesGenerator _notesGenerator;
private readonly ILogger<FeedserPedigreeDataProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FeedserPedigreeDataProvider"/> class.
/// </summary>
/// <param name="patchClient">Client for patch signature queries.</param>
/// <param name="backportClient">Client for backport proof queries.</param>
/// <param name="notesGenerator">Notes generator for human-readable summaries.</param>
/// <param name="logger">Logger instance.</param>
public FeedserPedigreeDataProvider(
IFeedserPatchSignatureClient patchClient,
IFeedserBackportProofClient backportClient,
PedigreeNotesGenerator notesGenerator,
ILogger<FeedserPedigreeDataProvider> logger)
{
_patchClient = patchClient;
_backportClient = backportClient;
_notesGenerator = notesGenerator;
_logger = logger;
}
/// <inheritdoc/>
public async Task<PedigreeData?> GetPedigreeAsync(
string purl,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(purl))
{
return null;
}
try
{
// Query both services in parallel
var patchTask = _patchClient.GetPatchSignaturesAsync(purl, cancellationToken);
var backportTask = _backportClient.GetBackportProofAsync(purl, cancellationToken);
await Task.WhenAll(patchTask, backportTask).ConfigureAwait(false);
var patchSignatures = await patchTask.ConfigureAwait(false);
var backportProof = await backportTask.ConfigureAwait(false);
if ((patchSignatures is null || patchSignatures.Count == 0) &&
backportProof is null)
{
return null;
}
return BuildPedigreeData(purl, patchSignatures, backportProof);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to retrieve pedigree for {Purl}", purl);
return null;
}
}
/// <inheritdoc/>
public async Task<IReadOnlyDictionary<string, PedigreeData>> GetPedigreesBatchAsync(
IEnumerable<string> purls,
CancellationToken cancellationToken = default)
{
var purlList = purls.Where(p => !string.IsNullOrEmpty(p)).Distinct().ToList();
if (purlList.Count == 0)
{
return ImmutableDictionary<string, PedigreeData>.Empty;
}
// Batch query both services
var patchTask = _patchClient.GetPatchSignaturesBatchAsync(purlList, cancellationToken);
var backportTask = _backportClient.GetBackportProofsBatchAsync(purlList, cancellationToken);
await Task.WhenAll(patchTask, backportTask).ConfigureAwait(false);
var patchResults = await patchTask.ConfigureAwait(false);
var backportResults = await backportTask.ConfigureAwait(false);
var results = new Dictionary<string, PedigreeData>(StringComparer.Ordinal);
foreach (var purl in purlList)
{
patchResults.TryGetValue(purl, out var patches);
backportResults.TryGetValue(purl, out var backport);
if ((patches is null || patches.Count == 0) && backport is null)
{
continue;
}
var pedigree = BuildPedigreeData(purl, patches, backport);
if (pedigree is not null && pedigree.HasData)
{
results[purl] = pedigree;
}
}
return results;
}
private PedigreeData? BuildPedigreeData(
string purl,
IReadOnlyList<FeedserPatchSignature>? patchSignatures,
FeedserBackportProof? backportProof)
{
var ancestorBuilder = new AncestorComponentBuilder();
var variantBuilder = new VariantComponentBuilder();
var commitBuilder = new CommitInfoBuilder();
var patchBuilder = new PatchInfoBuilder();
// Build ancestors from backport proof
if (backportProof?.UpstreamPackage is not null)
{
ancestorBuilder.AddAncestor(
backportProof.UpstreamPackage.Name,
backportProof.UpstreamPackage.Version,
backportProof.UpstreamPackage.Purl,
backportProof.UpstreamPackage.ProjectUrl);
}
// Build variants from backport proof
if (backportProof?.Variants is not null)
{
foreach (var variant in backportProof.Variants)
{
variantBuilder.AddVariant(
variant.Name,
variant.Version,
variant.Purl,
variant.Distribution,
variant.Release);
}
}
// Build commits and patches from patch signatures
if (patchSignatures is not null)
{
foreach (var sig in patchSignatures)
{
// Add commit info
if (!string.IsNullOrEmpty(sig.CommitSha))
{
var commitUrl = BuildCommitUrl(sig.UpstreamRepo, sig.CommitSha);
var cves = sig.CveId is not null ? new[] { sig.CveId } : null;
commitBuilder.AddCommit(
sig.CommitSha,
commitUrl,
message: null,
authorName: null,
resolvesCves: cves);
}
// Add patch info
var diffText = BuildDiffText(sig.Hunks);
patchBuilder.AddFromFeedserOrigin(
sig.PatchOrigin ?? "distro",
diffUrl: sig.PatchUrl,
diffText: diffText,
resolvesCves: sig.CveId is not null ? new[] { sig.CveId } : null,
affectedFunctions: sig.AffectedFunctions,
source: sig.Source);
}
}
var ancestors = ancestorBuilder.Build();
var variants = variantBuilder.Build();
var commits = commitBuilder.Build();
var patches = patchBuilder.Build();
if (ancestors.IsEmpty && variants.IsEmpty && commits.IsEmpty && patches.IsEmpty)
{
return null;
}
var data = new PedigreeData
{
Ancestors = ancestors,
Variants = variants,
Commits = commits,
Patches = patches
};
// Generate notes
var notes = _notesGenerator.GenerateNotes(
data,
backportProof?.ConfidencePercent,
backportProof?.FeedserTier);
return data with { Notes = notes };
}
private static string? BuildCommitUrl(string? upstreamRepo, string commitSha)
{
if (string.IsNullOrEmpty(upstreamRepo))
{
return null;
}
// Handle GitHub URLs
if (upstreamRepo.Contains("github.com", StringComparison.OrdinalIgnoreCase))
{
var cleanRepo = upstreamRepo
.Replace("https://", "", StringComparison.OrdinalIgnoreCase)
.Replace("http://", "", StringComparison.OrdinalIgnoreCase)
.Replace("github.com/", "", StringComparison.OrdinalIgnoreCase)
.TrimEnd('/');
if (cleanRepo.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
{
cleanRepo = cleanRepo[..^4];
}
return $"https://github.com/{cleanRepo}/commit/{commitSha}";
}
// Handle GitLab URLs
if (upstreamRepo.Contains("gitlab", StringComparison.OrdinalIgnoreCase))
{
var uri = new Uri(upstreamRepo);
var path = uri.AbsolutePath.TrimStart('/').TrimEnd('/');
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
{
path = path[..^4];
}
return $"https://{uri.Host}/{path}/-/commit/{commitSha}";
}
return null;
}
private static string? BuildDiffText(IReadOnlyList<FeedserPatchHunk>? hunks)
{
if (hunks is null || hunks.Count == 0)
{
return null;
}
var lines = new List<string>();
foreach (var hunk in hunks)
{
lines.Add($"--- a/{hunk.FilePath}");
lines.Add($"+++ b/{hunk.FilePath}");
lines.Add($"@@ -{hunk.StartLine},... +{hunk.StartLine},... @@");
foreach (var removed in hunk.RemovedLines)
{
lines.Add($"-{removed}");
}
foreach (var added in hunk.AddedLines)
{
lines.Add($"+{added}");
}
}
return string.Join("\n", lines);
}
}
/// <summary>
/// Client interface for Feedser patch signature queries.
/// </summary>
public interface IFeedserPatchSignatureClient
{
/// <summary>
/// Gets patch signatures for a component.
/// </summary>
Task<IReadOnlyList<FeedserPatchSignature>?> GetPatchSignaturesAsync(
string purl,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets patch signatures for multiple components.
/// </summary>
Task<IReadOnlyDictionary<string, IReadOnlyList<FeedserPatchSignature>>> GetPatchSignaturesBatchAsync(
IEnumerable<string> purls,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Client interface for Feedser backport proof queries.
/// </summary>
public interface IFeedserBackportProofClient
{
/// <summary>
/// Gets backport proof for a component.
/// </summary>
Task<FeedserBackportProof?> GetBackportProofAsync(
string purl,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets backport proofs for multiple components.
/// </summary>
Task<IReadOnlyDictionary<string, FeedserBackportProof>> GetBackportProofsBatchAsync(
IEnumerable<string> purls,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Patch signature from Feedser.
/// </summary>
public sealed record FeedserPatchSignature
{
public required string PatchSigId { get; init; }
public string? CveId { get; init; }
public string? UpstreamRepo { get; init; }
public required string CommitSha { get; init; }
public IReadOnlyList<FeedserPatchHunk>? Hunks { get; init; }
public string? PatchOrigin { get; init; }
public string? PatchUrl { get; init; }
public IReadOnlyList<string>? AffectedFunctions { get; init; }
public string? Source { get; init; }
}
/// <summary>
/// Patch hunk from Feedser.
/// </summary>
public sealed record FeedserPatchHunk
{
public required string FilePath { get; init; }
public int StartLine { get; init; }
public IReadOnlyList<string> AddedLines { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> RemovedLines { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Backport proof from Feedser.
/// </summary>
public sealed record FeedserBackportProof
{
public FeedserPackageReference? UpstreamPackage { get; init; }
public IReadOnlyList<FeedserVariantPackage>? Variants { get; init; }
public int? ConfidencePercent { get; init; }
public int? FeedserTier { get; init; }
}
/// <summary>
/// Reference to an upstream package.
/// </summary>
public sealed record FeedserPackageReference
{
public required string Name { get; init; }
public required string Version { get; init; }
public string? Purl { get; init; }
public string? ProjectUrl { get; init; }
}
/// <summary>
/// Variant package from a distribution.
/// </summary>
public sealed record FeedserVariantPackage
{
public required string Name { get; init; }
public required string Version { get; init; }
public required string Purl { get; init; }
public string? Distribution { get; init; }
public string? Release { get; init; }
}

View File

@@ -0,0 +1,279 @@
// <copyright file="IPedigreeDataProvider.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Provider interface for retrieving component pedigree data from Feedser.
/// Sprint: SPRINT_20260107_005_002 Task PD-001
/// </summary>
public interface IPedigreeDataProvider
{
/// <summary>
/// Retrieves pedigree data for a component identified by its PURL.
/// </summary>
/// <param name="purl">Package URL identifying the component.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Pedigree data if available, null otherwise.</returns>
Task<PedigreeData?> GetPedigreeAsync(
string purl,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves pedigree data for multiple components.
/// </summary>
/// <param name="purls">Package URLs identifying the components.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary of PURL to pedigree data (missing components not included).</returns>
Task<IReadOnlyDictionary<string, PedigreeData>> GetPedigreesBatchAsync(
IEnumerable<string> purls,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Aggregate of pedigree information for a component.
/// </summary>
public sealed record PedigreeData
{
/// <summary>
/// Gets the ancestor components (upstream sources).
/// </summary>
public ImmutableArray<AncestorComponent> Ancestors { get; init; } = ImmutableArray<AncestorComponent>.Empty;
/// <summary>
/// Gets the variant components (distro-specific packages derived from same source).
/// </summary>
public ImmutableArray<VariantComponent> Variants { get; init; } = ImmutableArray<VariantComponent>.Empty;
/// <summary>
/// Gets the relevant commits (security fixes, backports).
/// </summary>
public ImmutableArray<CommitInfo> Commits { get; init; } = ImmutableArray<CommitInfo>.Empty;
/// <summary>
/// Gets the patches applied to the component.
/// </summary>
public ImmutableArray<PatchInfo> Patches { get; init; } = ImmutableArray<PatchInfo>.Empty;
/// <summary>
/// Gets optional notes about the pedigree (e.g., backport explanation).
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// Gets whether any pedigree data is present.
/// </summary>
public bool HasData =>
!Ancestors.IsDefaultOrEmpty ||
!Variants.IsDefaultOrEmpty ||
!Commits.IsDefaultOrEmpty ||
!Patches.IsDefaultOrEmpty ||
Notes is not null;
}
/// <summary>
/// Represents an upstream ancestor component.
/// </summary>
public sealed record AncestorComponent
{
/// <summary>
/// Gets the component type (e.g., "library", "application").
/// </summary>
public string Type { get; init; } = "library";
/// <summary>
/// Gets the component name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets the upstream version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Gets the Package URL for the ancestor.
/// </summary>
public string? Purl { get; init; }
/// <summary>
/// Gets the URL to the upstream project.
/// </summary>
public string? ProjectUrl { get; init; }
/// <summary>
/// Gets the relationship level (1 = direct parent, 2 = grandparent, etc.).
/// </summary>
public int Level { get; init; } = 1;
}
/// <summary>
/// Represents a variant component (distro-specific package).
/// </summary>
public sealed record VariantComponent
{
/// <summary>
/// Gets the component type.
/// </summary>
public string Type { get; init; } = "library";
/// <summary>
/// Gets the package name in the distribution.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets the distribution-specific version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Gets the Package URL for the variant.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Gets the distribution name (e.g., "debian", "rhel", "alpine").
/// </summary>
public string? Distribution { get; init; }
/// <summary>
/// Gets the distribution release (e.g., "bookworm", "9.3").
/// </summary>
public string? Release { get; init; }
}
/// <summary>
/// Represents commit information for a security fix or backport.
/// </summary>
public sealed record CommitInfo
{
/// <summary>
/// Gets the commit SHA (full or abbreviated).
/// </summary>
public required string Uid { get; init; }
/// <summary>
/// Gets the URL to view the commit.
/// </summary>
public string? Url { get; init; }
/// <summary>
/// Gets the commit message (may be truncated).
/// </summary>
public string? Message { get; init; }
/// <summary>
/// Gets the author information.
/// </summary>
public CommitActor? Author { get; init; }
/// <summary>
/// Gets the committer information.
/// </summary>
public CommitActor? Committer { get; init; }
/// <summary>
/// Gets the CVE IDs resolved by this commit, if known.
/// </summary>
public ImmutableArray<string> ResolvesCves { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Represents an actor (author or committer) in a commit.
/// </summary>
public sealed record CommitActor
{
/// <summary>
/// Gets the actor's name.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets the actor's email.
/// </summary>
public string? Email { get; init; }
/// <summary>
/// Gets the timestamp of the action.
/// </summary>
public DateTimeOffset? Timestamp { get; init; }
}
/// <summary>
/// Represents a patch applied to the component.
/// </summary>
public sealed record PatchInfo
{
/// <summary>
/// Gets the patch type.
/// </summary>
public PatchType Type { get; init; } = PatchType.Backport;
/// <summary>
/// Gets the URL to the patch file.
/// </summary>
public string? DiffUrl { get; init; }
/// <summary>
/// Gets the patch diff content (optional, may be truncated).
/// </summary>
public string? DiffText { get; init; }
/// <summary>
/// Gets the CVE IDs resolved by this patch.
/// </summary>
public ImmutableArray<PatchResolution> Resolves { get; init; } = ImmutableArray<PatchResolution>.Empty;
/// <summary>
/// Gets the functions affected by this patch.
/// </summary>
public ImmutableArray<string> AffectedFunctions { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets the source of the patch (e.g., "debian-security").
/// </summary>
public string? Source { get; init; }
}
/// <summary>
/// Patch type enumeration per CycloneDX 1.7 specification.
/// </summary>
public enum PatchType
{
/// <summary>Informal patch not associated with upstream.</summary>
Unofficial,
/// <summary>A patch that is a bugfix or security fix that does not change feature.</summary>
Monkey,
/// <summary>A patch that is a backport of a fix from a later version.</summary>
Backport,
/// <summary>A cherry-picked commit from upstream.</summary>
CherryPick
}
/// <summary>
/// Represents a vulnerability resolved by a patch.
/// </summary>
public sealed record PatchResolution
{
/// <summary>
/// Gets the vulnerability ID (e.g., "CVE-2024-1234").
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Gets the source of the vulnerability reference.
/// </summary>
public string? SourceName { get; init; }
/// <summary>
/// Gets the URL to the vulnerability reference.
/// </summary>
public string? SourceUrl { get; init; }
}

View File

@@ -0,0 +1,244 @@
// <copyright file="PatchInfoBuilder.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Builds patch info entries from Feedser hunk signatures.
/// Sprint: SPRINT_20260107_005_002 Task PD-007
/// </summary>
public sealed class PatchInfoBuilder
{
private readonly List<PatchInfo> _patches = new();
/// <summary>
/// Adds a backport patch.
/// </summary>
/// <param name="diffUrl">URL to the patch file.</param>
/// <param name="diffText">Patch diff content.</param>
/// <param name="resolvesCves">CVE IDs resolved by this patch.</param>
/// <param name="source">Source of the patch (e.g., "debian-security").</param>
/// <returns>This builder for fluent chaining.</returns>
public PatchInfoBuilder AddBackport(
string? diffUrl = null,
string? diffText = null,
IEnumerable<string>? resolvesCves = null,
string? source = null)
{
return AddPatch(PatchType.Backport, diffUrl, diffText, resolvesCves, source: source);
}
/// <summary>
/// Adds a cherry-pick patch (from upstream).
/// </summary>
/// <param name="diffUrl">URL to the patch file.</param>
/// <param name="diffText">Patch diff content.</param>
/// <param name="resolvesCves">CVE IDs resolved by this patch.</param>
/// <param name="source">Source of the patch.</param>
/// <returns>This builder for fluent chaining.</returns>
public PatchInfoBuilder AddCherryPick(
string? diffUrl = null,
string? diffText = null,
IEnumerable<string>? resolvesCves = null,
string? source = null)
{
return AddPatch(PatchType.CherryPick, diffUrl, diffText, resolvesCves, source: source);
}
/// <summary>
/// Adds an unofficial patch (vendor/custom).
/// </summary>
/// <param name="diffUrl">URL to the patch file.</param>
/// <param name="diffText">Patch diff content.</param>
/// <param name="resolvesCves">CVE IDs resolved by this patch.</param>
/// <param name="source">Source of the patch.</param>
/// <returns>This builder for fluent chaining.</returns>
public PatchInfoBuilder AddUnofficialPatch(
string? diffUrl = null,
string? diffText = null,
IEnumerable<string>? resolvesCves = null,
string? source = null)
{
return AddPatch(PatchType.Unofficial, diffUrl, diffText, resolvesCves, source: source);
}
/// <summary>
/// Adds a patch with full configuration.
/// </summary>
/// <param name="type">Patch type.</param>
/// <param name="diffUrl">URL to the patch file.</param>
/// <param name="diffText">Patch diff content.</param>
/// <param name="resolvesCves">CVE IDs resolved by this patch.</param>
/// <param name="affectedFunctions">Functions affected by this patch.</param>
/// <param name="source">Source of the patch.</param>
/// <returns>This builder for fluent chaining.</returns>
public PatchInfoBuilder AddPatch(
PatchType type,
string? diffUrl = null,
string? diffText = null,
IEnumerable<string>? resolvesCves = null,
IEnumerable<string>? affectedFunctions = null,
string? source = null)
{
var resolutions = resolvesCves?
.Where(cve => !string.IsNullOrEmpty(cve))
.Select(cve => new PatchResolution
{
Id = cve.ToUpperInvariant(),
SourceName = DetermineSourceName(cve)
})
.ToImmutableArray() ?? ImmutableArray<PatchResolution>.Empty;
_patches.Add(new PatchInfo
{
Type = type,
DiffUrl = diffUrl,
DiffText = NormalizeDiffText(diffText),
Resolves = resolutions,
AffectedFunctions = affectedFunctions?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
Source = source
});
return this;
}
/// <summary>
/// Adds a patch from Feedser patch origin.
/// </summary>
/// <param name="feedserOrigin">Origin type from Feedser (upstream, distro, vendor).</param>
/// <param name="diffUrl">URL to the patch.</param>
/// <param name="diffText">Diff content.</param>
/// <param name="resolvesCves">CVEs resolved.</param>
/// <param name="affectedFunctions">Affected function names.</param>
/// <param name="source">Patch source identifier.</param>
/// <returns>This builder for fluent chaining.</returns>
public PatchInfoBuilder AddFromFeedserOrigin(
string feedserOrigin,
string? diffUrl = null,
string? diffText = null,
IEnumerable<string>? resolvesCves = null,
IEnumerable<string>? affectedFunctions = null,
string? source = null)
{
var type = MapFeedserOriginToType(feedserOrigin);
return AddPatch(type, diffUrl, diffText, resolvesCves, affectedFunctions, source);
}
/// <summary>
/// Adds a patch with resolution references including source URLs.
/// </summary>
/// <param name="type">Patch type.</param>
/// <param name="resolutions">Full resolution references.</param>
/// <param name="diffUrl">URL to the patch.</param>
/// <param name="diffText">Diff content.</param>
/// <param name="source">Patch source.</param>
/// <returns>This builder for fluent chaining.</returns>
public PatchInfoBuilder AddPatchWithResolutions(
PatchType type,
IEnumerable<PatchResolution> resolutions,
string? diffUrl = null,
string? diffText = null,
string? source = null)
{
_patches.Add(new PatchInfo
{
Type = type,
DiffUrl = diffUrl,
DiffText = NormalizeDiffText(diffText),
Resolves = resolutions.ToImmutableArray(),
Source = source
});
return this;
}
/// <summary>
/// Builds the immutable array of patches.
/// </summary>
/// <returns>Immutable array of patch info.</returns>
public ImmutableArray<PatchInfo> Build()
{
return _patches
.OrderBy(p => p.Type)
.ThenBy(p => p.Source, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.DiffUrl, StringComparer.Ordinal)
.ToImmutableArray();
}
/// <summary>
/// Clears the builder for reuse.
/// </summary>
/// <returns>This builder for fluent chaining.</returns>
public PatchInfoBuilder Clear()
{
_patches.Clear();
return this;
}
private static PatchType MapFeedserOriginToType(string origin) =>
origin.ToLowerInvariant() switch
{
"upstream" => PatchType.CherryPick,
"distro" => PatchType.Backport,
"vendor" => PatchType.Unofficial,
"backport" => PatchType.Backport,
"cherrypick" or "cherry-pick" => PatchType.CherryPick,
_ => PatchType.Unofficial
};
private static string DetermineSourceName(string cveId)
{
var upper = cveId.ToUpperInvariant();
if (upper.StartsWith("CVE-", StringComparison.Ordinal))
{
return "NVD";
}
if (upper.StartsWith("GHSA-", StringComparison.Ordinal))
{
return "GitHub";
}
if (upper.StartsWith("OSV-", StringComparison.Ordinal))
{
return "OSV";
}
return "Unknown";
}
private static string? NormalizeDiffText(string? diffText, int maxLength = 50000)
{
if (string.IsNullOrEmpty(diffText))
{
return null;
}
// Normalize line endings
var normalized = diffText
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace("\r", "\n", StringComparison.Ordinal);
// Truncate if too long
if (normalized.Length <= maxLength)
{
return normalized;
}
// Find a good truncation point (end of a hunk)
var truncateAt = normalized.LastIndexOf("\n@@", maxLength - 100, StringComparison.Ordinal);
if (truncateAt < maxLength / 2)
{
truncateAt = maxLength - 50;
}
var sb = new StringBuilder(truncateAt + 100);
sb.Append(normalized.AsSpan(0, truncateAt));
sb.AppendLine();
sb.AppendLine("... (truncated)");
return sb.ToString();
}
}

View File

@@ -0,0 +1,199 @@
// <copyright file="PedigreeNotesGenerator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Globalization;
using System.Text;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Generates human-readable notes for pedigree entries.
/// Sprint: SPRINT_20260107_005_002 Task PD-008
/// </summary>
public sealed class PedigreeNotesGenerator
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="PedigreeNotesGenerator"/> class.
/// </summary>
/// <param name="timeProvider">Time provider for timestamps.</param>
public PedigreeNotesGenerator(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
/// <summary>
/// Generates notes for the given pedigree data.
/// </summary>
/// <param name="data">Pedigree data to summarize.</param>
/// <param name="confidencePercent">Confidence level (0-100) for the pedigree analysis.</param>
/// <param name="feedserTier">Feedser evidence tier (1-4).</param>
/// <returns>Human-readable notes string.</returns>
public string GenerateNotes(
PedigreeData data,
int? confidencePercent = null,
int? feedserTier = null)
{
var sb = new StringBuilder();
// Backport summary
if (!data.Patches.IsDefaultOrEmpty)
{
var backportCount = data.Patches.Count(p => p.Type == PatchType.Backport);
var cherryPickCount = data.Patches.Count(p => p.Type == PatchType.CherryPick);
var totalCves = data.Patches
.SelectMany(p => p.Resolves)
.Select(r => r.Id)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
if (backportCount > 0 || cherryPickCount > 0)
{
sb.Append("Security patches: ");
var parts = new List<string>();
if (backportCount > 0)
{
parts.Add($"{backportCount} backport{(backportCount != 1 ? "s" : "")}");
}
if (cherryPickCount > 0)
{
parts.Add($"{cherryPickCount} cherry-pick{(cherryPickCount != 1 ? "s" : "")}");
}
sb.Append(string.Join(", ", parts));
if (totalCves > 0)
{
sb.Append($" resolving {totalCves} CVE{(totalCves != 1 ? "s" : "")}");
}
sb.AppendLine(".");
}
}
// Ancestor summary
if (!data.Ancestors.IsDefaultOrEmpty)
{
var ancestor = data.Ancestors.OrderBy(a => a.Level).First();
sb.AppendLine(
$"Derived from upstream {ancestor.Name} {ancestor.Version}.");
}
// Variant summary
if (!data.Variants.IsDefaultOrEmpty)
{
var distros = data.Variants
.Select(v => v.Distribution)
.Where(d => !string.IsNullOrEmpty(d))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(d => d, StringComparer.OrdinalIgnoreCase)
.ToList();
if (distros.Count > 0)
{
sb.AppendLine($"Variants exist for: {string.Join(", ", distros)}.");
}
}
// Confidence and tier
if (confidencePercent.HasValue || feedserTier.HasValue)
{
var evidenceParts = new List<string>();
if (confidencePercent.HasValue)
{
evidenceParts.Add(
$"confidence {confidencePercent.Value.ToString(CultureInfo.InvariantCulture)}%");
}
if (feedserTier.HasValue)
{
var tierDescription = feedserTier.Value switch
{
1 => "Tier 1 (exact match)",
2 => "Tier 2 (function match)",
3 => "Tier 3 (heuristic match)",
4 => "Tier 4 (advisory correlation)",
_ => $"Tier {feedserTier.Value}"
};
evidenceParts.Add(tierDescription);
}
sb.AppendLine($"Evidence: {string.Join(", ", evidenceParts)}.");
}
// Timestamp
var timestamp = _timeProvider.GetUtcNow();
sb.Append("Generated: ");
sb.Append(timestamp.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture));
sb.Append(" by StellaOps Feedser.");
return sb.ToString();
}
/// <summary>
/// Generates a concise summary line for pedigree data.
/// </summary>
/// <param name="data">Pedigree data to summarize.</param>
/// <returns>Single-line summary.</returns>
public string GenerateSummaryLine(PedigreeData data)
{
var parts = new List<string>();
if (!data.Patches.IsDefaultOrEmpty)
{
var backportCount = data.Patches.Count(p => p.Type == PatchType.Backport);
if (backportCount > 0)
{
parts.Add($"{backportCount} backport{(backportCount != 1 ? "s" : "")}");
}
}
if (!data.Ancestors.IsDefaultOrEmpty)
{
var ancestor = data.Ancestors.OrderBy(a => a.Level).First();
parts.Add($"from {ancestor.Name} {ancestor.Version}");
}
if (!data.Commits.IsDefaultOrEmpty)
{
parts.Add($"{data.Commits.Length} commit{(data.Commits.Length != 1 ? "s" : "")}");
}
return parts.Count > 0
? string.Join("; ", parts)
: "No pedigree data";
}
/// <summary>
/// Generates notes for a backport from upstream.
/// </summary>
/// <param name="upstreamVersion">Upstream version the fix came from.</param>
/// <param name="cveIds">CVE IDs resolved.</param>
/// <param name="confidencePercent">Confidence level.</param>
/// <returns>Notes string.</returns>
public string GenerateBackportNotes(
string upstreamVersion,
IEnumerable<string>? cveIds = null,
int? confidencePercent = null)
{
var sb = new StringBuilder();
sb.Append($"Backported security fix from upstream {upstreamVersion}");
var cveList = cveIds?.ToList();
if (cveList is { Count: > 0 })
{
sb.Append($" ({string.Join(", ", cveList)})");
}
sb.Append('.');
if (confidencePercent.HasValue)
{
sb.Append($" Confidence: {confidencePercent.Value.ToString(CultureInfo.InvariantCulture)}%.");
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,237 @@
// <copyright file="VariantComponentBuilder.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Builds variant component entries for distro-specific packages.
/// Sprint: SPRINT_20260107_005_002 Task PD-005
/// </summary>
public sealed class VariantComponentBuilder
{
private readonly List<VariantComponent> _variants = new();
/// <summary>
/// Adds a Debian package variant.
/// </summary>
/// <param name="packageName">Debian package name.</param>
/// <param name="version">Debian version (e.g., "1.1.1n-0+deb11u5").</param>
/// <param name="release">Debian release codename (e.g., "bullseye", "bookworm").</param>
/// <param name="arch">Architecture (e.g., "amd64").</param>
/// <returns>This builder for fluent chaining.</returns>
public VariantComponentBuilder AddDebianPackage(
string packageName,
string version,
string? release = null,
string? arch = null)
{
var purlParts = new List<string>
{
$"pkg:deb/debian/{Uri.EscapeDataString(packageName)}@{Uri.EscapeDataString(version)}"
};
var qualifiers = new List<string>();
if (!string.IsNullOrEmpty(release))
{
qualifiers.Add($"distro=debian-{Uri.EscapeDataString(release)}");
}
if (!string.IsNullOrEmpty(arch))
{
qualifiers.Add($"arch={Uri.EscapeDataString(arch)}");
}
var purl = qualifiers.Count > 0
? $"{purlParts[0]}?{string.Join("&", qualifiers)}"
: purlParts[0];
_variants.Add(new VariantComponent
{
Name = packageName,
Version = version,
Purl = purl,
Distribution = "debian",
Release = release
});
return this;
}
/// <summary>
/// Adds an Ubuntu package variant.
/// </summary>
/// <param name="packageName">Ubuntu package name.</param>
/// <param name="version">Ubuntu version.</param>
/// <param name="release">Ubuntu release codename (e.g., "jammy", "noble").</param>
/// <param name="arch">Architecture.</param>
/// <returns>This builder for fluent chaining.</returns>
public VariantComponentBuilder AddUbuntuPackage(
string packageName,
string version,
string? release = null,
string? arch = null)
{
var qualifiers = new List<string>();
if (!string.IsNullOrEmpty(release))
{
qualifiers.Add($"distro=ubuntu-{Uri.EscapeDataString(release)}");
}
if (!string.IsNullOrEmpty(arch))
{
qualifiers.Add($"arch={Uri.EscapeDataString(arch)}");
}
var basePurl = $"pkg:deb/ubuntu/{Uri.EscapeDataString(packageName)}@{Uri.EscapeDataString(version)}";
var purl = qualifiers.Count > 0
? $"{basePurl}?{string.Join("&", qualifiers)}"
: basePurl;
_variants.Add(new VariantComponent
{
Name = packageName,
Version = version,
Purl = purl,
Distribution = "ubuntu",
Release = release
});
return this;
}
/// <summary>
/// Adds an RPM package variant (RHEL/CentOS/Fedora).
/// </summary>
/// <param name="packageName">RPM package name.</param>
/// <param name="version">RPM version (EVR format).</param>
/// <param name="distro">Distribution (e.g., "rhel", "centos", "fedora").</param>
/// <param name="release">Release version (e.g., "9", "40").</param>
/// <param name="arch">Architecture (e.g., "x86_64").</param>
/// <returns>This builder for fluent chaining.</returns>
public VariantComponentBuilder AddRpmPackage(
string packageName,
string version,
string distro = "rhel",
string? release = null,
string? arch = null)
{
var qualifiers = new List<string>();
if (!string.IsNullOrEmpty(release))
{
qualifiers.Add($"distro={Uri.EscapeDataString(distro)}-{Uri.EscapeDataString(release)}");
}
if (!string.IsNullOrEmpty(arch))
{
qualifiers.Add($"arch={Uri.EscapeDataString(arch)}");
}
var basePurl = $"pkg:rpm/{Uri.EscapeDataString(distro)}/{Uri.EscapeDataString(packageName)}@{Uri.EscapeDataString(version)}";
var purl = qualifiers.Count > 0
? $"{basePurl}?{string.Join("&", qualifiers)}"
: basePurl;
_variants.Add(new VariantComponent
{
Name = packageName,
Version = version,
Purl = purl,
Distribution = distro,
Release = release
});
return this;
}
/// <summary>
/// Adds an Alpine package variant.
/// </summary>
/// <param name="packageName">Alpine package name.</param>
/// <param name="version">Alpine version.</param>
/// <param name="branch">Alpine branch (e.g., "3.19").</param>
/// <param name="arch">Architecture.</param>
/// <returns>This builder for fluent chaining.</returns>
public VariantComponentBuilder AddAlpinePackage(
string packageName,
string version,
string? branch = null,
string? arch = null)
{
var qualifiers = new List<string>();
if (!string.IsNullOrEmpty(branch))
{
qualifiers.Add($"distro=alpine-{Uri.EscapeDataString(branch)}");
}
if (!string.IsNullOrEmpty(arch))
{
qualifiers.Add($"arch={Uri.EscapeDataString(arch)}");
}
var basePurl = $"pkg:apk/alpine/{Uri.EscapeDataString(packageName)}@{Uri.EscapeDataString(version)}";
var purl = qualifiers.Count > 0
? $"{basePurl}?{string.Join("&", qualifiers)}"
: basePurl;
_variants.Add(new VariantComponent
{
Name = packageName,
Version = version,
Purl = purl,
Distribution = "alpine",
Release = branch
});
return this;
}
/// <summary>
/// Adds a generic variant component.
/// </summary>
/// <param name="name">Package name.</param>
/// <param name="version">Package version.</param>
/// <param name="purl">Full package URL.</param>
/// <param name="distribution">Distribution name.</param>
/// <param name="release">Release identifier.</param>
/// <returns>This builder for fluent chaining.</returns>
public VariantComponentBuilder AddVariant(
string name,
string version,
string purl,
string? distribution = null,
string? release = null)
{
_variants.Add(new VariantComponent
{
Name = name,
Version = version,
Purl = purl,
Distribution = distribution,
Release = release
});
return this;
}
/// <summary>
/// Builds the immutable array of variants.
/// </summary>
/// <returns>Immutable array of variant components.</returns>
public ImmutableArray<VariantComponent> Build()
{
return _variants
.OrderBy(v => v.Distribution, StringComparer.OrdinalIgnoreCase)
.ThenBy(v => v.Release, StringComparer.OrdinalIgnoreCase)
.ThenBy(v => v.Name, StringComparer.Ordinal)
.ToImmutableArray();
}
/// <summary>
/// Clears the builder for reuse.
/// </summary>
/// <returns>This builder for fluent chaining.</returns>
public VariantComponentBuilder Clear()
{
_variants.Clear();
return this;
}
}