mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +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
262 lines
7.6 KiB
Go
262 lines
7.6 KiB
Go
package db
|
||
|
||
import (
|
||
"fmt"
|
||
"net"
|
||
"net/url"
|
||
"strings"
|
||
)
|
||
|
||
// Matching/enforcement layer for asset interception rules. asset_intercept.go only
|
||
// stores rules; this file matches a target asset's domain/IP/URL candidates against
|
||
// the enabled rules. Called by agent tools (add_intent, insert_assets) before
|
||
// dispatching an intent / inserting assets; a hit means rejection.
|
||
|
||
// AssetInterceptKindLabel returns the human-readable label for kind, used in
|
||
// explanatory messages to the agent.
|
||
func AssetInterceptKindLabel(kind string) string {
|
||
switch kind {
|
||
case "exact_domain":
|
||
return "Domain (exact)"
|
||
case "exact_ip":
|
||
return "IP (exact)"
|
||
case "exact_url":
|
||
return "URL (exact)"
|
||
case "fuzzy_domain":
|
||
return "Domain (fuzzy)"
|
||
case "fuzzy_ip":
|
||
return "IP (fuzzy)"
|
||
case "fuzzy_url":
|
||
return "URL (fuzzy)"
|
||
case "cidr":
|
||
return "CIDR range"
|
||
}
|
||
return kind
|
||
}
|
||
|
||
// Reason returns a readable hit reason, e.g.: Matched asset interception rule [Domain (fuzzy): .gov.cn] (note).
|
||
func (r AssetInterceptRule) Reason() string {
|
||
s := fmt.Sprintf("Matched asset interception rule [%s: %s]", AssetInterceptKindLabel(r.Kind), r.Pattern)
|
||
if note := strings.TrimSpace(r.Note); note != "" {
|
||
s += " (" + note + ")"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// matchOne checks whether a single enabled rule matches the given domain/IP/URL
|
||
// candidates, and returns the concrete matched value.
|
||
func matchOne(r AssetInterceptRule, domains, ips, urls []string) (string, bool) {
|
||
p := strings.TrimSpace(r.Pattern)
|
||
if p == "" {
|
||
return "", false
|
||
}
|
||
switch r.Kind {
|
||
case "exact_domain":
|
||
for _, d := range domains {
|
||
if strings.EqualFold(strings.TrimSpace(d), p) {
|
||
return d, true
|
||
}
|
||
}
|
||
case "exact_ip":
|
||
for _, ip := range ips {
|
||
if strings.TrimSpace(ip) == p {
|
||
return ip, true
|
||
}
|
||
}
|
||
case "exact_url":
|
||
for _, u := range urls {
|
||
if strings.TrimSpace(u) == p {
|
||
return u, true
|
||
}
|
||
}
|
||
case "fuzzy_domain":
|
||
lp := strings.ToLower(p)
|
||
for _, d := range domains {
|
||
if d != "" && strings.Contains(strings.ToLower(d), lp) {
|
||
return d, true
|
||
}
|
||
}
|
||
case "fuzzy_ip":
|
||
for _, ip := range ips {
|
||
if ip != "" && strings.Contains(ip, p) {
|
||
return ip, true
|
||
}
|
||
}
|
||
case "fuzzy_url":
|
||
lp := strings.ToLower(p)
|
||
for _, u := range urls {
|
||
if u != "" && strings.Contains(strings.ToLower(u), lp) {
|
||
return u, true
|
||
}
|
||
}
|
||
case "cidr":
|
||
_, ipnet, err := net.ParseCIDR(p)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
for _, ip := range ips {
|
||
if pip := net.ParseIP(strings.TrimSpace(ip)); pip != nil && ipnet.Contains(pip) {
|
||
return ip, true
|
||
}
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// MatchAssetInterceptRules returns the first enabled rule matching the given
|
||
// domain/IP/URL candidates, plus the concrete matched value.
|
||
// Used by insert_assets to match raw input (assetInputItem not yet persisted).
|
||
func MatchAssetInterceptRules(rules []AssetInterceptRule, domains, ips, urls []string) (AssetInterceptRule, string, bool) {
|
||
for _, r := range rules {
|
||
if !r.Enabled {
|
||
continue
|
||
}
|
||
if v, ok := matchOne(r, domains, ips, urls); ok {
|
||
return r, v, true
|
||
}
|
||
}
|
||
return AssetInterceptRule{}, "", false
|
||
}
|
||
|
||
// interceptCandidates extracts a persisted asset's domain/IP/URL candidates for
|
||
// interception matching. A URL's host is split out and classified, so service
|
||
// assets that only carry a URL can still be hit by domain/IP rules.
|
||
func (a *Asset) interceptCandidates() (domains, ips, urls []string) {
|
||
add := func(dst *[]string, s string) {
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
*dst = append(*dst, s)
|
||
}
|
||
}
|
||
add(&domains, a.Domain)
|
||
add(&domains, a.RootDomain)
|
||
for _, d := range a.BoundDomains {
|
||
add(&domains, d)
|
||
}
|
||
add(&ips, a.IP)
|
||
add(&urls, a.URL)
|
||
if a.URL != "" {
|
||
if u, err := url.Parse(a.URL); err == nil {
|
||
if h := u.Hostname(); h != "" {
|
||
if net.ParseIP(h) != nil {
|
||
add(&ips, h)
|
||
} else {
|
||
add(&domains, h)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return domains, ips, urls
|
||
}
|
||
|
||
// InterceptLabel returns a short identifier for the asset, used in explanatory
|
||
// messages to the agent.
|
||
func (a *Asset) InterceptLabel() string {
|
||
var target string
|
||
switch {
|
||
case a.Domain != "":
|
||
target = a.Domain
|
||
case a.URL != "":
|
||
target = a.URL
|
||
case a.IP != "":
|
||
target = a.IP
|
||
default:
|
||
target = fmt.Sprintf("#%d", a.ID)
|
||
}
|
||
return fmt.Sprintf("Asset #%d[%s] %s", a.ID, a.Type, target)
|
||
}
|
||
|
||
// hasEnabledRule reports whether the rule set contains any enabled rule.
|
||
func hasEnabledRule(rules []AssetInterceptRule) bool {
|
||
for _, r := range rules {
|
||
if r.Enabled {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// AssetGateDecision is the result of the "intercept first, then allow" gate over
|
||
// a set of candidate strings.
|
||
type AssetGateDecision struct {
|
||
Allowed bool
|
||
Reason string // rejection reason (without the asset identifier); empty when Allowed=true
|
||
}
|
||
|
||
// EvaluateAssetGate performs the task-level gate decision:
|
||
// 1. Any enabled blockRules hit → reject (interception reason).
|
||
// 2. Otherwise, if allowRules has enabled entries and none match → reject (outside the allowed scope).
|
||
// 3. Otherwise allow.
|
||
//
|
||
// When allowRules is empty / has no enabled entries, the allow gate is inert
|
||
// (i.e. no whitelist, everything is allowed), so that "no allow rules
|
||
// configured" does not block every asset.
|
||
func EvaluateAssetGate(blockRules, allowRules []AssetInterceptRule, domains, ips, urls []string) AssetGateDecision {
|
||
if rule, _, ok := MatchAssetInterceptRules(blockRules, domains, ips, urls); ok {
|
||
return AssetGateDecision{Allowed: false, Reason: rule.Reason()}
|
||
}
|
||
if hasEnabledRule(allowRules) {
|
||
if _, _, ok := MatchAssetInterceptRules(allowRules, domains, ips, urls); !ok {
|
||
return AssetGateDecision{Allowed: false, Reason: "Not in the task's allowed (whitelist) scope; testing is not permitted"}
|
||
}
|
||
}
|
||
return AssetGateDecision{Allowed: true}
|
||
}
|
||
|
||
// AssetInterceptHit describes an asset rejected by the gate (interception hit or
|
||
// outside the allowed scope).
|
||
type AssetInterceptHit struct {
|
||
Asset *Asset
|
||
Reason string // readable reason
|
||
}
|
||
|
||
// Describe returns a readable explanation: asset info + reason.
|
||
func (h AssetInterceptHit) Describe() string {
|
||
return fmt.Sprintf("%s → %s", h.Asset.InterceptLabel(), h.Reason)
|
||
}
|
||
|
||
// ListAssetInterceptRules is a pass-through of the *DB method of the same name,
|
||
// so callers that only hold an AssetStore (e.g. agent tools) can also read rules.
|
||
func (s *AssetStore) ListAssetInterceptRules() ([]AssetInterceptRule, error) {
|
||
return s.db.ListAssetInterceptRules()
|
||
}
|
||
|
||
// CheckAssetsIntercept loads assets by id, runs the "intercept first, then allow"
|
||
// gate on each, and returns all rejected assets. Interception rules = global ∪
|
||
// task-level block; allow rules = task-level allow (this task only).
|
||
// Returns fast when there are no ids. Uses the global GetByIDs (not filtered by
|
||
// task scope) so interception is never weakened by scope.
|
||
func (s *AssetStore) CheckAssetsIntercept(taskID int64, ids []int64) ([]AssetInterceptHit, error) {
|
||
if len(ids) == 0 {
|
||
return nil, nil
|
||
}
|
||
blockRules, err := s.db.ListAssetInterceptRules()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var allowRules []AssetInterceptRule
|
||
if taskID > 0 {
|
||
tb, ta, err := s.TaskInterceptRulesSplit(taskID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
blockRules = append(blockRules, tb...)
|
||
allowRules = ta
|
||
}
|
||
// No interception rules and no enabled allow rules → nothing to decide, allow all.
|
||
if len(blockRules) == 0 && !hasEnabledRule(allowRules) {
|
||
return nil, nil
|
||
}
|
||
assets, err := s.GetByIDs(ids)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var hits []AssetInterceptHit
|
||
for _, a := range assets {
|
||
domains, ips, urls := a.interceptCandidates()
|
||
if d := EvaluateAssetGate(blockRules, allowRules, domains, ips, urls); !d.Allowed {
|
||
hits = append(hits, AssetInterceptHit{Asset: a, Reason: d.Reason})
|
||
}
|
||
}
|
||
return hits, nil
|
||
}
|