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())
}
}
@@ -1,3 +1,4 @@
import { useState } from "react"
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
@@ -7,6 +8,7 @@ import {
type LauncherForm,
} from "@/components/config/form-model"
import { Field, SwitchCardField } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
@@ -201,6 +203,56 @@ interface ExecSectionProps {
export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
const { t } = useTranslation()
const [testCommand, setTestCommand] = useState("")
const [testResult, setTestResult] = useState<{
allowed: boolean
blocked: boolean
matchedWhitelist: string | null
matchedBlacklist: string | null
} | null>(null)
const [isLoading, setIsLoading] = useState(false)
const testPatterns = async () => {
if (!testCommand.trim()) {
setTestResult(null)
return
}
const allowPatterns = form.customAllowPatternsText
.split("\n")
.map((p) => p.trim())
.filter((p) => p.length > 0)
const denyPatterns = form.enableDenyPatterns
? form.customDenyPatternsText
.split("\n")
.map((p) => p.trim())
.filter((p) => p.length > 0)
: []
setIsLoading(true)
try {
const res = await fetch("/api/config/test-command-patterns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
allow_patterns: allowPatterns,
deny_patterns: denyPatterns,
command: testCommand,
}),
})
const data = await res.json()
setTestResult({
allowed: data.allowed,
blocked: data.blocked,
matchedWhitelist: data.matched_whitelist ?? null,
matchedBlacklist: data.matched_blacklist ?? null,
})
} catch {
setTestResult(null)
} finally {
setIsLoading(false)
}
}
return (
<ConfigSectionCard title={t("pages.config.sections.exec")}>
@@ -266,6 +318,50 @@ export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
/>
</Field>
<Field
label={t("pages.config.pattern_detector_title")}
hint={t("pages.config.pattern_detector_hint")}
layout="setting-row"
controlClassName="md:max-w-md"
>
<div className="flex w-full flex-col gap-2">
<div className="flex gap-2">
<Input
value={testCommand}
placeholder={t(
"pages.config.pattern_detector_input_placeholder",
)}
onChange={(e) => setTestCommand(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
testPatterns()
}
}}
/>
<Button onClick={testPatterns} disabled={isLoading}>
{t("pages.config.pattern_detector_test_button")}
</Button>
</div>
{testResult && (
<div
className={`rounded-md p-2 text-sm ${
testResult.allowed
? "bg-green-500/10 text-green-600"
: testResult.blocked
? "bg-red-500/10 text-red-600"
: "bg-muted text-muted-foreground"
}`}
>
{testResult.allowed
? `${t("pages.config.pattern_detector_result_allowed")}${testResult.matchedWhitelist ? ` (${testResult.matchedWhitelist})` : ""}`
: testResult.blocked
? `${t("pages.config.pattern_detector_result_blocked")}${testResult.matchedBlacklist ? ` (${testResult.matchedBlacklist})` : ""}`
: t("pages.config.pattern_detector_result_no_match")}
</div>
)}
</div>
</Field>
<Field
label={t("pages.config.exec_timeout_seconds")}
hint={t("pages.config.exec_timeout_seconds_hint")}
+7
View File
@@ -433,6 +433,13 @@
"custom_allow_patterns": "Command Whitelist",
"custom_allow_patterns_hint": "Add extra command-allow rules, one regular expression per line. A command matching any rule here skips blacklist matching, but other safety limits still apply.",
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
"pattern_detector_title": "Pattern Detection Tool",
"pattern_detector_hint": "Enter a command to test if it matches any blacklist or whitelist patterns.",
"pattern_detector_input_placeholder": "Enter a command to test, e.g., rm -rf /tmp",
"pattern_detector_test_button": "Test",
"pattern_detector_result_allowed": "Allowed (matches whitelist)",
"pattern_detector_result_blocked": "Blocked (matches blacklist)",
"pattern_detector_result_no_match": "No match (will use default rules)",
"allow_shell_execution": "Allow Scheduled Commands",
"allow_shell_execution_hint": "Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.",
"cron_exec_timeout": "Scheduled Command Timeout (minutes)",
+7
View File
@@ -433,6 +433,13 @@
"custom_allow_patterns": "命令白名单",
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束。",
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
"pattern_detector_title": "规则检测工具",
"pattern_detector_hint": "输入命令以检测其是否匹配黑名单或白名单规则。",
"pattern_detector_input_placeholder": "输入要检测的命令,例如 rm -rf /tmp",
"pattern_detector_test_button": "检测",
"pattern_detector_result_allowed": "允许(匹配白名单)",
"pattern_detector_result_blocked": "阻止(匹配黑名单)",
"pattern_detector_result_no_match": "无匹配(将使用默认规则)",
"allow_shell_execution": "允许定时任务运行命令",
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。",
"cron_exec_timeout": "定时命令超时(分钟)",