mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05b72683aa | ||
|
|
69eab0c033 | ||
|
|
8f9d981bdc | ||
|
|
a94b67ffd6 |
No files matched your search
@@ -9,6 +9,7 @@ import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { FindingAgentChatDrawer } from "@/components/finding-agent-chat-drawer";
|
||||
import { FindingRetestPanel } from "@/components/finding-retest-panel";
|
||||
import { FindingTrafficPanel } from "@/components/finding-traffic-panel";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
@@ -160,6 +161,7 @@ function FindingDetailInner() {
|
||||
{finding.inherited && finding.source_task_id && (
|
||||
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
|
||||
)}
|
||||
<FindingAgentChatDrawer finding={finding} />
|
||||
</div>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowUpIcon, Loader2Icon, MessageCircleQuestionIcon, SquareIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api, openSSE } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type { Activity, Finding } from "@/lib/types";
|
||||
|
||||
import {
|
||||
buildFindingAgentContextMessage,
|
||||
findingAgentUnavailableReason,
|
||||
findingDisplayTitle,
|
||||
} from "./finding-agent-chat";
|
||||
|
||||
const MAIN_HISTORY_LIMIT = 200;
|
||||
|
||||
type FindingAgentChatDrawerProps = {
|
||||
finding: Finding;
|
||||
};
|
||||
|
||||
function mainActivity(activity: Activity, segment: number): boolean {
|
||||
if (activity.worker !== "mainagent") return false;
|
||||
return activity.main_seg == null || activity.main_seg === segment;
|
||||
}
|
||||
|
||||
function mergeActivities(current: Activity[], incoming: Activity[], segment: number): Activity[] {
|
||||
const bySeq = new Map<number, Activity>();
|
||||
for (const item of current) bySeq.set(item.seq, item);
|
||||
for (const item of incoming) {
|
||||
if (mainActivity(item, segment)) bySeq.set(item.seq, item);
|
||||
}
|
||||
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
|
||||
function LoadingActivity() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载主 Agent 对话…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyActivity({ available }: { available: boolean }) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{available ? "还没有对话。发送问题后,主 Agent 的回复会显示在这里。" : "暂无可显示的活动。"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps) {
|
||||
const taskId = finding.task_id ?? "";
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [activities, setActivities] = React.useState<Activity[]>([]);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
const sendMode = useChatSendMode();
|
||||
const [taskAvailable, setTaskAvailable] = React.useState<boolean | null>(null);
|
||||
const [unavailableReason, setUnavailableReason] = React.useState("");
|
||||
const eventSourceRef = React.useRef<EventSource | null>(null);
|
||||
const snapshotRef = React.useRef(0);
|
||||
const aliveRef = React.useRef(false);
|
||||
const requestGenerationRef = React.useRef(0);
|
||||
const sentContextRef = React.useRef(false);
|
||||
const currentSegmentRef = React.useRef(0);
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const atBottomRef = React.useRef(true);
|
||||
const findingIdRef = React.useRef(finding.id);
|
||||
findingIdRef.current = finding.id;
|
||||
|
||||
const findingId = finding.id;
|
||||
const inherited = Boolean(finding.inherited);
|
||||
const canAsk = Boolean(taskId && !inherited);
|
||||
|
||||
const closeStream = React.useCallback(() => {
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
}, []);
|
||||
|
||||
const refreshStatus = React.useCallback(
|
||||
(generation = requestGenerationRef.current) => {
|
||||
if (!taskId || !canAsk) return Promise.resolve();
|
||||
return api
|
||||
.chatStatus(taskId)
|
||||
.then(({ running: nextRunning }) => {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation) setRunning(nextRunning);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[canAsk, taskId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const generation = ++requestGenerationRef.current;
|
||||
const isCurrent = () =>
|
||||
aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId;
|
||||
|
||||
closeStream();
|
||||
aliveRef.current = open;
|
||||
if (!open) return;
|
||||
|
||||
sentContextRef.current = false;
|
||||
setInput("");
|
||||
setActivities([]);
|
||||
setTaskAvailable(null);
|
||||
setUnavailableReason(findingAgentUnavailableReason({ task_id: taskId, inherited }));
|
||||
setRunning(false);
|
||||
setLoading(false);
|
||||
setSending(false);
|
||||
setStopping(false);
|
||||
snapshotRef.current = 0;
|
||||
currentSegmentRef.current = 0;
|
||||
atBottomRef.current = true;
|
||||
|
||||
if (!canAsk) {
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
requestGenerationRef.current += 1;
|
||||
closeStream();
|
||||
};
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
if (MOCK) {
|
||||
api
|
||||
.activity(taskId)
|
||||
.then(({ items, cursor }) => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(true);
|
||||
setActivities(mergeActivities([], items, currentSegmentRef.current));
|
||||
snapshotRef.current = cursor;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(false);
|
||||
setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) setLoading(false);
|
||||
});
|
||||
} else {
|
||||
api
|
||||
.task(taskId)
|
||||
.then(() => api.mainSessions(taskId))
|
||||
.then((sessions) => {
|
||||
if (!isCurrent()) return null;
|
||||
const currentSegment = sessions.current ?? 0;
|
||||
currentSegmentRef.current = currentSegment;
|
||||
return api.activityHistory(taskId, `main:${currentSegment}`, 0, MAIN_HISTORY_LIMIT).then((history) => ({
|
||||
currentSegment,
|
||||
history,
|
||||
}));
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result || !isCurrent()) return;
|
||||
const { currentSegment, history } = result;
|
||||
setTaskAvailable(true);
|
||||
setActivities((current) => mergeActivities(current, history.items, currentSegment));
|
||||
snapshotRef.current = history.snapshotCursor;
|
||||
|
||||
closeStream();
|
||||
const stream = openSSE(
|
||||
`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`,
|
||||
);
|
||||
if (!isCurrent()) {
|
||||
stream.close();
|
||||
return;
|
||||
}
|
||||
eventSourceRef.current = stream;
|
||||
stream.onmessage = (event) => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const activity = JSON.parse(event.data) as Activity;
|
||||
setActivities((current) => mergeActivities(current, [activity], currentSegment));
|
||||
} catch {
|
||||
// Ignore malformed frames; the next history refresh still repairs the view.
|
||||
}
|
||||
};
|
||||
stream.onerror = () => undefined; // EventSource reconnects automatically.
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(false);
|
||||
setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
void refreshStatus(generation);
|
||||
const statusTimer = window.setInterval(() => void refreshStatus(generation), 2000);
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
requestGenerationRef.current += 1;
|
||||
window.clearInterval(statusTimer);
|
||||
closeStream();
|
||||
};
|
||||
}, [canAsk, closeStream, findingId, inherited, open, refreshStatus, taskId]);
|
||||
|
||||
const viewport = React.useCallback(
|
||||
() => (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? null,
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const vp = viewport();
|
||||
if (!vp) return;
|
||||
const onScroll = () => {
|
||||
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
|
||||
};
|
||||
vp.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => vp.removeEventListener("scroll", onScroll);
|
||||
}, [open, viewport]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const vp = viewport();
|
||||
if (vp) {
|
||||
vp.scrollTop = vp.scrollHeight;
|
||||
atBottomRef.current = true;
|
||||
}
|
||||
}, [open, viewport]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activity growth intentionally drives live-edge scrolling.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
}, [activities, open, running, viewport]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || typeof ResizeObserver === "undefined") return;
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [open, viewport]);
|
||||
|
||||
const send = React.useCallback(async () => {
|
||||
const message = input.trim();
|
||||
if (!message || sending || running || !taskId || taskAvailable !== true || !canAsk) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setSending(true);
|
||||
const payload = sentContextRef.current
|
||||
? message
|
||||
: `${buildFindingAgentContextMessage(finding)}\n\n用户问题:${message}`;
|
||||
try {
|
||||
const result = await api.chat(payload, taskId);
|
||||
if (!aliveRef.current || requestGenerationRef.current !== generation || findingIdRef.current !== findingId)
|
||||
return;
|
||||
if (MOCK) {
|
||||
const ts = new Date().toISOString();
|
||||
const reply = result.reply?.trim() || "(demo)主 Agent 暂无回复。";
|
||||
setActivities((current) =>
|
||||
mergeActivities(
|
||||
current,
|
||||
[
|
||||
{
|
||||
seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 1,
|
||||
worker: "mainagent",
|
||||
ts,
|
||||
kind: "user",
|
||||
summary: message,
|
||||
main_seg: currentSegmentRef.current,
|
||||
},
|
||||
{
|
||||
seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 2,
|
||||
worker: "mainagent",
|
||||
ts,
|
||||
kind: "text",
|
||||
summary: reply,
|
||||
main_seg: currentSegmentRef.current,
|
||||
},
|
||||
],
|
||||
currentSegmentRef.current,
|
||||
),
|
||||
);
|
||||
}
|
||||
sentContextRef.current = true;
|
||||
setInput("");
|
||||
if (!MOCK) void refreshStatus(generation);
|
||||
} catch (error) {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`);
|
||||
}
|
||||
} finally {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}, [canAsk, finding, findingId, input, refreshStatus, running, sending, taskAvailable, taskId]);
|
||||
|
||||
const stop = React.useCallback(async () => {
|
||||
if (!taskId || stopping) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setStopping(true);
|
||||
try {
|
||||
await api.stopChat(taskId);
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
await refreshStatus(generation);
|
||||
}
|
||||
} catch (error) {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
toast.error(`停止失败:${(error as Error).message || "请稍后重试"}`);
|
||||
}
|
||||
} finally {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
setStopping(false);
|
||||
}
|
||||
}
|
||||
}, [findingId, refreshStatus, stopping, taskId]);
|
||||
|
||||
const title = findingDisplayTitle(finding);
|
||||
const canSend = canAsk && taskAvailable === true && !running && !sending && Boolean(input.trim());
|
||||
let footerStatus = "Enter 发送,Shift+Enter 换行。";
|
||||
if (sending) footerStatus = "正在发送…";
|
||||
else if (running) footerStatus = "主 Agent 运行中,可停止当前执行。";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)} aria-label="问 Agent">
|
||||
<MessageCircleQuestionIcon />问 Agent
|
||||
</Button>
|
||||
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b pr-12">
|
||||
<SheetTitle className="truncate">问 Agent · {title}</SheetTitle>
|
||||
<SheetDescription className="flex min-w-0 items-center gap-1">
|
||||
所属任务:
|
||||
{taskId ? (
|
||||
<Link className="truncate text-primary hover:underline" href={`/function/tasks/detail?id=${taskId}`}>
|
||||
{finding.task_description ?? `#${taskId}`}
|
||||
</Link>
|
||||
) : (
|
||||
<span>无</span>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{unavailableReason ? (
|
||||
<Alert variant="destructive" className="m-4 mb-0">
|
||||
<AlertDescription>{unavailableReason}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div ref={contentRef} className="min-h-full p-4">
|
||||
{loading ? <LoadingActivity /> : null}
|
||||
{!loading && activities.length ? (
|
||||
<Transcript
|
||||
activity={activities}
|
||||
live={running}
|
||||
taskId={taskId}
|
||||
chat
|
||||
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail)}
|
||||
/>
|
||||
) : null}
|
||||
{!loading && !activities.length ? <EmptyActivity available={taskAvailable === true} /> : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="border-t bg-background p-3">
|
||||
<div className="flex w-full items-end gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (!shouldSubmitOnKey(event, sendMode)) return;
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
disabled={!canAsk || taskAvailable !== true || sending || running}
|
||||
placeholder={running ? "主 Agent 正在运行…" : "描述复现现象或继续提问…"}
|
||||
aria-label="向漏洞所属任务的主 Agent 提问"
|
||||
className="max-h-32 min-h-10 resize-none"
|
||||
/>
|
||||
{running ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => void stop()}
|
||||
disabled={stopping}
|
||||
title="停止当前执行"
|
||||
aria-label="停止当前执行"
|
||||
>
|
||||
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="w-full text-[11px] text-muted-foreground">{footerStatus}</p>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mentionToken } from "../lib/chat-mentions";
|
||||
import type { Finding } from "../lib/types";
|
||||
|
||||
type FindingContextFields = Finding & {
|
||||
url?: string;
|
||||
endpoint?: string;
|
||||
entry_point?: string;
|
||||
};
|
||||
|
||||
export function canAskFindingAgent(finding: Pick<Finding, "task_id" | "inherited"> | null | undefined): boolean {
|
||||
return Boolean(finding?.task_id && !finding.inherited);
|
||||
}
|
||||
|
||||
export function findingDisplayTitle(finding: Pick<Finding, "id" | "name" | "vulnclass">): string {
|
||||
if (finding.name?.trim()) return finding.name;
|
||||
if (finding.vulnclass?.trim()) return finding.vulnclass;
|
||||
return `漏洞 #${finding.id}`;
|
||||
}
|
||||
|
||||
export function findingAgentUnavailableReason(
|
||||
finding: Pick<Finding, "task_id" | "inherited"> | null | undefined,
|
||||
): string {
|
||||
if (!finding?.task_id) return "无所属任务,无法连接到主 Agent。";
|
||||
if (finding.inherited) return "继承漏洞只读,请到源任务提问。";
|
||||
return "";
|
||||
}
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
return compact.length > max ? `${compact.slice(0, max)}…` : compact;
|
||||
}
|
||||
|
||||
/**
|
||||
* The finding mention is included once, in the first turn, so later user turns
|
||||
* stay short while the task's main Agent still has an unambiguous finding anchor.
|
||||
*/
|
||||
export function buildFindingAgentContextMessage(finding: Finding): string {
|
||||
const extended = finding as FindingContextFields;
|
||||
const numericId = Number(finding.id);
|
||||
const mention = mentionToken({
|
||||
kind: "finding",
|
||||
id: Number.isFinite(numericId) ? numericId : 0,
|
||||
label: truncate(findingDisplayTitle(finding), 100),
|
||||
description: truncate(finding.summary || "", 200),
|
||||
});
|
||||
const lines = [
|
||||
"用户正在漏洞详情页复现并提问。",
|
||||
mention,
|
||||
`严重等级:${finding.severity};漏洞类型:${finding.vulnclass || "未分类"}。`,
|
||||
];
|
||||
const summary = truncate(finding.summary || "", 300);
|
||||
if (summary) lines.push(`摘要:${summary}`);
|
||||
const entryPoint = extended.url || extended.entry_point || extended.endpoint;
|
||||
if (entryPoint) lines.push(`入口点:${truncate(entryPoint, 240)}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { createRequire } = require("node:module");
|
||||
const test = require("node:test");
|
||||
|
||||
// Keep the tests runnable on Node 20, which does not load TypeScript natively.
|
||||
const requireFromTest = createRequire(__filename);
|
||||
requireFromTest("ts-node").register({
|
||||
transpileOnly: true,
|
||||
compilerOptions: { module: "CommonJS", moduleResolution: "Node10" },
|
||||
});
|
||||
|
||||
const {
|
||||
buildFindingAgentContextMessage,
|
||||
canAskFindingAgent,
|
||||
findingAgentUnavailableReason,
|
||||
} = requireFromTest("../components/finding-agent-chat.ts");
|
||||
|
||||
const finding = (overrides = {}) => ({
|
||||
id: "42",
|
||||
vulnclass: "SQL 注入",
|
||||
name: "搜索参数注入",
|
||||
severity: "high",
|
||||
status: "confirmed",
|
||||
summary: "搜索参数可控制数据库查询。",
|
||||
evidence: "large evidence omitted",
|
||||
ts: "2026-10-27T00:00:00Z",
|
||||
task_id: "task-7",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("finding without task cannot ask its Agent", () => {
|
||||
assert.equal(canAskFindingAgent(finding({ task_id: undefined })), false);
|
||||
assert.match(findingAgentUnavailableReason(finding({ task_id: undefined })), /无所属任务/);
|
||||
});
|
||||
|
||||
test("task-owned finding can ask and context contains a finding mention", () => {
|
||||
assert.equal(canAskFindingAgent(finding()), true);
|
||||
const context = buildFindingAgentContextMessage(finding());
|
||||
assert.match(context, /用户正在漏洞详情页复现并提问/);
|
||||
assert.match(context, /@\[漏洞#42 搜索参数注入\]/);
|
||||
assert.match(context, /SQL 注入/);
|
||||
});
|
||||
|
||||
test("inherited finding is read-only", () => {
|
||||
assert.equal(canAskFindingAgent(finding({ inherited: true, source_task_id: "source-1" })), false);
|
||||
assert.match(findingAgentUnavailableReason(finding({ inherited: true })), /继承漏洞只读/);
|
||||
});
|
||||
Reference in new issue
Block a user