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
30 changes: 30 additions & 0 deletions packages/browserstack-service/src/autoCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS } from './constants.js'

/**
* Opt-out for auto-captured debug logs.
*
* The service has uploaded its debug log for a long time, but this change puts the user's
* HOOK SOURCE in it, so an opt-out is warranted for the first time. Name matches the Node
* SDK's `disableAutoCaptureLogs` so the flag means the same thing across BrowserStack SDKs.
*
* Env var as well as the service option, because the detached cleanup process gets no
* options object — and because CI users cannot always edit a committed config.
*/
export function isAutoCaptureLogsDisabled(options?: { disableAutoCaptureLogs?: boolean }): boolean {
if (options?.disableAutoCaptureLogs === true) {
return true
}
return String(process.env[BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS] || '').toLowerCase() === 'true'
}

/**
* Mirror the service option onto the environment so the opt-out survives into the detached
* cleanup process, which re-runs the log upload with no options object.
*/
export function publishAutoCaptureDisabled(options?: { disableAutoCaptureLogs?: boolean }): boolean {
const disabled = isAutoCaptureLogsDisabled(options)
if (disabled) {
process.env[BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS] = 'true'
}
return disabled
}
118 changes: 118 additions & 0 deletions packages/browserstack-service/src/configSerializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
COMPOUND_SECRET_SUFFIXES_CAMEL,
COMPOUND_SECRET_SUFFIXES_SNAKE,
PEM_BLOCK_REGEX,
PEM_UNTERMINATED_REGEX,
REDACTED_KEYS,
URL_USERINFO_REGEX
} from './constants.js'

/**
* Safe, lossless-enough serialization of the user's wdio config for the debug log.
*
* The log already carries a config dump, but `JSON.parse(JSON.stringify(config))` loses
* exactly the parts that matter most when triaging: every hook serialises to `null`
* (`before: [null]`), `RegExp` values collapse to `{}`, and a circular reference — which
* plugins and reporters do produce — throws outright, in the service constructor, with no
* try/catch around it.
*
* This replaces that with a replacer that keeps function source, keeps RegExp, survives
* cycles, and scrubs credentials on the way out.
*/

const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

/* whole-word key match: `key`, `accessKey`, `browserstack.user`, … */
const WHOLE_WORD_KEY_REGEX = new RegExp(
`^(?:${[...REDACTED_KEYS].sort((a, b) => b.length - a.length).map(escapeRegex).join('|')})$`,
'i'
)
/* compound key match: `clientSecret` (camelCase) and `client_secret` / `CLIENT_SECRET` */
const COMPOUND_CAMEL_KEY_REGEX = new RegExp(`^[A-Za-z0-9_$]{0,64}[a-z0-9](?:${COMPOUND_SECRET_SUFFIXES_CAMEL})$`)
const COMPOUND_SNAKE_KEY_REGEX = new RegExp(`^[A-Za-z0-9_$]{0,64}_(?:${COMPOUND_SECRET_SUFFIXES_SNAKE})$`, 'i')

/**
* Is this config KEY one whose value must never be logged?
*
* Case matters for the camelCase form and not for the snake form, for the same reason as in
* the text scrubber: a capital (`privateKey`) or an explicit `_` (`client_secret`) is what
* separates a real secret name from `hotkey` and `keyword`.
*/
export function isSensitiveKey(key: string): boolean {
if (!key) {
return false
}
return WHOLE_WORD_KEY_REGEX.test(key)
|| COMPOUND_CAMEL_KEY_REGEX.test(key)
|| COMPOUND_SNAKE_KEY_REGEX.test(key)
}

/**
* Line-anchored credential scrub, applied to FUNCTION SOURCE.
*
* Hook bodies are real code, so a secret in one is a `const apiKey = '…'` line rather than a
* config key — object-level key redaction cannot see it. Running the line scrubber over the
* stringified source is what makes serialising functions safe at all.
*/
export function redactSensitiveContent(text: string): string {
if (!text) {
return text
}

const keys = [...REDACTED_KEYS].sort((a, b) => b.length - a.length).map(escapeRegex).join('|')
const wholeWord = new RegExp(`^.*?(?<![A-Za-z0-9_$])(${keys})(?![A-Za-z0-9_$]).*$`, 'gmi')
const compoundCamel = new RegExp(
`^.*?(?<![A-Za-z0-9_$])([A-Za-z0-9_$]{0,64}[a-z0-9](?:${COMPOUND_SECRET_SUFFIXES_CAMEL}))\\s*[:=].*$`, 'gm')
const compoundSnake = new RegExp(
`^.*?(?<![A-Za-z0-9_$])([A-Za-z0-9_$]{0,64}_(?:${COMPOUND_SECRET_SUFFIXES_SNAKE}))\\s*[:=].*$`, 'gmi')

return text.toString()
.replace(PEM_BLOCK_REGEX, '$1[REDACTED]$2')
.replace(PEM_UNTERMINATED_REGEX, '$1[REDACTED]')
.replace(URL_USERINFO_REGEX, '$1[REDACTED]@')
.replace(wholeWord, '$1: [REDACTED]')
.replace(compoundCamel, '$1: [REDACTED]')
.replace(compoundSnake, '$1: [REDACTED]')
}

