more audit work
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user