-
Notifications
You must be signed in to change notification settings - Fork 470
Handle workflow_dispatch PR targeting in deterministic safe-output sample replay #48398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -248,16 +248,30 @@ | |
| if (ref) return ref; | ||
| } | ||
|
|
||
| // 3. Explicit pull_request_number on the sample arguments. | ||
| const argNumber = Number(entry.arguments.pull_request_number); | ||
| if (Number.isFinite(argNumber) && argNumber > 0) { | ||
| const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber: argNumber }); | ||
| // 3. PR number from sample arguments, workflow_dispatch inputs, or config target. | ||
| const pullNumber = | ||
| toPositivePullRequestNumber(entry.arguments.pull_request_number) || | ||
| toPositivePullRequestNumber(payload?.inputs?.pull_request_number) || | ||
| toPositivePullRequestNumber(payload?.client_payload?.pull_request_number) || | ||
| readConfiguredTargetPullRequestNumber(entry.tool); | ||
| if (pullNumber) { | ||
| const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber }); | ||
| if (ref) return ref; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Convert unknown value to a positive pull request number, or null. | ||
| * @param {any} value | ||
| * @returns {number|null} | ||
| */ | ||
| function toPositivePullRequestNumber(value) { | ||
| const n = Number(value); | ||
| return Number.isFinite(n) && n > 0 ? n : null; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Suggested fixfunction toPositivePullRequestNumber(value) {
const n = Number(value);
return Number.isFinite(n) && Number.isInteger(n) && n > 0 ? n : null;
}Rejects |
||
| } | ||
|
|
||
| /** | ||
| * Read the configured `target-repo` for a given safe-output tool from the | ||
| * safe-outputs config file (GH_AW_SAFE_OUTPUTS_CONFIG_PATH). Returns an empty | ||
|
|
@@ -291,6 +305,44 @@ | |
| return ""; | ||
| } | ||
|
|
||
| /** | ||
| * Read configured `target` for a safe-output tool and coerce it into a PR number. | ||
| * Supports plain numeric values and `${ENV_VAR}` placeholders. | ||
| * @param {string} tool | ||
| * @returns {number|null} | ||
| */ | ||
| function readConfiguredTargetPullRequestNumber(tool) { | ||
| const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH; | ||
| if (!configPath || !configPath.trim()) { | ||
| return null; | ||
| } | ||
|
|
||
| const toolKey = typeof tool === "string" ? tool.replace(/-/g, "_") : ""; | ||
|
|
||
| try { | ||
| const raw = fs.readFileSync(configPath, "utf8"); | ||
| const parsed = JSON.parse(raw); | ||
| const config = parsed && typeof parsed === "object" ? Object.fromEntries(Object.entries(parsed).map(([k, v]) => [String(k).replace(/-/g, "_"), v])) : {}; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] Duplicated config-parsing logic — 💡 Suggested refactorExtract a shared helper: function parseNormalisedConfig(configPath) {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return {};
return Object.fromEntries(
Object.entries(parsed).map(([k, v]) => [String(k).replace(/-/g, "_"), v])
);
}Both readers call @copilot please address this. |
||
| const toolConfig = toolKey && config && typeof config === "object" ? config[toolKey] : null; | ||
| const target = toolConfig && typeof toolConfig === "object" ? toolConfig.target : null; | ||
|
|
||
| if (typeof target === "number") { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Config file is re-read and re-parsed on every call: 💡 Suggested fixCache the parsed result at module scope, similar to let _configCache = undefined; // undefined = not yet loaded; null = failed/missing
function _loadConfig() {
if (_configCache !== undefined) return _configCache;
const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
if (!configPath?.trim()) return (_configCache = null);
try {
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
_configCache = parsed && typeof parsed === "object" ? parsed : null;
} catch {
_configCache = null;
}
return _configCache;
}Then |
||
| return toPositivePullRequestNumber(target); | ||
| } | ||
| if (typeof target === "string") { | ||
| const trimmed = target.trim(); | ||
| const envMatch = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(trimmed); | ||
| if (envMatch) { | ||
| return toPositivePullRequestNumber(process.env[envMatch[1]]); | ||
|
Comment on lines
+334
to
+336
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] 💡 SuggestionAdd a if (envMatch) {
const resolved = toPositivePullRequestNumber(process.env[envMatch[1]]);
if (!resolved) {
core.debug(`apply_samples: env var ${envMatch[1]} did not resolve to a PR number (value: ${process.env[envMatch[1]]})`);
}
return resolved;
}This makes misconfiguration visible in the Actions debug log without changing behaviour. @copilot please address this. |
||
| } | ||
| return toPositivePullRequestNumber(trimmed); | ||
| } | ||
| } catch (err) { | ||
| core.debug(`apply_samples: could not read target from ${configPath}: ${getErrorMessage(err)}`); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the on-disk working directory in which a sample's patch should be | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Config parse errors are silently swallowed at 💡 Suggested fixEmit at least } catch (err) {
core.warning(`apply_samples: could not read target from ${configPath}: ${getErrorMessage(err)}`);
}Keep |
||
| * staged (branch created + patch committed). | ||
|
|
@@ -659,7 +711,7 @@ | |
|
|
||
| if (require.main === module) { | ||
| main().catch(err => { | ||
| core.setFailed(err && err.stack ? err.stack : String(err)); | ||
|
Check warning on line 714 in actions/setup/js/apply_samples.cjs
|
||
| }); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -504,6 +504,51 @@ describe("apply_samples.cjs preStagePatch (create_pull_request / push_to_pull_re | |
| } | ||
| }); | ||
|
|
||
| it("derives push_to_pull_request_branch branch from workflow_dispatch inputs.pull_request_number", async () => { | ||
| const workspace = makeTempDir("gh-aw-prestage-push-dispatch-input-"); | ||
| initRepo(workspace, "main"); | ||
|
|
||
| const headRef = "feat/dispatch-pr-number"; | ||
| const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ | ||
| ok: true, | ||
| status: 200, | ||
| json: async () => ({ head: { ref: headRef } }), | ||
| }); | ||
|
|
||
| const eventPath = path.join(workspace, "event.json"); | ||
| fs.writeFileSync( | ||
| eventPath, | ||
| JSON.stringify({ | ||
| inputs: { pull_request_number: "123" }, | ||
| }) | ||
| ); | ||
|
|
||
| const prevBase = process.env.GH_AW_CUSTOM_BASE_BRANCH; | ||
| const prevEvent = process.env.GITHUB_EVENT_PATH; | ||
| const prevRepo = process.env.GITHUB_REPOSITORY; | ||
| process.env.GH_AW_CUSTOM_BASE_BRANCH = "main"; | ||
| process.env.GITHUB_EVENT_PATH = eventPath; | ||
| process.env.GITHUB_REPOSITORY = "owner/repo"; | ||
| try { | ||
| const entry = { | ||
| tool: "push_to_pull_request_branch", | ||
| arguments: { message: "Push update" }, | ||
| sidecars: { patch: newFileDiff("dispatch-input.txt", "via workflow_dispatch input\n") }, | ||
| }; | ||
| await preStagePatch(entry, 0, workspace); | ||
| expect(git(["rev-parse", "--abbrev-ref", "HEAD"], workspace).trim()).toBe(headRef); | ||
| expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("/repos/owner/repo/pulls/123"), expect.anything()); | ||
| } finally { | ||
| fetchSpy.mockRestore(); | ||
| if (prevBase === undefined) delete process.env.GH_AW_CUSTOM_BASE_BRANCH; | ||
| else process.env.GH_AW_CUSTOM_BASE_BRANCH = prevBase; | ||
| if (prevEvent === undefined) delete process.env.GITHUB_EVENT_PATH; | ||
| else process.env.GITHUB_EVENT_PATH = prevEvent; | ||
| if (prevRepo === undefined) delete process.env.GITHUB_REPOSITORY; | ||
| else process.env.GITHUB_REPOSITORY = prevRepo; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The test covers only the happy path (valid numeric string 💡 Missing coverage to consider
These are the exact edge cases where the original bug could silently regress. @copilot please address this. |
||
| } | ||
| }); | ||
|
|
||
| it("derives push_to_pull_request_branch branch from explicit arguments.pull_request_number when no event payload exists", async () => { | ||
| // Resolution path 3: no GITHUB_EVENT_PATH, but the sample entry carries an | ||
| // explicit `arguments.pull_request_number`. The driver should hit the PR | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
client_payload.pull_request_numberis user-controlled and accepted without authorization checks:repository_dispatchevents let any actor with write access (or a workflow that forwards external input) setclient_payloadfreely. Accepting it here as a trusted PR number source can steer the replay to a different PR branch than intended.💡 Details
payload?.client_payload?.pull_request_numberis on the same priority level as the explicit sample arguments andworkflow_dispatchinputs. Unlikeworkflow_dispatch.inputs,client_payloadis entirely caller-controlled and there is no structural guarantee it comes from a trusted workflow step. If an attacker can send arepository_dispatch, they could redirect sample replay to an arbitrary PR head ref.Consider documenting the trust boundary clearly, or move the
client_payloadfallback behind the config-target fallback so it is the last resort — or require an explicit opt-in flag before trusting it.