mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Comments, doc comments, UI labels, log and error messages, LLM prompt strings, markdown docs, test data and assertions. Identifiers, enum keys, routes, placeholders and code structure are unchanged. Notes: - docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md - tests that exercised CJK specific handling (GBK zip names, Chinese full text search) now use ASCII equivalents; go build passes and the test suite matches the pre translation baseline (remaining failures need a PostgreSQL DSN and fail identically on the original) - frontend mention syntax now uses English kinds (finding, asset, ...) matching the backend, chat mentions tests pass
245 lines
9.1 KiB
Go
245 lines
9.1 KiB
Go
package db
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// cleanupTreeFixtures deletes the assets and findings created by one test case.
|
|
// It must be registered with defer (not t.Cleanup): t.Cleanup runs after the
|
|
// test function returns, by which time defer d.Close() has already closed the
|
|
// connection — the cleanup would silently fail and leave dirty data in the
|
|
// shared development database.
|
|
func cleanupTreeFixtures(d *DB, taskID int64, rootDomains ...string) {
|
|
d.Exec(`DELETE FROM assets WHERE root_domain = ANY($1::text[])`, rootDomains) //nolint:errcheck
|
|
d.DeleteFindingsByTask(taskID) //nolint:errcheck
|
|
}
|
|
|
|
// seedTreeAsset inserts one asset row.
|
|
func seedTreeAsset(t *testing.T, d *DB, kind string, cols map[string]any) int64 {
|
|
t.Helper()
|
|
names := []string{"type"}
|
|
values := []any{kind}
|
|
placeholders := []string{"$1"}
|
|
for k, v := range cols {
|
|
values = append(values, v)
|
|
names = append(names, k)
|
|
placeholders = append(placeholders, "$"+strconv.Itoa(len(values)))
|
|
}
|
|
q := "INSERT INTO assets(" + strings.Join(names, ",") + ") VALUES (" +
|
|
strings.Join(placeholders, ",") + ") RETURNING id"
|
|
var id int64
|
|
if err := d.QueryRow(q, values...).Scan(&id); err != nil {
|
|
t.Fatalf("seed %s asset: %v", kind, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func nodeByKey(tree *FindingAssetTree, key string) *FindingAssetNode {
|
|
for i := range tree.Nodes {
|
|
if tree.Nodes[i].Key == key {
|
|
return &tree.Nodes[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestBuildFindingAssetTree covers the whole shape of the "by asset" tree: the
|
|
// root→subdomain→service→endpoint chain gets rebuilt from a finding that only
|
|
// points at the leaf, ancestors aggregate their subtree, assets without any
|
|
// finding stay out, and a finding whose asset row is gone lands in the
|
|
// unassigned bucket.
|
|
func TestBuildFindingAssetTree(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
tk, err := d.CreateTask("asset tree test", "goal", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer d.DeleteTask(tk.ID)
|
|
|
|
const root = "tree-test.example"
|
|
const sub = "api.tree-test.example"
|
|
defer cleanupTreeFixtures(d, tk.ID, root)
|
|
rootID := seedTreeAsset(t, d, "root_domain", map[string]any{"domain": root, "root_domain": root})
|
|
subID := seedTreeAsset(t, d, "subdomain", map[string]any{"domain": sub, "root_domain": root})
|
|
svcID := seedTreeAsset(t, d, "service", map[string]any{
|
|
"domain": sub, "root_domain": root, "url": "https://" + sub, "port": 443, "service_type": "http",
|
|
})
|
|
epID := seedTreeAsset(t, d, "endpoint", map[string]any{
|
|
"domain": sub, "root_domain": root, "url": "https://" + sub + "/admin", "port": 443, "method": "GET",
|
|
})
|
|
// Another service under the same domain, with no findings attached — it must
|
|
// not appear in the tree.
|
|
seedTreeAsset(t, d, "service", map[string]any{
|
|
"domain": sub, "root_domain": root, "url": "http://" + sub + ":8080", "port": 8080, "service_type": "http",
|
|
})
|
|
|
|
// Attach the finding only to the deepest endpoint; the ancestor chain must be
|
|
// reconstructed by the tree builder itself.
|
|
if _, err := d.AddFinding(tk.ID, 0, "XSS", "reflected XSS", "high", "s", "e", "w", []int64{epID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// One attached directly to the service, to verify the difference between
|
|
// Self and Total.
|
|
if _, err := d.AddFinding(tk.ID, 0, "Info", "information disclosure", "low", "s", "e", "w", []int64{svcID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The asset row does not exist (deleted asset) → the unlinked bucket.
|
|
if _, err := d.AddFinding(tk.ID, 0, "Misc", "orphan", "medium", "s", "e", "w", []int64{999000111}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tree, err := d.BuildFindingAssetTree(FindingFilter{TaskID: strconv.FormatInt(tk.ID, 10)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tree.FindingTotal != 3 {
|
|
t.Fatalf("finding_total: want 3, got %d", tree.FindingTotal)
|
|
}
|
|
|
|
rootNode := nodeByKey(tree, assetKey(rootID))
|
|
subNode := nodeByKey(tree, assetKey(subID))
|
|
svcNode := nodeByKey(tree, assetKey(svcID))
|
|
epNode := nodeByKey(tree, assetKey(epID))
|
|
for name, n := range map[string]*FindingAssetNode{
|
|
"root": rootNode, "subdomain": subNode, "service": svcNode, "endpoint": epNode,
|
|
} {
|
|
if n == nil {
|
|
t.Fatalf("%s node missing from tree", name)
|
|
}
|
|
}
|
|
|
|
// Parent-child chain: endpoint → service → subdomain → root_domain.
|
|
if epNode.Parent != svcNode.Key {
|
|
t.Errorf("endpoint parent: want %s, got %s", svcNode.Key, epNode.Parent)
|
|
}
|
|
if svcNode.Parent != subNode.Key {
|
|
t.Errorf("service parent: want %s, got %s", subNode.Key, svcNode.Parent)
|
|
}
|
|
if subNode.Parent != rootNode.Key {
|
|
t.Errorf("subdomain parent: want %s, got %s", rootNode.Key, subNode.Parent)
|
|
}
|
|
if rootNode.Parent != "" {
|
|
t.Errorf("root parent: want top level, got %s", rootNode.Parent)
|
|
}
|
|
|
|
// Aggregation: the root domain has two (the endpoint's high + the service's
|
|
// low); the service has one itself and two in its subtree.
|
|
if rootNode.Total != 2 || rootNode.High != 1 || rootNode.Low != 1 {
|
|
t.Errorf("root totals: want 2/high1/low1, got %d/high%d/low%d", rootNode.Total, rootNode.High, rootNode.Low)
|
|
}
|
|
if rootNode.Self != 0 {
|
|
t.Errorf("root self: want 0 (ancestor only), got %d", rootNode.Self)
|
|
}
|
|
if svcNode.Total != 2 || svcNode.Self != 1 {
|
|
t.Errorf("service total/self: want 2/1, got %d/%d", svcNode.Total, svcNode.Self)
|
|
}
|
|
if epNode.Total != 1 || epNode.Self != 1 {
|
|
t.Errorf("endpoint total/self: want 1/1, got %d/%d", epNode.Total, epNode.Self)
|
|
}
|
|
|
|
// The sibling service without findings does not enter the tree.
|
|
for _, n := range tree.Nodes {
|
|
if n.Label == "http://"+sub+":8080" {
|
|
t.Errorf("asset without findings should be hidden: %+v", n)
|
|
}
|
|
}
|
|
|
|
// The unlinked bucket takes in the finding that points at a deleted asset.
|
|
none := nodeByKey(tree, FindingUnassignedAsset)
|
|
if none == nil || none.Total != 1 || none.Medium != 1 {
|
|
t.Fatalf("unassigned bucket: want 1 medium, got %+v", none)
|
|
}
|
|
}
|
|
|
|
// TestFindingAssetScopeFilter verifies that selecting a node narrows the
|
|
// findings list to that node's whole subtree, and that the unassigned sentinel
|
|
// works too.
|
|
func TestFindingAssetScopeFilter(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
tk, err := d.CreateTask("asset scope filter test", "goal", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer d.DeleteTask(tk.ID)
|
|
|
|
const root = "scope-test.example"
|
|
const sub = "api.scope-test.example"
|
|
const other = "other-scope-test.example"
|
|
defer cleanupTreeFixtures(d, tk.ID, root, other)
|
|
rootID := seedTreeAsset(t, d, "root_domain", map[string]any{"domain": root, "root_domain": root})
|
|
subID := seedTreeAsset(t, d, "subdomain", map[string]any{"domain": sub, "root_domain": root})
|
|
otherID := seedTreeAsset(t, d, "root_domain", map[string]any{"domain": other, "root_domain": other})
|
|
|
|
if _, err := d.AddFinding(tk.ID, 0, "A", "on the subdomain", "high", "s", "e", "w", []int64{subID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := d.AddFinding(tk.ID, 0, "B", "on another root domain", "high", "s", "e", "w", []int64{otherID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := d.AddFinding(tk.ID, 0, "C", "without an asset", "high", "s", "e", "w", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A finding pointing at a deleted asset is just as "unlinked" as one with an
|
|
// empty asset_ids — the tree's bucket takes it in, and the list filter must
|
|
// find it too; if the two disagree, the number on the bucket ends up larger
|
|
// than the rows shown after clicking through.
|
|
if _, err := d.AddFinding(tk.ID, 0, "D", "asset deleted", "high", "s", "e", "w", []int64{999000333}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
base := FindingFilter{TaskID: strconv.FormatInt(tk.ID, 10)}
|
|
cases := []struct {
|
|
name string
|
|
scope string
|
|
want int
|
|
}{
|
|
{"whole subtree", assetKey(rootID), 1}, // only the subdomain finding under the root domain
|
|
{"leaf node", assetKey(subID), 1}, // the subdomain itself
|
|
{"another tree", assetKey(otherID), 1}, // no cross-contamination
|
|
{"unlinked", FindingUnassignedAsset, 2}, // empty asset_ids + pointing at a deleted asset
|
|
{"nonexistent node", "a:999000222", 0}, // node absent under the current filter → empty result, not unfiltered
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := base
|
|
f.AssetScope = tc.scope
|
|
items, total, err := d.ListFindingsPage(f, 1, 50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total != tc.want || len(items) != tc.want {
|
|
t.Fatalf("scope %s: want %d findings, got total=%d items=%d", tc.scope, tc.want, total, len(items))
|
|
}
|
|
})
|
|
}
|
|
|
|
// Without a scope all four are present.
|
|
if _, total, err := d.ListFindingsPage(base, 1, 50); err != nil || total != 4 {
|
|
t.Fatalf("unscoped: want 4, got %d (%v)", total, err)
|
|
}
|
|
|
|
// The tree's unlinked-bucket count must match the rows found after clicking
|
|
// through — this is exactly the assertion that breaks when the two
|
|
// interpretations diverge.
|
|
tree, err := d.BuildFindingAssetTree(base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
none := nodeByKey(tree, FindingUnassignedAsset)
|
|
if none == nil || none.Total != 2 {
|
|
t.Fatalf("unassigned bucket count: want 2, got %+v", none)
|
|
}
|
|
}
|