diff --git a/docs/review-policy.md b/docs/review-policy.md index 263632cdc..f25449e4e 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -216,8 +216,9 @@ settings and host allowlists), archive extraction and path traversal (`tar.x`, adm-zip, yauzl, extract-zip, `extractall`, `tar -x`, `unzip`, `path.join` or `resolve` on request or entry input, a `'..'` check added or removed), and Node exec through any `child_process` spelling: a named import of `exec`, `execSync` -or `spawn` (one line or split over lines), a default or namespace import, a -whole-module or destructured `require` or dynamic `import`. **data** is user data: a path naming pii, gdpr, ccpa, privacy, +or `spawn` (on one line, split over lines when the whole statement is in the +diff, or an added `exec` binding line inside one), a default or namespace +import, a whole-module or destructured `require` or dynamic `import`. **data** is user data: a path naming pii, gdpr, ccpa, privacy, personal, account, customer, cookie, persistence, storage or database, or a line that drops a table or column, deletes rows or bulk-deletes records. Auth scope, migrations and deleted code paths were already gate class. `RISK_RULES` hashes @@ -234,18 +235,26 @@ the whole input in linear time and fail closed on anything they do not parse. `polygon`, `text`, `tspan`, `title`, `desc`, `defs`, gradients and `stop`, `clipPath`, `use`, `symbol`, `marker`, `pattern`, `mask`) and presentational attributes. No namespace prefixes, `on*` or binding attributes (`:x`, `v-bind:`, - `x-bind:`, `@x`), entities other than the five predefined ones, doctype, - CDATA or processing instructions other than the XML declaration; `href` only - on `use` and only `#fragment`; `url(#id)` only in presentation attributes and - no `url(` in `style`; no backslash escapes; no `{{` or `${` in text. An edited - SVG stays code. + `x-bind:`, `@x`), entities other than the five predefined named ones (no + numeric references), doctype, CDATA or processing instructions other than the + XML declaration, and no comment HTML would close early (``, ``) or + that holds `<` or `>`; `href` only on `use` and only `#fragment`; `url(#id)` + only in presentation attributes and no `url(` in `style`; no backslash escapes, + braces or `${` in attribute values; no `{{` or `${` in text. An edited SVG + stays code. - **A locale resource edit**: a JSON, ARB, YAML, PO, `.strings`, XLIFF, Fluent or `.properties` file under a `locale(s)`, `i18n`, `l10n`, `translation(s)` or - `lang` directory, where every deleted line pairs with an added line with the - same key and only the literal string value changes, with no markup, encoded - character, script or link scheme, and the same placeholders. Added or removed - keys, UI component files (`.tsx`, `.vue` and the rest), i18n source files and - anything under `config/` stay code. + `lang` directory (JSON, YAML and `.properties` also need a locale code such as + `en` or `pt-BR` as the parent directory or the file name), where every deleted + line pairs with an added line with the same key and a changed literal string + value, with no markup, escape other than `\n` or `\t`, script, link scheme or + protocol-relative link (read after escapes and whitespace are removed), no YAML + value that loads as null, a boolean or a number, and the same placeholders + (`{x}`, `%s`, `%(name)s` and the rest). Added, removed or moved keys, UI + component files (`.tsx`, `.vue` and the rest), i18n source files and anything + under `config/` stay code. + +A path any risk class matches stays code whatever its shape. A write-up with one clean chart and no code is one reader (the outside voice on its numbers). When a diff publishes numbers, the batched specialist's diff keeps diff --git a/lib/review-allowlist.ts b/lib/review-allowlist.ts index c8ee11de8..429480738 100644 --- a/lib/review-allowlist.ts +++ b/lib/review-allowlist.ts @@ -19,7 +19,6 @@ export const SVG_ATTRIBUTES = [ ]; const SVG_NS = 'http://www.w3.org/2000/svg'; const XML_DECL = /^<\?xml version="1\.[01]"(?: encoding="[A-Za-z0-9._-]+")?(?: standalone="(?:yes|no)")?\s*\?>$/; -const ENTITY = /^&(?:amp|lt|gt|quot|apos|#[0-9]{1,7}|#x[0-9a-fA-F]{1,6});/; const NAMED_ENTITY = /^&(?:amp|lt|gt|quot|apos);/; const FRAGMENT = /^#[A-Za-z_][\w.-]*$/; const LOCAL_URL = /url\(#[A-Za-z_][\w.-]*\)/g; @@ -27,17 +26,17 @@ const STYLE_VALUE = /^[A-Za-z0-9\s#%.,:;()_+-]*$/; const isWs = (c: string | undefined) => c === ' ' || c === '\t' || c === '\n' || c === '\r'; const isNameChar = (c: string | undefined) => !!c && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '-'); -/** Every & starts an allowed reference; named allows only the five predefined entities. */ -function entitiesOk(s: string, named: boolean): boolean { - for (let k = s.indexOf('&'); k >= 0; k = s.indexOf('&', k + 1)) if (!(named ? NAMED_ENTITY : ENTITY).test(s.slice(k, k + 12))) return false; +/** Every & starts one of the five predefined entities: a numeric reference could spell an interpolation or a scheme. */ +function entitiesOk(s: string): boolean { + for (let k = s.indexOf('&'); k >= 0; k = s.indexOf('&', k + 1)) if (!NAMED_ENTITY.test(s.slice(k, k + 8))) return false; return true; } function textOk(s: string, inside: boolean): boolean { if (!inside) { for (const c of s) if (!isWs(c)) return false; return true; } - return !/[\0-\x08\x0b\x0c\x0e-\x1f]|\{\{|\$\{|\]\]>/.test(s) && entitiesOk(s, false); + return !/[\0-\x08\x0b\x0c\x0e-\x1f]|\{\{|\$\{|\]\]>/.test(s) && entitiesOk(s); } function valueOk(element: string, name: string, v: string): boolean { - if (/[<\\`\0-\x1f]/.test(v) || !entitiesOk(v, true)) return false; + if (/[<\\`{}\0-\x1f]|\$\{/.test(v) || !entitiesOk(v)) return false; if (name === 'xmlns') return v === SVG_NS; if (name === 'href') return element === 'use' && FRAGMENT.test(v); if (name === 'style') return STYLE_VALUE.test(v) && !/url\(|expression/i.test(v); @@ -87,7 +86,8 @@ export function presentationalSvg(text: string): boolean { if (lt < 0) break; if (text.startsWith('', lt + 4); - if (end < 0 || text.slice(lt + 4, end).includes('--')) return false; + const body = text.slice(lt + 4, end); + if (end < 0 || body.includes('--') || /[<>]/.test(body) || body.startsWith('-')) return false; i = end + 3; continue; } @@ -111,9 +111,16 @@ export function presentationalSvg(text: string): boolean { export const LOCALE_DIR = /(^|\/)(locales?|i18n|l10n|translations?|lang)\//i; export const LOCALE_FORMATS = ['json', 'arb', 'yaml', 'yml', 'po', 'strings', 'xliff', 'xlf', 'ftl', 'properties']; -const PLACEHOLDER = /\{\{[^{}]*\}\}|\{[^{}]*\}|%(?:[1-9]\d*\$)?[-+ #0]*(?:[1-9]\d*)?(?:\.\d+)?[a-zA-Z@]|\$\{[^{}]*\}|\$t\([^()]*\)|@[:.][\w.:-]+|\$[A-Za-z_]\w*/g; +const PLACEHOLDER = /\{\{[^{}]*\}\}|\{[^{}]*\}|%(?:[1-9]\d*\$|\([^()]*\))?[-+ #0]*(?:[1-9]\d*)?(?:\.\d+)?[a-zA-Z@]|\$\{[^{}]*\}|\$t\([^()]*\)|@[:.][\w.:-]+|\$[A-Za-z_]\w*/g; /** Never UI text: markup, an encoded character that could spell markup, a script or link scheme, a template literal. */ -const NOT_TEXT = /[<>`]|&(?:lt|gt|#)|\\[uUx]|(?:javascript|vbscript|data):|:\/\//i; +const NOT_TEXT = /[<>`]|&(?:lt|gt|#)|\\[^nt"']|%\(/i; +/** A link scheme or a protocol-relative link, read after escapes, whitespace and control characters are removed (a URL parser drops them). */ +const LINK = /(?:javascript|vbscript|data):|\/\//i; +/** YAML plain scalars that load as null, a boolean or a number, not a string. */ +const YAML_NOT_STRING = /^(?:null|~|true|false|yes|no|on|off|y|n|[-+]?(?:\d[\d_]*(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?|0x[0-9a-f]+|0o[0-7]+|[-+]?\.(?:inf|nan))$/i; +/** A locale code as a path segment or a part of the file name: en, fr, pt-BR, zh_Hant. */ +const LOCALE_CODE = /^[a-z]{2}(?:[-_][A-Za-z]{2,4})?$/; +const LOCALE_ONLY_FORMATS = ['po', 'strings', 'xliff', 'xlf', 'arb', 'ftl']; const YAML_PLAIN = /^[A-Za-z0-9][^#:{}[\]&*!|>%@`"'<\\]*$/; const XLIFF_TARGET = /^(\s*)([^<]*)(<\/target>\s*)$/; const FTL_ENTRY = /^(\s*[.-]?[A-Za-z][\w-]*\s*=\s*)(\S.*)$/; @@ -160,7 +167,8 @@ function yamlPair(line: string): Entry { if (close !== end - 1) return null; return { key: line.slice(0, k + 1) + '\0' + line.slice(close), value: line.slice(k + 1, close) }; } - return YAML_PLAIN.test(line.slice(k, end)) ? { key: line.slice(0, k), value: line.slice(k, end) } : null; + const plain = line.slice(k, end); + return YAML_PLAIN.test(plain) && !YAML_NOT_STRING.test(plain) ? { key: line.slice(0, k), value: plain } : null; } function poPair(line: string): Entry { const m = /^msgstr(?:\[\d+\])? "/.exec(line); @@ -186,25 +194,28 @@ function entryOf(format: string, line: string): Entry { } /** The value's placeholders, sorted, or null when it is not plain text (markup, an encoded character, a scheme, an unpaired brace). */ function placeholders(value: string): string | null { - if (NOT_TEXT.test(value)) return null; + if (NOT_TEXT.test(value) || LINK.test(value.replace(/\\[nt]|[\s\0-\x1f]/g, ''))) return null; const found = value.match(PLACEHOLDER) ?? []; if (/[{}]/.test(value.replace(PLACEHOLDER, ''))) return null; return found.sort().join('\0'); } -/** The resource format of a path under a locale directory, or null. */ +/** The resource format of a path under a locale directory, or null; JSON, YAML and .properties also need a locale code as the parent directory, the file stem or the stem's last . or _ part. */ export function localeFormat(p: string): string | null { const ext = /\.([A-Za-z]+)$/.exec(p)?.[1]?.toLowerCase(); - return ext && LOCALE_FORMATS.includes(ext) && LOCALE_DIR.test(p) ? ext : null; + if (!ext || !LOCALE_FORMATS.includes(ext) || !LOCALE_DIR.test(p)) return null; + const parts = p.split('/'); + const stem = parts[parts.length - 1]!.slice(0, -ext.length - 1); + return LOCALE_ONLY_FORMATS.includes(ext) || [parts[parts.length - 2] ?? '', stem, stem.split(/[._]/).pop()!].some(s => LOCALE_CODE.test(s)) ? ext : null; } -/** Whether an edit to a locale resource changes only literal string values: lines pair up with the same key, and each value is plain text with the same placeholders. */ +/** Whether an edit to a locale resource changes only literal string values: lines pair up with the same key and a changed value (an unchanged pair is a moved key), and each value is plain text with the same placeholders. */ export function localeTextOnly(p: string, removed: readonly string[] = [], added: readonly string[] = []): boolean { const format = localeFormat(p); if (!format || !added.length || removed.length !== added.length) return false; return removed.every((line, n) => { const a = entryOf(format, line), b = entryOf(format, added[n]!); - if (!a || !b || a.key !== b.key) return false; + if (!a || !b || a.key !== b.key || a.value === b.value) return false; const ha = placeholders(a.value); return ha !== null && ha === placeholders(b.value); }); @@ -213,5 +224,6 @@ export function localeTextOnly(p: string, removed: readonly string[] = [], added /** A hash of the lists and patterns that decide an allowlisted docs path; lib/review-depth.ts folds it into DEPTH_RULES. */ export const ALLOWLIST_RULES = createHash('sha256').update(JSON.stringify({ SVG_ELEMENTS, SVG_ATTRIBUTES, SVG_NS, LOCALE_FORMATS, - patterns: [XML_DECL, ENTITY, NAMED_ENTITY, FRAGMENT, LOCAL_URL, STYLE_VALUE, LOCALE_DIR, PLACEHOLDER, NOT_TEXT, YAML_PLAIN, XLIFF_TARGET, FTL_ENTRY, PROPERTIES_ENTRY].map(r => '/' + r.source + '/' + r.flags), + LOCALE_ONLY_FORMATS, + patterns: [XML_DECL, NAMED_ENTITY, LINK, YAML_NOT_STRING, LOCALE_CODE, FRAGMENT, LOCAL_URL, STYLE_VALUE, LOCALE_DIR, PLACEHOLDER, NOT_TEXT, YAML_PLAIN, XLIFF_TARGET, FTL_ENTRY, PROPERTIES_ENTRY].map(r => '/' + r.source + '/' + r.flags), })).digest('hex').slice(0, 16); diff --git a/lib/review-depth.ts b/lib/review-depth.ts index 07fdb0f7e..c84a3076b 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -145,10 +145,12 @@ const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && m const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && dataCandidate(p, i.root); /** * An allowlisted docs path (GST-850, lib/review-allowlist.ts): a new SVG whose whole file is presentational, or a locale resource - * edit that changes only literal string values. Agent prose, gate paths and config files never qualify; an edited SVG stays code. + * edit that changes only literal string values. Agent prose, gate paths, config files and any path a risk class matches never qualify; + * an edited SVG stays code. */ const allowlisted = (i: ClassInput, p: string): 'svg' | 'locale' | null => { if (!i.added?.[p]?.length || classifyPath(p, i.root) !== 'code' || [...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p))) return null; + if (matchRisk({ codePaths: [p], added: i.added, removed: i.removed, scopeAuth: false }).size) return null; if (/\.svg$/i.test(p)) return i.created?.includes(p) && !i.removed?.[p]?.length && presentationalSvg(i.added[p]!.join('\n')) ? 'svg' : null; return localeTextOnly(p, i.removed?.[p], i.added[p]) ? 'locale' : null; }; diff --git a/lib/review-risk.ts b/lib/review-risk.ts index 03779beb6..514cb5d25 100644 --- a/lib/review-risk.ts +++ b/lib/review-risk.ts @@ -55,8 +55,9 @@ const SECURITY_LINE = [ /\b(?:HTTPS?_PROXY|NO_PROXY|ALL_PROXY|https?_proxy|no_proxy)\b|\b(?:Https?)?ProxyAgent\b|\bproxy\s*(?::|=(?![=>]))/, /\b(?:allowed|trusted|blocked|denied)[_-]?(?:hosts?|domains?|origins?|urls?)|\b(?:hosts?|domains?|origins?|urls?)[_-]?(?:allow|deny|block)[_-]?list/i, /\btar\.(?:x|extract)\s*\(|\.(?:extractall|extractAllTo|extractEntryTo)\s*\(|\b(?:tar|tf|zf|zip|archive)\.extract\s*\(|\bshutil\.unpack_archive\s*\(|\b(?:adm-zip|AdmZip|yauzl|extract-zip|unzipper|node-stream-zip)\b/, /\btar\s+(?:-[A-Za-z]*x|x[A-Za-z]*\s|--extract)|\bunzip\s+[-\w"'$]/, - /\bpath\.(?:join|resolve)\s*\([^)]*(?:\b(?:req|request|params|query|argv|headers?|filename|fileName|entryName|member|upload|untrusted|userInput)\b|\bentry\.(?:path|name|fileName)\b)/, + /\bpath\.(?:join|resolve)\s*\([^)]{0,300}?(?:\b(?:req|request|params|query|argv|headers?|filename|fileName|entryName|member|upload|untrusted|userInput)\b|\bentry\.(?:path|name|fileName)\b)/, /\b(?:includes|startsWith|endsWith|indexOf)\s*\(\s*['"`]\.\.[/\\]{0,2}['"`]|[!=]==?\s*['"`]\.\.['"`]|['"`]\.\.['"`]\s*[!=]==?|\\\.\\\./, + /^\s*(?:exec|execSync)(?:\s+as\s+[\w$]+|\s*:\s*[\w$]+)?\s*,?\s*$/, /(? t.some(x => words.includes(x)) || MONEY_PAIRS.some(([w, beside]) => t.includes(w) && t.some(x => beside.includes(x))); @@ -78,7 +91,7 @@ const namesMoney = (t: readonly string[], words: readonly string[]) => t.some(x export const RISK_RULES = createHash('sha256').update(JSON.stringify({ RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS, MONEY_LINE_TOKENS, MONEY_PAIRS, - patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, SECURITY_TEXT: [SECURITY_TEXT], DATA_LINE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]), + patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, SECURITY_TEXT: [IMPORT_OPEN, FROM_CHILD_PROCESS, EXEC_NAME], DATA_LINE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]), })).digest('hex').slice(0, 16); export const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); @@ -104,7 +117,7 @@ export function matchRisk(i: RiskInput): Map { if (t.some(x => SECURITY_PATH_TOKENS.includes(x))) hit('security', 'security path: ' + p); if (t.some(x => DATA_PATH_TOKENS.includes(x))) hit('data', 'data path: ' + p); for (const side of [i.added?.[p], i.removed?.[p]]) { - if (!why.has('security') && side?.length && [...side.join('\n').matchAll(SECURITY_TEXT)].some(m => EXEC_NAME.test(m[1]!.trim()) && !/^\s*type\b/.test(m[1]!))) hit('security', 'security import: ' + p); + if (!why.has('security') && side?.length && execImport(side.join('\n'))) hit('security', 'security import: ' + p); } for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) { if (!why.has('money') && (MONEY_LINE.some(r => r.test(line)) || namesMoney(tokens(line), MONEY_LINE_TOKENS))) hit('money', 'money line: ' + p); diff --git a/test/review-allowlist.test.ts b/test/review-allowlist.test.ts index ef7bb5436..f7a3be3a7 100644 --- a/test/review-allowlist.test.ts +++ b/test/review-allowlist.test.ts @@ -34,6 +34,11 @@ describe('linear time on hostile input', () => { expect(fast(() => localeTextOnly(p, [line], [line]))).toBeLessThan(100); } }); + test('a 512 KB comment of unclosed imports and a long path.join chain finish under 200 ms', () => { + const lines = Array.from({ length: 65_536 }, () => 'import {'); + expect(fast(() => matchRisk({ codePaths: ['src/a.ts'], added: { 'src/a.ts': lines }, removed: {}, scopeAuth: false }))).toBeLessThan(200); + expect(fast(() => matchRisk({ codePaths: ['src/a.ts'], added: { 'src/a.ts': ['path.join('.repeat(50_000)] }, removed: {}, scopeAuth: false }))).toBeLessThan(200); + }); test('the risk patterns finish under 100 ms on 10k-character lines', () => { for (const body of ['fetch(' + "'".repeat(N), 'import {' + ' exec'.repeat(N / 5), 'path.join(' + 'a'.repeat(N), 'import ' + 'a '.repeat(N / 2) + 'from', 'require(' + ' '.repeat(N), ' '.repeat(N) + '} = require(', "'..".repeat(N / 3), 'x'.repeat(N) + 'Usage', 'tar ' + '-'.repeat(N), 'import {'.repeat(N / 8)]) { @@ -46,7 +51,7 @@ describe('the rules hashes cover every new pattern', () => { // Value: protects=a receipt or queue verdict notices an allowlist or risk-pattern edit; fails_when=an edit to an SVG element, // a locale format, a security pattern or a money word leaves DEPTH_RULES unchanged; seam=DEPTH_RULES over ALLOWLIST_RULES and RISK_RULES test.each([ - ['review-allowlist.ts', "'foreignObject'", "'clipPath', 'foreignObject'", "'clipPath'"], + ['review-allowlist.ts', "'foreignObject'", "'clipPath', 'foreignObject',", "'clipPath',"], ['review-allowlist.ts', "'properties'];", "'properties', 'ini'];", null], ['review-risk.ts', "'billable', 'billed'", "'billable', 'billed', 'tariff'", null], ['review-risk.ts', 'extract-zip|unzipper', 'extract-zip|unzipper|jszip', null], diff --git a/test/review-risk-routing.test.ts b/test/review-risk-routing.test.ts index 02ce08e3a..b72f6eed2 100644 --- a/test/review-risk-routing.test.ts +++ b/test/review-risk-routing.test.ts @@ -158,7 +158,7 @@ describe('GST-850: allowlisted docs paths (a new presentational SVG, a locale va ['strings', 'Resources/lang/en.lproj/Localizable.strings', '"save" = "Save";', '"save" = "Save now";'], ['xliff', 'translations/messages.fr.xlf', ' Enregistrer', ' Sauvegarder'], ['ftl', 'locales/en-US/main.ftl', 'welcome = Welcome, { $user }!', 'welcome = Hello, { $user }!'], - ['properties', 'src/main/resources/i18n/messages.properties', 'button.save=Save', 'button.save=Save now'], + ['properties', 'src/main/resources/i18n/messages_en.properties', 'button.save=Save', 'button.save=Save now'], ])('a %s value edit under a locale directory is docs', (_n, p, before, after) => { expect(classOf(p, [after], [before])).toBe('docs'); }); @@ -198,6 +198,10 @@ describe('GST-850: every bypass of the line-pattern docs paths stays code', () = ['an unclosed root', CHART.slice(0, -2)], ['two roots', [...CHART, '']], ['text after the root', [...CHART, 'trailing']], + ['an HTML-closed empty comment ', swap('', '<!--><image width="1" height="1"/>--><title>')], + ['an HTML-closed empty comment <!--->', swap('<title>', '<!---><foreignObject/>--><title>')], + ['numeric references spelling an interpolation', swap('33/41 & up', '{{run()}}')], + ['an attribute interpolation', swap('<text x="30"', '<text class="{{run()}}" x="30"')], ])('a new SVG with %s is code', (_n, lines) => { expect(svgClass(lines as string[])).toBe('code'); }); @@ -220,11 +224,21 @@ describe('GST-850: every bypass of the line-pattern docs paths stays code', () = ['a script URL', 'locales/en.yml', ' link: "https://example.com"', ' link: "javascript:alert(1)"'], ['a locale file outside a locale directory', 'src/en.json', ' "save": "Save",', ' "save": "Save now",'], ['a locale file under config/', 'config/locales/en.yml', ' save: "Save"', ' save: "Save now"'], + ['an escaped script scheme', 'locales/en.json', ' "go": "Continue",', ' "go": "java\\nscript:alert(1)",'], + ['a protocol-relative link', 'locales/en.json', ' "help": "[docs](/help)",', ' "help": "[docs](//evil.example/help)",'], + ['a YAML null', 'locales/en.yml', ' label: Save', ' label: null'], + ['a YAML boolean', 'locales/en.yml', ' label: Save', ' label: false'], + ['a renamed gettext placeholder', 'locale/fr/LC_MESSAGES/app.po', 'msgstr "Bonjour %(name)s"', 'msgstr "Bonjour %(password)s"'], + ['an Ansible task under a locale role', 'roles/locale/tasks/main.yml', ' command: a-tool one', ' command: b-tool two'], + ['a non-message .properties under lang/', 'src/main/resources/lang/app.properties', 'db.url=host-a', 'db.url=host-b'], + ['a non-locale JSON under lang/', 'tools/lang/rules.json', ' "pattern": "^[a-z]+$",', ' "pattern": ".*",'], + ['a locale value carrying a risk line', 'src/i18n/en.yml', ' command: echo ok', ' command: rm -rf /tmp/x'], ])('%s is code', (_n, p, before, after) => { expect(classOf(p, [after], [before])).toBe('code'); }); test('an added key and the backtracking input are code, and the input finishes fast', () => { expect(classOf('locales/en.json', [' "extra": "Extra",'])).toBe('code'); + expect(classOf('src/i18n/en.yml', [' title: Hello', ' sub: World'], [' title: Hello', ' sub: World'])).toBe('code'); const t = performance.now(); expect(classOf('locales/en.yml', [' save: "' + '\\'.repeat(10_000) + 'x'], [' save: "Save"'])).toBe('code'); expect(classOf('locales/en.yml', [' save: "' + '\\'.repeat(9_999) + '"'], [' save: "Save"'])).toBe('code'); @@ -246,7 +260,7 @@ describe('GST-850: security reaches outbound URLs, archives, path traversal and "const ALLOWED_HOSTS = ['api.example.com'];", 'hostAllowlist.push(h);', 'await tar.x({ file, cwd: dest });', 'new AdmZip(buf).extractAllTo(dir, true);', "import yauzl from 'yauzl';", "import extract from 'extract-zip';", 'zipfile.ZipFile(p).extractall(d)', 'tarfile.open(p).extractall(d)', 'tar -xzf "$TGZ" -C "$DIR"', 'unzip -o "$ZIP" -d "$DIR"', 'fs.writeFileSync(path.join(root, entry.path), data);', 'const dest = path.resolve(out, req.params.name);', - "if (rel.startsWith('..')) throw new Error('escape');", "if (parts.includes('..')) return;", + "if (rel.startsWith('..')) throw new Error('escape');", "if (parts.includes('..')) return;", ' exec as run,', ' exec: run,', ])('%s is security', line => { expect(risk(['src/net.ts'], { 'src/net.ts': [line] })).toEqual(['security']); });