/** Secrets that hide inside an ordinary string value, e.g. `baseUrl: 'https://u:p@host'`. */
const redactStringValue = (value: string): string => value
.replace(PEM_BLOCK_REGEX, '$1[REDACTED]$2')
.replace(PEM_UNTERMINATED_REGEX, '$1[REDACTED]')
.replace(URL_USERINFO_REGEX, '$1[REDACTED]@')

/**
* Serialize any config-shaped object for the debug log. Never throws: a serialization
* failure returns a marker string rather than taking down the caller, which today is the
* service constructor.
*/
export function serializeConfigForLog(value: unknown): string {
try {
const seen = new WeakSet<object>()

return JSON.stringify(value, function (key, raw) {
if (isSensitiveKey(key)) {
return '[REDACTED]'
}
if (typeof raw === 'function') {
// the whole point: `before: [null]` becomes the actual hook source
return redactSensitiveContent(raw.toString())
}
if (raw instanceof RegExp) {
return raw.toString()
}
if (typeof raw === 'string') {
return redactStringValue(raw)
}
if (typeof raw === 'object' && raw !== null) {
if (seen.has(raw as object)) {
return '[Circular]'
}
seen.add(raw as object)
}
return raw
}) ?? 'undefined'
} catch (error) {
return `[unserializable: ${(error as Error)?.message || String(error)}]`
}
}
45 changes: 45 additions & 0 deletions packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,51 @@ export const UPLOAD_LOGS_ENDPOINT = 'client-logs/upload'

export const PERCY_LOGS_FILE = 'logs/percy.log'

/**
* Credential scrubbing for the debug-log config dump (SDK-7250).
*/

/* opt-out, mirroring the Node SDK's flag name */
export const BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS = 'BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS'

/*
* Word families that make an identifier sensitive when they appear as its SUFFIX.
* Split by case so camelCase requires a capital (`privateKey` vs `hotkey`) and snake_case
* requires an explicit `_` (`client_secret` vs `keyword`).
*/
export const COMPOUND_SECRET_SUFFIXES_CAMEL = 'Key|Token|Secret|Password|Passwd|Credential'
export const COMPOUND_SECRET_SUFFIXES_SNAKE = 'key|token|secret|password|passwd|credential'

/*
* The body is TEMPERED so it cannot cross a second `-----BEGIN`: an UNTERMINATED block
* would otherwise match through to a later, unrelated block's END marker and replace
* everything in between. Bounded so the scan stays linear.
*/
export const PEM_BLOCK_REGEX = /(-----BEGIN [^-\r\n]+-----)(?:(?!-----BEGIN)[\s\S]){0,65536}?(-----END [^-\r\n]+-----)/g
/*
* A PEM opened but never closed. Runs must be >=20 chars and end at a non-base64 character:
* letters are valid base64, so a looser rule eats ordinary lines like `nextOption: 1`.
*/
export const PEM_UNTERMINATED_REGEX = /(-----BEGIN [^-\r\n]+-----)(?:\r?\n[A-Za-z0-9+/=]{20,200}(?=[^A-Za-z0-9+/=]|$))+/g
/*
* Userinfo in ANY url value. Password half optional so single-token forms
* (`https://ghp_xxx@github.com`) are caught. Quantifiers BOUNDED: the unbounded form was
* measurably quadratic (100 KB took 6.1 s, 4x per doubling).
*/
export const URL_USERINFO_REGEX = /([a-zA-Z][a-zA-Z0-9+.-]{0,64}:\/\/)[^\s/@:]{1,256}(?::[^\s/@]{0,256})?@/g

/* Keys whose value is never logged. `user`/`key` are WDIO's own credential options. */
export const REDACTED_KEYS = [
'user', 'key',
'userName', 'accessKey',
'browserstack.user', 'browserstack.key',
'browserstack.userName', 'browserstack.accessKey',
'password', 'proxyPassword', 'proxyUser', 'proxyPass',
'localProxyUser', 'localProxyPass', 'proxyUrl',
'authToken', 'apiKey', 'accessToken', 'secret', 'token',
'customVariables', 'user_data', 'httpProxy', 'httpsProxy'
]

