@appthreat/cdx-proto
Version:
Library to serialize/deserialize CycloneDX BOM with protocol buffers
457 lines • 17.3 kB
JavaScript
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