Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
2552924
feat(SDK-7250): capture the wdio config file in auto-captured logs
AakashHotchandani Aug 11, 2026
aa16aa1
chore(changeset): auto-generate from PR template (minor)
github-actions[bot] Aug 11, 2026
e9999dd
feat(SDK-7250): log the full auto-capture archive manifest
AakashHotchandani Aug 11, 2026
97e2f83
fix(SDK-7250): address PR review - compound secret keys, package.json…
AakashHotchandani Aug 11, 2026
894bd10
fix(SDK-7250): scrub SCREAMING_SNAKE keys, PEM blocks and basic-auth …
AakashHotchandani Aug 11, 2026
e593093
chore(changeset): auto-generate from PR template (minor)
github-actions[bot] Aug 11, 2026
45d25d7
fix(SDK-7250): bound the URL userinfo scan, catch single-token userin…
AakashHotchandani Aug 11, 2026
e5b649e
docs(SDK-7250): keep the best-effort redaction wording in the changeset
AakashHotchandani Aug 11, 2026
5ebc318
fix(SDK-7250): manifest into the archive, acronym secret keys, disclo…
AakashHotchandani Aug 13, 2026
89a8c42
fix(SDK-7250): drop the config._ resolution rung — dead and actively …
AakashHotchandani Aug 13, 2026
812c85a
refactor(SDK-7250): resolve the config the way @wdio/cli does; keep m…
AakashHotchandani Aug 13, 2026
927c837
fix(SDK-7250): render a project-root manifest dir as "." instead of i…
AakashHotchandani Aug 13, 2026
02c66b5
Merge branch 'main' into feat/sdk-7250-capture-wdio-conf
AakashHotchandani Aug 13, 2026
22946ba
fix(SDK-7250): only follow imported files that are themselves configs
AakashHotchandani Aug 13, 2026
b8d39c1
fix(SDK-7250): drop capture-manifest.txt; put the capture summary in …
AakashHotchandani Aug 13, 2026
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
6 changes: 6 additions & 0 deletions .changeset/pr-128.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@wdio/browserstack-service": minor
---

- The debug logs the service uploads at the end of a run now include a copy of your `wdio.conf` file (and the local config files it imports) plus your `package.json`, with values under known credential keys removed on a best-effort basis, so BrowserStack support can investigate configuration issues without asking you to reproduce them.
- Set `disableAutoCaptureLogs: true` in the service options, or `BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS=true`, to turn this upload off entirely.
28 changes: 28 additions & 0 deletions packages/browserstack-service/src/bstackLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,34 @@ export class BStackLogger {
log.trace(redactedMessage)
}

/**
* Drain whatever is still sitting in the log stream's buffer onto disk.
*
* `logToFile` writes to an async `fs.WriteStream`, so a line logged immediately before the
* archive is built is very likely NOT in the file yet — the stream's `open` is async too,
* so early on the file may not exist at all. Anything that snapshots the log (the debug-log
* upload) must flush first or it ships a truncated copy.
*
* A zero-length write's callback fires only after every chunk queued ahead of it has been
* handed to the fs layer, which drains the buffer without ending the stream — unlike
* `clearLogger()`, logging continues to work afterwards.
*/
public static async flushLogFile(timeoutMs = 2000): Promise<void> {
const stream = this.logFileStream
if (!stream || !stream.writable) {
return
}
// Never let a stuck stream hold up the upload; a truncated log beats no log at all.
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, timeoutMs)
timer.unref?.()
stream.write('', () => {
clearTimeout(timer)
resolve()
})
})
}

