UNPKG

@cyclonedx/cdxgen

Version:

Creates CycloneDX Software Bill of Materials (SBOM) from source or container image

505 lines (458 loc) 16 kB
const IREG_NAME_CHAR_REGEX = /^([a-zA-Z0-9\-._~!$&'()*+,;=]|%[a-fA-F0-9]{2})*$/u; /** * Represents the parsed components of an IRI. * @typedef {Object} IRIComponents * @property {string} scheme * @property {string} [userinfo] * @property {string} [host] * @property {string} [port] * @property {string} path * @property {string} [query] * @property {string} [fragment] * @property {boolean} valid * @property {string} [error] */ /** * Parses an IRI string according to RFC 3987. * @param {string} iri The IRI string to parse. * @returns {IRIComponents} An object containing the parsed components and validity status. */ export function parseIRI(iri) { // Initialize result object const result = { scheme: "", path: "", valid: false, hasAuthority: false, error: "Parsing not started", }; if (typeof iri !== "string") { result.error = "Input must be a string"; return result; } if (iri.length === 0) { result.error = "Input IRI is empty"; return result; } if (iri.trim() !== iri) { result.error = "Input IRI is not trimmed"; return result; } let index = 0; const len = iri.length; // --- 1. Parse Scheme --- // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) const schemeStart = index; if (index >= len || !isAlpha(iri.codePointAt(index))) { result.error = "IRI must start with a scheme"; return result; } index++; // Consume the first ALPHA while (index < len) { const code = iri.codePointAt(index); if ( isAlpha(code) || isDigit(code) || code === 0x2b || code === 0x2d || code === 0x2e ) { // + - . // Move index by the character's UTF-16 code unit length index += code >= 0x10000 ? 2 : 1; } else { break; } } if (index === schemeStart) { result.error = "Scheme parsing failed"; return result; } result.scheme = iri.substring(schemeStart, index); // Expect ':' if (index >= len || iri.codePointAt(index) !== 0x3a) { // ':' result.error = "Scheme must be followed by a colon"; return result; } index++; // Consume ':' // --- 2. Parse Hierarchical Part (ihier-part) --- // ihier-part = "//" iauthority ipath-abempty / ipath-absolute / ipath-rootless / ipath-empty // Check for "//" indicating authority is present let hasAuthority = false; let authorityEnd = index; if ( index + 1 < len && iri.codePointAt(index) === 0x2f && iri.codePointAt(index + 1) === 0x2f ) { // "//" hasAuthority = true; index += 2; // Consume "//" // --- 2a. Parse Authority (iauthority) --- // iauthority = [ iuserinfo "@" ] ihost [ ":" port ] const authorityStart = index; // Find the end of the authority component // It ends at the first '/', '?', or '#' or at the end of the string while (authorityEnd < len) { const code = iri.codePointAt(authorityEnd); if (code === 0x2f || code === 0x3f || code === 0x23) { // '/', '?', '#' break; } authorityEnd += code >= 0x10000 ? 2 : 1; } if (authorityStart < authorityEnd) { const authority = iri.substring(authorityStart, authorityEnd); const userInfoEndIdx = findUserInfoEnd(authority); let host_part; let port_part; if (userInfoEndIdx !== -1) { result.userinfo = authority.substring(0, userInfoEndIdx); const hostPortStr = authority.substring(userInfoEndIdx + 1); [host_part, port_part] = parseHostPort(hostPortStr); } else { [host_part, port_part] = parseHostPort(authority); } // --- Validation: Check if host_part is a valid ireg-name --- // Only validate if it's not an IP-literal (doesn't start with '[') // parseHostPort should handle IPv4 structural checks. if (host_part && !host_part.startsWith("[")) { // Trim potential trailing colon from empty port (e.g., "host:") // This can happen if the authority ends with ':' const trimmedHost = host_part.endsWith(":") ? host_part.slice(0, -1) : host_part; if (!IREG_NAME_CHAR_REGEX.test(trimmedHost)) { // Find a specific character that failed? Not strictly necessary for boolean check, // but helpful for error message. Let's keep it simple for now. result.error = `Invalid character in host (ireg-name) '${trimmedHost}' for IRI '${iri}'`; return result; // valid remains false } } // --- End Validation --- result.host = host_part; result.port = port_part; } index = authorityEnd; } // else hasAuthority remains false, index is right after ':' // --- 3. Parse Path (one of ipath-abempty, ipath-absolute, ipath-rootless) const pathStart = hasAuthority ? authorityEnd : index; // Path ends at the first '?' or '#' or at the end of the string while (index < len) { const code = iri.codePointAt(index); if (code === 0x3f || code === 0x23) { // '?' '#' break; } index += code >= 0x10000 ? 2 : 1; } result.path = iri.substring(pathStart, index); // --- 4. Parse Query (?) if (index < len && iri.codePointAt(index) === 0x3f) { // '?' index++; // Consume '?' const queryStart = index; // Query ends at the first '#' or at the end of the string while (index < len && iri.codePointAt(index) !== 0x23) { // '#' const code = iri.codePointAt(index); index += code >= 0x10000 ? 2 : 1; } result.query = iri.substring(queryStart, index); } // --- 5. Parse Fragment (#) if (index < len && iri.codePointAt(index) === 0x23) { // '#' index++; // Consume '#' const fragmentStart = index; // Fragment ends at the end of the string index = len; result.fragment = iri.substring(fragmentStart, index); } result.hasAuthority = hasAuthority; // --- Final Validation --- result.valid = true; delete result.error; return result; } // --- Helper Functions --- function isAlpha(code) { return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); // A-Z or a-z } function isDigit(code) { return code >= 0x30 && code <= 0x39; // 0-9 } /** * Finds the index of the last '@' character that is not inside an IPv6 literal [ ... ]. * Returns -1 if no such '@' is found. */ function findUserInfoEnd(authority) { let inIPv6Literal = false; let userInfoEndIndex = -1; for (let i = 0; i < authority.length; ) { const code = authority.codePointAt(i); const charLength = code >= 0x10000 ? 2 : 1; if (code === 0x5b) { // '[' inIPv6Literal = true; } else if (code === 0x5d) { // ']' inIPv6Literal = false; } else if (code === 0x40 && !inIPv6Literal) { // '@' userInfoEndIndex = i; // Keep track of the last one outside brackets } i += charLength; // Move by the actual character length } return userInfoEndIndex; } /** * Parses the host and port from a string like "example.com:8080" or "[::1]:3000". * Returns an array [host, port], where port can be undefined. */ function parseHostPort(hostPortStr) { if (hostPortStr.length === 0) { return ["", undefined]; } // Check for IPv6 literal [ ... ] if (hostPortStr.codePointAt(0) === 0x5b) { // '[' const endBracketIdx = hostPortStr.indexOf("]"); if (endBracketIdx === -1) { // Malformed IPv6 literal return [hostPortStr, undefined]; } const host = hostPortStr.substring(0, endBracketIdx + 1); // Include ']' if ( endBracketIdx + 1 < hostPortStr.length && hostPortStr.codePointAt(endBracketIdx + 1) === 0x3a ) { // ':' const port = hostPortStr.substring(endBracketIdx + 2); // Everything after "]:" return [host, port || undefined]; } return [host, undefined]; // No port } // Not an IPv6 literal, look for the last ':' not %-encoded let portSepIdx = -1; for (let i = hostPortStr.length - 1; i >= 0; i--) { if (hostPortStr.codePointAt(i) === 0x3a) { // ':' // Check if it's percent-encoded (e.g., %3A) // Ensure we have at least 3 chars before ':' if ( i >= 3 && hostPortStr.codePointAt(i - 1) === 0x25 && // '%' isHexDigit(hostPortStr.codePointAt(i - 2)) && isHexDigit(hostPortStr.codePointAt(i - 3)) ) { // ignore } else { // This is the separator we are looking for portSepIdx = i; break; } } // Adjust index for potential surrogate pairs when moving backwards // This simple backward loop is okay for ASCII ':' check, // but full Unicode backward iteration is complex. // For ':' (U+003A), simple `i--` works. } if (portSepIdx !== -1) { const host = hostPortStr.substring(0, portSepIdx); const port = hostPortStr.substring(portSepIdx + 1); return [host, port || undefined]; // Handle case like "host:" (port is empty) } return [hostPortStr, undefined]; // No port separator found } function isHexDigit(code) { return ( (code >= 0x30 && code <= 0x39) || // 0-9 (code >= 0x41 && code <= 0x46) || // A-F (code >= 0x61 && code <= 0x66) ); // a-f } // --- Helper functions for detailed RFC 3987 validation --- const hexdig_raw = "a-fA-F0-9"; // Correct const pct_encoded_pattern = `%[${hexdig_raw}]{2}`; // Correct const sub_delims_raw = "!$&'()*+,;="; // Correct const ipchar_unreserved_without_hyphen = "a-zA-Z0-9._~"; const ipchar_other_allowed_raw = `${sub_delims_raw}:@`; const ipchar_chars_without_hyphen_raw = `${ipchar_unreserved_without_hyphen}${ipchar_other_allowed_raw}`; const ipchar_chars_raw = `${ipchar_chars_without_hyphen_raw}-`; const ipchar_element_pattern = `[${ipchar_chars_raw}]|${pct_encoded_pattern}`; const ipchar_sequence_pattern = `^(?:${ipchar_element_pattern})*$`; const ICHAR_SEQUENCE_REGEX = new RegExp(ipchar_sequence_pattern, "u"); const ireg_name_chars_without_hyphen_raw = `${ipchar_unreserved_without_hyphen}${sub_delims_raw}`; const ireg_name_chars_raw = `${ireg_name_chars_without_hyphen_raw}-`; const ireg_name_element_pattern = `[${ireg_name_chars_raw}]|${pct_encoded_pattern}`; const ireg_name_sequence_pattern = `^(?:${ireg_name_element_pattern})*$`; const IREG_NAME_REGEX = new RegExp(ireg_name_sequence_pattern, "u"); function isValidIpCharSequence(str) { if (str === null || str === undefined) { return true; // undefined/null components are valid (empty) } return ICHAR_SEQUENCE_REGEX.test(str); } function isValidIregName(hostStr) { if (hostStr === null || hostStr === undefined || hostStr === "") { return true; // Empty host is valid for some cases (e.g., file:) } // Assumes hostStr does NOT include brackets for IP-literals return IREG_NAME_REGEX.test(hostStr); } function validateParsedComponents(components) { if (!components.valid) { return components; } // 1. Validate Authority (host part) if ( components.host !== undefined && components.host !== null && components.host !== "" ) { if (components.host.startsWith("[") && components.host.endsWith("]")) { // ignore } else { if (!isValidIregName(components.host)) { return { ...components, valid: false, error: `Invalid character in ireg-name host: '${components.host}'`, }; } } } // 2. Validate Path segments if (components.path) { const segments = components.path.split("/"); const isValidPath = segments.every((segment) => isValidIpCharSequence(segment), ); if (!isValidPath) { // Find the first failing segment for a better error message? const failingSegment = segments.find((s) => !isValidIpCharSequence(s)); return { ...components, valid: false, error: `Invalid character in path segment: '${failingSegment}' (Path: '${components.path}')`, }; } } // 3. Validate Query if (components.query !== undefined && components.query !== null) { // iquery allows ipchar, /, ? const iquery_element_pattern = `${ipchar_element_pattern}|[/?]`; // Add / and ? to allowed elements const iquery_sequence_pattern = `^(?:${iquery_element_pattern})*$`; const IQUERY_REGEX = new RegExp(iquery_sequence_pattern, "u"); if (!IQUERY_REGEX.test(components.query)) { return { ...components, valid: false, error: `Invalid character in query: '${components.query}'`, }; } // Note: This still doesn't check for iprivate, but catches most bad chars/pct-encoding. } // 4. Validate Fragment if (components.fragment !== undefined && components.fragment !== null) { // Similar to query, but without iprivate const ifragment_element_pattern = `${ipchar_element_pattern}|[/?]`; const ifragment_sequence_pattern = `^(?:${ifragment_element_pattern})*$`; const IFRAGMENT_REGEX = new RegExp(ifragment_sequence_pattern, "u"); if (!IFRAGMENT_REGEX.test(components.fragment)) { return { ...components, valid: false, error: `Invalid character in fragment: '${components.fragment}'`, }; } } if ( components.scheme && !components.host && !components.path && !components.query ) { return { ...components, valid: false, error: "No hostname", }; } return components; } /** * Possible ways of validating an IRI. */ export const IriValidationStrategy = Object.freeze({ /** * Validates the IRI according to RFC 3987 using a custom parser. */ Parse: "parse", /** * Validates that the IRI has a valid scheme and does not contain any character forbidden by the Turtle specification. */ Pragmatic: "pragmatic", /** * Does not validate the IRI at all. */ None: "none", }); // biome-ignore-start lint/suspicious/noControlCharactersInRegex: parser // Using the regex from the original code const PRAGMATIC_IRI_REGEX = /^[A-Za-z][\d+-.A-Za-z]*:[^\u0000-\u0020"<>\\^`{|}]*$/u; // biome-ignore-end lint/suspicious/noControlCharactersInRegex: parser /** * Validate a given IRI according to the given strategy. * * @param {string} iri a string that may be an IRI. * @param {IriValidationStrategy} strategy IRI validation strategy. * @return {Error | undefined} An error if the IRI is invalid, or undefined if it is valid. */ export function validateIri(iri, strategy = IriValidationStrategy.Parse) { switch (strategy) { case IriValidationStrategy.Parse: { // console.log(`DEBUG: Validating IRI: '${iri}' with Parse strategy`); let parseResult = parseIRI(iri); // console.log( // "DEBUG: parseIRI result:", // JSON.stringify(parseResult, null, 2), // ); // Log full result if (parseResult.valid) { // console.log( // "DEBUG: IRI is structurally valid, performing detailed checks...", // ); parseResult = validateParsedComponents(parseResult); // console.log( // "DEBUG: validateParsedComponents result:", // JSON.stringify(parseResult, null, 2), // ); // Log full result after detailed checks } if (parseResult.valid) { // console.log(`DEBUG: Final result for '${iri}': VALID`); return undefined; } const errorMessage = parseResult.error || `IRI failed detailed RFC 3987 validation: '${iri}'`; // console.log( // `DEBUG: Final result for '${iri}': INVALID - ${errorMessage}`, // ); return new Error( `Invalid IRI according to RFC 3987 parsing: ${errorMessage}`, ); } case IriValidationStrategy.Pragmatic: return PRAGMATIC_IRI_REGEX.test(iri) ? undefined : new Error(`Invalid IRI according to RDF Turtle: '${iri}'`); case IriValidationStrategy.None: return undefined; default: return new Error(`Not supported validation strategy "${strategy}"`); } }