530 lines
15 KiB
JavaScript
530 lines
15 KiB
JavaScript
'use strict'
|
|
|
|
function deepClone (obj) {
|
|
if (obj === null || typeof obj !== 'object') {
|
|
return obj
|
|
}
|
|
|
|
if (obj instanceof Date) {
|
|
return new Date(obj.getTime())
|
|
}
|
|
|
|
if (obj instanceof Array) {
|
|
const cloned = []
|
|
for (let i = 0; i < obj.length; i++) {
|
|
cloned[i] = deepClone(obj[i])
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
if (typeof obj === 'object') {
|
|
const cloned = Object.create(Object.getPrototypeOf(obj))
|
|
for (const key in obj) {
|
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
cloned[key] = deepClone(obj[key])
|
|
}
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
return obj
|
|
}
|
|
|
|
function parsePath (path) {
|
|
const parts = []
|
|
let current = ''
|
|
let inBrackets = false
|
|
let inQuotes = false
|
|
let quoteChar = ''
|
|
|
|
for (let i = 0; i < path.length; i++) {
|
|
const char = path[i]
|
|
|
|
if (!inBrackets && char === '.') {
|
|
if (current) {
|
|
parts.push(current)
|
|
current = ''
|
|
}
|
|
} else if (char === '[') {
|
|
if (current) {
|
|
parts.push(current)
|
|
current = ''
|
|
}
|
|
inBrackets = true
|
|
} else if (char === ']' && inBrackets) {
|
|
// Always push the current value when closing brackets, even if it's an empty string
|
|
parts.push(current)
|
|
current = ''
|
|
inBrackets = false
|
|
inQuotes = false
|
|
} else if ((char === '"' || char === "'") && inBrackets) {
|
|
if (!inQuotes) {
|
|
inQuotes = true
|
|
quoteChar = char
|
|
} else if (char === quoteChar) {
|
|
inQuotes = false
|
|
quoteChar = ''
|
|
} else {
|
|
current += char
|
|
}
|
|
} else {
|
|
current += char
|
|
}
|
|
}
|
|
|
|
if (current) {
|
|
parts.push(current)
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
function setValue (obj, parts, value) {
|
|
let current = obj
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const key = parts[i]
|
|
// Type safety: Check if current is an object before using 'in' operator
|
|
if (typeof current !== 'object' || current === null || !(key in current)) {
|
|
return false // Path doesn't exist, don't create it
|
|
}
|
|
if (typeof current[key] !== 'object' || current[key] === null) {
|
|
return false // Path doesn't exist properly
|
|
}
|
|
current = current[key]
|
|
}
|
|
|
|
const lastKey = parts[parts.length - 1]
|
|
if (lastKey === '*') {
|
|
if (Array.isArray(current)) {
|
|
for (let i = 0; i < current.length; i++) {
|
|
current[i] = value
|
|
}
|
|
} else if (typeof current === 'object' && current !== null) {
|
|
for (const key in current) {
|
|
if (Object.prototype.hasOwnProperty.call(current, key)) {
|
|
current[key] = value
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Type safety: Check if current is an object before using 'in' operator
|
|
if (typeof current === 'object' && current !== null && lastKey in current && Object.prototype.hasOwnProperty.call(current, lastKey)) {
|
|
current[lastKey] = value
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
function removeKey (obj, parts) {
|
|
let current = obj
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const key = parts[i]
|
|
// Type safety: Check if current is an object before using 'in' operator
|
|
if (typeof current !== 'object' || current === null || !(key in current)) {
|
|
return false // Path doesn't exist, don't create it
|
|
}
|
|
if (typeof current[key] !== 'object' || current[key] === null) {
|
|
return false // Path doesn't exist properly
|
|
}
|
|
current = current[key]
|
|
}
|
|
|
|
const lastKey = parts[parts.length - 1]
|
|
if (lastKey === '*') {
|
|
if (Array.isArray(current)) {
|
|
// For arrays, we can't really "remove" all items as that would change indices
|
|
// Instead, we set them to undefined which will be omitted by JSON.stringify
|
|
for (let i = 0; i < current.length; i++) {
|
|
current[i] = undefined
|
|
}
|
|
} else if (typeof current === 'object' && current !== null) {
|
|
for (const key in current) {
|
|
if (Object.prototype.hasOwnProperty.call(current, key)) {
|
|
delete current[key]
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Type safety: Check if current is an object before using 'in' operator
|
|
if (typeof current === 'object' && current !== null && lastKey in current && Object.prototype.hasOwnProperty.call(current, lastKey)) {
|
|
delete current[lastKey]
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Sentinel object to distinguish between undefined value and non-existent path
|
|
const PATH_NOT_FOUND = Symbol('PATH_NOT_FOUND')
|
|
|
|
function getValueIfExists (obj, parts) {
|
|
let current = obj
|
|
|
|
for (const part of parts) {
|
|
if (current === null || current === undefined) {
|
|
return PATH_NOT_FOUND
|
|
}
|
|
// Type safety: Check if current is an object before property access
|
|
if (typeof current !== 'object' || current === null) {
|
|
return PATH_NOT_FOUND
|
|
}
|
|
// Check if the property exists before accessing it
|
|
if (!(part in current)) {
|
|
return PATH_NOT_FOUND
|
|
}
|
|
current = current[part]
|
|
}
|
|
|
|
return current
|
|
}
|
|
|
|
function getValue (obj, parts) {
|
|
let current = obj
|
|
|
|
for (const part of parts) {
|
|
if (current === null || current === undefined) {
|
|
return undefined
|
|
}
|
|
// Type safety: Check if current is an object before property access
|
|
if (typeof current !== 'object' || current === null) {
|
|
return undefined
|
|
}
|
|
current = current[part]
|
|
}
|
|
|
|
return current
|
|
}
|
|
|
|
function redactPaths (obj, paths, censor, remove = false) {
|
|
for (const path of paths) {
|
|
const parts = parsePath(path)
|
|
|
|
if (parts.includes('*')) {
|
|
redactWildcardPath(obj, parts, censor, path, remove)
|
|
} else {
|
|
if (remove) {
|
|
removeKey(obj, parts)
|
|
} else {
|
|
// Get value only if path exists - single traversal
|
|
const value = getValueIfExists(obj, parts)
|
|
if (value === PATH_NOT_FOUND) {
|
|
continue
|
|
}
|
|
|
|
const actualCensor = typeof censor === 'function'
|
|
? censor(value, parts)
|
|
: censor
|
|
setValue(obj, parts, actualCensor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function redactWildcardPath (obj, parts, censor, originalPath, remove = false) {
|
|
const wildcardIndex = parts.indexOf('*')
|
|
|
|
if (wildcardIndex === parts.length - 1) {
|
|
const parentParts = parts.slice(0, -1)
|
|
let current = obj
|
|
|
|
for (const part of parentParts) {
|
|
if (current === null || current === undefined) return
|
|
// Type safety: Check if current is an object before property access
|
|
if (typeof current !== 'object' || current === null) return
|
|
current = current[part]
|
|
}
|
|
|
|
if (Array.isArray(current)) {
|
|
if (remove) {
|
|
// For arrays, set all items to undefined which will be omitted by JSON.stringify
|
|
for (let i = 0; i < current.length; i++) {
|
|
current[i] = undefined
|
|
}
|
|
} else {
|
|
for (let i = 0; i < current.length; i++) {
|
|
const indexPath = [...parentParts, i.toString()]
|
|
const actualCensor = typeof censor === 'function'
|
|
? censor(current[i], indexPath)
|
|
: censor
|
|
current[i] = actualCensor
|
|
}
|
|
}
|
|
} else if (typeof current === 'object' && current !== null) {
|
|
if (remove) {
|
|
// Collect keys to delete to avoid issues with deleting during iteration
|
|
const keysToDelete = []
|
|
for (const key in current) {
|
|
if (Object.prototype.hasOwnProperty.call(current, key)) {
|
|
keysToDelete.push(key)
|
|
}
|
|
}
|
|
for (const key of keysToDelete) {
|
|
delete current[key]
|
|
}
|
|
} else {
|
|
for (const key in current) {
|
|
const keyPath = [...parentParts, key]
|
|
const actualCensor = typeof censor === 'function'
|
|
? censor(current[key], keyPath)
|
|
: censor
|
|
current[key] = actualCensor
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
redactIntermediateWildcard(obj, parts, censor, wildcardIndex, originalPath, remove)
|
|
}
|
|
}
|
|
|
|
function redactIntermediateWildcard (obj, parts, censor, wildcardIndex, originalPath, remove = false) {
|
|
const beforeWildcard = parts.slice(0, wildcardIndex)
|
|
const afterWildcard = parts.slice(wildcardIndex + 1)
|
|
const pathArray = [] // Cached array to avoid allocations
|
|
|
|
function traverse (current, pathLength) {
|
|
if (pathLength === beforeWildcard.length) {
|
|
if (Array.isArray(current)) {
|
|
for (let i = 0; i < current.length; i++) {
|
|
pathArray[pathLength] = i.toString()
|
|
traverse(current[i], pathLength + 1)
|
|
}
|
|
} else if (typeof current === 'object' && current !== null) {
|
|
for (const key in current) {
|
|
pathArray[pathLength] = key
|
|
traverse(current[key], pathLength + 1)
|
|
}
|
|
}
|
|
} else if (pathLength < beforeWildcard.length) {
|
|
const nextKey = beforeWildcard[pathLength]
|
|
// Type safety: Check if current is an object before using 'in' operator
|
|
if (current && typeof current === 'object' && current !== null && nextKey in current) {
|
|
pathArray[pathLength] = nextKey
|
|
traverse(current[nextKey], pathLength + 1)
|
|
}
|
|
} else {
|
|
// Check if afterWildcard contains more wildcards
|
|
if (afterWildcard.includes('*')) {
|
|
// Recursively handle remaining wildcards
|
|
// Wrap censor to prepend current path context
|
|
const wrappedCensor = typeof censor === 'function'
|
|
? (value, path) => {
|
|
const fullPath = [...pathArray.slice(0, pathLength), ...path]
|
|
return censor(value, fullPath)
|
|
}
|
|
: censor
|
|
redactWildcardPath(current, afterWildcard, wrappedCensor, originalPath, remove)
|
|
} else {
|
|
// No more wildcards, apply the redaction directly
|
|
if (remove) {
|
|
removeKey(current, afterWildcard)
|
|
} else {
|
|
const actualCensor = typeof censor === 'function'
|
|
? censor(getValue(current, afterWildcard), [...pathArray.slice(0, pathLength), ...afterWildcard])
|
|
: censor
|
|
setValue(current, afterWildcard, actualCensor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (beforeWildcard.length === 0) {
|
|
traverse(obj, 0)
|
|
} else {
|
|
let current = obj
|
|
for (let i = 0; i < beforeWildcard.length; i++) {
|
|
const part = beforeWildcard[i]
|
|
if (current === null || current === undefined) return
|
|
// Type safety: Check if current is an object before property access
|
|
if (typeof current !== 'object' || current === null) return
|
|
current = current[part]
|
|
pathArray[i] = part
|
|
}
|
|
if (current !== null && current !== undefined) {
|
|
traverse(current, beforeWildcard.length)
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildPathStructure (pathsToClone) {
|
|
if (pathsToClone.length === 0) {
|
|
return null // No paths to redact
|
|
}
|
|
|
|
// Parse all paths and organize by depth
|
|
const pathStructure = new Map()
|
|
for (const path of pathsToClone) {
|
|
const parts = parsePath(path)
|
|
let current = pathStructure
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i]
|
|
if (!current.has(part)) {
|
|
current.set(part, new Map())
|
|
}
|
|
current = current.get(part)
|
|
}
|
|
}
|
|
return pathStructure
|
|
}
|
|
|
|
function selectiveClone (obj, pathStructure) {
|
|
if (!pathStructure) {
|
|
return obj // No paths to redact, return original
|
|
}
|
|
|
|
function cloneSelectively (source, pathMap, depth = 0) {
|
|
if (!pathMap || pathMap.size === 0) {
|
|
return source // No more paths to clone, return reference
|
|
}
|
|
|
|
if (source === null || typeof source !== 'object') {
|
|
return source
|
|
}
|
|
|
|
if (source instanceof Date) {
|
|
return new Date(source.getTime())
|
|
}
|
|
|
|
if (Array.isArray(source)) {
|
|
const cloned = []
|
|
for (let i = 0; i < source.length; i++) {
|
|
const indexStr = i.toString()
|
|
if (pathMap.has(indexStr) || pathMap.has('*')) {
|
|
cloned[i] = cloneSelectively(source[i], pathMap.get(indexStr) || pathMap.get('*'))
|
|
} else {
|
|
cloned[i] = source[i] // Share reference for non-redacted items
|
|
}
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
// Handle objects
|
|
const cloned = Object.create(Object.getPrototypeOf(source))
|
|
for (const key in source) {
|
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
|
if (pathMap.has(key) || pathMap.has('*')) {
|
|
cloned[key] = cloneSelectively(source[key], pathMap.get(key) || pathMap.get('*'))
|
|
} else {
|
|
cloned[key] = source[key] // Share reference for non-redacted properties
|
|
}
|
|
}
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
return cloneSelectively(obj, pathStructure)
|
|
}
|
|
|
|
function validatePath (path) {
|
|
if (typeof path !== 'string') {
|
|
throw new Error('Paths must be (non-empty) strings')
|
|
}
|
|
|
|
if (path === '') {
|
|
throw new Error('Invalid redaction path ()')
|
|
}
|
|
|
|
// Check for double dots
|
|
if (path.includes('..')) {
|
|
throw new Error(`Invalid redaction path (${path})`)
|
|
}
|
|
|
|
// Check for comma-separated paths (invalid syntax)
|
|
if (path.includes(',')) {
|
|
throw new Error(`Invalid redaction path (${path})`)
|
|
}
|
|
|
|
// Check for unmatched brackets
|
|
let bracketCount = 0
|
|
let inQuotes = false
|
|
let quoteChar = ''
|
|
|
|
for (let i = 0; i < path.length; i++) {
|
|
const char = path[i]
|
|
|
|
if ((char === '"' || char === "'") && bracketCount > 0) {
|
|
if (!inQuotes) {
|
|
inQuotes = true
|
|
quoteChar = char
|
|
} else if (char === quoteChar) {
|
|
inQuotes = false
|
|
quoteChar = ''
|
|
}
|
|
} else if (char === '[' && !inQuotes) {
|
|
bracketCount++
|
|
} else if (char === ']' && !inQuotes) {
|
|
bracketCount--
|
|
if (bracketCount < 0) {
|
|
throw new Error(`Invalid redaction path (${path})`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bracketCount !== 0) {
|
|
throw new Error(`Invalid redaction path (${path})`)
|
|
}
|
|
}
|
|
|
|
function validatePaths (paths) {
|
|
if (!Array.isArray(paths)) {
|
|
throw new TypeError('paths must be an array')
|
|
}
|
|
|
|
for (const path of paths) {
|
|
validatePath(path)
|
|
}
|
|
}
|
|
|
|
function slowRedact (options = {}) {
|
|
const {
|
|
paths = [],
|
|
censor = '[REDACTED]',
|
|
serialize = JSON.stringify,
|
|
strict = true,
|
|
remove = false
|
|
} = options
|
|
|
|
// Validate paths upfront to match fast-redact behavior
|
|
validatePaths(paths)
|
|
|
|
// Build path structure once during setup, not on every call
|
|
const pathStructure = buildPathStructure(paths)
|
|
|
|
return function redact (obj) {
|
|
if (strict && (obj === null || typeof obj !== 'object')) {
|
|
if (obj === null || obj === undefined) {
|
|
return serialize ? serialize(obj) : obj
|
|
}
|
|
if (typeof obj !== 'object') {
|
|
return serialize ? serialize(obj) : obj
|
|
}
|
|
}
|
|
|
|
// Only clone paths that need redaction
|
|
const cloned = selectiveClone(obj, pathStructure)
|
|
const original = obj // Keep reference to original for restore
|
|
|
|
let actualCensor = censor
|
|
if (typeof censor === 'function') {
|
|
actualCensor = censor
|
|
}
|
|
|
|
redactPaths(cloned, paths, actualCensor, remove)
|
|
|
|
if (serialize === false) {
|
|
cloned.restore = function () {
|
|
return deepClone(original) // Full clone only when restore is called
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
if (typeof serialize === 'function') {
|
|
return serialize(cloned)
|
|
}
|
|
|
|
return JSON.stringify(cloned)
|
|
}
|
|
}
|
|
|
|
module.exports = slowRedact
|