UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

863 lines (778 loc) 34.3 kB
'use strict' const path = require('path') const { getArtifactId } = require('./artifact-id') const { MAX_GENERATED_FILES, getGeneratedFileContentError, } = require('./generated-file-policy') const { getInlineDatadogInitialization } = require('./local-command') const { hasUnsafeExecutionCharacter, hasUnsafeInvisibleCharacter, isSensitiveName, sanitizeForReport, sanitizeString, } = require('./redaction') const FRAMEWORKS = new Set([ 'jest', 'vitest', 'mocha', 'cucumber', 'cypress', 'playwright', 'node:test', 'ava', 'tap', 'jasmine', 'karma', 'uvu', 'testcafe', 'custom', 'unknown', ]) const STATUSES = new Set([ 'runnable', 'detected_not_runnable', 'requires_external_service', 'requires_manual_setup', 'unsupported_by_validator', 'unknown', ]) const CI_WIRING_STATUSES = new Set([ 'pass', 'fail', 'skip', 'unknown', ]) const CI_WIRING_REPLAYABILITIES = new Set(['replayable', 'not_replayable']) const CI_INITIALIZATION_STATUSES = new Set(['configured', 'not_configured', 'unknown']) const UNRESOLVED_PLACEHOLDER_PATTERN = /\$\{[^}]+\}/ const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ const MAX_COMMAND_TIMEOUT_MS = 30 * 60 * 1000 const MAX_FRAMEWORKS = 100 const MAX_MANIFEST_ARRAY_ENTRIES = 1000 const MAX_SETUP_COMMANDS = 100 const MAX_VALIDATION_ERRORS = 50 const MAX_REPRESENTATIVE_TESTS = 1000 const SECRET_PLACEHOLDER = 'dd-validation-placeholder' const SAFE_SECRET_FIELD_VALUES = new Set(['', '0', '1', 'false', 'true', 'none', 'disabled']) const COMMAND_FIELDS = new Set([ 'argv', 'cwd', 'description', 'env', 'required', 'requiredEnvVars', 'shell', 'shellCommand', 'shellReason', 'timeoutMs', 'usesShell', 'id', 'outputPaths', ]) const GENERATED_SCENARIO_IDS = new Set([ 'basic-pass', 'atr-fail-once', 'test-management-target', ]) const GENERATED_SCENARIO_EXIT_CODES = { 'basic-pass': 0, 'atr-fail-once': 1, 'test-management-target': 0, } function validateManifest (manifest) { const errors = createErrorCollector() if (!manifest || typeof manifest !== 'object') { return ['Manifest must be a JSON object.'] } requiredString(manifest, 'schemaVersion', errors) requiredObject(manifest, 'repository', errors) requiredObject(manifest, 'environment', errors) requiredArray(manifest, 'frameworks', errors) if (Array.isArray(manifest.frameworks) && manifest.frameworks.length === 0) { errors.push('frameworks must include at least one framework entry.') } validateArrayLimit(manifest, 'frameworks', MAX_FRAMEWORKS, errors) validateArrayLimit(manifest, 'omitted', MAX_MANIFEST_ARRAY_ENTRIES, errors) validateArrayLimit(manifest, 'omittedTestCommands', MAX_MANIFEST_ARRAY_ENTRIES, errors) if (manifest.repository) { requiredAbsolutePath(manifest.repository, 'root', errors) } if (manifest.ciDiscovery) { validateCiDiscovery(manifest.ciDiscovery, 'ciDiscovery', errors) } if (Array.isArray(manifest.frameworks)) { const frameworks = manifest.frameworks.slice(0, MAX_FRAMEWORKS) validateUniqueFrameworkIds(frameworks, errors) validateUniqueArtifactIds(frameworks, errors) validateDuplicateRunnableCoverage(frameworks, errors) validateGeneratedPathCollisions(frameworks, errors) for (const [index, framework] of frameworks.entries()) { validateFramework(framework, index, errors) } } validateRepositoryContainedPaths(manifest, errors) return errors.finalize() } /** * Rejects generated files or cleanup targets shared by multiple framework entries. * * @param {object[]} frameworks manifest framework entries * @param {{push: function(string): void}} errors bounded validation error collector * @returns {void} */ function validateGeneratedPathCollisions (frameworks, errors) { const seen = new Map() for (const [index, framework] of frameworks.entries()) { const strategy = framework?.generatedTestStrategy const frameworkPaths = new Map([ ...limitedArray(strategy?.files, MAX_GENERATED_FILES).map(file => file?.path), ...limitedArray(strategy?.cleanupPaths, MAX_MANIFEST_ARRAY_ENTRIES), ].filter(filename => typeof filename === 'string' && path.isAbsolute(filename)) .map(filename => [path.normalize(filename), filename])) for (const [key, filename] of frameworkPaths) { const previous = seen.get(key) if (previous === undefined) { seen.set(key, index) continue } errors.push( `frameworks[${index}].generatedTestStrategy path ${JSON.stringify(filename)} conflicts with ` + `frameworks[${previous}].generatedTestStrategy. Generated files and cleanup paths must be unique across ` + 'framework entries.' ) } } } function validateDuplicateRunnableCoverage (frameworks, errors) { const seen = new Map() for (const [index, framework] of frameworks.entries()) { if (framework?.status !== 'runnable' || !framework.ciWiringCommand) continue const key = JSON.stringify({ framework: framework.framework, projectRoot: framework.project?.root, existingTestCommand: commandExecutionShape(framework.existingTestCommand), ciWiringCommand: commandExecutionShape(framework.ciWiringCommand), ciInitializesDatadog: commandInitializesDatadog(framework.ciWiringCommand), }) const previous = seen.get(key) if (previous === undefined) { seen.set(key, index) continue } errors.push( `frameworks[${index}] duplicates runnable framework and CI command coverage from frameworks[${previous}]. ` + 'Keep one representative framework entry and record the other CI job as an omitted or duplicate candidate.' ) } } function validateUniqueArtifactIds (frameworks, errors) { const seen = new Map() for (const [index, framework] of frameworks.entries()) { if (typeof framework?.id !== 'string') continue const artifactId = getArtifactId(framework.id) const previous = seen.get(artifactId) if (previous !== undefined && previous !== framework.id) { errors.push( `frameworks[${index}].id collides with another framework artifact identifier after normalization.` ) } else { seen.set(artifactId, framework.id) } } } function commandExecutionShape (command) { if (!command) return null return { argv: command.argv, cwd: command.cwd, shell: command.shell, shellCommand: command.shellCommand, usesShell: Boolean(command.usesShell), } } function commandInitializesDatadog (command) { const values = [ ...(command?.argv || []), command?.shellCommand, command?.env?.NODE_OPTIONS, ].filter(Boolean).join(' ') return /dd-trace\/ci\/init/.test(values) } function validateRepositoryContainedPaths (manifest, errors) { const repositoryRoot = manifest.repository?.root if (typeof repositoryRoot !== 'string' || !path.isAbsolute(repositoryRoot)) return const frameworks = Array.isArray(manifest.frameworks) ? manifest.frameworks.slice(0, MAX_FRAMEWORKS) : [] for (const [index, framework] of frameworks.entries()) { if (!framework || typeof framework !== 'object' || Array.isArray(framework)) continue const prefix = `frameworks[${index}]` containedPath(repositoryRoot, framework.project?.root, `${prefix}.project.root`, errors) containedPath(repositoryRoot, framework.project?.packageJson, `${prefix}.project.packageJson`, errors) for (const [configIndex, configFile] of limitedArray(framework.project?.configFiles, MAX_MANIFEST_ARRAY_ENTRIES).entries()) { containedPath(repositoryRoot, configFile, `${prefix}.project.configFiles[${configIndex}]`, errors) } for (const [name, command] of getFrameworkCommands(framework)) { containedPath(repositoryRoot, command?.cwd, `${prefix}.${name}.cwd`, errors) } containedPath(repositoryRoot, framework.ciWiring?.configFile, `${prefix}.ciWiring.configFile`, errors) containedPath(repositoryRoot, framework.ciWiring?.workingDirectory, `${prefix}.ciWiring.workingDirectory`, errors) const strategy = framework.generatedTestStrategy containedPath(repositoryRoot, strategy?.testDirectory, `${prefix}.generatedTestStrategy.testDirectory`, errors) for (const [fileIndex, file] of limitedArray(strategy?.files, MAX_GENERATED_FILES).entries()) { containedPath(repositoryRoot, file?.path, `${prefix}.generatedTestStrategy.files[${fileIndex}].path`, errors) } for (const [cleanupIndex, cleanupPath] of limitedArray(strategy?.cleanupPaths, MAX_MANIFEST_ARRAY_ENTRIES).entries()) { containedPath( repositoryRoot, cleanupPath, `${prefix}.generatedTestStrategy.cleanupPaths[${cleanupIndex}]`, errors ) } for (const [scenarioIndex, scenario] of limitedArray(strategy?.scenarios, GENERATED_SCENARIO_IDS.size).entries()) { for (const [identityIndex, identity] of limitedArray(scenario?.testIdentities, MAX_MANIFEST_ARRAY_ENTRIES).entries()) { containedPath( repositoryRoot, identity?.file, `${prefix}.generatedTestStrategy.scenarios[${scenarioIndex}].testIdentities[${identityIndex}].file`, errors ) } } } } function getFrameworkCommands (framework) { const commands = [] for (const name of ['existingTestCommand', 'ciWiringCommand']) { if (framework[name]) commands.push([name, framework[name]]) } for (const [index, command] of limitedArray(framework.setup?.commands, MAX_SETUP_COMMANDS).entries()) { commands.push([`setup.commands[${index}]`, command]) } for (const [index, scenario] of limitedArray(framework.generatedTestStrategy?.scenarios, GENERATED_SCENARIO_IDS.size).entries()) { if (scenario?.runCommand) { commands.push([`generatedTestStrategy.scenarios[${index}].runCommand`, scenario.runCommand]) } } return commands } function containedPath (root, candidate, key, errors) { if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) return const relative = path.relative(path.resolve(root), path.resolve(candidate)) if (relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative))) return errors.push(`${key} must be inside repository.root.`) } function validateCiDiscovery (ciDiscovery, prefix, errors) { if (!ciDiscovery || typeof ciDiscovery !== 'object' || Array.isArray(ciDiscovery)) { errors.push(`${prefix} must be an object when present.`) return } for (const field of ['searched', 'found', 'staticFound', 'warnings', 'notes', 'contradictions']) { if (ciDiscovery[field] !== undefined) { if (Array.isArray(ciDiscovery[field])) { validateStringArray(ciDiscovery, field, errors, prefix) } else { errors.push(`${prefix}.${field} must be an array when present.`) } } } if (ciDiscovery.method !== undefined && typeof ciDiscovery.method !== 'string') { errors.push(`${prefix}.method must be a string when present.`) } } function validateFramework (framework, index, errors) { const prefix = `frameworks[${index}]` if (!framework || typeof framework !== 'object' || Array.isArray(framework)) { errors.push(`${prefix} must be an object.`) return } requiredString(framework, 'id', errors, prefix) enumString(framework, 'framework', FRAMEWORKS, errors, prefix) enumString(framework, 'status', STATUSES, errors, prefix) requiredObject(framework, 'project', errors, prefix) if (framework.project) { requiredAbsolutePath(framework.project, 'root', errors, `${prefix}.project`) optionalAbsolutePath(framework.project, 'packageJson', errors, `${prefix}.project`) optionalAbsolutePathArray(framework.project, 'configFiles', errors, `${prefix}.project`) validateArrayLimit(framework.project, 'configFiles', MAX_MANIFEST_ARRAY_ENTRIES, errors, `${prefix}.project`) } if (framework.status === 'runnable') { requiredCommand(framework, 'existingTestCommand', errors, prefix, { datadogClean: true }) validateDatadogCleanCommand(framework.existingTestCommand, `${prefix}.existingTestCommand`, errors) requiredObject(framework, 'preflight', errors, prefix) validatePreflight(framework.preflight, `${prefix}.preflight`, errors) requiredObject(framework, 'ciWiring', errors, prefix) } else { requireNonEmptyNotes(framework, errors, prefix) } if (framework.ciWiringCommand) { requiredCommand(framework, 'ciWiringCommand', errors, prefix) } if (framework.ciWiring) { validateCiWiring(framework, prefix, errors) } if (framework.forcedLocalCommand !== undefined) { errors.push( `${prefix}.forcedLocalCommand is not supported. Use the focused existingTestCommand for Basic Reporting ` + 'and ciWiringCommand for the CI-shaped replay.' ) } if (framework.setup?.commands) { if (Array.isArray(framework.setup.commands)) { validateArrayLimit(framework.setup, 'commands', MAX_SETUP_COMMANDS, errors, `${prefix}.setup`) for (const [commandIndex, command] of framework.setup.commands.slice(0, MAX_SETUP_COMMANDS).entries()) { requiredCommand({ command }, 'command', errors, `${prefix}.setup.commands[${commandIndex}]`) } } else { errors.push(`${prefix}.setup.commands must be an array.`) } } if (framework.generatedTestStrategy) { validateGeneratedTestStrategy(framework.generatedTestStrategy, `${prefix}.generatedTestStrategy`, errors) } } /** * Validates the approved upper bound for a representative test command. * * @param {object} preflight preflight declaration * @param {string} prefix manifest field path * @param {{push: function(string): void}} errors bounded validation error collector * @returns {void} */ function validatePreflight (preflight, prefix, errors) { if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return if (!Number.isInteger(preflight.maxTestCount) || preflight.maxTestCount < 1) { errors.push(`${prefix}.maxTestCount must be a positive integer.`) } else if (preflight.maxTestCount > MAX_REPRESENTATIVE_TESTS) { errors.push(`${prefix}.maxTestCount must not exceed ${MAX_REPRESENTATIVE_TESTS}.`) } } function validateGeneratedTestStrategy (strategy, prefix, errors) { if (!['planned', 'verified', 'proposed', 'not_possible'].includes(strategy.status)) { errors.push(`${prefix}.status must be planned, verified, proposed, or not_possible.`) } const completeStrategy = strategy.status === 'planned' || strategy.status === 'verified' if (completeStrategy) { requiredArray(strategy, 'files', errors, prefix) requiredArray(strategy, 'scenarios', errors, prefix) requiredArray(strategy, 'cleanupPaths', errors, prefix) validateCompleteGeneratedScenarioSet(strategy, prefix, errors) } else if ((strategy.status === 'proposed' || strategy.status === 'not_possible') && (typeof strategy.reason !== 'string' || strategy.reason.trim() === '')) { errors.push(`${prefix}.reason must explain why the generated test strategy is ${strategy.status}.`) } if (Array.isArray(strategy.files)) { if (strategy.files.length > MAX_GENERATED_FILES) { errors.push(`${prefix}.files must contain at most ${MAX_GENERATED_FILES} generated files.`) } for (const [index, file] of strategy.files.slice(0, MAX_GENERATED_FILES).entries()) { requiredAbsolutePath(file, 'path', errors, `${prefix}.files[${index}]`) requiredArray(file, 'contentLines', errors, `${prefix}.files[${index}]`) validateStringArray(file, 'contentLines', errors, `${prefix}.files[${index}]`) const policyError = getGeneratedFileContentError(file.contentLines) if (policyError) errors.push(`${prefix}.files[${index}].contentLines ${policyError}.`) } } if (Array.isArray(strategy.scenarios)) { validateArrayLimit(strategy, 'scenarios', GENERATED_SCENARIO_IDS.size, errors, prefix) for (const [index, scenario] of strategy.scenarios.slice(0, GENERATED_SCENARIO_IDS.size).entries()) { requiredString(scenario, 'id', errors, `${prefix}.scenarios[${index}]`) enumString(scenario, 'id', GENERATED_SCENARIO_IDS, errors, `${prefix}.scenarios[${index}]`) requiredCommand(scenario, 'runCommand', errors, `${prefix}.scenarios[${index}]`, { datadogClean: true }) validateDatadogCleanCommand(scenario.runCommand, `${prefix}.scenarios[${index}].runCommand`, errors) validateScenarioIdentities( scenario, `${prefix}.scenarios[${index}]`, errors, completeStrategy ) if (completeStrategy) { validateGeneratedScenarioOutcome(scenario, `${prefix}.scenarios[${index}]`, errors) } } } optionalAbsolutePath(strategy, 'testDirectory', errors, prefix) optionalAbsolutePathArray(strategy, 'cleanupPaths', errors, prefix) } function validateCiWiring (framework, prefix, errors) { const ciWiring = framework.ciWiring if (!ciWiring || typeof ciWiring !== 'object' || Array.isArray(ciWiring)) { errors.push(`${prefix}.ciWiring must be an object when present.`) return } if (!CI_WIRING_STATUSES.has(ciWiring.status)) { errors.push(`${prefix}.ciWiring.status must be pass, fail, skip, or unknown.`) } if (!CI_WIRING_REPLAYABILITIES.has(ciWiring.replayability)) { errors.push(`${prefix}.ciWiring.replayability must be replayable or not_replayable.`) } if (Object.hasOwn(ciWiring, 'ciWiringCommand')) { errors.push(`${prefix}.ciWiring.ciWiringCommand is misplaced; use ${prefix}.ciWiringCommand.`) } if (ciWiring.replayability === 'replayable' && !framework.ciWiringCommand) { errors.push(`${prefix}.ciWiringCommand is required when ${prefix}.ciWiring.replayability is replayable.`) } if (ciWiring.replayability === 'not_replayable') { if (ciWiring.status === 'pass' || ciWiring.status === 'fail') { errors.push(`${prefix}.ciWiring.status must be skip or unknown when replayability is not_replayable.`) } if (framework.ciWiringCommand) { errors.push(`${prefix}.ciWiringCommand must be omitted when ${prefix}.ciWiring.replayability is not_replayable.`) } if (!hasNonEmptyString(ciWiring.replayBlocker)) { errors.push(`${prefix}.ciWiring.replayBlocker must explain why CI replay is not_replayable.`) } } if (ciWiring.initialization !== undefined) { validateCiInitialization(ciWiring.initialization, `${prefix}.ciWiring.initialization`, errors) } if (ciWiring.initialization?.status === 'not_configured' && commandInitializesDatadog(framework.ciWiringCommand)) { errors.push( `${prefix}.ciWiring.initialization.status is not_configured, but ${prefix}.ciWiringCommand adds dd-trace ` + 'initialization. The replay command must preserve the discovered CI configuration; remove the added ' + 'initialization or correct the initialization status and evidence.' ) } if (framework.ciWiringCommand) { for (const field of ['provider', 'configFile', 'job', 'step', 'whySelected']) { requiredString(ciWiring, field, errors, `${prefix}.ciWiring`) } requiredAbsolutePath(ciWiring, 'configFile', errors, `${prefix}.ciWiring`) requiredAbsolutePath(ciWiring, 'workingDirectory', errors, `${prefix}.ciWiring`) if (path.resolve(ciWiring.workingDirectory || '') !== path.resolve(framework.ciWiringCommand.cwd || '')) { errors.push(`${prefix}.ciWiringCommand.cwd must match ${prefix}.ciWiring.workingDirectory.`) } if (ciWiring.shell !== undefined && ciWiring.shell !== null) { if (typeof ciWiring.shell !== 'string' || ciWiring.shell.trim() === '') { errors.push(`${prefix}.ciWiring.shell must be a non-empty string when present.`) } else if (hasUnsafeExecutionCharacter(ciWiring.shell)) { errors.push(`${prefix}.ciWiring.shell must not contain invisible or control characters.`) } } } if ((ciWiring.status === 'skip' || ciWiring.status === 'unknown') && !hasNonEmptyString(ciWiring.diagnosis) && !hasNonEmptyString(ciWiring.reason)) { errors.push(`${prefix}.ciWiring must explain why CI wiring is ${ciWiring.status}.`) } } function validateCiInitialization (initialization, prefix, errors) { if (!initialization || typeof initialization !== 'object' || Array.isArray(initialization)) { errors.push(`${prefix} must be an object when present.`) return } if (!CI_INITIALIZATION_STATUSES.has(initialization.status)) { errors.push(`${prefix}.status must be configured, not_configured, or unknown.`) } if (Array.isArray(initialization.evidence)) { validateStringArray(initialization, 'evidence', errors, prefix) if (initialization.status !== 'unknown' && initialization.evidence.length === 0) { errors.push(`${prefix}.evidence must explain the ${initialization.status} conclusion.`) } } else { errors.push(`${prefix}.evidence must be an array.`) } } function hasNonEmptyString (value) { return typeof value === 'string' && value.trim() !== '' } function validateDatadogCleanCommand (command, prefix, errors) { for (const [name, value] of Object.entries(command?.env || {})) { if (name.startsWith('DD_') || (name === 'NODE_OPTIONS' && /dd-trace/.test(String(value)))) { errors.push(`${prefix}.env.${name} must not configure Datadog initialization for local validation.`) } } const inlineInitialization = getInlineDatadogInitialization(command) if (inlineInitialization) { errors.push( `${prefix} ${inlineInitialization} and must be Datadog-clean for local validation. ` + 'Remove the inline initialization; preserve exact CI initialization only in ciWiringCommand.' ) } } function validateGeneratedScenarioOutcome (scenario, prefix, errors) { const expected = scenario.expectedWithoutDatadog if (!expected || typeof expected !== 'object' || Array.isArray(expected)) { errors.push(`${prefix}.expectedWithoutDatadog must be an object when generatedTestStrategy is planned or verified.`) return } const expectedExitCode = GENERATED_SCENARIO_EXIT_CODES[scenario.id] if (expectedExitCode !== undefined && expected.exitCode !== expectedExitCode) { errors.push(`${prefix}.expectedWithoutDatadog.exitCode must be ${expectedExitCode} for ${scenario.id}.`) } if (expected.observedTestCount !== 1) { errors.push(`${prefix}.expectedWithoutDatadog.observedTestCount must be 1 so the command isolates this scenario.`) } } function validateCompleteGeneratedScenarioSet (strategy, prefix, errors) { if (!Array.isArray(strategy.scenarios)) return const seen = new Set() for (const scenario of strategy.scenarios.slice(0, GENERATED_SCENARIO_IDS.size)) { if (typeof scenario?.id === 'string') seen.add(scenario.id) } for (const scenarioId of GENERATED_SCENARIO_IDS) { if (!seen.has(scenarioId)) { errors.push(`${prefix}.scenarios must include generated scenario "${scenarioId}" when status is planned or ` + 'verified.') } } } function validateUniqueFrameworkIds (frameworks, errors) { const seen = new Set() for (const [index, framework] of frameworks.entries()) { if (typeof framework?.id !== 'string') continue if (seen.has(framework.id)) { errors.push(`frameworks[${index}].id must be unique; duplicate "${framework.id}".`) } seen.add(framework.id) } } function requireNonEmptyNotes (framework, errors, prefix) { if (!Array.isArray(framework.notes) || framework.notes.length === 0) { errors.push(`${prefix}.notes must include a reason when status is ${framework.status}.`) return } validateStringArray(framework, 'notes', errors, prefix) } function validateScenarioIdentities (scenario, prefix, errors, required = false) { if (!Array.isArray(scenario.testIdentities)) { if (required) { errors.push(`${prefix}.testIdentities must be a non-empty array when generatedTestStrategy is planned or ` + 'verified.') } return } if (required && scenario.testIdentities.length === 0) { errors.push(`${prefix}.testIdentities must be a non-empty array when generatedTestStrategy is planned or verified.`) } validateArrayLimit( { testIdentities: scenario.testIdentities }, 'testIdentities', MAX_MANIFEST_ARRAY_ENTRIES, errors, prefix ) for (const [index, identity] of scenario.testIdentities.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) { const identityPrefix = `${prefix}.testIdentities[${index}]` if (!identity || typeof identity !== 'object' || Array.isArray(identity)) { errors.push(`${identityPrefix} must be an object.`) continue } requiredString(identity, 'name', errors, identityPrefix) if (identity.suite !== undefined && identity.suite !== null && typeof identity.suite !== 'string') { errors.push(`${identityPrefix}.suite must be a string or null when present.`) } optionalAbsolutePath(identity, 'file', errors, identityPrefix) } } function requiredCommand (target, field, errors, prefix = '', options = {}) { const value = target && target[field] const key = join(prefix, field) if (!value || typeof value !== 'object') { errors.push(`${key} must be an object.`) return } for (const name of Object.keys(value)) { if (!COMMAND_FIELDS.has(name)) errors.push(`${key}.${name} is not an allowed command field.`) } if (value.usesShell !== undefined && typeof value.usesShell !== 'boolean') { errors.push(`${key}.usesShell must be a boolean when present.`) } if (value.required !== undefined && typeof value.required !== 'boolean') { errors.push(`${key}.required must be a boolean when present.`) } requiredAbsolutePath(value, 'cwd', errors, key) rejectUnresolvedPlaceholder(value.cwd, `${key}.cwd`, errors) if (value.shell !== undefined) { requiredString(value, 'shell', errors, key) rejectUnresolvedPlaceholder(value.shell, `${key}.shell`, errors) if (!value.usesShell) errors.push(`${key}.shell requires usesShell to be true.`) if (typeof value.shell === 'string' && hasUnsafeExecutionCharacter(value.shell)) { errors.push(`${key}.shell must not contain invisible or control characters.`) } } if (value.usesShell) { requiredString(value, 'shellCommand', errors, key) rejectUnresolvedPlaceholder(value.shellCommand, `${key}.shellCommand`, errors) if (typeof value.shellCommand === 'string' && hasUnsafeInvisibleCharacter(value.shellCommand)) { errors.push(`${key}.shellCommand must not contain invisible or control characters.`) } else if (typeof value.shellCommand === 'string' && sanitizeString(value.shellCommand) !== value.shellCommand) { errors.push(`${key}.shellCommand must not contain inline secret-like values. Put safe placeholders in env.`) } } else if (!Array.isArray(value.argv) || value.argv.length === 0) { errors.push(`${key}.argv must be a non-empty array unless usesShell is true.`) } else { validateStringArray(value, 'argv', errors, key) for (const [index, arg] of value.argv.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) { rejectUnresolvedPlaceholder(arg, `${key}.argv[${index}]`, errors) } if (value.argv.some(hasUnsafeExecutionCharacter)) { errors.push(`${key}.argv must not contain invisible or control characters.`) } else if (JSON.stringify(sanitizeForReport(value.argv)) !== JSON.stringify(value.argv)) { errors.push(`${key}.argv must not contain inline secret-like values. Put safe placeholders in env.`) } } if (value.env !== undefined) { if (!value.env || typeof value.env !== 'object' || Array.isArray(value.env)) { errors.push(`${key}.env must be an object when present.`) } else { const environmentEntries = Object.entries(value.env) if (environmentEntries.length > MAX_MANIFEST_ARRAY_ENTRIES) { errors.push(`${key}.env must contain at most ${MAX_MANIFEST_ARRAY_ENTRIES} entries.`) } for (const [name, envValue] of environmentEntries.slice(0, MAX_MANIFEST_ARRAY_ENTRIES)) { if (!ENV_NAME_PATTERN.test(name)) { errors.push(`${key}.env contains invalid variable name ${JSON.stringify(name)}.`) } if (typeof envValue !== 'string') errors.push(`${key}.env.${name} must be a string.`) const validatePlaceholder = !(options.datadogClean && name.startsWith('DD_')) if (typeof envValue === 'string' && hasUnsafeExecutionCharacter(envValue)) { errors.push(`${key}.env.${name} must not contain invisible or control characters.`) } else if (validatePlaceholder && typeof envValue === 'string' && containsSecretValue(name, envValue) && envValue !== SECRET_PLACEHOLDER) { errors.push(`${key}.env.${name} must use the safe placeholder ${JSON.stringify(SECRET_PLACEHOLDER)}.`) } rejectUnresolvedPlaceholder(envValue, `${key}.env.${name}`, errors) } } } if (value.requiredEnvVars !== undefined) { if (Array.isArray(value.requiredEnvVars)) { validateArrayLimit(value, 'requiredEnvVars', MAX_MANIFEST_ARRAY_ENTRIES, errors, key) for (const [index] of value.requiredEnvVars.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) { requiredString(value.requiredEnvVars, index, errors, `${key}.requiredEnvVars`) } } else { errors.push(`${key}.requiredEnvVars must be an array when present.`) } } optionalAbsolutePathArray(value, 'outputPaths', errors, key) if (value.timeoutMs !== undefined && (!Number.isFinite(value.timeoutMs) || value.timeoutMs <= 0)) { errors.push(`${key}.timeoutMs must be a positive number when present.`) } else if (value.timeoutMs > MAX_COMMAND_TIMEOUT_MS) { errors.push(`${key}.timeoutMs must not exceed ${MAX_COMMAND_TIMEOUT_MS} ms.`) } } function containsSecretValue (name, value) { if (SAFE_SECRET_FIELD_VALUES.has(value.toLowerCase())) return false return isSensitiveName(name) || sanitizeString(value) !== value } function rejectUnresolvedPlaceholder (value, key, errors) { if (typeof value !== 'string' || !UNRESOLVED_PLACEHOLDER_PATTERN.test(value)) return errors.push(`${key} contains an unresolved placeholder. Resolve it before live validation.`) } function requiredObject (target, field, errors, prefix = '') { const value = target && target[field] if (!value || typeof value !== 'object' || Array.isArray(value)) { errors.push(`${join(prefix, field)} must be an object.`) } } function requiredArray (target, field, errors, prefix = '') { if (!Array.isArray(target && target[field])) { errors.push(`${join(prefix, field)} must be an array.`) } } function requiredString (target, field, errors, prefix = '') { if (typeof (target && target[field]) !== 'string' || target[field].length === 0) { errors.push(`${join(prefix, field)} must be a non-empty string.`) } } function enumString (target, field, values, errors, prefix = '') { if (!values.has(target && target[field])) { errors.push(`${join(prefix, field)} must be one of: ${[...values].join(', ')}.`) } } function requiredAbsolutePath (target, field, errors, prefix = '') { const value = target && target[field] if (typeof value !== 'string' || !path.isAbsolute(value)) { errors.push(`${join(prefix, field)} must be an absolute path.`) } else if (hasUnsafeExecutionCharacter(value)) { errors.push(`${join(prefix, field)} must not contain invisible or control characters.`) } } function optionalAbsolutePath (target, field, errors, prefix = '') { const value = target && target[field] if (value === undefined || value === null) return if (typeof value !== 'string' || !path.isAbsolute(value)) { errors.push(`${join(prefix, field)} must be an absolute path when present.`) } else if (hasUnsafeExecutionCharacter(value)) { errors.push(`${join(prefix, field)} must not contain invisible or control characters.`) } } function optionalAbsolutePathArray (target, field, errors, prefix = '') { const value = target && target[field] if (value === undefined) return if (!Array.isArray(value)) { errors.push(`${join(prefix, field)} must be an array when present.`) return } validateArrayLimit(target, field, MAX_MANIFEST_ARRAY_ENTRIES, errors, prefix) for (const [index, item] of value.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) { if (typeof item !== 'string' || !path.isAbsolute(item)) { errors.push(`${join(prefix, field)}[${index}] must be an absolute path.`) } else if (hasUnsafeExecutionCharacter(item)) { errors.push(`${join(prefix, field)}[${index}] must not contain invisible or control characters.`) } } } function validateStringArray (target, field, errors, prefix = '') { const value = target && target[field] if (!Array.isArray(value)) return validateArrayLimit(target, field, MAX_MANIFEST_ARRAY_ENTRIES, errors, prefix) for (const [index, item] of value.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) { if (typeof item !== 'string') { errors.push(`${join(prefix, field)}[${index}] must be a string.`) } } } function validateArrayLimit (target, field, limit, errors, prefix = '') { const value = target && target[field] if (Array.isArray(value) && value.length > limit) { errors.push(`${join(prefix, field)} must contain at most ${limit} entries.`) } } function limitedArray (value, limit) { return Array.isArray(value) ? value.slice(0, limit) : [] } function createErrorCollector () { const errors = [] let omitted = 0 Object.defineProperties(errors, { push: { value (...messages) { for (const message of messages) { if (this.length < MAX_VALIDATION_ERRORS - 1) { Array.prototype.push.call(this, message) } else { omitted++ } } return this.length }, }, finalize: { value () { if (omitted > 0) { Array.prototype.push.call(this, `${omitted} additional validation error(s) omitted.`) } return this }, }, }) return errors } function join (prefix, field) { return prefix ? `${prefix}.${field}` : field } module.exports = { MAX_FRAMEWORKS, MAX_REPRESENTATIVE_TESTS, MAX_VALIDATION_ERRORS, validateManifest, }