mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 12:40:55 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3edf9bfe | ||
|
|
2e55615713 | ||
|
|
251ab680cc |
@@ -7,6 +7,7 @@ All notable changes to this project are documented in this file. The format is b
|
||||
### Added
|
||||
|
||||
- **Noise profile at runtime**: System settings has a **Target noise** card. It sets the profile (`off`, `quiet`, `balanced`, `aggressive`) through the `noise_profile` setting, which takes precedence over `ARTEX_NOISE_PROFILE`. The change applies to the next WebFetch, http custom tool and enrich request, and to the next agent prompt, without a restart. The card also shows the per-host counters, which `GET /api/settings/noise` returns.
|
||||
- **Bash requests paced through the capture proxy**: with traffic capture on, the Bash proxy URL carries a marker userinfo. The recording proxy paces each request of a marked connection by the noise profile's per-host rate and jitter, and counts it, before it goes upstream. This applies inside CONNECT tunnels too. WebFetch, the http custom tools and enrich keep the unmarked URL and are not paced twice. The proxy now drops `Proxy-Authorization` from every request it forwards or records. This is best effort: only clients that honour the proxy are covered, with capture on.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -405,8 +405,13 @@ flight), `aggressive` = 20 req/s (burst 40, 4 in flight). A profile adds jitter,
|
||||
429/503 (honours `Retry-After`, bounded retries, GET/HEAD/OPTIONS only), deduplicates identical
|
||||
in-flight GETs and caches them briefly (60/30/10 s; never POST/PUT/PATCH/DELETE), and logs a
|
||||
per-host noise line each minute. `ARTEX_TARGET_USER_AGENT` sets a User-Agent on target requests
|
||||
that carry none. Bash-launched tools are not paced yet; the low-and-slow prompt block supplies
|
||||
their rate flags.
|
||||
that carry none. With traffic capture on, Bash commands that use the proxy (curl, wget,
|
||||
python-requests) are paced too, per host by the same rate and jitter, and counted in the same
|
||||
per-host lines. The in-flight caps, retries and cache apply only to ARTEX's own requests. This is
|
||||
best effort, not a security boundary. Raw scanners such as nmap are not paced, and neither is a
|
||||
command that bypasses the proxy (`--noproxy`, a removed proxy variable). With capture off,
|
||||
nothing launched from Bash is paced. The low-and-slow prompt block supplies the rate flags in
|
||||
every case.
|
||||
The **Target noise** card in System settings sets the profile at runtime (no restart) and shows the
|
||||
per-host counters; once saved, that setting takes precedence over `ARTEX_NOISE_PROFILE`.
|
||||
|
||||
|
||||
@@ -64,6 +64,6 @@ func renderNoiseProfile(p egress.Profile) string {
|
||||
fmt.Fprintf(&b, "- sqlmap: --delay=%s --threads=1\n", delaySec)
|
||||
fmt.Fprintf(&b, "For any other tool, stay at or below %d requests per second per host, at most %s in flight, and at least %d ms between requests, using the tool's own rate or delay flag. Keep bbot within the same limits with its own rate settings, and do not enable active modules the program policy does not allow.\n", rps, inFlight, delayMS)
|
||||
b.WriteString("Prefer passive and read-only checks; test one host at a time; do not run two scanners against the same host in parallel; on 429 or 503 stop and back off; never raise the rate to meet a deadline.\n")
|
||||
b.WriteString("ARTEX paces its own WebFetch, http custom tool and enrich traffic with this profile, but commands launched through Bash are not paced: these flags are the only brake.\n")
|
||||
b.WriteString("ARTEX paces its own WebFetch, http custom tool and enrich traffic with this profile. With traffic capture on, Bash commands that use the capture proxy (curl, wget, python-requests) are paced too, by rate and jitter; raw scanners such as nmap and commands that bypass the proxy are not. These flags remain the brake: do not rely on the proxy.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestRenderNoiseProfileTable(t *testing.T) {
|
||||
fmt.Sprintf("- dalfox scan: --rate-limit %d --workers %d", c.rps, c.conc),
|
||||
fmt.Sprintf("- sqlmap: --delay=%s --threads=1", c.sql),
|
||||
fmt.Sprintf("at least %d ms between requests", c.delay),
|
||||
"commands launched through Bash are not paced",
|
||||
"ARTEX paces its own WebFetch, http custom tool and enrich traffic with this profile. With traffic capture on, Bash commands that use the capture proxy (curl, wget, python-requests) are paced too, by rate and jitter; raw scanners such as nmap and commands that bypass the proxy are not. These flags remain the brake: do not rely on the proxy.",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("%s: missing %q in:\n%s", c.name, want, got)
|
||||
|
||||
@@ -49,3 +49,26 @@ func TestProxyEnvInjectsCAWhenRecording(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the recording proxy (a CA is present) gets the pace marker; the global
|
||||
// proxy of the capture-off path is passed as configured.
|
||||
func TestProxyEnvMarksOnlyTheRecordingProxy(t *testing.T) {
|
||||
proxyVars := map[string]bool{"HTTP_PROXY": true, "HTTPS_PROXY": true, "ALL_PROXY": true}
|
||||
check := func(env []string, want, what string) {
|
||||
t.Helper()
|
||||
n := 0
|
||||
for _, e := range env {
|
||||
if k, v, _ := strings.Cut(e, "="); proxyVars[strings.ToUpper(k)] {
|
||||
n++
|
||||
if v != want {
|
||||
t.Errorf("%s: %s, want %s", what, e, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n != 6 {
|
||||
t.Errorf("%s: %d proxy variables, want 6", what, n)
|
||||
}
|
||||
}
|
||||
check(proxyEnv("http://127.0.0.1:8788", "/data/ca.pem"), "http://artex-pace:artex-pace@127.0.0.1:8788", "capture on")
|
||||
check(proxyEnv("http://u:p@10.0.0.1:3128", ""), "http://u:p@10.0.0.1:3128", "capture off")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/agentcore"
|
||||
"github.com/Autumn-27/norma/harness"
|
||||
@@ -212,10 +213,19 @@ func (w *Worker) SetWebSearch(o WebSearchOpts) { w.webSearch = o }
|
||||
// honor the proxy vars. ALL_PROXY is set too so a socks5 egress proxy (which curl
|
||||
// only reads from ALL_PROXY, not HTTP(S)_PROXY) works in the capture-off path.
|
||||
// Empty proxyAddr → nil (direct, unchanged env).
|
||||
//
|
||||
// With a MITM CA (capture on) the proxy URL carries the pace marker userinfo
|
||||
// (egress.MarkProxyURL): the recording proxy then paces this connection's
|
||||
// requests by the noise profile. Only Bash gets the marker; WebFetch, the http
|
||||
// custom tools and enrich keep the plain URL, since egress paces them already.
|
||||
// The global proxy (capture off) is left as configured.
|
||||
func proxyEnv(proxyAddr, caCert string) []string {
|
||||
if proxyAddr == "" {
|
||||
return nil
|
||||
}
|
||||
if caCert != "" {
|
||||
proxyAddr = egress.MarkProxyURL(proxyAddr)
|
||||
}
|
||||
env := []string{
|
||||
"HTTP_PROXY=" + proxyAddr, "HTTPS_PROXY=" + proxyAddr,
|
||||
"http_proxy=" + proxyAddr, "https_proxy=" + proxyAddr,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package egress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Bash commands reach targets through the recording proxy, outside the clients
|
||||
// this package governs. With traffic capture on, the proxy URL in their
|
||||
// environment carries a marker userinfo (MarkProxyURL). The proxy paces each
|
||||
// request of a marked client connection with Wait, and leaves unmarked
|
||||
// connections alone: WebFetch, the http custom tools and the enrich probe use
|
||||
// the plain proxy URL and are already paced by their governor, so no request
|
||||
// is paced twice. Best effort, not a security boundary: a command that ignores
|
||||
// the proxy, or drops the marker, is not paced.
|
||||
|
||||
// PaceUser is the user and password of the marker userinfo.
|
||||
const PaceUser = "artex-pace"
|
||||
|
||||
// MarkProxyURL returns raw with the marker userinfo. raw is returned unchanged
|
||||
// when it does not parse or already carries a userinfo.
|
||||
func MarkProxyURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" || u.User != nil {
|
||||
return raw
|
||||
}
|
||||
u.User = url.UserPassword(PaceUser, PaceUser)
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// IsPaceMarker reports whether a Proxy-Authorization value is Basic
|
||||
// credentials for PaceUser.
|
||||
func IsPaceMarker(proxyAuthorization string) bool {
|
||||
scheme, enc, ok := strings.Cut(strings.TrimSpace(proxyAuthorization), " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Basic") {
|
||||
return false
|
||||
}
|
||||
dec, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
user, _, _ := strings.Cut(string(dec), ":")
|
||||
return user == PaceUser
|
||||
}
|
||||
|
||||
// Wait paces one request to hostKey under the current profile. See
|
||||
// Governor.Wait.
|
||||
func Wait(ctx context.Context, hostKey string) error { return Current().Wait(ctx, hostKey) }
|
||||
|
||||
// Wait takes a token of the host's bucket plus the jitter, as a governed request
|
||||
// does, and counts the request in the host's noise counter. It takes no
|
||||
// in-flight slot, and nothing is cached, coalesced or retried. Off, it returns
|
||||
// at once and counts nothing. An error means ctx ended first; the request is
|
||||
// counted either way, since the caller sends it regardless.
|
||||
func (g *Governor) Wait(ctx context.Context, hostKey string) error {
|
||||
if g.off() {
|
||||
return nil
|
||||
}
|
||||
h := g.host(hostKey)
|
||||
err := sleep(ctx, g.reserve(h)+g.jitter())
|
||||
g.mu.Lock()
|
||||
h.sent++
|
||||
g.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
package egress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMarkProxyURL(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"http://127.0.0.1:8788": "http://artex-pace:artex-pace@127.0.0.1:8788",
|
||||
"http://u:p@127.0.0.1:8788": "http://u:p@127.0.0.1:8788",
|
||||
"": "",
|
||||
"127.0.0.1:8788": "127.0.0.1:8788",
|
||||
"socks5://proxy.example.test:1080": "socks5://artex-pace:artex-pace@proxy.example.test:1080",
|
||||
} {
|
||||
if got := MarkProxyURL(in); got != want {
|
||||
t.Errorf("MarkProxyURL(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPaceMarker(t *testing.T) {
|
||||
enc := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
||||
for v, want := range map[string]bool{
|
||||
"Basic " + enc("artex-pace:artex-pace"): true,
|
||||
"basic " + enc("artex-pace:x"): true,
|
||||
"Basic " + enc("user:pass"): false,
|
||||
"Bearer " + enc("artex-pace:artex-pace"): false,
|
||||
"Basic !!!": false,
|
||||
"Basic": false,
|
||||
"": false,
|
||||
} {
|
||||
if got := IsPaceMarker(v); got != want {
|
||||
t.Errorf("IsPaceMarker(%q) = %v, want %v", v, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWait(t *testing.T) {
|
||||
if err := New(Profile{Name: "off"}).Wait(t.Context(), "a.example"); err != nil {
|
||||
t.Fatalf("off Wait: %v", err)
|
||||
}
|
||||
off := New(Profile{Name: "off"})
|
||||
_ = off.Wait(t.Context(), "a.example")
|
||||
if s := off.Snapshot(); len(s) != 0 {
|
||||
t.Fatalf("off counted: %v", s)
|
||||
}
|
||||
|
||||
// 10 req/s, burst 1, no jitter: the second Wait takes ~100 ms.
|
||||
g := New(Profile{Name: "test", RPS: 10, Burst: 1, MaxPerHost: 1, MaxGlobal: 1})
|
||||
start := time.Now()
|
||||
for range 2 {
|
||||
if err := g.Wait(t.Context(), "a.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if d := time.Since(start); d < 80*time.Millisecond {
|
||||
t.Fatalf("two Waits at 10 req/s took %v", d)
|
||||
}
|
||||
if s := g.Snapshot(); len(s) != 1 || s[0].RPM != 2 || s[0].Inflight != 0 || s[0].PeakInflight != 0 {
|
||||
t.Fatalf("snapshot after Wait = %+v, want rpm 2 and no in-flight slot", s)
|
||||
}
|
||||
|
||||
// A wait past the context's end returns its error and still counts.
|
||||
slow := New(Profile{Name: "test", RPS: 0.5, Burst: 1, MaxPerHost: 1, MaxGlobal: 1})
|
||||
_ = slow.Wait(t.Context(), "b.example")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := slow.Wait(ctx, "b.example"); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("bounded Wait: %v, want DeadlineExceeded", err)
|
||||
}
|
||||
if s := slow.Snapshot(); len(s) != 1 || s[0].RPM != 2 {
|
||||
t.Fatalf("snapshot after a timed-out Wait = %+v, want rpm 2", s)
|
||||
}
|
||||
|
||||
// The package Wait uses the current profile.
|
||||
keepProfile(t)
|
||||
SetProfile(Profile{Name: "off"})
|
||||
if err := Wait(t.Context(), "c.example"); err != nil || len(Current().Snapshot()) != 0 {
|
||||
t.Fatalf("package Wait under off: err=%v snapshot=%v", err, Current().Snapshot())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
mproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||
)
|
||||
|
||||
// paceWaitMax bounds the pacing wait of one request. Past it the request is
|
||||
// sent unpaced: pacing never blocks a command indefinitely.
|
||||
const paceWaitMax = 30 * time.Second
|
||||
|
||||
// Requestheaders runs for every request the proxy sees: the CONNECT that opens
|
||||
// a tunnel, each request inside an intercepted tunnel, and each plain-HTTP
|
||||
// request. The recording proxy takes no credentials, so a Proxy-Authorization
|
||||
// header is only ever meant for it (clients send the proxy URL's userinfo
|
||||
// there): it is dropped here, before anything is forwarded, and never reaches
|
||||
// the target or the recording. When it carries the pace marker
|
||||
// (egress.PaceUser), the client connection is marked; every request on it,
|
||||
// including those inside its tunnel, is then paced by the current noise
|
||||
// profile before it goes upstream. Unmarked connections are not paced here.
|
||||
func (s *sink) Requestheaders(f *mproxy.Flow) {
|
||||
if f.Request == nil || f.ConnContext == nil || f.ConnContext.ClientConn == nil {
|
||||
return
|
||||
}
|
||||
id := f.ConnContext.ClientConn.Id
|
||||
if egress.IsPaceMarker(f.Request.Header.Get("Proxy-Authorization")) {
|
||||
s.paced.Store(id, struct{}{})
|
||||
}
|
||||
f.Request.Header.Del("Proxy-Authorization")
|
||||
if f.Request.Method == http.MethodConnect || f.Request.URL == nil {
|
||||
return
|
||||
}
|
||||
if _, marked := s.paced.Load(id); marked {
|
||||
pace(f)
|
||||
}
|
||||
}
|
||||
|
||||
// ClientDisconnected forgets a closed connection's mark.
|
||||
func (s *sink) ClientDisconnected(c *mproxy.ClientConn) {
|
||||
if c != nil {
|
||||
s.paced.Delete(c.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// pace waits for the request's host under the current noise profile, bounded
|
||||
// by paceWaitMax and by the request's own context. Off returns at once.
|
||||
func pace(f *mproxy.Flow) {
|
||||
ctx := context.Background()
|
||||
if raw := f.Request.Raw(); raw != nil {
|
||||
ctx = raw.Context()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, paceWaitMax)
|
||||
defer cancel()
|
||||
host := egress.HostKey(f.Request.URL)
|
||||
if err := egress.Wait(ctx, host); err != nil {
|
||||
log.Printf("[traffic] pacing a Bash request to %s stopped waiting (%v); sending it unpaced", host, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
mproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||
)
|
||||
|
||||
// startProxy runs a real recording proxy on a free loopback port and returns it
|
||||
// with its sink and URL. extra addons run after the recording sink.
|
||||
func startProxy(t *testing.T, extra ...mproxy.Addon) (*Traffic, *sink, string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
tr, err := Open(t.TempDir(), addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := tr.proxy.Addons[0].(*sink)
|
||||
for _, a := range extra {
|
||||
tr.proxy.AddAddon(a)
|
||||
}
|
||||
go func() { _ = tr.Start() }()
|
||||
t.Cleanup(func() {
|
||||
_ = tr.proxy.Close()
|
||||
_ = tr.Close()
|
||||
})
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
c, err := net.Dial("tcp", addr)
|
||||
if err == nil {
|
||||
c.Close()
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("proxy did not listen on %s: %v", addr, err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return tr, s, "http://" + addr
|
||||
}
|
||||
|
||||
// proxyClient is a curl-like client: it sends the proxy URL's userinfo as
|
||||
// Proxy-Authorization on CONNECT and on plain-HTTP requests, and trusts the CA.
|
||||
func proxyClient(t *testing.T, tr *Traffic, proxyURL string) *http.Client {
|
||||
t.Helper()
|
||||
pem, err := os.ReadFile(tr.CACertPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
t.Fatal("no CA in", tr.CACertPath())
|
||||
}
|
||||
u, _ := url.Parse(proxyURL)
|
||||
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u), TLSClientConfig: &tls.Config{RootCAs: pool}}}
|
||||
}
|
||||
|
||||
// probe records the ClientConn of each request the proxy sees, after the sink.
|
||||
type probe struct {
|
||||
mproxy.BaseAddon
|
||||
mu sync.Mutex
|
||||
conn map[string]string // "METHOD url" -> ClientConn.Id
|
||||
}
|
||||
|
||||
func (p *probe) Requestheaders(f *mproxy.Flow) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.conn == nil {
|
||||
p.conn = map[string]string{}
|
||||
}
|
||||
p.conn[f.Request.Method+" "+f.Request.URL.String()] = f.ConnContext.ClientConn.Id.String()
|
||||
}
|
||||
|
||||
func get(t *testing.T, c *http.Client, u string) {
|
||||
t.Helper()
|
||||
resp, err := c.Get(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET %s: %d", u, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func markedCount(s *sink) int {
|
||||
n := 0
|
||||
s.paced.Range(func(any, any) bool { n++; return true })
|
||||
return n
|
||||
}
|
||||
|
||||
func hostRPM(host string) int {
|
||||
for _, h := range egress.Current().Snapshot() {
|
||||
if h.Host == host {
|
||||
return h.RPM
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// A marked client (the Bash proxy URL) is paced and counted, through a CONNECT
|
||||
// tunnel and on plain HTTP; an unmarked one (WebFetch, custom tools, enrich)
|
||||
// is not paced here. No Proxy-Authorization reaches a target or the recording,
|
||||
// every exchange is recorded, and a closed connection loses its mark.
|
||||
func TestBashPacingThroughProxy(t *testing.T) {
|
||||
prev := egress.CurrentProfile()
|
||||
t.Cleanup(func() { egress.SetProfile(prev) })
|
||||
// 2 req/s, burst 1, no jitter: from the second request on, each waits 500 ms.
|
||||
egress.SetProfile(egress.Profile{Name: "test", RPS: 2, Burst: 1, MaxPerHost: 1, MaxGlobal: 8})
|
||||
|
||||
var mu sync.Mutex
|
||||
leaked := map[string]string{}
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if v := r.Header.Get("Proxy-Authorization"); v != "" {
|
||||
mu.Lock()
|
||||
leaked[r.URL.Path] = v
|
||||
mu.Unlock()
|
||||
}
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
})
|
||||
marked := httptest.NewServer(handler)
|
||||
defer marked.Close()
|
||||
unmarked := httptest.NewServer(handler)
|
||||
defer unmarked.Close()
|
||||
tlsSrv := httptest.NewTLSServer(handler)
|
||||
defer tlsSrv.Close()
|
||||
// The MITM leaf for a bare IP has no IP SAN: address the TLS target by name.
|
||||
tlsURL := strings.Replace(tlsSrv.URL, "127.0.0.1", "localhost", 1)
|
||||
|
||||
p := &probe{}
|
||||
tr, s, proxyURL := startProxy(t, p)
|
||||
bash := proxyClient(t, tr, egress.MarkProxyURL(proxyURL))
|
||||
plain := proxyClient(t, tr, proxyURL)
|
||||
|
||||
start := time.Now()
|
||||
for range 3 {
|
||||
get(t, bash, marked.URL+"/bash")
|
||||
}
|
||||
if d := time.Since(start); d < 900*time.Millisecond {
|
||||
t.Fatalf("3 marked requests to one host took %v, want >= 1s of pacing", d)
|
||||
}
|
||||
if n := hostRPM(strings.TrimPrefix(marked.URL, "http://")); n != 3 {
|
||||
t.Fatalf("egress counter for the marked host = %d, want 3", n)
|
||||
}
|
||||
for range 3 {
|
||||
get(t, plain, unmarked.URL+"/plain")
|
||||
}
|
||||
if n := hostRPM(strings.TrimPrefix(unmarked.URL, "http://")); n != 0 {
|
||||
t.Fatalf("unmarked requests were paced by the proxy: counter %d, want 0", n)
|
||||
}
|
||||
get(t, bash, tlsURL+"/tls1")
|
||||
get(t, bash, tlsURL+"/tls2")
|
||||
tlsHost := strings.TrimPrefix(tlsURL, "https://")
|
||||
if n := hostRPM(tlsHost); n != 2 {
|
||||
t.Fatalf("egress counter for the tunnelled host = %d, want 2", n)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
connectID := p.conn["CONNECT //"+tlsHost]
|
||||
inner1, inner2 := p.conn["GET "+tlsURL+"/tls1"], p.conn["GET "+tlsURL+"/tls2"]
|
||||
p.mu.Unlock()
|
||||
if connectID == "" || inner1 != connectID || inner2 != connectID {
|
||||
t.Fatalf("tunnel flows do not share the CONNECT's connection: connect=%q inner=%q,%q", connectID, inner1, inner2)
|
||||
}
|
||||
mu.Lock()
|
||||
if len(leaked) != 0 {
|
||||
t.Fatalf("Proxy-Authorization reached the target: %v", leaked)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
rows, err := tr.Search("", 0, 100)
|
||||
if err != nil || len(rows) != 8 {
|
||||
t.Fatalf("recorded %d exchanges (err %v), want 8", len(rows), err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
req, _, err := tr.Get(r.ID)
|
||||
if err != nil || strings.Contains(strings.ToLower(req), "proxy-authorization") {
|
||||
t.Fatalf("recorded request %s keeps Proxy-Authorization (err %v):\n%s", r.ID, err, req)
|
||||
}
|
||||
}
|
||||
|
||||
if markedCount(s) == 0 {
|
||||
t.Fatal("no connection marked")
|
||||
}
|
||||
bash.CloseIdleConnections()
|
||||
plain.CloseIdleConnections()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for markedCount(s) != 0 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%d marks left after the clients closed", markedCount(s))
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Off: a marked request is neither paced nor counted.
|
||||
func TestBashPacingOff(t *testing.T) {
|
||||
prev := egress.CurrentProfile()
|
||||
t.Cleanup(func() { egress.SetProfile(prev) })
|
||||
egress.SetProfile(egress.Profile{Name: "off"})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer srv.Close()
|
||||
tr, _, proxyURL := startProxy(t)
|
||||
bash := proxyClient(t, tr, egress.MarkProxyURL(proxyURL))
|
||||
for range 3 {
|
||||
get(t, bash, srv.URL+"/")
|
||||
}
|
||||
if snap := egress.Current().Snapshot(); len(snap) != 0 {
|
||||
t.Fatalf("off counted marked requests: %v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
// The sink drops Proxy-Authorization from every request and marks only on the
|
||||
// pace marker; a disconnect clears the mark.
|
||||
func TestSinkStripsAndMarks(t *testing.T) {
|
||||
prev := egress.CurrentProfile()
|
||||
t.Cleanup(func() { egress.SetProfile(prev) })
|
||||
egress.SetProfile(egress.Profile{Name: "off"})
|
||||
s := &sink{}
|
||||
flow := func(method, auth string, conn *mproxy.ClientConn) *mproxy.Flow {
|
||||
u, _ := url.Parse("http://a.example/")
|
||||
h := http.Header{}
|
||||
if auth != "" {
|
||||
h.Set("Proxy-Authorization", auth)
|
||||
}
|
||||
return &mproxy.Flow{Request: &mproxy.Request{Method: method, URL: u, Header: h}, ConnContext: &mproxy.ConnContext{ClientConn: conn}}
|
||||
}
|
||||
f := flow(http.MethodGet, "Basic dXNlcjpwYXNz", &mproxy.ClientConn{}) // user:pass, not the marker
|
||||
s.Requestheaders(f)
|
||||
if f.Request.Header.Get("Proxy-Authorization") != "" || markedCount(s) != 0 {
|
||||
t.Fatalf("foreign credentials: header %q, marks %d", f.Request.Header.Get("Proxy-Authorization"), markedCount(s))
|
||||
}
|
||||
conn := &mproxy.ClientConn{}
|
||||
conn.Id[0] = 1
|
||||
f = flow(http.MethodConnect, "Basic YXJ0ZXgtcGFjZTphcnRleC1wYWNl", conn) // artex-pace:artex-pace
|
||||
s.Requestheaders(f)
|
||||
if f.Request.Header.Get("Proxy-Authorization") != "" || markedCount(s) != 1 {
|
||||
t.Fatalf("marker on CONNECT: header %q, marks %d", f.Request.Header.Get("Proxy-Authorization"), markedCount(s))
|
||||
}
|
||||
s.ClientDisconnected(conn)
|
||||
if markedCount(s) != 0 {
|
||||
t.Fatal("mark kept after disconnect")
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -383,10 +383,12 @@ func (t *Traffic) stopping() bool {
|
||||
}
|
||||
func (t *Traffic) DB() *sql.DB { return t.db }
|
||||
|
||||
// sink is the go-mitmproxy addon that records completed exchanges.
|
||||
// sink is the go-mitmproxy addon that records completed exchanges, and paces
|
||||
// the requests of client connections marked for pacing (see pace.go).
|
||||
type sink struct {
|
||||
mproxy.BaseAddon
|
||||
t *Traffic
|
||||
t *Traffic
|
||||
paced sync.Map // ClientConn.Id of a marked connection -> struct{}
|
||||
}
|
||||
|
||||
func (s *sink) Response(f *mproxy.Flow) {
|
||||
|
||||
@@ -78,8 +78,11 @@ export function NoiseProfileCard() {
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Paces the requests ARTEX itself sends to targets (WebFetch, http custom tools, the enrich probe) and sets the
|
||||
low-and-slow guidance in agent prompts. Commands run through Bash are not paced. This setting takes
|
||||
precedence over ARTEX_NOISE_PROFILE and applies to the next request.
|
||||
low-and-slow guidance in agent prompts. With traffic capture on, Bash commands that use the proxy (curl,
|
||||
wget, python-requests…) are paced too, by rate and jitter only: the in-flight, cache and retry limits apply
|
||||
to ARTEX's own requests. Raw scanners such as nmap, and commands that bypass the proxy, are not paced; this
|
||||
is best effort, not a security boundary. This setting takes precedence over ARTEX_NOISE_PROFILE and applies
|
||||
to the next request.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
|
||||
Reference in New Issue
Block a user