Files
ARTEX/db/task_scope.go
BaaRai e233c02314 Translate all Chinese text to English
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
2026-10-08 23:14:34 +02:00

645 lines
21 KiB
Go

package db
import (
"database/sql"
"fmt"
"net"
"strconv"
"strings"
)
// TaskScope is one row of a task's test scope — the coverage denominator and the
// per-task authorization edge. Rows come either from insertAssets (source='auto',
// conservative, one per explicitly-inserted asset) or from the add_task_scope tool
// (source='agent', for company / domain / network / ICP / keyword scope).
type TaskScope struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
Kind string `json:"kind"` // company|root_domain|subdomain|ip|cidr|icp|keyword
CompanyID *int64 `json:"company_id,omitempty"`
// CompanyName is resolved for kind=company so callers can label a scope row
// without a second lookup. Empty when the row is not a company reference.
CompanyName string `json:"company_name,omitempty"`
Domain string `json:"domain,omitempty"`
Net string `json:"net,omitempty"`
Value string `json:"value,omitempty"`
Source string `json:"source"`
Reason string `json:"reason,omitempty"`
}
// stripHostPort drops a trailing :port from a host:port / ip:port / [ipv6]:port
// value, returning the bare host. Bare hosts, bare IPs (v4 or v6, whose colons make
// them ambiguous), and anything not in host:port form are returned unchanged. Scope
// is host/net based, so the port from a target like "10.0.188.136:3000" or
// "api.example.com:8080" is simply discarded rather than baked into the key.
func stripHostPort(v string) string {
v = strings.TrimSpace(v)
if host, _, err := net.SplitHostPort(v); err == nil {
return host
}
return v
}
// ipToHostCIDR turns a bare IP into its single-host CIDR (/32 or /128). "" if invalid.
func ipToHostCIDR(ip string) string {
ip = strings.TrimSpace(ip)
p := net.ParseIP(ip)
if p == nil {
return ""
}
if p.To4() != nil {
return ip + "/32"
}
return ip + "/128"
}
// upsertTaskScope inserts one scope row idempotently (uq_task_scope). A duplicate is
// silently ignored. taskID<=0 or empty kind → no-op.
func (s *AssetStore) upsertTaskScopeResult(ts TaskScope) (bool, error) {
if ts.TaskID <= 0 || ts.Kind == "" {
return false, nil
}
var domainVal, netVal, companyVal, valueVal any
if ts.Domain != "" {
domainVal = ts.Domain
}
if ts.Net != "" {
netVal = ts.Net
}
if ts.CompanyID != nil && *ts.CompanyID > 0 {
companyVal = *ts.CompanyID
}
if ts.Value != "" {
valueVal = ts.Value
}
src := ts.Source
if src == "" {
src = "auto"
}
query := `
INSERT INTO task_scope(task_id, kind, company_id, domain, net, value, source, reason)
VALUES ($1,$2,$3,$4,$5::cidr,$6,$7,NULLIF($8,''))
ON CONFLICT DO NOTHING`
var (
result sql.Result
err error
)
if s.tx != nil {
result, err = s.tx.Exec(query, ts.TaskID, ts.Kind, companyVal, domainVal, netVal, valueVal, src, ts.Reason)
} else {
result, err = s.db.Exec(query, ts.TaskID, ts.Kind, companyVal, domainVal, netVal, valueVal, src, ts.Reason)
}
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
return rows > 0, err
}
func (s *AssetStore) upsertTaskScope(ts TaskScope) error {
_, err := s.upsertTaskScopeResult(ts)
return err
}
// AddAutoScope records the conservative task scope implied by ONE explicitly-inserted
// asset item (source='auto'). MUST be called only from insertAssets' top-level loop —
// never from a db-layer side effect (linkHostAssets), so derived assets do not blindly expand the scope.
// Rule: scope granularity follows the asset's own type. taskID<=0 → no-op.
func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip string) error {
if taskID <= 0 {
return nil
}
switch assetType {
case "root_domain":
if d := DomainKey(domain); d != "" {
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "root_domain", Domain: d})
}
case "subdomain":
if d := DomainKey(domain); d != "" {
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "subdomain", Domain: d})
}
case "service", "endpoint":
host := domain
if host == "" && rawURL != "" {
host, _, _ = parseURL(normalizeURL(rawURL))
}
host = DomainKey(host)
if host != "" && net.ParseIP(host) == nil {
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "subdomain", Domain: host})
}
// IP-literal host or no host → fall back to ip scope if we have one.
if c := ipToHostCIDR(ip); c != "" {
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "ip", Net: c})
}
case "ip":
if c := ipToHostCIDR(ip); c != "" {
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "ip", Net: c})
}
}
return nil
}
// AddAgentScope parses a (kind,value) pair and records it as task scope.
// source is "agent" when called from an LLM tool, "manual" from the UI.
// company: value = company name or id (must already exist). root_domain/subdomain:
// value = a domain. ip/cidr: value = an IP or CIDR (bare IP → /32,/128).
func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason, source string) (TaskScope, error) {
if source == "" {
source = "agent"
}
ts := TaskScope{TaskID: taskID, Kind: kind, Source: source, Reason: reason}
if taskID <= 0 {
return ts, fmt.Errorf("task_id is required")
}
value = strings.TrimSpace(value)
if value == "" {
return ts, fmt.Errorf("value must not be empty")
}
switch kind {
case "company":
if s.company == nil {
return ts, fmt.Errorf("company store is not enabled")
}
var comp *Company
var err error
if id, e := strconv.ParseInt(value, 10, 64); e == nil {
comp, err = s.company.GetCompany(id)
} else {
comp, err = s.company.GetCompanyByName(value)
}
if err != nil {
return ts, err
}
if comp == nil {
return ts, fmt.Errorf("company does not exist: %s (confirm with list_companies first, or create the company)", value)
}
ts.CompanyID = &comp.ID
case "root_domain":
d := DomainKey(stripHostPort(value))
root, _ := RootDomain(d)
if root == "" {
root = d
}
if root == "" {
return ts, fmt.Errorf("invalid root domain: %s", value)
}
ts.Domain = root
case "subdomain":
d := DomainKey(stripHostPort(value))
if d == "" {
return ts, fmt.Errorf("invalid subdomain: %s", value)
}
ts.Domain = d
case "ip", "cidr":
v := value
if !strings.Contains(v, "/") {
v = ipToHostCIDR(stripHostPort(v))
ts.Kind = "ip"
} else {
ts.Kind = "cidr"
}
if v == "" {
return ts, fmt.Errorf("invalid ip/cidr: %s", value)
}
if _, _, err := net.ParseCIDR(v); err != nil {
return ts, fmt.Errorf("invalid ip/cidr: %s", value)
}
ts.Net = v
case "icp", "keyword":
parsed, err := ParseScopeInput(ScopeInput{Kind: kind, Value: value})
if err != nil {
return ts, err
}
ts.Value = parsed.Value
default:
return ts, fmt.Errorf("unsupported kind: %s (company/root_domain/subdomain/ip/cidr/icp/keyword)", kind)
}
if err := s.upsertTaskScope(ts); err != nil {
return ts, err
}
return ts, nil
}
// DeleteTaskScope removes a single scope row by id, scoped to the given task.
// Returns whether a row was actually deleted.
func (s *AssetStore) DeleteTaskScope(taskID, scopeID int64) (bool, error) {
res, err := s.db.Exec(`DELETE FROM task_scope WHERE id=$1 AND task_id=$2`, scopeID, taskID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// ListTaskScope returns all scope rows for a task.
func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) {
rows, err := s.db.Query(`
SELECT ts.id, ts.kind, COALESCE(ts.company_id,0), COALESCE(c.name,''), COALESCE(ts.domain,''),
COALESCE(ts.net::text,''), COALESCE(ts.value,''), ts.source, COALESCE(ts.reason,'')
FROM task_scope ts
LEFT JOIN companies c ON c.id=ts.company_id
WHERE ts.task_id=$1 ORDER BY ts.id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TaskScope{}
for rows.Next() {
var t TaskScope
var cid int64
if err := rows.Scan(&t.ID, &t.Kind, &cid, &t.CompanyName, &t.Domain, &t.Net, &t.Value, &t.Source, &t.Reason); err != nil {
return nil, err
}
t.TaskID = taskID
if cid > 0 {
t.CompanyID = &cid
}
out = append(out, t)
}
return out, rows.Err()
}
// CoverageAsset is one in-scope asset (used for the untested backlog sample).
type CoverageAsset struct {
ID int64 `json:"id"`
Type string `json:"type"`
Label string `json:"label"`
}
// CoverageByType is per-asset-type coverage: total in scope vs tested.
type CoverageByType struct {
Type string `json:"type"`
Total int `json:"total"`
Tested int `json:"tested"`
}
// Coverage is a task's rough asset test coverage — a reference figure for the agent,
// NOT a precise metric. Denominator = assets matching any active task_scope row;
// Tested = those anchored to at least one fact node in the exploration.
type Coverage struct {
Enabled bool `json:"enabled"` // whether the asset coverage feature is on; when false the other fields are zero
ScopeRows int `json:"scope_rows"` // 0 → scope not anchored
Denominator int `json:"denominator"` // number of assets in scope
Tested int `json:"tested"` // tested (approximate)
Pct *float64 `json:"pct"` // coverage; null when the denominator is 0
ByType []CoverageByType `json:"by_type"` // total/tested per asset type
}
// CoverageEnabled reports whether a task has the asset-coverage feature turned on
// (tasks.coverage_enabled). Missing row / error → true (fail open to the default),
// so unknown/legacy tasks keep the historical behavior. taskID<=0 → true.
func (s *AssetStore) CoverageEnabled(taskID int64) bool {
if taskID <= 0 {
return true
}
var enabled bool
if err := s.db.QueryRow(`SELECT COALESCE(coverage_enabled,true) FROM tasks WHERE id=$1`, taskID).Scan(&enabled); err != nil {
return true
}
return enabled
}
// task_scope→assets match predicate, reused by the count / by-type / untested queries. $1=taskID.
const covTargetCTE = `
target AS (
SELECT DISTINCT a.id, a.type,
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
FROM assets a
JOIN task_scope ts ON ts.task_id = $1 AND (
(ts.kind='company' AND a.company_id = ts.company_id)
OR (ts.kind='root_domain' AND a.root_domain = ts.domain)
OR (ts.kind='subdomain' AND a.domain = ts.domain)
OR (ts.kind IN ('ip','cidr') AND ts.net >>= try_inet(a.ip))
OR (ts.kind='icp' AND (
lower(regexp_replace(COALESCE(a.icp,''), '[[:space:]]+', '', 'g')) = ts.value
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
))
)
),
tested AS (
SELECT DISTINCT ea.asset_id
FROM exploration_anchors ea
JOIN exploration_nodes en ON en.id = ea.node_id
WHERE en.exploration_id = $2 AND en.kind = 'fact'
)`
// TaskCoverage computes rough per-type coverage for a task. taskID indexes
// task_scope + assets; expID indexes the fact anchors. Reference figure only.
func (s *AssetStore) TaskCoverage(taskID, expID int64) (*Coverage, error) {
cov := &Coverage{ByType: []CoverageByType{}}
_ = s.db.QueryRow(`SELECT count(*) FROM task_scope WHERE task_id=$1`, taskID).Scan(&cov.ScopeRows)
rows, err := s.db.Query(`WITH `+covTargetCTE+`
SELECT t.type, count(*) AS total,
count(*) FILTER (WHERE t.id IN (SELECT asset_id FROM tested)) AS tested
FROM target t GROUP BY t.type ORDER BY t.type`, taskID, expID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var bt CoverageByType
if err := rows.Scan(&bt.Type, &bt.Total, &bt.Tested); err != nil {
return nil, err
}
cov.ByType = append(cov.ByType, bt)
cov.Denominator += bt.Total
cov.Tested += bt.Tested
}
if err := rows.Err(); err != nil {
return nil, err
}
if cov.Denominator > 0 {
p := float64(cov.Tested) / float64(cov.Denominator)
cov.Pct = &p
}
return cov, nil
}
// ---------------------------------------------------------------------------
// Coverage graph — a force-directed view of a task's in-scope assets.
// ---------------------------------------------------------------------------
// CoverageGraphNode is one node of the asset coverage graph. Node identity is a
// string key: an asset row is "a:<id>", a company is "c:<id>", and a root domain
// with no asset row of its own is the synthetic "r:<domain>". Out-of-scope
// connector nodes (roots pulled in only to link subdomains, and the companies
// above them) carry InScope=false and are rendered gray.
type CoverageGraphNode struct {
Key string `json:"key"`
Kind string `json:"kind"` // company|root_domain|subdomain|ip|service|app|endpoint
Label string `json:"label"`
Tested bool `json:"tested"`
InScope bool `json:"in_scope"`
AssetID int64 `json:"asset_id,omitempty"` // 0 for company / synthetic root
CompanyID int64 `json:"company_id,omitempty"`
Domain string `json:"domain,omitempty"`
RootDomain string `json:"root_domain,omitempty"`
IP string `json:"ip,omitempty"`
URL string `json:"url,omitempty"`
Port int `json:"port,omitempty"`
ServiceType string `json:"service_type,omitempty"`
AppName string `json:"app_name,omitempty"`
PageTitle string `json:"page_title,omitempty"`
StatusCode int `json:"status_code,omitempty"`
}
// CoverageGraphEdge is a child→parent containment link (endpoint→service→
// subdomain/ip→root_domain→company, app→company).
type CoverageGraphEdge struct {
Src string `json:"src"`
Dst string `json:"dst"`
}
// CoverageGraphData is the whole graph for one task.
type CoverageGraphData struct {
Nodes []CoverageGraphNode `json:"nodes"`
Edges []CoverageGraphEdge `json:"edges"`
}
func assetKey(id int64) string { return "a:" + strconv.FormatInt(id, 10) }
func companyKey(id int64) string { return "c:" + strconv.FormatInt(id, 10) }
// hostPortOf returns the (host, port) a service / endpoint node hangs off — the
// domain if set, otherwise the URL host, otherwise the IP.
func hostPortOf(n *CoverageGraphNode) (string, int) {
host, port := n.Domain, n.Port
if host == "" && n.URL != "" {
h, p, _ := parseURL(normalizeURL(n.URL))
host = h
if port == 0 {
port = p
}
}
if host == "" {
host = n.IP
}
return host, port
}
// BuildCoverageGraph assembles the full coverage graph for a task and its direct
// read-only sources: every in-scope asset plus connector root domains/companies,
// with current-or-source fact anchors reflected in Tested. The legacy expID
// argument is retained for API compatibility; the task registry is authoritative.
func (s *AssetStore) BuildCoverageGraph(taskID, _ int64) (*CoverageGraphData, error) {
g := &CoverageGraphData{Nodes: []CoverageGraphNode{}, Edges: []CoverageGraphEdge{}}
if taskID <= 0 {
return g, nil
}
rows, err := s.db.Query(`WITH `+contextCoverageCTE+`
SELECT a.id, a.type, COALESCE(a.company_id,0),
COALESCE(a.domain,''), COALESCE(a.root_domain,''), COALESCE(a.ip,''),
COALESCE(a.url,''), COALESCE(a.port,0), COALESCE(a.service_type,''),
COALESCE(a.app_name,''), COALESCE(a.page_title,''), COALESCE(a.status_code,0),
(a.id IN (SELECT asset_id FROM tested)) AS tested
FROM assets a JOIN target t ON t.id = a.id
ORDER BY a.id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
byKey := map[string]*CoverageGraphNode{}
rootByDomain := map[string]string{} // root domain → node key
subByDomain := map[string]string{} // subdomain → node key
ipByAddr := map[string]string{} // ip literal → node key
svcByHostPort := map[string]string{}
svcByHost := map[string]string{}
companyIDs := map[int64]bool{} // referenced company ids (need a node)
add := func(n CoverageGraphNode) *CoverageGraphNode {
if _, ok := byKey[n.Key]; ok {
return byKey[n.Key]
}
g.Nodes = append(g.Nodes, n)
p := &g.Nodes[len(g.Nodes)-1]
byKey[n.Key] = p
return p
}
for rows.Next() {
var n CoverageGraphNode
var companyID int64
if err := rows.Scan(&n.AssetID, &n.Kind, &companyID,
&n.Domain, &n.RootDomain, &n.IP, &n.URL, &n.Port, &n.ServiceType,
&n.AppName, &n.PageTitle, &n.StatusCode, &n.Tested); err != nil {
return nil, err
}
n.Key = assetKey(n.AssetID)
n.CompanyID = companyID
n.InScope = true
n.Label = coverageNodeLabel(&n)
p := add(n)
switch n.Kind {
case "root_domain":
if n.Domain != "" {
rootByDomain[n.Domain] = p.Key
}
case "subdomain":
if n.Domain != "" {
subByDomain[n.Domain] = p.Key
}
case "ip":
if n.IP != "" {
ipByAddr[n.IP] = p.Key
}
case "service":
host, port := hostPortOf(p)
if host != "" {
svcByHost[host] = p.Key
svcByHostPort[host+"|"+strconv.Itoa(port)] = p.Key
}
}
if companyID > 0 {
companyIDs[companyID] = true
}
}
if err := rows.Err(); err != nil {
return nil, err
}
// Connector root domains: any subdomain whose root domain is not itself an
// in-scope node. Pull the real asset row if one exists (so the drawer shows
// real detail), else synthesize a bare "r:<domain>" placeholder. Both gray.
missingRoots := map[string]bool{}
for _, n := range g.Nodes {
if n.Kind == "subdomain" && n.RootDomain != "" {
if _, ok := rootByDomain[n.RootDomain]; !ok {
missingRoots[n.RootDomain] = true
}
}
}
for root := range missingRoots {
var id, companyID int64
err := s.db.QueryRow(`SELECT id, COALESCE(company_id,0) FROM assets
WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID)
var node CoverageGraphNode
if err == nil && id > 0 {
node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id,
CompanyID: companyID, Domain: root, Label: root}
if companyID > 0 {
companyIDs[companyID] = true
}
} else {
node = CoverageGraphNode{Key: "r:" + root, Kind: "root_domain", Domain: root, Label: root}
}
add(node)
rootByDomain[root] = node.Key
}
// Company nodes for every referenced company id — always gray context.
for id := range companyIDs {
key := companyKey(id)
if _, ok := byKey[key]; ok {
continue
}
var name string
if err := s.db.QueryRow(`SELECT name FROM companies WHERE id=$1`, id).Scan(&name); err != nil {
continue
}
add(CoverageGraphNode{Key: key, Kind: "company", CompanyID: id,
Label: name, AssetID: 0})
}
// Derived containment edges (only when the parent node exists).
link := func(childKey, parentKey string) {
if parentKey == "" || parentKey == childKey {
return
}
if _, ok := byKey[parentKey]; !ok {
return
}
g.Edges = append(g.Edges, CoverageGraphEdge{Src: childKey, Dst: parentKey})
}
firstOf := func(keys ...string) string {
for _, k := range keys {
if k != "" {
if _, ok := byKey[k]; ok {
return k
}
}
}
return ""
}
for i := range g.Nodes {
n := &g.Nodes[i]
switch n.Kind {
case "subdomain":
link(n.Key, rootByDomain[n.RootDomain])
case "root_domain", "app", "ip":
if n.CompanyID > 0 {
link(n.Key, companyKey(n.CompanyID))
}
case "service":
link(n.Key, firstOf(subByDomain[n.Domain], ipByAddr[n.IP], rootByDomain[n.RootDomain]))
case "endpoint":
host, port := hostPortOf(n)
parent := firstOf(
svcByHostPort[host+"|"+strconv.Itoa(port)], svcByHost[host],
subByDomain[host], subByDomain[n.Domain], ipByAddr[host], ipByAddr[n.IP],
rootByDomain[n.RootDomain])
link(n.Key, parent)
}
}
return g, nil
}
// coverageNodeLabel picks the human label for a coverage-graph node.
func coverageNodeLabel(n *CoverageGraphNode) string {
switch n.Kind {
case "endpoint", "service":
if n.URL != "" {
return n.URL
}
case "app":
if n.AppName != "" {
return n.AppName
}
}
for _, v := range []string{n.URL, n.Domain, n.IP, n.AppName, n.RootDomain} {
if v != "" {
return v
}
}
return n.Key
}
// ListUntestedAssets returns a task's in-scope, not-yet-tested assets, optionally
// filtered by asset type, paginated. Returns the page + the total count. limit<=0 → 10.
func (s *AssetStore) ListUntestedAssets(taskID, expID int64, typ string, limit, offset int) ([]CoverageAsset, int, error) {
if limit <= 0 {
limit = 10
}
if offset < 0 {
offset = 0
}
typeFilter := ""
args := []any{taskID, expID}
if typ != "" {
typeFilter = " AND t.type = $3"
args = append(args, typ)
}
var total int
_ = s.db.QueryRow(`WITH `+covTargetCTE+`
SELECT count(*) FROM target t WHERE t.id NOT IN (SELECT asset_id FROM tested)`+typeFilter, args...).Scan(&total)
pageArgs := append(append([]any{}, args...), limit, offset)
limPos := strconv.Itoa(len(args) + 1)
offPos := strconv.Itoa(len(args) + 2)
rows, err := s.db.Query(`WITH `+covTargetCTE+`
SELECT t.id, t.type, t.label FROM target t
WHERE t.id NOT IN (SELECT asset_id FROM tested)`+typeFilter+`
ORDER BY t.id LIMIT $`+limPos+` OFFSET $`+offPos, pageArgs...)
if err != nil {
return nil, total, err
}
defer rows.Close()
out := []CoverageAsset{}
for rows.Next() {
var a CoverageAsset
if err := rows.Scan(&a.ID, &a.Type, &a.Label); err != nil {
return nil, total, err
}
out = append(out, a)
}
return out, total, rows.Err()
}