export const PERCY_DOM_CHANGING_COMMANDS_ENDPOINTS = [
'/session/:sessionId/url',
'/session/:sessionId/forward',
Expand Down
3 changes: 2 additions & 1 deletion packages/browserstack-service/src/exitHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
import PerformanceTester from './instrumentation/performance/performance-tester.js'
import TestOpsConfig from './testOps/testOpsConfig.js'
import { BStackLogger } from './bstackLogger.js'
import { isAutoCaptureLogsDisabled } from './autoCapture.js'
import { BrowserstackCLI } from './cli/index.js'
import { BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_TESTHUB_UUID, BROWSERSTACK_KILL_SIGNAL } from './constants.js'

Expand Down Expand Up @@ -109,7 +110,7 @@ export function shouldCallCleanup(config: BrowserStackConfig, isCLIEnabled = fal
// A signal-terminated run never reaches onComplete's log upload, leaving the
// build with no SDK-log object — rescue it from the detached cleanup process.
const clientBuildUuid = process.env[BROWSERSTACK_TESTHUB_UUID] || config.sdkRunID
if (!config.logsUploaded && config.userName && config.accessKey && clientBuildUuid) {
if (!isAutoCaptureLogsDisabled() && !config.logsUploaded && config.userName && config.accessKey && clientBuildUuid) {
args.push('--uploadLogs', clientBuildUuid)
}

Expand Down
18 changes: 13 additions & 5 deletions packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import {
validateSkipAppOverride
} from './util.js'
import CrashReporter from './crash-reporter.js'
import { serializeConfigForLog } from './configSerializer.js'
import { isAutoCaptureLogsDisabled, publishAutoCaptureDisabled } from './autoCapture.js'
import { finalizeOrphanedRuns } from './testOps/openRunsJournal.js'
import { BStackLogger } from './bstackLogger.js'
import { PercyLogger } from './Percy/PercyLogger.js'
Expand Down Expand Up @@ -120,11 +122,16 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
}

this.browserStackConfig = BrowserStackConfig.getInstance(_options, _config, capabilities)
BStackLogger.debug(`_options data: ${JSON.stringify(_options)}`)
BStackLogger.debug(`webdriver capabilities data: ${JSON.stringify(capabilities)}`)
const configCopy = JSON.parse(JSON.stringify(_config))
CrashReporter.recursivelyRedactKeysFromObject(configCopy, ['user', 'username', 'key', 'accesskey', 'password'])
BStackLogger.debug(`_config data: ${JSON.stringify(configCopy)}`)
// Serialized through serializeConfigForLog rather than JSON.stringify: it keeps hook
// SOURCE instead of `[null]`, keeps RegExp instead of `{}`, survives circular configs
// instead of throwing here in the constructor, and scrubs credentials — including the
// compound key names (`clientSecret`, `AWS_SECRET_ACCESS_KEY`) that the previous
// exact-name list could not see, and secrets inside the hook bodies themselves.
if (!isAutoCaptureLogsDisabled(_options)) {
BStackLogger.debug(`_options data: ${serializeConfigForLog(_options)}`)
BStackLogger.debug(`webdriver capabilities data: ${serializeConfigForLog(capabilities)}`)
BStackLogger.debug(`_config data: ${serializeConfigForLog(_config)}`)
}
if (Array.isArray(capabilities)) {
capabilities
.flatMap((c) => {
Expand Down Expand Up @@ -248,6 +255,7 @@ export default class BrowserstackLauncherService implements Services.ServiceInst

@PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_PRE_TEST)
async onPrepare (config: Options.Testrunner, capabilities: Capabilities.TestrunnerCapabilities | WebdriverIO.Capabilities) {
publishAutoCaptureDisabled(this._options)
PerformanceTester.start(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.INIT)

// skipAppOverride: emit the fixed warning once + handle the 3 edge cases before anything
Expand Down
14 changes: 14 additions & 0 deletions packages/browserstack-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,20 @@ export interface BrowserstackConfig {
* Currently supports testPlanId.
*/
testManagementOptions?: TestManagementOptions;
/**
* The service uploads its own debug log at the end of a run so BrowserStack support can
* debug issues without asking you to reproduce them. That log contains your resolved wdio
* config, now including the source of your hooks.
*
* Values under known credential keys are removed first on a best-effort basis, along with
* inline PEM blocks and basic-auth URLs, including inside hook bodies. It is key-name
* driven, so a secret stored under an unrecognised name can still be included — if your
* config or hooks hold secrets you would rather not send, set this to true.
*
* Can also be set with the `BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS=true` env var.
* @default false
*/
disableAutoCaptureLogs?: boolean;
/**
* Set this to true to enable BrowserStack Percy which will take screenshots
* and snapshots for your tests run on Browserstack
Expand Down
11 changes: 11 additions & 0 deletions packages/browserstack-service/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
} from './constants.js'
import CrashReporter from './crash-reporter.js'
import { BStackLogger } from './bstackLogger.js'
import { isAutoCaptureLogsDisabled } from './autoCapture.js'
import UsageStats from './testOps/usageStats.js'
import TestOpsConfig from './testOps/testOpsConfig.js'
import type { StartBinSessionResponse } from './grpc/index.js'
Expand Down Expand Up @@ -1527,6 +1528,16 @@ export async function uploadLogs(user: string | undefined, key: string | undefin
PerformanceTester.start(eventName)

try {
// Honour the opt-out here (not just at the call site) so the DETACHED cleanup rescue
// in cleanup.ts — which calls this with no options — is covered too. Opting out is
// exactly what leaves `logsUploaded` false, which is what arms that rescue.
if (isAutoCaptureLogsDisabled()) {
success = false
failure = 'skipped: disableAutoCaptureLogs=true'
BStackLogger.debug('Skipping log upload, auto-capture is disabled')
return
}

if (!user || !key) {
success = false
failure = 'skipped: missing_credentials'
Expand Down
Loading
Loading