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
136 changes: 136 additions & 0 deletions bin/accessibility-automation/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,142 @@
return CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension);
}

// Token identifying the accessibility plugin module path in source.
const ACCESSIBILITY_PLUGIN_PATH_TOKEN = 'accessibility-automation/plugin';

// Strip JS/TS comments so that commented-out plugin imports/calls are ignored
// by the static scans below.
//
// NOTE: this is an intentionally best-effort / lossy scrubber, NOT a real parser.
// It can also strip `//` or `/* */` sequences that appear inside string literals,
// and the `[^:]` guard only avoids `://` (URLs). This is acceptable because these
// static scans are a secondary signal: the authoritative "is the plugin imported"
// check is the require-load marker (BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED), so a
// mis-stripped string literal cannot cause a false import detection.
const stripComments = (src) => {

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.

💡 Suggestion — stripComments can corrupt string literals.

The :// guard protects URLs, but a // inside a non-URL string (e.g. const re = "a//b") is still stripped, and the block-comment pass strips /* */ inside strings too. ^ isn't multiline-anchored (a preceding \n satisfies [^:], so line-start comments are still handled). Impact is low since the require-load marker is the primary signal — worth a comment noting the scan is intentionally lossy.

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.

Ignore, a suggestion comment

return src
.replace(/\/\*[\s\S]*?\*\//g, ' ') // block comments
.replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // line comments (skip URLs like http://)
};

// Reads the cypress config source (comments stripped). Returns null if it cannot
// be read.
const readConfigSource = (user_config) => {
const configPath = user_config.run_settings && user_config.run_settings.cypressConfigFilePath;
if (!configPath || !fs.existsSync(configPath)) return null;
return stripComments(fs.readFileSync(configPath, { encoding: 'utf-8' }));
};

// Finds the symbol the accessibility plugin is imported as, via require() or
// import, regardless of path style. Handles `require()`, default `import X from`,
// and namespace `import * as X from`. Returns the binding name or null. Named
// (`import { X }`) and dynamic (`await import(...)`) forms are not parsed here —
// the strict fallback biases toward keeping accessibility on for those (see
// isAccessibilityPluginImportedAndCalledInSource).
const getAccessibilityPluginBinding = (content) => {

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.

⚠️ Warning — binding regex misses valid import forms → silent a11y disable.

These regexes only match const/let/var X = require('…/plugin') and import X from '…/plugin'. They do not match import * as X from …, import { X } from …, or dynamic await import(…).

On the require-succeeded path this is benign (the invocation scan is lenient). But on the inconclusive path (TS config before bstack packages are installed), isAccessibilityPluginImportedAndCalledInSource is strict and returns false when the binding isn't found — so a correctly-wired config using namespace/dynamic import gets accessibility silently disabled (a billed feature).

Suggest widening the regex to cover import * as X / import { X }, or biasing the strict fallback toward keeping a11y on when the plugin path is present in source at all.

const requireMatch = content.match(/(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*require\(\s*['"][^'"]*accessibility-automation\/plugin['"]\s*\)/);
const importNamespaceMatch = content.match(/import\s+\*\s+as\s+([A-Za-z0-9_$]+)\s+from\s+['"][^'"]*accessibility-automation\/plugin['"]/);
const importDefaultMatch = content.match(/import\s+([A-Za-z0-9_$]+)\s+from\s+['"][^'"]*accessibility-automation\/plugin['"]/);
return (requireMatch && requireMatch[1]) ||
(importNamespaceMatch && importNamespaceMatch[1]) ||
(importDefaultMatch && importDefaultMatch[1]) ||
null;
};

const isBindingCalled = (content, binding) => {
const callRegex = new RegExp('\\b' + binding.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\(');

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp Warning

RegExp() called with a binding function argument, this might allow an attacker to cause a Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on user-controlled input, consider performing input validation or use a regex checking/sanitization library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear vulnerable to ReDoS.
return callRegex.test(content);
};

// Static check: confirm the (already-imported) accessibility plugin is actually
// invoked in the config source. Lenient — if the import binding cannot be located
// via static parsing (unusual syntax) or the source cannot be read, we do NOT
// veto the require-based detection (return true), to avoid wrongly disabling
// valid configs.
const isAccessibilityPluginInvokedInSource = (user_config) => {
try {
const content = readConfigSource(user_config);
if (content === null) return true;
const binding = getAccessibilityPluginBinding(content);
if (!binding) return true;
return isBindingCalled(content, binding);
} catch (error) {
logger.debug(`Unable to verify accessibility plugin invocation: ${error.message || error}`);
return true;
}
};

// Pure static fallback: used only when the config could not be required (e.g. a
// TypeScript config before BrowserStack packages are installed), so such users
// are still evaluated. Biased toward KEEPING accessibility enabled: if the plugin
// path is present but we cannot confidently parse the import binding (e.g. named
// `import { X }` or dynamic `await import(...)`), we do not disable — silently
// turning off a billed feature based on a lossy source scan is worse than leaving
// it on. We only return false when there is positive evidence the plugin is not
// wired in (path absent, or binding parsed and demonstrably never called).
const isAccessibilityPluginImportedAndCalledInSource = (user_config) => {
try {
const content = readConfigSource(user_config);
if (content === null) return false;
// Plugin path not referenced at all -> definitely not imported.
if (!content.includes(ACCESSIBILITY_PLUGIN_PATH_TOKEN)) return false;
const binding = getAccessibilityPluginBinding(content);
// Path present but binding not parseable (namespace/named/dynamic import) ->
// keep accessibility on rather than risk a false disable.
if (!binding) return true;
// Binding parsed: trust the precise call check (catches import-without-call).
return isBindingCalled(content, binding);
} catch (error) {
logger.debug(`Unable to scan cypress config for accessibility plugin: ${error.message || error}`);
return false;
}
};

/**
* Determines whether the BrowserStack accessibility plugin is genuinely wired
* into the user's cypress config, i.e. both imported AND invoked.
*
* Detection combines two signals:
* 1) Require-load: reading the cypress config executes its top-level requires;
* the plugin sets BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED on load, which
* readCypressConfigFile propagates back as a definitive 'true'/'false'. This
* tells us whether the plugin is imported (and does not false-positive on a
* commented-out require, since commented code never executes).
* 2) Static source scan: confirms the imported plugin binding is actually called
* in the config — so "imported but never called" is treated as not loaded.
*
* If the config could not be required (env var stays undefined, e.g. a TS config
* before packages are installed), we fall back to a pure static scan that checks
* for both import and invocation.
*/
exports.isAccessibilityPluginLoaded = (user_config) => {
try {
// Reset before reading so a stale value from a previous run cannot leak in.
delete process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED;
const { readCypressConfigFile } = require('../helpers/readCypressConfigUtil');
readCypressConfigFile(user_config);

const detection = process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED;
if (detection === 'true') {
// Imported via require — additionally require that it is actually invoked.
const called = isAccessibilityPluginInvokedInSource(user_config);
if (!called) {
logger.debug('Accessibility plugin is imported but not invoked in the cypress config; treating as not loaded.');
}
return called;
}
if (detection === 'false') return false;

// Inconclusive (config could not be required) — fall back to a static scan
// that checks for both import and invocation.
logger.debug('Accessibility plugin detection inconclusive from config require; falling back to source scan.');
return isAccessibilityPluginImportedAndCalledInSource(user_config);
} catch (error) {
logger.debug(`Unable to determine if accessibility plugin is loaded: ${error.message || error}`);
return isAccessibilityPluginImportedAndCalledInSource(user_config);
}
}

exports.createAccessibilityTestRun = async (user_config, framework) => {

try {
Expand Down
5 changes: 5 additions & 0 deletions bin/accessibility-automation/plugin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ const { decodeJWTToken } = require("../../helpers/utils");
const utils = require('../../helpers/utils');
const http = require('http');

process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED = 'true';

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.

💡 Suggestion — load-bearing process.env mutation as an import side-effect.

Setting BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED='true' at module top level is the intended detection mechanism, but a global mutation on require is easy to miss. Since the whole feature depends on it, consider making the invariant unmissable so anyone refactoring this module preserves it.

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.

Ignore


const browserstackAccessibility = (on, config) => {
// Also set on invocation, so that a runtime read of the plugin reflects that
// it was actually called within setupNodeEvents.
process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED = 'true';
let browser_validation = true;
if (process.env.BROWSERSTACK_ACCESSIBILITY_DEBUG === 'true') {
config.env.BROWSERSTACK_LOGS = 'true';
Expand Down
19 changes: 17 additions & 2 deletions bin/commands/runs.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ const {
printBuildLink
} = require('../testObservability/helper/helper');

const {
const {
createAccessibilityTestRun,
setAccessibilityEventListeners,
checkAccessibilityPlatform,
isAccessibilityPluginLoaded,
supportFileCleanup
} = require('../accessibility-automation/helper');
const { isTurboScaleSession, getTurboScaleGridDetails, patchCypressConfigFileContent, atsFileCleanup } = require('../helpers/atsHelper');
Expand All @@ -42,6 +43,10 @@ const TestHubHandler = require('../testhub/testhubHandler');

module.exports = function run(args, rawArgs) {
utils.normalizeTestReportingEnvVars();
// Tracks the case where accessibility was requested but the plugin is not
// wired into the cypress config; surfaced in the end-of-session EDS event so
// such builds can be excluded from accessibility stability queries.
let accessibilityPluginNotLoaded = false;
markBlockStart('preBuild');
// set debug mode (--cli-debug)
utils.setDebugMode(args);
Expand Down Expand Up @@ -69,7 +74,7 @@ module.exports = function run(args, rawArgs) {
/* Set testObservability & browserstackAutomation flags */
const [isTestObservabilitySession, isBrowserstackInfra] = setTestObservabilityFlags(bsConfig);
const checkAccessibility = checkAccessibilityPlatform(bsConfig);
const isAccessibilitySession = bsConfig.run_settings.accessibility || checkAccessibility;
let isAccessibilitySession = bsConfig.run_settings.accessibility || checkAccessibility;
const turboScaleSession = isTurboScaleSession(bsConfig);
Constants.turboScaleObj.enabled = turboScaleSession;

Expand Down Expand Up @@ -113,6 +118,15 @@ module.exports = function run(args, rawArgs) {
// set build tag caps
utils.setBuildTags(bsConfig, args);

// If accessibility is requested but the BrowserStack accessibility plugin is
// not loaded in the cypress config, explicitly disable accessibility before
// the build start event so the build is not treated as an accessibility build.
if (isAccessibilitySession && isBrowserstackInfra && !isAccessibilityPluginLoaded(bsConfig)) {

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.

⚠️ Warning — redundant config read/compile on every a11y build.

isAccessibilityPluginLoaded(bsConfig) calls readCypressConfigFile, which is already invoked at capabilityHelper.js:251 and helper.js:378 during a normal run. This adds a third call. For a TS config each call runs convertTsConfigcp.execSync(tsc …) (a fresh sync TypeScript compile) plus a child-process require that re-executes the user config's top-level side-effects.

Consider reading the config once and threading the detection signal through, or memoizing on BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED once an earlier read in the same run has populated it, only forcing a fresh read when it's still undefined.

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.

added a fix for this

logger.warn(Constants.userMessages.ACCESSIBILITY_PLUGIN_NOT_LOADED);
accessibilityPluginNotLoaded = true;
isAccessibilitySession = false;
}

checkAndSetAccessibility(bsConfig, isAccessibilitySession);

const preferredPort = 5348;
Expand Down Expand Up @@ -422,6 +436,7 @@ module.exports = function run(args, rawArgs) {
unique_id: utils.generateUniqueHash(),
package_error: utils.checkError(packageData),
checkmd5_error: utils.checkError(md5data),
accessibility_plugin_not_loaded: accessibilityPluginNotLoaded,
build_id: data.build_id,
test_zip_size: test_zip_size,
npm_zip_size: npm_zip_size,
Expand Down
3 changes: 3 additions & 0 deletions bin/helpers/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ config.retries = 5;
config.networkErrorExitCode = 2;
config.compiledConfigJsDirName = 'tmpBstackCompiledJs';
config.configJsonFileName = 'tmpCypressConfig.json';
// Temp file used to surface, from the child process that requires the cypress
// config, whether the BrowserStack accessibility plugin was loaded by it.
config.accessibilityPluginFlagFileName = 'tmpA11yPluginLoaded.json';

// turboScale
config.turboScaleMd5Sum = `${config.turboScaleUrl}/md5sumcheck`;
Expand Down
2 changes: 2 additions & 0 deletions bin/helpers/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const syncCLI = {
};

const userMessages = {
ACCESSIBILITY_PLUGIN_NOT_LOADED:
"BrowserStack Accessibility Automation plugin is not loaded in your cypress config file. Disabling accessibility for this build. Please follow https://www.browserstack.com/docs/accessibility/automated-tests/get-started/cypress to enable accessibility testing.",
BUILD_FAILED: "Build creation failed.",
BUILD_GENERATE_REPORT_FAILED:
"Generating report for the build <build-id> failed.",
Expand Down
52 changes: 50 additions & 2 deletions bin/helpers/readCypressConfigUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const constants = require("./constants");
const utils = require("./utils");
const logger = require('./logger').winstonLogger;

const parsedCypressConfigCache = new Map();

// Defense-in-depth: reject file paths containing shell metacharacters.
// This guards against command injection even if execFileSync is ever
// replaced with a shell-based exec in the future.
Expand Down Expand Up @@ -226,31 +228,68 @@ exports.loadJsFile = (cypress_config_filepath, bstack_node_modules_path) => {
};
const args = [require_module_helper_path, cypress_config_filepath];

// Remove any stale detection flag from a crashed prior run so we never read an
// outdated value if the child fails to write a fresh one.
if (fs.existsSync(config.accessibilityPluginFlagFileName)) {
try {
fs.unlinkSync(config.accessibilityPluginFlagFileName)
} catch (e) { /* best-effort */ }
}

logger.debug(`Running: node ${args.map(a => '"' + a + '"').join(' ')} (via execFileSync, NODE_PATH=${bstack_node_modules_path})`);
cp.execFileSync('node', args, execOptions);

const cypress_config = JSON.parse(fs.readFileSync(config.configJsonFileName).toString())
if (fs.existsSync(config.configJsonFileName)) {
fs.unlinkSync(config.configJsonFileName)
}

// Propagate accessibility-plugin detection (written by requireModule.js in the
// child process) back into the parent process via an env var. We set it
// explicitly to 'true'/'false' only when the config was actually required, so
// callers can distinguish a definitive result from "could not read".
try {
if (fs.existsSync(config.accessibilityPluginFlagFileName)) {
const flag = JSON.parse(fs.readFileSync(config.accessibilityPluginFlagFileName).toString());
process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED = (flag && flag.accessibilityPluginLoaded) ? 'true' : 'false';
fs.unlinkSync(config.accessibilityPluginFlagFileName);
}
} catch (err) {
logger.debug(`Unable to read accessibility plugin detection flag: ${err.message}`);
}

return cypress_config
}

exports.readCypressConfigFile = (bsConfig) => {
const cypress_config_filepath = path.resolve(bsConfig.run_settings.cypressConfigFilePath)

// Return the memoized parse if this exact config was already read in this run.
if (parsedCypressConfigCache.has(cypress_config_filepath)) {
logger.debug(`Using memoized cypress config for: ${cypress_config_filepath}`);
return parsedCypressConfigCache.get(cypress_config_filepath);
}

try {
const cypress_config_filename = bsConfig.run_settings.cypress_config_filename
const bstack_node_modules_path = path.join(path.resolve(config.packageDirName), 'node_modules')
const conf_lang = this.detectLanguage(cypress_config_filename)

logger.debug(`cypress config path: ${cypress_config_filepath}`);

let parsedConfig;
if (conf_lang == 'js' || conf_lang == 'cjs') {
return this.loadJsFile(cypress_config_filepath, bstack_node_modules_path)
parsedConfig = this.loadJsFile(cypress_config_filepath, bstack_node_modules_path)
} else if (conf_lang === 'ts') {
const compiled_cypress_config_filepath = this.convertTsConfig(bsConfig, cypress_config_filepath, bstack_node_modules_path)
return this.loadJsFile(compiled_cypress_config_filepath, bstack_node_modules_path)
parsedConfig = this.loadJsFile(compiled_cypress_config_filepath, bstack_node_modules_path)
}

// Cache only successful parses so a later call can retry on failure.
if (parsedConfig !== undefined) {
parsedCypressConfigCache.set(cypress_config_filepath, parsedConfig);
}
return parsedConfig;
} catch (error) {
const errorMessage = `Error while reading cypress config: ${error.message}`
const errorCode = 'cypress_config_file_read_failed'
Expand All @@ -270,5 +309,14 @@ exports.readCypressConfigFile = (bsConfig) => {
if (fs.existsSync(complied_js_dir)) {
fs.rmdirSync(complied_js_dir, { recursive: true })
}
// Guaranteed cleanup of the accessibility-plugin detection flag file, even
// if loadJsFile threw before its own read/unlink of the flag.
if (fs.existsSync(config.accessibilityPluginFlagFileName)) {
try {
fs.unlinkSync(config.accessibilityPluginFlagFileName)
} catch (cleanupErr) {
logger.debug(`Unable to remove accessibility plugin flag file: ${cleanupErr.message || cleanupErr}`);
}
}
}
}
14 changes: 14 additions & 0 deletions bin/helpers/requireModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,17 @@ if (fs.existsSync(config.configJsonFileName)) {

// write module in temporary json file
fs.writeFileSync(config.configJsonFileName, JSON.stringify(mod))

// Requiring the cypress config above executes its top-level requires, which
// includes the BrowserStack accessibility plugin when the user has wired it in.
// The plugin sets BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED on load; surface that
// back to the parent CLI process via a temp flag file.
try {
const accessibilityPluginLoaded = process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED === 'true';
fs.writeFileSync(
config.accessibilityPluginFlagFileName,

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.

💡 Suggestion — temp flag file can be left behind.

tmpA11yPluginLoaded.json is unlinked only inside loadJsFile after a successful read. If loadJsFile returns early (e.g. the configJsonFileName read throws before the propagation block), the file lingers, and a stale file from a crashed prior run could be read on a path that bypasses a fresh write. The child overwrites it on the normal path, so risk is low — but a guaranteed cleanup (or writing under an os-temp dir) would be safer.

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.

added a fix for this

JSON.stringify({ accessibilityPluginLoaded })
);
} catch (err) {
// best-effort: detection falls back to "not loaded" if this fails
}
Loading