mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +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.
390 lines
15 KiB
Go
390 lines
15 KiB
Go
package intercept
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
)
|
|
|
|
// Block codes of the gate decision. Like the codes of a target request they
|
|
// are carried in the model-facing text of the blocked call. None of them opens
|
|
// an approval request.
|
|
const (
|
|
// TargetResolveFailed: a name did not resolve, so there is no address to
|
|
// compare with a pin or to show to a human.
|
|
TargetResolveFailed = "TARGET_RESOLVE_FAILED"
|
|
// TargetRuleBlocked: an asset block rule matches a target.
|
|
TargetRuleBlocked = "TARGET_RULE_BLOCKED"
|
|
// TargetTooMany: the call names more unpinned targets than one call may request.
|
|
TargetTooMany = "TARGET_TOO_MANY"
|
|
)
|
|
|
|
// maxUnpinnedTargets is how many distinct targets without a covering pin one
|
|
// call may request approval for. Each approval is one click on one host.
|
|
const maxUnpinnedTargets = 5
|
|
|
|
// literalResolver is the resolver recorded for an IP literal or a CIDR: the
|
|
// address is what was written, nothing was resolved.
|
|
const literalResolver = "literal"
|
|
|
|
// targetStore is what the gate reads and writes. The database implements it.
|
|
type targetStore interface {
|
|
// BlockRules returns the asset block rules that apply to the scope: the
|
|
// global ones and, for a task, its own. Allow rules play no role in the gate.
|
|
BlockRules(scope db.TargetScopeRef) ([]db.AssetInterceptRule, error)
|
|
// ActivePins returns the active pins that may cover (host, port) in scope,
|
|
// the pin on the exact port first.
|
|
ActivePins(scope db.TargetScopeRef, host string, port int) ([]*db.TargetPin, error)
|
|
// Exemptions returns the stored exemption list and its digest.
|
|
Exemptions() ([]db.TargetExemption, string, error)
|
|
// LogDecision records an outcome no human took.
|
|
LogDecision(db.TargetDecision) error
|
|
}
|
|
|
|
type dbTargetStore struct{ d *db.DB }
|
|
|
|
func (s dbTargetStore) BlockRules(scope db.TargetScopeRef) ([]db.AssetInterceptRule, error) {
|
|
rules, err := s.d.ListAssetInterceptRules()
|
|
if err != nil || scope.TaskID == 0 {
|
|
return rules, err
|
|
}
|
|
block, _, err := s.d.Assets().TaskInterceptRulesSplit(scope.TaskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(rules, block...), nil
|
|
}
|
|
|
|
func (s dbTargetStore) ActivePins(scope db.TargetScopeRef, host string, port int) ([]*db.TargetPin, error) {
|
|
return s.d.ActiveTargetPins(scope, host, port)
|
|
}
|
|
|
|
func (s dbTargetStore) Exemptions() ([]db.TargetExemption, string, error) {
|
|
list, raw, err := s.d.TargetExemptions()
|
|
if raw == "" {
|
|
raw = "[]"
|
|
}
|
|
return list, digestInput([]byte(raw)), err
|
|
}
|
|
|
|
func (s dbTargetStore) LogDecision(r db.TargetDecision) error {
|
|
_, err := s.d.LogTargetDecision(r)
|
|
return err
|
|
}
|
|
|
|
// CheckTargets decides, for one tool call of a gated scope, whether every
|
|
// network target the call declares may be reached: extraction, asset block
|
|
// rules, exemptions, resolution and the pin lookup. It creates no approval
|
|
// request and never waits.
|
|
//
|
|
// A block is reported in the result (Code, Reason and the target that caused
|
|
// it) and logged as a decided row. Otherwise the returned requests are the
|
|
// targets no pin covers, each with the addresses a human has to read; the
|
|
// caller passes them to RequestTargets and checks again after the decisions.
|
|
// No request and no code means the call may run.
|
|
//
|
|
// With the gate off the call returns at once: nothing is extracted, resolved,
|
|
// read or written.
|
|
func (i *Interceptor) CheckTargets(ctx context.Context, scope TargetScope, toolName string, input []byte) ([]TargetRequest, TargetResult) {
|
|
if scope.Enabled == nil || !scope.Enabled() {
|
|
return nil, TargetResult{}
|
|
}
|
|
declared := extractDeclared(toolName, input)
|
|
if len(declared.targets) == 0 {
|
|
return nil, TargetResult{}
|
|
}
|
|
c := &targetCheck{i: i, ctx: ctx, scope: scope, tool: toolName, input: input, urls: declared.urls}
|
|
return c.run(declared.targets)
|
|
}
|
|
|
|
// targetCheck is one run of the gate decision.
|
|
type targetCheck struct {
|
|
i *Interceptor
|
|
ctx context.Context
|
|
scope TargetScope
|
|
ref db.TargetScopeRef
|
|
tool string
|
|
input []byte
|
|
urls []string // the URLs the call declares, as written, for the URL block rules
|
|
}
|
|
|
|
// checkedTarget is one target of the call and what the gate learned about it.
|
|
type checkedTarget struct {
|
|
Target
|
|
ips []string // resolved addresses; the literal itself for an IP or a CIDR
|
|
resolver string
|
|
resolvedAt time.Time
|
|
err error // the name did not resolve
|
|
exemptVoid bool
|
|
}
|
|
|
|
func (t Target) String() string {
|
|
return targetName(TargetRequest{Host: t.Host, Port: t.Port})
|
|
}
|
|
|
|
func (t *checkedTarget) audit(outcome string) db.InterceptTarget {
|
|
return db.InterceptTarget{Host: t.Host, Port: t.Port, Kind: t.Kind, ResolvedIPs: t.ips,
|
|
Resolver: t.resolver, ResolvedAt: t.resolvedAt, Outcome: outcome, ExemptVoid: t.exemptVoid}
|
|
}
|
|
|
|
func (c *targetCheck) run(targets []Target) ([]TargetRequest, TargetResult) {
|
|
i := c.i
|
|
var err error
|
|
if c.ref, err = c.scope.ref(); err != nil {
|
|
return nil, TargetResult{Code: TargetGateUnavailable, Host: targets[0].Host, Port: targets[0].Port, Reason: err.Error()}
|
|
}
|
|
first := &checkedTarget{Target: targets[0]}
|
|
rules, err := i.targets.BlockRules(c.ref)
|
|
if err != nil {
|
|
return c.block(TargetGateUnavailable, "gate_unavailable", first, "the asset rules could not be read: "+err.Error())
|
|
}
|
|
ex, err := i.loadExemptions()
|
|
if err != nil {
|
|
return c.block(TargetGateUnavailable, "gate_unavailable", first, "the exemption list could not be read: "+err.Error())
|
|
}
|
|
|
|
// Block rules come first, and an exempt or pinned host that matches one is
|
|
// blocked. They are matched on what the call declares before anything is
|
|
// resolved: a hit there costs no lookup, for any target of the call.
|
|
//
|
|
// URL rules (exact_url, fuzzy_url) are matched once per decision, on the
|
|
// URLs as the call writes them. That is a known limit: exact_url compares
|
|
// the string as written, so another case, encoding (%2F), a trailing slash,
|
|
// an added query or /./ does not match, and fuzzy_url is evaded by encoding
|
|
// the substring; a command can also build its URL at run time. To keep an
|
|
// agent away from a host, a domain, IP or CIDR rule is the one to write.
|
|
if rule, u, hit := db.MatchAssetInterceptRules(rules, nil, nil, c.urls); hit {
|
|
ct := &checkedTarget{Target: targets[0]}
|
|
if t, ok := NormalizeTarget(u); ok {
|
|
ct.Target = t
|
|
}
|
|
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", u, rule.Reason()))
|
|
}
|
|
// Then the declared form of every target: the name as written, the IP
|
|
// literal, the range.
|
|
checked := make([]*checkedTarget, len(targets))
|
|
for n, t := range targets {
|
|
ct := &checkedTarget{Target: t}
|
|
checked[n] = ct
|
|
if t.Kind != TargetDomain {
|
|
ct.ips, ct.resolver, ct.resolvedAt = []string{t.Host}, literalResolver, time.Now().UTC()
|
|
}
|
|
if rule, hit := blockRule(rules, ct); hit {
|
|
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", t, rule.Reason()))
|
|
}
|
|
}
|
|
// Only then are the names resolved, and the rules matched on the addresses.
|
|
// A name the configuration exempts is never resolved.
|
|
for _, ct := range checked {
|
|
if ct.Kind != TargetDomain || ex.derived[db.TargetKey(ct.Host, ct.Port)] {
|
|
continue
|
|
}
|
|
c.resolve(ct, false)
|
|
if rule, hit := blockRule(rules, ct); hit {
|
|
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", ct.Target, rule.Reason()))
|
|
}
|
|
}
|
|
|
|
i.mu.RLock()
|
|
proxied := i.upstreamProxy
|
|
i.mu.RUnlock()
|
|
// Behind the upstream proxy the proxy chooses the address it connects to, so
|
|
// every pin is name-only for this call, whatever the pin row says: the proxy
|
|
// can be turned on after a pin was approved.
|
|
nameOnly := proxied != nil && proxied()
|
|
|
|
var unpinned []TargetRequest
|
|
var uses []func()
|
|
for _, ct := range checked {
|
|
key := db.TargetKey(ct.Host, ct.Port)
|
|
if ex.derived[key] {
|
|
uses = append(uses, func() { c.exemptUsed(ct, ex.digest) })
|
|
continue
|
|
}
|
|
if ex.stored[key] {
|
|
if exemptionHolds(ct) {
|
|
uses = append(uses, func() { c.exemptUsed(ct, ex.digest) })
|
|
continue
|
|
}
|
|
// A void exemption is not a block: the target goes on like any other.
|
|
ct.exemptVoid = true
|
|
}
|
|
if ct.err != nil {
|
|
return c.block(TargetResolveFailed, "resolve_failed", ct, c.resolveFailure(ct))
|
|
}
|
|
pins, err := i.targets.ActivePins(c.ref, ct.Host, ct.Port)
|
|
if err != nil {
|
|
return c.block(TargetGateUnavailable, "gate_unavailable", ct, "the pins could not be read: "+err.Error())
|
|
}
|
|
pin := coveringPin(pins, ct.ips, nameOnly)
|
|
if pin == nil && ct.Kind == TargetDomain {
|
|
// A request is about to be raised: the addresses a human reads and
|
|
// pins are resolved now, never taken from the cache.
|
|
if c.resolve(ct, true); ct.err != nil {
|
|
return c.block(TargetResolveFailed, "resolve_failed", ct, c.resolveFailure(ct))
|
|
}
|
|
if rule, hit := blockRule(rules, ct); hit {
|
|
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", ct.Target, rule.Reason()))
|
|
}
|
|
pin = coveringPin(pins, ct.ips, nameOnly)
|
|
}
|
|
if pin != nil {
|
|
uses = append(uses, func() { c.pinUsed(ct, pin) })
|
|
continue
|
|
}
|
|
req := TargetRequest{Host: ct.Host, Port: ct.Port, Kind: ct.Kind, ResolvedIPs: ct.ips,
|
|
Resolver: ct.resolver, ResolvedAt: ct.resolvedAt, Reason: "no_pin", ExemptVoid: ct.exemptVoid}
|
|
if len(pins) > 0 {
|
|
// The request is raised on the key of the pin that exists (the one on
|
|
// the exact port when both exist), so that an approval extends that pin.
|
|
req.Port, req.Reason, req.Pin = pins[0].Port, "new_ip", pins[0]
|
|
}
|
|
if !hasRequest(unpinned, req) {
|
|
unpinned = append(unpinned, req)
|
|
}
|
|
}
|
|
if len(unpinned) > maxUnpinnedTargets {
|
|
names := make([]string, len(unpinned))
|
|
audit := make([]db.InterceptTarget, len(unpinned))
|
|
for n, r := range unpinned {
|
|
names[n] = targetName(r)
|
|
audit[n] = db.InterceptTarget{Host: r.Host, Port: r.Port, Kind: r.Kind, ResolvedIPs: r.ResolvedIPs,
|
|
Resolver: r.Resolver, ResolvedAt: r.ResolvedAt, Reason: r.Reason, Outcome: "too_many"}
|
|
}
|
|
reason := fmt.Sprintf("this call names %d targets that are not approved (%s); at most %d are requested per call",
|
|
len(unpinned), strings.Join(names, ", "), maxUnpinnedTargets)
|
|
c.log("denied", TargetTooMany+": "+reason, "", audit...)
|
|
return nil, TargetResult{Code: TargetTooMany, Host: unpinned[0].Host, Port: unpinned[0].Port, Reason: reason}
|
|
}
|
|
if len(unpinned) == 0 {
|
|
// The call passes: record what let it pass, once per process.
|
|
for _, use := range uses {
|
|
use()
|
|
}
|
|
}
|
|
return unpinned, TargetResult{}
|
|
}
|
|
|
|
// resolve fills the addresses of a name. fresh skips the cache.
|
|
func (c *targetCheck) resolve(ct *checkedTarget, fresh bool) {
|
|
ct.resolver = c.i.resolve.name()
|
|
res, err := c.i.resolve.lookup(c.ctx, ct.Host, fresh)
|
|
ct.ips, ct.resolvedAt, ct.err = res.ips, res.at, err
|
|
}
|
|
|
|
func (c *targetCheck) resolveFailure(ct *checkedTarget) string {
|
|
reason := fmt.Sprintf("the platform could not resolve %s (%v)", ct.Host, ct.err)
|
|
if pins, err := c.i.targets.ActivePins(c.ref, ct.Host, ct.Port); err == nil && len(pins) > 0 {
|
|
reason += fmt.Sprintf(", so it cannot compare the address with the approved pin (v%d: %s)",
|
|
pins[0].Version, strings.Join(pins[0].PinnedIPs, ", "))
|
|
}
|
|
return reason
|
|
}
|
|
|
|
// block logs a blocked call as a decided row and returns the block.
|
|
func (c *targetCheck) block(code, outcome string, ct *checkedTarget, reason string) ([]TargetRequest, TargetResult) {
|
|
c.log("denied", code+": "+reason, "", ct.audit(outcome))
|
|
return nil, TargetResult{Code: code, Host: ct.Host, Port: ct.Port, Reason: reason}
|
|
}
|
|
|
|
// log writes one decided row of the gate. Best effort: a failed write never
|
|
// changes the outcome of the call.
|
|
func (c *targetCheck) log(status, reason, configDigest string, targets ...db.InterceptTarget) {
|
|
action := "deny"
|
|
if status == "allowed" {
|
|
action = "allow"
|
|
}
|
|
audit := auditFor(c.ctx, Decision{Action: action, Message: reason, ConfigDigest: configDigest}, c.input, status)
|
|
if status == "allowed" {
|
|
// The row records that a pin or an exemption was used, not this one call.
|
|
audit.ExecutionStatus = "unknown"
|
|
}
|
|
audit.Targets = targets
|
|
_, agentName := taskInfoFromCtx(c.ctx)
|
|
_ = c.i.targets.LogDecision(db.TargetDecision{Scope: c.ref, AgentName: agentName, ToolName: c.tool,
|
|
ToolInput: c.input, Status: status, Reason: reason, Audit: audit})
|
|
}
|
|
|
|
// exemptUsed logs the first call of this process that passes through an
|
|
// exemption, per scope, host, port and list digest.
|
|
func (c *targetCheck) exemptUsed(ct *checkedTarget, digest string) {
|
|
key := "exempt|" + c.scope.scopeName() + "|" + db.TargetKey(ct.Host, ct.Port) + "|" + digest
|
|
if _, seen := c.i.firstUse.LoadOrStore(key, true); seen {
|
|
return
|
|
}
|
|
c.log("allowed", fmt.Sprintf("Target %s is exempt from target approval for %s.", ct.Target, c.scope.scopeName()),
|
|
digest, ct.audit("exempt"))
|
|
}
|
|
|
|
// pinUsed logs the first call of this process that a pin version covers.
|
|
func (c *targetCheck) pinUsed(ct *checkedTarget, pin *db.TargetPin) {
|
|
if _, seen := c.i.firstUse.LoadOrStore(fmt.Sprintf("pin|%d", pin.ID), true); seen {
|
|
return
|
|
}
|
|
t := ct.audit("pin_match")
|
|
t.PinID, t.PinVersion, t.PinnedIPs, t.NameOnlyEffective = pin.ID, pin.Version, pin.PinnedIPs, pin.NameOnlyEffective
|
|
c.log("allowed", fmt.Sprintf("Target %s is covered by pin v%d of %s.", ct.Target, pin.Version, c.scope.scopeName()), "", t)
|
|
}
|
|
|
|
// blockRule matches the asset block rules against a target: its name, and its
|
|
// addresses. A range is matched as written and by its network address. URL
|
|
// rules are not matched here: run matches them once, on the declared URLs.
|
|
func blockRule(rules []db.AssetInterceptRule, ct *checkedTarget) (db.AssetInterceptRule, bool) {
|
|
var domains []string
|
|
ips := ct.ips
|
|
switch ct.Kind {
|
|
case TargetDomain:
|
|
domains = []string{ct.Host}
|
|
case TargetCIDR:
|
|
if p, err := netip.ParsePrefix(ct.Host); err == nil {
|
|
ips = []string{ct.Host, p.Addr().String()}
|
|
}
|
|
}
|
|
rule, _, hit := db.MatchAssetInterceptRules(rules, domains, ips, nil)
|
|
return rule, hit
|
|
}
|
|
|
|
// exemptionHolds reports whether the stored exemption on a target applies. On a
|
|
// name it is void when the name does not resolve or one of its addresses is
|
|
// private, loopback or otherwise local. An IP literal is not resolved; it holds
|
|
// unless it is an address that can never be exempt.
|
|
func exemptionHolds(ct *checkedTarget) bool {
|
|
if ct.Kind == TargetIP {
|
|
return ValidateExemptionHost(ct.Host) == nil
|
|
}
|
|
if ct.Kind != TargetDomain || ct.err != nil || len(ct.ips) == 0 {
|
|
return false
|
|
}
|
|
for _, ip := range ct.ips {
|
|
if voidsExemption(ip) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// coveringPin returns the pin that covers the resolved addresses: the first
|
|
// candidate that is name-only, or that holds every address. The address sets
|
|
// of two pins are never merged.
|
|
func coveringPin(pins []*db.TargetPin, resolved []string, nameOnly bool) *db.TargetPin {
|
|
for _, p := range pins {
|
|
if nameOnly || p.NameOnlyEffective || subset(resolved, p.PinnedIPs) {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasRequest(list []TargetRequest, r TargetRequest) bool {
|
|
for _, x := range list {
|
|
if x.Host == r.Host && x.Port == r.Port {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|