From 9e962bb670052ec06568bac80b6afea1ea7be99b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 21:27:25 -0700 Subject: [PATCH] FN-8415: scrub report file activity traces Scrub file-report activity traces before report pipeline processing. - Sanitize top-level and nested activity traces before parsing report input. - Add regressions for trace scrubbing and absent-trace behavior. - Document the report filing scrub boundary and add a security changeset. Files changed: .changeset/fn-8415-report-file-activity-trace-scrub.md | 7 +++++ docs/dashboard-guide.md | 2 +- packages/dashboard/src/__tests__/report-routes.test.ts | 35 ++++++++++++++++++++++ packages/dashboard/src/routes/register-report-routes.ts | 16 ++++++++-- 4 files changed, 56 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8415 Fusion-Task-Lineage: 90e42370-1682-42fc-bd53-61bb23da84a8 Co-authored-by: Fusion (runfusion.ai) --- ...n-8415-report-file-activity-trace-scrub.md | 7 ++++ docs/dashboard-guide.md | 2 +- .../src/__tests__/report-routes.test.ts | 35 +++++++++++++++++++ .../src/routes/register-report-routes.ts | 16 +++++++-- 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-8415-report-file-activity-trace-scrub.md diff --git a/.changeset/fn-8415-report-file-activity-trace-scrub.md b/.changeset/fn-8415-report-file-activity-trace-scrub.md new file mode 100644 index 0000000000..dca5215db9 --- /dev/null +++ b/.changeset/fn-8415-report-file-activity-trace-scrub.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Scrub top-level report activityTrace before filing so paths and tokens never reach the pipeline. +category: security +dev: /api/report/file now runs scrubReportPayload on raw.activityTrace before parseInput/runReportPipeline; route regression in report-routes.test.ts. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6324c7a744..0426a37b9d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -4,7 +4,7 @@ In **Settings → General**, operators can choose a default GitHub report target (**Issue** or **Discussion**) and add an optional per-action override for Bug, Feedback, Idea, or Help. Leaving these settings unset preserves the built-in routing: Bug and Idea file Issues; Feedback and Help file Discussions. The `reportDiscussionCategory` setting selects the category for Discussion reports. -Discussion filing uses the same scrub-before-egress report pipeline as Issues. Duplicate matching considers open Discussions only; a confirmed duplicate receives an upvote reaction and one scrubbed data-point comment rather than a new Discussion. If a repository has Discussions disabled, Fusion detects that during either Discussion search or creation, reruns Issue deduplication, and files an Issue instead; the filed result explicitly identifies that fallback destination. +Discussion filing uses the same scrub-before-egress report pipeline as Issues. File submissions re-scrub edited report text and activity traces before pipeline processing, so local paths, project labels, and credentials cannot cross that boundary. Duplicate matching considers open Discussions only; a confirmed duplicate receives an upvote reaction and one scrubbed data-point comment rather than a new Discussion. If a repository has Discussions disabled, Fusion detects that during either Discussion search or creation, reruns Issue deduplication, and files an Issue instead; the filed result explicitly identifies that fallback destination. [← Docs index](./README.md) diff --git a/packages/dashboard/src/__tests__/report-routes.test.ts b/packages/dashboard/src/__tests__/report-routes.test.ts index 0172ffe6d8..aa0a704477 100644 --- a/packages/dashboard/src/__tests__/report-routes.test.ts +++ b/packages/dashboard/src/__tests__/report-routes.test.ts @@ -86,6 +86,41 @@ describe("report routes", () => { expect(input.actionType).toBe("idea"); }); + it("scrubs top-level activityTrace before runReportPipeline on /report/file", async () => { + vi.mocked(runReportPipeline).mockResolvedValue({ kind: "filed" } as never); + const { handlers } = setup(); + await invoke(handlers.get("/report/file")!, { + actionType: "bug", + activityTrace: ["Saw crash at /Users/alice/private-project/src/a.ts in private-project with ghp_abcdefghijk1234567890"], + report: { userPrompt: "It crashes", body: "clean body", context: {} }, + }); + + expect(runReportPipeline).toHaveBeenCalledTimes(1); + const [input] = vi.mocked(runReportPipeline).mock.calls[0]!; + const trace = JSON.stringify(input.activityTrace); + expect(trace).not.toMatch(/\/Users\/alice|private-project|ghp_/); + expect(trace).toMatch(/\[REDACTED(?:_PATH)?\]/); + }); + + it("preserves absent traces and scrubs the nested activityTrace fallback", async () => { + vi.mocked(runReportPipeline).mockResolvedValue({ kind: "filed" } as never); + const { handlers } = setup(); + const route = handlers.get("/report/file")!; + await invoke(route, { actionType: "bug", report: { userPrompt: "It crashes", context: {} } }); + expect(vi.mocked(runReportPipeline).mock.calls[0]![0].activityTrace).toBeUndefined(); + + await invoke(route, { + actionType: "bug", + report: { + userPrompt: "It crashes", + context: { activityTrace: ["Saw crash at /Users/alice/private-project/src/a.ts in private-project with ghp_abcdefghijk1234567890"] }, + }, + }); + const nestedTrace = JSON.stringify(vi.mocked(runReportPipeline).mock.calls[1]![0].activityTrace); + expect(nestedTrace).not.toMatch(/\/Users\/alice|private-project|ghp_/); + expect(nestedTrace).toMatch(/\[REDACTED(?:_PATH)?\]/); + }); + describe("report screenshot references", () => { it("runs the multipart middleware before storing only signature-validated artifacts", async () => { const { handlers, single, store, setUploadFile } = setup(); diff --git a/packages/dashboard/src/routes/register-report-routes.ts b/packages/dashboard/src/routes/register-report-routes.ts index 3618a69801..38be0cd739 100644 --- a/packages/dashboard/src/routes/register-report-routes.ts +++ b/packages/dashboard/src/routes/register-report-routes.ts @@ -133,9 +133,19 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore router.post("/report/file", async (req, res) => { try { const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast(); const raw = (req.body ?? {}) as Record; const rawReport = (raw.report ?? raw) as StructuredReport; + const rootDir = store.getRootDir(); + const scrubContext = { rootDir, projectName: rootDir.split(/[\\/]/).pop() }; const { screenshotArtifactId: reportArtifactId, attachment: _untrustedAttachment, ...textualRawReport } = rawReport; - const untrusted = scrubReportPayload(textualRawReport, { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() }); - const input = parseInput({ actionType: raw.actionType ?? (untrusted.context as Record | undefined)?.actionType ?? "bug", userPrompt: untrusted.userPrompt ?? untrusted.summary, contextRefs: (untrusted.context as Record | undefined) && { taskId: typeof (untrusted.context as Record).taskId === "string" ? (untrusted.context as Record).taskId : undefined, agentId: typeof (untrusted.context as Record).agentId === "string" ? (untrusted.context as Record).agentId : undefined }, activityTrace: raw.activityTrace ?? (untrusted.context as Record | undefined)?.activityTrace, screenshotArtifactId: raw.screenshotArtifactId ?? reportArtifactId }); + const untrusted = scrubReportPayload(textualRawReport, scrubContext); + /* + * FNXC:ReportPipeline 2026-07-20-12:00: + * /report/file must scrub top-level activityTrace before parseInput and the + * pipeline boundary. Raw traces previously bypassed the edited-report scrub + * and exposed path, project, or token labels to gatherReportContext; the + * nested context fallback remains covered by textualRawReport scrubbing. + */ + const rawActivityTrace = raw.activityTrace === undefined ? undefined : scrubReportPayload({ activityTrace: raw.activityTrace }, scrubContext).activityTrace; + const input = parseInput({ actionType: raw.actionType ?? (untrusted.context as Record | undefined)?.actionType ?? "bug", userPrompt: untrusted.userPrompt ?? untrusted.summary, contextRefs: (untrusted.context as Record | undefined) && { taskId: typeof (untrusted.context as Record).taskId === "string" ? (untrusted.context as Record).taskId : undefined, agentId: typeof (untrusted.context as Record).agentId === "string" ? (untrusted.context as Record).agentId : undefined }, activityTrace: rawActivityTrace ?? (untrusted.context as Record | undefined)?.activityTrace, screenshotArtifactId: raw.screenshotArtifactId ?? reportArtifactId }); // FNXC:ReportPipeline 2026-07-16-21:00: Validate target before Help can return locally. const targetType = parseTargetType(raw.targetType); const discussionCategoryId = parseDiscussionCategoryId(raw.discussionCategoryId); @@ -143,7 +153,7 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore const inputWithAttachment = attachment ? { ...input, attachment } : input; const help = await selfCheckHelpBeforePipeline(store, inputWithAttachment); if (help?.answered) return void res.json({ kind: "help", answer: help.answer }); - res.json(await runReportPipeline(inputWithAttachment, { projectSettings: scopes.project, globalSettings: scopes.global, scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() }, gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record) }, { file: true, targetType, discussionCategoryId, endorseIssueNumber: typeof raw.endorseIssueNumber === "number" ? raw.endorseIssueNumber : undefined, endorseDiscussionId: typeof raw.endorseDiscussionId === "string" ? raw.endorseDiscussionId : undefined, endorseRoadmapIssueNumber: typeof raw.endorseRoadmapIssueNumber === "number" ? raw.endorseRoadmapIssueNumber : undefined, report: untrusted })); + res.json(await runReportPipeline(inputWithAttachment, { projectSettings: scopes.project, globalSettings: scopes.global, scrubContext, gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record) }, { file: true, targetType, discussionCategoryId, endorseIssueNumber: typeof raw.endorseIssueNumber === "number" ? raw.endorseIssueNumber : undefined, endorseDiscussionId: typeof raw.endorseDiscussionId === "string" ? raw.endorseDiscussionId : undefined, endorseRoadmapIssueNumber: typeof raw.endorseRoadmapIssueNumber === "number" ? raw.endorseRoadmapIssueNumber : undefined, report: untrusted })); } catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to file report"); } });