more audit work

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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