ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
603 lines
24 KiB
Go
603 lines
24 KiB
Go
// Package db is the PostgreSQL data source for ARTEX (取代旧 graph 单文件 SQLite)。
|
||
// 它打开连接、应用 schema、并 seed 内置 agent 与变量目录。
|
||
package db
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
_ "embed"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Autumn-27/artex/config"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
_ "github.com/jackc/pgx/v5/stdlib" // pgx database/sql driver ("pgx")
|
||
)
|
||
|
||
//go:embed schema.sql
|
||
var schemaSQL string
|
||
|
||
const schemaMigrationLockKey int64 = 7337741001
|
||
|
||
var schemaDeadlockRetryDelays = [...]time.Duration{
|
||
100 * time.Millisecond,
|
||
250 * time.Millisecond,
|
||
500 * time.Millisecond,
|
||
time.Second,
|
||
}
|
||
|
||
type schemaExecer interface {
|
||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||
}
|
||
|
||
func isPostgresDeadlock(err error) bool {
|
||
var pgErr *pgconn.PgError
|
||
return errors.As(err, &pgErr) && pgErr.Code == "40P01"
|
||
}
|
||
|
||
func applySchemaWithRetry(ctx context.Context, execer schemaExecer, sleep func(time.Duration)) error {
|
||
for attempt := 0; ; attempt++ {
|
||
if _, err := execer.ExecContext(ctx, schemaSQL); err != nil {
|
||
if !isPostgresDeadlock(err) || attempt >= len(schemaDeadlockRetryDelays) {
|
||
return err
|
||
}
|
||
sleep(schemaDeadlockRetryDelays[attempt])
|
||
continue
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// withSchemaMigrationLock pins the session-level lock to one checked-out
|
||
// connection. Running pg_advisory_lock through *sql.DB is incorrect because a
|
||
// later schema or unlock call may use a different pooled PostgreSQL session.
|
||
func withSchemaMigrationLock(ctx context.Context, sqlDB *sql.DB, action func(*sql.Conn) error) (err error) {
|
||
conn, err := sqlDB.Conn(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer conn.Close()
|
||
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, schemaMigrationLockKey); err != nil {
|
||
return fmt.Errorf("advisory lock: %w", err)
|
||
}
|
||
defer func() {
|
||
if _, unlockErr := conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock($1)`, schemaMigrationLockKey); unlockErr != nil && err == nil {
|
||
err = fmt.Errorf("advisory unlock: %w", unlockErr)
|
||
}
|
||
}()
|
||
return action(conn)
|
||
}
|
||
|
||
// coordinateWithSchemaMigration makes long, multi-table archive transactions
|
||
// mutually exclusive with startup DDL while allowing ordinary runtime queries
|
||
// to continue normally.
|
||
func coordinateWithSchemaMigration(tx *sql.Tx) error {
|
||
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, schemaMigrationLockKey); err != nil {
|
||
return fmt.Errorf("coordinate with schema migration: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DSN resolves the PostgreSQL connection string and reports where it came from.
|
||
// Precedence: env ARTEX_PG_DSN > config file (config.json). There is no
|
||
// built-in default — it errors if neither source is configured.
|
||
func DSN() (dsn, source string, err error) {
|
||
return config.PostgresDSN()
|
||
}
|
||
|
||
// DB wraps the shared *sql.DB. PG handles its own connection pool + concurrency
|
||
// (MVCC), so unlike the old SQLite store there is no process-wide write mutex.
|
||
type DB struct{ *sql.DB }
|
||
|
||
// ensureDatabase connects to the postgres system database and creates the target
|
||
// database if it does not exist. dsn must be a postgres:// URL.
|
||
func ensureDatabase(dsn string) error {
|
||
u, err := url.Parse(dsn)
|
||
if err != nil {
|
||
return nil // unparseable DSN — let the normal Open fail with a clear error
|
||
}
|
||
dbName := strings.TrimPrefix(u.Path, "/")
|
||
if dbName == "" || dbName == "postgres" {
|
||
return nil
|
||
}
|
||
// connect to the postgres maintenance database instead
|
||
adminDSN := *u
|
||
adminDSN.Path = "/postgres"
|
||
admin, err := sql.Open("pgx", adminDSN.String())
|
||
if err != nil {
|
||
return nil // best-effort; let Open surface the real error
|
||
}
|
||
defer admin.Close()
|
||
if err := admin.Ping(); err != nil {
|
||
return nil
|
||
}
|
||
var exists bool
|
||
_ = admin.QueryRow(`SELECT true FROM pg_database WHERE datname=$1`, dbName).Scan(&exists)
|
||
if !exists {
|
||
if _, err := admin.Exec(`CREATE DATABASE "` + dbName + `"`); err != nil {
|
||
return fmt.Errorf("create database %q: %w", dbName, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Open connects, applies the schema (idempotent), and seeds builtin rows.
|
||
func Open(dsn string) (*DB, error) {
|
||
if err := ensureDatabase(dsn); err != nil {
|
||
return nil, err
|
||
}
|
||
sqlDB, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := sqlDB.Ping(); err != nil {
|
||
sqlDB.Close()
|
||
return nil, fmt.Errorf("ping postgres (%s): %w", config.Redact(dsn), err)
|
||
}
|
||
d := &DB{sqlDB}
|
||
// pgx runs multi-statement Exec via the simple protocol when there are no args.
|
||
// Keep the dedicated lock connection checked out until both DDL and seeding
|
||
// finish so concurrent application instances cannot initialize out of order.
|
||
err = withSchemaMigrationLock(context.Background(), sqlDB, func(conn *sql.Conn) error {
|
||
if err := applySchemaWithRetry(context.Background(), conn, time.Sleep); err != nil {
|
||
return fmt.Errorf("apply schema: %w", err)
|
||
}
|
||
if err := d.seedBuiltins(); err != nil {
|
||
return fmt.Errorf("seed builtins: %w", err)
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
sqlDB.Close()
|
||
return nil, err
|
||
}
|
||
return d, nil
|
||
}
|
||
|
||
// builtinAgent describes one of the fixed agents and its prompt-variable catalog.
|
||
type builtinAgent struct {
|
||
key, name, role, desc string
|
||
vars []promptVar
|
||
interactiveShell bool // 建行时的默认交互式 shell 开关;ON CONFLICT 不覆盖用户后续手动开关
|
||
runSeconds *int // 建行时的单次 run 墙钟上限(秒);nil=用种子默认(1200),0=不限时
|
||
}
|
||
|
||
type promptVar struct{ name, desc, example, source string }
|
||
|
||
// intp 返回 v 的指针,用于给 builtinAgent 可选字段(如 runSeconds)显式取值。
|
||
func intp(v int) *int { return &v }
|
||
|
||
// builtinAgents mirrors docs §5(a). 内置工具不入库;这里只 seed agent + 变量目录。
|
||
// 注:planner/worker/mainagent/auto 的交互式 shell 默认由下方 interactive_shell_default_v1
|
||
// 块统一置 true(尊重后续 toggle);这里的 interactiveShell 只给需要「建行即默认开」的新 agent。
|
||
var builtinAgents = []builtinAgent{
|
||
{"goals", "目标拆解", "goals", "把渗透任务目标拆解成若干独立、可验证的子目标。", []promptVar{
|
||
{"EngagementDescription", "任务描述(测试对象/背景)", "测试 example.com 站点", "exploration"},
|
||
// Now 是全局 runtime 变量(见 server.globalPromptVars),不再在各 agent 目录里
|
||
// 重复定义,否则 withGlobalVars 追加时会与全局项撞名。
|
||
}, false, nil},
|
||
{"planner", "规划", "planner", "读取态势、判定目标,只在确有未覆盖的新方向时补充探索意图(每任务一个规划循环)。", []promptVar{
|
||
{"Goal", "任务总目标", "拿下 example.com 的管理员权限", "exploration"},
|
||
{"AssetSummary", "资产计数/类型分布摘要(可选)", "domain:3 ip:5 site:2", "distilled"},
|
||
}, false, nil},
|
||
{"mainagent", "主", "main", "人机接口:观察进展,把人的意图落成 hint 或高优先级意图。", []promptVar{
|
||
{"Goal", "当前任务目标", "拿下 example.com 的管理员权限", "exploration"},
|
||
{"AssetSummary", "开局态势摘要(可选)", "domain:3 ip:5", "distilled"},
|
||
{"FindingsSummary", "已确认漏洞摘要(可选)", "high:1 medium:2", "distilled"},
|
||
}, false, nil},
|
||
{"worker", "执行", "worker", "领取一条意图执行,把发现的事实/漏洞写回知识图谱后停止。", []promptVar{
|
||
{"ProxyAddr", "记录代理地址(驱动 if 双文案)", "127.0.0.1:8080", "runtime"},
|
||
{"WorkerName", "worker 自我标识(可选)", "worker-1", "runtime"},
|
||
}, false, nil},
|
||
// Auto:内置「平台操作」agent。不参与渗透编排循环,经对话页驱动,用工具操作平台。
|
||
{"auto", "Auto", "assistant", "平台操作助手:用工具管理任务(建/看/暂停/给提示)与资产,并可创建/修改 skill、自定义工具、MCP。", nil, false, nil},
|
||
// 渗透测试:内置「独立渗透」agent。经对话页驱动,一人从侦察到收尾走完整条渗透链,自己规划自己执行自己验证。默认开启交互式 shell。
|
||
{"pentest", "渗透测试", "assistant", "独立渗透 agent:一人从侦察→找攻击面→深入利用→验证→收尾走完整条链,自己规划、自己执行、自己对抗式验证。", nil, true, intp(0)},
|
||
}
|
||
|
||
// seedBuiltins inserts the fixed built-in agents and their variable catalog (idempotent).
|
||
func (d *DB) seedBuiltins() error {
|
||
for _, a := range builtinAgents {
|
||
var agentID int64
|
||
err := d.QueryRow(`
|
||
INSERT INTO agents(key, name, description, role, builtin, enabled, interactive_shell, run_seconds)
|
||
VALUES ($1, $2, NULLIF($3,''), $4, true, true, $5, COALESCE($6, 1200))
|
||
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description
|
||
RETURNING id`, a.key, a.name, a.desc, a.role, a.interactiveShell, a.runSeconds).Scan(&agentID)
|
||
if err != nil {
|
||
return fmt.Errorf("agent %s: %w", a.key, err)
|
||
}
|
||
for _, v := range a.vars {
|
||
if _, err := d.Exec(`
|
||
INSERT INTO agent_prompt_vars(agent_id, var_name, description, example, source)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (agent_id, var_name) DO UPDATE
|
||
SET description = EXCLUDED.description, example = EXCLUDED.example, source = EXCLUDED.source`,
|
||
agentID, v.name, v.desc, v.example, v.source); err != nil {
|
||
return fmt.Errorf("agent %s var %s: %w", a.key, v.name, err)
|
||
}
|
||
}
|
||
}
|
||
// Drop catalog entries for variables that were renamed, so the white-list no
|
||
// longer advertises a name templates can't resolve (EngagementTitle→Description).
|
||
// 'Now' 从各 agent 目录提升为全局 runtime 变量后,旧库里 goals 仍残留一条 'Now'
|
||
// 会与全局项撞名(前端变量列表 key 重复);一并清掉。
|
||
if _, err := d.Exec(`DELETE FROM agent_prompt_vars WHERE var_name IN ('EngagementTitle', 'CoverageGaps', 'Now')`); err != nil {
|
||
return fmt.Errorf("cleanup renamed vars: %w", err)
|
||
}
|
||
// Default-on interactive_shell for the runtime agents (planner/worker/mainagent/auto)
|
||
// ONCE — respects a later user toggle-off (guarded by a settings flag). goals(one-shot
|
||
// decomposer) stays off. Runs after the column exists (schema applied before seed).
|
||
if v, _, _ := d.GetSetting("interactive_shell_default_v1"); v != "true" {
|
||
if _, err := d.Exec(`UPDATE agents SET interactive_shell=true WHERE key IN ('planner','worker','mainagent','auto')`); err != nil {
|
||
return fmt.Errorf("seed interactive_shell defaults: %w", err)
|
||
}
|
||
_ = d.SetSetting("interactive_shell_default_v1", "true")
|
||
}
|
||
// Seed the built-in browser (Playwright) MCP once — DISABLED by default (用户
|
||
// 需要时自行启用), no proxy by default. The traffic-capture toggle injects/strips
|
||
// the recording proxy + CA at runtime (server.Manager.syncBrowserMCPProxy).
|
||
// Insert only if absent so we never clobber user edits (args/env/enabled/
|
||
// visibility) on restart.
|
||
if _, err := d.Exec(`
|
||
INSERT INTO mcp_servers(name, transport, command, args, env, enabled)
|
||
VALUES ('browser', 'stdio', 'npx', $1, '{}', false)
|
||
ON CONFLICT (name) DO NOTHING`,
|
||
`["@playwright/mcp","--headless"]`); err != nil {
|
||
return fmt.Errorf("seed browser mcp: %w", err)
|
||
}
|
||
// NOTE: the placeholder ScopeSentry data-source MCP (empty URL + empty X-API-Key,
|
||
// disabled) is seeded directly in schema.sql §F so a raw `psql < schema.sql` init
|
||
// also gets it. schema.sql is Exec'd on every startup, so it stays idempotent.
|
||
if err := d.seedBuiltinSkillVisibility(); err != nil {
|
||
return fmt.Errorf("seed skill visibility: %w", err)
|
||
}
|
||
if err := d.seedDefaultInterceptRules(); err != nil {
|
||
return fmt.Errorf("seed intercept rules: %w", err)
|
||
}
|
||
if err := d.seedDefaultInterceptRulesV2(); err != nil {
|
||
return fmt.Errorf("seed intercept rules v2: %w", err)
|
||
}
|
||
if err := d.seedDefaultInterceptRulesV3(); err != nil {
|
||
return fmt.Errorf("seed intercept rules v3: %w", err)
|
||
}
|
||
if err := d.seedDefaultAssetInterceptRules(); err != nil {
|
||
return fmt.Errorf("seed asset intercept rules: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// seedDefaultAssetInterceptRules inserts the built-in asset blocklist (fuzzy
|
||
// domain matches for government / education sites) once on first startup. Gated
|
||
// by a settings flag so a user's later disable/delete is never resurrected on
|
||
// restart — same policy as the intercept-rule seed.
|
||
func (d *DB) seedDefaultAssetInterceptRules() error {
|
||
if v, _, _ := d.GetSetting("asset_intercept_default_rules_v1"); v == "done" {
|
||
return nil
|
||
}
|
||
rules := []struct {
|
||
kind string
|
||
pattern string
|
||
note string
|
||
}{
|
||
{"fuzzy_domain", ".gov", "[内置] 政府网站 (.gov)"},
|
||
{"fuzzy_domain", ".gov.cn", "[内置] 政府网站 (.gov.cn)"},
|
||
{"fuzzy_domain", ".edu", "[内置] 教育网站 (.edu)"},
|
||
{"fuzzy_domain", ".edu.cn", "[内置] 教育网站 (.edu.cn)"},
|
||
}
|
||
for _, r := range rules {
|
||
if _, err := d.Exec(`
|
||
INSERT INTO asset_intercept_rules(enabled, kind, pattern, note, builtin)
|
||
VALUES (true, $1, $2, $3, true)
|
||
ON CONFLICT DO NOTHING`, r.kind, r.pattern, r.note); err != nil {
|
||
return fmt.Errorf("asset rule %q: %w", r.pattern, err)
|
||
}
|
||
}
|
||
return d.SetSetting("asset_intercept_default_rules_v1", "done")
|
||
}
|
||
|
||
// builtinSkillVisibility maps a shipped skill's directory name → the built-in
|
||
// agent keys that should see it by default. The skill FILES themselves live on the
|
||
// filesystem (SkillDir, loaded by norma at runtime); DB only carries this visibility
|
||
// binding. Skills omitted here (e.g. playwright-cli, scopesentry) ship invisible by
|
||
// default — the user turns them on per-agent when needed. scopesentry additionally
|
||
// declares `mcps: ScopeSentry`, which only takes effect once it's made visible and
|
||
// that MCP is enabled/configured.
|
||
var builtinSkillVisibility = map[string][]string{
|
||
"api-recon": {"auto", "pentest", "worker"},
|
||
}
|
||
|
||
// seedBuiltinSkillVisibility binds the shipped built-in skills to their default
|
||
// agents. Insert-if-absent (ON CONFLICT DO NOTHING) so a user's later toggle-off is
|
||
// never resurrected on restart — matches the browser-MCP / intercept-rule seed policy.
|
||
func (d *DB) seedBuiltinSkillVisibility() error {
|
||
for skillName, agentKeys := range builtinSkillVisibility {
|
||
for _, key := range agentKeys {
|
||
if _, err := d.Exec(`
|
||
INSERT INTO agent_skill_visibility(agent_id, skill_name, enabled)
|
||
SELECT id, $2, true FROM agents WHERE key=$1
|
||
ON CONFLICT (agent_id, skill_name) DO NOTHING`, key, skillName); err != nil {
|
||
return fmt.Errorf("skill %s → agent %s: %w", skillName, key, err)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// seedDefaultInterceptRules inserts built-in safety intercept rules once on
|
||
// first startup. The seed is gated by a settings flag so user edits (disable,
|
||
// delete, re-order) are never overwritten on subsequent restarts.
|
||
func (d *DB) seedDefaultInterceptRules() error {
|
||
if v, _, _ := d.GetSetting("intercept_default_rules_v1"); v == "done" {
|
||
return nil
|
||
}
|
||
type rule struct {
|
||
name string
|
||
target string // tool_name | tool_input
|
||
typ string // string | regex
|
||
pattern string
|
||
action string
|
||
message string
|
||
priority int
|
||
}
|
||
rules := []rule{
|
||
// ── 系统破坏性命令 (priority 100) ──────────────────────────────────
|
||
{
|
||
name: "[内置] 递归强制删除 rm -rf",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `(?i)\brm\b.{0,80}(?:-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*|--recursive|--no-preserve-root)`,
|
||
action: "deny",
|
||
message: "禁止执行递归强制删除(rm -rf / rm --recursive),可能永久损坏系统或靶机环境",
|
||
priority: 100,
|
||
},
|
||
{
|
||
name: "[内置] 删除系统关键目录",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `\brm\b[^"'\n]{0,60}["'\s](/|/etc|/bin|/usr|/boot|/var|/lib|/sys|/proc|/dev|/sbin|/root)`,
|
||
action: "deny",
|
||
message: "禁止删除系统关键路径",
|
||
priority: 100,
|
||
},
|
||
{
|
||
name: "[内置] 磁盘格式化 mkfs",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `\bmkfs\b`,
|
||
action: "deny",
|
||
message: "禁止格式化磁盘(mkfs)",
|
||
priority: 100,
|
||
},
|
||
{
|
||
name: "[内置] 覆写磁盘设备 dd",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `\bdd\b[^|\n]{0,100}\bof=\s*/dev/[a-zA-Z]`,
|
||
action: "deny",
|
||
message: "禁止使用 dd 覆写磁盘设备",
|
||
priority: 100,
|
||
},
|
||
{
|
||
name: "[内置] Fork 炸弹",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `:\(\)\s*\{[^}]*:\|:`,
|
||
action: "deny",
|
||
message: "禁止执行 Fork 炸弹",
|
||
priority: 100,
|
||
},
|
||
{
|
||
name: "[内置] 关机 / 重启",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `\b(?:shutdown|reboot|halt|poweroff|init\s+[06])\b`,
|
||
action: "deny",
|
||
message: "禁止执行关机或重启命令",
|
||
priority: 100,
|
||
},
|
||
{
|
||
name: "[内置] 杀死全部进程",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `\bkill\s+-9\s+-1\b|\bkillall\s+-9\b`,
|
||
action: "deny",
|
||
message: "禁止 kill -9 -1 或 killall -9(杀死所有进程)",
|
||
priority: 100,
|
||
},
|
||
{
|
||
name: "[内置] 磁盘擦除 shred / wipe",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `\b(?:shred|wipe)\b[^|\n]{0,80}/dev/[a-zA-Z]`,
|
||
action: "deny",
|
||
message: "禁止对磁盘设备执行 shred/wipe 擦除",
|
||
priority: 100,
|
||
},
|
||
{
|
||
name: "[内置] 清空防火墙规则",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `\biptables\s+(?:-F|--flush)\b|\bnft\s+flush\s+ruleset\b`,
|
||
action: "deny",
|
||
message: "禁止清空防火墙规则(iptables -F / nft flush)",
|
||
priority: 100,
|
||
},
|
||
// ── 数据库破坏性操作 (priority 90) ─────────────────────────────────
|
||
{
|
||
name: "[内置] SQL DROP DATABASE / TABLE / SCHEMA",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `(?i)\bDROP\s+(?:DATABASE|TABLE|SCHEMA|INDEX|VIEW|TABLESPACE|USER|ROLE)\b`,
|
||
action: "deny",
|
||
message: "禁止执行 DROP 操作,可能不可逆地销毁数据库对象",
|
||
priority: 90,
|
||
},
|
||
{
|
||
name: "[内置] SQL TRUNCATE",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `(?i)\bTRUNCATE\s+(?:TABLE\s+)?\w`,
|
||
action: "deny",
|
||
message: "禁止执行 TRUNCATE,可能清空数据表所有数据",
|
||
priority: 90,
|
||
},
|
||
{
|
||
name: "[内置] MongoDB drop / dropDatabase",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `(?i)\.(?:dropDatabase|dropCollection|drop)\s*\(`,
|
||
action: "deny",
|
||
message: "禁止执行 MongoDB drop 操作",
|
||
priority: 90,
|
||
},
|
||
{
|
||
name: "[内置] Redis FLUSHALL / FLUSHDB",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `(?i)\b(?:FLUSHALL|FLUSHDB)\b`,
|
||
action: "deny",
|
||
message: "禁止执行 Redis FLUSHALL / FLUSHDB,可能清空全部缓存数据",
|
||
priority: 90,
|
||
},
|
||
// ── HTTP 破坏性请求 (priority 80) ──────────────────────────────────
|
||
// Agent 发送 DELETE 请求的三种常见方式:
|
||
// 1. curl -X DELETE / --request DELETE(Bash 工具直接执行或写入脚本)
|
||
// 2. Python HTTP 客户端 .delete() 方法
|
||
// 3. JS/通用脚本里的 method: 'DELETE' / method="DELETE"
|
||
{
|
||
name: "[内置] curl / wget 发送 DELETE 请求",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `(?i)\bcurl\b[^|\n&;"]{0,300}(?:-X\s*DELETE|--request\s+DELETE|-XDELETE)|\bwget\b[^|\n&;"]{0,300}--method[=\s]+DELETE`,
|
||
action: "deny",
|
||
message: "禁止通过 curl/wget 发送 HTTP DELETE 请求,可能删除目标系统数据",
|
||
priority: 80,
|
||
},
|
||
{
|
||
name: "[内置] Python HTTP 客户端 DELETE(requests/httpx/aiohttp)",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `(?i)\b(?:requests|httpx|aiohttp|urllib\.request)\.delete\s*\(|session\.delete\s*\(|client\.delete\s*\(`,
|
||
action: "deny",
|
||
message: "禁止使用 Python HTTP 客户端发送 DELETE 请求",
|
||
priority: 80,
|
||
},
|
||
{
|
||
name: "[内置] 脚本中声明 HTTP DELETE 方法(JS/通用)",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `(?i)axios\.delete\s*\(|method\s*[:=]\s*['"]DELETE['"]`,
|
||
action: "deny",
|
||
message: "禁止在脚本中声明并发送 HTTP DELETE 请求",
|
||
priority: 80,
|
||
},
|
||
{
|
||
name: "[内置] 批量清空 / 清除接口路径",
|
||
target: "tool_input",
|
||
typ: "regex",
|
||
pattern: `(?i)/(?:clear|wipe|flush|purge|truncate|drop|destroy|factory[-_]reset|reset[-_]all)(?:[/?#"'\s]|$)`,
|
||
action: "deny",
|
||
message: "禁止调用批量清空或销毁类接口(/clear /wipe /flush /purge 等)",
|
||
priority: 80,
|
||
},
|
||
}
|
||
for _, r := range rules {
|
||
if _, err := d.Exec(`
|
||
INSERT INTO intercept_rules(name, enabled, priority, match_target, match_type, pattern, action, message, timeout_enabled, timeout_seconds, timeout_action)
|
||
VALUES ($1, true, $2, $3, $4, $5, $6, $7, false, 60, 'deny')
|
||
ON CONFLICT DO NOTHING`,
|
||
r.name, r.priority, r.target, r.typ, r.pattern, r.action, r.message,
|
||
); err != nil {
|
||
return fmt.Errorf("rule %q: %w", r.name, err)
|
||
}
|
||
}
|
||
return d.SetSetting("intercept_default_rules_v1", "done")
|
||
}
|
||
|
||
// seedDefaultInterceptRulesV2 migrates the two safety patterns that used to be
|
||
// hard-coded in guard.go (destructive shell + data-exfil pipe) into ordinary
|
||
// intercept rules. Gated by its own flag so it also lands on DBs that already ran
|
||
// v1. Unlike the old guard.go floor, these are plain [内置] rules — the user can
|
||
// disable or delete them. The exfil rule ships DISABLED by default (its
|
||
// curl/wget/nc pipe pattern mis-fires on legitimate CTF/pentest reverse-shell and
|
||
// data-transfer pipes); enable it manually when exfil gating is actually wanted.
|
||
func (d *DB) seedDefaultInterceptRulesV2() error {
|
||
if v, _, _ := d.GetSetting("intercept_default_rules_v2"); v == "done" {
|
||
return nil
|
||
}
|
||
rules := []struct {
|
||
name string
|
||
pattern string
|
||
action string
|
||
message string
|
||
enabled bool
|
||
priority int
|
||
}{
|
||
{
|
||
name: "[内置] 破坏性系统命令",
|
||
pattern: `(?i)\b(rm\s+-rf\s+/|mkfs|dd\s+if=|:\(\)\s*\{|shutdown|reboot|>\s*/dev/sd)`,
|
||
action: "deny",
|
||
message: "破坏性命令被拒绝(rm -rf / / mkfs / dd / fork bomb / 关机重启 / 覆写磁盘设备)",
|
||
enabled: true,
|
||
priority: 100,
|
||
},
|
||
{
|
||
name: "[内置] 数据外泄管道",
|
||
pattern: `(?i)(curl|wget|nc|ncat)\b[^|]*\b(\|\s*(curl|wget|nc))`,
|
||
action: "deny",
|
||
message: "疑似数据外泄管道被拒绝(命令输出经 curl/wget/nc 外传)",
|
||
enabled: false,
|
||
priority: 80,
|
||
},
|
||
}
|
||
for _, r := range rules {
|
||
if _, err := d.Exec(`
|
||
INSERT INTO intercept_rules(name, enabled, priority, match_target, match_type, pattern, action, message, timeout_enabled, timeout_seconds, timeout_action)
|
||
VALUES ($1, $2, $3, 'tool_input', 'regex', $4, $5, $6, false, 60, 'deny')
|
||
ON CONFLICT DO NOTHING`,
|
||
r.name, r.enabled, r.priority, r.pattern, r.action, r.message,
|
||
); err != nil {
|
||
return fmt.Errorf("rule %q: %w", r.name, err)
|
||
}
|
||
}
|
||
return d.SetSetting("intercept_default_rules_v2", "done")
|
||
}
|
||
|
||
// seedDefaultInterceptRulesV3 adds the delete-endpoint path rule. The v1 HTTP rules
|
||
// only catch the DELETE *method* (curl -X DELETE, requests.delete(, method:'DELETE'),
|
||
// and v1's path rule covers only /clear /wipe /flush /purge /truncate /drop /destroy
|
||
// /factory-reset /reset-all — so a plain `curl 'http://t/api/user/delete?id=1'` (a
|
||
// delete endpoint reached with GET/POST, which is how most web apps expose deletion)
|
||
// slipped through every built-in rule. Own flag so it also lands on DBs that already
|
||
// ran v1/v2, where editing the v1 seed would have no effect.
|
||
//
|
||
// The pattern deliberately requires a separator after the verb so /delivery,
|
||
// /details, /delta and /delegate do not match, while /deleteAll, /delete_user and
|
||
// /delete-user do. destroy is re-covered here because v1's rule does not allow a
|
||
// suffix (/destroyAll was missed).
|
||
//
|
||
// Exported as a package const only so the seeded regex is unit-testable without a DB.
|
||
const deleteEndpointPathPattern = `(?i)/(?:(?:delete|remove|unlink|erase|destroy)[-\w]*|del)(?:[/?#"'\s]|$)`
|
||
|
||
func (d *DB) seedDefaultInterceptRulesV3() error {
|
||
if v, _, _ := d.GetSetting("intercept_default_rules_v3"); v == "done" {
|
||
return nil
|
||
}
|
||
const name = "[内置] 删除类接口路径"
|
||
if _, err := d.Exec(`
|
||
INSERT INTO intercept_rules(name, enabled, priority, match_target, match_type, pattern, action, message, timeout_enabled, timeout_seconds, timeout_action)
|
||
SELECT $1, true, 80, 'tool_input', 'regex', $2, 'deny', $3, false, 60, 'deny'
|
||
WHERE NOT EXISTS (SELECT 1 FROM intercept_rules WHERE name = $1)`,
|
||
name,
|
||
deleteEndpointPathPattern,
|
||
"禁止调用删除类接口(/delete /remove /unlink /erase 等),不论使用哪种 HTTP 方法——多数应用的删除接口用 GET/POST 就能触发,同样会真实删除目标数据",
|
||
); err != nil {
|
||
return fmt.Errorf("rule %q: %w", name, err)
|
||
}
|
||
return d.SetSetting("intercept_default_rules_v3", "done")
|
||
}
|