Compare commits

...
Author SHA1 Message Date
ji ruo 0ff2130f46 fix: recover expired Basic Auth sessions in the current tab 2026-11-10 16:28:23 +08:00
ji ruo a05a886d3d fix: support traffic ID copying over HTTP 2026-11-09 16:17:03 +08:00
RuoJi6 9fe993f460 Merge pull request #126 from RuoJi6/codex/issue-125-traffic-id-filter-counts
feat: 展示流量 ID 并让发现统计跟随筛选
2026-11-09 16:15:09 +08:00
ji ruo 8ee94f2215 feat: expose traffic IDs and filter finding statistics 2026-11-09 16:11:15 +08:00
20 changed files with 608 additions and 91 deletions

No files matched your search

+1 -1
View File
@@ -326,7 +326,7 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
从仍使用 `/api/health` 读取版本的旧版网页升级到此版本时,旧页面可能无法自动判断升级完成;服务重启后手动刷新页面即可。新版网页的版本显示和升级轮询均使用 `/api/version`。
配置持久化到数据库,密码仅保存 bcrypt 哈希,界面不会回显。保存时密码留空表示不修改;关闭后保留凭据,方便再次开启。独立的 HttpOnly 验证 Cookie 有效期为 12 小时,兼容现有 JWT 和 SSE;保存配置会使其他浏览器的旧验证 Cookie 失效,当前管理员浏览器保持访问。浏览器可能缓存 Basic Auth 凭据,可用新的无痕窗口检查验证弹窗。
配置持久化到数据库,密码仅保存 bcrypt 哈希,界面不会回显。保存时密码留空表示不修改;关闭后保留凭据,方便再次开启。独立的 HttpOnly 验证 Cookie 有效期为 12 小时,兼容现有 JWT 和 SSE。验证过期时,后台请求会触发当前页面重新验证,保留 ARTEX 登录状态,无需新开标签页;保存配置会使其他浏览器的旧验证 Cookie 失效,当前管理员浏览器保持访问。浏览器可能缓存 Basic Auth 凭据,可用新的无痕窗口检查验证弹窗。
Docker 和内嵌前端二进制直接支持此功能。`next dev` 的认证检查仅向本机回环后端转发凭据(`localhost`、`127.0.0.1` 或 `::1`);前端与后端应使用同一主机名。远程开发后端未开启验证时仍可使用;开启后请通过内嵌前端访问远程服务,避免跨域验证 Cookie 问题。
+1 -1
View File
@@ -77,7 +77,7 @@ func findingWorkflowTools(agentKey string, tools []actool.CoreTool) ([]actool.Co
guidance += "\n\n**报告前自动关联流量(已开启)**:你负责为本次触发的漏洞核对并绑定流量,再撰写报告。先从 report_finding 返回 JSON 或 get_task_node_detail / list_task_findings 取得明确的 finding_id 与 finding_node_id。读取漏洞详情、对应意图的执行记录及已有证据清单,优先使用上报者交接的真实 ID。若本次验证为 HTTP 且流量工具可用,用 traffic_search 筛选候选,再用 traffic_get 逐条核实请求/响应确实支持该漏洞;域名和时间只用于筛选,不证明归属。将确认的证据按复现顺序用 bind_finding_traffic(finding_id, traffic_refs) 关联,选择 baseline / proof / verification / supporting 并说明用途。只能操作本次漏洞,不重复创建漏洞或重新探测目标。绑定成功后重新调用 get_finding_traffic 获取最新 version,读取所需正文,再将实际读取的 version 作为 evidence_version 传给 update_finding_report(其 finding_id 参数仍用 finding_node_id)。已有绑定不必重复追加。TCP、未采集、工具不可用或没有确切匹配时,跳过自动绑定,依据文字/命令证据正常写报告并说明原因,不得为凑齐流量而猜测。绑定失败不宣称成功;保留已有证据并在报告说明未绑定原因。"
}
if guidance != "" || has["get_finding_traffic"] || has["update_finding_report"] {
guidance += findingIDGuidance
guidance += "\n\n**流量 ID 约定**:流量页面展示/复制的流量 ID 就是 traffic_search 返回的 id,也是 traffic_get(id) 和 traffic_refs[].traffic_id 使用的原始字符串。它不是列表行号,不随分页、排序或筛选变化。用户交接完整 ID 时优先直接用 traffic_get 读取并核实,不要改写、截短或猜测 ID;报告引用流量证据时保留对应完整 ID,方便从流量页面检索。" + findingIDGuidance
}
return out, guidance
}
+3
View File
@@ -38,6 +38,9 @@ func TestFindingWorkflowSharedAssemblyAndSwitch(t *testing.T) {
if on && (!strings.Contains(system[0], "TCP") || !strings.Contains(system[0], "evidence_hint_id")) {
t.Fatal("missing optional/handoff contract")
}
if on && (!strings.Contains(system[0], "不是列表行号") || !strings.Contains(system[0], "完整 ID")) {
t.Fatal("missing stable traffic ID handoff contract")
}
for _, tool := range out {
if !strings.HasPrefix(tool.Description(), "CUSTOM DESCRIPTION") {
t.Fatal("custom description replaced")
+36
View File
@@ -656,6 +656,42 @@ func (d *DB) FindingStats() (*FindingStats, error) {
return st, nil
}
// FilteredFindingStats uses the same row and asset filters as the findings list.
// Options remain global so choosing a filter never removes other choices.
// Counts cover matching hot rows, exactly like ListFindingCases; archive-only
// aggregates cannot be filtered by record content and are not list results.
func (d *DB) FilteredFindingStats(filter FindingFilter) (*FindingStats, error) {
st, err := d.FindingStats()
if err != nil {
return nil, err
}
filter, err = d.applyAssetScope(filter)
if err != nil {
return nil, err
}
where, args := filter.where()
st.Distinct = new(FindingDistinctStats)
err = d.QueryRow(`SELECT COUNT(*),
COUNT(*) FILTER (WHERE f.status='pending'),
COUNT(*) FILTER (WHERE f.severity='critical'),
COUNT(*) FILTER (WHERE f.severity='high'),
COUNT(*) FILTER (WHERE f.severity='medium'),
COUNT(*) FILTER (WHERE f.severity='low'),
COUNT(DISTINCT CASE WHEN m.case_id IS NULL THEN 'f:'||f.id ELSE 'c:'||m.case_id END)
FROM findings f LEFT JOIN tasks t ON t.id=f.task_id
LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where, args...).Scan(
&st.Total, &st.Pending, &st.Critical, &st.High, &st.Medium, &st.Low, &st.Distinct.Total)
if err != nil {
return nil, err
}
// Severity buckets describe matching original reports, not a folder's rating.
st.Distinct.Reports = st.Total
st.Distinct.Critical, st.Distinct.High = st.Critical, st.High
st.Distinct.Medium, st.Distinct.Low = st.Medium, st.Low
st.Distinct.Unassessed = st.Total - st.Critical - st.High - st.Medium - st.Low
return st, nil
}
// GetFinding returns a single finding row (with task_description joined and the
// full Markdown report), or nil when no row has that id. Unlike the list queries
// it also selects `report` — that column is only needed on the detail page.
+74
View File
@@ -0,0 +1,74 @@
package db
import (
"context"
"fmt"
"slices"
"testing"
)
func TestFilteredFindingStatsMatchesList(t *testing.T) {
d, task, ids := timeSortFixture(t)
if _, err := d.MergeFindingCase(context.Background(), task.ID, ids[:3], "same defect", "verified shared root cause", "test"); err != nil {
t.Fatal(err)
}
if _, err := d.SetFindingStatus(ids[0], FindingResolved); err != nil {
t.Fatal(err)
}
if _, err := d.Exec(`UPDATE findings SET name='needle' WHERE id=$1`, ids[1]); err != nil {
t.Fatal(err)
}
asset := seedTreeAsset(t, d, "root_domain", map[string]any{"domain": "issue125.example", "root_domain": "issue125.example"})
t.Cleanup(func() { d.Exec(`DELETE FROM assets WHERE id=$1`, asset) })
if _, err := d.Exec(`UPDATE findings SET asset_ids=$2::jsonb WHERE id=$1`, ids[1], fmt.Sprintf("[%d]", asset)); err != nil {
t.Fatal(err)
}
base := FindingFilter{TaskID: fmt.Sprint(task.ID)}
for _, tc := range []struct {
name string
filter FindingFilter
reports, defects, critical, high, low int
}{
{"task", base, 4, 2, 1, 2, 1},
{"severity", FindingFilter{TaskID: base.TaskID, Severity: "high"}, 2, 2, 0, 2, 0},
{"status", FindingFilter{TaskID: base.TaskID, Status: FindingResolved}, 1, 1, 1, 0, 0},
{"query", FindingFilter{TaskID: base.TaskID, Query: "needle"}, 1, 1, 0, 1, 0},
{"class", FindingFilter{TaskID: base.TaskID, VulnClass: "IDOR"}, 4, 2, 1, 2, 1},
{"dates", FindingFilter{TaskID: base.TaskID, From: "2026-10-26T00:00:00Z", To: "2026-10-27T23:59:59Z"}, 2, 1, 0, 1, 1},
{"combined", FindingFilter{TaskID: base.TaskID, Severity: "high", Status: FindingPending, Query: "needle", VulnClass: "IDOR", From: "2026-10-26T00:00:00Z"}, 1, 1, 0, 1, 0},
{"asset", FindingFilter{TaskID: base.TaskID, AssetScope: fmt.Sprintf("a:%d", asset)}, 1, 1, 0, 1, 0},
{"unassigned asset", FindingFilter{TaskID: base.TaskID, AssetScope: FindingUnassignedAsset}, 3, 2, 1, 1, 1},
{"empty", FindingFilter{TaskID: base.TaskID, Query: "no matching finding"}, 0, 0, 0, 0, 0},
} {
t.Run(tc.name, func(t *testing.T) {
st, err := d.FilteredFindingStats(tc.filter)
if err != nil {
t.Fatal(err)
}
if st.Total != tc.reports || st.Distinct.Total != tc.defects || st.Critical != tc.critical || st.High != tc.high || st.Low != tc.low {
t.Fatalf("stats=%+v distinct=%+v", st, st.Distinct)
}
_, total, err := d.ListFindingCases(tc.filter, 1, 1)
if err != nil || total != st.Distinct.Total {
t.Fatalf("list total=%d stats=%d err=%v", total, st.Distinct.Total, err)
}
_, reports, err := d.ListFindingsPage(tc.filter, 1, 1)
if err != nil || reports != st.Total {
t.Fatalf("reports=%d stats=%d err=%v", reports, st.Total, err)
}
if !slices.Contains(st.VulnClasses, "IDOR") || !slices.ContainsFunc(st.Tasks, func(option FindingTaskOption) bool { return option.ID == task.ID && option.Count == 4 }) {
t.Fatal("filtered counts removed global options")
}
})
}
// Deleted-task records remain selectable through the unassigned sentinel.
orphan, err := d.AddFinding(0, 0, "__issue125_orphan__", "orphan", "medium", "", "", "test", nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.DeleteFinding(orphan) })
st, err := d.FilteredFindingStats(FindingFilter{TaskID: FindingUnassignedTask, VulnClass: "__issue125_orphan__"})
if err != nil || st.Total != 1 || st.Medium != 1 || st.Distinct.Total != 1 {
t.Fatalf("orphan stats=%+v err=%v", st, err)
}
}
+7 -1
View File
@@ -139,7 +139,13 @@ func (s *Server) requireBasicAuth(next http.Handler) http.Handler {
username, password, ok := r.BasicAuth()
if !ok || len(password) > maxPasswordBytes || username != cfg.Username ||
bcrypt.CompareHashAndPassword([]byte(cfg.PasswordHash), []byte(password)) != nil {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`)
// Background requests use Bearer auth. Challenging them with Basic
// can trap the browser in repeated native dialogs. Let the client
// reload the current page once to renew only the outer session.
w.Header().Set("X-Artex-Auth", "basic")
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/basic-auth/check" || r.URL.Path == "/api/health" {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`)
}
writeErr(w, http.StatusUnauthorized, "请先完成 HTTP Basic Auth 验证")
return
}
+73 -4
View File
@@ -279,8 +279,14 @@ func TestBasicAuthGate(t *testing.T) {
if w.Code != tc.want {
t.Fatalf("status=%d want %d: %s", w.Code, tc.want, w.Body.String())
}
if w.Code == 401 && w.Header().Get("WWW-Authenticate") != `Basic realm="Restricted", charset="UTF-8"` {
t.Fatal("missing browser challenge")
if w.Code == 401 {
wantChallenge := !strings.HasPrefix(tc.path, "/api/") || tc.path == "/api/basic-auth/check" || tc.path == "/api/health"
if got := w.Header().Get("WWW-Authenticate"); (got != "") != wantChallenge {
t.Fatalf("browser challenge=%q, want challenge=%v", got, wantChallenge)
}
if w.Header().Get("X-Artex-Auth") != "basic" {
t.Fatal("missing gate failure marker")
}
}
})
}
@@ -481,8 +487,8 @@ func TestBasicAuthPostgresLoginFlow(t *testing.T) {
r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody))
w = httptest.NewRecorder()
restarted.Handler().ServeHTTP(w, r)
if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" {
t.Fatal("login was not protected after restart")
if w.Code != 401 || w.Header().Get("X-Artex-Auth") != "basic" || w.Header().Get("WWW-Authenticate") != "" {
t.Fatal("login must fail with a recoverable gate error after restart")
}
r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody))
r.SetBasicAuth("entry", "entry-password")
@@ -504,3 +510,66 @@ func TestBasicAuthPostgresLoginFlow(t *testing.T) {
t.Fatalf("disable with gate cookie and JWT: %d %s", w.Code, w.Body.String())
}
}
// An expired gate must be recoverable without discarding the independent admin
// session. API failures must never open native Basic dialogs themselves.
func TestBasicAuthExpiredSessionRecovery(t *testing.T) {
s, _ := basicAuthTestServer(t)
w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"entry-password"}`)
if w.Code != 200 {
t.Fatal(w.Body.String())
}
cfg := s.basicAuth.snapshot()
expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
Subject: cfg.Revision, Audience: jwt.ClaimStrings{"artex-basic-auth"},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
}).SignedString(s.basicAuthSigningKey())
if err != nil {
t.Fatal(err)
}
adminJWT, err := signJWT(s.jwtKey)
if err != nil {
t.Fatal(err)
}
h := s.Handler()
for _, path := range []string{"/api/version", "/api/auth/status", "/api/logs/stream", "/api/workspace/upload", "/api/workspace/download"} {
t.Run(strings.TrimPrefix(path, "/"), func(t *testing.T) {
r := httptest.NewRequest("GET", path, nil)
r.Header.Set("Authorization", "Bearer "+adminJWT)
r.AddCookie(&http.Cookie{Name: basicAuthCookie, Value: expired})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 401 || w.Header().Get("X-Artex-Auth") != "basic" || w.Header().Get("WWW-Authenticate") != "" {
t.Fatalf("API must signal recovery without a browser challenge: %d %v", w.Code, w.Header())
}
if w.Header().Get("Cache-Control") != "no-store" || len(w.Result().Cookies()) != 0 {
t.Fatal("gate failure must not cache or change the admin session")
}
})
}
// Reloading the current document can use Basic authorization, while the
// admin JWT stays in its cookie for subsequent API requests.
r := httptest.NewRequest("GET", "/function/tasks/", nil)
r.AddCookie(&http.Cookie{Name: "artex_token", Value: adminJWT})
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" {
t.Fatal("document navigation must still prompt for Basic authentication")
}
r = httptest.NewRequest("GET", "/api/basic-auth/check", nil)
r.SetBasicAuth("entry", "entry-password")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNoContent || len(w.Result().Cookies()) != 1 {
t.Fatalf("reauthentication failed: %d %s", w.Code, w.Body.String())
}
renewed := w.Result().Cookies()[0]
r = httptest.NewRequest("GET", "/api/version", nil)
r.AddCookie(renewed)
r.Header.Set("Authorization", "Bearer "+adminJWT)
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 200 || w.Header().Get("WWW-Authenticate") != "" {
t.Fatalf("original admin session did not recover: %d %s", w.Code, w.Body.String())
}
}
+37
View File
@@ -0,0 +1,37 @@
package server
import (
"encoding/json"
"fmt"
"testing"
"github.com/Autumn-27/artex/db"
)
func TestFindingStatsFiltersHTTP(t *testing.T) {
s, recorded, request := trafficEvidenceServer(t)
finding, err := s.m.pg.GetFinding(recorded.FindingID)
if err != nil || finding == nil {
t.Fatal("missing fixture finding", err)
}
filter := fmt.Sprintf("task_id=%d&severity=low&status=pending&vulnclass=TEST&q=bound&from=2000-01-01T00:00:00Z&to=2100-01-01T00:00:00Z", *finding.TaskID)
for _, suffix := range []string{"", "&page=2&limit=1"} {
r := request("GET", "/api/exploration/findings/stats?"+filter+suffix, "")
var st db.FindingStats
if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &st) != nil || st.Total != 1 || st.Low != 1 || st.High != 0 || st.Distinct == nil || st.Distinct.Total != 1 {
t.Fatalf("status=%d stats=%s", r.Code, r.Body)
}
}
r := request("GET", "/api/exploration/findings/stats?"+filter+"&asset_scope=__none__", "")
if r.Code != 200 {
t.Fatal(r.Code, r.Body)
}
r = request("GET", fmt.Sprintf("/api/exploration/findings/stats?task_id=%d&severity=high", *finding.TaskID), "")
var st db.FindingStats
if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &st) != nil || st.Total != 0 || st.Distinct.Total != 0 {
t.Fatalf("empty filtered stats=%s", r.Body)
}
if len(st.Tasks) == 0 || len(st.VulnClasses) == 0 {
t.Fatal("empty result removed filter choices")
}
}
+2 -8
View File
@@ -2337,15 +2337,9 @@ func (s *Server) resolveAssetIDs(ids []int64) map[int64]*db.Asset {
return assets
}
// findingStats serves the whole-table aggregates (stat cards + vuln-class filter)
// for the paginated 发现 page.
// findingStats serves filtered, pagination-independent counts and global options.
func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) {
st, err := s.m.pg.FindingStats()
if err != nil {
writeErr(w, 500, err.Error())
return
}
st.Distinct, err = s.m.pg.DistinctFindingStats("")
st, err := s.m.pg.FilteredFindingStats(findingFilterFromQuery(r.URL.Query()))
if err != nil {
writeErr(w, 500, err.Error())
return
+5 -5
View File
@@ -962,13 +962,13 @@ func (t *Traffic) Page(f PageQuery, page, size int) (rows []ExchangeMeta, total
}
if s := strings.TrimSpace(f.Query); s != "" {
like := "%" + s + "%"
const meta = "host LIKE ? OR url LIKE ? OR url_template LIKE ? OR method LIKE ? OR content_type LIKE ? OR CAST(status AS TEXT) LIKE ?"
const meta = "id LIKE ? OR host LIKE ? OR url LIKE ? OR url_template LIKE ? OR method LIKE ? OR content_type LIKE ? OR CAST(status AS TEXT) LIKE ?"
// Metadata match OR full-text match: one search box, widest recall. Terms
// too short for trigram silently fall back to metadata only.
if cond, arg, ok := t.ftsFilter(s); ok {
add("(("+meta+") OR "+cond+")", like, like, like, like, like, like, arg)
add("(("+meta+") OR "+cond+")", like, like, like, like, like, like, like, arg)
} else {
add("("+meta+")", like, like, like, like, like, like)
add("("+meta+")", like, like, like, like, like, like, like)
}
}
// Body is the dedicated content box: it only searches captured request/response
@@ -2148,10 +2148,10 @@ func (t *Traffic) Tools() []actool.CoreTool {
get := actool.Build(actool.Spec{
Name: "traffic_get",
Description: "按 id 取一条已抓流量的请求/响应原文(过大会截断)。配合 traffic_search 用,避免重复 curl。",
Description: "按 id 取一条已抓流量的请求/响应原文(过大会截断)。id 与流量页面展示的流量 ID、traffic_search 返回的 id 相同,须完整保留,不是列表行号。已知 ID 时直接读取,无需重新搜索或请求目标。",
Schema: map[string]any{
"type": "object",
"properties": map[string]any{"id": map[string]any{"type": "string", "description": "traffic_search 返回的 id"}},
"properties": map[string]any{"id": map[string]any{"type": "string", "description": "流量页面或 traffic_search 返回的完整流量 ID(不是列表行号)"}},
"required": []any{"id"},
},
ReadOnly: ro,
+48
View File
@@ -0,0 +1,48 @@
package traffic
import (
"context"
"encoding/json"
"strings"
"testing"
)
func TestTrafficIDSearchAndToolReference(t *testing.T) {
tr, _ := openTraffic(t)
tr.record(newFlow("id.example", "GET", "/proof", nil, []byte("verified proof")))
tr.record(newFlow("other.example", "GET", "/other", nil, []byte("other response")))
rows, _, err := tr.Page(PageQuery{Host: "id.example", RespMin: -1, RespMax: -1}, 0, 10)
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%v err=%v", rows, err)
}
id := rows[0].ID
for _, fts := range []bool{true, false} {
if fts && !tr.fts {
continue
}
previous := tr.fts
tr.fts = fts
for _, query := range []string{id, id[len(id)-2:]} {
found, total, err := tr.Page(PageQuery{Host: "id.example", Query: query, RespMin: -1, RespMax: -1}, 0, 1)
if err != nil || total != 1 || len(found) != 1 || found[0].ID != id {
t.Fatalf("fts=%v query=%s rows=%v total=%d err=%v", fts, query, found, total, err)
}
}
tr.fts = previous
}
for _, tool := range tr.Tools() {
if tool.Name() != "traffic_get" {
continue
}
input, _ := json.Marshal(map[string]string{"id": id})
result, err := tool.Call(context.Background(), input, nil)
if err != nil || result.IsError || !strings.Contains(result.Flatten(), "verified proof") {
t.Fatalf("traffic_get result=%s err=%v", result.Flatten(), err)
}
}
// An ID search still respects host and method filters.
_, total, err := tr.Page(PageQuery{Query: id, Host: "other.example", RespMin: -1, RespMax: -1}, 0, 10)
if err != nil || total != 0 {
t.Fatalf("cross-host ID search total=%d err=%v", total, err)
}
}
+38 -45
View File
@@ -630,30 +630,38 @@ export default function FindingsPage() {
if (flatPage > lastPage) setFlatPage(lastPage);
}, [flat.loaded, flat.total, flatPage, flatPageSize]);
// Whole-table aggregates (stat cards + vuln-class options) — independent of the
// current page, so they stay exact.
const statsQuery = React.useMemo(
() => ({ severity, status, vulnclass, task, query, ...dateRange, assetScope: activeAssetScope ?? undefined }),
[severity, status, vulnclass, task, query, dateRange, activeAssetScope],
);
const statsFingerprint = JSON.stringify(statsQuery);
const activeStatsFingerprint = React.useRef(statsFingerprint);
activeStatsFingerprint.current = statsFingerprint;
const statsRequest = React.useRef(0);
const refreshStats = React.useCallback(async () => {
if (activeStatsFingerprint.current !== statsFingerprint) return;
const request = ++statsRequest.current;
try {
const result = await api.findingStats(statsQuery);
if (request !== statsRequest.current || activeStatsFingerprint.current !== statsFingerprint) return;
setStats(result);
setStatsLoaded(true);
} catch {
// Retain the previous snapshot during transient refresh failures.
}
}, [statsQuery, statsFingerprint]);
// Counts use the current filters and asset subtree, independently of pagination.
React.useEffect(() => {
let alive = true;
const load = () => {
api
.findingStats()
.then((s) => {
if (alive) {
setStats(s);
setStatsLoaded(true);
}
})
.catch(() => {
// Keep the previous aggregate snapshot until the next poll.
});
};
load();
const t = setInterval(load, 5000);
if (!preferencesHydrated) return;
void activeRetestFingerprint;
void refreshStats();
const t = setInterval(() => void refreshStats(), 5000);
return () => {
alive = false;
statsRequest.current++;
clearInterval(t);
};
}, []);
}, [preferencesHydrated, refreshStats, activeRetestFingerprint]);
React.useEffect(() => {
if (!statsLoaded) return;
@@ -677,12 +685,7 @@ export default function FindingsPage() {
await api.setFindingStatus(f.finding_id, next);
toast.success(`已标记为「${statusMeta("finding", next).label}」`);
// refresh stat cards (pending count) and drop the row if it no longer matches the status filter
api
.findingStats()
.then(setStats)
.catch(() => {
// The row update remains valid even if the aggregate refresh fails.
});
void refreshStats();
if (status !== "all" && next !== status) {
setFindings((cur) => cur.filter((x) => !isSameFinding(x, f)));
setTotal((t) => Math.max(0, t - 1));
@@ -694,7 +697,7 @@ export default function FindingsPage() {
toast.error(`更新失败:${(e as Error).message}`);
}
},
[refreshAfterMutation, setFindings, status],
[refreshAfterMutation, refreshStats, setFindings, status],
);
// 行内展开的详细报告缓存按全局稳定行键存。report 是大段 Markdown,列表查询不带它,
@@ -747,12 +750,7 @@ export default function FindingsPage() {
),
);
toast.success("已保存");
api
.findingStats()
.then(setStats)
.catch(() => {
// The edit remains valid even if the aggregate refresh fails.
});
void refreshStats();
refreshAfterMutation(f);
} catch (e) {
toast.error(`保存失败:${(e as Error).message}`);
@@ -760,7 +758,7 @@ export default function FindingsPage() {
setSaving(false);
}
},
[edit, refreshAfterMutation, setFindings],
[edit, refreshAfterMutation, refreshStats, setFindings],
);
// deleteFinding 删除一个漏洞(需二次确认):删成功后从列表移除、收起行、刷新统计。
@@ -780,18 +778,13 @@ export default function FindingsPage() {
const rowKey = findingRowKey(f);
setExpanded((cur) => (cur === rowKey ? null : cur));
toast.success("已删除漏洞");
api
.findingStats()
.then(setStats)
.catch(() => {
// The deletion remains valid even if the aggregate refresh fails.
});
void refreshStats();
refreshAfterMutation(f, true);
} catch (e) {
toast.error(`删除失败:${(e as Error).message}`);
}
},
[refreshAfterMutation, setFindings],
[refreshAfterMutation, refreshStats, setFindings],
);
const openDeepen = React.useCallback((f: Finding) => {
@@ -822,10 +815,10 @@ export default function FindingsPage() {
const statCards = [
{ label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon },
{ label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon },
{ label: "严重", value: stats.distinct?.critical ?? stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
{ label: "高危", value: stats.distinct?.high ?? stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
{ label: "中危", value: stats.distinct?.medium ?? stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
{ label: "低危", value: stats.distinct?.low ?? stats.low, tone: "text-slate-500", icon: InfoIcon },
{ label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
{ label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
{ label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
];
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
+2 -2
View File
@@ -3,7 +3,7 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { openSSE } from "@/lib/api";
import { apiFetch, openSSE } from "@/lib/api";
import { MOCK } from "@/lib/mock/enabled";
import type { LogLine } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -87,7 +87,7 @@ export default function LogsPage() {
setLoadingHistory(true);
try {
const params = minDbId > 0 ? `?before=${minDbId}&limit=200` : `?limit=200`;
const res = await fetch(`/api/logs/history${params}`);
const res = await apiFetch(`/api/logs/history${params}`);
if (!res.ok) return;
const data = await res.json() as { items: LogLine[]; has_more: boolean };
if (data.items?.length) {
+2 -1
View File
@@ -7,6 +7,7 @@ import { RotateCcwIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { apiFetch } from "@/lib/api";
import { auth } from "@/lib/auth";
function AuthenticatedImage({
@@ -39,7 +40,7 @@ function AuthenticatedImage({
const token = auth.getToken();
setCaptureTimestamp(null);
fetch(imageSrc, {
apiFetch(imageSrc, {
credentials: "same-origin",
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
})
+51 -4
View File
@@ -9,6 +9,7 @@ import {
ArrowUpNarrowWideIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
EraserIcon,
FilterXIcon,
ListChecksIcon,
@@ -47,7 +48,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { api } from "@/lib/api";
import { useStoredSortPreference } from "@/lib/sort-preference";
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
import { cn } from "@/lib/utils";
import { cn, copyText } from "@/lib/utils";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
@@ -81,6 +82,29 @@ function MethodBadge({ method }: { method: string }) {
return <Badge className="shrink-0 font-mono">{method}</Badge>;
}
function TrafficID({ id }: { id: string }) {
return (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-1 font-mono text-xs"
title="复制流量 ID,可用于搜索或交给报告 Agent"
aria-label={`复制流量 ID ${id}`}
onClick={async (event) => {
event.stopPropagation();
if (await copyText(id)) {
toast.success("已复制流量 ID");
} else {
toast.error("复制失败,请手动选择流量 ID");
}
}}
>
{id}
<CopyIcon className="size-3 shrink-0" />
</Button>
);
}
// Older captures may predate Host persistence because net/http keeps Host
// outside Request.Header. Fill it for display while newly recorded traffic is
// fixed at the recorder layer as well.
@@ -247,7 +271,22 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
alive = false;
clearInterval(t);
};
}, [page, size, hostQ, method, queryQ, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick, taskId, taskScoped]);
}, [
page,
size,
hostQ,
method,
queryQ,
bodyQ,
pathQ,
statusFilter,
respMinQ,
respMaxQ,
sort,
reloadTick,
taskId,
taskScoped,
]);
// Delete traffic for the current host filter (substring) or the checked
// hosts (exact batch), then refetch.
@@ -537,7 +576,7 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索全部(URL / 方法 / 类型 / 状态码…)"
placeholder="搜索全部(ID / URL / 方法 / 类型 / 状态码…)"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
@@ -699,6 +738,7 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
}
/>
</TableHead>
<TableHead className="w-44">流量 ID</TableHead>
<SortableHead
field="ts"
label="时间"
@@ -752,6 +792,9 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
}
/>
</TableCell>
<TableCell>
<TrafficID id={e.id} />
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
<TableCell className="font-mono text-xs">{e.host}</TableCell>
<TableCell>
@@ -771,7 +814,7 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
))}
{exchanges.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
<TableCell colSpan={9} className="py-12 text-center text-sm text-muted-foreground">
{traffic === null ? "加载中…" : "没有匹配的流量。"}
</TableCell>
</TableRow>
@@ -803,6 +846,10 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
</div>
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
流量 ID:
<TrafficID id={selected.id} />
</div>
<Button
variant="outline"
size="sm"
+149
View File
@@ -0,0 +1,149 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const vm = require("node:vm");
const ts = require("typescript");
const compiled = ts.transpileModule(fs.readFileSync(path.join(__dirname, "api.ts"), "utf8"), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText;
function client(fetch) {
const exports = {};
const state = { token: "original-admin-token", reloads: 0, requests: [], streams: [] };
const location = { href: "http://localhost/function/tasks/?page=2", hostname: "localhost", reload: () => state.reloads++ };
const document = { cookie: "artex_token=original-admin-token" };
class EventSource {
constructor(url, options) { this.url = url; this.options = options; this.listeners = {}; state.streams.push(this); }
addEventListener(name, listener) { this.listeners[name] = listener; }
}
vm.runInNewContext(compiled, {
exports, URL, URLSearchParams, FormData, EventSource,
process: { env: { NODE_ENV: "production" } },
window: { location }, document,
localStorage: { getItem: () => state.token, removeItem: () => { state.token = null; } },
require: (id) => {
if (id === "@/lib/mock/enabled") return { MOCK: false };
if (id === "@/lib/mock/handler") return { mockHandle: () => assert.fail("unexpected mock request") };
throw new Error(`Unexpected import: ${id}`);
},
fetch: async (...args) => { state.requests.push(args); return fetch(...args); },
});
return { ...exports, state, location, document };
}
const gateFailure = () => new Response('{"error":"请先完成 HTTP Basic Auth 验证"}', {
status: 401, headers: { "X-Artex-Auth": "basic" },
});
function assertSessionPreserved(c) {
assert.equal(c.state.token, "original-admin-token");
assert.equal(c.document.cookie, "artex_token=original-admin-token");
assert.equal(c.location.href, "http://localhost/function/tasks/?page=2");
}
test("expired Basic gate reloads the current tab and preserves the admin session", async () => {
const c = client(async () => gateFailure());
await assert.rejects(c.http("/version"), /访问验证已过期/);
assert.equal(c.state.reloads, 1);
assert.equal(c.state.requests[0][1].headers.Authorization, "Bearer original-admin-token");
assertSessionPreserved(c);
});
test("concurrent gate failures reload once and stop new polling requests", async () => {
let release;
const pending = new Promise((resolve) => { release = resolve; });
const c = client(async () => { await pending; return gateFailure(); });
const requests = [c.http("/version"), c.http("/tasks"), c.http("/settings")];
release();
const results = await Promise.allSettled(requests);
assert.ok(results.every((r) => r.status === "rejected"));
assert.equal(c.state.reloads, 1);
await assert.rejects(c.http("/tasks"), /访问验证已过期/);
assert.equal(c.state.requests.length, 3);
assertSessionPreserved(c);
});
test("a late admin 401 cannot clear the token once gate recovery has started", async () => {
let release;
const late = new Promise((resolve) => { release = resolve; });
const c = client(async (url) => {
if (url === "/api/tasks") { await late; return new Response("expired", { status: 401 }); }
return gateFailure();
});
const staleRequest = c.http("/tasks");
await assert.rejects(c.http("/version"), /访问验证已过期/);
release();
await assert.rejects(staleRequest, /访问验证已过期/);
assert.equal(c.state.reloads, 1);
assertSessionPreserved(c);
});
test("an ordinary expired admin JWT still clears the login and opens /login", async () => {
const c = client(async () => new Response('{"error":"token 无效或已过期"}', { status: 401 }));
await assert.rejects(c.http("/version"), /未授权/);
assert.equal(c.state.token, null);
assert.equal(c.location.href, "/login");
assert.equal(c.state.reloads, 0);
});
test("business errors do not trigger gate recovery", async () => {
const c = client(async () => new Response('{"error":"禁止操作"}', { status: 403 }));
await assert.rejects(c.http("/tasks"), /禁止操作/);
assert.equal(c.state.reloads, 0);
assertSessionPreserved(c);
});
test("uploads and downloads also recover the Basic gate", async () => {
for (const action of [
(c) => c.api.workspaceUpload("", [new File(["test"], "test.txt")]),
(c) => c.api.workspaceDownload("test.txt"),
(c) => c.api.report(),
(c) => c.api.interceptGetToolConfig(),
]) {
const c = client(async () => gateFailure());
await assert.rejects(action(c), /访问验证已过期/);
assert.equal(c.state.reloads, 1);
assertSessionPreserved(c);
}
});
const tick = () => new Promise((resolve) => setImmediate(resolve));
test("SSE failures probe the same-origin gate once for concurrent streams", async () => {
const c = client(async () => gateFailure());
const first = c.openSSE("/api/logs/stream");
const second = c.openSSE("/api/update/stream");
first.listeners.error();
second.listeners.error();
await tick();
assert.equal(c.state.requests.length, 1);
assert.equal(c.state.requests[0][0], "/api/auth/status");
assert.equal(c.state.reloads, 1);
assertSessionPreserved(c);
});
test("ordinary SSE disconnects and network errors do not reload the page", async () => {
for (const fetch of [
async () => new Response('{"initialized":true}'),
async () => { throw new TypeError("network unavailable"); },
]) {
const c = client(fetch);
const source = c.openSSE("/api/logs/stream");
source.listeners.error();
await tick();
source.listeners.error();
await tick();
assert.equal(c.state.reloads, 0);
assert.equal(c.state.requests.length, 2);
assertSessionPreserved(c);
}
});
test("a fresh document resumes API requests with the original admin token", async () => {
const c = client(async () => new Response('{"version":"test"}'));
assert.equal((await c.api.version()).version, "test");
assert.equal(c.state.requests[0][1].headers.Authorization, "Bearer original-admin-token");
assert.equal(c.state.reloads, 0);
});
+43 -12
View File
@@ -144,10 +144,28 @@ function getToken(): string | null {
return localStorage.getItem("artex_token");
}
let basicAuthReloading = false;
let basicAuthProbe: Promise<void> | null = null;
// Renew the outer gate through a page navigation, where the browser can send
// Basic credentials without replacing an API request's Bearer authorization.
// Keep the admin token and collapse concurrent failures into one reload.
export async function apiFetch(input: string, init?: RequestInit): Promise<Response> {
if (basicAuthReloading) throw new Error("访问验证已过期,正在重新验证");
const response = await fetch(input, init);
if (basicAuthReloading) throw new Error("访问验证已过期,正在重新验证");
if (response.status === 401 && response.headers.get("X-Artex-Auth") === "basic") {
basicAuthReloading = true;
window.location.reload();
throw new Error("访问验证已过期,正在重新验证");
}
return response;
}
export async function http<T>(path: string, init?: RequestInit): Promise<T> {
if (MOCK) return mockHandle<T>(init?.method ?? "GET", path, init?.body ?? null);
const token = getToken();
const r = await fetch(`/api${path}`, {
const r = await apiFetch(`/api${path}`, {
...init,
headers: {
...(init?.body ? { "Content-Type": "application/json" } : {}),
@@ -236,7 +254,19 @@ export function openSSE(path: string): EventSource {
// Same-origin streams already send cookies. Only opt into cross-port cookies
// for the local Next/Go dev pair; keep custom remote SSE endpoints unchanged.
const local = ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname);
return new EventSource(url, { withCredentials: local && target.hostname === window.location.hostname });
const source = new EventSource(url, { withCredentials: local && target.hostname === window.location.hostname });
source.addEventListener("error", () => {
// EventSource hides the response status. Probe a same-origin auth endpoint
// to distinguish an expired gate from an ordinary stream/network failure.
if (basicAuthProbe !== null || basicAuthReloading) return;
basicAuthProbe = apiFetch("/api/auth/status", { cache: "no-store" })
.then(() => undefined)
.catch(() => undefined)
.finally(() => {
basicAuthProbe = null;
});
});
return source;
}
const get = <T>(p: string) => http<T>(p);
@@ -554,7 +584,7 @@ export const api = {
const fd = new FormData();
for (const f of files) fd.append("file", f);
const token = getToken();
const r = await fetch(`/api/workspace/upload?path=${encodeURIComponent(dir)}`, {
const r = await apiFetch(`/api/workspace/upload?path=${encodeURIComponent(dir)}`, {
method: "POST",
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: fd,
@@ -569,7 +599,7 @@ export const api = {
blob = new Blob([`(demo)${path} 的下载内容示例。`], { type: "text/plain" });
} else {
const token = getToken();
const r = await fetch(`/api/workspace/download?path=${encodeURIComponent(path)}`, {
const r = await apiFetch(`/api/workspace/download?path=${encodeURIComponent(path)}`, {
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
});
if (!r.ok) throw new Error(`下载失败: ${r.status}`);
@@ -708,7 +738,8 @@ export const api = {
{ case_id: id },
),
findingCaseReviewRuns: () => get<FindingCaseReviewRun[]>("/exploration/finding-case-review"),
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
findingStats: (q: Omit<FindingQuery, "page" | "pageSize"> = {}) =>
get<FindingStats>(`/exploration/findings/stats?${findingFilterParams(q).toString()}`),
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
exportFindings: async (opts: {
@@ -728,7 +759,7 @@ export const api = {
for (const [k, v] of findingFilterParams(opts.filters)) p.set(k, v);
}
const token = getToken();
const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, {
const r = await apiFetch(`/api/exploration/findings/export?${p.toString()}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!r.ok) throw new Error(`export: ${r.status}`);
@@ -806,7 +837,7 @@ export const api = {
contextTask?: string,
) => {
const token = getToken();
const response = await fetch(
const response = await apiFetch(
`/api/exploration/findings/${id}/traffic/${bindingId}/body?side=${side}&download=1${contextTask ? `&context_task=${encodeURIComponent(contextTask)}` : ""}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
);
@@ -1080,7 +1111,7 @@ export const api = {
report: async (task?: string) => {
if (MOCK) return mockReport(task);
const token = getToken();
const r = await fetch(`/api/report${tq(task)}`, {
const r = await apiFetch(`/api/report${tq(task)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!r.ok) throw new Error(`report: ${r.status}`);
@@ -1108,7 +1139,7 @@ export const api = {
const fd = new FormData();
for (const f of files) fd.append("file", f);
const token = getToken();
const r = await fetch(`/api/chat/upload?scope=${scope}&id=${encodeURIComponent(id)}`, {
const r = await apiFetch(`/api/chat/upload?scope=${scope}&id=${encodeURIComponent(id)}`, {
method: "POST",
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: fd,
@@ -1368,7 +1399,7 @@ export const api = {
const fd = new FormData();
fd.append("file", file);
const token = getToken();
const r = await fetch(`/api/skills/upload${overwrite ? "?overwrite=true" : ""}`, {
const r = await apiFetch(`/api/skills/upload${overwrite ? "?overwrite=true" : ""}`, {
method: "POST",
body: fd,
headers: token ? { Authorization: `Bearer ${token}` } : {},
@@ -1457,7 +1488,7 @@ export const api = {
interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => {
if (MOCK) return { enabled_tools: ["bash"] };
const token = getToken();
const r = await fetch("/api/intercept/tool-config", {
const r = await apiFetch("/api/intercept/tool-config", {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!r.ok) throw new Error(await r.text());
@@ -1466,7 +1497,7 @@ export const api = {
interceptSetToolConfig: async (enabledTools: string[]): Promise<void> => {
if (MOCK) return;
const token = getToken();
const r = await fetch("/api/intercept/tool-config", {
const r = await apiFetch("/api/intercept/tool-config", {
method: "PUT",
headers: {
"Content-Type": "application/json",
@@ -132,3 +132,30 @@ test("group dates use the latest matching finding after filters", async () => {
assert.equal(result.items[0].task_id, "10");
assert.equal(result.items[0].last_found_at, fixtures[8].ts);
});
test("finding statistics follow combined filters and retain global choices", async () => {
const route = { path: "/exploration/findings/stats" };
const all = await read(route);
const filtered = await read(route, {
task_id: "10",
severity: "high",
status: "pending",
vulnclass: "Demo",
q: "needle",
from: "2026-01-04T00:00:00Z",
to: "2026-01-04T23:59:59Z",
page: "2",
limit: "1",
});
assert.equal(filtered.total, 1);
assert.equal(filtered.distinct.total, 1);
assert.equal(filtered.high, 1);
assert.equal(filtered.critical, 0);
assert.deepEqual(filtered.tasks, all.tasks);
assert.deepEqual(filtered.vulnclasses, all.vulnclasses);
const empty = await read(route, { q: "no matching fixture" });
assert.equal(empty.total, 0);
assert.equal(empty.distinct.total, 0);
assert.equal(empty.high + empty.medium + empty.low + empty.critical, 0);
assert.deepEqual(empty.tasks, all.tasks);
});
+8 -6
View File
@@ -2001,6 +2001,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── exploration ──
if (path === "/exploration/frontier") return D.frontier;
if (path === "/exploration/findings/stats") {
const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope"));
const vulnclasses = Array.from(new Set(mockFindings.map((f) => f.vulnclass))).sort();
// 「按任务」下拉:有漏洞的任务 + 描述 + 条数(mock 任务 id 是字符串,直接当 id 用)。
const taskMap = new Map<string, { name: string; description: string; count: number }>();
@@ -2017,12 +2018,13 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
}
const tasks = Array.from(taskMap, ([id, v]) => ({ id, name: v.name, description: v.description, count: v.count }));
return {
total: mockFindings.length,
pending: mockFindings.filter((f) => f.status === "pending").length,
critical: mockFindings.filter((f) => f.severity === "critical").length,
high: mockFindings.filter((f) => f.severity === "high").length,
medium: mockFindings.filter((f) => f.severity === "medium").length,
low: mockFindings.filter((f) => f.severity === "low").length,
total: list.length,
pending: list.filter((f) => f.status === "pending").length,
critical: list.filter((f) => f.severity === "critical").length,
high: list.filter((f) => f.severity === "high").length,
medium: list.filter((f) => f.severity === "medium").length,
low: list.filter((f) => f.severity === "low").length,
distinct: { total: list.length, reports: list.length },
vulnclasses,
tasks,
};
+1 -1
View File
@@ -579,7 +579,7 @@ export interface FindingDeepenResponse {
queued: boolean;
}
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
// FindingStats 的计数跟随列表筛选,不受分页影响;类型和任务选项保留全局范围。
export interface FindingStats {
distinct?: FindingDistinctStats;
total: number;