diff --git a/.github/scripts/scan-test-artifacts.mjs b/.github/scripts/scan-test-artifacts.mjs new file mode 100644 index 00000000..31691c4d --- /dev/null +++ b/.github/scripts/scan-test-artifacts.mjs @@ -0,0 +1,1108 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { TextDecoder } from 'node:util'; + +const EXIT_INVALID_ROOT = 2; +const EXIT_UNSAFE_ARTIFACT = 3; +const EXIT_SCAN_FAILURE = 4; + +const MAX_FILES = 128; +const MAX_FILE_BYTES = 512 * 1024; +const MAX_TOTAL_BYTES = 2 * 1024 * 1024; +const MAX_DEPTH = 6; +const MAX_ENTRIES = 256; +const MAX_ROOTS = 8; + +const ROOT_NAME = /^(?:\.test-artifacts|artifacts|coverage|reports|test-artifacts|test-results)(?:[-_.][a-z0-9]+)*$/i; +const SOURCE_ROOT_NAMES = new Set([ + '.git', + '.github', + 'docs', + 'functions', + 'node_modules', + 'public', + 'scripts', + 'src', + 'tests', +]); +const SOURCE_MARKERS = new Set([ + '.git', + '.github', + 'AGENTS.md', + 'firebase.json', + 'firestore.rules', + 'functions', + 'package.json', + 'public', + 'src', +]); +const ALLOWED_EXTENSIONS = new Set([ + '.info', + '.json', + '.jsonl', + '.lcov', + '.log', + '.tap', + '.txt', + '.xml', +]); +const ARCHIVE_EXTENSIONS = new Set([ + '.7z', + '.bz2', + '.gz', + '.rar', + '.tar', + '.tgz', + '.xz', + '.zip', +]); +const REDACTED_VALUES = new Set([ + '', + '*', + '***', + '', + '[redacted]', + 'redacted', +]); +const SYNTHETIC_IDENTITY = /^(?:demo|example|reserved|synthetic|test)(?:[-_ ](?:demo|example|reserved|synthetic|test))*$/i; +const utf8 = new TextDecoder('utf-8', { fatal: true }); + +class ArtifactSafetyError extends Error { + constructor(ruleId, exitCode) { + super('Test artifact scan failed.'); + this.name = 'ArtifactSafetyError'; + this.ruleId = ruleId; + this.exitCode = exitCode; + } +} + +function fail(ruleId, exitCode = EXIT_UNSAFE_ARTIFACT) { + throw new ArtifactSafetyError(ruleId, exitCode); +} + +function compareText(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function hasParentSegment(rawRoot) { + return rawRoot.split(/[\\/]+/u).includes('..'); +} + +function isAncestor(candidate, target) { + const relative = path.relative(candidate, target); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..'); +} + +function directoryIdentity(stat) { + return Object.freeze({ + ctimeNs: stat.ctimeNs, + dev: stat.dev, + ino: stat.ino, + }); +} + +function sameDirectoryIdentity(stat, expected) { + return stat.isDirectory() + && stat.dev === expected.dev + && stat.ino === expected.ino + && stat.ctimeNs === expected.ctimeNs; +} + +function sameIdentityRecord(left, right) { + return left.dev === right.dev + && left.ino === right.ino + && left.ctimeNs === right.ctimeNs; +} + +function readDirectoryIdentity( + directory, + ruleId = 'ENTRY_CHANGED', + exitCode = EXIT_SCAN_FAILURE, +) { + try { + const stat = fs.lstatSync(directory, { bigint: true }); + if (stat.isSymbolicLink() || !stat.isDirectory()) fail(ruleId, exitCode); + return directoryIdentity(stat); + } catch (error) { + if (error instanceof ArtifactSafetyError) throw error; + fail(ruleId, exitCode); + } +} + +function openBoundDirectory(directory, expected) { + let descriptor; + try { + descriptor = fs.openSync( + directory, + fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW, + ); + const stat = fs.fstatSync(descriptor, { bigint: true }); + if (!sameDirectoryIdentity(stat, expected)) fail('ENTRY_CHANGED', EXIT_SCAN_FAILURE); + return descriptor; + } catch (error) { + if (descriptor !== undefined) fs.closeSync(descriptor); + if (error instanceof ArtifactSafetyError) throw error; + fail('ENTRY_UNREADABLE', EXIT_SCAN_FAILURE); + } +} + +function assertBoundDirectory(directory, expected, descriptor) { + try { + const descriptorStat = fs.fstatSync(descriptor, { bigint: true }); + const pathStat = fs.lstatSync(directory, { bigint: true }); + if (!sameDirectoryIdentity(descriptorStat, expected) + || !sameDirectoryIdentity(pathStat, expected)) { + fail('ENTRY_CHANGED', EXIT_SCAN_FAILURE); + } + } catch (error) { + if (error instanceof ArtifactSafetyError) throw error; + fail('ENTRY_CHANGED', EXIT_SCAN_FAILURE); + } +} + +function validateRoot(rawRoot, cwd) { + if (typeof rawRoot !== 'string' + || rawRoot.length === 0 + || rawRoot.length > 1024 + || hasParentSegment(rawRoot)) { + fail('ROOT_INVALID', EXIT_INVALID_ROOT); + } + + const resolved = path.resolve(cwd, rawRoot); + if (isAncestor(resolved, cwd) || resolved === path.parse(resolved).root) { + fail('ROOT_TOO_BROAD', EXIT_INVALID_ROOT); + } + if (!ROOT_NAME.test(path.basename(resolved)) || SOURCE_ROOT_NAMES.has(path.basename(resolved))) { + fail('ROOT_NOT_ARTIFACT_OUTPUT', EXIT_INVALID_ROOT); + } + + let rootStat; + try { + rootStat = fs.lstatSync(resolved, { bigint: true }); + } catch { + fail('ROOT_UNREADABLE', EXIT_INVALID_ROOT); + } + if (rootStat.isSymbolicLink()) fail('ROOT_SYMLINK', EXIT_INVALID_ROOT); + if (!rootStat.isDirectory()) fail('ROOT_NOT_DIRECTORY', EXIT_INVALID_ROOT); + + let realRoot; + try { + realRoot = fs.realpathSync(resolved); + fs.accessSync(realRoot, fs.constants.R_OK); + } catch { + fail('ROOT_UNREADABLE', EXIT_INVALID_ROOT); + } + if (isAncestor(realRoot, cwd) || realRoot === path.parse(realRoot).root) { + fail('ROOT_TOO_BROAD', EXIT_INVALID_ROOT); + } + if (!ROOT_NAME.test(path.basename(realRoot)) || SOURCE_ROOT_NAMES.has(path.basename(realRoot))) { + fail('ROOT_NOT_ARTIFACT_OUTPUT', EXIT_INVALID_ROOT); + } + const initialIdentity = directoryIdentity(rootStat); + const realIdentity = readDirectoryIdentity(realRoot, 'ROOT_UNREADABLE', EXIT_INVALID_ROOT); + if (!sameIdentityRecord(initialIdentity, realIdentity)) { + fail('ENTRY_CHANGED', EXIT_SCAN_FAILURE); + } + return Object.freeze({ + identity: realIdentity, + path: realRoot, + }); +} + +function snapshotRootArguments(rawRoots) { + try { + if (!Array.isArray(rawRoots) || Object.getPrototypeOf(rawRoots) !== Array.prototype) { + throw new TypeError(); + } + const descriptors = Object.getOwnPropertyDescriptors(rawRoots); + const lengthDescriptor = descriptors.length; + if (!lengthDescriptor + || !Object.hasOwn(lengthDescriptor, 'value') + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0) { + throw new TypeError(); + } + + const allowedKeys = new Set(['length']); + const roots = []; + for (let index = 0; index < lengthDescriptor.value; index += 1) { + const key = String(index); + const descriptor = descriptors[key]; + if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw new TypeError(); + allowedKeys.add(key); + roots.push(descriptor.value); + } + if (Reflect.ownKeys(descriptors).some((key) => ( + typeof key !== 'string' || !allowedKeys.has(key) + ))) { + throw new TypeError(); + } + return roots; + } catch { + fail('API_INVALID', EXIT_INVALID_ROOT); + } +} + +function snapshotScanOptions(rawOptions) { + try { + if (rawOptions === undefined) return Object.freeze({ cwd: process.cwd() }); + if (rawOptions === null || typeof rawOptions !== 'object') throw new TypeError(); + const prototype = Object.getPrototypeOf(rawOptions); + if (prototype !== Object.prototype && prototype !== null) throw new TypeError(); + + const descriptors = Object.getOwnPropertyDescriptors(rawOptions); + if (Reflect.ownKeys(descriptors).some((key) => key !== 'cwd')) throw new TypeError(); + const cwdDescriptor = descriptors.cwd; + if (cwdDescriptor && !Object.hasOwn(cwdDescriptor, 'value')) throw new TypeError(); + const cwd = cwdDescriptor ? cwdDescriptor.value : process.cwd(); + if (typeof cwd !== 'string' || cwd.length === 0 || cwd.length > 4096) { + throw new TypeError(); + } + return Object.freeze({ cwd }); + } catch { + fail('API_INVALID', EXIT_INVALID_ROOT); + } +} + +function normalizedViews(text) { + const views = new Set([text]); + const decodeUnicodeEscapes = (value) => value.replace( + /\\u([0-9a-f]{4})/giu, + (_match, code) => String.fromCodePoint(Number.parseInt(code, 16)), + ); + const decodePercentRuns = (value) => value.replace(/(?:%[0-9a-f]{2})+/giu, (run) => { + try { + return decodeURIComponent(run); + } catch { + return run.replace(/%([0-9a-f]{2})/giu, (_match, octet) => ( + String.fromCodePoint(Number.parseInt(octet, 16)) + )); + } + }); + const xmlEntities = Object.freeze({ + amp: '&', apos: "'", gt: '>', lt: '<', quot: '"', + }); + const decodeXmlEntities = (value) => value.replace( + /&(?:#([0-9]{1,7})|#x([0-9a-f]{1,6})|(amp|apos|gt|lt|quot));/giu, + (_match, decimal, hexadecimal, named) => { + const codePoint = decimal === undefined + ? (hexadecimal === undefined ? null : Number.parseInt(hexadecimal, 16)) + : Number.parseInt(decimal, 10); + if (codePoint === null) return xmlEntities[named.toLowerCase()]; + if (!Number.isSafeInteger(codePoint) + || codePoint < 0 + || codePoint > 0x10ffff + || (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + return _match; + } + return String.fromCodePoint(codePoint); + }, + ); + + const queue = [text]; + const transforms = [ + decodeUnicodeEscapes, + (value) => decodePercentRuns(value.replace(/\+/gu, '%20')), + decodeXmlEntities, + (value) => value.normalize('NFKC'), + ]; + while (queue.length > 0 && views.size < 32) { + const current = queue.shift(); + for (const transform of transforms) { + const decoded = transform(current); + if (decoded !== current && !views.has(decoded)) { + views.add(decoded); + queue.push(decoded); + } + } + } + return [...views]; +} + +function isRedacted(value) { + return REDACTED_VALUES.has(value.trim().toLowerCase()); +} + +function isReservedEmail(value) { + const domain = value.slice(value.lastIndexOf('@') + 1).toLowerCase(); + return domain === 'example.com' + || domain === 'example.net' + || domain === 'example.org' + || domain === 'invalid' + || domain === 'localhost' + || domain.endsWith('.example') + || domain.endsWith('.invalid') + || domain.endsWith('.localhost') + || domain.endsWith('.test'); +} + +function isReservedPhone(value) { + const digits = value.replace(/\D/gu, ''); + const national = digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits; + if (national.length !== 10) return false; + const exchange = national.slice(3, 6); + const subscriber = Number.parseInt(national.slice(6), 10); + return exchange === '555' && subscriber >= 100 && subscriber <= 199; +} + +function isReservedIdentity(value) { + return SYNTHETIC_IDENTITY.test(value) + || /^Synthetic Runner [0-9]{6}$/u.test(value); +} + +function normalizedFieldName(value) { + return value.toLowerCase().replace(/[^a-z0-9]/gu, ''); +} + +const NAME_FIELD_NAMES = new Set([ + 'displayname', + 'emergencycontactname', + 'firstname', + 'fullname', + 'lastname', + 'legalname', + 'membername', + 'runnername', +]); +const EMAIL_FIELD_NAMES = new Set([ + 'contactemail', + 'email', + 'emailaddress', + 'emergencycontactemail', + 'memberemail', + 'runneremail', +]); +const PHONE_FIELD_NAMES = new Set([ + 'contactphone', + 'emergencycontactphone', + 'homephone', + 'mobilephone', + 'phone', + 'phonenumber', +]); +const BIRTH_FIELD_NAMES = new Set(['birthdate', 'birthday', 'dateofbirth', 'dob']); +const ADDRESS_FIELD_NAMES = new Set([ + 'address', + 'address1', + 'address2', + 'addressline1', + 'addressline2', + 'billingaddress', + 'homeaddress', + 'mailingaddress', + 'postaladdress', + 'shippingaddress', + 'streetaddress', +]); +const AUTH_FIELD_NAMES = new Set([ + 'accesstoken', + 'apicredential', + 'apikey', + 'authtoken', + 'authorization', + 'authorizationcode', + 'checkoutsessionid', + 'clientsecret', + 'confirmationtoken', + 'cookie', + 'csrftoken', + 'idtoken', + 'oauthcode', + 'oauthstate', + 'oauthtoken', + 'oobcode', + 'password', + 'paymentintentclientsecret', + 'privatekey', + 'refreshtoken', + 'resettoken', + 'secret', + 'sessionid', + 'sessiontoken', + 'setcookie', + 'token', + 'verificationtoken', + 'xapikey', +]); + +function assertSensitiveScalar(fieldName, rawValue) { + const value = String(rawValue).trim(); + const normalizedName = normalizedFieldName(fieldName); + if (isRedacted(value)) return; + if (NAME_FIELD_NAMES.has(normalizedName) && isReservedIdentity(value)) return; + if (EMAIL_FIELD_NAMES.has(normalizedName) && isReservedEmail(value)) return; + if (PHONE_FIELD_NAMES.has(normalizedName) && isReservedPhone(value)) return; + if (AUTH_FIELD_NAMES.has(normalizedName)) fail('AUTH_MATERIAL'); + if (NAME_FIELD_NAMES.has(normalizedName) + || EMAIL_FIELD_NAMES.has(normalizedName) + || PHONE_FIELD_NAMES.has(normalizedName) + || BIRTH_FIELD_NAMES.has(normalizedName)) { + fail('SENSITIVE_IDENTITY_FIELD'); + } +} + +function isSensitiveFieldName(fieldName) { + const normalizedName = normalizedFieldName(fieldName); + return ADDRESS_FIELD_NAMES.has(normalizedName) + || AUTH_FIELD_NAMES.has(normalizedName) + || BIRTH_FIELD_NAMES.has(normalizedName) + || EMAIL_FIELD_NAMES.has(normalizedName) + || NAME_FIELD_NAMES.has(normalizedName) + || PHONE_FIELD_NAMES.has(normalizedName) + || normalizedName.startsWith('emergencycontact'); +} + +function assertSensitiveNamedValue(fieldName, rawValue) { + const normalizedName = normalizedFieldName(fieldName); + if (ADDRESS_FIELD_NAMES.has(normalizedName) + || (normalizedName.startsWith('emergencycontact') + && normalizedName !== 'emergencycontactname' + && normalizedName !== 'emergencycontactemail' + && normalizedName !== 'emergencycontactphone')) { + if (!isRedacted(String(rawValue))) fail('SENSITIVE_IDENTITY_FIELD'); + return; + } + assertSensitiveScalar(normalizedName, rawValue); +} + +function xmlLocalName(qualifiedName) { + return qualifiedName.slice(qualifiedName.lastIndexOf(':') + 1); +} + +function xmlAttributes(fragment) { + return [...fragment.matchAll( + /(?:^|\s)([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu, + )].map((match) => Object.freeze({ + name: xmlLocalName(match[1]), + value: match[2] ?? match[3], + })); +} + +function sensitiveFieldValues(text) { + const values = []; + const field = '(?:address(?:[_-]?(?:line)?[12])?|billing[_-]?address|birth[_-]?date|birthday|contact[_-]?(?:email|phone)|date[_-]?of[_-]?birth|display[_-]?name|dob|email(?:[_-]?address)?|emergency[_-]?contact(?:[_-]?[a-z0-9]+)*|first[_-]?name|full[_-]?name|home[_-]?(?:address|phone)|last[_-]?name|legal[_-]?name|mailing[_-]?address|member[_-]?(?:email|name)|mobile[_-]?phone|phone(?:[_-]?number)?|postal[_-]?address|runner[_-]?(?:email|name)|shipping[_-]?address|street[_-]?address)'; + const patterns = [ + new RegExp(`["']?(${field})["']?\\s*[:=]\\s*["']([^"'\\r\\n]{0,256})["']`, 'giu'), + new RegExp(`(?:^|[?&#])(${field})=([^&#\\s]{0,512})`, 'giu'), + new RegExp(`(?:^|[\\s,{;])["']?(${field})["']?\\s*[:=]\\s*(?!["'])([^,;}\\r\\n]{1,512})`, 'giu'), + ]; + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + values.push(Object.freeze({ fieldName: match[1], value: match[2] })); + } + } + return values; +} + +function inspectJsonLookingIdentity(text) { + const trimmed = text.trim(); + if (!((trimmed.startsWith('{') && trimmed.endsWith('}')) + || (trimmed.startsWith('[') && trimmed.endsWith(']')))) { + return false; + } + try { + inspectStructuredIdentity(JSON.parse(trimmed)); + return true; + } catch (error) { + if (error instanceof ArtifactSafetyError) throw error; + return false; + } +} + +function rejectSensitiveContent(text) { + for (const view of normalizedViews(text)) { + const structuredJson = inspectJsonLookingIdentity(view); + if (/\b(?:mid-peninsula-running-club|runmprc-97922)\b/iu.test(view) + || /\bmid-peninsula-running-club\.(?:firebaseapp\.com|web\.app)\b/iu.test(view)) { + fail('PRODUCTION_AUTHORITY'); + } + + if (/-----BEGIN [A-Z0-9 -]{0,64}PRIVATE KEY(?: BLOCK)?-----/iu.test(view)) { + fail('PRIVATE_KEY_MATERIAL'); + } + if (/\b(?:A3T[A-Z0-9]|AKIA|ASIA)[A-Z0-9]{16}\b/u.test(view) + || /\bAIza[0-9A-Za-z_-]{30,}\b/u.test(view) + || /\bgh[pousr]_[0-9A-Za-z]{20,}\b/u.test(view) + || /\bgithub_pat_[0-9A-Za-z_]{20,}\b/u.test(view) + || /\bcs_(?:live|test)_[0-9A-Za-z]{12,}\b/iu.test(view) + || /\bpi_[0-9A-Za-z]{8,}_secret_[0-9A-Za-z]{12,}\b/iu.test(view) + || /\b(?:pk|rk|sk)_(?:live|test)_[0-9A-Za-z]{12,}\b/iu.test(view) + || /\bwhsec_[0-9A-Za-z]{12,}\b/iu.test(view)) { + fail('CREDENTIAL_SHAPE'); + } + if (/\b[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/u.test(view)) { + fail('JWT_SHAPE'); + } + + const authorizationAssignments = /\bauthorization["']?\s*[:=]\s*["']?([^"'\r\n]{1,512})/giu; + for (const match of view.matchAll(authorizationAssignments)) { + const value = match[1].trim(); + const bearer = /^bearer\s+([^\s,;]+)/iu.exec(value); + if (bearer) { + if (!isRedacted(bearer[1])) fail('BEARER_MATERIAL'); + } else if (!isRedacted(value)) { + fail('AUTH_MATERIAL'); + } + } + for (const match of view.matchAll(/(?:^|[^A-Za-z0-9_-])bearer\s+([^\s,;"']{1,512})/gimu)) { + if (!isRedacted(match[1])) fail('BEARER_MATERIAL'); + } + if (/\bhttps?:\/\/[^\s/@:]+:[^\s/@]+@[^\s/]+/iu.test(view)) { + fail('AUTH_MATERIAL'); + } + const authAssignments = /(?:^|[\s,{;])["']?(?:access[_-]?token|api[_-]?(?:credential|key)|auth[_-]?token|authorization[_-]?code|checkout[_-]?session[_-]?id|client[_-]?secret|confirmation[_-]?token|cookie|csrf[_-]?token|id[_-]?token|oauth[_-]?(?:code|state|token)|oob[_-]?code|password|payment[_-]?intent[_-]?client[_-]?secret|private[_-]?key|refresh[_-]?token|reset[_-]?token|secret|session[_-]?(?:id|token)|set[_-]?cookie|token|verification[_-]?token|x[_-]?api[_-]?key)["']?\s*[:=]\s*["']?([^"'\s,;}]{1,512})/gimu; + for (const match of view.matchAll(authAssignments)) { + if (!isRedacted(match[1])) fail('AUTH_MATERIAL'); + } + const capabilityParameter = '(?:access[_-]?token|api[_-]?key|auth|auth[_-]?token|authorization|bearer|checkout(?:[_-]?session)?|client[_-]?secret|code|confirmation(?:[_-]?token)?|cookie|csrf[_-]?token|id[_-]?token|key|oauth[_-]?token|oob[_-]?code|password|payment[_-]?intent[_-]?client[_-]?secret|refresh[_-]?token|reset[_-]?token|secret|session(?:[-_]?(?:id|token))?|set[_-]?cookie|sig(?:nature)?|state|token|verification[_-]?token|x[_-]?api[_-]?key)'; + const capabilityPattern = new RegExp(`(?:^|[?&#])${capabilityParameter}=([^&#\\s]+)`, 'gimu'); + if (new RegExp(`(?:^|[?&#])${capabilityParameter}=([^&#\\s]+)`, 'imu').test(view)) { + const values = [...view.matchAll(capabilityPattern)]; + if (values.some((match) => !isRedacted(match[1]))) fail('CAPABILITY_URL'); + } + + for (const match of view.matchAll(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,63}\b/giu)) { + if (!isReservedEmail(match[0])) fail('PERSONAL_EMAIL'); + } + for (const match of view.matchAll(/(?:\+?1[ .-]?)?(?:\([2-9][0-9]{2}\)|[2-9][0-9]{2})[ .-][0-9]{3}[ .-][0-9]{4}\b/gu)) { + if (!isReservedPhone(match[0])) fail('PERSONAL_PHONE'); + } + for (const match of view.matchAll(/\+[1-9][0-9]{7,14}\b/gu)) { + if (!isReservedPhone(match[0])) fail('PERSONAL_PHONE'); + } + + const sensitiveElementName = '(?:access[_-]?token|address(?:[_-]?(?:line)?[12])?|api[_-]?(?:credential|key)|auth[_-]?token|authorization(?:[_-]?code)?|billing[_-]?address|birth[_-]?date|birthday|checkout[_-]?session[_-]?id|client[_-]?secret|confirmation[_-]?token|contact[_-]?(?:email|phone)|cookie|csrf[_-]?token|date[_-]?of[_-]?birth|display[_-]?name|dob|email(?:[_-]?address)?|emergency[_-]?contact(?:[_-]?(?:email|name|phone))?|first[_-]?name|full[_-]?name|home[_-]?(?:address|phone)|id[_-]?token|last[_-]?name|legal[_-]?name|mailing[_-]?address|member[_-]?(?:email|name)|mobile[_-]?phone|oauth[_-]?(?:code|state|token)|oob[_-]?code|password|payment[_-]?intent[_-]?client[_-]?secret|phone(?:[_-]?number)?|postal[_-]?address|private[_-]?key|refresh[_-]?token|reset[_-]?token|runner[_-]?(?:email|name)|secret|session[_-]?(?:id|token)|set[_-]?cookie|shipping[_-]?address|street[_-]?address|token|verification[_-]?token|x[_-]?api[_-]?key)'; + const sensitiveElement = new RegExp(`<((?:[A-Za-z_][A-Za-z0-9_.-]*:)?(${sensitiveElementName}))\\b[^>]*>([\\s\\S]*?)<\\/\\1\\s*>`, 'giu'); + for (const match of view.matchAll(sensitiveElement)) { + if (/<[^>]*>/u.test(match[3])) { + fail(AUTH_FIELD_NAMES.has(normalizedFieldName(match[2])) + ? 'AUTH_MATERIAL' + : 'SENSITIVE_IDENTITY_FIELD'); + } + assertSensitiveNamedValue(match[2], match[3]); + } + + const startElement = /<([A-Za-z_][A-Za-z0-9_.:-]*)(\s[^<>]*?)?\/?>/gu; + for (const match of view.matchAll(startElement)) { + const elementName = xmlLocalName(match[1]); + const attributes = xmlAttributes(match[2] ?? ''); + for (const attribute of attributes) { + if (isSensitiveFieldName(attribute.name)) { + assertSensitiveNamedValue(attribute.name, attribute.value); + } + } + const valueAttribute = attributes.find((attribute) => ( + normalizedFieldName(attribute.name) === 'value' + )); + if (isSensitiveFieldName(elementName) && valueAttribute) { + assertSensitiveNamedValue(elementName, valueAttribute.value); + } + } + + const propertyElement = /<((?:[A-Za-z_][A-Za-z0-9_.-]*:)?(?:attribute|field|property))\b([^<>]*?)>([\s\S]*?)<\/\1\s*>/giu; + for (const match of view.matchAll(propertyElement)) { + const attributes = xmlAttributes(match[2]); + const fieldAttribute = attributes.find((attribute) => ( + ['key', 'name'].includes(normalizedFieldName(attribute.name)) + )); + if (!fieldAttribute || !isSensitiveFieldName(fieldAttribute.value)) continue; + const valueAttribute = attributes.find((attribute) => ( + normalizedFieldName(attribute.name) === 'value' + )); + const value = valueAttribute ? valueAttribute.value : match[3]; + if (/<[^>]*>/u.test(value)) { + fail(AUTH_FIELD_NAMES.has(normalizedFieldName(fieldAttribute.value)) + ? 'AUTH_MATERIAL' + : 'SENSITIVE_IDENTITY_FIELD'); + } + assertSensitiveNamedValue(fieldAttribute.value, value); + } + + const propertyStart = /<([A-Za-z_][A-Za-z0-9_.:-]*)(\s[^<>]*?)\/?>/gu; + for (const match of view.matchAll(propertyStart)) { + if (!['attribute', 'field', 'property'].includes(normalizedFieldName(xmlLocalName(match[1])))) { + continue; + } + const attributes = xmlAttributes(match[2] ?? ''); + const fieldAttribute = attributes.find((attribute) => ( + ['key', 'name'].includes(normalizedFieldName(attribute.name)) + )); + const valueAttribute = attributes.find((attribute) => ( + normalizedFieldName(attribute.name) === 'value' + )); + if (fieldAttribute && valueAttribute && isSensitiveFieldName(fieldAttribute.value)) { + assertSensitiveNamedValue(fieldAttribute.value, valueAttribute.value); + } + } + + if (!structuredJson) { + for (const { fieldName, value } of sensitiveFieldValues(view)) { + assertSensitiveNamedValue(fieldName, value); + } + } + } +} + +function rejectSensitiveFilename(relativePath) { + const portablePath = relativePath.split(path.sep).join('/'); + rejectSensitiveContent(portablePath); + for (const view of normalizedViews(portablePath)) { + if (/(?:^|[._/-])(?:auth|bearer|cookie|credential|password|private[-_]?key|secret|session[-_]?id|token)[-_=][0-9a-z_-]{8,}(?:[._/-]|$)/iu.test(view)) { + fail('SENSITIVE_FILENAME'); + } + } +} + +function isPlainRecord(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isExactSyntheticAddress(value) { + if (!isPlainRecord(value)) return false; + const expectedKeys = ['city', 'country', 'line1', 'line2', 'postalCode', 'state']; + const keys = Object.keys(value).sort(compareText); + return keys.length === expectedKeys.length + && keys.every((key, index) => key === expectedKeys[index]) + && /^[1-9][0-9]{2} Test Only Avenue$/u.test(value.line1) + && value.line2 === null + && value.city === 'Example' + && value.state === 'CA' + && value.postalCode === '00000' + && value.country === 'US'; +} + +function inspectStructuredIdentity(value, context = '') { + if (Array.isArray(value)) { + value.forEach((entry) => inspectStructuredIdentity(entry, context)); + return; + } + if (!isPlainRecord(value)) return; + + for (const [fieldName, fieldValue] of Object.entries(value)) { + const normalizedName = normalizedFieldName(fieldName); + const effectiveName = context === 'emergencycontact' && normalizedName === 'name' + ? 'emergencycontactname' + : (context === 'emergencycontact' && normalizedName === 'phone' + ? 'emergencycontactphone' + : (context === 'emergencycontact' && normalizedName === 'email' + ? 'emergencycontactemail' + : normalizedName)); + + if (ADDRESS_FIELD_NAMES.has(normalizedName)) { + if (typeof fieldValue === 'string' && isRedacted(fieldValue)) continue; + if (isExactSyntheticAddress(fieldValue)) continue; + fail('SENSITIVE_IDENTITY_FIELD'); + } + if (normalizedName === 'emergencycontact') { + if (typeof fieldValue === 'string' && isRedacted(fieldValue)) continue; + if (!isPlainRecord(fieldValue)) fail('SENSITIVE_IDENTITY_FIELD'); + inspectStructuredIdentity(fieldValue, 'emergencycontact'); + continue; + } + if (AUTH_FIELD_NAMES.has(effectiveName) + || NAME_FIELD_NAMES.has(effectiveName) + || EMAIL_FIELD_NAMES.has(effectiveName) + || PHONE_FIELD_NAMES.has(effectiveName) + || BIRTH_FIELD_NAMES.has(effectiveName)) { + if (!['string', 'number'].includes(typeof fieldValue)) { + fail(AUTH_FIELD_NAMES.has(effectiveName) + ? 'AUTH_MATERIAL' + : 'SENSITIVE_IDENTITY_FIELD'); + } + assertSensitiveScalar(effectiveName, fieldValue); + continue; + } + inspectStructuredIdentity(fieldValue, context); + } +} + +function isValidXmlCodePoint(codePoint) { + return codePoint === 0x09 + || codePoint === 0x0a + || codePoint === 0x0d + || (codePoint >= 0x20 && codePoint <= 0xd7ff) + || (codePoint >= 0xe000 && codePoint <= 0xfffd) + || (codePoint >= 0x10000 && codePoint <= 0x10ffff); +} + +function validateXml(text) { + if (/ !isValidXmlCodePoint(character.codePointAt(0)))) { + fail('FORMAT_INVALID'); + } + for (const match of text.matchAll(/&#(?:x([0-9a-f]{1,8})|([0-9]{1,9}));/giu)) { + const codePoint = match[1] === undefined + ? Number.parseInt(match[2], 10) + : Number.parseInt(match[1], 16); + if (!Number.isSafeInteger(codePoint) || !isValidXmlCodePoint(codePoint)) { + fail('FORMAT_INVALID'); + } + } + const withoutAllowedEntities = text.replace( + /&(?:amp|apos|gt|lt|quot|#[0-9]{1,9}|#x[0-9a-f]{1,8});/giu, + '', + ); + if (/&(?:#|[a-z])/iu.test(withoutAllowedEntities)) fail('FORMAT_INVALID'); + + const stack = []; + let cursor = 0; + let rootElements = 0; + let declarationSeen = false; + const tags = /<([^<>]+)>/gu; + for (const match of text.matchAll(tags)) { + const between = text.slice(cursor, match.index); + if (/[<>]/u.test(between) || (stack.length === 0 && between.trim() !== '')) { + fail('FORMAT_INVALID'); + } + cursor = match.index + match[0].length; + const body = match[1].trim(); + if (body.startsWith('?') && body.endsWith('?')) { + if (declarationSeen + || match.index !== 0 + || rootElements !== 0 + || stack.length !== 0 + || !/^\?xml\s+version\s*=\s*(?:"1\.0"|'1\.0')(?:\s+encoding\s*=\s*(?:"UTF-8"|'UTF-8'))?(?:\s+standalone\s*=\s*(?:"(?:yes|no)"|'(?:yes|no)'))?\s*\?$/iu.test(body)) { + fail('FORMAT_INVALID'); + } + declarationSeen = true; + continue; + } + if (body.startsWith('!--') && body.endsWith('--')) continue; + if (body.startsWith('/')) { + const name = body.slice(1).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_.:-]*$/u.test(name) || stack.pop() !== name) { + fail('FORMAT_INVALID'); + } + continue; + } + const selfClosing = body.endsWith('/'); + const content = selfClosing ? body.slice(0, -1).trim() : body; + const name = /^[A-Za-z_][A-Za-z0-9_.:-]*/u.exec(content)?.[0]; + if (!name) fail('FORMAT_INVALID'); + const attributes = content.slice(name.length); + if (!/^(?:\s+[A-Za-z_][A-Za-z0-9_.:-]*\s*=\s*(?:"[^"]*"|'[^']*'))*\s*$/u.test(attributes)) { + fail('FORMAT_INVALID'); + } + const attributeNames = [...attributes.matchAll(/\s+([A-Za-z_][A-Za-z0-9_.:-]*)\s*=/gu)] + .map((attribute) => attribute[1]); + if (new Set(attributeNames).size !== attributeNames.length) fail('FORMAT_INVALID'); + if (stack.length === 0) rootElements += 1; + if (!selfClosing) stack.push(name); + } + const trailing = text.slice(cursor); + if (cursor === 0 + || /[<>]/u.test(trailing) + || trailing.trim() !== '' + || stack.length !== 0 + || rootElements !== 1) { + fail('FORMAT_INVALID'); + } +} + +function validateTap(text) { + const lines = text.split(/\r?\n/u).filter((line) => line.trim() !== ''); + if (lines[0] !== 'TAP version 13') fail('FORMAT_INVALID'); + + const plans = []; + const results = []; + for (const [index, line] of lines.entries()) { + if (index === 0) continue; + if (/^\s/u.test(line)) continue; + const plan = /^1\.\.([0-9]+)(?:\s+#.*)?$/u.exec(line); + if (plan) { + plans.push(Number.parseInt(plan[1], 10)); + continue; + } + const result = /^(?:not )?ok\s+([0-9]+)(?:\s+-[^\r\n]*)?(?:\s+#.*)?$/u.exec(line); + if (result) { + results.push(Number.parseInt(result[1], 10)); + continue; + } + if (/^#/u.test(line)) continue; + fail('FORMAT_INVALID'); + } + + if (plans.length !== 1 + || results.length !== plans[0] + || results.some((number, index) => number !== index + 1)) { + fail('FORMAT_INVALID'); + } +} + +function validateLcov(text) { + const lines = text.split(/\r?\n/u).filter((line) => line !== ''); + let inRecord = false; + let pendingTestName = false; + let records = 0; + const metric = /^(?:BRDA:[0-9]+,[0-9]+,[0-9]+,(?:[0-9]+|-)|BRF:[0-9]+|BRH:[0-9]+|DA:[0-9]+,[0-9]+(?:,[^,\s]+)?|FN:[0-9]+(?:,[0-9]+)?,.+|FNDA:[0-9]+,.+|FNF:[0-9]+|FNH:[0-9]+|LF:[0-9]+|LH:[0-9]+)$/u; + + for (const line of lines) { + if (/^TN:[^\r\n]*$/u.test(line)) { + if (inRecord || pendingTestName) fail('FORMAT_INVALID'); + pendingTestName = true; + continue; + } + if (/^SF:.+$/u.test(line)) { + if (inRecord) fail('FORMAT_INVALID'); + inRecord = true; + pendingTestName = false; + records += 1; + continue; + } + if (line === 'end_of_record') { + if (!inRecord) fail('FORMAT_INVALID'); + inRecord = false; + continue; + } + if (!inRecord || !metric.test(line)) fail('FORMAT_INVALID'); + } + + if (inRecord || pendingTestName || records === 0) fail('FORMAT_INVALID'); +} + +function validateArtifactStructure(filePath, text) { + const extension = path.extname(filePath).toLowerCase(); + try { + if (extension === '.json') { + inspectStructuredIdentity(JSON.parse(text)); + return; + } + if (extension === '.jsonl') { + const lines = text.split(/\r?\n/u).filter((line) => line.trim() !== ''); + if (lines.length === 0) fail('FORMAT_INVALID'); + lines.forEach((line) => inspectStructuredIdentity(JSON.parse(line))); + return; + } + if (extension === '.xml') { + validateXml(text); + return; + } + if (extension === '.tap') { + validateTap(text); + return; + } + if (extension === '.lcov' || extension === '.info') { + validateLcov(text); + } + } catch (error) { + if (error instanceof ArtifactSafetyError) throw error; + fail('FORMAT_INVALID'); + } +} + +function readTextFile(filePath, expectedStat) { + const extension = path.extname(filePath).toLowerCase(); + if (ARCHIVE_EXTENSIONS.has(extension)) fail('ARCHIVE_UNSUPPORTED'); + if (!ALLOWED_EXTENSIONS.has(extension)) fail('FORMAT_UNSUPPORTED'); + if (expectedStat.size > MAX_FILE_BYTES) fail('FILE_TOO_LARGE'); + + let bytes; + let descriptor; + try { + descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + const actualStat = fs.fstatSync(descriptor); + if (!actualStat.isFile() + || actualStat.dev !== expectedStat.dev + || actualStat.ino !== expectedStat.ino + || actualStat.size !== expectedStat.size + || actualStat.ctimeMs !== expectedStat.ctimeMs) { + fail('ENTRY_CHANGED', EXIT_SCAN_FAILURE); + } + const boundedBuffer = Buffer.allocUnsafe(MAX_FILE_BYTES + 1); + let offset = 0; + while (offset < boundedBuffer.length) { + const bytesRead = fs.readSync( + descriptor, + boundedBuffer, + offset, + boundedBuffer.length - offset, + null, + ); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > MAX_FILE_BYTES) fail('FILE_TOO_LARGE'); + const finalStat = fs.fstatSync(descriptor); + if (finalStat.dev !== actualStat.dev + || finalStat.ino !== actualStat.ino + || finalStat.size !== offset + || finalStat.size !== actualStat.size + || finalStat.mtimeMs !== actualStat.mtimeMs + || finalStat.ctimeMs !== actualStat.ctimeMs + || finalStat.ctimeMs !== expectedStat.ctimeMs) { + fail('ENTRY_CHANGED', EXIT_SCAN_FAILURE); + } + bytes = boundedBuffer.subarray(0, offset); + } catch (error) { + if (error instanceof ArtifactSafetyError) throw error; + fail('ENTRY_UNREADABLE', EXIT_SCAN_FAILURE); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } + + let text; + try { + text = utf8.decode(bytes); + } catch { + fail('BINARY_CONTENT'); + } + if (/\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f]/u.test(text)) { + fail('BINARY_CONTENT'); + } + rejectSensitiveContent(text); + validateArtifactStructure(filePath, text); + return { bytes, text }; +} + +function scanRoot(rootRecord, rootIndex, digest, totals) { + const root = rootRecord.path; + const visit = (directory, expectedIdentity, depth, relativeDirectory) => { + if (depth > MAX_DEPTH) fail('DEPTH_LIMIT'); + const directoryDescriptor = openBoundDirectory(directory, expectedIdentity); + try { + let directoryHandle; + const entries = []; + try { + assertBoundDirectory(directory, expectedIdentity, directoryDescriptor); + directoryHandle = fs.opendirSync(directory); + while (true) { + const entry = directoryHandle.readSync(); + if (entry === null) break; + totals.entries += 1; + if (totals.entries > MAX_ENTRIES) fail('ENTRY_LIMIT'); + entries.push(entry); + } + directoryHandle.closeSync(); + directoryHandle = undefined; + assertBoundDirectory(directory, expectedIdentity, directoryDescriptor); + } catch (error) { + if (error instanceof ArtifactSafetyError) throw error; + fail('ENTRY_UNREADABLE', EXIT_SCAN_FAILURE); + } finally { + if (directoryHandle !== undefined) directoryHandle.closeSync(); + } + entries.sort((left, right) => compareText(left.name, right.name)); + + for (const entry of entries) { + if (relativeDirectory === '' && SOURCE_MARKERS.has(entry.name)) { + fail('ROOT_CONTAINS_SOURCE', EXIT_INVALID_ROOT); + } + const entryPath = path.join(directory, entry.name); + const relativePath = relativeDirectory + ? path.join(relativeDirectory, entry.name) + : entry.name; + if (!isAncestor(root, entryPath)) fail('PATH_ESCAPE', EXIT_SCAN_FAILURE); + + let stat; + try { + stat = fs.lstatSync(entryPath); + } catch { + fail('ENTRY_UNREADABLE', EXIT_SCAN_FAILURE); + } + if (stat.isSymbolicLink()) fail('SYMLINK_UNSUPPORTED'); + if (stat.isDirectory()) { + let realDirectory; + try { + realDirectory = fs.realpathSync(entryPath); + } catch { + fail('ENTRY_UNREADABLE', EXIT_SCAN_FAILURE); + } + if (realDirectory !== entryPath || !isAncestor(root, realDirectory)) { + fail('PATH_ESCAPE', EXIT_SCAN_FAILURE); + } + const childIdentity = readDirectoryIdentity(realDirectory); + visit(realDirectory, childIdentity, depth + 1, relativePath); + continue; + } + if (!stat.isFile()) fail('ENTRY_TYPE_UNSUPPORTED'); + + totals.files += 1; + if (totals.files > MAX_FILES) fail('FILE_LIMIT'); + + rejectSensitiveFilename(relativePath); + const artifact = readTextFile(entryPath, stat); + totals.bytes += artifact.bytes.length; + if (totals.bytes > MAX_TOTAL_BYTES) fail('TOTAL_BYTES_LIMIT'); + digest.update(`${rootIndex}\0${relativePath.split(path.sep).join('/')}\0`); + digest.update(artifact.bytes); + digest.update('\0'); + } + assertBoundDirectory(directory, expectedIdentity, directoryDescriptor); + } finally { + fs.closeSync(directoryDescriptor); + } + }; + + visit(root, rootRecord.identity, 0, ''); +} + +function scanArtifactRootsInternal(rawRoots, rawOptions) { + const rootsInput = snapshotRootArguments(rawRoots); + const { cwd } = snapshotScanOptions(rawOptions); + if (rootsInput.length === 0) { + fail('ROOT_REQUIRED', EXIT_INVALID_ROOT); + } + if (rootsInput.length > MAX_ROOTS) fail('ROOT_LIMIT', EXIT_INVALID_ROOT); + const roots = rootsInput + .map((rawRoot) => validateRoot(rawRoot, cwd)) + .sort((left, right) => compareText(left.path, right.path)); + if (new Set(roots.map((root) => root.path)).size !== roots.length) { + fail('ROOT_DUPLICATE', EXIT_INVALID_ROOT); + } + + const totals = { bytes: 0, entries: 0, files: 0 }; + const digest = crypto.createHash('sha256'); + roots.forEach((root, index) => scanRoot(root, index, digest, totals)); + return Object.freeze({ + bytes: totals.bytes, + files: totals.files, + manifestDigest: digest.digest('hex'), + roots: roots.length, + }); +} + +export function scanArtifactRoots(rawRoots, rawOptions) { + try { + return scanArtifactRootsInternal(rawRoots, rawOptions); + } catch (error) { + if (error instanceof ArtifactSafetyError) throw error; + fail('SCAN_INTERNAL', EXIT_SCAN_FAILURE); + } +} + +function reportFailure(error) { + if (error instanceof ArtifactSafetyError) { + process.stderr.write(`TEST_ARTIFACT_SCAN_FAILED ${error.ruleId}\n`); + process.exitCode = error.exitCode; + return; + } + process.stderr.write('TEST_ARTIFACT_SCAN_FAILED INTERNAL_ERROR\n'); + process.exitCode = EXIT_SCAN_FAILURE; +} + +const isMain = process.argv[1] + && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (isMain) { + try { + const summary = scanArtifactRoots(process.argv.slice(2)); + process.stdout.write(`TEST_ARTIFACT_SCAN_OK roots=${summary.roots} files=${summary.files} bytes=${summary.bytes} digest=${summary.manifestDigest}\n`); + } catch (error) { + reportFailure(error); + } +} + +export const TEST_ARTIFACT_LIMITS = Object.freeze({ + maxDepth: MAX_DEPTH, + maxEntries: MAX_ENTRIES, + maxFileBytes: MAX_FILE_BYTES, + maxFiles: MAX_FILES, + maxRoots: MAX_ROOTS, + maxTotalBytes: MAX_TOTAL_BYTES, +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7184a8a..4441666a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: run: | ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/deploy.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/ci.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' - node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js + node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/test-artifact-safety.test.js - run: npm run lint:fix -- --max-warnings=0 || true - name: Build env: @@ -39,6 +39,22 @@ jobs: REACT_APP_SENTRY_DSN: ${{ secrets.REACT_APP_SENTRY_DSN }} run: npm run build + test-artifact-scrubber: + name: Test artifact scrubber + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '20' + - name: Run emitted test artifact safety tests + run: node --test tests/test-artifact-safety.test.js + functions: name: Cloud Functions lint + test runs-on: ubuntu-latest diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bca7a43f..f2cc1201 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -113,6 +113,7 @@ jobs: "Frontend lint + build" \ "Cloud Functions lint + test" \ "Commerce command journal emulator" \ + "Test artifact scrubber" \ "Firestore security-rules tests" do if ! jq -e --arg name "$required_job" ' @@ -393,6 +394,7 @@ jobs: "Frontend lint + build" \ "Cloud Functions lint + test" \ "Commerce command journal emulator" \ + "Test artifact scrubber" \ "Firestore security-rules tests" do if ! jq -e --arg name "$required_job" ' diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 3d5d2417..ff447986 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -76,7 +76,7 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 24 | WEB-001 | Move to controlled hosting and add browser security policy | P1 | L | proposed | SAFETY-001, CI-001 | | 25 | OBS-001 | Add payment observability, alerts, SLOs, and redaction | P1 | M | proposed | PAY-003, PAY-005 | | 26 | RESILIENCE-001 | Establish backup, restore, retention, and audited repair | P1 | L | proposed | DATA-001, PAY-005 | -| 27 | TEST-001 | Build the Stripe/Firebase security integration and E2E suite | P0 | L | in progress through #175; most children proposed | core security/payment/data children | +| 27 | TEST-001 | Build the Stripe/Firebase security integration and E2E suite | P0 | L | in progress through #177; most children proposed | core security/payment/data children | | 28 | LEGAL-001 | Approve legal, tax, insurance, privacy, refund, and fulfillment policy | P0 | M | blocked_owner_decision | — | | 29 | OPS-001 | Configure production systems and run a controlled live pilot | P0 | L | blocked by all launch gates | CI-001, TEST-001, LEGAL-001, OBS-001, RESILIENCE-001 | @@ -1202,12 +1202,12 @@ Cloud backup configuration changes external state and needs an authorized owner. ## TEST-001 — Build the Stripe/Firebase security integration and E2E suite **Labels:** `priority:P0`, `type:testing`, `type:security`, `area:stripe`, `area:firebase`, `size:L` -**Status:** In progress through focused child [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175); the wider integration/E2E program remains proposed +**Status:** In progress through focused children [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175) and [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177); the wider integration/E2E program remains proposed **Depends on:** Core SEC/AUTH/ABUSE/PAY/RACE/MERCH/MAIL/DATA work ### Current child status -TEST-001A is split so a small safety foundation can be reviewed before any CI artifact or end-to-end expansion. TEST-001A1 is tracked in [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175): Functions tests receive deterministic reserved fixtures, one shared synthetic Stripe signer, fail-closed checks for supplied production/live configuration, and a Node transport guard that permits only loopback emulator targets. TEST-001A2 remains proposed for bounded emitted-artifact scanning and named CI/release wiring. Neither child authorizes provider calls, production data, Firebase deployment, or live payment testing. +TEST-001A is split so a small safety foundation can be reviewed before any end-to-end expansion. TEST-001A1 merged through [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175) as `e6d8929698b95ddcda78b00108cc7665bf9f5e01`: Functions tests receive deterministic reserved fixtures, one shared synthetic Stripe signer, fail-closed checks for supplied production/live configuration, and a Node transport guard that permits only loopback emulator targets. TEST-001A2 is tracked in [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177) for a bounded emitted-artifact scanner, its deliberate synthetic test suite, one exact named CI job, and both protected-release job lists. It uploads no artifact. Neither child authorizes provider calls, production data, Firebase deployment, or live payment testing. ### Problem diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index aebbd677..7875cc00 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -124,7 +124,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | RESILIENCE-001A | owner_action; DATA-001B, LEGAL-001 | Inventory RPO/RTO and configure least-privilege monitored backup/export retention. | Private access review and scheduled backup success. | | RESILIENCE-001B | proposed; A, staging | Restore into isolated no-live-Stripe/email/DNS project and validate rules/indexes/counts/references/reconciliation. | Dated timed restore drill and no-external-side-effect proof. | | TEST-001A1 | delivery tracked in [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175); CI-001A | Add the Functions-only deterministic synthetic factories, shared Stripe signer, supplied-environment rejection, and loopback-only Node test-network guard. | Deliberate unsafe environment/transport failures, fixed redacted errors, existing webhook adoption, full Functions suite, and loopback emulator proof; no runtime/provider claim. | -| TEST-001A2 | proposed; A1 | Add the bounded emitted-artifact scrubber and make its deliberate allow/deny suite a named CI and protected-release gate. | Synthetic artifact fixtures, exact workflow checks, and no source/canary false-positive proof. | +| TEST-001A2 | source/tests tracked in live [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177); A1 | Add the bounded emitted-artifact scrubber and make its deliberate allow/deny suite the exact `Test artifact scrubber` CI and protected-release gate; upload nothing. | Synthetic artifact fixtures, fixed redacted failures, exact workflow mutation checks, and no source/canary false-positive proof. | | TEST-001B | proposed; A1–A2 | Add Auth/Firestore/Functions emulator saga, RBAC, Rules, and App Check integration harness. | Repeatable local/CI demo-project run. | | TEST-001C | proposed; B, race/merch reducers | Add deterministic capacity/inventory concurrency and every local/Stripe failure-boundary test. | Repeated no-flake invariant matrix. | | TEST-001D | proposed; B, PAY-003/PAY-005 | Add signed Stripe lifecycle, duplicate/reorder, async, refund/dispute/reconcile fixtures. | Risk/invariant traceability and coverage thresholds. | diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 119ad03c..160a55df 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -37,6 +37,7 @@ Use the club's approved password manager for access. Share only a public link or - **Code changed:** What changed? - **Hosted frontend tests passed:** Did the `Frontend lint + build` job show a green `Run frontend Jest tests` step? Jest is the automated frontend behavior test. - **Hosted payment-safety test passed:** Did the `Commerce command journal emulator` job finish green? This check uses made-up records in a closed test database. +- **Hosted test-output safety check passed:** Did the `Test artifact scrubber` job finish green? It proves the deliberate made-up output scan ran. It does not prove that a report was uploaded or that a live service changed. - **Other tests passed:** What else was checked for this change? - **Code merged:** Which pull request was approved? - **Release approved:** Which environment, exact commit, and named approver were recorded? diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index dd87f9c7..ae67e661 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -263,9 +263,10 @@ npm --prefix functions run lint npm --prefix functions run test:run -- --runInBand CI=true npm test -- --watchAll=false --runInBand npm run test:spa-navigation +node --test tests/test-artifact-safety.test.js ``` -The frontend Jest command is a dependable local baseline and hosted CI runs it as the blocking `Run frontend Jest tests` step under [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124). Hosted CI also runs the separate Node SPA suite as `Run SPA callback safety tests` under [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126). CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) adds the blocking `Commerce command journal emulator` job. That job uses exact demo project `demo-pay002b2-test`, Firestore only, an explicit opt-in, and the focused transaction suite. The protected release source gate requires that named job both before artifact work and after approval. Its green result proves synthetic transaction behavior, not Firebase deployment or Stripe behavior. The manual-release source invariants remain under [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135). Protected environment/OIDC configuration, actual staged/live deployment, fail-closed lint, branch-required-check configuration, and controlled Netlify publication remain **NOT AVAILABLE YET** under [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105), [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133), [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136), and WEB-001. +The frontend Jest command is a dependable local baseline and hosted CI runs it as the blocking `Run frontend Jest tests` step under [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124). Hosted CI also runs the separate Node SPA suite as `Run SPA callback safety tests` under [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126). CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) adds the blocking `Commerce command journal emulator` job. That job uses exact demo project `demo-pay002b2-test`, Firestore only, an explicit opt-in, and the focused transaction suite. TEST-001A2 [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177) adds the exact `Test artifact scrubber` job. It deliberately creates bounded synthetic output and proves that the dependency-free scanner accepts reserved examples, rejects the documented recognizable sensitive patterns with fixed messages, ignores source outside an explicit output root, and protects both release job lists. Current CI uploads no test report. Later report-producing work must scan its explicit output before upload. A green scrubber proves only that this deliberate source test ran. It is not a general data-loss-prevention guarantee and does not prove that an unknown or newly obfuscated sensitive value is absent, that an artifact was uploaded, that all runtime logs are safe, or that Firebase, Stripe, the website, or another provider changed. The manual-release source invariants remain under [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135). Protected environment/OIDC configuration, actual staged/live deployment, fail-closed lint, branch-required-check configuration, and controlled Netlify publication remain **NOT AVAILABLE YET** under [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105), [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133), [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136), and WEB-001. ### Firestore Rules diff --git a/SECURITY.md b/SECURITY.md index d580cdbb..a2842fa7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -104,7 +104,8 @@ These entries are implementation evidence, not a production risk-acceptance deci | RISK-006, part of RISK-024/RISK-032 | #100 merged at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. OAuth secrets and server-owned financial/operational collections are denied; current catalog writes are restricted to inert drafts/display fields. | Firebase deployment skipped. Stage/deploy the exact Rules revision under #105, verify synthetic deny behavior, then move remaining catalog mutation to scoped APIs under ADMIN-001. | | RISK-009 | #99 preserves same-origin path, query, and fragment state through the Pages fallback, clears temporary state, rejects unsafe targets, and suppresses monitoring startup on initial capability callbacks. #126 adds the standalone suite as the named blocking `Run SPA callback safety tests` hosted step. | Keep exact hosted evidence on #126; verify exact website publication and later run approved test-mode Stripe/Strava callbacks; WEB-001 still owns controlled hosting. | | RISK-010 | #99 uses a fully synthetic `demo-mprc-local` configuration, connects Functions/Auth/Firestore to loopback in development/test, routes the direct CSV Function URL locally, fails startup on connector errors, and completed a demo-only three-port CLI smoke. | Current optimized previews still target production; provider calls are not isolated by Firebase. Add protected staging/config and test-provider/sink evidence before end-to-end flows. | -| Test-only foundation for RISK-010 and RISK-035 | TEST-001A1 is tracked in live [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175). The Functions Jest setup installs a fixed-error, loopback-only guard around supported Node outbound transports and rejects any supplied production-like project/origin/emulator host or live Stripe mode/key. Shared deterministic deeply frozen fixtures use reserved values, and the existing webhook suite adopts one synthetic signed-payload helper. | This is a test-process boundary, not a runtime sandbox. It does not intercept every possible native/add-on transport, isolate an emulated Function process, configure a test provider, scan emitted artifacts, deploy Firebase, or prove live behavior. TEST-001A2 must add the bounded artifact scrubber and named CI/release gate; later TEST-001 children must add emulator sagas and isolated staging evidence. | +| Test-only foundation for RISK-010 and RISK-035 | TEST-001A1 is tracked in live [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175). The Functions Jest setup installs a fixed-error, loopback-only guard around supported Node outbound transports and rejects any supplied production-like project/origin/emulator host or live Stripe mode/key. Shared deterministic deeply frozen fixtures use reserved values, and the existing webhook suite adopts one synthetic signed-payload helper. | This is a test-process boundary, not a runtime sandbox. It does not intercept every possible native/add-on transport, isolate an emulated Function process, configure a test provider, scan emitted artifacts, deploy Firebase, or prove live behavior. TEST-001A2 [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177) separately adds the bounded artifact scrubber and named CI/release gate; later TEST-001 children must add emulator sagas and isolated staging evidence. | +| Emitted-test-output part of RISK-035 | TEST-001A2 is tracked in live [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177). Its source adds a dependency-free read-only scanner for explicit narrow artifact roots, bounded deterministic traversal, fixed redacted failures, deliberate synthetic allow/deny tests, the exact `Test artifact scrubber` CI job, and that job in both protected-release CI rechecks. Current CI still uploads no test artifact. | This scanner covers a documented bounded set of recognizable patterns; green is not a general data-loss-prevention guarantee or proof that every unknown/obfuscated value is absent. It does not inspect source, runtime logs, website builds, provider responses, production data, or an operating-system sandbox, and it does not sanitize a rejected file. Every later report producer must invoke the scanner before upload, and exact hosted evidence remains separate from source and local-test evidence. | | Configuration part of RISK-001/RISK-010 | CONFIG-001A [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) merged through PR #150 as `2c62b8dd`, requiring an exact environment/origin/expected Stripe mode, key-mode compatibility on the four key-bound Functions, no production-origin fallback, and no Stripe API key on webhook/mail. CONFIG-001B1 [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) adds exact `COMMERCE_ENABLED` parsing. A false ceiling denies new commerce before Firestore; otherwise new-commerce commands and every refund perform a fresh strict server-only global/domain/resource admission read. Missing controls/resource flags mean disabled; fixed errors expose no supplied value; Rules tests deny browser control; webhook/mail remain independent. | Neither source result configures or deploys Firebase/Stripe. B1 cannot stop already-admitted commands or existing Sessions/Payment Links and has no safe operator writer. CONFIG-001B2, PAY-002/PAY-004/PAY-005, and #105/#133/#136 retain protected control, expiry/reconciliation, staging, deployment, provider readback, and drill. No live payment or email is authorized. | | Part of RISK-007 | AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) was merged through [PR #107](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/107) as `ce22c110f2132b157bd8a0d43b065585e0b43cb5`. Source and focused tests reject unverified targets at the two existing role-grant endpoints. | The workflow skipped Firebase because `FIREBASE_SERVICE_ACCOUNT` was absent, so backend-live behavior is unproven. Remaining AUTH-001 guards, Rules, verification mirror/refresh, demotion propagation, audit, and authoritative membership work stay open. | | RISK-013 | PROMO-001 [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) sets both current Checkout creators to promotion entry off and automatic tax off. Exact payload and signed event tests require an explicit all-zero breakdown; malformed or nonzero discount, tax, or shipping data creates durable review evidence while definitive failed/expired Sessions still cancel. | Privately inventory pre-change Sessions/provider promotion settings, keep the guard through protected staging/deploy, verify provider and live state separately, and implement an approved authoritative discount/tax/shipping contract before ever enabling it. | diff --git a/docs/officers/PUBLISH_AND_CHECK.md b/docs/officers/PUBLISH_AND_CHECK.md index 9362d52d..970dfa4f 100644 --- a/docs/officers/PUBLISH_AND_CHECK.md +++ b/docs/officers/PUBLISH_AND_CHECK.md @@ -44,7 +44,7 @@ As of **2026-07-13**: - `main` is the canonical branch. - A merge starts CI checks. It does not start `.github/workflows/deploy.yml`. - The release workflow accepts only a full commit already merged into `main`. -- It requires successful frontend, Functions, commerce command journal, and Firestore Rules checks for that commit. +- It requires successful frontend, Functions, commerce command journal, test artifact scrubber, and Firestore Rules checks for that commit. - Its only current release plan is the reviewed profile-recovery set: Firestore Rules, `createMemberOnSignUp`, and `ensureMemberProfile`. - A caller cannot type a Firebase project or deployment target into the release form. - Missing environment configuration or cloud authority makes the release red before backend dependencies, cloud authentication, or deployment. A public website artifact may be prepared without cloud authority, but it cannot be published. @@ -97,18 +97,19 @@ If a member or officer sees **Server configuration is unavailable**: 6. Confirm `Run frontend Jest tests` is present and green. 7. Confirm `Run SPA callback safety tests` is present and green. 8. Confirm `Commerce command journal emulator` is present and green. It uses made-up payment-command records and does not contact Stripe. -9. Confirm the Functions and Firestore Rules jobs are green. -10. Use a preview only for public, read-only pages. -11. Do not sign in, open private/admin pages, submit forms, or test signup, checkout, refund, email, or Strava in a preview. -12. Confirm the officer guide and undo note are present. -13. Approve or reject the merge. Do not describe merge approval as release approval. +9. Confirm `Test artifact scrubber` is green. It proves the deliberate made-up output scan ran. It does not prove a report was uploaded or a live service changed. +10. Confirm the Functions and Firestore Rules jobs are green. +11. Use a preview only for public, read-only pages. +12. Do not sign in, open private/admin pages, submit forms, or test signup, checkout, refund, email, or Strava in a preview. +13. Confirm the officer guide and undo note are present. +14. Approve or reject the merge. Do not describe merge approval as release approval. ## After merge 1. Record the pull request number. 2. Record the full merged commit. 3. Wait for that commit's CI jobs. -4. Confirm all four named jobs are green again: Frontend, Functions, commerce command journal, and Firestore Rules. +4. Confirm all five named jobs are green again: Frontend, Functions, commerce command journal, test artifact scrubber, and Firestore Rules. 5. Mark the result **merged — not released**. 6. Do not expect GitHub Pages, Firebase, Netlify, or `runmprc.com` to change from the merge. 7. If Netlify unexpectedly publishes the merge, stop and treat it as a hosting incident. @@ -179,6 +180,7 @@ Stop and contact the platform owner if: - A project or deployment target can be typed freely. - Netlify publishes unexpectedly or its live commit is unknown. - A test needs real member, payment, or private data. +- `Test artifact scrubber` is missing or red, or anyone asks you to bypass it. ## Success proof @@ -221,6 +223,6 @@ Known remaining problem: ## Undo -Use one reviewed rollback or safe roll-forward commit through the same protected, backend-first gate. Restore a compatible Firebase set before publishing a dependent website. A Netlify rollback remains a provider-owner procedure and is **NOT AVAILABLE YET** for backup officers. +Use one reviewed rollback or safe roll-forward commit through the same protected, backend-first gate. Restore a compatible Firebase set before publishing a dependent website. Before removing `Test artifact scrubber`, a platform maintainer must confirm that no later test-report job depends on its exact name. Never bypass the check to make a release pass. A Netlify rollback remains a provider-owner procedure and is **NOT AVAILABLE YET** for backup officers. -**Escalation:** platform owner plus backup for release/hosting; Firebase owner for backend; treasurer plus platform owner for commerce; privacy owner for member data. +**Escalation:** platform owner plus backup for release/hosting; Firebase owner for backend; treasurer plus platform owner for commerce; privacy owner for member data. If real credentials, member details, or private links appear in output, stop and open a security incident. Do not copy the value into GitHub, a screenshot, email, or an AI tool. diff --git a/tests/test-artifact-safety.test.js b/tests/test-artifact-safety.test.js new file mode 100644 index 00000000..66b97e72 --- /dev/null +++ b/tests/test-artifact-safety.test.js @@ -0,0 +1,1000 @@ +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const REPOSITORY = path.resolve(__dirname, '..'); +const SCANNER = path.join(REPOSITORY, '.github/scripts/scan-test-artifacts.mjs'); +const CI_PATH = path.join(REPOSITORY, '.github/workflows/ci.yml'); +const RELEASE_PATH = path.join(REPOSITORY, '.github/workflows/deploy.yml'); +const ciWorkflow = fs.readFileSync(CI_PATH, 'utf8'); +const releaseWorkflow = fs.readFileSync(RELEASE_PATH, 'utf8'); +const temporaryDirectories = []; + +const JOB_ID = 'test-artifact-scrubber'; +const JOB_NAME = 'Test artifact scrubber'; +const TEST_COMMAND = 'node --test tests/test-artifact-safety.test.js'; +const FRONTEND_TEST_COMMAND = 'node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/test-artifact-safety.test.js'; +const NEVER_RUN = ['$', '{{ false }}'].join(''); +const ALWAYS_TRUE = ['$', '{{ true }}'].join(''); +const BRACKET_SECRET = ['$', "{{ secrets['SYNTHETIC_CANARY'] }}"].join(''); +const BRACKET_GITHUB_TOKEN = ['$', "{{ github['token'] }}"].join(''); +const EXPECTED_CI_HEADER = `${[ + 'name: CI', + '', + 'on:', + ' push:', + ' branches: [master, main]', + ' pull_request:', + ' branches: [master, main]', + '', + 'jobs:', +].join('\n')}\n`; +const EXPECTED_SCRUBBER_JOB = `${[ + ` ${JOB_ID}:`, + ` name: ${JOB_NAME}`, + ' runs-on: ubuntu-latest', + ' timeout-minutes: 5', + ' permissions:', + ' contents: read', + ' steps:', + ' - uses: actions/checkout@v6', + ' with:', + ' persist-credentials: false', + ' - uses: actions/setup-node@v6', + ' with:', + " node-version: '20'", + ' - name: Run emitted test artifact safety tests', + ` run: ${TEST_COMMAND}`, +].join('\n')}\n`; +const EXPECTED_FRONTEND_VALIDATION_STEP = [ + ' - name: Validate protected release workflow', + ' run: |', + ' ruby -e \'require "yaml"; YAML.safe_load(File.read(".github/workflows/deploy.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)\'', + ' ruby -e \'require "yaml"; YAML.safe_load(File.read(".github/workflows/ci.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)\'', + ` ${FRONTEND_TEST_COMMAND}`, +].join('\n'); +const EXPECTED_RELEASE_JOB_LOOP = [ + ' for required_job in \\', + ' "Frontend lint + build" \\', + ' "Cloud Functions lint + test" \\', + ' "Commerce command journal emulator" \\', + ` "${JOB_NAME}" \\`, + ' "Firestore security-rules tests"', + ' do', +].join('\n'); +const EXPECTED_RELEASE_STEP_DIGESTS = Object.freeze({ + preflight: '3dafc148dd6062bf6d1f6f9f5627f12a7a18b9baf490aff9e46dd716377e7112', + postApproval: '93ecd6977236c93d8bb4367dec18349906a36d333587bf0891bfbdfec590fcc5', +}); +const EXPECTED_RELEASE_CONTEXT_DIGESTS = Object.freeze({ + header: 'd5242cf76b0e8710346728e6f6f34852f4180e22e7df4ac628bb633d5eafa9fe', + preflight: '55e3100f120b37c56813c149c34aeebcb9dc9095cfe9acdd5ac4a08f52e77c8f', + postApproval: 'db137fb23dda50944583e068701f0b6049b9b0e96f5c162e4c859f39ba16fd84', +}); + +let scannerModule; + +test.before(async () => { + scannerModule = await import(SCANNER); +}); + +test.after(() => { + temporaryDirectories.reverse().forEach((directory) => { + fs.rmSync(directory, { force: true, recursive: true }); + }); +}); + +function makeArtifactRoot(name = 'test-artifacts') { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'mprc-artifact-safety-')); + temporaryDirectories.push(temporaryDirectory); + const root = path.join(temporaryDirectory, name); + fs.mkdirSync(root, { recursive: true }); + return { root, temporaryDirectory }; +} + +function writeArtifact(root, relativePath, contents) { + const destination = path.join(root, relativePath); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, contents); + return destination; +} + +function scan(roots, options) { + return scannerModule.scanArtifactRoots(roots, options); +} + +function expectRule(ruleId, callback, context) { + let captured; + assert.throws(callback, (error) => { + captured = error; + return error && error.ruleId === ruleId; + }, context); + return captured; +} + +function runScanner(...roots) { + return spawnSync(process.execPath, [SCANNER, ...roots], { + cwd: REPOSITORY, + encoding: 'utf8', + }); +} + +function jobBlock(workflow, jobId) { + const startMarker = ` ${jobId}:\n`; + const start = workflow.indexOf(startMarker); + if (start < 0) return ''; + const remainder = workflow.slice(start + startMarker.length); + const nextJob = /\n {2}[a-z0-9-]+:\n/.exec(remainder); + const end = nextJob === null + ? workflow.length + : start + startMarker.length + nextJob.index; + return workflow.slice(start, end); +} + +function mutateJob(workflow, search, replacement) { + const block = jobBlock(workflow, JOB_ID); + assert.ok(block.includes(search), `Expected scrubber job to contain ${search}`); + return workflow.replace(block, block.replace(search, replacement)); +} + +function literalCount(text, value) { + return text.split(value).length - 1; +} + +function yamlSemanticSafetyView(workflow) { + return workflow + .replace(/\\\r?\n[ \t]*/gu, '') + .replace(/\\\//gu, '/') + .replace(/\\x([0-9a-f]{2})|\\u([0-9a-f]{4})|\\U([0-9a-f]{8})/giu, ( + match, + shortCode, + mediumCode, + longCode, + ) => { + const encoded = shortCode ?? mediumCode ?? longCode; + const codePoint = Number.parseInt(encoded, 16); + if (!Number.isSafeInteger(codePoint) + || codePoint > 0x10ffff + || (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + return match; + } + return String.fromCodePoint(codePoint); + }); +} + +function usesUnsupportedWorkflowMappingSyntax(workflow) { + return /^[ \t]*(?:\?|%|!{1,2}|!<|&|\*|<<\s*:)/mu.test(workflow) + || /(?:^|[{,])\s*(?:\?|!{1,2}|!<|&|\*|<<\s*:)/mu.test(workflow); +} + +function hasTopLevelDataAfterJobs(workflow) { + const jobsMarker = 'jobs:\n'; + const jobsIndex = workflow.indexOf(jobsMarker); + if (jobsIndex < 0) return true; + return workflow + .slice(jobsIndex + jobsMarker.length) + .split(/\r?\n/u) + .some((line) => line !== '' && !/^[ \t#]/u.test(line)); +} + +function namedStepBlock(workflow, jobId, stepName) { + const job = jobBlock(workflow, jobId); + const startMarker = ` - name: ${stepName}\n`; + const start = job.indexOf(startMarker); + if (start < 0) return ''; + const remainder = job.slice(start + startMarker.length); + const nextStep = /\n {6}- /u.exec(remainder); + const end = nextStep === null + ? job.length + : start + startMarker.length + nextStep.index; + return job.slice(start, end); +} + +function stepBlockByName(workflow, jobId, stepName) { + const job = jobBlock(workflow, jobId); + const marker = `name: ${stepName}`; + const namePosition = job.indexOf(marker); + if (namePosition < 0 || literalCount(job, marker) !== 1) return ''; + const startMarker = job.lastIndexOf('\n - ', namePosition); + const start = startMarker < 0 ? 0 : startMarker + 1; + const remainder = job.slice(namePosition); + const nextStep = /\n {6}- /u.exec(remainder); + const end = nextStep === null ? job.length : namePosition + nextStep.index; + return job.slice(start, end); +} + +function digestText(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function ciErrors(workflow) { + const errors = []; + const semanticWorkflow = yamlSemanticSafetyView(workflow); + if (!workflow.startsWith(EXPECTED_CI_HEADER)) { + errors.push('CI trigger must remain the exact push and pull-request header'); + } + if (usesUnsupportedWorkflowMappingSyntax(workflow)) { + errors.push('CI must not use explicit, tagged, anchored, aliased, or merged mapping keys'); + } + if (hasTopLevelDataAfterJobs(workflow)) { + errors.push('CI jobs must remain the final top-level workflow mapping'); + } + if ([...semanticWorkflow.matchAll(/^["']?jobs["']?\s*:/gimu)].length !== 1) { + errors.push('CI must contain one canonical jobs mapping'); + } + ['frontend', JOB_ID].forEach((jobId) => { + const jobKey = new RegExp(`^ {2}["']?${jobId}["']?\\s*:`, 'gimu'); + if ([...semanticWorkflow.matchAll(jobKey)].length !== 1) { + errors.push(`${jobId} must have one canonical semantic job key`); + } + }); + const block = jobBlock(workflow, JOB_ID); + if (!block) return ['missing scrubber job']; + if (literalCount(workflow, ` ${JOB_ID}:\n`) !== 1) { + errors.push('scrubber job ID must occur exactly once'); + } + if (block !== EXPECTED_SCRUBBER_JOB) { + errors.push('scrubber job must equal the dependency-free canonical block'); + } + if (/actions\/upload-[a-z0-9._-]*artifact@/iu.test(yamlSemanticSafetyView(workflow))) { + errors.push('CI contains an unscanned artifact upload'); + } + if (/\bpull_request_target\s*:/.test(workflow)) { + errors.push('CI runs untrusted code with target-repository authority'); + } + return errors; +} + +function frontendValidationErrors(workflow) { + const step = namedStepBlock(workflow, 'frontend', 'Validate protected release workflow'); + if (step !== EXPECTED_FRONTEND_VALIDATION_STEP) { + return ['frontend must run the exact independent workflow validation step']; + } + return []; +} + +function releaseErrors(workflow) { + const errors = []; + const semanticWorkflow = yamlSemanticSafetyView(workflow); + const jobsMarker = 'jobs:\n'; + const jobsIndex = workflow.indexOf(jobsMarker); + const header = jobsIndex < 0 ? '' : workflow.slice(0, jobsIndex + jobsMarker.length); + const preflightJob = jobBlock(workflow, 'preflight'); + const backendJob = jobBlock(workflow, 'deploy-backend'); + if (usesUnsupportedWorkflowMappingSyntax(workflow)) { + errors.push('release must not use explicit, tagged, anchored, aliased, or merged mapping keys'); + } + if (hasTopLevelDataAfterJobs(workflow)) { + errors.push('release jobs must remain the final top-level workflow mapping'); + } + if ([...semanticWorkflow.matchAll(/^["']?jobs["']?\s*:/gimu)].length !== 1) { + errors.push('release workflow must contain one canonical jobs mapping'); + } + ['preflight', 'deploy-backend'].forEach((jobId) => { + const jobKey = new RegExp(`^ {2}["']?${jobId}["']?\\s*:`, 'gimu'); + if ([...semanticWorkflow.matchAll(jobKey)].length !== 1) { + errors.push(`${jobId} must have one canonical semantic job key`); + } + }); + const preflight = stepBlockByName( + workflow, + 'preflight', + 'Require current main and its successful push CI', + ); + const backend = stepBlockByName( + workflow, + 'deploy-backend', + 'Revalidate current main and exact CI after protected approval', + ); + [ + [preflight, 'preflight'], + [backend, 'post-approval check'], + ].forEach(([block, label]) => { + if (literalCount(block, EXPECTED_RELEASE_JOB_LOOP) !== 1 + || literalCount(block, `"${JOB_NAME}"`) !== 1) { + errors.push(`${label} must contain the exact protected job loop once`); + } + }); + if (digestText(preflight) !== EXPECTED_RELEASE_STEP_DIGESTS.preflight) { + errors.push('preflight CI validation step must remain canonical'); + } + if (digestText(backend) !== EXPECTED_RELEASE_STEP_DIGESTS.postApproval) { + errors.push('post-approval CI validation step must remain canonical'); + } + if (digestText(header) !== EXPECTED_RELEASE_CONTEXT_DIGESTS.header) { + errors.push('release workflow-level execution context must remain canonical'); + } + if (digestText(preflightJob) !== EXPECTED_RELEASE_CONTEXT_DIGESTS.preflight) { + errors.push('preflight job execution context must remain canonical'); + } + if (digestText(backendJob) !== EXPECTED_RELEASE_CONTEXT_DIGESTS.postApproval) { + errors.push('post-approval job execution context must remain canonical'); + } + return errors; +} + +test('safe synthetic nested reports pass and source outside explicit roots is ignored', () => { + const { root, temporaryDirectory } = makeArtifactRoot(); + writeArtifact(root, 'nested/results.json', JSON.stringify({ + correlationId: 'corr_0123456789abcdef', + digest: 'a'.repeat(64), + displayName: 'Reserved Test', + email: 'runner@example.test', + emergencyContactName: 'Synthetic Test', + firstName: 'Synthetic Test', + fullName: 'Synthetic Test', + authorization: '[REDACTED]', + authorizationHeader: 'not-a-header-field', + cookie: '', + password: '***', + phone: '+1-202-555-0142', + phoneNumber: '+12025550142', + shippingAddress: '[REDACTED]', + total: 4, + })); + writeArtifact(root, 'nested/coverage.lcov', 'TN:\nSF:synthetic.js\nDA:1,1\nend_of_record\n'); + writeArtifact(root, 'nested/events.jsonl', '{"count":1}\n'); + writeArtifact(root, 'nested/junit.xml', '\n'); + writeArtifact(root, 'nested/output.log', 'synthetic outcome: pass\n'); + writeArtifact(root, 'nested/output.tap', 'TAP version 13\nok 1 - synthetic\n1..1\n'); + writeArtifact(root, 'nested/summary.txt', 'count=1\n'); + + const outsideCanary = ['s', 'k', '_', 'li', 've', '_', 'Z'.repeat(24)].join(''); + fs.writeFileSync(path.join(temporaryDirectory, 'source-canary.js'), outsideCanary); + + const result = scan([root]); + assert.equal(result.roots, 1); + assert.equal(result.files, 7); + assert.match(result.manifestDigest, /^[0-9a-f]{64}$/); +}); + +test('the exact reserved TEST-001A1 synthetic contact remains accepted', () => { + const { root } = makeArtifactRoot('test-artifacts-a1-contact'); + writeArtifact(root, 'contact.json', JSON.stringify({ + fixture: 'synthetic-contact-v1', + fullName: 'Synthetic Runner 000001', + email: 'runner-000001@example.test', + phoneNumber: '+12025550101', + address: { + line1: '101 Test Only Avenue', + line2: null, + city: 'Example', + state: 'CA', + postalCode: '00000', + country: 'US', + }, + })); + assert.equal(scan([root]).files, 1); +}); + +test('recognizable credential, capability, authority, and personal-data categories fail', () => { + const jwt = ['e'.repeat(20), 'y'.repeat(20), 's'.repeat(20)].join('.'); + const privateKey = ['-----BEGIN RSA ', 'PRIVATE KEY-----', 'synthetic-body'].join(''); + const privateBoundary = (qualifier) => [ + '-----BEGIN ', qualifier, ['PRI', 'VATE', ' KEY'].join(''), '-----', + ].join(''); + const forbidden = [ + ['CREDENTIAL_SHAPE', ['s', 'K', '_', 'Li', 'Ve', '_', 'A'.repeat(24)].join('')], + ['CREDENTIAL_SHAPE', ['A', 'K', 'I', 'A', 'B'.repeat(16)].join('')], + ['CREDENTIAL_SHAPE', `pk_live_${'p'.repeat(24)}`], + ['CREDENTIAL_SHAPE', `pk_test_${'q'.repeat(24)}`], + ['CREDENTIAL_SHAPE', `github_pat_${'r'.repeat(30)}`], + ['JWT_SHAPE', jwt], + ['PRIVATE_KEY_MATERIAL', privateKey], + ['PRIVATE_KEY_MATERIAL', privateBoundary('')], + ['PRIVATE_KEY_MATERIAL', privateBoundary('ENCRYPTED ')], + ['PRIVATE_KEY_MATERIAL', privateBoundary('DSA ')], + ['PRIVATE_KEY_MATERIAL', `${privateBoundary('PGP ').slice(0, -5)} BLOCK-----`], + ['BEARER_MATERIAL', `Authorization: Bearer ${'c'.repeat(24)}`], + ['BEARER_MATERIAL', `Bearer ${'d'.repeat(24)}`], + ['AUTH_MATERIAL', `Authorization: Basic ${'b'.repeat(24)}`], + ['AUTH_MATERIAL', `Cookie=${'session'.repeat(6)}`], + ['AUTH_MATERIAL', JSON.stringify({ clientSecret: 'f'.repeat(24) })], + ['AUTH_MATERIAL', `https://runner:${'u'.repeat(24)}@example.test/report`], + ['CAPABILITY_URL', `https://example.test/callback?code=${'d'.repeat(24)}`], + ['CAPABILITY_URL', `https://example.test/callback?session-id=${'s'.repeat(24)}`], + ['CAPABILITY_URL', `https://example.test/callback?apiKey=${'a'.repeat(24)}`], + ['CAPABILITY_URL', `https://example.test/callback?oauth_token=${'o'.repeat(24)}`], + ['CAPABILITY_URL', `https://example.test/callback#password=${'p'.repeat(24)}`], + ['CAPABILITY_URL', `https://example.test/callback?set-cookie=${'c'.repeat(24)}`], + ['CAPABILITY_URL', `https%3A%2F%2Fexample.test%2Freturn%3Fstate%3D${'e'.repeat(24)}`], + [ + 'CAPABILITY_URL', + `noise=%ZZ&next=https%3A%2F%2Fexample.test%2Freturn%3Fsession-id%3D${'m'.repeat(24)}&tail=100%`, + ], + [ + 'CAPABILITY_URL', + ``, + 'result.xml', + ], + [ + 'CREDENTIAL_SHAPE', + `%5Cu0073%5Cu006b%5Cu005f%5Cu006c%5Cu0069%5Cu0076%5Cu0065%5Cu005f${'z'.repeat(24)}`, + ], + ['PRODUCTION_AUTHORITY', ['MID', '-PENINSULA-RUNNING-CLUB'].join('')], + ['PERSONAL_EMAIL', ['runner', String.raw`\u0040`, 'private.club'].join('')], + ['PERSONAL_PHONE', ['+1 (650)', '234', '5678'].join('-')], + ['PERSONAL_PHONE', '+442071838750'], + ['SENSITIVE_IDENTITY_FIELD', JSON.stringify({ + dateOfBirth: ['1970', '01', '02'].join('-'), + })], + ['SENSITIVE_IDENTITY_FIELD', JSON.stringify({ fullName: 'Private Runner' })], + ['SENSITIVE_IDENTITY_FIELD', JSON.stringify({ displayName: 'Private Runner' })], + ['SENSITIVE_IDENTITY_FIELD', JSON.stringify({ phoneNumber: 6502345678 })], + ['SENSITIVE_IDENTITY_FIELD', 'phoneNumber: 6502345678'], + ['SENSITIVE_IDENTITY_FIELD', 'fullName: Private Runner'], + ['SENSITIVE_IDENTITY_FIELD', JSON.stringify({ emergencyContactName: 'Private Helper' })], + ['SENSITIVE_IDENTITY_FIELD', 'emergencyContact: { name: Private Helper }'], + ['SENSITIVE_IDENTITY_FIELD', JSON.stringify({ + emergencyContact: { name: 'Private Helper', phone: 6502345678 }, + })], + ['SENSITIVE_IDENTITY_FIELD', JSON.stringify({ + address: { city: 'Private City', line1: '123 Private Street' }, + })], + ['SENSITIVE_IDENTITY_FIELD', 'address: { city: Private City }'], + [ + 'SENSITIVE_IDENTITY_FIELD', + 'Private Runner', + 'result.xml', + ], + [ + 'SENSITIVE_IDENTITY_FIELD', + 'Private Runner', + 'result.xml', + ], + [ + 'SENSITIVE_IDENTITY_FIELD', + '', + 'result.xml', + ], + [ + 'SENSITIVE_IDENTITY_FIELD', + '', + 'result.xml', + ], + [ + 'AUTH_MATERIAL', + `${'t'.repeat(24)}`, + 'result.xml', + ], + [ + 'AUTH_MATERIAL', + `${'u'.repeat(24)}`, + 'result.xml', + ], + [ + 'AUTH_MATERIAL', + ``, + 'result.xml', + ], + [ + 'AUTH_MATERIAL', + ``, + 'result.xml', + ], + [ + 'AUTH_MATERIAL', + `${'u'.repeat(24)}`, + 'result.xml', + ], + ['AUTH_MATERIAL', JSON.stringify({ authToken: 'a'.repeat(24) })], + ['AUTH_MATERIAL', JSON.stringify({ oauthToken: 'o'.repeat(24) })], + ['AUTH_MATERIAL', JSON.stringify({ sessionToken: 's'.repeat(24) })], + ['AUTH_MATERIAL', JSON.stringify({ csrfToken: 'c'.repeat(24) })], + ['AUTH_MATERIAL', JSON.stringify({ verificationToken: 'v'.repeat(24) })], + ['AUTH_MATERIAL', JSON.stringify({ resetToken: 'r'.repeat(24) })], + ['AUTH_MATERIAL', JSON.stringify({ apiCredential: 'k'.repeat(24) })], + ['AUTH_MATERIAL', JSON.stringify({ privateKey: 'p'.repeat(24) })], + ['AUTH_MATERIAL', JSON.stringify({ confirmationToken: 'n'.repeat(24) })], + ]; + + forbidden.forEach(([ruleId, value, filename = 'result.txt'], index) => { + const { root } = makeArtifactRoot(`test-artifacts-${index}`); + writeArtifact(root, filename, value); + expectRule(ruleId, () => scan([root]), `forbidden case ${index}: ${ruleId}`); + }); +}); + +test('forbidden private-key boundary fixtures are not committed as complete literals', () => { + const source = fs.readFileSync(__filename, 'utf8'); + const boundaryPrefix = ['-----BEGIN ', 'PRI', 'VATE', ' KEY'].join(''); + assert.doesNotMatch(source, new RegExp(boundaryPrefix, 'u')); +}); + +test('missing, broad, source-shaped, duplicate, and escaping roots fail closed', () => { + expectRule('ROOT_REQUIRED', () => scan([])); + const { maxRoots } = scannerModule.TEST_ARTIFACT_LIMITS; + expectRule('ROOT_LIMIT', () => scan(Array(maxRoots + 1).fill('test-artifacts'))); + expectRule('ROOT_TOO_BROAD', () => scan([REPOSITORY])); + expectRule('ROOT_NOT_ARTIFACT_OUTPUT', () => scan([path.join(REPOSITORY, 'src')])); + + const { root, temporaryDirectory } = makeArtifactRoot(); + expectRule('ROOT_DUPLICATE', () => scan([root, root])); + writeArtifact(root, 'package.json', '{}'); + expectRule('ROOT_CONTAINS_SOURCE', () => scan([root])); + + const otherRoot = path.join(temporaryDirectory, 'test-results'); + fs.mkdirSync(otherRoot); + const escapingSpelling = `${root}${path.sep}..${path.sep}test-results`; + expectRule('ROOT_INVALID', () => scan([escapingSpelling])); +}); + +test('exported API snapshots own data descriptors and replaces hostile traps with a fixed error', () => { + const { root } = makeArtifactRoot('test-artifacts-api'); + writeArtifact(root, 'safe.txt', 'safe\n'); + const canary = 'HOSTILE_API_CANARY_MUST_NOT_ESCAPE'; + const hostileRoots = new Proxy([], { + ownKeys() { + throw new Error(canary); + }, + }); + const hostileOptions = new Proxy({}, { + ownKeys() { + throw new Error(canary); + }, + }); + let rootGetterCalled = false; + const accessorRoots = []; + Object.defineProperty(accessorRoots, '0', { + configurable: true, + get() { + rootGetterCalled = true; + throw new Error(canary); + }, + }); + accessorRoots.length = 1; + let optionGetterCalled = false; + const accessorOptions = {}; + Object.defineProperty(accessorOptions, 'cwd', { + configurable: true, + get() { + optionGetterCalled = true; + throw new Error(canary); + }, + }); + + const failures = [ + () => scan(null), + () => scan(hostileRoots), + () => scan(accessorRoots), + () => scan([root], null), + () => scan([root], hostileOptions), + () => scan([root], accessorOptions), + ]; + failures.forEach((callback) => { + const error = expectRule('API_INVALID', callback); + assert.equal(error.message, 'Test artifact scan failed.'); + assert.equal(error.exitCode, 2); + assert.doesNotMatch(`${error.name}\n${error.message}`, new RegExp(canary)); + }); + assert.equal(rootGetterCalled, false); + assert.equal(optionGetterCalled, false); +}); + +test('sensitive filenames fail even when their file contents are safe', () => { + const generic = makeArtifactRoot('test-artifacts-sensitive-name'); + writeArtifact(generic.root, `session-id-${'n'.repeat(24)}.txt`, 'safe\n'); + expectRule('SENSITIVE_FILENAME', () => scan([generic.root])); + + const credential = makeArtifactRoot('test-artifacts-credential-name'); + writeArtifact(credential.root, `whsec_${'w'.repeat(24)}.txt`, 'safe\n'); + expectRule('CREDENTIAL_SHAPE', () => scan([credential.root])); +}); + +test('malformed structured report formats fail closed while text remains supported', () => { + const malformed = [ + ['broken.json', '{"count":'], + ['broken.jsonl', '{"count":1}\nnot-json\n'], + ['broken.xml', ''], + ['multiple-roots.xml', ''], + ['unterminated-attribute.xml', ''], + ['invalid-control-zero.xml', ''], + ['invalid-control-one.xml', ''], + ['invalid-direct-character.xml', `${String.fromCodePoint(0xfffe)}`], + ['duplicate-attribute.xml', ''], + ['misplaced-declaration.xml', ''], + ['broken.tap', 'ok 1 - missing TAP header\n'], + ['plan-mismatch.tap', 'TAP version 13\nok 1 - synthetic\n1..2\n'], + ['duplicate-plan.tap', 'TAP version 13\nok 1 - synthetic\n1..1\n1..1\n'], + ['malformed-result.tap', 'TAP version 13\nok synthetic\n1..1\n'], + ['broken.lcov', 'TN:\nSF:synthetic.js\nDA:1,1\n'], + ['broken.info', 'TN:\nSF:synthetic.js\nUNKNOWN:value\nend_of_record\n'], + ['duplicate-source.lcov', 'SF:first.js\nSF:second.js\nend_of_record\n'], + ['metric-outside-record.lcov', 'DA:1,1\nSF:synthetic.js\nend_of_record\n'], + ['invalid-counter.lcov', 'SF:synthetic.js\nDA:not-a-line,1\nend_of_record\n'], + ['extra-end.lcov', 'SF:synthetic.js\nend_of_record\nend_of_record\n'], + ]; + malformed.forEach(([filename, contents], index) => { + const { root } = makeArtifactRoot(`test-artifacts-format-${index}`); + writeArtifact(root, filename, contents); + expectRule('FORMAT_INVALID', () => scan([root])); + }); + + const text = makeArtifactRoot('test-artifacts-text-format'); + writeArtifact(text.root, 'summary.txt', 'plain synthetic summary\n'); + writeArtifact(text.root, 'output.log', 'plain synthetic log\n'); + assert.equal(scan([text.root]).files, 2); +}); + +test('root and nested directory path swaps fail deterministic identity checks', () => { + ['root', 'nested'].forEach((mode) => { + const { root } = makeArtifactRoot(`test-artifacts-swap-${mode}`); + writeArtifact(root, 'nested/safe.txt', 'safe\n'); + const target = fs.realpathSync(mode === 'root' ? root : path.join(root, 'nested')); + const displaced = `${target}.original`; + const originalOpenDirectory = fs.opendirSync; + let swapped = false; + fs.opendirSync = function swapBeforeOpen(directory, ...args) { + if (!swapped && path.resolve(directory) === target) { + swapped = true; + fs.renameSync(target, displaced); + fs.mkdirSync(target); + fs.writeFileSync(path.join(target, 'replacement.txt'), 'safe\n'); + } + return originalOpenDirectory.call(fs, directory, ...args); + }; + try { + expectRule('ENTRY_CHANGED', () => scan([root])); + assert.equal(swapped, true); + } finally { + fs.opendirSync = originalOpenDirectory; + } + }); +}); + +test('a root replacement at the realpath boundary fails initial identity binding', () => { + const { root } = makeArtifactRoot('test-artifacts-initial-swap'); + const secret = ['s', 'k', '_', 'li', 've', '_', 'i'.repeat(24)].join(''); + writeArtifact(root, 'secret.txt', secret); + const displaced = `${root}.original`; + const originalRealpath = fs.realpathSync; + let swapped = false; + fs.realpathSync = function swapAfterInitialStat(target, ...args) { + const result = originalRealpath.call(fs, target, ...args); + if (!swapped && path.resolve(target) === path.resolve(root)) { + swapped = true; + fs.renameSync(root, displaced); + fs.mkdirSync(root); + fs.writeFileSync(path.join(root, 'safe.txt'), 'safe\n'); + } + return result; + }; + try { + expectRule('ENTRY_CHANGED', () => scan([root])); + assert.equal(swapped, true); + } finally { + fs.realpathSync = originalRealpath; + } +}); + +test('symlinks, archives, unsupported types, binary data, and unreadable input fail', () => { + const outside = makeArtifactRoot('reports-outside'); + + const rootLink = makeArtifactRoot('test-results-rootlink'); + fs.rmSync(rootLink.root, { recursive: true }); + fs.symlinkSync(outside.root, rootLink.root); + expectRule('ROOT_SYMLINK', () => scan([rootLink.root])); + + const symlinkRoot = makeArtifactRoot('test-artifacts-link'); + fs.symlinkSync(outside.root, path.join(symlinkRoot.root, 'escape')); + expectRule('SYMLINK_UNSUPPORTED', () => scan([symlinkRoot.root])); + + const archiveRoot = makeArtifactRoot('test-artifacts-archive'); + writeArtifact(archiveRoot.root, 'results.zip', 'not-an-archive'); + expectRule('ARCHIVE_UNSUPPORTED', () => scan([archiveRoot.root])); + + const unsupportedRoot = makeArtifactRoot('test-artifacts-unsupported'); + writeArtifact(unsupportedRoot.root, 'results.md', 'text'); + expectRule('FORMAT_UNSUPPORTED', () => scan([unsupportedRoot.root])); + + const binaryRoot = makeArtifactRoot('test-artifacts-binary'); + writeArtifact(binaryRoot.root, 'results.txt', Buffer.from([0x00, 0xff, 0x01])); + expectRule('BINARY_CONTENT', () => scan([binaryRoot.root])); + + const unreadableRoot = makeArtifactRoot('test-artifacts-unreadable'); + const unreadableFile = writeArtifact(unreadableRoot.root, 'private.log', 'safe'); + fs.chmodSync(unreadableFile, 0o000); + try { + expectRule('ENTRY_UNREADABLE', () => scan([unreadableRoot.root])); + } finally { + fs.chmodSync(unreadableFile, 0o600); + } +}); + +test('file size, total size, depth, file count, and entry count are bounded', () => { + const { + maxDepth, maxEntries, maxFileBytes, maxFiles, + } = scannerModule.TEST_ARTIFACT_LIMITS; + + const oversizeRoot = makeArtifactRoot('test-artifacts-oversize'); + writeArtifact(oversizeRoot.root, 'large.txt', Buffer.alloc(maxFileBytes + 1, 0x61)); + expectRule('FILE_TOO_LARGE', () => scan([oversizeRoot.root])); + + const totalRoot = makeArtifactRoot('test-artifacts-total'); + for (let index = 0; index < 5; index += 1) { + writeArtifact(totalRoot.root, `part-${index}.txt`, Buffer.alloc(maxFileBytes, 0x61)); + } + expectRule('TOTAL_BYTES_LIMIT', () => scan([totalRoot.root])); + + const deepRoot = makeArtifactRoot('test-artifacts-depth'); + writeArtifact( + deepRoot.root, + `${Array.from({ length: maxDepth + 1 }, (_unused, index) => `d${index}`).join('/')}/result.txt`, + 'safe', + ); + expectRule('DEPTH_LIMIT', () => scan([deepRoot.root])); + + const fileRoot = makeArtifactRoot('test-artifacts-files'); + for (let index = 0; index <= maxFiles; index += 1) { + writeArtifact(fileRoot.root, `result-${String(index).padStart(3, '0')}.txt`, 'safe'); + } + expectRule('FILE_LIMIT', () => scan([fileRoot.root])); + + const entryRoot = makeArtifactRoot('test-artifacts-entries'); + for (let index = 0; index <= maxEntries; index += 1) { + fs.mkdirSync(path.join(entryRoot.root, `empty-${String(index).padStart(3, '0')}`)); + } + expectRule('ENTRY_LIMIT', () => scan([entryRoot.root])); +}); + +test('sorted traversal and repeated multi-root scans are deterministic', () => { + const first = makeArtifactRoot('test-artifacts-z'); + const second = makeArtifactRoot('test-results-a'); + writeArtifact(first.root, 'z/report.txt', 'third'); + writeArtifact(first.root, 'a/report.txt', 'first'); + writeArtifact(second.root, 'm/report.txt', 'second'); + + const forward = scan([first.root, second.root]); + const reverse = scan([second.root, first.root]); + const repeated = scan([first.root, second.root]); + assert.deepEqual(forward, reverse); + assert.deepEqual(forward, repeated); +}); + +test('CLI failures use fixed rule IDs and never echo hostile values or filenames', () => { + const { root } = makeArtifactRoot('test-artifacts-redaction'); + const canary = ['w', 'h', 's', 'e', 'c', '_', 'Q'.repeat(24)].join(''); + const secretBearingFilename = `do-not-echo-${canary}.json`; + writeArtifact(root, secretBearingFilename, canary); + + const result = runScanner(root); + assert.equal(result.status, 3); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /^TEST_ARTIFACT_SCAN_FAILED CREDENTIAL_SHAPE\n$/); + assert.doesNotMatch(result.stderr, new RegExp(canary)); + assert.doesNotMatch(result.stderr, /do-not-echo/); +}); + +test('current scrubber CI job is exact, dependency-free, blocking, and upload-free', () => { + assert.deepEqual(ciErrors(ciWorkflow), []); + assert.deepEqual(frontendValidationErrors(ciWorkflow), []); + assert.deepEqual(releaseErrors(releaseWorkflow), []); +}); + +test('workflow guard rejects decoys, skips, wrappers, expressions, and altered scrubber jobs', () => { + const mutations = [ + ciWorkflow.replace(` ${JOB_ID}:\n`, ' removed-scrubber-job:\n'), + ciWorkflow.replace(`name: ${JOB_NAME}`, 'name: Generic output check'), + mutateJob(ciWorkflow, `name: ${JOB_NAME}`, `name: Generic output check # name: ${JOB_NAME}`), + mutateJob(ciWorkflow, "node-version: '20'", "node-version: '18' # '20'"), + ciWorkflow.replace(TEST_COMMAND, 'node --test tests/spa-navigation.test.js'), + ciWorkflow.replace(' timeout-minutes: 5', ` continue-on-error: ${ALWAYS_TRUE}`), + mutateJob( + ciWorkflow, + ' timeout-minutes: 5', + ' timeout-minutes: 5\n needs: frontend', + ), + mutateJob( + ciWorkflow, + ' timeout-minutes: 5', + ' timeout-minutes: 5\n strategy:\n fail-fast: false', + ), + ciWorkflow.replace( + ` - name: Run emitted test artifact safety tests\n run: ${TEST_COMMAND}`, + ` - name: Run emitted test artifact safety tests\n if: ${NEVER_RUN}\n run: ${TEST_COMMAND}`, + ), + ciWorkflow.replace(TEST_COMMAND, `${TEST_COMMAND} || true`), + mutateJob(ciWorkflow, ` run: ${TEST_COMMAND}`, ` run: ${TEST_COMMAND} | cat`), + mutateJob(ciWorkflow, ` run: ${TEST_COMMAND}`, ` run: ! ${TEST_COMMAND}`), + mutateJob( + ciWorkflow, + ` run: ${TEST_COMMAND}`, + ` run: bash -c '${TEST_COMMAND}'`, + ), + mutateJob( + ciWorkflow, + ` run: ${TEST_COMMAND}`, + ` run: |\n ${TEST_COMMAND}`, + ), + mutateJob( + ciWorkflow, + ' persist-credentials: false', + ' persist-credentials: true # false', + ), + mutateJob(ciWorkflow, ' contents: read', ' packages: write'), + ciWorkflow.replace( + ` name: ${JOB_NAME}`, + ` name: ${JOB_NAME}\n env:\n TEST_TOKEN: \${{ secrets.SYNTHETIC_CANARY }}`, + ), + mutateJob( + ciWorkflow, + ` name: ${JOB_NAME}`, + ` name: ${JOB_NAME}\n env:\n TEST_TOKEN: ${BRACKET_SECRET}`, + ), + mutateJob( + ciWorkflow, + ` name: ${JOB_NAME}`, + ` name: ${JOB_NAME}\n env:\n GH_TOKEN: ${BRACKET_GITHUB_TOKEN}`, + ), + mutateJob( + ciWorkflow, + ' - uses: actions/setup-node@v6', + ' - uses: actions/setup-node@v6\n - run: true', + ), + ]; + mutations.forEach((mutated) => assert.notDeepEqual(ciErrors(mutated), [])); +}); + +test('workflow guard rejects case-insensitive direct artifact upload actions anywhere in CI', () => { + const uploadActions = [ + `actions/UpLoAd-ArTiFaCt@${'a'.repeat(40)}`, + `actions/upload-pages-artifact@${'b'.repeat(40)}`, + ]; + uploadActions.forEach((action) => { + const mutated = ciWorkflow.replace( + ` run: ${TEST_COMMAND}`, + ` run: ${TEST_COMMAND}\n - uses: ${action}\n with:\n path: test-results`, + ); + assert.notDeepEqual(ciErrors(mutated), []); + + const flowMap = ciWorkflow.replace( + ` run: ${TEST_COMMAND}`, + ` run: ${TEST_COMMAND}\n - { uses: ${action}, with: { path: test-results } }`, + ); + assert.notDeepEqual(ciErrors(flowMap), []); + + const quotedKey = ciWorkflow.replace( + ` run: ${TEST_COMMAND}`, + ` run: ${TEST_COMMAND}\n - "uses": ${action}`, + ); + assert.notDeepEqual(ciErrors(quotedKey), []); + }); + + const escapedUploadOutsideScrubber = ciWorkflow.replace( + ' - run: npm run test:run', + ' - run: npm run test:run\n - "uses": "actions/upload\\u002dartifact@v4"', + ); + assert.notEqual(escapedUploadOutsideScrubber, ciWorkflow); + assert.notDeepEqual(ciErrors(escapedUploadOutsideScrubber), []); +}); + +test('workflow guard rejects quoted or flow-style privileged triggers', () => { + const quotedTarget = ciWorkflow.replace( + ' pull_request:', + ' "pull_request_target":', + ); + const flowTarget = ciWorkflow.replace( + EXPECTED_CI_HEADER, + 'name: CI\n\non: [pull_request_target]\n\njobs:\n', + ); + assert.notDeepEqual(ciErrors(quotedTarget), []); + assert.notDeepEqual(ciErrors(flowTarget), []); +}); + +test('workflow guard rejects duplicate and complex semantic CI keys', () => { + const duplicateFrontend = ciWorkflow.replace( + ` ${JOB_ID}:\n`, + ` "front\\u0065nd":\n runs-on: ubuntu-latest\n steps:\n - run: "true"\n\n ${JOB_ID}:\n`, + ); + assert.notDeepEqual(ciErrors(duplicateFrontend), []); + + const duplicateScrubber = ciWorkflow.replace( + ' functions:\n', + ' "test-artifact-scrubbe\\u0072":\n runs-on: ubuntu-latest\n steps:\n - run: "true"\n\n functions:\n', + ); + assert.notDeepEqual(ciErrors(duplicateScrubber), []); + + const explicitScrubber = ciWorkflow.replace( + ' functions:\n', + ` ? ${JOB_ID}\n :\n runs-on: ubuntu-latest\n steps:\n - run: "true"\n\n functions:\n`, + ); + assert.notDeepEqual(ciErrors(explicitScrubber), []); + + const duplicateJobs = `${ciWorkflow}\njobs:\n decoy:\n runs-on: ubuntu-latest\n`; + assert.notDeepEqual(ciErrors(duplicateJobs), []); + + const lateDefaults = `${ciWorkflow}\ndefaults:\n run:\n shell: bash -c 'bash "$1" || true' _ {0}\n`; + assert.notDeepEqual(ciErrors(lateDefaults), []); +}); + +test('frontend independently runs the exact workflow safety suite without a skip', () => { + const missingSafetyTest = ciWorkflow.replace( + ' tests/test-artifact-safety.test.js', + '', + ); + assert.notDeepEqual(frontendValidationErrors(missingSafetyTest), []); + + const skippedValidation = ciWorkflow.replace( + ' - name: Validate protected release workflow\n run: |', + ` - name: Validate protected release workflow\n if: ${NEVER_RUN}\n run: |`, + ); + assert.notDeepEqual(frontendValidationErrors(skippedValidation), []); +}); + +test('workflow guard rejects omission from either protected-release job loop', () => { + const requiredLine = ` "${JOB_NAME}" \\\n`; + const withoutFirst = releaseWorkflow.replace(requiredLine, ''); + assert.notDeepEqual(releaseErrors(withoutFirst), []); + + const secondIndex = releaseWorkflow.lastIndexOf(requiredLine); + const withoutSecond = releaseWorkflow.slice(0, secondIndex) + + releaseWorkflow.slice(secondIndex + requiredLine.length); + assert.notDeepEqual(releaseErrors(withoutSecond), []); + + const decoyOnly = releaseWorkflow.replace( + requiredLine, + ` "Renamed artifact check" \\\n # "${JOB_NAME}" \\\n`, + ); + assert.notDeepEqual(releaseErrors(decoyOnly), []); + + const loopDecoy = releaseWorkflow.replace( + requiredLine, + '', + ).replace( + ' for required_job in \\\n', + ` cat <<'INERT_JOB_LOOP' >/dev/null\n${EXPECTED_RELEASE_JOB_LOOP}\nINERT_JOB_LOOP\n\n for required_job in \\\n`, + ); + assert.notDeepEqual(releaseErrors(loopDecoy), []); +}); + +test('workflow guard binds workflow and job execution context around protected checks', () => { + const customShell = "bash -c 'bash \"$1\" || true' _ {0}"; + const workflowDefault = releaseWorkflow.replace( + 'jobs:\n', + `defaults:\n run:\n shell: ${customShell}\n\njobs:\n`, + ); + assert.notDeepEqual(releaseErrors(workflowDefault), []); + + ['preflight', 'deploy-backend'].forEach((jobId) => { + const jobDefault = releaseWorkflow.replace( + ` ${jobId}:\n`, + ` ${jobId}:\n defaults:\n run:\n shell: ${customShell}\n`, + ); + assert.notDeepEqual(releaseErrors(jobDefault), []); + }); + + const duplicatePreflight = releaseWorkflow.replace( + ' prepare-pages:\n', + ' "pre\\u0066light":\n runs-on: ubuntu-latest\n steps:\n - run: "true"\n\n prepare-pages:\n', + ); + assert.notDeepEqual(releaseErrors(duplicatePreflight), []); + + const duplicateBackend = releaseWorkflow.replace( + ' deploy-pages:\n', + ' "deploy-backend":\n runs-on: ubuntu-latest\n steps:\n - run: "true"\n\n deploy-pages:\n', + ); + assert.notDeepEqual(releaseErrors(duplicateBackend), []); + + const explicitPreflight = releaseWorkflow.replace( + ' deploy-backend:\n', + ' ? preflight\n :\n name: Explicit duplicate wins\n runs-on: ubuntu-latest\n steps:\n - run: "true"\n\n deploy-backend:\n', + ); + assert.notDeepEqual(releaseErrors(explicitPreflight), []); + + const taggedBackend = releaseWorkflow.replace( + ' deploy-pages:\n', + ' !!str deploy-backend:\n runs-on: ubuntu-latest\n steps:\n - run: "true"\n\n deploy-pages:\n', + ); + assert.notDeepEqual(releaseErrors(taggedBackend), []); + + const mergedJobs = releaseWorkflow.replace('jobs:\n', 'jobs:\n <<: *alternate-jobs\n'); + assert.notDeepEqual(releaseErrors(mergedJobs), []); + + const lateDefaults = `${releaseWorkflow}\ndefaults:\n run:\n shell: bash -c 'bash "$1" || true' _ {0}\n`; + assert.notDeepEqual(releaseErrors(lateDefaults), []); +});