mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
The target gate resolved each name through the cache before it matched the asset block rules, so a call to a name that a name rule blocks still cost a DNS lookup, and that query reached the name servers of the blocked domain. URL rules (exact_url, fuzzy_url) were not evaluated at dispatch at all: the gate passed no URL to the matcher, so they only applied to asset and intent writes. targetCheck.run now decides in this order, for a gated call: 1. URL rules, once per decision, on the URLs the call declares, as written. 2. Every block rule on the declared form of every target: the name as written, the IP literal, the range. 3. Only then resolution (names a derived exemption covers are still never resolved), and the block rules again on the resolved addresses. 4. Exemptions, pins and requests, unchanged. A hit in 1 or 2 blocks with TARGET_RULE_BLOCKED and the same decided row as any block, costs no lookup for any target of the call, and opens no request. - intercept.extractDeclared returns the targets (ExtractTargets is now a thin wrapper, unchanged for its callers) and the URLs that name a target: the WebFetch url, and every URL token the command-string rules read in Bash, shell_open, Monitor, shell_send and the string leaves of other tools, including ExecuteExtraTool's inner call. - The custom http tool's rendered URL and the URLs of a rendered command go to the same URL rules (gateRendered takes the URLs). - Known limit, stated in the comments next to the matcher (db.matchOne) and at the dispatch match: URL rules match the declared string. exact_url misses any other spelling (case, percent-encoding, trailing slash, added query, /./), fuzzy_url misses an encoded substring, and a command can build its URL at run time. For a host, a domain, IP or CIDR rule is the one to write. Unchanged: a tool-rule or judge deny still blocks before the gate is reached (no extraction, no lookup, no request); asset block rules still win over an allow and an ask; the gate off path does nothing; scripts stay advisory. Tests: - intercept: TestBlockRuleOnDeclaredNameNeedsNoLookup (exact_domain and fuzzy_domain; zero lookups, also for an unblocked name earlier in the same call), TestExtractDeclaredURLs, TestURLBlockRuleAtDispatch (WebFetch, Bash, MCP, ExecuteExtraTool, a pinned and an exempt host, a disabled rule, the rendered URL and command; zero lookups), TestExactURLRuleMatchesOnlyAsWritten (documents the limit). - guard: TestBlockRuleWinsOverAllowAndAsk (name rule and URL rule, each over a rule allow, judge allow, rule ask and judge ask, real interceptor: blocked, no allow row, nobody asked, zero lookups). TestDenyRuleWinsBeforeTargetRequest now also covers a judge deny and asserts zero lookups with the real gate. - server: TestBypass_CustomScriptToolTargetNotGated documents that a script tool's target, written in its stored code, is not visible to the gate: in a gated task the call is allowed and reaches the target with no lookup, no request and no row.
268 lines
8.0 KiB
Go
268 lines
8.0 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.
|
||
//
|
||
// URL rules match the URL string as given, with no normalisation. Known limit:
|
||
// exact_url misses any other spelling of the same URL (case, percent-encoding
|
||
// such as %2F, a trailing slash, an added query, /./), and fuzzy_url misses an
|
||
// encoded form of its substring. At tool dispatch the string is the URL the
|
||
// call declares (intercept, targetCheck.run).
|
||
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
|
||
}
|