public static clearLogger() {
if (this.logFileStream) {
this.logFileStream.end()
Expand Down
508 changes: 508 additions & 0 deletions packages/browserstack-service/src/configCapture.ts

Large diffs are not rendered by default.

90 changes: 90 additions & 0 deletions packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,96 @@ export const UPLOAD_LOGS_ENDPOINT = 'client-logs/upload'

export const PERCY_LOGS_FILE = 'logs/percy.log'

/**
* Auto-capture of the user's wdio config file (SDK-7250).
*/

/*
* Shown once per run when auto-capture is active. The Node SDK does the same
* (BrowserStackSetup.js -> AUTOLOGCAPTURE_NOTIFICATION); without it a wdio customer gets no
* runtime disclosure that their config file is collected, only a changeset entry and a JSDoc
* comment. Since the redaction is key-name driven and best-effort, the notice is part of the
* control rather than decoration.
*/
export const AUTOLOGCAPTURE_NOTIFICATION = 'Your wdio config file, the local config files it imports and package.json are captured with the debug logs at the end of the run, with values under known credential keys removed. To disable, set disableAutoCaptureLogs: true in the browserstack service options.'

/* Absolute path of the resolved wdio config, published once so the upload path never re-derives it */
export const BROWSERSTACK_WDIO_CONFIG_FILE_PATH = 'BROWSERSTACK_WDIO_CONFIG_FILE_PATH'
export const BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS = 'BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS'
/* Which ladder rung resolved the config, kept so the upload path reports the TRUE rung
instead of re-reading its own env var and always saying 'env_override' */
export const BROWSERSTACK_WDIO_CONFIG_STRATEGY = 'BROWSERSTACK_WDIO_CONFIG_STRATEGY'

/* Mirrors create-wdio's SUPPORTED_CONFIG_FILE_EXTENSION (identical in wdio v8 and v9) */
export const SUPPORTED_WDIO_CONFIG_EXTENSIONS = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts']
export const DEFAULT_WDIO_CONFIG_BASENAME = 'wdio.conf'
/* `wdio <cmd>` verbs that must never be mistaken for the config positional */
export const WDIO_CLI_SUBCOMMANDS = ['run', 'install', 'repl', 'config']

/* Configs are kilobytes; the cap only exists so a mislabelled path cannot bloat the archive */
export const MAX_CAPTURED_CONFIG_FILE_BYTES = 1024 * 1024
export const MAX_CAPTURED_CONFIG_FILES = 6
/* How far to follow relative imports out of the entry config (1 = direct imports only) */
export const CAPTURE_CONFIG_IMPORT_DEPTH = 1
/* How far to walk up from the config dir looking for the project's package.json */
export const MAX_PACKAGE_JSON_WALK_UP = 5

/**
* Keys whose line is scrubbed before a config file enters the archive.
* `user` / `key` are WDIO's own top-level credential options, hence the bare entries.
*/
/**
* Word families that make an identifier sensitive when they appear as its SUFFIX —
* `clientSecret`, `refreshToken`, `privateKey`, `client_secret`. Split by case so the
* camelCase form requires a capital (distinguishing `privateKey` from `hotkey`) and the
* snake_case form requires an explicit `_` (distinguishing `client_secret` from `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'

/**
* Secrets that span lines or hide inside a value, which a line/key-anchored scrub cannot
* reach. Applied as whole-block passes before the line passes.
*/
/* an inline PEM: the key bytes sit on lines that carry no key name at all */
/*
* The body is TEMPERED so it cannot cross a second `-----BEGIN`: with a plain `[\s\S]*?`,
* an UNTERMINATED block earlier in the file matches through to a later, unrelated block's
* END marker and everything in between is replaced — silently destroying unrelated config.
* Bounded as well, 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. The block regex above needs the END marker, so without it
* the key bytes survive every pass. Matched as BEGIN + the run of base64-only lines that
* follows. A run must be at least 20 characters and end at a non-base64 character: letters
* are valid base64, so a shorter/unbounded rule matches part of an ordinary line such as
* `nextOption: 1` and eats it. Stops at the first line that does not qualify, so a malformed
* block cannot swallow the rest of the file. The upper bound is generous (8 KB) because a
* key written unwrapped on ONE line would otherwise exceed it and fail OPEN, leaving the body
* in the bundle.
*/
export const PEM_UNTERMINATED_REGEX = /(-----BEGIN [^-\r\n]+-----)(?:\r?\n[A-Za-z0-9+/=]{20,8192}(?=[^A-Za-z0-9+/=]|$))+/g
/*
* Userinfo in ANY url value, not just the `proxyUrl` key. The password half is optional so
* single-token forms (`https://ghp_xxx@github.com`, common in CI git/npm remotes) are caught
* too. Quantifiers are BOUNDED: the unbounded form was measurably quadratic (100 KB of
* word characters took 6.1 s, 4x per doubling) because both halves scan forward for an `@`
* that never arrives. Real userinfo is short, so the bounds change no real-world match.
*/
export const URL_USERINFO_REGEX = /([a-zA-Z][a-zA-Z0-9+.-]{0,64}:\/\/)[^\s/@:]{1,256}(?::[^\s/@]{0,256})?@/g

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion — [SECURITY] Bounded redaction quantifiers fail open on oversized single tokens

Problem

The ReDoS fix bounds every greedy scan — {0,64} on the compound identifiers (configCapture.ts:315,324), {1,256}/{0,256} on URL userinfo (this line), {20,200} per base64 line and {0,65536} on the PEM block body (constants.ts:104,113). That correctly makes redaction linear (verified: 1 MB pathological input now ~4 ms for the compound passes, ~0.22 s for the URL pass, vs. minutes before).

The side effect is the failure mode: when a single token exceeds its bound, the affected regex fails to match at all, so nothing is redacted — the control fails open (leaks the whole secret) rather than fail-safe (redacting a truncated span). Reproduced on the exact head logic:

  • URL userinfo whose user or password half is > 256 chars → the @-terminated credential is left entirely un-redacted.
  • An unterminated PEM whose base64 body is a single line > 200 chars → the body survives (only the BEGIN line is scrubbed, and only because it happens to contain the word KEY).
  • A closed PEM whose body > 65536 chars → the body survives (again only the BEGIN/END header lines scrubbed via the coincidental KEY match).

All three are reachable inside the 1 MB MAX_CAPTURED_CONFIG_FILE_BYTES cap.

Suggested Fix

Mostly this is worth flagging so it's a conscious decision rather than a fix:

  • URL userinfo — keep as-is. Fail-open here is actually correct: a fail-safe variant that redacts "up to 256 chars after :// even without an @" would over-redact every ordinary long URL (host + path). A 300-char userinfo token is not realistic (GitHub PATs are ~40–93 chars).
  • PEM body — optionally add a length-agnostic fallback. The only case that could leave a real private-key body in the bundle is a config that embeds an unusually large (> 64 KB) or single-line-unwrapped key. If you want fail-safe there, add a coarse pass that redacts everything between a -----BEGIN … PRIVATE KEY----- marker and EOF / the next non-base64 line, independent of length. Standard 64-char-wrapped PEMs are already handled, so this is defence-in-depth only.

Either way the current behavior is safe for realistic configs — this does not block the PR.

Confidence: 🟢 Objectively verified — reproduced on the exact head regexes: tokens above each bound leak entirely, every sub-bound token (incl. AWS_SECRET_ACCESS_KEY, clientSecret, basic-auth URLs, multi-line PEM) scrubs correctly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and thank you for framing it as a conscious-decision item — I have taken your recommendation on both halves, one as a fix and one as a deliberate no-change.

URL userinfo — kept as-is, deliberately. Reproduced: a userinfo half over 256 chars leaks entirely. But your reasoning for leaving it is right and I would not want to "fix" it: a fail-safe variant that redacts up to N characters after :// without requiring the @ would over-redact every ordinary long URL. Real credentials are far below the bound (GitHub PATs ~40–93 chars), so the bound only fails open on inputs that are not credentials in practice.

Unterminated PEM — fixed in 5ebc318. This one I did not want to leave. Reproduced: an unterminated block whose base64 body is a single unwrapped line over 200 chars survives, and a real private key written unwrapped is entirely plausible. Raised the per-line bound to 8 KB, which covers any realistic key while staying bounded and linear. Test added with a 3 000-char single-line body.

Closed PEM over 65 536 chars — could not reproduce. A closed block with a ~78 KB body still redacts correctly on the head regexes, because the lazy tempered body matches to the first -----END. If you have an input where it leaks I will happily take it, but I could not construct one.

Net effect: the only remaining fail-open case is URL userinfo above 256 chars, which is a deliberate trade rather than an oversight.


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
5 changes: 4 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 './configCapture.js'
import { BrowserstackCLI } from './cli/index.js'
import { BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_TESTHUB_UUID, BROWSERSTACK_KILL_SIGNAL } from './constants.js'

Expand Down Expand Up @@ -108,8 +109,10 @@ 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.
// Opting out leaves logsUploaded false, so without this guard the rescue below fires
// on EVERY opted-out run and uploads exactly what the user opted out of.
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
19 changes: 18 additions & 1 deletion packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { BrowserstackConfig, BrowserstackOptions, App, AppConfig, AppUpload
import {
BSTACK_SERVICE_VERSION,
NOT_ALLOWED_KEYS_IN_CAPS, PERF_MEASUREMENT_ENV, RERUN_ENV, RERUN_TESTS_ENV,
AUTOLOGCAPTURE_NOTIFICATION,
BROWSERSTACK_TESTHUB_UUID,
VALID_APP_EXTENSION,
BROWSERSTACK_PERCY,
Expand Down Expand Up @@ -50,6 +51,7 @@ import {
validateSkipAppOverride
} from './util.js'
import CrashReporter from './crash-reporter.js'
import { initWdioConfigPath, isAutoCaptureLogsDisabled, publishAutoCaptureDisabled } from './configCapture.js'
import { finalizeOrphanedRuns } from './testOps/openRunsJournal.js'
import { BStackLogger } from './bstackLogger.js'
import { PercyLogger } from './Percy/PercyLogger.js'
Expand Down Expand Up @@ -250,6 +252,16 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
async onPrepare (config: Options.Testrunner, capabilities: Capabilities.TestrunnerCapabilities | WebdriverIO.Capabilities) {
PerformanceTester.start(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.INIT)

// Resolve the user's wdio config path ONCE, here, while the freshly parsed config
// still carries the CLI's `config-path` positional, and publish it on the env for
// the upload path. Re-deriving it at archive time from cwd is the exact bug
// SDK-5993 fixed in the Node SDK (silently dropped the config on every monorepo /
// subdir CI run). Best-effort: never blocks the run.
if (!publishAutoCaptureDisabled(this._options)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This turns on default collection of customer source (wdio.conf + its imports + package.json), and there is no runtime disclosure anywhere in the run. The only surfaces that mention it are the changeset/CHANGELOG and the JSDoc on disableAutoCaptureLogs — neither is visible to someone who upgrades the service and runs a build. Since the redaction is key-name driven and best-effort by the PR's own framing, the notice is part of the control, not decoration.

Evidence — the Node SDK, which this feature is ported from, does exactly this. browserstack-node-agent/src/helpers/BrowserStackSetup.js:154-155:

if (!this.config.disableAutoCaptureLogs) {
  logger.info(constants.AUTOLOGCAPTURE_NOTIFICATION);
}

with src/bin/utils/constants.js:111 = "Project and debug logs are captured by default. To disable, set disableAutoCaptureLogs: true in config."

No equivalent BStackLogger.info exists in this service today (the only info-level notices are accessibility/self-heal/build-link lines), so a wdio customer gets strictly less disclosure than a Node SDK customer for a strictly broader capture.

Fix — one info line in this same block, reusing the value you already compute:

if (!publishAutoCaptureDisabled(this._options)) {
    BStackLogger.info(AUTOLOGCAPTURE_NOTIFICATION)
    initWdioConfigPath(config)
}

with the constant naming what is included (config file + package.json) and how to turn it off.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — added in 5ebc318.

The Node SDK precedent is the right citation and I had not carried it across. Added AUTOLOGCAPTURE_NOTIFICATION to constants.ts and it is emitted from the same block, reusing the value already computed:

if (!publishAutoCaptureDisabled(this._options)) {
    BStackLogger.info(AUTOLOGCAPTURE_NOTIFICATION)
    initWdioConfigPath(config)
}

The text names what is collected (config file, its local imports, package.json), states that values under known credential keys are removed, and gives the opt-out — so it matches the "best-effort, key-name driven" framing rather than implying the capture is fully sanitised.

Agreed on the reasoning: with key-name-driven redaction the notice is part of the control, not decoration.

BStackLogger.info(AUTOLOGCAPTURE_NOTIFICATION)
initWdioConfigPath(config)
}

// skipAppOverride: emit the fixed warning once + handle the 3 edge cases before anything
// else. Runs once here in the launcher (main process). Edge-2 (explicit false + no app) is a
// deliberate pre-session config error, surfaced as SevereServiceError so the run aborts cleanly.
Expand Down Expand Up @@ -838,7 +850,12 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
// return path (no creds, archive failure, upload no-response, exception), so
// measureWrapper is no longer needed here.
const clientBuildUuid = this._getClientBuildUuid()
const response = await uploadLogs(getBrowserStackUser(this._config), getBrowserStackKey(this._config), clientBuildUuid)
const response = await uploadLogs(
getBrowserStackUser(this._config),
getBrowserStackKey(this._config),
clientBuildUuid,
{ disableAutoCaptureLogs: isAutoCaptureLogsDisabled(this._options), config: this._config }
)
// Treat a truthy response carrying a non-success status as a server-side
// rejection, not a delivery — a delivered upload must not be repeated by
// the exit-time cleanup rescue; failed/skipped uploads stay eligible for it.
Expand Down
16 changes: 16 additions & 0 deletions packages/browserstack-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ export interface BrowserstackConfig {
* Currently supports testPlanId.
*/
testManagementOptions?: TestManagementOptions;
/**
* By default the service uploads its own debug logs, your `package.json` and a copy of
* your wdio config file at the end of a run, so BrowserStack support can debug issues
* without asking you to reproduce them.
*
* Values under known credential keys are removed before upload on a best-effort basis:
* BrowserStack credentials, common third-party secret names (`clientSecret`,
* `AWS_SECRET_ACCESS_KEY`, …), inline PEM blocks and basic-auth URLs. It is key-name
* driven, so a secret stored under an unrecognised name can still be included — if your
* config holds secrets you would rather not send, set this to true.
*
* Set this to true to disable that upload entirely.
* 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
Loading
Loading