bashguard

package
v0.14.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 9, 2026 License: MIT Imports: 5 Imported by: 2

Documentation

Overview

Package bashguard bash 命令静态风险解析:不执行命令,经真 shell AST (mvdan.cc/sh)解析成调用序列,按策略表判定只读性。对标 ZCode bash-command-parser.ts + bash-readonly-policy 的「权限判定不执行命令」。

判定原则(保守到宁可误报):

  • 解析失败、含动态词($() / 反引号 / 未展开 $VAR)、含写重定向、 复合控制流(if/for/while/case/函数定义)→ 一律非只读——无法静态证明 安全的就不放行;
  • 调用按策略表:命令不在表=未知(非只读);命中危险 flag=非只读; 未知 flag=非只读;值型 flag 吃一个参数后继续;
  • 安全包装(env/nice/nohup/time/timeout/stdbuf/xargs)剥壳后判定内核;
  • 复合命令(&& || ; |)每一段都必须只读,整体才只读。

三档:只读 / 写 / 不可逆

只读判定回答「会不会改东西」;**不可逆判定**(Decision.Destructive)回答 「一旦跑就收不回来」——两者正交:rm file 不是只读但可逆,rm -rf / 不可逆。 不可逆判据走**独立的一张表**(Table.Destructive,见 destructive.go): 只读表只放只读命令,而不可逆判据要覆盖 rm / mkfs / dd 这类写命令。 「要不要二次确认」是调用方的策略决定;本包给的是「这条命令不可逆」这个事实。

