// // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // using System.Collections.Immutable; using System.Globalization; using CycloneDX.Models; using StellaOps.Scanner.Core.Contracts; namespace StellaOps.Scanner.Emit.Evidence; /// /// Writes evidence data to legacy CycloneDX property format for backward compatibility. /// Sprint: SPRINT_20260107_005_001 Task EV-009 /// /// /// Migration Support: /// /// 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. /// /// Legacy Property Format: /// /// stellaops:evidence[n]:kind - Evidence kind (identity, occurrence, license, callstack) /// stellaops:evidence[n]:source - Evidence source/analyzer /// stellaops:evidence[n]:value - Evidence value /// stellaops:evidence[n]:confidence - Confidence score (0.0-1.0) /// stellaops:evidence[n]:methods - Reference to evidence.methods[] (CycloneDX 1.7) /// /// public sealed class LegacyEvidencePropertyWriter { private const string PropertyPrefix = "stellaops:evidence"; /// /// Writes component evidence to legacy property format. /// /// The CycloneDX component to add properties to. /// The evidence collection from Scanner core. /// Options controlling evidence output. public void WriteEvidenceProperties( Component component, ImmutableArray 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++; } } /// /// Removes legacy evidence properties from a component. /// /// The CycloneDX component to clean. 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 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, }; } } /// /// Options for legacy evidence property output. /// public sealed class LegacyEvidenceOptions { /// /// Gets or sets a value indicating whether to include references to CycloneDX 1.7 evidence.methods[]. /// Default is true. /// public bool IncludeMethodsReference { get; set; } = true; /// /// 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. /// public bool EnableLegacyProperties { get; set; } = true; }