mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user