Skip to content

Commit 33e2f02

Browse files
committed
Mask secret fields from logging.
Signed-off-by: Rupesh J <rupesh.j@salesforce.com>
1 parent 5a7d525 commit 33e2f02

2 files changed

Lines changed: 162 additions & 2 deletions

File tree

src/commands/browserforce/apply.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,28 @@
1+
import { readFileSync } from 'fs';
12
import { BrowserforceCommand } from '../../browserforce-command.js';
3+
import { maskSensitiveValues } from '../../plugins/utils.js';
4+
5+
// Convert camelCase to kebab-case (e.g., "authProviders" -> "auth-providers")
6+
function camelToKebab(str: string): string {
7+
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
8+
}
9+
10+
// Load schema for a plugin
11+
function loadPluginSchema(pluginName: string): unknown | undefined {
12+
try {
13+
// Resolve schema path relative to the plugins directory
14+
// Since we're in src/commands/browserforce/, we need to go up to src/plugins/
15+
const schemaPath = new URL(
16+
`../../plugins/${camelToKebab(pluginName)}/schema.json`,
17+
import.meta.url,
18+
);
19+
const schemaContent = readFileSync(schemaPath, 'utf8');
20+
return JSON.parse(schemaContent);
21+
} catch (error) {
22+
// Schema file not found or invalid - return undefined to fall back to pattern matching
23+
return undefined;
24+
}
25+
}
226

