diff --git a/cdk/src/handlers/shared/orchestration-comment-trigger.ts b/cdk/src/handlers/shared/orchestration-comment-trigger.ts index d643ecaf..2730156e 100644 --- a/cdk/src/handlers/shared/orchestration-comment-trigger.ts +++ b/cdk/src/handlers/shared/orchestration-comment-trigger.ts @@ -213,12 +213,28 @@ export function buildIntegrationIterationInstruction(trigger: CommentTrigger): s */ const MAX_COMMAND_WORDS = 6; -/** Word/phrase boundary match: the phrase appears as whole words in ``text``. */ +/** True when ``c`` is one of the [a-z0-9] chars that count as "inside a word". */ +function isAlnum(c: string | undefined): boolean { + return c !== undefined && ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')); +} + +/** Word/phrase boundary match: the phrase appears as whole words in ``text``. + * + * Plain substring scan rather than a phrase-interpolated ``new RegExp`` — a + * dynamically built pattern would need metachar escaping to stay correct and + * would make the phrase list a regex-injection / ReDoS surface if it ever grew + * a non-literal member. Comparing chars directly cannot misparse its input. + */ function hasPhrase(text: string, phrase: string): boolean { - // Escape regex metachars (e.g. "re-run"); match on non-word boundaries so - // "retry" doesn't fire inside a longer word. - const esc = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`(^|[^a-z0-9])${esc}([^a-z0-9]|$)`, 'i').test(text); + const hay = text.toLowerCase(); + const needle = phrase.toLowerCase(); + if (!needle) return false; + // Accept only occurrences flanked by a non-[a-z0-9] char (or a string edge), + // so "retry" doesn't fire inside a longer word like "retryx" / "abcretry". + for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + 1)) { + if (!isAlnum(hay[i - 1]) && !isAlnum(hay[i + needle.length])) return true; + } + return false; } /** diff --git a/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts b/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts index f48e6489..5bd939f5 100644 --- a/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts +++ b/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts @@ -204,6 +204,26 @@ describe('parseRetryIntent — recognise a plain "retry" command', () => { expect(parseRetryIntent('**retry**')).toBe(true); }); + test('a retry phrase EMBEDDED in a longer word does not fire', () => { + // The phrase match is whole-word: only a non-[a-z0-9] char (or a string + // edge) may flank it. Guards the boundary contract that the substring scan + // in hasPhrase upholds (it replaced a phrase-interpolated `new RegExp`). + for (const s of ['retryx', 'abcretry', 'xrerun', 'rerunner', 'retry9', '9retry']) { + expect(parseRetryIntent(s)).toBe(false); + } + }); + + test('phrases containing regex metacharacters match literally, not as patterns', () => { + // "re-run" is a RETRY_PHRASES member containing '-'. A phrase must never be + // interpreted as a pattern: "re.run"/"reXrun" must NOT match "re-run", and a + // user typing regex syntax must not have it evaluated. + expect(parseRetryIntent('re-run')).toBe(true); + expect(parseRetryIntent('re.run')).toBe(false); + expect(parseRetryIntent('reXrun')).toBe(false); + expect(parseRetryIntent('.*')).toBe(false); + expect(parseRetryIntent('(retr)y')).toBe(false); + }); + test('KNOWN_EPIC_COMMANDS lists retry (kept in sync with the parser + panel copy)', () => { expect(KNOWN_EPIC_COMMANDS).toContain('retry'); }); diff --git a/scripts/linear_epic.py b/scripts/linear_epic.py index ea70bb87..90645280 100644 --- a/scripts/linear_epic.py +++ b/scripts/linear_epic.py @@ -32,8 +32,8 @@ import json import os import sys -import urllib.request import urllib.error +import urllib.request LINEAR_URL = "https://api.linear.app/graphql" @@ -79,6 +79,25 @@ def pat(): return p +def https_opener(): + """An opener that can speak ONLY https. + + ``urllib.request.urlopen`` (and ``build_opener``) install the default + handler set, which includes ``file://``, ``ftp://`` and ``data://``. That + turns any future mistake which lets a URL be configured — an env var, a CLI + flag, an API-returned redirect target — into an arbitrary-file read, since + this function carries the Linear PAT. Building the director by hand keeps + HTTPS as the only reachable scheme, so no such mistake can escalate. + ``HTTPErrorProcessor``/``HTTPDefaultErrorHandler`` preserve the usual + raise-on-4xx/5xx behaviour the caller below relies on. + """ + opener = urllib.request.OpenerDirector() + opener.add_handler(urllib.request.HTTPSHandler()) + opener.add_handler(urllib.request.HTTPErrorProcessor()) + opener.add_handler(urllib.request.HTTPDefaultErrorHandler()) + return opener + + def gql(query, variables=None): body = json.dumps({"query": query, "variables": variables or {}}).encode() req = urllib.request.Request( @@ -86,7 +105,7 @@ def gql(query, variables=None): headers={"Authorization": pat(), "Content-Type": "application/json"}, ) try: - with urllib.request.urlopen(req, timeout=30) as r: + with https_opener().open(req, timeout=30) as r: out = json.load(r) except urllib.error.HTTPError as e: sys.exit(f"HTTP {e.code}: {e.read().decode()[:400]}")