mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
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:
1 parent
e664d8f1d9
commit
1a62151a2c
6 files changed
+92
-37
No files matched your search
+21
-12
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 & 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']);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user