// // Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later. // using System.Collections.Immutable; using CycloneDX.Models; using StellaOps.Scanner.Core.Contracts; namespace StellaOps.Scanner.Emit.Evidence; /// /// Maps StellaOps evidence data to CycloneDX 1.7 native evidence fields. /// Sprint: SPRINT_20260107_005_001 Task EV-001 /// /// /// This mapper replaces the legacy property-based evidence storage with /// native CycloneDX 1.7 evidence structures for spec compliance. /// public sealed class CycloneDxEvidenceMapper { private readonly IdentityEvidenceBuilder _identityBuilder; private readonly OccurrenceEvidenceBuilder _occurrenceBuilder; private readonly LicenseEvidenceBuilder _licenseBuilder; /// /// Initializes a new instance of the class. /// public CycloneDxEvidenceMapper() { _identityBuilder = new IdentityEvidenceBuilder(); _occurrenceBuilder = new OccurrenceEvidenceBuilder(); _licenseBuilder = new LicenseEvidenceBuilder(); } /// /// Maps component evidence to CycloneDX 1.7 Evidence structure. /// /// The aggregated component with evidence data. /// The mapped CycloneDX Evidence, or null if no evidence available. 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 ConvertToEvidenceOccurrences(ImmutableArray occurrences) { return occurrences.Select(o => new EvidenceOccurrence { Location = o.Location, }).ToList(); } private static List ConvertToLicenseChoices(ImmutableArray licenses) { return licenses.Select(l => l.License).ToList(); } private static List ConvertToEvidenceCopyrights(ImmutableArray copyrights) { return copyrights.Select(c => new EvidenceCopyright { Text = c.Text, }).ToList(); } /// /// Maps legacy property-based evidence to component evidence data. /// /// Legacy properties containing evidence data. /// Parsed evidence records. public static ImmutableArray ParseLegacyProperties( IReadOnlyList? properties) { if (properties is null || properties.Count == 0) { return ImmutableArray.Empty; } var results = ImmutableArray.CreateBuilder(); 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 BuildCopyrightEvidence(AggregatedComponent component) { if (component.Evidence.IsDefaultOrEmpty) { return ImmutableArray.Empty; } var copyrightEvidence = component.Evidence .Where(e => string.Equals(e.Kind, "copyright", StringComparison.OrdinalIgnoreCase)) .Select(e => new CopyrightEvidence { Text = e.Value }) .ToImmutableArray(); return copyrightEvidence; } } /// /// Represents a parsed component evidence record from legacy or native formats. /// Sprint: SPRINT_20260107_005_001 Task EV-001 /// public sealed record ComponentEvidenceRecord { /// /// Gets or sets the kind of evidence (e.g., "crypto", "license", "copyright"). /// public required string Kind { get; init; } /// /// Gets or sets the evidence value (e.g., algorithm name, license ID). /// public required string Value { get; init; } /// /// Gets or sets the source location of the evidence. /// public required string Source { get; init; } /// /// Gets or sets the confidence score (0.0-1.0). /// public double? Confidence { get; init; } /// /// Gets or sets the detection technique used. /// public string? Technique { get; init; } } /// /// StellaOps internal Copyright Evidence model. /// Sprint: SPRINT_20260107_005_001 Task EV-001 /// public sealed class CopyrightEvidence { /// /// Gets or sets the copyright text. /// public string? Text { get; set; } }