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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 21:27:25 -07:00
parent 3008eb2dd8
commit 9e962bb670
4 changed files with 56 additions and 4 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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();

View File

@@ -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<string, unknown>; 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<string, unknown> | undefined)?.actionType ?? "bug", userPrompt: untrusted.userPrompt ?? untrusted.summary, contextRefs: (untrusted.context as Record<string, unknown> | undefined) && { taskId: typeof (untrusted.context as Record<string, unknown>).taskId === "string" ? (untrusted.context as Record<string, unknown>).taskId : undefined, agentId: typeof (untrusted.context as Record<string, unknown>).agentId === "string" ? (untrusted.context as Record<string, unknown>).agentId : undefined }, activityTrace: raw.activityTrace ?? (untrusted.context as Record<string, unknown> | 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<string, unknown> | undefined)?.actionType ?? "bug", userPrompt: untrusted.userPrompt ?? untrusted.summary, contextRefs: (untrusted.context as Record<string, unknown> | undefined) && { taskId: typeof (untrusted.context as Record<string, unknown>).taskId === "string" ? (untrusted.context as Record<string, unknown>).taskId : undefined, agentId: typeof (untrusted.context as Record<string, unknown>).agentId === "string" ? (untrusted.context as Record<string, unknown>).agentId : undefined }, activityTrace: rawActivityTrace ?? (untrusted.context as Record<string, unknown> | 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<string, unknown>) }, { 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<string, unknown>) }, { 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"); }
});