UNPKG

@appthreat/cdx-proto

Version:

Library to serialize/deserialize CycloneDX BOM with protocol buffers

457 lines 17.3 kB
import { create, fromBinary, fromJson, toBinary, toJson, } from "@bufbuild/protobuf"; import { BomSchema as BomSchema15 } from "./lib/bom-1.5_pb.js"; import { BomSchema as BomSchema16 } from "./lib/bom-1.6_pb.js"; import { BomSchema as BomSchema17 } from "./lib/bom-1.7_pb.js"; export const supportedSpecVersions = ["1.5", "1.6", "1.7"]; const bomSchemas = { "1.5": BomSchema15, "1.6": BomSchema16, "1.7": BomSchema17, }; const SUPPORTED_BINARY_READ_ORDER = [...supportedSpecVersions].reverse(); const FIELD_ALIASES = { bomRef: "bom-ref", mimeType: "mime-type", xTrustBoundary: "x-trust-boundary", }; const MESSAGE_FIELD_ALIASES = { "CommonExtension.name": "commonExtensionName", "CommonExtension.value": "commonExtensionValue", "CustomExtension.name": "customExtensionName", "CustomExtension.value": "customExtensionValue", "DistributionConstraints.tlp": "tlpClassification", "Hash.value": "content", }; const ENUM_CANONICAL_STYLE_OVERRIDES = { Aggregate: "lower-underscore", CO2MeasureUnitType: "co2-measure-unit", CommonExtensionName: "lower-camel", CryptoImplementationPlatform: "implementation-platform", EnergyMeasureUnitType: "energy-measure-unit", EvidenceFieldType: "lower-camel", HashAlg: "hash-algorithm", ImpactAnalysisJustification: "lower-underscore", ImpactAnalysisState: "lower-underscore", ScoreMethod: "score-method", TlpClassification: "upper-underscore", VulnerabilityAffectedStatus: "lower-underscore", VulnerabilityResponse: "lower-underscore", }; const SPECIAL_ENUM_CANONICAL_VALUES = { CO2MeasureUnitType: { TONNES_CO2_EQUIVALENT: "tCO2eq", }, CryptoImplementationPlatform: { ARMV7_A: "armv7-a", ARMV7_M: "armv7-m", ARMV8_A: "armv8-a", ARMV8_M: "armv8-m", ARMV9_A: "armv9-a", ARMV9_M: "armv9-m", GENERIC: "generic", OTHER: "other", PPC64: "ppc64", PPC64LE: "ppc64le", S390X: "s390x", UNKNOWN: "unknown", X86_32: "x86_32", X86_64: "x86_64", }, EnergyMeasureUnitType: { KILOWATT_HOURS: "kWh", }, HashAlg: { BLAKE_2_B_256: "BLAKE2b-256", BLAKE_2_B_384: "BLAKE2b-384", BLAKE_2_B_512: "BLAKE2b-512", BLAKE_3: "BLAKE3", MD_5: "MD5", SHA_1: "SHA-1", SHA_256: "SHA-256", SHA_384: "SHA-384", SHA_3_256: "SHA3-256", SHA_3_384: "SHA3-384", SHA_3_512: "SHA3-512", SHA_512: "SHA-512", STREEBOG_256: "STREEBOG_256", STREEBOG_512: "STREEBOG_512", }, ScoreMethod: { CVSSV2: "CVSSv2", CVSSV3: "CVSSv3", CVSSV31: "CVSSv31", CVSSV4: "CVSSv4", OTHER: "other", OWASP: "OWASP", SSVC: "SSVC", }, }; const enumMapCache = new Map(); const BOM_OBJECT_WRAPPED_LIST_FIELDS = new Set(["declarations", "definitions"]); function isJsonRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function sanitizeBomJsonValue(value) { if (Array.isArray(value)) { return value .filter((entry) => entry !== undefined) .map((entry) => sanitizeBomJsonValue(entry)); } if (isJsonRecord(value)) { return Object.fromEntries(Object.entries(value) .filter(([, entry]) => entry !== undefined) .map(([key, entry]) => [key, sanitizeBomJsonValue(entry)])); } return value; } function shouldWrapBomObjectListField(messageDescriptor, fieldDescriptor) { return (messageDescriptor.name === "Bom" && BOM_OBJECT_WRAPPED_LIST_FIELDS.has(fieldDescriptor.jsonName)); } function mergeBomObjectListEntries(entries) { const mergedEntry = {}; for (const entry of entries) { if (!isJsonRecord(entry)) { continue; } for (const [key, value] of Object.entries(entry)) { if (value === undefined) { continue; } if (Array.isArray(value)) { mergedEntry[key] = [ ...(mergedEntry[key] || []), ...value, ]; continue; } if (isJsonRecord(value) && isJsonRecord(mergedEntry[key])) { mergedEntry[key] = { ...mergedEntry[key], ...value, }; continue; } if (mergedEntry[key] === undefined) { mergedEntry[key] = value; } } } return Object.keys(mergedEntry).length ? mergedEntry : undefined; } function normalizeSpecVersion(specVersion) { const normalized = String(specVersion).trim().toLowerCase().replace(/^v/, ""); const match = /^(1\.[567])(?:\.0+)?$/.exec(normalized); if (match) { return match[1]; } throw new Error(`Unsupported CycloneDX spec version: ${String(specVersion)}. Supported versions: ${supportedSpecVersions.join(", ")}`); } function readSpecVersion(value) { const candidate = value.specVersion ?? value.spec_version; if (typeof candidate !== "string" && typeof candidate !== "number") { throw new Error("Unable to determine CycloneDX spec version. Expected a 'specVersion' or 'spec_version' field."); } return normalizeSpecVersion(candidate); } function assertMatchingSpecVersion(expected, value) { const actual = readSpecVersion(value); if (actual !== expected) { throw new Error(`CycloneDX spec version mismatch: expected ${expected}, received ${actual}.`); } } function toLowerCamelCase(value) { const [firstPart = "", ...rest] = value.toLowerCase().split("_"); return `${firstPart}${rest .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) .join("")}`; } function getEnumPrefix(enumDescriptor) { if (typeof enumDescriptor.sharedPrefix === "string") { return enumDescriptor.sharedPrefix.toUpperCase(); } const [firstName = ""] = enumDescriptor.values.map((entry) => entry.name); let prefix = firstName; for (const enumValue of enumDescriptor.values) { let index = 0; while (index < prefix.length && index < enumValue.name.length && prefix[index] === enumValue.name[index]) { index += 1; } prefix = prefix.slice(0, index); } const separatorIndex = prefix.lastIndexOf("_"); return separatorIndex === -1 ? "" : prefix.slice(0, separatorIndex + 1); } function enumSuffixToCanonical(enumDescriptor, suffix) { if (suffix === "NULL" || suffix === "UNSPECIFIED") { return undefined; } const specialValue = SPECIAL_ENUM_CANONICAL_VALUES[enumDescriptor.name]?.[suffix]; if (specialValue !== undefined) { return specialValue; } switch (ENUM_CANONICAL_STYLE_OVERRIDES[enumDescriptor.name]) { case "lower-camel": return toLowerCamelCase(suffix); case "lower-underscore": return suffix.toLowerCase(); case "upper-underscore": return suffix; default: return suffix.toLowerCase().replaceAll("_", "-"); } } function getEnumMaps(enumDescriptor) { const cachedMaps = enumMapCache.get(enumDescriptor.typeName); if (cachedMaps) { return cachedMaps; } const prefix = getEnumPrefix(enumDescriptor); const canonicalToProto = new Map(); const protoToCanonical = new Map(); for (const enumValue of enumDescriptor.values) { const suffix = enumValue.name.startsWith(prefix) ? enumValue.name.slice(prefix.length) : enumValue.name; const canonicalValue = enumSuffixToCanonical(enumDescriptor, suffix); protoToCanonical.set(enumValue.name, canonicalValue); if (canonicalValue !== undefined) { canonicalToProto.set(canonicalValue, enumValue.name); } } const enumMaps = { canonicalToProto, protoToCanonical, }; enumMapCache.set(enumDescriptor.typeName, enumMaps); return enumMaps; } function transformEnumValue(enumDescriptor, value, direction) { if (typeof value !== "string") { return value; } const enumMaps = getEnumMaps(enumDescriptor); if (direction === "toProto") { if (enumMaps.protoToCanonical.has(value)) { return value; } return enumMaps.canonicalToProto.get(value) ?? value; } return enumMaps.protoToCanonical.get(value) ?? value; } function getFieldAlias(messageDescriptor, fieldDescriptor) { return (MESSAGE_FIELD_ALIASES[`${messageDescriptor.name}.${fieldDescriptor.localName}`] ?? FIELD_ALIASES[fieldDescriptor.localName]); } function getFieldInputKeys(messageDescriptor, fieldDescriptor) { return Array.from(new Set([ getFieldAlias(messageDescriptor, fieldDescriptor), fieldDescriptor.jsonName, fieldDescriptor.localName, fieldDescriptor.name, `${fieldDescriptor.name}`.replaceAll("_", "-"), ].filter((entry) => Boolean(entry)))); } function getFieldOutputKey(messageDescriptor, fieldDescriptor) { return (getFieldAlias(messageDescriptor, fieldDescriptor) ?? fieldDescriptor.jsonName); } function transformFieldValue(fieldDescriptor, value, direction) { switch (fieldDescriptor.fieldKind) { case "enum": if (!fieldDescriptor.enum) { return value; } return transformEnumValue(fieldDescriptor.enum, value, direction); case "list": if (!Array.isArray(value)) { return value; } if (fieldDescriptor.listKind === "enum" && fieldDescriptor.enum) { const enumDescriptor = fieldDescriptor.enum; return value.map((entry) => transformEnumValue(enumDescriptor, entry, direction)); } if (fieldDescriptor.listKind === "message" && fieldDescriptor.message) { const messageDescriptor = fieldDescriptor.message; return value.map((entry) => transformMessageValue(messageDescriptor, entry, direction)); } return value; case "map": if (!isJsonRecord(value)) { return value; } if (fieldDescriptor.mapKind === "enum" && fieldDescriptor.enum) { const enumDescriptor = fieldDescriptor.enum; return Object.fromEntries(Object.entries(value).map(([key, entry]) => [ key, transformEnumValue(enumDescriptor, entry, direction), ])); } if (fieldDescriptor.mapKind === "message" && fieldDescriptor.message) { const messageDescriptor = fieldDescriptor.message; return Object.fromEntries(Object.entries(value).map(([key, entry]) => [ key, transformMessageValue(messageDescriptor, entry, direction), ])); } return value; case "message": if (!fieldDescriptor.message) { return value; } return transformMessageValue(fieldDescriptor.message, value, direction); default: return value; } } function transformMessageValue(messageDescriptor, value, direction) { if (!isJsonRecord(value)) { return value; } if (direction === "toProto") { const normalizedValue = { ...value }; for (const fieldDescriptor of messageDescriptor.fields) { const sourceKey = getFieldInputKeys(messageDescriptor, fieldDescriptor).find((key) => Object.hasOwn(value, key)); if (!sourceKey) { continue; } const sourceValue = shouldWrapBomObjectListField(messageDescriptor, fieldDescriptor) ? Array.isArray(value[sourceKey]) || !isJsonRecord(value[sourceKey]) ? value[sourceKey] : [value[sourceKey]] : value[sourceKey]; const transformedValue = transformFieldValue(fieldDescriptor, sourceValue, direction); for (const inputKey of getFieldInputKeys(messageDescriptor, fieldDescriptor)) { if (inputKey !== fieldDescriptor.jsonName) { delete normalizedValue[inputKey]; } } normalizedValue[fieldDescriptor.jsonName] = transformedValue; } return normalizedValue; } const normalizedValue = {}; for (const fieldDescriptor of messageDescriptor.fields) { const sourceKey = [ fieldDescriptor.jsonName, fieldDescriptor.localName, fieldDescriptor.name, ].find((key) => Object.hasOwn(value, key)); if (!sourceKey) { continue; } let transformedValue = transformFieldValue(fieldDescriptor, value[sourceKey], direction); if (shouldWrapBomObjectListField(messageDescriptor, fieldDescriptor)) { transformedValue = Array.isArray(transformedValue) ? mergeBomObjectListEntries(transformedValue) : transformedValue; } normalizedValue[getFieldOutputKey(messageDescriptor, fieldDescriptor)] = transformedValue; } return normalizedValue; } function normalizeBomJsonForProto(schema, bomJson) { const normalizedBomJson = sanitizeBomJsonValue(bomJson); if (!isJsonRecord(normalizedBomJson)) { return normalizedBomJson; } const protoCompatibleJson = transformMessageValue(schema, normalizedBomJson, "toProto"); if (isJsonRecord(protoCompatibleJson)) { delete protoCompatibleJson.bomFormat; delete protoCompatibleJson.bom_format; } return protoCompatibleJson; } function normalizeBomJsonFromProto(schema, bomJson, bom) { if (!isJsonRecord(bomJson)) { return bomJson; } return sanitizeBomJsonValue({ bomFormat: "CycloneDX", ...(transformMessageValue(schema, { ...bomJson, specVersion: typeof bomJson.specVersion === "string" ? bomJson.specVersion : typeof bomJson.spec_version === "string" ? bomJson.spec_version : bom?.specVersion, }, "fromProto") || {}), }); } export function getBomSchema(specVersion) { return bomSchemas[normalizeSpecVersion(specVersion)]; } export function detectBomSpecVersion(value) { return readSpecVersion(value); } export function getBomSchemaForBom(bom) { return getBomSchema(detectBomSpecVersion(bom)); } export function createBom(specVersion, init) { const normalized = normalizeSpecVersion(specVersion); return create(getBomSchema(normalized), { ...init, specVersion: normalized, }); } export function decodeBomBinary(specVersion, bytes, options) { const normalized = normalizeSpecVersion(specVersion); const bom = fromBinary(getBomSchema(normalized), bytes, options); assertMatchingSpecVersion(normalized, bom); return bom; } export function decodeBomJson(specVersion, json, options) { const normalized = normalizeSpecVersion(specVersion); const schema = getBomSchema(normalized); const protoCompatibleJson = normalizeBomJsonForProto(schema, json); if (isJsonRecord(protoCompatibleJson)) { const versionCarrier = protoCompatibleJson; if (versionCarrier.specVersion !== undefined || versionCarrier.spec_version !== undefined) { assertMatchingSpecVersion(normalized, versionCarrier); } } const bom = fromJson(schema, protoCompatibleJson, options); if (!bom.specVersion) { bom.specVersion = normalized; } return bom; } export function decodeBomJsonString(specVersion, json, options) { return decodeBomJson(specVersion, JSON.parse(json), options); } export function parseBomJson(json, options) { const sanitizedBomJson = sanitizeBomJsonValue(json); if (!isJsonRecord(sanitizedBomJson)) { throw new Error("CycloneDX BOM JSON must be an object."); } return decodeBomJson(detectBomSpecVersion(sanitizedBomJson), sanitizedBomJson, options); } export function parseBomJsonString(json, options) { const parsed = JSON.parse(json); return parseBomJson(parsed, options); } export function encodeBomBinary(bom, options) { return toBinary(getBomSchemaForBom(bom), bom, options); } export function encodeBomJson(bom, options) { return normalizeBomJsonFromProto(getBomSchemaForBom(bom), toJson(getBomSchemaForBom(bom), bom, options), bom); } export function parseBomBinary(bytes, options) { let lastError; for (const specVersion of SUPPORTED_BINARY_READ_ORDER) { try { return decodeBomBinary(specVersion, bytes, options); } catch (error) { lastError = error; } } throw (lastError ?? new Error("Unable to decode CycloneDX protobuf BOM binary.")); } export function encodeBomJsonString(bom, options) { return JSON.stringify(encodeBomJson(bom, options), undefined, options?.prettySpaces ?? 0); } //# sourceMappingURL=helpers.js.map