feat(config): add command pattern detection tool in exec settings (#1971)

* Add command pattern testing endpoint and UI tool

Adds a new API endpoint `/api/config/test-command-patterns` that tests a
command against configured whitelist and blacklist patterns, along with
a frontend UI component to interactively test patterns.

* Only process deny patterns when enableDenyPatterns is true
This commit is contained in:
柚子
2026-03-25 10:19:20 +08:00
committed by GitHub
parent 8da0638ee3
commit adf1a5749d
5 changed files with 343 additions and 0 deletions
+66
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -16,6 +17,7 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/config", h.handleGetConfig)
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
}
// handleGetConfig returns the complete system configuration.
@@ -179,6 +181,70 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleTestCommandPatterns tests a command against whitelist and blacklist patterns.
//
// POST /api/config/test-command-patterns
func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
AllowPatterns []string `json:"allow_patterns"`
DenyPatterns []string `json:"deny_patterns"`
Command string `json:"command"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
lower := strings.ToLower(strings.TrimSpace(req.Command))
type result struct {
Allowed bool `json:"allowed"`
Blocked bool `json:"blocked"`
MatchedWhitelist *string `json:"matched_whitelist,omitempty"`
MatchedBlacklist *string `json:"matched_blacklist,omitempty"`
}
resp := result{Allowed: false, Blocked: false}
// Check whitelist first
for _, pattern := range req.AllowPatterns {
re, err := regexp.Compile(pattern)
if err != nil {
continue // skip invalid patterns
}
if re.MatchString(lower) {
resp.Allowed = true
resp.MatchedWhitelist = &pattern
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
}
// Check blacklist
for _, pattern := range req.DenyPatterns {
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
if re.MatchString(lower) {
resp.Blocked = true
resp.MatchedBlacklist = &pattern
break
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// validateConfig checks the config for common errors before saving.
// Returns a list of human-readable error strings; empty means valid.
func validateConfig(cfg *config.Config) []string {
+167
View File
@@ -282,3 +282,170 @@ func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisable
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
// testCommandPatterns is a helper that sets up a handler and sends a test-command-patterns request.
func testCommandPatterns(t *testing.T, configPath string, body string) *httptest.ResponseRecorder {
t.Helper()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestHandleTestCommandPatterns_MatchesWhitelist(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": ["^echo\\s+hello"],
"deny_patterns": ["^rm\\s+-rf"],
"command": "echo hello world"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=true, body=%s", rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
t.Fatalf("expected blocked=false when whitelist matches, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_MatchesBlacklistNotWhitelist(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": ["^echo\\s+hello"],
"deny_patterns": ["^rm\\s+-rf"],
"command": "rm -rf /tmp"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
t.Fatalf("expected blocked=true, body=%s", rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=false when blacklist matches but not whitelist, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_MatchesNeither(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": ["^echo\\s+hello"],
"deny_patterns": ["^rm\\s+-rf"],
"command": "ls -la"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=false, body=%s", rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
t.Fatalf("expected blocked=false, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_CaseInsensitiveWithGoFlag(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": ["(?i)^ECHO"],
"deny_patterns": [],
"command": "echo hello"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=true with Go (?i) flag, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_EmptyPatterns(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": [],
"deny_patterns": [],
"command": "rm -rf /tmp"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=false with empty patterns, body=%s", rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
t.Fatalf("expected blocked=false with empty patterns, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_InvalidRegexSkipped(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": ["([[", "^echo"],
"deny_patterns": [],
"command": "echo hello"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=true, invalid pattern skipped and valid one matched, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_ReturnsMatchedPattern(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": [],
"deny_patterns": ["\\$(?i)[a-zA-Z_]*(SECRET|KEY|PASSWORD|TOKEN|AUTH)[a-zA-Z0-9_]*"],
"command": "echo $GITHUB_API_KEY"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
t.Fatalf("expected blocked=true, body=%s", rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`matched_blacklist`)) {
t.Fatalf("expected matched_blacklist field, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(
http.MethodPost,
"/api/config/test-command-patterns",
bytes.NewBufferString(`{invalid json}`),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}