From 4f7c1a199d26f6a17ed8f0a7f1078ce469ed1f0c Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:40:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=E5=88=A0=E9=99=A4=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=8F=AF=E9=80=89=E8=BF=9E=E5=B8=A6=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E8=B5=84=E4=BA=A7/=E6=BC=8F=E6=B4=9E/=E6=B5=81=E9=87=8F/?= =?UTF-8?q?=E6=96=87=E4=BB=B6(=E9=BB=98=E8=AE=A4=E4=B8=8D=E9=80=89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除弹窗新增四个默认不勾的复选框: - 删除关联的资产:仅删本任务独有资产,与其他任务共享的仅解除关联(保留)。 - 删除发现的漏洞:删本任务全部 findings。 - 删除测试的流量:按本任务资产的 host 清录制流量(host 无任务标签,会连带清 其他任务命中同一 host 的流量)。 - 删除测试过程中写的文件:删 /tasks/ 目录(worker 产物 + 上传)。 - db: AssetStore.DeleteByTaskID(独有删+共享解关联)/HostsByTask;DeleteFindingsByTask。 - server: pgDeleteTask 读可选 JSON body {assets,findings,traffic,files},先按 host 清流量(需在删资产前取 host)→删漏洞→删资产→删任务→删文件目录;空 body=只删任务。 - web: api.deleteTask(id,opts);删除弹窗四复选框(默认不选)+说明。 go build/vet、tsc 通过;新增 TestDeleteByTaskID(独有删/共享解关联/host 反查)。 --- db/assets.go | 39 +++++++++++++ db/assets_test.go | 54 ++++++++++++++++++ db/findings.go | 11 ++++ server/server_mgmt.go | 49 +++++++++++++++- web/src/app/(main)/function/tasks/page.tsx | 65 ++++++++++++++++++---- web/src/lib/api.ts | 11 +++- 6 files changed, 215 insertions(+), 14 deletions(-) diff --git a/db/assets.go b/db/assets.go index 3cf1c1b..583cc87 100644 --- a/db/assets.go +++ b/db/assets.go @@ -1053,6 +1053,45 @@ func (s *AssetStore) GetByIDs(ids []int64) ([]*Asset, error) { return scanAssets(rows) } +// DeleteByTaskID removes a task's assets: hard-deletes assets owned solely by this +// task (task_ids = {taskID}) and de-associates the rest (drops this task from their +// task_ids, keeping assets shared with other tasks intact). Returns rows deleted. +func (s *AssetStore) DeleteByTaskID(taskID int64) (int64, error) { + res, err := s.db.Exec(`DELETE FROM assets WHERE task_ids = ARRAY[$1]::bigint[]`, taskID) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + if _, err := s.db.Exec(`UPDATE assets SET task_ids = array_remove(task_ids, $1) WHERE $1 = ANY(task_ids)`, taskID); err != nil { + return n, err + } + return n, nil +} + +// HostsByTask returns the distinct hostnames/IPs the task's assets carry (domain, +// root_domain, ip) — used to purge host-keyed traffic recorded for that task. +func (s *AssetStore) HostsByTask(taskID int64) ([]string, error) { + rows, err := s.db.Query(` +SELECT DISTINCT h FROM ( + SELECT NULLIF(domain,'') AS h FROM assets WHERE $1 = ANY(task_ids) + UNION SELECT NULLIF(root_domain,'') FROM assets WHERE $1 = ANY(task_ids) + UNION SELECT NULLIF(ip,'') FROM assets WHERE $1 = ANY(task_ids) +) q WHERE h IS NOT NULL AND h <> ''`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var h string + if err := rows.Scan(&h); err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +} + // DeleteByCompanyID hard-deletes all assets belonging to a company. Returns rows deleted. func (s *AssetStore) DeleteByCompanyID(companyID int64) (int64, error) { res, err := s.db.Exec(`DELETE FROM assets WHERE company_id = $1`, companyID) diff --git a/db/assets_test.go b/db/assets_test.go index 2245192..367f7db 100644 --- a/db/assets_test.go +++ b/db/assets_test.go @@ -2,6 +2,7 @@ package db import ( "fmt" + "slices" "testing" ) @@ -535,6 +536,59 @@ func TestQueryByType(t *testing.T) { } } +// TestDeleteByTaskID: 独有资产被删,与其他任务共享的资产仅解除关联(保留),host 反查正确。 +func TestDeleteByTaskID(t *testing.T) { + d, av2, _ := testSetup(t) + defer d.Close() + + const taskA = int64(90001) + const taskB = int64(90002) + // solo:仅属 taskA + solo, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "solo-del.test", TaskID: taskA}) + if err != nil { + t.Fatal(err) + } + defer deleteAsset(d, solo) + // shared:先 taskA 再 taskB → task_ids={A,B} + shared, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "shared-del.test", TaskID: taskA}) + if err != nil { + t.Fatal(err) + } + defer deleteAsset(d, shared) + if _, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "shared-del.test", TaskID: taskB}); err != nil { + t.Fatal(err) + } + + // host 反查(删资产前):应含两个域名 + hosts, err := av2.HostsByTask(taskA) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(hosts, "solo-del.test") || !slices.Contains(hosts, "shared-del.test") { + t.Fatalf("HostsByTask 缺 host: %v", hosts) + } + + n, err := av2.DeleteByTaskID(taskA) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("DeleteByTaskID: 应删 1 个独有资产,实删 %d", n) + } + // solo 已删 + if a, _ := av2.GetByIDs([]int64{solo}); len(a) != 0 { + t.Fatalf("solo 资产应被删除") + } + // shared 保留,且 task_ids 只剩 taskB + sa, _ := av2.GetByIDs([]int64{shared}) + if len(sa) != 1 { + t.Fatalf("shared 资产应保留") + } + if slices.Contains(sa[0].TaskIDs, taskA) || !slices.Contains(sa[0].TaskIDs, taskB) { + t.Fatalf("shared task_ids 应解除 A 保留 B,得 %v", sa[0].TaskIDs) + } +} + func TestQueryByTask(t *testing.T) { d, av2, _ := testSetup(t) defer d.Close() diff --git a/db/findings.go b/db/findings.go index 277f371..6fc64db 100644 --- a/db/findings.go +++ b/db/findings.go @@ -329,6 +329,17 @@ func (d *DB) DeleteFinding(id int64) (int64, error) { return 1, nil } +// DeleteFindingsByTask removes all findings rows of a task. The originating +// exploration finding nodes are cascade-deleted separately when the task's +// exploration subgraph is dropped. Returns rows deleted. +func (d *DB) DeleteFindingsByTask(taskID int64) (int64, error) { + res, err := d.Exec(`DELETE FROM findings WHERE task_id=$1`, taskID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + // SetFindingStatus updates one finding's triage state. Returns rows affected. func (d *DB) SetFindingStatus(id int64, status string) (int64, error) { res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id) diff --git a/server/server_mgmt.go b/server/server_mgmt.go index f755afd..0595787 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -66,14 +66,61 @@ func decode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decod // ---------- tasks (delete) ---------- +// pgDeleteTask removes a task and, per opt-in flags in the (optional) JSON body, +// its associated data: assets (task-owned), findings, recorded traffic (by the +// task's hosts) and the per-task working directory. Empty body → all flags false → +// only the task + its exploration subgraph are removed (legacy behavior). func (s *Server) pgDeleteTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") + var opts struct { + Assets bool `json:"assets"` + Findings bool `json:"findings"` + Traffic bool `json:"traffic"` + Files bool `json:"files"` + } + _ = json.NewDecoder(r.Body).Decode(&opts) // optional body; ignore EOF/parse errors + + result := map[string]any{"deleted": id} + nid, _ := strconv.ParseInt(id, 10, 64) + as := s.m.Assets() + + // Traffic first: it needs the task's hosts, which come from assets that a later + // asset-delete would remove. Traffic is host-keyed (no per-task tag), so this + // purges ALL recorded traffic for those hosts, not just this task's. + if opts.Traffic && nid > 0 && as != nil { + if tr := s.m.Traffic(); tr != nil { + if hosts, err := as.HostsByTask(nid); err == nil && len(hosts) > 0 { + if n, err := tr.DeleteHostsExact(hosts); err == nil { + result["traffic_deleted"] = n + } + } + } + } + if opts.Findings && nid > 0 { + if n, err := s.m.pg.DeleteFindingsByTask(nid); err == nil { + result["findings_deleted"] = n + } + } + if opts.Assets && nid > 0 && as != nil { + if n, err := as.DeleteByTaskID(nid); err == nil { + result["assets_deleted"] = n + } + } + s.engine.Pause(id, agent.AbortTaskDeleted) // cancel running loops before removing if err := s.m.DeleteTask(id); err != nil { writeErr(w, 500, err.Error()) return } - writeJSON(w, 200, map[string]any{"deleted": id}) + + // Files last: the per-task working dir /tasks/ (worker artifacts + + // uploads). Confined to the work dir; id is numeric so no traversal risk. + if opts.Files && nid > 0 { + if err := os.RemoveAll(filepath.Join(s.m.dir, "tasks", id)); err == nil { + result["files_deleted"] = true + } + } + writeJSON(w, 200, result) } // ---------- agents ---------- diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 4cdd004..718b6de 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -227,15 +227,18 @@ export default function TasksPage() { return () => clearInterval(i); }, [hasRunning]); - const deleteTask = React.useCallback(async (id: string) => { - try { - await api.deleteTask(id); - toast.success("任务已删除(全局资产图保留)"); - load(); - } catch (e) { - toast.error("删除失败:" + (e as Error).message); - } - }, [load]); + const deleteTask = React.useCallback( + async (id: string, opts: DeleteOpts) => { + try { + await api.deleteTask(id, opts); + toast.success("任务已删除"); + load(); + } catch (e) { + toast.error("删除失败:" + (e as Error).message); + } + }, + [load], + ); return ( @@ -333,6 +336,15 @@ export default function TasksPage() { // the POLL_MS list refresh only re-render the rows whose data actually moved — a table page // is 20 rows × (StatusBadge + Link + a Radix AlertDialog tree), far too heavy to rebuild // wholesale on every parent render. +type DeleteOpts = { assets: boolean; findings: boolean; traffic: boolean; files: boolean }; + +const DELETE_EXTRAS: { key: keyof DeleteOpts; label: string; hint: string }[] = [ + { key: "assets", label: "删除关联的资产", hint: "仅本任务独有的资产;与其他任务共享的仅解除关联" }, + { key: "findings", label: "删除发现的漏洞", hint: "本任务记录的全部漏洞" }, + { key: "traffic", label: "删除测试的流量", hint: "按本任务涉及的 host 清除录制流量" }, + { key: "files", label: "删除测试过程中写的文件", hint: "本任务工作目录 tasks/(产物 + 上传)" }, +]; + const TaskRow = React.memo(function TaskRow({ task, nowSec, @@ -340,8 +352,14 @@ const TaskRow = React.memo(function TaskRow({ }: { task: Task; nowSec: number; - onDelete: (id: string) => void; + onDelete: (id: string, opts: DeleteOpts) => void; }) { + const [delOpts, setDelOpts] = React.useState({ + assets: false, + findings: false, + traffic: false, + files: false, + }); return ( @@ -430,12 +448,35 @@ const TaskRow = React.memo(function TaskRow({ ) : ( "该任务" )} - 的执行记录、会话与产物将被删除,此操作不可撤销(全局资产图保留)。 + 的执行记录、会话与产物将被删除,此操作不可撤销。 + + {/* 额外清理项:默认都不选,勾选后连带删除。全局资产图默认保留。 */} +
+ + 同时删除以下关联数据(默认不删): + + {DELETE_EXTRAS.map((opt) => ( + + ))} +
+ 取消 - onDelete(task.id)}> + onDelete(task.id, delOpts)}> 删除 diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a8eb4db..fc30e9d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -122,7 +122,16 @@ export const api = { seed_first_intent: seedFirstIntent ?? false, plan_heartbeat_seconds: planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) }), - deleteTask: (id: string) => del<{ deleted: number }>(`/tasks/${id}`), + // 删除任务;opts 里的四个可选项默认不删:assets 关联资产、findings 发现漏洞、 + // traffic 测试流量(按任务 host 清)、files 测试过程写的文件(tasks/ 目录)。 + deleteTask: ( + id: string, + opts?: { assets?: boolean; findings?: boolean; traffic?: boolean; files?: boolean }, + ) => + del<{ deleted: string; assets_deleted?: number; findings_deleted?: number; traffic_deleted?: number; files_deleted?: boolean }>( + `/tasks/${id}`, + opts, + ), controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }), // 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。 rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),