UNPKG

cumulocity-cypress

Version:
464 lines (463 loc) 18.5 kB
import _ from "lodash"; import * as setCookieParser from "set-cookie-parser"; import * as libCookie from "cookie"; import { toSensitiveObjectKeyPath } from "../util"; /** * Default options for `C8yPactPreprocessor`. Used when constructing an instance * without custom options, and as fallback for missing properties when applying * with partial options. */ export const C8yPactPreprocessorDefaultOptions = { ignore: [ "request.headers.accept-encoding", "response.headers.cache-control", "response.headers.content-length", "response.headers.content-encoding", "response.headers.transfer-encoding", "response.headers.keep-alive", ], obfuscate: [ "request.headers.cookie.authorization", "request.headers.cookie.XSRF-TOKEN", "request.headers.authorization", "request.headers.X-XSRF-TOKEN", "response.headers.set-cookie.authorization", "response.headers.set-cookie.XSRF-TOKEN", "response.body.password", "response.body.users.password", ], obfuscationPattern: "****", ignoreCase: true, }; /** * Preprocessor for `C8yPact` objects. A preprocessor is applied when a pact * record is **saved** (recording mode) and before it is applied (matched) * against any other record. This is used to unify records before they are * **matched**. A preprocessor transforms objects such as `Cypress.Response`, * `C8yPactRecord` or a full `C8yPact` in-place. It supports various operations * to remove or obfuscate sensitive data, or to pick only certain keys to keep. * * The default implementation is `C8yDefaultPactPreprocessor`. * * ### Supported operations (configured via `C8yPactPreprocessorOptions`) * * | Option | Effect | * |---|---| * | `ignore` | Removes the value at each key path | * | `obfuscate` | Replaces the value at each key path with `obfuscationPattern` | * | `pick` | Keeps only the specified child keys; removes all others | * | `regexReplace` | Applies one or more `/pattern/replacement/flags` expressions | * * ### Key-path syntax * * All key paths use dot-separated segments. Bracket notation and numeric array * indices are supported: * ``` * response.body.password * response.body.items[0].token * response.body.items.0.token * ``` * When a path segment resolves to an **array of objects** and the next segment * is *not* a numeric index, the operation fans out to every element: * ``` * response.body.users.password // applied to every object in `users` * ``` * * ### Recursive-descent operator (`..`) * * Prefix a leaf key with `..` to match it at **any depth** below the optional * prefix path: * ``` * ..password // `password` anywhere in the record * response.body..password // `password` anywhere inside `body` * ``` * * ### Case-insensitive matching * * When `ignoreCase` is `true` (the default), each path segment is resolved * without regard to capitalization. Mutations always use the actual key name * found in the object. * * ### Cookie / Set-Cookie shorthand * * Preprocessors automatically parse `Cookie` and `Set-Cookie` header strings and * apply obfuscation or ignoring to individual cookie values when the key path * is appended with the cookie name as an extra segment: * * ``` * request.headers.cookie.XSRF-TOKEN * response.headers.set-cookie.authorization * ``` * * ### Authorization-header obfuscation * * When obfuscating an `Authorization` header whose value starts with `Bearer` * or `Basic`, the scheme prefix is preserved and only the credential is * replaced: * ``` * Bearer ******** * Basic ******** * ``` */ export class C8yDefaultPactPreprocessor { constructor(options) { this.reservedKeys = ["id", "pact", "info", "records"]; this.options = options; } /** {@inheritDoc C8yPactPreprocessor.apply} */ apply(obj, options) { if (!obj || !_.isObjectLike(obj)) return; const objs = "records" in obj ? _.get(obj, "records") : [obj]; if (!_.isArray(objs)) return; const o = this.resolveOptions(options); const ignoreCase = o.ignoreCase; const obfuscationPattern = o.obfuscationPattern; objs.forEach((obj) => { if (o?.pick != null) { const keepPaths = []; if (_.isPlainObject(o.pick)) { Object.entries(o.pick ?? {}).forEach(([parentKey, childKeys]) => { if (_.isEmpty(childKeys)) keepPaths.push(parentKey); childKeys.forEach((childKey) => { keepPaths.push(`${parentKey}.${childKey}`); }); }); this.filterObjectByKeepPaths(obj, keepPaths, ignoreCase); } else if (_.isArray(o.pick)) { this.applyKeepArray(obj, o.pick); } } if (o?.regexReplace != null) { Object.entries(o.regexReplace).forEach(([key, value]) => { const patterns = Array.isArray(value) ? value : [value]; this.applyRegexReplace(obj, key, patterns, ignoreCase); }); } this.filterValidKeys(obj, o.obfuscate ?? []).forEach((key) => { this.obfuscateKey(obj, key, obfuscationPattern, ignoreCase); }); this.filterValidKeys(obj, o.ignore ?? []).forEach((key) => { this.removeKey(obj, key, ignoreCase); }); }); } filterObjectByKeepPaths(obj, keepPaths, ignoreCase = false) { const prepKey = (key) => key != null && ignoreCase === true ? key.toLowerCase() : key; const shouldKeep = (keyPath) => { return keepPaths .map((k) => prepKey(k)) .some((keepPath) => prepKey(keyPath) === keepPath || keepPath?.startsWith(`${prepKey(keyPath)}.`)); }; const recursiveFilter = (currentObj, currentPath) => { if (!_.isObjectLike(currentObj)) return; if (_.isArray(currentObj)) { // For arrays of objects, recurse into each element using the same path // so that array indices are not included in path matching. currentObj.forEach((item) => { if (_.isObjectLike(item)) { recursiveFilter(item, currentPath); } }); return; } Object.keys(currentObj).forEach((key) => { const fullPath = currentPath ? `${currentPath}.${key}` : key; if (!shouldKeep(fullPath)) { _.unset(currentObj, key); } else if (!keepPaths.map((k) => prepKey(k)).includes(prepKey(fullPath))) { recursiveFilter(_.get(currentObj, key), fullPath); } }); }; recursiveFilter(obj, ""); } applyKeepArray(obj, keep) { if (keep == null || _.isEmpty(keep)) return; if (_.isObjectLike(obj)) { const keysToRemove = Object.keys(obj).filter((childKey) => !keep.includes(childKey.toLowerCase())); keysToRemove.forEach((childKey) => { _.unset(obj, childKey); }); } } removeKey(obj, key, ignoreCase) { const keyPath = key.split("."); if (this.hasKey(keyPath, "set-cookie")) { this.removeSetCookie(obj, keyPath, ignoreCase); } else if (this.hasKey(keyPath, "cookie")) { this.removeCookie(obj, keyPath, ignoreCase); } else { this.traverseKeyPath(obj, key, ignoreCase, (parent, k) => _.unset(parent, k)); } } removeSetCookie(obj, keyParts, ignoreCase) { const { name, keyPath, cookieHeader } = this.getCookieObject(obj, keyParts, ignoreCase); if (!cookieHeader) return; if (!name) { _.unset(obj, keyPath); return; } const cookies = setCookieParser.parse(cookieHeader, { decodeValues: false }) ?? []; if (cookies.length) { const filteredCookies = cookies .filter((cookie) => cookie.name.toLowerCase() !== name.toLowerCase()) .map((cookie) => libCookie.serialize(cookie.name, cookie.value, cookie)); if (filteredCookies.length === 0) { _.unset(obj, keyPath); } else { _.set(obj, keyPath, filteredCookies); } } } removeCookie(obj, keyParts, ignoreCase) { const { name, keyPath, cookieHeader } = this.getCookieObject(obj, keyParts, ignoreCase); if (!cookieHeader) return; if (!name) { _.unset(obj, keyPath); return; } const cookies = libCookie.parse(cookieHeader); delete cookies[name]; const remainingCookies = Object.entries(cookies); if (remainingCookies.length === 0) { _.unset(obj, keyPath); } else { const v = remainingCookies .map(([name, value]) => `${name}=${value}`) .join("; "); _.set(obj, keyPath, v); } } filterValidKeys(obj, keys) { return _.without(keys, ...this.reservedKeys); } /** * Unified key-path traversal. Calls `fn(parent, resolvedKey)` on every * matching leaf. Handles recursive descent (`..leafKey` / `prefix..leafKey`) * and regular dot-/bracket-separated paths including array indices. */ traverseKeyPath(obj, key, ignoreCase, fn) { if (key.includes("..")) { const sep = key.indexOf(".."); const prefix = key.slice(0, sep); const leafKey = key.slice(sep + 2); if (!leafKey) return; let target = obj; if (prefix) { const resolvedPrefix = ignoreCase === true ? (toSensitiveObjectKeyPath(obj, prefix) ?? prefix) : prefix; target = _.get(obj, resolvedPrefix); if (target == null) return; } this.applyRecursive(target, leafKey, fn, ignoreCase); return; } const walk = (currentObj, remainingParts) => { if (!currentObj || remainingParts.length === 0) return; const [rawKey, ...restKeys] = remainingParts; const currentKey = ignoreCase === true ? (toSensitiveObjectKeyPath(currentObj, rawKey) ?? rawKey) : rawKey; const target = _.get(currentObj, currentKey); if (restKeys.length === 0) { fn(currentObj, currentKey); } else if (_.isArray(target)) { const [peekKey] = restKeys; if (peekKey != null && !isNaN(parseInt(peekKey))) { walk(target, restKeys); // numeric: consume the index on next iteration } else { target.forEach((item) => { if (item != null) walk(item, restKeys); }); } } else { walk(target, restKeys); } }; walk(obj, key.split(".")); } /** * Applies a list of regex-replace patterns to the value at the given key path. */ applyRegexReplace(obj, key, patterns, ignoreCase) { this.traverseKeyPath(obj, key, ignoreCase, (parent, k) => { const v = parent[k]; if (v == null) return; let result = v; for (const pattern of patterns) { try { result = performRegexReplace(result, parseRegexReplace(pattern)); } catch { // ignore invalid regex } } parent[k] = result; }); } /** * Recursively walks `obj` (depth-first) and calls `fn` on every node whose * key matches `leafKey` (case-sensitively, or case-insensitively when * `ignoreCase` is true). Traverses into arrays and plain objects. */ applyRecursive(obj, leafKey, fn, ignoreCase) { if (!_.isObjectLike(obj)) return; if (_.isArray(obj)) { obj.forEach((item) => this.applyRecursive(item, leafKey, fn, ignoreCase)); return; } // Apply at this level if a matching key exists const matchingKey = Object.keys(obj).find((k) => ignoreCase === true ? k.toLowerCase() === leafKey.toLowerCase() : k === leafKey); if (matchingKey !== undefined) { fn(obj, matchingKey); } // Recurse into all child values Object.values(obj).forEach((value) => this.applyRecursive(value, leafKey, fn, ignoreCase)); } obfuscateKey(obj, key, pattern, ignoreCase) { const p = pattern ?? C8yDefaultPactPreprocessor.defaultObfuscationPattern; const keyParts = key.split("."); if (this.hasKey(keyParts, "set-cookie")) { this.obfuscateSetCookie(obj, keyParts, p, ignoreCase); } else if (this.hasKey(keyParts, "cookie")) { this.obfuscateCookie(obj, keyParts, p, ignoreCase); } else { const isAuthorizationKey = this.hasKey(keyParts, "authorization"); this.traverseKeyPath(obj, key, ignoreCase, (parent, k) => { const value = parent[k]; if (value == null) return; const isAuthKey = isAuthorizationKey || (ignoreCase === true ? k.toLowerCase() === "authorization" : k === "authorization"); const authMatch = isAuthKey && _.isString(value) ? value.match(/^(Bearer|Basic)\s+(.+)$/i) : null; parent[k] = authMatch && authMatch[2]?.trim() ? `${authMatch[1]} ${p}` : p; }); } } obfuscateSetCookie(obj, keyParts, obfuscationPattern, ignoreCase) { const { name, keyPath, cookieHeader } = this.getCookieObject(obj, keyParts, ignoreCase); if (!cookieHeader) return; const cookies = setCookieParser.parse(cookieHeader, { decodeValues: false }) ?? []; if (cookies.length) { const fixedCookies = cookies.reduce((acc, cookie) => { const n = name?.toLowerCase(); const shouldObfuscate = !n || (n && n === cookie.name?.toLowerCase()); const cookieValue = shouldObfuscate ? (obfuscationPattern ?? "") : cookie.value; acc.push(libCookie.serialize(cookie.name, cookieValue, cookie)); return acc; }, []); _.set(obj, keyPath, fixedCookies); } } obfuscateCookie(obj, keyParts, obfuscationPattern, ignoreCase) { const { name, keyPath, cookieHeader } = this.getCookieObject(obj, keyParts, ignoreCase); if (!cookieHeader) return; const cookies = libCookie.parse(cookieHeader); Object.keys(cookies).forEach((cookieName) => { if (name != null && cookieName.toLowerCase() !== name.toLowerCase()) return; cookies[cookieName] = obfuscationPattern; }); const result = Object.entries(cookies) .map(([n, v]) => `${n}=${v}`) .join("; "); _.set(obj, keyPath, result); } resolveOptions(options) { return _.defaults(options, this.options, C8yPactPreprocessorDefaultOptions); } hasKey(keyPath, key) { return ((_.isArray(keyPath) ? keyPath : keyPath.split(".")).filter((k) => k.toLowerCase() === key.toLowerCase()).length > 0); } getCookieObject(obj, keyParts, ignoreCase) { let name = undefined; const l = _.last(keyParts)?.toLowerCase(); if (l !== "cookie" && l !== "set-cookie") { name = _.last(keyParts); keyParts = keyParts.slice(0, -1); } // Resolve case-sensitive path only if ignoreCase is enabled const keyPath = ignoreCase === true ? (toSensitiveObjectKeyPath(obj, keyParts) ?? keyParts.join(".")) : keyParts.join("."); const cookieHeader = _.get(obj, keyPath); return { name, keyPath, cookieHeader }; } } C8yDefaultPactPreprocessor.defaultObfuscationPattern = C8yPactPreprocessorDefaultOptions.obfuscationPattern; export function parseRegexReplace(input) { if (!input || !_.isString(input)) { throw new Error("Invalid replacement expression input. Regex must be a string."); } // Match a regex pattern with replacement in format /pattern/replacement/flags const match = input.match(/^\/(.+?)(?<!\\)\/(.*?)(?<!\\)\/([gimsuy]*)$/); if (!match) { throw new Error(`Invalid replacement regular expression: ${input}`); } const [, patternStr, replacement, flags] = match; return { pattern: new RegExp(patternStr, flags), replacement: replacement, }; } export function performRegexReplace(input, regexes) { if (!input) return input; // Convert single regex to array for uniform handling const regexArray = Array.isArray(regexes) ? regexes : [regexes]; if (regexArray.length === 0) return input; // Direct string replacement if (_.isString(input)) { return regexArray.reduce((result, regex) => result.replace(regex.pattern, regex.replacement), input); } // Object/array traversal - do a single traversal applying all regexes if (_.isObjectLike(input)) { return _.cloneDeepWith(input, (value) => { if (_.isString(value)) { // Apply all regex replacements to the string value return regexArray.reduce((result, regex) => result.replace(regex.pattern, regex.replacement), value); } return undefined; // Return undefined for default cloning }); } // Return unchanged for other types return input; }