641 lines
23 KiB
C#
641 lines
23 KiB
C#
using System.Diagnostics;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using StellaOps.Determinism;
|
|
using StellaOps.VexLens.Models;
|
|
|
|
namespace StellaOps.VexLens.Normalization;
|
|
|
|
/// <summary>
|
|
/// Normalizer for CycloneDX VEX format documents.
|
|
/// CycloneDX VEX uses the vulnerabilities array in CycloneDX BOM format.
|
|
/// </summary>
|
|
public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
|
{
|
|
private readonly IGuidProvider _guidProvider;
|
|
|
|
public CycloneDxVexNormalizer(IGuidProvider? guidProvider = null)
|
|
{
|
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
|
}
|
|
|
|
public VexSourceFormat SourceFormat => VexSourceFormat.CycloneDxVex;
|
|
|
|
public bool CanNormalize(string content)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(content))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(content);
|
|
var root = doc.RootElement;
|
|
|
|
// CycloneDX documents have bomFormat = "CycloneDX" and must have vulnerabilities
|
|
if (root.TryGetProperty("bomFormat", out var bomFormat))
|
|
{
|
|
var formatStr = bomFormat.GetString();
|
|
if (formatStr?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
// Must have vulnerabilities array to be a VEX document
|
|
return root.TryGetProperty("vulnerabilities", out var vulns) &&
|
|
vulns.ValueKind == JsonValueKind.Array &&
|
|
vulns.GetArrayLength() > 0;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public Task<NormalizationResult> NormalizeAsync(
|
|
string content,
|
|
NormalizationContext context,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var warnings = new List<NormalizationWarning>();
|
|
var statementsSkipped = 0;
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(content);
|
|
var root = doc.RootElement;
|
|
|
|
// Extract document ID from serialNumber or metadata
|
|
var documentId = ExtractDocumentId(root);
|
|
if (string.IsNullOrWhiteSpace(documentId))
|
|
{
|
|
documentId = $"cyclonedx:{_guidProvider.NewGuid():N}";
|
|
warnings.Add(new NormalizationWarning(
|
|
"WARN_CDX_001",
|
|
"Serial number not found; generated a random ID",
|
|
"serialNumber"));
|
|
}
|
|
|
|
// Extract issuer from metadata
|
|
var issuer = ExtractIssuer(root, warnings);
|
|
|
|
// Extract timestamps
|
|
var (issuedAt, lastUpdatedAt) = ExtractTimestamps(root);
|
|
|
|
// Build component lookup for product resolution
|
|
var componentLookup = BuildComponentLookup(root);
|
|
|
|
// Extract vulnerabilities and convert to statements
|
|
var statements = ExtractStatements(root, componentLookup, warnings, ref statementsSkipped);
|
|
|
|
// Calculate source digest
|
|
var sourceDigest = ComputeDigest(content);
|
|
|
|
// Build provenance
|
|
var provenance = new NormalizationProvenance(
|
|
NormalizedAt: context.NormalizedAt,
|
|
Normalizer: context.Normalizer,
|
|
SourceRevision: null,
|
|
TransformationRules: ["cyclonedx-vex-to-normalized-v1"]);
|
|
|
|
var normalizedDoc = new NormalizedVexDocument(
|
|
SchemaVersion: NormalizedVexDocument.CurrentSchemaVersion,
|
|
DocumentId: documentId,
|
|
SourceFormat: VexSourceFormat.CycloneDxVex,
|
|
SourceDigest: sourceDigest,
|
|
SourceUri: context.SourceUri,
|
|
Issuer: issuer,
|
|
IssuedAt: issuedAt,
|
|
LastUpdatedAt: lastUpdatedAt,
|
|
Statements: statements,
|
|
Provenance: provenance);
|
|
|
|
stopwatch.Stop();
|
|
|
|
return Task.FromResult(NormalizationResult.Successful(
|
|
normalizedDoc,
|
|
new NormalizationMetrics(
|
|
Duration: stopwatch.Elapsed,
|
|
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
|
StatementsExtracted: statements.Count,
|
|
StatementsSkipped: statementsSkipped,
|
|
ProductsMapped: statements.Count),
|
|
warnings));
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
stopwatch.Stop();
|
|
return Task.FromResult(NormalizationResult.Failed(
|
|
[new NormalizationError("ERR_CDX_001", "Invalid JSON", ex.Path, ex)],
|
|
new NormalizationMetrics(
|
|
Duration: stopwatch.Elapsed,
|
|
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
|
StatementsExtracted: 0,
|
|
StatementsSkipped: 0,
|
|
ProductsMapped: 0),
|
|
warnings));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
return Task.FromResult(NormalizationResult.Failed(
|
|
[new NormalizationError("ERR_CDX_999", "Unexpected error during normalization", null, ex)],
|
|
new NormalizationMetrics(
|
|
Duration: stopwatch.Elapsed,
|
|
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
|
StatementsExtracted: 0,
|
|
StatementsSkipped: 0,
|
|
ProductsMapped: 0),
|
|
warnings));
|
|
}
|
|
}
|
|
|
|
private static string? ExtractDocumentId(JsonElement root)
|
|
{
|
|
// Try serialNumber first
|
|
if (root.TryGetProperty("serialNumber", out var serialNumber))
|
|
{
|
|
return serialNumber.GetString();
|
|
}
|
|
|
|
// Fall back to metadata.component.bom-ref
|
|
if (root.TryGetProperty("metadata", out var metadata) &&
|
|
metadata.TryGetProperty("component", out var component) &&
|
|
component.TryGetProperty("bom-ref", out var bomRef))
|
|
{
|
|
return bomRef.GetString();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static VexIssuer? ExtractIssuer(JsonElement root, List<NormalizationWarning> warnings)
|
|
{
|
|
if (!root.TryGetProperty("metadata", out var metadata))
|
|
{
|
|
warnings.Add(new NormalizationWarning(
|
|
"WARN_CDX_002",
|
|
"No metadata found in document",
|
|
"metadata"));
|
|
return null;
|
|
}
|
|
|
|
// Try to extract from authors or supplier
|
|
string? issuerId = null;
|
|
string? issuerName = null;
|
|
|
|
if (metadata.TryGetProperty("authors", out var authors) &&
|
|
authors.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var author in authors.EnumerateArray())
|
|
{
|
|
issuerName = author.TryGetProperty("name", out var name) ? name.GetString() : null;
|
|
issuerId = author.TryGetProperty("email", out var email) ? email.GetString() : issuerName;
|
|
|
|
if (!string.IsNullOrWhiteSpace(issuerName))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(issuerName) &&
|
|
metadata.TryGetProperty("supplier", out var supplier))
|
|
{
|
|
issuerName = supplier.TryGetProperty("name", out var name) ? name.GetString() : null;
|
|
issuerId = supplier.TryGetProperty("url", out var url)
|
|
? url.ValueKind == JsonValueKind.Array
|
|
? url.EnumerateArray().FirstOrDefault().GetString()
|
|
: url.GetString()
|
|
: issuerName;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(issuerName) &&
|
|
metadata.TryGetProperty("manufacture", out var manufacture))
|
|
{
|
|
issuerName = manufacture.TryGetProperty("name", out var name) ? name.GetString() : null;
|
|
issuerId = issuerName;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(issuerName))
|
|
{
|
|
warnings.Add(new NormalizationWarning(
|
|
"WARN_CDX_003",
|
|
"No author/supplier found in metadata",
|
|
"metadata.authors"));
|
|
return null;
|
|
}
|
|
|
|
return new VexIssuer(
|
|
Id: issuerId ?? "unknown",
|
|
Name: issuerName ?? "unknown",
|
|
Category: null,
|
|
TrustTier: TrustTier.Unknown,
|
|
KeyFingerprints: null);
|
|
}
|
|
|
|
private static (DateTimeOffset? IssuedAt, DateTimeOffset? LastUpdatedAt) ExtractTimestamps(JsonElement root)
|
|
{
|
|
DateTimeOffset? issuedAt = null;
|
|
|
|
if (root.TryGetProperty("metadata", out var metadata) &&
|
|
metadata.TryGetProperty("timestamp", out var timestamp) &&
|
|
timestamp.ValueKind == JsonValueKind.String)
|
|
{
|
|
if (DateTimeOffset.TryParse(timestamp.GetString(), out var parsed))
|
|
{
|
|
issuedAt = parsed;
|
|
}
|
|
}
|
|
|
|
return (issuedAt, null);
|
|
}
|
|
|
|
private static Dictionary<string, ComponentInfo> BuildComponentLookup(JsonElement root)
|
|
{
|
|
var lookup = new Dictionary<string, ComponentInfo>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Add metadata component
|
|
if (root.TryGetProperty("metadata", out var metadata) &&
|
|
metadata.TryGetProperty("component", out var metaComponent))
|
|
{
|
|
AddComponentToLookup(lookup, metaComponent);
|
|
}
|
|
|
|
// Add all components
|
|
if (root.TryGetProperty("components", out var components) &&
|
|
components.ValueKind == JsonValueKind.Array)
|
|
{
|
|
AddComponentsRecursively(lookup, components);
|
|
}
|
|
|
|
return lookup;
|
|
}
|
|
|
|
private static void AddComponentsRecursively(Dictionary<string, ComponentInfo> lookup, JsonElement components)
|
|
{
|
|
foreach (var component in components.EnumerateArray())
|
|
{
|
|
AddComponentToLookup(lookup, component);
|
|
|
|
// Handle nested components
|
|
if (component.TryGetProperty("components", out var nested) &&
|
|
nested.ValueKind == JsonValueKind.Array)
|
|
{
|
|
AddComponentsRecursively(lookup, nested);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void AddComponentToLookup(Dictionary<string, ComponentInfo> lookup, JsonElement component)
|
|
{
|
|
var bomRef = component.TryGetProperty("bom-ref", out var br) ? br.GetString() : null;
|
|
var name = component.TryGetProperty("name", out var n) ? n.GetString() : null;
|
|
var version = component.TryGetProperty("version", out var v) ? v.GetString() : null;
|
|
var purl = component.TryGetProperty("purl", out var p) ? p.GetString() : null;
|
|
var cpe = component.TryGetProperty("cpe", out var c) ? c.GetString() : null;
|
|
|
|
// Extract hashes
|
|
Dictionary<string, string>? hashes = null;
|
|
if (component.TryGetProperty("hashes", out var hashArray) &&
|
|
hashArray.ValueKind == JsonValueKind.Array)
|
|
{
|
|
hashes = [];
|
|
foreach (var hash in hashArray.EnumerateArray())
|
|
{
|
|
var alg = hash.TryGetProperty("alg", out var a) ? a.GetString() : null;
|
|
var content = hash.TryGetProperty("content", out var cont) ? cont.GetString() : null;
|
|
if (!string.IsNullOrWhiteSpace(alg) && !string.IsNullOrWhiteSpace(content))
|
|
{
|
|
hashes[alg] = content;
|
|
}
|
|
}
|
|
|
|
if (hashes.Count == 0)
|
|
{
|
|
hashes = null;
|
|
}
|
|
}
|
|
|
|
var info = new ComponentInfo(name, version, purl, cpe, hashes);
|
|
|
|
if (!string.IsNullOrWhiteSpace(bomRef))
|
|
{
|
|
lookup[bomRef] = info;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(purl) && !lookup.ContainsKey(purl))
|
|
{
|
|
lookup[purl] = info;
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<NormalizedStatement> ExtractStatements(
|
|
JsonElement root,
|
|
Dictionary<string, ComponentInfo> componentLookup,
|
|
List<NormalizationWarning> warnings,
|
|
ref int skipped)
|
|
{
|
|
if (!root.TryGetProperty("vulnerabilities", out var vulnerabilities) ||
|
|
vulnerabilities.ValueKind != JsonValueKind.Array)
|
|
{
|
|
warnings.Add(new NormalizationWarning(
|
|
"WARN_CDX_004",
|
|
"No vulnerabilities array found",
|
|
"vulnerabilities"));
|
|
return [];
|
|
}
|
|
|
|
var statements = new List<NormalizedStatement>();
|
|
var index = 0;
|
|
|
|
foreach (var vuln in vulnerabilities.EnumerateArray())
|
|
{
|
|
var vulnStatements = ExtractVulnerabilityStatements(
|
|
vuln, componentLookup, index, warnings, ref skipped);
|
|
statements.AddRange(vulnStatements);
|
|
index += vulnStatements.Count > 0 ? vulnStatements.Count : 1;
|
|
}
|
|
|
|
return statements;
|
|
}
|
|
|
|
private static List<NormalizedStatement> ExtractVulnerabilityStatements(
|
|
JsonElement vuln,
|
|
Dictionary<string, ComponentInfo> componentLookup,
|
|
int startIndex,
|
|
List<NormalizationWarning> warnings,
|
|
ref int skipped)
|
|
{
|
|
var statements = new List<NormalizedStatement>();
|
|
|
|
// Extract vulnerability ID
|
|
var vulnerabilityId = vuln.TryGetProperty("id", out var id) ? id.GetString() : null;
|
|
|
|
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
|
{
|
|
warnings.Add(new NormalizationWarning(
|
|
"WARN_CDX_005",
|
|
"Vulnerability missing ID; skipped",
|
|
"vulnerabilities[].id"));
|
|
skipped++;
|
|
return statements;
|
|
}
|
|
|
|
// Extract aliases from references with type = "advisory"
|
|
var aliases = new List<string>();
|
|
if (vuln.TryGetProperty("references", out var refs) &&
|
|
refs.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var reference in refs.EnumerateArray())
|
|
{
|
|
if (reference.TryGetProperty("id", out var refId))
|
|
{
|
|
var refIdStr = refId.GetString();
|
|
if (!string.IsNullOrWhiteSpace(refIdStr) && refIdStr != vulnerabilityId)
|
|
{
|
|
aliases.Add(refIdStr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract affected components
|
|
if (!vuln.TryGetProperty("affects", out var affects) ||
|
|
affects.ValueKind != JsonValueKind.Array)
|
|
{
|
|
warnings.Add(new NormalizationWarning(
|
|
"WARN_CDX_006",
|
|
$"Vulnerability {vulnerabilityId} has no affects array",
|
|
"vulnerabilities[].affects"));
|
|
skipped++;
|
|
return statements;
|
|
}
|
|
|
|
var localIndex = 0;
|
|
foreach (var affect in affects.EnumerateArray())
|
|
{
|
|
var refStr = affect.TryGetProperty("ref", out var refProp) ? refProp.GetString() : null;
|
|
|
|
if (string.IsNullOrWhiteSpace(refStr))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var product = ResolveProduct(refStr, componentLookup);
|
|
if (product == null)
|
|
{
|
|
warnings.Add(new NormalizationWarning(
|
|
"WARN_CDX_007",
|
|
$"Could not resolve component ref '{refStr}'",
|
|
"vulnerabilities[].affects[].ref"));
|
|
continue;
|
|
}
|
|
|
|
// Extract analysis/status
|
|
var status = VexStatus.UnderInvestigation;
|
|
VexJustification? justification = null;
|
|
string? statusNotes = null;
|
|
string? actionStatement = null;
|
|
|
|
if (vuln.TryGetProperty("analysis", out var analysis))
|
|
{
|
|
var stateStr = analysis.TryGetProperty("state", out var state) ? state.GetString() : null;
|
|
status = MapAnalysisState(stateStr) ?? VexStatus.UnderInvestigation;
|
|
|
|
var justificationStr = analysis.TryGetProperty("justification", out var just) ? just.GetString() : null;
|
|
justification = MapJustification(justificationStr);
|
|
|
|
statusNotes = analysis.TryGetProperty("detail", out var detail) ? detail.GetString() : null;
|
|
|
|
if (analysis.TryGetProperty("response", out var response) &&
|
|
response.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var responses = new List<string>();
|
|
foreach (var r in response.EnumerateArray())
|
|
{
|
|
var rStr = r.GetString();
|
|
if (!string.IsNullOrWhiteSpace(rStr))
|
|
{
|
|
responses.Add(rStr);
|
|
}
|
|
}
|
|
|
|
if (responses.Count > 0)
|
|
{
|
|
actionStatement = string.Join(", ", responses);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract timestamps
|
|
DateTimeOffset? firstSeen = null;
|
|
DateTimeOffset? lastSeen = null;
|
|
|
|
if (vuln.TryGetProperty("created", out var created) &&
|
|
created.ValueKind == JsonValueKind.String)
|
|
{
|
|
if (DateTimeOffset.TryParse(created.GetString(), out var parsed))
|
|
{
|
|
firstSeen = parsed;
|
|
}
|
|
}
|
|
|
|
if (vuln.TryGetProperty("updated", out var updated) &&
|
|
updated.ValueKind == JsonValueKind.String)
|
|
{
|
|
if (DateTimeOffset.TryParse(updated.GetString(), out var parsed))
|
|
{
|
|
lastSeen = parsed;
|
|
}
|
|
}
|
|
else if (vuln.TryGetProperty("published", out var published) &&
|
|
published.ValueKind == JsonValueKind.String)
|
|
{
|
|
if (DateTimeOffset.TryParse(published.GetString(), out var parsed))
|
|
{
|
|
lastSeen = parsed;
|
|
}
|
|
}
|
|
|
|
// Extract version ranges if specified
|
|
VersionRange? versions = null;
|
|
if (affect.TryGetProperty("versions", out var versionsArray) &&
|
|
versionsArray.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var affectedVersions = new List<string>();
|
|
var fixedVersions = new List<string>();
|
|
|
|
foreach (var ver in versionsArray.EnumerateArray())
|
|
{
|
|
var verStr = ver.TryGetProperty("version", out var v) ? v.GetString() : null;
|
|
var statusStr = ver.TryGetProperty("status", out var s) ? s.GetString() : null;
|
|
|
|
if (!string.IsNullOrWhiteSpace(verStr))
|
|
{
|
|
if (statusStr?.Equals("affected", StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
affectedVersions.Add(verStr);
|
|
}
|
|
else if (statusStr?.Equals("unaffected", StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
fixedVersions.Add(verStr);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (affectedVersions.Count > 0 || fixedVersions.Count > 0)
|
|
{
|
|
versions = new VersionRange(
|
|
Affected: affectedVersions.Count > 0 ? affectedVersions : null,
|
|
Fixed: fixedVersions.Count > 0 ? fixedVersions : null,
|
|
Unaffected: null);
|
|
}
|
|
}
|
|
|
|
statements.Add(new NormalizedStatement(
|
|
StatementId: $"stmt-{startIndex + localIndex}",
|
|
VulnerabilityId: vulnerabilityId,
|
|
VulnerabilityAliases: aliases.Count > 0 ? aliases : null,
|
|
Product: product,
|
|
Status: status,
|
|
StatusNotes: statusNotes,
|
|
Justification: justification,
|
|
ImpactStatement: null,
|
|
ActionStatement: actionStatement,
|
|
ActionStatementTimestamp: null,
|
|
Versions: versions,
|
|
Subcomponents: null,
|
|
FirstSeen: firstSeen,
|
|
LastSeen: lastSeen ?? firstSeen));
|
|
|
|
localIndex++;
|
|
}
|
|
|
|
if (statements.Count == 0)
|
|
{
|
|
skipped++;
|
|
}
|
|
|
|
return statements;
|
|
}
|
|
|
|
private static NormalizedProduct? ResolveProduct(string refStr, Dictionary<string, ComponentInfo> componentLookup)
|
|
{
|
|
if (componentLookup.TryGetValue(refStr, out var info))
|
|
{
|
|
return new NormalizedProduct(
|
|
Key: info.Purl ?? refStr,
|
|
Name: info.Name,
|
|
Version: info.Version,
|
|
Purl: info.Purl,
|
|
Cpe: info.Cpe,
|
|
Hashes: info.Hashes);
|
|
}
|
|
|
|
// If not found in lookup, create a basic product entry
|
|
if (refStr.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new NormalizedProduct(
|
|
Key: refStr,
|
|
Name: null,
|
|
Version: null,
|
|
Purl: refStr,
|
|
Cpe: null,
|
|
Hashes: null);
|
|
}
|
|
|
|
return new NormalizedProduct(
|
|
Key: refStr,
|
|
Name: null,
|
|
Version: null,
|
|
Purl: null,
|
|
Cpe: null,
|
|
Hashes: null);
|
|
}
|
|
|
|
private static VexStatus? MapAnalysisState(string? state)
|
|
{
|
|
return state?.ToLowerInvariant() switch
|
|
{
|
|
"not_affected" => VexStatus.NotAffected,
|
|
"exploitable" or "in_triage" => VexStatus.Affected,
|
|
"resolved" or "resolved_with_pedigree" => VexStatus.Fixed,
|
|
"false_positive" => VexStatus.NotAffected,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private static VexJustification? MapJustification(string? justification)
|
|
{
|
|
return justification?.ToLowerInvariant() switch
|
|
{
|
|
"code_not_present" => VexJustification.ComponentNotPresent,
|
|
"code_not_reachable" => VexJustification.VulnerableCodeNotInExecutePath,
|
|
"requires_configuration" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
|
"requires_dependency" => VexJustification.ComponentNotPresent,
|
|
"requires_environment" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
|
"protected_by_compiler" or "protected_by_mitigating_control" or "protected_at_runtime" or "protected_at_perimeter" =>
|
|
VexJustification.InlineMitigationsAlreadyExist,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private static string ComputeDigest(string content)
|
|
{
|
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
|
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
|
}
|
|
|
|
private sealed record ComponentInfo(
|
|
string? Name,
|
|
string? Version,
|
|
string? Purl,
|
|
string? Cpe,
|
|
IReadOnlyDictionary<string, string>? Hashes);
|
|
}
|