GST-850: round-1 review fixes (locale admission, SVG comments and interpolation, escapes, YAML types, linear import scan)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-02 00:38:59 +00:00
1 parent e664d8f1d9
commit 1a62151a2c
6 files changed
+92 -37

No files matched your search

+21 -12
View File
@@ -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
+28 -16
View File
@@ -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)) {
const end = text.indexOf('-->', 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+state="[a-z-]+")?>)([^<]*)(<\/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);
+3 -1
View File
@@ -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;
};
+18 -5
View File
@@ -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*$/,
/(?<![.\w])eval\s*\(/, /(?:^|[;&|])\s*eval\s+["'$]/, /\bnew\s+Function\s*\(/,
/\b(?:spawn|spawnSync|execFile|execFileSync)\s*\(\s*['"](?:\/bin\/)?(?:ba|z)?sh['"]/, /\bBun\.spawn(?:Sync)?\s*\(\s*\[\s*['"](?:\/bin\/)?(?:ba|z)?sh['"]/,
/\brmSync\s*\([^)]*recursive\s*:\s*true/, /\brm\s+-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)\b/,
@@ -68,8 +69,20 @@ const DATA_LINE = [
/\.(?:deleteMany|destroyAll|bulkDelete)\s*\(/, /\b(?:destroy_all|delete_all)\b/,
];
/** A named import split over lines (`import {\n exec,\n} from 'node:child_process'`): read over a path's joined added or deleted lines. */
const SECURITY_TEXT = new RegExp(String.raw`\bimport\s*\{([^}]*)\}\s*from\s*${CHILD_PROCESS}`, 'g');
/** A named import split over lines (`import {\n exec,\n} from 'node:child_process'`), read over a path's joined added or deleted lines in one forward pass. */
function execImport(text: string): boolean {
IMPORT_OPEN.lastIndex = 0;
for (let m = IMPORT_OPEN.exec(text); m; m = IMPORT_OPEN.exec(text)) {
const close = text.indexOf('}', IMPORT_OPEN.lastIndex);
if (close < 0) return false;
const names = text.slice(IMPORT_OPEN.lastIndex, close).trim();
if (EXEC_NAME.test(names) && !/^type\b/.test(names) && FROM_CHILD_PROCESS.test(text.slice(close + 1, close + 40))) return true;
IMPORT_OPEN.lastIndex = close + 1;
}
return false;
}
const IMPORT_OPEN = /\bimport\s*\{/g;
const FROM_CHILD_PROCESS = new RegExp(String.raw`^\s*from\s*${CHILD_PROCESS}`);
const EXEC_NAME = /(?:^|[\s,{])(?:exec|execSync|spawn)(?:\s+as\s+[\w$]+)?\s*(?:,|$)/;
/** Whether a path or line's tokens name money: a metering or charge word (a cost word too, for a path), or a pair. */
const namesMoney = (t: readonly string[], words: readonly string[]) => 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<RiskClass, string> {
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);
+6 -1
View File
@@ -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],
+16 -2
View File
@@ -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', ' <target>Enregistrer</target>', ' <target>Sauvegarder</target>'],
['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, '<svg xmlns="http://www.w3.org/2000/svg"></svg>']],
['text after the root', [...CHART, 'trailing']],
['an HTML-closed empty comment <!-->', swap('<title>', '<!--><image width="1" height="1"/>--><title>')],
['an HTML-closed empty comment <!--->', swap('<title>', '<!---><foreignObject/>--><title>')],
['numeric references spelling an interpolation', swap('33/41 &amp; up', '&#123;&#123;run()&#125;&#125;')],
['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']);
});