//
// 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; }
}