Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion lib/apicontrollersbase.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,49 @@ var constants = require('./constants').constants;

var logger;

/**
* Safely extract and mask sensitive data from error objects before logging.
* Handles axios errors which may contain request config with sensitive data.
*/
function maskErrorForLogging(error) {
var safeError = {
message: error.message || 'Unknown error',
name: error.name || 'Error'
};

// If it's an axios error, extract safe response info
if (error.response) {
safeError.status = error.response.status;
safeError.statusText = error.response.statusText;
// Mask sensitive fields in response data if present
if (error.response.data) {
var dataCopy = JSON.parse(JSON.stringify(error.response.data));
Logger.maskSensitiveFields(dataCopy);
safeError.responseData = dataCopy;
}
}

// If axios error has config with request data, mask it
if (error.config && error.config.data) {
try {
var configData = typeof error.config.data === 'string'
? JSON.parse(error.config.data)
: JSON.parse(JSON.stringify(error.config.data));
Logger.maskSensitiveFields(configData);
safeError.requestData = configData;
} catch (e) {
// If parsing fails, don't include request data to avoid leaking sensitive info
safeError.requestData = '[Unable to parse - omitted for security]';
}
}

if (error.code) {
safeError.code = error.code;
}

return safeError;
}

class APIOperationBase {
constructor(apiRequest, externalConfig = null) {
logger = Logger.getLogger('ApiOperationBase', externalConfig);
Expand Down Expand Up @@ -115,7 +158,9 @@ class APIOperationBase {
}
}).catch(error => {
obj._error = error;
logger.error(error);
// Mask sensitive data in error objects before logging
var maskedError = maskErrorForLogging(error);
logger.error(JSON.stringify(maskedError));
});

logger.debug('Exit APIOperationBase execute');
Expand Down
165 changes: 155 additions & 10 deletions lib/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,75 @@ const { combine, timestamp, label, printf } = format;
require('winston-daily-rotate-file');
var config = require('./config').config;

var sensitiveFields = ['cardCode', 'cardNumber', 'expirationDate', 'accountNumber', 'nameOnAccount', 'transactionKey', 'email', 'phoneNumber', 'faxNumber', 'dateOfBirth'];
/**
* FAIL-CLOSED DESIGN: Allow-list of fields that are SAFE to log.
* All other leaf values are masked by default to prevent accidental
* exposure of PCI/SAD/credential data (CWE-532).
*
* Only add fields here if they are:
* - Non-sensitive metadata (IDs, codes, timestamps, flags)
* - Already public information
* - Result/status indicators
*/
var safeFields = [
// Result/status fields
'resultcode', 'messagecode', 'code', 'errorcode', 'responsecode',
'responsereasoncode', 'responsetext', 'responsereasontext',
'status', 'statustext', 'state', 'successful',
// Identifiers (non-sensitive)
'transid', 'transactionid', 'refid', 'reftransid', 'authcode',
'batchid', 'settlementtimeutc', 'settlementtimelocal',
'customerprofileid', 'customerpaymentprofileid', 'customeraddressid',
'subscriptionid', 'profileid', 'paymentprofileid', 'shippingprofileid',
// Metadata
'type', 'method', 'action', 'requesttype', 'responsetype',
'version', 'timestamp', 'datetime', 'date', 'time',
// Structure/format indicators
'formatted', 'encoding', 'encryptionalgorithm', 'descriptor',
'emvdescriptor', 'emvversion', 'datadescriptor',
// Counts and amounts (non-sensitive numeric values)
'totalcount', 'count', 'quantity', 'amount', 'settleamount',
'taxamount', 'dutyamount', 'freightamount', 'discountamount',
// Boolean flags
'testrequest', 'taxexempt', 'recurringbilling', 'processingmode',
// Message content (typically human-readable descriptions)
'message', 'text', 'description', 'errormessage',
// Order/invoice metadata
'invoicenumber', 'ponumber', 'purchaseordernumber',
// Card metadata (non-sensitive)
'cardtype', 'entrymode', 'accounttype',
// Network/device info
'clientid', 'useragent', 'ipaddress', 'devicetype',
// Pagination
'offset', 'limit', 'pagenumber', 'pagesize', 'totalnuminsettlement',
// AVS/CVV response codes (single char codes, not actual values)
'avscode', 'cavvresponsecode', 'cvvresultcode',
// Merchant info (public)
'merchantcustomerid', 'merchantname'
];

// Create a Set for O(1) lookup with lowercase keys
var safeFieldsSet = new Set(safeFields);

