Files
ARTEX/server/tools_secrets_api_test.go
T
BaaRai 68976a7680 secrets: encrypt the exec spec of custom tools at rest
Phase 3 of secrets at rest (#213, #216), same AES-256-GCM scheme and key.

- tools.exec joins the secret columns for system=false rows (AAD
  tools.exec): the whole JSON is sealed in the {"__artex_enc":"enc:v1:..."}
  envelope, so headers, body, proxy, script code and command are covered;
  {} stays {}
- CreateCustomTool and UpdateCustomTool seal it; scanTool opens it, so
  GetTool, ListTools and ListCustomTools (and through them customTools,
  the platform tools and the test endpoint) see the clear JSON in memory
- the startup backfill, the no-key refusal and the wrong-key check cover
  it, for system=false rows only; built-in rows stay in clear for the seeds

API: GET /api/tools returns the header values, proxy and body of a custom
tool masked, with headers_set, proxy_set and body_set. On update (API and
update_custom_tool) the placeholder keeps the stored value, an explicit
value replaces it and "" clears it; on create (API and create_custom_tool)
a placeholder is refused. The test endpoint takes an optional key: the
saved tool's stored values replace the placeholders; the editor sends it.
2026-11-13 21:52:27 +01:00

167 lines
6.8 KiB
Go

package server
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// A custom tool's exec: headers, proxy and body are masked by GET /api/tools;
// the placeholder keeps the stored value on update and in the test endpoint, a
// create with it is refused, and the executor runs on the clear exec.
func TestCustomToolExecMasking(t *testing.T) {
s, _, request := trafficEvidenceServer(t)
pg := s.m.PG()
echo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
io.WriteString(w, "auth="+r.Header.Get("Authorization")+" body="+string(b))
}))
t.Cleanup(echo.Close)
t.Cleanup(func() { pg.Exec(`DELETE FROM tools WHERE key LIKE 't_secexec%'`) })
secrets := []string{"hdr-plain-0001", "body-plain-0002", "proxy-plain-0003"}
schema := `{"type":"object","properties":{"q":{"type":"string"}}}`
execOf := func(hdr, body, proxy string) string {
e, _ := json.Marshal(map[string]any{"method": "POST", "url": echo.URL,
"headers": map[string]string{"Authorization": hdr, "X-Empty": ""}, "body": body, "proxy": proxy})
return string(e)
}
toolBody := func(key, exec string) string {
return `{"key":"` + key + `","kind":"http","enabled":true,"schema":` + schema + `,"exec":` + exec + `}`
}
stored := func() map[string]any {
tl, err := pg.GetTool("t_secexec")
if err != nil || tl == nil {
t.Fatalf("GetTool: %v", err)
}
var e map[string]any
_ = json.Unmarshal(tl.Exec, &e)
return e
}
plain := execOf("Bearer "+secrets[0], secrets[1], "http://u:"+secrets[2]+"@127.0.0.1:1")
if resp := request("POST", "/api/tools/custom", toolBody("t_secexec", plain)); resp.Code != 200 {
t.Fatalf("create: HTTP %d %s", resp.Code, resp.Body.String())
}
var raw string
if err := pg.QueryRow(`SELECT exec::text FROM tools WHERE key='t_secexec'`).Scan(&raw); err != nil || !strings.Contains(raw, "__artex_enc") {
t.Fatalf("exec not sealed at rest: %s %v", raw, err)
}
// List: masked with *_set flags, nothing in clear.
resp := request("GET", "/api/tools", "")
for _, sec := range secrets {
if resp.Code != 200 || strings.Contains(resp.Body.String(), sec) {
t.Fatalf("GET /api/tools: HTTP %d or a secret returned in clear", resp.Code)
}
}
var list struct {
Tools []struct {
Key string `json:"key"`
Exec json.RawMessage `json:"exec"`
}
}
_ = json.Unmarshal(resp.Body.Bytes(), &list)
var masked json.RawMessage
for _, tl := range list.Tools {
if tl.Key == "t_secexec" {
masked = tl.Exec
}
}
var m map[string]any
_ = json.Unmarshal(masked, &m)
h, _ := m["headers"].(map[string]any)
if h["Authorization"] != "__masked__" || h["X-Empty"] != "" || m["body"] != "__masked__" || m["proxy"] != "__masked__" ||
m["headers_set"] != true || m["body_set"] != true || m["proxy_set"] != true || m["url"] != echo.URL {
t.Fatalf("exec not masked as expected: %s", masked)
}
// Update echoing the masked exec: every stored value is kept, no flag stored.
if resp := request("PUT", "/api/tools/custom/t_secexec", toolBody("t_secexec", string(masked))); resp.Code != 200 {
t.Fatalf("update with placeholders: HTTP %d %s", resp.Code, resp.Body.String())
}
e := stored()
if e["headers"].(map[string]any)["Authorization"] != "Bearer "+secrets[0] || e["body"] != secrets[1] ||
!strings.Contains(e["proxy"].(string), secrets[2]) || e["body_set"] != nil {
t.Fatalf("placeholders did not keep the stored exec: %v", e)
}
// Test endpoint: the saved tool's key resolves the placeholders.
noProxy := execOf("__masked__", "__masked__", "")
if resp := request("POST", "/api/tools/custom/test", `{"kind":"http","exec":`+noProxy+`}`); resp.Code != 400 {
t.Fatalf("a placeholder without a saved tool must be refused, got HTTP %d", resp.Code)
}
resp = request("POST", "/api/tools/custom/test", `{"key":"t_secexec","kind":"http","exec":`+noProxy+`}`)
if resp.Code != 200 || !strings.Contains(resp.Body.String(), "auth=Bearer "+secrets[0]+" body="+secrets[1]) {
t.Fatalf("test endpoint did not use the stored values: HTTP %d %s", resp.Code, resp.Body.String())
}
// Replace the header, keep the body, clear the proxy.
if resp := request("PUT", "/api/tools/custom/t_secexec", toolBody("t_secexec", execOf("Bearer new-0004", "__masked__", ""))); resp.Code != 200 {
t.Fatalf("update: HTTP %d %s", resp.Code, resp.Body.String())
}
e = stored()
if e["headers"].(map[string]any)["Authorization"] != "Bearer new-0004" || e["body"] != secrets[1] || e["proxy"] != "" {
t.Fatalf("replace/keep/clear: %v", e)
}
if resp := request("GET", "/api/tools", ""); strings.Contains(resp.Body.String(), "new-0004") {
t.Fatal("a replaced secret is returned in clear")
}
// Create with a placeholder: refused, nothing written.
for _, ex := range []string{execOf("__masked__", "", ""), execOf("", "__masked__", ""), execOf("", "", "__masked__")} {
if resp := request("POST", "/api/tools/custom", toolBody("t_secexec_new", ex)); resp.Code != 400 {
t.Fatalf("create with a placeholder must be refused, got HTTP %d", resp.Code)
}
}
if tl, _ := pg.GetTool("t_secexec_new"); tl != nil {
t.Fatal("a refused create wrote a row")
}
// The executor receives the clear exec from the sealed row.
tools, err := s.customTools()
if err != nil {
t.Fatal(err)
}
for _, tl := range tools {
if tl.Name() != "t_secexec" {
continue
}
res, err := tl.Call(context.Background(), json.RawMessage(`{"q":"x"}`), nil)
if err != nil || !strings.Contains(res.Flatten(), "auth=Bearer new-0004 body="+secrets[1]) {
t.Fatalf("executor output %q %v", res.Flatten(), err)
}
return
}
t.Fatal("t_secexec not built by customTools")
}
// The platform tools follow the API: create refuses a placeholder, update keeps the stored value.
func TestPlatformCustomToolPlaceholders(t *testing.T) {
s, _, _ := trafficEvidenceServer(t)
pg := s.m.PG()
t.Cleanup(func() { pg.Exec(`DELETE FROM tools WHERE key LIKE 't_secexec%'`) })
in := func(exec string) json.RawMessage {
return json.RawMessage(`{"key":"t_secexec_pt","kind":"command","exec":` + exec + `}`)
}
create, update := s.toolCreateCustomTool(), s.toolUpdateCustomTool()
if res, _ := create.Call(context.Background(), in(`{"command":"echo","proxy":"__masked__"}`), nil); !res.IsError {
t.Fatalf("create with a placeholder must fail: %s", res.Flatten())
}
if res, _ := create.Call(context.Background(), in(`{"command":"echo","body":"pt-body-plain"}`), nil); res.IsError {
t.Fatalf("create: %s", res.Flatten())
}
if res, _ := update.Call(context.Background(), in(`{"command":"echo 2","body":"__masked__"}`), nil); res.IsError {
t.Fatalf("update: %s", res.Flatten())
}
tl, err := pg.GetTool("t_secexec_pt")
if err != nil || tl == nil || !jsonHas(tl.Exec, "pt-body-plain") || !jsonHas(tl.Exec, "echo 2") {
t.Fatalf("update with a placeholder must keep the stored body: %v %v", tl, err)
}
}
func jsonHas(raw json.RawMessage, s string) bool { return strings.Contains(string(raw), s) }