From 74dd2f38ccc1adcb4c7cd7fb825f14121b0d96b2 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:24:33 -0400 Subject: [PATCH] fix: harden release issue parsing (#1536) Hardens release issue parsing against a quadratic/ReDoS pattern while preserving parsing semantics. --- .github/workflows/dev-release.yml | 4 +- .github/workflows/label-pending-release.yml | 2 +- .../stable-release-issue-cleanup-lib.mjs | 62 +++++++++++++++---- .../stable-release-issue-cleanup.test.mjs | 5 ++ 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index afb7b699..5fd40c2a 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -139,7 +139,7 @@ jobs: COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true) ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \ - perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ + perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*+(?:,|and)?\s*+#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ grep -oE '#[0-9]+' || true) PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \ @@ -155,7 +155,7 @@ jobs: done ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \ - perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ + perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*+(?:,|and)?\s*+#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ grep -oE '#[0-9]+' || true) ISSUES=$(printf '%s\n%s\n' "$ISSUES_FROM_COMMITS" "$ISSUES_FROM_PRS" | \ diff --git a/.github/workflows/label-pending-release.yml b/.github/workflows/label-pending-release.yml index 3b10a329..3a71a132 100644 --- a/.github/workflows/label-pending-release.yml +++ b/.github/workflows/label-pending-release.yml @@ -38,7 +38,7 @@ jobs: $PR_BODY $COMMIT_TEXT" PR_ISSUES=$(printf '%s\n' "$PR_TEXT" | \ - perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ + perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*+(?:,|and)?\s*+#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ grep -oE '#[0-9]+' || true) if [[ -n "$PR_ISSUES" ]]; then ALL_REFERENCED_ISSUES=$(printf '%s\n%s\n' "$ALL_REFERENCED_ISSUES" "$PR_ISSUES") diff --git a/scripts/github/stable-release-issue-cleanup-lib.mjs b/scripts/github/stable-release-issue-cleanup-lib.mjs index 959b2e35..cb1a0b90 100644 --- a/scripts/github/stable-release-issue-cleanup-lib.mjs +++ b/scripts/github/stable-release-issue-cleanup-lib.mjs @@ -1,32 +1,72 @@ import { readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; -const ISSUE_REF_PATTERN = /#([0-9]+)/g; -const ACTION_VERB_PATTERN = - /\b(?:fixes|closes|resolves|refs?)\b\s+(#\d+\b(?:\s*(?:,|and)?\s*#\d+\b)*)/gi; -const RESOLVE_VERB_PATTERN = - /\b(?:fixes|closes|resolves)\b\s+(#\d+\b(?:\s*(?:,|and)?\s*#\d+\b)*)/gi; +const ACTION_VERB_PATTERN = /\b(?:fixes|closes|resolves|refs?)\b\s+/gi; +const RESOLVE_VERB_PATTERN = /\b(?:fixes|closes|resolves)\b\s+/gi; const PR_REF_PATTERN = /(?:Merge pull request #|\(#)([0-9]+)/g; const STABLE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+$/; export function extractIssueNumbers(text, { includeRefs = true } = {}) { const pattern = includeRefs ? ACTION_VERB_PATTERN : RESOLVE_VERB_PATTERN; + const source = text || ''; const issues = new Set(); let actionMatch; pattern.lastIndex = 0; - while ((actionMatch = pattern.exec(text || '')) !== null) { - const tail = actionMatch[1] || ''; - let issueMatch; - ISSUE_REF_PATTERN.lastIndex = 0; - while ((issueMatch = ISSUE_REF_PATTERN.exec(tail)) !== null) { - issues.add(Number(issueMatch[1])); + while ((actionMatch = pattern.exec(source)) !== null) { + let cursor = actionMatch.index + actionMatch[0].length; + + while (source[cursor] === '#') { + const numberStart = cursor + 1; + let numberEnd = numberStart; + while (numberEnd < source.length && isAsciiDigit(source[numberEnd])) { + numberEnd += 1; + } + + if (numberEnd === numberStart || isAsciiWord(source[numberEnd] || '')) break; + issues.add(Number(source.slice(numberStart, numberEnd))); + cursor = numberEnd; + + cursor = skipWhitespace(source, cursor); + if (source[cursor] === ',') { + cursor = skipWhitespace(source, cursor + 1); + } else if (isAndSeparator(source, cursor)) { + cursor = skipWhitespace(source, cursor + 3); + } } } return [...issues].sort((a, b) => a - b); } +function skipWhitespace(text, cursor) { + while (cursor < text.length && /\s/.test(text[cursor])) { + cursor += 1; + } + return cursor; +} + +function isAndSeparator(text, cursor) { + return ( + text.slice(cursor, cursor + 3).toLowerCase() === 'and' && + !isAsciiWord(text[cursor - 1] || '') && + !isAsciiWord(text[cursor + 3] || '') + ); +} + +function isAsciiDigit(value) { + return value >= '0' && value <= '9'; +} + +function isAsciiWord(value) { + return ( + (value >= '0' && value <= '9') || + (value >= 'A' && value <= 'Z') || + (value >= 'a' && value <= 'z') || + value === '_' + ); +} + export function extractPrNumbers(text) { const prs = new Set(); let match; diff --git a/tests/unit/github/stable-release-issue-cleanup.test.mjs b/tests/unit/github/stable-release-issue-cleanup.test.mjs index de97747d..ed422afd 100644 --- a/tests/unit/github/stable-release-issue-cleanup.test.mjs +++ b/tests/unit/github/stable-release-issue-cleanup.test.mjs @@ -30,6 +30,11 @@ describe('stable release issue cleanup', () => { expect(extractIssueNumbers(text, { includeRefs: false })).toEqual([12, 13, 14, 15]); }); + it('handles long unmatched whitespace after an issue reference in linear time', () => { + const text = `Fixes #1${' '.repeat(50_000)}X`; + expect(extractIssueNumbers(text, { includeRefs: true })).toEqual([1]); + }); + it('extracts PR numbers from merge and squash commit subjects', () => { const text = [ 'Merge pull request #1392 from kaitranntt/kai/fix/foo',