Files
ARTEX/server/customtool_test.go
T
BaaRai c0b741bcf6 feat(intercept): gate the rendered targets of custom tools (S3b 1.8)
The hook reads the parameters of a custom tool call, not the URL or the
command they render, and not a target written in the template itself. The
custom command and http tools now run the target gate inside the tool, on what
they are about to run, before anything is sent or started. Gate off (the
default) is unchanged: with no scope or the flag off nothing is parsed,
resolved, read or written.

- intercept.GateRenderedURL: the target is read as net/http dials it (host of
  the parsed URL, explicit port, else the scheme default), then goes through
  the same decision as at the hook (asset block rules, exemptions, resolution,
  pin lookup) and the same request-and-wait, at most three passes. A URL whose
  target cannot be read is blocked with TARGET_GATE_UNAVAILABLE and logged as
  a decided row; no request, no lookup.
- intercept.GateRenderedCommand: the rendered command is read with the
  command-string rules of Bash, and so is the rendering without the shell
  quoting of the parameters (a quoted parameter inside a host name hides the
  name from the rules). A command that names no target passes, as in Bash.
- A gated scope without the interceptor that owns the gate blocks
  (TARGET_GATE_UNAVAILABLE), it never lets the call through.
- runCommandTool and runHTTPTool take the tool key, which names the request
  rows. The gate runs before the tool's own timeout starts.
- guard.TargetBlockMessage (was targetBlockMessage) is exported so the tools
  return the same model-facing text as the hook.
