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
5 changes: 3 additions & 2 deletions actions/setup/js/add_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const { createDiscussionComment, resolveTopLevelDiscussionCommentId } = require(
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { ERR_NOT_FOUND } = require("./error_codes.cjs");
const { isPayloadUserBot } = require("./resolve_mentions.cjs");
const { resolveMentionsForItem } = require("./resolve_mentions_from_payload.cjs");
const { getMentionsGithubClient, resolveMentionsForItem } = require("./resolve_mentions_from_payload.cjs");
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { resolveInvocationContext } = require("./invocation_context_helpers.cjs");
Expand Down Expand Up @@ -753,7 +753,8 @@ async function main(config = {}) {
if (itemTargetResult.number != null || hasExplicitCommentId) {
// Explicit item_number/issue_number: fetch the issue/PR to get its author
try {
const { data: issueData } = await githubClient.rest.issues.get({
const mentionsGithubClient = getMentionsGithubClient(githubClient);
const { data: issueData } = await mentionsGithubClient.rest.issues.get({
owner: repoParts.owner,
repo: repoParts.repo,
issue_number: itemNumber,
Expand Down
69 changes: 10 additions & 59 deletions actions/setup/js/collect_ndjson_output.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,17 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { repairJson, sanitizePrototypePollution } = require("./json_repair_helpers.cjs");
const { AGENT_OUTPUT_FILENAME, TMP_GH_AW_PATH } = require("./constants.cjs");
const { ERR_API, ERR_PARSE } = require("./error_codes.cjs");
const { isPayloadUserBot } = require("./resolve_mentions.cjs");
const { parseIntTemplatable } = require("./templatable.cjs");
const { getDefaultTargetRepo, parseAllowedRepos, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { isProbingNoopMessage } = require("./intent_probe.cjs");
const { buildEmptyOutputOutcome } = require("./empty_output_outcome.cjs");

const MENTION_AWARE_OUTPUT_TYPES = new Set(["add_comment", "close_discussion", "create_discussion", "create_issue", "create_pull_request", "create_pull_request_review_comment", "reply_to_pull_request_review_comment"]);

async function main() {
try {
const fs = require("fs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { validateItem, getMaxAllowedForType, getMinRequiredForType, hasValidationConfig, MAX_BODY_LENGTH: maxBodyLength, resetValidationConfigCache } = require("./safe_output_type_validator.cjs");
const { resolveAllowedMentionsFromPayload } = require("./resolve_mentions_from_payload.cjs");

// Load validation config from file and set it in environment for the validator to read
const validationConfigPath = process.env.GH_AW_VALIDATION_CONFIG_PATH || `${process.env.RUNNER_TEMP}/gh-aw/safeoutputs/validation.json`;
/** @type {any} */
Expand All @@ -38,8 +36,10 @@ async function main() {
const mentionsConfig = validationConfig?.mentions || null;
const maxMentions = parseIntTemplatable(mentionsConfig?.max, 50);

// Resolve mentions for each output's destination before sanitizing it.
// Mention filtering happens in the trusted safe_outputs job. Preserve mentions
// in these output types until their destination and allowlist can be resolved.
let allowedMentions = [];
let deferMentionFiltering = false;

// maxBotMentions is populated after safeOutputsConfig is read below
/** @type {number | undefined} */
Expand Down Expand Up @@ -68,7 +68,7 @@ async function main() {
error: `Line ${lineNum}: ${fieldName} must be a string`,
};
}
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxMentions, maxBotMentions, allowedAliasesSeen });
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxMentions, maxBotMentions, allowedAliasesSeen, deferMentions: deferMentionFiltering });
break;
case "boolean":
if (typeof value !== "boolean") {
Expand Down Expand Up @@ -99,11 +99,11 @@ async function main() {
error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`,
};
}
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxMentions, maxBotMentions, allowedAliasesSeen });
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxMentions, maxBotMentions, allowedAliasesSeen, deferMentions: deferMentionFiltering });
break;
default:
if (typeof value === "string") {
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxMentions, maxBotMentions, allowedAliasesSeen });
normalizedValue = sanitizeContent(value, { allowedAliases: allowedMentions, maxMentions, maxBotMentions, allowedAliasesSeen, deferMentions: deferMentionFiltering });
}
break;
}
Expand Down Expand Up @@ -224,46 +224,6 @@ async function main() {
// indentation/pretty-printing, parsing will fail.
const lines = outputContent.trim().split("\n");

function resolveMentionRepo(item, itemType) {
const typeConfig = expectedOutputTypes[itemType];
const defaultTargetRepo = getDefaultTargetRepo(typeConfig && typeof typeConfig === "object" ? typeConfig : undefined);
const allowedRepos = parseAllowedRepos(typeConfig?.allowed_repos ?? safeOutputsConfig?.allowed_repos);
return resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, "mention");
}

// Pre-scan: collect target issue authors from add_comment items with explicit item_number
// so they are included when sanitizing the corresponding comment.
const targetIssueAuthors = new Map();
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
try {
const preview = JSON.parse(trimmedLine);
const previewType = (preview?.type || "").replace(/-/g, "_");
if (previewType === "add_comment" && preview.item_number != null && typeof preview.item_number === "number") {
const repoResult = resolveMentionRepo(preview, "add_comment");
if (!repoResult.success) {
core.info(`[MENTIONS] Skipping target issue author lookup: ${repoResult.error}`);
continue;
}
try {
const { data: issueData } = await github.rest.issues.get({
owner: repoResult.repoParts.owner,
repo: repoResult.repoParts.repo,
issue_number: preview.item_number,
});
if (issueData.user?.login && !isPayloadUserBot(issueData.user)) {
targetIssueAuthors.set(`${repoResult.repo.toLowerCase()}#${preview.item_number}`, issueData.user.login);
}
} catch (fetchErr) {
core.info(`[MENTIONS] Could not fetch issue #${preview.item_number} author for mention allowlist: ${getErrorMessage(fetchErr)}`);
}
}
} catch {
// Ignore parse errors - main loop will report them
}
}

const parsedItems = [];
const errors = collectionErrors;
for (let i = 0; i < lines.length; i++) {
Expand All @@ -286,6 +246,7 @@ async function main() {
core.info(`[INGESTION] Line ${i + 1}: Original type='${originalType}', Normalized type='${itemType}'`);
// Update item.type to normalized value
item.type = itemType;
deferMentionFiltering = MENTION_AWARE_OUTPUT_TYPES.has(itemType);
if (!expectedOutputTypes[itemType]) {
core.warning(`[INGESTION] Line ${i + 1}: Type '${itemType}' not found in expected types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`);
errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`);
Expand All @@ -295,17 +256,6 @@ async function main() {
core.info(`[INGESTION] Line ${i + 1}: Ignoring probing noop message (does not count against the noop budget): ${JSON.stringify(item.message)}`);
continue;
}
const repoResult = resolveMentionRepo(item, itemType);
allowedMentions = repoResult.success
? await resolveAllowedMentionsFromPayload(
context,
github,
core,
mentionsConfig,
itemType === "add_comment" ? [targetIssueAuthors.get(`${repoResult.repo.toLowerCase()}#${item.item_number}`)].filter(Boolean) : undefined,
repoResult.repoParts
)
: [];
const typeCount = parsedItems.filter(existing => existing.type === itemType).length;
const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes);
if (typeCount >= maxAllowed) {
Expand Down Expand Up @@ -333,6 +283,7 @@ async function main() {
allowedAliases: allowedMentions,
maxMentions,
maxBotMentions,
deferMentions: deferMentionFiltering,
normalizeIssueClosingKeywords,
dataEnabled: typeConfig !== null && typeof typeConfig === "object" && typeConfig.data_enabled === true,
dataSchema: typeConfig !== null && typeof typeConfig === "object" ? typeConfig.data_schema : undefined,
Expand Down
31 changes: 16 additions & 15 deletions actions/setup/js/collect_ndjson_output.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1391,7 +1391,7 @@ describe("collect_ndjson_output.cjs", () => {
parsedOutput = JSON.parse(outputCall[1]);
expect(parsedOutput.items[0].body).toBe("GitHub URLs: https://github.com/repo, https://api.github.com/users, https://githubusercontent.com/file. External: (example.com/redacted)");
}),
it("should handle @mentions neutralization", async () => {
it("should defer mention filtering for trusted output processing", async () => {
const testFile = "/tmp/gh-aw/test-ndjson-output.txt",
ndjsonContent = '{"type": "create_issue", "title": "@mention Test", "body": "Hey @username and @org/team, check this out! But preserve email@domain.com"}';
(fs.writeFileSync(testFile, ndjsonContent), (process.env.GH_AW_SAFE_OUTPUTS = testFile));
Expand All @@ -1400,9 +1400,10 @@ describe("collect_ndjson_output.cjs", () => {
(fs.mkdirSync("/tmp/gh-aw/safeoutputs", { recursive: !0 }), fs.writeFileSync(configPath, __config), await eval(`(async () => { ${collectScript}; await main(); })()`));
const outputCall = mockCore.setOutput.mock.calls.find(call => "output" === call[0]),
parsedOutput = JSON.parse(outputCall[1]);
expect(parsedOutput.items[0].body).toBe("Hey `@username` and `@org/team`, check this out! But preserve email@domain.com");
expect(parsedOutput.items[0].body).toBe("Hey @username and @org/team, check this out! But preserve email@domain.com");
expect(global.github.rest.repos.listCollaborators).not.toHaveBeenCalled();
}),
it("checks collaborators in each comment's target repository, never the workflow repository", async () => {
it("does not query collaborators during untrusted ingestion", async () => {
global.context.payload.issue = { user: { login: "alice", type: "User" } };
const testFile = "/tmp/gh-aw/test-ndjson-output.txt";
fs.writeFileSync(testFile, [JSON.stringify({ type: "add_comment", repo: "target-org/first", body: "Hello @alice" }), JSON.stringify({ type: "add_comment", repo: "target-org/second", body: "Hello @alice" })].join("\n"));
Expand All @@ -1411,11 +1412,11 @@ describe("collect_ndjson_output.cjs", () => {

await eval(`(async () => { ${collectScript}; await main(); })()`);

expect(global.github.rest.repos.listCollaborators).toHaveBeenCalledWith(expect.objectContaining({ owner: "target-org", repo: "first" }));
expect(global.github.rest.repos.listCollaborators).toHaveBeenCalledWith(expect.objectContaining({ owner: "target-org", repo: "second" }));
expect(global.github.rest.repos.listCollaborators).not.toHaveBeenCalledWith(expect.objectContaining({ owner: "test-owner", repo: "test-repo" }));
expect(global.github.rest.repos.listCollaborators).not.toHaveBeenCalled();
const parsed = JSON.parse(mockCore.setOutput.mock.calls.find(call => call[0] === "output")[1]);
expect(parsed.items.map(item => item.body)).toEqual(["Hello @alice", "Hello @alice"]);
}),
it("keeps target issue authors scoped to their own repository and issue", async () => {
it("does not query target issue authors during untrusted ingestion", async () => {
const testFile = "/tmp/gh-aw/test-ndjson-output.txt";
fs.writeFileSync(
testFile,
Expand All @@ -1433,10 +1434,10 @@ describe("collect_ndjson_output.cjs", () => {
await eval(`(async () => { ${collectScript}; await main(); })()`);

const parsed = JSON.parse(mockCore.setOutput.mock.calls.find(call => call[0] === "output")[1]);
expect(parsed.items.map(item => item.body)).toEqual(["Hello @first-author", "Hello `@first-author`"]);
expect(global.github.rest.issues.get).toHaveBeenCalledWith(expect.objectContaining({ owner: "target-org", repo: "first", issue_number: 7 }));
expect(parsed.items.map(item => item.body)).toEqual(["Hello @first-author", "Hello @first-author"]);
expect(global.github.rest.issues.get).not.toHaveBeenCalled();
}),
it("looks up explicit issue authors in a configured target-repo", async () => {
it("does not look up explicit issue authors during untrusted ingestion", async () => {
const testFile = "/tmp/gh-aw/test-ndjson-output.txt";
fs.writeFileSync(testFile, JSON.stringify({ type: "add_comment", item_number: 7, body: "Hello @target-author" }));
process.env.GH_AW_SAFE_OUTPUTS = testFile;
Expand All @@ -1445,11 +1446,11 @@ describe("collect_ndjson_output.cjs", () => {

await eval(`(async () => { ${collectScript}; await main(); })()`);

expect(global.github.rest.issues.get).toHaveBeenCalledWith(expect.objectContaining({ owner: "target-org", repo: "target-repo", issue_number: 7 }));
expect(global.github.rest.issues.get).not.toHaveBeenCalled();
const parsed = JSON.parse(mockCore.setOutput.mock.calls.find(call => call[0] === "output")[1]);
expect(parsed.items[0].body).toBe("Hello @target-author");
}),
it("does not query either repository for a disallowed per-item override", async () => {
it("does not query repositories for per-item overrides during untrusted ingestion", async () => {
const testFile = "/tmp/gh-aw/test-ndjson-output.txt";
fs.writeFileSync(testFile, JSON.stringify({ type: "add_comment", repo: "unauthorized/repo", body: "Hello @alice" }));
process.env.GH_AW_SAFE_OUTPUTS = testFile;
Expand All @@ -1460,7 +1461,7 @@ describe("collect_ndjson_output.cjs", () => {

expect(global.github.rest.repos.listCollaborators).not.toHaveBeenCalled();
const parsed = JSON.parse(mockCore.setOutput.mock.calls.find(call => call[0] === "output")[1]);
expect(parsed.items[0].body).toBe("Hello `@alice`");
expect(parsed.items[0].body).toBe("Hello @alice");
}),
it("should preserve allowed aliases after max when no more than max occur", async () => {
const allowed = Array.from({ length: 60 }, (_, i) => `user${i}`);
Expand All @@ -1481,7 +1482,7 @@ describe("collect_ndjson_output.cjs", () => {
const parsedOutput = JSON.parse(outputCall[1]);
expect(parsedOutput.items[0].body).toBe("Thanks @user57, @user58, and @user59");
}),
it("should apply the mention limit across all fields in one item", async () => {
it("should defer mention limits across all fields to trusted output processing", async () => {
const validationPath = "/tmp/gh-aw/safeoutputs/validation.json";
const validationConfig = JSON.parse(fs.readFileSync(validationPath, "utf8"));
validationConfig.mentions = { allowContext: false, allowed: ["user1", "user2", "user3", "user4"], max: 3 };
Expand All @@ -1497,7 +1498,7 @@ describe("collect_ndjson_output.cjs", () => {
const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
const parsedOutput = JSON.parse(outputCall[1]);
expect(parsedOutput.items[0].title).toBe("@user1 @user2");
expect(parsedOutput.items[0].body).toBe("@user3 `@user4` @user1");
expect(parsedOutput.items[0].body).toBe("@user3 @user4 @user1");
}),
it("should neutralize bot trigger phrases", async () => {
const testFile = "/tmp/gh-aw/test-ndjson-output.txt",
Expand Down
17 changes: 17 additions & 0 deletions actions/setup/js/resolve_mentions_from_payload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ const { resolveMentionsLazily, isPayloadUserBot } = require("./resolve_mentions.
const { getErrorMessage } = require("./error_helpers.cjs");
const { parseRepoSlug } = require("./repo_helpers.cjs");

/**
* Use the mention-specific token for allowlist lookups instead of inheriting a
* downstream safe-output write token.
* @param {any} fallback
* @returns {any}
*/
function getMentionsGithubClient(fallback) {
const token = process.env.GH_AW_MENTIONS_GITHUB_TOKEN;
const globalState = /** @type {any} */ global;
if (token && typeof globalState.getOctokit === "function") {
return globalState.getOctokit(token);
}
return fallback;
}

/**
* Push a non-bot user's login to the array if present.
* @param {string[]} users - Target array
Expand Down Expand Up @@ -182,6 +197,7 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions
if (!context || !github || !core) {
return [];
}
github = getMentionsGithubClient(github);

// If mentions is explicitly set to false, return empty array (all mentions escaped)
if (mentionsConfig === false || mentionsConfig?.enabled === false) {
Expand Down Expand Up @@ -298,6 +314,7 @@ async function resolveDefaultMentions(context, github, core, mentionsConfig, def
}

module.exports = {
getMentionsGithubClient,
resolveAllowedMentionsFromPayload,
resolveMentionsForItem,
resolveDefaultMentions,
Expand Down
29 changes: 28 additions & 1 deletion actions/setup/js/resolve_mentions_from_payload.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ vi.mock("./error_helpers.cjs", () => ({
getErrorMessage: vi.fn(err => (err instanceof Error ? err.message : String(err))),
}));

const { resolveAllowedMentionsFromPayload, extractKnownAuthorsFromPayload, fetchTeamMembers, pushNonBotUser, pushNonBotAssignees } = await import("./resolve_mentions_from_payload.cjs");
const { getMentionsGithubClient, resolveAllowedMentionsFromPayload, extractKnownAuthorsFromPayload, fetchTeamMembers, pushNonBotUser, pushNonBotAssignees } = await import("./resolve_mentions_from_payload.cjs");

/** @returns {{ info: ReturnType<typeof vi.fn>, warning: ReturnType<typeof vi.fn>, error: ReturnType<typeof vi.fn> }} */
function makeMockCore() {
Expand All @@ -30,6 +30,33 @@ function makeMockGithub() {
return {};
}

describe("getMentionsGithubClient", () => {
it("uses the configured mention token instead of the handler client", () => {
const fallback = makeMockGithub();
const mentionClient = makeMockGithub();
const originalToken = process.env.GH_AW_MENTIONS_GITHUB_TOKEN;
const originalGetOctokit = global.getOctokit;
process.env.GH_AW_MENTIONS_GITHUB_TOKEN = "mention-token";
global.getOctokit = vi.fn(token => (token === "mention-token" ? mentionClient : fallback));

try {
expect(getMentionsGithubClient(fallback)).toBe(mentionClient);
expect(global.getOctokit).toHaveBeenCalledWith("mention-token");
} finally {
if (originalToken === undefined) {
delete process.env.GH_AW_MENTIONS_GITHUB_TOKEN;
} else {
process.env.GH_AW_MENTIONS_GITHUB_TOKEN = originalToken;
}
if (originalGetOctokit === undefined) {
delete global.getOctokit;
} else {
global.getOctokit = originalGetOctokit;
}
}
});
});

describe("pushNonBotUser", () => {
it("pushes a regular user login", () => {
const users = /** @type {string[]} */ [];
Expand Down
Loading
Loading