上限 10K 字符(对标 ZCode:超长命令不进解析器)。超长路径带 Unevaluated 标记(细分归因 OverLength / Unparsed,tools#80)+ 头/尾窗 best-effort 不可逆扫描——不静默 fail-open。

sed 只读脚本判定(tools#81):sed 是含 I/O 原语的 mini-language——w file / W file 写文件、GNU 的 e 与 s///e 执行 shell 命令、r/R 注入外部文件内容, 而脚本串以裸操作数 / -e / --expression / -f 多路到达。flag 白名单 + NoBareOperands 的表驱动守卫只看得到「有没有操作数」,看不到「脚本内容」, `sed -n 'w /tmp/x' /etc/hosts` 曾整体放行(只读档内可写任意文件、可执行命令)。

处置不是黑名单(w/W/e/r/R 加行地址加 -e/-f 的组合面太大,漏一则全漏), 而是**受限文法**:脚本必须整体匹配

script   = { sep | 注释 | command }
command  = [addr ["," addr]] ["!"] cmd
addr     = 数字 | "$" | "/" regexp "/"
cmd      = "s" d re d repl d [sflags]     (d = 任意分隔符字符)
         | "y" d set1 d set2 d
         | "p" | "d"
sflags   = { "g" | "p" | "i" | "I" | "m" | "M" | 数字 }

文法外任何形态一律判非只读(保守 fail-closed,宁可误报),显式包括:

  • 写/执行原语:w / W / e / r / R 命令字母、s 的 w / e flag;
  • 常见但不在文法的命令字母({} 块、b/t 分支与标签、a/i/c、q、n/N、 h/H/g/G/x、=/l 等);
  • 扩展地址(~ 步进、+N 偏移、\I/\M 大小写修饰)、未闭合的 s///y///、 地址悬空(后无命令);
  • -f / --file:脚本内容来自文件,静态不可证 → 拒。

语义代价是误报(如 `sed -n '/x/{p}'`、`sed '2q'` 被拒):只读门禁的误报 代价是多一次确认/人工过目,漏报的代价是只读档内真写文件、真执行命令。

缺省只读策略表:保守起步集——不在表=非只读;生产按需经 Table 自定义扩展 (对标 ZCode 的表驱动形态:每命令声明安全 flag 及其值形态)。

Index

Constants

View Source
const MaxCommandLen = 10 * 1024

MaxCommandLen 解析上限。

Variables

This section is empty.

Functions

This section is empty.

Types

type Decision

type Decision struct {
	// ReadOnly 命令可判只读(不执行任何写路径)。
	ReadOnly bool
	// Reason 非只读原因(人可读,审计用)。
	Reason string
	// Destructive 该调用含不可逆动作(删数据 / 写设备 / 关机 / 递归改权限 …)。
	//
	// 与 ReadOnly **正交**:只读与否是另一档。一个命令可以「不是只读、但可逆」
	//(rm file),也可以「不是只读、且不可逆」(rm -rf /)。
	//
	// 语义边界:false 只意味着「没命中已知规则」,**不意味着安全**——
	// 是否要二次确认是调用方的策略决定,本包只给「这条命令不可逆」这个事实。
	Destructive bool
	// DestructiveReason 不可逆依据(人可读,审计用);未命中时为空。
	DestructiveReason string
	// Unevaluated 命令未进 AST 判定(超长未进解析器 / 解析失败,tools#45)。
	//
	// 此时 ReadOnly=false 只是保守拒,**Destructive=false 不再意味着「判了
	// 不不可逆」**——未评估就是不可判:「判据不可判」与「判了不不可逆」不得
	// 压成同一个信号。调用方对未评估输入须按保守侧处理(如照常要二次确认),
	// 不得当「已判安全」。超长路径另有头/尾窗 best-effort 扫描兜底(见
	// overlongDecision):窗口内命中的不可逆动作照常置 Destructive。
	//
	// 细分归因见 OverLength / Unparsed(tools#80):未评估的伞标记在此,
	// 两细分只回答「为什么没判」,两者置位时本标记必置位。
	Unevaluated bool
	// OverLength 命令超过 MaxCommandLen 未进解析器(tools#80):Unevaluated
	// 的细分归因之一。集成方据此区分「超长拒绝判定」与「解析失败拒绝判定」,
	// 并与「判定完成没发现风险」(Unevaluated/OverLength/Unparsed 全 false)
	// 相区分——拒绝判定(任一标记置位)时 ReadOnly=false 只是保守拒,命令
	// 文本里的实际风险(如 10KB 注释填充后尾随的 rm -rf …)未被评估,调用方
	// 须自行升级处置(如照常要确认)。
	OverLength bool
	// Unparsed 解析失败未进 AST 判定(tools#80):Unevaluated 的细分归因之一,
	// 语义同 OverLength(拒绝判定,须按保守侧处理)。
	Unparsed bool
	// Invocations 提取的调用序列(观测/风险面)。
	//
	// ⚠️ 这是**判定路径**的消费序列,不是全量事实:判定在首个非只读调用上
	// 提前返回(Analyze 的 break),其后的调用不再评估;包装剥壳后判定的是
	// 内核,调用面记录的是包装(sudo -n rm 只见 sudo)。做确认门禁/风险审计
	// 请用 AllCalls(见其注释)。
	Invocations []Invocation
	// AllCalls 全 AST 的 simple command 集合(syntax.Walk,oagentkit/tools#35):
	// 管道后半、命令替换 $(…)、子 shell (…)、{ …; } 里的调用**全部可见**,
	// 与判定路径是否提前返回无关。
	//
	// 与 Invocations 的分工:Invocations 服务 ReadOnly/Destructive 判定的
	// 归因(为何判非只读);AllCalls 服务「这条命令文本里**出现了什么**」的
	// 风险面事实——jumpserver-kit 此前为补这个集合在守卫层自接了一份
	// syntax.Walk(判定门禁的绕过全堵上依赖它),重复实现会漂、漂的那份
	// 决定门禁拦什么,故上游收编。
	//
	// 语义边界(调用方须知):
	//   - 包装不剥壳:sudo -n rm -rf /x 是一条调用(sudo,args 含 rm…)——
	//     sudo/xargs/env 的内核是**参数**不是语法调用,消费方按需自剥
	//     (本包自身的不可逆扫描已对未注册调度器做内核兜底,见
	//     destructive.go 的 destructiveKernel);
	//   - eval "rm -rf /x" 的 rm 是字符串数据不是语法调用,只见 eval;
	//   - Dynamic=true 的调用(参数含 $()/反引号/$VAR)同样入列——动态参数
	//     本身就是风险信号。
	AllCalls []Invocation
	// Redirs 全 AST 的重定向事实集(syntax.Walk,tools#79):语句级、子 shell
	// (…)、{ …; }、命令替换 $(…) 里的重定向全部可见,与判定路径是否提前返回
	// 无关。服务集成方的重定向目标判定(写块设备等走 Redirs 不走调用面),
	// 免集成方为拿重定向再 parse 一遍同一命令(与 AllCalls 的收编动机同型)。
	//
	// 语义边界见 RedirInfo:heredoc 体不在此结构;写重定向(Read=false)本身
	// 已使命令非只读,本事实面供非只读之后的目标细分。超长路径为头/尾窗
	// best-effort 收集(重叠区去重,与 AllCalls 同法);解析失败为空。
	Redirs []RedirInfo
}

Decision 判定结果。

func Analyze

func Analyze(cmdStr string, t *Table) Decision

Analyze 静态解析与判定。超长/解析失败返回非只读(保守)且 Unevaluated=true。

func (Decision) Risk added in v0.13.0

func (d Decision) Risk() RiskTier

Risk 单源推导本判定的风险档位:不可逆 > 未评估 > 只读 > 写。

Destructive 优先于 Unevaluated:超长命令的头/尾窗扫描命中不可逆时,按 更强的信号报(窗口未覆盖全文的事实已由 Unevaluated 并存表达)。

type FlagKind

type FlagKind string

FlagKind 值型 flag 的参数形态。

const (
	FlagBool     FlagKind = "bool"     // 不吃参数
	FlagString   FlagKind = "string"   // 吃一个任意字面参数
	FlagNumber   FlagKind = "number"   // 吃一个数值参数
	FlagOptional FlagKind = "optional" // 可带可不带参数(按字面形态分辨)
)

type Invocation

type Invocation struct {
	Name    string
	Args    []string
	Dynamic bool // 含动态词(argv 非字面)
}

Invocation 提取的单次调用(观测面)。

type Policy

type Policy struct {
	// ReadOnly 命令本体是否只读。
	ReadOnly bool
	// SafeFlags 已知安全 flag(含 - 前缀,如 "-n")。长 flag 单写。
	SafeFlags map[string]FlagKind
	// UnsafeFlags 命中即非只读(显式优先于 SafeFlags)。
	UnsafeFlags []string
	// Subcommands 子命令策略表(git 形态,可嵌套):子命令不在表=非只读。
	Subcommands map[string]Policy
	// Wrapper 安全包装(env/timeout 形态):剥壳后按剩余首参判定。
	Wrapper bool
	// ConsumeBare Wrapper 剥 flag 后先吃掉的前置裸操作数个数(timeout 的时长)。
	ConsumeBare int
	// NoBareOperands 裸操作数即写形态(git branch <名> 创建 / git stash 裸 push /
	// git tag <名> 打标):出现任何 flag 之外的裸操作数 → 非只读。
	NoBareOperands bool
	// AllowBareOperandsPrefix NoBareOperands 的豁免前缀(date 的 +FORMAT 形态:
	// 裸操作数以 "+" 开头是格式串不是写目标)。前缀空串不放行任何操作数。
	AllowBareOperandsPrefix []string
	// CheckArgs 参数深度校验钩子(tools#81):flag 表表达不了的 mini-language
	// 参数面(sed 脚本:w/W 写文件、e/s///e 执行命令可藏于裸操作数与 -e/
	// --expression 的**值**里,flag 白名单与 NoBareOperands 都看不到内容)。
	// 收到完整 argv(含 flag 及其值),返回非空串=非只读原因。
	//
	// 调用时机:叶子路径(evalLeafPolicy)在白名单 flag 消费**之后**——未知
	// flag 先被表拒,钩子只处理表放过来的形态;子命令/包装路径不调用。实现
	// 必须自带 fail-closed 纪律(看不懂的一律拒),钩子内不执行任何命令。
	CheckArgs func(name string, argv []string) string
	// Destructive 该命令恒不可逆(删数据 / 写设备 / 关机 …)。
	//
	// 只在**不可逆规则表**(Table.Destructive)里有意义——只读表是只读表,
	// 不可逆判据单列一张(见 destructive.go 的说明)。
	Destructive bool
	// DestructiveFlags 命中即不可逆的 flag("-r" / "--recursive")。
	// 短 flag 支持联合形态(-rf 里的 r)。
	DestructiveFlags []string
	// DestructiveArgs 命中即不可逆的参数(**前缀**匹配:"of=" 命中 "of=/dev/sda")。
	DestructiveArgs []string
}

Policy 单命令策略。

type RedirInfo added in v0.14.0

type RedirInfo struct {
	Op      string // 操作符字面(">" / ">>" / "&>" / "<" / "<<" / "<<<" …)
	FD      int    // 作用的目标 fd:">" 默认 1、"2>" 为 2;{varname}> 形态为 -1(fd 由运行时变量决定)
	Target  string // 目标词字面化文本(heredoc 为定界符;含展开时为拼接的部分文本,Dynamic=true)
	Dynamic bool   // 目标词含动态展开($()/反引号/$VAR)
	Read    bool   // 读侧重定向(<、<<、<<-、<<<):目标是被读的源,不产生写
}

RedirInfo 重定向事实(观测面,tools#79)。

收编动机与 AllCalls 同型:重定向判定(`cat disk.img > /dev/sda`——写块设备 走的是 Stmt.Redirs 不是调用面)此前集成方只能自己再 parse 一遍同一命令 (Analyze 内部一次 + 集成方一次),重复实现会漂、漂的那份决定门禁拦什么。 Decision.Redirs 给出全 AST 的重定向集,集成方在同一判定结果上做目标检查 (如 /dev/* 设备文件),解析 2→1。

语义边界:

  • 只读判定的保守结论不变——任何写重定向(Read=false)本身就把命令判非 只读;本结构是给「非只读之后还要细分目标」的调用方的事实面;
  • heredoc(<< / <<- / <<<)的 Target 是定界符词,**体不在本结构**(体的 动态展开由只读判定自身保守处理);
  • &> / &>> 同时作用于 fd 1 与 2,FD 记 1(Op 已含全量信息)。

type RiskTier added in v0.13.0

type RiskTier string

RiskTier 命令风险档位(单源枚举,tools#48)。

档位体系只此一份:消费方(如自带 MCP 工具风险注册表的宿主)的 per-tool 档位应引用同一组枚举,工具专属的不可逆规则注册进 Table(Destructive 面) 而不是另立一张平行表——两张表各判各的必然漂移:jms-mcp(堡垒机执行 MCP, 不可逆操作是核心安全面)曾锁 tools v0.10.4,错过 v0.11.0 的不可逆档与 v0.12.0 的 AllCalls,其手维护的 20+ 条 per-tool Risk_destructive 注册表与 命令层 bashguard 判定双源漂移,不可逆拦截面出现缝隙。命令提取统一走 Decision.AllCalls(全 AST 调用集,Analyze 已对其做不可逆扫描),不要手写。

粗粒度风险级(low/medium/high,对标 mcpkit ToolHints.RiskLevel)的映射: readonly→low、write→medium、destructive/unevaluated→high(保守侧)。

const (
	// RiskReadOnly 只读档:不执行任何写路径。
	RiskReadOnly RiskTier = "readonly"
	// RiskWrite 写档:非只读(写路径或未知命令)但未命中不可逆判据。
	// 注意未知命令并未被证明「可逆」,保守调用方可视同待确认。
	RiskWrite RiskTier = "write"
	// RiskDestructive 不可逆档:一旦跑就收不回来——二次确认的判据。
	RiskDestructive RiskTier = "destructive"
	// RiskUnevaluated 未评估档:未进 AST 判定(超长/解析失败)——保守侧,
	// 调用方按需与不可逆档同处置(要确认)。
	RiskUnevaluated RiskTier = "unevaluated"
)

type Table

type Table struct {
	Commands map[string]Policy
	// Destructive 不可逆规则表:命令名 → 判据(只用 Policy 的三个 Destructive* 字段)。
	//
	// 与只读表**分开**:只读表只放只读命令("不在只读策略表" 是既有语义),
	// 而不可逆判据要覆盖 rm / mkfs / dd 这类写命令。键尾的 "*" 表示前缀族
	//("mkfs*" 覆盖 mkfs.ext4 等)。
	Destructive map[string]Policy
}

Table 策略表。

func DefaultTable

func DefaultTable() *Table

DefaultTable 内置缺省策略表(副本:调用方改表不影响后续 DefaultTable)。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL