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