327
type BrowserforceApplyResponse = {
428
success: boolean;
@@ -35,10 +59,14 @@ export class BrowserforceApply extends BrowserforceCommand<BrowserforceApplyResp
3559
const diff = instance.diff(state, setting.value);
3660
const action = flags['dry-run'] ? 'would change' : 'changing';
3761
if (diff !== undefined) {
62+
// Load schema for this plugin to check for password fields
63+
const schema = loadPluginSchema(setting.key);
64+
// Mask sensitive values before logging (using schema if available)
65+
const maskedDiff = maskSensitiveValues(diff, '', schema) as typeof diff;
3866
this.spinner.start(
39-
`[${driver.name}] ${Object.keys(diff)
67+
`[${driver.name}] ${Object.keys(maskedDiff)
4068
.map((key) => {
41-
return `${action} '${key}' to '${JSON.stringify(diff[key])}'`;
69+
return `changing '${key}' to '${JSON.stringify(maskedDiff[key])}'`;
4270
})
4371
.join('\n')}`,
4472
);

src/plugins/utils.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,135 @@ export function deepDiff<T extends unknown>(source: T | undefined, target: T | u
4545
// simple value
4646
return target;
4747
}
48+
49+
/**
50+
* Extracts password field names from a JSON schema
51+
* @param schema The JSON schema object
52+
* @param prefix The current path prefix (for nested schemas)
53+
* @returns Set of field paths that are marked as password type
54+
*/
55+
function extractPasswordFields(schema: unknown, prefix = ''): Set<string> {
56+
const passwordFields = new Set<string>();
57+
58+
if (typeof schema !== 'object' || schema === null) {
59+
return passwordFields;
60+
}
61+
62+
const schemaObj = schema as Record<string, unknown>;
63+
64+
// Check if this schema object is marked as password using x-password property
65+
if (schemaObj['x-password'] === true) {
66+
if (prefix) {
67+
passwordFields.add(prefix);
68+
}
69+
}
70+
71+
// Handle patternProperties (for dynamic keys like auth-providers)
72+
if (schemaObj.patternProperties && typeof schemaObj.patternProperties === 'object') {
73+
const patternProps = schemaObj.patternProperties as Record<string, unknown>;
74+
for (const subSchema of Object.values(patternProps)) {
75+
const subPasswordFields = extractPasswordFields(subSchema, prefix);
76+
subPasswordFields.forEach((field) => passwordFields.add(field));
77+
}
78+
}
79+
80+
// Handle properties (for fixed keys)
81+
if (schemaObj.properties && typeof schemaObj.properties === 'object') {
82+
const properties = schemaObj.properties as Record<string, unknown>;
83+
for (const [key, propSchema] of Object.entries(properties)) {
84+
const currentPath = prefix ? `${prefix}.${key}` : key;
85+
const propPasswordFields = extractPasswordFields(propSchema, currentPath);
86+
propPasswordFields.forEach((field) => passwordFields.add(field));
87+
}
88+
}
89+
90+
return passwordFields;
91+
}
92+
93+
/**
94+
* Checks if a field path matches any password field from the schema
95+
* @param fieldPath The field path to check (e.g., "consumerSecret" or "test.consumerSecret")
96+
* @param passwordFields Set of password field paths from schema
97+
* @returns True if the field should be masked
98+
*/
99+
function isPasswordField(fieldPath: string, passwordFields: Set<string>): boolean {
100+
// Check exact match
101+
if (passwordFields.has(fieldPath)) {
102+
return true;
103+
}
104+
105+
// Check if any password field is a suffix of the current path
106+
// e.g., if schema has "consumerSecret" and path is "test.consumerSecret"
107+
const passwordFieldsArray = Array.from(passwordFields);
108+
for (const passwordField of passwordFieldsArray) {
109+
if (fieldPath.endsWith(`.${passwordField}`) || fieldPath === passwordField) {
110+
return true;
111+
}
112+
}
113+
114+
return false;
115+
}
116+
117+
/**
118+
* Masks sensitive values in an object for safe logging
119+
* Fields matching patterns like "secret", "password", "key", "token", etc. will be masked
120+
* Additionally, fields marked as type "password" in the schema will be masked
121+
* @param value The value to mask (can be object, array, or primitive)
122+
* @param keyPath The current key path (for nested objects)
123+
* @param schema Optional JSON schema to check for password type fields
124+
* @returns Masked value safe for logging
125+
*/
126+
export function maskSensitiveValues(value: unknown, keyPath = '', schema?: unknown): unknown {
127+
// Extract password fields from schema if provided
128+
const passwordFields = schema ? extractPasswordFields(schema) : new Set<string>();
129+
130+
// List of patterns that indicate sensitive fields (fallback for when schema is not available)
131+
const sensitivePatterns = [
132+
/secret/i,
133+
/password/i,
134+
/token/i,
135+
/key/i,
136+
/credential/i,
137+
/auth/i,
138+
/api[_-]?key/i,
139+
/access[_-]?token/i,
140+
];
141+
142+
const isSensitiveField = (key: string): boolean => {
143+
// First check schema-based password fields
144+
if (passwordFields.size > 0 && isPasswordField(key, passwordFields)) {
145+
return true;
146+
}
147+
// Fallback to pattern matching
148+
return sensitivePatterns.some((pattern) => pattern.test(key));
149+
};
150+
151+
if (value === null || value === undefined) {
152+
return value;
153+
}
154+
155+
// If it's a string and we're in a sensitive field context, mask it
156+
if (typeof value === 'string' && keyPath && isSensitiveField(keyPath)) {
157+
return '****';
158+
}
159+
160+
// If it's an object, recursively mask nested values
161+
if (typeof value === 'object') {
162+
if (Array.isArray(value)) {
163+
return value.map((item, index) => maskSensitiveValues(item, `${keyPath}[${index}]`, schema));
164+
} else {
165+
const masked: Record<string, unknown> = {};
166+
for (const [key, val] of Object.entries(value)) {
167+
const currentPath = keyPath ? `${keyPath}.${key}` : key;
168+
if (isSensitiveField(key) && typeof val === 'string' && val.length > 0) {
169+
masked[key] = '****';
170+
} else {
171+
masked[key] = maskSensitiveValues(val, currentPath, schema);
172+
}
173+
}
174+
return masked;
175+
}
176+
}
177+
178+
return value;
179+
}

0 commit comments

Comments
 (0)