- Unchanged: the script tool (its target is in stored code; advisory, D9), the
  custom-tool test endpoint (no scope: the operator's own action, D14), the
  hook order (a rule or judge deny blocks first; the tool then never runs, so
  its rendered target is never read), the evaluation of asset rules.

Tests: TestRendered* in intercept (no database); in server, against
PostgreSQL: TestCustomHTTPRenderedTargetGated,
TestCustomCommandRenderedTargetGated, TestCustomToolTestEndpointUngated,
TestTargetGateEndToEnd (a scripted worker's WebFetch waits, a click pins, the
next call passes, revoke, it asks again), TestDenyRuleWinsBeforeRenderedTarget
and TestGateOffTaskIsUnchanged.

Not in this change: redirect targets of WebFetch and of the custom http tool
(1.9).
2026-11-11 15:04:42 +01:00

188 lines
5.8 KiB
Go

package server
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os/exec"
"strings"
"testing"
"time"
)
func TestRenderTemplateCommand(t *testing.T) {
// command: string params shell-quoted; array → JSON (also quoted).
got := renderTemplate("nmap -p {ports} {target}", map[string]any{
"target": "10.0.0.1; rm -rf /", // injection attempt → must be single-quoted
"ports": []any{80.0, 443.0},
}, shellQuote)
if !strings.Contains(got, `'10.0.0.1; rm -rf /'`) {
t.Fatalf("target not shell-quoted: %q", got)
}
if strings.Contains(got, "rm -rf /'") && !strings.Contains(got, `'10.0.0.1; rm -rf /'`) {
t.Fatalf("possible injection leak: %q", got)
}
if strings.Contains(got, "{target}") || strings.Contains(got, "{ports}") {
t.Fatalf("placeholders not replaced: %q", got)
}
}
func TestRenderTemplateHTTP(t *testing.T) {
// http: identity (no shell quoting) — raw substitution into url/body.
got := renderTemplate("https://x/submit?flag={flag}", map[string]any{"flag": "CTF{abc}"}, identity)
if got != "https://x/submit?flag=CTF{abc}" {
t.Fatalf("http render: %q", got)
}
}
func TestScalarStr(t *testing.T) {
cases := []struct {
v any
want string
ok bool
}{
{"hi", "hi", true},
{true, "true", true},
{float64(80), "80", true}, // integer-valued float → no decimal
{float64(1.5), "1.5", true}, // real float
{[]any{1, 2}, "", false}, // array → not scalar
{map[string]any{}, "", false},
}
for _, c := range cases {
got, ok := scalarStr(c.v)
if ok != c.ok || (ok && got != c.want) {
t.Fatalf("scalarStr(%v) = (%q,%v), want (%q,%v)", c.v, got, ok, c.want, c.ok)
}
}
}
func TestEnsureSchema(t *testing.T) {
// empty → thin {args:string}
m := ensureSchema(nil)
props, _ := m["properties"].(map[string]any)
if _, ok := props["args"]; !ok {
t.Fatalf("empty schema should default to args: %v", m)
}
// non-empty → passthrough
raw := json.RawMessage(`{"type":"object","properties":{"target":{"type":"string"}}}`)
m2 := ensureSchema(raw)
p2, _ := m2["properties"].(map[string]any)
if _, ok := p2["target"]; !ok {
t.Fatalf("non-empty schema should pass through: %v", m2)
}
}
func TestShellQuote(t *testing.T) {
if got := shellQuote("a'b"); got != `'a'\''b'` {
t.Fatalf("shellQuote(a'b) = %q", got)
}
}
// TestExecPython runs a real Python script end-to-end: it must read params from
// stdin JSON and the mirrored env var, then print — verifying the whole script
// param-passing path. Skips if no python3.
func TestExecPython(t *testing.T) {
interp, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 unavailable")
}
code := `import json,sys,os
a = json.load(sys.stdin)
print("stdin_target=" + a["target"])
print("env_target=" + os.environ.get("TOOL_TARGET",""))
print("port=" + str(a["port"]))
`
out, err := execPython(context.Background(), interp, "test", code,
map[string]any{"target": "example.com", "port": float64(8080)},
t.TempDir(), nil, 10*time.Second)
if err != nil {
t.Fatalf("execPython error: %v", err)
}
for _, want := range []string{"stdin_target=example.com", "env_target=example.com", "port=8080"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
}
}
// TestRunHTTPTool functionally exercises the http executor against a local server:
// method/url/header/body templates are rendered from params, the request is sent,
// and the response status+body are returned. No recording proxy → s.m untouched.
func TestRunHTTPTool(t *testing.T) {
var gotMethod, gotAuth, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotAuth = r.Header.Get("Authorization")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(201)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
execRaw, _ := json.Marshal(map[string]any{
"method": "POST",
"url": srv.URL + "/submit?flag={flag}",
"headers": map[string]string{"Authorization": "Bearer {token}"},
"body": `{"flag":"{flag}"}`,
})
res, err := (&Server{}).runHTTPTool(context.Background(), "probe", execRaw,
map[string]any{"flag": "CTF{x}", "token": "sekret"}, nil)
if err != nil {
t.Fatalf("runHTTPTool: %v", err)
}
if res.IsError || len(res.Content) == 0 {
t.Fatalf("unexpected result: %+v", res)
}
var out struct {
Status int `json:"status"`
Body string `json:"body"`
}
_ = json.Unmarshal([]byte(res.Content[0].Text), &out)
if gotMethod != "POST" {
t.Fatalf("method: %q", gotMethod)
}
if gotAuth != "Bearer sekret" {
t.Fatalf("header template not rendered: %q", gotAuth)
}
if gotBody != `{"flag":"CTF{x}"}` {
t.Fatalf("body template not rendered: %q", gotBody)
}
if out.Status != 201 || !strings.Contains(out.Body, `"ok":true`) {
t.Fatalf("response not captured: %+v", out)
}
}
func TestDetectPython(t *testing.T) {
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 unavailable")
}
if p := detectPython(); p == "" {
t.Fatal("detectPython returned empty despite python3 on PATH")
}
}
// The http tool must truncate an oversized response through norma's Capture, the
// same valve every other tool uses — a session with no OutputDir falls back to a
// head+tail cut, so a body far past the default cap comes back shortened.
func TestHTTPToolTruncatesLargeBody(t *testing.T) {
big := strings.Repeat("x", 40000) // past Capture's 30000 default
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(big))
}))
defer srv.Close()
execRaw, _ := json.Marshal(map[string]any{"method": "GET", "url": srv.URL})
res, err := (&Server{}).runHTTPTool(context.Background(), "probe", execRaw, map[string]any{}, nil)
if err != nil {
t.Fatalf("runHTTPTool: %v", err)
}
out := res.Flatten()
if len(out) >= 40000 {
t.Fatalf("oversized http body was not truncated: len=%d", len(out))
}
}