Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Evidence/CycloneDxEvidenceMapper.cs
2026-01-08 20:46:43 +02:00

218 lines
7.2 KiB
C#

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