const maskedLoggingFormat = printf(({ level, message, label, timestamp }) => {
// FAIL-CLOSED: Always mask messages, regardless of format
var maskedMessage = maskMessage(message);
return `[${timestamp}] [${level.toUpperCase()}] [${label}] : ${maskedMessage}`;
});

/**
* Safely mask any message - handles both JSON and non-JSON formats.
* Non-JSON messages are wrapped and masked to prevent bypass via string concatenation.
*/
function maskMessage(message) {
if (isJson(message)) {
return `[${timestamp}] [${level.toUpperCase()}] [${label}] : ${maskSensitiveFields(JSON.parse(message))}`;
return maskSensitiveFields(JSON.parse(message));
} else {
return `[${timestamp}] [${level.toUpperCase()}] [${label}] : ${message}`;
// Non-JSON messages: wrap in a safe structure and mask
// This prevents sensitive data from leaking via string concatenation
return maskNonJsonMessage(message);
}
});
}

function isJson(str) {
try {
Expand All @@ -23,6 +83,50 @@ function isJson(str) {
return true;
}

/**
* Mask non-JSON messages by detecting and redacting common sensitive patterns.
* This ensures that string-concatenated log lines don't bypass masking.
*/
function maskNonJsonMessage(message) {
if (typeof message !== 'string') {
message = String(message);
}

// Patterns that may indicate sensitive data in string format
var sensitivePatterns = [
// Credit card numbers (13-19 digits, possibly with spaces/dashes)
/\b(?:\d[ -]*?){13,19}\b/g,
// CVV/CVC (3-4 digits preceded by common labels)
/\b(cvv|cvc|cvv2|cvc2|cardcode)[:\s]*\d{3,4}\b/gi,
// API keys, tokens (long alphanumeric strings)
/\b(apikey|apiloginid|transactionkey|accesstoken|sessiontoken|token|key)[:\s]*[A-Za-z0-9+/=]{8,}\b/gi,
// Email addresses
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
// Phone numbers (various formats)
/\b(\+?1?[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
// SSN
/\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/g,
// Routing numbers (9 digits)
/\b(routing|aba)[:\s]*\d{9}\b/gi,
// Account numbers (variable length)
/\b(account|acct)[:\s]*\d{4,17}\b/gi
];

var maskedMessage = message;
for (var pattern of sensitivePatterns) {
maskedMessage = maskedMessage.replace(pattern, function(match) {
// Preserve labels but mask values
var labelMatch = match.match(/^([a-zA-Z]+[:\s]*)/i);
if (labelMatch) {
return labelMatch[1] + 'XXXXXXXX';
}
return 'XXXXXXXX';
});
}

return maskedMessage;
}

function createTransportFromConfig(tempConfig) {
var transports = [];

Expand Down Expand Up @@ -87,17 +191,58 @@ function maskSensitiveFields(jsonMsg) {
var prop;

for (prop in jsonMsg) {
var isFieldSensitive = (sensitiveFields.indexOf(prop) > -1);

if (isFieldSensitive === true) {
jsonMsg[prop] = new Array(jsonMsg[prop].length + 1).join('X');
if (!jsonMsg.hasOwnProperty(prop)) {
continue;
}
else if (jsonMsg.hasOwnProperty(prop)) {
maskSensitiveFields(jsonMsg[prop]);

var value = jsonMsg[prop];
var propLower = prop.toLowerCase();

// Check if this is a leaf value (not an object/array)
if (value === null || value === undefined) {
// Keep null/undefined as-is
continue;
} else if (typeof value === 'object' && !Array.isArray(value)) {
// Recurse into nested objects
maskSensitiveFields(value);
} else if (Array.isArray(value)) {
// Process each array element
for (var i = 0; i < value.length; i++) {
if (typeof value[i] === 'object' && value[i] !== null) {
maskSensitiveFields(value[i]);
} else if (!safeFieldsSet.has(propLower)) {
// Mask array leaf values unless field is in safe list
value[i] = maskValue(value[i]);
}
}
} else {
// FAIL-CLOSED: Mask leaf values UNLESS field is in safe allow-list
if (!safeFieldsSet.has(propLower)) {
jsonMsg[prop] = maskValue(value);
}
}
}
}

return JSON.stringify(jsonMsg);
}

/**
* Safely mask a value, handling various types.
*/
function maskValue(value) {
if (value === null || value === undefined) {
return value;
}
// Coerce to string and mask with X's
var valueStr = String(value);
if (valueStr.length === 0) {
return '';
}
return new Array(valueStr.length + 1).join('X');
}

// Export maskSensitiveFields for use by other modules
exports.maskSensitiveFields = maskSensitiveFields;
exports.maskValue = maskValue;