mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 20:20:54 +08:00
Phase 2 of secrets at rest (#213), same AES-256-GCM scheme and key. - llm_profiles.proxy joins the secret columns (AAD llm_profiles.proxy); scanProfile decrypts it for both column lists, SaveProfile encrypts it, an empty proxy stays NULL - brave_search_api_key, tavily_search_api_key, web_search_proxy and global_proxy are stored encrypted (AAD settings.<key>) through GetSetting, SetSetting and InsertSettingIfAbsent; other settings stay in clear - the startup backfill, the no-key refusal and the wrong-key check cover the new values; legacy plaintext is read as-is until backfill API: the proxies are returned masked as a whole (placeholder plus a *_set flag) by GET /api/settings, GET /api/llm/profiles and GET /api/llm. On write the placeholder keeps the stored value, an explicit value replaces it and "" clears it; the placeholder is never stored. The test and model-list endpoints resolve the placeholder to the stored proxy.
262 lines
11 KiB
Go
262 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
)
|
|
|
|
// No read endpoint returns a stored secret in clear, and a masked placeholder
|
|
// sent back on write never replaces the stored secret.
|
|
func TestSecretsNeverReturnedInClear(t *testing.T) {
|
|
s, _, request := trafficEvidenceServer(t)
|
|
pg := s.m.PG()
|
|
secrets := []string{"sk-llm-plain-0001", "mcp-env-plain-0002", "cred-plain-0003", "SECding-plain-0004", "tok-plain-0005"}
|
|
task, err := s.m.CreateTask("secrets api", "goal", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
|
llmID, err := pg.SaveProfile(&db.LLMProfile{Name: "t-secapi", Format: "openai", Model: "m", APIKey: secrets[0]})
|
|
mcpID, err2 := pg.SaveMCP(&db.MCPServer{Name: "t-secapi", Transport: "http", Env: json.RawMessage(`{"X-API-Key":"` + secrets[1] + `","EMPTY":""}`)})
|
|
_, err3 := pg.UpsertTaskCredential(taskID, db.TaskCredentialInput{Domain: "x.example", Username: "u", Password: secrets[2]})
|
|
chID, err4 := pg.SaveNotificationChannel(context.Background(), &db.NotificationChannel{Name: "t-secapi", Kind: "dingtalk",
|
|
Config: json.RawMessage(`{"webhook":"https://oapi.example/send?access_token=` + secrets[4] + `","secret":"` + secrets[3] + `"}`)})
|
|
t.Cleanup(func() {
|
|
pg.Exec(`DELETE FROM llm_profiles WHERE id=$1`, llmID)
|
|
pg.Exec(`DELETE FROM mcp_servers WHERE id=$1`, mcpID)
|
|
pg.Exec(`DELETE FROM notification_channels WHERE id=$1`, chID)
|
|
pg.DeleteTask(taskID)
|
|
})
|
|
for _, e := range []error{err, err2, err3, err4} {
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
}
|
|
|
|
for _, path := range []string{"/api/llm/profiles", "/api/llm", "/api/mcp", "/api/notify/channels", "/api/tasks/" + task.ID + "/credentials"} {
|
|
resp := request("GET", path, "")
|
|
for _, sec := range secrets {
|
|
if resp.Code != 200 || strings.Contains(resp.Body.String(), sec) {
|
|
t.Fatalf("GET %s: HTTP %d or a secret returned in clear", path, resp.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MCP: echo the masked list entry back with a name change; the key survives.
|
|
var list struct{ Servers []db.MCPServer }
|
|
_ = json.Unmarshal(request("GET", "/api/mcp", "").Body.Bytes(), &list)
|
|
for _, m := range list.Servers {
|
|
if m.ID != mcpID {
|
|
continue
|
|
}
|
|
if string(m.Env) != `{"EMPTY":"","X-API-Key":"__masked__"}` {
|
|
t.Fatalf("mcp env not masked as expected: %s", m.Env)
|
|
}
|
|
m.Name = "t-secapi-renamed"
|
|
body, _ := json.Marshal(m)
|
|
if resp := request("POST", "/api/mcp", string(body)); resp.Code != 200 {
|
|
t.Fatalf("mcp save: HTTP %d %s", resp.Code, resp.Body.String())
|
|
}
|
|
}
|
|
all, _ := pg.ListMCP()
|
|
for _, m := range all {
|
|
if m.ID == mcpID && !strings.Contains(string(m.Env), secrets[1]) {
|
|
t.Fatal("masked placeholder replaced the stored MCP secret")
|
|
}
|
|
}
|
|
if resp := request("POST", "/api/mcp", `{"name":"t-secapi-new","transport":"http","env":{"K":"__masked__"}}`); resp.Code != 400 {
|
|
t.Fatalf("a placeholder with nothing stored must be refused, got HTTP %d", resp.Code)
|
|
}
|
|
|
|
// Task credential: a masked password on write is refused, the stored one kept.
|
|
if resp := request("POST", "/api/tasks/"+task.ID+"/credentials", `{"domain":"x.example","username":"u","password":"__masked__"}`); resp.Code != 400 {
|
|
t.Fatalf("masked credential write must be refused, got HTTP %d", resp.Code)
|
|
}
|
|
if creds, _ := pg.TaskCredentials(taskID); len(creds) != 1 || creds[0].Password != secrets[2] {
|
|
t.Fatal("stored credential changed")
|
|
}
|
|
}
|
|
|
|
// A masked MCP env value is only kept while the destination (transport,
|
|
// command, args, url) is unchanged; otherwise the secrets must be re-entered.
|
|
func TestMCPMaskedEnvDestinationGuard(t *testing.T) {
|
|
s, _, request := trafficEvidenceServer(t)
|
|
pg := s.m.PG()
|
|
const secret = "mcp-guard-secret-0006"
|
|
httpID, err := pg.SaveMCP(&db.MCPServer{Name: "t-guard-http", Transport: "http", URL: "http://127.0.0.1:9/a",
|
|
Env: json.RawMessage(`{"K":"` + secret + `"}`)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stdioID, err := pg.SaveMCP(&db.MCPServer{Name: "t-guard-stdio", Transport: "stdio", Command: "npx",
|
|
Args: json.RawMessage(`["pkg"]`), Env: json.RawMessage(`{"K":"` + secret + `"}`)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { pg.DeleteMCP(httpID); pg.DeleteMCP(stdioID) })
|
|
stored := func(id int64) db.MCPServer {
|
|
all, _ := pg.ListMCP()
|
|
for _, m := range all {
|
|
if m.ID == id {
|
|
return *m
|
|
}
|
|
}
|
|
t.Fatalf("mcp %d missing", id)
|
|
return db.MCPServer{}
|
|
}
|
|
save := func(id int64, edit func(*db.MCPServer), want int) {
|
|
t.Helper()
|
|
m := stored(id)
|
|
m.Env = maskMCPEnv(m.Env)
|
|
edit(&m)
|
|
body, _ := json.Marshal(m)
|
|
if resp := request("POST", "/api/mcp", string(body)); resp.Code != want {
|
|
t.Fatalf("want HTTP %d, got %d %s", want, resp.Code, resp.Body.String())
|
|
}
|
|
}
|
|
keeps := func(id int64, url string) {
|
|
t.Helper()
|
|
if m := stored(id); !strings.Contains(string(m.Env), secret) || m.URL != url {
|
|
t.Fatalf("stored row changed: url=%q", m.URL)
|
|
}
|
|
}
|
|
|
|
save(httpID, func(m *db.MCPServer) { m.Name = "t-guard-http2" }, 200)
|
|
keeps(httpID, "http://127.0.0.1:9/a")
|
|
save(httpID, func(m *db.MCPServer) { m.URL = "http://127.0.0.1:9/evil" }, 400)
|
|
keeps(httpID, "http://127.0.0.1:9/a")
|
|
save(httpID, func(m *db.MCPServer) { m.Transport = "sse" }, 400)
|
|
keeps(httpID, "http://127.0.0.1:9/a")
|
|
save(stdioID, func(m *db.MCPServer) { m.Command = "sh" }, 400)
|
|
save(stdioID, func(m *db.MCPServer) { m.Args = json.RawMessage(`["other"]`) }, 400)
|
|
keeps(stdioID, "")
|
|
save(stdioID, func(m *db.MCPServer) { m.Args = json.RawMessage(`[ "pkg" ]`) }, 200) // same args, other spacing
|
|
keeps(stdioID, "")
|
|
save(httpID, func(m *db.MCPServer) {
|
|
m.URL, m.Env = "http://127.0.0.1:9/b", json.RawMessage(`{"K":"new-explicit-value"}`)
|
|
}, 200)
|
|
if m := stored(httpID); m.URL != "http://127.0.0.1:9/b" || !strings.Contains(string(m.Env), "new-explicit-value") {
|
|
t.Fatal("explicit env with a new url was not saved")
|
|
}
|
|
}
|
|
|
|
// Secret settings and the LLM profile proxy: stored encrypted, never returned
|
|
// in clear; on write the placeholder keeps, an explicit value replaces, ""
|
|
// clears.
|
|
func TestProxySecretsAPI(t *testing.T) {
|
|
s, _, request := trafficEvidenceServer(t)
|
|
pg := s.m.PG()
|
|
t.Cleanup(func() {
|
|
pg.Exec(`DELETE FROM settings WHERE key IN ('brave_search_api_key','tavily_search_api_key','web_search_proxy','global_proxy')`)
|
|
pg.Exec(`DELETE FROM llm_profiles WHERE name='t-proxy'`)
|
|
})
|
|
secrets := []string{"brave-api-0007", "tavily-api-0008", "wsp-pass-0009", "gp-pass-0010", "llmp-pass-0011"}
|
|
put := func(body string) {
|
|
t.Helper()
|
|
resp := request("PUT", "/api/settings", body)
|
|
for _, sec := range secrets {
|
|
if resp.Code != 200 || strings.Contains(resp.Body.String(), sec) {
|
|
t.Fatalf("PUT /api/settings %s: HTTP %d or a secret returned in clear", body, resp.Code)
|
|
}
|
|
}
|
|
}
|
|
setting := func(k string) string {
|
|
t.Helper()
|
|
var raw string
|
|
if err := pg.QueryRow(`SELECT value FROM settings WHERE key=$1`, k).Scan(&raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v, _, err := pg.GetSetting(k)
|
|
if err != nil || (raw != "" && !strings.HasPrefix(raw, "enc:v1:")) {
|
|
t.Fatalf("setting %s stored as %q (%v)", k, raw, err)
|
|
}
|
|
return v
|
|
}
|
|
put(`{"brave_search_api_key":"brave-api-0007","tavily_search_api_key":"tavily-api-0008",
|
|
"web_search_proxy":"socks5://u:wsp-pass-0009@ws.example:1080","global_proxy":"http://u:gp-pass-0010@gp.example:3128"}`)
|
|
var got map[string]any
|
|
resp := request("GET", "/api/settings", "")
|
|
_ = json.Unmarshal(resp.Body.Bytes(), &got)
|
|
for _, sec := range secrets {
|
|
if strings.Contains(resp.Body.String(), sec) {
|
|
t.Fatal("GET /api/settings returned a secret in clear")
|
|
}
|
|
}
|
|
if got["global_proxy"] != "__masked__" || got["global_proxy_set"] != true || got["web_search_proxy"] != "__masked__" || got["web_search_proxy_set"] != true {
|
|
t.Fatalf("proxies not masked with a presence flag: %v", got)
|
|
}
|
|
|
|
put(`{"web_search_proxy":"__masked__","global_proxy":"__masked__","brave_search_api_key":"__masked__"}`)
|
|
if setting("web_search_proxy") != "socks5://u:wsp-pass-0009@ws.example:1080" || setting("global_proxy") != "http://u:gp-pass-0010@gp.example:3128" ||
|
|
setting("brave_search_api_key") != "brave-api-0007" || setting("tavily_search_api_key") != "tavily-api-0008" {
|
|
t.Fatal("a masked placeholder replaced a stored secret")
|
|
}
|
|
put(`{"global_proxy":"http://gp2.example:3128","web_search_proxy":""}`)
|
|
if setting("global_proxy") != "http://gp2.example:3128" || setting("web_search_proxy") != "" {
|
|
t.Fatal("explicit value must replace and \"\" must clear")
|
|
}
|
|
_ = json.Unmarshal(request("GET", "/api/settings", "").Body.Bytes(), &got)
|
|
if got["web_search_proxy"] != "" || got["web_search_proxy_set"] != false {
|
|
t.Fatalf("cleared proxy shown as %v / %v", got["web_search_proxy"], got["web_search_proxy_set"])
|
|
}
|
|
|
|
// LLM profile proxy.
|
|
id, err := pg.SaveProfile(&db.LLMProfile{Name: "t-proxy", Format: "openai", Model: "m", APIKey: "k", Proxy: "http://u:llmp-pass-0011@llm.example:3128"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
proxy := func() string {
|
|
var raw *string
|
|
if err := pg.QueryRow(`SELECT proxy FROM llm_profiles WHERE id=$1`, id).Scan(&raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if raw != nil && !strings.HasPrefix(*raw, "enc:v1:") {
|
|
t.Fatalf("profile proxy stored in clear: %q", *raw)
|
|
}
|
|
p, err := pg.ProfileByID(id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p.Proxy
|
|
}
|
|
for _, path := range []string{"/api/llm/profiles", "/api/llm"} {
|
|
if resp := request("GET", path, ""); resp.Code != 200 || strings.Contains(resp.Body.String(), "llmp-pass-0011") {
|
|
t.Fatalf("GET %s: HTTP %d or the proxy returned in clear", path, resp.Code)
|
|
}
|
|
}
|
|
var list struct{ Profiles []LLMProfileDTO }
|
|
_ = json.Unmarshal(request("GET", "/api/llm/profiles", "").Body.Bytes(), &list)
|
|
for _, p := range list.Profiles {
|
|
if p.Name == "t-proxy" && (p.Proxy != "__masked__" || !p.ProxySet) {
|
|
t.Fatalf("profile proxy not masked: %+v", p)
|
|
}
|
|
}
|
|
save := func(proxyJSON string, want int) {
|
|
t.Helper()
|
|
body := `{"id":` + strconv.FormatInt(id, 10) + `,"name":"t-proxy","format":"openai","model":"m","proxy":` + proxyJSON + `}`
|
|
if resp := request("POST", "/api/llm/profiles", body); resp.Code != want {
|
|
t.Fatalf("save proxy %s: want HTTP %d, got %d %s", proxyJSON, want, resp.Code, resp.Body.String())
|
|
}
|
|
}
|
|
save(`"__masked__"`, 200)
|
|
if proxy() != "http://u:llmp-pass-0011@llm.example:3128" {
|
|
t.Fatal("the placeholder replaced the stored profile proxy")
|
|
}
|
|
save(`"socks5://llm2.example:1080"`, 200)
|
|
if proxy() != "socks5://llm2.example:1080" {
|
|
t.Fatal("explicit proxy not saved")
|
|
}
|
|
save(`""`, 200)
|
|
if proxy() != "" {
|
|
t.Fatal("empty proxy must clear")
|
|
}
|
|
if resp := request("POST", "/api/llm/profiles", `{"name":"t-proxy","format":"openai","model":"m","proxy":"__masked__"}`); resp.Code != 400 {
|
|
t.Fatalf("a placeholder on a new profile must be refused, got HTTP %d", resp.Code)
|
|
}
|
|
}
|