feat: 支持会话级漏洞复测与运行状态展示
This commit is contained in:
1 parent
8dae851b9b
commit
82c1ed9cbb
30 files changed
+2080
-80
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { mergeActivities } from "./activity-merge.ts";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const activity = (seq, kind = "tool_use", extra = {}) => ({
|
||||
seq,
|
||||
kind,
|
||||
worker: "auto",
|
||||
ts: "2026-09-10T07:00:00Z",
|
||||
summary: String(seq),
|
||||
...extra,
|
||||
});
|
||||
|
||||
test("overlapping poll responses render tool 198 only once", () => {
|
||||
const first = [activity(197), activity(198)];
|
||||
const second = [activity(198), activity(199, "tool_result")];
|
||||
const merged = mergeActivities(mergeActivities([], first), second);
|
||||
assert.deepEqual(
|
||||
merged.map((item) => item.seq),
|
||||
[197, 198, 199],
|
||||
);
|
||||
assert.deepEqual(mergeActivities(merged, second), merged);
|
||||
});
|
||||
|
||||
test("a late response and overlapping older page preserve newer messages", () => {
|
||||
let rows = mergeActivities([activity(198)], [activity(199), activity(200)]);
|
||||
rows = mergeActivities(rows, [activity(198), activity(199)]);
|
||||
rows = mergeActivities([activity(196), activity(197), activity(198)], rows);
|
||||
assert.deepEqual(
|
||||
rows.map((item) => item.seq),
|
||||
[196, 197, 198, 199, 200],
|
||||
);
|
||||
});
|
||||
|
||||
test("replayed results and duplicate entries do not double-count usage", () => {
|
||||
const result = activity(201, "result", { input_tokens: 120, output_tokens: 30 });
|
||||
const rows = mergeActivities([result, result], [result, result]);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(
|
||||
rows.reduce((sum, item) => sum + item.input_tokens, 0),
|
||||
120,
|
||||
);
|
||||
});
|
||||
|
||||
test("merging does not mutate either response and keeps distinct IDs", () => {
|
||||
const first = Object.freeze([Object.freeze(activity(199))]);
|
||||
const second = Object.freeze([Object.freeze(activity(198)), Object.freeze(activity(200))]);
|
||||
assert.deepEqual(
|
||||
mergeActivities(first, second).map((item) => item.seq),
|
||||
[198, 199, 200],
|
||||
);
|
||||
assert.equal(first.length, 1);
|
||||
assert.equal(second.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Activity } from "./types";
|
||||
|
||||
// History and live responses can overlap or arrive out of order. A persisted
|
||||
// activity's seq is its identity; replay must not duplicate rows or token totals.
|
||||
export function mergeActivities(current: Activity[], incoming: Activity[]): Activity[] {
|
||||
const bySeq = new Map<number, Activity>();
|
||||
for (const activity of current) bySeq.set(activity.seq, activity);
|
||||
for (const activity of incoming) bySeq.set(activity.seq, activity);
|
||||
return [...bySeq.values()].sort((left, right) => left.seq - right.seq);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { mockHandle } from "@/lib/mock/handler";
|
||||
import type {
|
||||
ActiveFindingRetest,
|
||||
Activity,
|
||||
Agent,
|
||||
AgentDetail,
|
||||
@@ -33,6 +34,7 @@ import type {
|
||||
FindingDeepenResponse,
|
||||
FindingGroupsPage,
|
||||
FindingQuery,
|
||||
FindingRetest,
|
||||
FindingStats,
|
||||
FindingStatus,
|
||||
FindingsPage,
|
||||
@@ -537,6 +539,12 @@ export const api = {
|
||||
) => patch<Finding>(`/exploration/findings/${id}`, fields),
|
||||
// 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。
|
||||
deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`),
|
||||
findingRetests: (id: string) =>
|
||||
get<{ retests: FindingRetest[] }>(`/exploration/findings/${encodeURIComponent(id)}/retests`).then((r) => arr(r.retests)),
|
||||
activeFindingRetests: () =>
|
||||
get<{ retests: ActiveFindingRetest[] }>("/exploration/findings/retests/active").then((r) => arr(r.retests)),
|
||||
startFindingRetest: (id: string, notes: string) =>
|
||||
post<{ retest: FindingRetest; created: boolean }>(`/exploration/findings/${encodeURIComponent(id)}/retests`, { notes }),
|
||||
deepenFinding: (id: string, description: string) =>
|
||||
post<FindingDeepenResponse>(`/exploration/findings/${id}/deepen`, { description }),
|
||||
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
|
||||
|
||||
@@ -3058,6 +3058,10 @@ export const llmConfig = {
|
||||
|
||||
// ── Agents ───────────────────────────────────────────────────────────────────
|
||||
export const agents: Agent[] = [
|
||||
{
|
||||
id: "1001", key: "retester", name: "漏洞复测", role: "assistant", builtin: false, enabled: true,
|
||||
description: "从漏洞详情手动启动,保存独立复测结论", max_turns: 0, mcp_count: 0, skill_count: 0, tool_count: 2,
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
key: "goals",
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
Company,
|
||||
CompanyScopeRule,
|
||||
Conversation,
|
||||
FindingRetest,
|
||||
IntentAsset,
|
||||
ScopeRow,
|
||||
Task,
|
||||
@@ -39,6 +40,32 @@ const mockLLMRecords = structuredClone(D.llmRecords);
|
||||
const mockTaskTemplates = structuredClone(D.taskTemplates);
|
||||
const mockTaskCategories = structuredClone(D.taskCategories);
|
||||
const mockConversations = structuredClone(D.conversations);
|
||||
const mockRetests: FindingRetest[] = [];
|
||||
const mockRetestMessages: Record<number, Activity[]> = {};
|
||||
|
||||
function advanceMockRetests() {
|
||||
for (const retest of mockRetests) {
|
||||
if (retest.status !== "running" || retest.conversation_id == null) continue;
|
||||
if (Date.now() - Date.parse(retest.created_at) < 15000) continue;
|
||||
retest.status = "completed";
|
||||
retest.verdict = "inconclusive";
|
||||
retest.summary = "演示环境未执行真实验证,无法确认漏洞当前状态。";
|
||||
retest.evidence = "### 演示记录\n\n已关联原漏洞。此环境未连接真实 Agent,也未向目标发送请求;请在实际部署中执行复测。";
|
||||
retest.finished_at = new Date().toISOString();
|
||||
mockRetestMessages[retest.conversation_id].push({
|
||||
seq: 3, worker: "retester", ts: retest.finished_at, kind: "text", summary: retest.summary, detail: retest.evidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stopMockRetest(conversationID: number) {
|
||||
for (const retest of mockRetests) {
|
||||
if (retest.conversation_id !== conversationID || !["pending", "running"].includes(retest.status)) continue;
|
||||
retest.status = "stopped";
|
||||
retest.finished_at = new Date().toISOString();
|
||||
retest.error = "演示复测已停止";
|
||||
}
|
||||
}
|
||||
const mockIntents = structuredClone(D.intents);
|
||||
const mockCompanies = structuredClone(D.companies);
|
||||
const mockAssets = structuredClone(D.assets);
|
||||
@@ -837,6 +864,7 @@ function parseBody(body?: BodyInit | null): Record<string, unknown> {
|
||||
}
|
||||
|
||||
export async function mockHandle<T>(method: string, rawPath: string, body?: BodyInit | null): Promise<T> {
|
||||
advanceMockRetests();
|
||||
await delay();
|
||||
const [path, qs] = rawPath.split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
@@ -1700,6 +1728,35 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
page_size: pageSize,
|
||||
};
|
||||
}
|
||||
if (path === "/exploration/findings/retests/active" && m === "GET") {
|
||||
return { retests: mockRetests
|
||||
.filter((item) => ["pending", "running"].includes(item.status) && item.conversation_id != null)
|
||||
.map((item) => ({ id: item.id, finding_id: D.findings[item.finding_id - 1].id, conversation_id: item.conversation_id, status: item.status })) };
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "retests") {
|
||||
const finding = mockFindings.find((item) => item.id === seg[2]);
|
||||
if (!finding) throw new Error("漏洞不存在");
|
||||
const findingID = D.findings.findIndex((item) => item.id === finding.id) + 1;
|
||||
if (m === "GET") return { retests: structuredClone(mockRetests.filter((item) => item.finding_id === findingID)) };
|
||||
if (m === "POST") {
|
||||
const existing = mockRetests.find((item) => item.finding_id === findingID && ["pending", "running"].includes(item.status));
|
||||
if (existing) return { retest: structuredClone(existing), created: false };
|
||||
const now = new Date().toISOString();
|
||||
const conversationID = mockConversations.reduce((max, item) => Math.max(max, item.id), 0) + 1;
|
||||
mockConversations.unshift({ id: conversationID, agent_key: "retester", title: `复测 #${finding.id} · ${finding.name || finding.vulnclass}`, pinned: false, created_at: now, updated_at: now });
|
||||
const retest: FindingRetest = {
|
||||
id: mockRetests.length + 1, finding_id: findingID, conversation_id: conversationID,
|
||||
status: "running", verdict: "", notes: String(b.notes ?? ""), summary: "", evidence: "",
|
||||
error: "", created_at: now, started_at: now, finished_at: null,
|
||||
};
|
||||
mockRetests.unshift(retest);
|
||||
mockRetestMessages[conversationID] = [
|
||||
{ seq: 1, worker: "retester", ts: now, kind: "user", summary: `请复测漏洞 #${finding.id}`, detail: retest.notes },
|
||||
{ seq: 2, worker: "retester", ts: now, kind: "text", summary: "演示复测进行中(未向目标发送请求)", detail: "演示复测进行中(未向目标发送请求)" },
|
||||
];
|
||||
return { retest: structuredClone(retest), created: true };
|
||||
}
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "deepen" && m === "POST") {
|
||||
const finding = mockFindings.find((candidate) => candidate.id === seg[2]);
|
||||
const description = String(b.description ?? "").trim();
|
||||
@@ -1887,7 +1944,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
// ── conversations ──
|
||||
if (path === "/conversations" && m === "GET") {
|
||||
sortMockConversations();
|
||||
return { conversations: structuredClone(mockConversations) };
|
||||
return { conversations: structuredClone(mockConversations.map((conversation) => ({
|
||||
...conversation,
|
||||
running: mockRetests.some((item) => item.conversation_id === conversation.id && ["pending", "running"].includes(item.status)),
|
||||
}))) };
|
||||
}
|
||||
if (path === "/conversations" && m === "POST") {
|
||||
const now = new Date().toISOString();
|
||||
@@ -1918,8 +1978,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
if (seg[0] === "conversations" && seg.length === 2 && m === "DELETE") {
|
||||
const id = Number(seg[1]);
|
||||
stopMockRetest(id);
|
||||
const index = mockConversations.findIndex((item) => item.id === id);
|
||||
if (index >= 0) mockConversations.splice(index, 1);
|
||||
for (const retest of mockRetests) if (retest.conversation_id === id) retest.conversation_id = null;
|
||||
return { deleted: id };
|
||||
}
|
||||
if (path === "/conversations/delete/batch" && m === "POST") {
|
||||
@@ -1930,21 +1992,27 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const index = mockConversations.findIndex((item) => item.id === id);
|
||||
if (index < 0) return { id, ok: false, error: "conversation not found" };
|
||||
mockConversations.splice(index, 1);
|
||||
stopMockRetest(id);
|
||||
for (const retest of mockRetests) if (retest.conversation_id === id) retest.conversation_id = null;
|
||||
return { id, ok: true };
|
||||
});
|
||||
return { items };
|
||||
}
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
|
||||
const items = D.conversationMessages[Number(seg[1])] ?? [];
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
|
||||
const items = mockRetestMessages[Number(seg[1])] ?? D.conversationMessages[Number(seg[1])] ?? [];
|
||||
const running = mockRetests.some((item) => item.conversation_id === Number(seg[1]) && ["pending", "running"].includes(item.status));
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running };
|
||||
}
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 4) {
|
||||
const msgs = D.conversationMessages[Number(seg[1])] ?? [];
|
||||
const msgs = mockRetestMessages[Number(seg[1])] ?? D.conversationMessages[Number(seg[1])] ?? [];
|
||||
const a = msgs.find((x) => x.seq === Number(seg[3]));
|
||||
return { detail: a?.detail ?? a?.summary ?? "" };
|
||||
}
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && m === "POST") return { status: "ok" };
|
||||
if (seg[0] === "conversations" && seg[2] === "stop") return { status: "stopped" };
|
||||
if (seg[0] === "conversations" && seg[2] === "stop") {
|
||||
stopMockRetest(Number(seg[1]));
|
||||
return { status: "stopped" };
|
||||
}
|
||||
|
||||
// ── tools ──
|
||||
if (path === "/tools" && m === "GET") return { tools: D.tools };
|
||||
|
||||
@@ -79,6 +79,7 @@ const finding: Record<string, StatusMeta> = {
|
||||
in_progress: { label: "处理中", tone: "blue" },
|
||||
confirmed: { label: "已确认", tone: "red" },
|
||||
resolved: { label: "已处理", tone: "green" },
|
||||
fixed: { label: "已修复", tone: "green" },
|
||||
false_positive: { label: "误报", tone: "slate" },
|
||||
ignored: { label: "忽略", tone: "neutral" },
|
||||
duplicate: { label: "重复", tone: "neutral" },
|
||||
|
||||
+25
-1
@@ -443,12 +443,13 @@ export interface TaskConstraint {
|
||||
// ---- Findings ----
|
||||
export type Severity = "critical" | "high" | "medium" | "low";
|
||||
|
||||
// 漏洞处置状态:待处理 / 处理中 / 已确认 / 已处理 / 误报 / 忽略 / 重复 / 风险接受。
|
||||
// 漏洞处置状态:待处理 / 处理中 / 已确认 / 已处理 / 已修复 / 误报 / 忽略 / 重复 / 风险接受。
|
||||
export type FindingStatus =
|
||||
| "pending"
|
||||
| "in_progress"
|
||||
| "confirmed"
|
||||
| "resolved"
|
||||
| "fixed"
|
||||
| "false_positive"
|
||||
| "ignored"
|
||||
| "duplicate"
|
||||
@@ -681,8 +682,31 @@ export interface AgentTrigger {
|
||||
}
|
||||
|
||||
// ---- Conversations (chat page) ----
|
||||
export interface ActiveFindingRetest {
|
||||
id: number;
|
||||
finding_id: string;
|
||||
conversation_id: number;
|
||||
status: "pending" | "running";
|
||||
}
|
||||
|
||||
export interface FindingRetest {
|
||||
id: number;
|
||||
finding_id: number;
|
||||
conversation_id: number | null;
|
||||
status: "pending" | "running" | "completed" | "failed" | "stopped";
|
||||
verdict: "" | "reproduced" | "fixed" | "inconclusive";
|
||||
notes: string;
|
||||
summary: string;
|
||||
evidence: string;
|
||||
error: string;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
running?: boolean; // live server state, returned with the conversation list
|
||||
agent_key: string;
|
||||
title: string;
|
||||
llm_profile_id?: number;
|
||||
|
||||
Reference in new issue
Block a user