Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/scripts/pr-carry-attribution.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,23 @@ const TRAILER_RE = /^[ \t]*co-authored-by:[ \t]*(.+)$/gim;

const FENCED_CODE_RE = /^[ \t]*(\u0060{3,}|~{3,})[\s\S]*?^[ \t]*\1[ \t]*$/gm;
const INLINE_CODE_RE = /\u0060[^\u0060\n]*\u0060/g;
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
/**
* HTML comments, which GitHub never renders.
*
* The `(?:-->|$)` alternative is load-bearing and matches `pr-quality.cjs`: an
* UNCLOSED comment runs to the end of the text, because that is what GitHub
* does with it. Without the alternative, `<!--` with no terminator matched
* nothing, so everything after it stayed in the scanned text while GitHub
* rendered none of it — an author could write a carry claim that the gate reads
* and no human ever sees, or bury one the gate misses in text that renders.
* Either direction is a divergence between what is enforced and what is shown.
*
* CodeQL flagged the same shape as `js/incomplete-multi-character-sanitization`
* on #3342. The alert's own framing (HTML element injection) does not apply —
* this output is matched by regex, never rendered — but the underlying
* observation, that the strip is incomplete, is correct for this gate's purpose.
*/
const HTML_COMMENT_RE = /<!--[\s\S]*?(?:-->|$)/g;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Protect inline code before stripping unclosed comments

When a PR body contains a literal <!-- inside inline code before a visible carry declaration—for example, The token \<!--` starts a comment. Supersedes #2797.—this new end-of-input alternative treats the code-span content as an unclosed HTML comment and removes the declaration before referencedCarryNumbers` scans it. GitHub renders the opener literally inside the code span, so the missing-attribution gate incorrectly passes and can allow the contributor credit to be lost; protect/remove inline code before comment stripping, or use a Markdown-aware scanner, and add this case to the regression tests.

AGENTS.md reference: AGENTS.md:L266-L276

Useful? React with 👍 / 👎.


/**
* Carry language inside a fenced block, an inline span, or an HTML comment is
Expand Down
29 changes: 29 additions & 0 deletions .github/scripts/pr-carry-attribution.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,35 @@ describe("assessCarryAttribution", () => {
);
});

it("ignores carry language after an unclosed HTML comment", () => {
// GitHub renders nothing after an unterminated `<!--`, so neither does the
// gate. The closing-delimiter-only pattern used to match nothing here and
// leave the whole tail in the scanned text, which is the divergence CodeQL
// flagged on #3342: enforced text and rendered text stopped agreeing.
assert.deepEqual(
assessCarryAttribution(
base({
body: ["This is an ordinary fix.", "", "<!-- supersedes #2797"].join("\n"),
}),
),
[],
);
});

it("still reads carry language that follows a CLOSED comment", () => {
// The guard above must not swallow the rest of the body wholesale: a
// properly closed comment ends at its own `-->`, and a real claim after it
// is still a claim.
assert.equal(
assessCarryAttribution(
base({
body: ["<!-- a note -->", "", "Supersedes #2797."].join("\n"),
}),
).length,
1,
);
});

it("passes an ordinary pull request with no carry language", () => {
assert.deepEqual(
assessCarryAttribution(base({ body: "Closes #2797." })),
Expand Down
7 changes: 4 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@
},
"overrides": {
"@hono/node-server": "2.1.0",
"fast-uri": "^3.1.5",
"fast-uri": "^3.1.7",
"hono": "4.13.1",
"ip-address": "^10.4.0"
"ip-address": "^10.4.0",
"qs": "^6.16.0"
},
"keywords": [
"codex",
Expand Down
56 changes: 42 additions & 14 deletions tests/strict-semver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,63 @@ import { parseStrictSemver } from "../src/lib/strict-semver";
*
* The length ceiling did not help. It only chose where on the curve the input landed.
*/
/**
* Every timing assertion here measures the BEST of several runs, not a single one.
*
* A first call carries one-time cost the parse itself does not: regex compilation, JIT
* warm-up, and whatever the shared CI runner was doing during that millisecond. On a
* loaded macOS runner that noise reached 53.77ms against a 50ms budget and failed a
* suite whose subject is three orders of magnitude away from the regression it guards
* (522ms). A gate that fires on runner weather rather than on the defect teaches
* everyone to re-run it, which is how a real ReDoS regression would get waved through.
*
* The minimum is the right statistic for this question. Superlinear backtracking is a
* property of the pattern, so it reproduces on EVERY iteration; scheduler noise does
* not. If the exponential path returns, no run is fast.
*
* That claim was measured rather than assumed. Running the semver.org prerelease
* pattern this module replaced against the same inputs, three runs each:
*
* reps=20 len=68 17.6ms 17.4ms 17.4ms
* reps=30 len=98 545.4ms 521.2ms 500.0ms
* reps=39 len=125 492.3ms 493.5ms 491.3ms
* reps=45 len=128 495.3ms 507.9ms 527.8ms
*
* The blowup is on every run, not the first, so a best-of-N below 50ms still fails
* loudly if it comes back. The spread across runs is under 10%, which is what a
* deterministic cost looks like next to the 4ms of scheduler jitter that broke the
* single-sample form.
*/
function fastestParseMs(input: string, runs = 5): number {
let best = Infinity;
for (let i = 0; i < runs; i++) {
const started = performance.now();
parseStrictSemver(input);
const elapsed = performance.now() - started;
if (elapsed < best) best = elapsed;
}
return best;
}

describe("parseStrictSemver ReDoS resistance", () => {
test("the flagged attack shape stays linear at the length ceiling", () => {
// "0.0.0-0." followed by repetitions of "--." is the input CodeQL named.
const attack = ("0.0.0-0." + "--.".repeat(45)).slice(0, 128);
expect(attack.length).toBe(128);

const started = performance.now();
expect(parseStrictSemver(attack)).toBeNull();
const elapsed = performance.now() - started;

// The vulnerable pattern took ~522ms for this input. Anything in that region means the
// superlinear path is back; a linear parse lands three orders of magnitude below it.
expect(elapsed).toBeLessThan(50);
expect(fastestParseMs(attack)).toBeLessThan(50);
});

test("cost does not grow with the number of repetitions", () => {
const measure = (reps: number): number => {
const input = ("0.0.0-0." + "--.".repeat(reps)).slice(0, 128);
const started = performance.now();
parseStrictSemver(input);
return performance.now() - started;
};
const inputFor = (reps: number): string => ("0.0.0-0." + "--.".repeat(reps)).slice(0, 128);

// Under the old pattern, going from 20 to 39 repetitions moved 16ms to 524ms.
measure(20);
const short = measure(20);
const long = measure(39);
expect(short).toBeLessThan(50);
expect(long).toBeLessThan(50);
expect(fastestParseMs(inputFor(20))).toBeLessThan(50);
expect(fastestParseMs(inputFor(39))).toBeLessThan(50);
});

test("the length guard still rejects before any matching work", () => {
Expand Down
Loading