@appliqation/automation-sdk 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ const readline = require('readline');
2
+ const UuidValidator = require('../utils/UuidValidator');
3
+ const UuidExtractor = require('../reporters/playwright/helpers/UuidExtractor');
4
+ const logger = require('../utils/logger');
5
+
6
+ const STRATEGY = {
7
+ CANCEL: 'cancel',
8
+ ADHOC: 'adhoc',
9
+ FILTER: 'filter'
10
+ };
11
+
12
+ const VALID_STRATEGIES = Object.values(STRATEGY);
13
+
14
+ /**
15
+ * Validates that the UUIDs about to be executed match the user's configured
16
+ * scenarioId or testSetId scope, and applies a resolution strategy when they
17
+ * don't.
18
+ *
19
+ * Closes finding C5 in the SDK audit — without this, the SDK silently
20
+ * submits cross-scenario results to a single-scenario run, corrupting the
21
+ * Appliqation data store.
22
+ *
23
+ * Three resolution strategies, selected via `config.onScopeMismatch`:
24
+ * 'cancel' (default in CI) — fail fast, exit 1, submit nothing
25
+ * 'adhoc' — override config, create an ad-hoc run
26
+ * 'filter' — drop out-of-scope results, submit the rest
27
+ *
28
+ * In a TTY (no `CI=true` and stdin is a TTY), the user is prompted
29
+ * interactively before any test runs. In CI, the configured strategy is
30
+ * used; if unset, 'cancel' is the fail-safe default.
31
+ */
32
+ class ScopeValidator {
33
+ /**
34
+ * @param {Object} options
35
+ * @param {number} [options.scenarioId]
36
+ * @param {number} [options.testSetId]
37
+ * @param {'cancel'|'adhoc'|'filter'} [options.strategy]
38
+ * @param {Function} [options.fetchTestSetScope] async (testSetId) -> Set<uuid>
39
+ */
40
+ constructor(options = {}) {
41
+ this.scenarioId = options.scenarioId || null;
42
+ this.testSetId = options.testSetId || null;
43
+ this.strategy = this._resolveStrategy(options.strategy);
44
+ this.fetchTestSetScope = options.fetchTestSetScope || null;
45
+ }
46
+
47
+ _resolveStrategy(explicit) {
48
+ const envValue = process.env.APPLIQATION_ON_SCOPE_MISMATCH;
49
+ const raw = (explicit || envValue || STRATEGY.CANCEL).toLowerCase();
50
+ if (!VALID_STRATEGIES.includes(raw)) {
51
+ logger.warn(`Unknown onScopeMismatch strategy "${raw}", falling back to "cancel"`);
52
+ return STRATEGY.CANCEL;
53
+ }
54
+ return raw;
55
+ }
56
+
57
+ /**
58
+ * @returns {boolean} True if scenarioId or testSetId is set — i.e., the
59
+ * user has declared a scope and we need to validate against it.
60
+ */
61
+ isScoped() {
62
+ return Boolean(this.scenarioId || this.testSetId);
63
+ }
64
+
65
+ /**
66
+ * Extract every Appliqation UUID from a Playwright suite tree.
67
+ *
68
+ * @param {Object} suite - Playwright Suite from onBegin
69
+ * @returns {string[]} Unique UUIDs, in test-discovery order
70
+ */
71
+ extractUuidsFromSuite(suite) {
72
+ if (!suite || typeof suite.allTests !== 'function') return [];
73
+
74
+ const seen = new Set();
75
+ const ordered = [];
76
+ for (const test of suite.allTests()) {
77
+ const uuid = UuidExtractor.extractFromAnnotations(test.annotations || [])
78
+ || UuidExtractor.extractFromTest(test);
79
+ if (uuid && !seen.has(uuid)) {
80
+ seen.add(uuid);
81
+ ordered.push(uuid);
82
+ }
83
+ }
84
+ return ordered;
85
+ }
86
+
87
+ /**
88
+ * Validate the set of UUIDs against the configured scope.
89
+ *
90
+ * @param {string[]} uuids
91
+ * @returns {Promise<{
92
+ * hasMismatch: boolean,
93
+ * inScope: string[],
94
+ * outOfScope: Array<{uuid: string, actualScenarioId?: number}>,
95
+ * reason: 'scenario' | 'testset' | null
96
+ * }>}
97
+ */
98
+ async validate(uuids) {
99
+ if (!this.isScoped()) {
100
+ return { hasMismatch: false, inScope: uuids, outOfScope: [], reason: null };
101
+ }
102
+ if (!Array.isArray(uuids) || uuids.length === 0) {
103
+ return { hasMismatch: false, inScope: [], outOfScope: [], reason: null };
104
+ }
105
+
106
+ if (this.scenarioId) {
107
+ return this._validateScenario(uuids);
108
+ }
109
+ return this._validateTestSet(uuids);
110
+ }
111
+
112
+ _validateScenario(uuids) {
113
+ const inScope = [];
114
+ const outOfScope = [];
115
+
116
+ for (const uuid of uuids) {
117
+ const nid = UuidValidator.extractNid(uuid);
118
+ if (nid === null) {
119
+ // Malformed UUID — let the regular submission path reject it.
120
+ // Don't fail the scope check on bad data we didn't author.
121
+ inScope.push(uuid);
122
+ continue;
123
+ }
124
+ if (nid === Number(this.scenarioId)) {
125
+ inScope.push(uuid);
126
+ } else {
127
+ outOfScope.push({ uuid, actualScenarioId: nid });
128
+ }
129
+ }
130
+
131
+ return {
132
+ hasMismatch: outOfScope.length > 0,
133
+ inScope,
134
+ outOfScope,
135
+ reason: 'scenario'
136
+ };
137
+ }
138
+
139
+ async _validateTestSet(uuids) {
140
+ if (!this.fetchTestSetScope) {
141
+ // No way to fetch testset membership — fail open with a warning
142
+ // rather than blocking the run on a missing dependency.
143
+ logger.warn(
144
+ 'testSetId is set but no testset-scope fetcher was provided; '
145
+ + 'skipping scope validation. Pass `fetchTestSetScope` to enable.'
146
+ );
147
+ return { hasMismatch: false, inScope: uuids, outOfScope: [], reason: null };
148
+ }
149
+
150
+ let scope;
151
+ try {
152
+ scope = await this.fetchTestSetScope(this.testSetId);
153
+ } catch (error) {
154
+ logger.warn('Failed to fetch testset scope; skipping validation', {
155
+ testSetId: this.testSetId,
156
+ error: error.message
157
+ });
158
+ return { hasMismatch: false, inScope: uuids, outOfScope: [], reason: null };
159
+ }
160
+
161
+ const scopeSet = scope instanceof Set ? scope : new Set(scope || []);
162
+ const inScope = [];
163
+ const outOfScope = [];
164
+
165
+ for (const uuid of uuids) {
166
+ if (scopeSet.has(uuid)) {
167
+ inScope.push(uuid);
168
+ } else {
169
+ outOfScope.push({ uuid });
170
+ }
171
+ }
172
+
173
+ return {
174
+ hasMismatch: outOfScope.length > 0,
175
+ inScope,
176
+ outOfScope,
177
+ reason: 'testset'
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Resolve a scope mismatch by applying the chosen strategy. In a TTY,
183
+ * the user is prompted interactively before any strategy is applied.
184
+ *
185
+ * @param {Object} validation - result of `validate()`
186
+ * @returns {Promise<{
187
+ * action: 'cancel' | 'adhoc' | 'filter',
188
+ * submitUuids: string[],
189
+ * droppedUuids: string[],
190
+ * overrideToAdhoc: boolean,
191
+ * message: string
192
+ * }>}
193
+ */
194
+ async resolve(validation) {
195
+ if (!validation.hasMismatch) {
196
+ return {
197
+ action: 'pass',
198
+ submitUuids: validation.inScope,
199
+ droppedUuids: [],
200
+ overrideToAdhoc: false,
201
+ message: 'Scope check passed.'
202
+ };
203
+ }
204
+
205
+ const strategy = this._isInteractive()
206
+ ? await this._promptInteractive(validation)
207
+ : this.strategy;
208
+
209
+ return this._applyStrategy(strategy, validation);
210
+ }
211
+
212
+ _isInteractive() {
213
+ if (process.env.CI === 'true' || process.env.CI === '1') return false;
214
+ return Boolean(process.stdin && process.stdin.isTTY);
215
+ }
216
+
217
+ async _promptInteractive(validation) {
218
+ const summary = this._formatMismatchSummary(validation);
219
+ // Use process.stdout (not logger) because this is a synchronous user
220
+ // prompt that must appear regardless of log level.
221
+ process.stdout.write('\n');
222
+ process.stdout.write(summary);
223
+ process.stdout.write('\n');
224
+ process.stdout.write('Choose how to proceed:\n');
225
+ process.stdout.write(' [1] cancel — fail fast, submit nothing (safest)\n');
226
+ process.stdout.write(' [2] adhoc — create an ad-hoc run and submit everything\n');
227
+ process.stdout.write(' [3] filter — submit only in-scope results, drop the rest\n');
228
+
229
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
230
+ const ask = (q) => new Promise((res) => rl.question(q, (a) => res(a)));
231
+ try {
232
+ const answer = (await ask('Selection [1/2/3]: ')).trim();
233
+ const map = { '1': STRATEGY.CANCEL, '2': STRATEGY.ADHOC, '3': STRATEGY.FILTER };
234
+ return map[answer] || STRATEGY.CANCEL;
235
+ } finally {
236
+ rl.close();
237
+ }
238
+ }
239
+
240
+ _applyStrategy(strategy, validation) {
241
+ const droppedUuids = validation.outOfScope.map((o) => o.uuid);
242
+ const summary = this._formatMismatchSummary(validation);
243
+
244
+ if (strategy === STRATEGY.CANCEL) {
245
+ return {
246
+ action: STRATEGY.CANCEL,
247
+ submitUuids: [],
248
+ droppedUuids: [...validation.inScope, ...droppedUuids],
249
+ overrideToAdhoc: false,
250
+ message:
251
+ `${summary}\n`
252
+ + 'Strategy: cancel. No results will be submitted to Appliqation.\n'
253
+ + 'Set onScopeMismatch (or APPLIQATION_ON_SCOPE_MISMATCH) to '
254
+ + '"adhoc" or "filter" to change this behaviour.'
255
+ };
256
+ }
257
+
258
+ if (strategy === STRATEGY.ADHOC) {
259
+ return {
260
+ action: STRATEGY.ADHOC,
261
+ submitUuids: [...validation.inScope, ...droppedUuids],
262
+ droppedUuids: [],
263
+ overrideToAdhoc: true,
264
+ message:
265
+ `${summary}\n`
266
+ + 'Strategy: adhoc. The configured scenarioId/testSetId is being '
267
+ + 'overridden — an ad-hoc run will be created instead and all '
268
+ + 'results will be submitted to it.'
269
+ };
270
+ }
271
+
272
+ // FILTER
273
+ return {
274
+ action: STRATEGY.FILTER,
275
+ submitUuids: validation.inScope,
276
+ droppedUuids,
277
+ overrideToAdhoc: false,
278
+ message:
279
+ `${summary}\n`
280
+ + `Strategy: filter. ${validation.inScope.length} in-scope result(s) `
281
+ + `will be submitted; ${droppedUuids.length} out-of-scope result(s) `
282
+ + 'will execute locally but their verdicts will NOT be uploaded.'
283
+ };
284
+ }
285
+
286
+ _formatMismatchSummary(validation) {
287
+ const target = validation.reason === 'scenario'
288
+ ? `scenarioId ${this.scenarioId}`
289
+ : `testSetId ${this.testSetId}`;
290
+
291
+ const examples = validation.outOfScope.slice(0, 5).map((o) => {
292
+ if (o.actualScenarioId !== undefined) {
293
+ return ` - ${o.uuid} (belongs to scenario ${o.actualScenarioId})`;
294
+ }
295
+ return ` - ${o.uuid} (not in testset)`;
296
+ }).join('\n');
297
+
298
+ const more = validation.outOfScope.length > 5
299
+ ? `\n … and ${validation.outOfScope.length - 5} more`
300
+ : '';
301
+
302
+ return (
303
+ `[Appliqation] Scope mismatch detected.\n`
304
+ + `Configured: ${target}\n`
305
+ + `Out-of-scope test(s): ${validation.outOfScope.length} of ${validation.inScope.length + validation.outOfScope.length}\n`
306
+ + examples
307
+ + more
308
+ );
309
+ }
310
+ }
311
+
312
+ ScopeValidator.STRATEGY = STRATEGY;
313
+
314
+ module.exports = ScopeValidator;
@@ -1,4 +1,5 @@
1
1
  const logger = require('../utils/logger');
2
+ const { AUTO_TAG_NAME } = require('../constants');
2
3
 
3
4
  /**
4
5
  * Service for auto-tagging test cases after successful runs
@@ -8,12 +9,11 @@ class TaggingService {
8
9
  this.http = httpClient;
9
10
  this.config = config;
10
11
 
11
- // Configuration resolution: Runtime options > Env vars > Defaults
12
+ // Whether to auto-tag is configurable; the tag name itself is not —
13
+ // see AUTO_TAG_NAME's doc comment.
12
14
  this.enabled = this.config?.options?.autoTag !== false &&
13
15
  process.env.APPLIQATION_AUTO_TAG_ENABLED !== 'false';
14
- this.tagName = this.config?.options?.autoTagName ||
15
- process.env.APPLIQATION_AUTO_TAG_NAME ||
16
- 'Appq_automated';
16
+ this.tagName = AUTO_TAG_NAME;
17
17
  this.batchSize = this.config?.options?.autoTagBatchSize || 50;
18
18
  this.retries = this.config?.options?.autoTagRetries || 2;
19
19
  }
@@ -42,7 +42,7 @@ class TaggingService {
42
42
  const unique = Array.from(new Set(uuids.filter(Boolean)));
43
43
 
44
44
  try {
45
- // Build query string: uuids=123-xxx,124-yyy&tag=Appq_automated
45
+ // Build query string: uuids=123-xxx,124-yyy&tag=Appq_Auto
46
46
  const uuidsParam = unique.join(',');
47
47
  const url = `/api/automation/testcases/tags/check?uuids=${encodeURIComponent(uuidsParam)}&tag=${encodeURIComponent(tag)}`;
48
48
  const response = await this.http.get(url);
@@ -114,36 +114,19 @@ class PayloadBuilder {
114
114
  static normalizeStatus(status) {
115
115
  if (!status) return 'unknown';
116
116
 
117
+ // Playwright-only status map. Cypress/Jest/generic variants were
118
+ // removed alongside the Cypress and Jest reporters — D1/D2/D3.
117
119
  const statusMap = {
118
- // Standard statuses
119
120
  'passed': 'passed',
120
121
  'failed': 'failed',
121
122
  'skipped': 'skipped',
122
123
  'pending': 'pending',
123
-
124
- // Playwright variants
125
124
  'expected': 'passed',
126
125
  'unexpected': 'failed',
127
126
  'flaky': 'flaky',
128
127
  'timedOut': 'failed',
129
- 'interrupted': 'skipped',
130
-
131
- // Cypress variants
132
- 'pass': 'passed',
133
- 'fail': 'failed',
134
-
135
- // Jest variants
136
- 'success': 'passed',
137
- 'error': 'failed',
138
- 'disabled': 'skipped',
139
-
140
- // Generic variants
141
- 'ok': 'passed',
142
- 'success': 'passed',
143
- 'failure': 'failed',
144
- 'error': 'failed',
145
- 'skip': 'skipped',
146
- 'todo': 'pending'
128
+ // Aborted run, not a skip — see AppliqationReporter.mapStatus.
129
+ 'interrupted': 'failed'
147
130
  };
148
131
 
149
132
  const normalized = statusMap[status.toLowerCase()];
@@ -212,6 +195,114 @@ class PayloadBuilder {
212
195
  };
213
196
  }
214
197
 
198
+ /**
199
+ * S8 — Pre-submission payload validation for the automation result
200
+ * endpoint. Runs AFTER buildAutomationResultPayload has assembled the
201
+ * final wire payload, immediately before the HTTP POST. Enforces the
202
+ * exact shape the backend's InputValidator + AutomationResultManager
203
+ * accept, so shape drift (wrong field name, missing required field,
204
+ * bad type) is caught at the client instead of failing at the server
205
+ * with a generic 400 mid-run.
206
+ *
207
+ * Defence-in-depth on top of H2 (which validates user-supplied CONFIG
208
+ * at startup) — S8 validates the assembled OUTGOING payload.
209
+ *
210
+ * Backend constraints (mirror of appq_mongo InputValidator +
211
+ * AutomationApiController::submitResultBatch):
212
+ * run_id required, matches /^[a-zA-Z0-9_:-]+$/ max 100
213
+ * test_case_uuid required, matches UuidValidator's regex
214
+ * status required, one of {passed, failed, skipped}
215
+ * source 'automation' when SDK-submitted (post C1)
216
+ * browser required, matches /^[a-zA-Z0-9 .\-_(),\/]+$/ max 255
217
+ * environment matches /^[a-zA-Z0-9._-]+$/ max 255
218
+ * duration integer >= 0 when present
219
+ * timestamp positive integer when present
220
+ *
221
+ * @param {Object} payload - Fully assembled result payload
222
+ * @returns {{ valid: boolean, errors: string[] }}
223
+ */
224
+ static validateAutomationResultPayload(payload) {
225
+ const errors = [];
226
+
227
+ if (!payload || typeof payload !== 'object') {
228
+ return { valid: false, errors: ['payload must be an object'] };
229
+ }
230
+
231
+ // run_id
232
+ if (typeof payload.run_id !== 'string' || payload.run_id.length === 0) {
233
+ errors.push('run_id must be a non-empty string');
234
+ } else if (payload.run_id.length > 100) {
235
+ errors.push(`run_id exceeds 100 chars (got ${payload.run_id.length})`);
236
+ } else if (!/^[a-zA-Z0-9_:-]+$/.test(payload.run_id)) {
237
+ errors.push(`run_id contains invalid characters: "${payload.run_id}"`);
238
+ }
239
+
240
+ // test_case_uuid — reuse UuidValidator for the canonical check
241
+ if (!payload.test_case_uuid) {
242
+ errors.push('test_case_uuid is required');
243
+ } else {
244
+ // Require nid-prefixed uuid-v4 shape. Duplicated here to avoid a
245
+ // circular require between PayloadBuilder and UuidValidator.
246
+ const uuidPattern = /^\d+-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
247
+ if (!uuidPattern.test(payload.test_case_uuid)) {
248
+ errors.push(`test_case_uuid does not match {nid}-{uuid-v4} shape: "${payload.test_case_uuid}"`);
249
+ }
250
+ }
251
+
252
+ // status
253
+ const ALLOWED_STATUSES = new Set(['passed', 'failed', 'skipped']);
254
+ if (!payload.status) {
255
+ errors.push('status is required');
256
+ } else if (!ALLOWED_STATUSES.has(String(payload.status).toLowerCase())) {
257
+ errors.push(`status must be one of {passed, failed, skipped} (got "${payload.status}")`);
258
+ }
259
+
260
+ // source — must be 'automation' when set (C1 contract)
261
+ if (payload.source !== undefined && payload.source !== 'automation') {
262
+ errors.push(`source must be "automation" if provided (got "${payload.source}")`);
263
+ }
264
+
265
+ // browser
266
+ if (payload.browser !== undefined) {
267
+ if (typeof payload.browser !== 'string' || payload.browser.length === 0) {
268
+ errors.push('browser must be a non-empty string when provided');
269
+ } else if (payload.browser.length > 255) {
270
+ errors.push(`browser exceeds 255 chars (got ${payload.browser.length})`);
271
+ } else if (!/^[a-zA-Z0-9 .\-_(),\/]+$/.test(payload.browser)) {
272
+ errors.push(`browser contains invalid characters: "${payload.browser}"`);
273
+ }
274
+ }
275
+
276
+ // environment
277
+ if (payload.environment !== undefined && payload.environment !== '') {
278
+ if (typeof payload.environment !== 'string') {
279
+ errors.push(`environment must be a string (got ${typeof payload.environment})`);
280
+ } else if (payload.environment.length > 255) {
281
+ errors.push(`environment exceeds 255 chars (got ${payload.environment.length})`);
282
+ } else if (!/^[a-zA-Z0-9._-]+$/.test(payload.environment)) {
283
+ errors.push(`environment contains invalid characters: "${payload.environment}"`);
284
+ }
285
+ }
286
+
287
+ // duration
288
+ if (payload.duration !== undefined && payload.duration !== null) {
289
+ const d = payload.duration;
290
+ if (typeof d !== 'number' || !Number.isFinite(d) || d < 0 || !Number.isInteger(d)) {
291
+ errors.push(`duration must be a non-negative integer (got ${d})`);
292
+ }
293
+ }
294
+
295
+ // timestamp
296
+ if (payload.timestamp !== undefined && payload.timestamp !== null) {
297
+ const t = payload.timestamp;
298
+ if (typeof t !== 'number' || !Number.isFinite(t) || t <= 0 || !Number.isInteger(t)) {
299
+ errors.push(`timestamp must be a positive integer (got ${t})`);
300
+ }
301
+ }
302
+
303
+ return { valid: errors.length === 0, errors };
304
+ }
305
+
215
306
  /**
216
307
  * Validate run creation options
217
308
  * @param {Object} options - Run options
@@ -5,7 +5,8 @@ const LOG_LEVELS = {
5
5
  DEBUG: 3
6
6
  };
7
7
 
8
- // Sensitive data patterns to sanitize
8
+ // Regex-based sanitization catches value-shaped secrets inside string
9
+ // payloads (raw JWTs in error messages, axios request dumps, etc).
9
10
  const SENSITIVE_PATTERNS = [
10
11
  // API Keys
11
12
  { pattern: /(appq_\w+_[a-f0-9]{32})/gi, replacement: 'appq_***_REDACTED' },
@@ -30,6 +31,51 @@ const SENSITIVE_PATTERNS = [
30
31
  { pattern: /("session"\s*:\s*")([^"]+)(")/gi, replacement: '$1***REDACTED***$3' }
31
32
  ];
32
33
 
34
+ // H5 — field-name-based sanitization catches custom-named fields the regex
35
+ // list will miss: `x-secret`, `myAuthToken`, `dbPassword`, `serviceKey`, etc.
36
+ // Applied recursively before the regex pass. Errs on the side of over-
37
+ // redaction: any object key matching this pattern has its value replaced
38
+ // regardless of nesting depth.
39
+ const SENSITIVE_KEY_PATTERN = /token|secret|key|password|credential|auth/i;
40
+
41
+ function redactByFieldName(value, seen) {
42
+ if (value === null || typeof value !== 'object') return value;
43
+ if (!seen) seen = new WeakSet();
44
+ if (seen.has(value)) return '[Circular]';
45
+ seen.add(value);
46
+
47
+ if (Array.isArray(value)) {
48
+ return value.map((item) => redactByFieldName(item, seen));
49
+ }
50
+
51
+ const result = {};
52
+ for (const k of Object.keys(value)) {
53
+ const v = value[k];
54
+ if (SENSITIVE_KEY_PATTERN.test(k)) {
55
+ if (v === null || v === undefined) {
56
+ // Preserve null/undefined so consumers can detect "field present
57
+ // but empty" without leaking real values.
58
+ result[k] = v;
59
+ } else if (typeof v === 'object') {
60
+ // Recurse into nested objects/arrays. A sensitive container like
61
+ // `credentials: { user, password }` keeps its structure but its
62
+ // leaf values get redacted by the inner pass, so debug output
63
+ // still tells you which fields existed.
64
+ result[k] = redactByFieldName(v, seen);
65
+ } else if (typeof v === 'string' && /^Bearer\s+/i.test(v)) {
66
+ // Preserve scheme prefix for Authorization-style headers so the
67
+ // log reader can tell what kind of token was redacted.
68
+ result[k] = v.replace(/^(Bearer\s+).*$/i, '$1***REDACTED***');
69
+ } else {
70
+ result[k] = '***REDACTED***';
71
+ }
72
+ } else {
73
+ result[k] = redactByFieldName(v, seen);
74
+ }
75
+ }
76
+ return result;
77
+ }
78
+
33
79
  class Logger {
34
80
  constructor(level = 'INFO') {
35
81
  this.level = LOG_LEVELS[level.toUpperCase()] || LOG_LEVELS.INFO;
@@ -55,17 +101,25 @@ class Logger {
55
101
  return sanitized;
56
102
  }
57
103
 
58
- // Handle objects and arrays by converting to JSON and back
104
+ // Handle objects and arrays: redact by field name first (catches
105
+ // custom-named credential fields), then apply the regex pass to any
106
+ // remaining string values for value-shaped secrets.
59
107
  if (typeof data === 'object') {
60
108
  try {
61
- let jsonString = JSON.stringify(data);
109
+ const redacted = redactByFieldName(data);
110
+ let jsonString = JSON.stringify(redacted);
62
111
  SENSITIVE_PATTERNS.forEach(({ pattern, replacement }) => {
63
112
  jsonString = jsonString.replace(pattern, replacement);
64
113
  });
65
114
  return JSON.parse(jsonString);
66
115
  } catch (error) {
67
- // If JSON parsing fails, return original data
68
- return data;
116
+ // If JSON serialization fails, return the field-redacted form
117
+ // rather than the raw data — never leak unfiltered objects.
118
+ try {
119
+ return redactByFieldName(data);
120
+ } catch (e) {
121
+ return data;
122
+ }
69
123
  }
70
124
  }
71
125