mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-13 06:03:49 +08:00
fix(findings): address grok review on traffic step_index UX, ref merge, tool schema migration
This commit is contained in:
1 parent
fb0146f4b6
commit
8ad507e31f
6 files changed
+433
-125
No files matched your search
@@ -19,6 +19,15 @@ func (failingFindingRecorder) Record(context.Context, db.RecordFindingInput, []d
|
||||
return nil, errors.New("fixture persistence failed")
|
||||
}
|
||||
|
||||
type capturingFindingRecorder struct {
|
||||
refs []db.TrafficRef
|
||||
}
|
||||
|
||||
func (r *capturingFindingRecorder) Record(_ context.Context, _ db.RecordFindingInput, refs []db.TrafficRef) (*db.RecordedFinding, error) {
|
||||
r.refs = append([]db.TrafficRef(nil), refs...)
|
||||
return &db.RecordedFinding{Traffic: &db.FindingTraffic{}}, nil
|
||||
}
|
||||
|
||||
func TestReportFindingOptionalTrafficWithoutCapture(t *testing.T) {
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
@@ -90,6 +99,45 @@ func TestReportFindingPersistsStructuredReproduction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportFindingStepTrafficPreservesExplicitReferenceMetadata(t *testing.T) {
|
||||
old := FindingTrafficBindingEnabled
|
||||
FindingTrafficBindingEnabled = func() bool { return true }
|
||||
t.Cleanup(func() { FindingTrafficBindingEnabled = old })
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
task, err := d.CreateTask("step traffic merge", "fixture", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(task.ID)
|
||||
recorder := &capturingFindingRecorder{}
|
||||
ts := NewToolSet(d.Exploration(task.ExplorationID), "fixture")
|
||||
ts.taskID = task.ID
|
||||
ts.SetFindingRecorder(recorder)
|
||||
reproduction := `{"steps":[{"traffic_ids":["shared"]},{"traffic_ids":["new-step"]}]}`
|
||||
input := `{"vulnclass":"TEST","severity":"low","summary":"step merge","traffic_refs":[{"traffic_id":"shared","role":"proof","note":"preserve this note"},{"traffic_id":"overview","role":"baseline","note":"overview note"}],"reproduction":` + reproduction + `}`
|
||||
result, err := ts.addFinding().Call(t.Context(), json.RawMessage(input), nil)
|
||||
if err != nil || result.IsError {
|
||||
t.Fatalf("report failed: %v %s", err, result.Flatten())
|
||||
}
|
||||
if len(recorder.refs) != 3 {
|
||||
t.Fatalf("unexpected refs: %#v", recorder.refs)
|
||||
}
|
||||
byID := make(map[string]db.TrafficRef, len(recorder.refs))
|
||||
for _, ref := range recorder.refs {
|
||||
byID[ref.TrafficID] = ref
|
||||
}
|
||||
if got := byID["shared"]; got.Role != "proof" || got.Note != "preserve this note" || got.StepIndex != 1 {
|
||||
t.Fatalf("overlapping ref metadata changed: %#v", got)
|
||||
}
|
||||
if got := byID["overview"]; got.Role != "baseline" || got.Note != "overview note" || got.StepIndex != 0 {
|
||||
t.Fatalf("overview ref changed: %#v", got)
|
||||
}
|
||||
if got := byID["new-step"]; got.Role != "supporting" || got.Note != "" || got.StepIndex != 2 {
|
||||
t.Fatalf("new step ref not defaulted: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportFindingAtomicContract(t *testing.T) {
|
||||
old := FindingTrafficBindingEnabled
|
||||
FindingTrafficBindingEnabled = func() bool { return true }
|
||||
|
||||
+20
-8
@@ -1270,8 +1270,9 @@ func (t *ToolSet) addFinding() actool.CoreTool {
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
// Step traffic_ids are resolved at report time. Explicit traffic_refs that
|
||||
// are not listed by a step remain overview bindings (step_index=0).
|
||||
// Step traffic_ids are resolved at report time. Keep metadata supplied by
|
||||
// traffic_refs when an id is mentioned by a reproduction step; only ids
|
||||
// that are absent from traffic_refs get the supporting default.
|
||||
if findingTrafficBindingEnabled() && len(a.Reproduction) > 0 && string(a.Reproduction) != "null" {
|
||||
var recipe struct {
|
||||
Steps []struct {
|
||||
@@ -1281,19 +1282,30 @@ func (t *ToolSet) addFinding() actool.CoreTool {
|
||||
if err := json.Unmarshal(a.Reproduction, &recipe); err != nil {
|
||||
return actool.Errorf(fmt.Sprintf("reproduction JSON 无效: %v", err)), nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
stepRefs := make([]db.TrafficRef, 0)
|
||||
refIndex := make(map[string]int, len(refs))
|
||||
for i := range refs {
|
||||
id := strings.TrimSpace(refs[i].TrafficID)
|
||||
if id != "" {
|
||||
refs[i].TrafficID = id
|
||||
if _, exists := refIndex[id]; !exists {
|
||||
refIndex[id] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, step := range recipe.Steps {
|
||||
for _, id := range step.TrafficIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
stepRefs = append(stepRefs, db.TrafficRef{TrafficID: id, Role: "supporting", StepIndex: i + 1})
|
||||
if ref, exists := refIndex[id]; exists {
|
||||
refs[ref].StepIndex = i + 1
|
||||
continue
|
||||
}
|
||||
refIndex[id] = len(refs)
|
||||
refs = append(refs, db.TrafficRef{TrafficID: id, Role: "supporting", StepIndex: i + 1})
|
||||
}
|
||||
}
|
||||
refs = append(stepRefs, refs...)
|
||||
}
|
||||
input := db.RecordFindingInput{TaskID: t.taskID, ExplorationID: t.ts.ID(), IntentID: pid(a.IntentID), VulnClass: a.VulnClass, Name: a.Name, Severity: a.Severity, Summary: a.Summary, Evidence: a.Evidence, Worker: t.worker, AssetIDs: pidList(a.AssetIDs), Reproduction: a.Reproduction}
|
||||
var recorded *db.RecordedFinding
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) seedFindingWorkflowTools() {
|
||||
s.migrateFindingWorkflowSchemas()
|
||||
const hostSearchDescriptionFlag = "finding_workflow_tools_v3_host_search_description"
|
||||
if value, _, _ := s.m.pg.GetSetting(hostSearchDescriptionFlag); value != "true" {
|
||||
// Only replace the original built-in text. A user-edited description is
|
||||
@@ -106,6 +107,51 @@ func (s *Server) seedFindingWorkflowTools() {
|
||||
_ = s.m.pg.SetSetting(flag, "true")
|
||||
}
|
||||
|
||||
// migrateFindingWorkflowSchemas adds schema fields introduced after the
|
||||
// original finding workflow seed. It patches only missing properties so a
|
||||
// user's description, agent bindings, enabled state, and other schema edits
|
||||
// remain untouched.
|
||||
func (s *Server) migrateFindingWorkflowSchemas() {
|
||||
const flag = "finding_workflow_tools_v3_reproduction_step_index"
|
||||
if value, _, _ := s.m.pg.GetSetting(flag); value == "true" {
|
||||
return
|
||||
}
|
||||
properties := map[string]json.RawMessage{}
|
||||
for _, seed := range agent.BuiltinToolSeeds() {
|
||||
if seed.Key != "report_finding" {
|
||||
continue
|
||||
}
|
||||
if raw, err := json.Marshal(objectProperty(seed.Schema, "properties")["reproduction"]); err == nil && string(raw) != "null" {
|
||||
properties["report_finding"] = raw
|
||||
}
|
||||
break
|
||||
}
|
||||
for _, tool := range s.orchestrationTools() {
|
||||
if tool.Name() != "bind_finding_traffic" {
|
||||
continue
|
||||
}
|
||||
schema := tool.InputSchema()
|
||||
if raw, err := json.Marshal(objectProperty(schema, "properties")["step_index"]); err == nil && string(raw) != "null" {
|
||||
properties["bind_finding_traffic"] = raw
|
||||
}
|
||||
break
|
||||
}
|
||||
for key, property := range map[string]string{"report_finding": "reproduction", "bind_finding_traffic": "step_index"} {
|
||||
raw := properties[key]
|
||||
if len(raw) == 0 {
|
||||
log.Printf("[evidence] missing code schema property %s.%s", key, property)
|
||||
continue
|
||||
}
|
||||
if _, err := s.m.pg.Exec(`UPDATE tools
|
||||
SET schema=jsonb_set(COALESCE(schema,'{}'::jsonb),ARRAY['properties',$2::text],$3::jsonb,true),updated_at=now()
|
||||
WHERE key=$1 AND system AND NOT(COALESCE(schema->'properties','{}'::jsonb) ? $2)`, key, property, string(raw)); err != nil {
|
||||
log.Printf("[evidence] upgrade %s.%s: %v", key, property, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = s.m.pg.SetSetting(flag, "true")
|
||||
}
|
||||
|
||||
func objectProperty(parent map[string]any, key string) map[string]any {
|
||||
value, ok := parent[key].(map[string]any)
|
||||
if !ok {
|
||||
|
||||
@@ -227,6 +227,9 @@ func TestFindingWorkflowMigrationPreservesUserConfiguration(t *testing.T) {
|
||||
if err := pg.SetSetting("finding_workflow_tools_v2_reporter", "false"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pg.SetSetting("finding_workflow_tools_v3_reproduction_step_index", "false"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.seedFindingWorkflowTools()
|
||||
for _, key := range []string{"report_finding", "add_hint", "add_task_hint"} {
|
||||
row, _ := pg.GetTool(key)
|
||||
@@ -243,6 +246,13 @@ func TestFindingWorkflowMigrationPreservesUserConfiguration(t *testing.T) {
|
||||
if !strings.Contains(string(row.Schema), field) {
|
||||
t.Fatal("missing optional field", key)
|
||||
}
|
||||
if key == "report_finding" && !strings.Contains(string(row.Schema), "reproduction") {
|
||||
t.Fatal("missing reproduction schema migration")
|
||||
}
|
||||
}
|
||||
bindSchema, _ := pg.GetTool("bind_finding_traffic")
|
||||
if !strings.Contains(string(bindSchema.Schema), "step_index") {
|
||||
t.Fatal("missing bind_finding_traffic step_index migration")
|
||||
}
|
||||
search, _ := pg.GetTool("traffic_search")
|
||||
get, _ := pg.GetTool("traffic_get")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ExternalLinkIcon, PlusIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
|
||||
@@ -10,8 +11,27 @@ import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingTraffic, Reproduction } from "@/lib/types";
|
||||
import type { FindingTraffic, FindingTrafficBinding, Reproduction, TrafficEvidenceRole } from "@/lib/types";
|
||||
|
||||
const ROLES: Record<TrafficEvidenceRole, string> = {
|
||||
baseline: "正常对照",
|
||||
proof: "漏洞证明",
|
||||
verification: "补充验证",
|
||||
supporting: "辅助证据",
|
||||
};
|
||||
|
||||
export function hasStructuredReproduction(reproduction?: Reproduction | null) {
|
||||
return Boolean(reproduction && ((reproduction.steps?.length ?? 0) > 0 || reproduction.entry_point));
|
||||
@@ -33,14 +53,75 @@ export function FindingReproductionSection({
|
||||
const [traffic, setTraffic] = React.useState<FindingTraffic | null>(null);
|
||||
const [pickerStep, setPickerStep] = React.useState<number | null>(null);
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
|
||||
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
|
||||
const [note, setNote] = React.useState("");
|
||||
const [editingStepIndex, setEditingStepIndex] = React.useState("0");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [reload, setReload] = React.useState(0);
|
||||
const load = React.useCallback(() => {
|
||||
api.findingTraffic(findingId, contextTask).then(setTraffic).catch(() => setTraffic(null));
|
||||
api
|
||||
.findingTraffic(findingId, contextTask)
|
||||
.then(setTraffic)
|
||||
.catch(() => setTraffic(null));
|
||||
}, [findingId, contextTask]);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: retry token intentionally refreshes this request.
|
||||
React.useEffect(() => load(), [load, reload]);
|
||||
|
||||
const steps = reproduction.steps ?? [];
|
||||
const allBindings = traffic?.bindings ?? [];
|
||||
async function mutate(action: () => Promise<FindingTraffic>) {
|
||||
setBusy(true);
|
||||
try {
|
||||
setTraffic(await action());
|
||||
setEditing(null);
|
||||
onChanged?.();
|
||||
toast.success("流量证据已更新");
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
setReload((value) => value + 1);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function editBinding(item: FindingTrafficBinding) {
|
||||
setEditing(item);
|
||||
setRole(item.role);
|
||||
setNote(item.note);
|
||||
setEditingStepIndex(String(item.step_index ?? 0));
|
||||
}
|
||||
function bindingActions(item: FindingTrafficBinding) {
|
||||
return (
|
||||
<div key={item.id} className="flex flex-wrap items-center gap-1 rounded border px-2 py-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ROLES[item.role]}
|
||||
</Badge>
|
||||
{item.note ? <span className="max-w-64 truncate text-muted-foreground text-xs">{item.note}</span> : null}
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setPreview(item.id)}>
|
||||
<ExternalLinkIcon className="size-3" /> 查看流量
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" disabled={busy} onClick={() => editBinding(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={busy || !traffic}
|
||||
onClick={() => {
|
||||
if (traffic)
|
||||
void mutate(() => api.removeFindingTraffic(findingId, item.id, traffic.version, contextTask));
|
||||
}}
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -57,7 +138,9 @@ export function FindingReproductionSection({
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
<div className="text-muted-foreground">前置条件</div>
|
||||
<ul className="ml-5 list-disc">
|
||||
{reproduction.prerequisites?.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}
|
||||
{reproduction.prerequisites?.map((item, index) => (
|
||||
<li key={`${index}-${item}`}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -69,15 +152,16 @@ export function FindingReproductionSection({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">步骤 {stepIndex}</Badge>
|
||||
{step.expected ? <span className="text-sm">预期:{step.expected}</span> : null}
|
||||
{step.proves ? <span className="text-sm text-muted-foreground">证明:{step.proves}</span> : null}
|
||||
{step.proves ? <span className="text-muted-foreground text-sm">证明:{step.proves}</span> : null}
|
||||
<div className="ml-auto flex gap-1">
|
||||
{bound.map((item) => (
|
||||
<Button key={item.id} variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setPreview(item.id)}>
|
||||
<ExternalLinkIcon className="size-3" /> 查看流量
|
||||
</Button>
|
||||
))}
|
||||
{bound.map(bindingActions)}
|
||||
{!readOnly ? (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setPickerStep(stepIndex)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setPickerStep(stepIndex)}
|
||||
>
|
||||
<PlusIcon className="size-3" /> 绑定流量
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -86,15 +170,30 @@ export function FindingReproductionSection({
|
||||
{step.request?.trim() ? <HttpCodeBlock raw={step.request} /> : null}
|
||||
{step.response?.trim() ? (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-muted-foreground">响应</div>
|
||||
<div className="mb-1 text-muted-foreground text-xs">响应</div>
|
||||
<HttpCodeBlock raw={step.response} />
|
||||
</div>
|
||||
) : null}
|
||||
{bound.length > 0 ? <div className="text-xs text-muted-foreground">已绑定 {bound.length} 条流量</div> : null}
|
||||
{bound.length > 0 ? (
|
||||
<div className="text-muted-foreground text-xs">已绑定 {bound.length} 条流量</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{reproduction.provenance ? <p className="text-xs text-muted-foreground">来源:{reproduction.provenance}</p> : null}
|
||||
{allBindings.some((item) => (item.step_index ?? 0) > steps.length) ? (
|
||||
<div className="space-y-2 rounded-md border border-dashed p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">超出步骤范围</Badge>
|
||||
<span className="text-muted-foreground text-sm">这些绑定仍可编辑、解绑或改回总览/有效步骤。</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allBindings.filter((item) => (item.step_index ?? 0) > steps.length).map(bindingActions)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{reproduction.provenance ? (
|
||||
<p className="text-muted-foreground text-xs">来源:{reproduction.provenance}</p>
|
||||
) : null}
|
||||
{pickerStep !== null ? (
|
||||
<TrafficPickerDialog
|
||||
findingId={findingId}
|
||||
@@ -109,7 +208,86 @@ export function FindingReproductionSection({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<TrafficEvidenceViewer findingId={findingId} bindingId={preview} contextTask={contextTask} onClose={() => setPreview(null)} />
|
||||
<TrafficEvidenceViewer
|
||||
findingId={findingId}
|
||||
bindingId={preview}
|
||||
contextTask={contextTask}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) setEditing(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑流量证据</DialogTitle>
|
||||
<DialogDescription>修改用途、说明或所属复现步骤。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reproduction-evidence-role">用途</FieldLabel>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as TrafficEvidenceRole)}>
|
||||
<SelectTrigger id="reproduction-evidence-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(ROLES).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reproduction-evidence-note">证据说明</FieldLabel>
|
||||
<Textarea
|
||||
id="reproduction-evidence-note"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reproduction-evidence-step">复现步骤序号</FieldLabel>
|
||||
<Input
|
||||
id="reproduction-evidence-step"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={editingStepIndex}
|
||||
onChange={(event) => setEditingStepIndex(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || !editing || !traffic}
|
||||
onClick={() => {
|
||||
if (editing && traffic) {
|
||||
void mutate(() =>
|
||||
api.editFindingTraffic(
|
||||
findingId,
|
||||
editing.id,
|
||||
traffic.version,
|
||||
{ role, note, step_index: Math.max(0, Number.parseInt(editingStepIndex, 10) || 0) },
|
||||
contextTask,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -25,10 +25,10 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingTraffic, FindingTrafficBinding, TrafficEvidenceRole } from "@/lib/types";
|
||||
|
||||
@@ -63,7 +63,7 @@ export function FindingTrafficPanel({
|
||||
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
|
||||
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
|
||||
const [note, setNote] = React.useState("");
|
||||
const [selectedStepIndex, setSelectedStepIndex] = React.useState("0");
|
||||
const [editingStepIndex, setEditingStepIndex] = React.useState("0");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [reload, setReload] = React.useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: retry token intentionally refreshes this request.
|
||||
@@ -75,11 +75,7 @@ export function FindingTrafficPanel({
|
||||
.findingTraffic(findingId, contextTask)
|
||||
.then((value) => {
|
||||
if (active) {
|
||||
setData(
|
||||
filterStepIndex === undefined
|
||||
? value
|
||||
: { ...value, bindings: value.bindings.filter((b) => (b.step_index ?? 0) === filterStepIndex) },
|
||||
);
|
||||
setData(value);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
@@ -107,11 +103,116 @@ export function FindingTrafficPanel({
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
if (!data) return;
|
||||
const visibleBindings = data.bindings.filter(
|
||||
(b) => filterStepIndex === undefined || (b.step_index ?? 0) === filterStepIndex,
|
||||
);
|
||||
const target = visibleBindings[index + delta];
|
||||
if (!target) return;
|
||||
const ids = data.bindings.map((b) => b.id);
|
||||
[ids[index], ids[index + delta]] = [ids[index + delta], ids[index]];
|
||||
const from = ids.indexOf(visibleBindings[index].id);
|
||||
const to = ids.indexOf(target.id);
|
||||
if (from < 0 || to < 0) return;
|
||||
[ids[from], ids[to]] = [ids[to], ids[from]];
|
||||
void mutate(() => api.orderFindingTraffic(findingId, ids, data.version, contextTask));
|
||||
}
|
||||
|
||||
const visibleBindings =
|
||||
data?.bindings.filter((b) => filterStepIndex === undefined || (b.step_index ?? 0) === filterStepIndex) ?? [];
|
||||
const addStepIndex = filterStepIndex ?? 0;
|
||||
|
||||
function renderBindings() {
|
||||
if (!data) return <Skeleton className="h-28 w-full" />;
|
||||
if (visibleBindings.length === 0) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>暂无关联流量</EmptyTitle>
|
||||
<EmptyDescription>可绑定正常对照、漏洞证明和补充验证请求。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
return visibleBindings.map((b, index) => (
|
||||
<div key={b.id} className="flex min-w-0 flex-col gap-2 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{index + 1}. {ROLES[b.role]}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{b.snapshot.method} · {b.snapshot.status}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">证据 #{b.id}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start whitespace-normal p-0 text-left"
|
||||
onClick={() => setPreview(b.id)}
|
||||
>
|
||||
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
|
||||
</Button>
|
||||
{b.note ? <p className="whitespace-pre-wrap text-sm">{b.note}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(b.snapshot.captured_at * 1000).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
|
||||
查看报文
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={repeaterBusy} onClick={() => void seedRepeater(b.id)}>
|
||||
送入请求重发
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(b);
|
||||
setRole(b.role);
|
||||
setNote(b.note);
|
||||
setEditingStepIndex(String(b.step_index ?? 0));
|
||||
}}
|
||||
>
|
||||
编辑说明
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`上移证据 ${b.id}`}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`下移证据 ${b.id}`}
|
||||
disabled={busy || index === visibleBindings.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void mutate(() => api.removeFindingTraffic(findingId, b.id, data.version, contextTask))
|
||||
}
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
async function seedRepeater(bindingId: string) {
|
||||
setRepeaterBusy(true);
|
||||
try {
|
||||
@@ -143,7 +244,9 @@ export function FindingTrafficPanel({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle>{title ?? "关联流量"} {data ? `(${data.bindings.length})` : ""}</CardTitle>
|
||||
<CardTitle>
|
||||
{title ?? "关联流量"} {data ? `(${visibleBindings.length})` : ""}
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -178,96 +281,7 @@ export function FindingTrafficPanel({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!data ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
) : data.bindings.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>暂无关联流量</EmptyTitle>
|
||||
<EmptyDescription>可绑定正常对照、漏洞证明和补充验证请求。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
data.bindings.map((b, index) => (
|
||||
<div key={b.id} className="flex min-w-0 flex-col gap-2 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{index + 1}. {ROLES[b.role]}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{b.snapshot.method} · {b.snapshot.status}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">证据 #{b.id}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start whitespace-normal p-0 text-left"
|
||||
onClick={() => setPreview(b.id)}
|
||||
>
|
||||
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
|
||||
</Button>
|
||||
{b.note ? <p className="whitespace-pre-wrap text-sm">{b.note}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(b.snapshot.captured_at * 1000).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
|
||||
查看报文
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={repeaterBusy} onClick={() => void seedRepeater(b.id)}>
|
||||
送入请求重发
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(b);
|
||||
setRole(b.role);
|
||||
setNote(b.note);
|
||||
setSelectedStepIndex(String(b.step_index ?? 0));
|
||||
}}
|
||||
>
|
||||
编辑说明
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`上移证据 ${b.id}`}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`下移证据 ${b.id}`}
|
||||
disabled={busy || index === data.bindings.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void mutate(() => api.removeFindingTraffic(findingId, b.id, data.version, contextTask))
|
||||
}
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{renderBindings()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{adding && data ? (
|
||||
@@ -275,7 +289,7 @@ export function FindingTrafficPanel({
|
||||
findingId={findingId}
|
||||
contextTask={contextTask}
|
||||
bound={data.bindings}
|
||||
stepIndex={Math.max(0, Number.parseInt(selectedStepIndex, 10) || 0)}
|
||||
stepIndex={addStepIndex}
|
||||
onClose={() => setAdding(false)}
|
||||
onBound={() => {
|
||||
setReload((n) => n + 1);
|
||||
@@ -329,8 +343,8 @@ export function FindingTrafficPanel({
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={selectedStepIndex}
|
||||
onChange={(e) => setSelectedStepIndex(e.target.value)}
|
||||
value={editingStepIndex}
|
||||
onChange={(e) => setEditingStepIndex(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
@@ -347,7 +361,7 @@ export function FindingTrafficPanel({
|
||||
findingId,
|
||||
editing.id,
|
||||
data.version,
|
||||
{ role, note, step_index: Math.max(0, Number.parseInt(selectedStepIndex, 10) || 0) },
|
||||
{ role, note, step_index: Math.max(0, Number.parseInt(editingStepIndex, 10) || 0) },
|
||||
contextTask,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user