- Created `StellaOps.AuditPack.Tests.csproj` for unit testing the AuditPack library. - Implemented comprehensive unit tests in `index.test.js` for AST parsing, covering various JavaScript and TypeScript constructs including functions, classes, decorators, and JSX. - Added `sink-detect.test.js` to test security sink detection patterns, validating command injection, SQL injection, file write, deserialization, SSRF, NoSQL injection, and more. - Included tests for taint source detection in various contexts such as Express, Koa, and AWS Lambda.
659 lines
22 KiB
C#
659 lines
22 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using CycloneDX;
|
|
using CycloneDX.Models;
|
|
using CycloneDX.Models.Vulnerabilities;
|
|
using JsonSerializer = CycloneDX.Json.Serializer;
|
|
using ProtoSerializer = CycloneDX.Protobuf.Serializer;
|
|
using StellaOps.Scanner.Core.Contracts;
|
|
using StellaOps.Scanner.Core.Utility;
|
|
|
|
namespace StellaOps.Scanner.Emit.Composition;
|
|
|
|
public sealed class CycloneDxComposer
|
|
{
|
|
private static readonly Guid SerialNamespace = new("0d3a422b-6e1b-4d9b-9c35-654b706c97e8");
|
|
|
|
private const string InventoryMediaTypeJson = CycloneDx17Extensions.MediaTypes.InventoryJson;
|
|
private const string UsageMediaTypeJson = CycloneDx17Extensions.MediaTypes.UsageJson;
|
|
private const string InventoryMediaTypeProtobuf = CycloneDx17Extensions.MediaTypes.InventoryProtobuf;
|
|
private const string UsageMediaTypeProtobuf = CycloneDx17Extensions.MediaTypes.UsageProtobuf;
|
|
|
|
public SbomCompositionResult Compose(SbomCompositionRequest request)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
if (request.LayerFragments.IsDefaultOrEmpty)
|
|
{
|
|
throw new ArgumentException("At least one layer fragment is required.", nameof(request));
|
|
}
|
|
|
|
var graph = ComponentGraphBuilder.Build(request.LayerFragments);
|
|
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
|
|
|
|
var inventoryArtifact = BuildArtifact(
|
|
request,
|
|
graph,
|
|
SbomView.Inventory,
|
|
graph.Components,
|
|
generatedAt,
|
|
InventoryMediaTypeJson,
|
|
InventoryMediaTypeProtobuf);
|
|
|
|
var usageComponents = graph.Components
|
|
.Where(static component => component.Usage.UsedByEntrypoint)
|
|
.ToImmutableArray();
|
|
|
|
CycloneDxArtifact? usageArtifact = null;
|
|
if (!usageComponents.IsEmpty)
|
|
{
|
|
usageArtifact = BuildArtifact(
|
|
request,
|
|
graph,
|
|
SbomView.Usage,
|
|
usageComponents,
|
|
generatedAt,
|
|
UsageMediaTypeJson,
|
|
UsageMediaTypeProtobuf);
|
|
}
|
|
|
|
var compositionRecipeJson = BuildCompositionRecipeJson(graph, generatedAt);
|
|
var compositionRecipeSha = ComputeSha256(compositionRecipeJson);
|
|
var compositionRecipeUri = $"cas://sbom/composition/{compositionRecipeSha}.json";
|
|
|
|
inventoryArtifact = inventoryArtifact with
|
|
{
|
|
MerkleRoot = compositionRecipeSha,
|
|
CompositionRecipeUri = compositionRecipeUri,
|
|
};
|
|
|
|
if (usageArtifact is not null)
|
|
{
|
|
usageArtifact = usageArtifact with
|
|
{
|
|
MerkleRoot = compositionRecipeSha,
|
|
CompositionRecipeUri = compositionRecipeUri,
|
|
};
|
|
}
|
|
|
|
return new SbomCompositionResult
|
|
{
|
|
Inventory = inventoryArtifact,
|
|
Usage = usageArtifact,
|
|
Graph = graph,
|
|
CompositionRecipeJson = compositionRecipeJson,
|
|
CompositionRecipeSha256 = compositionRecipeSha,
|
|
};
|
|
}
|
|
|
|
private CycloneDxArtifact BuildArtifact(
|
|
SbomCompositionRequest request,
|
|
ComponentGraph graph,
|
|
SbomView view,
|
|
ImmutableArray<AggregatedComponent> components,
|
|
DateTimeOffset generatedAt,
|
|
string jsonMediaType,
|
|
string protobufMediaType)
|
|
{
|
|
var bom = BuildBom(request, graph, view, components, generatedAt);
|
|
var json16 = JsonSerializer.Serialize(bom);
|
|
// Upgrade serialized JSON from 1.6 to 1.7 (CycloneDX.Core doesn't support v1_7 natively yet)
|
|
var json = CycloneDx17Extensions.UpgradeJsonTo17(json16);
|
|
var jsonBytes = Encoding.UTF8.GetBytes(json);
|
|
var protobufBytes = ProtoSerializer.Serialize(bom);
|
|
|
|
var jsonHash = ComputeSha256(jsonBytes);
|
|
var protobufHash = ComputeSha256(protobufBytes);
|
|
|
|
var merkleRoot = request.AdditionalProperties is not null
|
|
&& request.AdditionalProperties.TryGetValue("stellaops:merkle.root", out var root)
|
|
? root
|
|
: null;
|
|
|
|
string? compositionUri = null;
|
|
string? compositionRecipeUri = null;
|
|
request.AdditionalProperties?.TryGetValue("stellaops:composition.manifest", out compositionUri);
|
|
request.AdditionalProperties?.TryGetValue("stellaops:composition.recipe", out compositionRecipeUri);
|
|
|
|
return new CycloneDxArtifact
|
|
{
|
|
View = view,
|
|
SerialNumber = bom.SerialNumber ?? string.Empty,
|
|
GeneratedAt = generatedAt,
|
|
Components = components,
|
|
JsonBytes = jsonBytes,
|
|
JsonSha256 = jsonHash,
|
|
ContentHash = jsonHash,
|
|
MerkleRoot = merkleRoot,
|
|
CompositionUri = compositionUri,
|
|
CompositionRecipeUri = compositionRecipeUri,
|
|
JsonMediaType = jsonMediaType,
|
|
ProtobufBytes = protobufBytes,
|
|
ProtobufSha256 = protobufHash,
|
|
ProtobufMediaType = protobufMediaType,
|
|
};
|
|
}
|
|
|
|
private static byte[] BuildCompositionRecipeJson(ComponentGraph graph, DateTimeOffset generatedAt)
|
|
{
|
|
var recipe = new
|
|
{
|
|
schema = "stellaops.composition.recipe@1",
|
|
generatedAt = ScannerTimestamps.ToIso8601(generatedAt),
|
|
layers = graph.Layers.Select(layer => new
|
|
{
|
|
layer.LayerDigest,
|
|
components = layer.Components
|
|
.Select(component => component.Identity.Key)
|
|
.OrderBy(key => key, StringComparer.Ordinal)
|
|
.ToArray(),
|
|
}).OrderBy(entry => entry.LayerDigest, StringComparer.Ordinal).ToArray(),
|
|
};
|
|
|
|
var json = System.Text.Json.JsonSerializer.Serialize(recipe, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false,
|
|
});
|
|
|
|
return Encoding.UTF8.GetBytes(json);
|
|
}
|
|
|
|
private Bom BuildBom(
|
|
SbomCompositionRequest request,
|
|
ComponentGraph graph,
|
|
SbomView view,
|
|
ImmutableArray<AggregatedComponent> components,
|
|
DateTimeOffset generatedAt)
|
|
{
|
|
// Use v1_6 for serialization; output is upgraded to 1.7 via CycloneDx17Extensions
|
|
var bom = new Bom
|
|
{
|
|
SpecVersion = SpecificationVersion.v1_6,
|
|
Version = 1,
|
|
Metadata = BuildMetadata(request, view, generatedAt),
|
|
Components = BuildComponents(components),
|
|
Dependencies = BuildDependencies(components),
|
|
};
|
|
|
|
var vulnerabilities = BuildVulnerabilities(request, graph, components);
|
|
if (vulnerabilities is not null)
|
|
{
|
|
bom.Vulnerabilities = vulnerabilities;
|
|
}
|
|
|
|
var serialPayload = $"{request.Image.ImageDigest}|{view}|{ScannerTimestamps.ToIso8601(generatedAt)}";
|
|
bom.SerialNumber = $"urn:uuid:{ScannerIdentifiers.CreateDeterministicGuid(SerialNamespace, Encoding.UTF8.GetBytes(serialPayload)).ToString("d", CultureInfo.InvariantCulture)}";
|
|
|
|
return bom;
|
|
}
|
|
|
|
private static Metadata BuildMetadata(SbomCompositionRequest request, SbomView view, DateTimeOffset generatedAt)
|
|
{
|
|
var metadata = new Metadata
|
|
{
|
|
Timestamp = generatedAt.UtcDateTime,
|
|
Component = BuildMetadataComponent(request.Image),
|
|
};
|
|
|
|
if (request.AdditionalProperties is not null && request.AdditionalProperties.Count > 0)
|
|
{
|
|
metadata.Properties = request.AdditionalProperties
|
|
.Where(static pair => !string.IsNullOrWhiteSpace(pair.Key) && pair.Value is not null)
|
|
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
|
.Select(pair => new Property
|
|
{
|
|
Name = pair.Key,
|
|
Value = pair.Value,
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
if (metadata.Properties is null)
|
|
{
|
|
metadata.Properties = new List<Property>();
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.GeneratorName))
|
|
{
|
|
metadata.Properties.Add(new Property
|
|
{
|
|
Name = "stellaops:generator.name",
|
|
Value = request.GeneratorName,
|
|
});
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.GeneratorVersion))
|
|
{
|
|
metadata.Properties.Add(new Property
|
|
{
|
|
Name = "stellaops:generator.version",
|
|
Value = request.GeneratorVersion,
|
|
});
|
|
}
|
|
}
|
|
|
|
metadata.Properties.Add(new Property
|
|
{
|
|
Name = "stellaops:sbom.view",
|
|
Value = view.ToString().ToLowerInvariant(),
|
|
});
|
|
|
|
return metadata;
|
|
}
|
|
|
|
private static Component BuildMetadataComponent(ImageArtifactDescriptor image)
|
|
{
|
|
var digest = image.ImageDigest;
|
|
var digestValue = digest.Split(':', 2, StringSplitOptions.TrimEntries)[^1];
|
|
var bomRef = $"image:{digestValue}";
|
|
|
|
var name = image.ImageReference ?? image.Repository ?? digest;
|
|
var component = new Component
|
|
{
|
|
BomRef = bomRef,
|
|
Type = Component.Classification.Container,
|
|
Name = name,
|
|
Version = digestValue,
|
|
Purl = BuildImagePurl(image),
|
|
Properties = new List<Property>
|
|
{
|
|
new() { Name = "stellaops:image.digest", Value = image.ImageDigest },
|
|
},
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(image.ImageReference))
|
|
{
|
|
component.Properties.Add(new Property { Name = "stellaops:image.reference", Value = image.ImageReference });
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(image.Repository))
|
|
{
|
|
component.Properties.Add(new Property { Name = "stellaops:image.repository", Value = image.Repository });
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(image.Tag))
|
|
{
|
|
component.Properties.Add(new Property { Name = "stellaops:image.tag", Value = image.Tag });
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(image.Architecture))
|
|
{
|
|
component.Properties.Add(new Property { Name = "stellaops:image.architecture", Value = image.Architecture });
|
|
}
|
|
|
|
return component;
|
|
}
|
|
|
|
private static string? BuildImagePurl(ImageArtifactDescriptor image)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(image.Repository))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var repo = image.Repository.Trim();
|
|
var tag = string.IsNullOrWhiteSpace(image.Tag) ? null : image.Tag.Trim();
|
|
var digest = image.ImageDigest.Trim();
|
|
|
|
var purlBuilder = new StringBuilder("pkg:oci/");
|
|
purlBuilder.Append(repo.Replace("/", "%2F", StringComparison.Ordinal));
|
|
if (!string.IsNullOrWhiteSpace(tag))
|
|
{
|
|
purlBuilder.Append('@').Append(tag);
|
|
}
|
|
|
|
purlBuilder.Append("?digest=").Append(Uri.EscapeDataString(digest));
|
|
|
|
if (!string.IsNullOrWhiteSpace(image.Architecture))
|
|
{
|
|
purlBuilder.Append("&arch=").Append(Uri.EscapeDataString(image.Architecture.Trim()));
|
|
}
|
|
|
|
return purlBuilder.ToString();
|
|
}
|
|
|
|
private static List<Component> BuildComponents(ImmutableArray<AggregatedComponent> components)
|
|
{
|
|
var result = new List<Component>(components.Length);
|
|
foreach (var component in components)
|
|
{
|
|
var model = new Component
|
|
{
|
|
BomRef = component.Identity.Key,
|
|
Name = component.Identity.Name,
|
|
Version = component.Identity.Version,
|
|
Purl = component.Identity.Purl,
|
|
Group = component.Identity.Group,
|
|
Type = MapClassification(component.Identity.ComponentType),
|
|
Scope = MapScope(component.Metadata?.Scope),
|
|
Properties = BuildProperties(component),
|
|
};
|
|
|
|
result.Add(model);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static List<Property>? BuildProperties(AggregatedComponent component)
|
|
{
|
|
var properties = new List<Property>();
|
|
|
|
if (component.Metadata?.Properties is not null)
|
|
{
|
|
foreach (var property in component.Metadata.Properties.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
|
{
|
|
properties.Add(new Property
|
|
{
|
|
Name = property.Key,
|
|
Value = property.Value,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(component.Metadata?.BuildId))
|
|
{
|
|
properties.Add(new Property
|
|
{
|
|
Name = "stellaops:buildId",
|
|
Value = component.Metadata!.BuildId,
|
|
});
|
|
}
|
|
|
|
properties.Add(new Property { Name = "stellaops:firstLayerDigest", Value = component.FirstLayerDigest });
|
|
if (component.LastLayerDigest is not null)
|
|
{
|
|
properties.Add(new Property { Name = "stellaops:lastLayerDigest", Value = component.LastLayerDigest });
|
|
}
|
|
|
|
if (!component.LayerDigests.IsDefaultOrEmpty)
|
|
{
|
|
properties.Add(new Property
|
|
{
|
|
Name = "stellaops:layerDigests",
|
|
Value = string.Join(",", component.LayerDigests),
|
|
});
|
|
}
|
|
|
|
if (component.Usage.UsedByEntrypoint)
|
|
{
|
|
properties.Add(new Property { Name = "stellaops:usage.usedByEntrypoint", Value = "true" });
|
|
}
|
|
|
|
if (!component.Usage.Entrypoints.IsDefaultOrEmpty && component.Usage.Entrypoints.Length > 0)
|
|
{
|
|
for (var index = 0; index < component.Usage.Entrypoints.Length; index++)
|
|
{
|
|
properties.Add(new Property
|
|
{
|
|
Name = $"stellaops:usage.entrypoint[{index}]",
|
|
Value = component.Usage.Entrypoints[index],
|
|
});
|
|
}
|
|
}
|
|
|
|
for (var index = 0; index < component.Evidence.Length; index++)
|
|
{
|
|
var evidence = component.Evidence[index];
|
|
var builder = new StringBuilder(evidence.Kind);
|
|
builder.Append(':').Append(evidence.Value);
|
|
if (!string.IsNullOrWhiteSpace(evidence.Source))
|
|
{
|
|
builder.Append('@').Append(evidence.Source);
|
|
}
|
|
|
|
properties.Add(new Property
|
|
{
|
|
Name = $"stellaops:evidence[{index}]",
|
|
Value = builder.ToString(),
|
|
});
|
|
}
|
|
|
|
return properties;
|
|
}
|
|
|
|
private static List<Dependency>? BuildDependencies(ImmutableArray<AggregatedComponent> components)
|
|
{
|
|
var componentKeys = components.Select(static component => component.Identity.Key).ToImmutableHashSet(StringComparer.Ordinal);
|
|
var dependencies = new List<Dependency>();
|
|
|
|
foreach (var component in components)
|
|
{
|
|
if (component.Dependencies.IsDefaultOrEmpty || component.Dependencies.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var filtered = component.Dependencies.Where(componentKeys.Contains).ToArray();
|
|
if (filtered.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
dependencies.Add(new Dependency
|
|
{
|
|
Ref = component.Identity.Key,
|
|
Dependencies = filtered
|
|
.Select(dependencyKey => new Dependency { Ref = dependencyKey })
|
|
.ToList(),
|
|
});
|
|
}
|
|
|
|
return dependencies.Count == 0 ? null : dependencies;
|
|
}
|
|
|
|
private static List<Vulnerability>? BuildVulnerabilities(
|
|
SbomCompositionRequest request,
|
|
ComponentGraph graph,
|
|
ImmutableArray<AggregatedComponent> viewComponents)
|
|
{
|
|
if (request.PolicyFindings.IsDefaultOrEmpty || request.PolicyFindings.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (viewComponents.IsDefaultOrEmpty || viewComponents.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var componentKeys = viewComponents
|
|
.Select(static component => component.Identity.Key)
|
|
.ToImmutableHashSet(StringComparer.Ordinal);
|
|
|
|
if (componentKeys.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var vulnerabilities = new List<Vulnerability>(request.PolicyFindings.Length);
|
|
foreach (var finding in request.PolicyFindings)
|
|
{
|
|
if (!graph.ComponentMap.TryGetValue(finding.ComponentKey, out var component))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!componentKeys.Contains(component.Identity.Key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var ratings = BuildRatings(finding.Score);
|
|
var properties = BuildVulnerabilityProperties(finding);
|
|
|
|
var vulnerability = new Vulnerability
|
|
{
|
|
BomRef = finding.FindingId,
|
|
Id = finding.VulnerabilityId ?? finding.FindingId,
|
|
Source = new Source { Name = "StellaOps.Policy" },
|
|
Affects = new List<Affects>
|
|
{
|
|
new() { Ref = component.Identity.Key }
|
|
},
|
|
Ratings = ratings,
|
|
Properties = properties,
|
|
};
|
|
|
|
vulnerabilities.Add(vulnerability);
|
|
}
|
|
|
|
return vulnerabilities.Count == 0 ? null : vulnerabilities;
|
|
}
|
|
|
|
private static List<Rating>? BuildRatings(double score)
|
|
{
|
|
if (double.IsNaN(score) || double.IsInfinity(score))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new List<Rating>
|
|
{
|
|
new()
|
|
{
|
|
Method = ScoreMethod.Other,
|
|
Justification = "StellaOps Policy score",
|
|
Score = score,
|
|
Severity = Severity.Unknown,
|
|
Source = new Source { Name = "StellaOps.Policy" },
|
|
}
|
|
};
|
|
}
|
|
|
|
private static List<Property>? BuildVulnerabilityProperties(SbomPolicyFinding finding)
|
|
{
|
|
var properties = new List<Property>();
|
|
|
|
AddStringProperty(properties, "stellaops:policy.status", finding.Status);
|
|
AddStringProperty(properties, "stellaops:policy.configVersion", finding.ConfigVersion);
|
|
AddBooleanProperty(properties, "stellaops:policy.quiet", finding.Quiet);
|
|
AddStringProperty(properties, "stellaops:policy.quietedBy", finding.QuietedBy);
|
|
AddStringProperty(properties, "stellaops:policy.confidenceBand", finding.ConfidenceBand);
|
|
AddStringProperty(properties, "stellaops:policy.sourceTrust", finding.SourceTrust);
|
|
AddStringProperty(properties, "stellaops:policy.reachability", finding.Reachability);
|
|
AddDoubleProperty(properties, "stellaops:policy.score", finding.Score);
|
|
AddNullableDoubleProperty(properties, "stellaops:policy.unknownConfidence", finding.UnknownConfidence);
|
|
AddNullableDoubleProperty(properties, "stellaops:policy.unknownAgeDays", finding.UnknownAgeDays);
|
|
|
|
if (!finding.Inputs.IsDefaultOrEmpty && finding.Inputs.Length > 0)
|
|
{
|
|
foreach (var (key, value) in finding.Inputs)
|
|
{
|
|
AddDoubleProperty(properties, $"stellaops:policy.input.{key}", value);
|
|
}
|
|
}
|
|
|
|
if (properties.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
properties.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Name, right.Name));
|
|
return properties;
|
|
}
|
|
|
|
private static void AddStringProperty(ICollection<Property> properties, string name, string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return;
|
|
}
|
|
|
|
properties.Add(new Property
|
|
{
|
|
Name = name,
|
|
Value = value.Trim(),
|
|
});
|
|
}
|
|
|
|
private static void AddBooleanProperty(ICollection<Property> properties, string name, bool value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
return;
|
|
}
|
|
|
|
properties.Add(new Property
|
|
{
|
|
Name = name,
|
|
Value = value ? "true" : "false",
|
|
});
|
|
}
|
|
|
|
private static void AddDoubleProperty(ICollection<Property> properties, string name, double value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name) || double.IsNaN(value) || double.IsInfinity(value))
|
|
{
|
|
return;
|
|
}
|
|
|
|
properties.Add(new Property
|
|
{
|
|
Name = name,
|
|
Value = FormatDouble(value),
|
|
});
|
|
}
|
|
|
|
private static void AddNullableDoubleProperty(ICollection<Property> properties, string name, double? value)
|
|
{
|
|
if (!value.HasValue)
|
|
{
|
|
return;
|
|
}
|
|
|
|
AddDoubleProperty(properties, name, value.Value);
|
|
}
|
|
|
|
private static Component.Classification MapClassification(string? type)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(type))
|
|
{
|
|
return Component.Classification.Library;
|
|
}
|
|
|
|
return type.Trim().ToLowerInvariant() switch
|
|
{
|
|
"application" => Component.Classification.Application,
|
|
"framework" => Component.Classification.Framework,
|
|
"container" => Component.Classification.Container,
|
|
"operating-system" or "os" => Component.Classification.Operating_System,
|
|
"device" => Component.Classification.Device,
|
|
"firmware" => Component.Classification.Firmware,
|
|
"file" => Component.Classification.File,
|
|
_ => Component.Classification.Library,
|
|
};
|
|
}
|
|
|
|
private static Component.ComponentScope? MapScope(string? scope)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(scope))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return scope.Trim().ToLowerInvariant() switch
|
|
{
|
|
"runtime" or "required" => Component.ComponentScope.Required,
|
|
"development" or "optional" => Component.ComponentScope.Optional,
|
|
"excluded" => Component.ComponentScope.Excluded,
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
private static string FormatDouble(double value)
|
|
=> value.ToString("0.############################", CultureInfo.InvariantCulture);
|
|
|
|
private static string ComputeSha256(byte[] bytes)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var hash = sha256.ComputeHash(bytes);
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
}
|