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:
7
.changeset/fn-8415-report-file-activity-trace-scrub.md
Normal file
7
.changeset/fn-8415-report-file-activity-trace-scrub.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user